switchroom 0.20.9 → 0.20.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/handoff-briefing.sh +57 -5
- package/bin/working-state-reload-hook.sh +262 -0
- package/dist/agent-scheduler/index.js +65 -2
- package/dist/auth-broker/index.js +204 -24
- package/dist/cli/notion-write-pretool.mjs +65 -2
- package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
- package/dist/cli/self-improve-stop.mjs +889 -7
- package/dist/cli/skill-validate-pretool.mjs +82 -3
- package/dist/cli/switchroom.js +3699 -2110
- package/dist/host-control/main.js +67 -4
- package/dist/vault/approvals/kernel-server.js +66 -3
- package/dist/vault/broker/server.js +66 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +49 -0
- package/profiles/_shared/agent-self-service.md.hbs +15 -22
- package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
- package/profiles/_shared/dev-protocol.md.hbs +1 -1
- package/profiles/_shared/execution-discipline.md.hbs +4 -4
- package/profiles/_shared/vault-protocol.md.hbs +2 -18
- package/profiles/default/CLAUDE.md.hbs +3 -5
- package/telegram-plugin/auto-fallback-fleet.ts +37 -2
- package/telegram-plugin/dist/gateway/gateway.js +1414 -918
- package/telegram-plugin/fallback-card-collapse.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +11 -1
- package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
- package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
- package/telegram-plugin/gateway/gateway.ts +85 -90
- package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/narrative-lane.ts +33 -2
- package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
- package/telegram-plugin/gateway/privacy-reset.ts +87 -0
- package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
- package/telegram-plugin/gateway/privacy-state.ts +206 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
- package/telegram-plugin/gateway/stale-pin-sweep.ts +48 -32
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
- package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
- package/telegram-plugin/throttle-tier.ts +12 -3
- package/telegram-plugin/turn-flush-safety.ts +97 -0
- package/vendor/hindsight-memory/CHANGELOG.md +31 -0
- package/vendor/hindsight-memory/hooks/hooks.json +2 -1
- package/vendor/hindsight-memory/scripts/retain.py +306 -0
- package/vendor/hindsight-memory/scripts/session_start.py +35 -8
- package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
- package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
- package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_durability.py +107 -0
|
@@ -368,6 +368,103 @@ describe('narrative-lane golden — silent-sentinel card suppression (#4348)', (
|
|
|
368
368
|
})
|
|
369
369
|
})
|
|
370
370
|
|
|
371
|
+
// ── #45 — HOLLOW activity card is SUPPRESSED (deleted, not finalized) ──────────
|
|
372
|
+
// A turn injected into the session (typically a duplicate sub-agent handback
|
|
373
|
+
// beat) adopts a per-turn activity card at turn START, then ends having done
|
|
374
|
+
// ZERO surfaced tool work, never calling reply, delivering no final answer, and
|
|
375
|
+
// emitting no captured/reply text. The card was posted but never got content —
|
|
376
|
+
// the contentless `🤖 Agent · done · 0 tools · Ns` record. The #4348 sentinel
|
|
377
|
+
// gate can't catch it (no NO_REPLY/HEARTBEAT_OK text to match), so the hollow
|
|
378
|
+
// gate must DELETE it. A turn with ANY real content keeps its card.
|
|
379
|
+
describe('narrative-lane golden — hollow-ghost card suppression (#45)', () => {
|
|
380
|
+
// Same open-then-stamp shape as the #4348 suite: open the feed card on a
|
|
381
|
+
// fresh (working) turn, then set the turn's terminal outcome and clear.
|
|
382
|
+
async function openThenClear(over: Partial<CurrentTurn>) {
|
|
383
|
+
const { lane, calls } = makeLane()
|
|
384
|
+
const turn = makeLaneTurn(lane)
|
|
385
|
+
lane.showNarrativeStep(turn, 'Doing the work now')
|
|
386
|
+
await turn.activityInFlight
|
|
387
|
+
const cardId = turn.activityMessageId
|
|
388
|
+
expect(cardId).not.toBeNull()
|
|
389
|
+
Object.assign(turn, over)
|
|
390
|
+
lane.clearActivitySummary(turn)
|
|
391
|
+
await settle()
|
|
392
|
+
return { calls, cardId, turn }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
it('genuinely hollow turn (0 tools, no reply, no text, no narration): DELETES the card, no done edit', async () => {
|
|
396
|
+
// The exact #45 ghost-reply signature Ken reported: a card opened by the
|
|
397
|
+
// liveness timer that stayed contentless — `mirrorLines` EMPTY. FAILS
|
|
398
|
+
// pre-fix: clearActivitySummary finalizes a contentless `done · 0 tools`
|
|
399
|
+
// card with an editMessageText instead of deleting it.
|
|
400
|
+
//
|
|
401
|
+
// `mirrorLines: []` is set explicitly: `openThenClear` opens the card via
|
|
402
|
+
// `showNarrativeStep`, which pushes a line into `mirrorLines`; resetting it
|
|
403
|
+
// to empty here isolates Ken's real signature (card open via the timer, no
|
|
404
|
+
// surfaced narration) from the narration-only case pinned below.
|
|
405
|
+
const { calls, cardId, turn } = await openThenClear({
|
|
406
|
+
replyCalled: false,
|
|
407
|
+
labeledToolCount: 0,
|
|
408
|
+
mirrorLines: [],
|
|
409
|
+
capturedText: [],
|
|
410
|
+
lastReplyText: '',
|
|
411
|
+
finalAnswerEverDelivered: false,
|
|
412
|
+
})
|
|
413
|
+
expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
|
|
414
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
|
|
415
|
+
// Tracking released so a late render / superseded-prior finalize can't
|
|
416
|
+
// resurrect the card.
|
|
417
|
+
expect(turn.activityMessageId).toBeNull()
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('a narration-only turn (a rendered mirror line, 0 tools/reply/text) KEEPS its card (edit, no delete)', async () => {
|
|
421
|
+
// The regression the edit-flood-fuse pin
|
|
422
|
+
// (activity-drain-fuse-drop-not-failure.test.ts "a terminal card render is
|
|
423
|
+
// never shed") protects, pinned here in the fix's own suite: a turn that
|
|
424
|
+
// surfaced a `showNarrativeStep` line the user SAW is legitimate content —
|
|
425
|
+
// NOT Ken's empty ghost card — so the card must FINALIZE, never be deleted.
|
|
426
|
+
// `openThenClear` already surfaced "Doing the work now" into `mirrorLines`;
|
|
427
|
+
// we deliberately do NOT reset it, so the hollow gate must keep the card.
|
|
428
|
+
const { calls, cardId } = await openThenClear({
|
|
429
|
+
replyCalled: false,
|
|
430
|
+
labeledToolCount: 0,
|
|
431
|
+
capturedText: [],
|
|
432
|
+
lastReplyText: '',
|
|
433
|
+
finalAnswerEverDelivered: false,
|
|
434
|
+
})
|
|
435
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
|
|
436
|
+
.toBeGreaterThanOrEqual(1)
|
|
437
|
+
expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('a turn that did surfaced tool work is NOT hollow — its card FINALIZES (edit, no delete)', async () => {
|
|
441
|
+
// The guarantee the fix must not break: a card carrying a real `✓ N steps`
|
|
442
|
+
// body stays as a record even when the turn never replied.
|
|
443
|
+
const { calls, cardId } = await openThenClear({
|
|
444
|
+
replyCalled: false,
|
|
445
|
+
labeledToolCount: 3,
|
|
446
|
+
capturedText: [],
|
|
447
|
+
lastReplyText: '',
|
|
448
|
+
finalAnswerEverDelivered: false,
|
|
449
|
+
})
|
|
450
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
|
|
451
|
+
.toBeGreaterThanOrEqual(1)
|
|
452
|
+
expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('a turn that delivered a final answer keeps its card (edit, no delete)', async () => {
|
|
456
|
+
const { calls, cardId } = await openThenClear({
|
|
457
|
+
replyCalled: true,
|
|
458
|
+
labeledToolCount: 0,
|
|
459
|
+
lastReplyText: 'The three services are all green.',
|
|
460
|
+
finalAnswerEverDelivered: true,
|
|
461
|
+
})
|
|
462
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
|
|
463
|
+
.toBeGreaterThanOrEqual(1)
|
|
464
|
+
expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
465
|
+
})
|
|
466
|
+
})
|
|
467
|
+
|
|
371
468
|
// ── THREE-MODULE cross-surface dedup (Amendment 1) ────────────────────────
|
|
372
469
|
// The REAL P4-A handleSessionEvent turn-flush, with the REAL P4-B lane wired
|
|
373
470
|
// into its deps, records the delivered answer into ONE OutboundDedupCache;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call-site pin for the gateway-owned session-start privacy reset (PR3).
|
|
3
|
+
*
|
|
4
|
+
* The whole point of PR3 (FIX 2 / FIX 3) is that the reset fires on GENUINE new
|
|
5
|
+
* sessions and is EXEMPT on paths that reattach to a persisting session. That
|
|
6
|
+
* is a wiring invariant, not a behavior a unit test on the module can observe —
|
|
7
|
+
* so this test parses gateway.ts and asserts WHERE `resetPrivacyForNewSession`
|
|
8
|
+
* is (and is not) called, the same source-parsing approach as
|
|
9
|
+
* `gateway-handler-registration-wiring.test.ts`.
|
|
10
|
+
*
|
|
11
|
+
* Instrumented (must call resetPrivacyForNewSession):
|
|
12
|
+
* - the `'boot'` boot-card branch (cold start / crash / planned restart)
|
|
13
|
+
* - the `bot.command('clear')` handler
|
|
14
|
+
* - the idle-clear dispatch (`maybeIdleClear`)
|
|
15
|
+
*
|
|
16
|
+
* Exempt by construction (must NOT call it):
|
|
17
|
+
* - `bridge-reconnect` (reattaches to a persisting session)
|
|
18
|
+
* - `bot.command('compact')` and resume/continue
|
|
19
|
+
*
|
|
20
|
+
* Run: npx vitest run telegram-plugin/tests/privacy-reset-call-sites.test.ts
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest'
|
|
24
|
+
import { readFileSync } from 'node:fs'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import { dirname, resolve } from 'node:path'
|
|
27
|
+
import ts from 'typescript'
|
|
28
|
+
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
30
|
+
const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
|
|
31
|
+
const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
|
|
32
|
+
const RESET = 'resetPrivacyForNewSession'
|
|
33
|
+
|
|
34
|
+
const sourceFile = ts.createSourceFile(
|
|
35
|
+
GATEWAY_PATH,
|
|
36
|
+
GATEWAY_SRC,
|
|
37
|
+
ts.ScriptTarget.Latest,
|
|
38
|
+
true,
|
|
39
|
+
ts.ScriptKind.TS,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
/** Line number (1-based) of every CALL to `resetPrivacyForNewSession(...)`. */
|
|
43
|
+
function resetCallLines(): number[] {
|
|
44
|
+
const lines: number[] = []
|
|
45
|
+
const visit = (node: ts.Node): void => {
|
|
46
|
+
if (
|
|
47
|
+
ts.isCallExpression(node) &&
|
|
48
|
+
ts.isIdentifier(node.expression) &&
|
|
49
|
+
node.expression.text === RESET
|
|
50
|
+
) {
|
|
51
|
+
lines.push(sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1)
|
|
52
|
+
}
|
|
53
|
+
ts.forEachChild(node, visit)
|
|
54
|
+
}
|
|
55
|
+
visit(sourceFile)
|
|
56
|
+
return lines.sort((a, b) => a - b)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The enclosing function/arrow name for a source line, best-effort. */
|
|
60
|
+
function enclosingContext(line: number): string {
|
|
61
|
+
const idx = line - 1
|
|
62
|
+
// Walk upward for the nearest recognizable anchor.
|
|
63
|
+
const lines = GATEWAY_SRC.split('\n')
|
|
64
|
+
for (let i = idx; i >= 0 && i > idx - 200; i--) {
|
|
65
|
+
const t = lines[i]
|
|
66
|
+
if (/bot\.command\('clear'/.test(t)) return 'command:clear'
|
|
67
|
+
if (/function maybeIdleClear/.test(t)) return 'maybeIdleClear'
|
|
68
|
+
if (/resolveBootChatId\(marker, markerAgeMs\)/.test(t)) return 'boot'
|
|
69
|
+
}
|
|
70
|
+
return 'unknown'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('privacy reset call sites (PR3 FIX 2 / FIX 3)', () => {
|
|
74
|
+
it('calls resetPrivacyForNewSession exactly THREE times', () => {
|
|
75
|
+
expect(resetCallLines()).toHaveLength(3)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('the three calls are the boot branch, the /clear handler, and idle-clear', () => {
|
|
79
|
+
const contexts = resetCallLines().map(enclosingContext).sort()
|
|
80
|
+
expect(contexts).toEqual(['boot', 'command:clear', 'maybeIdleClear'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('the boot reset is GUARDED against a --continue transcript-restore (MAJOR fix)', () => {
|
|
84
|
+
// The boot-branch call must be gated by !isContinueRestoreBoot(...) so a
|
|
85
|
+
// continue/auto restart that replays the SAME transcript does NOT flip
|
|
86
|
+
// privacy to public. The /clear + idle calls are always genuine new
|
|
87
|
+
// sessions and must NOT carry the guard.
|
|
88
|
+
const lines = GATEWAY_SRC.split('\n')
|
|
89
|
+
const bootLine = resetCallLines().find(l => enclosingContext(l) === 'boot')
|
|
90
|
+
expect(bootLine).toBeDefined()
|
|
91
|
+
expect(lines[bootLine! - 1]).toContain('isContinueRestoreBoot')
|
|
92
|
+
for (const l of resetCallLines()) {
|
|
93
|
+
if (enclosingContext(l) === 'boot') continue
|
|
94
|
+
expect(lines[l - 1]).not.toContain('isContinueRestoreBoot')
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('is NOT wired into the bridge-reconnect boot-card path', () => {
|
|
99
|
+
// Scope tightly to the bridge-reconnect block: from its dedupe tag to the
|
|
100
|
+
// `startBootCard(` it posts. The boot-path reset sits right before ITS
|
|
101
|
+
// startBootCard, so if the reconnect path were (wrongly) instrumented the
|
|
102
|
+
// reset would land in this same window. It must not.
|
|
103
|
+
const reconnectIdx = GATEWAY_SRC.indexOf("shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, 'bridge-reconnect')")
|
|
104
|
+
expect(reconnectIdx).toBeGreaterThan(-1)
|
|
105
|
+
const reconnectBootCard = GATEWAY_SRC.indexOf('startBootCard(', reconnectIdx)
|
|
106
|
+
expect(reconnectBootCard).toBeGreaterThan(reconnectIdx)
|
|
107
|
+
const window = GATEWAY_SRC.slice(reconnectIdx, reconnectBootCard)
|
|
108
|
+
expect(window.includes(RESET)).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("is NOT wired into the /compact handler", () => {
|
|
112
|
+
const compactIdx = GATEWAY_SRC.indexOf("bot.command('compact'")
|
|
113
|
+
const clearIdx = GATEWAY_SRC.indexOf("bot.command('clear'")
|
|
114
|
+
expect(compactIdx).toBeGreaterThan(-1)
|
|
115
|
+
expect(clearIdx).toBeGreaterThan(compactIdx)
|
|
116
|
+
// The compact handler body sits between the compact and clear registrations.
|
|
117
|
+
const compactBody = GATEWAY_SRC.slice(compactIdx, clearIdx)
|
|
118
|
+
expect(compactBody.includes(RESET)).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -569,6 +569,31 @@ describe("status-pin-store — envelope version compat", () => {
|
|
|
569
569
|
expect(loadStatusPins(PATH, fs)[0].threadId).toBe(77);
|
|
570
570
|
});
|
|
571
571
|
|
|
572
|
+
it("D4: a forum-topic foreground pin threads its topic THROUGH the orchestrator into the persisted row", async () => {
|
|
573
|
+
// The narrative-lane fix passes `thread` as reconcileStatusPin's 4th arg;
|
|
574
|
+
// this locks the plumbing beneath it — runStatusPinReconcile → the durable
|
|
575
|
+
// row — so a forum-topic foreground card orphaned by a crash keeps a
|
|
576
|
+
// threadId the sweep can aim `unpinAllForumTopicMessages` at. Before the
|
|
577
|
+
// fix the producer dropped `thread`, landing a threadId-less row here.
|
|
578
|
+
const { fs } = memFs();
|
|
579
|
+
const claims = new Map<string, StatusPinClaim>();
|
|
580
|
+
await runStatusPinReconcile({
|
|
581
|
+
pinKey: "fg:-100123:9",
|
|
582
|
+
chatId: "-100123",
|
|
583
|
+
threadId: 9,
|
|
584
|
+
prev: null,
|
|
585
|
+
desired: { pinned: true, messageId: 715 },
|
|
586
|
+
persist: { path: PATH, fs },
|
|
587
|
+
runPin: async (action) =>
|
|
588
|
+
action.kind === "pin" ? { messageId: action.messageId } : null,
|
|
589
|
+
claims,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// The durable row AND the in-memory claim both carry the topic.
|
|
593
|
+
expect(loadStatusPins(PATH, fs)[0]?.threadId).toBe(9);
|
|
594
|
+
expect(claims.get("fg:-100123:9")?.threadId).toBe(9);
|
|
595
|
+
});
|
|
596
|
+
|
|
572
597
|
it("drops a row with a non-numeric threadId", () => {
|
|
573
598
|
const { fs } = memFs({
|
|
574
599
|
[PATH]: JSON.stringify({
|
|
@@ -427,6 +427,22 @@ describe('renderThrottleEscalationNotice — corroborated-wall announcement', ()
|
|
|
427
427
|
expect(text).toContain('all blocked')
|
|
428
428
|
expect(text).toContain('/auth add')
|
|
429
429
|
})
|
|
430
|
+
|
|
431
|
+
it('strict-pinned caller: null rolledTo renders the riding-it-out variant, NOT all-blocked', () => {
|
|
432
|
+
// A strict pin (agents.<name>.auth.strict) deliberately does not roll —
|
|
433
|
+
// the broker reports caller_pinned_strict so this null is not misread as
|
|
434
|
+
// a fleet-wide exhaustion while the fleet is healthy.
|
|
435
|
+
const text = renderThrottleEscalationNotice({
|
|
436
|
+
account: 'work',
|
|
437
|
+
agent: 'workbot',
|
|
438
|
+
rolledTo: null,
|
|
439
|
+
callerPinnedStrict: true,
|
|
440
|
+
})
|
|
441
|
+
expect(text).toContain('strictly pinned')
|
|
442
|
+
expect(text).toContain('fleet is unaffected')
|
|
443
|
+
expect(text).not.toContain('all blocked')
|
|
444
|
+
expect(text).not.toContain('/auth add')
|
|
445
|
+
})
|
|
430
446
|
})
|
|
431
447
|
|
|
432
448
|
describe('rate_limited escalation card wording (model-unavailable integration)', () => {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
isCompositeSilentNoise,
|
|
22
22
|
endsWithSilentMarker,
|
|
23
23
|
isSilentSentinelCardOutcome,
|
|
24
|
+
isHollowGhostCardOutcome,
|
|
24
25
|
isTurnFlushSafetyEnabled,
|
|
25
26
|
selectFlushDeliveryText,
|
|
26
27
|
FLUSH_SUBSTANTIVE_MIN_CHARS,
|
|
@@ -976,3 +977,69 @@ describe('isSilentSentinelCardOutcome (#4348)', () => {
|
|
|
976
977
|
expect(isSilentSentinelCardOutcome({ ...base })).toBe(false)
|
|
977
978
|
})
|
|
978
979
|
})
|
|
980
|
+
|
|
981
|
+
describe('isHollowGhostCardOutcome (#45)', () => {
|
|
982
|
+
const base = {
|
|
983
|
+
replyCalled: false,
|
|
984
|
+
labeledToolCount: 0,
|
|
985
|
+
mirrorLines: [] as string[],
|
|
986
|
+
capturedText: [] as string[],
|
|
987
|
+
lastReplyText: '',
|
|
988
|
+
finalAnswerEverDelivered: false,
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
it('the ghost-reply signature — zero tools, no reply, no text, no narration — IS hollow', () => {
|
|
992
|
+
// The exact #45 condition the sentinel gate misses (no NO_REPLY text to
|
|
993
|
+
// match): the card adopted at turn start (liveness timer) never got any
|
|
994
|
+
// content — mirrorLines empty, no tool, no reply, no text.
|
|
995
|
+
expect(isHollowGhostCardOutcome({ ...base })).toBe(true)
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
it('a narration-only turn (a rendered mirror line, zero tools/reply/text) is NOT hollow', () => {
|
|
999
|
+
// The blocker this fix closes: a turn that surfaced a `showNarrativeStep`
|
|
1000
|
+
// ("Doing the work now") but did no labeled tool work, never replied, and
|
|
1001
|
+
// emitted no captured/reply text pushes a line into `mirrorLines`. That is
|
|
1002
|
+
// real content the user saw in the card — the card must be KEPT, not deleted.
|
|
1003
|
+
// This is the exact scenario the pre-existing edit-flood-fuse pin
|
|
1004
|
+
// (activity-drain-fuse-drop-not-failure.test.ts "a terminal card render is
|
|
1005
|
+
// never shed") protects.
|
|
1006
|
+
expect(
|
|
1007
|
+
isHollowGhostCardOutcome({ ...base, mirrorLines: ['Doing the work now'] }),
|
|
1008
|
+
).toBe(false)
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
it('whitespace-only mirror lines do NOT rescue the card — still hollow', () => {
|
|
1012
|
+
// Symmetric with the capturedText/lastReplyText blank guards: a mirrorLines
|
|
1013
|
+
// array of only blank strings surfaced nothing visible, so it stays hollow.
|
|
1014
|
+
expect(isHollowGhostCardOutcome({ ...base, mirrorLines: [' ', '\n'] })).toBe(true)
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
it('whitespace-only captured / reply text still counts as hollow', () => {
|
|
1018
|
+
expect(isHollowGhostCardOutcome({ ...base, capturedText: [' ', '\n'] })).toBe(true)
|
|
1019
|
+
expect(isHollowGhostCardOutcome({ ...base, lastReplyText: ' ' })).toBe(true)
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
it('a delivered final answer keeps the card — never hollow', () => {
|
|
1023
|
+
expect(isHollowGhostCardOutcome({ ...base, finalAnswerEverDelivered: true })).toBe(false)
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
it('any surfaced tool work keeps the card — the ✓ N steps body is real', () => {
|
|
1027
|
+
expect(isHollowGhostCardOutcome({ ...base, labeledToolCount: 1 })).toBe(false)
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
it('a reply-called turn is never hollow (sentinel gate owns dropped sentinels)', () => {
|
|
1031
|
+
expect(isHollowGhostCardOutcome({ ...base, replyCalled: true })).toBe(false)
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
it('captured terminal text keeps the card (not hollow)', () => {
|
|
1035
|
+
expect(
|
|
1036
|
+
isHollowGhostCardOutcome({ ...base, capturedText: ['Here is the answer.'] }),
|
|
1037
|
+
).toBe(false)
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
it('non-blank last reply text keeps the card (not hollow)', () => {
|
|
1041
|
+
expect(
|
|
1042
|
+
isHollowGhostCardOutcome({ ...base, lastReplyText: 'The three services are green.' }),
|
|
1043
|
+
).toBe(false)
|
|
1044
|
+
})
|
|
1045
|
+
})
|
|
@@ -367,8 +367,13 @@ export function renderThrottleNotice(opts: {
|
|
|
367
367
|
export function renderThrottleEscalationNotice(opts: {
|
|
368
368
|
account: string | null
|
|
369
369
|
agent: string
|
|
370
|
-
/** The account the fleet/agents rolled to; null = every fallback blocked
|
|
370
|
+
/** The account the fleet/agents rolled to; null = every fallback blocked —
|
|
371
|
+
* UNLESS `callerPinnedStrict`, where null just means the caller stayed put. */
|
|
371
372
|
rolledTo: string | null
|
|
373
|
+
/** True when the triggering agent has a strict pin (`auth.strict`): its
|
|
374
|
+
* null `rolledTo` is "riding out the wall on its own account", not a
|
|
375
|
+
* fleet-wide all-blocked. Absent from pre-flag brokers → false. */
|
|
376
|
+
callerPinnedStrict?: boolean
|
|
372
377
|
}): string {
|
|
373
378
|
const acct = opts.account ? `\`${escapeMarkdown(opts.account)}\`` : 'the active account'
|
|
374
379
|
const head =
|
|
@@ -376,7 +381,11 @@ export function renderThrottleEscalationNotice(opts: {
|
|
|
376
381
|
`(trigger: **${escapeMarkdown(opts.agent)}**) were corroborated by a live quota probe.`
|
|
377
382
|
const tail = opts.rolledTo
|
|
378
383
|
? `Marked exhausted and rolled to \`${escapeMarkdown(opts.rolledTo)}\`.`
|
|
379
|
-
:
|
|
380
|
-
`
|
|
384
|
+
: opts.callerPinnedStrict
|
|
385
|
+
? `Marked exhausted — **${escapeMarkdown(opts.agent)}** is strictly pinned to ${acct} ` +
|
|
386
|
+
`(\`auth.strict\`), so it rides out the wall on its own account. ` +
|
|
387
|
+
`The rest of the fleet is unaffected.`
|
|
388
|
+
: `Marked exhausted — no fallback account had quota (all blocked). ` +
|
|
389
|
+
`Use \`/auth add <label>\` to attach another subscription.`
|
|
381
390
|
return `${head}\n${tail}`
|
|
382
391
|
}
|
|
@@ -214,6 +214,103 @@ export function isSilentSentinelCardOutcome(input: SilentSentinelCardInput): boo
|
|
|
214
214
|
return false
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Inputs for {@link isHollowGhostCardOutcome} — the ending-turn fields the
|
|
219
|
+
* hollow-card gate reads. A strict superset of the silent-sentinel inputs plus
|
|
220
|
+
* `labeledToolCount` (the deterministic surfaced-tool total). All are already
|
|
221
|
+
* tracked on the gateway's `CurrentTurn`; passed as a plain struct so the
|
|
222
|
+
* decision stays a pure, unit-testable core.
|
|
223
|
+
*/
|
|
224
|
+
export interface HollowGhostCardInput {
|
|
225
|
+
/** True when the model called `reply` / `stream_reply` at least once. */
|
|
226
|
+
replyCalled: boolean
|
|
227
|
+
/**
|
|
228
|
+
* Count of surfaced (non-suppressed) tool steps this turn
|
|
229
|
+
* (`CurrentTurn.labeledToolCount`) — the same source that drives the card's
|
|
230
|
+
* `✓ N steps` total and the `tools=` lifecycle field. Zero means the turn did
|
|
231
|
+
* no surfaced tool work, so the card has no step body to record.
|
|
232
|
+
*/
|
|
233
|
+
labeledToolCount: number
|
|
234
|
+
/**
|
|
235
|
+
* Rendered narrative/mirror lines surfaced into the activity card this turn
|
|
236
|
+
* (`CurrentTurn.mirrorLines`) — every `showNarrativeStep` appends one via
|
|
237
|
+
* `appendActivityLabel`. A NON-EMPTY array means the user saw real narration
|
|
238
|
+
* content in the card (e.g. "Doing the work now"), so the card is a legitimate
|
|
239
|
+
* record even with zero tool steps and no reply — it is NOT the empty ghost
|
|
240
|
+
* card. This is the axis that separates Ken's #45 bug (a card opened by the
|
|
241
|
+
* liveness timer that stayed contentless — `🤖 Agent · done · 0 tools · 40s`,
|
|
242
|
+
* `mirrorLines` empty) from a narration-only turn (a rendered step, no tool).
|
|
243
|
+
*/
|
|
244
|
+
mirrorLines: string[]
|
|
245
|
+
/**
|
|
246
|
+
* Raw assistant text blocks accumulated across the turn
|
|
247
|
+
* (`CurrentTurn.capturedText`) — the turn-flush classifies the same source.
|
|
248
|
+
*/
|
|
249
|
+
capturedText: string[]
|
|
250
|
+
/**
|
|
251
|
+
* The most-recent `reply` / `stream_reply` `input.text` this turn
|
|
252
|
+
* (`CurrentTurn.lastReplyText`); empty when the reply tool was never called.
|
|
253
|
+
*/
|
|
254
|
+
lastReplyText: string
|
|
255
|
+
/**
|
|
256
|
+
* A SUBSTANTIVE final answer reached the user at some point this turn
|
|
257
|
+
* (`CurrentTurn.finalAnswerEverDelivered`). When true the card is a legitimate
|
|
258
|
+
* record beside a delivered answer and is NEVER suppressed.
|
|
259
|
+
*/
|
|
260
|
+
finalAnswerEverDelivered: boolean
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Decide whether an ending turn produced a genuinely HOLLOW activity card — the
|
|
265
|
+
* `#45` ghost-reply condition — so the gateway can DELETE the card instead of
|
|
266
|
+
* finalizing it to a contentless `🤖 Agent · done · 0 tools · Ns` record.
|
|
267
|
+
*
|
|
268
|
+
* The symptom (#45): a turn injected into the session (typically a duplicate
|
|
269
|
+
* sub-agent handback beat) adopts a per-turn activity card at turn START, then
|
|
270
|
+
* ends having done ZERO surfaced tool work, never calling reply, delivering no
|
|
271
|
+
* final answer, and emitting no captured terminal text. The card was already
|
|
272
|
+
* posted but never gets any content — the gateway already WARNs on this exact
|
|
273
|
+
* signature (`ghost-reply detected — turn ended with zero outbound messages …
|
|
274
|
+
* replyCalled=false capturedText=empty`) but does not suppress the empty card.
|
|
275
|
+
*
|
|
276
|
+
* This is the complement of {@link isSilentSentinelCardOutcome}: that gate
|
|
277
|
+
* handles a turn whose outcome was an INTENTIONAL silent sentinel (a NO_REPLY /
|
|
278
|
+
* HEARTBEAT_OK the model actually emitted, which reaches `lastReplyText` /
|
|
279
|
+
* `capturedText`). A hollow ghost turn emitted NOTHING at all — no sentinel text
|
|
280
|
+
* for the sentinel gate to match — so `decideTurnFlush` classifies it as
|
|
281
|
+
* `empty-text`, not `silent-marker`, and the sentinel gate returns false (see
|
|
282
|
+
* the `isSilentSentinelCardOutcome` test "a genuinely empty dark turn … is NOT a
|
|
283
|
+
* sentinel"). Both gates share the same conservative floor.
|
|
284
|
+
*
|
|
285
|
+
* Conservative by construction — every guarantee that keeps a real card is an
|
|
286
|
+
* early false:
|
|
287
|
+
* - `finalAnswerEverDelivered` — a substantive answer reached the user; the
|
|
288
|
+
* card is a legitimate record.
|
|
289
|
+
* - `replyCalled` — the model called reply (even a dropped sentinel-only reply
|
|
290
|
+
* is the SENTINEL gate's job, not this one); never treated as hollow here.
|
|
291
|
+
* - `labeledToolCount > 0` — the turn did surfaced tool work, so the card
|
|
292
|
+
* carries a real `✓ N steps` body worth keeping.
|
|
293
|
+
* - any non-blank `mirrorLines` — the turn surfaced narrative content into the
|
|
294
|
+
* card that the user saw (a `showNarrativeStep`), so the card is a real
|
|
295
|
+
* record, NOT the contentless ghost card. This is the axis that keeps a
|
|
296
|
+
* narration-only turn (rendered step, zero tools/reply/text) from being
|
|
297
|
+
* mistaken for Ken's empty `done · 0 tools` card, whose `mirrorLines` is
|
|
298
|
+
* empty because the card was opened by the liveness timer, not narration.
|
|
299
|
+
* - any non-blank `capturedText` / `lastReplyText` — the turn produced terminal
|
|
300
|
+
* text; not hollow (and, if it is silent-sentinel noise, the sentinel gate
|
|
301
|
+
* already suppresses it).
|
|
302
|
+
* Only a turn that is empty on ALL of those axes is hollow.
|
|
303
|
+
*/
|
|
304
|
+
export function isHollowGhostCardOutcome(input: HollowGhostCardInput): boolean {
|
|
305
|
+
if (input.finalAnswerEverDelivered) return false
|
|
306
|
+
if (input.replyCalled) return false
|
|
307
|
+
if (input.labeledToolCount > 0) return false
|
|
308
|
+
if (Array.isArray(input.mirrorLines) && input.mirrorLines.join('').trim().length > 0) return false
|
|
309
|
+
if (Array.isArray(input.capturedText) && input.capturedText.join('').trim().length > 0) return false
|
|
310
|
+
if (typeof input.lastReplyText === 'string' && input.lastReplyText.trim().length > 0) return false
|
|
311
|
+
return true
|
|
312
|
+
}
|
|
313
|
+
|
|
217
314
|
/**
|
|
218
315
|
* Substantive-answer floor (chars, trimmed). Mirrors
|
|
219
316
|
* `final-answer-detect.ts` `FINAL_ANSWER_MIN_CHARS` and
|
|
@@ -54,6 +54,37 @@
|
|
|
54
54
|
|
|
55
55
|
### Changed (switchroom divergence)
|
|
56
56
|
|
|
57
|
+
- **SessionStart hook runs async so its durability work stops being killed
|
|
58
|
+
mid-drain** (`hooks/hooks.json`, `scripts/session_start.py`). The hook does
|
|
59
|
+
the recovery a prior session's abrupt death skipped — drain the
|
|
60
|
+
SessionEnd-queued retains (#1071) and reconcile un-committed transcript turns
|
|
61
|
+
(#3244). Those two carry independent 4s wall-clock budgets
|
|
62
|
+
(`HINDSIGHT_DRAIN_BUDGET_S` / `HINDSIGHT_RECONCILE_BUDGET_S`), each sized in
|
|
63
|
+
isolation against the old synchronous 5s SessionStart timeout, plus a ~2s
|
|
64
|
+
Mode-1 external-server health probe. Summed, they routinely overran 5s, so
|
|
65
|
+
Claude Code SIGKILLed the hook part-way through — truncating exactly the
|
|
66
|
+
durability work it exists to do and letting the pending-retains backlog grow
|
|
67
|
+
(fleet transcripts carry 1000+ `hook_cancelled` attachments for
|
|
68
|
+
`session_start.py`, all `timedOut: true` with `durationMs > 5000`; a
|
|
69
|
+
successful firing injects no `additionalContext` and so leaves no record,
|
|
70
|
+
which made the failures look like ~100% of firings when the true rate is
|
|
71
|
+
unmeasurable from attachments). Setting `"async": true` on the hook (the same
|
|
72
|
+
non-blocking pattern the Stop-event `retain.py` already uses) detaches it from
|
|
73
|
+
the SessionStart critical path: it injects no context, so nothing depends on
|
|
74
|
+
it finishing first and async's dropped context costs nothing. The `timeout`
|
|
75
|
+
ceiling rises 5s → 30s purely as a background-lifetime bound above the ~10s
|
|
76
|
+
summed sub-budgets — it never re-introduces a startup block, because drain and
|
|
77
|
+
reconcile self-cap at their budgets regardless. The drain stays in the hook
|
|
78
|
+
(not deferred wholly to the `hindsight-drain` sidecar, which `start.sh` only
|
|
79
|
+
starts conditionally — when it is absent this hook is the sole backlog path)
|
|
80
|
+
and `reconcile_tail`, which has no sidecar equivalent, stays here and now runs
|
|
81
|
+
to completion; its over-budget remainder still resumes on the next boot.
|
|
82
|
+
Concurrent drain with the sidecar stays safe via `drain_pending`'s exclusive
|
|
83
|
+
`fcntl.flock`. Pinned by `scripts/tests/test_session_start_durability.py`
|
|
84
|
+
(drain + reconcile are still invoked, in order) and
|
|
85
|
+
`tests/hindsight-session-start-async.test.ts` (the async flag + budget-clearing
|
|
86
|
+
ceiling survive the scaffold's hooks-override round-trip).
|
|
87
|
+
|
|
57
88
|
- **Recall/retain hygiene guard-rail batch** (`scripts/recall.py`,
|
|
58
89
|
`scripts/subagent_retain.py`, `scripts/lib/content.py`, `scripts/tests/**`).
|
|
59
90
|
Closes guard-rail debt in the fork's hook scripts before extending them:
|