@raidiant/notifai 11.6.0 → 11.6.1-beta.3

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 (68) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +12 -14
  3. package/dist/codex-tool-messages.d.ts +1 -12
  4. package/dist/codex-tool-messages.js +20 -66
  5. package/dist/codex-tool-messages.js.map +1 -1
  6. package/dist/codex-wake.d.ts +6 -25
  7. package/dist/codex-wake.js +6 -25
  8. package/dist/codex-wake.js.map +1 -1
  9. package/dist/commands-ask.d.ts +2 -3
  10. package/dist/commands-ask.js +29 -11
  11. package/dist/commands-ask.js.map +1 -1
  12. package/dist/commands-core.d.ts +1 -1
  13. package/dist/commands-hook-attend.js +39 -53
  14. package/dist/commands-hook-attend.js.map +1 -1
  15. package/dist/commands-hook-run.js +50 -12
  16. package/dist/commands-hook-run.js.map +1 -1
  17. package/dist/commands-receive.d.ts +3 -0
  18. package/dist/commands-receive.js +55 -0
  19. package/dist/commands-receive.js.map +1 -0
  20. package/dist/commands-send.js +1 -1
  21. package/dist/commands-send.js.map +1 -1
  22. package/dist/commands-update-check.js +2 -1
  23. package/dist/commands-update-check.js.map +1 -1
  24. package/dist/commands.d.ts +1 -0
  25. package/dist/commands.js +1 -0
  26. package/dist/commands.js.map +1 -1
  27. package/dist/config-schema.js +2 -2
  28. package/dist/config-schema.js.map +1 -1
  29. package/dist/hook-acknowledgements.js +7 -1
  30. package/dist/hook-acknowledgements.js.map +1 -1
  31. package/dist/hook-gates.d.ts +1 -1
  32. package/dist/hook-gates.js +0 -1
  33. package/dist/hook-gates.js.map +1 -1
  34. package/dist/hook-lifecycle.d.ts +2 -0
  35. package/dist/hook-lifecycle.js +354 -360
  36. package/dist/hook-lifecycle.js.map +1 -1
  37. package/dist/hook-question-lock.js +3 -3
  38. package/dist/hook-question-lock.js.map +1 -1
  39. package/dist/hook-types.d.ts +15 -7
  40. package/dist/logging.js +1 -1
  41. package/dist/logging.js.map +1 -1
  42. package/dist/program.d.ts +2 -1
  43. package/dist/program.js +11 -6
  44. package/dist/program.js.map +1 -1
  45. package/dist/question-settlement-process.d.ts +5 -3
  46. package/dist/question-settlement-process.js +4 -4
  47. package/dist/question-settlement-process.js.map +1 -1
  48. package/dist/session-attendant-probe.d.ts +1 -1
  49. package/dist/session-attendant-probe.js +1 -1
  50. package/dist/session-attendant-state.d.ts +3 -3
  51. package/dist/session-attendant-state.js +3 -3
  52. package/dist/session-attendant.d.ts +2 -0
  53. package/dist/session-attendant.js +16 -1
  54. package/dist/session-attendant.js.map +1 -1
  55. package/dist/session-delivery.d.ts +5 -1
  56. package/dist/session-delivery.js +10 -0
  57. package/dist/session-delivery.js.map +1 -1
  58. package/dist/session-inputs.d.ts +22 -0
  59. package/dist/session-inputs.js +254 -0
  60. package/dist/session-inputs.js.map +1 -0
  61. package/dist/session-message-handoff.js +6 -1
  62. package/dist/session-message-handoff.js.map +1 -1
  63. package/dist/skill-source/manifest.json +6 -6
  64. package/dist/skill-source/notifai/SKILL.md +20 -21
  65. package/dist/skill-source/notifai/references/diagnostics.md +1 -1
  66. package/dist/skill-source/notifai/references/harness-setup.md +39 -33
  67. package/dist/skill-source/notifai/references/notes-and-edits.md +6 -1
  68. package/package.json +2 -2
@@ -5,7 +5,7 @@ import { HARNESS_CAPABILITIES } from './harnesses.js';
5
5
  import { gate } from './hook-gates.js';
6
6
  import { acknowledgementBlockContext, amendAcceptedAnswers, answersContext, classifyJournaledAcknowledgements, finishCommittedDelivery, holdForAcknowledgement, owedAcknowledgements, observeQueuedMessagePrompt, presentedMessageAcknowledgements, reconcileAcknowledgementObligations, recoverQueuedAnswers, resetAcknowledgementBlocks, settleAcceptedAnswers, stageAcceptedAnswers, stopAnswerOutput, } from './hook-acknowledgements.js';
7
7
  import { clearMatchingProjectSession, writeProjectSession } from './hook-project-sessions.js';
8
- import { claimHandoffState, claimQuestionPush, releaseQuestionPush } from './hook-question-lock.js';
8
+ import { acquireClaimFile, releaseClaimFile, claimHandoffState, claimQuestionPush, releaseQuestionPush } from './hook-question-lock.js';
9
9
  import { closeQuietly, drainOrphanRetirements, drainRetirements, finalizeReplies, selectedForClaimedDelivery, orphanRetirements, pendingAnsweredByPrompt, retirePendings, retiringQuestion, } from './hook-question-retirement.js';
10
10
  import { admitQueuedQuestion, clearFrozenSubmission, dropPendingQuestion, isSamePending, pendingQuestions, questionSubmissionAttempted, queuedQuestionStillEligible, rememberQuestionState, sourceContextAtHookEvent, summarizeRequestIds, } from './hook-question-state.js';
11
11
  import { clearSessionState, markSessionEnded, pendingList, readSessionState, readSessionIncarnation, sessionHasEnded, sessionStatePath, updateSessionState, writeSessionState, writeSessionStateUnlocked, recordSessionNotified, } from './hook-session-state.js';
