@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.
Files changed (61) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +7 -0
  4. package/crates/team-agent/src/cli/adapters.rs +8 -3
  5. package/crates/team-agent/src/cli/diagnose.rs +12 -3
  6. package/crates/team-agent/src/cli/emit.rs +1 -0
  7. package/crates/team-agent/src/cli/mod.rs +250 -9
  8. package/crates/team-agent/src/cli/send.rs +11 -2
  9. package/crates/team-agent/src/cli/status_port.rs +39 -4
  10. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
  11. package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
  12. package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
  13. package/crates/team-agent/src/cli/types.rs +5 -0
  14. package/crates/team-agent/src/coordinator/mod.rs +1 -0
  15. package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
  16. package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
  17. package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
  19. package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
  20. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
  21. package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
  22. package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
  23. package/crates/team-agent/src/coordinator/tick.rs +126 -30
  24. package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
  25. package/crates/team-agent/src/db/mod.rs +1 -0
  26. package/crates/team-agent/src/layout/mod.rs +7 -0
  27. package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
  28. package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
  29. package/crates/team-agent/src/leader/start.rs +27 -1
  30. package/crates/team-agent/src/leader/tests/identity.rs +50 -0
  31. package/crates/team-agent/src/lib.rs +6 -2
  32. package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
  33. package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
  34. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
  35. package/crates/team-agent/src/lifecycle/launch.rs +34 -0
  36. package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
  37. package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
  38. package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
  39. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
  40. package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
  41. package/crates/team-agent/src/lifecycle/restart.rs +6 -0
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
  43. package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
  44. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/types.rs +20 -1
  46. package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
  47. package/crates/team-agent/src/mcp_server/tools.rs +29 -4
  48. package/crates/team-agent/src/provider/adapter.rs +31 -1
  49. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
  50. package/crates/team-agent/src/provider/classify.rs +138 -0
  51. package/crates/team-agent/src/provider/command.rs +71 -0
  52. package/crates/team-agent/src/provider/mod.rs +3 -0
  53. package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
  54. package/crates/team-agent/src/provider/session/mod.rs +13 -0
  55. package/crates/team-agent/src/provider/session/resume.rs +417 -0
  56. package/crates/team-agent/src/provider/session_scan.rs +63 -0
  57. package/crates/team-agent/src/provider/types.rs +5 -0
  58. package/crates/team-agent/src/state/persist.rs +238 -0
  59. package/crates/team-agent/src/tmux_backend.rs +25 -0
  60. package/npm/install.mjs +27 -1
  61. 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
- assert_eq!(out["ok"], json!(true));
315
- assert_eq!(out["residuals"]["owned_files"], json!([]));
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
- assert_eq!(out["ok"], json!(true));
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
 
@@ -66,6 +66,7 @@ pub mod health;
66
66
  pub mod orphan;
67
67
  pub mod runtime_detectors;
68
68
  pub mod runtime_observation;
69
+ pub mod steps;
69
70
  pub mod tick;
70
71
  pub mod types;
71
72
 
@@ -0,0 +1,4 @@
1
+ //! unit-11 (Stage 4) — coordinator tick `abnormal` step group.
2
+ //!
3
+ //! Future home for abnormal-exit detection + classification steps from
4
+ //! `coordinator/tick.rs`.
@@ -0,0 +1,4 @@
1
+ //! unit-11 (Stage 4) — coordinator tick `delivery` step group.
2
+ //!
3
+ //! Future home for the message-delivery FSM ticks from
4
+ //! `coordinator/tick.rs`.
@@ -0,0 +1,4 @@
1
+ //! unit-11 (Stage 4) — coordinator tick `health_sync` step group.
2
+ //!
3
+ //! Future home for worker-liveness reconciliation steps from
4
+ //! `coordinator/tick.rs`.
@@ -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,4 @@
1
+ //! unit-11 (Stage 4) — coordinator tick `persist` step group.
2
+ //!
3
+ //! Future home for the durable state-persistence step at the end of each
4
+ //! tick (currently inlined in `coordinator/tick.rs`).
@@ -0,0 +1,4 @@
1
+ //! unit-11 (Stage 4) — coordinator tick `runtime_prompts` step group.
2
+ //!
3
+ //! Future home for the startup / approval / abnormal-exit prompt steps
4
+ //! from `coordinator/tick.rs`.
@@ -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.