@team-agent/installer 0.5.105 → 0.5.107

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 (46) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +5 -3
  4. package/README.zh.md +4 -3
  5. package/crates/team-agent/src/cli/adapters.rs +15 -1
  6. package/crates/team-agent/src/cli/emit.rs +33 -13
  7. package/crates/team-agent/src/cli/mod.rs +148 -21
  8. package/crates/team-agent/src/cli/status_port/brief.rs +62 -3
  9. package/crates/team-agent/src/cli/tests/base.rs +112 -225
  10. package/crates/team-agent/src/cli/tests/run_delegation.rs +4 -2
  11. package/crates/team-agent/src/cli/tests/status_send.rs +2 -2
  12. package/crates/team-agent/src/compiler/tests.rs +46 -59
  13. package/crates/team-agent/src/leader/tests/byte_findings.rs +1 -14
  14. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +57 -173
  15. package/crates/team-agent/src/lifecycle/launch/pi_mcp_extension.js +226 -0
  16. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +0 -37
  17. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -3
  18. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -0
  19. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +24 -17
  20. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +22 -9
  21. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_native_mcp_host.mjs +71 -0
  22. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +43 -66
  23. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +71 -392
  24. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +0 -5
  25. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +2 -361
  26. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +1 -16
  27. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +20 -112
  28. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1 -12
  29. package/crates/team-agent/src/lifecycle/tests/pi_native_mcp_capability_red.rs +546 -0
  30. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +6 -2
  31. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  32. package/crates/team-agent/src/lifecycle/worker_command_context.rs +11 -54
  33. package/crates/team-agent/src/messaging/tests/wave2.rs +2 -302
  34. package/crates/team-agent/src/model/name_similarity.rs +52 -67
  35. package/crates/team-agent/src/model/task_graph.rs +37 -124
  36. package/crates/team-agent/src/model/yaml/tests.rs +46 -165
  37. package/crates/team-agent/src/provider/tests/adapter.rs +0 -20
  38. package/crates/team-agent/src/provider/tests/classify.rs +106 -198
  39. package/crates/team-agent/src/state/projection.rs +15 -0
  40. package/crates/team-agent/src/tmux_backend/tests.rs +315 -501
  41. package/crates/team-agent/src/tmux_backend.rs +110 -0
  42. package/crates/team-agent/src/transport/test_support.rs +35 -0
  43. package/crates/team-agent/src/transport/tests/wire.rs +32 -58
  44. package/crates/team-agent/src/transport.rs +26 -12
  45. package/package.json +4 -4
  46. package/skills/team-agent/references/recovery-runbook.md +2 -2
@@ -5,9 +5,11 @@ use super::*;
5
5
  /// 把一段 YAML 文本经 `dumps(loads(t))` 往返,断言**逐字节**等于 Python golden。
6
6
  /// 这是 §4.2 双跑对该方言的字节锁:golden 由
7
7
  /// `PYTHONPATH=src python3 -c "from team_agent.simple_yaml import loads,dumps; ..."` 取得。
