@team-agent/installer 0.5.64 → 0.5.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +5 -0
- package/crates/team-agent/src/cli/emit.rs +15 -0
- package/crates/team-agent/src/cli/mod.rs +9 -8
- package/crates/team-agent/src/cli/send/presentation.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/tests/mod.rs +31 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +13 -3
- package/crates/team-agent/src/cli/types.rs +7 -0
- package/crates/team-agent/src/coordinator/health.rs +17 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +55 -2
- package/crates/team-agent/src/db/migration.rs +2 -1
- package/crates/team-agent/src/db/schema.rs +16 -8
- package/crates/team-agent/src/leader/lease.rs +1 -2
- package/crates/team-agent/src/leader/start.rs +8 -8
- package/crates/team-agent/src/lifecycle/restart/common.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +10 -6
- package/crates/team-agent/src/lifecycle/tests.rs +11 -5
- package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/delivery.rs +29 -0
- package/crates/team-agent/src/messaging/helpers.rs +2 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +12 -0
- package/crates/team-agent/src/messaging/mod.rs +2 -0
- package/crates/team-agent/src/messaging/results.rs +5 -0
- package/crates/team-agent/src/messaging/send.rs +9 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +32 -0
- package/crates/team-agent/src/messaging/types.rs +2 -0
- package/crates/team-agent/src/messaging/wait.rs +366 -0
- package/crates/team-agent/src/messaging/watchers.rs +153 -6
- package/crates/team-agent/src/tmux_backend.rs +16 -12
- package/crates/team-agent/src/transport.rs +11 -11
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -523,6 +523,11 @@ pub fn cmd_results(args: &ResultsArgs) -> Result<CmdResult, CliError> {
|
|
|
523
523
|
})
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
+
pub fn cmd_wait(args: &WaitArgs) -> Result<CmdResult, CliError> {
|
|
527
|
+
let result = messaging::wait_for_result(&args.workspace, &args.task_id)?;
|
|
528
|
+
Ok(CmdResult::from_json(result.to_json(), args.json))
|
|
529
|
+
}
|
|
530
|
+
|
|
526
531
|
/// `cmd_allow_peer_talk`(`parser.py allow-peer-talk`).
|
|
527
532
|
pub fn cmd_allow_peer_talk(args: &AllowPeerTalkArgs) -> Result<CmdResult, CliError> {
|
|
528
533
|
if args.team.is_some() {
|
|
@@ -196,6 +196,7 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
|
|
|
196
196
|
.map(emit_result)
|
|
197
197
|
}
|
|
198
198
|
"results" => cmd_results(&results_args(args, cwd)?).map(emit_result),
|
|
199
|
+
"wait" => cmd_wait(&wait_args(args, cwd)?).map(emit_result),
|
|
199
200
|
"diagnose" => cmd_diagnose(&diagnose_args(args, cwd)).map(emit_result),
|
|
200
201
|
"preflight" => cmd_preflight(&preflight_args(args, cwd)).map(emit_result),
|
|
201
202
|
"wait-ready" => cmd_wait_ready(&wait_ready_args(args, cwd)).map(emit_result),
|
|
@@ -243,6 +244,7 @@ const DISPATCH_COMMANDS: &[&str] = &[
|
|
|
243
244
|
"profile",
|
|
244
245
|
"collect",
|
|
245
246
|
"results",
|
|
247
|
+
"wait",
|
|
246
248
|
"diagnose",
|
|
247
249
|
"preflight",
|
|
248
250
|
"wait-ready",
|
|
@@ -403,6 +405,7 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
403
405
|
Some("profile") => "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--json]".to_string(),
|
|
404
406
|
Some("collect") => "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]".to_string(),
|
|
405
407
|
Some("results") => "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
408
|
+
Some("wait") => "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]".to_string(),
|
|
406
409
|
Some("diagnose") => "usage: team-agent diagnose [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
407
410
|
Some("preflight") => "usage: team-agent preflight [TEAMDIR] [--json]".to_string(),
|
|
408
411
|
Some("wait-ready") => "usage: team-agent wait-ready [--workspace WORKSPACE] [--team TEAM] [--timeout SECONDS] [--json]".to_string(),
|
|
@@ -1319,6 +1322,18 @@ fn watch_args(args: &[String], cwd: &Path) -> WatchArgs {
|
|
|
1319
1322
|
}
|
|
1320
1323
|
}
|
|
1321
1324
|
|
|
1325
|
+
fn wait_args(args: &[String], cwd: &Path) -> Result<WaitArgs, CliError> {
|
|
1326
|
+
let parsed = parse_args(args);
|
|
1327
|
+
let workspace = workspace(&parsed, cwd);
|
|
1328
|
+
Ok(WaitArgs {
|
|
1329
|
+
task_id: parsed
|
|
1330
|
+
.task
|
|
1331
|
+
.ok_or_else(|| CliError::Usage("wait requires --task <id>".to_string()))?,
|
|
1332
|
+
workspace,
|
|
1333
|
+
json: parsed.json,
|
|
1334
|
+
})
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1322
1337
|
fn approvals_args(args: &[String], cwd: &Path) -> ApprovalsArgs {
|
|
1323
1338
|
let parsed = parse_args(args);
|
|
1324
1339
|
ApprovalsArgs {
|
|
@@ -2245,14 +2245,15 @@ pub mod lifecycle_port {
|
|
|
2245
2245
|
/// runs, where the leader is never in the invoker's ancestry.
|
|
2246
2246
|
///
|
|
2247
2247
|
/// 0.4.x (CR R3): leader shell wrapper interaction. The leader pane's
|
|
2248
|
-
/// controlling process is the `sh -lc
|
|
2249
|
-
///
|
|
2250
|
-
///
|
|
2251
|
-
///
|
|
2252
|
-
///
|
|
2253
|
-
///
|
|
2254
|
-
///
|
|
2255
|
-
/// `leader_fallback_shell_protected_when_provider_exited`
|
|
2248
|
+
/// controlling process is the `sh -lc` wrapper that runs Claude as a
|
|
2249
|
+
/// CHILD. When Claude exits, the wrapper records the marker and replaces
|
|
2250
|
+
/// its command with the inert `/bin/sh -c` tail (which ignores INT/QUIT
|
|
2251
|
+
/// and does not read pane stdin). Because this function protects by
|
|
2252
|
+
/// `pane.pane_pid` (Source 1 & 2), that tail is covered by the same
|
|
2253
|
+
/// protection set — shutdown will NOT treat it as a stray process.
|
|
2254
|
+
/// This protection is currently untested; the former claim that
|
|
2255
|
+
/// `leader_fallback_shell_protected_when_provider_exited` verified it
|
|
2256
|
+
/// referred to a test absent from this repository (A-46).
|
|
2256
2257
|
///
|
|
2257
2258
|
/// Two leader-pane sources(N39 双来源,真机 grounded):
|
|
2258
2259
|
/// 1. **Session prefix**: tmux session starts with `team-agent-leader-`(契约 grounded;
|
|
@@ -108,6 +108,7 @@ pub(super) fn delivery_outcome_json(
|
|
|
108
108
|
"sender": opts.sender,
|
|
109
109
|
"message_id": outcome.message_id,
|
|
110
110
|
"message_status": outcome.message_status.0,
|
|
111
|
+
"ack_forced_off": outcome.ack_forced_off,
|
|
111
112
|
"verification": outcome.verification,
|
|
112
113
|
"stage": outcome.stage.map(delivery_stage_wire),
|
|
113
114
|
"reason": outcome.reason.map(delivery_refusal_wire),
|
|
@@ -78,6 +78,7 @@ pub(crate) enum DispatchKind {
|
|
|
78
78
|
Profile,
|
|
79
79
|
Collect,
|
|
80
80
|
Results,
|
|
81
|
+
Wait,
|
|
81
82
|
Diagnose,
|
|
82
83
|
Preflight,
|
|
83
84
|
WaitReady,
|
|
@@ -123,6 +124,7 @@ pub(crate) const ALL_DISPATCH_KINDS: &[DispatchKind] = &[
|
|
|
123
124
|
DispatchKind::Profile,
|
|
124
125
|
DispatchKind::Collect,
|
|
125
126
|
DispatchKind::Results,
|
|
127
|
+
DispatchKind::Wait,
|
|
126
128
|
DispatchKind::Diagnose,
|
|
127
129
|
DispatchKind::Preflight,
|
|
128
130
|
DispatchKind::WaitReady,
|
|
@@ -162,6 +164,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
|
162
164
|
CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
163
165
|
CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
164
166
|
CommandSpec { name: "results", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Results), summary: "read reported results for a case", usage: "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
167
|
+
CommandSpec { name: "wait", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Wait), summary: "block until a task result is stored", usage: "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]", default_help: false, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
165
168
|
CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
166
169
|
CommandSpec { name: "shutdown", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Shutdown), summary: "stop the selected team", usage: "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
167
170
|
CommandSpec { name: "add-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::AddAgent), summary: "add or force-recreate a worker", usage: "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
@@ -42,6 +42,37 @@ fn seed_status_workspace() -> std::path::PathBuf {
|
|
|
42
42
|
dir
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
fn seed_send_workspace() -> std::path::PathBuf {
|
|
46
|
+
let dir = std::env::temp_dir().join(format!(
|
|
47
|
+
"ta-cli-send-{}-{}",
|
|
48
|
+
std::process::id(),
|
|
49
|
+
std::time::SystemTime::now()
|
|
50
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
51
|
+
.unwrap()
|
|
52
|
+
.as_nanos()
|
|
53
|
+
));
|
|
54
|
+
let team = dir.join(".team");
|
|
55
|
+
std::fs::create_dir_all(team.join("runtime")).unwrap();
|
|
56
|
+
let state = json!({
|
|
57
|
+
"active_team_key": "teamA",
|
|
58
|
+
"teams": {
|
|
59
|
+
"teamA": {
|
|
60
|
+
"agents": {
|
|
61
|
+
"alice": {"provider": "codex"},
|
|
62
|
+
"drifted": {"provider": "codex", "status": "session_drift"}
|
|
63
|
+
},
|
|
64
|
+
"tasks": [{"id": "t-1"}]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
std::fs::write(
|
|
69
|
+
team.join("runtime").join("state.json"),
|
|
70
|
+
serde_json::to_vec_pretty(&state).unwrap(),
|
|
71
|
+
)
|
|
72
|
+
.unwrap();
|
|
73
|
+
dir
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
const DELEG_VALID_ROLE: &str = "---\nname: implementer\nrole: Implementation Engineer\nprovider: fake\nmodel: fake\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nImplement bounded tasks.\n";
|
|
46
77
|
const DELEG_INVALID_ROLE: &str = "---\nname: broken\nrole: Broken Worker\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nNo provider field.\n";
|
|
47
78
|
const DELEG_TEAM_MD: &str =
|
|
@@ -453,11 +453,13 @@ fn status_port_status_detail_full_keeps_uncompacted_events() {
|
|
|
453
453
|
// =========================================================================
|
|
454
454
|
|
|
455
455
|
fn send_args_fixture() -> SendArgs {
|
|
456
|
+
let workspace = seed_send_workspace();
|
|
457
|
+
let _ = crate::message_store::MessageStore::open(&workspace).unwrap();
|
|
456
458
|
SendArgs {
|
|
457
459
|
target: Some("alice".into()),
|
|
458
460
|
message: vec!["hello".into(), "world".into(), "foo".into()],
|
|
459
461
|
targets: None,
|
|
460
|
-
workspace
|
|
462
|
+
workspace,
|
|
461
463
|
team: Some("teamA".into()),
|
|
462
464
|
task: Some("t-1".into()),
|
|
463
465
|
sender: TrustedSender::leader(),
|
|
@@ -565,9 +567,17 @@ fn cmd_send_joins_message_with_single_space() {
|
|
|
565
567
|
CmdOutput::Json(ref v) => {
|
|
566
568
|
assert!(v.get("ok").is_some(), "send result Json must carry `ok`");
|
|
567
569
|
if v.get("ok").and_then(|ok| ok.as_bool()) == Some(true) {
|
|
570
|
+
let delivered = v.get("delivered").and_then(|x| x.as_bool()) == Some(true);
|
|
571
|
+
// hermetic tier 假设:此 fixture 无法产生真实 tmux Delivered。
|
|
572
|
+
// 若 delivered=true 命中说明 tier 前提变了(例如 seed 开了 Delivered 通路),
|
|
573
|
+
// 应立即失败以强制重新审视,而不是悄悄跑一条从未验过的分支。
|
|
574
|
+
assert!(
|
|
575
|
+
!delivered,
|
|
576
|
+
"hermetic seed 不应产生 delivered=true;tier 假设变了"
|
|
577
|
+
);
|
|
568
578
|
assert_eq!(
|
|
569
579
|
v.get("reminder").and_then(|reminder| reminder.as_str()),
|
|
570
|
-
Some(
|
|
580
|
+
Some("Message queued; coordinator will notify when the worker receives it. Do not poll the worker terminal with capture-pane.")
|
|
571
581
|
);
|
|
572
582
|
}
|
|
573
583
|
}
|
|
@@ -689,7 +699,7 @@ fn cmd_send_failed_outcome_yields_error_exit() {
|
|
|
689
699
|
// DeliveryOutcome ok=false (e.g. refused) -> from_json -> ExitCode::Error (parser.py:507).
|
|
690
700
|
// A failed send to a target must propagate non-zero exit reporting through CmdResult.
|
|
691
701
|
let args = SendArgs {
|
|
692
|
-
target: Some("
|
|
702
|
+
target: Some("drifted".into()),
|
|
693
703
|
no_ack: false,
|
|
694
704
|
no_wait: false,
|
|
695
705
|
watch_result: false,
|
|
@@ -677,6 +677,13 @@ pub struct ResultsArgs {
|
|
|
677
677
|
pub json: bool,
|
|
678
678
|
}
|
|
679
679
|
|
|
680
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
681
|
+
pub struct WaitArgs {
|
|
682
|
+
pub task_id: String,
|
|
683
|
+
pub workspace: PathBuf,
|
|
684
|
+
pub json: bool,
|
|
685
|
+
}
|
|
686
|
+
|
|
680
687
|
/// `diagnose`(`parser.py:298`) runtime health report, distinct from `doctor`.
|
|
681
688
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
682
689
|
pub struct DiagnoseArgs {
|
|
@@ -1064,6 +1064,23 @@ pub fn render_event_line(event: &Value) -> Option<String> {
|
|
|
1064
1064
|
clean_field(event, &["provider"], "-"),
|
|
1065
1065
|
clean_field(event, &["matched_pattern_snippet", "snippet"], "-")
|
|
1066
1066
|
)),
|
|
1067
|
+
"result_wake.registered" => Some(format!(
|
|
1068
|
+
"result_wake.registered: task={} watcher={}",
|
|
1069
|
+
clean_field(event, &["task_id"], "-"),
|
|
1070
|
+
clean_field(event, &["watcher_id"], "-")
|
|
1071
|
+
)),
|
|
1072
|
+
"result_wake.notified" => Some(format!(
|
|
1073
|
+
"result_wake.notified: task={} result={} watcher={}",
|
|
1074
|
+
clean_field(event, &["task_id"], "-"),
|
|
1075
|
+
clean_field(event, &["result_id"], "-"),
|
|
1076
|
+
clean_field(event, &["watcher_id"], "-")
|
|
1077
|
+
)),
|
|
1078
|
+
"result_wake.notify_failed" => Some(format!(
|
|
1079
|
+
"result_wake.notify_failed: task={} watcher={} reason={}",
|
|
1080
|
+
clean_field(event, &["task_id"], "-"),
|
|
1081
|
+
clean_field(event, &["watcher_id"], "-"),
|
|
1082
|
+
clean_field(event, &["reason", "error"], "-")
|
|
1083
|
+
)),
|
|
1067
1084
|
_ => None,
|
|
1068
1085
|
}
|
|
1069
1086
|
}
|
|
@@ -578,7 +578,7 @@ fn agent_process_liveness(
|
|
|
578
578
|
// pane for POSITIVE provider evidence via current-command + worker
|
|
579
579
|
// exit marker. Under the 0.5.39 wrapper, `state.status=running` is
|
|
580
580
|
// administrative spawn accounting, not runtime truth — a provider
|
|
581
|
-
// may have exited into the shell
|
|
581
|
+
// may have exited into the inert shell tail while state still reads
|
|
582
582
|
// running. The marker probe positively proves that case; when it
|
|
583
583
|
// fires we return Dead here instead of the false Alive further
|
|
584
584
|
// down.
|
|
@@ -795,9 +795,29 @@ fn worker_provider_exit_marker_check(
|
|
|
795
795
|
.capture(&target, crate::transport::CaptureRange::Tail(200))
|
|
796
796
|
.ok()?;
|
|
797
797
|
if cap.text.contains(&marker) {
|
|
798
|
+
// The wrapper writes its exit code immediately after this marker. A
|
|
799
|
+
// truncated capture can leave the marker without a complete token;
|
|
800
|
+
// keep that case explicit rather than guessing success (or zero).
|
|
801
|
+
// Scope: this detail only covers exits that execute the wrapper's
|
|
802
|
+
// marker printf. SIGKILL, direct pane termination, and host power loss
|
|
803
|
+
// produce no marker; that absence is silent, not `rc=unknown`.
|
|
804
|
+
let rc = cap
|
|
805
|
+
.text
|
|
806
|
+
.split_once(&marker)
|
|
807
|
+
.and_then(|(_, tail)| {
|
|
808
|
+
let token = tail.split_whitespace().next()?;
|
|
809
|
+
let end = tail.find(token)? + token.len();
|
|
810
|
+
tail.as_bytes()
|
|
811
|
+
.get(end)
|
|
812
|
+
.is_some_and(u8::is_ascii_whitespace)
|
|
813
|
+
.then_some(token)
|
|
814
|
+
})
|
|
815
|
+
.and_then(|token| token.parse::<i32>().ok())
|
|
816
|
+
.map(|value| value.to_string())
|
|
817
|
+
.unwrap_or_else(|| "unknown".to_string());
|
|
798
818
|
Some(process_check(
|
|
799
819
|
ProcessLiveness::Dead,
|
|
800
|
-
format!("worker_provider_exited:{pane_id_str}"),
|
|
820
|
+
format!("worker_provider_exited:{pane_id_str}:rc={rc}"),
|
|
801
821
|
))
|
|
802
822
|
} else {
|
|
803
823
|
None
|
|
@@ -1816,6 +1836,39 @@ mod tests {
|
|
|
1816
1836
|
);
|
|
1817
1837
|
}
|
|
1818
1838
|
|
|
1839
|
+
#[test]
|
|
1840
|
+
fn worker_exit_marker_detail_includes_exit_code() {
|
|
1841
|
+
let pane_id = crate::transport::PaneId::new("%marker-rc");
|
|
1842
|
+
let marker = crate::tmux_backend::worker_provider_exit_marker("codex");
|
|
1843
|
+
let transport = crate::transport::test_support::OfflineTransport::new()
|
|
1844
|
+
.with_capture_for_pane(pane_id.as_str(), format!("\n{marker} 23\n"));
|
|
1845
|
+
let mut agent = test_abnormal_agent("/tmp/rollout.jsonl", Some(1), None);
|
|
1846
|
+
agent.pane_id = Some(pane_id.as_str().to_string());
|
|
1847
|
+
|
|
1848
|
+
let check = worker_provider_exit_marker_check(&agent, &transport).expect("marker hit");
|
|
1849
|
+
|
|
1850
|
+
assert_eq!(check.state, ProcessLiveness::Dead);
|
|
1851
|
+
assert_eq!(check.detail, "worker_provider_exited:%marker-rc:rc=23");
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
#[test]
|
|
1855
|
+
fn worker_exit_marker_detail_uses_unknown_for_truncated_exit_code() {
|
|
1856
|
+
let pane_id = crate::transport::PaneId::new("%marker-truncated");
|
|
1857
|
+
let marker = crate::tmux_backend::worker_provider_exit_marker("codex");
|
|
1858
|
+
let transport = crate::transport::test_support::OfflineTransport::new()
|
|
1859
|
+
.with_capture_for_pane(pane_id.as_str(), format!("\n{marker} "));
|
|
1860
|
+
let mut agent = test_abnormal_agent("/tmp/rollout.jsonl", Some(1), None);
|
|
1861
|
+
agent.pane_id = Some(pane_id.as_str().to_string());
|
|
1862
|
+
|
|
1863
|
+
let check = worker_provider_exit_marker_check(&agent, &transport).expect("marker hit");
|
|
1864
|
+
|
|
1865
|
+
assert_eq!(check.state, ProcessLiveness::Dead);
|
|
1866
|
+
assert_eq!(
|
|
1867
|
+
check.detail,
|
|
1868
|
+
"worker_provider_exited:%marker-truncated:rc=unknown"
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1819
1872
|
#[test]
|
|
1820
1873
|
fn abnormal_dead_fresh_error_event_reports_actual_dead_booleans() {
|
|
1821
1874
|
let dir = temp_abnormal_dir("dead-fresh");
|
|
@@ -98,6 +98,7 @@ pub const MANAGED_TABLE_LAYOUTS: &[(&str, &[&str])] = &[
|
|
|
98
98
|
"agent_id",
|
|
99
99
|
"message_id",
|
|
100
100
|
"leader_id",
|
|
101
|
+
"recipient",
|
|
101
102
|
"status",
|
|
102
103
|
"created_at",
|
|
103
104
|
"completed_at",
|
|
@@ -129,7 +130,7 @@ const CREATE_TABLE_TEMPLATES: &[(&str, &str)] = &[
|
|
|
129
130
|
("delivery_tokens", "create table if not exists __TABLE__ (\n message_id text primary key,\n unique_token text not null,\n injected_at text not null,\n visible_at text,\n consumed_at text,\n failed_at text,\n failure_reason text\n )"),
|
|
130
131
|
("agent_health", "create table if not exists __TABLE__ (\n owner_team_id text,\n agent_id text not null,\n status text not null,\n last_output_at text,\n context_usage_pct integer,\n current_task_id text,\n updated_at text not null,\n unique(owner_team_id, agent_id)\n )"),
|
|
131
132
|
("peer_allowlist", "create table if not exists __TABLE__ (\n a text not null,\n b text not null,\n created_at text not null,\n primary key (a, b)\n )"),
|
|
132
|
-
("result_watchers", "create table if not exists __TABLE__ (\n watcher_id text primary key,\n owner_team_id text,\n task_id text,\n agent_id text,\n message_id text,\n leader_id text not null,\n status text not null,\n created_at text not null,\n completed_at text,\n result_id text,\n notified_message_id text,\n error text\n )"),
|
|
133
|
+
("result_watchers", "create table if not exists __TABLE__ (\n watcher_id text primary key,\n owner_team_id text,\n task_id text,\n agent_id text,\n message_id text,\n leader_id text not null,\n recipient text,\n status text not null,\n created_at text not null,\n completed_at text,\n result_id text,\n notified_message_id text,\n error text\n )"),
|
|
133
134
|
("leader_notification_log", "create table if not exists __TABLE__ (\n result_id text not null,\n owner_team_id text not null default '',\n owner_epoch integer not null default 0,\n leader_session_uuid text,\n notified_message_id text not null,\n notified_at text not null,\n leader_pane_id_at_notify text,\n envelope_content_hash text,\n primary key (result_id, owner_team_id, owner_epoch)\n )"),
|
|
134
135
|
];
|
|
135
136
|
|
|
@@ -13,7 +13,7 @@ use rusqlite::Connection;
|
|
|
13
13
|
use crate::db::DbError;
|
|
14
14
|
|
|
15
15
|
/// `schema.py:90`。
|
|
16
|
-
pub const SCHEMA_VERSION: i64 =
|
|
16
|
+
pub const SCHEMA_VERSION: i64 = 5;
|
|
17
17
|
|
|
18
18
|
/// 8 张表的 DDL(逐字照搬 `schema.py:initialize_schema` 的内联建表;含 `if not exists`)。
|
|
19
19
|
/// 顺序与 Python 一致(leader_notification_log 在 ensure 块后创建)。
|
|
@@ -23,7 +23,7 @@ const CREATE_SCHEDULED_EVENTS: &str = "create table if not exists scheduled_even
|
|
|
23
23
|
const CREATE_DELIVERY_TOKENS: &str = "create table if not exists delivery_tokens (\n message_id text primary key,\n unique_token text not null,\n injected_at text not null,\n visible_at text,\n consumed_at text,\n failed_at text,\n failure_reason text\n )";
|
|
24
24
|
const CREATE_AGENT_HEALTH: &str = "create table if not exists agent_health (\n owner_team_id text,\n agent_id text not null,\n status text not null,\n last_output_at text,\n context_usage_pct integer,\n current_task_id text,\n updated_at text not null,\n unique(owner_team_id, agent_id)\n )";
|
|
25
25
|
const CREATE_PEER_ALLOWLIST: &str = "create table if not exists peer_allowlist (\n a text not null,\n b text not null,\n created_at text not null,\n primary key (a, b)\n )";
|
|
26
|
-
const CREATE_RESULT_WATCHERS: &str = "create table if not exists result_watchers (\n watcher_id text primary key,\n owner_team_id text,\n task_id text,\n agent_id text,\n message_id text,\n leader_id text not null,\n status text not null,\n created_at text not null,\n completed_at text,\n result_id text,\n notified_message_id text,\n error text\n )";
|
|
26
|
+
const CREATE_RESULT_WATCHERS: &str = "create table if not exists result_watchers (\n watcher_id text primary key,\n owner_team_id text,\n task_id text,\n agent_id text,\n message_id text,\n leader_id text not null,\n recipient text,\n status text not null,\n created_at text not null,\n completed_at text,\n result_id text,\n notified_message_id text,\n error text\n )";
|
|
27
27
|
const CREATE_LEADER_NOTIFICATION_LOG: &str = "create table if not exists leader_notification_log (\n result_id text not null,\n owner_team_id text not null default '',\n owner_epoch integer not null default 0,\n leader_session_uuid text,\n notified_message_id text not null,\n notified_at text not null,\n leader_pane_id_at_notify text,\n envelope_content_hash text,\n primary key (result_id, owner_team_id, owner_epoch)\n )";
|
|
28
28
|
const CREATE_AGENT_HEALTH_NEW: &str = "create table agent_health_new (\n owner_team_id text,\n agent_id text not null,\n status text not null,\n last_output_at text,\n context_usage_pct integer,\n current_task_id text,\n updated_at text not null,\n unique(owner_team_id, agent_id)\n )";
|
|
29
29
|
|
|
@@ -104,6 +104,7 @@ const RESULT_WATCHER_COLUMNS: &[&str] = &[
|
|
|
104
104
|
"agent_id",
|
|
105
105
|
"message_id",
|
|
106
106
|
"leader_id",
|
|
107
|
+
"recipient",
|
|
107
108
|
"status",
|
|
108
109
|
"created_at",
|
|
109
110
|
"completed_at",
|
|
@@ -297,10 +298,16 @@ pub fn initialize_schema(
|
|
|
297
298
|
&tx,
|
|
298
299
|
"result_watchers",
|
|
299
300
|
RESULT_WATCHER_COLUMNS,
|
|
300
|
-
&[
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
301
|
+
&[
|
|
302
|
+
(
|
|
303
|
+
"owner_team_id",
|
|
304
|
+
"alter table result_watchers add column owner_team_id text",
|
|
305
|
+
),
|
|
306
|
+
(
|
|
307
|
+
"recipient",
|
|
308
|
+
"alter table result_watchers add column recipient text",
|
|
309
|
+
),
|
|
310
|
+
],
|
|
304
311
|
)?;
|
|
305
312
|
tx.execute(CREATE_LEADER_NOTIFICATION_LOG, [])?;
|
|
306
313
|
ensure_table_columns(
|
|
@@ -360,13 +367,13 @@ mod tests {
|
|
|
360
367
|
}
|
|
361
368
|
|
|
362
369
|
#[test]
|
|
363
|
-
fn
|
|
370
|
+
fn user_version_is_five() {
|
|
364
371
|
let conn = fresh();
|
|
365
372
|
let v: i64 = conn
|
|
366
373
|
.query_row("pragma user_version", [], |r| r.get(0))
|
|
367
374
|
.unwrap();
|
|
368
375
|
assert_eq!(v, SCHEMA_VERSION);
|
|
369
|
-
assert_eq!(v,
|
|
376
|
+
assert_eq!(v, 5);
|
|
370
377
|
}
|
|
371
378
|
|
|
372
379
|
#[test]
|
|
@@ -513,6 +520,7 @@ mod tests {
|
|
|
513
520
|
("agent_id", "TEXT", 0, None, 0),
|
|
514
521
|
("message_id", "TEXT", 0, None, 0),
|
|
515
522
|
("leader_id", "TEXT", 1, None, 0),
|
|
523
|
+
("recipient", "TEXT", 0, None, 0),
|
|
516
524
|
("status", "TEXT", 1, None, 0),
|
|
517
525
|
("created_at", "TEXT", 1, None, 0),
|
|
518
526
|
("completed_at", "TEXT", 0, None, 0),
|
|
@@ -505,7 +505,6 @@ pub fn claim_leader(
|
|
|
505
505
|
team: Option<&str>,
|
|
506
506
|
confirm: bool,
|
|
507
507
|
) -> Result<LeaseResult, LeaderError> {
|
|
508
|
-
let _ = confirm;
|
|
509
508
|
let caller = std::env::var("TMUX_PANE")
|
|
510
509
|
.ok()
|
|
511
510
|
.filter(|pane| !pane.is_empty())
|
|
@@ -598,7 +597,7 @@ pub fn claim_leader(
|
|
|
598
597
|
Some(team_id.as_str()),
|
|
599
598
|
&team_id,
|
|
600
599
|
&PaneId::new(caller),
|
|
601
|
-
|
|
600
|
+
confirm,
|
|
602
601
|
&event_log,
|
|
603
602
|
&liveness,
|
|
604
603
|
caller_target.as_ref(),
|
|
@@ -1269,12 +1269,12 @@ fn ensure_managed_provider_live_after_attach(
|
|
|
1269
1269
|
/// 0.4.x (CR C-3 P0): leader provider health reconciliation. The default
|
|
1270
1270
|
/// `liveness()` check only proves the pane is ADDRESSABLE via tmux — it
|
|
1271
1271
|
/// returns `Live` even when the provider has exited and the wrapper shell
|
|
1272
|
-
///
|
|
1272
|
+
/// remains at its inert tail. This function distinguishes
|
|
1273
1273
|
/// `provider_alive` from `provider_exited` by:
|
|
1274
1274
|
/// 1. Reading `pane_current_command` (tmux `#{pane_current_command}`).
|
|
1275
1275
|
/// 2. If the current command matches the expected provider binary (or
|
|
1276
1276
|
/// one of its known aliases), report `Alive`.
|
|
1277
|
-
/// 3. If the current command is
|
|
1277
|
+
/// 3. If the current command is a shell tail process AND the pane
|
|
1278
1278
|
/// content contains the exit marker `[team-agent] <provider> exited`
|
|
1279
1279
|
/// (emitted by `leader_shell_wrapper_command`), report
|
|
1280
1280
|
/// `ProviderExited`.
|
|
@@ -1282,7 +1282,7 @@ fn ensure_managed_provider_live_after_attach(
|
|
|
1282
1282
|
/// conservative default — avoid false-positive exit alarms.
|
|
1283
1283
|
///
|
|
1284
1284
|
/// Note: when the leader shell wrapper is used (CR C-2), a provider exit
|
|
1285
|
-
/// leaves the pane as
|
|
1285
|
+
/// leaves the pane as an inert `/bin/sh -c` tail with the exit marker in
|
|
1286
1286
|
/// scrollback. Pre-wrapper code that hit `exec claude` would have left the
|
|
1287
1287
|
/// pane as `[exited]` and `liveness()` would have returned `Dead`. The new
|
|
1288
1288
|
/// failure mode requires this richer health check to surface
|
|
@@ -1340,16 +1340,16 @@ pub enum LeaderProviderHealth {
|
|
|
1340
1340
|
Unreachable,
|
|
1341
1341
|
}
|
|
1342
1342
|
|
|
1343
|
-
/// 0.4.x (CR R6 + R3): single-source
|
|
1343
|
+
/// 0.4.x (CR R6 + R3): single-source shell-tail detection.
|
|
1344
1344
|
/// Used by:
|
|
1345
|
-
/// - `leader_provider_health` to decide "pane
|
|
1346
|
-
/// - shutdown logic to recognise a leader pane in
|
|
1345
|
+
/// - `leader_provider_health` to decide "pane is at the shell tail"
|
|
1346
|
+
/// - shutdown logic to recognise a leader pane in shell-tail mode
|
|
1347
1347
|
/// as still owned by the leader (not stray).
|
|
1348
1348
|
///
|
|
1349
1349
|
/// Matches by basename (case-insensitive) — `pane_current_command` returns
|
|
1350
1350
|
/// the basename of the running binary. Conservative whitelist of POSIX +
|
|
1351
|
-
/// common
|
|
1352
|
-
/// (shell looks like provider absent → health says Alive) which is the
|
|
1351
|
+
/// common shell processes; missing entries here are false negatives
|
|
1352
|
+
/// (shell tail looks like provider absent → health says Alive) which is the
|
|
1353
1353
|
/// safe default per the CR R6 conservative-Alive rule.
|
|
1354
1354
|
fn is_interactive_shell_basename(name: &str) -> bool {
|
|
1355
1355
|
let trimmed = name.trim().to_ascii_lowercase();
|
|
@@ -405,9 +405,9 @@ pub(super) fn spawn_agent_window(
|
|
|
405
405
|
|
|
406
406
|
// 0.5.39 Slice 2 (tmux-server-death-locate §7 Slice 2): route the
|
|
407
407
|
// primary worker spawn through the worker shell wrapper so provider
|
|
408
|
-
// exit leaves the pane at an
|
|
409
|
-
//
|
|
410
|
-
//
|
|
408
|
+
// exit leaves the pane at an inert sh tail with an explicit exit marker
|
|
409
|
+
// instead of collapsing the pane into `[exited]`. The inert tail does
|
|
410
|
+
// not read pane stdin. The split path stays
|
|
411
411
|
// on plain spawn_split — split panes are display overlays, not the
|
|
412
412
|
// primary worker process.
|
|
413
413
|
let provider_label = crate::provider::wire::command_name(provider);
|
|
@@ -518,9 +518,9 @@ if [ "$track" != 1 ]; then
|
|
|
518
518
|
fi
|
|
519
519
|
# 0.5.39 Slice 2: worker spawn now goes through the worker shell
|
|
520
520
|
# wrapper (tmux-server-death-locate §7 Slice 2), which embeds a
|
|
521
|
-
# printf format literal containing "\n" bytes so the pane
|
|
522
|
-
#
|
|
523
|
-
#
|
|
521
|
+
# printf format literal containing "\n" bytes so the pane retains an
|
|
522
|
+
# inert sh tail with an explicit exit marker instead of collapsing to
|
|
523
|
+
# `[exited]`. Those embedded newlines land inside the
|
|
524
524
|
# tmux argv payload, so a naive `printf '%s\n' "$*"` writes multiple
|
|
525
525
|
# lines per tmux invocation and downstream `raw.lines()` scans see
|
|
526
526
|
# fake "argv" lines that carry only the marker text. Escape newlines
|
|
@@ -167,13 +167,17 @@ fn restart_failure_aggregation_matches_serial_semantics_without_early_abort() {
|
|
|
167
167
|
["w1", "w2", "w4", "w5", "w6", "w7", "w8"],
|
|
168
168
|
"R2: successful workers after w3 must still be spawned and reported"
|
|
169
169
|
);
|
|
170
|
+
let mut observed_spawn_windows = transport
|
|
171
|
+
.spawn_calls()
|
|
172
|
+
.into_iter()
|
|
173
|
+
.map(|call| call.window)
|
|
174
|
+
.collect::<Vec<_>>();
|
|
175
|
+
let mut expected_spawn_windows = worker_ids(8);
|
|
176
|
+
observed_spawn_windows.sort();
|
|
177
|
+
expected_spawn_windows.sort();
|
|
170
178
|
assert_eq!(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
.iter()
|
|
174
|
-
.map(|call| call.window.as_str())
|
|
175
|
-
.collect::<Vec<_>>(),
|
|
176
|
-
worker_ids(8),
|
|
179
|
+
observed_spawn_windows,
|
|
180
|
+
expected_spawn_windows,
|
|
177
181
|
"R2: failure aggregation must attempt the full plan; no early return after w3"
|
|
178
182
|
);
|
|
179
183
|
|
|
@@ -24,17 +24,23 @@ fn sess(s: &str) -> SessionName {
|
|
|
24
24
|
pub(crate) fn test_binary_path() -> &'static str {
|
|
25
25
|
static PATH: OnceLock<String> = OnceLock::new();
|
|
26
26
|
PATH.get_or_init(|| {
|
|
27
|
-
if let Ok(path) = std::env::var("CARGO_BIN_EXE_team-agent") {
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
let path = if let Ok(path) = std::env::var("CARGO_BIN_EXE_team-agent") {
|
|
28
|
+
path
|
|
29
|
+
} else {
|
|
30
|
+
let current = std::env::current_exe().expect("test executable path");
|
|
31
|
+
current
|
|
32
32
|
.parent()
|
|
33
33
|
.and_then(|deps| deps.parent())
|
|
34
34
|
.map(|target| target.join("team-agent"))
|
|
35
35
|
.expect("team-agent test binary path")
|
|
36
36
|
.to_string_lossy()
|
|
37
37
|
.into_owned()
|
|
38
|
+
};
|
|
39
|
+
assert!(
|
|
40
|
+
std::path::Path::new(&path).is_file(),
|
|
41
|
+
"team-agent test binary does not exist: {path}; run `cargo build -p team-agent --bin team-agent` first"
|
|
42
|
+
);
|
|
43
|
+
path
|
|
38
44
|
})
|
|
39
45
|
.as_str()
|
|
40
46
|
}
|
|
@@ -211,6 +211,7 @@ pub(crate) fn delivery_outcome_value(out: &DeliveryOutcome) -> Value {
|
|
|
211
211
|
"ok": out.ok,
|
|
212
212
|
"status": enum_value(out.status),
|
|
213
213
|
"message_id": out.message_id,
|
|
214
|
+
"ack_forced_off": out.ack_forced_off,
|
|
214
215
|
});
|
|
215
216
|
if let Some(obj) = value.as_object_mut() {
|
|
216
217
|
if let Some(reason) = out.reason {
|