switchroom 0.19.42 → 0.19.44
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/dist/agent-scheduler/index.js +4 -1
- package/dist/auth-broker/index.js +284 -124
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +4094 -2711
- package/dist/host-control/main.js +472 -155
- package/dist/vault/approvals/kernel-server.js +231 -71
- package/dist/vault/broker/server.js +293 -133
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +93 -8
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1682 -936
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +5 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -105,6 +105,29 @@ import type { InboundMessage } from './ipc-protocol.js'
|
|
|
105
105
|
* the seam's `handleReaped` hook (stop typing loop + drop map entry). */
|
|
106
106
|
export const PRETURN_TURNKEY_PREFIX = 'preturn:'
|
|
107
107
|
|
|
108
|
+
/** `meta` key carrying the deterministic-recovery re-injection counter across a
|
|
109
|
+
* re-injected handback's round trip through the pending-inbound buffer. Stamped
|
|
110
|
+
* on the inbound just before it is re-injected on a genuine orphan; read back
|
|
111
|
+
* when that same inbound is released again, so the retry cap survives the
|
|
112
|
+
* drop-and-recreate of the per-topic pre-turn entry. `meta` is
|
|
113
|
+
* `Record<string,string>`, so the count is stored as a decimal string. */
|
|
114
|
+
export const HANDBACK_REINJECT_COUNT_META_KEY = 'handbackReinjectCount'
|
|
115
|
+
|
|
116
|
+
/** Emitted (NOT to chat) when a genuine orphan has exhausted its re-injection
|
|
117
|
+
* retries — the deterministic-recovery escalation surface. The gateway routes
|
|
118
|
+
* this to fleet-health telemetry (a structured log line the nightly sensor
|
|
119
|
+
* reads), never to an operator-facing card: the system must recover itself, not
|
|
120
|
+
* ask a human to nudge it. */
|
|
121
|
+
export interface HandbackOrphanEscalation {
|
|
122
|
+
statusKey: string
|
|
123
|
+
chatId: string
|
|
124
|
+
threadId: number | null
|
|
125
|
+
adoptTurnId: string
|
|
126
|
+
reinjectCount: number
|
|
127
|
+
/** Age (ms) of the handback at escalation — release → escalation. */
|
|
128
|
+
ageMs: number
|
|
129
|
+
}
|
|
130
|
+
|
|
108
131
|
/** True IFF `source` is the load-bearing subagent-handback source string. Kept
|
|
109
132
|
* here next to the seam so a source-string regression is caught by this
|
|
110
133
|
* module's own test, not only the inbound-builder's. */
|
|
@@ -162,9 +185,21 @@ export interface HandbackPreturnSignalDeps {
|
|
|
162
185
|
* when the send failed / was suppressed (best-effort — a null just means no
|
|
163
186
|
* card was painted, so nothing to adopt or reap). */
|
|
164
187
|
openCard: (chatId: string, threadId: number | null) => Promise<number | null>
|
|
165
|
-
/**
|
|
166
|
-
*
|
|
167
|
-
|
|
188
|
+
/** DELETE (not edit) a never-adopted pre-turn card. Used on the genuine-orphan
|
|
189
|
+
* reap so the frozen card is removed rather than rewritten to a stale
|
|
190
|
+
* user-facing "needs a nudge" message, and on the rare emit-race teardown of a
|
|
191
|
+
* card the adopting turn already owns. Best-effort; failures swallowed by the
|
|
192
|
+
* caller. */
|
|
193
|
+
deleteCard: (record: PreTurnCardRecord) => void | Promise<void>
|
|
194
|
+
/** Re-inject a genuinely-orphaned handback back through the pending-inbound
|
|
195
|
+
* buffer so the machine recovers itself — the deterministic alternative to
|
|
196
|
+
* asking the operator to nudge. The gateway's `pendingInboundBuffer.push`. The
|
|
197
|
+
* seam stamps `HANDBACK_REINJECT_COUNT_META_KEY` on `inbound.meta` first so the
|
|
198
|
+
* retry cap survives the round trip. */
|
|
199
|
+
reinjectHandback: (inbound: InboundMessage) => void
|
|
200
|
+
/** Escalate a genuine orphan that has exhausted its re-injection retries to
|
|
201
|
+
* fleet-health telemetry (NOT a chat card). */
|
|
202
|
+
escalateOrphan: (escalation: HandbackOrphanEscalation) => void
|
|
168
203
|
/** Persist the durable card record (the gateway's `writeActivityCardRecord`,
|
|
169
204
|
* scoped by `turnKey`). */
|
|
170
205
|
writeCardRecord: (record: PreTurnCardRecord) => void
|
|
@@ -190,16 +225,18 @@ export interface HandbackPreturnSignalDeps {
|
|
|
190
225
|
* handback is delivered into that single queue and only mints its adopting
|
|
191
226
|
* turn when claude next goes idle, so while claude is busy the handback is
|
|
192
227
|
* NOT orphaned — it is correctly enqueued BEHIND the in-flight turn (a long
|
|
193
|
-
* parent turn can run many minutes).
|
|
194
|
-
*
|
|
195
|
-
* therefore DEFERS (re-arms) while this is true
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
228
|
+
* parent turn can run many minutes). Reaping it as an orphan in that window
|
|
229
|
+
* used to post a false "never started — needs a nudge" card (the 3-cards-in-
|
|
230
|
+
* 4-min incident). The reap therefore DEFERS (re-arms) while this is true —
|
|
231
|
+
* and, complementarily, while the handback was delivered but its turn has not
|
|
232
|
+
* started streaming yet (`deliveredAwaitingTurnStart`, the #4027 coverage
|
|
233
|
+
* gap) — recovering only once claude is idle AND the delivered window has
|
|
234
|
+
* elapsed AND the handback still has not been adopted (the genuine bridge-
|
|
235
|
+
* death / dropped-inbound case). Recovery is deterministic (delete the frozen
|
|
236
|
+
* card + re-inject, then escalate), never an operator nudge. Distinct from
|
|
237
|
+
* `hasLiveTurn`, which is PER-KEY and only governs the typing-loop stop; this
|
|
238
|
+
* is the GLOBAL busy signal because the handback queues behind whatever turn
|
|
239
|
+
* is running, regardless of topic. Optional; defaults to "not busy". */
|
|
203
240
|
isClaudeBusy?: () => boolean
|
|
204
241
|
now?: () => number
|
|
205
242
|
/** Debounce before painting the pre-turn card (kills sub-second flicker). */
|
|
@@ -207,6 +244,17 @@ export interface HandbackPreturnSignalDeps {
|
|
|
207
244
|
/** Age after emit at which a never-adopted card self-reaps. Independent of
|
|
208
245
|
* topic liveness. */
|
|
209
246
|
adoptTimeoutMs?: number
|
|
247
|
+
/** TTL (ms) for the delivered-but-not-yet-streaming window. A released
|
|
248
|
+
* handback has been DELIVERED into claude's queue but its adopting turn may
|
|
249
|
+
* not have started streaming yet — the false-orphan window #4027 left open
|
|
250
|
+
* (idle machine + un-adopted = looked like an orphan). While within this TTL
|
|
251
|
+
* the reap DEFERS (`deliveredAwaitingTurnStart`); past it a truly-stuck
|
|
252
|
+
* delivery finally reaps for deterministic recovery rather than hanging
|
|
253
|
+
* forever. Defaults to 5 min. */
|
|
254
|
+
deliveredTtlMs?: number
|
|
255
|
+
/** Max deterministic re-injections of a genuine orphan before escalating to
|
|
256
|
+
* fleet-health telemetry. Defaults to 2. */
|
|
257
|
+
maxReinjects?: number
|
|
210
258
|
/** Injected scheduler (mirrors bridge-dead-watchdog.ts): defaults to
|
|
211
259
|
* setTimeout/clearTimeout. Injected so a runner-agnostic test can drive the
|
|
212
260
|
* debounce + self-reap deterministically WITHOUT any vitest-only fake-timer
|
|
@@ -224,6 +272,18 @@ interface PreTurnEntry {
|
|
|
224
272
|
adoptTurnId: string
|
|
225
273
|
syntheticTurnKey: string
|
|
226
274
|
startedAt: number
|
|
275
|
+
/** When the handback was RELEASED/DELIVERED for delivery (stamped at
|
|
276
|
+
* `noteHandbackRelease`). Drives `deliveredAwaitingTurnStart`: the reap must
|
|
277
|
+
* not treat a delivered-but-not-yet-streaming handback as an orphan within
|
|
278
|
+
* the delivered TTL. Equal to `startedAt` (release is the delivery point). */
|
|
279
|
+
deliveredAt: number
|
|
280
|
+
/** How many times this handback has already been deterministically re-injected
|
|
281
|
+
* (carried across the buffer round trip via `HANDBACK_REINJECT_COUNT_META_KEY`
|
|
282
|
+
* on the inbound). Governs the retry cap before fleet-health escalation. */
|
|
283
|
+
reinjectCount: number
|
|
284
|
+
/** The released handback inbound itself — retained so a genuine orphan can be
|
|
285
|
+
* re-injected through the pending-inbound buffer (deterministic recovery). */
|
|
286
|
+
inbound: InboundMessage
|
|
227
287
|
pinned: boolean
|
|
228
288
|
debounceTimer: unknown | null
|
|
229
289
|
reapTimer: unknown | null
|
|
@@ -273,6 +333,8 @@ export function createHandbackPreturnSignal(
|
|
|
273
333
|
const now = deps.now ?? (() => Date.now())
|
|
274
334
|
const debounceMs = deps.debounceMs ?? 700
|
|
275
335
|
const adoptTimeoutMs = deps.adoptTimeoutMs ?? 30_000
|
|
336
|
+
const deliveredTtlMs = deps.deliveredTtlMs ?? 300_000
|
|
337
|
+
const maxReinjects = deps.maxReinjects ?? 2
|
|
276
338
|
const setTimer =
|
|
277
339
|
deps.setTimer ??
|
|
278
340
|
((fn: () => void, ms: number) => {
|
|
@@ -333,9 +395,9 @@ export function createHandbackPreturnSignal(
|
|
|
333
395
|
if (messageId == null) return // no card painted; the reap timer cleans up
|
|
334
396
|
if (entry.consumed) {
|
|
335
397
|
// Adopted/reaped while the send was in flight: the card is now the
|
|
336
|
-
// turn's (adoption seeds a null id it can't use) —
|
|
337
|
-
//
|
|
338
|
-
void deps.
|
|
398
|
+
// turn's (adoption seeds a null id it can't use) — DELETE it so it
|
|
399
|
+
// never dangles. Rare; the debounce makes it unlikely.
|
|
400
|
+
void deps.deleteCard({
|
|
339
401
|
turnKey: entry.syntheticTurnKey,
|
|
340
402
|
chatId: entry.chatId,
|
|
341
403
|
threadId: entry.threadId,
|
|
@@ -385,30 +447,52 @@ export function createHandbackPreturnSignal(
|
|
|
385
447
|
deps.stopTypingLoop(entry.chatId, entry.threadId)
|
|
386
448
|
}
|
|
387
449
|
|
|
450
|
+
/**
|
|
451
|
+
* The delivered-but-not-yet-streaming window (#4027 coverage gap). A released
|
|
452
|
+
* handback has been DELIVERED into claude's single input queue, but its
|
|
453
|
+
* adopting turn may not have started streaming yet — and in that stretch the
|
|
454
|
+
* machine reads as idle (`isClaudeBusy()` false) with the entry un-adopted,
|
|
455
|
+
* which looked exactly like a genuine orphan and produced the false "needs a
|
|
456
|
+
* nudge" card even after #4027 gated on `isMachineInTurn`. While the handback
|
|
457
|
+
* is within the delivered TTL and its turn has not started (we are in `reap`,
|
|
458
|
+
* so the entry is un-consumed = no adopting turn minted), it is NOT orphaned —
|
|
459
|
+
* defer. Past the TTL a truly-stuck delivery finally reaps rather than hanging
|
|
460
|
+
* forever, handing off to deterministic recovery.
|
|
461
|
+
*/
|
|
462
|
+
function deliveredAwaitingTurnStart(entry: PreTurnEntry): boolean {
|
|
463
|
+
return now() - entry.deliveredAt < deliveredTtlMs
|
|
464
|
+
}
|
|
465
|
+
|
|
388
466
|
function reap(entry: PreTurnEntry): void {
|
|
389
467
|
entry.reapTimer = null
|
|
390
468
|
if (entry.consumed) return
|
|
391
469
|
// PRIMARY GATE (queue state, not a flat timeout): a released handback is
|
|
392
470
|
// delivered into claude's SINGLE input queue and mints its adopting turn
|
|
393
|
-
// only when claude next goes idle. If claude is STILL in a turn,
|
|
394
|
-
// handback
|
|
395
|
-
//
|
|
396
|
-
// a false "never started — needs a
|
|
397
|
-
// incident). Defer: re-arm and re-check.
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
471
|
+
// only when claude next goes idle. If claude is STILL in a turn, OR the
|
|
472
|
+
// handback was delivered and its turn has simply not started streaming yet
|
|
473
|
+
// (within the delivered TTL — the #4027 coverage gap), the handback is NOT
|
|
474
|
+
// orphaned. Reaping now would have posted a false "never started — needs a
|
|
475
|
+
// nudge" card (the 3-cards-in-4-min incident). Defer: re-arm and re-check.
|
|
476
|
+
// We proceed to recovery only once claude is idle AND the delivered window
|
|
477
|
+
// has elapsed AND the handback still has not been adopted (the genuine
|
|
478
|
+
// bridge-death / dropped-inbound case). This cannot loop forever: claude
|
|
479
|
+
// processes its queue FIFO (adoption cancels the reap), and the delivered
|
|
480
|
+
// TTL is bounded, so a stuck delivery eventually recovers deterministically.
|
|
481
|
+
if (deps.isClaudeBusy?.() === true || deliveredAwaitingTurnStart(entry)) {
|
|
403
482
|
log(
|
|
404
483
|
`handback-preturn-signal: reap deferred key=${entry.statusKey} ` +
|
|
405
|
-
`(
|
|
484
|
+
`(${deps.isClaudeBusy?.() === true ? 'claude busy' : 'delivered, turn not yet streaming'}` +
|
|
485
|
+
` — not orphaned)\n`,
|
|
406
486
|
)
|
|
407
487
|
entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs)
|
|
408
488
|
return
|
|
409
489
|
}
|
|
410
490
|
entry.consumed = true
|
|
411
|
-
//
|
|
491
|
+
// GENUINE ORPHAN. Recover DETERMINISTICALLY — never ask the operator to
|
|
492
|
+
// nudge. Stop the forever-running typing loop, DELETE the frozen card (do
|
|
493
|
+
// not leave a stale "needs a nudge" message), then re-inject the handback so
|
|
494
|
+
// the machine re-processes it itself. Cap the re-injections; past the cap
|
|
495
|
+
// escalate to fleet-health telemetry (NOT a chat card).
|
|
412
496
|
stopTypingUnlessTurnLive(entry, 'orphan reap')
|
|
413
497
|
if (entry.activityMessageId != null) {
|
|
414
498
|
const record: PreTurnCardRecord = {
|
|
@@ -419,17 +503,61 @@ export function createHandbackPreturnSignal(
|
|
|
419
503
|
startedAt: entry.startedAt,
|
|
420
504
|
pinned: entry.pinned,
|
|
421
505
|
}
|
|
422
|
-
// Clear the durable record BEFORE the
|
|
423
|
-
//
|
|
506
|
+
// Clear the durable record BEFORE the delete (at-most-once idempotency
|
|
507
|
+
// guard, mirroring the activity-card-store reapers).
|
|
424
508
|
deps.clearCardRecord(entry.syntheticTurnKey, entry.activityMessageId)
|
|
425
|
-
void Promise.resolve(deps.
|
|
509
|
+
void Promise.resolve(deps.deleteCard(record)).catch((err) => {
|
|
426
510
|
log(
|
|
427
|
-
`handback-preturn-signal: orphan
|
|
511
|
+
`handback-preturn-signal: orphan card delete failed key=${entry.statusKey}: ` +
|
|
428
512
|
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
429
513
|
)
|
|
430
514
|
})
|
|
431
515
|
}
|
|
516
|
+
// Drop the entry from the topic map BEFORE re-injecting so a redelivery that
|
|
517
|
+
// re-enters `noteHandbackRelease` (production: async buffer drain; possible
|
|
518
|
+
// synchronously) is not swallowed by the per-topic dedupe guard. The entry
|
|
519
|
+
// object stays valid for the captured re-inject below.
|
|
432
520
|
dropEntry(entry)
|
|
521
|
+
if (entry.reinjectCount < maxReinjects) {
|
|
522
|
+
const nextCount = entry.reinjectCount + 1
|
|
523
|
+
// Stamp the counter on the inbound so it survives the buffer round trip
|
|
524
|
+
// (the re-injected inbound re-enters `noteHandbackRelease`, which reads it
|
|
525
|
+
// back — the retry cap outlives the drop-and-recreate of this entry).
|
|
526
|
+
entry.inbound.meta[HANDBACK_REINJECT_COUNT_META_KEY] = String(nextCount)
|
|
527
|
+
log(
|
|
528
|
+
`handback-preturn-signal: orphan reap key=${entry.statusKey} ` +
|
|
529
|
+
`turnId=${entry.adoptTurnId} — re-injecting handback (attempt ${nextCount}/${maxReinjects})\n`,
|
|
530
|
+
)
|
|
531
|
+
try {
|
|
532
|
+
deps.reinjectHandback(entry.inbound)
|
|
533
|
+
} catch (err) {
|
|
534
|
+
log(
|
|
535
|
+
`handback-preturn-signal: orphan re-inject failed key=${entry.statusKey}: ` +
|
|
536
|
+
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
} else {
|
|
540
|
+
log(
|
|
541
|
+
`handback-preturn-signal: orphan reap key=${entry.statusKey} ` +
|
|
542
|
+
`turnId=${entry.adoptTurnId} — re-injection cap (${maxReinjects}) exhausted, ` +
|
|
543
|
+
`escalating to fleet-health telemetry\n`,
|
|
544
|
+
)
|
|
545
|
+
try {
|
|
546
|
+
deps.escalateOrphan({
|
|
547
|
+
statusKey: entry.statusKey,
|
|
548
|
+
chatId: entry.chatId,
|
|
549
|
+
threadId: entry.threadId,
|
|
550
|
+
adoptTurnId: entry.adoptTurnId,
|
|
551
|
+
reinjectCount: entry.reinjectCount,
|
|
552
|
+
ageMs: now() - entry.deliveredAt,
|
|
553
|
+
})
|
|
554
|
+
} catch (err) {
|
|
555
|
+
log(
|
|
556
|
+
`handback-preturn-signal: orphan escalation failed key=${entry.statusKey}: ` +
|
|
557
|
+
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
433
561
|
}
|
|
434
562
|
|
|
435
563
|
return {
|
|
@@ -466,6 +594,12 @@ export function createHandbackPreturnSignal(
|
|
|
466
594
|
)
|
|
467
595
|
const startedAt = now()
|
|
468
596
|
const syntheticTurnKey = `${PRETURN_TURNKEY_PREFIX}${statusKey}:${startedAt}`
|
|
597
|
+
// Carry the re-injection counter across the buffer round trip: a genuine
|
|
598
|
+
// orphan re-injects this inbound, which re-enters here with the counter
|
|
599
|
+
// stamped on `meta`, so the retry cap survives the entry's recreate.
|
|
600
|
+
const rawReinject = inbound.meta?.[HANDBACK_REINJECT_COUNT_META_KEY]
|
|
601
|
+
const parsedReinject = rawReinject != null ? Number.parseInt(rawReinject, 10) : 0
|
|
602
|
+
const reinjectCount = Number.isFinite(parsedReinject) && parsedReinject > 0 ? parsedReinject : 0
|
|
469
603
|
const entry: PreTurnEntry = {
|
|
470
604
|
statusKey,
|
|
471
605
|
chatId,
|
|
@@ -473,6 +607,10 @@ export function createHandbackPreturnSignal(
|
|
|
473
607
|
adoptTurnId,
|
|
474
608
|
syntheticTurnKey,
|
|
475
609
|
startedAt,
|
|
610
|
+
// Release IS the delivery point (this is the buffer-drain chokepoint).
|
|
611
|
+
deliveredAt: startedAt,
|
|
612
|
+
reinjectCount,
|
|
613
|
+
inbound,
|
|
476
614
|
pinned: false,
|
|
477
615
|
debounceTimer: null,
|
|
478
616
|
reapTimer: null,
|
|
@@ -179,6 +179,7 @@ export type GatewayToClient =
|
|
|
179
179
|
| Ms365ApprovalPostedEvent
|
|
180
180
|
| ConfigApprovalResolvedEvent
|
|
181
181
|
| RolloutStatusPostedEvent
|
|
182
|
+
| RolloutStatusEditedEvent
|
|
182
183
|
| PendingPermissionStatusEvent
|
|
183
184
|
| PreApprovedResultEvent;
|
|
184
185
|
|
|
@@ -567,9 +568,13 @@ export interface RolloutStatusPostMessage {
|
|
|
567
568
|
/**
|
|
568
569
|
* #2726 Part 2 — hostd asks the gateway to EDIT the rollout status message it
|
|
569
570
|
* previously posted (identified by `messageId`, returned in the
|
|
570
|
-
* `rollout_status_posted` reply). Best-effort
|
|
571
|
-
*
|
|
572
|
-
*
|
|
571
|
+
* `rollout_status_posted` reply). Best-effort toward the ROLL: hostd never
|
|
572
|
+
* blocks on it and Telegram 429s are retried gateway-side.
|
|
573
|
+
*
|
|
574
|
+
* #4065 — the gateway now replies with `rollout_status_edited` so hostd can
|
|
575
|
+
* tell "edited" from "edited into the void". A seeded-resume narrator holds a
|
|
576
|
+
* message_id it never posted itself; without the reply a stale id means the
|
|
577
|
+
* operator sees a frozen card for the rest of the roll.
|
|
573
578
|
*/
|
|
574
579
|
export interface RolloutStatusEditMessage {
|
|
575
580
|
type: "rollout_status_edit";
|
|
@@ -597,6 +602,29 @@ export interface RolloutStatusPostedEvent {
|
|
|
597
602
|
reason?: string;
|
|
598
603
|
}
|
|
599
604
|
|
|
605
|
+
/**
|
|
606
|
+
* #4065 — gateway → hostd reply after a `rollout_status_edit`, so a narrator
|
|
607
|
+
* editing a card it did NOT post (the seeded post-self-bump resume) can tell
|
|
608
|
+
* that the card is gone instead of editing into the void for the rest of the
|
|
609
|
+
* roll. Advisory only: hostd never blocks a roll on it, and a gateway that
|
|
610
|
+
* predates this event simply never sends one (hostd times out and behaves as
|
|
611
|
+
* before).
|
|
612
|
+
*/
|
|
613
|
+
export interface RolloutStatusEditedEvent {
|
|
614
|
+
type: "rollout_status_edited";
|
|
615
|
+
requestId: string;
|
|
616
|
+
/** True when the live card now carries the requested body. */
|
|
617
|
+
ok: boolean;
|
|
618
|
+
/**
|
|
619
|
+
* True IFF the failure means the target message no longer exists / can no
|
|
620
|
+
* longer be edited. ONLY this justifies a re-post — a transient failure
|
|
621
|
+
* (429 past retries, network) leaves a perfectly good card in the chat.
|
|
622
|
+
*/
|
|
623
|
+
gone?: boolean;
|
|
624
|
+
/** Diagnostic detail on failure. */
|
|
625
|
+
reason?: string;
|
|
626
|
+
}
|
|
627
|
+
|
|
600
628
|
/**
|
|
601
629
|
* Issue #2971 — sent by the wedge-watchdog (autoaccept-poll sidecar) over
|
|
602
630
|
* the gateway UDS, BEFORE it would otherwise Esc a shape-persistent
|
|
@@ -152,12 +152,14 @@ export interface IpcServerOptions {
|
|
|
152
152
|
) => Promise<void>;
|
|
153
153
|
/**
|
|
154
154
|
* #2726 Part 2 — hostd asks the gateway to EDIT a previously-posted rollout
|
|
155
|
-
* status message as later phases arrive.
|
|
155
|
+
* status message as later phases arrive. Optional. #4065: the handler replies
|
|
156
|
+
* with a `rollout_status_edited` outcome so hostd can tell an applied edit
|
|
157
|
+
* from an edit into a deleted card; the roll never blocks on that reply.
|
|
156
158
|
*/
|
|
157
159
|
onRolloutStatusEdit?: (
|
|
158
160
|
client: IpcClient,
|
|
159
161
|
msg: RolloutStatusEditMessage,
|
|
160
|
-
) => void
|
|
162
|
+
) => void | Promise<void>;
|
|
161
163
|
log?: (msg: string) => void;
|
|
162
164
|
/**
|
|
163
165
|
* How long (in ms) to wait without a heartbeat before force-closing the
|
|
@@ -813,14 +815,21 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
813
815
|
case "rollout_status_edit":
|
|
814
816
|
if (onRolloutStatusEdit) {
|
|
815
817
|
try {
|
|
816
|
-
|
|
818
|
+
void Promise.resolve(
|
|
819
|
+
onRolloutStatusEdit(client, msg as RolloutStatusEditMessage),
|
|
820
|
+
).catch((err) => {
|
|
821
|
+
log(
|
|
822
|
+
`rollout_status_edit handler rejected (client=${client.id}): ${(err as Error).message}`,
|
|
823
|
+
);
|
|
824
|
+
});
|
|
817
825
|
} catch (err) {
|
|
818
826
|
log(
|
|
819
827
|
`rollout_status_edit handler threw (client=${client.id}): ${(err as Error).message}`,
|
|
820
828
|
);
|
|
821
829
|
}
|
|
822
830
|
}
|
|
823
|
-
//
|
|
831
|
+
// The handler replies `rollout_status_edited` (#4065) when it can; an
|
|
832
|
+
// unwired gateway sends nothing and hostd's bounded wait expires.
|
|
824
833
|
break;
|
|
825
834
|
case "update_placeholder":
|
|
826
835
|
// Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
|
|
@@ -43,6 +43,11 @@ export interface ObligationStoreFsSeam {
|
|
|
43
43
|
* entry lives in the parent's own cached metadata and is otherwise lost
|
|
44
44
|
* in a power cut. */
|
|
45
45
|
fsyncDirSync: (path: string) => void
|
|
46
|
+
/** #4146: remove a snapshot this process will not maintain. Optional so any
|
|
47
|
+
* existing seam (tests, other callers) keeps compiling and keeps working —
|
|
48
|
+
* an absent `unlinkSync` degrades to "no cleanup", which is what those
|
|
49
|
+
* callers do today. */
|
|
50
|
+
unlinkSync?: (path: string) => void
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
interface SnapshotEnvelope {
|
|
@@ -126,6 +131,56 @@ export function loadObligations(path: string, fs: ObligationStoreFsSeam): Obliga
|
|
|
126
131
|
return env.obligations.filter(isObligationRow).map(sanitizeCapturedDelivery)
|
|
127
132
|
}
|
|
128
133
|
|
|
134
|
+
/**
|
|
135
|
+
* #4146 — drop a snapshot this process is NOT going to maintain.
|
|
136
|
+
*
|
|
137
|
+
* When the gateway is STATIC or `SWITCHROOM_OBLIGATION_LEDGER=0`, the ledger's
|
|
138
|
+
* `onChange` persister is never wired, so obligations open and close purely in
|
|
139
|
+
* memory while any previously-written `obligations.json` sits on disk
|
|
140
|
+
* unchanged. That leftover is not harmless: the W1-d audience classifier
|
|
141
|
+
* (`hooks/audience-classify.mjs`) reads this exact file to decide whether
|
|
142
|
+
* anyone is waiting on a chat, and an empty-but-stale open set reads there as
|
|
143
|
+
* POSITIVE evidence that nobody is — which is the one input that can make the
|
|
144
|
+
* classifier suppress a message owed to a human.
|
|
145
|
+
*
|
|
146
|
+
* Removing the file restores that classifier's stated invariant ("absent ⇒
|
|
147
|
+
* unknown ⇒ deliver") deterministically, with no freshness heuristic and no
|
|
148
|
+
* clock. Note this is safe precisely because the snapshot has exactly one
|
|
149
|
+
* writer: with the ledger ON, a merely-stopped gateway leaves a snapshot that
|
|
150
|
+
* is accurate rather than stale, so nothing is lost by only cleaning up in the
|
|
151
|
+
* modes where persistence is disabled.
|
|
152
|
+
*
|
|
153
|
+
* Best-effort and never throws — a failure degrades to the reader-side
|
|
154
|
+
* ledger-off guard in the hook.
|
|
155
|
+
*
|
|
156
|
+
* @returns `true` iff a file was actually removed.
|
|
157
|
+
*/
|
|
158
|
+
export function removeUnmaintainedSnapshot(
|
|
159
|
+
path: string,
|
|
160
|
+
fs: ObligationStoreFsSeam,
|
|
161
|
+
reason = 'persistence disabled',
|
|
162
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
163
|
+
): boolean {
|
|
164
|
+
const unlink = fs.unlinkSync
|
|
165
|
+
if (unlink == null) return false
|
|
166
|
+
try {
|
|
167
|
+
if (!fs.existsSync(path)) return false
|
|
168
|
+
unlink(path)
|
|
169
|
+
log(
|
|
170
|
+
`obligation-store: persistence is off (${reason}) — removed unmaintained ` +
|
|
171
|
+
`snapshot path=${path} so it cannot be read as "nobody is waiting" (#4146)\n`,
|
|
172
|
+
)
|
|
173
|
+
return true
|
|
174
|
+
} catch (err) {
|
|
175
|
+
log(
|
|
176
|
+
`obligation-store: could not remove unmaintained snapshot path=${path}: ` +
|
|
177
|
+
`${(err as Error).message} — audience classification falls back to the ` +
|
|
178
|
+
`reader-side ledger-off guard (#4146)\n`,
|
|
179
|
+
)
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
129
184
|
/**
|
|
130
185
|
* Persist the open set atomically (write sibling tmp → rename over the real
|
|
131
186
|
* path). Best-effort relative to fs availability: a write failure is logged but
|