@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/src/hooks.ts
CHANGED
|
@@ -11,8 +11,19 @@ import {
|
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from 'node:fs'
|
|
13
13
|
import path from 'node:path'
|
|
14
|
-
import type {
|
|
15
|
-
|
|
14
|
+
import type {
|
|
15
|
+
LifecycleEndState,
|
|
16
|
+
ListRepliesResponse,
|
|
17
|
+
NotificationDraftT,
|
|
18
|
+
QuestionT,
|
|
19
|
+
ReplyView,
|
|
20
|
+
SubmissionReceipt,
|
|
21
|
+
} from '@raidiant/notifai-protocol'
|
|
22
|
+
import {
|
|
23
|
+
ApiCallError,
|
|
24
|
+
isRetryableReplyPollError,
|
|
25
|
+
type ApiClient,
|
|
26
|
+
} from './client.js'
|
|
16
27
|
import {
|
|
17
28
|
loadConfig,
|
|
18
29
|
projectSessionPointerPath,
|
|
@@ -23,15 +34,17 @@ import {
|
|
|
23
34
|
} from './config.js'
|
|
24
35
|
import { buildDraft } from './send.js'
|
|
25
36
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
37
|
+
import { withFileLock } from './file-lock.js'
|
|
38
|
+
import type { Logger } from './logging.js'
|
|
39
|
+
import { HARNESS_CAPABILITIES, HARNESSES, HOOK_TIMING, type Harness } from './harnesses.js'
|
|
26
40
|
|
|
27
41
|
/**
|
|
28
42
|
* Harness hook handlers.
|
|
29
43
|
*
|
|
30
44
|
* The supported harnesses expose the same useful lifecycle joints: a turn-end
|
|
31
|
-
* event and an event that fires when the user submits a prompt. Claude Code
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* No question detection or context-window state is needed in either path.
|
|
45
|
+
* event and an event that fires when the user submits a prompt. Claude Code
|
|
46
|
+
* and Codex can continue directly from a turn-end answer. Harnesses without a
|
|
47
|
+
* proven exact-session continuation fail closed at question admission.
|
|
35
48
|
*
|
|
36
49
|
* The load-bearing constraint is that these hooks are synchronous: while one
|
|
37
50
|
* blocks, the harness cannot show its own prompt either. So a hook may only
|
|
@@ -57,6 +70,8 @@ export interface HookEnvelope {
|
|
|
57
70
|
export interface SessionState {
|
|
58
71
|
/** Epoch ms of the user's last prompt in this session — our presence signal. */
|
|
59
72
|
last_prompt_at?: number
|
|
73
|
+
/** Epoch ms of the last observed Stop hook, distinct from prompt routing. */
|
|
74
|
+
last_stop_at?: number
|
|
60
75
|
/**
|
|
61
76
|
* Questions registered by `notifai ask`, in registration order, each
|
|
62
77
|
* awaiting escalation or its answer. A list, deliberately: registering a
|
|
@@ -84,6 +99,20 @@ export interface SessionState {
|
|
|
84
99
|
answered_at: number
|
|
85
100
|
count: number
|
|
86
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* A phone answer durably captured but not yet acknowledged by a successor
|
|
104
|
+
* hook. It stays here until the next Stop proves the harness processed the
|
|
105
|
+
* continuation; a crash before stdout therefore replays instead of erasing
|
|
106
|
+
* the user's answer.
|
|
107
|
+
*/
|
|
108
|
+
accepted?: AcceptedAnswerDelivery
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface AcceptedAnswerDelivery {
|
|
112
|
+
answers: AnsweredPending[]
|
|
113
|
+
remaining: number
|
|
114
|
+
recorded_at: number
|
|
115
|
+
continuation_count?: number
|
|
87
116
|
}
|
|
88
117
|
|
|
89
118
|
/** A delivered question awaiting its retirement push. */
|
|
@@ -127,6 +156,8 @@ const ORPHAN_TTL_MS = 24 * 3600 * 1000
|
|
|
127
156
|
const ORPHAN_QUEUE_CAP = 50
|
|
128
157
|
|
|
129
158
|
export interface PendingQuestion {
|
|
159
|
+
/** Stable local identity across racing state writers and submit recovery. */
|
|
160
|
+
question_id?: string
|
|
130
161
|
/** One-line summary: the single question's text, or the set's first. */
|
|
131
162
|
question: string
|
|
132
163
|
/**
|
|
@@ -147,17 +178,42 @@ export interface PendingQuestion {
|
|
|
147
178
|
collapse_key?: string
|
|
148
179
|
/** Exact fanout of the live question; routing config may change afterwards. */
|
|
149
180
|
device_ids?: string[]
|
|
181
|
+
/** Absolute end of the server reply window and its local continuation owner. */
|
|
182
|
+
reply_deadline_at?: number
|
|
183
|
+
/** Frozen before the first network byte, so an ambiguous submit is replayable. */
|
|
184
|
+
submission?: PendingSubmissionIntent
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface PendingSubmissionIntent {
|
|
188
|
+
request_id: string
|
|
189
|
+
idempotency_key: string
|
|
190
|
+
collapse_key: string
|
|
191
|
+
device_ids: string[]
|
|
192
|
+
draft: NotificationDraftT
|
|
193
|
+
owner_deadline_at: number
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function summarizeRequestIds(entries: readonly PendingQuestion[]): {
|
|
197
|
+
ids: string[]
|
|
198
|
+
display: string
|
|
199
|
+
} {
|
|
200
|
+
const ids = entries.flatMap((entry) =>
|
|
201
|
+
entry.request_id === undefined ? [] : [entry.request_id],
|
|
202
|
+
)
|
|
203
|
+
return { ids, display: ids.join(', ') }
|
|
150
204
|
}
|
|
151
205
|
|
|
152
206
|
/** How often the grace window rechecks whether the user has come back. */
|
|
153
207
|
const GRACE_POLL_MS = 5_000
|
|
154
208
|
|
|
155
209
|
/**
|
|
156
|
-
* Total seconds
|
|
157
|
-
*
|
|
158
|
-
*
|
|
210
|
+
* Total seconds the complete Stop invocation may own. Generated adapters grant
|
|
211
|
+
* another minute of host headroom, because a killed hook can lose an answer
|
|
212
|
+
* the user has already given. Grace and reply waiting share this budget while
|
|
213
|
+
* the reply retains a small actionable window and finalization reserve.
|
|
159
214
|
*/
|
|
160
|
-
const STOP_BUDGET_SECONDS =
|
|
215
|
+
const STOP_BUDGET_SECONDS = HOOK_TIMING.totalSeconds
|
|
216
|
+
const MIN_REPLY_WAIT_SECONDS = HOOK_TIMING.minimumReplySeconds
|
|
161
217
|
|
|
162
218
|
export type GraceOutcome =
|
|
163
219
|
/** The window elapsed with the user still gone; escalate. */
|
|
@@ -188,10 +244,10 @@ export type GraceOutcome =
|
|
|
188
244
|
*/
|
|
189
245
|
export async function awaitGrace(ctx: HookContext, askedAt: number): Promise<GraceOutcome> {
|
|
190
246
|
const threshold = ctx.config.away_after_seconds.value
|
|
191
|
-
//
|
|
247
|
+
// Keep a real device-answer window even when a user selects maximum grace.
|
|
192
248
|
const graceSeconds = Math.min(
|
|
193
249
|
ctx.config.ask_grace_seconds.value,
|
|
194
|
-
|
|
250
|
+
HOOK_TIMING.maxGraceSeconds,
|
|
195
251
|
)
|
|
196
252
|
// `asked_at` is wall-clock too, and the same jumps apply: a stamp from the
|
|
197
253
|
// future would wait far past the hook's budget, one from the distant past
|
|
@@ -217,6 +273,8 @@ export interface HookOutcome {
|
|
|
217
273
|
stdout?: string
|
|
218
274
|
/** Diagnostics; harnesses surface hook stderr in the transcript. */
|
|
219
275
|
notes: string[]
|
|
276
|
+
/** Structured lifecycle detail that belongs on hook.end without user text. */
|
|
277
|
+
log?: Record<string, unknown>
|
|
220
278
|
}
|
|
221
279
|
|
|
222
280
|
function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
@@ -272,55 +330,6 @@ function updateSessionState(
|
|
|
272
330
|
})
|
|
273
331
|
}
|
|
274
332
|
|
|
275
|
-
/**
|
|
276
|
-
* Serialize a short read-modify-write without ever exposing partial JSON.
|
|
277
|
-
* A crashed holder is recoverable after 30 seconds; releases compare their
|
|
278
|
-
* random token under the same cooperating lock discipline before unlinking.
|
|
279
|
-
*/
|
|
280
|
-
const FILE_LOCK_STALE_MS = 30_000
|
|
281
|
-
const FILE_LOCK_WAIT_MS = 1_000
|
|
282
|
-
const FILE_LOCK_POLL_MS = 5
|
|
283
|
-
const lockSleep = new Int32Array(new SharedArrayBuffer(4))
|
|
284
|
-
|
|
285
|
-
function withFileLock<T>(file: string, action: () => T): T {
|
|
286
|
-
mkdirSync(path.dirname(file), { recursive: true })
|
|
287
|
-
const token = randomBytes(12).toString('base64url')
|
|
288
|
-
const deadline = Date.now() + FILE_LOCK_WAIT_MS
|
|
289
|
-
for (;;) {
|
|
290
|
-
try {
|
|
291
|
-
const handle = openSync(file, 'wx', 0o600)
|
|
292
|
-
try {
|
|
293
|
-
writeFileSync(handle, `${JSON.stringify({ token, at: Date.now() })}\n`)
|
|
294
|
-
} finally {
|
|
295
|
-
closeSync(handle)
|
|
296
|
-
}
|
|
297
|
-
break
|
|
298
|
-
} catch (err) {
|
|
299
|
-
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
300
|
-
let stale = false
|
|
301
|
-
try {
|
|
302
|
-
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
|
|
303
|
-
stale = typeof held.at !== 'number' || Date.now() - held.at >= FILE_LOCK_STALE_MS
|
|
304
|
-
} catch {
|
|
305
|
-
stale = true
|
|
306
|
-
}
|
|
307
|
-
if (stale) rmSync(file, { force: true })
|
|
308
|
-
else if (Date.now() >= deadline) throw new Error(`timed out waiting for state lock ${file}`)
|
|
309
|
-
else Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
try {
|
|
313
|
-
return action()
|
|
314
|
-
} finally {
|
|
315
|
-
try {
|
|
316
|
-
const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
|
|
317
|
-
if (held.token === token) rmSync(file, { force: true })
|
|
318
|
-
} catch {
|
|
319
|
-
// A replaced or externally removed lock is no longer ours to release.
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
333
|
/**
|
|
325
334
|
* Session state a crashed harness left behind.
|
|
326
335
|
*
|
|
@@ -384,8 +393,9 @@ export function pruneAbandonedSessions(
|
|
|
384
393
|
* Exclusive create is atomic on POSIX, so exactly one process gets the claim
|
|
385
394
|
* and the other steps aside. A short guard serializes stale replacement with
|
|
386
395
|
* contenders and releases; random ownership tokens keep an old holder from
|
|
387
|
-
* unlinking its replacement. A
|
|
388
|
-
*
|
|
396
|
+
* unlinking its replacement. A live PID owns the claim regardless of age; a
|
|
397
|
+
* known-dead PID is recoverable immediately. Only legacy/corrupt claims with
|
|
398
|
+
* no trustworthy PID fall back to an age limit.
|
|
389
399
|
*/
|
|
390
400
|
const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000
|
|
391
401
|
const heldClaims = new Map<string, string>()
|
|
@@ -412,9 +422,13 @@ export function claimQuestionPush(
|
|
|
412
422
|
} catch {
|
|
413
423
|
// Held. Break it only if whoever holds it cannot still be running.
|
|
414
424
|
try {
|
|
415
|
-
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
|
|
425
|
+
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown; pid?: unknown }
|
|
416
426
|
const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
|
|
417
|
-
if (
|
|
427
|
+
if (typeof held.pid === 'number') {
|
|
428
|
+
if (processIsAlive(held.pid)) return false
|
|
429
|
+
} else if (age >= 0 && age < CLAIM_TTL_MS) {
|
|
430
|
+
return false
|
|
431
|
+
}
|
|
418
432
|
} catch {
|
|
419
433
|
// Unreadable or corrupt: treat as abandoned.
|
|
420
434
|
}
|
|
@@ -436,6 +450,15 @@ export function claimQuestionPush(
|
|
|
436
450
|
}
|
|
437
451
|
}
|
|
438
452
|
|
|
453
|
+
function processIsAlive(pid: number): boolean {
|
|
454
|
+
try {
|
|
455
|
+
process.kill(pid, 0)
|
|
456
|
+
return true
|
|
457
|
+
} catch (err) {
|
|
458
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
439
462
|
export function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
440
463
|
const file = claimPath(sessionId, env)
|
|
441
464
|
const token = heldClaims.get(file)
|
|
@@ -490,7 +513,11 @@ function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
|
490
513
|
*/
|
|
491
514
|
const MAX_STORED_QUESTION_CHARS = 2000
|
|
492
515
|
|
|
493
|
-
|
|
516
|
+
interface StoredProjectSessionPointer extends ProjectSessionPointer {
|
|
517
|
+
updatedAt: number
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Records every live session working in a directory, for `notifai ask`. */
|
|
494
521
|
export function writeProjectSession(
|
|
495
522
|
cwd: string,
|
|
496
523
|
env: NodeJS.ProcessEnv,
|
|
@@ -500,9 +527,21 @@ export function writeProjectSession(
|
|
|
500
527
|
): void {
|
|
501
528
|
const file = projectSessionPointerPath(cwd, env)
|
|
502
529
|
withFileLock(`${file}.lock`, () => {
|
|
530
|
+
const existing = readStoredProjectSessionPointers(file).filter(
|
|
531
|
+
(entry) => entry.sessionId !== sessionId && now - entry.updatedAt <= 24 * 3600 * 1000,
|
|
532
|
+
)
|
|
503
533
|
atomicWriteFileSync(
|
|
504
534
|
file,
|
|
505
|
-
`${JSON.stringify({
|
|
535
|
+
`${JSON.stringify({
|
|
536
|
+
sessions: [
|
|
537
|
+
...existing.map((entry) => ({
|
|
538
|
+
session_id: entry.sessionId,
|
|
539
|
+
updated_at: entry.updatedAt,
|
|
540
|
+
harness: entry.harness,
|
|
541
|
+
})),
|
|
542
|
+
{ session_id: sessionId, updated_at: now, harness },
|
|
543
|
+
],
|
|
544
|
+
})}\n`,
|
|
506
545
|
)
|
|
507
546
|
})
|
|
508
547
|
}
|
|
@@ -512,6 +551,34 @@ export interface ProjectSessionPointer {
|
|
|
512
551
|
harness: HookHarness
|
|
513
552
|
}
|
|
514
553
|
|
|
554
|
+
function readStoredProjectSessionPointers(file: string): StoredProjectSessionPointer[] {
|
|
555
|
+
if (!existsSync(file)) return []
|
|
556
|
+
try {
|
|
557
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { sessions?: unknown }
|
|
558
|
+
if (!Array.isArray(parsed.sessions)) return []
|
|
559
|
+
return parsed.sessions.flatMap((candidate): StoredProjectSessionPointer[] => {
|
|
560
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return []
|
|
561
|
+
const entry = candidate as {
|
|
562
|
+
session_id?: unknown
|
|
563
|
+
updated_at?: unknown
|
|
564
|
+
harness?: unknown
|
|
565
|
+
}
|
|
566
|
+
if (typeof entry.session_id !== 'string' || entry.session_id === '') return []
|
|
567
|
+
if (typeof entry.updated_at !== 'number') return []
|
|
568
|
+
if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return []
|
|
569
|
+
return [
|
|
570
|
+
{
|
|
571
|
+
sessionId: entry.session_id,
|
|
572
|
+
updatedAt: entry.updated_at,
|
|
573
|
+
harness: entry.harness as HookHarness,
|
|
574
|
+
},
|
|
575
|
+
]
|
|
576
|
+
})
|
|
577
|
+
} catch {
|
|
578
|
+
return []
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
515
582
|
/**
|
|
516
583
|
* Resolves the session working in `cwd`. Stale pointers are ignored rather than
|
|
517
584
|
* trusted: an id left by a session that ended days ago would send the question
|
|
@@ -533,31 +600,56 @@ export function readProjectSessionPointer(
|
|
|
533
600
|
maxAgeMs = 24 * 3600 * 1000,
|
|
534
601
|
): ProjectSessionPointer | null {
|
|
535
602
|
const file = projectSessionPointerPath(cwd, env)
|
|
536
|
-
|
|
603
|
+
const live = readStoredProjectSessionPointers(file)
|
|
604
|
+
.filter((entry) => now - entry.updatedAt <= maxAgeMs)
|
|
605
|
+
.filter((entry) => {
|
|
606
|
+
try {
|
|
607
|
+
// A pointer is routing evidence only while its session state still
|
|
608
|
+
// exists and parses. SessionEnd and explicit cleanup therefore
|
|
609
|
+
// invalidate it even when a crash prevented removal from this index.
|
|
610
|
+
const sessionFile = sessionStatePath(entry.sessionId, env)
|
|
611
|
+
if (!existsSync(sessionFile)) return false
|
|
612
|
+
const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
|
|
613
|
+
return typeof state === 'object' && state !== null && !Array.isArray(state)
|
|
614
|
+
} catch {
|
|
615
|
+
return false
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
619
|
+
const latest = live[0]
|
|
620
|
+
return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness }
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function readMatchingProjectSessionPointer(
|
|
624
|
+
cwd: string,
|
|
625
|
+
env: NodeJS.ProcessEnv,
|
|
626
|
+
now: number,
|
|
627
|
+
sessionId: string,
|
|
628
|
+
harness: HookHarness,
|
|
629
|
+
maxAgeMs = 24 * 3600 * 1000,
|
|
630
|
+
): ProjectSessionPointer | null {
|
|
631
|
+
const file = projectSessionPointerPath(cwd, env)
|
|
632
|
+
const match = readStoredProjectSessionPointers(file).find(
|
|
633
|
+
(entry) =>
|
|
634
|
+
entry.sessionId === sessionId &&
|
|
635
|
+
entry.harness === harness &&
|
|
636
|
+
now - entry.updatedAt <= maxAgeMs,
|
|
637
|
+
)
|
|
638
|
+
if (match === undefined) return null
|
|
537
639
|
try {
|
|
538
|
-
const parsed = JSON.parse(readFileSync(file, 'utf8')) as {
|
|
539
|
-
session_id?: unknown
|
|
540
|
-
updated_at?: unknown
|
|
541
|
-
harness?: unknown
|
|
542
|
-
}
|
|
543
|
-
if (typeof parsed.session_id !== 'string' || parsed.session_id === '') return null
|
|
544
|
-
if (typeof parsed.updated_at !== 'number' || now - parsed.updated_at > maxAgeMs) return null
|
|
545
|
-
if (!(HOOK_HARNESSES as readonly unknown[]).includes(parsed.harness)) return null
|
|
546
640
|
// A pointer is routing evidence only while its session state still exists
|
|
547
641
|
// and parses. SessionEnd and explicit cleanup therefore invalidate it even
|
|
548
642
|
// when a crash prevented the pointer file itself from being removed.
|
|
549
|
-
const sessionFile = sessionStatePath(
|
|
643
|
+
const sessionFile = sessionStatePath(match.sessionId, env)
|
|
550
644
|
if (!existsSync(sessionFile)) return null
|
|
551
645
|
const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
|
|
552
646
|
if (typeof state !== 'object' || state === null || Array.isArray(state)) return null
|
|
553
|
-
return { sessionId:
|
|
647
|
+
return { sessionId: match.sessionId, harness: match.harness }
|
|
554
648
|
} catch {
|
|
555
649
|
return null
|
|
556
650
|
}
|
|
557
651
|
}
|
|
558
652
|
|
|
559
|
-
const HOOK_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
|
|
560
|
-
|
|
561
653
|
function clearMatchingProjectSession(
|
|
562
654
|
cwd: string,
|
|
563
655
|
env: NodeJS.ProcessEnv,
|
|
@@ -565,12 +657,23 @@ function clearMatchingProjectSession(
|
|
|
565
657
|
): void {
|
|
566
658
|
const file = projectSessionPointerPath(cwd, env)
|
|
567
659
|
withFileLock(`${file}.lock`, () => {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
660
|
+
const remaining = readStoredProjectSessionPointers(file).filter(
|
|
661
|
+
(entry) => entry.sessionId !== sessionId,
|
|
662
|
+
)
|
|
663
|
+
if (remaining.length === 0) {
|
|
664
|
+
rmSync(file, { force: true })
|
|
665
|
+
return
|
|
573
666
|
}
|
|
667
|
+
atomicWriteFileSync(
|
|
668
|
+
file,
|
|
669
|
+
`${JSON.stringify({
|
|
670
|
+
sessions: remaining.map((entry) => ({
|
|
671
|
+
session_id: entry.sessionId,
|
|
672
|
+
updated_at: entry.updatedAt,
|
|
673
|
+
harness: entry.harness,
|
|
674
|
+
})),
|
|
675
|
+
})}\n`,
|
|
676
|
+
)
|
|
574
677
|
})
|
|
575
678
|
}
|
|
576
679
|
|
|
@@ -641,19 +744,50 @@ export interface HookContext {
|
|
|
641
744
|
requestId: string,
|
|
642
745
|
timeoutSeconds: number,
|
|
643
746
|
) => Promise<{ replies: ReplyView[]; timedOut: boolean; degraded?: boolean }>
|
|
644
|
-
/**
|
|
747
|
+
/** The active harness selects the native continuation output adapter. */
|
|
645
748
|
harness?: HookHarness
|
|
749
|
+
/**
|
|
750
|
+
* The local record. A hook's decisions are invisible from everywhere else —
|
|
751
|
+
* its stderr belongs to the harness and its usual outcome is to do nothing —
|
|
752
|
+
* so this is the only account of why a question did or did not travel.
|
|
753
|
+
*/
|
|
754
|
+
log?: Logger
|
|
646
755
|
}
|
|
647
756
|
|
|
648
|
-
|
|
757
|
+
/**
|
|
758
|
+
* Record one gate decision.
|
|
759
|
+
*
|
|
760
|
+
* `verdict` is what happened to the question and `reason` is the closed-set
|
|
761
|
+
* name of the rule that decided it, so an agent can ask "why was it held" with
|
|
762
|
+
* a filter rather than by matching on prose. The prose still exists — it is what
|
|
763
|
+
* the user sees in the terminal — but it is free to be rewritten, and a log a
|
|
764
|
+
* filter cannot rely on is a log nobody filters.
|
|
765
|
+
*/
|
|
766
|
+
export type GateReason =
|
|
767
|
+
| 'no-session'
|
|
768
|
+
| 'no-question'
|
|
769
|
+
| 'answered'
|
|
770
|
+
| 'already-asked'
|
|
771
|
+
| 'continuation-repeat'
|
|
772
|
+
| 'continuation-limit'
|
|
773
|
+
| 'notifications-off'
|
|
774
|
+
| 'user-present'
|
|
775
|
+
| 'claimed-elsewhere'
|
|
776
|
+
| 'user-returned'
|
|
777
|
+
| 'no-devices'
|
|
778
|
+
| 'proceeding'
|
|
649
779
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
780
|
+
function gate(
|
|
781
|
+
ctx: HookContext,
|
|
782
|
+
verdict: 'held' | 'proceeding',
|
|
783
|
+
reason: GateReason,
|
|
784
|
+
data: Record<string, unknown> = {},
|
|
785
|
+
): void {
|
|
786
|
+
ctx.log?.info('hook.gate', { verdict, reason, ...data })
|
|
655
787
|
}
|
|
656
788
|
|
|
789
|
+
export type HookHarness = Harness
|
|
790
|
+
|
|
657
791
|
/** Re-check local presence at this cadence while a pushed question is waiting. */
|
|
658
792
|
const REPLY_PRESENCE_POLL_SECONDS = 5
|
|
659
793
|
|
|
@@ -672,7 +806,7 @@ const REPLY_PRESENCE_POLL_SECONDS = 5
|
|
|
672
806
|
* Put one registered question on the user's devices. Submission only — the
|
|
673
807
|
* wait happens once, across every live question, in the caller.
|
|
674
808
|
*/
|
|
675
|
-
async function
|
|
809
|
+
async function prepareQuestionSubmission(
|
|
676
810
|
ctx: HookContext,
|
|
677
811
|
options: {
|
|
678
812
|
title: string
|
|
@@ -684,8 +818,10 @@ async function submitQuestion(
|
|
|
684
818
|
session?: string | undefined
|
|
685
819
|
/** How long the server keeps accepting an answer. */
|
|
686
820
|
windowSeconds: number
|
|
821
|
+
/** Absolute owner deadline persisted before submit. */
|
|
822
|
+
ownerDeadlineAt: number
|
|
687
823
|
},
|
|
688
|
-
): Promise<
|
|
824
|
+
): Promise<PendingSubmissionIntent | { error: string }> {
|
|
689
825
|
const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`
|
|
690
826
|
// A draft carrying `reply` is rejected outright if it targets a device that
|
|
691
827
|
// cannot answer, so resolve the healthy companion platforms explicitly.
|
|
@@ -708,11 +844,34 @@ async function submitQuestion(
|
|
|
708
844
|
})
|
|
709
845
|
if (!build.ok) return { error: build.error }
|
|
710
846
|
|
|
847
|
+
return {
|
|
848
|
+
request_id: `req_${randomBytes(18).toString('base64url')}`,
|
|
849
|
+
idempotency_key: `hook-${randomBytes(12).toString('base64url')}`,
|
|
850
|
+
collapse_key: collapseKey,
|
|
851
|
+
device_ids: answerable,
|
|
852
|
+
draft: build.draft,
|
|
853
|
+
owner_deadline_at: options.ownerDeadlineAt,
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function submitQuestion(
|
|
858
|
+
ctx: HookContext,
|
|
859
|
+
intent: PendingSubmissionIntent,
|
|
860
|
+
): Promise<SubmissionReceipt> {
|
|
711
861
|
const receipt = await ctx.client.submit(
|
|
712
|
-
{
|
|
862
|
+
{
|
|
863
|
+
request_id: intent.request_id,
|
|
864
|
+
idempotency_key: intent.idempotency_key,
|
|
865
|
+
draft: intent.draft,
|
|
866
|
+
},
|
|
713
867
|
0,
|
|
714
868
|
)
|
|
715
|
-
|
|
869
|
+
if (receipt.request_id !== intent.request_id) {
|
|
870
|
+
throw new Error(
|
|
871
|
+
`server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`,
|
|
872
|
+
)
|
|
873
|
+
}
|
|
874
|
+
return receipt
|
|
716
875
|
}
|
|
717
876
|
|
|
718
877
|
/**
|
|
@@ -725,22 +884,29 @@ async function waitForAnyReplyWhileAway(
|
|
|
725
884
|
ctx: HookContext,
|
|
726
885
|
requestIds: string[],
|
|
727
886
|
timeoutSeconds: number,
|
|
728
|
-
|
|
887
|
+
monitorPresence = true,
|
|
888
|
+
): Promise<{
|
|
889
|
+
byRequest: Map<string, ReplyView[]>
|
|
890
|
+
degraded: boolean
|
|
891
|
+
userReturned: boolean
|
|
892
|
+
permanentFailures: Map<string, string>
|
|
893
|
+
}> {
|
|
729
894
|
const deadline = ctx.now() + timeoutSeconds * 1000
|
|
730
895
|
let degraded = false
|
|
731
896
|
let firstPoll = true
|
|
897
|
+
const permanentFailures = new Map<string, string>()
|
|
732
898
|
|
|
733
899
|
for (;;) {
|
|
734
|
-
if (ctx.config.require_idle.value) {
|
|
900
|
+
if (monitorPresence && ctx.config.require_idle.value) {
|
|
735
901
|
const idle = ctx.idleSeconds()
|
|
736
902
|
if (idle !== null && idle < ctx.config.away_after_seconds.value) {
|
|
737
|
-
return { byRequest: new Map(), degraded, userReturned: true }
|
|
903
|
+
return { byRequest: new Map(), degraded, userReturned: true, permanentFailures }
|
|
738
904
|
}
|
|
739
905
|
}
|
|
740
906
|
|
|
741
907
|
const remainingMs = Math.max(0, deadline - ctx.now())
|
|
742
908
|
if (!firstPoll && remainingMs === 0) {
|
|
743
|
-
return { byRequest: new Map(), degraded, userReturned: false }
|
|
909
|
+
return { byRequest: new Map(), degraded, userReturned: false, permanentFailures }
|
|
744
910
|
}
|
|
745
911
|
firstPoll = false
|
|
746
912
|
const pollSeconds = Math.min(
|
|
@@ -748,23 +914,51 @@ async function waitForAnyReplyWhileAway(
|
|
|
748
914
|
Math.max(0, Math.ceil(remainingMs / 1000)),
|
|
749
915
|
)
|
|
750
916
|
const results = await Promise.all(
|
|
751
|
-
requestIds
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
917
|
+
requestIds
|
|
918
|
+
.filter((requestId) => !permanentFailures.has(requestId))
|
|
919
|
+
.map(async (requestId) => {
|
|
920
|
+
try {
|
|
921
|
+
return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) }
|
|
922
|
+
} catch (err) {
|
|
923
|
+
if (isRetryableReplyPollError(err)) {
|
|
924
|
+
return { requestId, replies: [] as ReplyView[], degraded: true }
|
|
925
|
+
}
|
|
926
|
+
return {
|
|
927
|
+
requestId,
|
|
928
|
+
replies: [] as ReplyView[],
|
|
929
|
+
degraded: false,
|
|
930
|
+
permanentFailure: replyPollFailure(err),
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}),
|
|
758
934
|
)
|
|
759
935
|
const byRequest = new Map<string, ReplyView[]>()
|
|
760
936
|
for (const result of results) {
|
|
761
937
|
degraded ||= result.degraded === true
|
|
938
|
+
if (result.permanentFailure !== undefined) {
|
|
939
|
+
permanentFailures.set(result.requestId, result.permanentFailure)
|
|
940
|
+
}
|
|
762
941
|
if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
|
|
763
942
|
}
|
|
764
|
-
if (byRequest.size > 0
|
|
943
|
+
if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
|
|
944
|
+
return { byRequest, degraded, userReturned: false, permanentFailures }
|
|
945
|
+
}
|
|
765
946
|
}
|
|
766
947
|
}
|
|
767
948
|
|
|
949
|
+
/** A non-retryable replies fault, with enough server identity to act on it. */
|
|
950
|
+
function replyPollFailure(err: unknown): string {
|
|
951
|
+
return err instanceof ApiCallError
|
|
952
|
+
? `${err.code} (HTTP ${err.status}): ${err.message}`
|
|
953
|
+
: String(err)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function permanentReplyFailureNote(failures: Map<string, string>): string | null {
|
|
957
|
+
if (failures.size === 0) return null
|
|
958
|
+
const details = [...failures].map(([requestId, failure]) => `${requestId}: ${failure}`).join('; ')
|
|
959
|
+
return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`
|
|
960
|
+
}
|
|
961
|
+
|
|
768
962
|
/**
|
|
769
963
|
* Healthy companion devices that implement replies. Both the iOS app and the
|
|
770
964
|
* macOS app register reply categories and submit answers directly.
|
|
@@ -806,15 +1000,28 @@ function sessionLabel(ctx: HookContext, envelope: HookEnvelope): string | undefi
|
|
|
806
1000
|
*/
|
|
807
1001
|
export type RetireDeps = Pick<HookContext, 'client' | 'config'>
|
|
808
1002
|
|
|
809
|
-
/**
|
|
810
|
-
|
|
1003
|
+
/**
|
|
1004
|
+
* Close is a server-side serialization fence: a successful response contains
|
|
1005
|
+
* every reply that committed before the window closed. `null` is deliberately
|
|
1006
|
+
* different from an empty reply set — it means ownership could not be proven
|
|
1007
|
+
* final and the local record must stay recoverable.
|
|
1008
|
+
*/
|
|
1009
|
+
async function finalizeReplies(
|
|
1010
|
+
ctx: RetireDeps,
|
|
1011
|
+
requestId: string,
|
|
1012
|
+
): Promise<ListRepliesResponse | null> {
|
|
811
1013
|
try {
|
|
812
|
-
await ctx.client.closeReplies(requestId)
|
|
1014
|
+
return await ctx.client.closeReplies(requestId)
|
|
813
1015
|
} catch {
|
|
814
|
-
|
|
1016
|
+
return null
|
|
815
1017
|
}
|
|
816
1018
|
}
|
|
817
1019
|
|
|
1020
|
+
/** Best effort only for already-journaled retirement debt. */
|
|
1021
|
+
async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
|
|
1022
|
+
await finalizeReplies(ctx, requestId)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
818
1025
|
/**
|
|
819
1026
|
* Retire the question on every device it reached. A state change is not news
|
|
820
1027
|
* (D-B): the retirement rides as a background state sync — no alert, no
|
|
@@ -1086,7 +1293,7 @@ interface PendingPoll {
|
|
|
1086
1293
|
/** Every reply in arrival order, for free-text answers given in parts. */
|
|
1087
1294
|
replies: ReplyView[]
|
|
1088
1295
|
degraded: boolean
|
|
1089
|
-
|
|
1296
|
+
permanentFailure: string | null
|
|
1090
1297
|
}
|
|
1091
1298
|
|
|
1092
1299
|
/** One bounded recovery poll for a question that outlived its original wait. */
|
|
@@ -1096,7 +1303,7 @@ async function pollPendingReply(
|
|
|
1096
1303
|
timeoutSeconds: number,
|
|
1097
1304
|
): Promise<PendingPoll> {
|
|
1098
1305
|
if (pending.request_id === undefined) {
|
|
1099
|
-
return { reply: null, replies: [], degraded: false,
|
|
1306
|
+
return { reply: null, replies: [], degraded: false, permanentFailure: null }
|
|
1100
1307
|
}
|
|
1101
1308
|
try {
|
|
1102
1309
|
const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
|
|
@@ -1104,10 +1311,12 @@ async function pollPendingReply(
|
|
|
1104
1311
|
reply: result.replies.at(-1) ?? null,
|
|
1105
1312
|
replies: result.replies,
|
|
1106
1313
|
degraded: result.degraded === true,
|
|
1107
|
-
|
|
1314
|
+
permanentFailure: null,
|
|
1108
1315
|
}
|
|
1109
|
-
} catch {
|
|
1110
|
-
return
|
|
1316
|
+
} catch (err) {
|
|
1317
|
+
return isRetryableReplyPollError(err)
|
|
1318
|
+
? { reply: null, replies: [], degraded: true, permanentFailure: null }
|
|
1319
|
+
: { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) }
|
|
1111
1320
|
}
|
|
1112
1321
|
}
|
|
1113
1322
|
|
|
@@ -1118,6 +1327,48 @@ interface AnsweredPending {
|
|
|
1118
1327
|
replies: ReplyView[]
|
|
1119
1328
|
}
|
|
1120
1329
|
|
|
1330
|
+
interface FinalizedPending {
|
|
1331
|
+
pending: PendingQuestion
|
|
1332
|
+
response: ListRepliesResponse | null
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** Close several windows concurrently without confusing failure with silence. */
|
|
1336
|
+
async function finalizePendings(
|
|
1337
|
+
ctx: HookContext,
|
|
1338
|
+
pending: PendingQuestion[],
|
|
1339
|
+
): Promise<FinalizedPending[]> {
|
|
1340
|
+
return Promise.all(
|
|
1341
|
+
pending.map(async (entry) => ({
|
|
1342
|
+
pending: entry,
|
|
1343
|
+
response: await finalizeReplies(ctx, entry.request_id!),
|
|
1344
|
+
})),
|
|
1345
|
+
)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** Convert a fenced final reply set into the exact answer handed to the model. */
|
|
1349
|
+
function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
|
|
1350
|
+
const replies = finalized.response?.replies ?? []
|
|
1351
|
+
const reply = replies.at(-1)
|
|
1352
|
+
return reply === undefined ? null : { pending: finalized.pending, reply, replies }
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** Persist and surface one answer without letting the two representations drift. */
|
|
1356
|
+
function reportAnswer(
|
|
1357
|
+
ctx: HookContext,
|
|
1358
|
+
notes: string[],
|
|
1359
|
+
answered: AnsweredPending,
|
|
1360
|
+
late: boolean,
|
|
1361
|
+
): void {
|
|
1362
|
+
ctx.log?.info('hook.answer', {
|
|
1363
|
+
answered: true,
|
|
1364
|
+
...(late ? { late: true } : {}),
|
|
1365
|
+
request_id: answered.pending.request_id,
|
|
1366
|
+
device: answered.reply.device_name,
|
|
1367
|
+
text: answered.reply.text,
|
|
1368
|
+
})
|
|
1369
|
+
notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`)
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1121
1372
|
/**
|
|
1122
1373
|
* One bounded recovery poll across every delivered question, concurrently —
|
|
1123
1374
|
* N questions must not multiply the hook's latency budget by N.
|
|
@@ -1126,19 +1377,27 @@ async function pollPendingReplies(
|
|
|
1126
1377
|
ctx: HookContext,
|
|
1127
1378
|
live: PendingQuestion[],
|
|
1128
1379
|
timeoutSeconds: number,
|
|
1129
|
-
): Promise<{
|
|
1380
|
+
): Promise<{
|
|
1381
|
+
answered: AnsweredPending[]
|
|
1382
|
+
transientTrouble: boolean
|
|
1383
|
+
permanentFailures: Map<string, string>
|
|
1384
|
+
}> {
|
|
1130
1385
|
const polls = await Promise.all(
|
|
1131
1386
|
live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)),
|
|
1132
1387
|
)
|
|
1133
1388
|
const answered: AnsweredPending[] = []
|
|
1134
|
-
let
|
|
1389
|
+
let transientTrouble = false
|
|
1390
|
+
const permanentFailures = new Map<string, string>()
|
|
1135
1391
|
for (const [index, poll] of polls.entries()) {
|
|
1136
1392
|
if (poll.reply !== null) {
|
|
1137
1393
|
answered.push({ pending: live[index]!, reply: poll.reply, replies: poll.replies })
|
|
1138
1394
|
}
|
|
1139
|
-
if (poll.
|
|
1395
|
+
if (poll.degraded) transientTrouble = true
|
|
1396
|
+
if (poll.permanentFailure !== null) {
|
|
1397
|
+
permanentFailures.set(live[index]!.request_id!, poll.permanentFailure)
|
|
1398
|
+
}
|
|
1140
1399
|
}
|
|
1141
|
-
return { answered,
|
|
1400
|
+
return { answered, transientTrouble, permanentFailures }
|
|
1142
1401
|
}
|
|
1143
1402
|
|
|
1144
1403
|
/** The registered-question queue. Anything but the current shape reads as empty. */
|
|
@@ -1148,6 +1407,9 @@ function pendingList(state: SessionState): PendingQuestion[] {
|
|
|
1148
1407
|
|
|
1149
1408
|
/** Registration identity — the convention every racing writer compares by. */
|
|
1150
1409
|
function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
|
|
1410
|
+
if (a.question_id !== undefined || b.question_id !== undefined) {
|
|
1411
|
+
return a.question_id !== undefined && a.question_id === b.question_id
|
|
1412
|
+
}
|
|
1151
1413
|
return a.question === b.question && a.asked_at === b.asked_at
|
|
1152
1414
|
}
|
|
1153
1415
|
|
|
@@ -1208,33 +1470,50 @@ function answersContext(answered: AnsweredPending[], remaining: number): string
|
|
|
1208
1470
|
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}\nContinue with these answers.${tail}`
|
|
1209
1471
|
}
|
|
1210
1472
|
|
|
1211
|
-
function
|
|
1473
|
+
function stopAnswerOutput(context: string): string {
|
|
1212
1474
|
return JSON.stringify({
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
additionalContext: context,
|
|
1216
|
-
},
|
|
1475
|
+
decision: 'block',
|
|
1476
|
+
reason: context,
|
|
1217
1477
|
})
|
|
1218
1478
|
}
|
|
1219
1479
|
|
|
1220
|
-
function stopAnswerOutput(context: string): string {
|
|
1221
|
-
return JSON.stringify({ decision: 'block', reason: context })
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
1480
|
/**
|
|
1225
1481
|
* Close, truthfully retire, and forget every answered pending question in one
|
|
1226
1482
|
* state write, without dropping any other retirement debt or any question
|
|
1227
1483
|
* still waiting. Stop answers also open one bounded continuation generation;
|
|
1228
1484
|
* UserPromptSubmit answers ride the user's new turn.
|
|
1229
1485
|
*/
|
|
1230
|
-
|
|
1486
|
+
function stageAcceptedAnswers(
|
|
1231
1487
|
ctx: HookContext,
|
|
1232
|
-
envelope: HookEnvelope,
|
|
1233
1488
|
sessionId: string,
|
|
1234
1489
|
answered: AnsweredPending[],
|
|
1235
|
-
|
|
1236
|
-
):
|
|
1237
|
-
|
|
1490
|
+
remaining: number,
|
|
1491
|
+
): void {
|
|
1492
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1493
|
+
const pendingRemaining = pendingList(current).filter(
|
|
1494
|
+
(entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
|
|
1495
|
+
)
|
|
1496
|
+
const next: SessionState = {
|
|
1497
|
+
...current,
|
|
1498
|
+
accepted: {
|
|
1499
|
+
answers: answered,
|
|
1500
|
+
remaining,
|
|
1501
|
+
recorded_at: ctx.now(),
|
|
1502
|
+
},
|
|
1503
|
+
}
|
|
1504
|
+
if (pendingRemaining.length > 0) next.pending = pendingRemaining
|
|
1505
|
+
else delete next.pending
|
|
1506
|
+
return next
|
|
1507
|
+
})
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** Settle only after a successor hook proves the harness received the answer. */
|
|
1511
|
+
function settleAcceptedAnswers(
|
|
1512
|
+
ctx: HookContext,
|
|
1513
|
+
sessionId: string,
|
|
1514
|
+
accepted: AcceptedAnswerDelivery,
|
|
1515
|
+
): void {
|
|
1516
|
+
const retirements = accepted.answers
|
|
1238
1517
|
.map(({ pending }) => retiringQuestion(pending, 'answered'))
|
|
1239
1518
|
.filter((entry): entry is RetiringQuestion => entry !== null)
|
|
1240
1519
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
@@ -1244,24 +1523,44 @@ async function finishAnsweredPendings(
|
|
|
1244
1523
|
if (existing < 0) retiring.push(retirement)
|
|
1245
1524
|
else retiring[existing] = retirement
|
|
1246
1525
|
}
|
|
1247
|
-
const remaining = pendingList(current).filter(
|
|
1248
|
-
(entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
|
|
1249
|
-
)
|
|
1250
1526
|
const next: SessionState = { ...current, retiring }
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1527
|
+
delete next.accepted
|
|
1528
|
+
next.continuation = {
|
|
1529
|
+
answered_at: accepted.recorded_at,
|
|
1530
|
+
count: accepted.continuation_count ?? 1,
|
|
1531
|
+
}
|
|
1532
|
+
return next
|
|
1533
|
+
})
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
async function retirePendings(
|
|
1537
|
+
ctx: HookContext,
|
|
1538
|
+
envelope: HookEnvelope,
|
|
1539
|
+
sessionId: string,
|
|
1540
|
+
entries: PendingQuestion[],
|
|
1541
|
+
state: LifecycleEndState,
|
|
1542
|
+
): Promise<void> {
|
|
1543
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1544
|
+
const retiring = [...(current.retiring ?? [])]
|
|
1545
|
+
for (const entry of entries) {
|
|
1546
|
+
const retirement = retiringQuestion(entry, state)
|
|
1547
|
+
if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
|
|
1548
|
+
retiring.push(retirement)
|
|
1258
1549
|
}
|
|
1259
|
-
} else {
|
|
1260
|
-
next.last_prompt_at = ctx.now()
|
|
1261
1550
|
}
|
|
1551
|
+
const pending = pendingList(current).filter(
|
|
1552
|
+
(candidate) => !entries.some((entry) => isSamePending(candidate, entry)),
|
|
1553
|
+
)
|
|
1554
|
+
const next: SessionState = { ...current }
|
|
1555
|
+
if (pending.length > 0) next.pending = pending
|
|
1556
|
+
else delete next.pending
|
|
1557
|
+
if (retiring.length > 0) next.retiring = retiring
|
|
1558
|
+
else delete next.retiring
|
|
1262
1559
|
return next
|
|
1263
1560
|
})
|
|
1264
|
-
|
|
1561
|
+
// Network retirement is deliberately deferred to a later no-question hook.
|
|
1562
|
+
// The state write above is the durable operation; blocking here creates a
|
|
1563
|
+
// crash window before an answer can reach harness stdout.
|
|
1265
1564
|
}
|
|
1266
1565
|
|
|
1267
1566
|
// ---------------------------------------------------------------------------
|
|
@@ -1283,27 +1582,63 @@ export async function handleUserPromptSubmit(
|
|
|
1283
1582
|
if (!sessionId) return { notes }
|
|
1284
1583
|
|
|
1285
1584
|
const state = readSessionState(sessionId, ctx.env)
|
|
1585
|
+
if (state.accepted !== undefined) {
|
|
1586
|
+
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
1587
|
+
...current,
|
|
1588
|
+
last_prompt_at: ctx.now(),
|
|
1589
|
+
}))
|
|
1590
|
+
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
1591
|
+
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
|
|
1592
|
+
}
|
|
1593
|
+
notes.push('a device answer is safely journaled; the next Stop will deliver it')
|
|
1594
|
+
return { notes }
|
|
1595
|
+
}
|
|
1286
1596
|
const live = pendingList(state).filter((entry) => entry.request_id !== undefined)
|
|
1287
1597
|
let lateAnswers: AnsweredPending[] = []
|
|
1288
1598
|
if (live.length > 0) {
|
|
1289
|
-
const { answered,
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1599
|
+
const { answered, transientTrouble, permanentFailures } = await pollPendingReplies(
|
|
1600
|
+
ctx,
|
|
1601
|
+
live,
|
|
1602
|
+
LATE_PROMPT_POLL_SECONDS,
|
|
1603
|
+
)
|
|
1604
|
+
const finalized = await finalizePendings(
|
|
1605
|
+
ctx,
|
|
1606
|
+
answered.map((entry) => entry.pending),
|
|
1607
|
+
)
|
|
1608
|
+
lateAnswers = answered.map((entry) => {
|
|
1609
|
+
const response = finalized.find((candidate) =>
|
|
1610
|
+
isSamePending(candidate.pending, entry.pending),
|
|
1611
|
+
)?.response
|
|
1612
|
+
const replies = response?.replies.length ? response.replies : entry.replies
|
|
1613
|
+
if (response === null) {
|
|
1614
|
+
notes.push(
|
|
1615
|
+
`could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`,
|
|
1616
|
+
)
|
|
1295
1617
|
}
|
|
1618
|
+
return { ...entry, replies, reply: replies.at(-1)! }
|
|
1619
|
+
})
|
|
1620
|
+
if (answered.length > 0) {
|
|
1621
|
+
stageAcceptedAnswers(
|
|
1622
|
+
ctx,
|
|
1623
|
+
sessionId,
|
|
1624
|
+
lateAnswers,
|
|
1625
|
+
0,
|
|
1626
|
+
)
|
|
1627
|
+
for (const answer of lateAnswers) reportAnswer(ctx, notes, answer, true)
|
|
1296
1628
|
}
|
|
1297
|
-
if (
|
|
1629
|
+
if (transientTrouble) {
|
|
1298
1630
|
notes.push('could not check every pending question for a late answer before the prompt')
|
|
1299
1631
|
}
|
|
1632
|
+
const permanentFailure = permanentReplyFailureNote(permanentFailures)
|
|
1633
|
+
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
1300
1634
|
}
|
|
1301
1635
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
1302
1636
|
// the next hook sees both copies and dedupes them; the old order could die in
|
|
1303
1637
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
1304
1638
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1305
1639
|
const retiring = [...(current.retiring ?? [])]
|
|
1306
|
-
|
|
1640
|
+
const unasked = pendingList(current).filter((entry) => entry.request_id === undefined)
|
|
1641
|
+
for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
|
|
1307
1642
|
const retirement = retiringQuestion(entry, 'answered_elsewhere')
|
|
1308
1643
|
if (
|
|
1309
1644
|
retirement !== null &&
|
|
@@ -1312,7 +1647,14 @@ export async function handleUserPromptSubmit(
|
|
|
1312
1647
|
retiring.push(retirement)
|
|
1313
1648
|
}
|
|
1314
1649
|
}
|
|
1315
|
-
return {
|
|
1650
|
+
return {
|
|
1651
|
+
last_prompt_at: ctx.now(),
|
|
1652
|
+
...(current.last_stop_at === undefined ? {} : { last_stop_at: current.last_stop_at }),
|
|
1653
|
+
...(unasked.length > 0 ? { pending: unasked } : {}),
|
|
1654
|
+
...(retiring.length > 0 ? { retiring } : {}),
|
|
1655
|
+
...(current.accepted === undefined ? {} : { accepted: current.accepted }),
|
|
1656
|
+
...(current.continuation === undefined ? {} : { continuation: current.continuation }),
|
|
1657
|
+
}
|
|
1316
1658
|
})
|
|
1317
1659
|
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
1318
1660
|
// session: an agent shell command gets no hook payload, and not every
|
|
@@ -1321,17 +1663,19 @@ export async function handleUserPromptSubmit(
|
|
|
1321
1663
|
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
|
|
1322
1664
|
}
|
|
1323
1665
|
|
|
1666
|
+
if (lateAnswers.length > 0) {
|
|
1667
|
+
// UserPromptSubmit has no later hook field proving its stdout reached the
|
|
1668
|
+
// model. Keep the journal and let the next Stop use its acknowledged
|
|
1669
|
+
// continuation channel instead of recreating the crash-before-stdout gap.
|
|
1670
|
+
notes.push('the late device answer will continue the agent at this turn’s Stop')
|
|
1671
|
+
return { notes }
|
|
1672
|
+
}
|
|
1324
1673
|
const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
|
|
1325
1674
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
1326
1675
|
const swept = [...retired, ...orphaned]
|
|
1327
1676
|
if (swept.length > 0) {
|
|
1328
1677
|
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
|
|
1329
1678
|
}
|
|
1330
|
-
if (lateAnswers.length > 0) {
|
|
1331
|
-
// Whatever the user did not answer was just retired above — they are at
|
|
1332
|
-
// the keyboard now, so nothing is "still waiting" from their side.
|
|
1333
|
-
return { stdout: userPromptAnswerOutput(answersContext(lateAnswers, 0)), notes }
|
|
1334
|
-
}
|
|
1335
1679
|
return { notes }
|
|
1336
1680
|
}
|
|
1337
1681
|
|
|
@@ -1339,66 +1683,199 @@ export async function handleUserPromptSubmit(
|
|
|
1339
1683
|
// Stop — the turn ended; escalate a registered question
|
|
1340
1684
|
// ---------------------------------------------------------------------------
|
|
1341
1685
|
|
|
1342
|
-
/**
|
|
1343
|
-
* How long an escalated question keeps accepting an answer after the turn
|
|
1344
|
-
* ended. Long enough to survive a walk away from the desk, short enough that a
|
|
1345
|
-
* forgotten question does not resurface days later as a live prompt.
|
|
1346
|
-
*/
|
|
1347
|
-
const QUESTION_WINDOW_SECONDS = 3600
|
|
1348
|
-
|
|
1349
1686
|
/**
|
|
1350
1687
|
* Only engages for a question the agent explicitly registered with
|
|
1351
1688
|
* `notifai ask`. Guessing from the last assistant message was the alternative
|
|
1352
1689
|
* and it is not worth it: a false positive here hijacks the terminal.
|
|
1353
1690
|
*/
|
|
1354
|
-
export async function handleStop(
|
|
1691
|
+
export async function handleStop(
|
|
1692
|
+
ctx: HookContext,
|
|
1693
|
+
envelope: HookEnvelope,
|
|
1694
|
+
processDeadlineAt = ctx.now() + STOP_BUDGET_SECONDS * 1000,
|
|
1695
|
+
): Promise<HookOutcome> {
|
|
1355
1696
|
const notes: string[] = []
|
|
1697
|
+
const hardDeadlineAt = processDeadlineAt
|
|
1356
1698
|
const sessionId = envelope.session_id
|
|
1357
|
-
if (!sessionId)
|
|
1699
|
+
if (!sessionId) {
|
|
1700
|
+
gate(ctx, 'held', 'no-session')
|
|
1701
|
+
return { notes }
|
|
1702
|
+
}
|
|
1358
1703
|
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1704
|
+
// This event marker is diagnostic evidence in its own right. Record it
|
|
1705
|
+
// before every early return so doctor can distinguish a working Stop route
|
|
1706
|
+
// from the prompt-only state that previously looked healthy.
|
|
1707
|
+
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
1708
|
+
...current,
|
|
1709
|
+
last_stop_at: ctx.now(),
|
|
1710
|
+
}))
|
|
1711
|
+
|
|
1712
|
+
// Claim before any reply poll, not only before the grace window. Two racing
|
|
1713
|
+
// Stops can both observe the same accepted journal or live request. An
|
|
1714
|
+
// atomic state update cannot retract duplicate block output a loser already
|
|
1715
|
+
// built, so one session claim owns the complete Stop decision: accepted
|
|
1716
|
+
// replay/ack, late-answer collection, cleanup, and any new escalation.
|
|
1717
|
+
//
|
|
1718
|
+
// Real clock, deliberately, not `ctx.now` — the claim answers "is another
|
|
1719
|
+
// process alive right now", which the injectable clock cannot speak to. It
|
|
1720
|
+
// is also the only clock the *other* process shares.
|
|
1721
|
+
if (!claimQuestionPush(sessionId, ctx.env)) {
|
|
1722
|
+
gate(ctx, 'held', 'claimed-elsewhere')
|
|
1723
|
+
notes.push('another hook is already handling this question')
|
|
1724
|
+
return { notes }
|
|
1370
1725
|
}
|
|
1726
|
+
try {
|
|
1727
|
+
let state = readSessionState(sessionId, ctx.env)
|
|
1728
|
+
if (state.accepted !== undefined) {
|
|
1729
|
+
const accepted = state.accepted
|
|
1730
|
+
// A recursive Stop is emitted only after the previous Stop's blocking
|
|
1731
|
+
// output was admitted and the continued model turn ran. That event is
|
|
1732
|
+
// the acknowledgment: persisting a separate "offered" bit before stdout
|
|
1733
|
+
// would recreate the crash gap this journal exists to close.
|
|
1734
|
+
const deliveryProven = envelope.stop_hook_active === true
|
|
1735
|
+
if (!deliveryProven) {
|
|
1736
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1737
|
+
if (current.accepted === undefined) return current
|
|
1738
|
+
return {
|
|
1739
|
+
...current,
|
|
1740
|
+
accepted: {
|
|
1741
|
+
...current.accepted,
|
|
1742
|
+
continuation_count: (current.continuation?.count ?? 0) + 1,
|
|
1743
|
+
},
|
|
1744
|
+
}
|
|
1745
|
+
})
|
|
1746
|
+
for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
|
|
1747
|
+
return {
|
|
1748
|
+
stdout: stopAnswerOutput(answersContext(accepted.answers, accepted.remaining)),
|
|
1749
|
+
notes,
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
1753
|
+
state = readSessionState(sessionId, ctx.env)
|
|
1754
|
+
}
|
|
1371
1755
|
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1756
|
+
const pending = pendingList(state)
|
|
1757
|
+
if (pending.length === 0) {
|
|
1758
|
+
const swept = [
|
|
1759
|
+
...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
|
|
1760
|
+
...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
|
|
1761
|
+
]
|
|
1762
|
+
if (swept.length > 0) {
|
|
1763
|
+
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
|
|
1764
|
+
}
|
|
1765
|
+
gate(ctx, 'held', 'no-question')
|
|
1766
|
+
return { notes }
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
return await handleClaimedStop(
|
|
1770
|
+
ctx,
|
|
1771
|
+
envelope,
|
|
1772
|
+
sessionId,
|
|
1773
|
+
state,
|
|
1774
|
+
pending,
|
|
1775
|
+
notes,
|
|
1776
|
+
hardDeadlineAt,
|
|
1777
|
+
)
|
|
1778
|
+
} finally {
|
|
1779
|
+
releaseQuestionPush(sessionId, ctx.env)
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
1784
|
+
async function handleClaimedStop(
|
|
1785
|
+
ctx: HookContext,
|
|
1786
|
+
envelope: HookEnvelope,
|
|
1787
|
+
sessionId: string,
|
|
1788
|
+
state: SessionState,
|
|
1789
|
+
pending: PendingQuestion[],
|
|
1790
|
+
notes: string[],
|
|
1791
|
+
hardDeadlineAt: number,
|
|
1792
|
+
): Promise<HookOutcome> {
|
|
1375
1793
|
const live = pending.filter((entry) => entry.request_id !== undefined)
|
|
1376
1794
|
const unasked = pending.filter((entry) => entry.request_id === undefined)
|
|
1795
|
+
let liveToEscalate = live
|
|
1796
|
+
|
|
1797
|
+
if (
|
|
1798
|
+
ctx.harness !== undefined &&
|
|
1799
|
+
HARNESS_CAPABILITIES[ctx.harness].stopContinuation === 'unsupported'
|
|
1800
|
+
) {
|
|
1801
|
+
for (const entry of live) await closeQuietly(ctx, entry.request_id!)
|
|
1802
|
+
await retirePendings(ctx, envelope, sessionId, pending, 'expired')
|
|
1803
|
+
gate(ctx, 'held', 'no-question')
|
|
1804
|
+
notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`)
|
|
1805
|
+
return { notes }
|
|
1806
|
+
}
|
|
1377
1807
|
|
|
1378
1808
|
if (live.length > 0) {
|
|
1379
|
-
const { answered,
|
|
1809
|
+
const { answered, permanentFailures } = await pollPendingReplies(
|
|
1810
|
+
ctx,
|
|
1811
|
+
live,
|
|
1812
|
+
LATE_STOP_POLL_SECONDS,
|
|
1813
|
+
)
|
|
1814
|
+
const permanentFailure = permanentReplyFailureNote(permanentFailures)
|
|
1815
|
+
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
1816
|
+
const permanentlyRejected = live.filter((entry) =>
|
|
1817
|
+
permanentFailures.has(entry.request_id!),
|
|
1818
|
+
)
|
|
1819
|
+
await Promise.all(permanentlyRejected.map((entry) => closeQuietly(ctx, entry.request_id!)))
|
|
1820
|
+
if (permanentlyRejected.length > 0) {
|
|
1821
|
+
await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired')
|
|
1822
|
+
}
|
|
1380
1823
|
if (answered.length > 0) {
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1824
|
+
const finalized = await finalizePendings(
|
|
1825
|
+
ctx,
|
|
1826
|
+
answered.map((entry) => entry.pending),
|
|
1827
|
+
)
|
|
1828
|
+
const authoritative = answered.map((entry) => {
|
|
1829
|
+
const response = finalized.find((candidate) =>
|
|
1830
|
+
isSamePending(candidate.pending, entry.pending),
|
|
1831
|
+
)?.response
|
|
1832
|
+
const replies = response?.replies.length ? response.replies : entry.replies
|
|
1833
|
+
if (response === null) {
|
|
1834
|
+
notes.push(
|
|
1835
|
+
`could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`,
|
|
1836
|
+
)
|
|
1837
|
+
}
|
|
1838
|
+
return { ...entry, replies, reply: replies.at(-1)! }
|
|
1839
|
+
})
|
|
1840
|
+
stageAcceptedAnswers(
|
|
1841
|
+
ctx,
|
|
1842
|
+
sessionId,
|
|
1843
|
+
authoritative,
|
|
1844
|
+
pending.length - authoritative.length - permanentlyRejected.length,
|
|
1845
|
+
)
|
|
1846
|
+
gate(ctx, 'proceeding', 'answered', { answers: authoritative.length })
|
|
1847
|
+
for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
|
|
1385
1848
|
// Anything still unasked rides the next Stop: the agent is being resumed
|
|
1386
1849
|
// with answers right now, and may not even need the rest afterwards.
|
|
1387
1850
|
return {
|
|
1388
|
-
stdout: stopAnswerOutput(
|
|
1851
|
+
stdout: stopAnswerOutput(
|
|
1852
|
+
answersContext(
|
|
1853
|
+
authoritative,
|
|
1854
|
+
pending.length - authoritative.length - permanentlyRejected.length,
|
|
1855
|
+
),
|
|
1856
|
+
),
|
|
1389
1857
|
notes,
|
|
1390
1858
|
}
|
|
1391
1859
|
}
|
|
1860
|
+
const recoverableLive = live.filter(
|
|
1861
|
+
(entry) => !permanentFailures.has(entry.request_id!),
|
|
1862
|
+
)
|
|
1863
|
+
liveToEscalate = recoverableLive
|
|
1392
1864
|
if (unasked.length === 0) {
|
|
1393
|
-
|
|
1394
|
-
//
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1865
|
+
if (recoverableLive.length === 0) return { notes }
|
|
1866
|
+
// A previous answer may have resumed the model while independent
|
|
1867
|
+
// questions remain. The next Stop is their successor owner: keep waiting
|
|
1868
|
+
// to the original absolute deadline instead of polling for four seconds
|
|
1869
|
+
// and abandoning them.
|
|
1870
|
+
return await escalate(
|
|
1871
|
+
ctx,
|
|
1872
|
+
envelope,
|
|
1873
|
+
sessionId,
|
|
1874
|
+
[],
|
|
1875
|
+
recoverableLive,
|
|
1876
|
+
notes,
|
|
1877
|
+
hardDeadlineAt,
|
|
1400
1878
|
)
|
|
1401
|
-
return { notes }
|
|
1402
1879
|
}
|
|
1403
1880
|
// Questions registered after the earlier push still owe the user their
|
|
1404
1881
|
// notification; fall through and escalate just those.
|
|
@@ -1415,36 +1892,55 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
1415
1892
|
(entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at,
|
|
1416
1893
|
)
|
|
1417
1894
|
if (!isNew) {
|
|
1895
|
+
gate(ctx, 'held', 'continuation-repeat')
|
|
1418
1896
|
notes.push('already continuing from an answer; not asking again this turn')
|
|
1419
1897
|
return { notes }
|
|
1420
1898
|
}
|
|
1421
1899
|
if (continuation.count >= MAX_CONTINUATION_COUNT) {
|
|
1900
|
+
gate(ctx, 'held', 'continuation-limit', {
|
|
1901
|
+
count: continuation.count,
|
|
1902
|
+
limit: MAX_CONTINUATION_COUNT,
|
|
1903
|
+
})
|
|
1422
1904
|
notes.push(
|
|
1423
1905
|
`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
|
|
1424
1906
|
)
|
|
1425
1907
|
return { notes }
|
|
1426
1908
|
}
|
|
1427
1909
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1910
|
+
// Silent to the user by design — they switched routing off, so saying so on
|
|
1911
|
+
// every turn would be nagging about their own setting. That silence is also
|
|
1912
|
+
// why it belongs in the log: from outside, "ask_notifications = false" and "a
|
|
1913
|
+
// bug ate my question" look exactly the same.
|
|
1914
|
+
if (!ctx.config.ask_notifications.value) {
|
|
1915
|
+
gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source })
|
|
1431
1916
|
return { notes }
|
|
1432
1917
|
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1918
|
+
const idle = ctx.idleSeconds()
|
|
1919
|
+
if (!isUserAway(state, ctx.config, ctx.now(), idle)) {
|
|
1920
|
+
gate(ctx, 'held', 'user-present', {
|
|
1921
|
+
idle_seconds: idle,
|
|
1922
|
+
away_after_seconds: ctx.config.away_after_seconds.value,
|
|
1923
|
+
require_idle: ctx.config.require_idle.value,
|
|
1924
|
+
last_prompt_at: state.last_prompt_at ?? null,
|
|
1925
|
+
})
|
|
1926
|
+
notes.push('you are at the keyboard; leaving the question in the terminal')
|
|
1441
1927
|
return { notes }
|
|
1442
1928
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1929
|
+
gate(ctx, 'proceeding', 'proceeding', {
|
|
1930
|
+
unasked: unasked.length,
|
|
1931
|
+
already_live: liveToEscalate.length,
|
|
1932
|
+
idle_seconds: idle,
|
|
1933
|
+
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
1934
|
+
})
|
|
1935
|
+
return await escalate(
|
|
1936
|
+
ctx,
|
|
1937
|
+
envelope,
|
|
1938
|
+
sessionId,
|
|
1939
|
+
unasked,
|
|
1940
|
+
liveToEscalate,
|
|
1941
|
+
notes,
|
|
1942
|
+
hardDeadlineAt,
|
|
1943
|
+
)
|
|
1448
1944
|
}
|
|
1449
1945
|
|
|
1450
1946
|
/** The escalation itself, split out so the claim is released on every path. */
|
|
@@ -1455,133 +1951,367 @@ async function escalate(
|
|
|
1455
1951
|
unasked: PendingQuestion[],
|
|
1456
1952
|
alreadyLive: PendingQuestion[],
|
|
1457
1953
|
notes: string[],
|
|
1954
|
+
hardDeadlineAt: number,
|
|
1458
1955
|
): Promise<HookOutcome> {
|
|
1459
1956
|
// Away right now, but the questions still owe the user their terminal-first
|
|
1460
1957
|
// window before anything reaches their devices — measured from the oldest
|
|
1461
1958
|
// registration, because that is the question that has waited longest.
|
|
1462
|
-
const
|
|
1463
|
-
const
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1959
|
+
const budgetStartedAt = ctx.now()
|
|
1960
|
+
const existingDeadlines = alreadyLive.flatMap((entry) =>
|
|
1961
|
+
entry.reply_deadline_at === undefined || entry.reply_deadline_at <= budgetStartedAt
|
|
1962
|
+
? []
|
|
1963
|
+
: [entry.reply_deadline_at],
|
|
1964
|
+
)
|
|
1965
|
+
const budgetDeadlineAt = Math.min(
|
|
1966
|
+
hardDeadlineAt - HOOK_TIMING.finalizationReserveSeconds * 1000,
|
|
1967
|
+
...(existingDeadlines.length === 0 ? [] : existingDeadlines),
|
|
1968
|
+
)
|
|
1969
|
+
if (unasked.length > 0 && alreadyLive.length === 0) {
|
|
1970
|
+
const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
|
|
1971
|
+
const grace = await awaitGrace(ctx, oldest)
|
|
1972
|
+
ctx.log?.debug('hook.gate', { verdict: 'grace', reason: grace, waited_from: oldest })
|
|
1973
|
+
if (grace === 'user-returned') {
|
|
1974
|
+
gate(ctx, 'held', 'user-returned', { grace_seconds: ctx.config.ask_grace_seconds.value })
|
|
1975
|
+
notes.push('you came back before the wait elapsed; leaving the questions in the terminal')
|
|
1976
|
+
return { notes }
|
|
1977
|
+
}
|
|
1978
|
+
if (grace === 'no-signal') {
|
|
1979
|
+
notes.push('no idle signal on this machine; asking now rather than holding the terminal')
|
|
1980
|
+
}
|
|
1470
1981
|
}
|
|
1471
|
-
|
|
1472
1982
|
// Phase one: every registered question reaches the user's devices, each as
|
|
1473
1983
|
// its own notification — one ask never stands in for another.
|
|
1474
1984
|
const submitted: PendingQuestion[] = []
|
|
1475
1985
|
for (const entry of unasked) {
|
|
1986
|
+
const remainingSeconds = Math.floor((budgetDeadlineAt - ctx.now()) / 1000)
|
|
1987
|
+
if (
|
|
1988
|
+
remainingSeconds <
|
|
1989
|
+
MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds
|
|
1990
|
+
) {
|
|
1991
|
+
notes.push(
|
|
1992
|
+
'the Stop budget cannot fit device resolution, submission, and a full reply window; leaving this question in the terminal',
|
|
1993
|
+
)
|
|
1994
|
+
continue
|
|
1995
|
+
}
|
|
1996
|
+
const replyWindowSeconds = Math.min(
|
|
1997
|
+
ctx.config.hook_reply_timeout_seconds.value,
|
|
1998
|
+
remainingSeconds - HOOK_TIMING.submissionReserveSeconds,
|
|
1999
|
+
)
|
|
2000
|
+
const ownerDeadlineAt = Math.min(
|
|
2001
|
+
budgetDeadlineAt,
|
|
2002
|
+
ctx.now() +
|
|
2003
|
+
(replyWindowSeconds + HOOK_TIMING.submissionReserveSeconds) * 1000,
|
|
2004
|
+
)
|
|
1476
2005
|
const questions = pendingQuestions(entry)
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
2006
|
+
let intent = entry.submission
|
|
2007
|
+
if (intent === undefined) {
|
|
2008
|
+
const prepared = await prepareQuestionSubmission(ctx, {
|
|
2009
|
+
title:
|
|
2010
|
+
ctx.config.project.value === null
|
|
2011
|
+
? 'Question'
|
|
2012
|
+
: `Question · ${ctx.config.project.value}`,
|
|
2013
|
+
// A set is answered on the expanded card; the banner leads with the
|
|
2014
|
+
// first question and says how much more is waiting behind it.
|
|
2015
|
+
body:
|
|
2016
|
+
questions.length > 1
|
|
2017
|
+
? `${questions[0]!.text} (+${questions.length - 1} more)`
|
|
2018
|
+
: entry.question,
|
|
2019
|
+
questions,
|
|
2020
|
+
...(entry.detail !== undefined ? { detail: entry.detail } : {}),
|
|
2021
|
+
event: 'agent_question',
|
|
2022
|
+
session: sessionLabel(ctx, envelope),
|
|
2023
|
+
windowSeconds: replyWindowSeconds,
|
|
2024
|
+
ownerDeadlineAt,
|
|
2025
|
+
})
|
|
2026
|
+
if ('error' in prepared) {
|
|
2027
|
+
ctx.log?.error('hook.pushed', { ok: false, message: prepared.error })
|
|
2028
|
+
notes.push(prepared.error)
|
|
2029
|
+
continue
|
|
2030
|
+
}
|
|
2031
|
+
intent = prepared
|
|
2032
|
+
// Durable before submit. If the server commits and the response is lost,
|
|
2033
|
+
// the reserved request id still lets this owner poll and finalize the
|
|
2034
|
+
// exact card; a successor can also replay the frozen draft/key.
|
|
2035
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2036
|
+
const list = pendingList(current)
|
|
2037
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
2038
|
+
if (index < 0) return current
|
|
2039
|
+
const next = [...list]
|
|
2040
|
+
next[index] = { ...next[index]!, submission: prepared }
|
|
2041
|
+
return { ...current, pending: next }
|
|
2042
|
+
})
|
|
2043
|
+
} else if (intent.owner_deadline_at <= ctx.now()) {
|
|
2044
|
+
// The frozen wire identity survives a crashed owner, but its local owner
|
|
2045
|
+
// lease does not. Re-arm only the local deadline; request id, key,
|
|
2046
|
+
// targets, and draft remain byte-identical for idempotent replay.
|
|
2047
|
+
const frozenReplyWindow =
|
|
2048
|
+
intent.draft.reply?.expires_in_seconds ?? replyWindowSeconds
|
|
2049
|
+
intent = {
|
|
2050
|
+
...intent,
|
|
2051
|
+
owner_deadline_at: Math.min(
|
|
2052
|
+
budgetDeadlineAt,
|
|
2053
|
+
ctx.now() +
|
|
2054
|
+
(frozenReplyWindow + HOOK_TIMING.submissionReserveSeconds) * 1000,
|
|
2055
|
+
),
|
|
2056
|
+
}
|
|
2057
|
+
const rearmed = intent
|
|
2058
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2059
|
+
const list = pendingList(current)
|
|
2060
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
2061
|
+
if (index < 0) return current
|
|
2062
|
+
const next = [...list]
|
|
2063
|
+
next[index] = { ...next[index]!, submission: rearmed }
|
|
2064
|
+
return { ...current, pending: next }
|
|
2065
|
+
})
|
|
2066
|
+
}
|
|
2067
|
+
if (ctx.now() >= intent.owner_deadline_at) {
|
|
2068
|
+
notes.push('the Stop budget ended before submission; preserving the frozen intent')
|
|
1498
2069
|
continue
|
|
1499
2070
|
}
|
|
2071
|
+
const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000)
|
|
2072
|
+
if (
|
|
2073
|
+
beforeSubmitSeconds <
|
|
2074
|
+
MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds / 2
|
|
2075
|
+
) {
|
|
2076
|
+
notes.push(
|
|
2077
|
+
'device resolution consumed the submission reserve; preserving the frozen intent instead of shrinking its reply window',
|
|
2078
|
+
)
|
|
2079
|
+
continue
|
|
2080
|
+
}
|
|
2081
|
+
let receipt: SubmissionReceipt | undefined
|
|
2082
|
+
let admissionConfirmed = false
|
|
2083
|
+
try {
|
|
2084
|
+
receipt = await submitQuestion(ctx, intent)
|
|
2085
|
+
admissionConfirmed = true
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
if (err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
|
|
2088
|
+
// A concrete client response is an admission rejection, not an
|
|
2089
|
+
// ambiguous commit. Keep the frozen intent for a later retry and leave
|
|
2090
|
+
// the question in the terminal; polling a request the server says it
|
|
2091
|
+
// did not accept only burns the owner window and hides the real fault.
|
|
2092
|
+
notes.push(
|
|
2093
|
+
`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`,
|
|
2094
|
+
)
|
|
2095
|
+
ctx.log?.error('hook.pushed', {
|
|
2096
|
+
ok: false,
|
|
2097
|
+
request_id: intent.request_id,
|
|
2098
|
+
status: err.status,
|
|
2099
|
+
code: err.code,
|
|
2100
|
+
message: err.message,
|
|
2101
|
+
})
|
|
2102
|
+
continue
|
|
2103
|
+
}
|
|
2104
|
+
// This is deliberately not a fail-open return. A transport failure is
|
|
2105
|
+
// ambiguous: the server may already have dispatched the card. The
|
|
2106
|
+
// client-generated request id keeps polling/finalization possible.
|
|
2107
|
+
notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
|
|
2108
|
+
ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
|
|
2109
|
+
}
|
|
2110
|
+
if (admissionConfirmed) {
|
|
2111
|
+
ctx.log?.info('hook.pushed', {
|
|
2112
|
+
ok: true,
|
|
2113
|
+
request_id: intent.request_id,
|
|
2114
|
+
devices: intent.device_ids.length,
|
|
2115
|
+
questions: questions.length,
|
|
2116
|
+
text: questions[0]!.text,
|
|
2117
|
+
})
|
|
2118
|
+
}
|
|
2119
|
+
const committedReplyDeadline =
|
|
2120
|
+
receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
|
|
2121
|
+
? intent.owner_deadline_at
|
|
2122
|
+
: Date.parse(receipt.reply_expires_at)
|
|
1500
2123
|
const live: PendingQuestion = {
|
|
1501
2124
|
...entry,
|
|
1502
|
-
request_id:
|
|
1503
|
-
collapse_key:
|
|
1504
|
-
device_ids:
|
|
2125
|
+
request_id: intent.request_id,
|
|
2126
|
+
collapse_key: intent.collapse_key,
|
|
2127
|
+
device_ids: intent.device_ids,
|
|
2128
|
+
// Submission and every later poll share one wall-clock owner. The
|
|
2129
|
+
// server may start its relative window a little later while the submit
|
|
2130
|
+
// response is in flight; closing at this deadline is therefore the
|
|
2131
|
+
// authoritative backstop against accepting an answer into a void.
|
|
2132
|
+
reply_deadline_at: Math.min(
|
|
2133
|
+
intent.owner_deadline_at,
|
|
2134
|
+
Number.isFinite(committedReplyDeadline)
|
|
2135
|
+
? committedReplyDeadline
|
|
2136
|
+
: intent.owner_deadline_at,
|
|
2137
|
+
),
|
|
1505
2138
|
}
|
|
2139
|
+
delete live.submission
|
|
1506
2140
|
submitted.push(live)
|
|
1507
2141
|
// Record what is now live on the user's devices BEFORE any wait. If we
|
|
1508
2142
|
// only learned these ids afterwards, a question that timed out would
|
|
1509
2143
|
// leave no trace, and the user returning to the terminal could never
|
|
1510
2144
|
// retire it — the notification would stay answerable for an hour with
|
|
1511
2145
|
// nobody listening.
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
retiring.
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
2146
|
+
if (admissionConfirmed) {
|
|
2147
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2148
|
+
const list = pendingList(current)
|
|
2149
|
+
const index = list.findIndex(
|
|
2150
|
+
(candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined,
|
|
2151
|
+
)
|
|
2152
|
+
if (index >= 0) {
|
|
2153
|
+
const next = [...list]
|
|
2154
|
+
next[index] = live
|
|
2155
|
+
return { ...current, pending: next }
|
|
2156
|
+
}
|
|
2157
|
+
// The entry vanished while the submit was in flight (the user's prompt
|
|
2158
|
+
// wiped the queue). The delivered notification must still be retirable,
|
|
2159
|
+
// so park it rather than lose its only identifiers.
|
|
2160
|
+
const retirement = retiringQuestion(live, 'answered_elsewhere')!
|
|
2161
|
+
const retiring = [...(current.retiring ?? [])]
|
|
2162
|
+
if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
2163
|
+
retiring.push(retirement)
|
|
2164
|
+
}
|
|
2165
|
+
return { ...current, retiring }
|
|
2166
|
+
})
|
|
2167
|
+
}
|
|
1532
2168
|
}
|
|
1533
2169
|
|
|
1534
|
-
const
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
2170
|
+
const staleLive = alreadyLive.filter(
|
|
2171
|
+
(entry) =>
|
|
2172
|
+
entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now(),
|
|
2173
|
+
)
|
|
2174
|
+
const finalizedStale = await finalizePendings(ctx, staleLive)
|
|
2175
|
+
const staleAnswers = finalizedStale
|
|
2176
|
+
.map(finalizedAnswer)
|
|
2177
|
+
.filter((entry): entry is AnsweredPending => entry !== null)
|
|
2178
|
+
const staleSilence = finalizedStale
|
|
2179
|
+
.filter((entry) => entry.response !== null && entry.response.replies.length === 0)
|
|
2180
|
+
.map((entry) => entry.pending)
|
|
2181
|
+
const staleUnproven = finalizedStale.filter((entry) => entry.response === null)
|
|
2182
|
+
if (staleSilence.length > 0) {
|
|
2183
|
+
await retirePendings(ctx, envelope, sessionId, staleSilence, 'expired')
|
|
1538
2184
|
notes.push(
|
|
1539
|
-
`question${
|
|
2185
|
+
`expired ${staleSilence.length} question${staleSilence.length === 1 ? '' : 's'} whose continuation owner had ended`,
|
|
1540
2186
|
)
|
|
1541
|
-
return { notes }
|
|
1542
2187
|
}
|
|
2188
|
+
if (staleUnproven.length > 0) {
|
|
2189
|
+
notes.push(
|
|
2190
|
+
`could not finalize ${staleUnproven.length} expired question${staleUnproven.length === 1 ? '' : 's'}; preserving ownership for recovery`,
|
|
2191
|
+
)
|
|
2192
|
+
}
|
|
2193
|
+
if (staleAnswers.length > 0) {
|
|
2194
|
+
const remaining = Math.max(
|
|
2195
|
+
0,
|
|
2196
|
+
pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length,
|
|
2197
|
+
)
|
|
2198
|
+
stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
|
|
2199
|
+
for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
|
|
2200
|
+
return {
|
|
2201
|
+
stdout: stopAnswerOutput(answersContext(staleAnswers, remaining)),
|
|
2202
|
+
notes,
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
const waitingOn = [
|
|
2206
|
+
...alreadyLive.filter(
|
|
2207
|
+
(entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now(),
|
|
2208
|
+
),
|
|
2209
|
+
...submitted,
|
|
2210
|
+
]
|
|
2211
|
+
if (waitingOn.length === 0) return { notes }
|
|
1543
2212
|
|
|
1544
2213
|
// Phase two: one blocking wait across everything live, old and new alike.
|
|
1545
|
-
const
|
|
2214
|
+
const ownerDeadline = Math.min(
|
|
2215
|
+
budgetDeadlineAt,
|
|
2216
|
+
Math.max(...waitingOn.map((entry) => entry.reply_deadline_at!)),
|
|
2217
|
+
)
|
|
2218
|
+
const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000))
|
|
1546
2219
|
const waited = await waitForAnyReplyWhileAway(
|
|
1547
2220
|
ctx,
|
|
1548
2221
|
waitingOn.map((entry) => entry.request_id!),
|
|
1549
2222
|
timeoutSeconds,
|
|
2223
|
+
ctx.harness !== 'opencode',
|
|
1550
2224
|
)
|
|
2225
|
+
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
|
|
2226
|
+
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
1551
2227
|
|
|
1552
2228
|
if (waited.byRequest.size === 0) {
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1555
|
-
//
|
|
1556
|
-
const
|
|
2229
|
+
// Once this owner returns, no shipped command-hook harness can guarantee
|
|
2230
|
+
// a later answer reaches the same agent turn. Close first, then retire the
|
|
2231
|
+
// local records, so the Companion never accepts an answer into a void.
|
|
2232
|
+
const finalized = await finalizePendings(ctx, waitingOn)
|
|
2233
|
+
const finalAnswers = finalized
|
|
2234
|
+
.map(finalizedAnswer)
|
|
2235
|
+
.filter((entry): entry is AnsweredPending => entry !== null)
|
|
2236
|
+
const confirmedSilent = finalized
|
|
2237
|
+
.filter((entry) => entry.response !== null && entry.response.replies.length === 0)
|
|
2238
|
+
.map((entry) => entry.pending)
|
|
2239
|
+
const unproven = finalized.filter((entry) => entry.response === null)
|
|
2240
|
+
if (confirmedSilent.length > 0) {
|
|
2241
|
+
await retirePendings(
|
|
2242
|
+
ctx,
|
|
2243
|
+
envelope,
|
|
2244
|
+
sessionId,
|
|
2245
|
+
confirmedSilent,
|
|
2246
|
+
waited.userReturned ? 'answered_elsewhere' : 'expired',
|
|
2247
|
+
)
|
|
2248
|
+
}
|
|
2249
|
+
if (unproven.length > 0) {
|
|
2250
|
+
notes.push(
|
|
2251
|
+
`could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`,
|
|
2252
|
+
)
|
|
2253
|
+
}
|
|
2254
|
+
if (finalAnswers.length > 0) {
|
|
2255
|
+
const remaining = Math.max(
|
|
2256
|
+
0,
|
|
2257
|
+
pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length,
|
|
2258
|
+
)
|
|
2259
|
+
stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
|
|
2260
|
+
for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
|
|
2261
|
+
return {
|
|
2262
|
+
stdout: stopAnswerOutput(answersContext(finalAnswers, remaining)),
|
|
2263
|
+
notes,
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
const requestIdSummary = summarizeRequestIds(waitingOn)
|
|
2267
|
+
ctx.log?.info('hook.answer', {
|
|
2268
|
+
answered: false,
|
|
2269
|
+
request_ids: requestIdSummary.ids,
|
|
2270
|
+
user_returned: waited.userReturned,
|
|
2271
|
+
degraded: waited.degraded,
|
|
2272
|
+
permanent_failures: waited.permanentFailures.size,
|
|
2273
|
+
waited_seconds: timeoutSeconds,
|
|
2274
|
+
})
|
|
1557
2275
|
if (waited.userReturned) {
|
|
1558
2276
|
notes.push(
|
|
1559
|
-
`you came back after the question${waitingOn.length === 1 ? ' was' : 's were'} sent;
|
|
1560
|
-
'Retrieve answers with: notifai replies --pending',
|
|
2277
|
+
`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})`,
|
|
1561
2278
|
)
|
|
1562
|
-
} else {
|
|
2279
|
+
} else if (waited.permanentFailures.size === 0) {
|
|
1563
2280
|
notes.push(
|
|
1564
2281
|
waited.degraded
|
|
1565
|
-
? 'could not reach the server
|
|
1566
|
-
: 'no answer in time;
|
|
2282
|
+
? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
|
|
2283
|
+
: 'no answer in time; the question expired with its continuation owner',
|
|
1567
2284
|
)
|
|
1568
2285
|
}
|
|
1569
2286
|
return { notes }
|
|
1570
2287
|
}
|
|
1571
2288
|
|
|
2289
|
+
const polledAnswered = waitingOn.filter((entry) => waited.byRequest.has(entry.request_id!))
|
|
2290
|
+
const finalizedAnswered = await finalizePendings(ctx, polledAnswered)
|
|
1572
2291
|
const answered: AnsweredPending[] = []
|
|
1573
|
-
for (const
|
|
2292
|
+
for (const finalized of finalizedAnswered) {
|
|
2293
|
+
const entry = finalized.pending
|
|
1574
2294
|
const replies = waited.byRequest.get(entry.request_id!)
|
|
1575
2295
|
if (replies === undefined) continue
|
|
1576
|
-
//
|
|
1577
|
-
//
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
2296
|
+
// The fenced set wins because it includes corrections that committed
|
|
2297
|
+
// while close was waiting for earlier writers. If finalization itself was
|
|
2298
|
+
// unreachable, never discard the durable reply already observed.
|
|
2299
|
+
const finalReplies = finalized.response?.replies ?? []
|
|
2300
|
+
const authoritative = finalReplies.length > 0 ? finalReplies : replies
|
|
2301
|
+
answered.push({ pending: entry, reply: authoritative.at(-1)!, replies: authoritative })
|
|
2302
|
+
if (finalized.response === null) {
|
|
2303
|
+
notes.push(
|
|
2304
|
+
`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`,
|
|
2305
|
+
)
|
|
2306
|
+
}
|
|
1584
2307
|
}
|
|
2308
|
+
stageAcceptedAnswers(
|
|
2309
|
+
ctx,
|
|
2310
|
+
sessionId,
|
|
2311
|
+
answered,
|
|
2312
|
+
waitingOn.length - answered.length,
|
|
2313
|
+
)
|
|
2314
|
+
for (const answer of answered) reportAnswer(ctx, notes, answer, false)
|
|
1585
2315
|
return {
|
|
1586
2316
|
stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
|
|
1587
2317
|
notes,
|
|
@@ -1607,7 +2337,7 @@ export function handleSessionEnd(
|
|
|
1607
2337
|
): HookOutcome {
|
|
1608
2338
|
const notes: string[] = []
|
|
1609
2339
|
const sessionId = envelope.session_id
|
|
1610
|
-
if (!sessionId) return { notes }
|
|
2340
|
+
if (!sessionId) return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } }
|
|
1611
2341
|
if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
|
|
1612
2342
|
|
|
1613
2343
|
const state = readSessionState(sessionId, env)
|
|
@@ -1620,7 +2350,10 @@ export function handleSessionEnd(
|
|
|
1620
2350
|
notes.push(err instanceof Error ? err.message : String(err))
|
|
1621
2351
|
// Preserve the only identifiers instead of turning an explicit corrupt
|
|
1622
2352
|
// state into an unretirable question during SessionEnd cleanup.
|
|
1623
|
-
return {
|
|
2353
|
+
return {
|
|
2354
|
+
notes,
|
|
2355
|
+
log: { outcome: 'preserved', reason: 'incomplete-question-identifiers' },
|
|
2356
|
+
}
|
|
1624
2357
|
}
|
|
1625
2358
|
}
|
|
1626
2359
|
if (orphans.length > 0) {
|
|
@@ -1630,8 +2363,24 @@ export function handleSessionEnd(
|
|
|
1630
2363
|
`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
|
|
1631
2364
|
)
|
|
1632
2365
|
}
|
|
2366
|
+
if (state.accepted !== undefined) {
|
|
2367
|
+
const preserved: SessionState = { accepted: state.accepted }
|
|
2368
|
+
if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
|
|
2369
|
+
if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
|
|
2370
|
+
if (state.continuation !== undefined) preserved.continuation = state.continuation
|
|
2371
|
+
writeSessionState(sessionId, env, preserved)
|
|
2372
|
+
notes.push('preserved an accepted device answer for this exact session to resume')
|
|
2373
|
+
return {
|
|
2374
|
+
notes,
|
|
2375
|
+
log: {
|
|
2376
|
+
outcome: 'answer-preserved',
|
|
2377
|
+
queued_retirements: orphans.length,
|
|
2378
|
+
accepted_answers: state.accepted.answers.length,
|
|
2379
|
+
},
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
1633
2382
|
clearSessionState(sessionId, env)
|
|
1634
|
-
return { notes }
|
|
2383
|
+
return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } }
|
|
1635
2384
|
}
|
|
1636
2385
|
|
|
1637
2386
|
// ---------------------------------------------------------------------------
|
|
@@ -1676,6 +2425,7 @@ export function registerQuestion(
|
|
|
1676
2425
|
pending: [
|
|
1677
2426
|
...pending,
|
|
1678
2427
|
{
|
|
2428
|
+
question_id: `q_${randomBytes(12).toString('base64url')}`,
|
|
1679
2429
|
asked_at: now,
|
|
1680
2430
|
...question,
|
|
1681
2431
|
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|