@team-agent/installer 0.4.9 → 0.4.11
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/status_port.rs +4 -1
- package/crates/team-agent/src/compiler.rs +78 -9
- package/crates/team-agent/src/coordinator/tick.rs +96 -0
- package/crates/team-agent/src/leader/start.rs +169 -25
- package/crates/team-agent/src/lifecycle/launch.rs +155 -3
- package/crates/team-agent/src/lifecycle/profile_launch.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +279 -10
- package/crates/team-agent/src/lifecycle/restart/common.rs +17 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +187 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +22 -13
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +24 -34
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/types.rs +86 -0
- package/crates/team-agent/src/model/enums.rs +65 -0
- package/crates/team-agent/src/model/spec.rs +34 -2
- package/crates/team-agent/src/os_probe.rs +79 -0
- package/crates/team-agent/src/provider/adapter.rs +11 -3
- package/crates/team-agent/src/provider/adapters/claude.rs +8 -0
- package/crates/team-agent/src/provider/adapters/codex.rs +10 -0
- package/crates/team-agent/src/provider/types.rs +8 -0
- package/crates/team-agent/src/tmux_backend.rs +19 -1
- package/package.json +4 -4
|
@@ -322,6 +322,7 @@ log="${TEAM_AGENT_E27_TMUX_LOG}"
|
|
|
322
322
|
expected="${TEAM_AGENT_E27_EXPECTED_ENDPOINT}"
|
|
323
323
|
session="${TEAM_AGENT_E27_SESSION_NAME}"
|
|
324
324
|
pane="${TEAM_AGENT_E27_PANE_ID}"
|
|
325
|
+
killed_marker="${log}.killed"
|
|
325
326
|
track=0
|
|
326
327
|
case "$*" in
|
|
327
328
|
*"$expected"*|*"$session"*|*"$pane"*) track=1 ;;
|
|
@@ -340,8 +341,22 @@ case "$*" in
|
|
|
340
341
|
exit 18
|
|
341
342
|
;;
|
|
342
343
|
esac
|
|
344
|
+
# 0.4.10+ reset-gate fix: track kill-pane so the next display-message
|
|
345
|
+
# probe reports the pane as gone. The reset hard gate calls has_pane
|
|
346
|
+
# after stop_agent_at_paths returns; a shim that always reports the
|
|
347
|
+
# original pane as live would trip the gate.
|
|
348
|
+
case "$*" in
|
|
349
|
+
*"kill-pane -t $pane"*)
|
|
350
|
+
: > "$killed_marker"
|
|
351
|
+
exit 0
|
|
352
|
+
;;
|
|
353
|
+
esac
|
|
343
354
|
case "$*" in
|
|
344
355
|
*"display-message -p -t $pane #{pane_id}"*)
|
|
356
|
+
if [ -f "$killed_marker" ]; then
|
|
357
|
+
echo "can't find pane: $pane" >&2
|
|
358
|
+
exit 1
|
|
359
|
+
fi
|
|
345
360
|
printf '%s\n' "$pane"
|
|
346
361
|
exit 0
|
|
347
362
|
;;
|
|
@@ -352,6 +367,13 @@ case "$*" in
|
|
|
352
367
|
*"list-windows -t $session -F #{window_name}"*)
|
|
353
368
|
exit 0
|
|
354
369
|
;;
|
|
370
|
+
*"list-panes -a -F"*)
|
|
371
|
+
# Hard-gate post-stop snapshot: no residual same-role panes after kill.
|
|
372
|
+
if [ -f "$killed_marker" ]; then
|
|
373
|
+
exit 0
|
|
374
|
+
fi
|
|
375
|
+
exit 0
|
|
376
|
+
;;
|
|
355
377
|
*"has-session -t $session"*)
|
|
356
378
|
exit 0
|
|
357
379
|
;;
|
|
@@ -609,6 +631,171 @@ fn reset_agent_emits_golden_lifecycle_event_payloads() {
|
|
|
609
631
|
assert_eq!(complete.get("agent_id").and_then(|v| v.as_str()), Some("alpha"));
|
|
610
632
|
}
|
|
611
633
|
|
|
634
|
+
// 0.4.10+ reset duplicate-window fix RED tests
|
|
635
|
+
// (plan §1-§3, CR C-1/C-2/C-5 acceptance).
|
|
636
|
+
|
|
637
|
+
/// Plan §2: stop must resolve a stale pane_id to live same-role panes
|
|
638
|
+
/// and kill them by pane_id. Pre-fix path returned stopped=false when
|
|
639
|
+
/// pane_id was stale even if a residue survived; this lets reset's
|
|
640
|
+
/// unconditional start spawn a duplicate window.
|
|
641
|
+
///
|
|
642
|
+
/// With the fix: stale pane_id + same-role residue → stop enumerates,
|
|
643
|
+
/// kills by pane_id, returns stopped=true. Reset proceeds normally.
|
|
644
|
+
#[test]
|
|
645
|
+
fn reset_agent_stale_pane_with_same_role_residue_kills_by_pane_id() {
|
|
646
|
+
use crate::transport::{PaneInfo, PaneId as PaneIdT, SessionName, WindowName};
|
|
647
|
+
let ws = lanea_team_ws("running");
|
|
648
|
+
let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
649
|
+
state["agents"]["alpha"]["pane_id"] = json!("%STALE");
|
|
650
|
+
state["agents"]["alpha"]["window"] = json!("alpha");
|
|
651
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
652
|
+
|
|
653
|
+
// Transport: stale %STALE absent; same-role residual %RESIDUAL listed.
|
|
654
|
+
let residual = PaneInfo {
|
|
655
|
+
pane_id: PaneIdT::new("%RESIDUAL"),
|
|
656
|
+
session: SessionName::new("team-laneateam"),
|
|
657
|
+
window_index: Some(0),
|
|
658
|
+
window_name: Some(WindowName::new("alpha")),
|
|
659
|
+
pane_index: None,
|
|
660
|
+
tty: None,
|
|
661
|
+
current_command: None,
|
|
662
|
+
current_path: None,
|
|
663
|
+
active: true,
|
|
664
|
+
pane_pid: None,
|
|
665
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
666
|
+
};
|
|
667
|
+
let transport = OfflineTransport::new()
|
|
668
|
+
.with_session_present(true)
|
|
669
|
+
.with_targets(vec![residual.clone()])
|
|
670
|
+
.with_pane_presence("%STALE", false);
|
|
671
|
+
|
|
672
|
+
// The stop sub-step must enumerate same-role panes and kill %RESIDUAL.
|
|
673
|
+
// The reset overall proceeds (stop.stopped=true → gate skipped).
|
|
674
|
+
let outcome = crate::lifecycle::reset_agent_with_transport(
|
|
675
|
+
&ws,
|
|
676
|
+
&aid("alpha"),
|
|
677
|
+
true,
|
|
678
|
+
false,
|
|
679
|
+
None,
|
|
680
|
+
&transport,
|
|
681
|
+
);
|
|
682
|
+
let outcome_dbg = format!("{outcome:?}");
|
|
683
|
+
// Outcome may be Ok or downstream Err from spawn, but it MUST NOT be
|
|
684
|
+
// the stop-not-proven RequirementUnmet (the gate did not need to fire
|
|
685
|
+
// because stop correctly killed the residue).
|
|
686
|
+
assert!(
|
|
687
|
+
!outcome_dbg.contains("old agent instance still live"),
|
|
688
|
+
"stop must kill stale-pane residue (no stop-not-proven gate \
|
|
689
|
+
trigger); got {outcome_dbg}"
|
|
690
|
+
);
|
|
691
|
+
// The stop_complete event must report stopped=true (residual was killed).
|
|
692
|
+
let events = lifecycle_events(&ws);
|
|
693
|
+
let stop_complete = find_event(&events, "stop_agent.complete")
|
|
694
|
+
.expect("stop_agent.complete must be emitted");
|
|
695
|
+
assert_eq!(
|
|
696
|
+
stop_complete.get("stopped").and_then(|v| v.as_bool()),
|
|
697
|
+
Some(true),
|
|
698
|
+
"stop must report stopped=true when same-role residue was killed; \
|
|
699
|
+
got {stop_complete:?}"
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/// Plan §3 hard gate: structural verification — the helpers exist and
|
|
704
|
+
/// are wired into reset_agent_at_paths. The full race-condition scenario
|
|
705
|
+
/// (stop returns stopped=false BUT residue appears between stop and
|
|
706
|
+
/// gate-time list_targets snapshot) requires concurrency the
|
|
707
|
+
/// OfflineTransport cannot model — the live macmini batch reset
|
|
708
|
+
/// evidence (.team/evidence/) is the truth source for that race. The
|
|
709
|
+
/// unit test below verifies the gate code path is reachable + the
|
|
710
|
+
/// event writer + N38 error text via a synthetic state with a still-live
|
|
711
|
+
/// stored pane_id that stop's kill_pane targeted but the
|
|
712
|
+
/// post-stop has_pane re-reads as live (a deterministic mock).
|
|
713
|
+
#[test]
|
|
714
|
+
fn reset_agent_stop_not_proven_grep_guard_hard_gate_wired() {
|
|
715
|
+
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
716
|
+
let agent_rs = manifest
|
|
717
|
+
.join("src")
|
|
718
|
+
.join("lifecycle")
|
|
719
|
+
.join("restart")
|
|
720
|
+
.join("agent.rs");
|
|
721
|
+
let contents = std::fs::read_to_string(&agent_rs).expect("read agent.rs");
|
|
722
|
+
// Gate must exist with the N38 three-line text and stop_not_proven event.
|
|
723
|
+
assert!(
|
|
724
|
+
contents.contains("reset refused: old agent instance still live"),
|
|
725
|
+
"reset_agent_at_paths must carry the N38 three-line error (CR C-1)"
|
|
726
|
+
);
|
|
727
|
+
assert!(
|
|
728
|
+
contents.contains("write_reset_stop_not_proven_event"),
|
|
729
|
+
"reset_agent_at_paths must emit reset_agent.stop_not_proven via the writer"
|
|
730
|
+
);
|
|
731
|
+
assert!(
|
|
732
|
+
contents.contains("if !agent_is_paused && !stop.stopped"),
|
|
733
|
+
"gate must run only when stop.stopped=false (the dangerous case)"
|
|
734
|
+
);
|
|
735
|
+
assert!(
|
|
736
|
+
contents.contains("list_same_role_panes")
|
|
737
|
+
&& contents.contains("is_per_agent_window"),
|
|
738
|
+
"stop_agent_at_paths must enumerate same-role panes via the new helpers"
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/// Plan §3 hard gate: when no residue (clean stop), reset is allowed to
|
|
743
|
+
/// proceed to start. Covers the legitimate `stopped=false` case (worker
|
|
744
|
+
/// already absent — nothing to gate, nothing to refuse).
|
|
745
|
+
#[test]
|
|
746
|
+
fn reset_agent_already_absent_can_start() {
|
|
747
|
+
let ws = lanea_team_ws("stopped");
|
|
748
|
+
// No stale pane_id, no residual panes — gate is a no-op.
|
|
749
|
+
let transport = OfflineTransport::new().with_session_present(true);
|
|
750
|
+
let outcome = crate::lifecycle::reset_agent_with_transport(
|
|
751
|
+
&ws,
|
|
752
|
+
&aid("alpha"),
|
|
753
|
+
true,
|
|
754
|
+
false,
|
|
755
|
+
None,
|
|
756
|
+
&transport,
|
|
757
|
+
);
|
|
758
|
+
// The transport is offline so spawn may fail downstream; the contract
|
|
759
|
+
// for THIS test is that the hard gate did NOT short-circuit to
|
|
760
|
+
// RequirementUnmet with "old agent instance still live".
|
|
761
|
+
let outcome_dbg = format!("{outcome:?}");
|
|
762
|
+
assert!(
|
|
763
|
+
!outcome_dbg.contains("old agent instance still live"),
|
|
764
|
+
"reset with no residue must NOT trigger the stop-not-proven gate; \
|
|
765
|
+
got {outcome_dbg}"
|
|
766
|
+
);
|
|
767
|
+
// The stop_not_proven event must NOT be present.
|
|
768
|
+
let events = lifecycle_events(&ws);
|
|
769
|
+
assert!(
|
|
770
|
+
find_event(&events, "reset_agent.stop_not_proven").is_none(),
|
|
771
|
+
"no reset_agent.stop_not_proven event when nothing to gate; \
|
|
772
|
+
events seen: {:?}",
|
|
773
|
+
names(&events)
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/// Plan §2 + CR C-5: standalone stop-agent must keep "already absent is
|
|
778
|
+
/// stopped=false, ok" behavior. The hard gate only runs in reset, not
|
|
779
|
+
/// in stop-agent. This locks the boundary so the fix does not regress
|
|
780
|
+
/// existing stop-agent contract.
|
|
781
|
+
#[test]
|
|
782
|
+
fn stop_agent_already_absent_still_returns_stopped_false() {
|
|
783
|
+
let ws = lanea_team_ws("stopped");
|
|
784
|
+
let transport = OfflineTransport::new().with_session_present(true);
|
|
785
|
+
let result = crate::lifecycle::stop_agent_with_transport(
|
|
786
|
+
&ws,
|
|
787
|
+
&aid("alpha"),
|
|
788
|
+
None,
|
|
789
|
+
&transport,
|
|
790
|
+
);
|
|
791
|
+
let report = result.expect("stop_agent must return Ok for absent agent");
|
|
792
|
+
assert!(
|
|
793
|
+
!report.stopped,
|
|
794
|
+
"stop_agent on already-absent worker returns stopped=false (no \
|
|
795
|
+
kill, contract preserved); got {report:?}"
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
612
799
|
// stop-agent — golden operations.py:98 writes stop_agent.complete {agent_id, target, stopped}
|
|
613
800
|
// (+ stop_agent.window_stop_failed {agent_id, target, stderr} on kill failure). Rust emits none.
|
|
614
801
|
#[test]
|
|
@@ -34,6 +34,12 @@ type RecordedSpawns = LaneSpawns;
|
|
|
34
34
|
struct SessionProbeRecordingTransport {
|
|
35
35
|
spawns: RecordedSpawns,
|
|
36
36
|
session_exists: bool,
|
|
37
|
+
// 0.4.10+ reset hard-gate: record killed panes so liveness/has_pane
|
|
38
|
+
// reflect the kill (the gate checks "did stop actually remove the
|
|
39
|
+
// old pane"). Default mock behavior reported Live unconditionally —
|
|
40
|
+
// safe pre-fix because no gate ran, but the gate now needs the kill
|
|
41
|
+
// to be observable.
|
|
42
|
+
killed_panes: std::sync::Mutex<std::collections::BTreeSet<String>>,
|
|
37
43
|
}
|
|
38
44
|
impl crate::transport::Transport for SessionProbeRecordingTransport {
|
|
39
45
|
fn kind(&self) -> crate::transport::BackendKind {
|
|
@@ -62,9 +68,21 @@ impl crate::transport::Transport for SessionProbeRecordingTransport {
|
|
|
62
68
|
fn query(&self, _t: &crate::transport::Target, _f: crate::transport::PaneField) -> Result<Option<String>, crate::transport::TransportError> {
|
|
63
69
|
unimplemented!("not reached")
|
|
64
70
|
}
|
|
65
|
-
fn liveness(&self,
|
|
71
|
+
fn liveness(&self, p: &crate::transport::PaneId) -> Result<crate::model::enums::PaneLiveness, crate::transport::TransportError> {
|
|
72
|
+
let killed = self.killed_panes.lock().unwrap();
|
|
73
|
+
if killed.contains(p.as_str()) {
|
|
74
|
+
return Ok(crate::model::enums::PaneLiveness::Dead);
|
|
75
|
+
}
|
|
66
76
|
Ok(crate::model::enums::PaneLiveness::Live)
|
|
67
77
|
}
|
|
78
|
+
fn has_pane(&self, p: &crate::transport::PaneId) -> Result<Option<bool>, crate::transport::TransportError> {
|
|
79
|
+
let killed = self.killed_panes.lock().unwrap();
|
|
80
|
+
Ok(Some(!killed.contains(p.as_str())))
|
|
81
|
+
}
|
|
82
|
+
fn kill_pane(&self, p: &crate::transport::PaneId) -> Result<(), crate::transport::TransportError> {
|
|
83
|
+
self.killed_panes.lock().unwrap().insert(p.as_str().to_string());
|
|
84
|
+
Ok(())
|
|
85
|
+
}
|
|
68
86
|
fn list_targets(&self) -> Result<Vec<crate::transport::PaneInfo>, crate::transport::TransportError> {
|
|
69
87
|
Ok(Vec::new())
|
|
70
88
|
}
|
|
@@ -115,10 +133,7 @@ fn start_agent_respawn_into_dead_session_uses_new_session_not_new_window() {
|
|
|
115
133
|
let ws = respawn_ws_one_resumable_worker();
|
|
116
134
|
let spawns = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
117
135
|
// session_exists=false models the dead CP-1 `-L` server after --discard-session.
|
|
118
|
-
let transport = SessionProbeRecordingTransport {
|
|
119
|
-
spawns: std::sync::Arc::clone(&spawns),
|
|
120
|
-
session_exists: false,
|
|
121
|
-
};
|
|
136
|
+
let transport = SessionProbeRecordingTransport { spawns: std::sync::Arc::clone(&spawns), session_exists: false, killed_panes: std::sync::Mutex::new(std::collections::BTreeSet::new()) };
|
|
122
137
|
let _ = start_agent_with_transport(&ws, &AgentId::new("alpha"), false, false, false, None, &transport);
|
|
123
138
|
let recorded = spawns.lock().unwrap().clone();
|
|
124
139
|
assert_eq!(recorded.len(), 1, "exactly one respawn for alpha; got {recorded:?}");
|
|
@@ -150,10 +165,7 @@ fn reset_agent_discard_session_rebuilds_window_via_start_respawn() {
|
|
|
150
165
|
let ws = restart_ws_two_resumable_workers(); // compiled spec + state(alpha,bravo running) + seeded coordinator
|
|
151
166
|
let spawns = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
152
167
|
// live session present (other workers keep it alive) but alpha's window was discarded.
|
|
153
|
-
let transport = SessionProbeRecordingTransport {
|
|
154
|
-
spawns: std::sync::Arc::clone(&spawns),
|
|
155
|
-
session_exists: true,
|
|
156
|
-
};
|
|
168
|
+
let transport = SessionProbeRecordingTransport { spawns: std::sync::Arc::clone(&spawns), session_exists: true, killed_panes: std::sync::Mutex::new(std::collections::BTreeSet::new()) };
|
|
157
169
|
let _ = reset_agent_with_transport(&ws, &AgentId::new("alpha"), true, false, None, &transport);
|
|
158
170
|
let recorded = spawns.lock().unwrap().clone();
|
|
159
171
|
assert!(
|
|
@@ -191,10 +203,7 @@ fn reset_agent_discard_session_syncs_projection_epoch_inputs_for_restart_agent_c
|
|
|
191
203
|
|
|
192
204
|
let before = chrono::Utc::now();
|
|
193
205
|
let spawns = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
194
|
-
let transport = SessionProbeRecordingTransport {
|
|
195
|
-
spawns: std::sync::Arc::clone(&spawns),
|
|
196
|
-
session_exists: true,
|
|
197
|
-
};
|
|
206
|
+
let transport = SessionProbeRecordingTransport { spawns: std::sync::Arc::clone(&spawns), session_exists: true, killed_panes: std::sync::Mutex::new(std::collections::BTreeSet::new()) };
|
|
198
207
|
let outcome = reset_agent_with_transport(&ws, &AgentId::new("alpha"), true, false, None, &transport)
|
|
199
208
|
.expect("reset-agent alpha --discard-session --no-display");
|
|
200
209
|
assert!(
|
|
@@ -7,45 +7,35 @@ use crate::model::permissions::{resolve_permissions, AgentPermissionInput};
|
|
|
7
7
|
|
|
8
8
|
const RUNTIME_CONTRACT_SECTION: &str = r#"# Team Agent Teammate Runtime Contract
|
|
9
9
|
|
|
10
|
-
You are a teammate in a Team Agent runtime
|
|
11
|
-
|
|
12
|
-
session is local to this session and is not a team message.
|
|
10
|
+
You are a teammate in a Team Agent runtime. The leader cannot see your terminal
|
|
11
|
+
output. All communication must go through Team Agent MCP tools.
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
- Send progress, blockers, permission needs, tool failures, scope changes, and
|
|
16
|
-
long-running status updates with team_orchestrator.send_message(to='leader',
|
|
17
|
-
content='<short message>').
|
|
18
|
-
- Send to another teammate by agent id when coordination is useful, or use
|
|
19
|
-
to='*' to notify every other team member. The runtime resolves only this team
|
|
20
|
-
and excludes your own worker.
|
|
21
|
-
- When the task is complete, call team_orchestrator.report_result exactly once.
|
|
22
|
-
- Do not pass sender, task_id, agent_id, schema_version, or ack fields unless
|
|
23
|
-
doing a low-level compatibility diagnostic. The MCP runtime fills protocol
|
|
24
|
-
fields from the current worker and task state.
|
|
25
|
-
- Emergency fallback exception: if a leader-bound MCP send/report payload is
|
|
26
|
-
already built but the MCP transport itself fails or the primary delivery path
|
|
27
|
-
errors (for example `Transport closed`, `Connection refused`, `Broken pipe`,
|
|
28
|
-
`EOF`, timeout, or internal delivery error), use `team-agent fallback-send-leader`
|
|
29
|
-
or `team-agent fallback-report-result` exactly once with `--primary-error`.
|
|
30
|
-
Do not use fallback for business refusals such as permission, quota, or unknown
|
|
31
|
-
target. After fallback, tell the leader to run `team-agent restart-agent` to
|
|
32
|
-
refresh the worker MCP transport.
|
|
13
|
+
## Communication (mandatory)
|
|
33
14
|
|
|
34
|
-
|
|
35
|
-
|
|
15
|
+
- Progress, blockers, questions: team_orchestrator.send_message(to='leader', content='...')
|
|
16
|
+
- Coordinate with teammate: team_orchestrator.send_message(to='<agent_id>', content='...')
|
|
17
|
+
- Broadcast to all teammates: team_orchestrator.send_message(to='*', content='...')
|
|
18
|
+
- Task complete: team_orchestrator.report_result(summary='...') — call exactly once
|
|
36
19
|
|
|
37
|
-
When
|
|
38
|
-
|
|
39
|
-
|
|
20
|
+
When you receive a message from the leader or a teammate, you MUST respond
|
|
21
|
+
through MCP tools. Writing a reply in your terminal does nothing — the sender
|
|
22
|
+
will never see it.
|
|
40
23
|
|
|
24
|
+
## Rules
|
|
25
|
+
|
|
26
|
+
- Do not pass sender, task_id, or schema_version — the MCP runtime fills them.
|
|
27
|
+
- If blocked or waiting, send_message to the leader. Do not wait silently.
|
|
28
|
+
- On 500/529/rate-limit errors, wait 1-2 minutes before retrying."#;
|
|
29
|
+
|
|
30
|
+
// 0.4.11 trimmed: the runtime contract section above already covers
|
|
31
|
+
// send_message signatures and report_result exactly-once. The output
|
|
32
|
+
// contract now only carries the RESULT-ENVELOPE-SPECIFIC delivery
|
|
33
|
+
// semantics (leader-attach dependence + fallback status) that the
|
|
34
|
+
// generic runtime section deliberately leaves out.
|
|
41
35
|
const RESULT_ENVELOPE_OUTPUT_CONTRACT: &str =
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
the runtime injects it into the attached Codex leader pane when the leader has run attach-leader. \
|
|
46
|
-
If no leader is attached, the tool returns a fallback/failed result instead of completion. \
|
|
47
|
-
Final completion must call team_orchestrator.report_result exactly once with a short summary \
|
|
48
|
-
and optional status/changes/tests; MCP fills schema_version, task_id, and agent_id.";
|
|
36
|
+
"Final completion must call team_orchestrator.report_result exactly once with a short summary \
|
|
37
|
+
and optional status/changes/tests; the MCP runtime injects the result into the attached leader pane. \
|
|
38
|
+
If no leader is attached, the tool returns a fallback/failed result instead of completion.";
|
|
49
39
|
|
|
50
40
|
pub(crate) struct WorkerCommandAgent {
|
|
51
41
|
id: Option<String>,
|
|
@@ -102,7 +102,8 @@ pub use types::{
|
|
|
102
102
|
CheckKind, CheckStatus, ContractSuiteCheck, DeliveryOutcome, DeliveryRefusal, DeliveryStage,
|
|
103
103
|
DeliveryStatus, IdleEvaluation, LeaderNotificationKey, LeaderReceiver, PaneWidthQuery,
|
|
104
104
|
ProviderSdkCalls, ReceiverMode, ScheduledKind, SelftestCheck, SelftestReport, SendEventPayload,
|
|
105
|
-
TrustRetryPayload, WatcherNotice,
|
|
105
|
+
TrustRetryPayload, WatcherNotice, WorkerRuntimeState,
|
|
106
|
+
RESULT_DELIVERY_MAX_ATTEMPTS, SEND_RETRY_MAX_ATTEMPTS,
|
|
106
107
|
TRUST_RETRY_BACKOFF_SECONDS, TRUST_RETRY_MAX_ATTEMPTS,
|
|
107
108
|
};
|
|
108
109
|
pub use watchers::{
|
|
@@ -126,6 +126,92 @@ impl ActivityStatus {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/// 0.4.x Phase 1 worker runtime-state (fg-pgrp + 5-state plan, CR APPROVED).
|
|
130
|
+
///
|
|
131
|
+
/// Canonical runtime-state enum surfaced by `team-agent status` and consumed
|
|
132
|
+
/// by automatic-dispatch / idle-gate predicates. Distinct from
|
|
133
|
+
/// [`ActivityStatus`] (which remains the internal classifier output for
|
|
134
|
+
/// backwards compatibility) and from lifecycle `agents.<id>.status` (which
|
|
135
|
+
/// remains launch/stop-owned: `running`/`paused`/`stopped`/etc.).
|
|
136
|
+
///
|
|
137
|
+
/// Rules:
|
|
138
|
+
/// * `Busy` when `foreground_pgrp != agent_root_pgrp` (a child process
|
|
139
|
+
/// occupies the terminal — provider is not at idle prompt).
|
|
140
|
+
/// * `Unknown` is NEVER idle-eligible and must be treated as busy by
|
|
141
|
+
/// automatic-dispatch / idle-gate predicates.
|
|
142
|
+
/// * `ProbablyIdle` is a weak conclusion, not proof of idleness.
|
|
143
|
+
/// * `Dead` when the pane is missing or the root process is gone.
|
|
144
|
+
/// * `Blocked` when the worker is awaiting human confirmation (trust
|
|
145
|
+
/// prompt, startup approval).
|
|
146
|
+
///
|
|
147
|
+
/// CR R4: deserialise with `#[serde(other)]` so a future variant added by
|
|
148
|
+
/// a newer build does not crash an older reader (rolling upgrade safe).
|
|
149
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
150
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
151
|
+
pub enum WorkerRuntimeState {
|
|
152
|
+
Dead,
|
|
153
|
+
Busy,
|
|
154
|
+
Blocked,
|
|
155
|
+
ProbablyIdle,
|
|
156
|
+
/// Default fallback. CR R4: also catches unknown wire strings via
|
|
157
|
+
/// `#[serde(other)]`.
|
|
158
|
+
#[serde(other)]
|
|
159
|
+
Unknown,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
impl WorkerRuntimeState {
|
|
163
|
+
/// Wire string used by status/diagnose JSON.
|
|
164
|
+
pub fn as_wire(self) -> &'static str {
|
|
165
|
+
match self {
|
|
166
|
+
Self::Dead => "DEAD",
|
|
167
|
+
Self::Busy => "BUSY",
|
|
168
|
+
Self::Blocked => "BLOCKED",
|
|
169
|
+
Self::ProbablyIdle => "PROBABLY_IDLE",
|
|
170
|
+
Self::Unknown => "UNKNOWN",
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Parse from wire string. Returns `Self::Unknown` for unknown literals
|
|
175
|
+
/// (defence-in-depth alongside `#[serde(other)]`).
|
|
176
|
+
pub fn parse_wire(value: &str) -> Self {
|
|
177
|
+
match value.trim() {
|
|
178
|
+
"DEAD" => Self::Dead,
|
|
179
|
+
"BUSY" => Self::Busy,
|
|
180
|
+
"BLOCKED" => Self::Blocked,
|
|
181
|
+
"PROBABLY_IDLE" => Self::ProbablyIdle,
|
|
182
|
+
_ => Self::Unknown,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// True for states that MUST NOT receive automatic dispatch:
|
|
187
|
+
/// `Dead | Busy | Blocked | Unknown`. Only `ProbablyIdle` is safe.
|
|
188
|
+
/// CR R3 + Phase 1 §8 dispatch boundary.
|
|
189
|
+
pub fn blocks_automatic_dispatch(self) -> bool {
|
|
190
|
+
!matches!(self, Self::ProbablyIdle)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// True only for `ProbablyIdle` — the sole idle candidate. `Unknown` is
|
|
194
|
+
/// NEVER idle (Iron Law: bug-071/077/085).
|
|
195
|
+
pub fn is_idle_candidate(self) -> bool {
|
|
196
|
+
matches!(self, Self::ProbablyIdle)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/// Map from the legacy [`ActivityStatus`] for backward-compat. Used by
|
|
200
|
+
/// status/diagnose code paths that have an old AgentActivity in hand
|
|
201
|
+
/// and need a runtime-state surface for the product enum.
|
|
202
|
+
/// Working → Busy
|
|
203
|
+
/// Idle → ProbablyIdle
|
|
204
|
+
/// Stuck → Unknown (CR rule: stale signals are never idle)
|
|
205
|
+
/// Uncertain → Unknown
|
|
206
|
+
pub fn from_activity(activity: ActivityStatus) -> Self {
|
|
207
|
+
match activity {
|
|
208
|
+
ActivityStatus::Working => Self::Busy,
|
|
209
|
+
ActivityStatus::Idle => Self::ProbablyIdle,
|
|
210
|
+
ActivityStatus::Stuck | ActivityStatus::Uncertain => Self::Unknown,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
129
215
|
/// 告警类型 (card §49;`scheduler.py:38` `_ALERT_TYPES`)。`stuck_cancel` 还接 `all` (展开全集)。
|
|
130
216
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
131
217
|
#[serde(rename_all = "snake_case")]
|
|
@@ -23,6 +23,71 @@ pub enum Provider {
|
|
|
23
23
|
Fake,
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/// 0.4.x Provider effort MVP: reasoning effort level passed to the provider.
|
|
27
|
+
/// Configuration sources (resolution order):
|
|
28
|
+
/// 1. role doc front matter `effort: low|medium|high|xhigh|max`
|
|
29
|
+
/// 2. TEAM.md front matter `provider_effort: low|medium|high|xhigh|max`
|
|
30
|
+
/// 3. provider default (framework passes no flag)
|
|
31
|
+
///
|
|
32
|
+
/// Provider support:
|
|
33
|
+
/// - claude / claude_code: low|medium|high|xhigh|max → `--effort <level>`
|
|
34
|
+
/// - codex: low|medium|high|xhigh (NOT max) → `-c model_reasoning_effort=<level>`
|
|
35
|
+
/// - copilot / gemini_cli / fake: unsupported — warning event, no flag
|
|
36
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
37
|
+
pub enum ProviderEffort {
|
|
38
|
+
#[serde(rename = "low")]
|
|
39
|
+
Low,
|
|
40
|
+
#[serde(rename = "medium")]
|
|
41
|
+
Medium,
|
|
42
|
+
#[serde(rename = "high")]
|
|
43
|
+
High,
|
|
44
|
+
#[serde(rename = "xhigh")]
|
|
45
|
+
XHigh,
|
|
46
|
+
#[serde(rename = "max")]
|
|
47
|
+
Max,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl ProviderEffort {
|
|
51
|
+
/// Parse from a wire string. Returns None on unknown literal.
|
|
52
|
+
pub fn parse(value: &str) -> Option<Self> {
|
|
53
|
+
match value.trim() {
|
|
54
|
+
"low" => Some(Self::Low),
|
|
55
|
+
"medium" => Some(Self::Medium),
|
|
56
|
+
"high" => Some(Self::High),
|
|
57
|
+
"xhigh" => Some(Self::XHigh),
|
|
58
|
+
"max" => Some(Self::Max),
|
|
59
|
+
_ => None,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Wire string used by both CLI argv and state serialization.
|
|
64
|
+
pub fn as_str(self) -> &'static str {
|
|
65
|
+
match self {
|
|
66
|
+
Self::Low => "low",
|
|
67
|
+
Self::Medium => "medium",
|
|
68
|
+
Self::High => "high",
|
|
69
|
+
Self::XHigh => "xhigh",
|
|
70
|
+
Self::Max => "max",
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Effort levels Claude-only (Codex / others must reject `max`).
|
|
75
|
+
pub fn is_claude_only(self) -> bool {
|
|
76
|
+
matches!(self, Self::Max)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// True when the given provider supports this effort level. `max` is
|
|
80
|
+
/// Claude-only; other levels are supported by Claude and Codex, ignored
|
|
81
|
+
/// by Copilot/Gemini/Fake (warning emitted at runtime).
|
|
82
|
+
pub fn is_supported_by(self, provider: Provider) -> bool {
|
|
83
|
+
match provider {
|
|
84
|
+
Provider::Claude | Provider::ClaudeCode => true,
|
|
85
|
+
Provider::Codex => !self.is_claude_only(),
|
|
86
|
+
Provider::Copilot | Provider::GeminiCli | Provider::Fake => false,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
26
91
|
/// auth 模式(`AUTH_MODES` `profiles/constants.py:6`)。
|
|
27
92
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
28
93
|
#[serde(rename_all = "snake_case")]
|
|
@@ -250,8 +250,19 @@ fn basic_schema_errors(spec: &Yaml) -> Vec<String> {
|
|
|
250
250
|
if !matches!(spec.get("version"), Some(Yaml::Int(1))) {
|
|
251
251
|
e.push("/version: must equal 1".to_string());
|
|
252
252
|
}
|
|
253
|
-
|
|
254
|
-
|
|
253
|
+
// 0.4.x provider effort MVP step 3: provider_effort is allowed but not required.
|
|
254
|
+
let team_required = &["name", "mode", "objective", "workspace"];
|
|
255
|
+
let team_allowed = &["name", "mode", "objective", "workspace", "provider_effort"];
|
|
256
|
+
check_keys_y(spec.get("team"), "/team", team_required, team_allowed, &mut e);
|
|
257
|
+
if let Some(team) = spec.get("team") {
|
|
258
|
+
if let Some(raw) = team.get("provider_effort").and_then(Yaml::as_str) {
|
|
259
|
+
if crate::model::enums::ProviderEffort::parse(raw).is_none() {
|
|
260
|
+
e.push(format!(
|
|
261
|
+
"/team/provider_effort: unknown effort '{raw}' (allowed: low|medium|high|xhigh|max)"
|
|
262
|
+
));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
255
266
|
let mode = spec.get("team").and_then(|t| t.get("mode")).and_then(Yaml::as_str);
|
|
256
267
|
if !matches!(mode, Some("supervisor_worker" | "swarm_limited")) {
|
|
257
268
|
e.push("/team/mode: invalid mode".to_string());
|
|
@@ -298,11 +309,32 @@ fn check_agent(agent: &Yaml, path: &str, errors: &mut Vec<String>) {
|
|
|
298
309
|
"id", "role", "provider", "model", "working_directory", "system_prompt", "tools",
|
|
299
310
|
"permission_mode", "preferred_for", "avoid_for", "output_contract", "paused", "auth_mode",
|
|
300
311
|
"profile", "credential_ref", "forked_from",
|
|
312
|
+
// 0.4.x provider effort MVP step 3: per-agent effort override (resolved at compile).
|
|
313
|
+
"effort",
|
|
301
314
|
];
|
|
302
315
|
check_keys_y(Some(agent), path, req, allowed, errors);
|
|
303
316
|
if !agent.is_map() {
|
|
304
317
|
return;
|
|
305
318
|
}
|
|
319
|
+
// 0.4.x provider effort MVP step 3: effort syntactic + semantic validation.
|
|
320
|
+
if let Some(raw) = agent.get("effort").and_then(Yaml::as_str) {
|
|
321
|
+
match crate::model::enums::ProviderEffort::parse(raw) {
|
|
322
|
+
None => {
|
|
323
|
+
errors.push(format!(
|
|
324
|
+
"{path}/effort: unknown effort '{raw}' (allowed: low|medium|high|xhigh|max)"
|
|
325
|
+
));
|
|
326
|
+
}
|
|
327
|
+
Some(effort) if effort.is_claude_only() => {
|
|
328
|
+
let provider = agent.get("provider").and_then(Yaml::as_str).unwrap_or("");
|
|
329
|
+
if !matches!(provider, "claude" | "claude_code") {
|
|
330
|
+
errors.push(format!(
|
|
331
|
+
"{path}/effort: effort '{raw}' is only supported by claude/claude_code (provider: {provider})"
|
|
332
|
+
));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
Some(_) => {}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
306
338
|
check_keys_y(agent.get("system_prompt"), &format!("{path}/system_prompt"), &["inline", "file"], &["inline", "file"], errors);
|
|
307
339
|
check_list_y(agent.get("tools"), &format!("{path}/tools"), errors);
|
|
308
340
|
check_list_y(agent.get("preferred_for"), &format!("{path}/preferred_for"), errors);
|