@@ -215,10 +215,10 @@ async function waitForAnyReply(ctx, requestIds, timeoutSeconds, interruption) {
215
215
  *
216
216
  * SessionEnd and terminal-side retirement remove an owned request from pending
217
217
  * state; the detached process must stop instead of recreating that session.
218
- * A new unpushed question asks this owner to yield the session claim so the
219
- * successor Stop can submit it promptly.
218
+ * A newly registered or submitted question asks a native observer to hand off
219
+ * to an owner whose lifetime covers the later answer window.
220
220
  */
221
- function waiterInterruption(sessionId, env, requestIds) {
221
+ function waiterInterruption(sessionId, env, requestIds, attemptedLocal) {
222
222
  if (sessionHasEnded(sessionId, env))
223
223
  return 'ownership-ended';
224
224
  const pending = pendingList(readSessionState(sessionId, env));
@@ -226,8 +226,8 @@ function waiterInterruption(sessionId, env, requestIds) {
226
226
  // One question can be retired from the terminal while this observer owns
227
227
  // several. That is a per-question change, not whole-session cancellation;
228
228
  // the caller filters the changed set after each poll and keeps the rest.
229
- return pending.some((entry) => entry.request_id === undefined &&
230
- (entry.submission === undefined || !owned.has(entry.submission.request_id)))
229
+ return pending.some((entry) => entry.request_id !== undefined ? !owned.has(entry.request_id) :
230
+ !attemptedLocal.has(entry.question_id ?? entry.question))
231
231
  ? 'new-question'
232
232
  : null;
233
233
  }
@@ -459,8 +459,10 @@ async function answerPrompt(ctx, envelope, sessionId, reminder) {
459
459
  // interrupted command can leave debt after the service recorded its
460
460
  // acknowledgement. Reconcile both before replaying recovery context.
461
461
  await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due ?? []);
462
- if (sessionHasEnded(sessionId, ctx.env))
462
+ if (sessionHasEnded(sessionId, ctx.env)) {
463
+ notes.push('the Agent Session ended before answer delivery; stopping this observer');
463
464
  return { notes };
465
+ }
464
466
  state = readSessionState(sessionId, ctx.env);
465
467
  if (state.accepted !== undefined &&
466
468
  acceptedAnswersAwaitingAcknowledgement(state.accepted, state).length === 0) {
@@ -665,7 +667,7 @@ function acceptedAnswersAwaitingAcknowledgement(accepted, state) {
665
667
  (state.acknowledgement_due ?? []).some((entry) => entry.request_id === pending.request_id));
666
668
  }
667
669
  // ---------------------------------------------------------------------------
668
- // Stop — the turn ended; escalate a registered question
670
+ // Answer ownership — observe registered questions through a harness delivery route
669
671
  // ---------------------------------------------------------------------------
670
672
  /**
671
673
  * Only engages for a question the agent explicitly registered with
@@ -721,6 +723,9 @@ async function acquireQuestionOwner(ctx, sessionId, hardDeadlineAt) {
721
723
  export async function runEscalationWaiter(ctx, options) {
722
724
  const notes = [];
723
725
  const { sessionId, envelope } = options;
726
+ // Detached observers have no harness stdout consumer for Stop reminders.
727
+ // They must neither spend reminder attempts nor block fresh answer collection.
728
+ const canRemind = options.recordStop !== false;
724
729
  const hardDeadlineAt = options.processDeadlineAt ?? ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000;
725
730
  if (sessionHasEnded(sessionId, ctx.env)) {
726
731
  notes.push('the Agent Session already ended; no answer observer was started');
@@ -745,13 +750,12 @@ export async function runEscalationWaiter(ctx, options) {
745
750
  notes.push('another hook is already handling this session; a newly registered question remains queued for the next owner');
746
751
  return { notes };
747
752
  }
748
- let settledAnswerThisPass = false;
749
753
  try {
750
754
  recoverQueuedAnswers(sessionId, ctx.env);
751
755
  let state = readSessionState(sessionId, ctx.env);
752
756
  if (state.accepted !== undefined) {
753
757
  const accepted = state.accepted;
754
- if (envelope.stop_hook_active === true && pendingList(state).length > 0) {
758
+ if (canRemind && envelope.stop_hook_active === true && pendingList(state).length > 0) {
755
759
  const due = await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due ?? []);
756
760
  if (due.length > 0) {
757
761
  gate(ctx, 'held', 'acknowledgement-required', {
@@ -790,19 +794,18 @@ export async function runEscalationWaiter(ctx, options) {
790
794
  return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes, envelope.cwd);
791
795
  }
792
796
  settleAcceptedAnswers(ctx, sessionId, accepted, envelope.cwd);
793
- settledAnswerThisPass = true;
794
797
  state = readSessionState(sessionId, ctx.env);
795
798
  const currentAcceptedIds = new Set(accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]));
796
799
  const due = await reconcileAcknowledgementObligations(ctx, sessionId, (state.acknowledgement_due ?? []).filter((entry) => currentAcceptedIds.has(entry.request_id)));
797
- const held = holdForAcknowledgement(ctx, sessionId, due, notes);
800
+ const held = canRemind ? holdForAcknowledgement(ctx, sessionId, due, notes) : null;
798
801
  if (held !== null)
799
802
  return held;
800
803
  state = readSessionState(sessionId, ctx.env);
801
804
  }
802
805
  if (state.accepted === undefined && owedAcknowledgements(state).length > 0 &&
803
- !(options.recordStop === false && options.route.kind === 'session-queue')) {
806
+ canRemind) {
804
807
  const due = await reconcileAcknowledgementObligations(ctx, sessionId, owedAcknowledgements(state));
805
- const held = holdForAcknowledgement(ctx, sessionId, due, notes);
808
+ const held = canRemind ? holdForAcknowledgement(ctx, sessionId, due, notes) : null;
806
809
  if (held !== null)
807
810
  return held;
808
811
  resetAcknowledgementBlocks(sessionId, ctx.env);
@@ -821,11 +824,7 @@ export async function runEscalationWaiter(ctx, options) {
821
824
  gate(ctx, 'held', 'no-question', { stage: 'queued' });
822
825
  return { notes };
823
826
  }
824
- return await handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, options.route,
825
- // Every route's successor turn-end, named the same way: the harness flag
826
- // for a blocking continuation, and the settle above for a route that woke
827
- // a brand-new turn out of band.
828
- envelope.stop_hook_active === true || settledAnswerThisPass, options.recordStop !== false && ctx.harness === 'codex' && options.route.kind === 'session-queue');
827
+ return await handleClaimedStop(ctx, envelope, sessionId, pending, notes, hardDeadlineAt, options.route, options.recordStop !== false && ctx.harness === 'codex' && options.route.kind === 'session-queue');
829
828
  }
830
829
  finally {
831
830
  releaseQuestionPush(sessionId, ctx.env);
@@ -858,12 +857,14 @@ export function hookContinuationRoute() {
858
857
  * session 250 times.
859
858
  */
860
859
  async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cwd) {
861
- if (route.kind === 'session-queue') {
860
+ if (route.kind === 'session-queue' || route.defer !== undefined) {
862
861
  accepted = classifyJournaledAcknowledgements(sessionId, ctx.env) ?? accepted;
863
862
  const ids = new Set(accepted.answers.map((answer) => answer.pending.request_id));
864
863
  await reconcileAcknowledgementObligations(ctx, sessionId, (readSessionState(sessionId, ctx.env).acknowledgement_due ?? []).filter((entry) => ids.has(entry.request_id)));
865
- if (sessionHasEnded(sessionId, ctx.env))
864
+ if (sessionHasEnded(sessionId, ctx.env)) {
865
+ notes.push('the Agent Session ended before answer delivery; stopping this observer');
866
866
  return { notes };
867
+ }
867
868
  const unresolved = acceptedAnswersAwaitingAcknowledgement(accepted, readSessionState(sessionId, ctx.env));
868
869
  if (unresolved.length === 0) {
869
870
  settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
@@ -886,6 +887,14 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
886
887
  const replayed = await suppressWrittenReplay(ctx, sessionId, accepted, notes, cwd);
887
888
  if (replayed !== null)
888
889
  return replayed;
890
+ if (route.defer !== undefined) {
891
+ await route.defer(accepted);
892
+ return {
893
+ notes,
894
+ log: { stage: 'input-staged', route: route.kind, answers: accepted.answers.length },
895
+ settlementRequired: pendingList(readSessionState(sessionId, ctx.env)).length > 0,
896
+ };
897
+ }
889
898
  const held = accepted.held_deliveries ?? 0;
890
899
  if (held >= MAX_HELD_DELIVERIES) {
891
900
  gate(ctx, 'held', 'delivery-limit', {
@@ -1198,10 +1207,9 @@ function ownerLeaseActive(entry, now) {
1198
1207
  return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now;
1199
1208
  }
1200
1209
  /** The complete per-session Stop decision, after this process owns the claim. */
1201
- async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, route, continuingFromAnswer, handoffToDetachedOwner) {
1210
+ async function handleClaimedStop(ctx, envelope, sessionId, pending, notes, hardDeadlineAt, route, handoffToDetachedOwner) {
1202
1211
  const live = pending.filter((entry) => entry.request_id !== undefined);
1203
1212
  const unasked = pending.filter((entry) => entry.request_id === undefined);
1204
- let liveToEscalate = live;
1205
1213
  if (ctx.harness !== undefined &&
1206
1214
  HARNESS_CAPABILITIES[ctx.harness].stopContinuation === 'unsupported') {
1207
1215
  for (const entry of live)
@@ -1257,7 +1265,6 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1257
1265
  // can monopolize the per-session claim after expiry. Independent questions
1258
1266
  // whose lease is still running keep their successor wait.
1259
1267
  const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()));
1260
- liveToEscalate = stillOwned;
1261
1268
  if (unasked.length === 0) {
1262
1269
  if (recoverableLive.length === 0)
1263
1270
  return { notes };
@@ -1272,372 +1279,330 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1272
1279
  notes.push(`${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`);
1273
1280
  return { notes };
1274
1281
  }
1275
- return await escalate(ctx, envelope, sessionId, [], stillOwned, notes, hardDeadlineAt, route, handoffToDetachedOwner);
1282
+ return await escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route, handoffToDetachedOwner);
1276
1283
  }
1277
1284
  // Questions registered after the earlier push still owe the user their
1278
1285
  // notification; fall through and escalate just those, plus any live
1279
1286
  // question whose owner lease is still running.
1280
1287
  }
1281
- // A Stop answer may immediately produce a legitimate follow-up question.
1282
- // Allow that new generation, but never re-run an old pending question and
1283
- // never let answer continuations become an unbounded agent loop.
1284
- if (envelope.stop_hook_active === true) {
1285
- const continuation = state.continuation;
1286
- const isNew = continuation !== undefined &&
1287
- unasked.some((entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at);
1288
- if (!isNew) {
1289
- gate(ctx, 'held', 'continuation-repeat');
1290
- notes.push('already continuing from an answer; not asking again this turn');
1291
- return { notes };
1292
- }
1293
- }
1294
- // The cap is deliberately not keyed to `stop_hook_active`. That flag names one
1295
- // route's continuation; a route that wakes a brand-new turn never sets it, and
1296
- // a limit only one route consults bounds only that route. `continuingFromAnswer`
1297
- // is the same event named for every route, so the chain of answer → follow-up
1298
- // question → answer is bounded whatever delivered it.
1299
- if (continuingFromAnswer &&
1300
- state.continuation !== undefined &&
1301
- state.continuation.count >= MAX_CONTINUATION_COUNT) {
1302
- gate(ctx, 'held', 'continuation-limit', {
1303
- count: state.continuation.count,
1304
- limit: MAX_CONTINUATION_COUNT,
1305
- route: route.kind,
1306
- });
1307
- notes.push(`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`);
1308
- return { notes };
1309
- }
1310
- // Silent to the user by design — they switched routing off, so saying so on
1311
- // every turn would be nagging about their own setting. That silence is also
1312
- // why it belongs in the log: from outside, "ask_notifications = false" and "a
1313
- // bug ate my question" look exactly the same.
1314
- if (!ctx.config.ask_notifications.value) {
1315
- gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source });
1316
- return { notes };
1317
- }
1318
1288
  gate(ctx, 'proceeding', 'proceeding', {
1319
1289
  unasked: unasked.length,
1320
- already_live: liveToEscalate.length,
1290
+ already_live: live.length,
1321
1291
  grace_seconds: ctx.config.ask_grace_seconds.value,
1322
1292
  });
1323
- return await escalate(ctx, envelope, sessionId, unasked, liveToEscalate, notes, hardDeadlineAt, route, handoffToDetachedOwner);
1293
+ return await escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route, handoffToDetachedOwner);
1324
1294
  }
1325
- /** The escalation itself, split out so the claim is released on every path. */
1326
- async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, hardDeadlineAt, route, handoffToDetachedOwner) {
1327
- // The questions still owe the user their terminal-first window before
1328
- // anything reaches their devices — measured from the oldest registration,
1329
- // because that is the question that has waited longest.
1330
- const ceilingAt = hardDeadlineAt;
1331
- if (unasked.length > 0 && alreadyLive.length === 0) {
1332
- const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()));
1333
- await awaitTerminalFirstWindow(ctx, oldest, ceilingAt);
1334
- ctx.log?.debug('hook.gate', {
1335
- verdict: 'grace',
1336
- reason: 'elapsed',
1337
- waited_from: oldest,
1338
- grace_seconds: ctx.config.ask_grace_seconds.value,
1339
- });
1295
+ /** Submit independently of the long-lived answer owner, preserving frozen identities. */
1296
+ export async function submitSessionQuestions(ctx, envelope, hardDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000, notes = []) {
1297
+ const sessionId = envelope.session_id;
1298
+ if (sessionId === undefined || sessionHasEnded(sessionId, ctx.env))
1299
+ return [];
1300
+ if (!ctx.config.ask_notifications.value) {
1301
+ gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source });
1302
+ return [];
1340
1303
  }
1341
- // Phase one: every registered question reaches the user's devices, each as
1342
- // its own notification — one ask never stands in for another.
1343
- const submitted = [];
1344
- const admissionAnswers = [];
1345
- for (const entry of unasked) {
1346
- if (!queuedQuestionStillEligible(sessionId, ctx.env, entry)) {
1347
- notes.push('the question was retired before submission; not uploading it');
1348
- continue;
1304
+ const lock = `${sessionStatePath(sessionId, ctx.env)}.submission`;
1305
+ // This lock spans admission only, never reply polling or harness delivery.
1306
+ const lockDeadline = Math.min(hardDeadlineAt, ctx.now() + 60_000);
1307
+ let token = acquireClaimFile(lock, {}, Date.now());
1308
+ while (token === null && ctx.now() < lockDeadline && !sessionHasEnded(sessionId, ctx.env)) {
1309
+ await ctx.sleep(Math.min(50, lockDeadline - ctx.now()));
1310
+ token = acquireClaimFile(lock, {}, Date.now());
1311
+ }
1312
+ if (token === null) {
1313
+ notes.push('question submission is already owned; the durable registration remains available for recovery');
1314
+ return [];
1315
+ }
1316
+ try {
1317
+ const ceilingAt = hardDeadlineAt;
1318
+ const state = readSessionState(sessionId, ctx.env);
1319
+ const pending = pendingList(state);
1320
+ if (state.continuation !== undefined && state.continuation.count >= MAX_CONTINUATION_COUNT) {
1321
+ gate(ctx, 'held', 'continuation-limit', { count: state.continuation.count, limit: MAX_CONTINUATION_COUNT });
1322
+ notes.push(`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`);
1323
+ return [];
1349
1324
  }
1350
- // The service owns how long the answer is accepted. This process begins
1351
- // before submission, so its larger maximum-window budget includes startup
1352
- // headroom and remains alive through the complete committed window.
1353
- const replyWindowSeconds = ctx.config.reply_window_seconds.value;
1354
- if (ceilingAt - ctx.now() <
1355
- (replyWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000) {
1356
- notes.push('too little owner lifetime remains for the complete configured answer window; leaving this question frozen for a successor owner');
1357
- continue;
1325
+ if (pending.length > 0 && pending.every((entry) => entry.request_id === undefined)) {
1326
+ await awaitTerminalFirstWindow(ctx, Math.min(...pending.map((entry) => entry.asked_at ?? ctx.now())), ceilingAt);
1358
1327
  }
1359
- const ownerDeadlineAt = ceilingAt;
1360
- const questions = pendingQuestions(entry);
1361
- const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd);
1362
- let intent = entry.submission;
1363
- if (intent === undefined) {
1364
- const prepared = await prepareQuestionSubmission(ctx, {
1365
- summary: entry.summary,
1366
- ...(entry.body !== undefined ? { body: entry.body } : {}),
1367
- questions,
1368
- ...(entry.media !== undefined ? { media: entry.media } : {}),
1369
- ...(entry.project !== undefined ? { project: entry.project } : {}),
1370
- ...(eventSource !== undefined ? { source: eventSource } : {}),
1371
- windowSeconds: replyWindowSeconds,
1372
- ownerDeadlineAt,
1373
- });
1374
- if ('error' in prepared) {
1375
- ctx.log?.error('hook.pushed', { ok: false, message: prepared.error });
1376
- notes.push(prepared.error);
1328
+ // Phase one: every registered question reaches the user's devices, each as
1329
+ // its own notification — one ask never stands in for another.
1330
+ const submitted = [];
1331
+ for (const entry of pendingList(readSessionState(sessionId, ctx.env)).filter((entry) => entry.request_id === undefined)) {
1332
+ if (!queuedQuestionStillEligible(sessionId, ctx.env, entry)) {
1333
+ notes.push('the question was retired before submission; not uploading it');
1377
1334
  continue;
1378
1335
  }
1379
- intent = prepared;
1380
- // Durable before submit. If the server commits and the response is lost,
1381
- // the reserved request id still lets this owner poll and finalize the
1382
- // exact card; a successor can also replay the frozen draft/key.
1383
- updateSessionState(sessionId, ctx.env, (current) => {
1384
- const list = pendingList(current);
1385
- const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1386
- if (index < 0)
1387
- return current;
1388
- const next = [...list];
1389
- next[index] = { ...next[index], submission: prepared };
1390
- return { ...current, pending: next };
1391
- });
1392
- }
1393
- else if (intent.owner_deadline_at <= ctx.now()) {
1394
- // The frozen wire identity survives a crashed owner, but its local owner
1395
- // lease does not. Re-arm only the local deadline; request id, key,
1396
- // targets, and draft remain byte-identical for idempotent replay.
1397
- intent = { ...intent, owner_deadline_at: ceilingAt };
1398
- const rearmed = intent;
1399
- updateSessionState(sessionId, ctx.env, (current) => {
1400
- const list = pendingList(current);
1401
- const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1402
- if (index < 0)
1403
- return current;
1404
- const next = [...list];
1405
- next[index] = { ...next[index], submission: rearmed };
1406
- return { ...current, pending: next };
1407
- });
1408
- }
1409
- if (ctx.now() >= intent.owner_deadline_at) {
1410
- notes.push('the owner lifetime ended before submission; preserving the frozen intent');
1411
- continue;
1412
- }
1413
- if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
1414
- notes.push('setup consumed the admission allowance; preserving the frozen intent instead of publishing an answer window this owner cannot observe completely');
1415
- continue;
1416
- }
1417
- if (sessionHasEnded(sessionId, ctx.env)) {
1418
- notes.push('the Agent Session ended before submission; preserving no live observer');
1419
- continue;
1420
- }
1421
- let receipt;
1422
- let admissionConfirmed = false;
1423
- try {
1424
- const attempt = submitQuestion(ctx, sessionId, entry, intent, notes);
1425
- if (attempt === null)
1336
+ // The service owns how long the answer is accepted. This process begins
1337
+ // before submission, so its larger maximum-window budget includes startup
1338
+ // headroom and remains alive through the complete committed window.
1339
+ const replyWindowSeconds = ctx.config.reply_window_seconds.value;
1340
+ if (ceilingAt - ctx.now() <
1341
+ (replyWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000) {
1342
+ notes.push('too little owner lifetime remains for the complete configured answer window; leaving this question frozen for a successor owner');
1426
1343
  continue;
1427
- receipt = await attempt;
1428
- admissionConfirmed = true;
1429
- }
1430
- catch (caught) {
1431
- let err = caught;
1432
- if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
1433
- ctx.log?.error('hook.pushed', {
1434
- ok: false,
1435
- request_id: intent.request_id,
1436
- status: err.status,
1437
- code: err.code,
1438
- message: err.message,
1344
+ }
1345
+ const ownerDeadlineAt = ceilingAt;
1346
+ const questions = pendingQuestions(entry);
1347
+ const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd);
1348
+ let intent = entry.submission;
1349
+ if (intent === undefined) {
1350
+ const prepared = await prepareQuestionSubmission(ctx, {
1351
+ summary: entry.summary,
1352
+ ...(entry.body !== undefined ? { body: entry.body } : {}),
1353
+ questions,
1354
+ ...(entry.media !== undefined ? { media: entry.media } : {}),
1355
+ ...(entry.project !== undefined ? { project: entry.project } : {}),
1356
+ ...(eventSource !== undefined ? { source: eventSource } : {}),
1357
+ windowSeconds: replyWindowSeconds,
1358
+ ownerDeadlineAt,
1439
1359
  });
1440
- if (entry.submission !== undefined) {
1441
- notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`);
1442
- clearFrozenSubmission(sessionId, ctx.env, entry);
1443
- const reminted = await prepareQuestionSubmission(ctx, {
1444
- summary: entry.summary,
1445
- ...(entry.body !== undefined ? { body: entry.body } : {}),
1446
- questions,
1447
- ...(entry.media !== undefined ? { media: entry.media } : {}),
1448
- ...(entry.project !== undefined ? { project: entry.project } : {}),
1449
- ...(eventSource !== undefined ? { source: eventSource } : {}),
1450
- windowSeconds: replyWindowSeconds,
1451
- ownerDeadlineAt,
1452
- });
1453
- if ('error' in reminted) {
1454
- dropPendingQuestion(sessionId, ctx.env, entry);
1455
- notes.push(reminted.error);
1456
- continue;
1457
- }
1458
- intent = reminted;
1459
- updateSessionState(sessionId, ctx.env, (current) => {
1460
- const list = pendingList(current);
1461
- const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1462
- if (index < 0)
1463
- return current;
1464
- const next = [...list];
1465
- next[index] = { ...next[index], submission: reminted };
1466
- return { ...current, pending: next };
1360
+ if ('error' in prepared) {
1361
+ ctx.log?.error('hook.pushed', { ok: false, message: prepared.error });
1362
+ notes.push(prepared.error);
1363
+ continue;
1364
+ }
1365
+ intent = prepared;
1366
+ // Durable before submit. If the server commits and the response is lost,
1367
+ // the reserved request id still lets this owner poll and finalize the
1368
+ // exact card; a successor can also replay the frozen draft/key.
1369
+ updateSessionState(sessionId, ctx.env, (current) => {
1370
+ const list = pendingList(current);
1371
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1372
+ if (index < 0)
1373
+ return current;
1374
+ const next = [...list];
1375
+ next[index] = { ...next[index], submission: prepared };
1376
+ return { ...current, pending: next };
1377
+ });
1378
+ }
1379
+ else if (intent.owner_deadline_at <= ctx.now()) {
1380
+ // The frozen wire identity survives a crashed owner, but its local owner
1381
+ // lease does not. Re-arm only the local deadline; request id, key,
1382
+ // targets, and draft remain byte-identical for idempotent replay.
1383
+ intent = { ...intent, owner_deadline_at: ceilingAt };
1384
+ const rearmed = intent;
1385
+ updateSessionState(sessionId, ctx.env, (current) => {
1386
+ const list = pendingList(current);
1387
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1388
+ if (index < 0)
1389
+ return current;
1390
+ const next = [...list];
1391
+ next[index] = { ...next[index], submission: rearmed };
1392
+ return { ...current, pending: next };
1393
+ });
1394
+ }
1395
+ if (ctx.now() >= intent.owner_deadline_at) {
1396
+ notes.push('the owner lifetime ended before submission; preserving the frozen intent');
1397
+ continue;
1398
+ }
1399
+ if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
1400
+ notes.push('setup consumed the admission allowance; preserving the frozen intent instead of publishing an answer window this owner cannot observe completely');
1401
+ continue;
1402
+ }
1403
+ if (sessionHasEnded(sessionId, ctx.env)) {
1404
+ notes.push('the Agent Session ended before submission; preserving no live observer');
1405
+ continue;
1406
+ }
1407
+ let receipt;
1408
+ let admissionConfirmed = false;
1409
+ try {
1410
+ const attempt = submitQuestion(ctx, sessionId, entry, intent, notes);
1411
+ if (attempt === null)
1412
+ continue;
1413
+ receipt = await attempt;
1414
+ admissionConfirmed = true;
1415
+ }
1416
+ catch (caught) {
1417
+ let err = caught;
1418
+ if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
1419
+ ctx.log?.error('hook.pushed', {
1420
+ ok: false,
1421
+ request_id: intent.request_id,
1422
+ status: err.status,
1423
+ code: err.code,
1424
+ message: err.message,
1467
1425
  });
1468
- try {
1469
- if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
1470
- notes.push('draft recovery consumed the admission allowance; preserving the frozen intent for a successor owner');
1426
+ if (entry.submission !== undefined) {
1427
+ notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`);
1428
+ clearFrozenSubmission(sessionId, ctx.env, entry);
1429
+ const reminted = await prepareQuestionSubmission(ctx, {
1430
+ summary: entry.summary,
1431
+ ...(entry.body !== undefined ? { body: entry.body } : {}),
1432
+ questions,
1433
+ ...(entry.media !== undefined ? { media: entry.media } : {}),
1434
+ ...(entry.project !== undefined ? { project: entry.project } : {}),
1435
+ ...(eventSource !== undefined ? { source: eventSource } : {}),
1436
+ windowSeconds: replyWindowSeconds,
1437
+ ownerDeadlineAt,
1438
+ });
1439
+ if ('error' in reminted) {
1440
+ dropPendingQuestion(sessionId, ctx.env, entry);
1441
+ notes.push(reminted.error);
1471
1442
  continue;
1472
1443
  }
1473
- if (sessionHasEnded(sessionId, ctx.env)) {
1474
- notes.push('the Agent Session ended before recovered submission');
1475
- continue;
1444
+ intent = reminted;
1445
+ updateSessionState(sessionId, ctx.env, (current) => {
1446
+ const list = pendingList(current);
1447
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1448
+ if (index < 0)
1449
+ return current;
1450
+ const next = [...list];
1451
+ next[index] = { ...next[index], submission: reminted };
1452
+ return { ...current, pending: next };
1453
+ });
1454
+ try {
1455
+ if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
1456
+ notes.push('draft recovery consumed the admission allowance; preserving the frozen intent for a successor owner');
1457
+ continue;
1458
+ }
1459
+ if (sessionHasEnded(sessionId, ctx.env)) {
1460
+ notes.push('the Agent Session ended before recovered submission');
1461
+ continue;
1462
+ }
1463
+ const attempt = submitQuestion(ctx, sessionId, entry, intent, notes);
1464
+ if (attempt === null)
1465
+ continue;
1466
+ receipt = await attempt;
1467
+ admissionConfirmed = true;
1476
1468
  }
1477
- const attempt = submitQuestion(ctx, sessionId, entry, intent, notes);
1478
- if (attempt === null)
1479
- continue;
1480
- receipt = await attempt;
1481
- admissionConfirmed = true;
1482
- }
1483
- catch (retryErr) {
1484
- if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
1485
- dropPendingQuestion(sessionId, ctx.env, entry);
1486
- notes.push(`reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`);
1487
- ctx.log?.error('hook.pushed', {
1488
- ok: false,
1489
- request_id: intent.request_id,
1490
- status: retryErr.status,
1491
- code: retryErr.code,
1492
- message: retryErr.message,
1493
- });
1494
- continue;
1469
+ catch (retryErr) {
1470
+ if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
1471
+ dropPendingQuestion(sessionId, ctx.env, entry);
1472
+ notes.push(`reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`);
1473
+ ctx.log?.error('hook.pushed', {
1474
+ ok: false,
1475
+ request_id: intent.request_id,
1476
+ status: retryErr.status,
1477
+ code: retryErr.code,
1478
+ message: retryErr.message,
1479
+ });
1480
+ continue;
1481
+ }
1482
+ err = retryErr;
1495
1483
  }
1496
- err = retryErr;
1484
+ }
1485
+ else {
1486
+ dropPendingQuestion(sessionId, ctx.env, entry);
1487
+ notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`);
1488
+ continue;
1497
1489
  }
1498
1490
  }
1499
- else {
1500
- dropPendingQuestion(sessionId, ctx.env, entry);
1501
- notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`);
1491
+ if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
1492
+ notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`);
1493
+ ctx.log?.error('hook.pushed', {
1494
+ ok: false,
1495
+ request_id: intent.request_id,
1496
+ status: err.status,
1497
+ code: err.code,
1498
+ message: err.message,
1499
+ });
1502
1500
  continue;
1503
1501
  }
1502
+ if (!admissionConfirmed) {
1503
+ notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`);
1504
+ ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) });
1505
+ }
1504
1506
  }
1505
- if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
1506
- notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`);
1507
- ctx.log?.error('hook.pushed', {
1508
- ok: false,
1507
+ if (admissionConfirmed) {
1508
+ ctx.log?.info('hook.pushed', {
1509
+ ok: true,
1509
1510
  request_id: intent.request_id,
1510
- status: err.status,
1511
- code: err.code,
1512
- message: err.message,
1511
+ devices: intent.device_ids.length,
1512
+ questions: questions.length,
1513
+ text_chars: questions[0].text.length,
1513
1514
  });
1514
- continue;
1515
- }
1516
- if (!admissionConfirmed) {
1517
- notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`);
1518
- ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) });
1519
1515
  }
