@cohortapp/agent-sdk 2.18.15 → 2.18.17
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/lib/org/inbound/hydrate.mjs +35 -1
- package/lib/org/inbound/project.mjs +3 -0
- package/lib/session/frontdoor.mjs +105 -8
- package/lib/session/handoffs.mjs +57 -0
- package/lib/session/inbox-claims.mjs +106 -2
- package/lib/session/revive.mjs +302 -5
- package/lib/telemetry/collect.mjs +224 -0
- package/package.json +1 -1
- package/scripts/daemon/agent-daemon.mjs +262 -10
- package/scripts/daemon/assurance.mjs +56 -1
- package/scripts/daemon/deliver.mjs +80 -0
- package/scripts/fleet/rollout.mjs +48 -4
- package/scripts/hooks/pre-write-yaml-validate.mjs +63 -2
- package/scripts/local-triggers/autoupdate.sh +287 -22
|
@@ -160,10 +160,12 @@ import { processOne } from "../../lib/execution/pipeline.mjs";
|
|
|
160
160
|
// of spawning `claude --print`; not live → today's dispatch, unchanged. The
|
|
161
161
|
// gate is pure (lib/session/frontdoor.mjs); the assurance sweep reopens a
|
|
162
162
|
// session claim that was neither replied nor done within 20 min.
|
|
163
|
-
import { shouldReviveFrontDoor, reviveCommand, sessionJobLabelOnDisk } from "../../lib/session/revive.mjs";
|
|
164
|
-
import {
|
|
163
|
+
import { shouldReviveFrontDoor, reviveCommand, sessionJobLabelOnDisk, confirmRevived, isTerminalReviveFailure, detectUnrequestedRestart } from "../../lib/session/revive.mjs";
|
|
164
|
+
import { budgetSpentNote } from "../../lib/telemetry/collect.mjs";
|
|
165
|
+
import { checkLock as checkProcessLock } from "../../lib/singleton.js";
|
|
166
|
+
import { makeFrontDoorGate, shouldDaemonDispatch, sessionLiveFromHeartbeat, readFrontDoorState, DEFAULT_STALE_MS } from "../../lib/session/frontdoor.mjs";
|
|
165
167
|
import { CADENCE_REGISTRY } from "./cadence-handlers.mjs";
|
|
166
|
-
import { sweepSessionInboxForDaemon } from "../../lib/session/inbox-claims.mjs";
|
|
168
|
+
import { sweepSessionInboxForDaemon, itemHandledByDoor } from "../../lib/session/inbox-claims.mjs";
|
|
167
169
|
import { defaultEffects, scheduleToQueue } from "../../lib/execution/effects.mjs";
|
|
168
170
|
import { loadReactObligations } from "../../lib/execution/match.mjs";
|
|
169
171
|
import { policyFor } from "../../lib/execution/surface-policy.mjs";
|
|
@@ -294,33 +296,264 @@ const _frontDoorGate = makeFrontDoorGate({ agentRoot: AGENT_REPO_DIR });
|
|
|
294
296
|
// we want rather than a stale count carried across it.
|
|
295
297
|
/** How often the daemon asks whether the front door has gone quiet. */
|
|
296
298
|
const REVIVE_CHECK_INTERVAL_MS = 60 * 1000;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
+
// `notLive` is the count of CONSECUTIVE not-live reads — the actor-side
|
|
300
|
+
// hysteresis (Ravi). Takeover is harmless and flips at one read; a kickstart
|
|
301
|
+
// drops a working session, so the FIRST one waits for two consecutive not-live
|
|
302
|
+
// reads. A live read resets it. Per-process and deliberately not persisted: a
|
|
303
|
+
// daemon restart is itself a change of circumstances.
|
|
304
|
+
// `halted` latches when an assert comes back TERMINAL (phantom pid / crash
|
|
305
|
+
// loop): the seat needs a person, and a blind second kickstart would bury the
|
|
306
|
+
// first failure's log under the loop. One attempt on that road, then hand up.
|
|
307
|
+
//
|
|
308
|
+
// `confirmedIdentity` carries the last poll's {pid, startTime} ACROSS the 60s
|
|
309
|
+
// poll cadence, and `kickstartThisInterval` records whether THIS daemon
|
|
310
|
+
// kickstarted the job since that identity was recorded. A within-window
|
|
311
|
+
// continuity check cannot see a crash loop whose period exceeds the settle
|
|
312
|
+
// window — the same identity appears twice inside it. Comparing the confirmed
|
|
313
|
+
// identity against the next poll's observation, with the kickstart flag,
|
|
314
|
+
// catches an UNREQUESTED restart at ANY period, with no window to tune.
|
|
315
|
+
const _revive = { attempts: 0, lastAt: null, notLive: 0, halted: false, confirmedIdentity: null, kickstartThisInterval: false };
|
|
316
|
+
/** TEST-ONLY: clear the per-process revive state between cases. */
|
|
317
|
+
export function _resetRevive() {
|
|
318
|
+
_revive.attempts = 0; _revive.lastAt = null; _revive.notLive = 0; _revive.halted = false;
|
|
319
|
+
_revive.confirmedIdentity = null; _revive.kickstartThisInterval = false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Gap between the two settle-window reads that prove the new session did not
|
|
323
|
+
* come up and immediately crash. Short — the door was already dark for the
|
|
324
|
+
* full grace before we kickstarted, so this only has to outlast a boot wobble. */
|
|
325
|
+
const CONFIRM_SETTLE_MS = 5000;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* One observation of the session job: its launchctl pid, whether that pid is a
|
|
329
|
+
* GENUINELY LIVE process (not a phantom launchctl lists but `ps` says is dead —
|
|
330
|
+
* a silent crash loop), the process start-time that fingerprints the instance,
|
|
331
|
+
* and the COMM (the binary running under the pid). A relaunch changes the
|
|
332
|
+
* start-time even if the pid is reused, so comparing start-times across the
|
|
333
|
+
* settle window is how a crash loop is told apart from one continuous process;
|
|
334
|
+
* the comm is the third, complementary check — a live pid running the WRONG
|
|
335
|
+
* binary is not the process we kickstarted.
|
|
336
|
+
*
|
|
337
|
+
* ABSOLUTE `/bin/ps`, never bare `ps`: `ps` is aliased to `pnpm start` on this
|
|
338
|
+
* fleet, so a bare `ps` is poison. `kill -0` (a builtin/alias-immune existence
|
|
339
|
+
* signal) decides liveness; `/bin/ps` reads the immutable start-time and comm.
|
|
340
|
+
*/
|
|
341
|
+
async function readSessionSample(label) {
|
|
342
|
+
let pid = null, alive = false, startTime = null, comm = null;
|
|
343
|
+
try {
|
|
344
|
+
const { stdout } = await execFileAsync("launchctl", ["list", label]);
|
|
345
|
+
const m = /"PID"\s*=\s*(\d+)/.exec(String(stdout || ""));
|
|
346
|
+
if (m) pid = Number(m[1]);
|
|
347
|
+
} catch { /* no pid — the kickstart produced no running process */ }
|
|
348
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
349
|
+
try { process.kill(pid, 0); alive = true; } catch { alive = false; } // signal 0 = existence check
|
|
350
|
+
try {
|
|
351
|
+
// One /bin/ps call for both the start-time and the comm — absolute path so
|
|
352
|
+
// the `ps`→`pnpm start` alias cannot poison it.
|
|
353
|
+
const { stdout } = await execFileAsync("/bin/ps", ["-o", "lstart=,comm=", "-p", String(pid)]);
|
|
354
|
+
const line = String(stdout || "").trim();
|
|
355
|
+
// `lstart` is a fixed-width 24-char date ("Wed Sep 25 12:00:00 2026")
|
|
356
|
+
// followed by the comm; split on the last field boundary the date ends at.
|
|
357
|
+
const m = /^(.{24})\s+(.*)$/.exec(line);
|
|
358
|
+
if (m) { startTime = m[1].trim() || null; comm = m[2].trim() || null; }
|
|
359
|
+
else { startTime = line || null; } // unexpected shape — keep what we have, comm unknown
|
|
360
|
+
} catch { startTime = null; comm = null; } // ps gone or pid vanished — degrade; pid continuity still carries
|
|
361
|
+
}
|
|
362
|
+
return { pid, alive, startTime, comm };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The binary the session job should be running — matched by suffix against the
|
|
366
|
+
* observed comm. Bare "node" so any install prefix (/opt/homebrew/bin/node,
|
|
367
|
+
* /usr/local/bin/node) matches; a live pid running anything else (a `ps`-alias
|
|
368
|
+
* stray `pnpm`, a wrong process) fails the comm-check and names `wrong-comm`. */
|
|
369
|
+
const EXPECTED_SESSION_COMM = "node";
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* After a kickstart, ASSERT the door came back before recording success — a
|
|
373
|
+
* restart helper's exit code is not a revive (Jacob's drain-then-restart-daemon.sh
|
|
374
|
+
* exited 0 and left the process stopped with NO pid). A ONE-SHOT read is not
|
|
375
|
+
* enough: it passes a phantom pid and a come-up-then-crash loop whose local beat
|
|
376
|
+
* file always looks fresh. So take TWO reads across a settle window (proving the
|
|
377
|
+
* process is one continuous instance, not a crash loop) and pair them with the
|
|
378
|
+
* session's OWN beat freshness — for a session that beat IS the after-signal a
|
|
379
|
+
* wedge kills, because the heartbeat rides tool use, so a session that stops
|
|
380
|
+
* working stops beating (unlike a daemon, which a crash loop keeps re-stamping).
|
|
381
|
+
*/
|
|
382
|
+
async function defaultConfirmAfter({ label, now, staleMs, sleep }) {
|
|
383
|
+
const _sleep = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
384
|
+
const s1 = await readSessionSample(label);
|
|
385
|
+
await _sleep(CONFIRM_SETTLE_MS);
|
|
386
|
+
const s2 = await readSessionSample(label);
|
|
387
|
+
// serverBeatFresh for the SESSION: is the heartbeat it should now be writing
|
|
388
|
+
// fresh? undefined (couldn't read) does not block — continuity carries it.
|
|
389
|
+
let serverBeatFresh;
|
|
390
|
+
try {
|
|
391
|
+
const st = readFrontDoorState(AGENT_REPO_DIR, { now, staleMs });
|
|
392
|
+
if (st && st.liveness && Number.isFinite(st.liveness.ageMs)) {
|
|
393
|
+
serverBeatFresh = st.liveness.ageMs < (staleMs || DEFAULT_STALE_MS);
|
|
394
|
+
}
|
|
395
|
+
} catch { /* still stale — a fresh session has not beaten yet; leave undefined */ }
|
|
396
|
+
return { samples: [s1, s2], serverBeatFresh };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// A failed revive has to reach the FLEET, not just this log: a kickstart that
|
|
400
|
+
// returned with no live pid is a failure to escalate — the seat needs a person.
|
|
401
|
+
// collect.mjs reads this into `machine.reviveNote`, so the presence beat carries
|
|
402
|
+
// it. Best-effort (the console line already fired); cleared on a confirmed revive.
|
|
403
|
+
const REVIVE_NOTE_PATH = join(AGENT_REPO_DIR, "state", "telemetry", "revive-note.json");
|
|
404
|
+
function writeReviveNote({ reason, target, detail, at }) {
|
|
405
|
+
try {
|
|
406
|
+
mkdirSync(join(AGENT_REPO_DIR, "state", "telemetry"), { recursive: true });
|
|
407
|
+
const tmp = `${REVIVE_NOTE_PATH}.${process.pid}.tmp`;
|
|
408
|
+
writeFileSync(tmp, JSON.stringify({ reason, target: target || null, detail: detail || "", at }));
|
|
409
|
+
renameSync(tmp, REVIVE_NOTE_PATH);
|
|
410
|
+
} catch { /* the beat surface is best-effort; the log line already fired */ }
|
|
411
|
+
}
|
|
412
|
+
function clearReviveNote() { try { unlinkSync(REVIVE_NOTE_PATH); } catch { /* nothing to clear */ } }
|
|
413
|
+
|
|
414
|
+
export async function reviveFrontDoorIfShut(state, deps = {}) {
|
|
299
415
|
const now = deps.now ? deps.now() : Date.now();
|
|
416
|
+
// TEST-ONLY seam: seed the per-process ladder state so a case can start on a
|
|
417
|
+
// given rung (e.g. budget already spent) without threading a dozen polls.
|
|
418
|
+
if (deps.seedRevive && typeof deps.seedRevive === "object") Object.assign(_revive, deps.seedRevive);
|
|
419
|
+
// A latched terminal failure (a prior kickstart came up and crash-looped, or
|
|
420
|
+
// produced a phantom pid) means the seat is a person's problem now — do not
|
|
421
|
+
// fire another blind kickstart on top of a loop that would bury the evidence.
|
|
422
|
+
if (_revive.halted) return { revive: false, reason: "halted-escalated" };
|
|
300
423
|
const live = sessionLiveFromHeartbeat(state && state.heartbeat, { now });
|
|
424
|
+
const label = deps.label || sessionJobLabelOnDisk(readdirSync, homedir());
|
|
425
|
+
|
|
426
|
+
// ── CROSS-POLL CRASH-LOOP CATCH (unbounded, no threshold) ─────────────────
|
|
427
|
+
//
|
|
428
|
+
// The within-window continuity check inside confirmRevived cannot see a crash
|
|
429
|
+
// loop whose period EXCEEDS the settle window: it shows the same identity
|
|
430
|
+
// twice inside a few seconds and false-confirms. Between two 60s polls,
|
|
431
|
+
// though, the loop has restarted — so if we hold a previously CONFIRMED
|
|
432
|
+
// identity and this poll observes a DIFFERENT one with no kickstart issued in
|
|
433
|
+
// the interval, that is an unrequested restart at ANY period. HALT and note it
|
|
434
|
+
// rather than blindly re-confirm/re-kickstart a loop forever.
|
|
435
|
+
//
|
|
436
|
+
// Armed ONLY while the door is still in trouble (not reading live). A door
|
|
437
|
+
// that is healthily answering again has recovered — a later identity change is
|
|
438
|
+
// ordinary churn (an SDK upgrade, a `session restart`, a supervisor resume
|
|
439
|
+
// rotation), none of which this daemon should police as a crash loop. So a
|
|
440
|
+
// healthy live read DISARMS the check; the loop this hunts is precisely one
|
|
441
|
+
// whose relaunched instance never beats a full grace, so it stays not-live.
|
|
442
|
+
if (label && _revive.confirmedIdentity && state && state.sessionLive !== true) {
|
|
443
|
+
const readSample = deps.readSessionSample || readSessionSample;
|
|
444
|
+
let curr = null;
|
|
445
|
+
try { curr = await readSample(label); } catch { curr = null; }
|
|
446
|
+
const drift = detectUnrequestedRestart({
|
|
447
|
+
prev: _revive.confirmedIdentity,
|
|
448
|
+
curr,
|
|
449
|
+
kickstartIssuedSince: _revive.kickstartThisInterval,
|
|
450
|
+
});
|
|
451
|
+
if (drift.restarted) {
|
|
452
|
+
_revive.halted = true;
|
|
453
|
+
const detail = `crash-loop-cross-poll: pid ${_revive.confirmedIdentity.pid}(${_revive.confirmedIdentity.startTime}) → pid ${curr && curr.pid}(${curr && curr.startTime}) with no kickstart since`;
|
|
454
|
+
console.error(`[daemon] ${label} identity changed across the poll cadence with no kickstart issued (${detail}) — a crash loop whose period outran the settle window; halting and escalating (this seat needs a person)`);
|
|
455
|
+
writeReviveNote({ reason: "session-revive-failed", target: label, detail, at: new Date(now).toISOString() });
|
|
456
|
+
return { revive: false, reason: "crash-loop-cross-poll", confirmed: { ok: false, reason: "crash-loop-cross-poll" } };
|
|
457
|
+
}
|
|
458
|
+
} else if (state && state.sessionLive === true && _revive.confirmedIdentity) {
|
|
459
|
+
// Recovered and answering — disarm; a healthy session's later identity
|
|
460
|
+
// changes are churn, not the loop.
|
|
461
|
+
_revive.confirmedIdentity = null;
|
|
462
|
+
_revive.kickstartThisInterval = false;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Track the consecutive not-live streak BEFORE the decision; a live read
|
|
466
|
+
// resets it so a session that beats between two quiet reads never accrues.
|
|
467
|
+
const notLiveNow = !(state && state.sessionLive === true);
|
|
468
|
+
_revive.notLive = notLiveNow ? _revive.notLive + 1 : 0;
|
|
469
|
+
const reviveAfterMs = Number(process.env.MAESTRO_SESSION_STALE_S || 0) * 1000 || undefined;
|
|
301
470
|
const verdict = shouldReviveFrontDoor({
|
|
302
471
|
frontDoor: state && state.frontDoor,
|
|
303
472
|
sessionLive: state && state.sessionLive,
|
|
304
473
|
silentMs: live.ageMs,
|
|
474
|
+
notLiveReads: _revive.notLive,
|
|
305
475
|
attempts: _revive.attempts,
|
|
306
476
|
lastAttemptAt: _revive.lastAt,
|
|
307
477
|
now,
|
|
308
|
-
reviveAfterMs
|
|
478
|
+
reviveAfterMs,
|
|
309
479
|
});
|
|
310
|
-
if (!verdict.revive)
|
|
311
|
-
|
|
480
|
+
if (!verdict.revive) {
|
|
481
|
+
// A ladder that spent its whole budget and STOPPED is a failure that does
|
|
482
|
+
// not name itself — the seat goes quiet and a quiet system looks healthy.
|
|
483
|
+
// Turn that silence into a beat note through the same reviveNote channel.
|
|
484
|
+
if (verdict.reason === "budget-spent") {
|
|
485
|
+
const note = budgetSpentNote({ verdict: verdict.reason, target: label || null, attempts: _revive.attempts, at: new Date(now).toISOString() });
|
|
486
|
+
if (note) writeReviveNote(note);
|
|
487
|
+
}
|
|
488
|
+
return verdict;
|
|
489
|
+
}
|
|
312
490
|
if (!label) return { revive: false, reason: "no-session-job" };
|
|
491
|
+
|
|
492
|
+
// Supervisor coordination via the resume loop's OWN lock, not a soft note
|
|
493
|
+
// (Jacob). The supervisor holds the `session` singleton for its whole life
|
|
494
|
+
// and writes the heartbeat as a healthy resume makes progress. We only reach
|
|
495
|
+
// here after the door has been dark past the FULL grace AND confirmed over
|
|
496
|
+
// two reads 60s apart — so a resume gap is already excluded by time, and a
|
|
497
|
+
// live holder here is a supervisor PARKED in its launch probe (the 3.5-day /
|
|
498
|
+
// 15-day case this module exists for), for which the hard kickstart is
|
|
499
|
+
// exactly right. Consulting the lock tells a parked supervisor (override,
|
|
500
|
+
// logged) apart from a dead one (a clean restart), and never blocks the
|
|
501
|
+
// parked case — the failure that a hard "skip if held" would reintroduce.
|
|
502
|
+
const supervisor = (deps.checkSessionLock || (() => checkProcessLock("session")))();
|
|
503
|
+
if (supervisor && supervisor.running) {
|
|
504
|
+
console.error(`[daemon] ${label} front door dark past grace while a supervisor (pid ${supervisor.pid}) still holds the session lock — parked in its launch probe; hard-kickstarting over it`);
|
|
505
|
+
}
|
|
506
|
+
|
|
313
507
|
_revive.attempts += 1;
|
|
314
508
|
_revive.lastAt = now;
|
|
509
|
+
// This daemon is now the one that asked for the restart, so a new identity on
|
|
510
|
+
// the NEXT poll is expected, not the crash loop the cross-poll check hunts.
|
|
511
|
+
_revive.kickstartThisInterval = true;
|
|
315
512
|
const cmd = reviveCommand(label, typeof process.getuid === "function" ? process.getuid() : 0);
|
|
316
513
|
console.error(`[daemon] front door shut — ${verdict.reason}; restarting ${label}`);
|
|
317
514
|
try {
|
|
318
515
|
await (deps.execFile || execFileAsync)(cmd.file, cmd.args);
|
|
319
|
-
console.error(`[daemon] ${label} restarted; a fresh session clears whatever the old one was sitting on`);
|
|
320
516
|
} catch (err) {
|
|
321
517
|
console.error(`[daemon] could not restart ${label}: ${err && err.message ? err.message : err} — this seat needs a person`);
|
|
518
|
+
return { ...verdict, confirmed: { ok: false, reason: "kickstart-threw" } };
|
|
519
|
+
}
|
|
520
|
+
// The kickstart returned. Now prove it actually came back — across a settle
|
|
521
|
+
// window, not a one-shot read that a phantom pid or a come-up-then-crash
|
|
522
|
+
// would pass.
|
|
523
|
+
const staleMs = reviveAfterMs;
|
|
524
|
+
const after = deps.confirmAfter
|
|
525
|
+
? await deps.confirmAfter({ label, now, staleMs })
|
|
526
|
+
: await defaultConfirmAfter({ label, now, staleMs });
|
|
527
|
+
const confirmed = confirmRevived({ ...after, staleMs, expectedComm: EXPECTED_SESSION_COMM });
|
|
528
|
+
if (confirmed.ok) {
|
|
529
|
+
console.error(`[daemon] ${label} restarted and answering (${confirmed.reason}); a fresh session clears whatever the old one was sitting on`);
|
|
530
|
+
clearReviveNote();
|
|
531
|
+
// Record the identity this revive confirmed so the NEXT poll can tell one
|
|
532
|
+
// continuous instance from a crash loop whose period outran the settle
|
|
533
|
+
// window. Reset the kickstart flag: the interval it justified is over.
|
|
534
|
+
const alive = Array.isArray(after && after.samples) ? after.samples.find((s) => s && Number.isInteger(s.pid) && s.pid > 0 && s.alive === true) : null;
|
|
535
|
+
_revive.confirmedIdentity = alive ? { pid: alive.pid, startTime: alive.startTime } : null;
|
|
536
|
+
_revive.kickstartThisInterval = false;
|
|
537
|
+
} else if (isTerminalReviveFailure(confirmed)) {
|
|
538
|
+
// Phantom pid or a restart boundary inside the settle window: the kickstart
|
|
539
|
+
// produced no living, continuous process. STOP — one attempt, then hand up
|
|
540
|
+
// with what we saw, rather than kickstart again into a loop that buries it.
|
|
541
|
+
_revive.halted = true;
|
|
542
|
+
const evidence = describeSamples(after && after.samples);
|
|
543
|
+
console.error(`[daemon] ${label} kickstart did NOT revive it (${confirmed.reason}): ${evidence} — a helper's exit code is not a revive; this seat needs a person (failure to escalate)`);
|
|
544
|
+
writeReviveNote({ reason: "session-revive-failed", target: label, detail: `${confirmed.reason}: ${evidence}`, at: new Date(now).toISOString() });
|
|
545
|
+
} else {
|
|
546
|
+
console.error(`[daemon] ${label} restarted; awaiting its first beat (${confirmed.reason}) — the next check confirms or re-attempts within budget`);
|
|
322
547
|
}
|
|
323
|
-
return verdict;
|
|
548
|
+
return { ...verdict, confirmed };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** One-line evidence of what the settle-window reads actually saw, for the log
|
|
552
|
+
* and the beat note — so a failed escalation carries the fingerprint, not just
|
|
553
|
+
* a verdict. */
|
|
554
|
+
function describeSamples(samples) {
|
|
555
|
+
if (!Array.isArray(samples) || !samples.length) return "no reads";
|
|
556
|
+
return samples.map((s) => `pid=${s && s.pid != null ? s.pid : "none"}${s && s.alive ? "(alive)" : "(dead)"}`).join(" then ");
|
|
324
557
|
}
|
|
325
558
|
|
|
326
559
|
/**
|
|
@@ -362,6 +595,25 @@ export async function pollService(svc, deps = {}) {
|
|
|
362
595
|
return false;
|
|
363
596
|
}
|
|
364
597
|
|
|
598
|
+
// DM-LATENCY DOUBLE-ANSWER GUARD (2026-09-27). Past the seat-wide front-door
|
|
599
|
+
// gate, this is the per-item check the gate cannot make: has the door
|
|
600
|
+
// ALREADY handled THIS item on disk? The gate opened for the service because
|
|
601
|
+
// a live door had not stamped last-consumed (reason `session-not-consuming`),
|
|
602
|
+
// but the door that read+closed a specific message left its marker
|
|
603
|
+
// (`.yaml.processed`, or a live `claimed_by: session` `.yaml.dispatched`) on
|
|
604
|
+
// that item regardless of whether it restarted onto the stamp path. Taking
|
|
605
|
+
// it here posts a SECOND reply in the seat owner's name and races the door's
|
|
606
|
+
// own queue writer — the exact regression (Hannah Brooks, James's 18:42Z DM).
|
|
607
|
+
// `shouldDaemonDispatch` makes the call so the one predicate owns the whole
|
|
608
|
+
// gate; `itemHandledByDoor` is the id-keyed disk read it needs. Fail-open:
|
|
609
|
+
// an unreadable marker reads as not-handled, so a real ask is never dropped.
|
|
610
|
+
const handled = itemHandledByDoor(AGENT_REPO_DIR, svc.name, item);
|
|
611
|
+
const perItem = shouldDaemonDispatch({ ...(fd.state || {}), service: svc.name, itemHandled: handled.handled });
|
|
612
|
+
if (!perItem.dispatch) {
|
|
613
|
+
console.log(`[daemon] leaving ${item.id || item.raw_ref} to the front door on ${svc.name} (${perItem.reason}${handled.reason ? `: ${handled.reason}` : ""})`);
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
|
|
365
617
|
const lockKey = item.raw_ref || item.id || `${svc.name}-${Date.now()}`;
|
|
366
618
|
if (seenRefs.has(lockKey)) return false;
|
|
367
619
|
seenRefs.add(lockKey);
|
|
@@ -78,7 +78,8 @@ import * as counters from "../../lib/diagnostics/counters.mjs";
|
|
|
78
78
|
// the only composer of interim copy left in the system, and carries the two
|
|
79
79
|
// copy rules (GENERIC_OPENER, FORWARD_PROMISE) every interim must satisfy.
|
|
80
80
|
import { replyTier } from "../../lib/assurance/tier.mjs";
|
|
81
|
-
import { isRollCallItem } from "../../lib/org/inbound/broadcast.mjs";
|
|
81
|
+
import { isRollCallItem, BROADCAST_REASON } from "../../lib/org/inbound/broadcast.mjs";
|
|
82
|
+
import { isMembershipReason } from "../../lib/org/inbound/directedness.mjs";
|
|
82
83
|
import {
|
|
83
84
|
claimRoomInterim,
|
|
84
85
|
claimRoomNotice,
|
|
@@ -397,6 +398,16 @@ export function itemSnapshot(item) {
|
|
|
397
398
|
// reads belongs in this snapshot.
|
|
398
399
|
scope_id: item.scope_id || null,
|
|
399
400
|
thread_id: item.thread_id || null,
|
|
401
|
+
// WHY THIS ADDRESS PROVENANCE IS A DEBT FIELD, not decoration. The sweep and
|
|
402
|
+
// the silent-success path SPEAK from `rec.item`, long after the live item is
|
|
403
|
+
// gone, and one decision they make is whether a rescued result belongs in
|
|
404
|
+
// this room at all: a membership/broadcast-proved surface (a doc comment
|
|
405
|
+
// shared to a channel, a room this seat merely belongs to) is ambient, and
|
|
406
|
+
// dumping a finished session's result text into it is narration nobody is
|
|
407
|
+
// waiting on 1:1 — the exact leak doc-comment surfaces close silently to
|
|
408
|
+
// avoid. Without `direct_reason` in the snapshot the DM/ambient distinction
|
|
409
|
+
// is unrecoverable at sweep time; with it, the guard fails open to a DM.
|
|
410
|
+
direct_reason: item.direct_reason || null,
|
|
400
411
|
sender: item.sender || null,
|
|
401
412
|
sender_email: item.sender_email || null,
|
|
402
413
|
subject: item.subject || null,
|
|
@@ -1280,6 +1291,32 @@ export function composeSilentSuccess(rec, o = {}) {
|
|
|
1280
1291
|
return `${head} I don't have a clean result to show you, though. Want me to run it again?`;
|
|
1281
1292
|
}
|
|
1282
1293
|
|
|
1294
|
+
/**
|
|
1295
|
+
* Is this obligation's surface AMBIENT rather than a 1:1 the requester waits on?
|
|
1296
|
+
*
|
|
1297
|
+
* The signal is `direct_reason` — WHY the inbound join decided this event was
|
|
1298
|
+
* this seat's — carried into the snapshot by `itemSnapshot`. A membership or
|
|
1299
|
+
* broadcast reason (`channel`/`participant`/`shared`, or `collective`) is
|
|
1300
|
+
* access-shaped: the seat was addressed because it can SEE the thing, not
|
|
1301
|
+
* because anything named it, so any number of seats in the room hold the same
|
|
1302
|
+
* event. An identity-shaped reason (a DM, `owner`, `named`, an assignee) is one
|
|
1303
|
+
* person waiting on one reply.
|
|
1304
|
+
*
|
|
1305
|
+
* FAILS OPEN TO "NOT AMBIENT" (i.e. narrate). An item whose provenance was
|
|
1306
|
+
* never stamped — the historical default, and every pre-existing silent-success
|
|
1307
|
+
* test — is treated as a waiting DM, because the cost of narrating into a real
|
|
1308
|
+
* DM is nothing and the cost of silencing one is the very defect this module
|
|
1309
|
+
* exists to fix.
|
|
1310
|
+
*
|
|
1311
|
+
* @param {object} rec the obligation record; its `item.direct_reason` is read
|
|
1312
|
+
* @returns {boolean}
|
|
1313
|
+
*/
|
|
1314
|
+
function isAmbientSurface(rec) {
|
|
1315
|
+
const reason = String((rec && rec.item && rec.item.direct_reason) || "");
|
|
1316
|
+
if (!reason) return false; // unknown provenance → treat as a DM, narrate
|
|
1317
|
+
return isMembershipReason(reason) || reason === BROADCAST_REASON;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1283
1320
|
/** A session interrupted by the daemon itself dying/restarting. */
|
|
1284
1321
|
export function composeInterrupted() {
|
|
1285
1322
|
return `Heads up — my session on this was interrupted before it finished (my end restarted). I've picked it back up; I'll come back with the answer.`;
|
|
@@ -1843,6 +1880,24 @@ export async function settleSession(a = {}) {
|
|
|
1843
1880
|
}
|
|
1844
1881
|
|
|
1845
1882
|
if (a.ok && !heard) {
|
|
1883
|
+
// A CLEAN SILENT EXIT OUTSIDE A DM CLOSES QUIETLY, LIKE A DOC COMMENT.
|
|
1884
|
+
// The rescue message exists to hand a waiting requester the result their
|
|
1885
|
+
// session produced but never sent. On an ambient/membership-proved surface
|
|
1886
|
+
// there is no such requester — the seat was addressed because it can see
|
|
1887
|
+
// the room, not because anyone named it — so the same message is result
|
|
1888
|
+
// text NARRATED into a shared room, the leak doc-comment surfaces already
|
|
1889
|
+
// close silently to avoid. The debt is still discharged (silent is not
|
|
1890
|
+
// un-closed); only the narration is withheld. `direct_reason` reaches here
|
|
1891
|
+
// through `itemSnapshot`, and the guard fails open to narrating.
|
|
1892
|
+
if (isAmbientSurface(rec)) {
|
|
1893
|
+
closeObligation(a.key, {
|
|
1894
|
+
outcome: "answered",
|
|
1895
|
+
now,
|
|
1896
|
+
note: "session exited 0 without speaking; ambient surface closed without narration",
|
|
1897
|
+
});
|
|
1898
|
+
counters.bump("assurance.silent_success_ambient", { service: rec.service });
|
|
1899
|
+
return { spoke: false, text: null, verdict: "silent-success", willRetry: false };
|
|
1900
|
+
}
|
|
1846
1901
|
const finalText = safeResultText(a.stdout);
|
|
1847
1902
|
const text = composeSilentSuccess(rec, { finalText });
|
|
1848
1903
|
const res = await send(item, text, { kind: "reply", idempotencySuffix: `rescue-${rec.attempts || 0}` });
|
|
@@ -714,6 +714,72 @@ export function scaffoldMarkerLeak(text) {
|
|
|
714
714
|
return m ? m[0] : null;
|
|
715
715
|
}
|
|
716
716
|
|
|
717
|
+
// ── A REPLY IN THE SEAT'S VOICE NEVER CARRIES A MACHINE-LOCAL PATH ────────────
|
|
718
|
+
// (DM-LATENCY, 2026-09-27.) When the daemon takeover composed a reply in the
|
|
719
|
+
// seat owner's name, it leaked an absolute local filesystem path — a string no
|
|
720
|
+
// one off this Mac mini can open, and one CLAUDE.md forbids in any outbound.
|
|
721
|
+
// The absolute forms below (`/Users/…`, `/Volumes/…`, `/private/…`,
|
|
722
|
+
// `/var/folders/…`, `/tmp/…`, `/home/…`) are unambiguously machine-local: unlike
|
|
723
|
+
// a bare `lib/foo.mjs` a person might legitimately name in an engineering
|
|
724
|
+
// channel, none of these is ever a shareable reference, so refusing them is
|
|
725
|
+
// high-precision. Permanent refusal (no retry), the same shape as the scaffold
|
|
726
|
+
// gate: the caller escalates rather than re-send, and the double-answer this
|
|
727
|
+
// backstops should not have been composed at all (FIX 1 stops it upstream).
|
|
728
|
+
const LOCAL_PATH_RE = /(?:\/(?:Users|Volumes|private|home)\/|\/var\/folders\/|\/tmp\/)[^\s`)"'<>|]+/;
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* The first machine-local absolute path in `text`, or null when it carries none.
|
|
732
|
+
* @param {string} text
|
|
733
|
+
* @returns {string|null}
|
|
734
|
+
*/
|
|
735
|
+
export function localPathLeak(text) {
|
|
736
|
+
const m = typeof text === "string" ? text.match(LOCAL_PATH_RE) : null;
|
|
737
|
+
return m ? m[0] : null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ── STRIP A LEADING SEAT-FLUSH / RECOVERY FILLER OPENER ───────────────────────
|
|
741
|
+
// The takeover posts the owner would never write opened with a flush apology —
|
|
742
|
+
// "finished this but didn't get a reply out", "catching up on my last" and the
|
|
743
|
+
// like (the Layla/Eli/Candace symptom, 2026-09-27). It is the daemon narrating
|
|
744
|
+
// its OWN plumbing to a human who asked about something else. Unlike a leaked
|
|
745
|
+
// path this is safely removable: only a LEADING filler sentence is stripped, and
|
|
746
|
+
// the substantive answer beneath it is left byte-for-byte. Conservative by
|
|
747
|
+
// design — it matches the flush family and a couple of generic recovery openers,
|
|
748
|
+
// never an arbitrary first sentence, so a real answer that merely starts with
|
|
749
|
+
// "I finished the audit…" is untouched (no reply/flush verb follows).
|
|
750
|
+
const REPLY_FILLER_OPENERS = [
|
|
751
|
+
// "(I've) finished/completed/did this, but the reply didn't get out / never came back / catching up …"
|
|
752
|
+
/^\s*(?:i(?:['’]ve|['’]m| have| am)?\s+)?(?:finished|completed|did|wrapped\s+up|ran)\s+this\b[^.!?\n]*?\b(?:repl(?:y|ies)|did(?:n['’]?t| not)|never|go(?:t)?\s+out|come\s+back|came\s+back|catch(?:ing)?\s+up|flush)[^.!?\n]*[.!?]+\s*/i,
|
|
753
|
+
// Generic flush / recovery openers.
|
|
754
|
+
/^\s*(?:heads[- ]up[—,\-: ]+)?(?:just\s+)?(?:flushing|catching\s+up|picking\s+this\s+back\s+up|circling\s+back|following\s+up\s+on\s+my\s+last|sorry[—,\-: ]+(?:for\s+)?the\s+(?:double|delay|resend))[^.!?\n]*[.!?]+\s*/i,
|
|
755
|
+
];
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Remove a single leading seat-flush / recovery filler sentence, but ONLY when a
|
|
759
|
+
* real answer remains beneath it. Returns the input unchanged when there is no
|
|
760
|
+
* leading filler, when the input is not a string, OR when stripping the opener
|
|
761
|
+
* would leave nothing — because that last case cannot be told apart from a
|
|
762
|
+
* filler CLAUSE that runs into the answer on one line ("finished this but the
|
|
763
|
+
* reply didn't go out — the deploy is green"), and destroying a real answer is
|
|
764
|
+
* far worse than letting a bare filler line through. That bare-filler case is
|
|
765
|
+
* the double-post itself, which FIX 1 stops upstream; here the rule is simply
|
|
766
|
+
* "never eat content". Only the first matching opener is considered.
|
|
767
|
+
*
|
|
768
|
+
* @param {unknown} text
|
|
769
|
+
* @returns {string}
|
|
770
|
+
*/
|
|
771
|
+
export function stripReplyFiller(text) {
|
|
772
|
+
if (typeof text !== "string") return text;
|
|
773
|
+
for (const re of REPLY_FILLER_OPENERS) {
|
|
774
|
+
const m = text.match(re);
|
|
775
|
+
if (m) {
|
|
776
|
+
const rest = text.slice(m[0].length).trimStart();
|
|
777
|
+
return rest ? rest : text; // strip only when an answer survives; never eat content
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return text;
|
|
781
|
+
}
|
|
782
|
+
|
|
717
783
|
/**
|
|
718
784
|
* Deliver `text` to the human behind `item`. No generation, no model, no
|
|
719
785
|
* budget — only transport.
|
|
@@ -805,6 +871,10 @@ export async function deliverReaction(item, emoji, o = {}) {
|
|
|
805
871
|
|
|
806
872
|
export async function deliver(item, text, o = {}) {
|
|
807
873
|
const kind = o.kind || "reply";
|
|
874
|
+
// FIX 2 (DM-LATENCY, 2026-09-27): strip a leading seat-flush / recovery filler
|
|
875
|
+
// opener the door would never write, BEFORE the empty check — a body that was
|
|
876
|
+
// nothing but filler then correctly falls through to "nothing to deliver".
|
|
877
|
+
text = stripReplyFiller(text);
|
|
808
878
|
if (!item || !text || !String(text).trim()) {
|
|
809
879
|
return { sent: false, via: null, error: "nothing to deliver" };
|
|
810
880
|
}
|
|
@@ -859,6 +929,16 @@ export async function deliver(item, text, o = {}) {
|
|
|
859
929
|
try { console.error(`[deliver] refused: raw scaffold marker ${JSON.stringify(leak)} in outbound body (permanent, not retrying)`); } catch { /* */ }
|
|
860
930
|
return { sent: false, via: null, permanent: true, channel: item && item.channel_id, surface, code: "FORBIDDEN_SCOPE", error: `FORBIDDEN_SCOPE: raw scaffold marker ${leak} in outbound body — strip the marker; never send reasoning or transcript wrappers` };
|
|
861
931
|
}
|
|
932
|
+
// FIX 2 (DM-LATENCY, 2026-09-27): a reply in the seat's voice must never carry a
|
|
933
|
+
// machine-local path — no one off this box can open it, and CLAUDE.md forbids
|
|
934
|
+
// it in any outbound. Permanent refusal, same shape as the scaffold gate: the
|
|
935
|
+
// caller escalates rather than leak the path in the seat owner's name.
|
|
936
|
+
const pathLeak = localPathLeak(text);
|
|
937
|
+
if (pathLeak) {
|
|
938
|
+
const surface = (item && item.channel_id) ? "room" : "unknown";
|
|
939
|
+
try { console.error(`[deliver] refused: machine-local path ${JSON.stringify(pathLeak)} in outbound body (permanent, not retrying)`); } catch { /* */ }
|
|
940
|
+
return { sent: false, via: null, permanent: true, channel: item && item.channel_id, surface, code: "FORBIDDEN_SCOPE", error: `FORBIDDEN_SCOPE: machine-local path ${pathLeak} in outbound body — no one off this machine can open it; a reply must not carry a local path` };
|
|
941
|
+
}
|
|
862
942
|
// `permanent` says retrying changes nothing. A caller that cannot tell the
|
|
863
943
|
// difference between "hq blipped" and "there is no transport for telegram"
|
|
864
944
|
// retries both at the same cadence, which is how an impossible send becomes an
|
|
@@ -545,6 +545,15 @@ export function seatVersion(entry) {
|
|
|
545
545
|
* cannot see, and it would be wrong on whichever side of the hq deploy it was
|
|
546
546
|
* not written for.
|
|
547
547
|
*
|
|
548
|
+
* Each row also carries `wedged` (bool) and `wedgeReason` (string|null),
|
|
549
|
+
* derived from the same `machine` block {@link seatStuck} reads: a seat is
|
|
550
|
+
* wedged when the beat itself reports `machine.wedge.reason` (published by
|
|
551
|
+
* collect.mjs's sessionWedge — beating, but not reading its inbox/handoffs),
|
|
552
|
+
* OR when `machine.frontDoor === "session"` and `machine.sessionLive === false`
|
|
553
|
+
* (the front door is shut outright). The version stays on the row either way —
|
|
554
|
+
* wedged is a verdict {@link classifySeat} applies, not a fact this function
|
|
555
|
+
* withholds.
|
|
556
|
+
*
|
|
548
557
|
* @param {object[]} members
|
|
549
558
|
* @param {{seatWindowMs?:number, now?:number}} [o]
|
|
550
559
|
* @returns {{seats:object[], nonSeats:object[], readCarriesVersionField:boolean}}
|
|
@@ -563,6 +572,10 @@ export function toSeats(members, o = {}) {
|
|
|
563
572
|
}
|
|
564
573
|
const p = m.presence && typeof m.presence === "object" ? m.presence : null;
|
|
565
574
|
const beatMs = p && Number.isFinite(p.lastBeatMs) ? p.lastBeatMs : null;
|
|
575
|
+
// Same accessor seatStuck uses: the beat's machine block, wherever it hangs.
|
|
576
|
+
const mach = (m.status && typeof m.status === "object" && m.status.machine) || m.machine || {};
|
|
577
|
+
const wedge = mach.wedge && typeof mach.wedge === "object" ? mach.wedge : null;
|
|
578
|
+
const doorDown = mach.frontDoor === "session" && mach.sessionLive === false;
|
|
566
579
|
const row = {
|
|
567
580
|
id: String(m.id || ""),
|
|
568
581
|
slug: String(m.slug || ""),
|
|
@@ -572,6 +585,8 @@ export function toSeats(members, o = {}) {
|
|
|
572
585
|
lastBeatMs: beatMs,
|
|
573
586
|
...seatVersions(m),
|
|
574
587
|
stuck: seatStuck(m, o.now),
|
|
588
|
+
wedged: !!((wedge && wedge.reason) || doorDown),
|
|
589
|
+
wedgeReason: (wedge && typeof wedge.reason === "string" && wedge.reason) || (doorDown ? "front-door-not-live" : null),
|
|
575
590
|
};
|
|
576
591
|
if (beatMs !== null && beatMs <= windowMs) seats.push(row);
|
|
577
592
|
else nonSeats.push(row);
|
|
@@ -582,14 +597,18 @@ export function toSeats(members, o = {}) {
|
|
|
582
597
|
|
|
583
598
|
/**
|
|
584
599
|
* Verdict for one seat against the target version. Pure.
|
|
600
|
+
* wedged — beating, but its front door is not answering (a shut
|
|
601
|
+
* session or an overdue handoff); outranks current/stale —
|
|
602
|
+
* the version stays on the row for display, unread here
|
|
585
603
|
* current — reported version equals the target
|
|
586
604
|
* stale — reported a different (older or newer) version
|
|
587
605
|
* unverifiable — beating, but the read carries no version at all
|
|
588
606
|
* @param {object} seat
|
|
589
607
|
* @param {string} target
|
|
590
|
-
* @returns {"current"|"stale"|"unverifiable"}
|
|
608
|
+
* @returns {"wedged"|"current"|"stale"|"unverifiable"}
|
|
591
609
|
*/
|
|
592
610
|
export function classifySeat(seat, target) {
|
|
611
|
+
if (seat && seat.wedged) return "wedged";
|
|
593
612
|
const v = seat && seat.version;
|
|
594
613
|
if (!v) return "unverifiable";
|
|
595
614
|
return compareVersions(v, target) === 0 ? "current" : "stale";
|
|
@@ -604,7 +623,7 @@ export function classifySeat(seat, target) {
|
|
|
604
623
|
*/
|
|
605
624
|
export function summarise(fleet, target) {
|
|
606
625
|
const rows = (fleet.seats || []).map((s) => ({ ...s, verdict: classifySeat(s, target) }));
|
|
607
|
-
const counts = { current: 0, stale: 0, unverifiable: 0 };
|
|
626
|
+
const counts = { current: 0, stale: 0, unverifiable: 0, wedged: 0 };
|
|
608
627
|
for (const r of rows) counts[r.verdict] += 1;
|
|
609
628
|
// Behind AND proved it cannot arrive — a strictly stronger statement than
|
|
610
629
|
// `stale`, which clears itself on the next hourly run.
|
|
@@ -623,6 +642,7 @@ export function summarise(fleet, target) {
|
|
|
623
642
|
// AND no seat is known to be unable to move.
|
|
624
643
|
const stuck = rows.filter((r) => r.stuck && r.stuck.verdict === "stuck");
|
|
625
644
|
const stale = rows.filter((r) => r.verdict === "stale");
|
|
645
|
+
const wedged = rows.filter((r) => r.verdict === "wedged");
|
|
626
646
|
const staleSlugs = new Set(stale.map((r) => r.slug));
|
|
627
647
|
return {
|
|
628
648
|
target,
|
|
@@ -636,8 +656,10 @@ export function summarise(fleet, target) {
|
|
|
636
656
|
stuckBehind: stuck.filter((r) => staleSlugs.has(r.slug)),
|
|
637
657
|
/** Stuck against some other version while current on this one. Still a finding. */
|
|
638
658
|
stuckElsewhere: stuck.filter((r) => !staleSlugs.has(r.slug)),
|
|
659
|
+
/** Beating, but its front door is not answering — a 4th verdict beside stale. */
|
|
660
|
+
wedged,
|
|
639
661
|
readCarriesVersionField: fleet.readCarriesVersionField === true,
|
|
640
|
-
done: rows.length > 0 && counts.current === rows.length && stuck.length === 0,
|
|
662
|
+
done: rows.length > 0 && counts.current === rows.length && stuck.length === 0 && wedged.length === 0,
|
|
641
663
|
};
|
|
642
664
|
}
|
|
643
665
|
|
|
@@ -793,10 +815,18 @@ export function stuckClause(row, target) {
|
|
|
793
815
|
* exit code: `behind` and `unverifiable` are different problems with different
|
|
794
816
|
* fixes, and only the first is answered by waiting.
|
|
795
817
|
*
|
|
818
|
+
* `wedged` is a fourth problem with a fourth fix: the seat is beating (so it is
|
|
819
|
+
* not `unverifiable`, and waiting for its next beat proves nothing) but its
|
|
820
|
+
* front door is not answering — a shut session or an overdue handoff. It is
|
|
821
|
+
* checked after `stuck` (stuck is the longer-standing, already-acted-on
|
|
822
|
+
* signal) and before `empty-fleet`/`unverifiable`/`behind`: reporting a wedged
|
|
823
|
+
* seat as merely "behind" would invite waiting it out, and waiting does
|
|
824
|
+
* nothing for a seat that is not reading.
|
|
825
|
+
*
|
|
796
826
|
* Pure.
|
|
797
827
|
*
|
|
798
828
|
* @param {ReturnType<typeof summarise>|null|undefined} summary
|
|
799
|
-
* @returns {{code:number, reason:"verified"|"stuck"|"behind"|"unverifiable"|"empty-fleet"|"no-fleet-read", note:string|null}}
|
|
829
|
+
* @returns {{code:number, reason:"verified"|"stuck"|"wedged"|"behind"|"unverifiable"|"empty-fleet"|"no-fleet-read", note:string|null}}
|
|
800
830
|
*/
|
|
801
831
|
export function propagationOutcome(summary) {
|
|
802
832
|
if (!summary || !Array.isArray(summary.rows)) {
|
|
@@ -829,6 +859,20 @@ export function propagationOutcome(summary) {
|
|
|
829
859
|
renderVerdict(summary),
|
|
830
860
|
};
|
|
831
861
|
}
|
|
862
|
+
// WEDGED comes after stuck (stuck is longer-standing and still the one a
|
|
863
|
+
// person must act on first) but before empty-fleet/unverifiable/behind: a
|
|
864
|
+
// seat that is beating but not reading is not a seat anyone can wait out
|
|
865
|
+
// either, and it must not be reported as merely "behind" — waiting changes
|
|
866
|
+
// nothing when the front door itself is what is not answering.
|
|
867
|
+
if (summary.wedged && summary.wedged.length) {
|
|
868
|
+
return {
|
|
869
|
+
code: 3,
|
|
870
|
+
reason: "wedged",
|
|
871
|
+
note:
|
|
872
|
+
`${summary.wedged.length} seat(s) are beating but their front door is not answering (wedged) — the ` +
|
|
873
|
+
`daemon revive or a person must clear them; a version cannot be verified on a seat that is not reading.`,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
832
876
|
if (!summary.rows.length) {
|
|
833
877
|
return {
|
|
834
878
|
code: 3,
|