@team-agent/installer 0.3.24 → 0.3.26
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/emit.rs +1 -0
- package/crates/team-agent/src/cli/mod.rs +193 -7
- package/crates/team-agent/src/cli/send.rs +106 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +270 -13
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +7 -0
- package/crates/team-agent/src/coordinator/tests/energy.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/spine.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/takeover.rs +1 -0
- package/crates/team-agent/src/coordinator/tick.rs +55 -7
- package/crates/team-agent/src/leader/lease.rs +57 -0
- package/crates/team-agent/src/leader/start.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch.rs +217 -19
- package/crates/team-agent/src/lifecycle/restart/agent.rs +116 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +66 -1
- package/crates/team-agent/src/lifecycle/restart.rs +19 -2
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +994 -0
- package/crates/team-agent/src/messaging/delivery.rs +91 -1
- package/crates/team-agent/src/messaging/helpers.rs +64 -24
- package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +158 -4
- package/crates/team-agent/src/tmux_backend/tests.rs +414 -2
- package/crates/team-agent/src/tmux_backend.rs +366 -2
- package/crates/team-agent/src/transport/test_support.rs +1 -0
- package/crates/team-agent/src/transport/tests/mod.rs +1 -0
- package/crates/team-agent/src/transport/tests/wire.rs +2 -1
- package/crates/team-agent/src/transport.rs +103 -1
- package/npm/install.mjs +312 -39
- package/package.json +4 -4
|
@@ -348,6 +348,7 @@ pub fn deliver_pending_message(
|
|
|
348
348
|
attempt,
|
|
349
349
|
"send_inject_exhausted",
|
|
350
350
|
&reason,
|
|
351
|
+
None,
|
|
351
352
|
)?;
|
|
352
353
|
return Ok(DeliveryOutcome {
|
|
353
354
|
ok: false,
|
|
@@ -387,6 +388,13 @@ pub fn deliver_pending_message(
|
|
|
387
388
|
submit_verification_wire(inject_report.submit_verification)
|
|
388
389
|
)
|
|
389
390
|
};
|
|
391
|
+
// E50 PR-1 (0.3.24 P0): render forensic submit_diagnostics into the
|
|
392
|
+
// event so operators see per-attempt pane state without grepping. The
|
|
393
|
+
// legacy keys (`message_id` / `recipient` / `reason` / `attempts`)
|
|
394
|
+
// are preserved byte-for-byte for grep compatibility; new keys are
|
|
395
|
+
// ADDITIONAL.
|
|
396
|
+
let submit_attempts_detail =
|
|
397
|
+
render_submit_diagnostics(&inject_report);
|
|
390
398
|
event_log.write(
|
|
391
399
|
"send.unverified",
|
|
392
400
|
serde_json::json!({
|
|
@@ -394,6 +402,7 @@ pub fn deliver_pending_message(
|
|
|
394
402
|
"recipient": message.recipient,
|
|
395
403
|
"reason": reason,
|
|
396
404
|
"attempts": inject_report.attempts,
|
|
405
|
+
"submit_attempts_detail": submit_attempts_detail,
|
|
397
406
|
}),
|
|
398
407
|
)?;
|
|
399
408
|
if inject_report.attempts >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
|
|
@@ -407,6 +416,7 @@ pub fn deliver_pending_message(
|
|
|
407
416
|
inject_report.attempts,
|
|
408
417
|
"send_unverified_exhausted",
|
|
409
418
|
&reason,
|
|
419
|
+
Some(&inject_report),
|
|
410
420
|
)?;
|
|
411
421
|
return Ok(DeliveryOutcome {
|
|
412
422
|
ok: false,
|
|
@@ -469,7 +479,19 @@ fn inject_submit_verified(report: &InjectReport) -> bool {
|
|
|
469
479
|
SubmitVerification::PastedContentPromptStillPresentAfterSubmit => false,
|
|
470
480
|
SubmitVerification::PastedContentPromptAbsentAfterSubmit => true,
|
|
471
481
|
SubmitVerification::KeySentAfterVisibleToken { .. } => true,
|
|
482
|
+
// MUST-10 preserved: EnterSentWithoutPlaceholderCheck still ⇒ delivered
|
|
483
|
+
// (provider_submit_verification_red.rs:113-159 contract). E46 narrows
|
|
484
|
+
// when transport returns this variant: it is now emitted ONLY after
|
|
485
|
+
// post-Enter input-consumption confirmation succeeds. Fresh TUI
|
|
486
|
+
// (bracketed-paste-stuck) instead returns SubmitConsumptionUnverified
|
|
487
|
+
// so this branch keeps delivered semantics intact.
|
|
472
488
|
SubmitVerification::EnterSentWithoutPlaceholderCheck => true,
|
|
489
|
+
// E46 (0.3.24 bug#5): Enter was sent but post-Enter consumption was
|
|
490
|
+
// NOT observed within the bounded resend cap. Treat as not delivered
|
|
491
|
+
// (submitted_unverified / failed) — prevents the macmini假阳 where
|
|
492
|
+
// demo-director's stuck bracketed-paste swallowed the Enter and
|
|
493
|
+
// delivery still reported delivered.
|
|
494
|
+
SubmitVerification::SubmitConsumptionUnverified => false,
|
|
473
495
|
}
|
|
474
496
|
}
|
|
475
497
|
|
|
@@ -492,7 +514,8 @@ fn pane_readback_verified(report: &InjectReport) -> bool {
|
|
|
492
514
|
/// [team-agent-token:{message_id}]`. The worker (fake or real provider) only builds a result_envelope
|
|
493
515
|
/// when it sees this block + extracts the token — the bare content gives WORKING but never a report
|
|
494
516
|
/// (rt-host-a loop #4). token == message_id (exactly-once correlation).
|
|
495
|
-
|
|
517
|
+
/// F1 (0.3.26): promoted to `pub` for direct pane send in cli/send.rs.
|
|
518
|
+
pub fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id: &str) -> String {
|
|
496
519
|
let mut header = format!("Team Agent message from {sender}");
|
|
497
520
|
if let Some(task_id) = task_id.filter(|t| !t.is_empty()) {
|
|
498
521
|
header.push_str(&format!(" for {task_id}"));
|
|
@@ -545,7 +568,23 @@ fn resolve_inject_target(
|
|
|
545
568
|
.and_then(serde_json::Value::as_str)
|
|
546
569
|
.filter(|s| !s.is_empty())
|
|
547
570
|
.map(PaneId::new);
|
|
571
|
+
// E51 (0.3.26 P0, delivery loop guard): if the worker's cached pane_id is
|
|
572
|
+
// the SAME as leader_receiver.pane_id (the leader's handle), injecting into
|
|
573
|
+
// it would deliver the worker's message back to the leader pane — a routing
|
|
574
|
+
// loop that the macmini "hand-handle mapping 灾難" truth source exposed. Fail
|
|
575
|
+
// loud with a SessionWindow target that will surface as a structured error
|
|
576
|
+
// (the SessionWindow "leader" target trips the existing leader_not_attached
|
|
577
|
+
// guard on any subsequent delivery attempt for this message). The root fix
|
|
578
|
+
// is in lease.rs (E51 guard #1) which prevents the conflation; this is the
|
|
579
|
+
// defence-in-depth in the delivery layer.
|
|
548
580
|
if let Some(pane) = cached_pane.as_ref() {
|
|
581
|
+
let leader_pane = leader_receiver_pane_id(state);
|
|
582
|
+
if leader_pane.is_some_and(|lp| lp == pane.as_str()) {
|
|
583
|
+
return Target::SessionWindow {
|
|
584
|
+
session: SessionName::new(session),
|
|
585
|
+
window: WindowName::new(format!("{recipient}_pane_conflicts_with_leader")),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
549
588
|
if live_targets
|
|
550
589
|
.iter()
|
|
551
590
|
.any(|target| target.pane_id.as_str() == pane.as_str())
|
|
@@ -743,6 +782,34 @@ fn leader_receiver_field_in_state<'a>(
|
|
|
743
782
|
.filter(|value| !value.is_empty())
|
|
744
783
|
}
|
|
745
784
|
|
|
785
|
+
/// E50 PR-1 (0.3.24 P0): render `InjectReport.submit_diagnostics` as a JSON
|
|
786
|
+
/// array suitable for inclusion in `send.unverified` / `send.failed` events.
|
|
787
|
+
/// `null` if no diagnostics were attached (e.g. the inject went through a
|
|
788
|
+
/// path that bypassed the paste-prompt + Enter instrumentation, or this is
|
|
789
|
+
/// the pre-PR-2 inject-failed path with no `InjectReport`).
|
|
790
|
+
fn render_submit_diagnostics(report: &InjectReport) -> serde_json::Value {
|
|
791
|
+
let Some(diag) = report.submit_diagnostics.as_ref() else {
|
|
792
|
+
return serde_json::Value::Null;
|
|
793
|
+
};
|
|
794
|
+
serde_json::Value::Array(
|
|
795
|
+
diag.attempts_detail
|
|
796
|
+
.iter()
|
|
797
|
+
.map(|obs| {
|
|
798
|
+
serde_json::json!({
|
|
799
|
+
"attempt_index": obs.attempt_index,
|
|
800
|
+
"matched": obs.matched,
|
|
801
|
+
"matched_literal": obs.matched_literal,
|
|
802
|
+
"where_in_tail": obs.where_in_tail,
|
|
803
|
+
"pane_tail_excerpt": obs.pane_tail_excerpt,
|
|
804
|
+
"pane_tail_lines": obs.pane_tail_lines,
|
|
805
|
+
"elapsed_ms": obs.elapsed_ms,
|
|
806
|
+
})
|
|
807
|
+
})
|
|
808
|
+
.collect(),
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
#[allow(clippy::too_many_arguments)]
|
|
746
813
|
fn emit_send_failed_exhausted(
|
|
747
814
|
workspace: &Path,
|
|
748
815
|
state: &serde_json::Value,
|
|
@@ -752,7 +819,26 @@ fn emit_send_failed_exhausted(
|
|
|
752
819
|
attempts: u32,
|
|
753
820
|
failure_reason: &str,
|
|
754
821
|
verification: &str,
|
|
822
|
+
inject_report: Option<&InjectReport>,
|
|
755
823
|
) -> Result<(), MessagingError> {
|
|
824
|
+
// E50 PR-1 (0.3.24 P0): forensic fields on send.failed. Legacy keys
|
|
825
|
+
// (`message_id` / `recipient` / `attempts` / `max_attempts` / `reason` /
|
|
826
|
+
// `verification`) preserved byte-for-byte for grep compatibility.
|
|
827
|
+
let submit_attempts_detail = inject_report
|
|
828
|
+
.map(render_submit_diagnostics)
|
|
829
|
+
.unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
|
|
830
|
+
let total_elapsed_ms = inject_report
|
|
831
|
+
.and_then(|r| r.submit_diagnostics.as_ref())
|
|
832
|
+
.map(|d| d.total_elapsed_ms)
|
|
833
|
+
.unwrap_or(0);
|
|
834
|
+
let last_matched_literal = inject_report
|
|
835
|
+
.and_then(|r| r.submit_diagnostics.as_ref())
|
|
836
|
+
.and_then(|d| d.attempts_detail.last())
|
|
837
|
+
.and_then(|a| a.matched_literal.clone());
|
|
838
|
+
let last_pane_tail_excerpt = inject_report
|
|
839
|
+
.and_then(|r| r.submit_diagnostics.as_ref())
|
|
840
|
+
.and_then(|d| d.attempts_detail.last())
|
|
841
|
+
.map(|a| a.pane_tail_excerpt.clone());
|
|
756
842
|
event_log.write(
|
|
757
843
|
"send.failed",
|
|
758
844
|
serde_json::json!({
|
|
@@ -762,6 +848,10 @@ fn emit_send_failed_exhausted(
|
|
|
762
848
|
"max_attempts": SEND_RETRY_MAX_ATTEMPTS,
|
|
763
849
|
"reason": failure_reason,
|
|
764
850
|
"verification": verification,
|
|
851
|
+
"submit_attempts_detail": submit_attempts_detail,
|
|
852
|
+
"total_elapsed_ms": total_elapsed_ms,
|
|
853
|
+
"last_matched_literal": last_matched_literal,
|
|
854
|
+
"last_pane_tail_excerpt": last_pane_tail_excerpt,
|
|
765
855
|
}),
|
|
766
856
|
)?;
|
|
767
857
|
let content = format!(
|
|
@@ -112,9 +112,20 @@ pub(crate) fn parse_scheduled_kind(kind: &str) -> Result<ScheduledKind, Messagin
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
pub(crate) fn working_seconds(scrollback: &str) -> Option<u64> {
|
|
115
|
-
|
|
115
|
+
// E47 ROOT-FIX#1 (0.3.24 P0, idle/busy 假阳): the pre-fix `lower.find(...)`
|
|
116
|
+
// scanned the WHOLE scrollback (Tail(40) lines) and matched historical
|
|
117
|
+
// working markers — e.g. an old `• Working (514s · esc to interrupt)`
|
|
118
|
+
// still in the 40-line window after the worker idled. That stale token
|
|
119
|
+
// flipped a truly-idle agent to Stuck whenever the historical seconds
|
|
120
|
+
// exceeded 300. Real fix: only consult the LIVE composer / status line
|
|
121
|
+
// (last non-empty line). If the current bottom line is something like
|
|
122
|
+
// `❯ Run /review`, `Worked for 8m 34s`, or an empty composer prompt,
|
|
123
|
+
// there is no LIVE working indicator and we return None — handing off
|
|
124
|
+
// to the structural latest_prompt_signal / IRON LAW Uncertain path.
|
|
125
|
+
let last_non_empty = scrollback.lines().rev().find(|line| !line.trim().is_empty())?;
|
|
126
|
+
let lower = last_non_empty.to_ascii_lowercase();
|
|
116
127
|
let start = lower.find("working (")?;
|
|
117
|
-
let rest =
|
|
128
|
+
let rest = last_non_empty.get(start + "Working (".len()..)?;
|
|
118
129
|
let seconds = rest.split_once('s')?.0;
|
|
119
130
|
seconds.parse::<u64>().ok()
|
|
120
131
|
}
|
|
@@ -129,32 +140,61 @@ pub(crate) fn non_provider_command(command: &str) -> Option<&str> {
|
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
pub(crate) fn latest_prompt_signal(scrollback: &str) -> Option<AgentActivity> {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
// E47 ROOT-FIX#2 (0.3.24 P0, idle/busy 假阳): limit the scan to the
|
|
144
|
+
// BOTTOM ACTIVE REGION (last 1-3 non-empty lines = composer / status
|
|
145
|
+
// line). The pre-fix `rfind` across the whole Tail(40) buffer let a
|
|
146
|
+
// historical spinner/`Working` token out-position the live `❯`/`›`
|
|
147
|
+
// composer (the rfind-recency family bug — same shape as the #320
|
|
148
|
+
// codex prompt residue). When the composer line is `❯ Run /review`
|
|
149
|
+
// (idle), a scrollback `Working (514s)` 20 lines up should NOT win.
|
|
150
|
+
//
|
|
151
|
+
// IRON LAW (activity.rs:3 / bug-071/077/085): no-signal in the bottom
|
|
152
|
+
// region must be Uncertain (caller treats None here as "no decisive
|
|
153
|
+
// signal" and surfaces Uncertain), NEVER silently flipped to Idle.
|
|
154
|
+
let active_region: Vec<&str> = scrollback
|
|
155
|
+
.lines()
|
|
156
|
+
.rev()
|
|
157
|
+
.filter(|line| !line.trim().is_empty())
|
|
158
|
+
.take(3)
|
|
159
|
+
.collect();
|
|
160
|
+
if active_region.is_empty() {
|
|
161
|
+
return None;
|
|
162
|
+
}
|
|
163
|
+
let mut has_idle_prompt = false;
|
|
164
|
+
let mut has_live_working_indicator = false;
|
|
165
|
+
for line in &active_region {
|
|
166
|
+
let lower = line.to_ascii_lowercase();
|
|
167
|
+
if line.contains('❯') || line.contains('›') {
|
|
168
|
+
has_idle_prompt = true;
|
|
169
|
+
}
|
|
170
|
+
// codex live spinner shapes (provider/adapter.rs:875-876 markers):
|
|
171
|
+
// braille spinner, `• Working (`, `Thinking`; claude `✶`/`✢` per
|
|
172
|
+
// adapter; we look for STRUCTURAL composer signals.
|
|
173
|
+
if [
|
|
174
|
+
"working (", "thinking", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
|
|
175
|
+
"⠧", "⠇", "⠏", "✶", "✢",
|
|
176
|
+
]
|
|
177
|
+
.iter()
|
|
178
|
+
.any(|needle| lower.contains(needle))
|
|
179
|
+
{
|
|
180
|
+
has_live_working_indicator = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Working indicator IN the active region wins over a stale `❯`/`›`
|
|
184
|
+
// (composer may show a prompt char before the live spinner refreshes).
|
|
185
|
+
if has_live_working_indicator {
|
|
186
|
+
return Some(AgentActivity {
|
|
144
187
|
status: ActivityStatus::Working,
|
|
145
188
|
confidence: 0.9,
|
|
146
189
|
rationale: "working_indicator".to_string(),
|
|
147
|
-
})
|
|
148
|
-
(None, None) => None,
|
|
190
|
+
});
|
|
149
191
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.chain(scrollback.match_indices('›').map(|(idx, _)| idx))
|
|
157
|
-
.max()
|
|
192
|
+
if has_idle_prompt {
|
|
193
|
+
return Some(idle_activity());
|
|
194
|
+
}
|
|
195
|
+
// No structural signal in the bottom region → caller (activity.rs:184)
|
|
196
|
+
// gets None and treats as no-decisive-signal → Uncertain (IRON LAW).
|
|
197
|
+
None
|
|
158
198
|
}
|
|
159
199
|
|
|
160
200
|
fn idle_activity() -> AgentActivity {
|
|
@@ -1308,6 +1308,7 @@ impl Transport for UnverifiedInjectTransport {
|
|
|
1308
1308
|
crate::transport::SubmitVerification::PastedContentPromptStillPresentAfterSubmit,
|
|
1309
1309
|
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
1310
1310
|
attempts: u32::from(SEND_RETRY_MAX_ATTEMPTS),
|
|
1311
|
+
submit_diagnostics: None,
|
|
1311
1312
|
})
|
|
1312
1313
|
}
|
|
1313
1314
|
fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
|
|
@@ -1467,6 +1468,7 @@ impl Transport for ReadbackMissingTransport {
|
|
|
1467
1468
|
},
|
|
1468
1469
|
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
1469
1470
|
attempts: 1,
|
|
1471
|
+
submit_diagnostics: None,
|
|
1470
1472
|
})
|
|
1471
1473
|
}
|
|
1472
1474
|
fn send_keys(&self, _: &Target, _: &[Key]) -> Result<(), TransportError> {
|
|
@@ -71,7 +71,14 @@ fn p2_owner_bypass_denies_on_env_agent_id_mismatch() {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// P1 — classify_agent_activity must read current_command, stale last_output, multiline /
|
|
74
|
-
// Codex idle prompts, and Thinking/
|
|
74
|
+
// Codex idle prompts, and Thinking/codex-spinner working indicators
|
|
75
|
+
// (activity_detector.py:90-146). **E47 amendment (0.3.24 P0)**: the
|
|
76
|
+
// working-indicator probe is now bottom-active-region scoped (last 1-3
|
|
77
|
+
// non-empty lines) and looks for STRUCTURAL spinner markers (codex
|
|
78
|
+
// `• Working (`/`Thinking`/braille; claude `✶`/`✢`), not bare lowercase
|
|
79
|
+
// `working` anywhere in the scrollback. Pre-fix rfind across the whole
|
|
80
|
+
// Tail(40) buffer is the macmini假阳 root cause (historical Working tokens
|
|
81
|
+
// out-positioning a live idle composer).
|
|
75
82
|
#[test]
|
|
76
83
|
fn p2_classify_activity_reads_command_stale_and_prompts() {
|
|
77
84
|
let st = serde_json::json!({});
|
|
@@ -85,12 +92,28 @@ fn p2_classify_activity_reads_command_stale_and_prompts() {
|
|
|
85
92
|
// (3) embedded multiline idle prompt (Codex ❯ not on its own trimmed line) → idle 0.9.
|
|
86
93
|
let c = classify_agent_activity(&st, "some line\n❯\nmore", false, None, None);
|
|
87
94
|
assert_eq!((c.status, c.confidence), (ActivityStatus::Idle, 0.9));
|
|
88
|
-
// (4) 'Thinking' working indicator → working 0.9.
|
|
95
|
+
// (4) 'Thinking' working indicator in the bottom active region → working 0.9.
|
|
89
96
|
let d = classify_agent_activity(&st, "Thinking about it", false, None, None);
|
|
90
97
|
assert_eq!((d.status, d.confidence), (ActivityStatus::Working, 0.9));
|
|
91
|
-
// (5) lowercase 'working'
|
|
98
|
+
// (5) E47 amendment: bare lowercase 'working on it' is NOT a structural
|
|
99
|
+
// spinner marker; the live codex spinner is `• Working (Ns · esc to
|
|
100
|
+
// interrupt)`. Treat as no-decisive-signal → Uncertain. The pre-fix
|
|
101
|
+
// expectation here is exactly the macmini假阳 shape — a stale 'working'
|
|
102
|
+
// token (without the parenthesised seconds + esc-to-interrupt tail) is
|
|
103
|
+
// either scrollback residue or unrelated text, not a live working
|
|
104
|
+
// indicator. To assert the Working path use the structural codex shape.
|
|
92
105
|
let e = classify_agent_activity(&st, "working on it", false, None, None);
|
|
93
|
-
assert_eq!((e.status, e.confidence), (ActivityStatus::
|
|
106
|
+
assert_eq!((e.status, e.confidence), (ActivityStatus::Uncertain, 0.5));
|
|
107
|
+
// (5b) E47 structural Working: a real codex live spinner in the bottom
|
|
108
|
+
// active region must still classify Working 0.9.
|
|
109
|
+
let e_struct = classify_agent_activity(
|
|
110
|
+
&st,
|
|
111
|
+
"• Working (12s · esc to interrupt)",
|
|
112
|
+
false,
|
|
113
|
+
None,
|
|
114
|
+
None,
|
|
115
|
+
);
|
|
116
|
+
assert_eq!((e_struct.status, e_struct.confidence), (ActivityStatus::Working, 0.9));
|
|
94
117
|
}
|
|
95
118
|
|
|
96
119
|
// P1 — scheduler dispatch must be exhaustive: an unknown kind surfaces an error, not a
|
|
@@ -150,6 +173,7 @@ impl Transport for DeliverOkTransport {
|
|
|
150
173
|
submit_verification: crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
151
174
|
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
152
175
|
attempts: 1,
|
|
176
|
+
submit_diagnostics: None,
|
|
153
177
|
})
|
|
154
178
|
}
|
|
155
179
|
fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
|
|
@@ -484,3 +508,133 @@ fn spine_delivery_injects_rendered_protocol_block_not_raw_content() {
|
|
|
484
508
|
worker builds a result_envelope; got {payload:?}"
|
|
485
509
|
);
|
|
486
510
|
}
|
|
511
|
+
|
|
512
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
513
|
+
// E47 (0.3.24 P0, idle/busy 假阳) — TUI keyword grep is structurally bounded.
|
|
514
|
+
//
|
|
515
|
+
// Real-machine repro (macmini): a Tail(40) capture of an idle codex worker
|
|
516
|
+
// still carries historical `• Working (514s · esc to interrupt)` plus a
|
|
517
|
+
// `─ Worked for 8m 34s ─` summary plus a fresh `❯ Run /review` prompt. The
|
|
518
|
+
// pre-fix `working_seconds` full-buffer find matched the 514s token and
|
|
519
|
+
// returned ≥300 → Stuck. The pre-fix `latest_prompt_signal` rfind let the
|
|
520
|
+
// historical spinner out-position the bottom `❯` → Working. E47 narrows
|
|
521
|
+
// both probes to the bottom active region (last 1-3 non-empty lines).
|
|
522
|
+
//
|
|
523
|
+
// The authoritative provider JSONL classify is wired in coordinator/tick.rs
|
|
524
|
+
// (jsonl_activity_for_agent); these unit tests pin the TUI fallback layer.
|
|
525
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
526
|
+
|
|
527
|
+
#[test]
|
|
528
|
+
fn e47_codex_idle_with_historical_working_in_scrollback_is_idle_not_stuck_or_working() {
|
|
529
|
+
// Exact macmini repro shape from the architect locate (E47).
|
|
530
|
+
let scrollback = "• Working (514s · esc to interrupt)\n\
|
|
531
|
+
tool call 1\n\
|
|
532
|
+
tool call 2\n\
|
|
533
|
+
─ Worked for 8m 34s ─\n\
|
|
534
|
+
› Run /review\n\
|
|
535
|
+
❯ ";
|
|
536
|
+
let st = serde_json::json!({});
|
|
537
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
538
|
+
assert_eq!(
|
|
539
|
+
a.status,
|
|
540
|
+
ActivityStatus::Idle,
|
|
541
|
+
"E47 (RED-1): codex idle worker whose scrollback still carries \
|
|
542
|
+
historical `• Working (514s · esc to interrupt)` must classify IDLE \
|
|
543
|
+
(bottom active region is `❯`+`› Run /review` = idle composer). \
|
|
544
|
+
pre-fix: Stuck/Working via rfind-recency. Got {a:?}"
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
#[test]
|
|
549
|
+
fn e47_past_tense_worked_for_is_not_stale_working_indicator() {
|
|
550
|
+
// RED-2: past-tense `Worked for` summary line must NOT trigger
|
|
551
|
+
// working_seconds Stuck. The pre-fix scanned the whole buffer and would
|
|
552
|
+
// match the embedded `8m 34s ─` against `working (` if formatted slightly
|
|
553
|
+
// differently — but more importantly this test pins the "past-tense
|
|
554
|
+
// summary is idle context" semantics.
|
|
555
|
+
let scrollback = "─ Worked for 8m 34s ─\n❯ ";
|
|
556
|
+
let st = serde_json::json!({});
|
|
557
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
558
|
+
assert_ne!(
|
|
559
|
+
a.status,
|
|
560
|
+
ActivityStatus::Stuck,
|
|
561
|
+
"E47 (RED-2): past-tense `Worked for 8m 34s` must NOT trigger \
|
|
562
|
+
stale_working_indicator Stuck. Got {a:?}"
|
|
563
|
+
);
|
|
564
|
+
assert_eq!(
|
|
565
|
+
a.status,
|
|
566
|
+
ActivityStatus::Idle,
|
|
567
|
+
"E47 (RED-2): bottom `❯` composer = idle. Got {a:?}"
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
#[test]
|
|
572
|
+
fn e47_claude_idle_with_historical_spinner_classifies_idle() {
|
|
573
|
+
// RED-3: claude idle worker; historical spinner shape (`✶` per
|
|
574
|
+
// adapter.rs:875-876) earlier in the buffer (>= 3 non-empty lines deep),
|
|
575
|
+
// bottom composer is idle (`›` glyph variant or `> ` claude prompt).
|
|
576
|
+
// The bottom active region (last 3 non-empty lines) has NO spinner.
|
|
577
|
+
let scrollback = "✶ Working on plan\n\
|
|
578
|
+
tool 1\n\
|
|
579
|
+
tool 2\n\
|
|
580
|
+
assistant reply text\n\
|
|
581
|
+
summary line A\n\
|
|
582
|
+
summary line B\n\
|
|
583
|
+
› Continue?";
|
|
584
|
+
let st = serde_json::json!({});
|
|
585
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
586
|
+
assert_eq!(
|
|
587
|
+
a.status,
|
|
588
|
+
ActivityStatus::Idle,
|
|
589
|
+
"E47 (RED-3): claude idle worker — historical ✶ above, idle `›` \
|
|
590
|
+
below — must classify IDLE. Got {a:?}"
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
#[test]
|
|
595
|
+
fn e47_codex_live_spinner_in_bottom_active_region_classifies_working() {
|
|
596
|
+
// RED-4 (defence vs over-tightening 假阴): a TRULY busy codex worker
|
|
597
|
+
// whose bottom active region carries the live spinner must still be
|
|
598
|
+
// Working. No idle composer present.
|
|
599
|
+
let scrollback = "tool call x\nassistant response so far\n• Working (12s · esc to interrupt)";
|
|
600
|
+
let st = serde_json::json!({});
|
|
601
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
602
|
+
assert_eq!(
|
|
603
|
+
a.status,
|
|
604
|
+
ActivityStatus::Working,
|
|
605
|
+
"E47 (RED-4): live codex spinner in bottom active region must \
|
|
606
|
+
classify Working. Got {a:?}"
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
#[test]
|
|
611
|
+
fn e47_claude_live_working_in_bottom_active_region_classifies_working() {
|
|
612
|
+
// RED-5: a TRULY busy claude worker showing `✶ Working` at the bottom
|
|
613
|
+
// active region must be Working — even without the `(Ns)` codex shape.
|
|
614
|
+
let scrollback = "earlier output\nmore output\n✶ Working through the request";
|
|
615
|
+
let st = serde_json::json!({});
|
|
616
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
617
|
+
assert_eq!(
|
|
618
|
+
a.status,
|
|
619
|
+
ActivityStatus::Working,
|
|
620
|
+
"E47 (RED-5): claude ✶ Working in bottom active region → Working. \
|
|
621
|
+
Got {a:?}"
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
#[test]
|
|
626
|
+
fn e47_iron_law_no_signal_in_bottom_region_stays_uncertain_not_idle() {
|
|
627
|
+
// Defence: IRON LAW (activity.rs:3 / bug-071/077/085). When the bottom
|
|
628
|
+
// active region has no spinner AND no `❯`/`›` AND no other decisive
|
|
629
|
+
// signal, classify_agent_activity falls through to no_decisive_signal →
|
|
630
|
+
// Uncertain — NEVER silently to Idle.
|
|
631
|
+
let scrollback = "some neutral text\nmore neutral text\nstill nothing decisive";
|
|
632
|
+
let st = serde_json::json!({});
|
|
633
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
634
|
+
assert_eq!(
|
|
635
|
+
a.status,
|
|
636
|
+
ActivityStatus::Uncertain,
|
|
637
|
+
"E47 IRON LAW guard: bottom region carries no spinner and no idle \
|
|
638
|
+
composer → Uncertain, never silently Idle. Got {a:?}"
|
|
639
|
+
);
|
|
640
|
+
}
|