@team-agent/installer 0.4.11 → 0.5.1

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 (80) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +98 -5
  6. package/crates/team-agent/src/cli/mod.rs +2 -0
  7. package/crates/team-agent/src/cli/named_address.rs +864 -0
  8. package/crates/team-agent/src/cli/send.rs +104 -0
  9. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
  12. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  13. package/crates/team-agent/src/cli/types.rs +4 -0
  14. package/crates/team-agent/src/compiler.rs +11 -20
  15. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  16. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  17. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  19. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  20. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  21. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  22. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  23. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  24. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  25. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  26. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  27. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  28. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  29. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  30. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  31. package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
  32. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  33. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  34. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  35. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  36. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  38. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  41. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
  42. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  43. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  44. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  45. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  46. package/crates/team-agent/src/messaging/activity.rs +43 -18
  47. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  48. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  49. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  50. package/crates/team-agent/src/messaging/results.rs +34 -9
  51. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  52. package/crates/team-agent/src/model/spec.rs +10 -6
  53. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  54. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  55. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  56. package/crates/team-agent/src/provider/classify.rs +23 -48
  57. package/crates/team-agent/src/provider/command.rs +16 -7
  58. package/crates/team-agent/src/provider/faults.rs +93 -53
  59. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  60. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  61. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  62. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  63. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  64. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  65. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  66. package/crates/team-agent/src/provider/types.rs +33 -1
  67. package/crates/team-agent/src/provider/wire.rs +171 -1
  68. package/crates/team-agent/src/state/identity.rs +242 -57
  69. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  70. package/crates/team-agent/src/state/mod.rs +5 -1
  71. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  72. package/crates/team-agent/src/state/ownership.rs +12 -11
  73. package/crates/team-agent/src/state/paths.rs +13 -6
  74. package/crates/team-agent/src/state/persist.rs +671 -128
  75. package/crates/team-agent/src/state/projection.rs +240 -49
  76. package/crates/team-agent/src/state/selector.rs +11 -5
  77. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  78. package/crates/team-agent/src/tmux_backend.rs +42 -5
  79. package/crates/team-agent/src/transport/test_support.rs +55 -6
  80. package/package.json +4 -4
