Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .agentworkforce/trajectories/fleet-startup-task-summary.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"id": "compact_vh1unpxve3p7",
"version": 1,
"type": "compacted",
"compactedAt": "2026-10-09T00:06:51.822Z",
"sourceTrajectories": [
"traj_q94ajuue54aw"
],
"dateRange": {
"start": "2026-10-08T23:57:27.648Z",
"end": "2026-10-09T00:06:30.759Z"
},
"summary": {
"totalDecisions": 2,
"totalEvents": 3,
"uniqueAgents": [
"default"
]
},
"decisionGroups": [
{
"category": "other",
"decisions": [
{
"question": "Pass Claude and Codex startup briefs through native argv prompts",
"chosen": "Pass Claude and Codex startup briefs through native argv prompts",
"reasoning": "PTY startup typing can lose a prefix; argv preserves the complete task and existing argv bookkeeping prevents duplicate injection.",
"fromTrajectory": "traj_q94ajuue54aw"
},
{
"question": "Preserve task whitespace and recognize active argv startup turns",
"chosen": "Preserve task whitespace and recognize active argv startup turns",
"reasoning": "Fleet task parsing previously trimmed bytes; native prompts may start working before an idle composer appears, so readiness must accept a busy harness while still vetoing trust dialogs.",
"fromTrajectory": "traj_q94ajuue54aw"
}
]
}
],
"keyLearnings": [],
"keyFindings": [],
"filesAffected": [],
"commits": []
}
26 changes: 26 additions & 0 deletions .agentworkforce/trajectories/fleet-startup-task-summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Trajectory Compaction: Oct 8, 2026 - Oct 9, 2026

## Summary
- Sessions: 1
- Decisions: 2
- Events: 3
- Agents: default
- Files: 0
- Commits: 0

## Other
- Pass Claude and Codex startup briefs through native argv prompts -> Pass Claude and Codex startup briefs through native argv prompts (traj_q94ajuue54aw)
- Preserve task whitespace and recognize active argv startup turns -> Preserve task whitespace and recognize active argv startup turns (traj_q94ajuue54aw)

## Key Learnings
- No additional findings recorded.

## Key Findings
- No additional findings recorded.

## Validation

- Real broker/PTY fleet fixtures: 7 passed, including Claude/Codex 400-character and 8-KiB tasks and explicit PTY configs. Exact first task and absence of duplicate PTY injection were checked. No provider credentials were used.
- Broker unit suite: 1,412 passed, 5 ignored, zero failures after removing inherited Git config overrides from the test process.
- Production Clippy, TypeScript compilation, Rust formatting and changed-file Prettier checks passed. Strict all-target Clippy has seven existing test-code lints under Rust 1.99; compatibility lint allowances pass.
- Initial argv tasks containing NUL or exceeding 16 KiB fail before registration. Deployments must upgrade and restart brokers to receive this fix.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Patch]

### Fixed

- `fleet spawn --task` passes Claude and Codex initial briefs directly to the harness at startup, preventing task prefixes from being lost while the terminal composer loads. Startup prompts containing NUL or exceeding 16 KiB fail explicitly before registration.

## [13.2.0] - 2026-10-08

Expand Down
83 changes: 83 additions & 0 deletions crates/broker/src/pty_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,20 @@ fn startup_gate_block_reason(resolved_cli: &str, screen: &str) -> Option<Startup
None
}

fn native_startup_turn_running(cli: &str, screen: &str) -> bool {
match cli_basename(cli).to_ascii_lowercase().as_str() {
"codex" | "codex.exe" => screen.lines().any(is_codex_busy_status_line),
"claude" | "claude.exe" => screen.lines().any(|line| {
let lower = line.to_ascii_lowercase();
lower.contains("esc to interrupt")
&& line
.trim_start()
.starts_with(['✻', '✽', '✶', '✳', '✢', '·'])
}),
_ => false,
}
}

