@team-agent/installer 0.3.23 → 0.3.25
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/coordinator/tick.rs +55 -7
- package/crates/team-agent/src/leader/lease.rs +34 -13
- package/crates/team-agent/src/leader/tests/lease_api.rs +79 -0
- package/crates/team-agent/src/lifecycle/helpers.rs +11 -3
- package/crates/team-agent/src/lifecycle/launch.rs +219 -35
- package/crates/team-agent/src/lifecycle/restart/agent.rs +61 -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 +86 -27
- package/crates/team-agent/src/messaging/helpers.rs +64 -24
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +157 -4
- package/crates/team-agent/src/messaging/tests/wave2.rs +689 -0
- package/crates/team-agent/src/state/projection.rs +5 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +297 -0
- package/crates/team-agent/src/tmux_backend.rs +145 -5
- package/crates/team-agent/src/transport/test_support.rs +79 -6
- package/crates/team-agent/src/transport.rs +26 -1
- package/npm/install.mjs +90 -25
- package/package.json +4 -4
|
@@ -203,8 +203,42 @@ pub fn deliver_pending_message(
|
|
|
203
203
|
let delivery_transport =
|
|
204
204
|
delivery_transport_for_recipient(workspace, transport, state, &message.recipient);
|
|
205
205
|
let transport = delivery_transport.as_transport();
|
|
206
|
+
// U1-B: probe `list_targets()` ONCE per delivery and DEFER on Err. The prior
|
|
207
|
+
// `.unwrap_or_default()` coerced server jitter (Err = subprocess fork failed)
|
|
208
|
+
// to an empty vec, and the downstream cache-reuse branch
|
|
209
|
+
// (`live_targets.is_empty() && !known_dead`) would then inject into a
|
|
210
|
+
// never-validated cached pane on every tmux hiccup. Err is OBSERVED here as
|
|
211
|
+
// jitter and the delivery is deferred (status stays `target_resolved`, no
|
|
212
|
+
// inject attempted) so the next tick gets a fresh probe.
|
|
213
|
+
let live_targets = match transport.list_targets() {
|
|
214
|
+
Ok(targets) => targets,
|
|
215
|
+
Err(err) => {
|
|
216
|
+
let reason = format!("list_targets_server_jitter:{err}");
|
|
217
|
+
event_log.write(
|
|
218
|
+
"delivery.deferred_list_targets_jitter",
|
|
219
|
+
serde_json::json!({
|
|
220
|
+
"message_id": message_id,
|
|
221
|
+
"recipient": message.recipient,
|
|
222
|
+
"reason": reason,
|
|
223
|
+
}),
|
|
224
|
+
)?;
|
|
225
|
+
store.mark(message_id, "target_resolved", Some(&reason))?;
|
|
226
|
+
return Ok(DeliveryOutcome {
|
|
227
|
+
ok: false,
|
|
228
|
+
status: DeliveryStatus::Degraded,
|
|
229
|
+
message_status: MessageStatusShadow("target_resolved".to_string()),
|
|
230
|
+
message_id: Some(message_id.to_string()),
|
|
231
|
+
verification: Some(reason),
|
|
232
|
+
stage: None,
|
|
233
|
+
reason: None,
|
|
234
|
+
channel: None,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
};
|
|
206
238
|
// Do not inject queued leader messages into a synthetic "leader" window.
|
|
207
|
-
if message.recipient == "leader"
|
|
239
|
+
if message.recipient == "leader"
|
|
240
|
+
&& !leader_receiver_pane_is_usable(transport, state, &live_targets)
|
|
241
|
+
{
|
|
208
242
|
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
209
243
|
event_log.write(
|
|
210
244
|
"leader_receiver.delivery_blocked",
|
|
@@ -227,7 +261,7 @@ pub fn deliver_pending_message(
|
|
|
227
261
|
channel: Some("rebind_required".to_string()),
|
|
228
262
|
});
|
|
229
263
|
}
|
|
230
|
-
let target = resolve_inject_target(state, &message.recipient, transport);
|
|
264
|
+
let target = resolve_inject_target(state, &message.recipient, transport, &live_targets);
|
|
231
265
|
// Contract B / MUST-10 / N31/N32: physical paste+Enter into a startup trust/update
|
|
232
266
|
// menu is NOT provider delivery — the menu consumes the Enter and the task text
|
|
233
267
|
// is lost (PROBE-2 root-cause). Before injection, peek at the recipient's pane for
|
|
@@ -435,7 +469,19 @@ fn inject_submit_verified(report: &InjectReport) -> bool {
|
|
|
435
469
|
SubmitVerification::PastedContentPromptStillPresentAfterSubmit => false,
|
|
436
470
|
SubmitVerification::PastedContentPromptAbsentAfterSubmit => true,
|
|
437
471
|
SubmitVerification::KeySentAfterVisibleToken { .. } => true,
|
|
472
|
+
// MUST-10 preserved: EnterSentWithoutPlaceholderCheck still ⇒ delivered
|
|
473
|
+
// (provider_submit_verification_red.rs:113-159 contract). E46 narrows
|
|
474
|
+
// when transport returns this variant: it is now emitted ONLY after
|
|
475
|
+
// post-Enter input-consumption confirmation succeeds. Fresh TUI
|
|
476
|
+
// (bracketed-paste-stuck) instead returns SubmitConsumptionUnverified
|
|
477
|
+
// so this branch keeps delivered semantics intact.
|
|
438
478
|
SubmitVerification::EnterSentWithoutPlaceholderCheck => true,
|
|
479
|
+
// E46 (0.3.24 bug#5): Enter was sent but post-Enter consumption was
|
|
480
|
+
// NOT observed within the bounded resend cap. Treat as not delivered
|
|
481
|
+
// (submitted_unverified / failed) — prevents the macmini假阳 where
|
|
482
|
+
// demo-director's stuck bracketed-paste swallowed the Enter and
|
|
483
|
+
// delivery still reported delivered.
|
|
484
|
+
SubmitVerification::SubmitConsumptionUnverified => false,
|
|
439
485
|
}
|
|
440
486
|
}
|
|
441
487
|
|
|
@@ -473,12 +519,24 @@ fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id
|
|
|
473
519
|
///
|
|
474
520
|
/// Leader delivery uses the bound leader receiver pane. The leader is not a worker agent and
|
|
475
521
|
/// must not fall through to a synthetic `SessionWindow{window="leader"}` target.
|
|
522
|
+
///
|
|
523
|
+
/// `live_targets` is passed in BY THE CALLER (probed ONCE per delivery — see U1-B); this fn is
|
|
524
|
+
/// pure w.r.t. transport state. The caller must DEFER on `list_targets()` Err and never coerce
|
|
525
|
+
/// to an empty vec.
|
|
526
|
+
///
|
|
527
|
+
/// **0.3.24 excision (U1-A real-machine RED v2, macmini fixture res_e4b40473d36f)**:
|
|
528
|
+
/// the wave-2 LEADER drift fallback chain (session+window probe) was removed — see
|
|
529
|
+
/// `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md` for the root-cause analysis.
|
|
530
|
+
/// Drift now fails loudly as `leader_not_attached` rather than silently selecting a stale
|
|
531
|
+
/// pane. U1-A real-machine fix is deferred to v0.3.25 (writer-shape + projection +
|
|
532
|
+
/// rediscover-writer triad). U1-B jitter defer at try_deliver_message:213-237 and
|
|
533
|
+
/// U1-C-Tail at the startup-prompt peek site are unchanged.
|
|
476
534
|
fn resolve_inject_target(
|
|
477
535
|
state: &serde_json::Value,
|
|
478
536
|
recipient: &str,
|
|
479
537
|
transport: &dyn Transport,
|
|
538
|
+
live_targets: &[PaneInfo],
|
|
480
539
|
) -> Target {
|
|
481
|
-
let live_targets = transport.list_targets().unwrap_or_default();
|
|
482
540
|
if recipient == "leader" {
|
|
483
541
|
if let Some(pane_id) = leader_receiver_pane_id(state) {
|
|
484
542
|
return Target::Pane(PaneId::new(pane_id));
|
|
@@ -507,7 +565,7 @@ fn resolve_inject_target(
|
|
|
507
565
|
return Target::Pane(pane.clone());
|
|
508
566
|
}
|
|
509
567
|
}
|
|
510
|
-
if let Some(live_pane) = live_pane_for_session_window(
|
|
568
|
+
if let Some(live_pane) = live_pane_for_session_window(live_targets, session, window) {
|
|
511
569
|
return Target::Pane(live_pane);
|
|
512
570
|
}
|
|
513
571
|
if let Some(pane) = cached_pane {
|
|
@@ -552,16 +610,21 @@ fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
|
|
|
552
610
|
.or_else(|| only_team_entry(state).and_then(leader_receiver_pane_id_in_state))
|
|
553
611
|
}
|
|
554
612
|
|
|
555
|
-
|
|
613
|
+
/// `state` is "usable" for leader delivery when the bound `leader_receiver.pane_id`
|
|
614
|
+
/// is present and alive (in `live_targets` or `liveness` probe returns not-Dead).
|
|
615
|
+
///
|
|
616
|
+
/// **0.3.24 excision (U1-A real-machine RED v2)**: the wave-2 session+window drift
|
|
617
|
+
/// fallback was removed. Drift now fails loudly as `leader_not_attached` —
|
|
618
|
+
/// see resolve_inject_target and `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
|
|
619
|
+
fn leader_receiver_pane_is_usable(
|
|
620
|
+
transport: &dyn Transport,
|
|
621
|
+
state: &serde_json::Value,
|
|
622
|
+
live_targets: &[PaneInfo],
|
|
623
|
+
) -> bool {
|
|
556
624
|
let Some(pane_id) = leader_receiver_pane_id(state) else {
|
|
557
625
|
return false;
|
|
558
626
|
};
|
|
559
|
-
if
|
|
560
|
-
.list_targets()
|
|
561
|
-
.unwrap_or_default()
|
|
562
|
-
.iter()
|
|
563
|
-
.any(|target| target.pane_id.as_str() == pane_id)
|
|
564
|
-
{
|
|
627
|
+
if live_targets.iter().any(|target| target.pane_id.as_str() == pane_id) {
|
|
565
628
|
return true;
|
|
566
629
|
}
|
|
567
630
|
!matches!(
|
|
@@ -956,8 +1019,16 @@ fn recipient_pane_has_actionable_startup_prompt(
|
|
|
956
1019
|
if matches!(startup_prompts, Some("handled" | "complete")) {
|
|
957
1020
|
return false;
|
|
958
1021
|
}
|
|
1022
|
+
// U1-C wave-2: Tail(80) instead of Full. The peek is the DELIVERY-SITE pre-check
|
|
1023
|
+
// (NOT the startup-prompts dismissal phase, which legitimately needs the full
|
|
1024
|
+
// scrollback to anchor recency). Limiting to the visible screen avoids matching
|
|
1025
|
+
// residual already-answered trust modals that scrolled off — while a live trust
|
|
1026
|
+
// modal would still appear in the last 80 lines because Codex pins it to the
|
|
1027
|
+
// visible region until dismissed. Pair this with the U1-C recency guard in
|
|
1028
|
+
// `has_actionable_trust_shape`: either fix alone covers the symptom; both
|
|
1029
|
+
// together defend against both scrollback-residue AND pre-render pathologies.
|
|
959
1030
|
let captured = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
960
|
-
transport.capture(target, crate::transport::CaptureRange::
|
|
1031
|
+
transport.capture(target, crate::transport::CaptureRange::Tail(80))
|
|
961
1032
|
})) {
|
|
962
1033
|
Ok(Ok(captured)) => captured.text,
|
|
963
1034
|
_ => return false,
|
|
@@ -1351,27 +1422,15 @@ pub(crate) fn normalize_owner_team_id_rows(
|
|
|
1351
1422
|
if requested == canonical {
|
|
1352
1423
|
return Ok(());
|
|
1353
1424
|
}
|
|
1354
|
-
let store = MessageStore::open(workspace)?;
|
|
1355
|
-
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
1356
|
-
for table in [
|
|
1357
|
-
"messages",
|
|
1358
|
-
"results",
|
|
1359
|
-
"scheduled_events",
|
|
1360
|
-
"agent_health",
|
|
1361
|
-
"result_watchers",
|
|
1362
|
-
"leader_notification_log",
|
|
1363
|
-
] {
|
|
1364
|
-
let sql =
|
|
1365
|
-
format!("update or ignore {table} set owner_team_id = ?1 where owner_team_id = ?2");
|
|
1366
|
-
conn.execute(&sql, params![canonical, requested])?;
|
|
1367
|
-
}
|
|
1368
1425
|
if let Some(event_log) = event_log {
|
|
1369
1426
|
event_log.write(
|
|
1370
|
-
"owner_team_id.
|
|
1427
|
+
"owner_team_id.compatibility_alias_detected",
|
|
1371
1428
|
serde_json::json!({
|
|
1372
1429
|
"requested_owner_team_id": requested,
|
|
1373
1430
|
"canonical_owner_team_id": canonical,
|
|
1374
1431
|
"message_id": message_id,
|
|
1432
|
+
"action": "read_only_no_db_update",
|
|
1433
|
+
"workspace": workspace.to_string_lossy().to_string(),
|
|
1375
1434
|
}),
|
|
1376
1435
|
)?;
|
|
1377
1436
|
}
|
|
@@ -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 {
|
|
@@ -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
|
|
@@ -484,3 +507,133 @@ fn spine_delivery_injects_rendered_protocol_block_not_raw_content() {
|
|
|
484
507
|
worker builds a result_envelope; got {payload:?}"
|
|
485
508
|
);
|
|
486
509
|
}
|
|
510
|
+
|
|
511
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
512
|
+
// E47 (0.3.24 P0, idle/busy 假阳) — TUI keyword grep is structurally bounded.
|
|
513
|
+
//
|
|
514
|
+
// Real-machine repro (macmini): a Tail(40) capture of an idle codex worker
|
|
515
|
+
// still carries historical `• Working (514s · esc to interrupt)` plus a
|
|
516
|
+
// `─ Worked for 8m 34s ─` summary plus a fresh `❯ Run /review` prompt. The
|
|
517
|
+
// pre-fix `working_seconds` full-buffer find matched the 514s token and
|
|
518
|
+
// returned ≥300 → Stuck. The pre-fix `latest_prompt_signal` rfind let the
|
|
519
|
+
// historical spinner out-position the bottom `❯` → Working. E47 narrows
|
|
520
|
+
// both probes to the bottom active region (last 1-3 non-empty lines).
|
|
521
|
+
//
|
|
522
|
+
// The authoritative provider JSONL classify is wired in coordinator/tick.rs
|
|
523
|
+
// (jsonl_activity_for_agent); these unit tests pin the TUI fallback layer.
|
|
524
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
525
|
+
|
|
526
|
+
#[test]
|
|
527
|
+
fn e47_codex_idle_with_historical_working_in_scrollback_is_idle_not_stuck_or_working() {
|
|
528
|
+
// Exact macmini repro shape from the architect locate (E47).
|
|
529
|
+
let scrollback = "• Working (514s · esc to interrupt)\n\
|
|
530
|
+
tool call 1\n\
|
|
531
|
+
tool call 2\n\
|
|
532
|
+
─ Worked for 8m 34s ─\n\
|
|
533
|
+
› Run /review\n\
|
|
534
|
+
❯ ";
|
|
535
|
+
let st = serde_json::json!({});
|
|
536
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
537
|
+
assert_eq!(
|
|
538
|
+
a.status,
|
|
539
|
+
ActivityStatus::Idle,
|
|
540
|
+
"E47 (RED-1): codex idle worker whose scrollback still carries \
|
|
541
|
+
historical `• Working (514s · esc to interrupt)` must classify IDLE \
|
|
542
|
+
(bottom active region is `❯`+`› Run /review` = idle composer). \
|
|
543
|
+
pre-fix: Stuck/Working via rfind-recency. Got {a:?}"
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
#[test]
|
|
548
|
+
fn e47_past_tense_worked_for_is_not_stale_working_indicator() {
|
|
549
|
+
// RED-2: past-tense `Worked for` summary line must NOT trigger
|
|
550
|
+
// working_seconds Stuck. The pre-fix scanned the whole buffer and would
|
|
551
|
+
// match the embedded `8m 34s ─` against `working (` if formatted slightly
|
|
552
|
+
// differently — but more importantly this test pins the "past-tense
|
|
553
|
+
// summary is idle context" semantics.
|
|
554
|
+
let scrollback = "─ Worked for 8m 34s ─\n❯ ";
|
|
555
|
+
let st = serde_json::json!({});
|
|
556
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
557
|
+
assert_ne!(
|
|
558
|
+
a.status,
|
|
559
|
+
ActivityStatus::Stuck,
|
|
560
|
+
"E47 (RED-2): past-tense `Worked for 8m 34s` must NOT trigger \
|
|
561
|
+
stale_working_indicator Stuck. Got {a:?}"
|
|
562
|
+
);
|
|
563
|
+
assert_eq!(
|
|
564
|
+
a.status,
|
|
565
|
+
ActivityStatus::Idle,
|
|
566
|
+
"E47 (RED-2): bottom `❯` composer = idle. Got {a:?}"
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#[test]
|
|
571
|
+
fn e47_claude_idle_with_historical_spinner_classifies_idle() {
|
|
572
|
+
// RED-3: claude idle worker; historical spinner shape (`✶` per
|
|
573
|
+
// adapter.rs:875-876) earlier in the buffer (>= 3 non-empty lines deep),
|
|
574
|
+
// bottom composer is idle (`›` glyph variant or `> ` claude prompt).
|
|
575
|
+
// The bottom active region (last 3 non-empty lines) has NO spinner.
|
|
576
|
+
let scrollback = "✶ Working on plan\n\
|
|
577
|
+
tool 1\n\
|
|
578
|
+
tool 2\n\
|
|
579
|
+
assistant reply text\n\
|
|
580
|
+
summary line A\n\
|
|
581
|
+
summary line B\n\
|
|
582
|
+
› Continue?";
|
|
583
|
+
let st = serde_json::json!({});
|
|
584
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
585
|
+
assert_eq!(
|
|
586
|
+
a.status,
|
|
587
|
+
ActivityStatus::Idle,
|
|
588
|
+
"E47 (RED-3): claude idle worker — historical ✶ above, idle `›` \
|
|
589
|
+
below — must classify IDLE. Got {a:?}"
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
#[test]
|
|
594
|
+
fn e47_codex_live_spinner_in_bottom_active_region_classifies_working() {
|
|
595
|
+
// RED-4 (defence vs over-tightening 假阴): a TRULY busy codex worker
|
|
596
|
+
// whose bottom active region carries the live spinner must still be
|
|
597
|
+
// Working. No idle composer present.
|
|
598
|
+
let scrollback = "tool call x\nassistant response so far\n• Working (12s · esc to interrupt)";
|
|
599
|
+
let st = serde_json::json!({});
|
|
600
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
601
|
+
assert_eq!(
|
|
602
|
+
a.status,
|
|
603
|
+
ActivityStatus::Working,
|
|
604
|
+
"E47 (RED-4): live codex spinner in bottom active region must \
|
|
605
|
+
classify Working. Got {a:?}"
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
#[test]
|
|
610
|
+
fn e47_claude_live_working_in_bottom_active_region_classifies_working() {
|
|
611
|
+
// RED-5: a TRULY busy claude worker showing `✶ Working` at the bottom
|
|
612
|
+
// active region must be Working — even without the `(Ns)` codex shape.
|
|
613
|
+
let scrollback = "earlier output\nmore output\n✶ Working through the request";
|
|
614
|
+
let st = serde_json::json!({});
|
|
615
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
616
|
+
assert_eq!(
|
|
617
|
+
a.status,
|
|
618
|
+
ActivityStatus::Working,
|
|
619
|
+
"E47 (RED-5): claude ✶ Working in bottom active region → Working. \
|
|
620
|
+
Got {a:?}"
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
#[test]
|
|
625
|
+
fn e47_iron_law_no_signal_in_bottom_region_stays_uncertain_not_idle() {
|
|
626
|
+
// Defence: IRON LAW (activity.rs:3 / bug-071/077/085). When the bottom
|
|
627
|
+
// active region has no spinner AND no `❯`/`›` AND no other decisive
|
|
628
|
+
// signal, classify_agent_activity falls through to no_decisive_signal →
|
|
629
|
+
// Uncertain — NEVER silently to Idle.
|
|
630
|
+
let scrollback = "some neutral text\nmore neutral text\nstill nothing decisive";
|
|
631
|
+
let st = serde_json::json!({});
|
|
632
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
633
|
+
assert_eq!(
|
|
634
|
+
a.status,
|
|
635
|
+
ActivityStatus::Uncertain,
|
|
636
|
+
"E47 IRON LAW guard: bottom region carries no spinner and no idle \
|
|
637
|
+
composer → Uncertain, never silently Idle. Got {a:?}"
|
|
638
|
+
);
|
|
639
|
+
}
|