1520
- }
1521
- if (admissionConfirmed) {
1522
- ctx.log?.info('hook.pushed', {
1523
- ok: true,
1516
+ const committedReplyDeadline = receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
1517
+ ? intent.owner_deadline_at
1518
+ : Date.parse(receipt.reply_expires_at);
1519
+ const live = {
1520
+ ...entry,
1521
+ ...(intent.draft.source !== undefined ? { source: intent.draft.source } : {}),
1524
1522
  request_id: intent.request_id,
1525
- devices: intent.device_ids.length,
1526
- questions: questions.length,
1527
- text_chars: questions[0].text.length,
1528
- });
1529
- }
1530
- const committedReplyDeadline = receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
1531
- ? intent.owner_deadline_at
1532
- : Date.parse(receipt.reply_expires_at);
1533
- const live = {
1534
- ...entry,
1535
- ...(intent.draft.source !== undefined ? { source: intent.draft.source } : {}),
1536
- request_id: intent.request_id,
1537
- collapse_key: intent.collapse_key,
1538
- device_ids: intent.device_ids,
1539
- // The server's committed answer deadline is authoritative. The local
1540
- // owner deadline starts earlier and includes startup headroom, so it may
1541
- // be later but must never be earlier.
1542
- reply_deadline_at: Number.isFinite(committedReplyDeadline)
1543
- ? committedReplyDeadline
1544
- : intent.owner_deadline_at,
1545
- owner_deadline_at: intent.owner_deadline_at,
1546
- };
1547
- if (intent.draft.source === undefined)
1548
- delete live.source;
1549
- delete live.submission;
1550
- if (live.reply_deadline_at > live.owner_deadline_at) {
1551
- const response = await finalizeReplies(ctx, live.request_id, answerCloseDisposition(ctx, route));
1552
- if (response === null) {
1553
- // Closing was unreachable, so the question is still potentially live.
1554
- // Preserve it in the exact session instead of demoting it to the orphan
1555
- // retirement queue, whose later close has no route for an answer.
1523
+ collapse_key: intent.collapse_key,
1524
+ device_ids: intent.device_ids,
1525
+ // The server's committed answer deadline is authoritative. The local
1526
+ // owner deadline starts earlier and includes startup headroom, so it may
1527
+ // be later but must never be earlier.
1528
+ reply_deadline_at: Number.isFinite(committedReplyDeadline)
1529
+ ? committedReplyDeadline
1530
+ : intent.owner_deadline_at,
1531
+ owner_deadline_at: intent.owner_deadline_at,
1532
+ };
1533
+ if (intent.draft.source === undefined)
1534
+ delete live.source;
1535
+ delete live.submission;
1536
+ // Record what is now live on the user's devices BEFORE any wait. If we
1537
+ // only learned these ids afterwards, a question that timed out would
1538
+ // leave no trace, and the user returning to the terminal could never
1539
+ // retire it — the notification would stay answerable for an hour with
1540
+ // nobody listening.
1541
+ if (admissionConfirmed) {
1556
1542
  updateSessionState(sessionId, ctx.env, (current) => {
1557
1543
  const list = pendingList(current);
1558
- const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1559
- if (index < 0) {
1560
- // Terminal input removed this registration while the anomalous
1561
- // close was in flight. Preserve retirement identity exactly as
1562
- // normal submit promotion does; the observer must not become the
1563
- // only remaining record of a live card.
1564
- const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd);
1565
- const retiring = [...(current.retiring ?? [])];
1566
- if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
1567
- retiring.push(retirement);
1568
- }
1569
- return { ...current, retiring };
1544
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined);
1545
+ if (index >= 0) {
1546
+ const next = [...list];
1547
+ next[index] = live;
1548
+ return { ...current, pending: next };
1570
1549
  }
1571
- const next = [...list];
1572
- next[index] = live;
1573
- return { ...current, pending: next };
1550
+ // The entry vanished while the submit was in flight (the user's prompt
1551
+ // wiped the queue). The delivered notification must still be retirable,
1552
+ // so park it rather than lose its only identifiers.
1553
+ const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd);
1554
+ const retiring = [...(current.retiring ?? [])];
1555
+ if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
1556
+ retiring.push(retirement);
1557
+ }
1558
+ return rememberQuestionState({ ...current, retiring }, live, 'retired');
1574
1559
  });
