@team-agent/installer 0.4.7 → 0.4.9
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/cli/diagnose.rs +52 -0
- package/crates/team-agent/src/cli/mod.rs +10 -0
- package/crates/team-agent/src/leader/start.rs +180 -6
- package/crates/team-agent/src/lifecycle/profile_launch.rs +32 -1
- 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/crates/team-agent/src/tmux_backend.rs +116 -1
- package/crates/team-agent/src/transport/test_support.rs +62 -3
- package/crates/team-agent/src/transport.rs +38 -0
- package/package.json +4 -4
|
@@ -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);
|
|
@@ -636,7 +636,21 @@ impl TmuxBackend {
|
|
|
636
636
|
first: bool,
|
|
637
637
|
) -> Result<SpawnResult, TransportError> {
|
|
638
638
|
let command = shell_command(argv, cwd, env, env_unset);
|
|
639
|
-
|
|
639
|
+
self.spawn_with_command(session, window, &command, first)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/// 0.4.x (CR C-2): spawn variant that takes a pre-built shell command
|
|
643
|
+
/// (used by `spawn_first_with_leader_shell_wrapper` /
|
|
644
|
+
/// `spawn_into_with_leader_shell_wrapper` to inject the leader wrapper
|
|
645
|
+
/// shape without going through `shell_command`'s `exec`-only template).
|
|
646
|
+
fn spawn_with_command(
|
|
647
|
+
&self,
|
|
648
|
+
session: &SessionName,
|
|
649
|
+
window: &WindowName,
|
|
650
|
+
command: &str,
|
|
651
|
+
first: bool,
|
|
652
|
+
) -> Result<SpawnResult, TransportError> {
|
|
653
|
+
let spawn_argv = tmux_spawn_argv(session, window, command, first);
|
|
640
654
|
self.run_spawn(&spawn_argv)?;
|
|
641
655
|
let pane_argv = vec![
|
|
642
656
|
"tmux".to_string(),
|
|
@@ -1327,6 +1341,75 @@ fn shell_command(
|
|
|
1327
1341
|
parts.join(" ")
|
|
1328
1342
|
}
|
|
1329
1343
|
|
|
1344
|
+
/// 0.4.x (CR R6): single-source marker prefix. The exit marker emitted by
|
|
1345
|
+
/// `leader_shell_wrapper_command` and the substring detected by
|
|
1346
|
+
/// `leader_provider_health` MUST share this prefix exactly. Format:
|
|
1347
|
+
/// `"[team-agent] {provider_label} exited with {rc}"`.
|
|
1348
|
+
pub const LEADER_PROVIDER_EXIT_MARKER_PREFIX: &str = "[team-agent]";
|
|
1349
|
+
pub const LEADER_PROVIDER_EXIT_MARKER_SUFFIX: &str = "exited with";
|
|
1350
|
+
|
|
1351
|
+
/// 0.4.x (CR R6): build the leader exit marker text for `provider_label`.
|
|
1352
|
+
/// Used by both the shell wrapper (printf source) and the health check
|
|
1353
|
+
/// (capture substring) so they cannot drift.
|
|
1354
|
+
pub fn leader_provider_exit_marker(provider_label: &str) -> String {
|
|
1355
|
+
format!(
|
|
1356
|
+
"{LEADER_PROVIDER_EXIT_MARKER_PREFIX} {provider_label} {LEADER_PROVIDER_EXIT_MARKER_SUFFIX}"
|
|
1357
|
+
)
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/// 0.4.x (CR C-2): leader shell wrapper — provider runs as a CHILD of a
|
|
1361
|
+
/// long-lived shell, not as the pane's primary process. When the provider
|
|
1362
|
+
/// exits, the pane returns to an interactive shell with an explicit exit
|
|
1363
|
+
/// marker, matching manual `tmux new-session` then `claude` behaviour.
|
|
1364
|
+
///
|
|
1365
|
+
/// Four required envelope sections (CR C-2):
|
|
1366
|
+
/// 1. cd <cwd> — same as `shell_command`
|
|
1367
|
+
/// 2. unset <KEY> ... — provider env_unset block
|
|
1368
|
+
/// 3. KEY=val ... <provider> — env exports + provider invocation
|
|
1369
|
+
/// (NO `exec` — runs as child)
|
|
1370
|
+
/// 4. printf exit marker; exec shell -l
|
|
1371
|
+
///
|
|
1372
|
+
/// `provider_label` is a human-readable provider name (e.g. "claude",
|
|
1373
|
+
/// "codex") embedded in the exit marker for diagnostics.
|
|
1374
|
+
pub fn leader_shell_wrapper_command(
|
|
1375
|
+
argv: &[String],
|
|
1376
|
+
cwd: &Path,
|
|
1377
|
+
env: &BTreeMap<String, String>,
|
|
1378
|
+
env_unset: &[String],
|
|
1379
|
+
provider_label: &str,
|
|
1380
|
+
) -> String {
|
|
1381
|
+
let mut parts = Vec::new();
|
|
1382
|
+
// 1. cd
|
|
1383
|
+
parts.push("cd".to_string());
|
|
1384
|
+
parts.push(shell_quote(&cwd.to_string_lossy()));
|
|
1385
|
+
parts.push("&&".to_string());
|
|
1386
|
+
// 2. unset
|
|
1387
|
+
for key in env_unset {
|
|
1388
|
+
parts.push("unset".to_string());
|
|
1389
|
+
parts.push(key.clone());
|
|
1390
|
+
parts.push("&&".to_string());
|
|
1391
|
+
}
|
|
1392
|
+
// 3. env exports + provider (NO `exec` so the provider is a child)
|
|
1393
|
+
for (key, value) in env {
|
|
1394
|
+
parts.push(format!("{key}={}", shell_quote(value)));
|
|
1395
|
+
}
|
|
1396
|
+
parts.extend(argv.iter().map(|arg| shell_quote(arg)));
|
|
1397
|
+
parts.push(";".to_string());
|
|
1398
|
+
// 4. exit marker + fall back to interactive shell
|
|
1399
|
+
parts.push("rc=$?;".to_string());
|
|
1400
|
+
parts.push("printf".to_string());
|
|
1401
|
+
// CR R6: marker text comes from single-source `leader_provider_exit_marker`.
|
|
1402
|
+
parts.push(shell_quote(&format!(
|
|
1403
|
+
"\n{} %s\n",
|
|
1404
|
+
leader_provider_exit_marker(provider_label)
|
|
1405
|
+
)));
|
|
1406
|
+
parts.push("\"$rc\";".to_string());
|
|
1407
|
+
parts.push("exec".to_string());
|
|
1408
|
+
parts.push("\"${SHELL:-/bin/zsh}\"".to_string());
|
|
1409
|
+
parts.push("-l".to_string());
|
|
1410
|
+
parts.join(" ")
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1330
1413
|
fn shell_quote(raw: &str) -> String {
|
|
1331
1414
|
if raw.is_empty() {
|
|
1332
1415
|
return "''".to_string();
|
|
@@ -1422,6 +1505,38 @@ impl Transport for TmuxBackend {
|
|
|
1422
1505
|
self.spawn_split(session, window, argv, cwd, env, env_unset)
|
|
1423
1506
|
}
|
|
1424
1507
|
|
|
1508
|
+
/// 0.4.x (CR C-2): TmuxBackend override of the leader-shell-wrapper
|
|
1509
|
+
/// variant. Builds the wrapper shell line via
|
|
1510
|
+
/// `leader_shell_wrapper_command` and runs it through
|
|
1511
|
+
/// `spawn_with_command` (bypassing the default `exec <cmd>` shape).
|
|
1512
|
+
fn spawn_first_with_leader_shell_wrapper(
|
|
1513
|
+
&self,
|
|
1514
|
+
session: &SessionName,
|
|
1515
|
+
window: &WindowName,
|
|
1516
|
+
argv: &[String],
|
|
1517
|
+
cwd: &Path,
|
|
1518
|
+
env: &BTreeMap<String, String>,
|
|
1519
|
+
env_unset: &[String],
|
|
1520
|
+
provider_label: &str,
|
|
1521
|
+
) -> Result<SpawnResult, TransportError> {
|
|
1522
|
+
let command = leader_shell_wrapper_command(argv, cwd, env, env_unset, provider_label);
|
|
1523
|
+
self.spawn_with_command(session, window, &command, true)
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
fn spawn_into_with_leader_shell_wrapper(
|
|
1527
|
+
&self,
|
|
1528
|
+
session: &SessionName,
|
|
1529
|
+
window: &WindowName,
|
|
1530
|
+
argv: &[String],
|
|
1531
|
+
cwd: &Path,
|
|
1532
|
+
env: &BTreeMap<String, String>,
|
|
1533
|
+
env_unset: &[String],
|
|
1534
|
+
provider_label: &str,
|
|
1535
|
+
) -> Result<SpawnResult, TransportError> {
|
|
1536
|
+
let command = leader_shell_wrapper_command(argv, cwd, env, env_unset, provider_label);
|
|
1537
|
+
self.spawn_with_command(session, window, &command, false)
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1425
1540
|
fn inject(
|
|
1426
1541
|
&self,
|
|
1427
1542
|
target: &Target,
|
|
@@ -50,6 +50,9 @@ struct OfflineState {
|
|
|
50
50
|
/// U1-C Tail-peek contract: every `capture()` call records its `CaptureRange`,
|
|
51
51
|
/// in order, so a test can prove the delivery peek site narrowed Full → Tail(80).
|
|
52
52
|
capture_ranges: Vec<CaptureRange>,
|
|
53
|
+
/// 0.4.x (CR C-3 / CR C-5): pre-staged `query(PaneField::PaneCurrentCommand)`
|
|
54
|
+
/// answer keyed by pane id. Used by leader provider health tests.
|
|
55
|
+
pane_current_commands: BTreeMap<String, String>,
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
impl Default for OfflineState {
|
|
@@ -73,6 +76,7 @@ impl Default for OfflineState {
|
|
|
73
76
|
list_targets_error: None,
|
|
74
77
|
capture_text: BTreeMap::new(),
|
|
75
78
|
capture_ranges: Vec::new(),
|
|
79
|
+
pane_current_commands: BTreeMap::new(),
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
}
|
|
@@ -163,6 +167,52 @@ impl OfflineTransport {
|
|
|
163
167
|
self
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
/// 0.4.x (CR C-3 / CR C-5): pre-stage a `pane_current_command` answer
|
|
171
|
+
/// for `query(PaneField::PaneCurrentCommand)`. Used by leader provider
|
|
172
|
+
/// health tests to simulate "pane is now in a shell" vs
|
|
173
|
+
/// "pane is running the provider".
|
|
174
|
+
pub fn with_pane_current_command(
|
|
175
|
+
self,
|
|
176
|
+
pane_id: impl Into<String>,
|
|
177
|
+
command: impl Into<String>,
|
|
178
|
+
) -> Self {
|
|
179
|
+
self.with_state(|state| {
|
|
180
|
+
state.pane_current_commands.insert(pane_id.into(), command.into());
|
|
181
|
+
});
|
|
182
|
+
self
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// 0.4.x (CR C-3 / CR C-5): mutable setters for fluent test seeding
|
|
186
|
+
/// (alternative to builder chain when the transport already exists).
|
|
187
|
+
pub fn set_pane_addressable(&self, pane: &PaneId, addressable: bool) {
|
|
188
|
+
self.with_state(|state| {
|
|
189
|
+
state.liveness.insert(
|
|
190
|
+
pane.as_str().to_string(),
|
|
191
|
+
if addressable {
|
|
192
|
+
PaneLiveness::Live
|
|
193
|
+
} else {
|
|
194
|
+
PaneLiveness::Dead
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
pub fn set_pane_current_command(&self, pane: &PaneId, command: &str) {
|
|
201
|
+
self.with_state(|state| {
|
|
202
|
+
state
|
|
203
|
+
.pane_current_commands
|
|
204
|
+
.insert(pane.as_str().to_string(), command.to_string());
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pub fn set_pane_capture(&self, pane: &PaneId, text: &str) {
|
|
209
|
+
self.with_state(|state| {
|
|
210
|
+
state
|
|
211
|
+
.capture_text
|
|
212
|
+
.insert(pane.as_str().to_string(), text.to_string());
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
166
216
|
/// Pre-stage `capture()` output for a `Target::SessionWindow{session,window}` key.
|
|
167
217
|
pub fn with_capture_for_session_window(
|
|
168
218
|
self,
|
|
@@ -378,11 +428,20 @@ impl Transport for OfflineTransport {
|
|
|
378
428
|
|
|
379
429
|
fn query(
|
|
380
430
|
&self,
|
|
381
|
-
|
|
382
|
-
|
|
431
|
+
target: &Target,
|
|
432
|
+
field: PaneField,
|
|
383
433
|
) -> Result<Option<String>, TransportError> {
|
|
384
434
|
self.record("query");
|
|
385
|
-
|
|
435
|
+
if !matches!(field, PaneField::PaneCurrentCommand) {
|
|
436
|
+
return Ok(None);
|
|
437
|
+
}
|
|
438
|
+
let pane_id = match target {
|
|
439
|
+
Target::Pane(p) => p.as_str().to_string(),
|
|
440
|
+
Target::SessionWindow { .. } => return Ok(None),
|
|
441
|
+
};
|
|
442
|
+
Ok(self.with_state(|state| {
|
|
443
|
+
state.pane_current_commands.get(&pane_id).cloned()
|
|
444
|
+
}))
|
|
386
445
|
}
|
|
387
446
|
|
|
388
447
|
fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
|