@raidiant/notifai 11.4.0-beta.2 → 11.4.1
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 +28 -2
- package/dist/claude-wake.d.ts +11 -3
- package/dist/claude-wake.js +74 -67
- package/dist/claude-wake.js.map +1 -1
- package/dist/client.d.ts +23 -2
- package/dist/client.js +11 -5
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.d.ts +12 -2
- package/dist/codex-wake.js +50 -13
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-acknowledge.d.ts +4 -2
- package/dist/commands-acknowledge.js +35 -4
- package/dist/commands-acknowledge.js.map +1 -1
- package/dist/commands-ask.js +2 -2
- package/dist/commands-ask.js.map +1 -1
- package/dist/commands-close.js +16 -4
- package/dist/commands-close.js.map +1 -1
- package/dist/commands-core.d.ts +6 -0
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +46 -1
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hook-attend.d.ts +63 -0
- package/dist/commands-hook-attend.js +475 -0
- package/dist/commands-hook-attend.js.map +1 -0
- package/dist/commands-hook-diagnostics.js +2 -2
- package/dist/commands-hook-diagnostics.js.map +1 -1
- package/dist/commands-hook-install.js +1 -1
- package/dist/commands-hook-install.js.map +1 -1
- package/dist/commands-hook-run.js +64 -2
- package/dist/commands-hook-run.js.map +1 -1
- package/dist/commands-send.d.ts +1 -0
- package/dist/commands-send.js +123 -9
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-update.js +22 -7
- package/dist/commands-update.js.map +1 -1
- package/dist/guidance-content.js +9 -35
- package/dist/guidance-content.js.map +1 -1
- package/dist/hook-acknowledgements.d.ts +29 -5
- package/dist/hook-acknowledgements.js +128 -45
- package/dist/hook-acknowledgements.js.map +1 -1
- package/dist/hook-events.d.ts +26 -1
- package/dist/hook-events.js +38 -2
- package/dist/hook-events.js.map +1 -1
- package/dist/hook-lifecycle.d.ts +6 -0
- package/dist/hook-lifecycle.js +304 -26
- package/dist/hook-lifecycle.js.map +1 -1
- package/dist/hook-question-lock.d.ts +18 -0
- package/dist/hook-question-lock.js +80 -57
- package/dist/hook-question-lock.js.map +1 -1
- package/dist/hook-question-retirement.d.ts +10 -2
- package/dist/hook-question-retirement.js +21 -4
- package/dist/hook-question-retirement.js.map +1 -1
- package/dist/hook-session-state.d.ts +90 -1
- package/dist/hook-session-state.js +201 -9
- package/dist/hook-session-state.js.map +1 -1
- package/dist/hook-types.d.ts +56 -2
- package/dist/injection-render.d.ts +36 -0
- package/dist/injection-render.js +61 -0
- package/dist/injection-render.js.map +1 -0
- package/dist/install-hooks.d.ts +14 -0
- package/dist/install-hooks.js +47 -2
- package/dist/install-hooks.js.map +1 -1
- package/dist/logging.d.ts +1 -1
- package/dist/logging.js +12 -0
- package/dist/logging.js.map +1 -1
- package/dist/process-identity.d.ts +26 -0
- package/dist/process-identity.js +82 -0
- package/dist/process-identity.js.map +1 -0
- package/dist/program.js +8 -7
- package/dist/program.js.map +1 -1
- package/dist/session-attendant-probe.d.ts +74 -0
- package/dist/session-attendant-probe.js +131 -0
- package/dist/session-attendant-probe.js.map +1 -0
- package/dist/session-attendant-state.d.ts +55 -0
- package/dist/session-attendant-state.js +176 -0
- package/dist/session-attendant-state.js.map +1 -0
- package/dist/session-attendant.d.ts +135 -0
- package/dist/session-attendant.js +493 -0
- package/dist/session-attendant.js.map +1 -0
- package/dist/session-delivery.d.ts +214 -0
- package/dist/session-delivery.js +562 -0
- package/dist/session-delivery.js.map +1 -0
- package/dist/session-handoff.d.ts +98 -0
- package/dist/session-handoff.js +118 -0
- package/dist/session-handoff.js.map +1 -0
- package/dist/session-message-handoff.d.ts +35 -0
- package/dist/session-message-handoff.js +100 -0
- package/dist/session-message-handoff.js.map +1 -0
- package/dist/skill-source/manifest.json +20 -4
- package/dist/skill-source/notifai/SKILL.md +19 -57
- package/dist/skill-source/notifai/references/diagnostics.md +31 -0
- package/dist/skill-source/notifai/references/harness-setup.md +3 -0
- package/dist/skill-source/notifai/references/notes-and-edits.md +38 -0
- package/dist/skill-source/notifai/references/send-details.md +14 -0
- package/dist/skill-source/notifai/references/writing-examples.md +56 -0
- package/dist/wake-support.d.ts +33 -1
- package/dist/wake-support.js +65 -8
- package/dist/wake-support.js.map +1 -1
- package/package.json +2 -2
package/dist/hook-lifecycle.js
CHANGED
|
@@ -3,13 +3,16 @@ import { ApiCallError, isRetryableReplyPollError } from './client.js';
|
|
|
3
3
|
import { withFileLock } from './file-lock.js';
|
|
4
4
|
import { HARNESS_CAPABILITIES } from './harnesses.js';
|
|
5
5
|
import { gate } from './hook-gates.js';
|
|
6
|
-
import { acknowledgementBlockContext, amendAcceptedAnswers, answersContext, classifyJournaledAcknowledgements, finishCommittedDelivery, holdForAcknowledgement, reconcileAcknowledgementObligations, recoverQueuedAnswers, resetAcknowledgementBlocks, settleAcceptedAnswers, stageAcceptedAnswers, stopAnswerOutput, } from './hook-acknowledgements.js';
|
|
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
8
|
import { claimHandoffState, claimQuestionPush, releaseQuestionPush } from './hook-question-lock.js';
|
|
9
|
-
import { closeQuietly, drainOrphanRetirements, drainRetirements, finalizeReplies, orphanRetirements, pendingAnsweredByPrompt, retirePendings, retiringQuestion, } from './hook-question-retirement.js';
|
|
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
|
-
import { clearSessionState, markSessionEnded, pendingList, readSessionState, sessionHasEnded, sessionStatePath, updateSessionState, writeSessionState, writeSessionStateUnlocked, } from './hook-session-state.js';
|
|
11
|
+
import { clearSessionState, markSessionEnded, pendingList, readSessionState, sessionHasEnded, sessionStatePath, updateSessionState, writeSessionState, writeSessionStateUnlocked, recordSessionNotified, } from './hook-session-state.js';
|
|
12
|
+
import { currentProcessIdentity } from './process-identity.js';
|
|
12
13
|
import { userPromptContextOutput } from './session-activation.js';
|
|
14
|
+
import { answersAlreadyWritten, beginHandOff, recordUnclaimedHandOffs, recoverDeliveryJournal, sweepDeliveryJournals, } from './session-delivery.js';
|
|
15
|
+
import { WRITE_ABORTED_REASON } from './wake-support.js';
|
|
13
16
|
import { QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS, QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS, QUESTION_WAITER_CEILING_SECONDS, } from './question-timing.js';
|
|
14
17
|
import { buildDraft } from './send.js';
|
|
15
18
|
/**
|
|
@@ -117,6 +120,8 @@ function submitQuestion(ctx, sessionId, entry, intent, notes) {
|
|
|
117
120
|
if (receipt.request_id !== intent.request_id) {
|
|
118
121
|
throw new Error(`server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`);
|
|
119
122
|
}
|
|
123
|
+
// Wakes this session's dormant Session Attendant.
|
|
124
|
+
recordSessionNotified(sessionId, ctx.env, ctx.now());
|
|
120
125
|
return receipt;
|
|
121
126
|
});
|
|
122
127
|
if (attempt === null)
|
|
@@ -297,10 +302,27 @@ async function pollPendingReply(ctx, pending, timeoutSeconds) {
|
|
|
297
302
|
: { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) };
|
|
298
303
|
}
|
|
299
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* How an answer-collecting close names itself: `deliver` only when this
|
|
307
|
+
* waiter will claim the fenced answer before writing it in place — a Claude
|
|
308
|
+
* inbox write or a Codex thread-queue write while this session's attendant
|
|
309
|
+
* holds a lease. Otherwise no disposition, exactly as released CLIs close.
|
|
310
|
+
*/
|
|
311
|
+
function answerCloseDisposition(ctx, route) {
|
|
312
|
+
return claimableRoute(route) && ctx.answerClaims?.lease() != null ? 'deliver' : undefined;
|
|
313
|
+
}
|
|
314
|
+
/** Routes that write into the running session itself, so a claim can cover the write. */
|
|
315
|
+
function claimableRoute(route) {
|
|
316
|
+
return route.kind === 'inbox-socket' || route.kind === 'session-queue';
|
|
317
|
+
}
|
|
318
|
+
/** The claim marker for an answer its own `deliver` close selected. */
|
|
319
|
+
function claimMarker(response) {
|
|
320
|
+
return selectedForClaimedDelivery(response) ? { delivery_claim: true } : {};
|
|
321
|
+
}
|
|
300
322
|
/** Close several windows concurrently without confusing failure with silence. */
|
|
301
|
-
async function finalizePendings(ctx, pending) {
|
|
323
|
+
async function finalizePendings(ctx, pending, disposition) {
|
|
302
324
|
return Promise.all(pending.map(async (entry) => {
|
|
303
|
-
const response = await finalizeReplies(ctx, entry.request_id);
|
|
325
|
+
const response = await finalizeReplies(ctx, entry.request_id, disposition);
|
|
304
326
|
ctx.log?.info('hook.retirement', {
|
|
305
327
|
request_id: entry.request_id,
|
|
306
328
|
attempted: true,
|
|
@@ -321,6 +343,7 @@ function finalizedAnswer(finalized) {
|
|
|
321
343
|
replies,
|
|
322
344
|
agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
|
|
323
345
|
agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
|
|
346
|
+
...claimMarker(finalized.response),
|
|
324
347
|
};
|
|
325
348
|
}
|
|
326
349
|
/** Persist and surface one answer without letting the two representations drift. */
|
|
@@ -373,10 +396,54 @@ function isTerminalDraftRejection(err) {
|
|
|
373
396
|
* may be answering a different question on a device.
|
|
374
397
|
*/
|
|
375
398
|
export async function handleUserPromptSubmit(ctx, envelope) {
|
|
376
|
-
const notes = [];
|
|
377
399
|
const sessionId = envelope.session_id;
|
|
378
400
|
if (!sessionId)
|
|
379
|
-
return { notes };
|
|
401
|
+
return { notes: [] };
|
|
402
|
+
if (ctx.harness === 'codex') {
|
|
403
|
+
const presented = observeQueuedMessagePrompt(sessionId, ctx.env, envelope.prompt);
|
|
404
|
+
if (presented.length > 0)
|
|
405
|
+
ctx.log?.info('delivery.handoff', {
|
|
406
|
+
route: 'session-queue', stage: 'prompt-observed', message_ids: presented,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
// Owed Session Message acknowledgements come before any resumed work: a
|
|
410
|
+
// session resumed after SessionEnd has no Stop to remind it first.
|
|
411
|
+
const reminder = { text: await messageAcknowledgementReminder(ctx, sessionId), used: false };
|
|
412
|
+
const outcome = await answerPrompt(ctx, envelope, sessionId, reminder);
|
|
413
|
+
if (reminder.text === null || reminder.used || outcome.stdout !== undefined || sessionHasEnded(sessionId, ctx.env)) {
|
|
414
|
+
return outcome;
|
|
415
|
+
}
|
|
416
|
+
const stdout = userPromptContextOutput(ctx.harness, reminder.text);
|
|
417
|
+
if (stdout === undefined)
|
|
418
|
+
return outcome;
|
|
419
|
+
outcome.notes.push('an owed Session Message acknowledgement was added to the user\'s new turn');
|
|
420
|
+
return {
|
|
421
|
+
...outcome,
|
|
422
|
+
stdout,
|
|
423
|
+
decided: false,
|
|
424
|
+
log: { ...outcome.log, stage: outcome.log?.['stage'] ?? 'acknowledgement-reminded' },
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/** Context for the new turn, carrying the owed-acknowledgement reminder once. */
|
|
428
|
+
function withReminder(reminder, context) {
|
|
429
|
+
if (reminder.text === null)
|
|
430
|
+
return context;
|
|
431
|
+
reminder.used = true;
|
|
432
|
+
return `${context}\n\n${reminder.text}`;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* The instruction for Session Message acknowledgements still owed after asking
|
|
436
|
+
* the service, or null when none is.
|
|
437
|
+
*/
|
|
438
|
+
async function messageAcknowledgementReminder(ctx, sessionId) {
|
|
439
|
+
const owed = presentedMessageAcknowledgements(readSessionState(sessionId, ctx.env));
|
|
440
|
+
if (owed.length === 0)
|
|
441
|
+
return null;
|
|
442
|
+
const still = await reconcileAcknowledgementObligations(ctx, sessionId, owed);
|
|
443
|
+
return still.length === 0 ? null : acknowledgementBlockContext(still);
|
|
444
|
+
}
|
|
445
|
+
async function answerPrompt(ctx, envelope, sessionId, reminder) {
|
|
446
|
+
const notes = [];
|
|
380
447
|
recoverQueuedAnswers(sessionId, ctx.env);
|
|
381
448
|
let state = readSessionState(sessionId, ctx.env);
|
|
382
449
|
if (state.accepted !== undefined || (state.delivered_answers?.length ?? 0) > 0) {
|
|
@@ -393,6 +460,23 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
393
460
|
state = readSessionState(sessionId, ctx.env);
|
|
394
461
|
}
|
|
395
462
|
}
|
|
463
|
+
if (state.accepted !== undefined) {
|
|
464
|
+
// A journaled write of this batch already began (claimed, or recorded
|
|
465
|
+
// after an unclaimed hand-off): adding it to this turn would hand it over
|
|
466
|
+
// twice. It settles instead; its acknowledgement stays owed.
|
|
467
|
+
const written = answersAlreadyWritten(sessionId, ctx.env, state.accepted.answers.flatMap(({ pending }) => (pending.request_id === undefined ? [] : [pending.request_id])));
|
|
468
|
+
if (written.length > 0) {
|
|
469
|
+
settleAcceptedAnswers(ctx, sessionId, state.accepted, envelope.cwd);
|
|
470
|
+
ctx.log?.info('hook.answer', {
|
|
471
|
+
answered: true,
|
|
472
|
+
stage: 'replay-suppressed',
|
|
473
|
+
route: 'user-prompt-submit',
|
|
474
|
+
journal: written.map((entry) => `${entry.requestId}:${entry.stage}`),
|
|
475
|
+
});
|
|
476
|
+
notes.push('an earlier hand-off of the journaled answer already began its write; not adding it to this turn');
|
|
477
|
+
state = readSessionState(sessionId, ctx.env);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
396
480
|
if (state.accepted !== undefined) {
|
|
397
481
|
const answers = [
|
|
398
482
|
...(state.delivered_answers ?? []),
|
|
@@ -406,13 +490,17 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
406
490
|
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
407
491
|
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
|
|
408
492
|
}
|
|
409
|
-
const stdout = userPromptContextOutput(ctx.harness, answersContext(answers, state.accepted.remaining));
|
|
493
|
+
const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext(answers, state.accepted.remaining)));
|
|
410
494
|
if (stdout !== undefined) {
|
|
411
495
|
notes.push('the journaled device answer was added to the user\'s new turn');
|
|
496
|
+
const selected = unclaimedSelectedAnswers(sessionId, ctx.env, answers);
|
|
412
497
|
return {
|
|
413
498
|
stdout,
|
|
414
499
|
decided: false,
|
|
415
500
|
notes,
|
|
501
|
+
...(selected.length === 0
|
|
502
|
+
? {}
|
|
503
|
+
: { afterOutput: () => reportHandedOffAfterTheFact(ctx, sessionId, selected) }),
|
|
416
504
|
log: {
|
|
417
505
|
stage: 'context-added',
|
|
418
506
|
route: 'user-prompt-submit',
|
|
@@ -518,7 +606,7 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
518
606
|
// UserPromptSubmit can add context to the turn already beginning. Keep the
|
|
519
607
|
// accepted-answer journal until the agent acknowledges: if this process or
|
|
520
608
|
// host dies around stdout, a later prompt or Stop safely replays it.
|
|
521
|
-
const stdout = userPromptContextOutput(ctx.harness, answersContext([...(readSessionState(sessionId, ctx.env).delivered_answers ?? []), ...lateAnswers], pendingList(updated).length));
|
|
609
|
+
const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext([...(readSessionState(sessionId, ctx.env).delivered_answers ?? []), ...lateAnswers], pendingList(updated).length)));
|
|
522
610
|
if (stdout !== undefined)
|
|
523
611
|
notes.push('the late device answer was added to the user\'s new turn');
|
|
524
612
|
else
|
|
@@ -541,13 +629,14 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
541
629
|
}
|
|
542
630
|
const retired = await drainRetirements(ctx, sessionId, ctx.env);
|
|
543
631
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now());
|
|
632
|
+
await sweepDeliveryJournalsFor(ctx);
|
|
544
633
|
const swept = [...retired, ...orphaned];
|
|
545
634
|
if (swept.length > 0) {
|
|
546
635
|
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
547
636
|
}
|
|
548
637
|
const delivered = readSessionState(sessionId, ctx.env).delivered_answers ?? [];
|
|
549
638
|
const stdout = delivered.length > 0
|
|
550
|
-
? userPromptContextOutput(ctx.harness, answersContext(delivered, pendingList(updated).length))
|
|
639
|
+
? userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext(delivered, pendingList(updated).length)))
|
|
551
640
|
: undefined;
|
|
552
641
|
return {
|
|
553
642
|
notes,
|
|
@@ -702,9 +791,9 @@ export async function runEscalationWaiter(ctx, options) {
|
|
|
702
791
|
return held;
|
|
703
792
|
state = readSessionState(sessionId, ctx.env);
|
|
704
793
|
}
|
|
705
|
-
if (state.accepted === undefined && (state.
|
|
794
|
+
if (state.accepted === undefined && owedAcknowledgements(state).length > 0 &&
|
|
706
795
|
!(options.recordStop === false && options.route.kind === 'session-queue')) {
|
|
707
|
-
const due = await reconcileAcknowledgementObligations(ctx, sessionId, state
|
|
796
|
+
const due = await reconcileAcknowledgementObligations(ctx, sessionId, owedAcknowledgements(state));
|
|
708
797
|
const held = holdForAcknowledgement(ctx, sessionId, due, notes);
|
|
709
798
|
if (held !== null)
|
|
710
799
|
return held;
|
|
@@ -717,6 +806,7 @@ export async function runEscalationWaiter(ctx, options) {
|
|
|
717
806
|
...(await drainRetirements(ctx, sessionId, ctx.env)),
|
|
718
807
|
...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
|
|
719
808
|
];
|
|
809
|
+
await sweepDeliveryJournalsFor(ctx);
|
|
720
810
|
if (swept.length > 0) {
|
|
721
811
|
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
722
812
|
}
|
|
@@ -785,6 +875,9 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
|
|
|
785
875
|
notes.push('the Agent Session ended before answer delivery; stopping this observer');
|
|
786
876
|
return { notes };
|
|
787
877
|
}
|
|
878
|
+
const replayed = await suppressWrittenReplay(ctx, sessionId, accepted, notes, cwd);
|
|
879
|
+
if (replayed !== null)
|
|
880
|
+
return replayed;
|
|
788
881
|
const held = accepted.held_deliveries ?? 0;
|
|
789
882
|
if (held >= MAX_HELD_DELIVERIES) {
|
|
790
883
|
gate(ctx, 'held', 'delivery-limit', {
|
|
@@ -853,14 +946,42 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
|
|
|
853
946
|
return true;
|
|
854
947
|
});
|
|
855
948
|
};
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
949
|
+
// Answers a `deliver` close selected are claimed first, under this session's
|
|
950
|
+
// delivery lock, so an Answer Edit can only follow the write. A claim that
|
|
951
|
+
// cannot be had never withholds the answer: it is written unclaimed, as
|
|
952
|
+
// released CLIs write it, and its edits simply wait.
|
|
953
|
+
const handOff = await claimAnswerHandOff(ctx, sessionId, route, answered);
|
|
954
|
+
let delivered;
|
|
955
|
+
try {
|
|
956
|
+
delivered = await route.deliver({
|
|
957
|
+
context: answersContext(answered, remaining),
|
|
958
|
+
answers: answered.length,
|
|
959
|
+
remaining,
|
|
960
|
+
request_ids: requestIds,
|
|
961
|
+
journal_recorded_at: accepted.recorded_at,
|
|
962
|
+
commitDelivery: handOff === null
|
|
963
|
+
? commitDelivery
|
|
964
|
+
: (writer) => handOff.begin(commitDelivery, { subprocess: writer === 'subprocess' }),
|
|
965
|
+
...(handOff === null || handOff.claimed.length === 0
|
|
966
|
+
? {}
|
|
967
|
+
: {
|
|
968
|
+
writeGuard: { writable: () => handOff.writable(), remainingMs: () => handOff.remainingMs() },
|
|
969
|
+
writerGroup: (pgid) => handOff.recordGroup(pgid),
|
|
970
|
+
}),
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
catch (err) {
|
|
974
|
+
await handOff?.finish('failed');
|
|
975
|
+
throw err;
|
|
976
|
+
}
|
|
977
|
+
await handOff?.finish(delivered.acknowledgement === 'delivered'
|
|
978
|
+
? 'written'
|
|
979
|
+
: delivered.log?.['reason'] === WRITE_ABORTED_REASON
|
|
980
|
+
? 'aborted'
|
|
981
|
+
: 'not-written');
|
|
982
|
+
if (delivered.acknowledgement === 'delivered' && deliveryCommitted) {
|
|
983
|
+
await reportUnclaimedHandOff(ctx, sessionId, answered, handOff);
|
|
984
|
+
}
|
|
864
985
|
if (delivered.acknowledgement === 'delivered' && !deliveryCommitted) {
|
|
865
986
|
delivered = {
|
|
866
987
|
notes: [
|
|
@@ -911,6 +1032,152 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
|
|
|
911
1032
|
}
|
|
912
1033
|
return outcome;
|
|
913
1034
|
}
|
|
1035
|
+
/**
|
|
1036
|
+
* A restart must never write an answer twice. When this session's delivery
|
|
1037
|
+
* journal shows that an earlier hand-off of any answer in the batch already
|
|
1038
|
+
* began its write — completed (`written`) or possibly (`writing`, `failed`) —
|
|
1039
|
+
* the batch settles without another write. Journal recovery reports the
|
|
1040
|
+
* earlier attempt's outcome; an unconfirmed one is never retried.
|
|
1041
|
+
*/
|
|
1042
|
+
async function suppressWrittenReplay(ctx, sessionId, accepted, notes, cwd) {
|
|
1043
|
+
const requestIds = accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]);
|
|
1044
|
+
const written = answersAlreadyWritten(sessionId, ctx.env, requestIds);
|
|
1045
|
+
if (written.length === 0)
|
|
1046
|
+
return null;
|
|
1047
|
+
if (ctx.answerClaims !== undefined) {
|
|
1048
|
+
try {
|
|
1049
|
+
await recoverDeliveryJournal(answerSequencer(ctx, sessionId, ctx.answerClaims));
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
// Reporting is recovery's to retry; suppression does not depend on it.
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
|
|
1056
|
+
ctx.log?.info('hook.answer', {
|
|
1057
|
+
answered: true,
|
|
1058
|
+
stage: 'replay-suppressed',
|
|
1059
|
+
request_ids: requestIds,
|
|
1060
|
+
journal: written.map((entry) => `${entry.requestId}:${entry.stage}`),
|
|
1061
|
+
});
|
|
1062
|
+
notes.push('an earlier hand-off of this answer already began its write; not writing it again');
|
|
1063
|
+
return { notes, log: { stage: 'replay-suppressed', request_ids: requestIds } };
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* The Answer Edit release rule needs the fenced answer's outcome. A selected
|
|
1067
|
+
* answer written without a claim (the lease moved, the lock stayed busy, the
|
|
1068
|
+
* claim was refused) is recorded after the fact as handed off, so its edits
|
|
1069
|
+
* follow it instead of waiting on an attempt that never existed.
|
|
1070
|
+
*/
|
|
1071
|
+
async function reportUnclaimedHandOff(ctx, sessionId, answered, handOff) {
|
|
1072
|
+
const claimed = new Set((handOff?.claimed ?? []).flatMap((attempt) => attempt.subject.type === 'answer' ? [attempt.subject.request_id] : []));
|
|
1073
|
+
const unclaimed = answered.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined && !claimed.has(pending.request_id)
|
|
1074
|
+
? [pending.request_id]
|
|
1075
|
+
: []);
|
|
1076
|
+
await reportHandedOffAfterTheFact(ctx, sessionId, unclaimed);
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Journal, then record, selected answers already written without a claim. A
|
|
1080
|
+
* record that fails stays in this session's delivery journal, so the next
|
|
1081
|
+
* hand-off here or the machine-wide sweep records it later.
|
|
1082
|
+
*/
|
|
1083
|
+
export async function reportHandedOffAfterTheFact(ctx, sessionId, requestIds) {
|
|
1084
|
+
if (requestIds.length === 0)
|
|
1085
|
+
return;
|
|
1086
|
+
const writer = ctx.answerClaims?.writer ?? currentProcessIdentity();
|
|
1087
|
+
if (writer === null)
|
|
1088
|
+
return;
|
|
1089
|
+
await recordUnclaimedHandOffs({
|
|
1090
|
+
sessionId,
|
|
1091
|
+
env: ctx.env,
|
|
1092
|
+
client: ctx.client,
|
|
1093
|
+
monotonic: ctx.answerClaims?.monotonic ?? (() => performance.now()),
|
|
1094
|
+
wall: ctx.now,
|
|
1095
|
+
sleep: ctx.sleep,
|
|
1096
|
+
writer,
|
|
1097
|
+
...(ctx.log === undefined ? {} : { log: ctx.log }),
|
|
1098
|
+
}, requestIds);
|
|
1099
|
+
}
|
|
1100
|
+
function answerSequencer(ctx, sessionId, claims) {
|
|
1101
|
+
return {
|
|
1102
|
+
sessionId,
|
|
1103
|
+
env: ctx.env,
|
|
1104
|
+
client: ctx.client,
|
|
1105
|
+
monotonic: claims.monotonic,
|
|
1106
|
+
wall: ctx.now,
|
|
1107
|
+
sleep: ctx.sleep,
|
|
1108
|
+
writer: claims.writer,
|
|
1109
|
+
...(ctx.log === undefined ? {} : { log: ctx.log }),
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Selected answers this prompt hands off in its context without a claim; the
|
|
1114
|
+
* ones a claimed write already covered are left to that attempt.
|
|
1115
|
+
*/
|
|
1116
|
+
function unclaimedSelectedAnswers(sessionId, env, answers) {
|
|
1117
|
+
const selected = answers.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined ? [pending.request_id] : []);
|
|
1118
|
+
const covered = new Set(answersAlreadyWritten(sessionId, env, selected).map((entry) => entry.requestId));
|
|
1119
|
+
return selected.filter((requestId) => !covered.has(requestId));
|
|
1120
|
+
}
|
|
1121
|
+
/** The machine-wide journal sweep, from a hook that already holds a client. */
|
|
1122
|
+
async function sweepDeliveryJournalsFor(ctx) {
|
|
1123
|
+
const writer = ctx.answerClaims?.writer ?? currentProcessIdentity();
|
|
1124
|
+
if (writer === null)
|
|
1125
|
+
return;
|
|
1126
|
+
try {
|
|
1127
|
+
await sweepDeliveryJournals({
|
|
1128
|
+
env: ctx.env,
|
|
1129
|
+
client: ctx.client,
|
|
1130
|
+
writer,
|
|
1131
|
+
now: ctx.now(),
|
|
1132
|
+
...(ctx.log === undefined ? {} : { log: ctx.log }),
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
catch {
|
|
1136
|
+
// Housekeeping never fails a hook; the next sweep retries.
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
/** How long a waiter waits for the attendant to re-acquire a moved lease. */
|
|
1140
|
+
const ANSWER_CLAIM_RETRY_MS = 2_000;
|
|
1141
|
+
const ANSWER_CLAIM_ROUNDS = 3;
|
|
1142
|
+
/**
|
|
1143
|
+
* Claim every selected answer of this batch for one in-place write, or null
|
|
1144
|
+
* when nothing may be claimed: no attendant lease, a route that does not write
|
|
1145
|
+
* in place, or a delivery lock another writer kept.
|
|
1146
|
+
*/
|
|
1147
|
+
async function claimAnswerHandOff(ctx, sessionId, route, answered) {
|
|
1148
|
+
const claims = ctx.answerClaims;
|
|
1149
|
+
const subjects = answered.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined
|
|
1150
|
+
? [{ type: 'answer', request_id: pending.request_id }]
|
|
1151
|
+
: []);
|
|
1152
|
+
if (claims === undefined || subjects.length === 0 || !claimableRoute(route))
|
|
1153
|
+
return null;
|
|
1154
|
+
const deps = answerSequencer(ctx, sessionId, claims);
|
|
1155
|
+
for (let round = 1;; round += 1) {
|
|
1156
|
+
const lease = claims.lease();
|
|
1157
|
+
const handOff = lease === null ? null : await beginHandOff(deps, { lease, subjects });
|
|
1158
|
+
if (handOff === null) {
|
|
1159
|
+
ctx.log?.info('delivery.claimed', {
|
|
1160
|
+
subject: 'answer',
|
|
1161
|
+
request_ids: subjects.map((subject) => subject.request_id),
|
|
1162
|
+
claimed: false,
|
|
1163
|
+
reason: lease === null ? 'no-attendant-lease' : 'delivery-lock-busy',
|
|
1164
|
+
});
|
|
1165
|
+
if (lease === null && round < ANSWER_CLAIM_ROUNDS) {
|
|
1166
|
+
await ctx.sleep(ANSWER_CLAIM_RETRY_MS);
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
// A lease that moved between the close and this claim is re-read once the
|
|
1172
|
+
// attendant has had a moment to re-acquire; any other refusal is final.
|
|
1173
|
+
const fenced = handOff.claimed.length === 0 &&
|
|
1174
|
+
handOff.refused.every((refusal) => refusal.reason === 'generation_fenced');
|
|
1175
|
+
if (!fenced || round >= ANSWER_CLAIM_ROUNDS)
|
|
1176
|
+
return handOff;
|
|
1177
|
+
await handOff.finish('not-written');
|
|
1178
|
+
await ctx.sleep(ANSWER_CLAIM_RETRY_MS);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
914
1181
|
/** True while this Stop owner is still allowed a direct-wake wait. */
|
|
915
1182
|
function ownerLeaseActive(entry, now) {
|
|
916
1183
|
return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now;
|
|
@@ -944,7 +1211,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
944
1211
|
await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired');
|
|
945
1212
|
}
|
|
946
1213
|
if (answered.length > 0) {
|
|
947
|
-
const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending));
|
|
1214
|
+
const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending), answerCloseDisposition(ctx, route));
|
|
948
1215
|
const authoritative = answered.map((entry) => {
|
|
949
1216
|
const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
|
|
950
1217
|
const replies = response?.replies.length ? response.replies : entry.replies;
|
|
@@ -959,6 +1226,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
959
1226
|
entry.agent_acknowledgement_required,
|
|
960
1227
|
agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
|
|
961
1228
|
entry.agent_acknowledgement_text_required,
|
|
1229
|
+
...claimMarker(response),
|
|
962
1230
|
};
|
|
963
1231
|
});
|
|
964
1232
|
const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
|
|
@@ -1265,7 +1533,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1265
1533
|
delete live.source;
|
|
1266
1534
|
delete live.submission;
|
|
1267
1535
|
if (live.reply_deadline_at > live.owner_deadline_at) {
|
|
1268
|
-
const response = await finalizeReplies(ctx, live.request_id);
|
|
1536
|
+
const response = await finalizeReplies(ctx, live.request_id, answerCloseDisposition(ctx, route));
|
|
1269
1537
|
if (response === null) {
|
|
1270
1538
|
// Closing was unreachable, so the question is still potentially live.
|
|
1271
1539
|
// Preserve it in the exact session instead of demoting it to the orphan
|
|
@@ -1300,6 +1568,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1300
1568
|
replies: response.replies,
|
|
1301
1569
|
agent_acknowledgement_required: response.agent_acknowledgement_required,
|
|
1302
1570
|
agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
|
|
1571
|
+
...claimMarker(response),
|
|
1303
1572
|
});
|
|
1304
1573
|
}
|
|
1305
1574
|
dropPendingQuestion(sessionId, ctx.env, entry);
|
|
@@ -1354,7 +1623,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1354
1623
|
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
1355
1624
|
}
|
|
1356
1625
|
const staleLive = alreadyLive.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now());
|
|
1357
|
-
const finalizedStale = await finalizePendings(ctx, staleLive);
|
|
1626
|
+
const finalizedStale = await finalizePendings(ctx, staleLive, answerCloseDisposition(ctx, route));
|
|
1358
1627
|
const staleAnswers = finalizedStale
|
|
1359
1628
|
.map(finalizedAnswer)
|
|
1360
1629
|
.filter((entry) => entry !== null);
|
|
@@ -1431,7 +1700,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1431
1700
|
entry.reply_deadline_at <= waitDeadline ||
|
|
1432
1701
|
waited.permanentFailures.has(entry.request_id));
|
|
1433
1702
|
const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry));
|
|
1434
|
-
const finalized = await finalizePendings(ctx, expired);
|
|
1703
|
+
const finalized = await finalizePendings(ctx, expired, answerCloseDisposition(ctx, route));
|
|
1435
1704
|
const finalAnswers = finalized
|
|
1436
1705
|
.map(finalizedAnswer)
|
|
1437
1706
|
.filter((entry) => entry !== null);
|
|
@@ -1476,7 +1745,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1476
1745
|
return { notes };
|
|
1477
1746
|
}
|
|
1478
1747
|
const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id));
|
|
1479
|
-
const finalizedAnswered = await finalizePendings(ctx, polledAnswered);
|
|
1748
|
+
const finalizedAnswered = await finalizePendings(ctx, polledAnswered, answerCloseDisposition(ctx, route));
|
|
1480
1749
|
const answered = [];
|
|
1481
1750
|
for (const finalized of finalizedAnswered) {
|
|
1482
1751
|
const entry = finalized.pending;
|
|
@@ -1494,6 +1763,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1494
1763
|
replies: authoritative,
|
|
1495
1764
|
agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
|
|
1496
1765
|
agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
|
|
1766
|
+
...claimMarker(finalized.response),
|
|
1497
1767
|
});
|
|
1498
1768
|
if (finalized.response === null) {
|
|
1499
1769
|
notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
|
|
@@ -1564,7 +1834,9 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1564
1834
|
orphanRetirements(env, orphans, now);
|
|
1565
1835
|
notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
|
|
1566
1836
|
}
|
|
1567
|
-
if (state.accepted !== undefined ||
|
|
1837
|
+
if (state.accepted !== undefined ||
|
|
1838
|
+
(state.acknowledgement_due?.length ?? 0) > 0 ||
|
|
1839
|
+
(state.message_acknowledgement_due?.length ?? 0) > 0) {
|
|
1568
1840
|
const preserved = { ...stateWithHistory };
|
|
1569
1841
|
delete preserved.pending;
|
|
1570
1842
|
delete preserved.retiring;
|
|
@@ -1572,6 +1844,9 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1572
1844
|
if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
|
|
1573
1845
|
delete preserved.acknowledgement_due;
|
|
1574
1846
|
}
|
|
1847
|
+
if ((preserved.message_acknowledgement_due?.length ?? 0) === 0) {
|
|
1848
|
+
delete preserved.message_acknowledgement_due;
|
|
1849
|
+
}
|
|
1575
1850
|
writeSessionState(sessionId, env, preserved);
|
|
1576
1851
|
notes.push(state.accepted !== undefined
|
|
1577
1852
|
? 'preserved an accepted device answer for this exact session to resume'
|
|
@@ -1583,6 +1858,9 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1583
1858
|
queued_retirements: orphans.length,
|
|
1584
1859
|
accepted_answers: state.accepted?.answers.length ?? 0,
|
|
1585
1860
|
acknowledgement_due: state.acknowledgement_due?.length ?? 0,
|
|
1861
|
+
...(state.message_acknowledgement_due === undefined
|
|
1862
|
+
? {}
|
|
1863
|
+
: { message_acknowledgement_due: state.message_acknowledgement_due.length }),
|
|
1586
1864
|
},
|
|
1587
1865
|
};
|
|
1588
1866
|
}
|