1575
- submitted.push(live);
1576
- notes.push('the server committed an answer deadline beyond this process owner and immediate closure was unreachable; preserving the live question for exact-session recovery');
1560
+ }
1561
+ if (sessionHasEnded(sessionId, ctx.env)) {
1562
+ // SessionEnd may have raced either the submit or promotion. Its snapshot
1563
+ // usually already queued the frozen intent; this idempotent add closes
1564
+ // the other ordering where the request committed just after cleanup.
1565
+ orphanRetirements(ctx.env, [retiringQuestion(live, 'expired', envelope.cwd)], ctx.now());
1566
+ notes.push('the Agent Session ended during submission; queued the question for retirement');
1577
1567
  continue;
1578
1568
  }
1579
- else if (response.replies.length > 0) {
1580
- admissionAnswers.push({
1581
- pending: live,
1582
- reply: response.replies.at(-1),
1583
- replies: response.replies,
1584
- agent_acknowledgement_required: response.agent_acknowledgement_required,
1585
- agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
1586
- ...claimMarker(response),
1587
- });
1569
+ if (!pendingList(readSessionState(sessionId, ctx.env)).some((candidate) => isSamePending(candidate, entry))) {
1570
+ // A local close won after admission. Never resurrect the queue or wait
1571
+ // for an answer to a retired question; settle its reserved remote id.
1572
+ await drainRetirements(ctx, sessionId, ctx.env);
1573
+ continue;
1588
1574
  }
1589
- dropPendingQuestion(sessionId, ctx.env, entry);
1590
- notes.push('the server committed an answer deadline beyond this process owner; closed the anomalous window instead of abandoning it early');
1591
- continue;
1592
- }
1593
- // Record what is now live on the user's devices BEFORE any wait. If we
1594
- // only learned these ids afterwards, a question that timed out would
1595
- // leave no trace, and the user returning to the terminal could never
1596
- // retire it — the notification would stay answerable for an hour with
1597
- // nobody listening.
1598
- if (admissionConfirmed) {
1599
- updateSessionState(sessionId, ctx.env, (current) => {
1600
- const list = pendingList(current);
1601
- const index = list.findIndex((candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined);
1602
- if (index >= 0) {
1603
- const next = [...list];
1604
- next[index] = live;
1605
- return { ...current, pending: next };
1606
- }
1607
- // The entry vanished while the submit was in flight (the user's prompt
1608
- // wiped the queue). The delivered notification must still be retirable,
1609
- // so park it rather than lose its only identifiers.
1610
- const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd);
1611
- const retiring = [...(current.retiring ?? [])];
1612
- if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
1613
- retiring.push(retirement);
1614
- }
1615
- return rememberQuestionState({ ...current, retiring }, live, 'retired');
1616
- });
1617
- }
1618
- if (sessionHasEnded(sessionId, ctx.env)) {
1619
- // SessionEnd may have raced either the submit or promotion. Its snapshot
1620
- // usually already queued the frozen intent; this idempotent add closes
1621
- // the other ordering where the request committed just after cleanup.
1622
- orphanRetirements(ctx.env, [retiringQuestion(live, 'expired', envelope.cwd)], ctx.now());
1623
- notes.push('the Agent Session ended during submission; queued the question for retirement');
1624
- continue;
1575
+ submitted.push(live);
1625
1576
  }
