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