@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.
- package/CHANGELOG.md +36 -0
- package/README.md +12 -14
- package/dist/codex-tool-messages.d.ts +1 -12
- package/dist/codex-tool-messages.js +20 -66
- package/dist/codex-tool-messages.js.map +1 -1
- package/dist/codex-wake.d.ts +6 -25
- package/dist/codex-wake.js +6 -25
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-ask.d.ts +2 -3
- package/dist/commands-ask.js +29 -11
- package/dist/commands-ask.js.map +1 -1
- package/dist/commands-core.d.ts +1 -1
- package/dist/commands-hook-attend.js +39 -53
- package/dist/commands-hook-attend.js.map +1 -1
- package/dist/commands-hook-run.js +50 -12
- package/dist/commands-hook-run.js.map +1 -1
- package/dist/commands-receive.d.ts +3 -0
- package/dist/commands-receive.js +55 -0
- package/dist/commands-receive.js.map +1 -0
- package/dist/commands-send.js +1 -1
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-update-check.js +2 -1
- package/dist/commands-update-check.js.map +1 -1
- package/dist/commands.d.ts +1 -0
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +2 -2
- package/dist/config-schema.js.map +1 -1
- package/dist/hook-acknowledgements.js +7 -1
- package/dist/hook-acknowledgements.js.map +1 -1
- package/dist/hook-gates.d.ts +1 -1
- package/dist/hook-gates.js +0 -1
- package/dist/hook-gates.js.map +1 -1
- package/dist/hook-lifecycle.d.ts +2 -0
- package/dist/hook-lifecycle.js +354 -360
- package/dist/hook-lifecycle.js.map +1 -1
- package/dist/hook-question-lock.js +3 -3
- package/dist/hook-question-lock.js.map +1 -1
- package/dist/hook-types.d.ts +15 -7
- package/dist/logging.js +1 -1
- package/dist/logging.js.map +1 -1
- package/dist/program.d.ts +2 -1
- package/dist/program.js +11 -6
- package/dist/program.js.map +1 -1
- package/dist/question-settlement-process.d.ts +5 -3
- package/dist/question-settlement-process.js +4 -4
- package/dist/question-settlement-process.js.map +1 -1
- package/dist/session-attendant-probe.d.ts +1 -1
- package/dist/session-attendant-probe.js +1 -1
- package/dist/session-attendant-state.d.ts +3 -3
- package/dist/session-attendant-state.js +3 -3
- package/dist/session-attendant.d.ts +2 -0
- package/dist/session-attendant.js +16 -1
- package/dist/session-attendant.js.map +1 -1
- package/dist/session-delivery.d.ts +5 -1
- package/dist/session-delivery.js +10 -0
- package/dist/session-delivery.js.map +1 -1
- package/dist/session-inputs.d.ts +22 -0
- package/dist/session-inputs.js +254 -0
- package/dist/session-inputs.js.map +1 -0
- package/dist/session-message-handoff.js +6 -1
- package/dist/session-message-handoff.js.map +1 -1
- package/dist/skill-source/manifest.json +6 -6
- package/dist/skill-source/notifai/SKILL.md +20 -21
- package/dist/skill-source/notifai/references/diagnostics.md +1 -1
- package/dist/skill-source/notifai/references/harness-setup.md +39 -33
- package/dist/skill-source/notifai/references/notes-and-edits.md +6 -1
- package/package.json +2 -2
package/dist/hook-lifecycle.js
CHANGED
|
@@ -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
|
|
219
|
-
*
|
|
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
|
|
230
|
-
(entry.
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
|
1290
|
+
already_live: live.length,
|
|
1321
1291
|
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
1322
1292
|
});
|
|
1323
|
-
return await escalate(ctx, envelope, sessionId,
|
|
1293
|
+
return await escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route, handoffToDetachedOwner);
|
|
1324
1294
|
}
|
|
1325
|
-
/**
|
|
1326
|
-
async function
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
-
|
|
1342
|
-
//
|
|
1343
|
-
const
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
1361
|
-
const
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
let
|
|
1432
|
-
if (
|
|
1433
|
-
ctx
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
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 (
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
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
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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 (
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
ok: false,
|
|
1507
|
+
if (admissionConfirmed) {
|
|
1508
|
+
ctx.log?.info('hook.pushed', {
|
|
1509
|
+
ok: true,
|
|
1509
1510
|
request_id: intent.request_id,
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
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
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
1542
|
-
|
|
1543
|
-
|
|
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
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
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
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
1576
|
-
|
|
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
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1635
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
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,
|
|
1688
|
-
if (waited.interrupted
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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;
|