@team-agent/installer 0.4.7 → 0.4.8
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/src/cli/adapters.rs +27 -0
- package/crates/team-agent/src/lifecycle/profile_launch.rs +24 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +19 -4
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +19 -1
- package/crates/team-agent/src/lifecycle/restart/selection.rs +67 -16
- package/crates/team-agent/src/lifecycle/restart.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/core.rs +99 -0
- package/crates/team-agent/src/messaging/delivery.rs +113 -0
- package/crates/team-agent/src/provider/session/capture.rs +131 -1
- package/crates/team-agent/src/state/paths.rs +8 -4
- package/crates/team-agent/src/state/persist.rs +17 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -226,6 +226,33 @@ pub fn cmd_status_for_team(args: &StatusArgs, team: Option<&str>) -> Result<CmdR
|
|
|
226
226
|
"status --summary does not accept an agent argument".to_string(),
|
|
227
227
|
));
|
|
228
228
|
}
|
|
229
|
+
// S4QR-001 (0.4.8): selected-team ambiguity gate. status is a selected-team
|
|
230
|
+
// command: when the workspace has 2+ alive teams and no --team was passed,
|
|
231
|
+
// refuse instead of silently defaulting to the active team. Uses the same
|
|
232
|
+
// CommandScope::resolve helper as refuse_if_multi_alive_team_missing_scope
|
|
233
|
+
// (cli/emit.rs:964-979) so the destructive-command ambiguity gate and the
|
|
234
|
+
// selected-team-read ambiguity gate share a single source of truth.
|
|
235
|
+
if team.is_none() {
|
|
236
|
+
let scope = crate::state::paths::CommandScope::resolve(&args.workspace, None);
|
|
237
|
+
if scope.is_ambiguous() {
|
|
238
|
+
let candidates: Vec<String> = scope.candidates().to_vec();
|
|
239
|
+
let message = format!(
|
|
240
|
+
"status: workspace has multiple alive teams ({}); pass `--team <key>` to choose one",
|
|
241
|
+
candidates.join(", ")
|
|
242
|
+
);
|
|
243
|
+
if args.json {
|
|
244
|
+
let payload = serde_json::json!({
|
|
245
|
+
"ok": false,
|
|
246
|
+
"status": "refused",
|
|
247
|
+
"reason": "team_target_ambiguous",
|
|
248
|
+
"candidates": candidates,
|
|
249
|
+
"message": message,
|
|
250
|
+
});
|
|
251
|
+
return Ok(CmdResult::from_json(payload, args.json));
|
|
252
|
+
}
|
|
253
|
+
return Err(CliError::Usage(message));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
229
256
|
let selected = match crate::state::selector::resolve_active_team(
|
|
230
257
|
&args.workspace,
|
|
231
258
|
team,
|
|
@@ -419,6 +419,30 @@ fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<Stri
|
|
|
419
419
|
let mut unsets = BTreeSet::new();
|
|
420
420
|
match provider {
|
|
421
421
|
Provider::Claude | Provider::ClaudeCode => {
|
|
422
|
+
// E57 (0.3.27 P0): unset CLAUDE_CODE_SESSION_ID inherited from the
|
|
423
|
+
// leader's environment. When a Claude leader spawns a Claude worker,
|
|
424
|
+
// the worker inherits the leader's CLAUDE_CODE_SESSION_ID; Claude
|
|
425
|
+
// Code then routes the worker's transcript to the leader's session
|
|
426
|
+
// file (~/.claude/projects/<hash>/<leader-session-uuid>.jsonl),
|
|
427
|
+
// corrupting attribution and transcripts. Always unset so Claude
|
|
428
|
+
// Code generates a fresh session id per worker.
|
|
429
|
+
//
|
|
430
|
+
// S1-CAPTURE (0.4.x P0): also unset the rest of the Claude Code
|
|
431
|
+
// child/team env block. These variables make the spawned process
|
|
432
|
+
// believe it is a CHILD of the parent Claude (CLAUDE_CODE_CHILD_SESSION),
|
|
433
|
+
// a TEAM member (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS), or otherwise
|
|
434
|
+
// inherit the parent's invocation identity (ENTRYPOINT / EXECPATH /
|
|
435
|
+
// CLAUDECODE marker). Without unsetting these, a Claude worker
|
|
436
|
+
// spawned from a Claude leader is not an INDEPENDENT process — it
|
|
437
|
+
// joins the leader's session tree and its transcript / state /
|
|
438
|
+
// billing are attributed to the leader. CLAUDE_EFFORT is preserved
|
|
439
|
+
// (effort preference does not affect session attribution).
|
|
440
|
+
unsets.insert("CLAUDE_CODE_SESSION_ID".to_string());
|
|
441
|
+
unsets.insert("CLAUDE_CODE_CHILD_SESSION".to_string());
|
|
442
|
+
unsets.insert("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS".to_string());
|
|
443
|
+
unsets.insert("CLAUDE_CODE_ENTRYPOINT".to_string());
|
|
444
|
+
unsets.insert("CLAUDE_CODE_EXECPATH".to_string());
|
|
445
|
+
unsets.insert("CLAUDECODE".to_string());
|
|
422
446
|
if auth_mode == AuthMode::CompatibleApi {
|
|
423
447
|
unsets.insert("ANTHROPIC_API_KEY".to_string());
|
|
424
448
|
}
|
|
@@ -208,7 +208,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
208
208
|
None,
|
|
209
209
|
Some(resolved_team_key.as_str()),
|
|
210
210
|
)?;
|
|
211
|
-
mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety)?;
|
|
211
|
+
mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety, start_mode)?;
|
|
212
212
|
// **0.3.24 add-agent socket drift fix**: keep `state.tmux_endpoint` /
|
|
213
213
|
// `state.tmux_socket` synchronized with the transport actually used for the
|
|
214
214
|
// spawn. Without this, add-agent / fork-agent could spawn to a socket that
|
|
@@ -700,6 +700,7 @@ fn mark_agent_started(
|
|
|
700
700
|
spawn: &SpawnedAgentWindow,
|
|
701
701
|
transport: &dyn crate::transport::Transport,
|
|
702
702
|
safety: &DangerousApproval,
|
|
703
|
+
start_mode: StartMode,
|
|
703
704
|
) -> Result<(), LifecycleError> {
|
|
704
705
|
let Some(agent) = state
|
|
705
706
|
.get_mut("agents")
|
|
@@ -712,6 +713,31 @@ fn mark_agent_started(
|
|
|
712
713
|
agent_id
|
|
713
714
|
)));
|
|
714
715
|
};
|
|
716
|
+
// S1-CAPTURE-001 (0.4.8, CR M3 provider-agnostic): on a Fresh /
|
|
717
|
+
// FreshAfterMissingRollout start, the prior session's authoritative
|
|
718
|
+
// capture tuple MUST be cleared before persist_command_plan_state
|
|
719
|
+
// writes the new _pending_session_id. Otherwise old session_id +
|
|
720
|
+
// rollout_path coexist with new _pending_session_id and
|
|
721
|
+
// agent_session_complete returns true on the stale tuple — capture
|
|
722
|
+
// never re-binds to the new process, and any delivered token lands
|
|
723
|
+
// in the old transcript (the leader/unassigned mis-attribution seen
|
|
724
|
+
// in the gate evidence). This applies to all providers that resume:
|
|
725
|
+
// codex, claude, copilot. Reset_agent --discard-session already does
|
|
726
|
+
// this at common.rs:1144-1188; here we mirror it for start-agent /
|
|
727
|
+
// restart-agent fresh paths so the fresh-tuple invariant is global.
|
|
728
|
+
if matches!(start_mode, StartMode::Fresh | StartMode::FreshAfterMissingRollout) {
|
|
729
|
+
for field in [
|
|
730
|
+
"session_id",
|
|
731
|
+
"rollout_path",
|
|
732
|
+
"captured_at",
|
|
733
|
+
"captured_via",
|
|
734
|
+
"attribution_confidence",
|
|
735
|
+
"capture_state",
|
|
736
|
+
"attribution_ambiguous",
|
|
737
|
+
] {
|
|
738
|
+
agent.remove(field);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
715
741
|
agent.insert("status".to_string(), serde_json::json!("running"));
|
|
716
742
|
agent.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
|
|
717
743
|
agent.insert("window".to_string(), serde_json::json!(window));
|
|
@@ -895,10 +895,25 @@ pub(crate) fn restart_required_missing_session_agent_ids(state: &serde_json::Val
|
|
|
895
895
|
.get("status")
|
|
896
896
|
.and_then(|value| value.as_str())
|
|
897
897
|
.is_some_and(|status| status == "running");
|
|
898
|
-
// E6 层2 (C2): required-missing
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
|
|
898
|
+
// E6 层2 (C2) + RESTART-RESUME-001 (0.4.8): required-missing
|
|
899
|
+
// predicate gates on session_id absence + running, but ALSO
|
|
900
|
+
// skips never-captured workers (no session_id AND no context
|
|
901
|
+
// signals at all). A never-captured worker has nothing to
|
|
902
|
+
// lose by fresh-start, so it must not block convergence and
|
|
903
|
+
// burn the capture deadline. This matches the selection-stage
|
|
904
|
+
// partial-resume semantic in
|
|
905
|
+
// selection.rs::classify_resume_decision (never_captured →
|
|
906
|
+
// FreshStart without --allow-fresh).
|
|
907
|
+
//
|
|
908
|
+
// The "has context to preserve" signal is the shared
|
|
909
|
+
// restart_agent_has_context_to_preserve helper: first_send_at
|
|
910
|
+
// (leader→worker delivery), last_result_at (MCP report path
|
|
911
|
+
// that may skip first_send_at), or task_prompt_delivered.
|
|
912
|
+
// Only context-bearing null-session workers continue to
|
|
913
|
+
// require convergence (so we never silently drop context).
|
|
914
|
+
missing_session_id
|
|
915
|
+
&& is_running
|
|
916
|
+
&& !super::selection::restart_agent_never_captured(agent, None)
|
|
902
917
|
})
|
|
903
918
|
.collect::<Vec<_>>();
|
|
904
919
|
missing.sort();
|
|
@@ -1361,11 +1361,29 @@ fn mark_agent_respawned(
|
|
|
1361
1361
|
// Claude family clear path uniformly. The Copilot scanner
|
|
1362
1362
|
// (provider/adapter.rs:1217-1228) already gates expected-id with a
|
|
1363
1363
|
// sqlite point-check, so no truth is lost.
|
|
1364
|
+
// S1-CAPTURE-001 (0.4.8): on Fresh / FreshAfterMissingRollout, clear the
|
|
1365
|
+
// FULL prior-session authoritative capture tuple — not just session_id.
|
|
1366
|
+
// Mirrors mark_agent_started (restart/agent.rs:728-740) so the rebuild path
|
|
1367
|
+
// (multi-agent restart) and the single-agent start path enforce the same
|
|
1368
|
+
// fresh-tuple invariant. Without this, save_restart_state's persist
|
|
1369
|
+
// backfill can revive the stale rollout_path/captured_at/capture_state
|
|
1370
|
+
// tuple from latest, defeating the fresh-tuple guarantee and leaving
|
|
1371
|
+
// delivered tokens in the old transcript (leader/unassigned mis-attrib).
|
|
1364
1372
|
if matches!(
|
|
1365
1373
|
restart_mode,
|
|
1366
1374
|
StartMode::Fresh | StartMode::FreshAfterMissingRollout
|
|
1367
1375
|
) {
|
|
1368
|
-
|
|
1376
|
+
for field in [
|
|
1377
|
+
"session_id",
|
|
1378
|
+
"rollout_path",
|
|
1379
|
+
"captured_at",
|
|
1380
|
+
"captured_via",
|
|
1381
|
+
"attribution_confidence",
|
|
1382
|
+
"capture_state",
|
|
1383
|
+
"attribution_ambiguous",
|
|
1384
|
+
] {
|
|
1385
|
+
agent.remove(field);
|
|
1386
|
+
}
|
|
1369
1387
|
}
|
|
1370
1388
|
crate::lifecycle::launch::persist_command_plan_state(agent, &spawn.plan, &spawn.profile_launch);
|
|
1371
1389
|
persist_effective_approval_policy_for_restart(agent, safety);
|
|
@@ -53,6 +53,57 @@ pub fn classify_first_send_at(raw: &serde_json::Value) -> FirstSendAtState {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/// RESTART-RESUME-001 (0.4.8): true if the agent has any signal of prior
|
|
57
|
+
/// interaction whose context would be lost by silent fresh-start. Checks
|
|
58
|
+
/// `first_send_at` (leader→worker delivery), `last_result_at` (worker
|
|
59
|
+
/// report_result), and `task_prompt_delivered` (MCP-only worker first task).
|
|
60
|
+
/// Used by both the selection-stage never-captured decision and the
|
|
61
|
+
/// pre-selection convergence missing-set predicate so the two layers share
|
|
62
|
+
/// a single "needs context preservation" semantic.
|
|
63
|
+
///
|
|
64
|
+
/// CR M2 callers (must stay in sync):
|
|
65
|
+
/// * lifecycle/restart/selection.rs (never_captured branch, classify_resume_decision)
|
|
66
|
+
/// * lifecycle/restart/common.rs::restart_required_missing_session_agent_ids
|
|
67
|
+
pub(crate) fn restart_agent_has_context_to_preserve(agent: &serde_json::Value) -> bool {
|
|
68
|
+
let has_valid_first_send_at = matches!(
|
|
69
|
+
classify_first_send_at(agent.get("first_send_at").unwrap_or(&serde_json::Value::Null)),
|
|
70
|
+
FirstSendAtState::Valid
|
|
71
|
+
);
|
|
72
|
+
if has_valid_first_send_at {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
let has_last_result_at = agent
|
|
76
|
+
.get("last_result_at")
|
|
77
|
+
.and_then(serde_json::Value::as_str)
|
|
78
|
+
.is_some_and(|s| !s.is_empty());
|
|
79
|
+
if has_last_result_at {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
agent
|
|
83
|
+
.get("task_prompt_delivered")
|
|
84
|
+
.and_then(serde_json::Value::as_bool)
|
|
85
|
+
.unwrap_or(false)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// RESTART-RESUME-001: an agent is "never-captured" when its session_id is
|
|
89
|
+
/// absent AND there is no context to preserve. Such an agent is safe to
|
|
90
|
+
/// auto-fresh without `--allow-fresh` and should NOT be required to capture
|
|
91
|
+
/// a transcript before restart proceeds.
|
|
92
|
+
pub(crate) fn restart_agent_never_captured(
|
|
93
|
+
agent: &serde_json::Value,
|
|
94
|
+
session_id: Option<&str>,
|
|
95
|
+
) -> bool {
|
|
96
|
+
let session_present = session_id.is_some_and(|s| !s.is_empty())
|
|
97
|
+
|| agent
|
|
98
|
+
.get("session_id")
|
|
99
|
+
.and_then(serde_json::Value::as_str)
|
|
100
|
+
.is_some_and(|s| !s.is_empty());
|
|
101
|
+
if session_present {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
!restart_agent_has_context_to_preserve(agent)
|
|
105
|
+
}
|
|
106
|
+
|
|
56
107
|
fn is_python_fromisoformat_like(raw: &str) -> bool {
|
|
57
108
|
if raw.is_empty() {
|
|
58
109
|
return false;
|
|
@@ -221,25 +272,25 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
|
|
|
221
272
|
}
|
|
222
273
|
_ => (true, Vec::new()),
|
|
223
274
|
};
|
|
224
|
-
// 0.4.7 partial-resume: when a worker
|
|
225
|
-
//
|
|
226
|
-
// non-resumable — there is no
|
|
227
|
-
// safe even without --allow-fresh.
|
|
228
|
-
// never-captured role (e.g. MCP-only worker that the leader never
|
|
229
|
-
// messaged) from blocking restart of the other 6 roles that DO
|
|
230
|
-
// have complete resume tuples.
|
|
275
|
+
// 0.4.7 partial-resume + RESTART-RESUME-001 (0.4.8): when a worker
|
|
276
|
+
// has NEVER been captured (no session_id AND no context-bearing
|
|
277
|
+
// signal at all), it is structurally non-resumable — there is no
|
|
278
|
+
// context to lose, so auto-fresh is safe even without --allow-fresh.
|
|
231
279
|
//
|
|
232
|
-
// The "never_captured" predicate is the
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
280
|
+
// The "never_captured" predicate is now the shared
|
|
281
|
+
// restart_agent_never_captured(): session_id absent AND none of
|
|
282
|
+
// first_send_at(Valid) / last_result_at / task_prompt_delivered.
|
|
283
|
+
// This matches the pre-selection convergence semantic in
|
|
284
|
+
// common.rs::restart_required_missing_session_agent_ids so both
|
|
285
|
+
// layers refuse / auto-fresh in unison.
|
|
236
286
|
//
|
|
237
|
-
// If session_id is None but
|
|
238
|
-
// "received message but session not captured" bug state —
|
|
239
|
-
// the Refuse so we never silently drop context (architect
|
|
240
|
-
// "绝不静默 fresh").
|
|
287
|
+
// If session_id is None but ANY context signal exists, that's the
|
|
288
|
+
// "received message/result but session not captured" bug state —
|
|
289
|
+
// keep the Refuse so we never silently drop context (architect
|
|
290
|
+
// rule: "绝不静默 fresh").
|
|
291
|
+
let _ = first_send_at_state; // retained for the Corrupt branch above
|
|
241
292
|
let never_captured =
|
|
242
|
-
session_id.
|
|
293
|
+
restart_agent_never_captured(agent, session_id.as_ref().map(|s| s.as_str()));
|
|
243
294
|
let decision = if session_id.is_some() && provider_can_resume && resume_backing_exists {
|
|
244
295
|
ResumeDecision::Resume
|
|
245
296
|
} else if session_id.is_some() && allow_fresh {
|
|
@@ -35,6 +35,7 @@ mod team_state;
|
|
|
35
35
|
pub use agent::{reset_agent, reset_agent_with_transport, start_agent, start_agent_with_transport, stop_agent, stop_agent_with_transport};
|
|
36
36
|
pub(crate) use agent::start_agent_at_paths;
|
|
37
37
|
pub(crate) use common::refresh_missing_provider_sessions;
|
|
38
|
+
pub(crate) use common::restart_required_missing_session_agent_ids;
|
|
38
39
|
// 0.3.24 add-agent socket drift fix: state-aware tmux resolver shared with
|
|
39
40
|
// `lifecycle::launch::add_agent` / `fork_agent` so all three (restart / add / fork)
|
|
40
41
|
// route to the SAME tmux socket the live team uses.
|
|
@@ -636,6 +636,105 @@ fn classify_restart_plan_never_captured_null_session_auto_fresh_partial_resume()
|
|
|
636
636
|
);
|
|
637
637
|
}
|
|
638
638
|
|
|
639
|
+
#[test]
|
|
640
|
+
fn restart_required_missing_skips_never_captured_workers() {
|
|
641
|
+
// RESTART-RESUME-001 (0.4.8): the pre-selection convergence missing-set
|
|
642
|
+
// predicate must skip never-captured workers (null session_id, no
|
|
643
|
+
// first_send_at/last_result_at/task_prompt_delivered) so a single
|
|
644
|
+
// never-captured role doesn't burn the capture deadline and trigger
|
|
645
|
+
// resume_not_ready when the selection stage would auto-fresh it anyway.
|
|
646
|
+
use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
|
|
647
|
+
let state = json!({
|
|
648
|
+
"agents": {
|
|
649
|
+
"w1": { "provider": "claude", "session_id": null, "status": "running" }
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
let missing = restart_required_missing_session_agent_ids(&state);
|
|
653
|
+
assert!(
|
|
654
|
+
missing.is_empty(),
|
|
655
|
+
"never-captured null-session worker must NOT appear in required-missing; \
|
|
656
|
+
got {missing:?}"
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
#[test]
|
|
661
|
+
fn restart_required_missing_keeps_null_session_with_first_send_at() {
|
|
662
|
+
// RESTART-RESUME-001: null session_id + valid first_send_at means the
|
|
663
|
+
// leader DID send a message; that context must be preserved → still
|
|
664
|
+
// missing → restart refuses without --allow-fresh.
|
|
665
|
+
use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
|
|
666
|
+
let state = json!({
|
|
667
|
+
"agents": {
|
|
668
|
+
"w1": {
|
|
669
|
+
"provider": "claude",
|
|
670
|
+
"session_id": null,
|
|
671
|
+
"status": "running",
|
|
672
|
+
"spawn_cwd": "/tmp/ws",
|
|
673
|
+
"first_send_at": "2026-01-01T00:00:00+00:00",
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
let missing = restart_required_missing_session_agent_ids(&state);
|
|
678
|
+
assert_eq!(
|
|
679
|
+
missing,
|
|
680
|
+
vec!["w1".to_string()],
|
|
681
|
+
"null-session + valid first_send_at (context bearing) must remain in \
|
|
682
|
+
required-missing; got {missing:?}"
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
#[test]
|
|
687
|
+
fn restart_required_missing_keeps_null_session_with_last_result_at() {
|
|
688
|
+
// RESTART-RESUME-001: MCP / report_result paths may not have
|
|
689
|
+
// first_send_at, but a non-empty last_result_at means the worker DID
|
|
690
|
+
// produce a result; that context must be preserved.
|
|
691
|
+
use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
|
|
692
|
+
let state = json!({
|
|
693
|
+
"agents": {
|
|
694
|
+
"w1": {
|
|
695
|
+
"provider": "claude",
|
|
696
|
+
"session_id": null,
|
|
697
|
+
"status": "running",
|
|
698
|
+
"spawn_cwd": "/tmp/ws",
|
|
699
|
+
"last_result_at": "2026-01-01T00:00:00+00:00",
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
let missing = restart_required_missing_session_agent_ids(&state);
|
|
704
|
+
assert_eq!(
|
|
705
|
+
missing,
|
|
706
|
+
vec!["w1".to_string()],
|
|
707
|
+
"null-session + last_result_at must remain in required-missing; \
|
|
708
|
+
got {missing:?}"
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
#[test]
|
|
713
|
+
fn classify_restart_plan_null_session_with_last_result_at_refuses_without_allow_fresh() {
|
|
714
|
+
// RESTART-RESUME-001: selection stage must also refuse null-session +
|
|
715
|
+
// last_result_at (no first_send_at) without --allow-fresh. Otherwise the
|
|
716
|
+
// pre-selection convergence and selection-stage refuse semantics drift
|
|
717
|
+
// and the gate fails for MCP-only report paths.
|
|
718
|
+
let state = json!({
|
|
719
|
+
"agents": {
|
|
720
|
+
"w1": {
|
|
721
|
+
"provider": "claude",
|
|
722
|
+
"session_id": null,
|
|
723
|
+
"last_result_at": "2026-01-01T00:00:00+00:00",
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
|
|
728
|
+
assert_eq!(plan.decisions.len(), 1);
|
|
729
|
+
assert_eq!(
|
|
730
|
+
plan.decisions[0].decision,
|
|
731
|
+
ResumeDecision::Refuse,
|
|
732
|
+
"null-session + last_result_at must Refuse without --allow-fresh \
|
|
733
|
+
(context to preserve); got {:?}",
|
|
734
|
+
plan.decisions[0].decision
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
639
738
|
#[test]
|
|
640
739
|
fn classify_restart_plan_never_interacted_null_session_with_allow_fresh_marks_forced_fresh() {
|
|
641
740
|
// E6 层2: 同上自启动 null-session worker,但显式 --allow-fresh → 用户主动认账丢上下文 → FreshStart。
|
|
@@ -452,6 +452,71 @@ pub fn deliver_pending_message(
|
|
|
452
452
|
channel: None,
|
|
453
453
|
});
|
|
454
454
|
}
|
|
455
|
+
// S1-CAPTURE-001 (0.4.8, CR M4 Claude phase-1): for Claude/ClaudeCode
|
|
456
|
+
// recipients with a known authoritative rollout_path, verify the message
|
|
457
|
+
// token actually reached the worker's transcript before marking
|
|
458
|
+
// delivered. This catches the gate's mis-attribution: pane inject
|
|
459
|
+
// succeeded but the token landed in the leader/unassigned transcript,
|
|
460
|
+
// not the worker's. Budget per architect plan: 64KB tail / single
|
|
461
|
+
// per-delivery check / short grace window. Phase-1 Claude only —
|
|
462
|
+
// codex/copilot keep the pre-fix behaviour (will be addressed in
|
|
463
|
+
// phase-2 once Claude phase-1 is field-validated).
|
|
464
|
+
if let Some((rollout_path, provider_wire_str)) = claude_recipient_rollout(state, &message.recipient) {
|
|
465
|
+
let token_marker = format!("[team-agent-token:{message_id}]");
|
|
466
|
+
let grace = std::time::Duration::from_millis(200);
|
|
467
|
+
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
|
|
468
|
+
let mut transcript_has_token = false;
|
|
469
|
+
loop {
|
|
470
|
+
transcript_has_token =
|
|
471
|
+
rollout_tail_contains(&rollout_path, &token_marker, 64 * 1024);
|
|
472
|
+
if transcript_has_token || std::time::Instant::now() >= deadline {
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
std::thread::sleep(grace);
|
|
476
|
+
}
|
|
477
|
+
if !transcript_has_token {
|
|
478
|
+
// Loud but non-fatal: emit a mismatch event for diagnose/status
|
|
479
|
+
// observability. Do NOT mark delivered — degrade to
|
|
480
|
+
// submitted_unverified so capture is forced to re-attribute.
|
|
481
|
+
let reason = format!(
|
|
482
|
+
"transcript_missing:provider={provider_wire_str},rollout={}",
|
|
483
|
+
rollout_path.display()
|
|
484
|
+
);
|
|
485
|
+
event_log.write(
|
|
486
|
+
"provider.session.transcript_mismatch",
|
|
487
|
+
serde_json::json!({
|
|
488
|
+
"message_id": message_id,
|
|
489
|
+
"recipient": message.recipient,
|
|
490
|
+
"provider": provider_wire_str,
|
|
491
|
+
"rollout_path": rollout_path.to_string_lossy(),
|
|
492
|
+
"pane_id": state
|
|
493
|
+
.get("agents")
|
|
494
|
+
.and_then(|a| a.get(&message.recipient))
|
|
495
|
+
.and_then(|a| a.get("pane_id"))
|
|
496
|
+
.and_then(serde_json::Value::as_str)
|
|
497
|
+
.unwrap_or(""),
|
|
498
|
+
"spawn_epoch": state
|
|
499
|
+
.get("agents")
|
|
500
|
+
.and_then(|a| a.get(&message.recipient))
|
|
501
|
+
.and_then(|a| a.get("spawn_epoch"))
|
|
502
|
+
.and_then(serde_json::Value::as_u64)
|
|
503
|
+
.unwrap_or(0),
|
|
504
|
+
"reason": "transcript_missing",
|
|
505
|
+
}),
|
|
506
|
+
)?;
|
|
507
|
+
store.mark(message_id, "submitted_unverified", Some(&reason))?;
|
|
508
|
+
return Ok(DeliveryOutcome {
|
|
509
|
+
ok: false,
|
|
510
|
+
status: DeliveryStatus::Failed,
|
|
511
|
+
message_status: MessageStatusShadow("submitted_unverified".to_string()),
|
|
512
|
+
message_id: Some(message_id.to_string()),
|
|
513
|
+
verification: Some(reason),
|
|
514
|
+
stage: Some(DeliveryStage::Submit),
|
|
515
|
+
reason: None,
|
|
516
|
+
channel: None,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
455
520
|
store.mark(message_id, "delivered", None)?;
|
|
456
521
|
event_log.write(
|
|
457
522
|
"message.delivered",
|
|
@@ -1690,3 +1755,51 @@ pub fn retry_injection_after_trust_auto_answer(
|
|
|
1690
1755
|
channel: None,
|
|
1691
1756
|
})
|
|
1692
1757
|
}
|
|
1758
|
+
|
|
1759
|
+
/// S1-CAPTURE-001 (0.4.8 phase-1): returns (rollout_path, provider_wire) when
|
|
1760
|
+
/// the recipient agent is Claude/ClaudeCode with a known authoritative
|
|
1761
|
+
/// rollout_path; otherwise None (skip transcript verify). Phase-1 Claude only —
|
|
1762
|
+
/// codex/copilot return None and keep pre-fix delivery semantics.
|
|
1763
|
+
fn claude_recipient_rollout(
|
|
1764
|
+
state: &serde_json::Value,
|
|
1765
|
+
recipient: &str,
|
|
1766
|
+
) -> Option<(std::path::PathBuf, &'static str)> {
|
|
1767
|
+
let agent = state.get("agents")?.get(recipient)?;
|
|
1768
|
+
let provider = agent.get("provider").and_then(serde_json::Value::as_str)?;
|
|
1769
|
+
let provider_wire_str = match provider {
|
|
1770
|
+
"claude" => "claude",
|
|
1771
|
+
"claude_code" | "claude-code" => "claude_code",
|
|
1772
|
+
_ => return None,
|
|
1773
|
+
};
|
|
1774
|
+
let rollout = agent
|
|
1775
|
+
.get("rollout_path")
|
|
1776
|
+
.and_then(serde_json::Value::as_str)
|
|
1777
|
+
.filter(|s| !s.is_empty())?;
|
|
1778
|
+
Some((std::path::PathBuf::from(rollout), provider_wire_str))
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/// S1-CAPTURE-001 (0.4.8 phase-1): bounded tail read of the rollout file
|
|
1782
|
+
/// searching for `needle`. Reads up to `tail_bytes` from the end of the file
|
|
1783
|
+
/// (default 64KB budget). Returns true iff the needle appears in the tail.
|
|
1784
|
+
/// Silent on read errors — callers treat missing/unreadable as "token not
|
|
1785
|
+
/// present" which forces the unverified path.
|
|
1786
|
+
fn rollout_tail_contains(path: &std::path::Path, needle: &str, tail_bytes: u64) -> bool {
|
|
1787
|
+
use std::io::{Read, Seek, SeekFrom};
|
|
1788
|
+
let Ok(mut file) = std::fs::File::open(path) else {
|
|
1789
|
+
return false;
|
|
1790
|
+
};
|
|
1791
|
+
let Ok(metadata) = file.metadata() else {
|
|
1792
|
+
return false;
|
|
1793
|
+
};
|
|
1794
|
+
let len = metadata.len();
|
|
1795
|
+
let start = len.saturating_sub(tail_bytes);
|
|
1796
|
+
if file.seek(SeekFrom::Start(start)).is_err() {
|
|
1797
|
+
return false;
|
|
1798
|
+
}
|
|
1799
|
+
let mut buf = Vec::with_capacity(tail_bytes.min(len) as usize);
|
|
1800
|
+
if file.take(tail_bytes).read_to_end(&mut buf).is_err() {
|
|
1801
|
+
return false;
|
|
1802
|
+
}
|
|
1803
|
+
let haystack = String::from_utf8_lossy(&buf);
|
|
1804
|
+
haystack.contains(needle)
|
|
1805
|
+
}
|
|
@@ -296,7 +296,24 @@ where
|
|
|
296
296
|
.get("rollout_path")
|
|
297
297
|
.and_then(Value::as_str)
|
|
298
298
|
.is_some_and(|s| !s.is_empty());
|
|
299
|
-
|
|
299
|
+
// S1-CAPTURE-001 (0.4.8): a pending_session_id that differs from the
|
|
300
|
+
// on-row session_id means a fresh respawn is in flight — the prior
|
|
301
|
+
// worker's session_id may still be on the row (concurrent capture race,
|
|
302
|
+
// delayed persist, or persist-backfill replay) but the NEW worker has
|
|
303
|
+
// not yet been bound. Stamping capture_state=captured here would
|
|
304
|
+
// falsely declare the OLD session authoritative for the NEW worker
|
|
305
|
+
// pane — the leader/unassigned mis-attribution surfaced in S1-CAPTURE-001
|
|
306
|
+
// gate evidence. Only stamp `captured` when session_id agrees with
|
|
307
|
+
// _pending_session_id (or _pending is absent, meaning no rebind in
|
|
308
|
+
// flight).
|
|
309
|
+
let pending_mismatch = match (
|
|
310
|
+
agent_obj.get("_pending_session_id").and_then(Value::as_str).filter(|s| !s.is_empty()),
|
|
311
|
+
agent_obj.get("session_id").and_then(Value::as_str).filter(|s| !s.is_empty()),
|
|
312
|
+
) {
|
|
313
|
+
(Some(pending), Some(current)) => pending != current,
|
|
314
|
+
_ => false,
|
|
315
|
+
};
|
|
316
|
+
if has_session && !pending_mismatch {
|
|
300
317
|
// Captured already — fix state field if drifted.
|
|
301
318
|
if agent_obj
|
|
302
319
|
.get("capture_state")
|
|
@@ -684,6 +701,29 @@ fn agent_session_complete(agent: &Value) -> bool {
|
|
|
684
701
|
if !session_id_ok {
|
|
685
702
|
return false;
|
|
686
703
|
}
|
|
704
|
+
// S1-CAPTURE-001 (0.4.8, CR M3 provider-agnostic): pending mismatch guard.
|
|
705
|
+
// When fresh-start has written a new `_pending_session_id` but the old
|
|
706
|
+
// authoritative `session_id` is still present and differs, the tuple is
|
|
707
|
+
// STALE — the new process has a different session that has not been
|
|
708
|
+
// captured yet. Returning true here would let capture skip this agent
|
|
709
|
+
// and keep treating the old transcript as authoritative (the gate's
|
|
710
|
+
// observed mis-attribution). Force re-capture by reporting incomplete.
|
|
711
|
+
// The fresh-tuple clear in mark_agent_started (lifecycle/restart/agent.rs)
|
|
712
|
+
// is the primary fix; this guard handles historical poison state where
|
|
713
|
+
// both fields already coexist from a prior version.
|
|
714
|
+
let pending = agent
|
|
715
|
+
.get("_pending_session_id")
|
|
716
|
+
.and_then(Value::as_str)
|
|
717
|
+
.filter(|s| !s.is_empty());
|
|
718
|
+
let current = agent
|
|
719
|
+
.get("session_id")
|
|
720
|
+
.and_then(Value::as_str)
|
|
721
|
+
.filter(|s| !s.is_empty());
|
|
722
|
+
if let (Some(pending), Some(current)) = (pending, current) {
|
|
723
|
+
if pending != current {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
687
727
|
let rollout_path = match agent
|
|
688
728
|
.get("rollout_path")
|
|
689
729
|
.and_then(Value::as_str)
|
|
@@ -1002,6 +1042,16 @@ fn apply_captured_session(
|
|
|
1002
1042
|
serde_json::to_value(captured.attribution_confidence).unwrap_or(Value::Null),
|
|
1003
1043
|
);
|
|
1004
1044
|
agent_obj.remove("attribution_ambiguous");
|
|
1045
|
+
// S1-CAPTURE-001 (0.4.8): after writing the authoritative tuple, the
|
|
1046
|
+
// `_pending_session_id` placeholder is no longer needed — remove it so
|
|
1047
|
+
// future reads don't trip the pending-mismatch guard in
|
|
1048
|
+
// agent_session_complete. Also stamp capture_state="captured" for
|
|
1049
|
+
// diagnose/status observability.
|
|
1050
|
+
agent_obj.remove("_pending_session_id");
|
|
1051
|
+
agent_obj.insert(
|
|
1052
|
+
"capture_state".to_string(),
|
|
1053
|
+
serde_json::json!("captured"),
|
|
1054
|
+
);
|
|
1005
1055
|
true
|
|
1006
1056
|
}
|
|
1007
1057
|
|
|
@@ -1784,4 +1834,84 @@ mod u1_tests {
|
|
|
1784
1834
|
let _ = std::fs::remove_dir_all(workspace.join(".team"));
|
|
1785
1835
|
let _ = std::fs::remove_dir_all(&workspace);
|
|
1786
1836
|
}
|
|
1837
|
+
|
|
1838
|
+
/// S1-CAPTURE-001 (0.4.8): pending mismatch guard. When fresh-start has
|
|
1839
|
+
/// written a new `_pending_session_id` but the old authoritative
|
|
1840
|
+
/// `session_id` is still present and differs, `agent_session_complete`
|
|
1841
|
+
/// must return false so capture re-runs and re-binds to the new process.
|
|
1842
|
+
/// Without this, the historical poison state (old + new coexist) lets
|
|
1843
|
+
/// the stale rollout look authoritative.
|
|
1844
|
+
#[test]
|
|
1845
|
+
fn agent_session_complete_returns_false_on_pending_mismatch() {
|
|
1846
|
+
let dir = std::env::temp_dir().join(format!(
|
|
1847
|
+
"ta-pending-mismatch-{}",
|
|
1848
|
+
std::process::id()
|
|
1849
|
+
));
|
|
1850
|
+
let _ = std::fs::create_dir_all(&dir);
|
|
1851
|
+
let rollout = dir.join("old.jsonl");
|
|
1852
|
+
std::fs::write(&rollout, "{\"type\":\"assistant\"}\n").unwrap();
|
|
1853
|
+
|
|
1854
|
+
let agent = serde_json::json!({
|
|
1855
|
+
"provider": "claude",
|
|
1856
|
+
"session_id": "old",
|
|
1857
|
+
"rollout_path": rollout.to_string_lossy(),
|
|
1858
|
+
"captured_at": "2026-01-01T00:00:00+00:00",
|
|
1859
|
+
"captured_via": "fs_watch",
|
|
1860
|
+
"_pending_session_id": "new",
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
assert!(
|
|
1864
|
+
!super::agent_session_complete(&agent),
|
|
1865
|
+
"S1-CAPTURE-001: pending != session_id must force incomplete \
|
|
1866
|
+
(capture must re-attribute to new session)"
|
|
1867
|
+
);
|
|
1868
|
+
|
|
1869
|
+
// Sanity: when they match, complete is true.
|
|
1870
|
+
let agent_match = serde_json::json!({
|
|
1871
|
+
"provider": "claude",
|
|
1872
|
+
"session_id": "new",
|
|
1873
|
+
"rollout_path": rollout.to_string_lossy(),
|
|
1874
|
+
"captured_at": "2026-01-01T00:00:00+00:00",
|
|
1875
|
+
"captured_via": "fs_watch",
|
|
1876
|
+
"_pending_session_id": "new",
|
|
1877
|
+
});
|
|
1878
|
+
assert!(
|
|
1879
|
+
super::agent_session_complete(&agent_match),
|
|
1880
|
+
"pending == session_id (in-progress capture confirmed): must be complete"
|
|
1881
|
+
);
|
|
1882
|
+
|
|
1883
|
+
let _ = std::fs::remove_file(&rollout);
|
|
1884
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/// S1-CAPTURE-001 (0.4.8): apply_captured_session clears
|
|
1888
|
+
/// `_pending_session_id` and stamps `capture_state="captured"` so the
|
|
1889
|
+
/// pending-mismatch guard doesn't fire on subsequent reads.
|
|
1890
|
+
#[test]
|
|
1891
|
+
fn apply_captured_session_clears_pending_and_stamps_capture_state() {
|
|
1892
|
+
let mut agent = serde_json::Map::new();
|
|
1893
|
+
agent.insert(
|
|
1894
|
+
"_pending_session_id".to_string(),
|
|
1895
|
+
serde_json::json!("new-sess"),
|
|
1896
|
+
);
|
|
1897
|
+
let captured = CapturedSession {
|
|
1898
|
+
session_id: Some(SessionId::new("new-sess")),
|
|
1899
|
+
rollout_path: Some(RolloutPath::new(PathBuf::from("/tmp/new.jsonl"))),
|
|
1900
|
+
captured_via: CaptureVia::FsWatch,
|
|
1901
|
+
attribution_confidence: Confidence::High,
|
|
1902
|
+
spawn_cwd: PathBuf::from("/tmp/cwd"),
|
|
1903
|
+
};
|
|
1904
|
+
let written = super::apply_captured_session(&mut agent, &captured);
|
|
1905
|
+
assert!(written, "apply must return true for valid captured");
|
|
1906
|
+
assert!(
|
|
1907
|
+
agent.get("_pending_session_id").is_none(),
|
|
1908
|
+
"S1-CAPTURE-001: _pending_session_id must be removed after capture \
|
|
1909
|
+
(agent={agent:?})"
|
|
1910
|
+
);
|
|
1911
|
+
assert_eq!(
|
|
1912
|
+
agent.get("capture_state").and_then(serde_json::Value::as_str),
|
|
1913
|
+
Some("captured"),
|
|
1914
|
+
"S1-CAPTURE-001: capture_state must be stamped 'captured'"
|
|
1915
|
+
);
|
|
1916
|
+
}
|
|
1787
1917
|
}
|
|
@@ -160,10 +160,14 @@ pub enum CommandScope {
|
|
|
160
160
|
/// Caller passed `--team X` explicitly (or there's only one alive team
|
|
161
161
|
/// and we resolved it). Carries the canonical team_key.
|
|
162
162
|
Resolved(String),
|
|
163
|
-
/// No `--team` and multiple alive teams. Destructive commands
|
|
164
|
-
///
|
|
165
|
-
///
|
|
166
|
-
///
|
|
163
|
+
/// No `--team` and multiple alive teams. Destructive commands AND
|
|
164
|
+
/// selected-team commands (status, etc.) MUST refuse with this list
|
|
165
|
+
/// of candidates so the operator chooses explicitly. Only true
|
|
166
|
+
/// all-team aggregation commands may proceed without --team.
|
|
167
|
+
/// (S4QR-001 0.4.8: status was previously documented as a read-only
|
|
168
|
+
/// command that could proceed; in fact it projects a single team's
|
|
169
|
+
/// view and silently defaulting to the active team caused gate
|
|
170
|
+
/// failures when multiple alive teams existed.)
|
|
167
171
|
Ambiguous(Vec<String>),
|
|
168
172
|
/// No `--team` and no teams alive yet (fresh workspace) — bare
|
|
169
173
|
/// commands fall through to legacy single-team behaviour.
|
|
@@ -560,6 +560,23 @@ fn backfill_capture_fields(incoming_agent: &mut Value, latest_agent: &Value) {
|
|
|
560
560
|
if !latest_complete {
|
|
561
561
|
return;
|
|
562
562
|
}
|
|
563
|
+
// S1-CAPTURE-001 (0.4.8) Rule 5: a fresh restart marker (`_pending_session_id`
|
|
564
|
+
// present on incoming) signals the worker just respawned and the prior
|
|
565
|
+
// tuple was intentionally cleared. The capture scanner is the only path
|
|
566
|
+
// permitted to promote `_pending_session_id` into the authoritative tuple
|
|
567
|
+
// after it confirms backing. Persist-side backfill MUST NOT revive the
|
|
568
|
+
// latest tuple in this state — doing so resurrects the previous worker's
|
|
569
|
+
// session/rollout pair and delivered tokens land in the OLD transcript
|
|
570
|
+
// (the leader/unassigned mis-attribution surfaced in S1-CAPTURE-001 gate
|
|
571
|
+
// evidence). Refuse backfill regardless of whether incoming session_id
|
|
572
|
+
// is null vs latest's value: the pending marker is authoritative intent.
|
|
573
|
+
let incoming_pending = incoming_row
|
|
574
|
+
.get("_pending_session_id")
|
|
575
|
+
.and_then(Value::as_str)
|
|
576
|
+
.filter(|s| !s.is_empty());
|
|
577
|
+
if incoming_pending.is_some() {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
563
580
|
// Rule 2: incoming carries a DIFFERENT non-null session_id → do not mix.
|
|
564
581
|
let incoming_session = incoming_row.get("session_id").and_then(Value::as_str);
|
|
565
582
|
let latest_session = latest_agent.get("session_id").and_then(Value::as_str);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.4.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.4.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.4.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.4.8",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.4.8",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.4.8"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|