@raidiant/notifai 9.2.2 → 9.3.0

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +8 -5
  3. package/dist/claude-wake.js +11 -0
  4. package/dist/claude-wake.js.map +1 -1
  5. package/dist/codex-wake.js +9 -0
  6. package/dist/codex-wake.js.map +1 -1
  7. package/dist/commands-core.d.ts +0 -3
  8. package/dist/commands-core.js.map +1 -1
  9. package/dist/commands-doctor.js +41 -2
  10. package/dist/commands-doctor.js.map +1 -1
  11. package/dist/commands-hook-shape.d.ts +4 -3
  12. package/dist/commands-hook-shape.js +19 -10
  13. package/dist/commands-hook-shape.js.map +1 -1
  14. package/dist/commands-hooks.js +64 -13
  15. package/dist/commands-hooks.js.map +1 -1
  16. package/dist/commands-init.js +12 -1
  17. package/dist/commands-init.js.map +1 -1
  18. package/dist/commands-project.d.ts +4 -0
  19. package/dist/commands-project.js +41 -0
  20. package/dist/commands-project.js.map +1 -0
  21. package/dist/commands-send-support.js +1 -22
  22. package/dist/commands-send-support.js.map +1 -1
  23. package/dist/commands-send.js +10 -4
  24. package/dist/commands-send.js.map +1 -1
  25. package/dist/commands-skill.js +58 -0
  26. package/dist/commands-skill.js.map +1 -1
  27. package/dist/commands.d.ts +1 -0
  28. package/dist/commands.js +1 -0
  29. package/dist/commands.js.map +1 -1
  30. package/dist/config-schema.js +2 -2
  31. package/dist/config-schema.js.map +1 -1
  32. package/dist/config.d.ts +3 -3
  33. package/dist/config.js +4 -7
  34. package/dist/config.js.map +1 -1
  35. package/dist/guidance-content.d.ts +3 -0
  36. package/dist/guidance-content.js +20 -0
  37. package/dist/guidance-content.js.map +1 -1
  38. package/dist/guidance-render.js +3 -3
  39. package/dist/guidance-render.js.map +1 -1
  40. package/dist/harnesses.js +2 -2
  41. package/dist/harnesses.js.map +1 -1
  42. package/dist/hooks.d.ts +26 -50
  43. package/dist/hooks.js +391 -150
  44. package/dist/hooks.js.map +1 -1
  45. package/dist/install-hooks.d.ts +7 -28
  46. package/dist/install-hooks.js +18 -37
  47. package/dist/install-hooks.js.map +1 -1
  48. package/dist/program.d.ts +4 -1
  49. package/dist/program.js +19 -1
  50. package/dist/program.js.map +1 -1
  51. package/dist/project-enablement.d.ts +15 -0
  52. package/dist/project-enablement.js +87 -0
  53. package/dist/project-enablement.js.map +1 -0
  54. package/dist/question-timing.d.ts +46 -0
  55. package/dist/question-timing.js +48 -0
  56. package/dist/question-timing.js.map +1 -0
  57. package/dist/readiness.d.ts +7 -0
  58. package/dist/readiness.js.map +1 -1
  59. package/dist/send.d.ts +2 -0
  60. package/dist/send.js +6 -1
  61. package/dist/send.js.map +1 -1
  62. package/dist/session-activation.d.ts +1 -1
  63. package/dist/session-activation.js +3 -4
  64. package/dist/session-activation.js.map +1 -1
  65. package/package.json +2 -2
  66. package/src/claude-wake.test.ts +1 -0
  67. package/src/claude-wake.ts +10 -0
  68. package/src/codex-wake.test.ts +1 -0
  69. package/src/codex-wake.ts +9 -0
  70. package/src/commands-core.ts +0 -3
  71. package/src/commands-doctor.ts +48 -4
  72. package/src/commands-hook-shape.ts +26 -10
  73. package/src/commands-hooks.ts +59 -13
  74. package/src/commands-init.ts +11 -1
  75. package/src/commands-project.ts +47 -0
  76. package/src/commands-send-support.ts +1 -27
  77. package/src/commands-send.ts +8 -3
  78. package/src/commands-skill.test.ts +44 -0
  79. package/src/commands-skill.ts +57 -0
  80. package/src/commands.test.ts +47 -17
  81. package/src/commands.ts +5 -0
  82. package/src/config-schema.ts +2 -2
  83. package/src/config.test.ts +2 -3
  84. package/src/config.ts +7 -10
  85. package/src/guidance-content.ts +22 -0
  86. package/src/guidance-render.ts +3 -3
  87. package/src/harnesses.ts +2 -2
  88. package/src/hook-adapter.test.ts +27 -8
  89. package/src/hooks.test.ts +808 -80
  90. package/src/hooks.ts +525 -181
  91. package/src/install-hooks.test.ts +8 -12
  92. package/src/install-hooks.ts +21 -37
  93. package/src/program.test.ts +18 -0
  94. package/src/program.ts +22 -0
  95. package/src/project-enablement.test.ts +55 -0
  96. package/src/project-enablement.ts +99 -0
  97. package/src/question-timing.ts +56 -0
  98. package/src/readiness.ts +8 -1
  99. package/src/send.ts +8 -1
  100. package/src/session-activation.ts +3 -4
  101. package/dist/orca-session-title.d.ts +0 -10
  102. package/dist/orca-session-title.js +0 -99
  103. package/dist/orca-session-title.js.map +0 -1
  104. package/src/orca-session-title.test.ts +0 -95
  105. package/src/orca-session-title.ts +0 -105
package/src/hooks.ts CHANGED
@@ -12,6 +12,12 @@ import {
12
12
  } from 'node:fs'
13
13
  import path from 'node:path'
14
14
  import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
