@team-agent/installer 0.4.6 → 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 +33 -1
- package/crates/team-agent/src/cli/status_port.rs +119 -66
- package/crates/team-agent/src/cli/tests/status_send.rs +74 -48
- package/crates/team-agent/src/coordinator/tick.rs +2 -21
- package/crates/team-agent/src/leader/helpers.rs +1 -22
- package/crates/team-agent/src/lifecycle/launch.rs +1 -11
- package/crates/team-agent/src/lifecycle/profile_launch.rs +25 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +20 -26
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +30 -8
- 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/adapter.rs +18 -372
- package/crates/team-agent/src/provider/adapters/claude.rs +106 -0
- package/crates/team-agent/src/provider/adapters/codex.rs +117 -0
- package/crates/team-agent/src/provider/adapters/copilot.rs +124 -0
- package/crates/team-agent/src/provider/adapters/fake.rs +17 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +27 -0
- package/crates/team-agent/src/provider/mod.rs +6 -0
- package/crates/team-agent/src/provider/session/capture.rs +131 -1
- package/crates/team-agent/src/provider/wire.rs +139 -0
- 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/skills/team-agent/SKILL.md +6 -0
|
@@ -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();
|
|
@@ -913,28 +928,7 @@ pub(super) fn agent_window(agent: &serde_json::Value, agent_id: &AgentId) -> Str
|
|
|
913
928
|
.to_string()
|
|
914
929
|
}
|
|
915
930
|
|
|
916
|
-
pub(super)
|
|
917
|
-
match raw {
|
|
918
|
-
"claude" => Some(Provider::Claude),
|
|
919
|
-
"claude_code" => Some(Provider::ClaudeCode),
|
|
920
|
-
"codex" => Some(Provider::Codex),
|
|
921
|
-
"copilot" => Some(Provider::Copilot),
|
|
922
|
-
"gemini_cli" => Some(Provider::GeminiCli),
|
|
923
|
-
"fake" => Some(Provider::Fake),
|
|
924
|
-
_ => None,
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
pub(super) fn provider_wire(provider: Provider) -> &'static str {
|
|
929
|
-
match provider {
|
|
930
|
-
Provider::Claude => "claude",
|
|
931
|
-
Provider::ClaudeCode => "claude_code",
|
|
932
|
-
Provider::Codex => "codex",
|
|
933
|
-
Provider::Copilot => "copilot",
|
|
934
|
-
Provider::GeminiCli => "gemini_cli",
|
|
935
|
-
Provider::Fake => "fake",
|
|
936
|
-
}
|
|
937
|
-
}
|
|
931
|
+
pub(super) use crate::provider::wire::{parse_provider, provider_wire};
|
|
938
932
|
|
|
939
933
|
pub(super) fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
|
|
940
934
|
match raw {
|
|
@@ -1183,13 +1183,17 @@ fn try_autobind_leader_after_restart(
|
|
|
1183
1183
|
.pointer("/leader_receiver/provider")
|
|
1184
1184
|
.and_then(serde_json::Value::as_str)
|
|
1185
1185
|
})
|
|
1186
|
-
.and_then(|s|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1186
|
+
.and_then(|s| {
|
|
1187
|
+
// Legacy auto-attach: collapse any claude variant to ClaudeCode
|
|
1188
|
+
// (this site historically treated `Claude` and `ClaudeCode` as
|
|
1189
|
+
// the same attach target). Wire-format `parse_provider` keeps
|
|
1190
|
+
// them distinct everywhere else.
|
|
1191
|
+
crate::provider::wire::parse_provider(s).map(|p| match p {
|
|
1192
|
+
crate::model::enums::Provider::Claude => {
|
|
1193
|
+
crate::model::enums::Provider::ClaudeCode
|
|
1194
|
+
}
|
|
1195
|
+
other => other,
|
|
1196
|
+
})
|
|
1193
1197
|
})
|
|
1194
1198
|
.unwrap_or(crate::model::enums::Provider::ClaudeCode);
|
|
1195
1199
|
let team_str = team;
|
|
@@ -1357,11 +1361,29 @@ fn mark_agent_respawned(
|
|
|
1357
1361
|
// Claude family clear path uniformly. The Copilot scanner
|
|
1358
1362
|
// (provider/adapter.rs:1217-1228) already gates expected-id with a
|
|
1359
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).
|
|
1360
1372
|
if matches!(
|
|
1361
1373
|
restart_mode,
|
|
1362
1374
|
StartMode::Fresh | StartMode::FreshAfterMissingRollout
|
|
1363
1375
|
) {
|
|
1364
|
-
|
|
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
|
+
}
|
|
1365
1387
|
}
|
|
1366
1388
|
crate::lifecycle::launch::persist_command_plan_state(agent, &spawn.plan, &spawn.profile_launch);
|
|
1367
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
|
+
}
|