1626
- if (!pendingList(readSessionState(sessionId, ctx.env)).some((candidate) => isSamePending(candidate, entry))) {
1627
- // A local close won after admission. Never resurrect the queue or wait
1628
- // for an answer to a retired question; settle its reserved remote id.
1629
- await drainRetirements(ctx, sessionId, ctx.env);
1630
- continue;
1631
- }
1632
- submitted.push(live);
1577
+ return submitted;
1633
1578
  }
1634
- if (admissionAnswers.length > 0) {
1635
- const accepted = stageAcceptedAnswers(ctx, sessionId, admissionAnswers, pendingList(readSessionState(sessionId, ctx.env)).length);
1636
- for (const answer of admissionAnswers)
1637
- reportAnswer(ctx, notes, answer, false);
1638
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1579
+ finally {
1580
+ releaseClaimFile(lock, token);
1639
1581
  }
1640
- const staleLive = alreadyLive.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now());
1582
+ }
1583
+ /** Merge this admission's uncertain receipts with fresh confirmed state. */
1584
+ function observableQuestions(state, submitted, now) {
1585
+ return pendingList(state).flatMap((entry) => {
1586
+ if (entry.request_id !== undefined)
1587
+ return ownerLeaseActive(entry, now) ? [entry] : [];
1588
+ const attempted = submitted.findLast((candidate) => isSamePending(candidate, entry) && candidate.request_id === entry.submission?.request_id);
1589
+ return attempted === undefined ? [] : [attempted];
1590
+ });
1591
+ }
1592
+ /** The escalation itself, split out so the claim is released on every path. */
1593
+ async function escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route, handoffToDetachedOwner) {
1594
+ const ceilingAt = hardDeadlineAt;
1595
+ const attemptedLocal = new Set(pendingList(readSessionState(sessionId, ctx.env))
1596
+ .filter((entry) => entry.request_id === undefined).map((entry) => entry.question_id ?? entry.question));
1597
+ const submitted = await submitSessionQuestions(ctx, envelope, hardDeadlineAt, notes);
1598
+ // Another submitter may have promoted a question while this owner waited.
1599
+ // The durable queue, not either caller's earlier snapshot, is authoritative.
1600
+ const live = observableQuestions(readSessionState(sessionId, ctx.env), submitted, ctx.now());
1601
+ const anomalous = live.filter((entry) => entry.reply_deadline_at !== undefined &&
1602
+ entry.reply_deadline_at > ceilingAt);
1603
+ if (anomalous.length > 0)
1604
+ notes.push('server committed an answer deadline beyond this process owner; reconciling the anomalous window');
1605
+ const staleLive = live.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now() || anomalous.includes(entry));
1641
1606
  const finalizedStale = await finalizePendings(ctx, staleLive, answerCloseDisposition(ctx, route));