15
+ import {
16
+ LEGACY_QUESTION_CLAIM_TTL_SECONDS,
17
+ QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS,
18
+ QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS,
19
+ QUESTION_WAITER_CEILING_SECONDS,
20
+ } from './question-timing.js'
15
21
  import type {
16
22
  LifecycleEndState,
17
23
  ListRepliesResponse,
@@ -56,12 +62,11 @@ import {
56
62
  * proven exact-session continuation fail closed at question admission.
57
63
  *
58
64
  * Where the user is standing no longer decides anything here. It used to: the
59
- * turn-end hook held the terminal for as long as it waited, so waiting was
60
- * only defensible while the person was demonstrably not at the keyboard. On
61
- * Claude Code the handler now returns instantly and the waiter runs out of
62
- * band, and nothing is monopolised in the first place — so the question is
63
- * simply whether the user wants to be reached, which they answer once in
64
- * configuration rather than implicitly with every keystroke.
65
+ * old turn-end route held the terminal briefly and made keyboard presence an
66
+ * input to whether it waited. The current policy is explicit instead: Claude
67
+ * Code owns the complete answer window out of band, while Codex owns it by
68
+ * holding the asking turn. Neither infers notification preference from
69
+ * keystrokes.
65
70
  */
66
71
 
67
72
  /** Fields we read from harness hook JSON. Everything else is passed through. */
@@ -175,6 +180,11 @@ interface AcceptedAnswerDelivery {
175
180
  * passes through.
176
181
  */
177
182
  delivery_attempts?: number
183
+ /**
184
+ * Linearization point between delivery and SessionEnd. Once recorded, the
185
+ * route began first; before it, SessionEnd cancellation wins.
186
+ */
187
+ delivery_committed_at?: number
178
188
  /** Turns this answer has been held without being handed to the agent. */
179
189
  held_deliveries?: number
180
190
  }
@@ -250,15 +260,13 @@ export interface PendingQuestion {
250
260
  collapse_key?: string
251
261
  /** Exact fanout of the live question; routing config may change afterwards. */
252
262
  device_ids?: string[]
253
- /** Absolute end of the server reply window and its local continuation owner. */
263
+ /** Absolute end of the server reply window. */
254
264
  reply_deadline_at?: number
255
265
  /**
256
- * When this Stop owner stops listening for a direct wake. Distinct from
257
- * `reply_deadline_at`: the server may still accept an answer for a day after
258
- * this owner has returned. A later Stop that treats the reply window as a
259
- * fresh waiter ceiling monopolizes the claim and skips newly registered
260
- * questions. Absent on state written before this field existed — treated as
261
- * already spent, because that is the shape a degraded waiter left behind.
266
+ * Absolute process-owner deadline. It begins before submission and includes
267
+ * startup headroom, so it must be later than `reply_deadline_at`. Absent on
268
+ * state written before ownership was persisted — treated as already spent
269
+ * instead of inventing a fresh multi-day claim.
262
270
  */
263
271
  owner_deadline_at?: number
264
272
  /** Frozen before the first network byte, so an ambiguous submit is replayable. */
@@ -300,40 +308,6 @@ function summarizeRequestIds(entries: readonly PendingQuestion[]): {
300
308
  return { ids, display: ids.join(', ') }
301
309
  }
302
310
 
303
- /**
304
- * The waiter's whole wall clock: the terminal-first timer, submission, and the
305
- * wait for an answer, from the moment the turn-end hook starts.
306
- *
307
- * One plain ceiling, deliberately not a budget carved into reserves. The
308
- * reserves existed to fit every phase inside a *held* turn; on Claude Code the
309
- * turn is not held any more, and on a host that still holds one the useful
310
- * question is only how long that host is willing to wait in total.
311
- */
312
- export const WAITER_CEILING_SECONDS = 480
313
-
314
- /**
315
- * The same wall clock where the waiter runs in the background instead of
316
- * inside a held turn.
317
- *
318
- * On a host that holds its turn, 480 s is a real cost to the person sitting in
319
- * front of it, and the number above is right. On Claude Code the Stop hook
320
- * returns at once and the waiter is detached, so the same 480 s buys nothing
321
- * and costs a direct wake: an answer arriving at minute nine had to wait for
322
- * the session's next turn even though nothing was blocked.
323
- *
324
- * This sits under `CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS` (3600) with headroom,
325
- * because that declaration's kill is silent, and under the reply window the
326
- * question was pushed with. Past it the journal still delivers at the next
327
- * turn — the ceiling decides how long a *direct* wake stays possible, never
328
- * whether the answer survives.
329
- */
330
- export const DETACHED_WAITER_CEILING_SECONDS = 3300
331
-
332
- /** The waiter's wall clock, which depends on whether a turn is held open for it. */
333
- export function waiterCeilingSeconds(detached: boolean): number {
334
- return detached ? DETACHED_WAITER_CEILING_SECONDS : WAITER_CEILING_SECONDS
335
- }
336
-
337
311
  /**
338
312
  * The wire contract's shortest reply window. A question submitted with less
339
313
  * than this is rejected outright, and accepting one would in any case let the
@@ -341,14 +315,6 @@ export function waiterCeilingSeconds(detached: boolean): number {
341
315
  */
342
316
  export const MIN_REPLY_WINDOW_SECONDS = 60
343
317
 
344
- /**
345
- * HTTP budget reserved for the waiter's final reply fetch and close fence.
346
- * Taken from the owner deadline rather than added after it, so the hook does
347
- * not block past its ceiling. A near-zero remainder used to produce a
348
- * misleading 0s timeout and an unproven retirement.
349
- */
350
- export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3
351
-
352
318
  /**
353
319
  * The terminal-first wait: the question sits in the terminal for
354
320
  * `ask_grace_seconds` from when it was sent, and only then reaches companion
@@ -357,8 +323,9 @@ export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3
357
323
  * A plain timer, and nothing else. It once polled an idle signal so it could
358
324
  * abandon the wait the moment the user touched the keyboard — necessary while
359
325
  * the wait blocked the terminal, because a user wanting to answer locally was
360
- * otherwise locked out of their own prompt. Nothing is monopolised now, so
361
- * there is nothing to watch for and no machine this cannot run on.
326
+ * otherwise locked out of their own prompt. Presence no longer changes this
327
+ * deterministic timer; whether the harness holds or wakes is a separate route
328
+ * decision.
362
329
  *
363
330
  * The window is measured from registration, not from the turn's end: a
364
331
  * question the agent asked five minutes ago while it kept working has already
@@ -380,6 +347,8 @@ async function awaitTerminalFirstWindow(
380
347
  export interface HookOutcome {
381
348
  /** Written to stdout verbatim — the harness parses this as output. */
382
349
  stdout?: string
350
+ /** Commit a blocking continuation immediately before the harness stdout write. */
351
+ commitStdout?: () => boolean
383
352
  /** Whether stdout takes over the turn, rather than adding prompt context. */
384
353
  decided?: boolean
385
354
  /** Diagnostics; harnesses surface hook stderr in the transcript. */
@@ -395,6 +364,12 @@ export interface ContinuationEvent {
395
364
  remaining: number
396
365
  request_ids: string[]
397
366
  journal_recorded_at: number
367
+ /**
368
+ * Must be called immediately before the route's irreversible harness write.
369
+ * It atomically orders that write against SessionEnd; false means cancellation
370
+ * won and the route must hand nothing over.
371
+ */
372
+ commitDelivery(): boolean
398
373
  }
399
374
 
400
375
  /**
@@ -423,6 +398,8 @@ export type DeliveryAcknowledgement = 'delivered' | 'stdout' | 'held'
423
398
  /** What a route returns: the hook's outcome plus what the attempt proved. */
424
399
  export interface DeliveryOutcome {
425
400
  stdout?: string
401
+ /** Deferred SessionEnd fence for a blocking stdout continuation. */
402
+ commitStdout?: () => boolean
426
403
  notes?: string[]
427
404
  log?: Record<string, unknown>
428
405
  acknowledgement: DeliveryAcknowledgement
@@ -445,6 +422,37 @@ function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
445
422
  return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`)
446
423
  }
447
424
 
425
+ /**
426
+ * Durable cancellation for observers that can outlive the harness process.
427
+ *
428
+ * Absence of session state is not enough: SessionEnd deliberately deletes it,
429
+ * and an in-flight submit/fence could otherwise recreate it afterwards. The
430
+ * marker shares the session-state lock, giving every racing writer one total
431
+ * order: either its state lands before SessionEnd and is cleaned up there, or
432
+ * it observes this marker and must not write or deliver into the ended session.
433
+ */
434
+ function sessionEndMarkerPath(sessionId: string, env: NodeJS.ProcessEnv): string {
435
+ return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.ended`)
436
+ }
437
+
438
+ function sessionHasEnded(sessionId: string, env: NodeJS.ProcessEnv): boolean {
439
+ return existsSync(sessionEndMarkerPath(sessionId, env))
440
+ }
441
+
442
+ function markSessionEnded(sessionId: string, env: NodeJS.ProcessEnv, now: number): void {
443
+ const stateFile = sessionStatePath(sessionId, env)
444
+ withFileLock(`${stateFile}.lock`, () => {
445
+ atomicWriteFileSync(sessionEndMarkerPath(sessionId, env), `${now}\n`)
446
+ })
447
+ }
448
+
449
+ function clearSessionEndMarker(sessionId: string, env: NodeJS.ProcessEnv): void {
450
+ const stateFile = sessionStatePath(sessionId, env)
451
+ withFileLock(`${stateFile}.lock`, () => {
452
+ rmSync(sessionEndMarkerPath(sessionId, env), { force: true })
453
+ })
454
+ }
455
+
448
456
  export function readSessionState(sessionId: string, env: NodeJS.ProcessEnv): SessionState {
449
457
  const file = sessionStatePath(sessionId, env)
450
458
  if (!existsSync(file)) return {}
@@ -537,7 +545,11 @@ function updateSessionState(
537
545
  ): SessionState {
538
546
  const file = sessionStatePath(sessionId, env)
539
547
  return withFileLock(`${file}.lock`, () => {
540
- const next = update(readSessionState(sessionId, env))
548
+ const current = readSessionState(sessionId, env)
549
+ // SessionEnd and every asynchronous writer share this lock. Once the
550
+ // durable marker exists, no observer may recreate state for that session.
551
+ if (sessionHasEnded(sessionId, env)) return current
552
+ const next = update(current)
541
553
  writeSessionStateUnlocked(file, sessionId, next)
542
554
  return next
543
555
  })
@@ -550,6 +562,9 @@ export function recordSessionStart(
550
562
  harness?: HookHarness,
551
563
  cwd?: string,
552
564
  ): void {
565
+ // Harnesses may reuse a session id only by explicitly starting that session
566
+ // again. That lifecycle edge is the sole authority for clearing cancellation.
567
+ clearSessionEndMarker(sessionId, env)
553
568
  updateSessionState(sessionId, env, (current) => ({
554
569
  ...current,
555
570
  ...(harness === undefined ? {} : { harness }),
@@ -617,9 +632,10 @@ export function resetCursorStopActivation(sessionId: string, env: NodeJS.Process
617
632
  /**
618
633
  * Session state a crashed harness left behind.
619
634
  *
620
- * `SessionEnd` removes both the marker and the session override, but a harness
621
- * that crashes or is killed never reaches it. At roughly a hundred sessions a
622
- * day that is tens of thousands of files a year, none of which anything reads.
635
+ * `SessionEnd` removes session state and leaves a short-lived cancellation
636
+ * marker; a harness that crashes or is killed never reaches it. At roughly a
637
+ * hundred sessions a day that is tens of thousands of files a year, none of
638
+ * which anything reads.
623
639
  *
624
640
  * Opportunistic rather than scheduled: hooks are the only thing that runs, so
625
641
  * a hook is where this has to live. It is rate-limited by its own stamp file
@@ -681,7 +697,7 @@ export function pruneAbandonedSessions(
681
697
  * known-dead PID is recoverable immediately. Only legacy/corrupt claims with
682
698
  * no trustworthy PID fall back to an age limit.
683
699
  */
684
- const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000
700
+ const CLAIM_TTL_MS = LEGACY_QUESTION_CLAIM_TTL_SECONDS * 1000
685
701
 
686
702
  /**
687
703
  * How long a crashed claim *guard* blocks the next hook.
@@ -700,18 +716,26 @@ export function claimQuestionPush(
700
716
  env: NodeJS.ProcessEnv,
701
717
  now: number = Date.now(),
702
718
  beforeStaleReplace?: () => void,
719
+ ownerDeadlineAt?: number,
703
720
  ): boolean {
704
721
  const file = claimPath(sessionId, env)
705
722
  const guard = `${file}.guard`
706
723
  const token = randomBytes(12).toString('base64url')
724
+ const pendingQuestionIds = pendingList(readSessionState(sessionId, env)).map(
725
+ claimQuestionIdentity,
726
+ )
707
727
  mkdirSync(path.dirname(file), { recursive: true })
708
728
  if (!acquireClaimGuard(guard)) return false
709
729
  try {
710
730
  try {
711
- writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
731
+ writeFileSync(
732
+ file,
733
+ `${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`,
734
+ {
712
735
  mode: 0o600,
713
736
  flag: 'wx',
714
- })
737
+ },
738
+ )
715
739
  heldClaims.set(file, token)
716
740
  return true
717
741
  } catch {
@@ -730,10 +754,14 @@ export function claimQuestionPush(
730
754
  beforeStaleReplace?.()
731
755
  rmSync(file, { force: true })
732
756
  try {
733
- writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
757
+ writeFileSync(
758
+ file,
759
+ `${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`,
760
+ {
734
761
  mode: 0o600,
735
762
  flag: 'wx',
736
- })
763
+ },
764
+ )
737
765
  heldClaims.set(file, token)
738
766
  return true
739
767
  } catch {
@@ -745,6 +773,42 @@ export function claimQuestionPush(
745
773
  }
746
774
  }
747
775
 
776
+ function claimQuestionIdentity(entry: PendingQuestion): string {
777
+ return entry.question_id ?? `${entry.asked_at ?? 'legacy'}\u0000${entry.question}`
778
+ }
779
+
780
+ /** True only when the live owner could not have snapshotted this unpushed ask. */
781
+ function claimHandoffState(
782
+ sessionId: string,
783
+ env: NodeJS.ProcessEnv,
784
+ ): { hasNewQuestion: boolean; ownerDeadlineAt?: number } {
785
+ const unasked = pendingList(readSessionState(sessionId, env)).filter(
786
+ (entry) => entry.request_id === undefined,
787
+ )
788
+ if (unasked.length === 0) return { hasNewQuestion: false }
789
+ try {
790
+ const held = JSON.parse(readFileSync(claimPath(sessionId, env), 'utf8')) as {
791
+ pending_question_ids?: unknown
792
+ owner_deadline_at?: unknown
793
+ }
794
+ if (!Array.isArray(held.pending_question_ids)) return { hasNewQuestion: false }
795
+ const snapshotted = new Set(
796
+ held.pending_question_ids.filter((entry): entry is string => typeof entry === 'string'),
797
+ )
798
+ return {
799
+ hasNewQuestion: unasked.some(
800
+ (entry) => !snapshotted.has(claimQuestionIdentity(entry)),
801
+ ),
802
+ ...(typeof held.owner_deadline_at === 'number' &&
803
+ Number.isFinite(held.owner_deadline_at)
804
+ ? { ownerDeadlineAt: held.owner_deadline_at }
805
+ : {}),
806
+ }
807
+ } catch {
808
+ return { hasNewQuestion: false }
809
+ }
810
+ }
811
+
748
812
  function processIsAlive(pid: number): boolean {
749
813
  try {
750
814
  process.kill(pid, 0)
@@ -1079,6 +1143,9 @@ export type HookHarness = Harness
1079
1143
  */
1080
1144
  const REPLY_POLL_SECONDS = 5
1081
1145
 
1146
+ /** Avoid hot-looping while a live observer cooperatively yields its claim. */
1147
+ const CLAIM_HANDOFF_POLL_MS = 1_000
1148
+
1082
1149
  /**
1083
1150
  * Push a question and block for the answer.
1084
1151
  *
@@ -1164,6 +1231,19 @@ async function submitQuestion(
1164
1231
  return receipt
1165
1232
  }
1166
1233
 
1234
+ function canSubmitCompleteWindow(
1235
+ ctx: HookContext,
1236
+ intent: PendingSubmissionIntent,
1237
+ fallbackWindowSeconds: number,
1238
+ ): boolean {
1239
+ const frozenWindowSeconds =
1240
+ intent.draft.reply?.expires_in_seconds ?? fallbackWindowSeconds
1241
+ return (
1242
+ intent.owner_deadline_at - ctx.now() >=
1243
+ (frozenWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000
1244
+ )
1245
+ }
1246
+
1167
1247
  /**
1168
1248
  * The waiter's wait: one bounded poll across every live question at once.
1169
1249
  *
@@ -1177,10 +1257,12 @@ async function waitForAnyReply(
1177
1257
  ctx: HookContext,
1178
1258
  requestIds: string[],
1179
1259
  timeoutSeconds: number,
1260
+ interruption?: () => 'ownership-ended' | 'new-question' | null,
1180
1261
  ): Promise<{
1181
1262
  byRequest: Map<string, ReplyView[]>
1182
1263
  degraded: boolean
1183
1264
  permanentFailures: Map<string, string>
1265
+ interrupted: 'ownership-ended' | 'new-question' | null
1184
1266
  }> {
1185
1267
  const deadline = ctx.now() + timeoutSeconds * 1000
1186
1268
  let degraded = false
@@ -1188,13 +1270,17 @@ async function waitForAnyReply(
1188
1270
  const permanentFailures = new Map<string, string>()
1189
1271
 
1190
1272
  if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
1191
- return { byRequest: new Map(), degraded: false, permanentFailures }
1273
+ return { byRequest: new Map(), degraded: false, permanentFailures, interrupted: null }
1192
1274
  }
1193
1275
 
1194
1276
  for (;;) {
1277
+ const beforePoll = interruption?.() ?? null
1278
+ if (beforePoll !== null) {
1279
+ return { byRequest: new Map(), degraded, permanentFailures, interrupted: beforePoll }
1280
+ }
1195
1281
  const remainingMs = Math.max(0, deadline - ctx.now())
1196
1282
  if (!firstPoll && remainingMs === 0) {
1197
- return { byRequest: new Map(), degraded, permanentFailures }
1283
+ return { byRequest: new Map(), degraded, permanentFailures, interrupted: null }
1198
1284
  }
1199
1285
  firstPoll = false
1200
1286
  const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)))
@@ -1225,12 +1311,50 @@ async function waitForAnyReply(
1225
1311
  }
1226
1312
  if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
1227
1313
  }
1314
+ const afterPoll = interruption?.() ?? null
1315
+ // SessionEnd or terminal-side retirement wins even if an in-flight poll
1316
+ // happened to return an answer. A newly registered question merely asks
1317
+ // this owner to yield; an answer already observed remains authoritative.
1318
+ if (afterPoll === 'ownership-ended') {
1319
+ return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll }
1320
+ }
1228
1321
  if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
1229
- return { byRequest, degraded, permanentFailures }
1322
+ return { byRequest, degraded, permanentFailures, interrupted: null }
1323
+ }
1324
+ if (afterPoll === 'new-question') {
1325
+ return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll }
1230
1326
  }
1231
1327
  }
1232
1328
  }
1233
1329
 
1330
+ /**
1331
+ * Local lifecycle signal for a long-lived observer.
1332
+ *
1333
+ * SessionEnd and terminal-side retirement remove an owned request from pending
1334
+ * state; the detached process must stop instead of recreating that session.
1335
+ * A new unpushed question asks this owner to yield the session claim so the
1336
+ * successor Stop can submit it promptly.
1337
+ */
1338
+ function waiterInterruption(
1339
+ sessionId: string,
1340
+ env: NodeJS.ProcessEnv,
1341
+ requestIds: readonly string[],
1342
+ ): 'ownership-ended' | 'new-question' | null {
1343
+ if (sessionHasEnded(sessionId, env)) return 'ownership-ended'
1344
+ const pending = pendingList(readSessionState(sessionId, env))
1345
+ const owned = new Set(requestIds)
1346
+ // One question can be retired from the terminal while this observer owns
1347
+ // several. That is a per-question change, not whole-session cancellation;
1348
+ // the caller filters the changed set after each poll and keeps the rest.
1349
+ return pending.some(
1350
+ (entry) =>
1351
+ entry.request_id === undefined &&
1352
+ (entry.submission === undefined || !owned.has(entry.submission.request_id)),
1353
+ )
1354
+ ? 'new-question'
1355
+ : null
1356
+ }
1357
+
1234
1358
  /** A non-retryable replies fault, with enough server identity to act on it. */
1235
1359
  function replyPollFailure(err: unknown): string {
1236
1360
  return err instanceof ApiCallError
@@ -1352,9 +1476,16 @@ function retiringQuestion(
1352
1476
  state: LifecycleEndState,
1353
1477
  cwd?: string,
1354
1478
  ): RetiringQuestion | null {
1355
- const hasRequest = pending.request_id !== undefined
1356
- const hasCollapse = pending.collapse_key !== undefined
1357
- const hasDevices = pending.device_ids !== undefined && pending.device_ids.length > 0
1479
+ // A submit journal is written before the first network byte. SessionEnd can
1480
+ // therefore race a request that the server accepts after local cleanup. Its
1481
+ // reserved request id and exact fanout are already sufficient retirement
1482
+ // identity, even though the ordinary live fields have not been promoted yet.
1483
+ const requestId = pending.request_id ?? pending.submission?.request_id
1484
+ const collapseKey = pending.collapse_key ?? pending.submission?.collapse_key
1485
+ const deviceIds = pending.device_ids ?? pending.submission?.device_ids
1486
+ const hasRequest = requestId !== undefined
1487
+ const hasCollapse = collapseKey !== undefined
1488
+ const hasDevices = deviceIds !== undefined && deviceIds.length > 0
1358
1489
  if (!hasRequest && !hasCollapse && !hasDevices) return null
1359
1490
  if (!hasRequest || !hasCollapse || !hasDevices) {
1360
1491
  throw new Error(
@@ -1363,9 +1494,9 @@ function retiringQuestion(
1363
1494
  }
1364
1495
  const source = sourceContextAtHookEvent(pending.source, cwd)
1365
1496
  return {
1366
- request_id: pending.request_id!,
1367
- collapse_key: pending.collapse_key!,
1368
- device_ids: [...pending.device_ids!],
1497
+ request_id: requestId!,
1498
+ collapse_key: collapseKey!,
1499
+ device_ids: [...deviceIds!],
1369
1500
  question: pending.question,
1370
1501
  ...(pending.project !== undefined ? { project: pending.project } : {}),
1371
1502
  ...(source !== undefined ? { source } : {}),
@@ -1943,6 +2074,40 @@ function amendAcceptedAnswers(
1943
2074
  )
1944
2075
  }
1945
2076
 
2077
+ /**
2078
+ * Finish a route write that linearized before SessionEnd.
2079
+ *
2080
+ * Ordinary state writers stop at the ended marker. This one narrow completion
2081
+ * is allowed through because the journal's own `delivery_committed_at` proves
2082
+ * the irreversible write won the ordering first; without the matching finish,
2083
+ * a later SessionStart would replay a write that already reached the harness.
2084
+ */
2085
+ function finishCommittedDelivery(
2086
+ ctx: HookContext,
2087
+ sessionId: string,
2088
+ accepted: AcceptedAnswerDelivery,
2089
+ deliveredRoute: string,
2090
+ ): void {
2091
+ const file = sessionStatePath(sessionId, ctx.env)
2092
+ withFileLock(`${file}.lock`, () => {
2093
+ const current = readSessionState(sessionId, ctx.env)
2094
+ if (
2095
+ current.accepted?.recorded_at !== accepted.recorded_at ||
2096
+ current.accepted.delivery_committed_at === undefined
2097
+ ) {
2098
+ return
2099
+ }
2100
+ writeSessionStateUnlocked(file, sessionId, {
2101
+ ...current,
2102
+ accepted: {
2103
+ ...current.accepted,
2104
+ delivered_at: ctx.now(),
2105
+ delivered_route: deliveredRoute,
2106
+ },
2107
+ })
2108
+ })
2109
+ }
2110
+
1946
2111
  export function clearAcknowledgementObligation(
1947
2112
  sessionId: string,
1948
2113
  env: NodeJS.ProcessEnv,
@@ -2310,7 +2475,7 @@ export async function handleUserPromptSubmit(
2310
2475
  export async function handleStop(
2311
2476
  ctx: HookContext,
2312
2477
  envelope: HookEnvelope,
2313
- processDeadlineAt = ctx.now() + WAITER_CEILING_SECONDS * 1000,
2478
+ processDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000,
2314
2479
  route: EscalationDeliveryRoute = hookContinuationRoute(),
2315
2480
  ): Promise<HookOutcome> {
2316
2481
  const sessionId = envelope.session_id
@@ -2326,6 +2491,39 @@ export async function handleStop(
2326
2491
  })
2327
2492
  }
2328
2493
 
2494
+ /**
2495
+ * Acquire the per-session owner, waiting only when this Stop carries a
2496
+ * newly registered question. The live owner sees that state on its next reply
2497
+ * poll and yields; without this handoff the new question could sit unpushed for
2498
+ * the older question's complete answer window.
2499
+ */
2500
+ async function acquireQuestionOwner(
2501
+ ctx: HookContext,
2502
+ sessionId: string,
2503
+ hardDeadlineAt: number,
2504
+ ): Promise<boolean> {
2505
+ if (sessionHasEnded(sessionId, ctx.env)) return false
2506
+ if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt)) return true
2507
+
2508
+ while (ctx.now() < hardDeadlineAt) {
2509
+ const handoff = claimHandoffState(sessionId, ctx.env)
2510
+ if (!handoff.hasNewQuestion) return false
2511
+ const holderReleaseDeadlineAt = Math.min(
2512
+ hardDeadlineAt,
2513
+ handoff.ownerDeadlineAt === undefined
2514
+ ? hardDeadlineAt
2515
+ : handoff.ownerDeadlineAt + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS * 1000,
2516
+ )
2517
+ if (ctx.now() >= holderReleaseDeadlineAt) return false
2518
+ await ctx.sleep(
2519
+ Math.min(CLAIM_HANDOFF_POLL_MS, holderReleaseDeadlineAt - ctx.now()),
2520
+ )
2521
+ if (sessionHasEnded(sessionId, ctx.env)) return false
2522
+ if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt)) return true
2523
+ }
2524
+ return false
2525
+ }
2526
+
2329
2527
  /**
2330
2528
  * Own one registered question pipeline from its terminal-first timer through
2331
2529
  * its final delivery or retirement. Hosts differ only in the injected route.
@@ -2337,7 +2535,12 @@ export async function runEscalationWaiter(
2337
2535
  const notes: string[] = []
2338
2536
  const { sessionId, envelope } = options
2339
2537
  const hardDeadlineAt =
2340
- options.processDeadlineAt ?? ctx.now() + WAITER_CEILING_SECONDS * 1000
2538
+ options.processDeadlineAt ?? ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000
2539
+
2540
+ if (sessionHasEnded(sessionId, ctx.env)) {
2541
+ notes.push('the Agent Session already ended; no answer observer was started')
2542
+ return { notes }
2543
+ }
2341
2544
 
2342
2545
  // This event marker is diagnostic evidence in its own right. Record it
2343
2546
  // before every early return so doctor can distinguish a working Stop route
@@ -2352,9 +2555,9 @@ export async function runEscalationWaiter(
2352
2555
  // polling, close fencing, route delivery, and every retirement path.
2353
2556
  // Real clock, deliberately, not `ctx.now` — the claim answers whether
2354
2557
  // another process is alive right now, which an injected clock cannot know.
2355
- if (!claimQuestionPush(sessionId, ctx.env)) {
2558
+ if (!(await acquireQuestionOwner(ctx, sessionId, hardDeadlineAt))) {
2356
2559
  gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' })
2357
- notes.push('another hook is already handling this question')
2560
+ notes.push('another hook is already handling this session; a newly registered question remains queued for the next owner')
2358
2561
  return { notes }
2359
2562
  }
2360
2563
  let settledAnswerThisPass = false
@@ -2481,6 +2684,7 @@ export function hookContinuationRoute(): EscalationDeliveryRoute {
2481
2684
  kind: 'hook-continuation',
2482
2685
  deliver: async (event) => ({
2483
2686
  stdout: stopAnswerOutput(event.context),
2687
+ commitStdout: event.commitDelivery,
2484
2688
  notes: [],
2485
2689
  // The harness reads this stdout after the process exits, so only the
2486
2690
  // successor Stop can acknowledge it.
@@ -2507,6 +2711,10 @@ async function deliverAcceptedAnswers(
2507
2711
  ): Promise<HookOutcome> {
2508
2712
  const { answers: answered, remaining } = accepted
2509
2713
  const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
2714
+ if (sessionHasEnded(sessionId, ctx.env)) {
2715
+ notes.push('the Agent Session ended before answer delivery; stopping this observer')
2716
+ return { notes }
2717
+ }
2510
2718
  const held = accepted.held_deliveries ?? 0
2511
2719
  if (held >= MAX_HELD_DELIVERIES) {
2512
2720
  gate(ctx, 'held', 'delivery-limit', {
@@ -2544,6 +2752,12 @@ async function deliverAcceptedAnswers(
2544
2752
  ...current,
2545
2753
  delivery_attempts: attempt,
2546
2754
  }))
2755
+ // Linearize delivery after the journal write. If SessionEnd acquired the
2756
+ // shared state lock in between, its durable marker wins and no wake starts.
2757
+ if (sessionHasEnded(sessionId, ctx.env)) {
2758
+ notes.push('the Agent Session ended before answer delivery; stopping this observer')
2759
+ return { notes }
2760
+ }
2547
2761
  ctx.log?.info('hook.gate', {
2548
2762
  verdict: 'proceeding',
2549
2763
  reason: 'answered',
@@ -2554,23 +2768,46 @@ async function deliverAcceptedAnswers(
2554
2768
  journal_recorded_at: accepted.recorded_at,
2555
2769
  delivery_attempt: attempt,
2556
2770
  })
2557
- const delivered = await route.deliver({
2771
+ let deliveryCommitted = false
2772
+ const commitDelivery = (): boolean => {
2773
+ if (deliveryCommitted) return true
2774
+ const file = sessionStatePath(sessionId, ctx.env)
2775
+ return withFileLock(`${file}.lock`, () => {
2776
+ if (sessionHasEnded(sessionId, ctx.env)) return false
2777
+ const current = readSessionState(sessionId, ctx.env)
2778
+ if (current.accepted === undefined) return false
2779
+ deliveryCommitted = true
2780
+ writeSessionStateUnlocked(file, sessionId, {
2781
+ ...current,
2782
+ accepted: { ...current.accepted, delivery_committed_at: ctx.now() },
2783
+ })
2784
+ return true
2785
+ })
2786
+ }
2787
+ let delivered = await route.deliver({
2558
2788
  context: answersContext(answered, remaining),
2559
2789
  answers: answered.length,
2560
2790
  remaining,
2561
2791
  request_ids: requestIds,
2562
2792
  journal_recorded_at: accepted.recorded_at,
2793
+ commitDelivery,
2563
2794
  })
2795
+ if (delivered.acknowledgement === 'delivered' && !deliveryCommitted) {
2796
+ delivered = {
2797
+ notes: [
2798
+ ...(delivered.notes ?? []),
2799
+ `the ${route.kind} route reported delivery without committing against SessionEnd; preserving the answer instead of accepting an unordered write`,
2800
+ ],
2801
+ log: { route: route.kind, stage: 'queued', reason: 'delivery-not-committed' },
2802
+ acknowledgement: 'held',
2803
+ }
2804
+ }
2564
2805
  const deliveredRoute =
2565
2806
  typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind
2566
2807
  const deliveredStage =
2567
2808
  typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered'
2568
2809
  if (delivered.acknowledgement === 'delivered') {
2569
- amendAcceptedAnswers(ctx, sessionId, (current) => ({
2570
- ...current,
2571
- delivered_at: ctx.now(),
2572
- delivered_route: deliveredRoute,
2573
- }))
2810
+ finishCommittedDelivery(ctx, sessionId, accepted, deliveredRoute)
2574
2811
  } else if (delivered.acknowledgement === 'held') {
2575
2812
  // Give the delivery attempt back and count the hold instead. The attempt
2576
2813
  // was counted before the call because one that dies mid-delivery still
@@ -2596,6 +2833,7 @@ async function deliverAcceptedAnswers(
2596
2833
  })
2597
2834
  const outcome: HookOutcome = { notes: [...notes, ...(delivered.notes ?? [])] }
2598
2835
  if (delivered.stdout !== undefined) outcome.stdout = delivered.stdout
2836
+ if (delivered.commitStdout !== undefined) outcome.commitStdout = delivered.commitStdout
2599
2837
  if (delivered.log !== undefined) outcome.log = delivered.log
2600
2838
  return outcome
2601
2839
  }
@@ -2692,13 +2930,10 @@ async function handleClaimedStop(
2692
2930
  const recoverableLive = live.filter(
2693
2931
  (entry) => !permanentFailures.has(entry.request_id!),
2694
2932
  )
2695
- // A spent owner has already used its waiter ceiling. Passing those
2696
- // questions back into `escalate` starts a fresh ceiling, holds the
2697
- // per-session claim for another 3300s, and is why a later `ask` never
2698
- // reached a device after a degraded waiter (the next Stop never ran, or
2699
- // ran as claimed-elsewhere). Late answers still ride the short poll above
2700
- // and every later turn. Independent questions whose lease is still running
2701
- // keep their successor wait.
2933
+ // A spent owner has already reached the end of the answer window it owned.
2934
+ // Passing those questions back into `escalate` starts a fresh ceiling and
2935
+ // can monopolize the per-session claim after expiry. Independent questions
2936
+ // whose lease is still running keep their successor wait.
2702
2937
  const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()))
2703
2938
  liveToEscalate = stillOwned
2704
2939
  if (unasked.length === 0) {
@@ -2712,7 +2947,7 @@ async function handleClaimedStop(
2712
2947
  owner_spent: true,
2713
2948
  })
2714
2949
  notes.push(
2715
- `${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still answerable; the previous waiter already used its ceiling, so a later turn will collect a late answer rather than waiting again`,
2950
+ `${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still recorded after the previous answer owner ended; a later turn will reconcile ${recoverableLive.length === 1 ? 'it' : 'them'} without starting another full-window wait`,
2716
2951
  )
2717
2952
  return { notes }
2718
2953
  }
@@ -2807,16 +3042,7 @@ async function escalate(
2807
3042
  // The questions still owe the user their terminal-first window before
2808
3043
  // anything reaches their devices — measured from the oldest registration,
2809
3044
  // because that is the question that has waited longest.
2810
- const startedAt = ctx.now()
2811
- const existingDeadlines = alreadyLive.flatMap((entry) =>
2812
- entry.reply_deadline_at === undefined || entry.reply_deadline_at <= startedAt
2813
- ? []
2814
- : [entry.reply_deadline_at],
2815
- )
2816
- const ceilingAt = Math.min(
2817
- hardDeadlineAt,
2818
- ...(existingDeadlines.length === 0 ? [] : existingDeadlines),
2819
- )
3045
+ const ceilingAt = hardDeadlineAt
2820
3046
  if (unasked.length > 0 && alreadyLive.length === 0) {
2821
3047
  const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
2822
3048
  await awaitTerminalFirstWindow(ctx, oldest, ceilingAt)
@@ -2830,26 +3056,21 @@ async function escalate(
2830
3056
  // Phase one: every registered question reaches the user's devices, each as
2831
3057
  // its own notification — one ask never stands in for another.
2832
3058
  const submitted: PendingQuestion[] = []
3059
+ const admissionAnswers: AnsweredPending[] = []
2833
3060
  for (const entry of unasked) {
2834
- // How long the answer is accepted, and how long this owner listens for it,
2835
- // are two different spans. They used to be one: the window was whatever
2836
- // remained of the waiter's ceiling, so a question asked at a turn's end
2837
- // stopped being answerable about eight minutes later, whatever the user
2838
- // had configured.
2839
- //
2840
- // That was right when the waiter was the only way an answer came back. It
2841
- // is not any more: a late answer is found by the next turn's poll or
2842
- // replayed from the journal, so this owner giving up listening says
2843
- // nothing about whether the user's answer is still wanted.
3061
+ // The service owns how long the answer is accepted. This process begins
3062
+ // before submission, so its larger maximum-window budget includes startup
3063
+ // headroom and remains alive through the complete committed window.
2844
3064
  const replyWindowSeconds = ctx.config.reply_window_seconds.value
2845
- if (ceilingAt - ctx.now() < MIN_REPLY_WINDOW_SECONDS * 1000) {
3065
+ if (
3066
+ ceilingAt - ctx.now() <
3067
+ (replyWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000
3068
+ ) {
2846
3069
  notes.push(
2847
- 'too little of the waiter ceiling is left to submit and confirm this question; leaving it in the terminal',
3070
+ 'too little owner lifetime remains for the complete configured answer window; leaving this question frozen for a successor owner',
2848
3071
  )
2849
3072
  continue
2850
3073
  }
2851
- // The owner still stops listening at its own ceiling. Only the span the
2852
- // server accepts answers into changed.
2853
3074
  const ownerDeadlineAt = ceilingAt
2854
3075
  const questions = pendingQuestions(entry)
2855
3076
  const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd)
@@ -2903,16 +3124,19 @@ async function escalate(
2903
3124
  })
2904
3125
  }
2905
3126
  if (ctx.now() >= intent.owner_deadline_at) {
2906
- notes.push('the waiter ceiling ended before submission; preserving the frozen intent')
3127
+ notes.push('the owner lifetime ended before submission; preserving the frozen intent')
2907
3128
  continue
2908
3129
  }
2909
- const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000)
2910
- if (beforeSubmitSeconds < MIN_REPLY_WINDOW_SECONDS) {
3130
+ if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
2911
3131
  notes.push(
2912
- 'device resolution consumed the reply window; preserving the frozen intent instead of shrinking it below what the server accepts',
3132
+ 'setup consumed the admission allowance; preserving the frozen intent instead of publishing an answer window this owner cannot observe completely',
2913
3133
  )
2914
3134
  continue
2915
3135
  }
3136
+ if (sessionHasEnded(sessionId, ctx.env)) {
3137
+ notes.push('the Agent Session ended before submission; preserving no live observer')
3138
+ continue
3139
+ }
2916
3140
  let receipt: SubmissionReceipt | undefined
2917
3141
  let admissionConfirmed = false
2918
3142
  try {
@@ -2963,6 +3187,16 @@ async function escalate(
2963
3187
  return { ...current, pending: next }
2964
3188
  })
2965
3189
  try {
3190
+ if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
3191
+ notes.push(
3192
+ 'draft recovery consumed the admission allowance; preserving the frozen intent for a successor owner',
3193
+ )
3194
+ continue
3195
+ }
3196
+ if (sessionHasEnded(sessionId, ctx.env)) {
3197
+ notes.push('the Agent Session ended before recovered submission')
3198
+ continue
3199
+ }
2966
3200
  receipt = await submitQuestion(ctx, intent)
2967
3201
  admissionConfirmed = true
2968
3202
  } catch (retryErr) {
@@ -3027,17 +3261,9 @@ async function escalate(
3027
3261
  request_id: intent.request_id,
3028
3262
  collapse_key: intent.collapse_key,
3029
3263
  device_ids: intent.device_ids,
3030
- // When the server stops accepting an answer — not when this owner stops
3031
- // waiting for one. Those were the same number until the window became a
3032
- // setting, and taking the minimum silently capped every question at the
3033
- // waiter's ceiling: a question asked with a day-long window was swept as
3034
- // stale about eight minutes later and retired, so the answer given over
3035
- // lunch met a closed question.
3036
- //
3037
- // The reply window is how long the server accepts an answer. This
3038
- // process still clamps its own wait to `hardDeadlineAt`. Recording the
3039
- // owner lease separately is what stops the *next* Stop from treating
3040
- // the remaining window as a fresh waiter ceiling.
3264
+ // The server's committed answer deadline is authoritative. The local
3265
+ // owner deadline starts earlier and includes startup headroom, so it may
3266
+ // be later but must never be earlier.
3041
3267
  reply_deadline_at: Number.isFinite(committedReplyDeadline)
3042
3268
  ? committedReplyDeadline
3043
3269
  : intent.owner_deadline_at,
@@ -3045,7 +3271,56 @@ async function escalate(
3045
3271
  }
3046
3272
  if (intent.draft.source === undefined) delete live.source
3047
3273
  delete live.submission
3048
- submitted.push(live)
3274
+ if (live.reply_deadline_at! > live.owner_deadline_at!) {
3275
+ const response = await finalizeReplies(ctx, live.request_id!)
3276
+ if (response === null) {
3277
+ // Closing was unreachable, so the question is still potentially live.
3278
+ // Preserve it in the exact session instead of demoting it to the orphan
3279
+ // retirement queue, whose later close has no route for an answer.
3280
+ updateSessionState(sessionId, ctx.env, (current) => {
3281
+ const list = pendingList(current)
3282
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry))
3283
+ if (index < 0) {
3284
+ // Terminal input removed this registration while the anomalous
3285
+ // close was in flight. Preserve retirement identity exactly as
3286
+ // normal submit promotion does; the observer must not become the
3287
+ // only remaining record of a live card.
3288
+ const retirement = retiringQuestion(
3289
+ live,
3290
+ 'answered_elsewhere',
3291
+ envelope.cwd,
3292
+ )!
3293
+ const retiring = [...(current.retiring ?? [])]
3294
+ if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
3295
+ retiring.push(retirement)
3296
+ }
3297
+ return { ...current, retiring }
3298
+ }
3299
+ const next = [...list]
3300
+ next[index] = live
3301
+ return { ...current, pending: next }
3302
+ })
3303
+ submitted.push(live)
3304
+ notes.push(
3305
+ 'the server committed an answer deadline beyond this process owner and immediate closure was unreachable; preserving the live question for exact-session recovery',
3306
+ )
3307
+ continue
3308
+ } else if (response.replies.length > 0) {
3309
+ admissionAnswers.push({
3310
+ pending: live,
3311
+ reply: response.replies.at(-1)!,
3312
+ replies: response.replies,
3313
+ agent_acknowledgement_required: response.agent_acknowledgement_required,
3314
+ agent_acknowledgement_text_required:
3315
+ response.agent_acknowledgement_text_required,
3316
+ })
3317
+ }
3318
+ dropPendingQuestion(sessionId, ctx.env, entry)
3319
+ notes.push(
3320
+ 'the server committed an answer deadline beyond this process owner; closed the anomalous window instead of abandoning it early',
3321
+ )
3322
+ continue
3323
+ }
3049
3324
  // Record what is now live on the user's devices BEFORE any wait. If we
3050
3325
  // only learned these ids afterwards, a question that timed out would
3051
3326
  // leave no trace, and the user returning to the terminal could never
@@ -3073,6 +3348,30 @@ async function escalate(
3073
3348
  return { ...current, retiring }
3074
3349
  })
3075
3350
  }
3351
+ if (sessionHasEnded(sessionId, ctx.env)) {
3352
+ // SessionEnd may have raced either the submit or promotion. Its snapshot
3353
+ // usually already queued the frozen intent; this idempotent add closes
3354
+ // the other ordering where the request committed just after cleanup.
3355
+ orphanRetirements(
3356
+ ctx.env,
3357
+ [retiringQuestion(live, 'expired', envelope.cwd)!],
3358
+ ctx.now(),
3359
+ )
3360
+ notes.push('the Agent Session ended during submission; queued the question for retirement')
3361
+ continue
3362
+ }
3363
+ submitted.push(live)
3364
+ }
3365
+
3366
+ if (admissionAnswers.length > 0) {
3367
+ const accepted = stageAcceptedAnswers(
3368
+ ctx,
3369
+ sessionId,
3370
+ admissionAnswers,
3371
+ pendingList(readSessionState(sessionId, ctx.env)).length,
3372
+ )
3373
+ for (const answer of admissionAnswers) reportAnswer(ctx, notes, answer, false)
3374
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
3076
3375
  }
3077
3376
 
3078
3377
  const staleLive = alreadyLive.filter(
@@ -3115,52 +3414,73 @@ async function escalate(
3115
3414
  ]
3116
3415
  if (waitingOn.length === 0) return { notes }
3117
3416
 
3118
- // Phase two: one wait across everything live, old and new alike.
3119
- const ownerDeadline = Math.min(
3120
- ceilingAt,
3121
- Math.max(...waitingOn.map((entry) => entry.reply_deadline_at!)),
3122
- )
3123
- const waitUntil = ownerDeadline - FINALIZATION_NETWORK_BUDGET_SECONDS * 1000
3124
- const timeoutSeconds = Math.max(0, Math.ceil((waitUntil - ctx.now()) / 1000))
3125
- const waited = await waitForAnyReply(
3126
- ctx,
3127
- waitingOn.map((entry) => entry.request_id!),
3128
- timeoutSeconds,
3129
- )
3130
- const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
3131
- if (permanentFailure !== null) notes.push(permanentFailure)
3132
-
3133
- if (waited.byRequest.size === 0) {
3134
- // This owner is done listening. That used to mean the question was over:
3135
- // it closed the window server-side and retired the record, on the reasoning
3136
- // that no later answer could reach the same agent turn.
3137
- //
3138
- // The reaching was never the point — the *accepting* is. A question whose
3139
- // window is still open is polled again by the next prompt and the next
3140
- // Stop, and an answer found there is journaled and replayed. Closing here
3141
- // capped every question at this owner's ceiling however long a window the
3142
- // user had configured, which is what made a day-long window mean eight
3143
- // minutes.
3144
- //
3145
- // So: finalize only what the server has genuinely stopped accepting, and
3146
- // leave the rest live for the turn that comes next. A session that truly
3147
- // ends still retires its questions — that is what the session-end hook is
3148
- // for, and it is the honest place for it.
3149
- const expired = waitingOn.filter(
3150
- (entry) =>
3151
- entry.reply_deadline_at === undefined ||
3152
- entry.reply_deadline_at <= ownerDeadline ||
3153
- // A permanent rejection is the server saying this question can never be
3154
- // answered, whatever its window says. Leaving it open would strand a
3155
- // dead question on the user's devices.
3156
- waited.permanentFailures.has(entry.request_id!),
3417
+ // Phase two: keep the same owner alive across every committed answer window.
3418
+ // Different questions may expire at different times, so finalize each one at
3419
+ // its own server deadline and continue waiting on the rest. Reserving time by
3420
+ // closing early would make the advertised reply window untrue.
3421
+ let activeWaiting = waitingOn
3422
+ let timeoutSeconds = 0
3423
+ let waited: Awaited<ReturnType<typeof waitForAnyReply>>
3424
+ for (;;) {
3425
+ const nextReplyDeadline = Math.min(
3426
+ ...activeWaiting.map((entry) => entry.reply_deadline_at!),
3157
3427
  )
3158
- const stillAnswerable = waitingOn.filter((entry) => !expired.includes(entry))
3159
- if (stillAnswerable.length > 0) {
3428
+ const waitDeadline = Math.min(ceilingAt, nextReplyDeadline)
3429
+ timeoutSeconds = Math.max(0, Math.ceil((waitDeadline - ctx.now()) / 1000))
3430
+ waited = await waitForAnyReply(
3431
+ ctx,
3432
+ activeWaiting.map((entry) => entry.request_id!),
3433
+ timeoutSeconds,
3434
+ () =>
3435
+ waiterInterruption(
3436
+ sessionId,
3437
+ ctx.env,
3438
+ activeWaiting.map((entry) => entry.request_id!),
3439
+ ),
3440
+ )
3441
+ if (waited.interrupted !== null) {
3442
+ notes.push(
3443
+ waited.interrupted === 'new-question'
3444
+ ? 'yielding the answer owner so a newly registered question can be sent now'
3445
+ : 'answer ownership ended locally; stopping this observer without delivering into a closed session',
3446
+ )
3447
+ return { notes }
3448
+ }
3449
+ const currentRequestIds = new Set(
3450
+ pendingList(readSessionState(sessionId, ctx.env)).flatMap((entry) =>
3451
+ [entry.request_id, entry.submission?.request_id].filter(
3452
+ (requestId): requestId is string => requestId !== undefined,
3453
+ ),
3454
+ ),
3455
+ )
3456
+ const removedDuringPoll = activeWaiting.filter(
3457
+ (entry) => !currentRequestIds.has(entry.request_id!),
3458
+ )
3459
+ for (const entry of removedDuringPoll) {
3460
+ waited.byRequest.delete(entry.request_id!)
3461
+ waited.permanentFailures.delete(entry.request_id!)
3462
+ }
3463
+ if (removedDuringPoll.length > 0) {
3160
3464
  notes.push(
3161
- `${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' is' : 's are'} still answerable; leaving ${stillAnswerable.length === 1 ? 'it' : 'them'} open for the next turn rather than closing ${stillAnswerable.length === 1 ? 'it' : 'them'} with this waiter`,
3465
+ `${removedDuringPoll.length} question${removedDuringPoll.length === 1 ? ' changed' : 's changed'} outside this observer; continuing with the independently live questions`,
3162
3466
  )
3467
+ activeWaiting = activeWaiting.filter((entry) => currentRequestIds.has(entry.request_id!))
3468
+ if (activeWaiting.length === 0) return { notes }
3163
3469
  }
3470
+ const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
3471
+ if (permanentFailure !== null) notes.push(permanentFailure)
3472
+ if (waited.byRequest.size > 0) break
3473
+
3474
+ // A permanent rejection is authoritative immediately. Time-based expiry is
3475
+ // authoritative only when this owner reached that question's committed
3476
+ // server deadline; reaching the process ceiling first preserves the record.
3477
+ const expired = activeWaiting.filter(
3478
+ (entry) =>
3479
+ entry.reply_deadline_at === undefined ||
3480
+ entry.reply_deadline_at <= waitDeadline ||
3481
+ waited.permanentFailures.has(entry.request_id!),
3482
+ )
3483
+ const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry))
3164
3484
  const finalized = await finalizePendings(ctx, expired)
3165
3485
  const finalAnswers = finalized
3166
3486
  .map(finalizedAnswer)
@@ -3186,6 +3506,11 @@ async function escalate(
3186
3506
  for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
3187
3507
  return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
3188
3508
  }
3509
+ if (stillAnswerable.length > 0 && waitDeadline < ceilingAt) {
3510
+ activeWaiting = stillAnswerable
3511
+ continue
3512
+ }
3513
+
3189
3514
  const requestIdSummary = summarizeRequestIds(waitingOn)
3190
3515
  ctx.log?.info('hook.answer', {
3191
3516
  answered: false,
@@ -3195,17 +3520,21 @@ async function escalate(
3195
3520
  permanent_failures: waited.permanentFailures.size,
3196
3521
  waited_seconds: timeoutSeconds,
3197
3522
  })
3198
- if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
3523
+ if (stillAnswerable.length > 0) {
3524
+ notes.push(
3525
+ `${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' remains' : 's remain'} answerable after the process owner ended; preserving ${stillAnswerable.length === 1 ? 'it' : 'them'} for recovery`,
3526
+ )
3527
+ } else if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
3199
3528
  notes.push(
3200
3529
  waited.degraded
3201
- ? 'could not reach the server before the owner deadline; expired questions were retired so no answer can be lost later'
3530
+ ? 'could not reach the server before the answer deadline; expired questions were retired so no answer can be lost later'
3202
3531
  : 'no answer in time; the question expired with its continuation owner',
3203
3532
  )
3204
3533
  }
3205
3534
  return { notes }
3206
3535
  }
3207
3536
 
3208
- const polledAnswered = waitingOn.filter((entry) => waited.byRequest.has(entry.request_id!))
3537
+ const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id!))
3209
3538
  const finalizedAnswered = await finalizePendings(ctx, polledAnswered)
3210
3539
  const answered: AnsweredPending[] = []
3211
3540
  for (const finalized of finalizedAnswered) {
@@ -3236,7 +3565,10 @@ async function escalate(
3236
3565
  ctx,
3237
3566
  sessionId,
3238
3567
  answered,
3239
- waitingOn.length - answered.length,
3568
+ Math.max(
3569
+ 0,
3570
+ pendingList(readSessionState(sessionId, ctx.env)).length - answered.length,
3571
+ ),
3240
3572
  )
3241
3573
  for (const answer of answered) reportAnswer(ctx, notes, answer, false)
3242
3574
  return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
@@ -3262,14 +3594,26 @@ export function handleSessionEnd(
3262
3594
  const notes: string[] = []
3263
3595
  const sessionId = envelope.session_id
3264
3596
  if (!sessionId) return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } }
3597
+ // Publish cancellation before reading or clearing anything. In-flight Stop
3598
+ // writers use the same session lock, so none can recreate state after this.
3599
+ markSessionEnded(sessionId, env, now)
3265
3600
  if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
3266
3601
 
3267
3602
  const state = readSessionState(sessionId, env)
3268
3603
  const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
3269
- for (const entry of pendingList(state)) {
3604
+ const retirementCandidates = [
3605
+ ...pendingList(state),
3606
+ ...(state.accepted?.answers.map((entry) => entry.pending) ?? []),
3607
+ ]
3608
+ for (const entry of retirementCandidates) {
3270
3609
  try {
3271
3610
  const orphan = retiringQuestion(entry, 'expired', envelope.cwd)
3272
- if (orphan !== null) orphans.push(orphan)
3611
+ if (
3612
+ orphan !== null &&
3613
+ !orphans.some((candidate) => candidate.request_id === orphan.request_id)
3614
+ ) {
3615
+ orphans.push(orphan)
3616
+ }
3273
3617
  } catch (err) {
3274
3618
  notes.push(err instanceof Error ? err.message : String(err))
3275
3619
  // Preserve the only identifiers instead of turning an explicit corrupt