@raidiant/notifai 11.3.2 → 11.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/agent-update-notice.js +3 -3
- package/dist/agent-update-notice.js.map +1 -1
- 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/cli-bin.js +2 -2
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-contract.d.ts +6 -2
- package/dist/cli-contract.js +11 -3
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli-release.d.ts +31 -4
- package/dist/cli-release.js +45 -15
- package/dist/cli-release.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 +10 -1
- package/dist/commands-core.js +4 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +49 -4
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hook-attend.d.ts +61 -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 +59 -2
- package/dist/commands-hook-run.js.map +1 -1
- package/dist/commands-send.js +6 -1
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-update-check.js +9 -6
- package/dist/commands-update-check.js.map +1 -1
- package/dist/commands-update.d.ts +2 -1
- package/dist/commands-update.js +74 -11
- package/dist/commands-update.js.map +1 -1
- package/dist/guidance-content.js +11 -37
- package/dist/guidance-content.js.map +1 -1
- package/dist/hook-acknowledgements.d.ts +25 -5
- package/dist/hook-acknowledgements.js +102 -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 +309 -31
- 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 +50 -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 +17 -6
- 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 +53 -0
- package/dist/session-attendant-state.js +175 -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 +560 -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 +99 -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 +28 -67
- 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/update-handoff.js +3 -3
- package/dist/update-handoff.js.map +1 -1
- package/dist/version.d.ts +18 -0
- package/dist/version.js +82 -1
- package/dist/version.js.map +1 -1
- 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, 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,47 @@ 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
|
+
// Owed Session Message acknowledgements come before any resumed work: a
|
|
403
|
+
// session resumed after SessionEnd has no Stop to remind it first.
|
|
404
|
+
const reminder = { text: await messageAcknowledgementReminder(ctx, sessionId), used: false };
|
|
405
|
+
const outcome = await answerPrompt(ctx, envelope, sessionId, reminder);
|
|
406
|
+
if (reminder.text === null || reminder.used || outcome.stdout !== undefined || sessionHasEnded(sessionId, ctx.env)) {
|
|
407
|
+
return outcome;
|
|
408
|
+
}
|
|
409
|
+
const stdout = userPromptContextOutput(ctx.harness, reminder.text);
|
|
410
|
+
if (stdout === undefined)
|
|
411
|
+
return outcome;
|
|
412
|
+
outcome.notes.push('an owed Session Message acknowledgement was added to the user\'s new turn');
|
|
413
|
+
return {
|
|
414
|
+
...outcome,
|
|
415
|
+
stdout,
|
|
416
|
+
decided: false,
|
|
417
|
+
log: { ...outcome.log, stage: outcome.log?.['stage'] ?? 'acknowledgement-reminded' },
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/** Context for the new turn, carrying the owed-acknowledgement reminder once. */
|
|
421
|
+
function withReminder(reminder, context) {
|
|
422
|
+
if (reminder.text === null)
|
|
423
|
+
return context;
|
|
424
|
+
reminder.used = true;
|
|
425
|
+
return `${context}\n\n${reminder.text}`;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The instruction for Session Message acknowledgements still owed after asking
|
|
429
|
+
* the service, or null when none is.
|
|
430
|
+
*/
|
|
431
|
+
async function messageAcknowledgementReminder(ctx, sessionId) {
|
|
432
|
+
const owed = readSessionState(sessionId, ctx.env).message_acknowledgement_due ?? [];
|
|
433
|
+
if (owed.length === 0)
|
|
434
|
+
return null;
|
|
435
|
+
const still = await reconcileAcknowledgementObligations(ctx, sessionId, owed);
|
|
436
|
+
return still.length === 0 ? null : acknowledgementBlockContext(still);
|
|
437
|
+
}
|
|
438
|
+
async function answerPrompt(ctx, envelope, sessionId, reminder) {
|
|
439
|
+
const notes = [];
|
|
380
440
|
recoverQueuedAnswers(sessionId, ctx.env);
|
|
381
441
|
let state = readSessionState(sessionId, ctx.env);
|
|
382
442
|
if (state.accepted !== undefined || (state.delivered_answers?.length ?? 0) > 0) {
|
|
@@ -393,6 +453,23 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
393
453
|
state = readSessionState(sessionId, ctx.env);
|
|
394
454
|
}
|
|
395
455
|
}
|
|
456
|
+
if (state.accepted !== undefined) {
|
|
457
|
+
// A journaled write of this batch already began (claimed, or recorded
|
|
458
|
+
// after an unclaimed hand-off): adding it to this turn would hand it over
|
|
459
|
+
// twice. It settles instead; its acknowledgement stays owed.
|
|
460
|
+
const written = answersAlreadyWritten(sessionId, ctx.env, state.accepted.answers.flatMap(({ pending }) => (pending.request_id === undefined ? [] : [pending.request_id])));
|
|
461
|
+
if (written.length > 0) {
|
|
462
|
+
settleAcceptedAnswers(ctx, sessionId, state.accepted, envelope.cwd);
|
|
463
|
+
ctx.log?.info('hook.answer', {
|
|
464
|
+
answered: true,
|
|
465
|
+
stage: 'replay-suppressed',
|
|
466
|
+
route: 'user-prompt-submit',
|
|
467
|
+
journal: written.map((entry) => `${entry.requestId}:${entry.stage}`),
|
|
468
|
+
});
|
|
469
|
+
notes.push('an earlier hand-off of the journaled answer already began its write; not adding it to this turn');
|
|
470
|
+
state = readSessionState(sessionId, ctx.env);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
396
473
|
if (state.accepted !== undefined) {
|
|
397
474
|
const answers = [
|
|
398
475
|
...(state.delivered_answers ?? []),
|
|
@@ -406,13 +483,17 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
406
483
|
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
407
484
|
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
|
|
408
485
|
}
|
|
409
|
-
const stdout = userPromptContextOutput(ctx.harness, answersContext(answers, state.accepted.remaining));
|
|
486
|
+
const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext(answers, state.accepted.remaining)));
|
|
410
487
|
if (stdout !== undefined) {
|
|
411
488
|
notes.push('the journaled device answer was added to the user\'s new turn');
|
|
489
|
+
const selected = unclaimedSelectedAnswers(sessionId, ctx.env, answers);
|
|
412
490
|
return {
|
|
413
491
|
stdout,
|
|
414
492
|
decided: false,
|
|
415
493
|
notes,
|
|
494
|
+
...(selected.length === 0
|
|
495
|
+
? {}
|
|
496
|
+
: { afterOutput: () => reportHandedOffAfterTheFact(ctx, sessionId, selected) }),
|
|
416
497
|
log: {
|
|
417
498
|
stage: 'context-added',
|
|
418
499
|
route: 'user-prompt-submit',
|
|
@@ -518,7 +599,7 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
518
599
|
// UserPromptSubmit can add context to the turn already beginning. Keep the
|
|
519
600
|
// accepted-answer journal until the agent acknowledges: if this process or
|
|
520
601
|
// 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));
|
|
602
|
+
const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext([...(readSessionState(sessionId, ctx.env).delivered_answers ?? []), ...lateAnswers], pendingList(updated).length)));
|
|
522
603
|
if (stdout !== undefined)
|
|
523
604
|
notes.push('the late device answer was added to the user\'s new turn');
|
|
524
605
|
else
|
|
@@ -541,13 +622,14 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
541
622
|
}
|
|
542
623
|
const retired = await drainRetirements(ctx, sessionId, ctx.env);
|
|
543
624
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now());
|
|
625
|
+
await sweepDeliveryJournalsFor(ctx);
|
|
544
626
|
const swept = [...retired, ...orphaned];
|
|
545
627
|
if (swept.length > 0) {
|
|
546
628
|
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
547
629
|
}
|
|
548
630
|
const delivered = readSessionState(sessionId, ctx.env).delivered_answers ?? [];
|
|
549
631
|
const stdout = delivered.length > 0
|
|
550
|
-
? userPromptContextOutput(ctx.harness, answersContext(delivered, pendingList(updated).length))
|
|
632
|
+
? userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext(delivered, pendingList(updated).length)))
|
|
551
633
|
: undefined;
|
|
552
634
|
return {
|
|
553
635
|
notes,
|
|
@@ -702,9 +784,9 @@ export async function runEscalationWaiter(ctx, options) {
|
|
|
702
784
|
return held;
|
|
703
785
|
state = readSessionState(sessionId, ctx.env);
|
|
704
786
|
}
|
|
705
|
-
if (state.accepted === undefined && (state.
|
|
787
|
+
if (state.accepted === undefined && owedAcknowledgements(state).length > 0 &&
|
|
706
788
|
!(options.recordStop === false && options.route.kind === 'session-queue')) {
|
|
707
|
-
const due = await reconcileAcknowledgementObligations(ctx, sessionId, state
|
|
789
|
+
const due = await reconcileAcknowledgementObligations(ctx, sessionId, owedAcknowledgements(state));
|
|
708
790
|
const held = holdForAcknowledgement(ctx, sessionId, due, notes);
|
|
709
791
|
if (held !== null)
|
|
710
792
|
return held;
|
|
@@ -717,6 +799,7 @@ export async function runEscalationWaiter(ctx, options) {
|
|
|
717
799
|
...(await drainRetirements(ctx, sessionId, ctx.env)),
|
|
718
800
|
...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
|
|
719
801
|
];
|
|
802
|
+
await sweepDeliveryJournalsFor(ctx);
|
|
720
803
|
if (swept.length > 0) {
|
|
721
804
|
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
722
805
|
}
|
|
@@ -727,7 +810,7 @@ export async function runEscalationWaiter(ctx, options) {
|
|
|
727
810
|
// Every route's successor turn-end, named the same way: the harness flag
|
|
728
811
|
// for a blocking continuation, and the settle above for a route that woke
|
|
729
812
|
// a brand-new turn out of band.
|
|
730
|
-
envelope.stop_hook_active === true || settledAnswerThisPass);
|
|
813
|
+
envelope.stop_hook_active === true || settledAnswerThisPass, options.recordStop !== false && ctx.harness === 'codex' && options.route.kind === 'session-queue');
|
|
731
814
|
}
|
|
732
815
|
finally {
|
|
733
816
|
releaseQuestionPush(sessionId, ctx.env);
|
|
@@ -785,6 +868,9 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
|
|
|
785
868
|
notes.push('the Agent Session ended before answer delivery; stopping this observer');
|
|
786
869
|
return { notes };
|
|
787
870
|
}
|
|
871
|
+
const replayed = await suppressWrittenReplay(ctx, sessionId, accepted, notes, cwd);
|
|
872
|
+
if (replayed !== null)
|
|
873
|
+
return replayed;
|
|
788
874
|
const held = accepted.held_deliveries ?? 0;
|
|
789
875
|
if (held >= MAX_HELD_DELIVERIES) {
|
|
790
876
|
gate(ctx, 'held', 'delivery-limit', {
|
|
@@ -853,14 +939,42 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
|
|
|
853
939
|
return true;
|
|
854
940
|
});
|
|
855
941
|
};
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
942
|
+
// Answers a `deliver` close selected are claimed first, under this session's
|
|
943
|
+
// delivery lock, so an Answer Edit can only follow the write. A claim that
|
|
944
|
+
// cannot be had never withholds the answer: it is written unclaimed, as
|
|
945
|
+
// released CLIs write it, and its edits simply wait.
|
|
946
|
+
const handOff = await claimAnswerHandOff(ctx, sessionId, route, answered);
|
|
947
|
+
let delivered;
|
|
948
|
+
try {
|
|
949
|
+
delivered = await route.deliver({
|
|
950
|
+
context: answersContext(answered, remaining),
|
|
951
|
+
answers: answered.length,
|
|
952
|
+
remaining,
|
|
953
|
+
request_ids: requestIds,
|
|
954
|
+
journal_recorded_at: accepted.recorded_at,
|
|
955
|
+
commitDelivery: handOff === null
|
|
956
|
+
? commitDelivery
|
|
957
|
+
: (writer) => handOff.begin(commitDelivery, { subprocess: writer === 'subprocess' }),
|
|
958
|
+
...(handOff === null || handOff.claimed.length === 0
|
|
959
|
+
? {}
|
|
960
|
+
: {
|
|
961
|
+
writeGuard: { writable: () => handOff.writable(), remainingMs: () => handOff.remainingMs() },
|
|
962
|
+
writerGroup: (pgid) => handOff.recordGroup(pgid),
|
|
963
|
+
}),
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
catch (err) {
|
|
967
|
+
await handOff?.finish('failed');
|
|
968
|
+
throw err;
|
|
969
|
+
}
|
|
970
|
+
await handOff?.finish(delivered.acknowledgement === 'delivered'
|
|
971
|
+
? 'written'
|
|
972
|
+
: delivered.log?.['reason'] === WRITE_ABORTED_REASON
|
|
973
|
+
? 'aborted'
|
|
974
|
+
: 'not-written');
|
|
975
|
+
if (delivered.acknowledgement === 'delivered' && deliveryCommitted) {
|
|
976
|
+
await reportUnclaimedHandOff(ctx, sessionId, answered, handOff);
|
|
977
|
+
}
|
|
864
978
|
if (delivered.acknowledgement === 'delivered' && !deliveryCommitted) {
|
|
865
979
|
delivered = {
|
|
866
980
|
notes: [
|
|
@@ -911,12 +1025,158 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
|
|
|
911
1025
|
}
|
|
912
1026
|
return outcome;
|
|
913
1027
|
}
|
|
1028
|
+
/**
|
|
1029
|
+
* A restart must never write an answer twice. When this session's delivery
|
|
1030
|
+
* journal shows that an earlier hand-off of any answer in the batch already
|
|
1031
|
+
* began its write — completed (`written`) or possibly (`writing`, `failed`) —
|
|
1032
|
+
* the batch settles without another write. Journal recovery reports the
|
|
1033
|
+
* earlier attempt's outcome; an unconfirmed one is never retried.
|
|
1034
|
+
*/
|
|
1035
|
+
async function suppressWrittenReplay(ctx, sessionId, accepted, notes, cwd) {
|
|
1036
|
+
const requestIds = accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]);
|
|
1037
|
+
const written = answersAlreadyWritten(sessionId, ctx.env, requestIds);
|
|
1038
|
+
if (written.length === 0)
|
|
1039
|
+
return null;
|
|
1040
|
+
if (ctx.answerClaims !== undefined) {
|
|
1041
|
+
try {
|
|
1042
|
+
await recoverDeliveryJournal(answerSequencer(ctx, sessionId, ctx.answerClaims));
|
|
1043
|
+
}
|
|
1044
|
+
catch {
|
|
1045
|
+
// Reporting is recovery's to retry; suppression does not depend on it.
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
|
|
1049
|
+
ctx.log?.info('hook.answer', {
|
|
1050
|
+
answered: true,
|
|
1051
|
+
stage: 'replay-suppressed',
|
|
1052
|
+
request_ids: requestIds,
|
|
1053
|
+
journal: written.map((entry) => `${entry.requestId}:${entry.stage}`),
|
|
1054
|
+
});
|
|
1055
|
+
notes.push('an earlier hand-off of this answer already began its write; not writing it again');
|
|
1056
|
+
return { notes, log: { stage: 'replay-suppressed', request_ids: requestIds } };
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* The Answer Edit release rule needs the fenced answer's outcome. A selected
|
|
1060
|
+
* answer written without a claim (the lease moved, the lock stayed busy, the
|
|
1061
|
+
* claim was refused) is recorded after the fact as handed off, so its edits
|
|
1062
|
+
* follow it instead of waiting on an attempt that never existed.
|
|
1063
|
+
*/
|
|
1064
|
+
async function reportUnclaimedHandOff(ctx, sessionId, answered, handOff) {
|
|
1065
|
+
const claimed = new Set((handOff?.claimed ?? []).flatMap((attempt) => attempt.subject.type === 'answer' ? [attempt.subject.request_id] : []));
|
|
1066
|
+
const unclaimed = answered.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined && !claimed.has(pending.request_id)
|
|
1067
|
+
? [pending.request_id]
|
|
1068
|
+
: []);
|
|
1069
|
+
await reportHandedOffAfterTheFact(ctx, sessionId, unclaimed);
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Journal, then record, selected answers already written without a claim. A
|
|
1073
|
+
* record that fails stays in this session's delivery journal, so the next
|
|
1074
|
+
* hand-off here or the machine-wide sweep records it later.
|
|
1075
|
+
*/
|
|
1076
|
+
export async function reportHandedOffAfterTheFact(ctx, sessionId, requestIds) {
|
|
1077
|
+
if (requestIds.length === 0)
|
|
1078
|
+
return;
|
|
1079
|
+
const writer = ctx.answerClaims?.writer ?? currentProcessIdentity();
|
|
1080
|
+
if (writer === null)
|
|
1081
|
+
return;
|
|
1082
|
+
await recordUnclaimedHandOffs({
|
|
1083
|
+
sessionId,
|
|
1084
|
+
env: ctx.env,
|
|
1085
|
+
client: ctx.client,
|
|
1086
|
+
monotonic: ctx.answerClaims?.monotonic ?? (() => performance.now()),
|
|
1087
|
+
wall: ctx.now,
|
|
1088
|
+
sleep: ctx.sleep,
|
|
1089
|
+
writer,
|
|
1090
|
+
...(ctx.log === undefined ? {} : { log: ctx.log }),
|
|
1091
|
+
}, requestIds);
|
|
1092
|
+
}
|
|
1093
|
+
function answerSequencer(ctx, sessionId, claims) {
|
|
1094
|
+
return {
|
|
1095
|
+
sessionId,
|
|
1096
|
+
env: ctx.env,
|
|
1097
|
+
client: ctx.client,
|
|
1098
|
+
monotonic: claims.monotonic,
|
|
1099
|
+
wall: ctx.now,
|
|
1100
|
+
sleep: ctx.sleep,
|
|
1101
|
+
writer: claims.writer,
|
|
1102
|
+
...(ctx.log === undefined ? {} : { log: ctx.log }),
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Selected answers this prompt hands off in its context without a claim; the
|
|
1107
|
+
* ones a claimed write already covered are left to that attempt.
|
|
1108
|
+
*/
|
|
1109
|
+
function unclaimedSelectedAnswers(sessionId, env, answers) {
|
|
1110
|
+
const selected = answers.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined ? [pending.request_id] : []);
|
|
1111
|
+
const covered = new Set(answersAlreadyWritten(sessionId, env, selected).map((entry) => entry.requestId));
|
|
1112
|
+
return selected.filter((requestId) => !covered.has(requestId));
|
|
1113
|
+
}
|
|
1114
|
+
/** The machine-wide journal sweep, from a hook that already holds a client. */
|
|
1115
|
+
async function sweepDeliveryJournalsFor(ctx) {
|
|
1116
|
+
const writer = ctx.answerClaims?.writer ?? currentProcessIdentity();
|
|
1117
|
+
if (writer === null)
|
|
1118
|
+
return;
|
|
1119
|
+
try {
|
|
1120
|
+
await sweepDeliveryJournals({
|
|
1121
|
+
env: ctx.env,
|
|
1122
|
+
client: ctx.client,
|
|
1123
|
+
writer,
|
|
1124
|
+
now: ctx.now(),
|
|
1125
|
+
...(ctx.log === undefined ? {} : { log: ctx.log }),
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
// Housekeeping never fails a hook; the next sweep retries.
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
/** How long a waiter waits for the attendant to re-acquire a moved lease. */
|
|
1133
|
+
const ANSWER_CLAIM_RETRY_MS = 2_000;
|
|
1134
|
+
const ANSWER_CLAIM_ROUNDS = 3;
|
|
1135
|
+
/**
|
|
1136
|
+
* Claim every selected answer of this batch for one in-place write, or null
|
|
1137
|
+
* when nothing may be claimed: no attendant lease, a route that does not write
|
|
1138
|
+
* in place, or a delivery lock another writer kept.
|
|
1139
|
+
*/
|
|
1140
|
+
async function claimAnswerHandOff(ctx, sessionId, route, answered) {
|
|
1141
|
+
const claims = ctx.answerClaims;
|
|
1142
|
+
const subjects = answered.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined
|
|
1143
|
+
? [{ type: 'answer', request_id: pending.request_id }]
|
|
1144
|
+
: []);
|
|
1145
|
+
if (claims === undefined || subjects.length === 0 || !claimableRoute(route))
|
|
1146
|
+
return null;
|
|
1147
|
+
const deps = answerSequencer(ctx, sessionId, claims);
|
|
1148
|
+
for (let round = 1;; round += 1) {
|
|
1149
|
+
const lease = claims.lease();
|
|
1150
|
+
const handOff = lease === null ? null : await beginHandOff(deps, { lease, subjects });
|
|
1151
|
+
if (handOff === null) {
|
|
1152
|
+
ctx.log?.info('delivery.claimed', {
|
|
1153
|
+
subject: 'answer',
|
|
1154
|
+
request_ids: subjects.map((subject) => subject.request_id),
|
|
1155
|
+
claimed: false,
|
|
1156
|
+
reason: lease === null ? 'no-attendant-lease' : 'delivery-lock-busy',
|
|
1157
|
+
});
|
|
1158
|
+
if (lease === null && round < ANSWER_CLAIM_ROUNDS) {
|
|
1159
|
+
await ctx.sleep(ANSWER_CLAIM_RETRY_MS);
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1164
|
+
// A lease that moved between the close and this claim is re-read once the
|
|
1165
|
+
// attendant has had a moment to re-acquire; any other refusal is final.
|
|
1166
|
+
const fenced = handOff.claimed.length === 0 &&
|
|
1167
|
+
handOff.refused.every((refusal) => refusal.reason === 'generation_fenced');
|
|
1168
|
+
if (!fenced || round >= ANSWER_CLAIM_ROUNDS)
|
|
1169
|
+
return handOff;
|
|
1170
|
+
await handOff.finish('not-written');
|
|
1171
|
+
await ctx.sleep(ANSWER_CLAIM_RETRY_MS);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
914
1174
|
/** True while this Stop owner is still allowed a direct-wake wait. */
|
|
915
1175
|
function ownerLeaseActive(entry, now) {
|
|
916
1176
|
return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now;
|
|
917
1177
|
}
|
|
918
1178
|
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
919
|
-
async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, route, continuingFromAnswer) {
|
|
1179
|
+
async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, route, continuingFromAnswer, handoffToDetachedOwner) {
|
|
920
1180
|
const live = pending.filter((entry) => entry.request_id !== undefined);
|
|
921
1181
|
const unasked = pending.filter((entry) => entry.request_id === undefined);
|
|
922
1182
|
let liveToEscalate = live;
|
|
@@ -944,7 +1204,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
944
1204
|
await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired');
|
|
945
1205
|
}
|
|
946
1206
|
if (answered.length > 0) {
|
|
947
|
-
const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending));
|
|
1207
|
+
const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending), answerCloseDisposition(ctx, route));
|
|
948
1208
|
const authoritative = answered.map((entry) => {
|
|
949
1209
|
const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
|
|
950
1210
|
const replies = response?.replies.length ? response.replies : entry.replies;
|
|
@@ -959,6 +1219,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
959
1219
|
entry.agent_acknowledgement_required,
|
|
960
1220
|
agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
|
|
961
1221
|
entry.agent_acknowledgement_text_required,
|
|
1222
|
+
...claimMarker(response),
|
|
962
1223
|
};
|
|
963
1224
|
});
|
|
964
1225
|
const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
|
|
@@ -989,7 +1250,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
989
1250
|
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`);
|
|
990
1251
|
return { notes };
|
|
991
1252
|
}
|
|
992
|
-
return await escalate(ctx, envelope, sessionId, [], stillOwned, notes, hardDeadlineAt, route);
|
|
1253
|
+
return await escalate(ctx, envelope, sessionId, [], stillOwned, notes, hardDeadlineAt, route, handoffToDetachedOwner);
|
|
993
1254
|
}
|
|
994
1255
|
// Questions registered after the earlier push still owe the user their
|
|
995
1256
|
// notification; fall through and escalate just those, plus any live
|
|
@@ -1037,10 +1298,10 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1037
1298
|
already_live: liveToEscalate.length,
|
|
1038
1299
|
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
1039
1300
|
});
|
|
1040
|
-
return await escalate(ctx, envelope, sessionId, unasked, liveToEscalate, notes, hardDeadlineAt, route);
|
|
1301
|
+
return await escalate(ctx, envelope, sessionId, unasked, liveToEscalate, notes, hardDeadlineAt, route, handoffToDetachedOwner);
|
|
1041
1302
|
}
|
|
1042
1303
|
/** The escalation itself, split out so the claim is released on every path. */
|
|
1043
|
-
async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, hardDeadlineAt, route) {
|
|
1304
|
+
async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, hardDeadlineAt, route, handoffToDetachedOwner) {
|
|
1044
1305
|
// The questions still owe the user their terminal-first window before
|
|
1045
1306
|
// anything reaches their devices — measured from the oldest registration,
|
|
1046
1307
|
// because that is the question that has waited longest.
|
|
@@ -1265,7 +1526,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1265
1526
|
delete live.source;
|
|
1266
1527
|
delete live.submission;
|
|
1267
1528
|
if (live.reply_deadline_at > live.owner_deadline_at) {
|
|
1268
|
-
const response = await finalizeReplies(ctx, live.request_id);
|
|
1529
|
+
const response = await finalizeReplies(ctx, live.request_id, answerCloseDisposition(ctx, route));
|
|
1269
1530
|
if (response === null) {
|
|
1270
1531
|
// Closing was unreachable, so the question is still potentially live.
|
|
1271
1532
|
// Preserve it in the exact session instead of demoting it to the orphan
|
|
@@ -1300,6 +1561,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1300
1561
|
replies: response.replies,
|
|
1301
1562
|
agent_acknowledgement_required: response.agent_acknowledgement_required,
|
|
1302
1563
|
agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
|
|
1564
|
+
...claimMarker(response),
|
|
1303
1565
|
});
|
|
1304
1566
|
}
|
|
1305
1567
|
dropPendingQuestion(sessionId, ctx.env, entry);
|
|
@@ -1354,7 +1616,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1354
1616
|
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
1355
1617
|
}
|
|
1356
1618
|
const staleLive = alreadyLive.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now());
|
|
1357
|
-
const finalizedStale = await finalizePendings(ctx, staleLive);
|
|
1619
|
+
const finalizedStale = await finalizePendings(ctx, staleLive, answerCloseDisposition(ctx, route));
|
|
1358
1620
|
const staleAnswers = finalizedStale
|
|
1359
1621
|
.map(finalizedAnswer)
|
|
1360
1622
|
.filter((entry) => entry !== null);
|
|
@@ -1382,6 +1644,13 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1382
1644
|
];
|
|
1383
1645
|
if (waitingOn.length === 0)
|
|
1384
1646
|
return { notes };
|
|
1647
|
+
// Codex has a durable exact-thread queue route. Release the short Stop hook
|
|
1648
|
+
// after admission so a detached owner, independent of the TUI process, can
|
|
1649
|
+
// observe the entire committed answer window. The owner lease and pending
|
|
1650
|
+
// request identities remain in session state for that successor.
|
|
1651
|
+
if (handoffToDetachedOwner) {
|
|
1652
|
+
return { notes, settlementRequired: true };
|
|
1653
|
+
}
|
|
1385
1654
|
// Phase two: keep the same owner alive across every committed answer window.
|
|
1386
1655
|
// Different questions may expire at different times, so finalize each one at
|
|
1387
1656
|
// its own server deadline and continue waiting on the rest. Reserving time by
|
|
@@ -1424,7 +1693,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1424
1693
|
entry.reply_deadline_at <= waitDeadline ||
|
|
1425
1694
|
waited.permanentFailures.has(entry.request_id));
|
|
1426
1695
|
const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry));
|
|
1427
|
-
const finalized = await finalizePendings(ctx, expired);
|
|
1696
|
+
const finalized = await finalizePendings(ctx, expired, answerCloseDisposition(ctx, route));
|
|
1428
1697
|
const finalAnswers = finalized
|
|
1429
1698
|
.map(finalizedAnswer)
|
|
1430
1699
|
.filter((entry) => entry !== null);
|
|
@@ -1469,7 +1738,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1469
1738
|
return { notes };
|
|
1470
1739
|
}
|
|
1471
1740
|
const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id));
|
|
1472
|
-
const finalizedAnswered = await finalizePendings(ctx, polledAnswered);
|
|
1741
|
+
const finalizedAnswered = await finalizePendings(ctx, polledAnswered, answerCloseDisposition(ctx, route));
|
|
1473
1742
|
const answered = [];
|
|
1474
1743
|
for (const finalized of finalizedAnswered) {
|
|
1475
1744
|
const entry = finalized.pending;
|
|
@@ -1487,6 +1756,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1487
1756
|
replies: authoritative,
|
|
1488
1757
|
agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
|
|
1489
1758
|
agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
|
|
1759
|
+
...claimMarker(finalized.response),
|
|
1490
1760
|
});
|
|
1491
1761
|
if (finalized.response === null) {
|
|
1492
1762
|
notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
|
|
@@ -1557,7 +1827,9 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1557
1827
|
orphanRetirements(env, orphans, now);
|
|
1558
1828
|
notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
|
|
1559
1829
|
}
|
|
1560
|
-
if (state.accepted !== undefined ||
|
|
1830
|
+
if (state.accepted !== undefined ||
|
|
1831
|
+
(state.acknowledgement_due?.length ?? 0) > 0 ||
|
|
1832
|
+
(state.message_acknowledgement_due?.length ?? 0) > 0) {
|
|
1561
1833
|
const preserved = { ...stateWithHistory };
|
|
1562
1834
|
delete preserved.pending;
|
|
1563
1835
|
delete preserved.retiring;
|
|
@@ -1565,6 +1837,9 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1565
1837
|
if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
|
|
1566
1838
|
delete preserved.acknowledgement_due;
|
|
1567
1839
|
}
|
|
1840
|
+
if ((preserved.message_acknowledgement_due?.length ?? 0) === 0) {
|
|
1841
|
+
delete preserved.message_acknowledgement_due;
|
|
1842
|
+
}
|
|
1568
1843
|
writeSessionState(sessionId, env, preserved);
|
|
1569
1844
|
notes.push(state.accepted !== undefined
|
|
1570
1845
|
? 'preserved an accepted device answer for this exact session to resume'
|
|
@@ -1576,6 +1851,9 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1576
1851
|
queued_retirements: orphans.length,
|
|
1577
1852
|
accepted_answers: state.accepted?.answers.length ?? 0,
|
|
1578
1853
|
acknowledgement_due: state.acknowledgement_due?.length ?? 0,
|
|
1854
|
+
...(state.message_acknowledgement_due === undefined
|
|
1855
|
+
? {}
|
|
1856
|
+
: { message_acknowledgement_due: state.message_acknowledgement_due.length }),
|
|
1579
1857
|
},
|
|
1580
1858
|
};
|
|
1581
1859
|
}
|