@team-agent/installer 0.3.39 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +7 -0
- package/crates/team-agent/src/cli/adapters.rs +8 -3
- package/crates/team-agent/src/cli/diagnose.rs +12 -3
- package/crates/team-agent/src/cli/emit.rs +1 -0
- package/crates/team-agent/src/cli/mod.rs +250 -9
- package/crates/team-agent/src/cli/send.rs +11 -2
- package/crates/team-agent/src/cli/status_port.rs +39 -4
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
- package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
- package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +5 -0
- package/crates/team-agent/src/coordinator/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
- package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
- package/crates/team-agent/src/coordinator/tick.rs +126 -30
- package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
- package/crates/team-agent/src/db/mod.rs +1 -0
- package/crates/team-agent/src/layout/mod.rs +7 -0
- package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
- package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
- package/crates/team-agent/src/leader/start.rs +27 -1
- package/crates/team-agent/src/leader/tests/identity.rs +50 -0
- package/crates/team-agent/src/lib.rs +6 -2
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
- package/crates/team-agent/src/lifecycle/launch.rs +34 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
- package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
- package/crates/team-agent/src/lifecycle/restart.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
- package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +20 -1
- package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
- package/crates/team-agent/src/mcp_server/tools.rs +29 -4
- package/crates/team-agent/src/provider/adapter.rs +31 -1
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
- package/crates/team-agent/src/provider/classify.rs +138 -0
- package/crates/team-agent/src/provider/command.rs +71 -0
- package/crates/team-agent/src/provider/mod.rs +3 -0
- package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
- package/crates/team-agent/src/provider/session/mod.rs +13 -0
- package/crates/team-agent/src/provider/session/resume.rs +417 -0
- package/crates/team-agent/src/provider/session_scan.rs +63 -0
- package/crates/team-agent/src/provider/types.rs +5 -0
- package/crates/team-agent/src/state/persist.rs +238 -0
- package/crates/team-agent/src/tmux_backend.rs +25 -0
- package/npm/install.mjs +27 -1
- package/package.json +4 -4
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
//! unit-0 (Stage 0) characterization tests for restart resume preflight.
|
|
2
|
+
//!
|
|
3
|
+
//! These pin the current `classify_restart_plan` behavior so that the
|
|
4
|
+
//! Stage 2 refactor (unit-5: replace the opaque `session_unresumable`
|
|
5
|
+
//! string with a `ResumeRefusalReason` enum + structured backing-store
|
|
6
|
+
//! check) is detectable as a behavior change rather than an accidental
|
|
7
|
+
//! regression.
|
|
8
|
+
//!
|
|
9
|
+
//! Pinned invariants:
|
|
10
|
+
//! - A worker with `session_id` set but backing store absent is currently
|
|
11
|
+
//! tagged with the OPAQUE string `session_unresumable`. unit-5 must
|
|
12
|
+
//! evolve this to a structured reason (e.g. `session_backing_store_missing`).
|
|
13
|
+
//! - A worker with NO `session_id` is currently tagged
|
|
14
|
+
//! `no_persisted_session_id` — distinct from the missing-backing case.
|
|
15
|
+
//! unit-5 must keep this distinction.
|
|
16
|
+
//! - `allow_fresh=true` converts both refusal classes into a `FreshStart`
|
|
17
|
+
//! decision (no entries in `unresumable`).
|
|
18
|
+
|
|
19
|
+
use super::*;
|
|
20
|
+
use crate::lifecycle::restart::classify_restart_plan;
|
|
21
|
+
use crate::lifecycle::types::{ResumeDecision, StartMode};
|
|
22
|
+
|
|
23
|
+
fn agent_codex(session_id: Option<&str>, first_send_at: serde_json::Value) -> serde_json::Value {
|
|
24
|
+
let mut obj = serde_json::Map::new();
|
|
25
|
+
obj.insert("provider".to_string(), json!("codex"));
|
|
26
|
+
obj.insert("status".to_string(), json!("running"));
|
|
27
|
+
obj.insert("first_send_at".to_string(), first_send_at);
|
|
28
|
+
if let Some(sid) = session_id {
|
|
29
|
+
obj.insert("session_id".to_string(), json!(sid));
|
|
30
|
+
}
|
|
31
|
+
serde_json::Value::Object(obj)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#[test]
|
|
35
|
+
fn unit0_restart_missing_session_id_is_no_persisted_session_id() {
|
|
36
|
+
let state = json!({
|
|
37
|
+
"agents": {
|
|
38
|
+
"a": agent_codex(None, json!("2026-01-01T00:00:00Z")),
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
let plan = classify_restart_plan(&state, false).unwrap();
|
|
42
|
+
assert_eq!(plan.unresumable.len(), 1);
|
|
43
|
+
assert_eq!(plan.unresumable[0].agent_id.as_str(), "a");
|
|
44
|
+
assert_eq!(plan.unresumable[0].reason, "no_persisted_session_id");
|
|
45
|
+
assert!(plan.unresumable[0].session_id.is_none());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#[test]
|
|
49
|
+
fn unit0_restart_session_id_present_no_backing_is_session_unresumable() {
|
|
50
|
+
// session_id is set, but resume_backing_exists_for_agent will return
|
|
51
|
+
// false because no workspace was passed AND rollout_path is missing.
|
|
52
|
+
// The current behavior is to flatten this into `session_unresumable`
|
|
53
|
+
// — the opaque reason that unit-5 will replace with structured enum.
|
|
54
|
+
let state = json!({
|
|
55
|
+
"agents": {
|
|
56
|
+
"a": agent_codex(Some("sess-unit0-missing"), json!("2026-01-01T00:00:00Z")),
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
let plan = classify_restart_plan(&state, false).unwrap();
|
|
60
|
+
assert_eq!(plan.unresumable.len(), 1);
|
|
61
|
+
assert_eq!(plan.unresumable[0].agent_id.as_str(), "a");
|
|
62
|
+
assert_eq!(plan.unresumable[0].reason, "session_unresumable");
|
|
63
|
+
assert!(plan.unresumable[0].session_id.is_some());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#[test]
|
|
67
|
+
fn unit0_restart_allow_fresh_converts_refusals_into_fresh_decisions() {
|
|
68
|
+
let state = json!({
|
|
69
|
+
"agents": {
|
|
70
|
+
"a": agent_codex(None, json!("2026-01-01T00:00:00Z")),
|
|
71
|
+
"b": agent_codex(Some("sess-unit0-allow-fresh"), json!("2026-01-01T00:00:00Z")),
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
let plan = classify_restart_plan(&state, true).unwrap();
|
|
75
|
+
// Both unresumable-class workers should be turned into FreshStart.
|
|
76
|
+
assert!(
|
|
77
|
+
plan.unresumable.is_empty(),
|
|
78
|
+
"allow_fresh should drain the unresumable bucket; got {:?}",
|
|
79
|
+
plan.unresumable
|
|
80
|
+
);
|
|
81
|
+
assert_eq!(plan.decisions.len(), 2);
|
|
82
|
+
for d in &plan.decisions {
|
|
83
|
+
assert!(
|
|
84
|
+
matches!(d.restart_mode, StartMode::Fresh),
|
|
85
|
+
"agent {} should be Fresh under allow_fresh; got {:?}",
|
|
86
|
+
d.agent_id.as_str(),
|
|
87
|
+
d.restart_mode
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
let _ = ResumeDecision::FreshStart; // touch the enum so unused-import lint stays quiet.
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[test]
|
|
94
|
+
fn layer2_backing_missing_refusal_carries_checked_paths_and_recovery_hint() {
|
|
95
|
+
// Leader follow-up 2026-06-22: when classify_restart_plan_with_resume_validation
|
|
96
|
+
// runs with a real workspace, a codex worker whose session_id is set
|
|
97
|
+
// but whose rollout_path does not exist on disk should produce a
|
|
98
|
+
// refusal whose structured ResumeRefusalReason::SessionBackingStoreMissing
|
|
99
|
+
// carries the actual probed paths (the persisted rollout_path) AND a
|
|
100
|
+
// RecoveryHint with the agent_id as the picker name.
|
|
101
|
+
use std::sync::atomic::{AtomicU32, Ordering};
|
|
102
|
+
static N: AtomicU32 = AtomicU32::new(0);
|
|
103
|
+
let n = N.fetch_add(1, Ordering::Relaxed);
|
|
104
|
+
let ws = std::env::temp_dir().join(format!(
|
|
105
|
+
"ta_rs_l2_backingmiss_{}_{}",
|
|
106
|
+
std::process::id(),
|
|
107
|
+
n
|
|
108
|
+
));
|
|
109
|
+
std::fs::create_dir_all(&ws).unwrap();
|
|
110
|
+
|
|
111
|
+
let missing_rollout = ws.join(".missing-rollout.jsonl");
|
|
112
|
+
// Do NOT create the file. Probe must report it as not-existing AND
|
|
113
|
+
// include it in checked_paths.
|
|
114
|
+
let mut agent = serde_json::Map::new();
|
|
115
|
+
agent.insert("provider".to_string(), json!("codex"));
|
|
116
|
+
agent.insert("status".to_string(), json!("running"));
|
|
117
|
+
agent.insert("session_id".to_string(), json!("sess-layer2-missing"));
|
|
118
|
+
agent.insert(
|
|
119
|
+
"rollout_path".to_string(),
|
|
120
|
+
json!(missing_rollout.to_string_lossy()),
|
|
121
|
+
);
|
|
122
|
+
agent.insert("first_send_at".to_string(), json!("2026-01-01T00:00:00Z"));
|
|
123
|
+
agent.insert("spawn_cwd".to_string(), json!(ws.to_string_lossy()));
|
|
124
|
+
let state = json!({ "agents": { "a": serde_json::Value::Object(agent) } });
|
|
125
|
+
|
|
126
|
+
let plan = crate::lifecycle::restart::classify_restart_plan_with_resume_validation(
|
|
127
|
+
Some(&ws),
|
|
128
|
+
&state,
|
|
129
|
+
false,
|
|
130
|
+
)
|
|
131
|
+
.unwrap();
|
|
132
|
+
assert_eq!(plan.unresumable.len(), 1);
|
|
133
|
+
let entry = &plan.unresumable[0];
|
|
134
|
+
assert_eq!(entry.agent_id.as_str(), "a");
|
|
135
|
+
assert_eq!(entry.reason, "session_unresumable");
|
|
136
|
+
match entry.refusal_reason.as_ref() {
|
|
137
|
+
Some(crate::provider::session::ResumeRefusalReason::SessionBackingStoreMissing {
|
|
138
|
+
checked_paths,
|
|
139
|
+
recovery_hint,
|
|
140
|
+
}) => {
|
|
141
|
+
assert!(
|
|
142
|
+
!checked_paths.is_empty(),
|
|
143
|
+
"checked_paths must be populated; got empty"
|
|
144
|
+
);
|
|
145
|
+
assert!(
|
|
146
|
+
checked_paths.iter().any(|p| p == &missing_rollout),
|
|
147
|
+
"checked_paths must include the persisted rollout_path; got {checked_paths:?}"
|
|
148
|
+
);
|
|
149
|
+
let hint = recovery_hint
|
|
150
|
+
.as_ref()
|
|
151
|
+
.expect("recovery_hint should be populated");
|
|
152
|
+
assert_eq!(
|
|
153
|
+
hint.provider_session_name_hint.as_deref(),
|
|
154
|
+
Some("a"),
|
|
155
|
+
"name hint must equal agent_id"
|
|
156
|
+
);
|
|
157
|
+
assert_eq!(
|
|
158
|
+
hint.spawn_cwd.as_deref(),
|
|
159
|
+
Some(ws.as_path()),
|
|
160
|
+
"spawn_cwd should round-trip through the hint"
|
|
161
|
+
);
|
|
162
|
+
assert_eq!(hint.provider, "codex");
|
|
163
|
+
}
|
|
164
|
+
other => panic!(
|
|
165
|
+
"expected SessionBackingStoreMissing with structured fields; got {:?}",
|
|
166
|
+
other
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#[test]
|
|
172
|
+
fn unit0_restart_corrupt_first_send_at_blocks_before_resume_classification() {
|
|
173
|
+
// The corrupt-first_send_at branch is the hard-refuse gate that fires
|
|
174
|
+
// BEFORE resume classification. This pins that corrupt entries land in
|
|
175
|
+
// `corrupt_entries` (with python type name) and the unresumable bucket
|
|
176
|
+
// stays empty for the corrupt agent.
|
|
177
|
+
let state = json!({
|
|
178
|
+
"agents": {
|
|
179
|
+
"a": agent_codex(Some("sess-unit0-corrupt"), json!(false)),
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
let plan = classify_restart_plan(&state, false).unwrap();
|
|
183
|
+
assert_eq!(plan.corrupt_entries.len(), 1);
|
|
184
|
+
assert_eq!(plan.corrupt_entries[0].worker_id.as_str(), "a");
|
|
185
|
+
assert_eq!(plan.corrupt_entries[0].raw_first_send_at_type, "bool");
|
|
186
|
+
assert!(plan.unresumable.is_empty());
|
|
187
|
+
}
|
|
@@ -667,6 +667,18 @@ pub enum RestartReport {
|
|
|
667
667
|
allow_fresh: bool,
|
|
668
668
|
error: String,
|
|
669
669
|
},
|
|
670
|
+
/// unit-3 (Stage 1): session-identity preflight refused — `state.session_name`
|
|
671
|
+
/// is a leader launcher session (`team-agent-leader-*`). Proceeding would
|
|
672
|
+
/// tear down the leader pane (E49 / 0.3.39). **nothing created or killed**.
|
|
673
|
+
RefusedDirtyTopology {
|
|
674
|
+
/// The session name that failed the preflight, verbatim from state.
|
|
675
|
+
session_name: String,
|
|
676
|
+
/// Stable machine-readable reason (currently
|
|
677
|
+
/// `"worker_session_is_leader_session"`).
|
|
678
|
+
reason: String,
|
|
679
|
+
/// Human-readable error directing the user to a recovery action.
|
|
680
|
+
error: String,
|
|
681
|
+
},
|
|
670
682
|
}
|
|
671
683
|
|
|
672
684
|
/// 单个重建后的 worker(carry restart_mode)。
|
|
@@ -690,11 +702,18 @@ pub struct RestartFailedAgent {
|
|
|
690
702
|
}
|
|
691
703
|
|
|
692
704
|
/// atomic refusal 里的不可重建 worker(`orchestration.py:517`)。
|
|
705
|
+
///
|
|
706
|
+
/// unit-5 (Stage 2): `reason` 是历史 free-form 字符串,JSON/CLI 直接吃它;新增
|
|
707
|
+
/// `refusal_reason` 携带 unit-5 的 closed enum(`provider::session::ResumeRefusalReason`)
|
|
708
|
+
/// 同源 wire 字符串。两者必须 round-trip 一致(see `ResumeRefusalReason::wire`)。
|
|
709
|
+
/// 旧调用方读 `reason` 不变;新调用方走 enum 拿到结构化原因。
|
|
693
710
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
694
711
|
pub struct UnresumableWorker {
|
|
695
712
|
pub agent_id: AgentId,
|
|
696
|
-
/// `no_persisted_session_id` | `session_unresumable
|
|
713
|
+
/// `no_persisted_session_id` | `session_unresumable` | `session_backing_store_missing` | …
|
|
697
714
|
pub reason: String,
|
|
715
|
+
/// unit-5 structured refusal (optional during migration; populated alongside `reason`).
|
|
716
|
+
pub refusal_reason: Option<crate::provider::session::ResumeRefusalReason>,
|
|
698
717
|
pub session_id: Option<SessionId>,
|
|
699
718
|
pub first_send_at: Option<String>,
|
|
700
719
|
}
|
|
@@ -221,9 +221,36 @@ pub(crate) fn delivery_outcome_value(out: &DeliveryOutcome) -> Value {
|
|
|
221
221
|
value
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
|
|
224
|
+
/// Find the latest nonterminal task assigned to `agent_id`.
|
|
225
|
+
///
|
|
226
|
+
/// Blocker-1 (prerelease 0.4.0): scoped teams own their tasks under
|
|
227
|
+
/// `state.teams.<owner>.tasks`; top-level `state.tasks` is the legacy /
|
|
228
|
+
/// fallback list and is often stale or assigned to a different agent in
|
|
229
|
+
/// scoped-team workspaces. Search the scoped view first (when an owner team
|
|
230
|
+
/// is supplied), then fall back to top-level.
|
|
231
|
+
pub(crate) fn latest_task_for_assignee(
|
|
232
|
+
workspace: &Path,
|
|
233
|
+
agent_id: &str,
|
|
234
|
+
owner_team_id: Option<&str>,
|
|
235
|
+
) -> Option<String> {
|
|
225
236
|
let state = load_runtime_state(workspace).ok()?;
|
|
237
|
+
if let Some(owner) = owner_team_id.filter(|s| !s.is_empty()) {
|
|
238
|
+
if let Some(tasks) = state
|
|
239
|
+
.get("teams")
|
|
240
|
+
.and_then(|teams| teams.get(owner))
|
|
241
|
+
.and_then(|team| team.get("tasks"))
|
|
242
|
+
.and_then(Value::as_array)
|
|
243
|
+
{
|
|
244
|
+
if let Some(id) = find_latest_nonterminal_task_for(tasks, agent_id) {
|
|
245
|
+
return Some(id);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
226
249
|
let tasks = state.get("tasks").and_then(Value::as_array)?;
|
|
250
|
+
find_latest_nonterminal_task_for(tasks, agent_id)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fn find_latest_nonterminal_task_for(tasks: &[Value], agent_id: &str) -> Option<String> {
|
|
227
254
|
for task in tasks.iter().rev() {
|
|
228
255
|
let assignee = task.get("assignee").and_then(Value::as_str)?;
|
|
229
256
|
if assignee != agent_id {
|
|
@@ -246,3 +273,47 @@ pub(crate) fn latest_task_for_assignee(workspace: &Path, agent_id: &str) -> Opti
|
|
|
246
273
|
}
|
|
247
274
|
None
|
|
248
275
|
}
|
|
276
|
+
|
|
277
|
+
/// Find the most recent delivered direct message to `agent_id` whose `task_id`
|
|
278
|
+
/// is empty/null AND for which no result row exists yet. Used by
|
|
279
|
+
/// `report_result` as a message-scoped fallback before defaulting to
|
|
280
|
+
/// `"manual"`, so `collect` can correlate via the existing message-scope path
|
|
281
|
+
/// (`messaging::results::is_message_scoped_result`).
|
|
282
|
+
pub(crate) fn latest_uncorrelated_delivered_message_for(
|
|
283
|
+
workspace: &Path,
|
|
284
|
+
agent_id: &str,
|
|
285
|
+
owner_team_id: Option<&str>,
|
|
286
|
+
) -> Option<String> {
|
|
287
|
+
use crate::db::message_store::MessageStore;
|
|
288
|
+
let store = MessageStore::open(workspace).ok()?;
|
|
289
|
+
let conn = crate::db::schema::open_db(store.db_path()).ok()?;
|
|
290
|
+
let sql = match owner_team_id {
|
|
291
|
+
Some(_) => "select m.message_id from messages m \
|
|
292
|
+
where m.recipient = ?1 and m.status = 'delivered' \
|
|
293
|
+
and (m.task_id is null or m.task_id = '') \
|
|
294
|
+
and m.owner_team_id = ?2 \
|
|
295
|
+
and not exists ( \
|
|
296
|
+
select 1 from results r \
|
|
297
|
+
where r.task_id = m.message_id and r.agent_id = m.recipient \
|
|
298
|
+
) \
|
|
299
|
+
order by m.created_at desc limit 1",
|
|
300
|
+
None => "select m.message_id from messages m \
|
|
301
|
+
where m.recipient = ?1 and m.status = 'delivered' \
|
|
302
|
+
and (m.task_id is null or m.task_id = '') \
|
|
303
|
+
and not exists ( \
|
|
304
|
+
select 1 from results r \
|
|
305
|
+
where r.task_id = m.message_id and r.agent_id = m.recipient \
|
|
306
|
+
) \
|
|
307
|
+
order by m.created_at desc limit 1",
|
|
308
|
+
};
|
|
309
|
+
let mut stmt = conn.prepare(sql).ok()?;
|
|
310
|
+
let id: Option<String> = match owner_team_id {
|
|
311
|
+
Some(team) => stmt
|
|
312
|
+
.query_row(rusqlite::params![agent_id, team], |row| row.get::<_, String>(0))
|
|
313
|
+
.ok(),
|
|
314
|
+
None => stmt
|
|
315
|
+
.query_row(rusqlite::params![agent_id], |row| row.get::<_, String>(0))
|
|
316
|
+
.ok(),
|
|
317
|
+
};
|
|
318
|
+
id
|
|
319
|
+
}
|
|
@@ -19,7 +19,8 @@ use crate::messaging::{self, MessageTarget, SendOptions};
|
|
|
19
19
|
|
|
20
20
|
use super::helpers::{
|
|
21
21
|
delivery_outcome_value, ensure_object, enum_value, insert_array, is_worker_recipient,
|
|
22
|
-
json_dumps_default, latest_task_for_assignee,
|
|
22
|
+
json_dumps_default, latest_task_for_assignee, latest_uncorrelated_delivered_message_for,
|
|
23
|
+
non_empty_string, normalized_envelope_value, object_fields,
|
|
23
24
|
requires_ack_for_target, tool_runtime_error,
|
|
24
25
|
};
|
|
25
26
|
use super::normalize::{
|
|
@@ -292,11 +293,35 @@ impl TeamOrchestratorTools {
|
|
|
292
293
|
obj.insert("status".to_string(), enum_value(status));
|
|
293
294
|
}
|
|
294
295
|
if !obj.contains_key("task_id") {
|
|
296
|
+
// Blocker-1 (prerelease 0.4.0): scoped-team task inference +
|
|
297
|
+
// message-scoped fallback, before defaulting to "manual".
|
|
298
|
+
// 1. explicit arg
|
|
299
|
+
// 2. latest nonterminal task assigned to this agent in
|
|
300
|
+
// teams.<owner>.tasks (scoped) → top-level tasks
|
|
301
|
+
// 3. latest delivered direct message to this agent with no
|
|
302
|
+
// task id and no result yet (message-scope correlation —
|
|
303
|
+
// collect path: is_message_scoped_result)
|
|
304
|
+
// 4. "manual" — truly uncorrelated; collect still rejects
|
|
305
|
+
let owner_team_id_str = self
|
|
306
|
+
.owner_team_id
|
|
307
|
+
.as_ref()
|
|
308
|
+
.map(|t| t.as_str().to_string());
|
|
295
309
|
let resolved = task_id
|
|
296
310
|
.map(ToString::to_string)
|
|
297
|
-
.or_else(|| self.agent_id
|
|
298
|
-
|
|
299
|
-
|
|
311
|
+
.or_else(|| self.agent_id.as_ref().and_then(|agent| {
|
|
312
|
+
latest_task_for_assignee(
|
|
313
|
+
&self.workspace,
|
|
314
|
+
agent.as_str(),
|
|
315
|
+
owner_team_id_str.as_deref(),
|
|
316
|
+
)
|
|
317
|
+
}))
|
|
318
|
+
.or_else(|| self.agent_id.as_ref().and_then(|agent| {
|
|
319
|
+
latest_uncorrelated_delivered_message_for(
|
|
320
|
+
&self.workspace,
|
|
321
|
+
agent.as_str(),
|
|
322
|
+
owner_team_id_str.as_deref(),
|
|
323
|
+
)
|
|
324
|
+
}))
|
|
300
325
|
.unwrap_or_else(|| "manual".to_string());
|
|
301
326
|
obj.insert("task_id".to_string(), Value::String(resolved));
|
|
302
327
|
}
|
|
@@ -455,6 +455,18 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
455
455
|
)?;
|
|
456
456
|
argv.push("--session-id".to_string());
|
|
457
457
|
argv.push(expected.clone());
|
|
458
|
+
// Layer 1 self-healing (architect probe 2026-06-22, claude help
|
|
459
|
+
// `-n, --name <name>`): pass `--name <agent_id>` so the
|
|
460
|
+
// resume picker and on-disk `~/.claude/sessions/*.json name`
|
|
461
|
+
// field carry our role label. This is a secondary diagnostic
|
|
462
|
+
// hint — primary restart key remains `session_id + backing
|
|
463
|
+
// store revalidation` (see provider/session/resume.rs).
|
|
464
|
+
if let Some(agent_id) = ctx.agent_id_hint {
|
|
465
|
+
if !agent_id.is_empty() {
|
|
466
|
+
argv.push("--name".to_string());
|
|
467
|
+
argv.push(agent_id.to_string());
|
|
468
|
+
}
|
|
469
|
+
}
|
|
458
470
|
Ok(CommandPlan {
|
|
459
471
|
argv,
|
|
460
472
|
expected_session_id: Some(SessionId::new(expected)),
|
|
@@ -654,6 +666,15 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
654
666
|
)?;
|
|
655
667
|
argv.push("--resume".to_string());
|
|
656
668
|
argv.push(session_id.as_str().to_string());
|
|
669
|
+
// Layer 1 self-healing: keep the role name on resume too —
|
|
670
|
+
// makes resumed sessions findable in the picker by agent_id
|
|
671
|
+
// when state.json drifts.
|
|
672
|
+
if let Some(agent_id) = ctx.agent_id_hint {
|
|
673
|
+
if !agent_id.is_empty() {
|
|
674
|
+
argv.push("--name".to_string());
|
|
675
|
+
argv.push(agent_id.to_string());
|
|
676
|
+
}
|
|
677
|
+
}
|
|
657
678
|
let mut plan = CommandPlan::argv_only(argv);
|
|
658
679
|
plan.provider_projects_root = ctx
|
|
659
680
|
.profile_launch
|
|
@@ -1195,7 +1216,16 @@ fn candidate_session_files(
|
|
|
1195
1216
|
collect_optional_candidate_files(&home.join(".codex").join("sessions"), &context.agent_id, &mut out)?;
|
|
1196
1217
|
}
|
|
1197
1218
|
Provider::Claude | Provider::ClaudeCode => {
|
|
1198
|
-
|
|
1219
|
+
// Blocker-2 Layer-1 (prerelease 0.4.0): the activity rollout_path
|
|
1220
|
+
// MUST be the real transcript under .claude/projects/<encoded cwd>/
|
|
1221
|
+
// <session_id>.jsonl. ~/.claude/sessions/<pid>.json is a small
|
|
1222
|
+
// session-metadata file (~300-400 bytes) that contains a
|
|
1223
|
+
// sessionId but no lifecycle records; if it wins capture, the
|
|
1224
|
+
// activity classifier reads it forever and never sees the real
|
|
1225
|
+
// transcript. Architect verdict (bugs-prerelease-blockers.md §138):
|
|
1226
|
+
// do not let .claude/sessions/<pid>.json become rollout_path.
|
|
1227
|
+
// Skip that directory entirely; resume backing probe still scans
|
|
1228
|
+
// it via its own path checks in restart/common.rs.
|
|
1199
1229
|
// E6 层1·B:优先锚到 ~/.claude/projects/<canonical spawn_cwd 编码> 子目录
|
|
1200
1230
|
// (claude 把 cwd 的 '/' 编码成 '-';交互式 worker 的真实 transcript 落在此),
|
|
1201
1231
|
// 而非全 projects 树盲扫(交互式 claude 自生成 UUID,锚 cwd 子目录 + 时间窗才能唯一选)。
|
|
@@ -16,6 +16,31 @@ pub fn runtime_mcp_prompt_allowlisted(prompt: &ApprovalPrompt) -> bool {
|
|
|
16
16
|
&& prompt.tool.as_deref().is_some_and(runtime_mcp_tool_allowlisted)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/// Issue 1 (Round 3b gate review §6): Team-Agent control-plane MCP tools
|
|
20
|
+
/// that are safe to auto-approve INDEPENDENT of `leader_auto_approval_allowed`.
|
|
21
|
+
/// These tools never execute user-supplied commands; they are pure team-coord
|
|
22
|
+
/// state transitions. Without this carve-out, a worker spawned by a leader
|
|
23
|
+
/// that lacks a dangerous-bypass flag would have `report_result` blocked at
|
|
24
|
+
/// the MCP approval gate, breaking the entire result-collection pipeline.
|
|
25
|
+
///
|
|
26
|
+
/// Strict scope:
|
|
27
|
+
/// * server MUST be `team_orchestrator` (RUNTIME_MCP_APPROVAL_SERVER) —
|
|
28
|
+
/// never broaden to arbitrary MCP servers.
|
|
29
|
+
/// * tool MUST be one of the explicit safe-list entries. `report_result`
|
|
30
|
+
/// is the canonical case; `send_message` is added because a worker
|
|
31
|
+
/// reporting status to leader through the message bus is the same
|
|
32
|
+
/// architectural layer as `report_result` and is harmless. New tools
|
|
33
|
+
/// join only after explicit product decision (per architect note).
|
|
34
|
+
pub fn runtime_mcp_tool_is_internal_control_plane(prompt: &ApprovalPrompt) -> bool {
|
|
35
|
+
if prompt.server.as_deref() != Some(RUNTIME_MCP_APPROVAL_SERVER) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
matches!(
|
|
39
|
+
prompt.tool.as_deref(),
|
|
40
|
+
Some("report_result") | Some("send_message")
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
19
44
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
20
45
|
#[serde(rename_all = "snake_case")]
|
|
21
46
|
pub enum RuntimeApprovalDecision {
|
|
@@ -81,6 +106,15 @@ pub fn awaiting_human_confirm_reason(
|
|
|
81
106
|
ApprovalKind::McpTool => {
|
|
82
107
|
if !runtime_mcp_prompt_allowlisted(prompt) {
|
|
83
108
|
Some("tool_not_allowlisted")
|
|
109
|
+
} else if runtime_mcp_tool_is_internal_control_plane(prompt) {
|
|
110
|
+
// Issue 1 (Round 3b gate review §6): Team-Agent control-plane
|
|
111
|
+
// MCP tools (report_result / send_message) auto-approve
|
|
112
|
+
// independent of leader dangerous-bypass. Without this,
|
|
113
|
+
// workers spawned under a non-bypass leader can never
|
|
114
|
+
// collect results — the entire correlation pipeline blocks
|
|
115
|
+
// at the MCP approval gate. Command approvals still go
|
|
116
|
+
// through `ApprovalKind::Command` and stay human-gated.
|
|
117
|
+
None
|
|
84
118
|
} else if !leader_auto_approval_allowed {
|
|
85
119
|
Some("leader_restricted")
|
|
86
120
|
} else {
|
|
@@ -33,6 +33,28 @@ pub fn classify(
|
|
|
33
33
|
));
|
|
34
34
|
}
|
|
35
35
|
let facts = extract_lifecycle_facts(provider, &records);
|
|
36
|
+
// Blocker-2 Layer-2 (prerelease 0.4.0): Claude background-task lifecycle.
|
|
37
|
+
// Claude's long-running work (e.g. `Bash run_in_background:true`,
|
|
38
|
+
// followed by `task-notification status=completed`) can outlive the
|
|
39
|
+
// assistant turn that started it. The existing lifecycle-fact scan
|
|
40
|
+
// recognises `assistant.stop_reason=end_turn` as TurnComplete, so the
|
|
41
|
+
// classifier would otherwise mark the worker idle while a background
|
|
42
|
+
// shell continues. Synthesize a Working fact when at least one
|
|
43
|
+
// background task started but has not yet been closed. Architect
|
|
44
|
+
// verdict: bugs-prerelease-blockers.md §141-§143.
|
|
45
|
+
if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
|
|
46
|
+
if let Some(open_turn_id) = claude_background_task_open(&records) {
|
|
47
|
+
return Ok(decide_state(
|
|
48
|
+
&lifecycle(
|
|
49
|
+
FactKind::TurnOpen,
|
|
50
|
+
open_turn_id,
|
|
51
|
+
"background_task",
|
|
52
|
+
vec!["background_task".to_string()],
|
|
53
|
+
),
|
|
54
|
+
process,
|
|
55
|
+
));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
36
58
|
let Some(fact) = facts.last() else {
|
|
37
59
|
return Ok(classify_result(
|
|
38
60
|
TurnState::Unknown,
|
|
@@ -45,6 +67,122 @@ pub fn classify(
|
|
|
45
67
|
Ok(decide_state(fact, process))
|
|
46
68
|
}
|
|
47
69
|
|
|
70
|
+
/// Blocker-2 Layer-2: scan Claude transcript records for background task
|
|
71
|
+
/// lifecycle markers. Returns `Some(turn_id)` when at least one background
|
|
72
|
+
/// task is OPEN (started but not closed) — caller treats as `TurnOpen` with
|
|
73
|
+
/// reason `background_task`. Returns `None` when no background task started,
|
|
74
|
+
/// or every started task has a matching close (completed / failed).
|
|
75
|
+
///
|
|
76
|
+
/// Open markers (set):
|
|
77
|
+
/// * Any record nested string containing
|
|
78
|
+
/// "Command running in background with ID: <id>" (Bash tool output).
|
|
79
|
+
/// * `tool_use_result.backgroundTaskId` (the structured form).
|
|
80
|
+
///
|
|
81
|
+
/// Close markers (clear):
|
|
82
|
+
/// * `task-notification` content with `<task-id>` matching open id and
|
|
83
|
+
/// `<status>completed</status>` or `failed`.
|
|
84
|
+
/// * `tool_use_result.bashOutput.status = "completed" | "failed"` for the
|
|
85
|
+
/// matching backgroundTaskId.
|
|
86
|
+
fn claude_background_task_open(records: &[serde_json::Value]) -> Option<Option<TurnId>> {
|
|
87
|
+
use std::collections::BTreeSet;
|
|
88
|
+
let mut open: BTreeSet<String> = BTreeSet::new();
|
|
89
|
+
let mut last_open_request: Option<String> = None;
|
|
90
|
+
for record in records {
|
|
91
|
+
let mut found_open_ids: Vec<String> = Vec::new();
|
|
92
|
+
collect_background_open_ids(record, &mut found_open_ids);
|
|
93
|
+
for id in &found_open_ids {
|
|
94
|
+
open.insert(id.clone());
|
|
95
|
+
}
|
|
96
|
+
if !found_open_ids.is_empty() {
|
|
97
|
+
if let Some(req) = record.get("requestId").and_then(serde_json::Value::as_str) {
|
|
98
|
+
last_open_request = Some(req.to_string());
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let mut closed_ids: Vec<String> = Vec::new();
|
|
102
|
+
collect_background_close_ids(record, &mut closed_ids);
|
|
103
|
+
for id in &closed_ids {
|
|
104
|
+
open.remove(id);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if open.is_empty() {
|
|
108
|
+
None
|
|
109
|
+
} else {
|
|
110
|
+
Some(last_open_request.map(TurnId::new))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fn collect_background_open_ids(value: &serde_json::Value, out: &mut Vec<String>) {
|
|
115
|
+
const MARKER: &str = "Command running in background with ID: ";
|
|
116
|
+
match value {
|
|
117
|
+
serde_json::Value::String(s) => {
|
|
118
|
+
for piece in s.split(MARKER).skip(1) {
|
|
119
|
+
let id: String = piece.chars().take_while(|c| !c.is_whitespace() && *c != '\n').collect();
|
|
120
|
+
if !id.is_empty() {
|
|
121
|
+
out.push(id);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
serde_json::Value::Object(map) => {
|
|
126
|
+
if let Some(id) = map.get("backgroundTaskId").and_then(serde_json::Value::as_str) {
|
|
127
|
+
if !id.is_empty() {
|
|
128
|
+
out.push(id.to_string());
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for v in map.values() {
|
|
132
|
+
collect_background_open_ids(v, out);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
serde_json::Value::Array(arr) => {
|
|
136
|
+
for v in arr {
|
|
137
|
+
collect_background_open_ids(v, out);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
_ => {}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn collect_background_close_ids(value: &serde_json::Value, out: &mut Vec<String>) {
|
|
145
|
+
// Form A: tool_use_result.bashOutput { backgroundTaskId, status } where status is
|
|
146
|
+
// "completed" or "failed".
|
|
147
|
+
// Form B: <task-notification> wrapper in a string with a status tag.
|
|
148
|
+
if let serde_json::Value::Object(map) = value {
|
|
149
|
+
if let Some(bash) = map.get("bashOutput").and_then(serde_json::Value::as_object) {
|
|
150
|
+
let status = bash.get("status").and_then(serde_json::Value::as_str).unwrap_or("");
|
|
151
|
+
if matches!(status, "completed" | "failed" | "killed") {
|
|
152
|
+
if let Some(id) = bash.get("backgroundTaskId").and_then(serde_json::Value::as_str) {
|
|
153
|
+
if !id.is_empty() {
|
|
154
|
+
out.push(id.to_string());
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for v in map.values() {
|
|
160
|
+
collect_background_close_ids(v, out);
|
|
161
|
+
}
|
|
162
|
+
} else if let serde_json::Value::Array(arr) = value {
|
|
163
|
+
for v in arr {
|
|
164
|
+
collect_background_close_ids(v, out);
|
|
165
|
+
}
|
|
166
|
+
} else if let serde_json::Value::String(s) = value {
|
|
167
|
+
// Form B: <task-notification ...><task-id>X</task-id>...<status>completed</status>...
|
|
168
|
+
if s.contains("<task-notification") && s.contains("<status>completed</status>") {
|
|
169
|
+
if let Some(id) = extract_xml_tag(s, "task-id") {
|
|
170
|
+
if !id.is_empty() {
|
|
171
|
+
out.push(id);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fn extract_xml_tag(text: &str, tag: &str) -> Option<String> {
|
|
179
|
+
let open = format!("<{tag}>");
|
|
180
|
+
let close = format!("</{tag}>");
|
|
181
|
+
let start = text.find(&open)? + open.len();
|
|
182
|
+
let end_rel = text[start..].find(&close)?;
|
|
183
|
+
Some(text[start..start + end_rel].trim().to_string())
|
|
184
|
+
}
|
|
185
|
+
|
|
48
186
|
pub fn latest_explicit_error_fact(provider: Provider, session_log_text: &str) -> Option<FaultFact> {
|
|
49
187
|
let record = latest_jsonl_record(session_log_text)?;
|
|
50
188
|
match provider {
|