@team-agent/installer 0.3.39 → 0.4.0
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/Cargo.toml +7 -0
- package/crates/team-agent/src/cli/adapters.rs +8 -3
- package/crates/team-agent/src/cli/diagnose.rs +12 -3
- package/crates/team-agent/src/cli/emit.rs +1 -0
- package/crates/team-agent/src/cli/mod.rs +250 -9
- package/crates/team-agent/src/cli/send.rs +11 -2
- package/crates/team-agent/src/cli/status_port.rs +39 -4
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
- package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
- package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +5 -0
- package/crates/team-agent/src/coordinator/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
- package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
- package/crates/team-agent/src/coordinator/tick.rs +126 -30
- package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
- package/crates/team-agent/src/db/mod.rs +1 -0
- package/crates/team-agent/src/layout/mod.rs +7 -0
- package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
- package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
- package/crates/team-agent/src/leader/start.rs +27 -1
- package/crates/team-agent/src/leader/tests/identity.rs +50 -0
- package/crates/team-agent/src/lib.rs +6 -2
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
- package/crates/team-agent/src/lifecycle/launch.rs +34 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
- package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
- package/crates/team-agent/src/lifecycle/restart.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
- package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +20 -1
- package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
- package/crates/team-agent/src/mcp_server/tools.rs +29 -4
- package/crates/team-agent/src/provider/adapter.rs +31 -1
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
- package/crates/team-agent/src/provider/classify.rs +138 -0
- package/crates/team-agent/src/provider/command.rs +71 -0
- package/crates/team-agent/src/provider/mod.rs +3 -0
- package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
- package/crates/team-agent/src/provider/session/mod.rs +13 -0
- package/crates/team-agent/src/provider/session/resume.rs +417 -0
- package/crates/team-agent/src/provider/session_scan.rs +63 -0
- package/crates/team-agent/src/provider/types.rs +5 -0
- package/crates/team-agent/src/state/persist.rs +238 -0
- package/crates/team-agent/src/tmux_backend.rs +25 -0
- package/npm/install.mjs +27 -1
- package/package.json +4 -4
|
@@ -311,8 +311,16 @@ fn scoped_shutdown_keeps_owned_endpoint_when_sibling_session_remains() {
|
|
|
311
311
|
crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, Some("team-a"), &transport)
|
|
312
312
|
.expect("shutdown should complete");
|
|
313
313
|
|
|
314
|
-
|
|
315
|
-
|
|
314
|
+
// 0.4.0 refactor: shutdown now reports `ok=false` when sessions appear as
|
|
315
|
+
// residuals (`status="failed"`). This test uses
|
|
316
|
+
// `with_targets_persist_after_kill()` which leaves the killed session
|
|
317
|
+
// visible to the residual probe, so the new refactor surfaces it as a
|
|
318
|
+
// residual. The actual contract of this test is the SOCKET keep behavior
|
|
319
|
+
// (owned endpoint preserved when sibling sessions live on it), which the
|
|
320
|
+
// assertions below cover. We assert no owned_files residual + socket on
|
|
321
|
+
// disk + no kill_server, which are the invariants this test actually
|
|
322
|
+
// exists to guard.
|
|
323
|
+
assert_eq!(out["residuals"]["owned_files"], json!([]), "full out={out}");
|
|
316
324
|
assert!(
|
|
317
325
|
socket.exists(),
|
|
318
326
|
"owned endpoint stays while sibling team session remains"
|
|
@@ -677,7 +685,7 @@ fn shutdown_outcome_late_or_postcheck_gone_is_ok_with_lsof_diagnostic() {
|
|
|
677
685
|
cleanup_truth_degraded: false,
|
|
678
686
|
coordinator_timeout: true,
|
|
679
687
|
coordinator_stop_ok: None,
|
|
680
|
-
coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Gone,
|
|
688
|
+
coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Gone, target_session_spared: false,
|
|
681
689
|
},
|
|
682
690
|
);
|
|
683
691
|
|
|
@@ -697,7 +705,7 @@ fn shutdown_outcome_coordinator_timeout_still_running_is_timeout() {
|
|
|
697
705
|
cleanup_truth_degraded: false,
|
|
698
706
|
coordinator_timeout: true,
|
|
699
707
|
coordinator_stop_ok: None,
|
|
700
|
-
coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Running,
|
|
708
|
+
coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Running, target_session_spared: false,
|
|
701
709
|
},
|
|
702
710
|
);
|
|
703
711
|
|
|
@@ -717,7 +725,7 @@ fn shutdown_outcome_ps_table_degraded_still_partial_after_coordinator_gone() {
|
|
|
717
725
|
cleanup_truth_degraded: true,
|
|
718
726
|
coordinator_timeout: true,
|
|
719
727
|
coordinator_stop_ok: None,
|
|
720
|
-
coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Gone,
|
|
728
|
+
coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Gone, target_session_spared: false,
|
|
721
729
|
},
|
|
722
730
|
);
|
|
723
731
|
|
|
@@ -911,7 +919,26 @@ fn e49_managed_leader_shutdown_spares_leader_session_and_kills_workers_per_pane(
|
|
|
911
919
|
.any(|v| v.as_str() == Some("team-current")),
|
|
912
920
|
"E49: report's spared_sessions MUST contain the leader session. Got {out_spared:?}"
|
|
913
921
|
);
|
|
914
|
-
|
|
922
|
+
// 0.4.0 refactor: when the target session is deliberately spared (E49
|
|
923
|
+
// managed-leader topology), shutdown reports
|
|
924
|
+
// `status="dirty_state", phase="target_session_spared", ok=false`. This
|
|
925
|
+
// is semantically the SUCCESS path for E49 — leader pane preserved — but
|
|
926
|
+
// the new refactor surfaces it as a non-clean exit so callers can detect
|
|
927
|
+
// residual sessions. The E49 invariant (leader spared, workers killed per
|
|
928
|
+
// pane) is already verified by the preceding `out_killed` / `out_spared`
|
|
929
|
+
// assertions; here we pin the new top-level shape.
|
|
930
|
+
assert_eq!(
|
|
931
|
+
out["status"],
|
|
932
|
+
json!("dirty_state"),
|
|
933
|
+
"0.4.0 refactor: managed-leader spare path surfaces dirty_state \
|
|
934
|
+
(target_session_spared) — leader was spared, not killed. Got out={out}"
|
|
935
|
+
);
|
|
936
|
+
assert_eq!(
|
|
937
|
+
out["phase"],
|
|
938
|
+
json!("target_session_spared"),
|
|
939
|
+
"0.4.0 refactor: phase must name the spare reason. Got out={out}"
|
|
940
|
+
);
|
|
941
|
+
assert_eq!(out["session_killed"], json!(false));
|
|
915
942
|
}
|
|
916
943
|
|
|
917
944
|
/// E49 regression guard: external-leader topology (is_external_leader=true)
|
|
@@ -1065,3 +1092,159 @@ fn shutdown_missing_topology_marker_defaults_to_managed_cleanup() {
|
|
|
1065
1092
|
"missing topology marker must default to managed cleanup, not external kill-server"
|
|
1066
1093
|
);
|
|
1067
1094
|
}
|
|
1095
|
+
|
|
1096
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1097
|
+
// unit-0 (Stage 0) characterization tests
|
|
1098
|
+
//
|
|
1099
|
+
// These pin the CURRENT classify_shutdown_outcome behavior so that the
|
|
1100
|
+
// Stage 1 refactor (unit-2: move shutdown into lifecycle and feed real
|
|
1101
|
+
// session-kill facts into the classifier) is detectable as a behavior
|
|
1102
|
+
// change rather than an accidental regression.
|
|
1103
|
+
//
|
|
1104
|
+
// The classifier today does NOT consume `session_killed` / `spared_sessions`
|
|
1105
|
+
// facts — it only looks at residuals. That is the 0.3.39 false-green bug
|
|
1106
|
+
// surface. The tests below LOCK the current OK/failed/partial/timeout
|
|
1107
|
+
// branches against opaque residual booleans so that:
|
|
1108
|
+
// - unit-2 can wire in the missing topology facts and we observe the
|
|
1109
|
+
// intended new behavior on the false-green scenario,
|
|
1110
|
+
// - any unintended status change in OTHER branches breaks the suite.
|
|
1111
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1112
|
+
|
|
1113
|
+
#[test]
|
|
1114
|
+
fn unit0_classify_outcome_no_residuals_returns_ok() {
|
|
1115
|
+
// Baseline: nothing residual, coordinator clean -> ok/status:"ok".
|
|
1116
|
+
let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
|
|
1117
|
+
crate::cli::lifecycle_port::ShutdownOutcomeInput {
|
|
1118
|
+
kill_error: false,
|
|
1119
|
+
session_residuals: false,
|
|
1120
|
+
process_residuals: false,
|
|
1121
|
+
owned_file_residuals: false,
|
|
1122
|
+
cleanup_truth_degraded: false,
|
|
1123
|
+
coordinator_timeout: false,
|
|
1124
|
+
coordinator_stop_ok: Some(true),
|
|
1125
|
+
coordinator_post_stop:
|
|
1126
|
+
crate::cli::lifecycle_port::CoordinatorStopObservation::NotNeeded,
|
|
1127
|
+
target_session_spared: false,
|
|
1128
|
+
},
|
|
1129
|
+
);
|
|
1130
|
+
assert!(out.ok);
|
|
1131
|
+
assert_eq!(out.status, "ok");
|
|
1132
|
+
assert_eq!(out.phase, None);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
#[test]
|
|
1136
|
+
fn unit0_classify_outcome_session_residual_returns_failed() {
|
|
1137
|
+
let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
|
|
1138
|
+
crate::cli::lifecycle_port::ShutdownOutcomeInput {
|
|
1139
|
+
kill_error: false,
|
|
1140
|
+
session_residuals: true,
|
|
1141
|
+
process_residuals: false,
|
|
1142
|
+
owned_file_residuals: false,
|
|
1143
|
+
cleanup_truth_degraded: false,
|
|
1144
|
+
coordinator_timeout: false,
|
|
1145
|
+
coordinator_stop_ok: Some(true),
|
|
1146
|
+
coordinator_post_stop:
|
|
1147
|
+
crate::cli::lifecycle_port::CoordinatorStopObservation::Gone,
|
|
1148
|
+
target_session_spared: false,
|
|
1149
|
+
},
|
|
1150
|
+
);
|
|
1151
|
+
assert!(!out.ok);
|
|
1152
|
+
assert_eq!(out.status, "failed");
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
#[test]
|
|
1156
|
+
fn unit0_classify_outcome_coordinator_timeout_returns_timeout_phase() {
|
|
1157
|
+
let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
|
|
1158
|
+
crate::cli::lifecycle_port::ShutdownOutcomeInput {
|
|
1159
|
+
kill_error: false,
|
|
1160
|
+
session_residuals: false,
|
|
1161
|
+
process_residuals: false,
|
|
1162
|
+
owned_file_residuals: false,
|
|
1163
|
+
cleanup_truth_degraded: false,
|
|
1164
|
+
coordinator_timeout: true,
|
|
1165
|
+
coordinator_stop_ok: Some(false),
|
|
1166
|
+
coordinator_post_stop:
|
|
1167
|
+
crate::cli::lifecycle_port::CoordinatorStopObservation::Running,
|
|
1168
|
+
target_session_spared: false,
|
|
1169
|
+
},
|
|
1170
|
+
);
|
|
1171
|
+
assert!(!out.ok);
|
|
1172
|
+
assert_eq!(out.status, "timeout");
|
|
1173
|
+
assert_eq!(out.phase, Some("stop_coordinator"));
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
#[test]
|
|
1177
|
+
fn unit0_classify_outcome_cleanup_truth_degraded_returns_partial_os_probe() {
|
|
1178
|
+
let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
|
|
1179
|
+
crate::cli::lifecycle_port::ShutdownOutcomeInput {
|
|
1180
|
+
kill_error: false,
|
|
1181
|
+
session_residuals: false,
|
|
1182
|
+
process_residuals: false,
|
|
1183
|
+
owned_file_residuals: false,
|
|
1184
|
+
cleanup_truth_degraded: true,
|
|
1185
|
+
coordinator_timeout: false,
|
|
1186
|
+
coordinator_stop_ok: Some(true),
|
|
1187
|
+
coordinator_post_stop:
|
|
1188
|
+
crate::cli::lifecycle_port::CoordinatorStopObservation::Gone,
|
|
1189
|
+
target_session_spared: false,
|
|
1190
|
+
},
|
|
1191
|
+
);
|
|
1192
|
+
assert!(!out.ok);
|
|
1193
|
+
assert_eq!(out.status, "partial");
|
|
1194
|
+
assert_eq!(out.phase, Some("os_probe"));
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
#[test]
|
|
1198
|
+
fn unit2_classify_outcome_target_session_spared_is_not_green() {
|
|
1199
|
+
// unit-2 false-green guard: when the target worker session was spared
|
|
1200
|
+
// (still alive) the classifier MUST NOT return ok/status:"ok". This is
|
|
1201
|
+
// the 0.3.39 shutdown false-green shape unit-2 fixes.
|
|
1202
|
+
let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
|
|
1203
|
+
crate::cli::lifecycle_port::ShutdownOutcomeInput {
|
|
1204
|
+
kill_error: false,
|
|
1205
|
+
session_residuals: false,
|
|
1206
|
+
process_residuals: false,
|
|
1207
|
+
owned_file_residuals: false,
|
|
1208
|
+
cleanup_truth_degraded: false,
|
|
1209
|
+
coordinator_timeout: false,
|
|
1210
|
+
coordinator_stop_ok: Some(true),
|
|
1211
|
+
coordinator_post_stop:
|
|
1212
|
+
crate::cli::lifecycle_port::CoordinatorStopObservation::Gone,
|
|
1213
|
+
target_session_spared: true,
|
|
1214
|
+
},
|
|
1215
|
+
);
|
|
1216
|
+
assert!(
|
|
1217
|
+
!out.ok,
|
|
1218
|
+
"false-green regression: target_session_spared=true must force ok=false"
|
|
1219
|
+
);
|
|
1220
|
+
assert_eq!(out.status, "dirty_state");
|
|
1221
|
+
assert_eq!(out.phase, Some("target_session_spared"));
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
#[test]
|
|
1225
|
+
fn unit0_sessions_to_kill_leader_prefixed_session_is_always_spared() {
|
|
1226
|
+
// Pinned invariant: a session named `team-agent-leader-*` is NEVER in
|
|
1227
|
+
// the kill list, even without an anchor. unit-1/3 wraps this in typed
|
|
1228
|
+
// identity; the behavior must remain identical.
|
|
1229
|
+
use crate::transport::SessionName;
|
|
1230
|
+
let session_names = vec![
|
|
1231
|
+
SessionName::new("team-real-worker"),
|
|
1232
|
+
SessionName::new("team-agent-leader-claude-x"),
|
|
1233
|
+
];
|
|
1234
|
+
let decision = sessions_to_kill(&session_names, &BTreeSet::new());
|
|
1235
|
+
match decision {
|
|
1236
|
+
KillDecision::KillIndividually { to_kill, spared } => {
|
|
1237
|
+
assert!(to_kill.iter().any(|s| s.as_str() == "team-real-worker"));
|
|
1238
|
+
assert!(!to_kill
|
|
1239
|
+
.iter()
|
|
1240
|
+
.any(|s| s.as_str().starts_with("team-agent-leader-")));
|
|
1241
|
+
assert!(spared
|
|
1242
|
+
.iter()
|
|
1243
|
+
.any(|s| s.as_str() == "team-agent-leader-claude-x"));
|
|
1244
|
+
}
|
|
1245
|
+
other => panic!(
|
|
1246
|
+
"expected KillIndividually with leader spared; got {:?}",
|
|
1247
|
+
other
|
|
1248
|
+
),
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
@@ -23,6 +23,195 @@ use super::*;
|
|
|
23
23
|
// RED: status_port::status is unimplemented!() so the call panics until ported.
|
|
24
24
|
// =========================================================================
|
|
25
25
|
|
|
26
|
+
// =========================================================================
|
|
27
|
+
// RM-039-STAT-001 regression guard (real-machine evidence 2026-06-22).
|
|
28
|
+
//
|
|
29
|
+
// Architect verdict (bugs-stat001-sess001-architecture-analysis.md §root-cause):
|
|
30
|
+
// the coordinator-tick activity classifier writes
|
|
31
|
+
// `activity {status, confidence, rationale}` to the top-level
|
|
32
|
+
// `agents.<id>` slot of state.json (T1 invariant 60/61); the compact
|
|
33
|
+
// `status --json` projection MUST preserve it, last_output_at, and
|
|
34
|
+
// the enrich_agents-injected `interacted` marker. Lifecycle `status`
|
|
35
|
+
// and turn `activity.status` are separate fields per T1; the
|
|
36
|
+
// projection MUST NOT collapse them.
|
|
37
|
+
// =========================================================================
|
|
38
|
+
#[test]
|
|
39
|
+
fn rm039_stat001_compact_status_preserves_activity_last_output_and_interacted() {
|
|
40
|
+
let ws = seed_status_workspace();
|
|
41
|
+
let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
42
|
+
if let Some(agents) = state
|
|
43
|
+
.pointer_mut("/agents")
|
|
44
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
45
|
+
{
|
|
46
|
+
if let Some(agent) = agents
|
|
47
|
+
.get_mut("a1")
|
|
48
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
49
|
+
{
|
|
50
|
+
agent.insert(
|
|
51
|
+
"activity".to_string(),
|
|
52
|
+
json!({
|
|
53
|
+
"status": "working",
|
|
54
|
+
"confidence": 0.95,
|
|
55
|
+
"rationale": "provider_jsonl:open_turn",
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
agent.insert(
|
|
59
|
+
"last_output_at".to_string(),
|
|
60
|
+
json!("2026-06-22T02:52:30+00:00"),
|
|
61
|
+
);
|
|
62
|
+
// first_send_at is already set by seed_status_workspace so
|
|
63
|
+
// enrich_agents will inject `interacted` with the same ISO value.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
67
|
+
|
|
68
|
+
let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false)
|
|
69
|
+
.expect("compact status should project a value");
|
|
70
|
+
let agent = v
|
|
71
|
+
.pointer("/agents/a1")
|
|
72
|
+
.and_then(serde_json::Value::as_object)
|
|
73
|
+
.expect("seeded agent a1 must appear in compact projection");
|
|
74
|
+
|
|
75
|
+
// T1 split: lifecycle `status` stays unchanged.
|
|
76
|
+
assert_eq!(
|
|
77
|
+
agent.get("status").and_then(serde_json::Value::as_str),
|
|
78
|
+
Some("running"),
|
|
79
|
+
"RM-039-STAT-001: compact projection must NOT collapse `status` into `activity.status`"
|
|
80
|
+
);
|
|
81
|
+
// Turn activity preserved (the field that was historically dropped).
|
|
82
|
+
let activity = agent
|
|
83
|
+
.get("activity")
|
|
84
|
+
.expect("RM-039-STAT-001: compact projection must preserve `activity`");
|
|
85
|
+
assert_eq!(
|
|
86
|
+
activity.pointer("/status").and_then(serde_json::Value::as_str),
|
|
87
|
+
Some("working"),
|
|
88
|
+
"compact activity.status must survive the projection"
|
|
89
|
+
);
|
|
90
|
+
assert_eq!(
|
|
91
|
+
activity.pointer("/rationale").and_then(serde_json::Value::as_str),
|
|
92
|
+
Some("provider_jsonl:open_turn"),
|
|
93
|
+
"compact activity.rationale must survive the projection"
|
|
94
|
+
);
|
|
95
|
+
// last_output_at is the timestamp the classifier advances when
|
|
96
|
+
// scrollback digest changes; operators read it alongside activity.
|
|
97
|
+
assert_eq!(
|
|
98
|
+
agent.get("last_output_at").and_then(serde_json::Value::as_str),
|
|
99
|
+
Some("2026-06-22T02:52:30+00:00"),
|
|
100
|
+
"compact projection must preserve `last_output_at` for the \"is something moving\" view"
|
|
101
|
+
);
|
|
102
|
+
// enrich_agents injects `interacted` from first_send_at; the
|
|
103
|
+
// compact path must carry it (operators read it as a one-glance
|
|
104
|
+
// \"has the leader ever sent this worker a message?\" signal).
|
|
105
|
+
assert_eq!(
|
|
106
|
+
agent.get("interacted").and_then(serde_json::Value::as_str),
|
|
107
|
+
Some("2026-01-01T00:00:00Z"),
|
|
108
|
+
"compact projection must preserve `interacted` from enrich_agents"
|
|
109
|
+
);
|
|
110
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// =========================================================================
|
|
114
|
+
// RM-039-STAT-001 second-round regression (architect verdict 2026-06-22).
|
|
115
|
+
//
|
|
116
|
+
// Real failure shape: tick wrote `activity` to root .agents.coder and
|
|
117
|
+
// .teams.current.agents.coder because `team_state_key` cascaded to the
|
|
118
|
+
// `team_dir = "./.team/current"` basename. But status's selector reads
|
|
119
|
+
// the `active_team_key = "rm039-status-working-891"` projection, which
|
|
120
|
+
// was stale. The compact whitelist alone cannot fix this — by the time
|
|
121
|
+
// compact_agent_state runs, the selected team slot already lacks activity.
|
|
122
|
+
//
|
|
123
|
+
// Fix expected at the projection/state-key layer: when load_runtime_state
|
|
124
|
+
// sees `active_team_key` naming an existing teams entry and no root
|
|
125
|
+
// `team_key`, it must promote `team_key = active_team_key` so subsequent
|
|
126
|
+
// tick writes and status reads agree on which teams entry to use. The
|
|
127
|
+
// assertion here drives the real CLI path through `status_port::status`.
|
|
128
|
+
// =========================================================================
|
|
129
|
+
#[test]
|
|
130
|
+
fn rm039_stat001_status_resolves_active_team_when_root_team_key_missing() {
|
|
131
|
+
let ws = seed_status_workspace();
|
|
132
|
+
// Seed the exact dirty shape from the evidence: active_team_key
|
|
133
|
+
// disagrees with team_dir basename; root team_key absent; the
|
|
134
|
+
// teams.<active> entry is stale (no activity); root + teams.current
|
|
135
|
+
// carry activity that the tick had already written there.
|
|
136
|
+
let active = "rm039-status-working-891";
|
|
137
|
+
let activity = json!({
|
|
138
|
+
"status": "working",
|
|
139
|
+
"confidence": 0.95,
|
|
140
|
+
"rationale": "provider_jsonl:open_turn",
|
|
141
|
+
});
|
|
142
|
+
let state = json!({
|
|
143
|
+
"session_name": "team-rm039-status-working",
|
|
144
|
+
"team_dir": "./.team/current",
|
|
145
|
+
"active_team_key": active,
|
|
146
|
+
// intentionally no top-level "team_key" — that is the bug shape.
|
|
147
|
+
"leader": {"id": "leader"},
|
|
148
|
+
"leader_receiver": {"pane_id": "%3", "status": "running"},
|
|
149
|
+
"agents": {
|
|
150
|
+
"coder": {
|
|
151
|
+
"status": "running",
|
|
152
|
+
"first_send_at": "2026-01-01T00:00:00Z",
|
|
153
|
+
"activity": activity.clone(),
|
|
154
|
+
"last_output_at": "2026-06-22T02:52:30+00:00",
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"teams": {
|
|
158
|
+
"current": {
|
|
159
|
+
"active_team_key": active,
|
|
160
|
+
"session_name": "team-rm039-status-working",
|
|
161
|
+
"agents": {
|
|
162
|
+
"coder": {
|
|
163
|
+
"status": "running",
|
|
164
|
+
"first_send_at": "2026-01-01T00:00:00Z",
|
|
165
|
+
"activity": activity.clone(),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
active: {
|
|
170
|
+
"active_team_key": active,
|
|
171
|
+
"session_name": "team-rm039-status-working",
|
|
172
|
+
"agents": {
|
|
173
|
+
"coder": {
|
|
174
|
+
"status": "running",
|
|
175
|
+
"first_send_at": "2026-01-01T00:00:00Z",
|
|
176
|
+
// NO `activity` here — this is the stale entry
|
|
177
|
+
// that the selector landed on pre-fix.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
std::fs::write(
|
|
184
|
+
ws.join(".team").join("runtime").join("state.json"),
|
|
185
|
+
serde_json::to_vec_pretty(&state).unwrap(),
|
|
186
|
+
)
|
|
187
|
+
.unwrap();
|
|
188
|
+
|
|
189
|
+
let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false)
|
|
190
|
+
.expect("compact status should project a value");
|
|
191
|
+
let agent = v
|
|
192
|
+
.pointer("/agents/coder")
|
|
193
|
+
.and_then(serde_json::Value::as_object)
|
|
194
|
+
.expect("coder agent must appear in compact projection");
|
|
195
|
+
let got_activity = agent
|
|
196
|
+
.get("activity")
|
|
197
|
+
.expect("RM-039-STAT-001 second-round: activity must reach the compact projection \
|
|
198
|
+
even when active_team_key disagrees with team_dir basename");
|
|
199
|
+
assert_eq!(
|
|
200
|
+
got_activity.pointer("/status").and_then(serde_json::Value::as_str),
|
|
201
|
+
Some("working"),
|
|
202
|
+
"RM-039-STAT-001 second-round: compact status.agents.coder.activity.status must \
|
|
203
|
+
be `working` when state.active_team_key names an existing teams entry, \
|
|
204
|
+
regardless of team_dir basename"
|
|
205
|
+
);
|
|
206
|
+
// Lifecycle status unchanged — T1 split invariant.
|
|
207
|
+
assert_eq!(
|
|
208
|
+
agent.get("status").and_then(serde_json::Value::as_str),
|
|
209
|
+
Some("running"),
|
|
210
|
+
"lifecycle status must NOT collapse into activity.status"
|
|
211
|
+
);
|
|
212
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
213
|
+
}
|
|
214
|
+
|
|
26
215
|
#[test]
|
|
27
216
|
fn status_port_status_compact_json_shape_against_seeded_fixture() {
|
|
28
217
|
// cmd_status json branch (detail=false) delegates status_port::status(compact=true).
|
|
@@ -140,6 +140,7 @@ fn settle_success_json_byte_shape_wraps_collect() {
|
|
|
140
140
|
seed_settle_workspace(&ws);
|
|
141
141
|
|
|
142
142
|
let result = cmd_settle(&SettleArgs {
|
|
143
|
+
team: None,
|
|
143
144
|
workspace: ws.clone(),
|
|
144
145
|
json: true,
|
|
145
146
|
})
|
|
@@ -204,6 +205,7 @@ fn settle_success_human_output_insertion_order() {
|
|
|
204
205
|
seed_settle_workspace(&ws);
|
|
205
206
|
|
|
206
207
|
let result = cmd_settle(&SettleArgs {
|
|
208
|
+
team: None,
|
|
207
209
|
workspace: ws.clone(),
|
|
208
210
|
json: false,
|
|
209
211
|
})
|
|
@@ -614,6 +614,11 @@ pub struct CollectArgs {
|
|
|
614
614
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
615
615
|
pub struct SettleArgs {
|
|
616
616
|
pub workspace: PathBuf,
|
|
617
|
+
/// Bug #6 (prerelease 0.4.0 gate review): explicit team scope. When
|
|
618
|
+
/// `Some`, settle scopes collect/status/team-state to this team via
|
|
619
|
+
/// `resolve_active_team(workspace, Some(team), ...)`. When `None`,
|
|
620
|
+
/// the selector falls back to top-level `active_team_key` (legacy).
|
|
621
|
+
pub team: Option<String>,
|
|
617
622
|
pub json: bool,
|
|
618
623
|
}
|
|
619
624
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
//! unit-11 (Stage 4) — coordinator tick steps namespace.
|
|
2
|
+
//!
|
|
3
|
+
//! The 3083 LOC `coordinator/tick.rs` orchestrator runs an ordered
|
|
4
|
+
//! sequence of step groups per tick. This module establishes a named
|
|
5
|
+
//! home for each group so future commits migrate them in small,
|
|
6
|
+
//! reviewable pieces. The orchestrator stays in `tick.rs`; this is the
|
|
7
|
+
//! boundary for the upcoming step relocations.
|
|
8
|
+
//!
|
|
9
|
+
//! Step groups (canonical order):
|
|
10
|
+
//! 1. `session_gate` — provider session capture + readiness gates
|
|
11
|
+
//! 2. `health_sync` — worker liveness + reconciliation
|
|
12
|
+
//! 3. `delivery` — message delivery FSM ticks
|
|
13
|
+
//! 4. `runtime_prompts` — startup / approval / abnormal-exit prompts
|
|
14
|
+
//! 5. `abnormal` — abnormal-exit detection + classification
|
|
15
|
+
//! 6. `persist` — durable state persistence at tick end
|
|
16
|
+
|
|
17
|
+
pub mod abnormal;
|
|
18
|
+
pub mod delivery;
|
|
19
|
+
pub mod health_sync;
|
|
20
|
+
pub mod persist;
|
|
21
|
+
pub mod runtime_prompts;
|
|
22
|
+
pub mod session_gate;
|
|
23
|
+
|
|
24
|
+
/// Canonical ordering of tick step groups. The orchestrator must invoke
|
|
25
|
+
/// steps in this order so persisted state reflects every earlier step's
|
|
26
|
+
/// outcome.
|
|
27
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
28
|
+
pub enum TickStepGroup {
|
|
29
|
+
SessionGate,
|
|
30
|
+
HealthSync,
|
|
31
|
+
Delivery,
|
|
32
|
+
RuntimePrompts,
|
|
33
|
+
Abnormal,
|
|
34
|
+
Persist,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
impl TickStepGroup {
|
|
38
|
+
/// Stable label used in event-log and metric fields.
|
|
39
|
+
pub fn as_str(self) -> &'static str {
|
|
40
|
+
match self {
|
|
41
|
+
Self::SessionGate => "tick.session_gate",
|
|
42
|
+
Self::HealthSync => "tick.health_sync",
|
|
43
|
+
Self::Delivery => "tick.delivery",
|
|
44
|
+
Self::RuntimePrompts => "tick.runtime_prompts",
|
|
45
|
+
Self::Abnormal => "tick.abnormal",
|
|
46
|
+
Self::Persist => "tick.persist",
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Canonical ordering as a const slice — exhaustive over the enum.
|
|
51
|
+
pub fn ordered() -> &'static [TickStepGroup] {
|
|
52
|
+
&[
|
|
53
|
+
TickStepGroup::SessionGate,
|
|
54
|
+
TickStepGroup::HealthSync,
|
|
55
|
+
TickStepGroup::Delivery,
|
|
56
|
+
TickStepGroup::RuntimePrompts,
|
|
57
|
+
TickStepGroup::Abnormal,
|
|
58
|
+
TickStepGroup::Persist,
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#[cfg(test)]
|
|
64
|
+
mod tests {
|
|
65
|
+
use super::*;
|
|
66
|
+
|
|
67
|
+
#[test]
|
|
68
|
+
fn ordered_groups_cover_every_variant() {
|
|
69
|
+
let ordered = TickStepGroup::ordered();
|
|
70
|
+
assert_eq!(ordered.len(), 6);
|
|
71
|
+
// Persist is last (state writes follow every other step).
|
|
72
|
+
assert_eq!(*ordered.last().unwrap(), TickStepGroup::Persist);
|
|
73
|
+
// SessionGate is first (downstream steps depend on it).
|
|
74
|
+
assert_eq!(*ordered.first().unwrap(), TickStepGroup::SessionGate);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#[test]
|
|
78
|
+
fn labels_are_dotted_paths_under_tick() {
|
|
79
|
+
for g in TickStepGroup::ordered() {
|
|
80
|
+
assert!(g.as_str().starts_with("tick."));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
//! unit-11 (Stage 4) — coordinator tick `session_gate` step group.
|
|
2
|
+
//!
|
|
3
|
+
//! Future home for the provider session-capture + readiness-gate steps
|
|
4
|
+
//! currently inlined in `coordinator/tick.rs`. Boundary established by
|
|
5
|
+
//! unit-11; actual relocation lands in follow-up commits.
|