@raidiant/notifai 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude-wake.d.ts +101 -0
- package/dist/claude-wake.js +397 -0
- package/dist/claude-wake.js.map +1 -0
- package/dist/codex-wake.d.ts +84 -0
- package/dist/codex-wake.js +238 -0
- package/dist/codex-wake.js.map +1 -0
- package/dist/commands.d.ts +15 -10
- package/dist/commands.js +321 -88
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +2 -2
- package/dist/config-schema.js +9 -35
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +5 -38
- package/dist/config.js +11 -32
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +3 -13
- package/dist/harnesses.js +6 -17
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +119 -65
- package/dist/hooks.js +330 -285
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +22 -7
- package/dist/install-hooks.js +43 -28
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +2 -4
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +6 -13
- package/dist/main.js.map +1 -1
- package/dist/release.d.ts +31 -0
- package/dist/release.js +52 -0
- package/dist/release.js.map +1 -0
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +9 -9
- package/src/commands.test.ts +22 -5
- package/src/commands.ts +24 -6
- package/src/main.ts +6 -12
- package/src/release.ts +51 -0
- package/dist/idle.d.ts +0 -16
- package/dist/idle.js +0 -44
- package/dist/idle.js.map +0 -1
package/dist/hooks.js
CHANGED
|
@@ -6,7 +6,7 @@ import { loadConfig, projectSessionPointerPath, sanitizeSessionId, sessionConfig
|
|
|
6
6
|
import { buildDraft } from './send.js';
|
|
7
7
|
import { atomicWriteFileSync } from './atomic-file.js';
|
|
8
8
|
import { withFileLock } from './file-lock.js';
|
|
9
|
-
import { HARNESS_CAPABILITIES, HARNESSES,
|
|
9
|
+
import { HARNESS_CAPABILITIES, HARNESSES, } from './harnesses.js';
|
|
10
10
|
/**
|
|
11
11
|
* Past this, the question's reply window (3600s) has long expired server-side
|
|
12
12
|
* and the companion shows it as dead on next open anyway; pushing a retirement
|
|
@@ -20,58 +20,46 @@ function summarizeRequestIds(entries) {
|
|
|
20
20
|
const ids = entries.flatMap((entry) => entry.request_id === undefined ? [] : [entry.request_id]);
|
|
21
21
|
return { ids, display: ids.join(', ') };
|
|
22
22
|
}
|
|
23
|
-
/** How often the grace window rechecks whether the user has come back. */
|
|
24
|
-
const GRACE_POLL_MS = 5_000;
|
|
25
23
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
24
|
+
* The waiter's whole wall clock: the terminal-first timer, submission, and the
|
|
25
|
+
* wait for an answer, from the moment the turn-end hook starts.
|
|
26
|
+
*
|
|
27
|
+
* One plain ceiling, deliberately not a budget carved into reserves. The
|
|
28
|
+
* reserves existed to fit every phase inside a *held* turn; on Claude Code the
|
|
29
|
+
* turn is not held any more, and on a host that still holds one the useful
|
|
30
|
+
* question is only how long that host is willing to wait in total.
|
|
31
|
+
*/
|
|
32
|
+
export const WAITER_CEILING_SECONDS = 480;
|
|
33
|
+
/**
|
|
34
|
+
* The wire contract's shortest reply window. A question submitted with less
|
|
35
|
+
* than this is rejected outright, and accepting one would in any case let the
|
|
36
|
+
* server go on taking an answer after the waiter that owns it has returned.
|
|
30
37
|
*/
|
|
31
|
-
const
|
|
32
|
-
const MIN_REPLY_WAIT_SECONDS = HOOK_TIMING.minimumReplySeconds;
|
|
38
|
+
const MIN_REPLY_WINDOW_SECONDS = 60;
|
|
33
39
|
/**
|
|
34
40
|
* The terminal-first wait: the question sits in the terminal for
|
|
35
41
|
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
36
42
|
* devices.
|
|
37
43
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* That safety depends entirely on the idle signal, so with no idle source this
|
|
45
|
-
* refuses to wait at all rather than holding a terminal it cannot monitor.
|
|
44
|
+
* A plain timer, and nothing else. It once polled an idle signal so it could
|
|
45
|
+
* abandon the wait the moment the user touched the keyboard — necessary while
|
|
46
|
+
* the wait blocked the terminal, because a user wanting to answer locally was
|
|
47
|
+
* otherwise locked out of their own prompt. Nothing is monopolised now, so
|
|
48
|
+
* there is nothing to watch for and no machine this cannot run on.
|
|
46
49
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
50
|
+
* The window is measured from registration, not from the turn's end: a
|
|
51
|
+
* question the agent asked five minutes ago while it kept working has already
|
|
52
|
+
* served its wait. A stamp from the future would otherwise consume the whole
|
|
53
|
+
* ceiling and leave no room to answer, so it starts no later than now.
|
|
51
54
|
*/
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const elapsed = ctx.now() - askedAt;
|
|
61
|
-
const start = elapsed >= 0 && elapsed <= MAX_PLAUSIBLE_SILENCE_MS ? askedAt : ctx.now();
|
|
62
|
-
const deadline = start + graceSeconds * 1000;
|
|
63
|
-
for (;;) {
|
|
64
|
-
if (ctx.config.require_idle.value) {
|
|
65
|
-
const idle = ctx.idleSeconds();
|
|
66
|
-
if (idle === null)
|
|
67
|
-
return 'no-signal';
|
|
68
|
-
if (idle < threshold)
|
|
69
|
-
return 'user-returned';
|
|
70
|
-
}
|
|
71
|
-
if (ctx.now() >= deadline)
|
|
72
|
-
return 'absent';
|
|
73
|
-
await ctx.sleep(Math.min(GRACE_POLL_MS, Math.max(0, deadline - ctx.now())));
|
|
74
|
-
}
|
|
55
|
+
async function awaitTerminalFirstWindow(ctx, askedAt, ceilingAt) {
|
|
56
|
+
const graceSeconds = ctx.config.ask_grace_seconds.value;
|
|
57
|
+
if (graceSeconds <= 0)
|
|
58
|
+
return;
|
|
59
|
+
const until = Math.min(Math.min(askedAt, ctx.now()) + graceSeconds * 1000, ceilingAt);
|
|
60
|
+
const remaining = until - ctx.now();
|
|
61
|
+
if (remaining > 0)
|
|
62
|
+
await ctx.sleep(remaining);
|
|
75
63
|
}
|
|
76
64
|
function sessionStatePath(sessionId, env) {
|
|
77
65
|
return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`);
|
|
@@ -179,7 +167,7 @@ export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION
|
|
|
179
167
|
* known-dead PID is recoverable immediately. Only legacy/corrupt claims with
|
|
180
168
|
* no trustworthy PID fall back to an age limit.
|
|
181
169
|
*/
|
|
182
|
-
const CLAIM_TTL_MS =
|
|
170
|
+
const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000;
|
|
183
171
|
const heldClaims = new Map();
|
|
184
172
|
export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleReplace) {
|
|
185
173
|
const file = claimPath(sessionId, env);
|
|
@@ -354,9 +342,16 @@ function readStoredProjectSessionPointers(file) {
|
|
|
354
342
|
export function readProjectSession(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
|
|
355
343
|
return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null;
|
|
356
344
|
}
|
|
357
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Every session that has fired a hook in this directory recently, newest first.
|
|
347
|
+
*
|
|
348
|
+
* This is the only local record of which harness sessions are actually alive
|
|
349
|
+
* here, which makes it the tiebreaker when the environment alone cannot say
|
|
350
|
+
* which harness owns the current shell.
|
|
351
|
+
*/
|
|
352
|
+
export function readLiveProjectSessionPointers(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
|
|
358
353
|
const file = projectSessionPointerPath(cwd, env);
|
|
359
|
-
|
|
354
|
+
return readStoredProjectSessionPointers(file)
|
|
360
355
|
.filter((entry) => now - entry.updatedAt <= maxAgeMs)
|
|
361
356
|
.filter((entry) => {
|
|
362
357
|
try {
|
|
@@ -373,32 +368,20 @@ export function readProjectSessionPointer(cwd, env, now, maxAgeMs = 24 * 3600 *
|
|
|
373
368
|
return false;
|
|
374
369
|
}
|
|
375
370
|
})
|
|
376
|
-
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
377
|
-
|
|
371
|
+
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
372
|
+
.map((entry) => ({
|
|
373
|
+
sessionId: entry.sessionId,
|
|
374
|
+
harness: entry.harness,
|
|
375
|
+
updatedAt: entry.updatedAt,
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
export function readProjectSessionPointer(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
|
|
379
|
+
const latest = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs)[0];
|
|
378
380
|
return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness };
|
|
379
381
|
}
|
|
380
382
|
export function readMatchingProjectSessionPointer(cwd, env, now, sessionId, harness, maxAgeMs = 24 * 3600 * 1000) {
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
entry.harness === harness &&
|
|
384
|
-
now - entry.updatedAt <= maxAgeMs);
|
|
385
|
-
if (match === undefined)
|
|
386
|
-
return null;
|
|
387
|
-
try {
|
|
388
|
-
// A pointer is routing evidence only while its session state still exists
|
|
389
|
-
// and parses. SessionEnd and explicit cleanup therefore invalidate it even
|
|
390
|
-
// when a crash prevented the pointer file itself from being removed.
|
|
391
|
-
const sessionFile = sessionStatePath(match.sessionId, env);
|
|
392
|
-
if (!existsSync(sessionFile))
|
|
393
|
-
return null;
|
|
394
|
-
const state = JSON.parse(readFileSync(sessionFile, 'utf8'));
|
|
395
|
-
if (typeof state !== 'object' || state === null || Array.isArray(state))
|
|
396
|
-
return null;
|
|
397
|
-
return { sessionId: match.sessionId, harness: match.harness };
|
|
398
|
-
}
|
|
399
|
-
catch {
|
|
400
|
-
return null;
|
|
401
|
-
}
|
|
383
|
+
const match = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs).find((entry) => entry.sessionId === sessionId && entry.harness === harness);
|
|
384
|
+
return match === undefined ? null : { sessionId: match.sessionId, harness: match.harness };
|
|
402
385
|
}
|
|
403
386
|
function clearMatchingProjectSession(cwd, env, sessionId) {
|
|
404
387
|
const file = projectSessionPointerPath(cwd, env);
|
|
@@ -417,61 +400,14 @@ function clearMatchingProjectSession(cwd, env, sessionId) {
|
|
|
417
400
|
})}\n`);
|
|
418
401
|
});
|
|
419
402
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
*
|
|
423
|
-
* OS idle time answers that directly, so where it exists it decides alone.
|
|
424
|
-
* Silence since the user's last prompt is only ever a proxy for it, and a poor
|
|
425
|
-
* one — it is wrong in both directions:
|
|
426
|
-
*
|
|
427
|
-
* - Too long: it counts the agent's own turn, so a user watching a build was
|
|
428
|
-
* read as absent and had the question pushed at them.
|
|
429
|
-
* - Too short: a session that has just been spawned always has a fresh
|
|
430
|
-
* prompt, so its FIRST question could never escalate however long its
|
|
431
|
-
* owner had been gone. That is the "kick off some agents and
|
|
432
|
-
* walk away" case this feature mainly exists for, and requiring both
|
|
433
|
-
* signals to agree is what broke it.
|
|
434
|
-
*
|
|
435
|
-
* The proxy therefore survives only as the fallback for machines with no idle
|
|
436
|
-
* source. There, never having seen a prompt is not evidence of absence — it is
|
|
437
|
-
* a missing `UserPromptSubmit` hook — so it resolves to "present".
|
|
438
|
-
*
|
|
439
|
-
* Answering from a companion device does not make the user present; only touching this
|
|
440
|
-
* machine does. Answering on a device is evidence of being away from it.
|
|
441
|
-
*
|
|
442
|
-
* All of which only matters if presence is being consulted at all. With
|
|
443
|
-
* `require_idle` off the user has said their whereabouts are not a
|
|
444
|
-
* precondition, so this stops guessing and answers yes.
|
|
445
|
-
*/
|
|
446
|
-
export function isUserAway(state, config, now, idleSeconds) {
|
|
447
|
-
if (!config.require_idle.value)
|
|
448
|
-
return true;
|
|
449
|
-
const threshold = config.away_after_seconds.value;
|
|
450
|
-
if (idleSeconds !== null)
|
|
451
|
-
return idleSeconds >= threshold;
|
|
452
|
-
if (state.last_prompt_at === undefined)
|
|
453
|
-
return false;
|
|
454
|
-
const silence = now - state.last_prompt_at;
|
|
455
|
-
// Both signs of a clock jump produce a wrong answer here, and the fallback
|
|
456
|
-
// path has no monotonic reference to check against: a forward jump (NTP
|
|
457
|
-
// correction, VM resume) hijacks a terminal whose user is sitting right
|
|
458
|
-
// there, and a backward one suppresses escalation for someone genuinely
|
|
459
|
-
// gone. Neither delta is evidence of anything, so it resolves the way every
|
|
460
|
-
// other absence of evidence does — present.
|
|
461
|
-
if (silence < 0 || silence > MAX_PLAUSIBLE_SILENCE_MS)
|
|
462
|
-
return false;
|
|
463
|
-
return silence >= threshold * 1000;
|
|
403
|
+
function gate(ctx, verdict, reason, data = {}) {
|
|
404
|
+
ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data });
|
|
464
405
|
}
|
|
465
406
|
/**
|
|
466
|
-
*
|
|
467
|
-
*
|
|
407
|
+
* One round of the waiter's reply poll. Short enough that several questions
|
|
408
|
+
* still share one wall clock, long enough not to hammer the server.
|
|
468
409
|
*/
|
|
469
|
-
const
|
|
470
|
-
function gate(ctx, verdict, reason, data = {}) {
|
|
471
|
-
ctx.log?.info('hook.gate', { verdict, reason, ...data });
|
|
472
|
-
}
|
|
473
|
-
/** Re-check local presence at this cadence while a pushed question is waiting. */
|
|
474
|
-
const REPLY_PRESENCE_POLL_SECONDS = 5;
|
|
410
|
+
const REPLY_POLL_SECONDS = 5;
|
|
475
411
|
/**
|
|
476
412
|
* Push a question and block for the answer.
|
|
477
413
|
*
|
|
@@ -531,29 +467,26 @@ async function submitQuestion(ctx, intent) {
|
|
|
531
467
|
return receipt;
|
|
532
468
|
}
|
|
533
469
|
/**
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
470
|
+
* The waiter's wait: one bounded poll across every live question at once.
|
|
471
|
+
*
|
|
472
|
+
* Each round polls all of them concurrently, so several questions cost the
|
|
473
|
+
* same wall clock as one; the first round that finds any reply returns
|
|
474
|
+
* everything found in that round, and whatever was not answered stays
|
|
475
|
+
* registered. A question whose polling is permanently rejected drops out of
|
|
476
|
+
* the round rather than failing the whole wait.
|
|
538
477
|
*/
|
|
539
|
-
async function
|
|
478
|
+
async function waitForAnyReply(ctx, requestIds, timeoutSeconds) {
|
|
540
479
|
const deadline = ctx.now() + timeoutSeconds * 1000;
|
|
541
480
|
let degraded = false;
|
|
542
481
|
let firstPoll = true;
|
|
543
482
|
const permanentFailures = new Map();
|
|
544
483
|
for (;;) {
|
|
545
|
-
if (monitorPresence && ctx.config.require_idle.value) {
|
|
546
|
-
const idle = ctx.idleSeconds();
|
|
547
|
-
if (idle !== null && idle < ctx.config.away_after_seconds.value) {
|
|
548
|
-
return { byRequest: new Map(), degraded, userReturned: true, permanentFailures };
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
484
|
const remainingMs = Math.max(0, deadline - ctx.now());
|
|
552
485
|
if (!firstPoll && remainingMs === 0) {
|
|
553
|
-
return { byRequest: new Map(), degraded,
|
|
486
|
+
return { byRequest: new Map(), degraded, permanentFailures };
|
|
554
487
|
}
|
|
555
488
|
firstPoll = false;
|
|
556
|
-
const pollSeconds = Math.min(
|
|
489
|
+
const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
|
|
557
490
|
const results = await Promise.all(requestIds
|
|
558
491
|
.filter((requestId) => !permanentFailures.has(requestId))
|
|
559
492
|
.map(async (requestId) => {
|
|
@@ -582,7 +515,7 @@ async function waitForAnyReplyWhileAway(ctx, requestIds, timeoutSeconds, monitor
|
|
|
582
515
|
byRequest.set(result.requestId, result.replies);
|
|
583
516
|
}
|
|
584
517
|
if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
|
|
585
|
-
return { byRequest, degraded,
|
|
518
|
+
return { byRequest, degraded, permanentFailures };
|
|
586
519
|
}
|
|
587
520
|
}
|
|
588
521
|
}
|
|
@@ -840,7 +773,15 @@ export async function drainOrphanRetirements(ctx, env, now) {
|
|
|
840
773
|
}
|
|
841
774
|
const LATE_STOP_POLL_SECONDS = 4;
|
|
842
775
|
const LATE_PROMPT_POLL_SECONDS = 3;
|
|
843
|
-
|
|
776
|
+
/**
|
|
777
|
+
* The one bound on answer deliveries, for every route.
|
|
778
|
+
*
|
|
779
|
+
* It caps two things that both used to be able to run away: how many times a
|
|
780
|
+
* single accepted answer may be handed to a route before the journal gives up
|
|
781
|
+
* on it, and how many answer-driven continuation generations one chain may
|
|
782
|
+
* produce.
|
|
783
|
+
*/
|
|
784
|
+
export const MAX_CONTINUATION_COUNT = 3;
|
|
844
785
|
/** One bounded recovery poll for a question that outlived its original wait. */
|
|
845
786
|
async function pollPendingReply(ctx, pending, timeoutSeconds) {
|
|
846
787
|
if (pending.request_id === undefined) {
|
|
@@ -878,6 +819,7 @@ function finalizedAnswer(finalized) {
|
|
|
878
819
|
function reportAnswer(ctx, notes, answered, late) {
|
|
879
820
|
ctx.log?.info('hook.answer', {
|
|
880
821
|
answered: true,
|
|
822
|
+
stage: 'queued',
|
|
881
823
|
...(late ? { late: true } : {}),
|
|
882
824
|
request_id: answered.pending.request_id,
|
|
883
825
|
device: answered.reply.device_name,
|
|
@@ -931,18 +873,27 @@ function pendingHasChoices(pending) {
|
|
|
931
873
|
* it. Free-text answers can arrive in parts, and every part reaches the
|
|
932
874
|
* agent in the order it was written, so it can tell expansion from
|
|
933
875
|
* correction itself.
|
|
876
|
+
*
|
|
877
|
+
* The continuation repeats only identity the agent actually registered and
|
|
878
|
+
* the answer the server accepted. It never invents trust, urgency, permission,
|
|
879
|
+
* or approval claims for the transport to assert.
|
|
934
880
|
*/
|
|
935
|
-
function answerContext(
|
|
881
|
+
function answerContext(answered) {
|
|
882
|
+
const { pending, replies } = answered;
|
|
936
883
|
const latest = replies.at(-1);
|
|
937
884
|
if (latest === undefined)
|
|
938
885
|
return 'Notifai — no answer was recorded.';
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
886
|
+
const answeredQuestionIds = [...new Set(latest.answers.map((answer) => answer.question_id))];
|
|
887
|
+
const identity = answeredQuestionIds.length === 0
|
|
888
|
+
? ''
|
|
889
|
+
: `question_id${answeredQuestionIds.length === 1 ? '' : 's'} ${answeredQuestionIds.join(', ')}, `;
|
|
890
|
+
const question = `question ${JSON.stringify(pending.question)}`;
|
|
891
|
+
if (replies.length === 1 || pendingHasChoices(pending)) {
|
|
892
|
+
return `Notifai — ${identity}${question}; the user answered ${JSON.stringify(latest.text)}.`;
|
|
893
|
+
}
|
|
894
|
+
const parts = replies.map((reply) => JSON.stringify(reply.text)).join(', then ');
|
|
895
|
+
return (`Notifai — ${identity}${question}; the user answered in ${replies.length} parts, ` +
|
|
896
|
+
`in the order written: ${parts}. Later parts extend or correct earlier ones.`);
|
|
946
897
|
}
|
|
947
898
|
/**
|
|
948
899
|
* Every answer that has arrived, as one message. Several registered questions
|
|
@@ -955,17 +906,18 @@ function answersContext(answered, remaining) {
|
|
|
955
906
|
? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
|
|
956
907
|
: '';
|
|
957
908
|
if (answered.length === 1) {
|
|
958
|
-
|
|
959
|
-
return answerContext(only.replies, pendingHasChoices(only.pending)) + tail;
|
|
909
|
+
return answerContext(answered[0]) + tail;
|
|
960
910
|
}
|
|
961
911
|
const lines = answered.map(({ pending, replies }) => {
|
|
962
912
|
const latest = replies.at(-1);
|
|
963
913
|
const answer = replies.length === 1 || pendingHasChoices(pending)
|
|
964
|
-
?
|
|
965
|
-
: `${replies.map((reply) =>
|
|
966
|
-
|
|
914
|
+
? JSON.stringify(latest.text)
|
|
915
|
+
: `${replies.map((reply) => JSON.stringify(reply.text)).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`;
|
|
916
|
+
const ids = [...new Set(latest.answers.map((entry) => entry.question_id))];
|
|
917
|
+
const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `;
|
|
918
|
+
return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`;
|
|
967
919
|
});
|
|
968
|
-
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}
|
|
920
|
+
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}`;
|
|
969
921
|
}
|
|
970
922
|
function stopAnswerOutput(context) {
|
|
971
923
|
return JSON.stringify({
|
|
@@ -980,15 +932,16 @@ function stopAnswerOutput(context) {
|
|
|
980
932
|
* UserPromptSubmit answers ride the user's new turn.
|
|
981
933
|
*/
|
|
982
934
|
function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
|
|
935
|
+
const accepted = {
|
|
936
|
+
answers: answered,
|
|
937
|
+
remaining,
|
|
938
|
+
recorded_at: ctx.now(),
|
|
939
|
+
};
|
|
983
940
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
984
941
|
const pendingRemaining = pendingList(current).filter((entry) => !answered.some(({ pending }) => isSamePending(entry, pending)));
|
|
985
942
|
const next = {
|
|
986
943
|
...current,
|
|
987
|
-
accepted
|
|
988
|
-
answers: answered,
|
|
989
|
-
remaining,
|
|
990
|
-
recorded_at: ctx.now(),
|
|
991
|
-
},
|
|
944
|
+
accepted,
|
|
992
945
|
};
|
|
993
946
|
if (pendingRemaining.length > 0)
|
|
994
947
|
next.pending = pendingRemaining;
|
|
@@ -996,8 +949,20 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
|
|
|
996
949
|
delete next.pending;
|
|
997
950
|
return next;
|
|
998
951
|
});
|
|
952
|
+
return accepted;
|
|
999
953
|
}
|
|
1000
|
-
/**
|
|
954
|
+
/** Amend the live accepted record in place, without resurrecting a settled one. */
|
|
955
|
+
function amendAcceptedAnswers(ctx, sessionId, amend) {
|
|
956
|
+
updateSessionState(sessionId, ctx.env, (current) => current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) });
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Retire the answered questions and close the journal.
|
|
960
|
+
*
|
|
961
|
+
* Called once an answer's delivery is acknowledged — by the route's own write,
|
|
962
|
+
* or by the successor Stop of a blocking continuation. The continuation counter
|
|
963
|
+
* grows with each settled generation, so the cap that bounds chained
|
|
964
|
+
* answer-to-question loops is a real count rather than a constant 1.
|
|
965
|
+
*/
|
|
1001
966
|
function settleAcceptedAnswers(ctx, sessionId, accepted) {
|
|
1002
967
|
const retirements = accepted.answers
|
|
1003
968
|
.map(({ pending }) => retiringQuestion(pending, 'answered'))
|
|
@@ -1015,7 +980,7 @@ function settleAcceptedAnswers(ctx, sessionId, accepted) {
|
|
|
1015
980
|
delete next.accepted;
|
|
1016
981
|
next.continuation = {
|
|
1017
982
|
answered_at: accepted.recorded_at,
|
|
1018
|
-
count:
|
|
983
|
+
count: (current.continuation?.count ?? 0) + 1,
|
|
1019
984
|
};
|
|
1020
985
|
return next;
|
|
1021
986
|
});
|
|
@@ -1068,7 +1033,9 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
1068
1033
|
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
1069
1034
|
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
|
|
1070
1035
|
}
|
|
1071
|
-
notes.push(
|
|
1036
|
+
notes.push(state.accepted.delivered_at === undefined
|
|
1037
|
+
? 'a device answer is safely journaled; the next Stop will deliver it'
|
|
1038
|
+
: 'a device answer has already been delivered; the next Stop will close it out');
|
|
1072
1039
|
return { notes };
|
|
1073
1040
|
}
|
|
1074
1041
|
const live = pendingList(state).filter((entry) => entry.request_id !== undefined);
|
|
@@ -1115,7 +1082,14 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
1115
1082
|
...(unasked.length > 0 ? { pending: unasked } : {}),
|
|
1116
1083
|
...(retiring.length > 0 ? { retiring } : {}),
|
|
1117
1084
|
...(current.accepted === undefined ? {} : { accepted: current.accepted }),
|
|
1118
|
-
|
|
1085
|
+
// The user typed, so whatever chain of answer-driven continuations was
|
|
1086
|
+
// running, a human has taken the turn and the consecutive count starts
|
|
1087
|
+
// over. Only a real prompt reaches here: the journal branch above returns
|
|
1088
|
+
// first, and a wake route's injected message always arrives while its own
|
|
1089
|
+
// answer is still journaled, so it can never pass for the user's presence.
|
|
1090
|
+
...(current.continuation === undefined
|
|
1091
|
+
? {}
|
|
1092
|
+
: { continuation: { ...current.continuation, count: 0 } }),
|
|
1119
1093
|
};
|
|
1120
1094
|
});
|
|
1121
1095
|
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
@@ -1147,14 +1121,27 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
1147
1121
|
* `notifai ask`. Guessing from the last assistant message was the alternative
|
|
1148
1122
|
* and it is not worth it: a false positive here hijacks the terminal.
|
|
1149
1123
|
*/
|
|
1150
|
-
export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() +
|
|
1151
|
-
const notes = [];
|
|
1152
|
-
const hardDeadlineAt = processDeadlineAt;
|
|
1124
|
+
export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() + WAITER_CEILING_SECONDS * 1000, route = hookContinuationRoute()) {
|
|
1153
1125
|
const sessionId = envelope.session_id;
|
|
1154
1126
|
if (!sessionId) {
|
|
1155
|
-
gate(ctx, 'held', 'no-session');
|
|
1156
|
-
return { notes };
|
|
1157
|
-
}
|
|
1127
|
+
gate(ctx, 'held', 'no-session', { stage: 'queued' });
|
|
1128
|
+
return { notes: [] };
|
|
1129
|
+
}
|
|
1130
|
+
return runEscalationWaiter(ctx, {
|
|
1131
|
+
sessionId,
|
|
1132
|
+
envelope,
|
|
1133
|
+
route,
|
|
1134
|
+
processDeadlineAt,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Own one registered question pipeline from its terminal-first timer through
|
|
1139
|
+
* its final delivery or retirement. Hosts differ only in the injected route.
|
|
1140
|
+
*/
|
|
1141
|
+
export async function runEscalationWaiter(ctx, options) {
|
|
1142
|
+
const notes = [];
|
|
1143
|
+
const { sessionId, envelope } = options;
|
|
1144
|
+
const hardDeadlineAt = options.processDeadlineAt ?? ctx.now() + WAITER_CEILING_SECONDS * 1000;
|
|
1158
1145
|
// This event marker is diagnostic evidence in its own right. Record it
|
|
1159
1146
|
// before every early return so doctor can distinguish a working Stop route
|
|
1160
1147
|
// from the prompt-only state that previously looked healthy.
|
|
@@ -1162,49 +1149,39 @@ export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() +
|
|
|
1162
1149
|
...current,
|
|
1163
1150
|
last_stop_at: ctx.now(),
|
|
1164
1151
|
}));
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
// replay/ack, late-answer collection, cleanup, and any new escalation.
|
|
1170
|
-
//
|
|
1171
|
-
// Real clock, deliberately, not `ctx.now` — the claim answers "is another
|
|
1172
|
-
// process alive right now", which the injectable clock cannot speak to. It
|
|
1173
|
-
// is also the only clock the *other* process shares.
|
|
1152
|
+
// The waiter, not its host, owns this lease. It spans grace, submission,
|
|
1153
|
+
// polling, close fencing, route delivery, and every retirement path.
|
|
1154
|
+
// Real clock, deliberately, not `ctx.now` — the claim answers whether
|
|
1155
|
+
// another process is alive right now, which an injected clock cannot know.
|
|
1174
1156
|
if (!claimQuestionPush(sessionId, ctx.env)) {
|
|
1175
|
-
gate(ctx, 'held', 'claimed-elsewhere');
|
|
1157
|
+
gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' });
|
|
1176
1158
|
notes.push('another hook is already handling this question');
|
|
1177
1159
|
return { notes };
|
|
1178
1160
|
}
|
|
1161
|
+
let settledAnswerThisPass = false;
|
|
1179
1162
|
try {
|
|
1180
1163
|
let state = readSessionState(sessionId, ctx.env);
|
|
1181
1164
|
if (state.accepted !== undefined) {
|
|
1182
1165
|
const accepted = state.accepted;
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
1187
|
-
|
|
1166
|
+
// Acknowledgement is per route, because routes end in different places.
|
|
1167
|
+
//
|
|
1168
|
+
// A blocking continuation ends at this process's stdout, which the
|
|
1169
|
+
// harness reads only after the process exits: the recursive Stop's
|
|
1170
|
+
// `stop_hook_active` is the acknowledgement, and persisting a separate
|
|
1171
|
+
// "offered" bit before stdout would recreate the crash gap this journal
|
|
1172
|
+
// exists to close. An out-of-band route ends at a write this process made
|
|
1173
|
+
// itself and recorded here. A socket write is not proof the model acted
|
|
1174
|
+
// on the answer — delivery is not consumption — but nothing later can
|
|
1175
|
+
// prove more, and an answer redelivered without end is strictly worse
|
|
1176
|
+
// than one settled on delivery. So a recorded delivery settles.
|
|
1177
|
+
const deliveryProven = accepted.delivered_at !== undefined || envelope.stop_hook_active === true;
|
|
1188
1178
|
if (!deliveryProven) {
|
|
1189
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1190
|
-
if (current.accepted === undefined)
|
|
1191
|
-
return current;
|
|
1192
|
-
return {
|
|
1193
|
-
...current,
|
|
1194
|
-
accepted: {
|
|
1195
|
-
...current.accepted,
|
|
1196
|
-
continuation_count: (current.continuation?.count ?? 0) + 1,
|
|
1197
|
-
},
|
|
1198
|
-
};
|
|
1199
|
-
});
|
|
1200
1179
|
for (const answer of accepted.answers)
|
|
1201
1180
|
reportAnswer(ctx, notes, answer, true);
|
|
1202
|
-
return
|
|
1203
|
-
stdout: stopAnswerOutput(answersContext(accepted.answers, accepted.remaining)),
|
|
1204
|
-
notes,
|
|
1205
|
-
};
|
|
1181
|
+
return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes);
|
|
1206
1182
|
}
|
|
1207
1183
|
settleAcceptedAnswers(ctx, sessionId, accepted);
|
|
1184
|
+
settledAnswerThisPass = true;
|
|
1208
1185
|
state = readSessionState(sessionId, ctx.env);
|
|
1209
1186
|
}
|
|
1210
1187
|
const pending = pendingList(state);
|
|
@@ -1216,17 +1193,114 @@ export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() +
|
|
|
1216
1193
|
if (swept.length > 0) {
|
|
1217
1194
|
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
1218
1195
|
}
|
|
1219
|
-
gate(ctx, 'held', 'no-question');
|
|
1196
|
+
gate(ctx, 'held', 'no-question', { stage: 'queued' });
|
|
1220
1197
|
return { notes };
|
|
1221
1198
|
}
|
|
1222
|
-
return await handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt
|
|
1199
|
+
return await handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, options.route,
|
|
1200
|
+
// Every route's successor turn-end, named the same way: the harness flag
|
|
1201
|
+
// for a blocking continuation, and the settle above for a route that woke
|
|
1202
|
+
// a brand-new turn out of band.
|
|
1203
|
+
envelope.stop_hook_active === true || settledAnswerThisPass);
|
|
1223
1204
|
}
|
|
1224
1205
|
finally {
|
|
1225
1206
|
releaseQuestionPush(sessionId, ctx.env);
|
|
1226
1207
|
}
|
|
1227
1208
|
}
|
|
1209
|
+
/**
|
|
1210
|
+
* The blocking Stop hook's own last meter: the answer becomes this process's
|
|
1211
|
+
* stdout, and the harness admits it as a new user turn. Exported because a
|
|
1212
|
+
* host adapter that has other routes still falls back to this one.
|
|
1213
|
+
*/
|
|
1214
|
+
export function hookContinuationRoute() {
|
|
1215
|
+
return {
|
|
1216
|
+
kind: 'hook-continuation',
|
|
1217
|
+
deliver: async (event) => ({
|
|
1218
|
+
stdout: stopAnswerOutput(event.context),
|
|
1219
|
+
notes: [],
|
|
1220
|
+
// The harness reads this stdout after the process exits, so only the
|
|
1221
|
+
// successor Stop can acknowledge it.
|
|
1222
|
+
acknowledgement: 'stdout',
|
|
1223
|
+
}),
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Hand one accepted answer to a route and record what the attempt proved.
|
|
1228
|
+
*
|
|
1229
|
+
* Every delivery on every route passes through here, which is what makes the
|
|
1230
|
+
* cap below a backstop rather than a suggestion. A guard only one route
|
|
1231
|
+
* consults is not a backstop: that is how a single answer once reached a
|
|
1232
|
+
* session 250 times.
|
|
1233
|
+
*/
|
|
1234
|
+
async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes) {
|
|
1235
|
+
const { answers: answered, remaining } = accepted;
|
|
1236
|
+
const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids;
|
|
1237
|
+
const attempt = (accepted.delivery_attempts ?? 0) + 1;
|
|
1238
|
+
if (attempt > MAX_CONTINUATION_COUNT) {
|
|
1239
|
+
gate(ctx, 'held', 'delivery-limit', {
|
|
1240
|
+
route: route.kind,
|
|
1241
|
+
attempts: accepted.delivery_attempts ?? 0,
|
|
1242
|
+
limit: MAX_CONTINUATION_COUNT,
|
|
1243
|
+
request_ids: requestIds,
|
|
1244
|
+
});
|
|
1245
|
+
// Settle rather than journal for ever. The answer has been offered as often
|
|
1246
|
+
// as any working route could need, it is already in the notes of each of
|
|
1247
|
+
// those turns, and the questions it answered still deserve retirement.
|
|
1248
|
+
settleAcceptedAnswers(ctx, sessionId, accepted);
|
|
1249
|
+
notes.push(`the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`);
|
|
1250
|
+
return { notes };
|
|
1251
|
+
}
|
|
1252
|
+
// Count the attempt before making it: an attempt that dies mid-delivery still
|
|
1253
|
+
// happened, and a counter written afterwards could be evaded by crashing.
|
|
1254
|
+
amendAcceptedAnswers(ctx, sessionId, (current) => ({
|
|
1255
|
+
...current,
|
|
1256
|
+
delivery_attempts: attempt,
|
|
1257
|
+
}));
|
|
1258
|
+
ctx.log?.info('hook.gate', {
|
|
1259
|
+
verdict: 'proceeding',
|
|
1260
|
+
reason: 'answered',
|
|
1261
|
+
stage: 'routed',
|
|
1262
|
+
route: route.kind,
|
|
1263
|
+
answers: answered.length,
|
|
1264
|
+
request_ids: requestIds,
|
|
1265
|
+
journal_recorded_at: accepted.recorded_at,
|
|
1266
|
+
delivery_attempt: attempt,
|
|
1267
|
+
});
|
|
1268
|
+
const delivered = await route.deliver({
|
|
1269
|
+
context: answersContext(answered, remaining),
|
|
1270
|
+
answers: answered.length,
|
|
1271
|
+
remaining,
|
|
1272
|
+
request_ids: requestIds,
|
|
1273
|
+
journal_recorded_at: accepted.recorded_at,
|
|
1274
|
+
});
|
|
1275
|
+
const deliveredRoute = typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind;
|
|
1276
|
+
const deliveredStage = typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered';
|
|
1277
|
+
if (delivered.acknowledgement === 'delivered') {
|
|
1278
|
+
amendAcceptedAnswers(ctx, sessionId, (current) => ({
|
|
1279
|
+
...current,
|
|
1280
|
+
delivered_at: ctx.now(),
|
|
1281
|
+
delivered_route: deliveredRoute,
|
|
1282
|
+
}));
|
|
1283
|
+
}
|
|
1284
|
+
ctx.log?.info('hook.answer', {
|
|
1285
|
+
answered: true,
|
|
1286
|
+
stage: deliveredStage,
|
|
1287
|
+
route: deliveredRoute,
|
|
1288
|
+
acknowledgement: delivered.acknowledgement,
|
|
1289
|
+
delivery_attempt: attempt,
|
|
1290
|
+
request_ids: requestIds,
|
|
1291
|
+
answers: answered.length,
|
|
1292
|
+
journal_recorded_at: accepted.recorded_at,
|
|
1293
|
+
...(delivered.log?.['reason'] === undefined ? {} : { reason: delivered.log['reason'] }),
|
|
1294
|
+
});
|
|
1295
|
+
const outcome = { notes: [...notes, ...(delivered.notes ?? [])] };
|
|
1296
|
+
if (delivered.stdout !== undefined)
|
|
1297
|
+
outcome.stdout = delivered.stdout;
|
|
1298
|
+
if (delivered.log !== undefined)
|
|
1299
|
+
outcome.log = delivered.log;
|
|
1300
|
+
return outcome;
|
|
1301
|
+
}
|
|
1228
1302
|
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
1229
|
-
async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt) {
|
|
1303
|
+
async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, route, continuingFromAnswer) {
|
|
1230
1304
|
const live = pending.filter((entry) => entry.request_id !== undefined);
|
|
1231
1305
|
const unasked = pending.filter((entry) => entry.request_id === undefined);
|
|
1232
1306
|
let liveToEscalate = live;
|
|
@@ -1259,16 +1333,12 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1259
1333
|
}
|
|
1260
1334
|
return { ...entry, replies, reply: replies.at(-1) };
|
|
1261
1335
|
});
|
|
1262
|
-
stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
|
|
1263
|
-
gate(ctx, 'proceeding', 'answered', { answers: authoritative.length });
|
|
1336
|
+
const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
|
|
1264
1337
|
for (const answer of authoritative)
|
|
1265
1338
|
reportAnswer(ctx, notes, answer, true);
|
|
1266
1339
|
// Anything still unasked rides the next Stop: the agent is being resumed
|
|
1267
1340
|
// with answers right now, and may not even need the rest afterwards.
|
|
1268
|
-
return
|
|
1269
|
-
stdout: stopAnswerOutput(answersContext(authoritative, pending.length - authoritative.length - permanentlyRejected.length)),
|
|
1270
|
-
notes,
|
|
1271
|
-
};
|
|
1341
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
|
|
1272
1342
|
}
|
|
1273
1343
|
const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
|
|
1274
1344
|
liveToEscalate = recoverableLive;
|
|
@@ -1279,7 +1349,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1279
1349
|
// questions remain. The next Stop is their successor owner: keep waiting
|
|
1280
1350
|
// to the original absolute deadline instead of polling for four seconds
|
|
1281
1351
|
// and abandoning them.
|
|
1282
|
-
return await escalate(ctx, envelope, sessionId, [], recoverableLive, notes, hardDeadlineAt);
|
|
1352
|
+
return await escalate(ctx, envelope, sessionId, [], recoverableLive, notes, hardDeadlineAt, route);
|
|
1283
1353
|
}
|
|
1284
1354
|
// Questions registered after the earlier push still owe the user their
|
|
1285
1355
|
// notification; fall through and escalate just those.
|
|
@@ -1296,14 +1366,22 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1296
1366
|
notes.push('already continuing from an answer; not asking again this turn');
|
|
1297
1367
|
return { notes };
|
|
1298
1368
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1369
|
+
}
|
|
1370
|
+
// The cap is deliberately not keyed to `stop_hook_active`. That flag names one
|
|
1371
|
+
// route's continuation; a route that wakes a brand-new turn never sets it, and
|
|
1372
|
+
// a limit only one route consults bounds only that route. `continuingFromAnswer`
|
|
1373
|
+
// is the same event named for every route, so the chain of answer → follow-up
|
|
1374
|
+
// question → answer is bounded whatever delivered it.
|
|
1375
|
+
if (continuingFromAnswer &&
|
|
1376
|
+
state.continuation !== undefined &&
|
|
1377
|
+
state.continuation.count >= MAX_CONTINUATION_COUNT) {
|
|
1378
|
+
gate(ctx, 'held', 'continuation-limit', {
|
|
1379
|
+
count: state.continuation.count,
|
|
1380
|
+
limit: MAX_CONTINUATION_COUNT,
|
|
1381
|
+
route: route.kind,
|
|
1382
|
+
});
|
|
1383
|
+
notes.push(`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`);
|
|
1384
|
+
return { notes };
|
|
1307
1385
|
}
|
|
1308
1386
|
// Silent to the user by design — they switched routing off, so saying so on
|
|
1309
1387
|
// every turn would be nagging about their own setting. That silence is also
|
|
@@ -1313,61 +1391,46 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1313
1391
|
gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source });
|
|
1314
1392
|
return { notes };
|
|
1315
1393
|
}
|
|
1316
|
-
const idle = ctx.idleSeconds();
|
|
1317
|
-
if (!isUserAway(state, ctx.config, ctx.now(), idle)) {
|
|
1318
|
-
gate(ctx, 'held', 'user-present', {
|
|
1319
|
-
idle_seconds: idle,
|
|
1320
|
-
away_after_seconds: ctx.config.away_after_seconds.value,
|
|
1321
|
-
require_idle: ctx.config.require_idle.value,
|
|
1322
|
-
last_prompt_at: state.last_prompt_at ?? null,
|
|
1323
|
-
});
|
|
1324
|
-
notes.push('you are at the keyboard; leaving the question in the terminal');
|
|
1325
|
-
return { notes };
|
|
1326
|
-
}
|
|
1327
1394
|
gate(ctx, 'proceeding', 'proceeding', {
|
|
1328
1395
|
unasked: unasked.length,
|
|
1329
1396
|
already_live: liveToEscalate.length,
|
|
1330
|
-
idle_seconds: idle,
|
|
1331
1397
|
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
1332
1398
|
});
|
|
1333
|
-
return await escalate(ctx, envelope, sessionId, unasked, liveToEscalate, notes, hardDeadlineAt);
|
|
1399
|
+
return await escalate(ctx, envelope, sessionId, unasked, liveToEscalate, notes, hardDeadlineAt, route);
|
|
1334
1400
|
}
|
|
1335
1401
|
/** The escalation itself, split out so the claim is released on every path. */
|
|
1336
|
-
async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, hardDeadlineAt) {
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1340
|
-
const
|
|
1341
|
-
const existingDeadlines = alreadyLive.flatMap((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <=
|
|
1402
|
+
async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, hardDeadlineAt, route) {
|
|
1403
|
+
// The questions still owe the user their terminal-first window before
|
|
1404
|
+
// anything reaches their devices — measured from the oldest registration,
|
|
1405
|
+
// because that is the question that has waited longest.
|
|
1406
|
+
const startedAt = ctx.now();
|
|
1407
|
+
const existingDeadlines = alreadyLive.flatMap((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= startedAt
|
|
1342
1408
|
? []
|
|
1343
1409
|
: [entry.reply_deadline_at]);
|
|
1344
|
-
const
|
|
1410
|
+
const ceilingAt = Math.min(hardDeadlineAt, ...(existingDeadlines.length === 0 ? [] : existingDeadlines));
|
|
1345
1411
|
if (unasked.length > 0 && alreadyLive.length === 0) {
|
|
1346
1412
|
const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()));
|
|
1347
|
-
|
|
1348
|
-
ctx.log?.debug('hook.gate', {
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
}
|
|
1354
|
-
if (grace === 'no-signal') {
|
|
1355
|
-
notes.push('no idle signal on this machine; asking now rather than holding the terminal');
|
|
1356
|
-
}
|
|
1413
|
+
await awaitTerminalFirstWindow(ctx, oldest, ceilingAt);
|
|
1414
|
+
ctx.log?.debug('hook.gate', {
|
|
1415
|
+
verdict: 'grace',
|
|
1416
|
+
reason: 'elapsed',
|
|
1417
|
+
waited_from: oldest,
|
|
1418
|
+
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
1419
|
+
});
|
|
1357
1420
|
}
|
|
1358
1421
|
// Phase one: every registered question reaches the user's devices, each as
|
|
1359
1422
|
// its own notification — one ask never stands in for another.
|
|
1360
1423
|
const submitted = [];
|
|
1361
1424
|
for (const entry of unasked) {
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1425
|
+
// The reply window is the question's own: whatever is left of the waiter's
|
|
1426
|
+
// ceiling, granted to the server so it stops accepting answers at the same
|
|
1427
|
+
// moment this owner stops listening for them.
|
|
1428
|
+
const replyWindowSeconds = Math.floor((ceilingAt - ctx.now()) / 1000);
|
|
1429
|
+
if (replyWindowSeconds < MIN_REPLY_WINDOW_SECONDS) {
|
|
1430
|
+
notes.push('too little of the waiter ceiling is left for a reply window the server would accept; leaving this question in the terminal');
|
|
1366
1431
|
continue;
|
|
1367
1432
|
}
|
|
1368
|
-
const
|
|
1369
|
-
const ownerDeadlineAt = Math.min(budgetDeadlineAt, ctx.now() +
|
|
1370
|
-
(replyWindowSeconds + HOOK_TIMING.submissionReserveSeconds) * 1000);
|
|
1433
|
+
const ownerDeadlineAt = ceilingAt;
|
|
1371
1434
|
const questions = pendingQuestions(entry);
|
|
1372
1435
|
let intent = entry.submission;
|
|
1373
1436
|
if (intent === undefined) {
|
|
@@ -1410,12 +1473,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1410
1473
|
// The frozen wire identity survives a crashed owner, but its local owner
|
|
1411
1474
|
// lease does not. Re-arm only the local deadline; request id, key,
|
|
1412
1475
|
// targets, and draft remain byte-identical for idempotent replay.
|
|
1413
|
-
|
|
1414
|
-
intent = {
|
|
1415
|
-
...intent,
|
|
1416
|
-
owner_deadline_at: Math.min(budgetDeadlineAt, ctx.now() +
|
|
1417
|
-
(frozenReplyWindow + HOOK_TIMING.submissionReserveSeconds) * 1000),
|
|
1418
|
-
};
|
|
1476
|
+
intent = { ...intent, owner_deadline_at: ceilingAt };
|
|
1419
1477
|
const rearmed = intent;
|
|
1420
1478
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1421
1479
|
const list = pendingList(current);
|
|
@@ -1428,13 +1486,12 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1428
1486
|
});
|
|
1429
1487
|
}
|
|
1430
1488
|
if (ctx.now() >= intent.owner_deadline_at) {
|
|
1431
|
-
notes.push('the
|
|
1489
|
+
notes.push('the waiter ceiling ended before submission; preserving the frozen intent');
|
|
1432
1490
|
continue;
|
|
1433
1491
|
}
|
|
1434
1492
|
const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000);
|
|
1435
|
-
if (beforeSubmitSeconds <
|
|
1436
|
-
|
|
1437
|
-
notes.push('device resolution consumed the submission reserve; preserving the frozen intent instead of shrinking its reply window');
|
|
1493
|
+
if (beforeSubmitSeconds < MIN_REPLY_WINDOW_SECONDS) {
|
|
1494
|
+
notes.push('device resolution consumed the reply window; preserving the frozen intent instead of shrinking it below what the server accepts');
|
|
1438
1495
|
continue;
|
|
1439
1496
|
}
|
|
1440
1497
|
let receipt;
|
|
@@ -1536,13 +1593,10 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1536
1593
|
}
|
|
1537
1594
|
if (staleAnswers.length > 0) {
|
|
1538
1595
|
const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length);
|
|
1539
|
-
stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining);
|
|
1596
|
+
const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining);
|
|
1540
1597
|
for (const answer of staleAnswers)
|
|
1541
1598
|
reportAnswer(ctx, notes, answer, true);
|
|
1542
|
-
return
|
|
1543
|
-
stdout: stopAnswerOutput(answersContext(staleAnswers, remaining)),
|
|
1544
|
-
notes,
|
|
1545
|
-
};
|
|
1599
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
|
|
1546
1600
|
}
|
|
1547
1601
|
const waitingOn = [
|
|
1548
1602
|
...alreadyLive.filter((entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now()),
|
|
@@ -1550,10 +1604,10 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1550
1604
|
];
|
|
1551
1605
|
if (waitingOn.length === 0)
|
|
1552
1606
|
return { notes };
|
|
1553
|
-
// Phase two: one
|
|
1554
|
-
const ownerDeadline = Math.min(
|
|
1607
|
+
// Phase two: one wait across everything live, old and new alike.
|
|
1608
|
+
const ownerDeadline = Math.min(ceilingAt, Math.max(...waitingOn.map((entry) => entry.reply_deadline_at)));
|
|
1555
1609
|
const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000));
|
|
1556
|
-
const waited = await
|
|
1610
|
+
const waited = await waitForAnyReply(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds);
|
|
1557
1611
|
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
|
|
1558
1612
|
if (permanentFailure !== null)
|
|
1559
1613
|
notes.push(permanentFailure);
|
|
@@ -1570,34 +1624,28 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1570
1624
|
.map((entry) => entry.pending);
|
|
1571
1625
|
const unproven = finalized.filter((entry) => entry.response === null);
|
|
1572
1626
|
if (confirmedSilent.length > 0) {
|
|
1573
|
-
await retirePendings(ctx, envelope, sessionId, confirmedSilent,
|
|
1627
|
+
await retirePendings(ctx, envelope, sessionId, confirmedSilent, 'expired');
|
|
1574
1628
|
}
|
|
1575
1629
|
if (unproven.length > 0) {
|
|
1576
1630
|
notes.push(`could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`);
|
|
1577
1631
|
}
|
|
1578
1632
|
if (finalAnswers.length > 0) {
|
|
1579
1633
|
const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length);
|
|
1580
|
-
stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining);
|
|
1634
|
+
const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining);
|
|
1581
1635
|
for (const answer of finalAnswers)
|
|
1582
1636
|
reportAnswer(ctx, notes, answer, false);
|
|
1583
|
-
return
|
|
1584
|
-
stdout: stopAnswerOutput(answersContext(finalAnswers, remaining)),
|
|
1585
|
-
notes,
|
|
1586
|
-
};
|
|
1637
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
|
|
1587
1638
|
}
|
|
1588
1639
|
const requestIdSummary = summarizeRequestIds(waitingOn);
|
|
1589
1640
|
ctx.log?.info('hook.answer', {
|
|
1590
1641
|
answered: false,
|
|
1642
|
+
stage: 'queued',
|
|
1591
1643
|
request_ids: requestIdSummary.ids,
|
|
1592
|
-
user_returned: waited.userReturned,
|
|
1593
1644
|
degraded: waited.degraded,
|
|
1594
1645
|
permanent_failures: waited.permanentFailures.size,
|
|
1595
1646
|
waited_seconds: timeoutSeconds,
|
|
1596
1647
|
});
|
|
1597
|
-
if (waited.
|
|
1598
|
-
notes.push(`you came back after the question${waitingOn.length === 1 ? ' was' : 's were'} sent; ${waitingOn.length === 1 ? 'it was' : 'they were'} retired before returning the terminal (${requestIdSummary.display})`);
|
|
1599
|
-
}
|
|
1600
|
-
else if (waited.permanentFailures.size === 0) {
|
|
1648
|
+
if (waited.permanentFailures.size === 0) {
|
|
1601
1649
|
notes.push(waited.degraded
|
|
1602
1650
|
? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
|
|
1603
1651
|
: 'no answer in time; the question expired with its continuation owner');
|
|
@@ -1622,13 +1670,10 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1622
1670
|
notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
|
|
1623
1671
|
}
|
|
1624
1672
|
}
|
|
1625
|
-
stageAcceptedAnswers(ctx, sessionId, answered, waitingOn.length - answered.length);
|
|
1673
|
+
const accepted = stageAcceptedAnswers(ctx, sessionId, answered, waitingOn.length - answered.length);
|
|
1626
1674
|
for (const answer of answered)
|
|
1627
1675
|
reportAnswer(ctx, notes, answer, false);
|
|
1628
|
-
return
|
|
1629
|
-
stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
|
|
1630
|
-
notes,
|
|
1631
|
-
};
|
|
1676
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
|
|
1632
1677
|
}
|
|
1633
1678
|
// ---------------------------------------------------------------------------
|
|
1634
1679
|
// SessionEnd — local cleanup only
|