@@ -0,0 +1,675 @@
1
+ use super::launch_spawn::seed_healthy_coordinator;
2
+ use super::*;
3
+ use crate::cli::{
4
+ cmd_collect, cmd_send, cmd_status, lifecycle_port, CmdOutput, CmdResult, CollectArgs, SendArgs,
5
+ StatusArgs,
6
+ };
7
+ use crate::transport::test_support::OfflineTransport;
8
+ use crate::transport::WindowName;
9
+ use serde_json::{json, Map, Value};
10
+ use std::collections::{BTreeMap, BTreeSet};
11
+ use std::path::{Path, PathBuf};
12
+
13
+ const ANCESTRY_ENV: &str = "TEAM_AGENT_TEST_PROCESS_ANCESTRY_ARGV_JSON";
14
+ const CALLER_IDENTITY_ENVS: &[&str] = &[
15
+ "TMUX",
16
+ "TMUX_PANE",
17
+ "TEAM_AGENT_LEADER_PANE_ID",
18
+ "TEAM_AGENT_LEADER_SESSION_UUID",
19
+ "TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE",
20
+ "TEAM_AGENT_LEADER_PROVIDER",
21
+ "TEAM_AGENT_MACHINE_FINGERPRINT",
22
+ "TEAM_AGENT_WORKSPACE",
23
+ "TEAM_AGENT_TEAM_ID",
24
+ "TEAM_AGENT_OWNER_TEAM_ID",
25
+ "TEAM_AGENT_ACTIVE_TEAM",
26
+ "TEAM_AGENT_ID",
27
+ ];
28
+
29
+ #[test]
30
+ #[serial_test::serial(env)]
31
+ fn phase_b_golden_events_state_status_zero_drift() {
32
+ let baseline = phase_fixture_path("phase_b").join("golden.json");
33
+ let actual = run_phase_golden(PhaseGolden {
34
+ phase: "phase_b",
35
+ team_key: "teamdir",
36
+ lifecycle_op: phase_b_reset_discard_session,
37
+ });
38
+ if std::env::var_os("TEAM_AGENT_UPDATE_GOLDEN").is_some() {
39
+ std::fs::create_dir_all(baseline.parent().expect("baseline parent")).unwrap();
40
+ std::fs::write(&baseline, pretty(&actual)).unwrap();
41
+ return;
42
+ }
43
+ let expected = std::fs::read_to_string(&baseline)
44
+ .unwrap_or_else(|e| panic!("missing golden baseline {}: {e}", baseline.display()));
45
+ let expected: Value = serde_json::from_str(&expected).expect("parse golden baseline");
46
+ assert_eq!(
47
+ actual,
48
+ expected,
49
+ "phase B golden drift; update intentionally with TEAM_AGENT_UPDATE_GOLDEN=1 only after review"
50
+ );
51
+ }
52
+
53
+ #[test]
54
+ #[serial_test::serial(env)]
55
+ fn phase_c_golden_events_state_status_zero_drift() {
56
+ let baseline = phase_fixture_path("phase_c").join("golden.json");
57
+ let actual = run_phase_golden(PhaseGolden {
58
+ phase: "phase_c",
59
+ team_key: "teamdir",
60
+ lifecycle_op: phase_b_reset_discard_session,
61
+ });
62
+ if std::env::var_os("TEAM_AGENT_UPDATE_GOLDEN").is_some() {
63
+ std::fs::create_dir_all(baseline.parent().expect("baseline parent")).unwrap();
64
+ std::fs::write(&baseline, pretty(&actual)).unwrap();
65
+ return;
66
+ }
67
+ let expected = std::fs::read_to_string(&baseline)
68
+ .unwrap_or_else(|e| panic!("missing golden baseline {}: {e}", baseline.display()));
69
+ let expected: Value = serde_json::from_str(&expected).expect("parse golden baseline");
70
+ assert_eq!(
71
+ actual,
72
+ expected,
73
+ "phase C golden drift; update intentionally with TEAM_AGENT_UPDATE_GOLDEN=1 only after review"
74
+ );
75
+ }
76
+
77
+ #[test]
78
+ #[serial_test::serial(env)]
79
+ fn phase_d_golden_events_state_status_zero_drift() {
80
+ let baseline = phase_fixture_path("phase_d").join("golden.json");
81
+ let actual = run_phase_golden(PhaseGolden {
82
+ phase: "phase_d",
83
+ team_key: "teamdir",
84
+ lifecycle_op: phase_b_reset_discard_session,
85
+ });
86
+ if std::env::var_os("TEAM_AGENT_UPDATE_GOLDEN").is_some() {
87
+ std::fs::create_dir_all(baseline.parent().expect("baseline parent")).unwrap();
88
+ std::fs::write(&baseline, pretty(&actual)).unwrap();
89
+ return;
90
+ }
91
+ let expected = std::fs::read_to_string(&baseline)
92
+ .unwrap_or_else(|e| panic!("missing golden baseline {}: {e}", baseline.display()));
93
+ let expected: Value = serde_json::from_str(&expected).expect("parse golden baseline");
94
+ assert_eq!(
95
+ actual,
96
+ expected,
97
+ "phase D golden drift; update intentionally with TEAM_AGENT_UPDATE_GOLDEN=1 only after review"
98
+ );
99
+ }
100
+
101
+ #[test]
102
+ #[serial_test::serial(env)]
103
+ fn phase_e_golden_events_state_status_zero_drift() {
104
+ let baseline = phase_fixture_path("phase_e").join("golden.json");
105
+ let actual = run_phase_golden(PhaseGolden {
106
+ phase: "phase_e",
107
+ team_key: "teamdir",
108
+ lifecycle_op: phase_b_reset_discard_session,
109
+ });
110
+ if std::env::var_os("TEAM_AGENT_UPDATE_GOLDEN").is_some() {
111
+ std::fs::create_dir_all(baseline.parent().expect("baseline parent")).unwrap();
112
+ std::fs::write(&baseline, pretty(&actual)).unwrap();
113
+ return;
114
+ }
115
+ let expected = std::fs::read_to_string(&baseline)
116
+ .unwrap_or_else(|e| panic!("missing golden baseline {}: {e}", baseline.display()));
117
+ let expected: Value = serde_json::from_str(&expected).expect("parse golden baseline");
118
+ assert_eq!(
119
+ actual,
120
+ expected,
121
+ "phase E golden drift; update intentionally with TEAM_AGENT_UPDATE_GOLDEN=1 only after review"
122
+ );
123
+ }
124
+
125
+ #[test]
126
+ #[serial_test::serial(env)]
127
+ fn phase_f_golden_events_state_status_zero_drift() {
128
+ let baseline = phase_fixture_path("phase_f").join("golden.json");
129
+ let actual = run_phase_golden(PhaseGolden {
130
+ phase: "phase_f",
131
+ team_key: "teamdir",
132
+ lifecycle_op: phase_b_reset_discard_session,
133
+ });
134
+ if std::env::var_os("TEAM_AGENT_UPDATE_GOLDEN").is_some() {
135
+ std::fs::create_dir_all(baseline.parent().expect("baseline parent")).unwrap();
136
+ std::fs::write(&baseline, pretty(&actual)).unwrap();
137
+ return;
138
+ }
139
+ let expected = std::fs::read_to_string(&baseline)
140
+ .unwrap_or_else(|e| panic!("missing golden baseline {}: {e}", baseline.display()));
141
+ let expected: Value = serde_json::from_str(&expected).expect("parse golden baseline");
142
+ assert_eq!(
143
+ actual,
144
+ expected,
145
+ "phase F golden drift; update intentionally with TEAM_AGENT_UPDATE_GOLDEN=1 only after review"
146
+ );
147
+ }
148
+
149
+ #[derive(Clone, Copy)]
150
+ struct PhaseGolden {
151
+ phase: &'static str,
152
+ team_key: &'static str,
153
+ lifecycle_op: fn(&Path, &OfflineTransport, &'static str) -> Value,
154
+ }
155
+
156
+ fn run_phase_golden(spec: PhaseGolden) -> Value {
157
+ let _permission_mode = EnvVarGuard::set(ANCESTRY_ENV, "[]");
158
+ let _caller_identity = CALLER_IDENTITY_ENVS
159
+ .iter()
160
+ .map(|key| EnvVarGuard::unset(key))
161
+ .collect::<Vec<_>>();
162
+ let team = two_worker_team_dir();
163
+ let workspace = team.parent().expect("workspace").to_path_buf();
164
+ seed_healthy_coordinator(&workspace);
165
+ let launch_transport = codex_ready_transport();
166
+ let quick_start = quick_start_with_transport_in_workspace_with_display(
167
+ &workspace,
168
+ &team,
169
+ None,
170
+ true,
171
+ None,
172
+ &launch_transport,
173
+ false,
174
+ );
175
+ let status_compact = cmd_status(&StatusArgs {
176
+ agent: None,
177
+ workspace: workspace.clone(),
178
+ detail: false,
179
+ summary: false,
180
+ json: true,
181
+ team: Some(spec.team_key.to_string()),
182
+ });
183
+ let status_detail = cmd_status(&StatusArgs {
184
+ agent: None,
185
+ workspace: workspace.clone(),
186
+ detail: true,
187
+ summary: false,
188
+ json: true,
189
+ team: Some(spec.team_key.to_string()),
190
+ });
191
+ let send = cmd_send(&SendArgs {
192
+ target: Some("w1".to_string()),
193
+ message: vec!["phase".to_string(), "golden".to_string()],
194
+ targets: None,
195
+ workspace: workspace.clone(),
196
+ team: Some(spec.team_key.to_string()),
197
+ task: None,
198
+ sender: "leader".to_string(),
199
+ no_ack: true,
200
+ no_wait: true,
201
+ watch_result: false,
202
+ timeout: 0.1,
203
+ confirm_human: false,
204
+ json: true,
205
+ message_id: Some("phase-golden-message".to_string()),
206
+ pane: None,
207
+ to_name: None,
208
+ });
209
+ let collect = cmd_collect(&CollectArgs {
210
+ workspace: workspace.clone(),
211
+ result_file: None,
212
+ json: true,
213
+ team: Some(spec.team_key.to_string()),
214
+ });
215
+ let lifecycle_transport = codex_ready_transport()
216
+ .with_session_present(true)
217
+ .with_windows(vec![WindowName::new("w1"), WindowName::new("w2")]);
218
+ let lifecycle = (spec.lifecycle_op)(&workspace, &lifecycle_transport, spec.team_key);
219
+ let shutdown = lifecycle_port::shutdown_with_transport(
220
+ &workspace,
221
+ true,
222
+ Some(spec.team_key),
223
+ &lifecycle_transport,
224
+ );
225
+
226
+ let raw = json!({
227
+ "phase": spec.phase,
228
+ "script": [
229
+ {
230
+ "step": "quick-start",
231
+ "exit_code": if quick_start.is_ok() { 0 } else { 1 },
232
+ "output": quick_start_value(quick_start),
233
+ },
234
+ { "step": "status-json", "result": cmd_value(status_compact) },
235
+ { "step": "status-detail-json", "result": cmd_value(status_detail) },
236
+ { "step": "send", "result": cmd_value(send) },
237
+ { "step": "collect", "result": cmd_value(collect) },
238
+ { "step": "phase-lifecycle-op", "output": lifecycle },
239
+ {
240
+ "step": "shutdown-keep-logs",
241
+ "exit_code": if shutdown.is_ok() { 0 } else { 1 },
242
+ "output": match shutdown {
243
+ Ok(value) => value,
244
+ Err(error) => json!({"ok": false, "error": error.to_string()}),
245
+ },
246
+ },
247
+ ],
248
+ "events_jsonl": read_events(&workspace),
249
+ "state_json": read_state(&workspace),
250
+ "transport": {
251
+ "spawns": lifecycle_transport.spawn_window_records(),
252
+ },
253
+ });
254
+
255
+ let mut ctx = NormalizeCtx::new(&workspace);
256
+ normalize_value(raw, &mut ctx, None)
257
+ }
258
+
259
+ fn phase_b_reset_discard_session(
260
+ workspace: &Path,
261
+ transport: &OfflineTransport,
262
+ team_key: &'static str,
263
+ ) -> Value {
264
+ match crate::lifecycle::reset_agent_with_transport(
265
+ workspace,
266
+ &aid("w1"),
267
+ true,
268
+ false,
269
+ Some(team_key),
270
+ transport,
271
+ ) {
272
+ Ok(ResetAgentOutcome::Reset {
273
+ start_mode,
274
+ discarded_session_id,
275
+ session_id,
276
+ new_session_id,
277
+ ..
278
+ }) => json!({
279
+ "ok": true,
280
+ "operation": "reset-agent --discard-session",
281
+ "status": "running",
282
+ "start_mode": format!("{start_mode:?}"),
283
+ "discarded_session_id": discarded_session_id.map(|id| id.as_str().to_string()),
284
+ "session_id": session_id.map(|id| id.as_str().to_string()),
285
+ "new_session_id": new_session_id.map(|id| id.as_str().to_string()),
286
+ }),
287
+ Ok(other) => json!({
288
+ "ok": true,
289
+ "operation": "reset-agent --discard-session",
290
+ "status": format!("{other:?}"),
291
+ }),
292
+ Err(error) => json!({
293
+ "ok": false,
294
+ "operation": "reset-agent --discard-session",
295
+ "error": error.to_string(),
296
+ }),
297
+ }
298
+ }
299
+
300
+ fn two_worker_team_dir() -> PathBuf {
301
+ let team = temp_ws().join("teamdir");
302
+ std::fs::create_dir_all(team.join("agents")).unwrap();
303
+ std::fs::write(
304
+ team.join("TEAM.md"),
305
+ "---\nname: phasegolden\nobjective: Phase golden.\nprovider: codex\n---\n\nPhase golden team.\n",
306
+ )
307
+ .unwrap();
308
+ for id in ["w1", "w2"] {
309
+ std::fs::write(team.join("agents").join(format!("{id}.md")), role_doc(id)).unwrap();
310
+ }
311
+ team
312
+ }
313
+
314
+ fn codex_ready_transport() -> OfflineTransport {
315
+ let mut transport = OfflineTransport::new();
316
+ for pane in 0..16 {
317
+ transport = transport.with_capture_for_pane(format!("%{pane}"), "OpenAI Codex");
318
+ }
319
+ transport
320
+ }
321
+
322
+ fn role_doc(id: &str) -> String {
323
+ format!(
324
+ "---\nname: {id}\nrole: {id} Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\n{id} worker.\n"
325
+ )
326
+ }
327
+
328
+ fn quick_start_value(result: Result<QuickStartReport, LifecycleError>) -> Value {
329
+ match result {
330
+ Ok(QuickStartReport::Ready {
331
+ session_name,
332
+ launch,
333
+ display_backend,
334
+ worker_readiness,
335
+ ..
336
+ }) => {
337
+ let agents = launch
338
+ .started
339
+ .iter()
340
+ .map(|started| {
341
+ json!({
342
+ "agent_id": started.agent_id.as_str(),
343
+ "target": started.target,
344
+ "start_mode": format!("{:?}", started.start_mode),
345
+ "session_id": started.session_id.as_ref().map(|id| id.as_str().to_string()),
346
+ "rollout_path": started.rollout_path.as_ref().map(|path| path.as_path().to_string_lossy().to_string()),
347
+ "layout_window": started.layout_window.as_ref().map(|window| window.as_str().to_string()),
348
+ })
349
+ })
350
+ .collect::<Vec<_>>();
351
+ json!({
352
+ "ok": true,
353
+ "status": "ready",
354
+ "session_name": session_name.as_str(),
355
+ "display_backend": display_backend,
356
+ "worker_readiness": format!("{worker_readiness:?}"),
357
+ "started": agents,
358
+ })
359
+ }
360
+ Ok(other) => json!({"ok": false, "status": format!("{other:?}")}),
361
+ Err(error) => json!({"ok": false, "error": error.to_string()}),
362
+ }
363
+ }
364
+
365
+ fn cmd_value(result: Result<CmdResult, crate::cli::CliError>) -> Value {
366
+ match result {
367
+ Ok(cmd) => json!({
368
+ "exit_code": cmd.exit.code(),
369
+ "output": match cmd.output {
370
+ CmdOutput::Json(value) => value,
371
+ CmdOutput::Human(text) => json!({"human": text}),
372
+ CmdOutput::None => Value::Null,
373
+ },
374
+ }),
375
+ Err(error) => json!({
376
+ "exit_code": 1,
377
+ "error": error.to_string(),
378
+ }),
379
+ }
380
+ }
381
+
382
+ fn read_events(workspace: &Path) -> Vec<Value> {
383
+ let path = crate::model::paths::logs_dir(workspace).join("events.jsonl");
384
+ std::fs::read_to_string(path)
385
+ .unwrap_or_default()
386
+ .lines()
387
+ .filter_map(|line| serde_json::from_str(line).ok())
388
+ .collect()
389
+ }
390
+
391
+ fn read_state(workspace: &Path) -> Value {
392
+ crate::state::persist::load_runtime_state(workspace).unwrap_or_else(|_| json!(null))
393
+ }
394
+
395
+ fn phase_fixture_path(phase: &str) -> PathBuf {
396
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
397
+ .join("tests")
398
+ .join("fixtures")
399
+ .join("0_5_0_phase_golden")
400
+ .join(phase)
401
+ }
402
+
403
+ struct EnvVarGuard {
404
+ key: &'static str,
405
+ previous: Option<String>,
406
+ }
407
+
408
+ impl EnvVarGuard {
409
+ fn set(key: &'static str, value: &str) -> Self {
410
+ let previous = std::env::var(key).ok();
411
+ unsafe {
412
+ std::env::set_var(key, value);
413
+ }
414
+ Self { key, previous }
415
+ }
416
+
417
+ fn unset(key: &'static str) -> Self {
418
+ let previous = std::env::var(key).ok();
419
+ unsafe {
420
+ std::env::remove_var(key);
421
+ }
422
+ Self { key, previous }
423
+ }
424
+ }
425
+
426
+ impl Drop for EnvVarGuard {
427
+ fn drop(&mut self) {
428
+ unsafe {
429
+ if let Some(value) = self.previous.take() {
430
+ std::env::set_var(self.key, value);
431
+ } else {
432
+ std::env::remove_var(self.key);
433
+ }
434
+ }
435
+ }
436
+ }
437
+
438
+ struct NormalizeCtx {
439
+ workspace_aliases: Vec<String>,
440
+ temp_aliases: Vec<String>,
441
+ pane_ids: BTreeMap<String, String>,
442
+ }
443
+
444
+ impl NormalizeCtx {
445
+ fn new(workspace: &Path) -> Self {
446
+ Self {
447
+ workspace_aliases: path_aliases(workspace),
448
+ temp_aliases: path_aliases(&std::env::temp_dir()),
449
+ pane_ids: BTreeMap::new(),
450
+ }
451
+ }
452
+
453
+ fn pane_token(&mut self, pane: &str) -> String {
454
+ if let Some(token) = self.pane_ids.get(pane) {
455
+ return token.clone();
456
+ }
457
+ let token = format!("<PANE:{}>", self.pane_ids.len());
458
+ self.pane_ids.insert(pane.to_string(), token.clone());
459
+ token
460
+ }
461
+ }
462
+
463
+ fn path_aliases(path: &Path) -> Vec<String> {
464
+ let mut aliases = Vec::new();
465
+ push_path_alias(&mut aliases, path.to_path_buf());
466
+ if let Ok(canonical) = std::fs::canonicalize(path) {
467
+ push_path_alias(&mut aliases, canonical);
468
+ }
469
+ aliases.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
470
+ aliases.dedup();
471
+ aliases
472
+ }
473
+
474
+ fn push_path_alias(aliases: &mut Vec<String>, path: PathBuf) {
475
+ let path = path.to_string_lossy().to_string();
476
+ if path.is_empty() {
477
+ return;
478
+ }
479
+ aliases.push(path.clone());
480
+ if let Some(stripped) = path.strip_prefix("/private/") {
481
+ aliases.push(format!("/{stripped}"));
482
+ } else if path.starts_with('/') {
483
+ aliases.push(format!("/private{path}"));
484
+ }
485
+ }
486
+
487
+ fn normalize_value(value: Value, ctx: &mut NormalizeCtx, key: Option<&str>) -> Value {
488
+ if matches!(key, Some("env_overlay_keys" | "env_unset_keys")) {
489
+ return json!("<ENV_KEYS>");
490
+ }
491
+ match value {
492
+ Value::Object(map) => {
493
+ let sorted = map.into_iter().collect::<BTreeMap<_, _>>();
494
+ let mut out = Map::new();
495
+ for (child_key, child) in sorted {
496
+ out.insert(
497
+ child_key.clone(),
498
+ normalize_value(child, ctx, Some(child_key.as_str())),
499
+ );
500
+ }
501
+ Value::Object(out)
502
+ }
503
+ Value::Array(items) => Value::Array(
504
+ items
505
+ .into_iter()
506
+ .map(|item| normalize_value(item, ctx, key))
507
+ .collect(),
508
+ ),
509
+ Value::String(text) => normalize_string(text, ctx, key),
510
+ Value::Number(number) if key_is_pid_or_duration(key) => {
511
+ if number.is_u64() || number.is_i64() {
512
+ json!(0)
513
+ } else {
514
+ Value::Number(number)
515
+ }
516
+ }
517
+ other => other,
518
+ }
519
+ }
520
+
521
+ fn normalize_string(text: String, ctx: &mut NormalizeCtx, key: Option<&str>) -> Value {
522
+ let key = key.unwrap_or_default();
523
+ if key_is_timestamp(key) {
524
+ return json!("<TS>");
525
+ }
526
+ if key.contains("endpoint") {
527
+ return json!("<SOCKET>");
528
+ }
529
+ if key_is_id(key, &text) {
530
+ return json!("<ID>");
531
+ }
532
+ if key_contains_path(key) {
533
+ return json!(normalize_path_string(&text, ctx));
534
+ }
535
+ if text.starts_with('%') && text[1..].chars().all(|c| c.is_ascii_digit()) {
536
+ return json!(ctx.pane_token(&text));
537
+ }
538
+ let normalized = normalize_path_string(&text, ctx);
539
+ if normalized != text {
540
+ return json!(normalized);
541
+ }
542
+ json!(text)
543
+ }
544
+
545
+ fn normalize_path_string(text: &str, ctx: &NormalizeCtx) -> String {
546
+ let mut out = text.to_string();
547
+ for alias in &ctx.workspace_aliases {
548
+ out = out.replace(alias, "<WORKSPACE>");
549
+ }
550
+ out = out.replace("/private<WORKSPACE>", "<WORKSPACE>");
551
+ for alias in &ctx.temp_aliases {
552
+ out = out.replace(alias, "<TMP>");
553
+ }
554
+ out = normalize_team_agent_binary_path(&out);
555
+ out = normalize_tmux_socket_dir(&out);
556
+ normalize_socket_token(&out)
557
+ }
558
+
559
+ fn normalize_team_agent_binary_path(text: &str) -> String {
560
+ let mut out = text.to_string();
561
+ for marker in [
562
+ "/target/debug/deps/team_agent-",
563
+ "/target/debug/team-agent",
564
+ "/target/release/team-agent",
565
+ ] {
566
+ out = replace_path_with_marker(out, marker, "<TEAM_AGENT_BIN>");
567
+ }
568
+ out
569
+ }
570
+
571
+ fn replace_path_with_marker(mut text: String, marker: &str, token: &str) -> String {
572
+ let mut search_from = 0;
573
+ while let Some(offset) = text[search_from..].find(marker) {
574
+ let marker_idx = search_from + offset;
575
+ let start = text[..marker_idx]
576
+ .rfind(|c: char| matches!(c, '"' | '\'' | ' ' | '[' | '('))
577
+ .map(|idx| idx + 1)
578
+ .unwrap_or(0);
579
+ let mut end = marker_idx + marker.len();
580
+ while end < text.len() {
581
+ let ch = text[end..].chars().next().expect("char at boundary");
582
+ if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
583
+ end += ch.len_utf8();
584
+ } else {
585
+ break;
586
+ }
587
+ }
588
+ text.replace_range(start..end, token);
589
+ search_from = start + token.len();
590
+ }
591
+ text
592
+ }
593
+
594
+ fn normalize_tmux_socket_dir(text: &str) -> String {
595
+ let out = text
596
+ .replace("/private/tmp/tmux-", "<TMP>/tmux-")
597
+ .replace("/tmp/tmux-", "<TMP>/tmux-");
598
+ normalize_tmux_uid_with_prefix(out, "<TMP>/tmux-")
599
+ }
600
+
601
+ fn normalize_tmux_uid_with_prefix(mut text: String, prefix: &str) -> String {
602
+ let mut search_from = 0;
603
+ while let Some(offset) = text[search_from..].find(prefix) {
604
+ let start = search_from + offset + prefix.len();
605
+ let mut end = start;
606
+ while end < text.len() {
607
+ let ch = text[end..].chars().next().expect("char at boundary");
608
+ if ch.is_ascii_digit() {
609
+ end += ch.len_utf8();
610
+ } else {
611
+ break;
612
+ }
613
+ }
614
+ if end == start {
615
+ search_from = start;
616
+ continue;
617
+ }
618
+ text.replace_range(start..end, "<UID>");
619
+ search_from = start + "<UID>".len();
620
+ }
621
+ text
622
+ }
623
+
624
+ fn normalize_socket_token(text: &str) -> String {
625
+ let mut out = text.to_string();
626
+ while let Some(idx) = out.find("ta-") {
627
+ let end = out[idx..]
628
+ .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
629
+ .map(|offset| idx + offset)
630
+ .unwrap_or_else(|| out.len());
631
+ if end == idx + 3 {
632
+ break;
633
+ }
634
+ out.replace_range(idx..end, "ta-<SOCKET>");
635
+ }
636
+ out
637
+ }
638
+
639
+ fn key_is_timestamp(key: &str) -> bool {
640
+ key == "ts" || key.ends_with("_at") || key.contains("timestamp")
641
+ }
642
+
643
+ fn key_is_pid_or_duration(key: Option<&str>) -> bool {
644
+ let Some(key) = key else {
645
+ return false;
646
+ };
647
+ key.contains("pid")
648
+ || key.ends_with("_ms")
649
+ || key.contains("duration")
650
+ || key.contains("elapsed")
651
+ }
652
+
653
+ fn key_contains_path(key: &str) -> bool {
654
+ key.contains("path")
655
+ || key.contains("workspace")
656
+ || key.contains("file")
657
+ || key.contains("socket")
658
+ || key.contains("endpoint")
659
+ || key == "log"
660
+ }
661
+
662
+ fn key_is_id(key: &str, text: &str) -> bool {
663
+ key == "message_id"
664
+ || key == "result_id"
665
+ || key == "watcher_id"
666
+ || key.ends_with("_message_id")
667
+ || key.ends_with("_result_id")
668
+ || text.starts_with("msg_")
669
+ }
670
+
671
+ fn pretty(value: &Value) -> String {
672
+ let mut text = serde_json::to_string_pretty(value).unwrap();
673
+ text.push('\n');
674
+ text
675
+ }
@@ -22,7 +22,10 @@ fn sess(s: &str) -> SessionName {
22
22
 
23
23
  mod core;
24
24
  mod launch_spawn;
25
+ mod lifecycle_lock;
25
26
  mod agent_ops;
26
27
  mod lane_ops;
27
28
  mod main_preserved;
29
+ mod phase_b_contracts;
30
+ mod phase_golden;
28
31
  mod restart;
@@ -377,6 +377,14 @@ pub enum LifecycleError {
377
377
  /// state 持久化失败(bug-084:`os.replace` EACCES/EPERM/EBUSY 退避后仍败)。
378
378
  #[error("state persistence failed: {0}")]
379
379
  StatePersist(String),
380
+ /// workspace lifecycle transaction lock acquisition timed out.
381
+ #[error("error: lifecycle lock timeout after 30s\naction: retry or check for hung reset/start\nlog: {log_path}")]
382
+ LifecycleLockTimeout {
383
+ lock_path: PathBuf,
384
+ log_path: PathBuf,
385
+ operation: String,
386
+ waited_ms: u128,
387
+ },
380
388
  /// 编译 spec / role doc 失败(`compile_team`/`compile_role_doc_agent`)。
381
389
  #[error("spec compile failed: {0}")]
382
390
  Compile(String),