@team-agent/installer 0.3.39 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +7 -0
- package/crates/team-agent/src/cli/adapters.rs +8 -3
- package/crates/team-agent/src/cli/diagnose.rs +12 -3
- package/crates/team-agent/src/cli/emit.rs +1 -0
- package/crates/team-agent/src/cli/mod.rs +250 -9
- package/crates/team-agent/src/cli/send.rs +11 -2
- package/crates/team-agent/src/cli/status_port.rs +39 -4
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
- package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
- package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +5 -0
- package/crates/team-agent/src/coordinator/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
- package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
- package/crates/team-agent/src/coordinator/tick.rs +126 -30
- package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
- package/crates/team-agent/src/db/mod.rs +1 -0
- package/crates/team-agent/src/layout/mod.rs +7 -0
- package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
- package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
- package/crates/team-agent/src/leader/start.rs +27 -1
- package/crates/team-agent/src/leader/tests/identity.rs +50 -0
- package/crates/team-agent/src/lib.rs +6 -2
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
- package/crates/team-agent/src/lifecycle/launch.rs +34 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
- package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
- package/crates/team-agent/src/lifecycle/restart.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
- package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +20 -1
- package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
- package/crates/team-agent/src/mcp_server/tools.rs +29 -4
- package/crates/team-agent/src/provider/adapter.rs +31 -1
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
- package/crates/team-agent/src/provider/classify.rs +138 -0
- package/crates/team-agent/src/provider/command.rs +71 -0
- package/crates/team-agent/src/provider/mod.rs +3 -0
- package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
- package/crates/team-agent/src/provider/session/mod.rs +13 -0
- package/crates/team-agent/src/provider/session/resume.rs +417 -0
- package/crates/team-agent/src/provider/session_scan.rs +63 -0
- package/crates/team-agent/src/provider/types.rs +5 -0
- package/crates/team-agent/src/state/persist.rs +238 -0
- package/crates/team-agent/src/tmux_backend.rs +25 -0
- package/npm/install.mjs +27 -1
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -29,5 +29,12 @@ rusqlite.workspace = true # step 3 db(bundled SQLite,静态链接)
|
|
|
29
29
|
[dev-dependencies]
|
|
30
30
|
serial_test = { version = "3", features = ["file_locks"] }
|
|
31
31
|
|
|
32
|
+
# `tests/e2e/` 黑盒 E2E 框架专用 — 不需要 assert_cmd / predicates,
|
|
33
|
+
# 框架内部直接用 std::process::Command + serde_json + 自写 assert_* helpers,
|
|
34
|
+
# 保持 zero new external test-CLI deps(避开 crates.io 代理限制)。
|
|
35
|
+
[[test]]
|
|
36
|
+
name = "e2e"
|
|
37
|
+
path = "tests/e2e/main.rs"
|
|
38
|
+
|
|
32
39
|
[lints]
|
|
33
40
|
workspace = true
|
|
@@ -443,7 +443,7 @@ pub fn cmd_collect_for_team(args: &CollectArgs, team: Option<&str>) -> Result<Cm
|
|
|
443
443
|
|
|
444
444
|
/// `cmd_settle`(`commands.py:86`)。
|
|
445
445
|
pub fn cmd_settle(args: &SettleArgs) -> Result<CmdResult, CliError> {
|
|
446
|
-
match settle_value(&args.workspace) {
|
|
446
|
+
match settle_value(&args.workspace, args.team.as_deref()) {
|
|
447
447
|
Ok(value) => Ok(CmdResult::from_json(value, args.json)),
|
|
448
448
|
Err(error) => Ok(CmdResult::from_json(
|
|
449
449
|
json!({
|
|
@@ -456,10 +456,15 @@ pub fn cmd_settle(args: &SettleArgs) -> Result<CmdResult, CliError> {
|
|
|
456
456
|
}
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
-
fn settle_value(workspace: &Path) -> Result<Value, CliError> {
|
|
459
|
+
fn settle_value(workspace: &Path, team: Option<&str>) -> Result<Value, CliError> {
|
|
460
|
+
// Bug #6 (prerelease 0.4.0 gate review §6): pass explicit team through to
|
|
461
|
+
// resolve_active_team so settle scopes collect/status/team-state to the
|
|
462
|
+
// requested team. Without this, the selector falls back to top-level
|
|
463
|
+
// active_team_key and settle operates on the wrong team. resolve_active_team
|
|
464
|
+
// logic unchanged per 不可改项.
|
|
460
465
|
let selected = crate::state::selector::resolve_active_team(
|
|
461
466
|
workspace,
|
|
462
|
-
|
|
467
|
+
team,
|
|
463
468
|
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
464
469
|
)
|
|
465
470
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
@@ -418,12 +418,21 @@ pub(crate) fn build_wait_ready_report(workspace: &std::path::Path, timeout: f64)
|
|
|
418
418
|
}
|
|
419
419
|
|
|
420
420
|
fn inject_tmux_session_present(workspace: &std::path::Path, state: &mut Value) {
|
|
421
|
+
// Bug #7 (prerelease 0.4.0 gate review §6): probe the SAME endpoint the
|
|
422
|
+
// runtime actually uses (state.tmux_endpoint / tmux_socket), not the
|
|
423
|
+
// workspace-hash socket. Otherwise readiness reports `process_started=false`
|
|
424
|
+
// even though the session is alive on the persisted socket.
|
|
421
425
|
let Some(session_name) = state.get("session_name").and_then(Value::as_str).filter(|s| !s.is_empty()) else {
|
|
422
426
|
return;
|
|
423
427
|
};
|
|
424
|
-
let
|
|
425
|
-
let
|
|
426
|
-
|
|
428
|
+
let session_name_owned = session_name.to_string();
|
|
429
|
+
let selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
430
|
+
workspace,
|
|
431
|
+
Some(state),
|
|
432
|
+
);
|
|
433
|
+
let present = selection
|
|
434
|
+
.backend
|
|
435
|
+
.has_session(&crate::transport::SessionName::new(session_name_owned))
|
|
427
436
|
.unwrap_or(false);
|
|
428
437
|
if let Value::Object(map) = state {
|
|
429
438
|
map.insert("tmux_session_present".to_string(), Value::Bool(present));
|
|
@@ -859,6 +859,18 @@ pub mod lifecycle_port {
|
|
|
859
859
|
.as_ref()
|
|
860
860
|
.and_then(|stopped| stopped.pid.map(|p| p.get()));
|
|
861
861
|
let coordinator_pid = coordinator_pid.or(coordinator_pid_for_report);
|
|
862
|
+
// unit-2 (Stage 1) false-green guard fact:
|
|
863
|
+
// target_session_spared = "we had a target worker session name AND
|
|
864
|
+
// that exact name appears in spared_sessions"
|
|
865
|
+
// Mirrors the "we asked to kill X but X is still alive" check the
|
|
866
|
+
// 0.3.39 false-green bug missed. session_name is the configured
|
|
867
|
+
// target; spared_sessions is the cleanup's "kept alive" list.
|
|
868
|
+
let target_session_spared = match session_name.as_ref() {
|
|
869
|
+
Some(target) => spared_sessions
|
|
870
|
+
.iter()
|
|
871
|
+
.any(|s| s.as_str() == target.as_str()),
|
|
872
|
+
None => false,
|
|
873
|
+
};
|
|
862
874
|
let outcome = classify_shutdown_outcome(ShutdownOutcomeInput {
|
|
863
875
|
kill_error: kill_error.is_some(),
|
|
864
876
|
session_residuals: !session_residuals.is_empty(),
|
|
@@ -868,6 +880,7 @@ pub mod lifecycle_port {
|
|
|
868
880
|
coordinator_timeout,
|
|
869
881
|
coordinator_stop_ok: stopped.as_ref().map(|stopped| stopped.ok),
|
|
870
882
|
coordinator_post_stop,
|
|
883
|
+
target_session_spared,
|
|
871
884
|
});
|
|
872
885
|
let ok = outcome.ok;
|
|
873
886
|
let status = outcome.status;
|
|
@@ -956,6 +969,16 @@ pub mod lifecycle_port {
|
|
|
956
969
|
pub(crate) coordinator_timeout: bool,
|
|
957
970
|
pub(crate) coordinator_stop_ok: Option<bool>,
|
|
958
971
|
pub(crate) coordinator_post_stop: CoordinatorStopObservation,
|
|
972
|
+
/// unit-2 (Stage 1) false-green guard:
|
|
973
|
+
/// `true` when the workspace had a target worker session name AND
|
|
974
|
+
/// the cleanup did NOT confirm kill of that name (i.e. the target
|
|
975
|
+
/// was spared, leaving the worker alive while ok/status:"ok" would
|
|
976
|
+
/// otherwise be reported — the 0.3.39 shutdown false-green shape).
|
|
977
|
+
///
|
|
978
|
+
/// Default `false` for callers that have no target (legacy bare
|
|
979
|
+
/// shutdown on a not-yet-bound workspace) so the existing
|
|
980
|
+
/// well-defined OK branch keeps working.
|
|
981
|
+
pub(crate) target_session_spared: bool,
|
|
959
982
|
}
|
|
960
983
|
|
|
961
984
|
pub(crate) fn classify_shutdown_outcome(input: ShutdownOutcomeInput) -> ShutdownOutcome {
|
|
@@ -971,7 +994,12 @@ pub mod lifecycle_port {
|
|
|
971
994
|
&& !input.session_residuals
|
|
972
995
|
&& !input.process_residuals
|
|
973
996
|
&& !input.owned_file_residuals
|
|
974
|
-
&& !input.cleanup_truth_degraded
|
|
997
|
+
&& !input.cleanup_truth_degraded
|
|
998
|
+
// unit-2 false-green guard: refuse OK when the configured worker
|
|
999
|
+
// session was spared (still alive). The user asked for shutdown,
|
|
1000
|
+
// not "shutdown but maybe keep the session" — make the disagreement
|
|
1001
|
+
// explicit instead of paint-it-green.
|
|
1002
|
+
&& !input.target_session_spared;
|
|
975
1003
|
if ok {
|
|
976
1004
|
return ShutdownOutcome {
|
|
977
1005
|
ok,
|
|
@@ -989,6 +1017,12 @@ pub mod lifecycle_port {
|
|
|
989
1017
|
|| input.owned_file_residuals
|
|
990
1018
|
{
|
|
991
1019
|
("failed", None)
|
|
1020
|
+
} else if input.target_session_spared {
|
|
1021
|
+
// The worker session that was supposed to be killed survived
|
|
1022
|
+
// because the cleanup decision spared it (dirty topology — e.g.
|
|
1023
|
+
// 0.3.39 leader_receiver.session_name == worker session). Surface
|
|
1024
|
+
// a status that names the cause instead of fall-through "partial".
|
|
1025
|
+
("dirty_state", Some("target_session_spared"))
|
|
992
1026
|
} else {
|
|
993
1027
|
("partial", None)
|
|
994
1028
|
};
|
|
@@ -2713,14 +2747,209 @@ pub mod lifecycle_port {
|
|
|
2713
2747
|
unresumable,
|
|
2714
2748
|
allow_fresh,
|
|
2715
2749
|
error,
|
|
2716
|
-
} =>
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2750
|
+
} => {
|
|
2751
|
+
// Unit 5 + Layer 2 wire-through (leader directive 2026-06-22):
|
|
2752
|
+
// `unresumable` JSON shape is now `[{agent_id, reason,
|
|
2753
|
+
// session_id?, checked_paths?, recovery_hint?}, ...]` so the
|
|
2754
|
+
// structured refusal class is visible to CLI consumers. The
|
|
2755
|
+
// legacy string-array shape (single agent_id per entry) is
|
|
2756
|
+
// available under `unresumable_ids` for tooling that still
|
|
2757
|
+
// wants the cheap list.
|
|
2758
|
+
let unresumable_detail: Vec<Value> = unresumable
|
|
2759
|
+
.iter()
|
|
2760
|
+
.map(|w| {
|
|
2761
|
+
let mut entry = serde_json::Map::new();
|
|
2762
|
+
entry.insert(
|
|
2763
|
+
"agent_id".to_string(),
|
|
2764
|
+
json!(w.agent_id.as_str()),
|
|
2765
|
+
);
|
|
2766
|
+
// Prefer the structured wire string when the
|
|
2767
|
+
// ResumeRefusalReason enum is populated; fall back
|
|
2768
|
+
// to the legacy free-form `reason` otherwise.
|
|
2769
|
+
let reason_wire = w
|
|
2770
|
+
.refusal_reason
|
|
2771
|
+
.as_ref()
|
|
2772
|
+
.map(|r| r.wire().to_string())
|
|
2773
|
+
.unwrap_or_else(|| w.reason.clone());
|
|
2774
|
+
entry.insert("reason".to_string(), json!(reason_wire));
|
|
2775
|
+
if let Some(sid) = &w.session_id {
|
|
2776
|
+
entry.insert(
|
|
2777
|
+
"session_id".to_string(),
|
|
2778
|
+
json!(sid.as_str()),
|
|
2779
|
+
);
|
|
2780
|
+
}
|
|
2781
|
+
if let Some(reason) = &w.refusal_reason {
|
|
2782
|
+
if let crate::provider::session::ResumeRefusalReason::SessionBackingStoreMissing {
|
|
2783
|
+
checked_paths,
|
|
2784
|
+
recovery_hint,
|
|
2785
|
+
} = reason
|
|
2786
|
+
{
|
|
2787
|
+
if !checked_paths.is_empty() {
|
|
2788
|
+
entry.insert(
|
|
2789
|
+
"checked_paths".to_string(),
|
|
2790
|
+
json!(checked_paths
|
|
2791
|
+
.iter()
|
|
2792
|
+
.map(|p| p.to_string_lossy().into_owned())
|
|
2793
|
+
.collect::<Vec<_>>()),
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
if let Some(hint) = recovery_hint {
|
|
2797
|
+
let mut h = serde_json::Map::new();
|
|
2798
|
+
h.insert(
|
|
2799
|
+
"provider".to_string(),
|
|
2800
|
+
json!(hint.provider),
|
|
2801
|
+
);
|
|
2802
|
+
if let Some(name) = &hint.provider_session_name_hint {
|
|
2803
|
+
h.insert("name".to_string(), json!(name));
|
|
2804
|
+
}
|
|
2805
|
+
if let Some(cwd) = &hint.spawn_cwd {
|
|
2806
|
+
h.insert(
|
|
2807
|
+
"spawn_cwd".to_string(),
|
|
2808
|
+
json!(cwd.to_string_lossy()),
|
|
2809
|
+
);
|
|
2810
|
+
}
|
|
2811
|
+
h.insert(
|
|
2812
|
+
"picker_hint".to_string(),
|
|
2813
|
+
json!(hint.picker_hint()),
|
|
2814
|
+
);
|
|
2815
|
+
entry.insert(
|
|
2816
|
+
"recovery_hint".to_string(),
|
|
2817
|
+
Value::Object(h),
|
|
2818
|
+
);
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
Value::Object(entry)
|
|
2823
|
+
})
|
|
2824
|
+
.collect();
|
|
2825
|
+
let unresumable_ids: Vec<&str> = unresumable
|
|
2826
|
+
.iter()
|
|
2827
|
+
.map(|w| w.agent_id.as_str())
|
|
2828
|
+
.collect();
|
|
2829
|
+
// Layer 2 self-healing (leader follow-up 2026-06-22): when
|
|
2830
|
+
// EVERY unresumable worker shares the same structured
|
|
2831
|
+
// refusal_reason wire string, refine the top-level `status`
|
|
2832
|
+
// from the coarse `refused_resume_atomicity` to a class-
|
|
2833
|
+
// specific label and overlay an `error` that names the
|
|
2834
|
+
// specific cause + concrete recovery moves. The original
|
|
2835
|
+
// `refused_resume_atomicity` status is kept as
|
|
2836
|
+
// `status_class` so anything matching on the coarse label
|
|
2837
|
+
// still works.
|
|
2838
|
+
let distinct_wires: std::collections::BTreeSet<&'static str> = unresumable
|
|
2839
|
+
.iter()
|
|
2840
|
+
.filter_map(|w| w.refusal_reason.as_ref().map(|r| r.wire()))
|
|
2841
|
+
.collect();
|
|
2842
|
+
let (status_str, error_str): (String, String) = if distinct_wires.len() == 1
|
|
2843
|
+
&& distinct_wires.len() == unresumable.len()
|
|
2844
|
+
{
|
|
2845
|
+
// Every entry had a structured reason and they all
|
|
2846
|
+
// agreed. Refine.
|
|
2847
|
+
let wire = *distinct_wires.iter().next().unwrap_or(&"");
|
|
2848
|
+
match wire {
|
|
2849
|
+
"session_backing_store_missing" => {
|
|
2850
|
+
// Collect a flat list of every checked path so
|
|
2851
|
+
// operators can copy/paste-grep for files that
|
|
2852
|
+
// moved. picker_hint per worker gives the
|
|
2853
|
+
// alternate-recovery one-liner.
|
|
2854
|
+
let mut probed: Vec<String> = Vec::new();
|
|
2855
|
+
let mut picker_lines: Vec<String> = Vec::new();
|
|
2856
|
+
for w in unresumable.iter() {
|
|
2857
|
+
if let Some(
|
|
2858
|
+
crate::provider::session::ResumeRefusalReason::SessionBackingStoreMissing {
|
|
2859
|
+
checked_paths,
|
|
2860
|
+
recovery_hint,
|
|
2861
|
+
},
|
|
2862
|
+
) = w.refusal_reason.as_ref()
|
|
2863
|
+
{
|
|
2864
|
+
for p in checked_paths {
|
|
2865
|
+
probed.push(p.to_string_lossy().into_owned());
|
|
2866
|
+
}
|
|
2867
|
+
if let Some(h) = recovery_hint {
|
|
2868
|
+
picker_lines.push(format!(
|
|
2869
|
+
" {}: try `{}`",
|
|
2870
|
+
w.agent_id.as_str(),
|
|
2871
|
+
h.picker_hint(),
|
|
2872
|
+
));
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
probed.sort();
|
|
2877
|
+
probed.dedup();
|
|
2878
|
+
let mut msg = format!(
|
|
2879
|
+
"restart refused: provider session backing store missing for {} worker(s) ({}). \
|
|
2880
|
+
Pass --allow-fresh to start a new session, or restore the backing files listed below.",
|
|
2881
|
+
unresumable.len(),
|
|
2882
|
+
unresumable_ids.join(", "),
|
|
2883
|
+
);
|
|
2884
|
+
if !probed.is_empty() {
|
|
2885
|
+
msg.push_str("\nProbed paths (none contained the expected session):");
|
|
2886
|
+
for p in &probed {
|
|
2887
|
+
msg.push_str("\n ");
|
|
2888
|
+
msg.push_str(p);
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
if !picker_lines.is_empty() {
|
|
2892
|
+
msg.push_str("\nProvider picker recovery hints:");
|
|
2893
|
+
for line in &picker_lines {
|
|
2894
|
+
msg.push_str("\n");
|
|
2895
|
+
msg.push_str(line);
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
(
|
|
2899
|
+
"refused_session_backing_missing".to_string(),
|
|
2900
|
+
msg,
|
|
2901
|
+
)
|
|
2902
|
+
}
|
|
2903
|
+
"no_persisted_session_id" => (
|
|
2904
|
+
"refused_no_session_id".to_string(),
|
|
2905
|
+
format!(
|
|
2906
|
+
"restart refused: no persisted session_id for {} worker(s) ({}). Pass --allow-fresh to start fresh.",
|
|
2907
|
+
unresumable.len(),
|
|
2908
|
+
unresumable_ids.join(", "),
|
|
2909
|
+
),
|
|
2910
|
+
),
|
|
2911
|
+
"provider_resume_unsupported" => (
|
|
2912
|
+
"refused_provider_resume_unsupported".to_string(),
|
|
2913
|
+
format!(
|
|
2914
|
+
"restart refused: provider does not support resume for {} worker(s) ({}). Pass --allow-fresh to start fresh.",
|
|
2915
|
+
unresumable.len(),
|
|
2916
|
+
unresumable_ids.join(", "),
|
|
2917
|
+
),
|
|
2918
|
+
),
|
|
2919
|
+
_ => ("refused_resume_atomicity".to_string(), error.clone()),
|
|
2920
|
+
}
|
|
2921
|
+
} else {
|
|
2922
|
+
// Mixed or unstructured reasons — keep the coarse
|
|
2923
|
+
// label, but augment the error so it lists each
|
|
2924
|
+
// worker's specific reason.
|
|
2925
|
+
let per_worker = unresumable
|
|
2926
|
+
.iter()
|
|
2927
|
+
.map(|w| {
|
|
2928
|
+
let reason = w
|
|
2929
|
+
.refusal_reason
|
|
2930
|
+
.as_ref()
|
|
2931
|
+
.map(|r| r.wire().to_string())
|
|
2932
|
+
.unwrap_or_else(|| w.reason.clone());
|
|
2933
|
+
format!("{} ({})", w.agent_id.as_str(), reason)
|
|
2934
|
+
})
|
|
2935
|
+
.collect::<Vec<_>>()
|
|
2936
|
+
.join(", ");
|
|
2937
|
+
(
|
|
2938
|
+
"refused_resume_atomicity".to_string(),
|
|
2939
|
+
format!("{error} Per-worker: {per_worker}."),
|
|
2940
|
+
)
|
|
2941
|
+
};
|
|
2942
|
+
json!({
|
|
2943
|
+
"ok": false,
|
|
2944
|
+
"status": status_str,
|
|
2945
|
+
"status_class": "refused_resume_atomicity",
|
|
2946
|
+
"allow_fresh": allow_fresh,
|
|
2947
|
+
"error": error_str,
|
|
2948
|
+
"unresumable": unresumable_detail,
|
|
2949
|
+
"unresumable_ids": unresumable_ids,
|
|
2950
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
2951
|
+
})
|
|
2952
|
+
}
|
|
2724
2953
|
crate::lifecycle::RestartReport::RefusedResumeNotReady {
|
|
2725
2954
|
missing,
|
|
2726
2955
|
allow_fresh,
|
|
@@ -2758,6 +2987,18 @@ pub mod lifecycle_port {
|
|
|
2758
2987
|
"invalid": invalid.iter().map(|w| w.worker_id.as_str()).collect::<Vec<_>>(),
|
|
2759
2988
|
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
2760
2989
|
}),
|
|
2990
|
+
crate::lifecycle::RestartReport::RefusedDirtyTopology {
|
|
2991
|
+
session_name,
|
|
2992
|
+
reason,
|
|
2993
|
+
error,
|
|
2994
|
+
} => json!({
|
|
2995
|
+
"ok": false,
|
|
2996
|
+
"status": "refused_dirty_topology",
|
|
2997
|
+
"reason": reason,
|
|
2998
|
+
"session_name": session_name,
|
|
2999
|
+
"error": error,
|
|
3000
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
3001
|
+
}),
|
|
2761
3002
|
}
|
|
2762
3003
|
}
|
|
2763
3004
|
|
|
@@ -283,12 +283,15 @@ fn routing_ambiguous_value(
|
|
|
283
283
|
if in_team {
|
|
284
284
|
return None;
|
|
285
285
|
}
|
|
286
|
+
// aeab1c7 follow-up: `content` is no longer emitted anywhere from `send`
|
|
287
|
+
// responses (including this refusal). Replace with `content_length_bytes`
|
|
288
|
+
// to keep the size-sanity field consistent with the normal-send shape.
|
|
286
289
|
Some(json!({
|
|
287
290
|
"ok": false,
|
|
288
291
|
"status": "refused",
|
|
289
292
|
"target": null,
|
|
290
293
|
"agent_id": null,
|
|
291
|
-
"
|
|
294
|
+
"content_length_bytes": name.len(),
|
|
292
295
|
"sender": opts.sender,
|
|
293
296
|
"message_id": null,
|
|
294
297
|
"message_status": "refused",
|
|
@@ -456,13 +459,19 @@ fn delivery_outcome_json(
|
|
|
456
459
|
content: &str,
|
|
457
460
|
opts: &SendOptions,
|
|
458
461
|
) -> Value {
|
|
462
|
+
// Pre-release 0.4.0 user directive: send result MUST NOT carry the
|
|
463
|
+
// message body — neither in human form (cli/emit.rs) NOR in --json.
|
|
464
|
+
// External consumers who need the message content read it via `inbox`,
|
|
465
|
+
// not from the send response. We surface `content_length_bytes` as a
|
|
466
|
+
// size sanity field so callers can verify the body size they intended
|
|
467
|
+
// to send arrived intact without exposing the body itself.
|
|
459
468
|
let target_wire = target_json(target);
|
|
460
469
|
json!({
|
|
461
470
|
"ok": outcome.ok,
|
|
462
471
|
"status": delivery_status_wire(outcome.status),
|
|
463
472
|
"target": target_wire,
|
|
464
473
|
"agent_id": first_target(target),
|
|
465
|
-
"
|
|
474
|
+
"content_length_bytes": content.len(),
|
|
466
475
|
"sender": opts.sender,
|
|
467
476
|
"message_id": outcome.message_id,
|
|
468
477
|
"message_status": outcome.message_status.0,
|
|
@@ -58,14 +58,19 @@ use rusqlite::params;
|
|
|
58
58
|
.and_then(Value::as_str)
|
|
59
59
|
.unwrap_or("leader");
|
|
60
60
|
session_name.as_str().and_then(|session| {
|
|
61
|
-
|
|
61
|
+
// Bug #7 (gate review §6): build the attach command from the
|
|
62
|
+
// SAME endpoint the readiness probe uses (state's persisted
|
|
63
|
+
// tmux_endpoint/tmux_socket), so the printed command matches
|
|
64
|
+
// where the session actually lives.
|
|
65
|
+
crate::tmux_backend::attach_command_for_runtime_state_or_workspace(
|
|
62
66
|
workspace,
|
|
67
|
+
Some(state),
|
|
63
68
|
&crate::transport::SessionName::new(session.to_string()),
|
|
64
69
|
window_name,
|
|
65
70
|
)
|
|
66
71
|
})
|
|
67
72
|
};
|
|
68
|
-
let tmux_present = tmux_session_present(workspace, session_name.as_str());
|
|
73
|
+
let tmux_present = tmux_session_present(workspace, state, session_name.as_str());
|
|
69
74
|
let mut readiness_state = state.clone();
|
|
70
75
|
if let Some(obj) = readiness_state.as_object_mut() {
|
|
71
76
|
obj.insert("tmux_session_present".to_string(), serde_json::json!(tmux_present));
|
|
@@ -435,7 +440,16 @@ use rusqlite::params;
|
|
|
435
440
|
}
|
|
436
441
|
}
|
|
437
442
|
|
|
438
|
-
fn tmux_session_present(
|
|
443
|
+
fn tmux_session_present(
|
|
444
|
+
workspace: &Path,
|
|
445
|
+
state: &Value,
|
|
446
|
+
session_name: Option<&str>,
|
|
447
|
+
) -> bool {
|
|
448
|
+
// Bug #7 (prerelease 0.4.0 gate review §6): probe the SAME endpoint
|
|
449
|
+
// the runtime actually uses (state.tmux_endpoint / tmux_socket), not
|
|
450
|
+
// the workspace-hash socket. When state has no persisted endpoint,
|
|
451
|
+
// fall back to workspace — preserves legacy behavior. wait_readiness
|
|
452
|
+
// formula unchanged per 不可改项; only the input signal is fixed.
|
|
439
453
|
let Some(name) = session_name else {
|
|
440
454
|
return false;
|
|
441
455
|
};
|
|
@@ -444,7 +458,12 @@ use rusqlite::params;
|
|
|
444
458
|
}
|
|
445
459
|
let run_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
446
460
|
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
447
|
-
crate::tmux_backend::
|
|
461
|
+
let selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
462
|
+
&run_ws,
|
|
463
|
+
Some(state),
|
|
464
|
+
);
|
|
465
|
+
selection
|
|
466
|
+
.backend
|
|
448
467
|
.has_session(&crate::transport::SessionName::new(name))
|
|
449
468
|
.unwrap_or(false)
|
|
450
469
|
}
|
|
@@ -635,6 +654,22 @@ use rusqlite::params;
|
|
|
635
654
|
"session_id",
|
|
636
655
|
"captured_via",
|
|
637
656
|
"attribution_confidence",
|
|
657
|
+
// RM-039-STAT-001 fix (real-machine evidence 2026-06-22): the
|
|
658
|
+
// coordinator-tick activity classifier writes activity (status,
|
|
659
|
+
// confidence, rationale) to the top-level agent state, but the
|
|
660
|
+
// status --json compact projection dropped it. Operators saw
|
|
661
|
+
// "status: running" with no working/idle signal even when the
|
|
662
|
+
// pane was clearly producing output. Add the classifier output
|
|
663
|
+
// here so the compact form surfaces it.
|
|
664
|
+
// last_output_at is the timestamp the classifier advanced when
|
|
665
|
+
// the scrollback digest changed; keeping it adjacent to activity
|
|
666
|
+
// gives the operator a one-glance "is something moving" view.
|
|
667
|
+
// interacted is enriched in enrich_agents (true|false|never)
|
|
668
|
+
// and is the canonical "leader has ever sent this worker a
|
|
669
|
+
// message" signal — also needed for status sanity at a glance.
|
|
670
|
+
"activity",
|
|
671
|
+
"last_output_at",
|
|
672
|
+
"interacted",
|
|
638
673
|
] {
|
|
639
674
|
if let Some(value) = input.get(key) {
|
|
640
675
|
out.insert(key.to_string(), value.clone());
|