1642
1607
  const staleAnswers = finalizedStale
1643
1608
  .map(finalizedAnswer)
@@ -1651,7 +1616,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1651
1616
  notes.push(`expired ${staleSilence.length} question${staleSilence.length === 1 ? '' : 's'} whose continuation owner had ended`);
1652
1617
  }
1653
1618
  if (staleUnproven.length > 0) {
1654
- notes.push(`could not finalize ${staleUnproven.length} expired question${staleUnproven.length === 1 ? '' : 's'}; preserving ownership for recovery`);
1619
+ notes.push(`could not finalize ${staleUnproven.length} question${staleUnproven.length === 1 ? '' : 's'}; preserving the live question and observing for recovery`);
1655
1620
  }
1656
1621
  if (staleAnswers.length > 0) {
1657
1622
  const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length);
@@ -1660,10 +1625,14 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1660
1625
  reportAnswer(ctx, notes, answer, true);
1661
1626
  return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1662
1627
  }
1663
- const waitingOn = [
1664
- ...alreadyLive.filter((entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now()),
1665
- ...submitted,
1666
- ];
1628
+ // A terminal close can race the remote close fence. Retain its remote identity
1629
+ // for retirement without resurrecting a removed local question.
1630
+ const remaining = pendingList(readSessionState(sessionId, ctx.env));
1631
+ const removed = staleUnproven.filter(({ pending }) => !remaining.some((entry) => isSamePending(entry, pending)));
1632
+ if (removed.length > 0)
1633
+ await retirePendings(ctx, envelope, sessionId, removed.map(({ pending }) => pending), 'answered_elsewhere');
1634
+ const waitingState = readSessionState(sessionId, ctx.env);
1635
+ const waitingOn = observableQuestions(waitingState, submitted, ctx.now()).filter((entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now());
1667
1636
  if (waitingOn.length === 0)
1668
1637
  return { notes };
1669
1638
  // Codex has a durable exact-thread queue route. Release the short Stop hook
@@ -1678,17 +1647,38 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1678
1647
  // its own server deadline and continue waiting on the rest. Reserving time by
1679
1648
  // closing early would make the advertised reply window untrue.
1680
1649
  let activeWaiting = waitingOn;
1650
+ const knownRequestIds = new Set([
1651
+ ...pendingList(waitingState).flatMap((entry) => entry.request_id === undefined ? [] : [entry.request_id]),
1652
+ ...waitingOn.map((entry) => entry.request_id),
1653
+ ]);
1681
1654
  let timeoutSeconds = 0;
1682
1655
  let waited;
1683
1656
  for (;;) {
1684
1657
  const nextReplyDeadline = Math.min(...activeWaiting.map((entry) => entry.reply_deadline_at));
1685
1658
  const waitDeadline = Math.min(ceilingAt, nextReplyDeadline);
1686
1659
  timeoutSeconds = Math.max(0, Math.ceil((waitDeadline - ctx.now()) / 1000));
1687
- waited = await waitForAnyReply(ctx, activeWaiting.map((entry) => entry.request_id), timeoutSeconds, () => waiterInterruption(sessionId, ctx.env, activeWaiting.map((entry) => entry.request_id)));
1688
- if (waited.interrupted !== null) {
1689
- notes.push(waited.interrupted === 'new-question'
1690
- ? 'yielding the answer owner so a newly registered question can be sent now'
1691
- : 'answer ownership ended locally; stopping this observer without delivering into a closed session');
1660
+ waited = await waitForAnyReply(ctx, activeWaiting.map((entry) => entry.request_id), timeoutSeconds, () => waiterInterruption(sessionId, ctx.env, [...knownRequestIds], attemptedLocal));
1661
+ if (waited.interrupted === 'new-question') {
1662
+ // Native observers can renew their process ceiling for a later question.
1663
+ // A held Stop keeps its actual stdout owner and includes the new live ID.
1664
+ if (route.kind === 'session-queue' || route.defer !== undefined) {
1665
+ notes.push('yielding the answer owner to include the newly registered question');
1666
+ return { notes, settlementRequired: true };
1667
+ }
1668
+ for (const entry of pendingList(readSessionState(sessionId, ctx.env))) {
1669
+ if (entry.request_id === undefined)
1670
+ attemptedLocal.add(entry.question_id ?? entry.question);
1671
+ }
1672
+ submitted.push(...await submitSessionQuestions(ctx, envelope, hardDeadlineAt, notes));
1673
+ activeWaiting = observableQuestions(readSessionState(sessionId, ctx.env), submitted, ctx.now());
1674
+ for (const entry of activeWaiting)
1675
+ knownRequestIds.add(entry.request_id);
1676
+ if (activeWaiting.length === 0)
1677
+ return { notes };
1678
+ continue;
1679
+ }
1680
+ if (waited.interrupted === 'ownership-ended') {
1681
+ notes.push('answer ownership ended locally; stopping this observer without delivering into a closed session');
1692
1682
  return { notes };
1693
1683
  }
1694
1684
  const currentRequestIds = new Set(pendingList(readSessionState(sessionId, ctx.env)).flatMap((entry) => [entry.request_id, entry.submission?.request_id].filter((requestId) => requestId !== undefined)));
@@ -1815,7 +1805,7 @@ export function handleSessionEnd(env, envelope, now = Date.now(), preserveAccept
1815
1805
  for (const entry of pendingList(state)) {
1816
1806
  stateWithHistory = rememberQuestionState(stateWithHistory, entry, entry.request_id === undefined && entry.submission === undefined ? 'withdrawn' : 'retired');
1817
1807
  }
1818
- for (const answer of [...(state.accepted?.answers ?? []), ...(state.delivered_answers ?? [])]) {
1808
+ for (const answer of [...(state.accepted?.answers ?? []), ...(state.delivered_answers ?? []), ...(state.waiting_answers ?? [])]) {
1819
1809
  stateWithHistory = rememberQuestionState(stateWithHistory, answer.pending, 'answered');
1820
1810
  }
1821
1811
  for (const retirement of state.retiring ?? []) {
@@ -1826,6 +1816,7 @@ export function handleSessionEnd(env, envelope, now = Date.now(), preserveAccept
1826
1816
  ...pendingList(state),
1827
1817
  ...(state.accepted?.answers.map((entry) => entry.pending) ?? []),
1828
1818
  ...(state.delivered_answers?.map((entry) => entry.pending) ?? []),
1819
+ ...(state.waiting_answers?.map((entry) => entry.pending) ?? []),
1829
1820
  ];
1830
1821
  for (const entry of retirementCandidates) {
1831
1822
  try {
@@ -1855,6 +1846,9 @@ export function handleSessionEnd(env, envelope, now = Date.now(), preserveAccept
1855
1846
  const preserved = { ...stateWithHistory };
1856
1847
  if (!preserveAccepted)
1857
1848
  delete preserved.accepted;
1849
+ if (!preserveAccepted)
1850
+ delete preserved.waiting_answers;
1851
+ delete preserved.input_wake;
1858
1852
  delete preserved.pending;
1859
1853
  delete preserved.retiring;
1860
1854
  delete preserved.acknowledgement_blocks;