@raidiant/notifai 9.2.1 → 9.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +8 -5
- package/dist/claude-wake.js +11 -0
- package/dist/claude-wake.js.map +1 -1
- package/dist/codex-wake.js +9 -0
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-core.d.ts +0 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +41 -2
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +2 -2
- package/dist/commands-harness-context.js +1 -1
- package/dist/commands-hook-shape.d.ts +4 -3
- package/dist/commands-hook-shape.js +19 -10
- package/dist/commands-hook-shape.js.map +1 -1
- package/dist/commands-hooks.js +66 -15
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +12 -1
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-project.d.ts +4 -0
- package/dist/commands-project.js +41 -0
- package/dist/commands-project.js.map +1 -0
- package/dist/commands-send-support.js +1 -22
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +11 -5
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +58 -0
- package/dist/commands-skill.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/config.d.ts +8 -8
- package/dist/config.js +6 -9
- package/dist/config.js.map +1 -1
- package/dist/guidance-content.d.ts +3 -0
- package/dist/guidance-content.js +20 -0
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.js +3 -3
- package/dist/guidance-render.js.map +1 -1
- package/dist/harnesses.js +2 -2
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +26 -50
- package/dist/hooks.js +391 -150
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +7 -28
- package/dist/install-hooks.js +18 -37
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -1
- package/dist/invocation-context.js +2 -2
- package/dist/invocation-context.js.map +1 -1
- package/dist/program.d.ts +4 -1
- package/dist/program.js +33 -15
- package/dist/program.js.map +1 -1
- package/dist/project-enablement.d.ts +15 -0
- package/dist/project-enablement.js +87 -0
- package/dist/project-enablement.js.map +1 -0
- package/dist/question-timing.d.ts +46 -0
- package/dist/question-timing.js +48 -0
- package/dist/question-timing.js.map +1 -0
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +4 -2
- package/dist/send.js +6 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +1 -1
- package/dist/session-activation.js +3 -4
- package/dist/session-activation.js.map +1 -1
- package/package.json +2 -2
- package/src/claude-wake.test.ts +1 -0
- package/src/claude-wake.ts +10 -0
- package/src/codex-wake.test.ts +1 -0
- package/src/codex-wake.ts +9 -0
- package/src/commands-core.ts +0 -3
- package/src/commands-doctor.ts +48 -4
- package/src/commands-harness-context.ts +2 -2
- package/src/commands-hook-shape.ts +26 -10
- package/src/commands-hooks.ts +62 -16
- package/src/commands-init.ts +11 -1
- package/src/commands-project.ts +47 -0
- package/src/commands-send-support.ts +1 -27
- package/src/commands-send.ts +9 -4
- package/src/commands-skill.test.ts +44 -0
- package/src/commands-skill.ts +57 -0
- package/src/commands.test.ts +47 -17
- package/src/commands.ts +5 -0
- package/src/config-schema.ts +2 -2
- package/src/config.test.ts +2 -3
- package/src/config.ts +12 -15
- package/src/guidance-content.ts +22 -0
- package/src/guidance-render.ts +3 -3
- package/src/harnesses.ts +2 -2
- package/src/hook-adapter.test.ts +27 -8
- package/src/hooks.test.ts +808 -80
- package/src/hooks.ts +525 -181
- package/src/install-hooks.test.ts +8 -12
- package/src/install-hooks.ts +21 -37
- package/src/invocation-context.test.ts +1 -1
- package/src/invocation-context.ts +2 -2
- package/src/program.test.ts +18 -0
- package/src/program.ts +36 -14
- package/src/project-enablement.test.ts +55 -0
- package/src/project-enablement.ts +99 -0
- package/src/question-timing.ts +56 -0
- package/src/readiness.ts +8 -1
- package/src/send.ts +10 -3
- package/src/session-activation.ts +3 -4
- package/src/skill.test.ts +2 -2
- package/dist/orca-session-title.d.ts +0 -10
- package/dist/orca-session-title.js +0 -99
- package/dist/orca-session-title.js.map +0 -1
- package/src/orca-session-title.test.ts +0 -95
- package/src/orca-session-title.ts +0 -105
package/dist/hooks.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol';
|
|
5
|
+
import { LEGACY_QUESTION_CLAIM_TTL_SECONDS, QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS, QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS, QUESTION_WAITER_CEILING_SECONDS, } from './question-timing.js';
|
|
5
6
|
import { ApiCallError, isRetryableReplyPollError, } from './client.js';
|
|
6
7
|
import { loadConfig, projectSessionPointerPath, sanitizeSessionId, sessionConfigPath, stateDir, } from './config.js';
|
|
7
8
|
import { buildDraft } from './send.js';
|
|
@@ -37,50 +38,12 @@ function summarizeRequestIds(entries) {
|
|
|
37
38
|
const ids = entries.flatMap((entry) => entry.request_id === undefined ? [] : [entry.request_id]);
|
|
38
39
|
return { ids, display: ids.join(', ') };
|
|
39
40
|
}
|
|
40
|
-
/**
|
|
41
|
-
* The waiter's whole wall clock: the terminal-first timer, submission, and the
|
|
42
|
-
* wait for an answer, from the moment the turn-end hook starts.
|
|
43
|
-
*
|
|
44
|
-
* One plain ceiling, deliberately not a budget carved into reserves. The
|
|
45
|
-
* reserves existed to fit every phase inside a *held* turn; on Claude Code the
|
|
46
|
-
* turn is not held any more, and on a host that still holds one the useful
|
|
47
|
-
* question is only how long that host is willing to wait in total.
|
|
48
|
-
*/
|
|
49
|
-
export const WAITER_CEILING_SECONDS = 480;
|
|
50
|
-
/**
|
|
51
|
-
* The same wall clock where the waiter runs in the background instead of
|
|
52
|
-
* inside a held turn.
|
|
53
|
-
*
|
|
54
|
-
* On a host that holds its turn, 480 s is a real cost to the person sitting in
|
|
55
|
-
* front of it, and the number above is right. On Claude Code the Stop hook
|
|
56
|
-
* returns at once and the waiter is detached, so the same 480 s buys nothing
|
|
57
|
-
* and costs a direct wake: an answer arriving at minute nine had to wait for
|
|
58
|
-
* the session's next turn even though nothing was blocked.
|
|
59
|
-
*
|
|
60
|
-
* This sits under `CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS` (3600) with headroom,
|
|
61
|
-
* because that declaration's kill is silent, and under the reply window the
|
|
62
|
-
* question was pushed with. Past it the journal still delivers at the next
|
|
63
|
-
* turn — the ceiling decides how long a *direct* wake stays possible, never
|
|
64
|
-
* whether the answer survives.
|
|
65
|
-
*/
|
|
66
|
-
export const DETACHED_WAITER_CEILING_SECONDS = 3300;
|
|
67
|
-
/** The waiter's wall clock, which depends on whether a turn is held open for it. */
|
|
68
|
-
export function waiterCeilingSeconds(detached) {
|
|
69
|
-
return detached ? DETACHED_WAITER_CEILING_SECONDS : WAITER_CEILING_SECONDS;
|
|
70
|
-
}
|
|
71
41
|
/**
|
|
72
42
|
* The wire contract's shortest reply window. A question submitted with less
|
|
73
43
|
* than this is rejected outright, and accepting one would in any case let the
|
|
74
44
|
* server go on taking an answer after the waiter that owns it has returned.
|
|
75
45
|
*/
|
|
76
46
|
export const MIN_REPLY_WINDOW_SECONDS = 60;
|
|
77
|
-
/**
|
|
78
|
-
* HTTP budget reserved for the waiter's final reply fetch and close fence.
|
|
79
|
-
* Taken from the owner deadline rather than added after it, so the hook does
|
|
80
|
-
* not block past its ceiling. A near-zero remainder used to produce a
|
|
81
|
-
* misleading 0s timeout and an unproven retirement.
|
|
82
|
-
*/
|
|
83
|
-
export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3;
|
|
84
47
|
/**
|
|
85
48
|
* The terminal-first wait: the question sits in the terminal for
|
|
86
49
|
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
@@ -89,8 +52,9 @@ export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3;
|
|
|
89
52
|
* A plain timer, and nothing else. It once polled an idle signal so it could
|
|
90
53
|
* abandon the wait the moment the user touched the keyboard — necessary while
|
|
91
54
|
* the wait blocked the terminal, because a user wanting to answer locally was
|
|
92
|
-
* otherwise locked out of their own prompt.
|
|
93
|
-
*
|
|
55
|
+
* otherwise locked out of their own prompt. Presence no longer changes this
|
|
56
|
+
* deterministic timer; whether the harness holds or wakes is a separate route
|
|
57
|
+
* decision.
|
|
94
58
|
*
|
|
95
59
|
* The window is measured from registration, not from the turn's end: a
|
|
96
60
|
* question the agent asked five minutes ago while it kept working has already
|
|
@@ -109,6 +73,33 @@ async function awaitTerminalFirstWindow(ctx, askedAt, ceilingAt) {
|
|
|
109
73
|
function sessionStatePath(sessionId, env) {
|
|
110
74
|
return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`);
|
|
111
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Durable cancellation for observers that can outlive the harness process.
|
|
78
|
+
*
|
|
79
|
+
* Absence of session state is not enough: SessionEnd deliberately deletes it,
|
|
80
|
+
* and an in-flight submit/fence could otherwise recreate it afterwards. The
|
|
81
|
+
* marker shares the session-state lock, giving every racing writer one total
|
|
82
|
+
* order: either its state lands before SessionEnd and is cleaned up there, or
|
|
83
|
+
* it observes this marker and must not write or deliver into the ended session.
|
|
84
|
+
*/
|
|
85
|
+
function sessionEndMarkerPath(sessionId, env) {
|
|
86
|
+
return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.ended`);
|
|
87
|
+
}
|
|
88
|
+
function sessionHasEnded(sessionId, env) {
|
|
89
|
+
return existsSync(sessionEndMarkerPath(sessionId, env));
|
|
90
|
+
}
|
|
91
|
+
function markSessionEnded(sessionId, env, now) {
|
|
92
|
+
const stateFile = sessionStatePath(sessionId, env);
|
|
93
|
+
withFileLock(`${stateFile}.lock`, () => {
|
|
94
|
+
atomicWriteFileSync(sessionEndMarkerPath(sessionId, env), `${now}\n`);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function clearSessionEndMarker(sessionId, env) {
|
|
98
|
+
const stateFile = sessionStatePath(sessionId, env);
|
|
99
|
+
withFileLock(`${stateFile}.lock`, () => {
|
|
100
|
+
rmSync(sessionEndMarkerPath(sessionId, env), { force: true });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
112
103
|
export function readSessionState(sessionId, env) {
|
|
113
104
|
const file = sessionStatePath(sessionId, env);
|
|
114
105
|
if (!existsSync(file))
|
|
@@ -187,13 +178,21 @@ function writeSessionStateUnlocked(file, sessionId, state) {
|
|
|
187
178
|
function updateSessionState(sessionId, env, update) {
|
|
188
179
|
const file = sessionStatePath(sessionId, env);
|
|
189
180
|
return withFileLock(`${file}.lock`, () => {
|
|
190
|
-
const
|
|
181
|
+
const current = readSessionState(sessionId, env);
|
|
182
|
+
// SessionEnd and every asynchronous writer share this lock. Once the
|
|
183
|
+
// durable marker exists, no observer may recreate state for that session.
|
|
184
|
+
if (sessionHasEnded(sessionId, env))
|
|
185
|
+
return current;
|
|
186
|
+
const next = update(current);
|
|
191
187
|
writeSessionStateUnlocked(file, sessionId, next);
|
|
192
188
|
return next;
|
|
193
189
|
});
|
|
194
190
|
}
|
|
195
191
|
/** Record the current installation when an owner session starts. */
|
|
196
192
|
export function recordSessionStart(sessionId, env, harness, cwd) {
|
|
193
|
+
// Harnesses may reuse a session id only by explicitly starting that session
|
|
194
|
+
// again. That lifecycle edge is the sole authority for clearing cancellation.
|
|
195
|
+
clearSessionEndMarker(sessionId, env);
|
|
197
196
|
updateSessionState(sessionId, env, (current) => ({
|
|
198
197
|
...current,
|
|
199
198
|
...(harness === undefined ? {} : { harness }),
|
|
@@ -247,9 +246,10 @@ export function resetCursorStopActivation(sessionId, env) {
|
|
|
247
246
|
/**
|
|
248
247
|
* Session state a crashed harness left behind.
|
|
249
248
|
*
|
|
250
|
-
* `SessionEnd` removes
|
|
251
|
-
* that crashes or is killed never reaches it. At roughly a
|
|
252
|
-
* day that is tens of thousands of files a year, none of
|
|
249
|
+
* `SessionEnd` removes session state and leaves a short-lived cancellation
|
|
250
|
+
* marker; a harness that crashes or is killed never reaches it. At roughly a
|
|
251
|
+
* hundred sessions a day that is tens of thousands of files a year, none of
|
|
252
|
+
* which anything reads.
|
|
253
253
|
*
|
|
254
254
|
* Opportunistic rather than scheduled: hooks are the only thing that runs, so
|
|
255
255
|
* a hook is where this has to live. It is rate-limited by its own stamp file
|
|
@@ -309,7 +309,7 @@ export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION
|
|
|
309
309
|
* known-dead PID is recoverable immediately. Only legacy/corrupt claims with
|
|
310
310
|
* no trustworthy PID fall back to an age limit.
|
|
311
311
|
*/
|
|
312
|
-
const CLAIM_TTL_MS =
|
|
312
|
+
const CLAIM_TTL_MS = LEGACY_QUESTION_CLAIM_TTL_SECONDS * 1000;
|
|
313
313
|
/**
|
|
314
314
|
* How long a crashed claim *guard* blocks the next hook.
|
|
315
315
|
*
|
|
@@ -321,16 +321,17 @@ const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000;
|
|
|
321
321
|
*/
|
|
322
322
|
const CLAIM_GUARD_TTL_MS = 30_000;
|
|
323
323
|
const heldClaims = new Map();
|
|
324
|
-
export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleReplace) {
|
|
324
|
+
export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleReplace, ownerDeadlineAt) {
|
|
325
325
|
const file = claimPath(sessionId, env);
|
|
326
326
|
const guard = `${file}.guard`;
|
|
327
327
|
const token = randomBytes(12).toString('base64url');
|
|
328
|
+
const pendingQuestionIds = pendingList(readSessionState(sessionId, env)).map(claimQuestionIdentity);
|
|
328
329
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
329
330
|
if (!acquireClaimGuard(guard))
|
|
330
331
|
return false;
|
|
331
332
|
try {
|
|
332
333
|
try {
|
|
333
|
-
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
|
|
334
|
+
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`, {
|
|
334
335
|
mode: 0o600,
|
|
335
336
|
flag: 'wx',
|
|
336
337
|
});
|
|
@@ -356,7 +357,7 @@ export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleR
|
|
|
356
357
|
beforeStaleReplace?.();
|
|
357
358
|
rmSync(file, { force: true });
|
|
358
359
|
try {
|
|
359
|
-
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
|
|
360
|
+
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`, {
|
|
360
361
|
mode: 0o600,
|
|
361
362
|
flag: 'wx',
|
|
362
363
|
});
|
|
@@ -372,6 +373,31 @@ export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleR
|
|
|
372
373
|
rmSync(guard, { force: true });
|
|
373
374
|
}
|
|
374
375
|
}
|
|
376
|
+
function claimQuestionIdentity(entry) {
|
|
377
|
+
return entry.question_id ?? `${entry.asked_at ?? 'legacy'}\u0000${entry.question}`;
|
|
378
|
+
}
|
|
379
|
+
/** True only when the live owner could not have snapshotted this unpushed ask. */
|
|
380
|
+
function claimHandoffState(sessionId, env) {
|
|
381
|
+
const unasked = pendingList(readSessionState(sessionId, env)).filter((entry) => entry.request_id === undefined);
|
|
382
|
+
if (unasked.length === 0)
|
|
383
|
+
return { hasNewQuestion: false };
|
|
384
|
+
try {
|
|
385
|
+
const held = JSON.parse(readFileSync(claimPath(sessionId, env), 'utf8'));
|
|
386
|
+
if (!Array.isArray(held.pending_question_ids))
|
|
387
|
+
return { hasNewQuestion: false };
|
|
388
|
+
const snapshotted = new Set(held.pending_question_ids.filter((entry) => typeof entry === 'string'));
|
|
389
|
+
return {
|
|
390
|
+
hasNewQuestion: unasked.some((entry) => !snapshotted.has(claimQuestionIdentity(entry))),
|
|
391
|
+
...(typeof held.owner_deadline_at === 'number' &&
|
|
392
|
+
Number.isFinite(held.owner_deadline_at)
|
|
393
|
+
? { ownerDeadlineAt: held.owner_deadline_at }
|
|
394
|
+
: {}),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return { hasNewQuestion: false };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
375
401
|
function processIsAlive(pid) {
|
|
376
402
|
try {
|
|
377
403
|
process.kill(pid, 0);
|
|
@@ -605,6 +631,8 @@ function gate(ctx, verdict, reason, data = {}) {
|
|
|
605
631
|
* still share one wall clock, long enough not to hammer the server.
|
|
606
632
|
*/
|
|
607
633
|
const REPLY_POLL_SECONDS = 5;
|
|
634
|
+
/** Avoid hot-looping while a live observer cooperatively yields its claim. */
|
|
635
|
+
const CLAIM_HANDOFF_POLL_MS = 1_000;
|
|
608
636
|
/**
|
|
609
637
|
* Push a question and block for the answer.
|
|
610
638
|
*
|
|
@@ -662,6 +690,11 @@ async function submitQuestion(ctx, intent) {
|
|
|
662
690
|
}
|
|
663
691
|
return receipt;
|
|
664
692
|
}
|
|
693
|
+
function canSubmitCompleteWindow(ctx, intent, fallbackWindowSeconds) {
|
|
694
|
+
const frozenWindowSeconds = intent.draft.reply?.expires_in_seconds ?? fallbackWindowSeconds;
|
|
695
|
+
return (intent.owner_deadline_at - ctx.now() >=
|
|
696
|
+
(frozenWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000);
|
|
697
|
+
}
|
|
665
698
|
/**
|
|
666
699
|
* The waiter's wait: one bounded poll across every live question at once.
|
|
667
700
|
*
|
|
@@ -671,18 +704,22 @@ async function submitQuestion(ctx, intent) {
|
|
|
671
704
|
* registered. A question whose polling is permanently rejected drops out of
|
|
672
705
|
* the round rather than failing the whole wait.
|
|
673
706
|
*/
|
|
674
|
-
async function waitForAnyReply(ctx, requestIds, timeoutSeconds) {
|
|
707
|
+
async function waitForAnyReply(ctx, requestIds, timeoutSeconds, interruption) {
|
|
675
708
|
const deadline = ctx.now() + timeoutSeconds * 1000;
|
|
676
709
|
let degraded = false;
|
|
677
710
|
let firstPoll = true;
|
|
678
711
|
const permanentFailures = new Map();
|
|
679
712
|
if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
|
|
680
|
-
return { byRequest: new Map(), degraded: false, permanentFailures };
|
|
713
|
+
return { byRequest: new Map(), degraded: false, permanentFailures, interrupted: null };
|
|
681
714
|
}
|
|
682
715
|
for (;;) {
|
|
716
|
+
const beforePoll = interruption?.() ?? null;
|
|
717
|
+
if (beforePoll !== null) {
|
|
718
|
+
return { byRequest: new Map(), degraded, permanentFailures, interrupted: beforePoll };
|
|
719
|
+
}
|
|
683
720
|
const remainingMs = Math.max(0, deadline - ctx.now());
|
|
684
721
|
if (!firstPoll && remainingMs === 0) {
|
|
685
|
-
return { byRequest: new Map(), degraded, permanentFailures };
|
|
722
|
+
return { byRequest: new Map(), degraded, permanentFailures, interrupted: null };
|
|
686
723
|
}
|
|
687
724
|
firstPoll = false;
|
|
688
725
|
const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
|
|
@@ -713,11 +750,42 @@ async function waitForAnyReply(ctx, requestIds, timeoutSeconds) {
|
|
|
713
750
|
if (result.replies.length > 0)
|
|
714
751
|
byRequest.set(result.requestId, result.replies);
|
|
715
752
|
}
|
|
753
|
+
const afterPoll = interruption?.() ?? null;
|
|
754
|
+
// SessionEnd or terminal-side retirement wins even if an in-flight poll
|
|
755
|
+
// happened to return an answer. A newly registered question merely asks
|
|
756
|
+
// this owner to yield; an answer already observed remains authoritative.
|
|
757
|
+
if (afterPoll === 'ownership-ended') {
|
|
758
|
+
return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll };
|
|
759
|
+
}
|
|
716
760
|
if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
|
|
717
|
-
return { byRequest, degraded, permanentFailures };
|
|
761
|
+
return { byRequest, degraded, permanentFailures, interrupted: null };
|
|
762
|
+
}
|
|
763
|
+
if (afterPoll === 'new-question') {
|
|
764
|
+
return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll };
|
|
718
765
|
}
|
|
719
766
|
}
|
|
720
767
|
}
|
|
768
|
+
/**
|
|
769
|
+
* Local lifecycle signal for a long-lived observer.
|
|
770
|
+
*
|
|
771
|
+
* SessionEnd and terminal-side retirement remove an owned request from pending
|
|
772
|
+
* state; the detached process must stop instead of recreating that session.
|
|
773
|
+
* A new unpushed question asks this owner to yield the session claim so the
|
|
774
|
+
* successor Stop can submit it promptly.
|
|
775
|
+
*/
|
|
776
|
+
function waiterInterruption(sessionId, env, requestIds) {
|
|
777
|
+
if (sessionHasEnded(sessionId, env))
|
|
778
|
+
return 'ownership-ended';
|
|
779
|
+
const pending = pendingList(readSessionState(sessionId, env));
|
|
780
|
+
const owned = new Set(requestIds);
|
|
781
|
+
// One question can be retired from the terminal while this observer owns
|
|
782
|
+
// several. That is a per-question change, not whole-session cancellation;
|
|
783
|
+
// the caller filters the changed set after each poll and keeps the rest.
|
|
784
|
+
return pending.some((entry) => entry.request_id === undefined &&
|
|
785
|
+
(entry.submission === undefined || !owned.has(entry.submission.request_id)))
|
|
786
|
+
? 'new-question'
|
|
787
|
+
: null;
|
|
788
|
+
}
|
|
721
789
|
/** A non-retryable replies fault, with enough server identity to act on it. */
|
|
722
790
|
function replyPollFailure(err) {
|
|
723
791
|
return err instanceof ApiCallError
|
|
@@ -811,9 +879,16 @@ export function parkForRetirement(sessionId, env, pending, state) {
|
|
|
811
879
|
* the pending record instead of silently broadening or narrowing the fanout.
|
|
812
880
|
*/
|
|
813
881
|
function retiringQuestion(pending, state, cwd) {
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
882
|
+
// A submit journal is written before the first network byte. SessionEnd can
|
|
883
|
+
// therefore race a request that the server accepts after local cleanup. Its
|
|
884
|
+
// reserved request id and exact fanout are already sufficient retirement
|
|
885
|
+
// identity, even though the ordinary live fields have not been promoted yet.
|
|
886
|
+
const requestId = pending.request_id ?? pending.submission?.request_id;
|
|
887
|
+
const collapseKey = pending.collapse_key ?? pending.submission?.collapse_key;
|
|
888
|
+
const deviceIds = pending.device_ids ?? pending.submission?.device_ids;
|
|
889
|
+
const hasRequest = requestId !== undefined;
|
|
890
|
+
const hasCollapse = collapseKey !== undefined;
|
|
891
|
+
const hasDevices = deviceIds !== undefined && deviceIds.length > 0;
|
|
817
892
|
if (!hasRequest && !hasCollapse && !hasDevices)
|
|
818
893
|
return null;
|
|
819
894
|
if (!hasRequest || !hasCollapse || !hasDevices) {
|
|
@@ -821,9 +896,9 @@ function retiringQuestion(pending, state, cwd) {
|
|
|
821
896
|
}
|
|
822
897
|
const source = sourceContextAtHookEvent(pending.source, cwd);
|
|
823
898
|
return {
|
|
824
|
-
request_id:
|
|
825
|
-
collapse_key:
|
|
826
|
-
device_ids: [...
|
|
899
|
+
request_id: requestId,
|
|
900
|
+
collapse_key: collapseKey,
|
|
901
|
+
device_ids: [...deviceIds],
|
|
827
902
|
question: pending.question,
|
|
828
903
|
...(pending.project !== undefined ? { project: pending.project } : {}),
|
|
829
904
|
...(source !== undefined ? { source } : {}),
|
|
@@ -1269,6 +1344,32 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
|
|
|
1269
1344
|
function amendAcceptedAnswers(ctx, sessionId, amend) {
|
|
1270
1345
|
updateSessionState(sessionId, ctx.env, (current) => current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) });
|
|
1271
1346
|
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Finish a route write that linearized before SessionEnd.
|
|
1349
|
+
*
|
|
1350
|
+
* Ordinary state writers stop at the ended marker. This one narrow completion
|
|
1351
|
+
* is allowed through because the journal's own `delivery_committed_at` proves
|
|
1352
|
+
* the irreversible write won the ordering first; without the matching finish,
|
|
1353
|
+
* a later SessionStart would replay a write that already reached the harness.
|
|
1354
|
+
*/
|
|
1355
|
+
function finishCommittedDelivery(ctx, sessionId, accepted, deliveredRoute) {
|
|
1356
|
+
const file = sessionStatePath(sessionId, ctx.env);
|
|
1357
|
+
withFileLock(`${file}.lock`, () => {
|
|
1358
|
+
const current = readSessionState(sessionId, ctx.env);
|
|
1359
|
+
if (current.accepted?.recorded_at !== accepted.recorded_at ||
|
|
1360
|
+
current.accepted.delivery_committed_at === undefined) {
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
writeSessionStateUnlocked(file, sessionId, {
|
|
1364
|
+
...current,
|
|
1365
|
+
accepted: {
|
|
1366
|
+
...current.accepted,
|
|
1367
|
+
delivered_at: ctx.now(),
|
|
1368
|
+
delivered_route: deliveredRoute,
|
|
1369
|
+
},
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1272
1373
|
export function clearAcknowledgementObligation(sessionId, env, requestId) {
|
|
1273
1374
|
let cleared = false;
|
|
1274
1375
|
updateSessionState(sessionId, env, (current) => {
|
|
@@ -1577,7 +1678,7 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
1577
1678
|
* `notifai ask`. Guessing from the last assistant message was the alternative
|
|
1578
1679
|
* and it is not worth it: a false positive here hijacks the terminal.
|
|
1579
1680
|
*/
|
|
1580
|
-
export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() +
|
|
1681
|
+
export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000, route = hookContinuationRoute()) {
|
|
1581
1682
|
const sessionId = envelope.session_id;
|
|
1582
1683
|
if (!sessionId) {
|
|
1583
1684
|
gate(ctx, 'held', 'no-session', { stage: 'queued' });
|
|
@@ -1590,6 +1691,34 @@ export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() +
|
|
|
1590
1691
|
processDeadlineAt,
|
|
1591
1692
|
});
|
|
1592
1693
|
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Acquire the per-session owner, waiting only when this Stop carries a
|
|
1696
|
+
* newly registered question. The live owner sees that state on its next reply
|
|
1697
|
+
* poll and yields; without this handoff the new question could sit unpushed for
|
|
1698
|
+
* the older question's complete answer window.
|
|
1699
|
+
*/
|
|
1700
|
+
async function acquireQuestionOwner(ctx, sessionId, hardDeadlineAt) {
|
|
1701
|
+
if (sessionHasEnded(sessionId, ctx.env))
|
|
1702
|
+
return false;
|
|
1703
|
+
if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt))
|
|
1704
|
+
return true;
|
|
1705
|
+
while (ctx.now() < hardDeadlineAt) {
|
|
1706
|
+
const handoff = claimHandoffState(sessionId, ctx.env);
|
|
1707
|
+
if (!handoff.hasNewQuestion)
|
|
1708
|
+
return false;
|
|
1709
|
+
const holderReleaseDeadlineAt = Math.min(hardDeadlineAt, handoff.ownerDeadlineAt === undefined
|
|
1710
|
+
? hardDeadlineAt
|
|
1711
|
+
: handoff.ownerDeadlineAt + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS * 1000);
|
|
1712
|
+
if (ctx.now() >= holderReleaseDeadlineAt)
|
|
1713
|
+
return false;
|
|
1714
|
+
await ctx.sleep(Math.min(CLAIM_HANDOFF_POLL_MS, holderReleaseDeadlineAt - ctx.now()));
|
|
1715
|
+
if (sessionHasEnded(sessionId, ctx.env))
|
|
1716
|
+
return false;
|
|
1717
|
+
if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt))
|
|
1718
|
+
return true;
|
|
1719
|
+
}
|
|
1720
|
+
return false;
|
|
1721
|
+
}
|
|
1593
1722
|
/**
|
|
1594
1723
|
* Own one registered question pipeline from its terminal-first timer through
|
|
1595
1724
|
* its final delivery or retirement. Hosts differ only in the injected route.
|
|
@@ -1597,7 +1726,11 @@ export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() +
|
|
|
1597
1726
|
export async function runEscalationWaiter(ctx, options) {
|
|
1598
1727
|
const notes = [];
|
|
1599
1728
|
const { sessionId, envelope } = options;
|
|
1600
|
-
const hardDeadlineAt = options.processDeadlineAt ?? ctx.now() +
|
|
1729
|
+
const hardDeadlineAt = options.processDeadlineAt ?? ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000;
|
|
1730
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
1731
|
+
notes.push('the Agent Session already ended; no answer observer was started');
|
|
1732
|
+
return { notes };
|
|
1733
|
+
}
|
|
1601
1734
|
// This event marker is diagnostic evidence in its own right. Record it
|
|
1602
1735
|
// before every early return so doctor can distinguish a working Stop route
|
|
1603
1736
|
// from the prompt-only state that previously looked healthy.
|
|
@@ -1610,9 +1743,9 @@ export async function runEscalationWaiter(ctx, options) {
|
|
|
1610
1743
|
// polling, close fencing, route delivery, and every retirement path.
|
|
1611
1744
|
// Real clock, deliberately, not `ctx.now` — the claim answers whether
|
|
1612
1745
|
// another process is alive right now, which an injected clock cannot know.
|
|
1613
|
-
if (!
|
|
1746
|
+
if (!(await acquireQuestionOwner(ctx, sessionId, hardDeadlineAt))) {
|
|
1614
1747
|
gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' });
|
|
1615
|
-
notes.push('another hook is already handling this question');
|
|
1748
|
+
notes.push('another hook is already handling this session; a newly registered question remains queued for the next owner');
|
|
1616
1749
|
return { notes };
|
|
1617
1750
|
}
|
|
1618
1751
|
let settledAnswerThisPass = false;
|
|
@@ -1704,6 +1837,7 @@ export function hookContinuationRoute() {
|
|
|
1704
1837
|
kind: 'hook-continuation',
|
|
1705
1838
|
deliver: async (event) => ({
|
|
1706
1839
|
stdout: stopAnswerOutput(event.context),
|
|
1840
|
+
commitStdout: event.commitDelivery,
|
|
1707
1841
|
notes: [],
|
|
1708
1842
|
// The harness reads this stdout after the process exits, so only the
|
|
1709
1843
|
// successor Stop can acknowledge it.
|
|
@@ -1722,6 +1856,10 @@ export function hookContinuationRoute() {
|
|
|
1722
1856
|
async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cwd) {
|
|
1723
1857
|
const { answers: answered, remaining } = accepted;
|
|
1724
1858
|
const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids;
|
|
1859
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
1860
|
+
notes.push('the Agent Session ended before answer delivery; stopping this observer');
|
|
1861
|
+
return { notes };
|
|
1862
|
+
}
|
|
1725
1863
|
const held = accepted.held_deliveries ?? 0;
|
|
1726
1864
|
if (held >= MAX_HELD_DELIVERIES) {
|
|
1727
1865
|
gate(ctx, 'held', 'delivery-limit', {
|
|
@@ -1755,6 +1893,12 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
|
|
|
1755
1893
|
...current,
|
|
1756
1894
|
delivery_attempts: attempt,
|
|
1757
1895
|
}));
|
|
1896
|
+
// Linearize delivery after the journal write. If SessionEnd acquired the
|
|
1897
|
+
// shared state lock in between, its durable marker wins and no wake starts.
|
|
1898
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
1899
|
+
notes.push('the Agent Session ended before answer delivery; stopping this observer');
|
|
1900
|
+
return { notes };
|
|
1901
|
+
}
|
|
1758
1902
|
ctx.log?.info('hook.gate', {
|
|
1759
1903
|
verdict: 'proceeding',
|
|
1760
1904
|
reason: 'answered',
|
|
@@ -1765,21 +1909,47 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
|
|
|
1765
1909
|
journal_recorded_at: accepted.recorded_at,
|
|
1766
1910
|
delivery_attempt: attempt,
|
|
1767
1911
|
});
|
|
1768
|
-
|
|
1912
|
+
let deliveryCommitted = false;
|
|
1913
|
+
const commitDelivery = () => {
|
|
1914
|
+
if (deliveryCommitted)
|
|
1915
|
+
return true;
|
|
1916
|
+
const file = sessionStatePath(sessionId, ctx.env);
|
|
1917
|
+
return withFileLock(`${file}.lock`, () => {
|
|
1918
|
+
if (sessionHasEnded(sessionId, ctx.env))
|
|
1919
|
+
return false;
|
|
1920
|
+
const current = readSessionState(sessionId, ctx.env);
|
|
1921
|
+
if (current.accepted === undefined)
|
|
1922
|
+
return false;
|
|
1923
|
+
deliveryCommitted = true;
|
|
1924
|
+
writeSessionStateUnlocked(file, sessionId, {
|
|
1925
|
+
...current,
|
|
1926
|
+
accepted: { ...current.accepted, delivery_committed_at: ctx.now() },
|
|
1927
|
+
});
|
|
1928
|
+
return true;
|
|
1929
|
+
});
|
|
1930
|
+
};
|
|
1931
|
+
let delivered = await route.deliver({
|
|
1769
1932
|
context: answersContext(answered, remaining),
|
|
1770
1933
|
answers: answered.length,
|
|
1771
1934
|
remaining,
|
|
1772
1935
|
request_ids: requestIds,
|
|
1773
1936
|
journal_recorded_at: accepted.recorded_at,
|
|
1937
|
+
commitDelivery,
|
|
1774
1938
|
});
|
|
1939
|
+
if (delivered.acknowledgement === 'delivered' && !deliveryCommitted) {
|
|
1940
|
+
delivered = {
|
|
1941
|
+
notes: [
|
|
1942
|
+
...(delivered.notes ?? []),
|
|
1943
|
+
`the ${route.kind} route reported delivery without committing against SessionEnd; preserving the answer instead of accepting an unordered write`,
|
|
1944
|
+
],
|
|
1945
|
+
log: { route: route.kind, stage: 'queued', reason: 'delivery-not-committed' },
|
|
1946
|
+
acknowledgement: 'held',
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1775
1949
|
const deliveredRoute = typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind;
|
|
1776
1950
|
const deliveredStage = typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered';
|
|
1777
1951
|
if (delivered.acknowledgement === 'delivered') {
|
|
1778
|
-
|
|
1779
|
-
...current,
|
|
1780
|
-
delivered_at: ctx.now(),
|
|
1781
|
-
delivered_route: deliveredRoute,
|
|
1782
|
-
}));
|
|
1952
|
+
finishCommittedDelivery(ctx, sessionId, accepted, deliveredRoute);
|
|
1783
1953
|
}
|
|
1784
1954
|
else if (delivered.acknowledgement === 'held') {
|
|
1785
1955
|
// Give the delivery attempt back and count the hold instead. The attempt
|
|
@@ -1807,6 +1977,8 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
|
|
|
1807
1977
|
const outcome = { notes: [...notes, ...(delivered.notes ?? [])] };
|
|
1808
1978
|
if (delivered.stdout !== undefined)
|
|
1809
1979
|
outcome.stdout = delivered.stdout;
|
|
1980
|
+
if (delivered.commitStdout !== undefined)
|
|
1981
|
+
outcome.commitStdout = delivered.commitStdout;
|
|
1810
1982
|
if (delivered.log !== undefined)
|
|
1811
1983
|
outcome.log = delivered.log;
|
|
1812
1984
|
return outcome;
|
|
@@ -1869,13 +2041,10 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1869
2041
|
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
1870
2042
|
}
|
|
1871
2043
|
const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
|
|
1872
|
-
// A spent owner has already
|
|
1873
|
-
// questions back into `escalate` starts a fresh ceiling
|
|
1874
|
-
// per-session claim
|
|
1875
|
-
//
|
|
1876
|
-
// ran as claimed-elsewhere). Late answers still ride the short poll above
|
|
1877
|
-
// and every later turn. Independent questions whose lease is still running
|
|
1878
|
-
// keep their successor wait.
|
|
2044
|
+
// A spent owner has already reached the end of the answer window it owned.
|
|
2045
|
+
// Passing those questions back into `escalate` starts a fresh ceiling and
|
|
2046
|
+
// can monopolize the per-session claim after expiry. Independent questions
|
|
2047
|
+
// whose lease is still running keep their successor wait.
|
|
1879
2048
|
const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()));
|
|
1880
2049
|
liveToEscalate = stillOwned;
|
|
1881
2050
|
if (unasked.length === 0) {
|
|
@@ -1889,7 +2058,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1889
2058
|
request_ids: requestIdSummary.ids,
|
|
1890
2059
|
owner_spent: true,
|
|
1891
2060
|
});
|
|
1892
|
-
notes.push(`${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still
|
|
2061
|
+
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`);
|
|
1893
2062
|
return { notes };
|
|
1894
2063
|
}
|
|
1895
2064
|
return await escalate(ctx, envelope, sessionId, [], stillOwned, notes, hardDeadlineAt, route);
|
|
@@ -1947,11 +2116,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1947
2116
|
// The questions still owe the user their terminal-first window before
|
|
1948
2117
|
// anything reaches their devices — measured from the oldest registration,
|
|
1949
2118
|
// because that is the question that has waited longest.
|
|
1950
|
-
const
|
|
1951
|
-
const existingDeadlines = alreadyLive.flatMap((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= startedAt
|
|
1952
|
-
? []
|
|
1953
|
-
: [entry.reply_deadline_at]);
|
|
1954
|
-
const ceilingAt = Math.min(hardDeadlineAt, ...(existingDeadlines.length === 0 ? [] : existingDeadlines));
|
|
2119
|
+
const ceilingAt = hardDeadlineAt;
|
|
1955
2120
|
if (unasked.length > 0 && alreadyLive.length === 0) {
|
|
1956
2121
|
const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()));
|
|
1957
2122
|
await awaitTerminalFirstWindow(ctx, oldest, ceilingAt);
|
|
@@ -1965,24 +2130,17 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1965
2130
|
// Phase one: every registered question reaches the user's devices, each as
|
|
1966
2131
|
// its own notification — one ask never stands in for another.
|
|
1967
2132
|
const submitted = [];
|
|
2133
|
+
const admissionAnswers = [];
|
|
1968
2134
|
for (const entry of unasked) {
|
|
1969
|
-
//
|
|
1970
|
-
//
|
|
1971
|
-
//
|
|
1972
|
-
// stopped being answerable about eight minutes later, whatever the user
|
|
1973
|
-
// had configured.
|
|
1974
|
-
//
|
|
1975
|
-
// That was right when the waiter was the only way an answer came back. It
|
|
1976
|
-
// is not any more: a late answer is found by the next turn's poll or
|
|
1977
|
-
// replayed from the journal, so this owner giving up listening says
|
|
1978
|
-
// nothing about whether the user's answer is still wanted.
|
|
2135
|
+
// The service owns how long the answer is accepted. This process begins
|
|
2136
|
+
// before submission, so its larger maximum-window budget includes startup
|
|
2137
|
+
// headroom and remains alive through the complete committed window.
|
|
1979
2138
|
const replyWindowSeconds = ctx.config.reply_window_seconds.value;
|
|
1980
|
-
if (ceilingAt - ctx.now() <
|
|
1981
|
-
|
|
2139
|
+
if (ceilingAt - ctx.now() <
|
|
2140
|
+
(replyWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000) {
|
|
2141
|
+
notes.push('too little owner lifetime remains for the complete configured answer window; leaving this question frozen for a successor owner');
|
|
1982
2142
|
continue;
|
|
1983
2143
|
}
|
|
1984
|
-
// The owner still stops listening at its own ceiling. Only the span the
|
|
1985
|
-
// server accepts answers into changed.
|
|
1986
2144
|
const ownerDeadlineAt = ceilingAt;
|
|
1987
2145
|
const questions = pendingQuestions(entry);
|
|
1988
2146
|
const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd);
|
|
@@ -2039,12 +2197,15 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2039
2197
|
});
|
|
2040
2198
|
}
|
|
2041
2199
|
if (ctx.now() >= intent.owner_deadline_at) {
|
|
2042
|
-
notes.push('the
|
|
2200
|
+
notes.push('the owner lifetime ended before submission; preserving the frozen intent');
|
|
2201
|
+
continue;
|
|
2202
|
+
}
|
|
2203
|
+
if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
|
|
2204
|
+
notes.push('setup consumed the admission allowance; preserving the frozen intent instead of publishing an answer window this owner cannot observe completely');
|
|
2043
2205
|
continue;
|
|
2044
2206
|
}
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
notes.push('device resolution consumed the reply window; preserving the frozen intent instead of shrinking it below what the server accepts');
|
|
2207
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
2208
|
+
notes.push('the Agent Session ended before submission; preserving no live observer');
|
|
2048
2209
|
continue;
|
|
2049
2210
|
}
|
|
2050
2211
|
let receipt;
|
|
@@ -2097,6 +2258,14 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2097
2258
|
return { ...current, pending: next };
|
|
2098
2259
|
});
|
|
2099
2260
|
try {
|
|
2261
|
+
if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
|
|
2262
|
+
notes.push('draft recovery consumed the admission allowance; preserving the frozen intent for a successor owner');
|
|
2263
|
+
continue;
|
|
2264
|
+
}
|
|
2265
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
2266
|
+
notes.push('the Agent Session ended before recovered submission');
|
|
2267
|
+
continue;
|
|
2268
|
+
}
|
|
2100
2269
|
receipt = await submitQuestion(ctx, intent);
|
|
2101
2270
|
admissionConfirmed = true;
|
|
2102
2271
|
}
|
|
@@ -2156,17 +2325,9 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2156
2325
|
request_id: intent.request_id,
|
|
2157
2326
|
collapse_key: intent.collapse_key,
|
|
2158
2327
|
device_ids: intent.device_ids,
|
|
2159
|
-
//
|
|
2160
|
-
//
|
|
2161
|
-
//
|
|
2162
|
-
// waiter's ceiling: a question asked with a day-long window was swept as
|
|
2163
|
-
// stale about eight minutes later and retired, so the answer given over
|
|
2164
|
-
// lunch met a closed question.
|
|
2165
|
-
//
|
|
2166
|
-
// The reply window is how long the server accepts an answer. This
|
|
2167
|
-
// process still clamps its own wait to `hardDeadlineAt`. Recording the
|
|
2168
|
-
// owner lease separately is what stops the *next* Stop from treating
|
|
2169
|
-
// the remaining window as a fresh waiter ceiling.
|
|
2328
|
+
// The server's committed answer deadline is authoritative. The local
|
|
2329
|
+
// owner deadline starts earlier and includes startup headroom, so it may
|
|
2330
|
+
// be later but must never be earlier.
|
|
2170
2331
|
reply_deadline_at: Number.isFinite(committedReplyDeadline)
|
|
2171
2332
|
? committedReplyDeadline
|
|
2172
2333
|
: intent.owner_deadline_at,
|
|
@@ -2175,7 +2336,48 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2175
2336
|
if (intent.draft.source === undefined)
|
|
2176
2337
|
delete live.source;
|
|
2177
2338
|
delete live.submission;
|
|
2178
|
-
|
|
2339
|
+
if (live.reply_deadline_at > live.owner_deadline_at) {
|
|
2340
|
+
const response = await finalizeReplies(ctx, live.request_id);
|
|
2341
|
+
if (response === null) {
|
|
2342
|
+
// Closing was unreachable, so the question is still potentially live.
|
|
2343
|
+
// Preserve it in the exact session instead of demoting it to the orphan
|
|
2344
|
+
// retirement queue, whose later close has no route for an answer.
|
|
2345
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2346
|
+
const list = pendingList(current);
|
|
2347
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry));
|
|
2348
|
+
if (index < 0) {
|
|
2349
|
+
// Terminal input removed this registration while the anomalous
|
|
2350
|
+
// close was in flight. Preserve retirement identity exactly as
|
|
2351
|
+
// normal submit promotion does; the observer must not become the
|
|
2352
|
+
// only remaining record of a live card.
|
|
2353
|
+
const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd);
|
|
2354
|
+
const retiring = [...(current.retiring ?? [])];
|
|
2355
|
+
if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
2356
|
+
retiring.push(retirement);
|
|
2357
|
+
}
|
|
2358
|
+
return { ...current, retiring };
|
|
2359
|
+
}
|
|
2360
|
+
const next = [...list];
|
|
2361
|
+
next[index] = live;
|
|
2362
|
+
return { ...current, pending: next };
|
|
2363
|
+
});
|
|
2364
|
+
submitted.push(live);
|
|
2365
|
+
notes.push('the server committed an answer deadline beyond this process owner and immediate closure was unreachable; preserving the live question for exact-session recovery');
|
|
2366
|
+
continue;
|
|
2367
|
+
}
|
|
2368
|
+
else if (response.replies.length > 0) {
|
|
2369
|
+
admissionAnswers.push({
|
|
2370
|
+
pending: live,
|
|
2371
|
+
reply: response.replies.at(-1),
|
|
2372
|
+
replies: response.replies,
|
|
2373
|
+
agent_acknowledgement_required: response.agent_acknowledgement_required,
|
|
2374
|
+
agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
dropPendingQuestion(sessionId, ctx.env, entry);
|
|
2378
|
+
notes.push('the server committed an answer deadline beyond this process owner; closed the anomalous window instead of abandoning it early');
|
|
2379
|
+
continue;
|
|
2380
|
+
}
|
|
2179
2381
|
// Record what is now live on the user's devices BEFORE any wait. If we
|
|
2180
2382
|
// only learned these ids afterwards, a question that timed out would
|
|
2181
2383
|
// leave no trace, and the user returning to the terminal could never
|
|
@@ -2201,6 +2403,21 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2201
2403
|
return { ...current, retiring };
|
|
2202
2404
|
});
|
|
2203
2405
|
}
|
|
2406
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
2407
|
+
// SessionEnd may have raced either the submit or promotion. Its snapshot
|
|
2408
|
+
// usually already queued the frozen intent; this idempotent add closes
|
|
2409
|
+
// the other ordering where the request committed just after cleanup.
|
|
2410
|
+
orphanRetirements(ctx.env, [retiringQuestion(live, 'expired', envelope.cwd)], ctx.now());
|
|
2411
|
+
notes.push('the Agent Session ended during submission; queued the question for retirement');
|
|
2412
|
+
continue;
|
|
2413
|
+
}
|
|
2414
|
+
submitted.push(live);
|
|
2415
|
+
}
|
|
2416
|
+
if (admissionAnswers.length > 0) {
|
|
2417
|
+
const accepted = stageAcceptedAnswers(ctx, sessionId, admissionAnswers, pendingList(readSessionState(sessionId, ctx.env)).length);
|
|
2418
|
+
for (const answer of admissionAnswers)
|
|
2419
|
+
reportAnswer(ctx, notes, answer, false);
|
|
2420
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
2204
2421
|
}
|
|
2205
2422
|
const staleLive = alreadyLive.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now());
|
|
2206
2423
|
const finalizedStale = await finalizePendings(ctx, staleLive);
|
|
@@ -2231,40 +2448,48 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2231
2448
|
];
|
|
2232
2449
|
if (waitingOn.length === 0)
|
|
2233
2450
|
return { notes };
|
|
2234
|
-
// Phase two:
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
// for, and it is the honest place for it.
|
|
2258
|
-
const expired = waitingOn.filter((entry) => entry.reply_deadline_at === undefined ||
|
|
2259
|
-
entry.reply_deadline_at <= ownerDeadline ||
|
|
2260
|
-
// A permanent rejection is the server saying this question can never be
|
|
2261
|
-
// answered, whatever its window says. Leaving it open would strand a
|
|
2262
|
-
// dead question on the user's devices.
|
|
2263
|
-
waited.permanentFailures.has(entry.request_id));
|
|
2264
|
-
const stillAnswerable = waitingOn.filter((entry) => !expired.includes(entry));
|
|
2265
|
-
if (stillAnswerable.length > 0) {
|
|
2266
|
-
notes.push(`${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' is' : 's are'} still answerable; leaving ${stillAnswerable.length === 1 ? 'it' : 'them'} open for the next turn rather than closing ${stillAnswerable.length === 1 ? 'it' : 'them'} with this waiter`);
|
|
2451
|
+
// Phase two: keep the same owner alive across every committed answer window.
|
|
2452
|
+
// Different questions may expire at different times, so finalize each one at
|
|
2453
|
+
// its own server deadline and continue waiting on the rest. Reserving time by
|
|
2454
|
+
// closing early would make the advertised reply window untrue.
|
|
2455
|
+
let activeWaiting = waitingOn;
|
|
2456
|
+
let timeoutSeconds = 0;
|
|
2457
|
+
let waited;
|
|
2458
|
+
for (;;) {
|
|
2459
|
+
const nextReplyDeadline = Math.min(...activeWaiting.map((entry) => entry.reply_deadline_at));
|
|
2460
|
+
const waitDeadline = Math.min(ceilingAt, nextReplyDeadline);
|
|
2461
|
+
timeoutSeconds = Math.max(0, Math.ceil((waitDeadline - ctx.now()) / 1000));
|
|
2462
|
+
waited = await waitForAnyReply(ctx, activeWaiting.map((entry) => entry.request_id), timeoutSeconds, () => waiterInterruption(sessionId, ctx.env, activeWaiting.map((entry) => entry.request_id)));
|
|
2463
|
+
if (waited.interrupted !== null) {
|
|
2464
|
+
notes.push(waited.interrupted === 'new-question'
|
|
2465
|
+
? 'yielding the answer owner so a newly registered question can be sent now'
|
|
2466
|
+
: 'answer ownership ended locally; stopping this observer without delivering into a closed session');
|
|
2467
|
+
return { notes };
|
|
2468
|
+
}
|
|
2469
|
+
const currentRequestIds = new Set(pendingList(readSessionState(sessionId, ctx.env)).flatMap((entry) => [entry.request_id, entry.submission?.request_id].filter((requestId) => requestId !== undefined)));
|
|
2470
|
+
const removedDuringPoll = activeWaiting.filter((entry) => !currentRequestIds.has(entry.request_id));
|
|
2471
|
+
for (const entry of removedDuringPoll) {
|
|
2472
|
+
waited.byRequest.delete(entry.request_id);
|
|
2473
|
+
waited.permanentFailures.delete(entry.request_id);
|
|
2267
2474
|
}
|
|
2475
|
+
if (removedDuringPoll.length > 0) {
|
|
2476
|
+
notes.push(`${removedDuringPoll.length} question${removedDuringPoll.length === 1 ? ' changed' : 's changed'} outside this observer; continuing with the independently live questions`);
|
|
2477
|
+
activeWaiting = activeWaiting.filter((entry) => currentRequestIds.has(entry.request_id));
|
|
2478
|
+
if (activeWaiting.length === 0)
|
|
2479
|
+
return { notes };
|
|
2480
|
+
}
|
|
2481
|
+
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
|
|
2482
|
+
if (permanentFailure !== null)
|
|
2483
|
+
notes.push(permanentFailure);
|
|
2484
|
+
if (waited.byRequest.size > 0)
|
|
2485
|
+
break;
|
|
2486
|
+
// A permanent rejection is authoritative immediately. Time-based expiry is
|
|
2487
|
+
// authoritative only when this owner reached that question's committed
|
|
2488
|
+
// server deadline; reaching the process ceiling first preserves the record.
|
|
2489
|
+
const expired = activeWaiting.filter((entry) => entry.reply_deadline_at === undefined ||
|
|
2490
|
+
entry.reply_deadline_at <= waitDeadline ||
|
|
2491
|
+
waited.permanentFailures.has(entry.request_id));
|
|
2492
|
+
const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry));
|
|
2268
2493
|
const finalized = await finalizePendings(ctx, expired);
|
|
2269
2494
|
const finalAnswers = finalized
|
|
2270
2495
|
.map(finalizedAnswer)
|
|
@@ -2286,6 +2511,10 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2286
2511
|
reportAnswer(ctx, notes, answer, false);
|
|
2287
2512
|
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
2288
2513
|
}
|
|
2514
|
+
if (stillAnswerable.length > 0 && waitDeadline < ceilingAt) {
|
|
2515
|
+
activeWaiting = stillAnswerable;
|
|
2516
|
+
continue;
|
|
2517
|
+
}
|
|
2289
2518
|
const requestIdSummary = summarizeRequestIds(waitingOn);
|
|
2290
2519
|
ctx.log?.info('hook.answer', {
|
|
2291
2520
|
answered: false,
|
|
@@ -2295,14 +2524,17 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2295
2524
|
permanent_failures: waited.permanentFailures.size,
|
|
2296
2525
|
waited_seconds: timeoutSeconds,
|
|
2297
2526
|
});
|
|
2298
|
-
if (
|
|
2527
|
+
if (stillAnswerable.length > 0) {
|
|
2528
|
+
notes.push(`${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' remains' : 's remain'} answerable after the process owner ended; preserving ${stillAnswerable.length === 1 ? 'it' : 'them'} for recovery`);
|
|
2529
|
+
}
|
|
2530
|
+
else if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
|
|
2299
2531
|
notes.push(waited.degraded
|
|
2300
|
-
? 'could not reach the server before the
|
|
2532
|
+
? 'could not reach the server before the answer deadline; expired questions were retired so no answer can be lost later'
|
|
2301
2533
|
: 'no answer in time; the question expired with its continuation owner');
|
|
2302
2534
|
}
|
|
2303
2535
|
return { notes };
|
|
2304
2536
|
}
|
|
2305
|
-
const polledAnswered =
|
|
2537
|
+
const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id));
|
|
2306
2538
|
const finalizedAnswered = await finalizePendings(ctx, polledAnswered);
|
|
2307
2539
|
const answered = [];
|
|
2308
2540
|
for (const finalized of finalizedAnswered) {
|
|
@@ -2326,7 +2558,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2326
2558
|
notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
|
|
2327
2559
|
}
|
|
2328
2560
|
}
|
|
2329
|
-
const accepted = stageAcceptedAnswers(ctx, sessionId, answered,
|
|
2561
|
+
const accepted = stageAcceptedAnswers(ctx, sessionId, answered, Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - answered.length));
|
|
2330
2562
|
for (const answer of answered)
|
|
2331
2563
|
reportAnswer(ctx, notes, answer, false);
|
|
2332
2564
|
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
@@ -2347,15 +2579,24 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
2347
2579
|
const sessionId = envelope.session_id;
|
|
2348
2580
|
if (!sessionId)
|
|
2349
2581
|
return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } };
|
|
2582
|
+
// Publish cancellation before reading or clearing anything. In-flight Stop
|
|
2583
|
+
// writers use the same session lock, so none can recreate state after this.
|
|
2584
|
+
markSessionEnded(sessionId, env, now);
|
|
2350
2585
|
if (envelope.cwd !== undefined)
|
|
2351
2586
|
clearMatchingProjectSession(envelope.cwd, env, sessionId);
|
|
2352
2587
|
const state = readSessionState(sessionId, env);
|
|
2353
2588
|
const orphans = [...(state.retiring ?? [])];
|
|
2354
|
-
|
|
2589
|
+
const retirementCandidates = [
|
|
2590
|
+
...pendingList(state),
|
|
2591
|
+
...(state.accepted?.answers.map((entry) => entry.pending) ?? []),
|
|
2592
|
+
];
|
|
2593
|
+
for (const entry of retirementCandidates) {
|
|
2355
2594
|
try {
|
|
2356
2595
|
const orphan = retiringQuestion(entry, 'expired', envelope.cwd);
|
|
2357
|
-
if (orphan !== null
|
|
2596
|
+
if (orphan !== null &&
|
|
2597
|
+
!orphans.some((candidate) => candidate.request_id === orphan.request_id)) {
|
|
2358
2598
|
orphans.push(orphan);
|
|
2599
|
+
}
|
|
2359
2600
|
}
|
|
2360
2601
|
catch (err) {
|
|
2361
2602
|
notes.push(err instanceof Error ? err.message : String(err));
|