/// Classify the harness's startup state from a single grid render, so the
/// readiness and blocking verdicts cannot straddle two repaints.
fn startup_gate(
Expand All @@ -663,8 +677,19 @@ fn startup_gate(
startup_total_bytes: usize,
output_quiet: Duration,
pty: &PtySession,
startup_task_in_argv: bool,
) -> StartupGate {
let screen = pty.screen_text();
if let Some(blocked) = startup_gate_block_reason(resolved_cli, &screen) {
return blocked;
}
// A native argv task can already be running before an idle composer is
// ever drawn. Its busy status proves startup without waiting for that
// first (potentially long) turn to finish. PTY-injected tasks still require
// the idle prompt, and trust/auth dialogs always veto this path.
if startup_task_in_argv && native_startup_turn_running(resolved_cli, &screen) {
return StartupGate::Ready;
}
if evaluate_startup_gate(
resolved_cli,
startup_output,
Expand Down Expand Up @@ -1066,6 +1091,7 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> {
let mut startup_total_bytes = 0usize;
let mut init_request_id: Option<RequestId> = None;
let mut init_received_at: Option<Instant> = None;
let mut startup_task_in_argv = false;
let mut startup_readiness = StartupReadinessState::default();
let suppress_multiline_mcp_reminder = cli_basename(&resolved_cli).eq_ignore_ascii_case("agent")
|| cli_basename(&resolved_cli).eq_ignore_ascii_case("cursor-agent")
Expand Down Expand Up @@ -1234,6 +1260,10 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> {
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| "pty-worker".to_string());
startup_task_in_argv = frame.payload
.get("startup_task_in_argv")
.and_then(Value::as_bool)
.unwrap_or(false);
init_request_id = frame.request_id;
init_received_at = Some(Instant::now());
// Process liveness and safe input readiness are
Expand All @@ -1255,6 +1285,7 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> {
startup_total_bytes,
last_pty_output_time.elapsed(),
&pty,
startup_task_in_argv,
);
try_emit_worker_ready(
&out_tx,
Expand Down Expand Up @@ -1683,6 +1714,7 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> {
startup_total_bytes,
last_pty_output_time.elapsed(),
&pty,
startup_task_in_argv,
);
try_emit_worker_ready(
&out_tx,
Expand Down Expand Up @@ -2390,6 +2422,7 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> {
startup_total_bytes,
last_pty_output_time.elapsed(),
&pty,
startup_task_in_argv,
);
try_emit_worker_ready(
&out_tx,
Expand Down Expand Up @@ -2639,6 +2672,56 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> {
mod tests {
use super::*;

#[cfg(unix)]
#[tokio::test]
async fn native_startup_busy_gate_requires_argv_task_and_respects_trust() {
for (cli, screen) in [
("codex", "Working (1s • esc to interrupt)"),
("claude", "✻ Thinking… (esc to interrupt)"),
("codex", "Do you trust the contents of this directory?\n› 1. Yes, continue\n2. No, quit\nWorking (esc to interrupt)"),
] {
let blocked = screen.contains("Do you trust");
let script = format!("printf '%s' '{}' ; sleep 5", screen);
let (pty, mut rx) = PtySession::spawn("/bin/sh", &["-c".into(), script], 24, 120).unwrap();
let drain = tokio::spawn(async move { while rx.recv().await.is_some() {} });
for _ in 0..100 {
if pty.screen_text().contains("esc to interrupt") { break; }
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(pty.screen_text().contains("esc to interrupt"));
let expected = if blocked { StartupGate::Blocked } else { StartupGate::Unrecognised };
assert_eq!(startup_gate(cli, "", 0, Duration::ZERO, &pty, false), expected);
let expected = if blocked { StartupGate::Blocked } else { StartupGate::Ready };
assert_eq!(startup_gate(cli, "", 0, Duration::ZERO, &pty, true), expected);
pty.shutdown().unwrap();
drain.abort();
}
}

#[test]
fn native_startup_turn_requires_harness_specific_busy_status() {
assert!(native_startup_turn_running(
"codex",
"Working (1s • esc to interrupt)"
));
assert!(native_startup_turn_running(
"claude",
"✻ Thinking… (esc to interrupt)"
));
assert!(!native_startup_turn_running(
"claude",
"Task says esc to interrupt"
));
assert!(!native_startup_turn_running(
"codex",
"› Ask Codex to do anything"
));
assert!(!native_startup_turn_running(
"gemini",
"Working (esc to interrupt)"
));
}

#[test]
fn completed_delivery_replay_retains_the_original_event_identity() {
let mut completed = CompletedWorkerDeliveries::default();
Expand Down
2 changes: 1 addition & 1 deletion crates/broker/src/runtime/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ impl BrokerRuntime {
);
}
}
if let Err(error) = crate::worker::validate_muse_startup_prompt_for_spec(
if let Err(error) = crate::worker::validate_pty_startup_prompt_for_spec(
&spec,
effective_task.as_deref(),
) {
Expand Down
41 changes: 40 additions & 1 deletion crates/broker/src/runtime/fleet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1548,7 +1548,7 @@ impl BrokerRuntime {
return;
}
};
let task = action_invoke_string(&invoke.input, &["task", "initial_task", "prompt"]);
let task = action_invoke_task(&invoke.input);
let channel = action_invoke_string(&invoke.input, &["channel"]);
let model = action_invoke_string(&invoke.input, &["model"]);

Expand Down Expand Up @@ -3075,6 +3075,21 @@ fn action_invoke_agent_name(invoke: &ActionInvoke) -> Option<WorkerName> {
})
}

/// Task text is opaque: use trimming only to reject empty values, never to
/// change the bytes that become the harness's first turn.
fn action_invoke_task(input: &Value) -> Option<String> {
for source in [Some(input), input.get("agent")].into_iter().flatten() {
for key in ["task", "initial_task", "prompt"] {
if let Some(value) = source.get(key).and_then(Value::as_str) {
if !value.trim().is_empty() {
return Some(value.to_string());
}
}
}
}
None
}

/// Read the first non-empty string at any of the given top-level keys of an
/// `action.invoke` input object (also checks under a nested `agent` object,
/// mirroring the firehose payload shape).
Expand Down Expand Up @@ -3759,6 +3774,30 @@ mod tests {
);
}

#[test]
fn action_invoke_task_preserves_whitespace_and_alias_precedence() {
let task = " implement issue #34\n\t trailing ";
for key in ["task", "initial_task", "prompt"] {
assert_eq!(
action_invoke_task(&json!({key: task})),
Some(task.to_string())
);
assert_eq!(
action_invoke_task(&json!({"agent": {key: task}})),
Some(task.to_string())
);
}
assert_eq!(
action_invoke_task(&json!({"task": " ", "agent": {"task": task}})),
Some(task.to_string())
);
assert_eq!(
action_invoke_task(&json!({"task": "top", "agent": {"task": "nested"}})),
Some("top".to_string())
);
assert_eq!(action_invoke_task(&json!({"task": "\n\t"})), None);
}

#[test]
fn action_invoke_string_reads_top_level_and_nested_agent() {
let input = json!({"cli": "codex", "agent": {"model": "gpt-5"}});
Expand Down
2 changes: 1 addition & 1 deletion crates/broker/src/runtime/relaycast_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,7 @@ pub(super) async fn spawn_worker_from_request(
"prepared relay skill prefix before Relaycast worker startup"
);
}
crate::worker::validate_muse_startup_prompt_for_spec(&spec, effective_task.as_deref())?;
crate::worker::validate_pty_startup_prompt_for_spec(&spec, effective_task.as_deref())?;

// Pre-register an agent token for every spawned worker.
// The Agent Relay MCP server needs RELAY_AGENT_TOKEN +
Expand Down
Loading
Loading