8
- fn roundtrip_eq(input: &str, golden: &str) {
9
- let v = loads(input).unwrap();
10
- assert_eq!(dumps(&v), golden, "roundtrip mismatch for input:\n{input}");
8
+ fn roundtrip_eq(id: &str, input: &str, golden: &str, note: &str) {
9
+ let parsed = loads(input);
10
+ assert!(parsed.is_ok(), "case={id}; {note}; parse failed: {parsed:?}");
11
+ let v = parsed.unwrap();
12
+ assert_eq!(dumps(&v), golden, "case={id}; {note}; roundtrip mismatch for input:\n{input}");
11
13
  }
12
14
 
13
15
  // === 真实语料:examples/team.spec.yaml 的 dumps(loads(t)) golden(逐字节) ===
@@ -41,168 +43,47 @@ fn rich_edge_fixture_roundtrip_matches_python_golden() {
41
43
  assert_eq!(dumps(&v), golden);
42
44
  }
43
45
 
44
- // === SKILL.md front-matter 块(`---` 之间那段)的 loads/dumps ===
45
- #[test]
46
- fn skill_front_matter_roundtrip() {
47
- // 取自 skills/team-agent/SKILL.md 的 front matter。
48
- let input = "name: team-agent\ndescription: Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.\n";
49
- let golden = "name: \"team-agent\"\ndescription: \"Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.\"\n";
50
- roundtrip_eq(input, golden);
51
- }
52
-
53
- // === 方言怪癖逐一对拍(每个 golden 来自 Python 真相源)===
54
-
55
- #[test]
56
- fn block_scalar_basic() {
57
- roundtrip_eq(
58
- "msg: |\n line one\n line two\n",
59
- "msg: |\n line one\n line two\n",
60
- );
61
- }
62
-
63
- #[test]
64
- fn block_scalar_blank_line_becomes_padded() {
65
- // 空行在 dumps 时产生仅含 pad 的行(尾随空格)—— Python 行为,必须复刻。
66
- roundtrip_eq("msg: |\n a\n\n b\n", "msg: |\n a\n \n b\n");
67
- }
68
-
69
- #[test]
70
- fn inline_unquoted_list_stays_string() {
71
- // ast.literal_eval('[a, b, c]') 失败 → 原样字符串。
72
- roundtrip_eq("tools: [a, b, c]\n", "tools: \"[a, b, c]\"\n");
73
- }
74
-
75
- #[test]
76
- fn inline_quoted_list_becomes_list() {
77
- roundtrip_eq("x: ['a', 'b']\n", "x:\n - \"a\"\n - \"b\"\n");
78
- }
79
-
80
- #[test]
81
- fn ints_canonicalize() {
82
- // 007 -> 7,-5 保号。
83
- roundtrip_eq("a: 1\nb: -5\nc: 007\n", "a: 1\nb: -5\nc: 7\n");
84
- }
85
-
86
- #[test]
87
- fn int_plus_and_underscore() {
88
- roundtrip_eq("a: +3\nb: 1_000\n", "a: 3\nb: 1000\n");
89
- }
90
-
91
- #[test]
92
- fn non_ints_stay_strings() {
93
- roundtrip_eq(
94
- "a: 1.5\nb: 0x1f\nc: 1e3\nd: _1\n",
95
- "a: \"1.5\"\nb: \"0x1f\"\nc: \"1e3\"\nd: \"_1\"\n",
96
- );
97
- }
98
-
99
- #[test]
100
- fn bools_and_nulls() {
101
- roundtrip_eq(
102
- "a: true\nb: False\nc: NULL\nd: ~\n",
103
- "a: true\nb: false\nc: null\nd: null\n",
104
- );
105
- }
106
-
107
- #[test]
108
- fn empty_collections() {
109
- roundtrip_eq("a: {}\nb: []\n", "a: {}\nb: []\n");
110
- }
111
-
112
- #[test]
113
- fn quoted_strings_unwrap() {
114
- roundtrip_eq("a: \"hello\"\nb: 'world'\n", "a: \"hello\"\nb: \"world\"\n");
115
- }
116
-
117
- #[test]
118
- fn nested_map() {
119
- roundtrip_eq("a:\n b:\n c: 1\n", "a:\n b:\n c: 1\n");
120
- }
121
-
122
- #[test]
123
- fn list_of_maps() {
124
- roundtrip_eq(
125
- "items:\n - id: x\n val: 1\n - id: y\n val: 2\n",
126
- "items:\n - id: \"x\"\n val: 1\n - id: \"y\"\n val: 2\n",
127
- );
128
- }
129
-
130
- #[test]
131
- fn scalar_list_mixed() {
132
- roundtrip_eq(
133
- "a:\n - 1\n - two\n - true\n",
134
- "a:\n - 1\n - \"two\"\n - true\n",
135
- );
136
- }
137
-
138
- #[test]
139
- fn empty_value_is_null() {
140
- roundtrip_eq("a:\nb: 2\n", "a: null\nb: 2\n");
141
- }
142
-
143
- #[test]
144
- fn inline_comment_not_stripped() {
145
- // 整行注释被跳过;行内 `#` 不被当注释,留在值里。
146
- roundtrip_eq(
147
- "# top\na: 1 # inline?\nb: 2\n",
148
- "a: \"1 # inline?\"\nb: 2\n",
149
- );
150
- }
151
-
152
- #[test]
153
- fn key_no_space_after_colon() {
154
- roundtrip_eq("a:1\n", "a: 1\n");
155
- }
156
-
157
- #[test]
158
- fn special_chars_json_escaped() {
159
- roundtrip_eq("a: he said \"hi\"\n", "a: \"he said \\\"hi\\\"\"\n");
160
- }
161
-
162
- #[test]
163
- fn unicode_preserved() {
164
- roundtrip_eq("name: café ☕\n", "name: \"café ☕\"\n");
165
- }
166
-
167
- #[test]
168
- fn list_item_value_has_colon_and_space() {
169
- // foo: bar baz —— item_text 看起来是 key-value(key=foo 合法),value = "bar baz"。
170
- roundtrip_eq("a:\n - foo: bar baz\n", "a:\n - foo: \"bar baz\"\n");
171
- }
172
-
173
- // === JSON 顶层路径(`{`/`[` 开头 → json.loads,保留键序)===
174
-
175
- #[test]
176
- fn json_object_path_preserves_key_order() {
177
- roundtrip_eq("{\"a\": 1, \"b\": [1,2]}", "a: 1\nb:\n - 1\n - 2\n");
178
- }
179
-
180
- #[test]
181
- fn json_array_path() {
182
- roundtrip_eq("[1, 2, 3]", "- 1\n- 2\n- 3\n");
183
- }
184
-
185
- #[test]
186
- fn json_leading_whitespace() {
187
- roundtrip_eq(" {\"a\": 1}", "a: 1\n");
188
- }
189
-
190
- #[test]
191
- fn json_float_becomes_quoted_string() {
192
- // json.loads -> float 1.5; dumps -> json.dumps(str(1.5)) = "1.5"。
193
- roundtrip_eq("{\"a\": 1.5}", "a: \"1.5\"\n");
194
- }
195
-
196
- #[test]
197
- fn json_string_with_newline_becomes_block_scalar() {
198
- roundtrip_eq("{\"x\": \"with\\nnewline\"}", "x: |\n with\n newline\n");
199
- }
200
-
201
- #[test]
202
- fn json_empty_object_and_array_top_level() {
203
- // 顶层 {} / [] → _dump 返回空 → "\n"。
204
- roundtrip_eq("{}", "\n");
205
- roundtrip_eq("[]", "\n");
46
+ // === SKILL front matter、方言怪癖与 JSON 顶层路径:逐字节锁定 Python golden ===
47
+ #[test]
48
+ fn python_golden_roundtrip_cases() {
49
+ // (旧函数 id, input, golden, Python 语义说明)。26 个旧测试包含 27 次往返。
50
+ let cases = [
51
+ (
52
+ "skill_front_matter_roundtrip",
53
+ "name: team-agent\ndescription: Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.\n",
54
+ "name: \"team-agent\"\ndescription: \"Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.\"\n",
55
+ "取自 skills/team-agent/SKILL.md 的 front matter。SKILL.md front-matter 块(`---` 之间那段)。",
56
+ ),
57
+ ("block_scalar_basic", "msg: |\n line one\n line two\n", "msg: |\n line one\n line two\n", ""),
58
+ ("block_scalar_blank_line_becomes_padded", "msg: |\n a\n\n b\n", "msg: |\n a\n \n b\n", "空行在 dumps 时产生仅含 pad 的行(尾随空格)—— Python 行为,必须复刻。"),
59
+ ("inline_unquoted_list_stays_string", "tools: [a, b, c]\n", "tools: \"[a, b, c]\"\n", "ast.literal_eval('[a, b, c]') 失败 → 原样字符串。"),
60
+ ("inline_quoted_list_becomes_list", "x: ['a', 'b']\n", "x:\n - \"a\"\n - \"b\"\n", ""),
61
+ ("ints_canonicalize", "a: 1\nb: -5\nc: 007\n", "a: 1\nb: -5\nc: 7\n", "007 -> 7,-5 保号。"),
62
+ ("int_plus_and_underscore", "a: +3\nb: 1_000\n", "a: 3\nb: 1000\n", ""),
63
+ ("non_ints_stay_strings", "a: 1.5\nb: 0x1f\nc: 1e3\nd: _1\n", "a: \"1.5\"\nb: \"0x1f\"\nc: \"1e3\"\nd: \"_1\"\n", ""),
64
+ ("bools_and_nulls", "a: true\nb: False\nc: NULL\nd: ~\n", "a: true\nb: false\nc: null\nd: null\n", ""),
65
+ ("empty_collections", "a: {}\nb: []\n", "a: {}\nb: []\n", ""),
66
+ ("quoted_strings_unwrap", "a: \"hello\"\nb: 'world'\n", "a: \"hello\"\nb: \"world\"\n", ""),
67
+ ("nested_map", "a:\n b:\n c: 1\n", "a:\n b:\n c: 1\n", ""),
68
+ ("list_of_maps", "items:\n - id: x\n val: 1\n - id: y\n val: 2\n", "items:\n - id: \"x\"\n val: 1\n - id: \"y\"\n val: 2\n", ""),
69
+ ("scalar_list_mixed", "a:\n - 1\n - two\n - true\n", "a:\n - 1\n - \"two\"\n - true\n", ""),
70
+ ("empty_value_is_null", "a:\nb: 2\n", "a: null\nb: 2\n", ""),
71
+ ("inline_comment_not_stripped", "# top\na: 1 # inline?\nb: 2\n", "a: \"1 # inline?\"\nb: 2\n", "整行注释被跳过;行内 `#` 不被当注释,留在值里。"),
72
+ ("key_no_space_after_colon", "a:1\n", "a: 1\n", ""),
73
+ ("special_chars_json_escaped", "a: he said \"hi\"\n", "a: \"he said \\\"hi\\\"\"\n", ""),
74
+ ("unicode_preserved", "name: café ☕\n", "name: \"café ☕\"\n", ""),
75
+ ("list_item_value_has_colon_and_space", "a:\n - foo: bar baz\n", "a:\n - foo: \"bar baz\"\n", "foo: bar baz —— item_text 看起来是 key-value(key=foo 合法),value = \"bar baz\"。"),
76
+ ("json_object_path_preserves_key_order", "{\"a\": 1, \"b\": [1,2]}", "a: 1\nb:\n - 1\n - 2\n", "JSON 顶层路径保留键序。"),
77
+ ("json_array_path", "[1, 2, 3]", "- 1\n- 2\n- 3\n", ""),
78
+ ("json_leading_whitespace", " {\"a\": 1}", "a: 1\n", ""),
79
+ ("json_float_becomes_quoted_string", "{\"a\": 1.5}", "a: \"1.5\"\n", "json.loads -> float 1.5; dumps -> json.dumps(str(1.5)) = \"1.5\"。"),
80
+ ("json_string_with_newline_becomes_block_scalar", "{\"x\": \"with\\nnewline\"}", "x: |\n with\n newline\n", ""),
81
+ ("json_empty_object_and_array_top_level", "{}", "\n", "顶层 {} / [] → _dump 返回空 → \"\\n\"。"),
82
+ ("json_empty_object_and_array_top_level", "[]", "\n", "顶层 {} / [] → _dump 返回空 → \"\\n\"。"),
83
+ ];
84
+ for (id, input, golden, note) in cases {
85
+ roundtrip_eq(id, input, golden, note);
86
+ }
206
87
  }
207
88
 
208
89
  // === dumps-only 结构(直接构造 Value,golden 来自 Python dumps)===
@@ -403,26 +403,6 @@ fn test_cursor_agent_accepts_mcp_config_without_putting_it_on_argv() {
403
403
  );
404
404
  }
405
405
 
406
- #[test]
407
- fn test_cursor_agent_refuses_mcp_config_loudly() {
408
- // 已废除的行为:旧实现收到 mcp_config 就返回 CapabilityUnsupported
409
- // (「MCP is not implemented」),此断言证明它确实没了。
410
- let adapter = get_adapter(Provider::CursorAgent);
411
- let cfg = McpConfig {
412
- raw: serde_json::json!({
413
- "team_orchestrator": {
414
- "command": "/bin/team-agent",
415
- "args": ["mcp-server"]
416
- }
417
- }),
418
- };
419
- let result = adapter.build_command(AuthMode::Subscription, Some(&cfg), None, None);
420
- assert!(
421
- !matches!(result, Err(ProviderError::CapabilityUnsupported(_))),
422
- "feeding mcp_config must not return CapabilityUnsupported; got {result:?}"
423
- );
424
- }
425
-
426
406
  #[test]
427
407
  fn test_grok_build_command_plan_binds_expected_session_id_to_argv() {
428
408
  let adapter = get_adapter(Provider::Grok);
@@ -6,53 +6,113 @@ fn claude_open_turn() -> String {
6
6
  }
7
7
 
8
8
  #[test]
9
- fn classify_empty_text_is_unknown_never_idle() {
10
- // probe empty_text: state=unknown turn_id=None reason=unreadable_or_empty
11
- // source=session_file. BLOOD-LINE: unknown is NEVER idle (bug-071/077/085).
12
- let c = classify(Provider::ClaudeCode, "", ProcessLiveness::Unverifiable, 0.0)
13
- .expect("classify ok");
14
- assert_eq!(c.state, TurnState::Unknown);
15
- assert_eq!(c.reason, "unreadable_or_empty");
16
- assert_eq!(c.turn_id, None);
17
- assert_eq!(c.source, ClassifySource::SessionFile);
18
- // The pin the audit demands: classify(unreadable) MUST NOT be treatable as idle.
19
- assert!(
20
- !c.state.is_idle_for_takeover(),
21
- "unreadable/empty input must never be idle (C5)"
9
+ fn classify_state_table() {
10
+ use ClassifySource::{ProcessGuard, SessionFile};
11
+ use ProcessLiveness::{Alive, Unverifiable};
12
+ use Provider::{Claude, ClaudeCode, Codex};
13
+ use TurnState::{Abnormal, BlockedOnHuman, Idle, IdleInterrupted, Unknown, Working};
14
+
15
+ type Input<'a> = (Provider, &'a str, ProcessLiveness, f64);
16
+ // State, reason, turn ID, source, exact annotations, idle-for-takeover.
17
+ // None skips an originally absent assertion; Some(None) asserts no turn ID.
18
+ type Expected<'a> = (
19
+ TurnState, &'a str, Option<Option<&'a str>>, Option<ClassifySource>,
20
+ Option<&'a [&'a str]>, Option<bool>,
22
21
  );
23
- }
24
-
25
- #[test]
26
- fn classify_whitespace_only_is_unknown() {
27
- // probe whitespace_only → unknown / unreadable_or_empty.
28
- let c = classify(Provider::Claude, " \n \t \n", ProcessLiveness::Unverifiable, 0.0)
29
- .expect("classify ok");
30
- assert_eq!(c.state, TurnState::Unknown);
31
- assert_eq!(c.reason, "unreadable_or_empty");
32
- assert!(!c.state.is_idle_for_takeover());
33
- }
34
-
35
- #[test]
36
- fn classify_garbage_jsonl_is_unknown_not_idle() {
37
- // probe garbage_jsonl ("not json\n{broken"): parse yields diagnostics but
38
- // had_records=false → reason=unreadable_or_empty (the !had_records branch
39
- // wins over diagnostics; common.py:72-75). NEVER idle.
40
- let c = classify(Provider::Claude, "not json\n{broken", ProcessLiveness::Unverifiable, 0.0)
41
- .expect("classify ok");
42
- assert_eq!(c.state, TurnState::Unknown);
43
- assert_eq!(c.reason, "unreadable_or_empty");
44
- assert!(!c.state.is_idle_for_takeover());
45
- }
46
-
47
- #[test]
48
- fn classify_unrecognized_format_is_unknown() {
49
- // probe '{"foo":"bar"}': had_records=true, no lifecycle fact, NO diagnostics
50
- // → reason=no_turn_lifecycle_fact (NOT unrecognized_format — golden-confirmed).
51
- let c = classify(Provider::Claude, r#"{"foo":"bar"}"#, ProcessLiveness::Unverifiable, 0.0)
52
- .expect("classify ok");
53
- assert_eq!(c.state, TurnState::Unknown);
54
- assert_eq!(c.reason, "no_turn_lifecycle_fact");
55
- assert!(!c.state.is_idle_for_takeover());
22
+ let open = claude_open_turn();
23
+ let two = format!(
24
+ "{}\n{}",
25
+ r#"{"type":"assistant","requestId":"a","message":{"stop_reason":"tool_use"}}"#,
26
+ r#"{"type":"assistant","requestId":"b","message":{"stop_reason":"end_turn"}}"#,
27
+ );
28
+ let c14 = format!(
29
+ "{}\n{}",
30
+ r#"{"type":"assistant","requestId":"a","message":{"stop_reason":"end_turn"}}"#,
31
+ r#"{"type":"assistant","requestId":"b","message":{"stop_reason":"tool_use"}}"#,
32
+ );
33
+ let cases: &[(&str, Input<'_>, Expected<'_>)] = &[
34
+ // Unknown is NEVER idle (bug-071/077/085, C5).
35
+ ("empty",
36
+ (ClaudeCode, "", Unverifiable, 0.0),
37
+ (Unknown, "unreadable_or_empty", Some(None), Some(SessionFile), None, Some(false))),
38
+ ("whitespace",
39
+ (Claude, " \n \t \n", Unverifiable, 0.0),
40
+ (Unknown, "unreadable_or_empty", None, None, None, Some(false))),
41
+ // !had_records wins over parse diagnostics (common.py:72-75).
42
+ ("garbage_jsonl",
43
+ (Claude, "not json\n{broken", Unverifiable, 0.0),
44
+ (Unknown, "unreadable_or_empty", None, None, None, Some(false))),
45
+ // Valid record without lifecycle facts: NOT unrecognized_format.
46
+ ("unrecognized_record",
47
+ (Claude, r#"{"foo":"bar"}"#, Unverifiable, 0.0),
48
+ (Unknown, "no_turn_lifecycle_fact", None, None, None, Some(false))),
49
+ // C4: missing process identity is NEVER optimistically working.
50
+ ("open_unverifiable_identity",
51
+ (Claude, &open, Unverifiable, 0.0),
52
+ (Unknown, "process_identity_unverified", Some(Some("r1")), Some(ProcessGuard), None, Some(false))),
53
+ ("open_alive",
54
+ (Claude, &open, Alive, 0.0),
55
+ (Working, "open_turn", Some(Some("r1")), Some(SessionFile), None, None)),
56
+ ("open_unverifiable_process",
57
+ (Claude, &open, Unverifiable, 0.0),
58
+ (Unknown, "process_identity_unverified", None, None, None, Some(false))),
59
+ ("last_fact_wins",
60
+ (Claude, &two, Unverifiable, 0.0),
61
+ (Idle, "end_turn", Some(Some("b")), None, None, None)),
62
+ // C14: silence is discarded; only a dead process can demote an open turn.
63
+ ("open_beats_silence",
64
+ (Claude, &c14, Alive, 9999.0),
65
+ (Working, "open_turn", Some(Some("b")), None, None, None)),
66
+ // C12: idle_interrupted IS idle for take-over, with exact annotations.
67
+ ("claude_interrupted",
68
+ (Claude, r#"{"type":"user","uuid":"u1","message":{"content":[{"type":"text","text":"[Request interrupted by user]"}]}}"#, Unverifiable, 0.0),
69
+ (IdleInterrupted, "user_interrupt", Some(Some("u1")), None, Some(&["interrupted"]), Some(true))),
70
+ ("claude_stop_sequence",
71
+ (Claude, r#"{"type":"assistant","requestId":"r1","message":{"stop_reason":"stop_sequence"}}"#, Unverifiable, 0.0),
72
+ (Idle, "stop_sequence", None, None, None, None)),
73
+ ("codex_task_complete",
74
+ (Codex, r#"{"type":"event_msg","payload":{"type":"task_complete","turn_id":"ct1"}}"#, Unverifiable, 0.0),
75
+ (Idle, "task_complete", Some(Some("ct1")), None, None, None)),
76
+ ("codex_abort_interrupted",
77
+ (Codex, r#"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"ct2","reason":"interrupted"}}"#, Unverifiable, 0.0),
78
+ (IdleInterrupted, "interrupted", None, None, None, None)),
79
+ // Raw abort reason must pass through, including non-interrupted errors.
80
+ ("codex_abort_error",
81
+ (Codex, r#"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"ct3","reason":"error"}}"#, Unverifiable, 0.0),
82
+ (IdleInterrupted, "error", Some(Some("ct3")), None, None, None)),
83
+ ("codex_turn_failed",
84
+ (Codex, r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"ct4","status":"failed"}}}"#, Unverifiable, 0.0),
85
+ (Abnormal, "turn_failed", Some(Some("ct4")), None, Some(&["turn_failed"]), Some(false))),
86
+ // blocked_on_human is NOT idle for take-over.
87
+ ("codex_approval",
88
+ (Codex, r#"{"jsonrpc":"2.0","method":"session/requestApproval","params":{"turnId":"ct5"}}"#, Unverifiable, 0.0),
89
+ (BlockedOnHuman, "approval_required", Some(Some("ct5")), None, Some(&["awaiting_approval"]), Some(false))),
90
+ ];
91
+ for &(id, (provider, text, liveness, silence), expected) in cases {
92
+ let c = classify(provider, text, liveness, silence)
93
+ .unwrap_or_else(|err| panic!("{id}: classify failed: {err:?}"));
94
+ let (state, reason, turn_id, source, annotations, idle) = expected;
95
+ let note = match id {
96
+ "empty" => "unreadable/empty input must never be idle (C5)",
97
+ "codex_abort_error" => "raw abort reason must pass through",
98
+ _ => "",
99
+ };
100
+ assert_eq!(c.state, state, "{id}: state");
101
+ assert_eq!(c.reason, reason, "{id}: reason; {note}");
102
+ if let Some(turn_id) = turn_id {
103
+ assert_eq!(c.turn_id.as_ref().map(TurnId::as_str), turn_id, "{id}: turn ID");
104
+ }
105
+ if let Some(source) = source {
106
+ assert_eq!(c.source, source, "{id}: source");
107
+ }
108
+ if let Some(annotations) = annotations {
109
+ let actual: Vec<_> = c.annotations.iter().map(String::as_str).collect();
110
+ assert_eq!(actual.as_slice(), annotations, "{id}: exact annotations");
111
+ }
112
+ if let Some(idle) = idle {
113
+ assert_eq!(c.state.is_idle_for_takeover(), idle, "{id}: idle-for-takeover; {note}");
114
+ }
115
+ }
56
116
  }
57
117
 
58
118
  #[test]
@@ -69,31 +129,6 @@ fn classify_claude_code_alias_normalizes_to_claude_reader() {
69
129
  assert_eq!(alias, canon, "claude_code must normalize to the claude reader");
70
130
  }
71
131
 
72
- #[test]
73
- fn classify_open_turn_with_no_process_is_unknown_not_working() {
74
- // probe open_turn_process_none: open turn + Unverifiable (None process) →
75
- // unknown / process_identity_unverified / source=process_guard, turn_id=r1.
76
- // C4: missing identity is NEVER optimistically working.
77
- let c = classify(Provider::Claude, &claude_open_turn(), ProcessLiveness::Unverifiable, 0.0)
78
- .expect("classify ok");
79
- assert_eq!(c.state, TurnState::Unknown);
80
- assert_eq!(c.reason, "process_identity_unverified");
81
- assert_eq!(c.source, ClassifySource::ProcessGuard);
82
- assert_eq!(c.turn_id, Some(TurnId::new("r1")));
83
- assert!(!c.state.is_idle_for_takeover());
84
- }
85
-
86
- #[test]
87
- fn classify_open_turn_alive_is_working() {
88
- // probe open_turn_alive: open turn + Alive → working / open_turn / source=session_file.
89
- let c = classify(Provider::Claude, &claude_open_turn(), ProcessLiveness::Alive, 0.0)
90
- .expect("classify ok");
91
- assert_eq!(c.state, TurnState::Working);
92
- assert_eq!(c.reason, "open_turn");
93
- assert_eq!(c.source, ClassifySource::SessionFile);
94
- assert_eq!(c.turn_id, Some(TurnId::new("r1")));
95
- }
96
-
97
132
  #[test]
98
133
  fn classify_open_turn_dead_is_abnormal_crashed_mid_turn() {
99
134
  // probe open_turn_dead: open turn + Dead → abnormal / crashed_mid_turn /
@@ -106,133 +141,6 @@ fn classify_open_turn_dead_is_abnormal_crashed_mid_turn() {
106
141
  assert!(c.annotations.contains(&"crashed_mid_turn".to_string()));
107
142
  }
108
143
 
109
- #[test]
110
- fn classify_open_turn_unverifiable_process_is_unknown() {
111
- // probe open_turn_unverifiable: open turn + Unverifiable →
112
- // unknown / process_identity_unverified (NOT working). NEVER idle.
113
- let c = classify(Provider::Claude, &claude_open_turn(), ProcessLiveness::Unverifiable, 0.0)
114
- .expect("classify ok");
115
- assert_eq!(c.state, TurnState::Unknown);
116
- assert_eq!(c.reason, "process_identity_unverified");
117
- assert!(!c.state.is_idle_for_takeover());
118
- }
119
-
120
- #[test]
121
- fn classify_last_lifecycle_fact_wins() {
122
- // probe last_fact_wins_complete (tool_use 'a' then end_turn 'b') →
123
- // idle / end_turn, turn_id = the LAST lifecycle fact's id ("b").
124
- let two = format!(
125
- "{}\n{}",
126
- r#"{"type":"assistant","requestId":"a","message":{"stop_reason":"tool_use"}}"#,
127
- r#"{"type":"assistant","requestId":"b","message":{"stop_reason":"end_turn"}}"#,
128
- );
129
- let c = classify(Provider::Claude, &two, ProcessLiveness::Unverifiable, 0.0)
130
- .expect("classify ok");
131
- assert_eq!(c.state, TurnState::Idle);
132
- assert_eq!(c.reason, "end_turn");
133
- assert_eq!(c.turn_id, Some(TurnId::new("b")));
134
- }
135
-
136
- #[test]
137
- fn classify_c14_open_turn_beats_silence() {
138
- // probe c14_open_after_complete_alive (end_turn 'a' then tool_use 'b', alive,
139
- // file_silence=9999) → working / open_turn, turn_id="b". Silence is DISCARDED:
140
- // only a dead process guard can demote an open turn (common.py:93-103, C14).
141
- let c14 = format!(
142
- "{}\n{}",
143
- r#"{"type":"assistant","requestId":"a","message":{"stop_reason":"end_turn"}}"#,
144
- r#"{"type":"assistant","requestId":"b","message":{"stop_reason":"tool_use"}}"#,
145
- );
146
- let c = classify(Provider::Claude, &c14, ProcessLiveness::Alive, 9999.0)
147
- .expect("classify ok");
148
- assert_eq!(c.state, TurnState::Working);
149
- assert_eq!(c.reason, "open_turn");
150
- assert_eq!(c.turn_id, Some(TurnId::new("b")));
151
- }
152
-
153
- #[test]
154
- fn classify_claude_interrupted_is_idle_interrupted_annotated() {
155
- // probe claude_interrupted ("[Request interrupted by user]") →
156
- // idle_interrupted / user_interrupt / annotations=["interrupted"], turn_id=u1.
157
- let txt = r#"{"type":"user","uuid":"u1","message":{"content":[{"type":"text","text":"[Request interrupted by user]"}]}}"#;
158
- let c = classify(Provider::Claude, txt, ProcessLiveness::Unverifiable, 0.0)
159
- .expect("classify ok");
160
- assert_eq!(c.state, TurnState::IdleInterrupted);
161
- assert_eq!(c.reason, "user_interrupt");
162
- assert_eq!(c.annotations, vec!["interrupted".to_string()]);
163
- assert_eq!(c.turn_id, Some(TurnId::new("u1")));
164
- // C12: idle_interrupted IS idle for take-over (annotated).
165
- assert!(c.state.is_idle_for_takeover());
166
- }
167
-
168
- #[test]
169
- fn classify_claude_stop_sequence_is_idle() {
170
- // probe claude_stop_sequence → idle / stop_sequence.
171
- let txt = r#"{"type":"assistant","requestId":"r1","message":{"stop_reason":"stop_sequence"}}"#;
172
- let c = classify(Provider::Claude, txt, ProcessLiveness::Unverifiable, 0.0)
173
- .expect("classify ok");
174
- assert_eq!(c.state, TurnState::Idle);
175
- assert_eq!(c.reason, "stop_sequence");
176
- }
177
-
178
- #[test]
179
- fn classify_codex_task_complete_is_idle() {
180
- // probe codex_task_complete → idle / task_complete, turn_id from payload (ct1).
181
- let txt = r#"{"type":"event_msg","payload":{"type":"task_complete","turn_id":"ct1"}}"#;
182
- let c = classify(Provider::Codex, txt, ProcessLiveness::Unverifiable, 0.0)
183
- .expect("classify ok");
184
- assert_eq!(c.state, TurnState::Idle);
185
- assert_eq!(c.reason, "task_complete");
186
- assert_eq!(c.turn_id, Some(TurnId::new("ct1")));
187
- }
188
-
189
- #[test]
190
- fn classify_codex_turn_aborted_interrupted_is_idle_interrupted() {
191
- // probe codex_turn_aborted_interrupted → idle_interrupted / interrupted.
192
- let interrupted = r#"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"ct2","reason":"interrupted"}}"#;
193
- let c = classify(Provider::Codex, interrupted, ProcessLiveness::Unverifiable, 0.0)
194
- .expect("classify ok");
195
- assert_eq!(c.state, TurnState::IdleInterrupted);
196
- assert_eq!(c.reason, "interrupted");
197
- // probe codex_turn_aborted_other (reason="error") → idle_interrupted with the
198
- // RAW reason string passed through ("error"), turn_id=ct3.
199
- let other = r#"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"ct3","reason":"error"}}"#;
200
- let c2 = classify(Provider::Codex, other, ProcessLiveness::Unverifiable, 0.0)
201
- .expect("classify ok");
202
- assert_eq!(c2.state, TurnState::IdleInterrupted);
203
- assert_eq!(c2.reason, "error", "raw abort reason must pass through");
204
- assert_eq!(c2.turn_id, Some(TurnId::new("ct3")));
205
- }
206
-
207
- #[test]
208
- fn classify_codex_appserver_failed_is_abnormal() {
209
- // probe codex_appserver_failed (turn.status==failed) → abnormal / turn_failed /
210
- // annotations=["turn_failed"], source=session_file, turn_id=ct4.
211
- let txt = r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"ct4","status":"failed"}}}"#;
212
- let c = classify(Provider::Codex, txt, ProcessLiveness::Unverifiable, 0.0)
213
- .expect("classify ok");
214
- assert_eq!(c.state, TurnState::Abnormal);
215
- assert_eq!(c.reason, "turn_failed");
216
- assert_eq!(c.annotations, vec!["turn_failed".to_string()]);
217
- assert_eq!(c.turn_id, Some(TurnId::new("ct4")));
218
- assert!(!c.state.is_idle_for_takeover());
219
- }
220
-
221
- #[test]
222
- fn classify_codex_appserver_approval_is_blocked_on_human() {
223
- // probe codex_appserver_approval (method endswith requestApproval) →
224
- // blocked_on_human / approval_required / annotations=["awaiting_approval"], turn_id=ct5.
225
- let txt = r#"{"jsonrpc":"2.0","method":"session/requestApproval","params":{"turnId":"ct5"}}"#;
226
- let c = classify(Provider::Codex, txt, ProcessLiveness::Unverifiable, 0.0)
227
- .expect("classify ok");
228
- assert_eq!(c.state, TurnState::BlockedOnHuman);
229
- assert_eq!(c.reason, "approval_required");
230
- assert_eq!(c.annotations, vec!["awaiting_approval".to_string()]);
231
- assert_eq!(c.turn_id, Some(TurnId::new("ct5")));
232
- // blocked_on_human is NOT idle for take-over.
233
- assert!(!c.state.is_idle_for_takeover());
234
- }
235
-
236
144
  // ---- (b) idle predicate / evaluate_takeover_reminder (NoPingReason cases) ----
237
145
  //
238
146
  // Golden via /tmp/probe_idle.py (idle_predicate.evaluate_takeover_reminder).
@@ -26,6 +26,21 @@ use crate::state::persist::{
26
26
 
27
27
  pub(crate) const CURRENT_TEAM_ALIAS: &str = "current";
28
28
 
29
+ /// Physical tmux session birth identity, independent of later worker spawns.
30
+ /// Legacy launches did not persist it: freeze their fallback BEFORE respawn.
31
+ pub fn session_generation(state: &Value) -> Option<&str> {
32
+ state.get("generation").and_then(Value::as_str).or_else(|| {
33
+ state
34
+ .get("agents")
35
+ .and_then(Value::as_object)
36
+ .and_then(|agents| {
37
+ agents.values().find_map(|agent| {
38
+ agent.get("spawned_at").and_then(Value::as_str)
39
+ })
40
+ })
41
+ })
42
+ }
43
+
29
44
  ///
30
45
  /// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
31
46
  /// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。