@raidiant/notifai 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +56 -7
- package/dist/commands.js +980 -318
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +46 -8
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +3 -3
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +97 -36
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/config-view.js +3 -0
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +3 -2
- package/src/atomic-file.ts +86 -7
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/commands.test.ts +891 -43
- package/src/commands.ts +1133 -332
- package/src/config-schema.ts +51 -9
- package/src/config.test.ts +6 -5
- package/src/config.ts +85 -16
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +48 -0
- package/src/harnesses.ts +59 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1318 -240
- package/src/hooks.ts +1046 -296
- package/src/install-hooks.test.ts +90 -41
- package/src/install-hooks.ts +259 -74
- package/src/interactive.ts +3 -3
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +113 -32
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +11 -0
- package/src/ui/config-view.test.ts +4 -2
- package/src/ui/config-view.ts +3 -0
package/dist/hooks.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
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
|
+
import { ApiCallError, isRetryableReplyPollError, } from './client.js';
|
|
4
5
|
import { loadConfig, projectSessionPointerPath, sanitizeSessionId, sessionConfigPath, stateDir, } from './config.js';
|
|
5
6
|
import { buildDraft } from './send.js';
|
|
6
7
|
import { atomicWriteFileSync } from './atomic-file.js';
|
|
8
|
+
import { withFileLock } from './file-lock.js';
|
|
9
|
+
import { HARNESS_CAPABILITIES, HARNESSES, HOOK_TIMING } from './harnesses.js';
|
|
7
10
|
/**
|
|
8
11
|
* Past this, the question's reply window (3600s) has long expired server-side
|
|
9
12
|
* and the companion shows it as dead on next open anyway; pushing a retirement
|
|
@@ -13,14 +16,20 @@ import { atomicWriteFileSync } from './atomic-file.js';
|
|
|
13
16
|
const ORPHAN_TTL_MS = 24 * 3600 * 1000;
|
|
14
17
|
/** More orphans than this means something is looping; keep the newest. */
|
|
15
18
|
const ORPHAN_QUEUE_CAP = 50;
|
|
19
|
+
function summarizeRequestIds(entries) {
|
|
20
|
+
const ids = entries.flatMap((entry) => entry.request_id === undefined ? [] : [entry.request_id]);
|
|
21
|
+
return { ids, display: ids.join(', ') };
|
|
22
|
+
}
|
|
16
23
|
/** How often the grace window rechecks whether the user has come back. */
|
|
17
24
|
const GRACE_POLL_MS = 5_000;
|
|
18
25
|
/**
|
|
19
|
-
* Total seconds
|
|
20
|
-
*
|
|
21
|
-
*
|
|
26
|
+
* Total seconds the complete Stop invocation may own. Generated adapters grant
|
|
27
|
+
* another minute of host headroom, because a killed hook can lose an answer
|
|
28
|
+
* the user has already given. Grace and reply waiting share this budget while
|
|
29
|
+
* the reply retains a small actionable window and finalization reserve.
|
|
22
30
|
*/
|
|
23
|
-
const STOP_BUDGET_SECONDS =
|
|
31
|
+
const STOP_BUDGET_SECONDS = HOOK_TIMING.totalSeconds;
|
|
32
|
+
const MIN_REPLY_WAIT_SECONDS = HOOK_TIMING.minimumReplySeconds;
|
|
24
33
|
/**
|
|
25
34
|
* The terminal-first wait: the question sits in the terminal for
|
|
26
35
|
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
@@ -42,8 +51,8 @@ const STOP_BUDGET_SECONDS = 480;
|
|
|
42
51
|
*/
|
|
43
52
|
export async function awaitGrace(ctx, askedAt) {
|
|
44
53
|
const threshold = ctx.config.away_after_seconds.value;
|
|
45
|
-
//
|
|
46
|
-
const graceSeconds = Math.min(ctx.config.ask_grace_seconds.value,
|
|
54
|
+
// Keep a real device-answer window even when a user selects maximum grace.
|
|
55
|
+
const graceSeconds = Math.min(ctx.config.ask_grace_seconds.value, HOOK_TIMING.maxGraceSeconds);
|
|
47
56
|
// `asked_at` is wall-clock too, and the same jumps apply: a stamp from the
|
|
48
57
|
// future would wait far past the hook's budget, one from the distant past
|
|
49
58
|
// would skip the terminal-first window the user asked for entirely. Anything
|
|
@@ -105,63 +114,6 @@ function updateSessionState(sessionId, env, update) {
|
|
|
105
114
|
return next;
|
|
106
115
|
});
|
|
107
116
|
}
|
|
108
|
-
/**
|
|
109
|
-
* Serialize a short read-modify-write without ever exposing partial JSON.
|
|
110
|
-
* A crashed holder is recoverable after 30 seconds; releases compare their
|
|
111
|
-
* random token under the same cooperating lock discipline before unlinking.
|
|
112
|
-
*/
|
|
113
|
-
const FILE_LOCK_STALE_MS = 30_000;
|
|
114
|
-
const FILE_LOCK_WAIT_MS = 1_000;
|
|
115
|
-
const FILE_LOCK_POLL_MS = 5;
|
|
116
|
-
const lockSleep = new Int32Array(new SharedArrayBuffer(4));
|
|
117
|
-
function withFileLock(file, action) {
|
|
118
|
-
mkdirSync(path.dirname(file), { recursive: true });
|
|
119
|
-
const token = randomBytes(12).toString('base64url');
|
|
120
|
-
const deadline = Date.now() + FILE_LOCK_WAIT_MS;
|
|
121
|
-
for (;;) {
|
|
122
|
-
try {
|
|
123
|
-
const handle = openSync(file, 'wx', 0o600);
|
|
124
|
-
try {
|
|
125
|
-
writeFileSync(handle, `${JSON.stringify({ token, at: Date.now() })}\n`);
|
|
126
|
-
}
|
|
127
|
-
finally {
|
|
128
|
-
closeSync(handle);
|
|
129
|
-
}
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
catch (err) {
|
|
133
|
-
if (err.code !== 'EEXIST')
|
|
134
|
-
throw err;
|
|
135
|
-
let stale = false;
|
|
136
|
-
try {
|
|
137
|
-
const held = JSON.parse(readFileSync(file, 'utf8'));
|
|
138
|
-
stale = typeof held.at !== 'number' || Date.now() - held.at >= FILE_LOCK_STALE_MS;
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
stale = true;
|
|
142
|
-
}
|
|
143
|
-
if (stale)
|
|
144
|
-
rmSync(file, { force: true });
|
|
145
|
-
else if (Date.now() >= deadline)
|
|
146
|
-
throw new Error(`timed out waiting for state lock ${file}`);
|
|
147
|
-
else
|
|
148
|
-
Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
try {
|
|
152
|
-
return action();
|
|
153
|
-
}
|
|
154
|
-
finally {
|
|
155
|
-
try {
|
|
156
|
-
const held = JSON.parse(readFileSync(file, 'utf8'));
|
|
157
|
-
if (held.token === token)
|
|
158
|
-
rmSync(file, { force: true });
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
// A replaced or externally removed lock is no longer ours to release.
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
117
|
/**
|
|
166
118
|
* Session state a crashed harness left behind.
|
|
167
119
|
*
|
|
@@ -223,8 +175,9 @@ export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION
|
|
|
223
175
|
* Exclusive create is atomic on POSIX, so exactly one process gets the claim
|
|
224
176
|
* and the other steps aside. A short guard serializes stale replacement with
|
|
225
177
|
* contenders and releases; random ownership tokens keep an old holder from
|
|
226
|
-
* unlinking its replacement. A
|
|
227
|
-
*
|
|
178
|
+
* unlinking its replacement. A live PID owns the claim regardless of age; a
|
|
179
|
+
* known-dead PID is recoverable immediately. Only legacy/corrupt claims with
|
|
180
|
+
* no trustworthy PID fall back to an age limit.
|
|
228
181
|
*/
|
|
229
182
|
const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000;
|
|
230
183
|
const heldClaims = new Map();
|
|
@@ -249,8 +202,13 @@ export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleR
|
|
|
249
202
|
try {
|
|
250
203
|
const held = JSON.parse(readFileSync(file, 'utf8'));
|
|
251
204
|
const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY;
|
|
252
|
-
if (
|
|
205
|
+
if (typeof held.pid === 'number') {
|
|
206
|
+
if (processIsAlive(held.pid))
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
else if (age >= 0 && age < CLAIM_TTL_MS) {
|
|
253
210
|
return false;
|
|
211
|
+
}
|
|
254
212
|
}
|
|
255
213
|
catch {
|
|
256
214
|
// Unreadable or corrupt: treat as abandoned.
|
|
@@ -274,6 +232,15 @@ export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleR
|
|
|
274
232
|
rmSync(guard, { force: true });
|
|
275
233
|
}
|
|
276
234
|
}
|
|
235
|
+
function processIsAlive(pid) {
|
|
236
|
+
try {
|
|
237
|
+
process.kill(pid, 0);
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
return err.code === 'EPERM';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
277
244
|
export function releaseQuestionPush(sessionId, env) {
|
|
278
245
|
const file = claimPath(sessionId, env);
|
|
279
246
|
const token = heldClaims.get(file);
|
|
@@ -332,13 +299,53 @@ function claimPath(sessionId, env) {
|
|
|
332
299
|
* meantime, and keeps one runaway agent from writing megabytes per session.
|
|
333
300
|
*/
|
|
334
301
|
const MAX_STORED_QUESTION_CHARS = 2000;
|
|
335
|
-
/** Records
|
|
302
|
+
/** Records every live session working in a directory, for `notifai ask`. */
|
|
336
303
|
export function writeProjectSession(cwd, env, sessionId, now, harness) {
|
|
337
304
|
const file = projectSessionPointerPath(cwd, env);
|
|
338
305
|
withFileLock(`${file}.lock`, () => {
|
|
339
|
-
|
|
306
|
+
const existing = readStoredProjectSessionPointers(file).filter((entry) => entry.sessionId !== sessionId && now - entry.updatedAt <= 24 * 3600 * 1000);
|
|
307
|
+
atomicWriteFileSync(file, `${JSON.stringify({
|
|
308
|
+
sessions: [
|
|
309
|
+
...existing.map((entry) => ({
|
|
310
|
+
session_id: entry.sessionId,
|
|
311
|
+
updated_at: entry.updatedAt,
|
|
312
|
+
harness: entry.harness,
|
|
313
|
+
})),
|
|
314
|
+
{ session_id: sessionId, updated_at: now, harness },
|
|
315
|
+
],
|
|
316
|
+
})}\n`);
|
|
340
317
|
});
|
|
341
318
|
}
|
|
319
|
+
function readStoredProjectSessionPointers(file) {
|
|
320
|
+
if (!existsSync(file))
|
|
321
|
+
return [];
|
|
322
|
+
try {
|
|
323
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
324
|
+
if (!Array.isArray(parsed.sessions))
|
|
325
|
+
return [];
|
|
326
|
+
return parsed.sessions.flatMap((candidate) => {
|
|
327
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate))
|
|
328
|
+
return [];
|
|
329
|
+
const entry = candidate;
|
|
330
|
+
if (typeof entry.session_id !== 'string' || entry.session_id === '')
|
|
331
|
+
return [];
|
|
332
|
+
if (typeof entry.updated_at !== 'number')
|
|
333
|
+
return [];
|
|
334
|
+
if (!HARNESSES.includes(entry.harness))
|
|
335
|
+
return [];
|
|
336
|
+
return [
|
|
337
|
+
{
|
|
338
|
+
sessionId: entry.session_id,
|
|
339
|
+
updatedAt: entry.updated_at,
|
|
340
|
+
harness: entry.harness,
|
|
341
|
+
},
|
|
342
|
+
];
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
}
|
|
342
349
|
/**
|
|
343
350
|
* Resolves the session working in `cwd`. Stale pointers are ignored rather than
|
|
344
351
|
* trusted: an id left by a session that ended days ago would send the question
|
|
@@ -349,43 +356,65 @@ export function readProjectSession(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
|
|
|
349
356
|
}
|
|
350
357
|
export function readProjectSessionPointer(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
|
|
351
358
|
const file = projectSessionPointerPath(cwd, env);
|
|
352
|
-
|
|
359
|
+
const live = readStoredProjectSessionPointers(file)
|
|
360
|
+
.filter((entry) => now - entry.updatedAt <= maxAgeMs)
|
|
361
|
+
.filter((entry) => {
|
|
362
|
+
try {
|
|
363
|
+
// A pointer is routing evidence only while its session state still
|
|
364
|
+
// exists and parses. SessionEnd and explicit cleanup therefore
|
|
365
|
+
// invalidate it even when a crash prevented removal from this index.
|
|
366
|
+
const sessionFile = sessionStatePath(entry.sessionId, env);
|
|
367
|
+
if (!existsSync(sessionFile))
|
|
368
|
+
return false;
|
|
369
|
+
const state = JSON.parse(readFileSync(sessionFile, 'utf8'));
|
|
370
|
+
return typeof state === 'object' && state !== null && !Array.isArray(state);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
377
|
+
const latest = live[0];
|
|
378
|
+
return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness };
|
|
379
|
+
}
|
|
380
|
+
export function readMatchingProjectSessionPointer(cwd, env, now, sessionId, harness, maxAgeMs = 24 * 3600 * 1000) {
|
|
381
|
+
const file = projectSessionPointerPath(cwd, env);
|
|
382
|
+
const match = readStoredProjectSessionPointers(file).find((entry) => entry.sessionId === sessionId &&
|
|
383
|
+
entry.harness === harness &&
|
|
384
|
+
now - entry.updatedAt <= maxAgeMs);
|
|
385
|
+
if (match === undefined)
|
|
353
386
|
return null;
|
|
354
387
|
try {
|
|
355
|
-
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
356
|
-
if (typeof parsed.session_id !== 'string' || parsed.session_id === '')
|
|
357
|
-
return null;
|
|
358
|
-
if (typeof parsed.updated_at !== 'number' || now - parsed.updated_at > maxAgeMs)
|
|
359
|
-
return null;
|
|
360
|
-
if (!HOOK_HARNESSES.includes(parsed.harness))
|
|
361
|
-
return null;
|
|
362
388
|
// A pointer is routing evidence only while its session state still exists
|
|
363
389
|
// and parses. SessionEnd and explicit cleanup therefore invalidate it even
|
|
364
390
|
// when a crash prevented the pointer file itself from being removed.
|
|
365
|
-
const sessionFile = sessionStatePath(
|
|
391
|
+
const sessionFile = sessionStatePath(match.sessionId, env);
|
|
366
392
|
if (!existsSync(sessionFile))
|
|
367
393
|
return null;
|
|
368
394
|
const state = JSON.parse(readFileSync(sessionFile, 'utf8'));
|
|
369
395
|
if (typeof state !== 'object' || state === null || Array.isArray(state))
|
|
370
396
|
return null;
|
|
371
|
-
return { sessionId:
|
|
397
|
+
return { sessionId: match.sessionId, harness: match.harness };
|
|
372
398
|
}
|
|
373
399
|
catch {
|
|
374
400
|
return null;
|
|
375
401
|
}
|
|
376
402
|
}
|
|
377
|
-
const HOOK_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'];
|
|
378
403
|
function clearMatchingProjectSession(cwd, env, sessionId) {
|
|
379
404
|
const file = projectSessionPointerPath(cwd, env);
|
|
380
405
|
withFileLock(`${file}.lock`, () => {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
catch {
|
|
387
|
-
// Missing/corrupt is already not a live pointer.
|
|
406
|
+
const remaining = readStoredProjectSessionPointers(file).filter((entry) => entry.sessionId !== sessionId);
|
|
407
|
+
if (remaining.length === 0) {
|
|
408
|
+
rmSync(file, { force: true });
|
|
409
|
+
return;
|
|
388
410
|
}
|
|
411
|
+
atomicWriteFileSync(file, `${JSON.stringify({
|
|
412
|
+
sessions: remaining.map((entry) => ({
|
|
413
|
+
session_id: entry.sessionId,
|
|
414
|
+
updated_at: entry.updatedAt,
|
|
415
|
+
harness: entry.harness,
|
|
416
|
+
})),
|
|
417
|
+
})}\n`);
|
|
389
418
|
});
|
|
390
419
|
}
|
|
391
420
|
/**
|
|
@@ -438,6 +467,9 @@ export function isUserAway(state, config, now, idleSeconds) {
|
|
|
438
467
|
* than about the user. Matches the project pointer's staleness horizon.
|
|
439
468
|
*/
|
|
440
469
|
const MAX_PLAUSIBLE_SILENCE_MS = 24 * 3600 * 1000;
|
|
470
|
+
function gate(ctx, verdict, reason, data = {}) {
|
|
471
|
+
ctx.log?.info('hook.gate', { verdict, reason, ...data });
|
|
472
|
+
}
|
|
441
473
|
/** Re-check local presence at this cadence while a pushed question is waiting. */
|
|
442
474
|
const REPLY_PRESENCE_POLL_SECONDS = 5;
|
|
443
475
|
/**
|
|
@@ -455,7 +487,7 @@ const REPLY_PRESENCE_POLL_SECONDS = 5;
|
|
|
455
487
|
* Put one registered question on the user's devices. Submission only — the
|
|
456
488
|
* wait happens once, across every live question, in the caller.
|
|
457
489
|
*/
|
|
458
|
-
async function
|
|
490
|
+
async function prepareQuestionSubmission(ctx, options) {
|
|
459
491
|
const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`;
|
|
460
492
|
// A draft carrying `reply` is rejected outright if it targets a device that
|
|
461
493
|
// cannot answer, so resolve the healthy companion platforms explicitly.
|
|
@@ -478,8 +510,25 @@ async function submitQuestion(ctx, options) {
|
|
|
478
510
|
});
|
|
479
511
|
if (!build.ok)
|
|
480
512
|
return { error: build.error };
|
|
481
|
-
|
|
482
|
-
|
|
513
|
+
return {
|
|
514
|
+
request_id: `req_${randomBytes(18).toString('base64url')}`,
|
|
515
|
+
idempotency_key: `hook-${randomBytes(12).toString('base64url')}`,
|
|
516
|
+
collapse_key: collapseKey,
|
|
517
|
+
device_ids: answerable,
|
|
518
|
+
draft: build.draft,
|
|
519
|
+
owner_deadline_at: options.ownerDeadlineAt,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
async function submitQuestion(ctx, intent) {
|
|
523
|
+
const receipt = await ctx.client.submit({
|
|
524
|
+
request_id: intent.request_id,
|
|
525
|
+
idempotency_key: intent.idempotency_key,
|
|
526
|
+
draft: intent.draft,
|
|
527
|
+
}, 0);
|
|
528
|
+
if (receipt.request_id !== intent.request_id) {
|
|
529
|
+
throw new Error(`server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`);
|
|
530
|
+
}
|
|
531
|
+
return receipt;
|
|
483
532
|
}
|
|
484
533
|
/**
|
|
485
534
|
* One blocking wait across every live question. Each round polls all of them
|
|
@@ -487,41 +536,68 @@ async function submitQuestion(ctx, options) {
|
|
|
487
536
|
* first round that finds any reply returns everything found in that round, and
|
|
488
537
|
* whatever was not answered stays registered.
|
|
489
538
|
*/
|
|
490
|
-
async function waitForAnyReplyWhileAway(ctx, requestIds, timeoutSeconds) {
|
|
539
|
+
async function waitForAnyReplyWhileAway(ctx, requestIds, timeoutSeconds, monitorPresence = true) {
|
|
491
540
|
const deadline = ctx.now() + timeoutSeconds * 1000;
|
|
492
541
|
let degraded = false;
|
|
493
542
|
let firstPoll = true;
|
|
543
|
+
const permanentFailures = new Map();
|
|
494
544
|
for (;;) {
|
|
495
|
-
if (ctx.config.require_idle.value) {
|
|
545
|
+
if (monitorPresence && ctx.config.require_idle.value) {
|
|
496
546
|
const idle = ctx.idleSeconds();
|
|
497
547
|
if (idle !== null && idle < ctx.config.away_after_seconds.value) {
|
|
498
|
-
return { byRequest: new Map(), degraded, userReturned: true };
|
|
548
|
+
return { byRequest: new Map(), degraded, userReturned: true, permanentFailures };
|
|
499
549
|
}
|
|
500
550
|
}
|
|
501
551
|
const remainingMs = Math.max(0, deadline - ctx.now());
|
|
502
552
|
if (!firstPoll && remainingMs === 0) {
|
|
503
|
-
return { byRequest: new Map(), degraded, userReturned: false };
|
|
553
|
+
return { byRequest: new Map(), degraded, userReturned: false, permanentFailures };
|
|
504
554
|
}
|
|
505
555
|
firstPoll = false;
|
|
506
556
|
const pollSeconds = Math.min(REPLY_PRESENCE_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
|
|
507
|
-
const results = await Promise.all(requestIds
|
|
557
|
+
const results = await Promise.all(requestIds
|
|
558
|
+
.filter((requestId) => !permanentFailures.has(requestId))
|
|
559
|
+
.map(async (requestId) => {
|
|
508
560
|
try {
|
|
509
561
|
return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) };
|
|
510
562
|
}
|
|
511
|
-
catch {
|
|
512
|
-
|
|
563
|
+
catch (err) {
|
|
564
|
+
if (isRetryableReplyPollError(err)) {
|
|
565
|
+
return { requestId, replies: [], degraded: true };
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
requestId,
|
|
569
|
+
replies: [],
|
|
570
|
+
degraded: false,
|
|
571
|
+
permanentFailure: replyPollFailure(err),
|
|
572
|
+
};
|
|
513
573
|
}
|
|
514
574
|
}));
|
|
515
575
|
const byRequest = new Map();
|
|
516
576
|
for (const result of results) {
|
|
517
577
|
degraded ||= result.degraded === true;
|
|
578
|
+
if (result.permanentFailure !== undefined) {
|
|
579
|
+
permanentFailures.set(result.requestId, result.permanentFailure);
|
|
580
|
+
}
|
|
518
581
|
if (result.replies.length > 0)
|
|
519
582
|
byRequest.set(result.requestId, result.replies);
|
|
520
583
|
}
|
|
521
|
-
if (byRequest.size > 0)
|
|
522
|
-
return { byRequest, degraded, userReturned: false };
|
|
584
|
+
if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
|
|
585
|
+
return { byRequest, degraded, userReturned: false, permanentFailures };
|
|
586
|
+
}
|
|
523
587
|
}
|
|
524
588
|
}
|
|
589
|
+
/** A non-retryable replies fault, with enough server identity to act on it. */
|
|
590
|
+
function replyPollFailure(err) {
|
|
591
|
+
return err instanceof ApiCallError
|
|
592
|
+
? `${err.code} (HTTP ${err.status}): ${err.message}`
|
|
593
|
+
: String(err);
|
|
594
|
+
}
|
|
595
|
+
function permanentReplyFailureNote(failures) {
|
|
596
|
+
if (failures.size === 0)
|
|
597
|
+
return null;
|
|
598
|
+
const details = [...failures].map(([requestId, failure]) => `${requestId}: ${failure}`).join('; ');
|
|
599
|
+
return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`;
|
|
600
|
+
}
|
|
525
601
|
/**
|
|
526
602
|
* Healthy companion devices that implement replies. Both the iOS app and the
|
|
527
603
|
* macOS app register reply categories and submit answers directly.
|
|
@@ -552,15 +628,24 @@ function sessionLabel(ctx, envelope) {
|
|
|
552
628
|
return explicit;
|
|
553
629
|
return envelope.session_id;
|
|
554
630
|
}
|
|
555
|
-
/**
|
|
556
|
-
|
|
631
|
+
/**
|
|
632
|
+
* Close is a server-side serialization fence: a successful response contains
|
|
633
|
+
* every reply that committed before the window closed. `null` is deliberately
|
|
634
|
+
* different from an empty reply set — it means ownership could not be proven
|
|
635
|
+
* final and the local record must stay recoverable.
|
|
636
|
+
*/
|
|
637
|
+
async function finalizeReplies(ctx, requestId) {
|
|
557
638
|
try {
|
|
558
|
-
await ctx.client.closeReplies(requestId);
|
|
639
|
+
return await ctx.client.closeReplies(requestId);
|
|
559
640
|
}
|
|
560
641
|
catch {
|
|
561
|
-
|
|
642
|
+
return null;
|
|
562
643
|
}
|
|
563
644
|
}
|
|
645
|
+
/** Best effort only for already-journaled retirement debt. */
|
|
646
|
+
async function closeQuietly(ctx, requestId) {
|
|
647
|
+
await finalizeReplies(ctx, requestId);
|
|
648
|
+
}
|
|
564
649
|
/**
|
|
565
650
|
* Retire the question on every device it reached. A state change is not news
|
|
566
651
|
* (D-B): the retirement rides as a background state sync — no alert, no
|
|
@@ -759,7 +844,7 @@ const MAX_CONTINUATION_COUNT = 3;
|
|
|
759
844
|
/** One bounded recovery poll for a question that outlived its original wait. */
|
|
760
845
|
async function pollPendingReply(ctx, pending, timeoutSeconds) {
|
|
761
846
|
if (pending.request_id === undefined) {
|
|
762
|
-
return { reply: null, replies: [], degraded: false,
|
|
847
|
+
return { reply: null, replies: [], degraded: false, permanentFailure: null };
|
|
763
848
|
}
|
|
764
849
|
try {
|
|
765
850
|
const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds);
|
|
@@ -767,13 +852,39 @@ async function pollPendingReply(ctx, pending, timeoutSeconds) {
|
|
|
767
852
|
reply: result.replies.at(-1) ?? null,
|
|
768
853
|
replies: result.replies,
|
|
769
854
|
degraded: result.degraded === true,
|
|
770
|
-
|
|
855
|
+
permanentFailure: null,
|
|
771
856
|
};
|
|
772
857
|
}
|
|
773
|
-
catch {
|
|
774
|
-
return
|
|
858
|
+
catch (err) {
|
|
859
|
+
return isRetryableReplyPollError(err)
|
|
860
|
+
? { reply: null, replies: [], degraded: true, permanentFailure: null }
|
|
861
|
+
: { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) };
|
|
775
862
|
}
|
|
776
863
|
}
|
|
864
|
+
/** Close several windows concurrently without confusing failure with silence. */
|
|
865
|
+
async function finalizePendings(ctx, pending) {
|
|
866
|
+
return Promise.all(pending.map(async (entry) => ({
|
|
867
|
+
pending: entry,
|
|
868
|
+
response: await finalizeReplies(ctx, entry.request_id),
|
|
869
|
+
})));
|
|
870
|
+
}
|
|
871
|
+
/** Convert a fenced final reply set into the exact answer handed to the model. */
|
|
872
|
+
function finalizedAnswer(finalized) {
|
|
873
|
+
const replies = finalized.response?.replies ?? [];
|
|
874
|
+
const reply = replies.at(-1);
|
|
875
|
+
return reply === undefined ? null : { pending: finalized.pending, reply, replies };
|
|
876
|
+
}
|
|
877
|
+
/** Persist and surface one answer without letting the two representations drift. */
|
|
878
|
+
function reportAnswer(ctx, notes, answered, late) {
|
|
879
|
+
ctx.log?.info('hook.answer', {
|
|
880
|
+
answered: true,
|
|
881
|
+
...(late ? { late: true } : {}),
|
|
882
|
+
request_id: answered.pending.request_id,
|
|
883
|
+
device: answered.reply.device_name,
|
|
884
|
+
text: answered.reply.text,
|
|
885
|
+
});
|
|
886
|
+
notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`);
|
|
887
|
+
}
|
|
777
888
|
/**
|
|
778
889
|
* One bounded recovery poll across every delivered question, concurrently —
|
|
779
890
|
* N questions must not multiply the hook's latency budget by N.
|
|
@@ -781,15 +892,19 @@ async function pollPendingReply(ctx, pending, timeoutSeconds) {
|
|
|
781
892
|
async function pollPendingReplies(ctx, live, timeoutSeconds) {
|
|
782
893
|
const polls = await Promise.all(live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)));
|
|
783
894
|
const answered = [];
|
|
784
|
-
let
|
|
895
|
+
let transientTrouble = false;
|
|
896
|
+
const permanentFailures = new Map();
|
|
785
897
|
for (const [index, poll] of polls.entries()) {
|
|
786
898
|
if (poll.reply !== null) {
|
|
787
899
|
answered.push({ pending: live[index], reply: poll.reply, replies: poll.replies });
|
|
788
900
|
}
|
|
789
|
-
if (poll.
|
|
790
|
-
|
|
901
|
+
if (poll.degraded)
|
|
902
|
+
transientTrouble = true;
|
|
903
|
+
if (poll.permanentFailure !== null) {
|
|
904
|
+
permanentFailures.set(live[index].request_id, poll.permanentFailure);
|
|
905
|
+
}
|
|
791
906
|
}
|
|
792
|
-
return { answered,
|
|
907
|
+
return { answered, transientTrouble, permanentFailures };
|
|
793
908
|
}
|
|
794
909
|
/** The registered-question queue. Anything but the current shape reads as empty. */
|
|
795
910
|
function pendingList(state) {
|
|
@@ -797,6 +912,9 @@ function pendingList(state) {
|
|
|
797
912
|
}
|
|
798
913
|
/** Registration identity — the convention every racing writer compares by. */
|
|
799
914
|
function isSamePending(a, b) {
|
|
915
|
+
if (a.question_id !== undefined || b.question_id !== undefined) {
|
|
916
|
+
return a.question_id !== undefined && a.question_id === b.question_id;
|
|
917
|
+
}
|
|
800
918
|
return a.question === b.question && a.asked_at === b.asked_at;
|
|
801
919
|
}
|
|
802
920
|
/** The question set this pending record pushes, however it was registered. */
|
|
@@ -849,25 +967,39 @@ function answersContext(answered, remaining) {
|
|
|
849
967
|
});
|
|
850
968
|
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}\nContinue with these answers.${tail}`;
|
|
851
969
|
}
|
|
852
|
-
function
|
|
970
|
+
function stopAnswerOutput(context) {
|
|
853
971
|
return JSON.stringify({
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
additionalContext: context,
|
|
857
|
-
},
|
|
972
|
+
decision: 'block',
|
|
973
|
+
reason: context,
|
|
858
974
|
});
|
|
859
975
|
}
|
|
860
|
-
function stopAnswerOutput(context) {
|
|
861
|
-
return JSON.stringify({ decision: 'block', reason: context });
|
|
862
|
-
}
|
|
863
976
|
/**
|
|
864
977
|
* Close, truthfully retire, and forget every answered pending question in one
|
|
865
978
|
* state write, without dropping any other retirement debt or any question
|
|
866
979
|
* still waiting. Stop answers also open one bounded continuation generation;
|
|
867
980
|
* UserPromptSubmit answers ride the user's new turn.
|
|
868
981
|
*/
|
|
869
|
-
|
|
870
|
-
|
|
982
|
+
function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
|
|
983
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
984
|
+
const pendingRemaining = pendingList(current).filter((entry) => !answered.some(({ pending }) => isSamePending(entry, pending)));
|
|
985
|
+
const next = {
|
|
986
|
+
...current,
|
|
987
|
+
accepted: {
|
|
988
|
+
answers: answered,
|
|
989
|
+
remaining,
|
|
990
|
+
recorded_at: ctx.now(),
|
|
991
|
+
},
|
|
992
|
+
};
|
|
993
|
+
if (pendingRemaining.length > 0)
|
|
994
|
+
next.pending = pendingRemaining;
|
|
995
|
+
else
|
|
996
|
+
delete next.pending;
|
|
997
|
+
return next;
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
/** Settle only after a successor hook proves the harness received the answer. */
|
|
1001
|
+
function settleAcceptedAnswers(ctx, sessionId, accepted) {
|
|
1002
|
+
const retirements = accepted.answers
|
|
871
1003
|
.map(({ pending }) => retiringQuestion(pending, 'answered'))
|
|
872
1004
|
.filter((entry) => entry !== null);
|
|
873
1005
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
@@ -879,24 +1011,39 @@ async function finishAnsweredPendings(ctx, envelope, sessionId, answered, contin
|
|
|
879
1011
|
else
|
|
880
1012
|
retiring[existing] = retirement;
|
|
881
1013
|
}
|
|
882
|
-
const remaining = pendingList(current).filter((entry) => !answered.some(({ pending }) => isSamePending(entry, pending)));
|
|
883
1014
|
const next = { ...current, retiring };
|
|
884
|
-
|
|
885
|
-
|
|
1015
|
+
delete next.accepted;
|
|
1016
|
+
next.continuation = {
|
|
1017
|
+
answered_at: accepted.recorded_at,
|
|
1018
|
+
count: accepted.continuation_count ?? 1,
|
|
1019
|
+
};
|
|
1020
|
+
return next;
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
async function retirePendings(ctx, envelope, sessionId, entries, state) {
|
|
1024
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1025
|
+
const retiring = [...(current.retiring ?? [])];
|
|
1026
|
+
for (const entry of entries) {
|
|
1027
|
+
const retirement = retiringQuestion(entry, state);
|
|
1028
|
+
if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
|
|
1029
|
+
retiring.push(retirement);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
const pending = pendingList(current).filter((candidate) => !entries.some((entry) => isSamePending(candidate, entry)));
|
|
1033
|
+
const next = { ...current };
|
|
1034
|
+
if (pending.length > 0)
|
|
1035
|
+
next.pending = pending;
|
|
886
1036
|
else
|
|
887
1037
|
delete next.pending;
|
|
888
|
-
if (
|
|
889
|
-
next.
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
};
|
|
893
|
-
}
|
|
894
|
-
else {
|
|
895
|
-
next.last_prompt_at = ctx.now();
|
|
896
|
-
}
|
|
1038
|
+
if (retiring.length > 0)
|
|
1039
|
+
next.retiring = retiring;
|
|
1040
|
+
else
|
|
1041
|
+
delete next.retiring;
|
|
897
1042
|
return next;
|
|
898
1043
|
});
|
|
899
|
-
|
|
1044
|
+
// Network retirement is deliberately deferred to a later no-question hook.
|
|
1045
|
+
// The state write above is the durable operation; blocking here creates a
|
|
1046
|
+
// crash window before an answer can reach harness stdout.
|
|
900
1047
|
}
|
|
901
1048
|
// ---------------------------------------------------------------------------
|
|
902
1049
|
// UserPromptSubmit — the user is at the keyboard
|
|
@@ -913,34 +1060,63 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
913
1060
|
if (!sessionId)
|
|
914
1061
|
return { notes };
|
|
915
1062
|
const state = readSessionState(sessionId, ctx.env);
|
|
1063
|
+
if (state.accepted !== undefined) {
|
|
1064
|
+
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
1065
|
+
...current,
|
|
1066
|
+
last_prompt_at: ctx.now(),
|
|
1067
|
+
}));
|
|
1068
|
+
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
1069
|
+
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
|
|
1070
|
+
}
|
|
1071
|
+
notes.push('a device answer is safely journaled; the next Stop will deliver it');
|
|
1072
|
+
return { notes };
|
|
1073
|
+
}
|
|
916
1074
|
const live = pendingList(state).filter((entry) => entry.request_id !== undefined);
|
|
917
1075
|
let lateAnswers = [];
|
|
918
1076
|
if (live.length > 0) {
|
|
919
|
-
const { answered,
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1077
|
+
const { answered, transientTrouble, permanentFailures } = await pollPendingReplies(ctx, live, LATE_PROMPT_POLL_SECONDS);
|
|
1078
|
+
const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending));
|
|
1079
|
+
lateAnswers = answered.map((entry) => {
|
|
1080
|
+
const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
|
|
1081
|
+
const replies = response?.replies.length ? response.replies : entry.replies;
|
|
1082
|
+
if (response === null) {
|
|
1083
|
+
notes.push(`could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`);
|
|
925
1084
|
}
|
|
1085
|
+
return { ...entry, replies, reply: replies.at(-1) };
|
|
1086
|
+
});
|
|
1087
|
+
if (answered.length > 0) {
|
|
1088
|
+
stageAcceptedAnswers(ctx, sessionId, lateAnswers, 0);
|
|
1089
|
+
for (const answer of lateAnswers)
|
|
1090
|
+
reportAnswer(ctx, notes, answer, true);
|
|
926
1091
|
}
|
|
927
|
-
if (
|
|
1092
|
+
if (transientTrouble) {
|
|
928
1093
|
notes.push('could not check every pending question for a late answer before the prompt');
|
|
929
1094
|
}
|
|
1095
|
+
const permanentFailure = permanentReplyFailureNote(permanentFailures);
|
|
1096
|
+
if (permanentFailure !== null)
|
|
1097
|
+
notes.push(permanentFailure);
|
|
930
1098
|
}
|
|
931
1099
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
932
1100
|
// the next hook sees both copies and dedupes them; the old order could die in
|
|
933
1101
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
934
1102
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
935
1103
|
const retiring = [...(current.retiring ?? [])];
|
|
936
|
-
|
|
1104
|
+
const unasked = pendingList(current).filter((entry) => entry.request_id === undefined);
|
|
1105
|
+
for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
|
|
937
1106
|
const retirement = retiringQuestion(entry, 'answered_elsewhere');
|
|
938
1107
|
if (retirement !== null &&
|
|
939
1108
|
!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
940
1109
|
retiring.push(retirement);
|
|
941
1110
|
}
|
|
942
1111
|
}
|
|
943
|
-
return {
|
|
1112
|
+
return {
|
|
1113
|
+
last_prompt_at: ctx.now(),
|
|
1114
|
+
...(current.last_stop_at === undefined ? {} : { last_stop_at: current.last_stop_at }),
|
|
1115
|
+
...(unasked.length > 0 ? { pending: unasked } : {}),
|
|
1116
|
+
...(retiring.length > 0 ? { retiring } : {}),
|
|
1117
|
+
...(current.accepted === undefined ? {} : { accepted: current.accepted }),
|
|
1118
|
+
...(current.continuation === undefined ? {} : { continuation: current.continuation }),
|
|
1119
|
+
};
|
|
944
1120
|
});
|
|
945
1121
|
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
946
1122
|
// session: an agent shell command gets no hook payload, and not every
|
|
@@ -948,78 +1124,162 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
948
1124
|
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
949
1125
|
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
|
|
950
1126
|
}
|
|
1127
|
+
if (lateAnswers.length > 0) {
|
|
1128
|
+
// UserPromptSubmit has no later hook field proving its stdout reached the
|
|
1129
|
+
// model. Keep the journal and let the next Stop use its acknowledged
|
|
1130
|
+
// continuation channel instead of recreating the crash-before-stdout gap.
|
|
1131
|
+
notes.push('the late device answer will continue the agent at this turn’s Stop');
|
|
1132
|
+
return { notes };
|
|
1133
|
+
}
|
|
951
1134
|
const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope));
|
|
952
1135
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now());
|
|
953
1136
|
const swept = [...retired, ...orphaned];
|
|
954
1137
|
if (swept.length > 0) {
|
|
955
1138
|
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
956
1139
|
}
|
|
957
|
-
if (lateAnswers.length > 0) {
|
|
958
|
-
// Whatever the user did not answer was just retired above — they are at
|
|
959
|
-
// the keyboard now, so nothing is "still waiting" from their side.
|
|
960
|
-
return { stdout: userPromptAnswerOutput(answersContext(lateAnswers, 0)), notes };
|
|
961
|
-
}
|
|
962
1140
|
return { notes };
|
|
963
1141
|
}
|
|
964
1142
|
// ---------------------------------------------------------------------------
|
|
965
1143
|
// Stop — the turn ended; escalate a registered question
|
|
966
1144
|
// ---------------------------------------------------------------------------
|
|
967
|
-
/**
|
|
968
|
-
* How long an escalated question keeps accepting an answer after the turn
|
|
969
|
-
* ended. Long enough to survive a walk away from the desk, short enough that a
|
|
970
|
-
* forgotten question does not resurface days later as a live prompt.
|
|
971
|
-
*/
|
|
972
|
-
const QUESTION_WINDOW_SECONDS = 3600;
|
|
973
1145
|
/**
|
|
974
1146
|
* Only engages for a question the agent explicitly registered with
|
|
975
1147
|
* `notifai ask`. Guessing from the last assistant message was the alternative
|
|
976
1148
|
* and it is not worth it: a false positive here hijacks the terminal.
|
|
977
1149
|
*/
|
|
978
|
-
export async function handleStop(ctx, envelope) {
|
|
1150
|
+
export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() + STOP_BUDGET_SECONDS * 1000) {
|
|
979
1151
|
const notes = [];
|
|
1152
|
+
const hardDeadlineAt = processDeadlineAt;
|
|
980
1153
|
const sessionId = envelope.session_id;
|
|
981
|
-
if (!sessionId)
|
|
1154
|
+
if (!sessionId) {
|
|
1155
|
+
gate(ctx, 'held', 'no-session');
|
|
982
1156
|
return { notes };
|
|
983
|
-
// Anything retired since the last hook is still live on the devices. This
|
|
984
|
-
// runs before every early return below, including the nagging guard: a queued
|
|
985
|
-
// retirement has nothing to do with whether *this* turn has a question to
|
|
986
|
-
// escalate, and the turn that supersedes a question is very often the one
|
|
987
|
-
// continuing from the previous answer.
|
|
988
|
-
const swept = [
|
|
989
|
-
...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
|
|
990
|
-
...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
|
|
991
|
-
];
|
|
992
|
-
if (swept.length > 0) {
|
|
993
|
-
notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
994
1157
|
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1158
|
+
// This event marker is diagnostic evidence in its own right. Record it
|
|
1159
|
+
// before every early return so doctor can distinguish a working Stop route
|
|
1160
|
+
// from the prompt-only state that previously looked healthy.
|
|
1161
|
+
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
1162
|
+
...current,
|
|
1163
|
+
last_stop_at: ctx.now(),
|
|
1164
|
+
}));
|
|
1165
|
+
// Claim before any reply poll, not only before the grace window. Two racing
|
|
1166
|
+
// Stops can both observe the same accepted journal or live request. An
|
|
1167
|
+
// atomic state update cannot retract duplicate block output a loser already
|
|
1168
|
+
// built, so one session claim owns the complete Stop decision: accepted
|
|
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.
|
|
1174
|
+
if (!claimQuestionPush(sessionId, ctx.env)) {
|
|
1175
|
+
gate(ctx, 'held', 'claimed-elsewhere');
|
|
1176
|
+
notes.push('another hook is already handling this question');
|
|
998
1177
|
return { notes };
|
|
1178
|
+
}
|
|
1179
|
+
try {
|
|
1180
|
+
let state = readSessionState(sessionId, ctx.env);
|
|
1181
|
+
if (state.accepted !== undefined) {
|
|
1182
|
+
const accepted = state.accepted;
|
|
1183
|
+
// A recursive Stop is emitted only after the previous Stop's blocking
|
|
1184
|
+
// output was admitted and the continued model turn ran. That event is
|
|
1185
|
+
// the acknowledgment: persisting a separate "offered" bit before stdout
|
|
1186
|
+
// would recreate the crash gap this journal exists to close.
|
|
1187
|
+
const deliveryProven = envelope.stop_hook_active === true;
|
|
1188
|
+
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
|
+
for (const answer of accepted.answers)
|
|
1201
|
+
reportAnswer(ctx, notes, answer, true);
|
|
1202
|
+
return {
|
|
1203
|
+
stdout: stopAnswerOutput(answersContext(accepted.answers, accepted.remaining)),
|
|
1204
|
+
notes,
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
settleAcceptedAnswers(ctx, sessionId, accepted);
|
|
1208
|
+
state = readSessionState(sessionId, ctx.env);
|
|
1209
|
+
}
|
|
1210
|
+
const pending = pendingList(state);
|
|
1211
|
+
if (pending.length === 0) {
|
|
1212
|
+
const swept = [
|
|
1213
|
+
...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
|
|
1214
|
+
...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
|
|
1215
|
+
];
|
|
1216
|
+
if (swept.length > 0) {
|
|
1217
|
+
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
1218
|
+
}
|
|
1219
|
+
gate(ctx, 'held', 'no-question');
|
|
1220
|
+
return { notes };
|
|
1221
|
+
}
|
|
1222
|
+
return await handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt);
|
|
1223
|
+
}
|
|
1224
|
+
finally {
|
|
1225
|
+
releaseQuestionPush(sessionId, ctx.env);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
1229
|
+
async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt) {
|
|
999
1230
|
const live = pending.filter((entry) => entry.request_id !== undefined);
|
|
1000
1231
|
const unasked = pending.filter((entry) => entry.request_id === undefined);
|
|
1232
|
+
let liveToEscalate = live;
|
|
1233
|
+
if (ctx.harness !== undefined &&
|
|
1234
|
+
HARNESS_CAPABILITIES[ctx.harness].stopContinuation === 'unsupported') {
|
|
1235
|
+
for (const entry of live)
|
|
1236
|
+
await closeQuietly(ctx, entry.request_id);
|
|
1237
|
+
await retirePendings(ctx, envelope, sessionId, pending, 'expired');
|
|
1238
|
+
gate(ctx, 'held', 'no-question');
|
|
1239
|
+
notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`);
|
|
1240
|
+
return { notes };
|
|
1241
|
+
}
|
|
1001
1242
|
if (live.length > 0) {
|
|
1002
|
-
const { answered,
|
|
1243
|
+
const { answered, permanentFailures } = await pollPendingReplies(ctx, live, LATE_STOP_POLL_SECONDS);
|
|
1244
|
+
const permanentFailure = permanentReplyFailureNote(permanentFailures);
|
|
1245
|
+
if (permanentFailure !== null)
|
|
1246
|
+
notes.push(permanentFailure);
|
|
1247
|
+
const permanentlyRejected = live.filter((entry) => permanentFailures.has(entry.request_id));
|
|
1248
|
+
await Promise.all(permanentlyRejected.map((entry) => closeQuietly(ctx, entry.request_id)));
|
|
1249
|
+
if (permanentlyRejected.length > 0) {
|
|
1250
|
+
await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired');
|
|
1251
|
+
}
|
|
1003
1252
|
if (answered.length > 0) {
|
|
1004
|
-
await
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1253
|
+
const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending));
|
|
1254
|
+
const authoritative = answered.map((entry) => {
|
|
1255
|
+
const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
|
|
1256
|
+
const replies = response?.replies.length ? response.replies : entry.replies;
|
|
1257
|
+
if (response === null) {
|
|
1258
|
+
notes.push(`could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`);
|
|
1259
|
+
}
|
|
1260
|
+
return { ...entry, replies, reply: replies.at(-1) };
|
|
1261
|
+
});
|
|
1262
|
+
stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
|
|
1263
|
+
gate(ctx, 'proceeding', 'answered', { answers: authoritative.length });
|
|
1264
|
+
for (const answer of authoritative)
|
|
1265
|
+
reportAnswer(ctx, notes, answer, true);
|
|
1008
1266
|
// Anything still unasked rides the next Stop: the agent is being resumed
|
|
1009
1267
|
// with answers right now, and may not even need the rest afterwards.
|
|
1010
1268
|
return {
|
|
1011
|
-
stdout: stopAnswerOutput(answersContext(
|
|
1269
|
+
stdout: stopAnswerOutput(answersContext(authoritative, pending.length - authoritative.length - permanentlyRejected.length)),
|
|
1012
1270
|
notes,
|
|
1013
1271
|
};
|
|
1014
1272
|
}
|
|
1273
|
+
const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
|
|
1274
|
+
liveToEscalate = recoverableLive;
|
|
1015
1275
|
if (unasked.length === 0) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
return
|
|
1276
|
+
if (recoverableLive.length === 0)
|
|
1277
|
+
return { notes };
|
|
1278
|
+
// A previous answer may have resumed the model while independent
|
|
1279
|
+
// questions remain. The next Stop is their successor owner: keep waiting
|
|
1280
|
+
// to the original absolute deadline instead of polling for four seconds
|
|
1281
|
+
// and abandoning them.
|
|
1282
|
+
return await escalate(ctx, envelope, sessionId, [], recoverableLive, notes, hardDeadlineAt);
|
|
1023
1283
|
}
|
|
1024
1284
|
// Questions registered after the earlier push still owe the user their
|
|
1025
1285
|
// notification; fall through and escalate just those.
|
|
@@ -1032,147 +1292,339 @@ export async function handleStop(ctx, envelope) {
|
|
|
1032
1292
|
const isNew = continuation !== undefined &&
|
|
1033
1293
|
unasked.some((entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at);
|
|
1034
1294
|
if (!isNew) {
|
|
1295
|
+
gate(ctx, 'held', 'continuation-repeat');
|
|
1035
1296
|
notes.push('already continuing from an answer; not asking again this turn');
|
|
1036
1297
|
return { notes };
|
|
1037
1298
|
}
|
|
1038
1299
|
if (continuation.count >= MAX_CONTINUATION_COUNT) {
|
|
1300
|
+
gate(ctx, 'held', 'continuation-limit', {
|
|
1301
|
+
count: continuation.count,
|
|
1302
|
+
limit: MAX_CONTINUATION_COUNT,
|
|
1303
|
+
});
|
|
1039
1304
|
notes.push(`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`);
|
|
1040
1305
|
return { notes };
|
|
1041
1306
|
}
|
|
1042
1307
|
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1308
|
+
// Silent to the user by design — they switched routing off, so saying so on
|
|
1309
|
+
// every turn would be nagging about their own setting. That silence is also
|
|
1310
|
+
// why it belongs in the log: from outside, "ask_notifications = false" and "a
|
|
1311
|
+
// bug ate my question" look exactly the same.
|
|
1312
|
+
if (!ctx.config.ask_notifications.value) {
|
|
1313
|
+
gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source });
|
|
1047
1314
|
return { notes };
|
|
1048
1315
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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');
|
|
1056
1325
|
return { notes };
|
|
1057
1326
|
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
}
|
|
1327
|
+
gate(ctx, 'proceeding', 'proceeding', {
|
|
1328
|
+
unasked: unasked.length,
|
|
1329
|
+
already_live: liveToEscalate.length,
|
|
1330
|
+
idle_seconds: idle,
|
|
1331
|
+
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
1332
|
+
});
|
|
1333
|
+
return await escalate(ctx, envelope, sessionId, unasked, liveToEscalate, notes, hardDeadlineAt);
|
|
1064
1334
|
}
|
|
1065
1335
|
/** The escalation itself, split out so the claim is released on every path. */
|
|
1066
|
-
async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes) {
|
|
1336
|
+
async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, hardDeadlineAt) {
|
|
1067
1337
|
// Away right now, but the questions still owe the user their terminal-first
|
|
1068
1338
|
// window before anything reaches their devices — measured from the oldest
|
|
1069
1339
|
// registration, because that is the question that has waited longest.
|
|
1070
|
-
const
|
|
1071
|
-
const
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1340
|
+
const budgetStartedAt = ctx.now();
|
|
1341
|
+
const existingDeadlines = alreadyLive.flatMap((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= budgetStartedAt
|
|
1342
|
+
? []
|
|
1343
|
+
: [entry.reply_deadline_at]);
|
|
1344
|
+
const budgetDeadlineAt = Math.min(hardDeadlineAt - HOOK_TIMING.finalizationReserveSeconds * 1000, ...(existingDeadlines.length === 0 ? [] : existingDeadlines));
|
|
1345
|
+
if (unasked.length > 0 && alreadyLive.length === 0) {
|
|
1346
|
+
const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()));
|
|
1347
|
+
const grace = await awaitGrace(ctx, oldest);
|
|
1348
|
+
ctx.log?.debug('hook.gate', { verdict: 'grace', reason: grace, waited_from: oldest });
|
|
1349
|
+
if (grace === 'user-returned') {
|
|
1350
|
+
gate(ctx, 'held', 'user-returned', { grace_seconds: ctx.config.ask_grace_seconds.value });
|
|
1351
|
+
notes.push('you came back before the wait elapsed; leaving the questions in the terminal');
|
|
1352
|
+
return { notes };
|
|
1353
|
+
}
|
|
1354
|
+
if (grace === 'no-signal') {
|
|
1355
|
+
notes.push('no idle signal on this machine; asking now rather than holding the terminal');
|
|
1356
|
+
}
|
|
1078
1357
|
}
|
|
1079
1358
|
// Phase one: every registered question reaches the user's devices, each as
|
|
1080
1359
|
// its own notification — one ask never stands in for another.
|
|
1081
1360
|
const submitted = [];
|
|
1082
1361
|
for (const entry of unasked) {
|
|
1362
|
+
const remainingSeconds = Math.floor((budgetDeadlineAt - ctx.now()) / 1000);
|
|
1363
|
+
if (remainingSeconds <
|
|
1364
|
+
MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds) {
|
|
1365
|
+
notes.push('the Stop budget cannot fit device resolution, submission, and a full reply window; leaving this question in the terminal');
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
const replyWindowSeconds = Math.min(ctx.config.hook_reply_timeout_seconds.value, remainingSeconds - HOOK_TIMING.submissionReserveSeconds);
|
|
1369
|
+
const ownerDeadlineAt = Math.min(budgetDeadlineAt, ctx.now() +
|
|
1370
|
+
(replyWindowSeconds + HOOK_TIMING.submissionReserveSeconds) * 1000);
|
|
1083
1371
|
const questions = pendingQuestions(entry);
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
:
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
:
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1372
|
+
let intent = entry.submission;
|
|
1373
|
+
if (intent === undefined) {
|
|
1374
|
+
const prepared = await prepareQuestionSubmission(ctx, {
|
|
1375
|
+
title: ctx.config.project.value === null
|
|
1376
|
+
? 'Question'
|
|
1377
|
+
: `Question · ${ctx.config.project.value}`,
|
|
1378
|
+
// A set is answered on the expanded card; the banner leads with the
|
|
1379
|
+
// first question and says how much more is waiting behind it.
|
|
1380
|
+
body: questions.length > 1
|
|
1381
|
+
? `${questions[0].text} (+${questions.length - 1} more)`
|
|
1382
|
+
: entry.question,
|
|
1383
|
+
questions,
|
|
1384
|
+
...(entry.detail !== undefined ? { detail: entry.detail } : {}),
|
|
1385
|
+
event: 'agent_question',
|
|
1386
|
+
session: sessionLabel(ctx, envelope),
|
|
1387
|
+
windowSeconds: replyWindowSeconds,
|
|
1388
|
+
ownerDeadlineAt,
|
|
1389
|
+
});
|
|
1390
|
+
if ('error' in prepared) {
|
|
1391
|
+
ctx.log?.error('hook.pushed', { ok: false, message: prepared.error });
|
|
1392
|
+
notes.push(prepared.error);
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
intent = prepared;
|
|
1396
|
+
// Durable before submit. If the server commits and the response is lost,
|
|
1397
|
+
// the reserved request id still lets this owner poll and finalize the
|
|
1398
|
+
// exact card; a successor can also replay the frozen draft/key.
|
|
1399
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1400
|
+
const list = pendingList(current);
|
|
1401
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry));
|
|
1402
|
+
if (index < 0)
|
|
1403
|
+
return current;
|
|
1404
|
+
const next = [...list];
|
|
1405
|
+
next[index] = { ...next[index], submission: prepared };
|
|
1406
|
+
return { ...current, pending: next };
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
else if (intent.owner_deadline_at <= ctx.now()) {
|
|
1410
|
+
// The frozen wire identity survives a crashed owner, but its local owner
|
|
1411
|
+
// lease does not. Re-arm only the local deadline; request id, key,
|
|
1412
|
+
// targets, and draft remain byte-identical for idempotent replay.
|
|
1413
|
+
const frozenReplyWindow = intent.draft.reply?.expires_in_seconds ?? replyWindowSeconds;
|
|
1414
|
+
intent = {
|
|
1415
|
+
...intent,
|
|
1416
|
+
owner_deadline_at: Math.min(budgetDeadlineAt, ctx.now() +
|
|
1417
|
+
(frozenReplyWindow + HOOK_TIMING.submissionReserveSeconds) * 1000),
|
|
1418
|
+
};
|
|
1419
|
+
const rearmed = intent;
|
|
1420
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1421
|
+
const list = pendingList(current);
|
|
1422
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry));
|
|
1423
|
+
if (index < 0)
|
|
1424
|
+
return current;
|
|
1425
|
+
const next = [...list];
|
|
1426
|
+
next[index] = { ...next[index], submission: rearmed };
|
|
1427
|
+
return { ...current, pending: next };
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
if (ctx.now() >= intent.owner_deadline_at) {
|
|
1431
|
+
notes.push('the Stop budget ended before submission; preserving the frozen intent');
|
|
1103
1432
|
continue;
|
|
1104
1433
|
}
|
|
1434
|
+
const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000);
|
|
1435
|
+
if (beforeSubmitSeconds <
|
|
1436
|
+
MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds / 2) {
|
|
1437
|
+
notes.push('device resolution consumed the submission reserve; preserving the frozen intent instead of shrinking its reply window');
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
let receipt;
|
|
1441
|
+
let admissionConfirmed = false;
|
|
1442
|
+
try {
|
|
1443
|
+
receipt = await submitQuestion(ctx, intent);
|
|
1444
|
+
admissionConfirmed = true;
|
|
1445
|
+
}
|
|
1446
|
+
catch (err) {
|
|
1447
|
+
if (err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
|
|
1448
|
+
// A concrete client response is an admission rejection, not an
|
|
1449
|
+
// ambiguous commit. Keep the frozen intent for a later retry and leave
|
|
1450
|
+
// the question in the terminal; polling a request the server says it
|
|
1451
|
+
// did not accept only burns the owner window and hides the real fault.
|
|
1452
|
+
notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`);
|
|
1453
|
+
ctx.log?.error('hook.pushed', {
|
|
1454
|
+
ok: false,
|
|
1455
|
+
request_id: intent.request_id,
|
|
1456
|
+
status: err.status,
|
|
1457
|
+
code: err.code,
|
|
1458
|
+
message: err.message,
|
|
1459
|
+
});
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
// This is deliberately not a fail-open return. A transport failure is
|
|
1463
|
+
// ambiguous: the server may already have dispatched the card. The
|
|
1464
|
+
// client-generated request id keeps polling/finalization possible.
|
|
1465
|
+
notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`);
|
|
1466
|
+
ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) });
|
|
1467
|
+
}
|
|
1468
|
+
if (admissionConfirmed) {
|
|
1469
|
+
ctx.log?.info('hook.pushed', {
|
|
1470
|
+
ok: true,
|
|
1471
|
+
request_id: intent.request_id,
|
|
1472
|
+
devices: intent.device_ids.length,
|
|
1473
|
+
questions: questions.length,
|
|
1474
|
+
text: questions[0].text,
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
const committedReplyDeadline = receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
|
|
1478
|
+
? intent.owner_deadline_at
|
|
1479
|
+
: Date.parse(receipt.reply_expires_at);
|
|
1105
1480
|
const live = {
|
|
1106
1481
|
...entry,
|
|
1107
|
-
request_id:
|
|
1108
|
-
collapse_key:
|
|
1109
|
-
device_ids:
|
|
1482
|
+
request_id: intent.request_id,
|
|
1483
|
+
collapse_key: intent.collapse_key,
|
|
1484
|
+
device_ids: intent.device_ids,
|
|
1485
|
+
// Submission and every later poll share one wall-clock owner. The
|
|
1486
|
+
// server may start its relative window a little later while the submit
|
|
1487
|
+
// response is in flight; closing at this deadline is therefore the
|
|
1488
|
+
// authoritative backstop against accepting an answer into a void.
|
|
1489
|
+
reply_deadline_at: Math.min(intent.owner_deadline_at, Number.isFinite(committedReplyDeadline)
|
|
1490
|
+
? committedReplyDeadline
|
|
1491
|
+
: intent.owner_deadline_at),
|
|
1110
1492
|
};
|
|
1493
|
+
delete live.submission;
|
|
1111
1494
|
submitted.push(live);
|
|
1112
1495
|
// Record what is now live on the user's devices BEFORE any wait. If we
|
|
1113
1496
|
// only learned these ids afterwards, a question that timed out would
|
|
1114
1497
|
// leave no trace, and the user returning to the terminal could never
|
|
1115
1498
|
// retire it — the notification would stay answerable for an hour with
|
|
1116
1499
|
// nobody listening.
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
retiring.
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1500
|
+
if (admissionConfirmed) {
|
|
1501
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1502
|
+
const list = pendingList(current);
|
|
1503
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined);
|
|
1504
|
+
if (index >= 0) {
|
|
1505
|
+
const next = [...list];
|
|
1506
|
+
next[index] = live;
|
|
1507
|
+
return { ...current, pending: next };
|
|
1508
|
+
}
|
|
1509
|
+
// The entry vanished while the submit was in flight (the user's prompt
|
|
1510
|
+
// wiped the queue). The delivered notification must still be retirable,
|
|
1511
|
+
// so park it rather than lose its only identifiers.
|
|
1512
|
+
const retirement = retiringQuestion(live, 'answered_elsewhere');
|
|
1513
|
+
const retiring = [...(current.retiring ?? [])];
|
|
1514
|
+
if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
1515
|
+
retiring.push(retirement);
|
|
1516
|
+
}
|
|
1517
|
+
return { ...current, retiring };
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
const staleLive = alreadyLive.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now());
|
|
1522
|
+
const finalizedStale = await finalizePendings(ctx, staleLive);
|
|
1523
|
+
const staleAnswers = finalizedStale
|
|
1524
|
+
.map(finalizedAnswer)
|
|
1525
|
+
.filter((entry) => entry !== null);
|
|
1526
|
+
const staleSilence = finalizedStale
|
|
1527
|
+
.filter((entry) => entry.response !== null && entry.response.replies.length === 0)
|
|
1528
|
+
.map((entry) => entry.pending);
|
|
1529
|
+
const staleUnproven = finalizedStale.filter((entry) => entry.response === null);
|
|
1530
|
+
if (staleSilence.length > 0) {
|
|
1531
|
+
await retirePendings(ctx, envelope, sessionId, staleSilence, 'expired');
|
|
1532
|
+
notes.push(`expired ${staleSilence.length} question${staleSilence.length === 1 ? '' : 's'} whose continuation owner had ended`);
|
|
1533
|
+
}
|
|
1534
|
+
if (staleUnproven.length > 0) {
|
|
1535
|
+
notes.push(`could not finalize ${staleUnproven.length} expired question${staleUnproven.length === 1 ? '' : 's'}; preserving ownership for recovery`);
|
|
1536
|
+
}
|
|
1537
|
+
if (staleAnswers.length > 0) {
|
|
1538
|
+
const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length);
|
|
1539
|
+
stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining);
|
|
1540
|
+
for (const answer of staleAnswers)
|
|
1541
|
+
reportAnswer(ctx, notes, answer, true);
|
|
1542
|
+
return {
|
|
1543
|
+
stdout: stopAnswerOutput(answersContext(staleAnswers, remaining)),
|
|
1544
|
+
notes,
|
|
1545
|
+
};
|
|
1135
1546
|
}
|
|
1136
|
-
const waitingOn = [
|
|
1547
|
+
const waitingOn = [
|
|
1548
|
+
...alreadyLive.filter((entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now()),
|
|
1549
|
+
...submitted,
|
|
1550
|
+
];
|
|
1137
1551
|
if (waitingOn.length === 0)
|
|
1138
1552
|
return { notes };
|
|
1139
|
-
if (ctx.harness === 'opencode') {
|
|
1140
|
-
notes.push(`question${submitted.length === 1 ? '' : 's'} sent without blocking OpenCode; retrieve answers on the next prompt or with: notifai replies --pending`);
|
|
1141
|
-
return { notes };
|
|
1142
|
-
}
|
|
1143
1553
|
// Phase two: one blocking wait across everything live, old and new alike.
|
|
1144
|
-
const
|
|
1145
|
-
const
|
|
1554
|
+
const ownerDeadline = Math.min(budgetDeadlineAt, Math.max(...waitingOn.map((entry) => entry.reply_deadline_at)));
|
|
1555
|
+
const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000));
|
|
1556
|
+
const waited = await waitForAnyReplyWhileAway(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds, ctx.harness !== 'opencode');
|
|
1557
|
+
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
|
|
1558
|
+
if (permanentFailure !== null)
|
|
1559
|
+
notes.push(permanentFailure);
|
|
1146
1560
|
if (waited.byRequest.size === 0) {
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
const
|
|
1561
|
+
// Once this owner returns, no shipped command-hook harness can guarantee
|
|
1562
|
+
// a later answer reaches the same agent turn. Close first, then retire the
|
|
1563
|
+
// local records, so the Companion never accepts an answer into a void.
|
|
1564
|
+
const finalized = await finalizePendings(ctx, waitingOn);
|
|
1565
|
+
const finalAnswers = finalized
|
|
1566
|
+
.map(finalizedAnswer)
|
|
1567
|
+
.filter((entry) => entry !== null);
|
|
1568
|
+
const confirmedSilent = finalized
|
|
1569
|
+
.filter((entry) => entry.response !== null && entry.response.replies.length === 0)
|
|
1570
|
+
.map((entry) => entry.pending);
|
|
1571
|
+
const unproven = finalized.filter((entry) => entry.response === null);
|
|
1572
|
+
if (confirmedSilent.length > 0) {
|
|
1573
|
+
await retirePendings(ctx, envelope, sessionId, confirmedSilent, waited.userReturned ? 'answered_elsewhere' : 'expired');
|
|
1574
|
+
}
|
|
1575
|
+
if (unproven.length > 0) {
|
|
1576
|
+
notes.push(`could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`);
|
|
1577
|
+
}
|
|
1578
|
+
if (finalAnswers.length > 0) {
|
|
1579
|
+
const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length);
|
|
1580
|
+
stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining);
|
|
1581
|
+
for (const answer of finalAnswers)
|
|
1582
|
+
reportAnswer(ctx, notes, answer, false);
|
|
1583
|
+
return {
|
|
1584
|
+
stdout: stopAnswerOutput(answersContext(finalAnswers, remaining)),
|
|
1585
|
+
notes,
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
const requestIdSummary = summarizeRequestIds(waitingOn);
|
|
1589
|
+
ctx.log?.info('hook.answer', {
|
|
1590
|
+
answered: false,
|
|
1591
|
+
request_ids: requestIdSummary.ids,
|
|
1592
|
+
user_returned: waited.userReturned,
|
|
1593
|
+
degraded: waited.degraded,
|
|
1594
|
+
permanent_failures: waited.permanentFailures.size,
|
|
1595
|
+
waited_seconds: timeoutSeconds,
|
|
1596
|
+
});
|
|
1151
1597
|
if (waited.userReturned) {
|
|
1152
|
-
notes.push(`you came back after the question${waitingOn.length === 1 ? ' was' : 's were'} sent;
|
|
1153
|
-
'Retrieve answers with: notifai replies --pending');
|
|
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})`);
|
|
1154
1599
|
}
|
|
1155
|
-
else {
|
|
1600
|
+
else if (waited.permanentFailures.size === 0) {
|
|
1156
1601
|
notes.push(waited.degraded
|
|
1157
|
-
? 'could not reach the server
|
|
1158
|
-
: 'no answer in time;
|
|
1602
|
+
? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
|
|
1603
|
+
: 'no answer in time; the question expired with its continuation owner');
|
|
1159
1604
|
}
|
|
1160
1605
|
return { notes };
|
|
1161
1606
|
}
|
|
1607
|
+
const polledAnswered = waitingOn.filter((entry) => waited.byRequest.has(entry.request_id));
|
|
1608
|
+
const finalizedAnswered = await finalizePendings(ctx, polledAnswered);
|
|
1162
1609
|
const answered = [];
|
|
1163
|
-
for (const
|
|
1610
|
+
for (const finalized of finalizedAnswered) {
|
|
1611
|
+
const entry = finalized.pending;
|
|
1164
1612
|
const replies = waited.byRequest.get(entry.request_id);
|
|
1165
1613
|
if (replies === undefined)
|
|
1166
1614
|
continue;
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1615
|
+
// The fenced set wins because it includes corrections that committed
|
|
1616
|
+
// while close was waiting for earlier writers. If finalization itself was
|
|
1617
|
+
// unreachable, never discard the durable reply already observed.
|
|
1618
|
+
const finalReplies = finalized.response?.replies ?? [];
|
|
1619
|
+
const authoritative = finalReplies.length > 0 ? finalReplies : replies;
|
|
1620
|
+
answered.push({ pending: entry, reply: authoritative.at(-1), replies: authoritative });
|
|
1621
|
+
if (finalized.response === null) {
|
|
1622
|
+
notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
|
|
1623
|
+
}
|
|
1175
1624
|
}
|
|
1625
|
+
stageAcceptedAnswers(ctx, sessionId, answered, waitingOn.length - answered.length);
|
|
1626
|
+
for (const answer of answered)
|
|
1627
|
+
reportAnswer(ctx, notes, answer, false);
|
|
1176
1628
|
return {
|
|
1177
1629
|
stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
|
|
1178
1630
|
notes,
|
|
@@ -1193,7 +1645,7 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1193
1645
|
const notes = [];
|
|
1194
1646
|
const sessionId = envelope.session_id;
|
|
1195
1647
|
if (!sessionId)
|
|
1196
|
-
return { notes };
|
|
1648
|
+
return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } };
|
|
1197
1649
|
if (envelope.cwd !== undefined)
|
|
1198
1650
|
clearMatchingProjectSession(envelope.cwd, env, sessionId);
|
|
1199
1651
|
const state = readSessionState(sessionId, env);
|
|
@@ -1208,7 +1660,10 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1208
1660
|
notes.push(err instanceof Error ? err.message : String(err));
|
|
1209
1661
|
// Preserve the only identifiers instead of turning an explicit corrupt
|
|
1210
1662
|
// state into an unretirable question during SessionEnd cleanup.
|
|
1211
|
-
return {
|
|
1663
|
+
return {
|
|
1664
|
+
notes,
|
|
1665
|
+
log: { outcome: 'preserved', reason: 'incomplete-question-identifiers' },
|
|
1666
|
+
};
|
|
1212
1667
|
}
|
|
1213
1668
|
}
|
|
1214
1669
|
if (orphans.length > 0) {
|
|
@@ -1216,8 +1671,27 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1216
1671
|
orphanRetirements(env, orphans, label !== undefined && label !== '' ? label : sessionId, now);
|
|
1217
1672
|
notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
|
|
1218
1673
|
}
|
|
1674
|
+
if (state.accepted !== undefined) {
|
|
1675
|
+
const preserved = { accepted: state.accepted };
|
|
1676
|
+
if (state.last_prompt_at !== undefined)
|
|
1677
|
+
preserved.last_prompt_at = state.last_prompt_at;
|
|
1678
|
+
if (state.last_stop_at !== undefined)
|
|
1679
|
+
preserved.last_stop_at = state.last_stop_at;
|
|
1680
|
+
if (state.continuation !== undefined)
|
|
1681
|
+
preserved.continuation = state.continuation;
|
|
1682
|
+
writeSessionState(sessionId, env, preserved);
|
|
1683
|
+
notes.push('preserved an accepted device answer for this exact session to resume');
|
|
1684
|
+
return {
|
|
1685
|
+
notes,
|
|
1686
|
+
log: {
|
|
1687
|
+
outcome: 'answer-preserved',
|
|
1688
|
+
queued_retirements: orphans.length,
|
|
1689
|
+
accepted_answers: state.accepted.answers.length,
|
|
1690
|
+
},
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1219
1693
|
clearSessionState(sessionId, env);
|
|
1220
|
-
return { notes };
|
|
1694
|
+
return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } };
|
|
1221
1695
|
}
|
|
1222
1696
|
// ---------------------------------------------------------------------------
|
|
1223
1697
|
// ask — register a question for the turn's end
|
|
@@ -1254,6 +1728,7 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
|
|
|
1254
1728
|
pending: [
|
|
1255
1729
|
...pending,
|
|
1256
1730
|
{
|
|
1731
|
+
question_id: `q_${randomBytes(12).toString('base64url')}`,
|
|
1257
1732
|
asked_at: now,
|
|
1258
1733
|
...question,
|
|
1259
1734
|
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|