@raidiant/notifai 9.2.1 → 9.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +8 -5
- package/dist/claude-wake.js +11 -0
- package/dist/claude-wake.js.map +1 -1
- package/dist/codex-wake.js +9 -0
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-core.d.ts +0 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +41 -2
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +2 -2
- package/dist/commands-harness-context.js +1 -1
- package/dist/commands-hook-shape.d.ts +4 -3
- package/dist/commands-hook-shape.js +19 -10
- package/dist/commands-hook-shape.js.map +1 -1
- package/dist/commands-hooks.js +66 -15
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +12 -1
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-project.d.ts +4 -0
- package/dist/commands-project.js +41 -0
- package/dist/commands-project.js.map +1 -0
- package/dist/commands-send-support.js +1 -22
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +11 -5
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +58 -0
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +1 -0
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +2 -2
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +8 -8
- package/dist/config.js +6 -9
- package/dist/config.js.map +1 -1
- package/dist/guidance-content.d.ts +3 -0
- package/dist/guidance-content.js +20 -0
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.js +3 -3
- package/dist/guidance-render.js.map +1 -1
- package/dist/harnesses.js +2 -2
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +26 -50
- package/dist/hooks.js +391 -150
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +7 -28
- package/dist/install-hooks.js +18 -37
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -1
- package/dist/invocation-context.js +2 -2
- package/dist/invocation-context.js.map +1 -1
- package/dist/program.d.ts +4 -1
- package/dist/program.js +33 -15
- package/dist/program.js.map +1 -1
- package/dist/project-enablement.d.ts +15 -0
- package/dist/project-enablement.js +87 -0
- package/dist/project-enablement.js.map +1 -0
- package/dist/question-timing.d.ts +46 -0
- package/dist/question-timing.js +48 -0
- package/dist/question-timing.js.map +1 -0
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +4 -2
- package/dist/send.js +6 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +1 -1
- package/dist/session-activation.js +3 -4
- package/dist/session-activation.js.map +1 -1
- package/package.json +2 -2
- package/src/claude-wake.test.ts +1 -0
- package/src/claude-wake.ts +10 -0
- package/src/codex-wake.test.ts +1 -0
- package/src/codex-wake.ts +9 -0
- package/src/commands-core.ts +0 -3
- package/src/commands-doctor.ts +48 -4
- package/src/commands-harness-context.ts +2 -2
- package/src/commands-hook-shape.ts +26 -10
- package/src/commands-hooks.ts +62 -16
- package/src/commands-init.ts +11 -1
- package/src/commands-project.ts +47 -0
- package/src/commands-send-support.ts +1 -27
- package/src/commands-send.ts +9 -4
- package/src/commands-skill.test.ts +44 -0
- package/src/commands-skill.ts +57 -0
- package/src/commands.test.ts +47 -17
- package/src/commands.ts +5 -0
- package/src/config-schema.ts +2 -2
- package/src/config.test.ts +2 -3
- package/src/config.ts +12 -15
- package/src/guidance-content.ts +22 -0
- package/src/guidance-render.ts +3 -3
- package/src/harnesses.ts +2 -2
- package/src/hook-adapter.test.ts +27 -8
- package/src/hooks.test.ts +808 -80
- package/src/hooks.ts +525 -181
- package/src/install-hooks.test.ts +8 -12
- package/src/install-hooks.ts +21 -37
- package/src/invocation-context.test.ts +1 -1
- package/src/invocation-context.ts +2 -2
- package/src/program.test.ts +18 -0
- package/src/program.ts +36 -14
- package/src/project-enablement.test.ts +55 -0
- package/src/project-enablement.ts +99 -0
- package/src/question-timing.ts +56 -0
- package/src/readiness.ts +8 -1
- package/src/send.ts +10 -3
- package/src/session-activation.ts +3 -4
- package/src/skill.test.ts +2 -2
- package/dist/orca-session-title.d.ts +0 -10
- package/dist/orca-session-title.js +0 -99
- package/dist/orca-session-title.js.map +0 -1
- package/src/orca-session-title.test.ts +0 -95
- package/src/orca-session-title.ts +0 -105
package/src/hooks.ts
CHANGED
|
@@ -12,6 +12,12 @@ import {
|
|
|
12
12
|
} from 'node:fs'
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
|
|
15
|
+
import {
|
|
16
|
+
LEGACY_QUESTION_CLAIM_TTL_SECONDS,
|
|
17
|
+
QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS,
|
|
18
|
+
QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS,
|
|
19
|
+
QUESTION_WAITER_CEILING_SECONDS,
|
|
20
|
+
} from './question-timing.js'
|
|
15
21
|
import type {
|
|
16
22
|
LifecycleEndState,
|
|
17
23
|
ListRepliesResponse,
|
|
@@ -56,12 +62,11 @@ import {
|
|
|
56
62
|
* proven exact-session continuation fail closed at question admission.
|
|
57
63
|
*
|
|
58
64
|
* Where the user is standing no longer decides anything here. It used to: the
|
|
59
|
-
* turn-end
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* configuration rather than implicitly with every keystroke.
|
|
65
|
+
* old turn-end route held the terminal briefly and made keyboard presence an
|
|
66
|
+
* input to whether it waited. The current policy is explicit instead: Claude
|
|
67
|
+
* Code owns the complete answer window out of band, while Codex owns it by
|
|
68
|
+
* holding the asking turn. Neither infers notification preference from
|
|
69
|
+
* keystrokes.
|
|
65
70
|
*/
|
|
66
71
|
|
|
67
72
|
/** Fields we read from harness hook JSON. Everything else is passed through. */
|
|
@@ -175,6 +180,11 @@ interface AcceptedAnswerDelivery {
|
|
|
175
180
|
* passes through.
|
|
176
181
|
*/
|
|
177
182
|
delivery_attempts?: number
|
|
183
|
+
/**
|
|
184
|
+
* Linearization point between delivery and SessionEnd. Once recorded, the
|
|
185
|
+
* route began first; before it, SessionEnd cancellation wins.
|
|
186
|
+
*/
|
|
187
|
+
delivery_committed_at?: number
|
|
178
188
|
/** Turns this answer has been held without being handed to the agent. */
|
|
179
189
|
held_deliveries?: number
|
|
180
190
|
}
|
|
@@ -250,15 +260,13 @@ export interface PendingQuestion {
|
|
|
250
260
|
collapse_key?: string
|
|
251
261
|
/** Exact fanout of the live question; routing config may change afterwards. */
|
|
252
262
|
device_ids?: string[]
|
|
253
|
-
/** Absolute end of the server reply window
|
|
263
|
+
/** Absolute end of the server reply window. */
|
|
254
264
|
reply_deadline_at?: number
|
|
255
265
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* questions. Absent on state written before this field existed — treated as
|
|
261
|
-
* already spent, because that is the shape a degraded waiter left behind.
|
|
266
|
+
* Absolute process-owner deadline. It begins before submission and includes
|
|
267
|
+
* startup headroom, so it must be later than `reply_deadline_at`. Absent on
|
|
268
|
+
* state written before ownership was persisted — treated as already spent
|
|
269
|
+
* instead of inventing a fresh multi-day claim.
|
|
262
270
|
*/
|
|
263
271
|
owner_deadline_at?: number
|
|
264
272
|
/** Frozen before the first network byte, so an ambiguous submit is replayable. */
|
|
@@ -300,40 +308,6 @@ function summarizeRequestIds(entries: readonly PendingQuestion[]): {
|
|
|
300
308
|
return { ids, display: ids.join(', ') }
|
|
301
309
|
}
|
|
302
310
|
|
|
303
|
-
/**
|
|
304
|
-
* The waiter's whole wall clock: the terminal-first timer, submission, and the
|
|
305
|
-
* wait for an answer, from the moment the turn-end hook starts.
|
|
306
|
-
*
|
|
307
|
-
* One plain ceiling, deliberately not a budget carved into reserves. The
|
|
308
|
-
* reserves existed to fit every phase inside a *held* turn; on Claude Code the
|
|
309
|
-
* turn is not held any more, and on a host that still holds one the useful
|
|
310
|
-
* question is only how long that host is willing to wait in total.
|
|
311
|
-
*/
|
|
312
|
-
export const WAITER_CEILING_SECONDS = 480
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* The same wall clock where the waiter runs in the background instead of
|
|
316
|
-
* inside a held turn.
|
|
317
|
-
*
|
|
318
|
-
* On a host that holds its turn, 480 s is a real cost to the person sitting in
|
|
319
|
-
* front of it, and the number above is right. On Claude Code the Stop hook
|
|
320
|
-
* returns at once and the waiter is detached, so the same 480 s buys nothing
|
|
321
|
-
* and costs a direct wake: an answer arriving at minute nine had to wait for
|
|
322
|
-
* the session's next turn even though nothing was blocked.
|
|
323
|
-
*
|
|
324
|
-
* This sits under `CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS` (3600) with headroom,
|
|
325
|
-
* because that declaration's kill is silent, and under the reply window the
|
|
326
|
-
* question was pushed with. Past it the journal still delivers at the next
|
|
327
|
-
* turn — the ceiling decides how long a *direct* wake stays possible, never
|
|
328
|
-
* whether the answer survives.
|
|
329
|
-
*/
|
|
330
|
-
export const DETACHED_WAITER_CEILING_SECONDS = 3300
|
|
331
|
-
|
|
332
|
-
/** The waiter's wall clock, which depends on whether a turn is held open for it. */
|
|
333
|
-
export function waiterCeilingSeconds(detached: boolean): number {
|
|
334
|
-
return detached ? DETACHED_WAITER_CEILING_SECONDS : WAITER_CEILING_SECONDS
|
|
335
|
-
}
|
|
336
|
-
|
|
337
311
|
/**
|
|
338
312
|
* The wire contract's shortest reply window. A question submitted with less
|
|
339
313
|
* than this is rejected outright, and accepting one would in any case let the
|
|
@@ -341,14 +315,6 @@ export function waiterCeilingSeconds(detached: boolean): number {
|
|
|
341
315
|
*/
|
|
342
316
|
export const MIN_REPLY_WINDOW_SECONDS = 60
|
|
343
317
|
|
|
344
|
-
/**
|
|
345
|
-
* HTTP budget reserved for the waiter's final reply fetch and close fence.
|
|
346
|
-
* Taken from the owner deadline rather than added after it, so the hook does
|
|
347
|
-
* not block past its ceiling. A near-zero remainder used to produce a
|
|
348
|
-
* misleading 0s timeout and an unproven retirement.
|
|
349
|
-
*/
|
|
350
|
-
export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3
|
|
351
|
-
|
|
352
318
|
/**
|
|
353
319
|
* The terminal-first wait: the question sits in the terminal for
|
|
354
320
|
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
@@ -357,8 +323,9 @@ export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3
|
|
|
357
323
|
* A plain timer, and nothing else. It once polled an idle signal so it could
|
|
358
324
|
* abandon the wait the moment the user touched the keyboard — necessary while
|
|
359
325
|
* the wait blocked the terminal, because a user wanting to answer locally was
|
|
360
|
-
* otherwise locked out of their own prompt.
|
|
361
|
-
*
|
|
326
|
+
* otherwise locked out of their own prompt. Presence no longer changes this
|
|
327
|
+
* deterministic timer; whether the harness holds or wakes is a separate route
|
|
328
|
+
* decision.
|
|
362
329
|
*
|
|
363
330
|
* The window is measured from registration, not from the turn's end: a
|
|
364
331
|
* question the agent asked five minutes ago while it kept working has already
|
|
@@ -380,6 +347,8 @@ async function awaitTerminalFirstWindow(
|
|
|
380
347
|
export interface HookOutcome {
|
|
381
348
|
/** Written to stdout verbatim — the harness parses this as output. */
|
|
382
349
|
stdout?: string
|
|
350
|
+
/** Commit a blocking continuation immediately before the harness stdout write. */
|
|
351
|
+
commitStdout?: () => boolean
|
|
383
352
|
/** Whether stdout takes over the turn, rather than adding prompt context. */
|
|
384
353
|
decided?: boolean
|
|
385
354
|
/** Diagnostics; harnesses surface hook stderr in the transcript. */
|
|
@@ -395,6 +364,12 @@ export interface ContinuationEvent {
|
|
|
395
364
|
remaining: number
|
|
396
365
|
request_ids: string[]
|
|
397
366
|
journal_recorded_at: number
|
|
367
|
+
/**
|
|
368
|
+
* Must be called immediately before the route's irreversible harness write.
|
|
369
|
+
* It atomically orders that write against SessionEnd; false means cancellation
|
|
370
|
+
* won and the route must hand nothing over.
|
|
371
|
+
*/
|
|
372
|
+
commitDelivery(): boolean
|
|
398
373
|
}
|
|
399
374
|
|
|
400
375
|
/**
|
|
@@ -423,6 +398,8 @@ export type DeliveryAcknowledgement = 'delivered' | 'stdout' | 'held'
|
|
|
423
398
|
/** What a route returns: the hook's outcome plus what the attempt proved. */
|
|
424
399
|
export interface DeliveryOutcome {
|
|
425
400
|
stdout?: string
|
|
401
|
+
/** Deferred SessionEnd fence for a blocking stdout continuation. */
|
|
402
|
+
commitStdout?: () => boolean
|
|
426
403
|
notes?: string[]
|
|
427
404
|
log?: Record<string, unknown>
|
|
428
405
|
acknowledgement: DeliveryAcknowledgement
|
|
@@ -445,6 +422,37 @@ function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
|
445
422
|
return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`)
|
|
446
423
|
}
|
|
447
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Durable cancellation for observers that can outlive the harness process.
|
|
427
|
+
*
|
|
428
|
+
* Absence of session state is not enough: SessionEnd deliberately deletes it,
|
|
429
|
+
* and an in-flight submit/fence could otherwise recreate it afterwards. The
|
|
430
|
+
* marker shares the session-state lock, giving every racing writer one total
|
|
431
|
+
* order: either its state lands before SessionEnd and is cleaned up there, or
|
|
432
|
+
* it observes this marker and must not write or deliver into the ended session.
|
|
433
|
+
*/
|
|
434
|
+
function sessionEndMarkerPath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
435
|
+
return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.ended`)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function sessionHasEnded(sessionId: string, env: NodeJS.ProcessEnv): boolean {
|
|
439
|
+
return existsSync(sessionEndMarkerPath(sessionId, env))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function markSessionEnded(sessionId: string, env: NodeJS.ProcessEnv, now: number): void {
|
|
443
|
+
const stateFile = sessionStatePath(sessionId, env)
|
|
444
|
+
withFileLock(`${stateFile}.lock`, () => {
|
|
445
|
+
atomicWriteFileSync(sessionEndMarkerPath(sessionId, env), `${now}\n`)
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function clearSessionEndMarker(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
450
|
+
const stateFile = sessionStatePath(sessionId, env)
|
|
451
|
+
withFileLock(`${stateFile}.lock`, () => {
|
|
452
|
+
rmSync(sessionEndMarkerPath(sessionId, env), { force: true })
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
448
456
|
export function readSessionState(sessionId: string, env: NodeJS.ProcessEnv): SessionState {
|
|
449
457
|
const file = sessionStatePath(sessionId, env)
|
|
450
458
|
if (!existsSync(file)) return {}
|
|
@@ -537,7 +545,11 @@ function updateSessionState(
|
|
|
537
545
|
): SessionState {
|
|
538
546
|
const file = sessionStatePath(sessionId, env)
|
|
539
547
|
return withFileLock(`${file}.lock`, () => {
|
|
540
|
-
const
|
|
548
|
+
const current = readSessionState(sessionId, env)
|
|
549
|
+
// SessionEnd and every asynchronous writer share this lock. Once the
|
|
550
|
+
// durable marker exists, no observer may recreate state for that session.
|
|
551
|
+
if (sessionHasEnded(sessionId, env)) return current
|
|
552
|
+
const next = update(current)
|
|
541
553
|
writeSessionStateUnlocked(file, sessionId, next)
|
|
542
554
|
return next
|
|
543
555
|
})
|
|
@@ -550,6 +562,9 @@ export function recordSessionStart(
|
|
|
550
562
|
harness?: HookHarness,
|
|
551
563
|
cwd?: string,
|
|
552
564
|
): void {
|
|
565
|
+
// Harnesses may reuse a session id only by explicitly starting that session
|
|
566
|
+
// again. That lifecycle edge is the sole authority for clearing cancellation.
|
|
567
|
+
clearSessionEndMarker(sessionId, env)
|
|
553
568
|
updateSessionState(sessionId, env, (current) => ({
|
|
554
569
|
...current,
|
|
555
570
|
...(harness === undefined ? {} : { harness }),
|
|
@@ -617,9 +632,10 @@ export function resetCursorStopActivation(sessionId: string, env: NodeJS.Process
|
|
|
617
632
|
/**
|
|
618
633
|
* Session state a crashed harness left behind.
|
|
619
634
|
*
|
|
620
|
-
* `SessionEnd` removes
|
|
621
|
-
* that crashes or is killed never reaches it. At roughly a
|
|
622
|
-
* day that is tens of thousands of files a year, none of
|
|
635
|
+
* `SessionEnd` removes session state and leaves a short-lived cancellation
|
|
636
|
+
* marker; a harness that crashes or is killed never reaches it. At roughly a
|
|
637
|
+
* hundred sessions a day that is tens of thousands of files a year, none of
|
|
638
|
+
* which anything reads.
|
|
623
639
|
*
|
|
624
640
|
* Opportunistic rather than scheduled: hooks are the only thing that runs, so
|
|
625
641
|
* a hook is where this has to live. It is rate-limited by its own stamp file
|
|
@@ -681,7 +697,7 @@ export function pruneAbandonedSessions(
|
|
|
681
697
|
* known-dead PID is recoverable immediately. Only legacy/corrupt claims with
|
|
682
698
|
* no trustworthy PID fall back to an age limit.
|
|
683
699
|
*/
|
|
684
|
-
const CLAIM_TTL_MS =
|
|
700
|
+
const CLAIM_TTL_MS = LEGACY_QUESTION_CLAIM_TTL_SECONDS * 1000
|
|
685
701
|
|
|
686
702
|
/**
|
|
687
703
|
* How long a crashed claim *guard* blocks the next hook.
|
|
@@ -700,18 +716,26 @@ export function claimQuestionPush(
|
|
|
700
716
|
env: NodeJS.ProcessEnv,
|
|
701
717
|
now: number = Date.now(),
|
|
702
718
|
beforeStaleReplace?: () => void,
|
|
719
|
+
ownerDeadlineAt?: number,
|
|
703
720
|
): boolean {
|
|
704
721
|
const file = claimPath(sessionId, env)
|
|
705
722
|
const guard = `${file}.guard`
|
|
706
723
|
const token = randomBytes(12).toString('base64url')
|
|
724
|
+
const pendingQuestionIds = pendingList(readSessionState(sessionId, env)).map(
|
|
725
|
+
claimQuestionIdentity,
|
|
726
|
+
)
|
|
707
727
|
mkdirSync(path.dirname(file), { recursive: true })
|
|
708
728
|
if (!acquireClaimGuard(guard)) return false
|
|
709
729
|
try {
|
|
710
730
|
try {
|
|
711
|
-
writeFileSync(
|
|
731
|
+
writeFileSync(
|
|
732
|
+
file,
|
|
733
|
+
`${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`,
|
|
734
|
+
{
|
|
712
735
|
mode: 0o600,
|
|
713
736
|
flag: 'wx',
|
|
714
|
-
|
|
737
|
+
},
|
|
738
|
+
)
|
|
715
739
|
heldClaims.set(file, token)
|
|
716
740
|
return true
|
|
717
741
|
} catch {
|
|
@@ -730,10 +754,14 @@ export function claimQuestionPush(
|
|
|
730
754
|
beforeStaleReplace?.()
|
|
731
755
|
rmSync(file, { force: true })
|
|
732
756
|
try {
|
|
733
|
-
writeFileSync(
|
|
757
|
+
writeFileSync(
|
|
758
|
+
file,
|
|
759
|
+
`${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`,
|
|
760
|
+
{
|
|
734
761
|
mode: 0o600,
|
|
735
762
|
flag: 'wx',
|
|
736
|
-
|
|
763
|
+
},
|
|
764
|
+
)
|
|
737
765
|
heldClaims.set(file, token)
|
|
738
766
|
return true
|
|
739
767
|
} catch {
|
|
@@ -745,6 +773,42 @@ export function claimQuestionPush(
|
|
|
745
773
|
}
|
|
746
774
|
}
|
|
747
775
|
|
|
776
|
+
function claimQuestionIdentity(entry: PendingQuestion): string {
|
|
777
|
+
return entry.question_id ?? `${entry.asked_at ?? 'legacy'}\u0000${entry.question}`
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** True only when the live owner could not have snapshotted this unpushed ask. */
|
|
781
|
+
function claimHandoffState(
|
|
782
|
+
sessionId: string,
|
|
783
|
+
env: NodeJS.ProcessEnv,
|
|
784
|
+
): { hasNewQuestion: boolean; ownerDeadlineAt?: number } {
|
|
785
|
+
const unasked = pendingList(readSessionState(sessionId, env)).filter(
|
|
786
|
+
(entry) => entry.request_id === undefined,
|
|
787
|
+
)
|
|
788
|
+
if (unasked.length === 0) return { hasNewQuestion: false }
|
|
789
|
+
try {
|
|
790
|
+
const held = JSON.parse(readFileSync(claimPath(sessionId, env), 'utf8')) as {
|
|
791
|
+
pending_question_ids?: unknown
|
|
792
|
+
owner_deadline_at?: unknown
|
|
793
|
+
}
|
|
794
|
+
if (!Array.isArray(held.pending_question_ids)) return { hasNewQuestion: false }
|
|
795
|
+
const snapshotted = new Set(
|
|
796
|
+
held.pending_question_ids.filter((entry): entry is string => typeof entry === 'string'),
|
|
797
|
+
)
|
|
798
|
+
return {
|
|
799
|
+
hasNewQuestion: unasked.some(
|
|
800
|
+
(entry) => !snapshotted.has(claimQuestionIdentity(entry)),
|
|
801
|
+
),
|
|
802
|
+
...(typeof held.owner_deadline_at === 'number' &&
|
|
803
|
+
Number.isFinite(held.owner_deadline_at)
|
|
804
|
+
? { ownerDeadlineAt: held.owner_deadline_at }
|
|
805
|
+
: {}),
|
|
806
|
+
}
|
|
807
|
+
} catch {
|
|
808
|
+
return { hasNewQuestion: false }
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
748
812
|
function processIsAlive(pid: number): boolean {
|
|
749
813
|
try {
|
|
750
814
|
process.kill(pid, 0)
|
|
@@ -1079,6 +1143,9 @@ export type HookHarness = Harness
|
|
|
1079
1143
|
*/
|
|
1080
1144
|
const REPLY_POLL_SECONDS = 5
|
|
1081
1145
|
|
|
1146
|
+
/** Avoid hot-looping while a live observer cooperatively yields its claim. */
|
|
1147
|
+
const CLAIM_HANDOFF_POLL_MS = 1_000
|
|
1148
|
+
|
|
1082
1149
|
/**
|
|
1083
1150
|
* Push a question and block for the answer.
|
|
1084
1151
|
*
|
|
@@ -1164,6 +1231,19 @@ async function submitQuestion(
|
|
|
1164
1231
|
return receipt
|
|
1165
1232
|
}
|
|
1166
1233
|
|
|
1234
|
+
function canSubmitCompleteWindow(
|
|
1235
|
+
ctx: HookContext,
|
|
1236
|
+
intent: PendingSubmissionIntent,
|
|
1237
|
+
fallbackWindowSeconds: number,
|
|
1238
|
+
): boolean {
|
|
1239
|
+
const frozenWindowSeconds =
|
|
1240
|
+
intent.draft.reply?.expires_in_seconds ?? fallbackWindowSeconds
|
|
1241
|
+
return (
|
|
1242
|
+
intent.owner_deadline_at - ctx.now() >=
|
|
1243
|
+
(frozenWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000
|
|
1244
|
+
)
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1167
1247
|
/**
|
|
1168
1248
|
* The waiter's wait: one bounded poll across every live question at once.
|
|
1169
1249
|
*
|
|
@@ -1177,10 +1257,12 @@ async function waitForAnyReply(
|
|
|
1177
1257
|
ctx: HookContext,
|
|
1178
1258
|
requestIds: string[],
|
|
1179
1259
|
timeoutSeconds: number,
|
|
1260
|
+
interruption?: () => 'ownership-ended' | 'new-question' | null,
|
|
1180
1261
|
): Promise<{
|
|
1181
1262
|
byRequest: Map<string, ReplyView[]>
|
|
1182
1263
|
degraded: boolean
|
|
1183
1264
|
permanentFailures: Map<string, string>
|
|
1265
|
+
interrupted: 'ownership-ended' | 'new-question' | null
|
|
1184
1266
|
}> {
|
|
1185
1267
|
const deadline = ctx.now() + timeoutSeconds * 1000
|
|
1186
1268
|
let degraded = false
|
|
@@ -1188,13 +1270,17 @@ async function waitForAnyReply(
|
|
|
1188
1270
|
const permanentFailures = new Map<string, string>()
|
|
1189
1271
|
|
|
1190
1272
|
if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
|
|
1191
|
-
return { byRequest: new Map(), degraded: false, permanentFailures }
|
|
1273
|
+
return { byRequest: new Map(), degraded: false, permanentFailures, interrupted: null }
|
|
1192
1274
|
}
|
|
1193
1275
|
|
|
1194
1276
|
for (;;) {
|
|
1277
|
+
const beforePoll = interruption?.() ?? null
|
|
1278
|
+
if (beforePoll !== null) {
|
|
1279
|
+
return { byRequest: new Map(), degraded, permanentFailures, interrupted: beforePoll }
|
|
1280
|
+
}
|
|
1195
1281
|
const remainingMs = Math.max(0, deadline - ctx.now())
|
|
1196
1282
|
if (!firstPoll && remainingMs === 0) {
|
|
1197
|
-
return { byRequest: new Map(), degraded, permanentFailures }
|
|
1283
|
+
return { byRequest: new Map(), degraded, permanentFailures, interrupted: null }
|
|
1198
1284
|
}
|
|
1199
1285
|
firstPoll = false
|
|
1200
1286
|
const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)))
|
|
@@ -1225,12 +1311,50 @@ async function waitForAnyReply(
|
|
|
1225
1311
|
}
|
|
1226
1312
|
if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
|
|
1227
1313
|
}
|
|
1314
|
+
const afterPoll = interruption?.() ?? null
|
|
1315
|
+
// SessionEnd or terminal-side retirement wins even if an in-flight poll
|
|
1316
|
+
// happened to return an answer. A newly registered question merely asks
|
|
1317
|
+
// this owner to yield; an answer already observed remains authoritative.
|
|
1318
|
+
if (afterPoll === 'ownership-ended') {
|
|
1319
|
+
return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll }
|
|
1320
|
+
}
|
|
1228
1321
|
if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
|
|
1229
|
-
return { byRequest, degraded, permanentFailures }
|
|
1322
|
+
return { byRequest, degraded, permanentFailures, interrupted: null }
|
|
1323
|
+
}
|
|
1324
|
+
if (afterPoll === 'new-question') {
|
|
1325
|
+
return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll }
|
|
1230
1326
|
}
|
|
1231
1327
|
}
|
|
1232
1328
|
}
|
|
1233
1329
|
|
|
1330
|
+
/**
|
|
1331
|
+
* Local lifecycle signal for a long-lived observer.
|
|
1332
|
+
*
|
|
1333
|
+
* SessionEnd and terminal-side retirement remove an owned request from pending
|
|
1334
|
+
* state; the detached process must stop instead of recreating that session.
|
|
1335
|
+
* A new unpushed question asks this owner to yield the session claim so the
|
|
1336
|
+
* successor Stop can submit it promptly.
|
|
1337
|
+
*/
|
|
1338
|
+
function waiterInterruption(
|
|
1339
|
+
sessionId: string,
|
|
1340
|
+
env: NodeJS.ProcessEnv,
|
|
1341
|
+
requestIds: readonly string[],
|
|
1342
|
+
): 'ownership-ended' | 'new-question' | null {
|
|
1343
|
+
if (sessionHasEnded(sessionId, env)) return 'ownership-ended'
|
|
1344
|
+
const pending = pendingList(readSessionState(sessionId, env))
|
|
1345
|
+
const owned = new Set(requestIds)
|
|
1346
|
+
// One question can be retired from the terminal while this observer owns
|
|
1347
|
+
// several. That is a per-question change, not whole-session cancellation;
|
|
1348
|
+
// the caller filters the changed set after each poll and keeps the rest.
|
|
1349
|
+
return pending.some(
|
|
1350
|
+
(entry) =>
|
|
1351
|
+
entry.request_id === undefined &&
|
|
1352
|
+
(entry.submission === undefined || !owned.has(entry.submission.request_id)),
|
|
1353
|
+
)
|
|
1354
|
+
? 'new-question'
|
|
1355
|
+
: null
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1234
1358
|
/** A non-retryable replies fault, with enough server identity to act on it. */
|
|
1235
1359
|
function replyPollFailure(err: unknown): string {
|
|
1236
1360
|
return err instanceof ApiCallError
|
|
@@ -1352,9 +1476,16 @@ function retiringQuestion(
|
|
|
1352
1476
|
state: LifecycleEndState,
|
|
1353
1477
|
cwd?: string,
|
|
1354
1478
|
): RetiringQuestion | null {
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1479
|
+
// A submit journal is written before the first network byte. SessionEnd can
|
|
1480
|
+
// therefore race a request that the server accepts after local cleanup. Its
|
|
1481
|
+
// reserved request id and exact fanout are already sufficient retirement
|
|
1482
|
+
// identity, even though the ordinary live fields have not been promoted yet.
|
|
1483
|
+
const requestId = pending.request_id ?? pending.submission?.request_id
|
|
1484
|
+
const collapseKey = pending.collapse_key ?? pending.submission?.collapse_key
|
|
1485
|
+
const deviceIds = pending.device_ids ?? pending.submission?.device_ids
|
|
1486
|
+
const hasRequest = requestId !== undefined
|
|
1487
|
+
const hasCollapse = collapseKey !== undefined
|
|
1488
|
+
const hasDevices = deviceIds !== undefined && deviceIds.length > 0
|
|
1358
1489
|
if (!hasRequest && !hasCollapse && !hasDevices) return null
|
|
1359
1490
|
if (!hasRequest || !hasCollapse || !hasDevices) {
|
|
1360
1491
|
throw new Error(
|
|
@@ -1363,9 +1494,9 @@ function retiringQuestion(
|
|
|
1363
1494
|
}
|
|
1364
1495
|
const source = sourceContextAtHookEvent(pending.source, cwd)
|
|
1365
1496
|
return {
|
|
1366
|
-
request_id:
|
|
1367
|
-
collapse_key:
|
|
1368
|
-
device_ids: [...
|
|
1497
|
+
request_id: requestId!,
|
|
1498
|
+
collapse_key: collapseKey!,
|
|
1499
|
+
device_ids: [...deviceIds!],
|
|
1369
1500
|
question: pending.question,
|
|
1370
1501
|
...(pending.project !== undefined ? { project: pending.project } : {}),
|
|
1371
1502
|
...(source !== undefined ? { source } : {}),
|
|
@@ -1943,6 +2074,40 @@ function amendAcceptedAnswers(
|
|
|
1943
2074
|
)
|
|
1944
2075
|
}
|
|
1945
2076
|
|
|
2077
|
+
/**
|
|
2078
|
+
* Finish a route write that linearized before SessionEnd.
|
|
2079
|
+
*
|
|
2080
|
+
* Ordinary state writers stop at the ended marker. This one narrow completion
|
|
2081
|
+
* is allowed through because the journal's own `delivery_committed_at` proves
|
|
2082
|
+
* the irreversible write won the ordering first; without the matching finish,
|
|
2083
|
+
* a later SessionStart would replay a write that already reached the harness.
|
|
2084
|
+
*/
|
|
2085
|
+
function finishCommittedDelivery(
|
|
2086
|
+
ctx: HookContext,
|
|
2087
|
+
sessionId: string,
|
|
2088
|
+
accepted: AcceptedAnswerDelivery,
|
|
2089
|
+
deliveredRoute: string,
|
|
2090
|
+
): void {
|
|
2091
|
+
const file = sessionStatePath(sessionId, ctx.env)
|
|
2092
|
+
withFileLock(`${file}.lock`, () => {
|
|
2093
|
+
const current = readSessionState(sessionId, ctx.env)
|
|
2094
|
+
if (
|
|
2095
|
+
current.accepted?.recorded_at !== accepted.recorded_at ||
|
|
2096
|
+
current.accepted.delivery_committed_at === undefined
|
|
2097
|
+
) {
|
|
2098
|
+
return
|
|
2099
|
+
}
|
|
2100
|
+
writeSessionStateUnlocked(file, sessionId, {
|
|
2101
|
+
...current,
|
|
2102
|
+
accepted: {
|
|
2103
|
+
...current.accepted,
|
|
2104
|
+
delivered_at: ctx.now(),
|
|
2105
|
+
delivered_route: deliveredRoute,
|
|
2106
|
+
},
|
|
2107
|
+
})
|
|
2108
|
+
})
|
|
2109
|
+
}
|
|
2110
|
+
|
|
1946
2111
|
export function clearAcknowledgementObligation(
|
|
1947
2112
|
sessionId: string,
|
|
1948
2113
|
env: NodeJS.ProcessEnv,
|
|
@@ -2310,7 +2475,7 @@ export async function handleUserPromptSubmit(
|
|
|
2310
2475
|
export async function handleStop(
|
|
2311
2476
|
ctx: HookContext,
|
|
2312
2477
|
envelope: HookEnvelope,
|
|
2313
|
-
processDeadlineAt = ctx.now() +
|
|
2478
|
+
processDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000,
|
|
2314
2479
|
route: EscalationDeliveryRoute = hookContinuationRoute(),
|
|
2315
2480
|
): Promise<HookOutcome> {
|
|
2316
2481
|
const sessionId = envelope.session_id
|
|
@@ -2326,6 +2491,39 @@ export async function handleStop(
|
|
|
2326
2491
|
})
|
|
2327
2492
|
}
|
|
2328
2493
|
|
|
2494
|
+
/**
|
|
2495
|
+
* Acquire the per-session owner, waiting only when this Stop carries a
|
|
2496
|
+
* newly registered question. The live owner sees that state on its next reply
|
|
2497
|
+
* poll and yields; without this handoff the new question could sit unpushed for
|
|
2498
|
+
* the older question's complete answer window.
|
|
2499
|
+
*/
|
|
2500
|
+
async function acquireQuestionOwner(
|
|
2501
|
+
ctx: HookContext,
|
|
2502
|
+
sessionId: string,
|
|
2503
|
+
hardDeadlineAt: number,
|
|
2504
|
+
): Promise<boolean> {
|
|
2505
|
+
if (sessionHasEnded(sessionId, ctx.env)) return false
|
|
2506
|
+
if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt)) return true
|
|
2507
|
+
|
|
2508
|
+
while (ctx.now() < hardDeadlineAt) {
|
|
2509
|
+
const handoff = claimHandoffState(sessionId, ctx.env)
|
|
2510
|
+
if (!handoff.hasNewQuestion) return false
|
|
2511
|
+
const holderReleaseDeadlineAt = Math.min(
|
|
2512
|
+
hardDeadlineAt,
|
|
2513
|
+
handoff.ownerDeadlineAt === undefined
|
|
2514
|
+
? hardDeadlineAt
|
|
2515
|
+
: handoff.ownerDeadlineAt + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS * 1000,
|
|
2516
|
+
)
|
|
2517
|
+
if (ctx.now() >= holderReleaseDeadlineAt) return false
|
|
2518
|
+
await ctx.sleep(
|
|
2519
|
+
Math.min(CLAIM_HANDOFF_POLL_MS, holderReleaseDeadlineAt - ctx.now()),
|
|
2520
|
+
)
|
|
2521
|
+
if (sessionHasEnded(sessionId, ctx.env)) return false
|
|
2522
|
+
if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt)) return true
|
|
2523
|
+
}
|
|
2524
|
+
return false
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2329
2527
|
/**
|
|
2330
2528
|
* Own one registered question pipeline from its terminal-first timer through
|
|
2331
2529
|
* its final delivery or retirement. Hosts differ only in the injected route.
|
|
@@ -2337,7 +2535,12 @@ export async function runEscalationWaiter(
|
|
|
2337
2535
|
const notes: string[] = []
|
|
2338
2536
|
const { sessionId, envelope } = options
|
|
2339
2537
|
const hardDeadlineAt =
|
|
2340
|
-
options.processDeadlineAt ?? ctx.now() +
|
|
2538
|
+
options.processDeadlineAt ?? ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000
|
|
2539
|
+
|
|
2540
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
2541
|
+
notes.push('the Agent Session already ended; no answer observer was started')
|
|
2542
|
+
return { notes }
|
|
2543
|
+
}
|
|
2341
2544
|
|
|
2342
2545
|
// This event marker is diagnostic evidence in its own right. Record it
|
|
2343
2546
|
// before every early return so doctor can distinguish a working Stop route
|
|
@@ -2352,9 +2555,9 @@ export async function runEscalationWaiter(
|
|
|
2352
2555
|
// polling, close fencing, route delivery, and every retirement path.
|
|
2353
2556
|
// Real clock, deliberately, not `ctx.now` — the claim answers whether
|
|
2354
2557
|
// another process is alive right now, which an injected clock cannot know.
|
|
2355
|
-
if (!
|
|
2558
|
+
if (!(await acquireQuestionOwner(ctx, sessionId, hardDeadlineAt))) {
|
|
2356
2559
|
gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' })
|
|
2357
|
-
notes.push('another hook is already handling this question')
|
|
2560
|
+
notes.push('another hook is already handling this session; a newly registered question remains queued for the next owner')
|
|
2358
2561
|
return { notes }
|
|
2359
2562
|
}
|
|
2360
2563
|
let settledAnswerThisPass = false
|
|
@@ -2481,6 +2684,7 @@ export function hookContinuationRoute(): EscalationDeliveryRoute {
|
|
|
2481
2684
|
kind: 'hook-continuation',
|
|
2482
2685
|
deliver: async (event) => ({
|
|
2483
2686
|
stdout: stopAnswerOutput(event.context),
|
|
2687
|
+
commitStdout: event.commitDelivery,
|
|
2484
2688
|
notes: [],
|
|
2485
2689
|
// The harness reads this stdout after the process exits, so only the
|
|
2486
2690
|
// successor Stop can acknowledge it.
|
|
@@ -2507,6 +2711,10 @@ async function deliverAcceptedAnswers(
|
|
|
2507
2711
|
): Promise<HookOutcome> {
|
|
2508
2712
|
const { answers: answered, remaining } = accepted
|
|
2509
2713
|
const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
|
|
2714
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
2715
|
+
notes.push('the Agent Session ended before answer delivery; stopping this observer')
|
|
2716
|
+
return { notes }
|
|
2717
|
+
}
|
|
2510
2718
|
const held = accepted.held_deliveries ?? 0
|
|
2511
2719
|
if (held >= MAX_HELD_DELIVERIES) {
|
|
2512
2720
|
gate(ctx, 'held', 'delivery-limit', {
|
|
@@ -2544,6 +2752,12 @@ async function deliverAcceptedAnswers(
|
|
|
2544
2752
|
...current,
|
|
2545
2753
|
delivery_attempts: attempt,
|
|
2546
2754
|
}))
|
|
2755
|
+
// Linearize delivery after the journal write. If SessionEnd acquired the
|
|
2756
|
+
// shared state lock in between, its durable marker wins and no wake starts.
|
|
2757
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
2758
|
+
notes.push('the Agent Session ended before answer delivery; stopping this observer')
|
|
2759
|
+
return { notes }
|
|
2760
|
+
}
|
|
2547
2761
|
ctx.log?.info('hook.gate', {
|
|
2548
2762
|
verdict: 'proceeding',
|
|
2549
2763
|
reason: 'answered',
|
|
@@ -2554,23 +2768,46 @@ async function deliverAcceptedAnswers(
|
|
|
2554
2768
|
journal_recorded_at: accepted.recorded_at,
|
|
2555
2769
|
delivery_attempt: attempt,
|
|
2556
2770
|
})
|
|
2557
|
-
|
|
2771
|
+
let deliveryCommitted = false
|
|
2772
|
+
const commitDelivery = (): boolean => {
|
|
2773
|
+
if (deliveryCommitted) return true
|
|
2774
|
+
const file = sessionStatePath(sessionId, ctx.env)
|
|
2775
|
+
return withFileLock(`${file}.lock`, () => {
|
|
2776
|
+
if (sessionHasEnded(sessionId, ctx.env)) return false
|
|
2777
|
+
const current = readSessionState(sessionId, ctx.env)
|
|
2778
|
+
if (current.accepted === undefined) return false
|
|
2779
|
+
deliveryCommitted = true
|
|
2780
|
+
writeSessionStateUnlocked(file, sessionId, {
|
|
2781
|
+
...current,
|
|
2782
|
+
accepted: { ...current.accepted, delivery_committed_at: ctx.now() },
|
|
2783
|
+
})
|
|
2784
|
+
return true
|
|
2785
|
+
})
|
|
2786
|
+
}
|
|
2787
|
+
let delivered = await route.deliver({
|
|
2558
2788
|
context: answersContext(answered, remaining),
|
|
2559
2789
|
answers: answered.length,
|
|
2560
2790
|
remaining,
|
|
2561
2791
|
request_ids: requestIds,
|
|
2562
2792
|
journal_recorded_at: accepted.recorded_at,
|
|
2793
|
+
commitDelivery,
|
|
2563
2794
|
})
|
|
2795
|
+
if (delivered.acknowledgement === 'delivered' && !deliveryCommitted) {
|
|
2796
|
+
delivered = {
|
|
2797
|
+
notes: [
|
|
2798
|
+
...(delivered.notes ?? []),
|
|
2799
|
+
`the ${route.kind} route reported delivery without committing against SessionEnd; preserving the answer instead of accepting an unordered write`,
|
|
2800
|
+
],
|
|
2801
|
+
log: { route: route.kind, stage: 'queued', reason: 'delivery-not-committed' },
|
|
2802
|
+
acknowledgement: 'held',
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2564
2805
|
const deliveredRoute =
|
|
2565
2806
|
typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind
|
|
2566
2807
|
const deliveredStage =
|
|
2567
2808
|
typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered'
|
|
2568
2809
|
if (delivered.acknowledgement === 'delivered') {
|
|
2569
|
-
|
|
2570
|
-
...current,
|
|
2571
|
-
delivered_at: ctx.now(),
|
|
2572
|
-
delivered_route: deliveredRoute,
|
|
2573
|
-
}))
|
|
2810
|
+
finishCommittedDelivery(ctx, sessionId, accepted, deliveredRoute)
|
|
2574
2811
|
} else if (delivered.acknowledgement === 'held') {
|
|
2575
2812
|
// Give the delivery attempt back and count the hold instead. The attempt
|
|
2576
2813
|
// was counted before the call because one that dies mid-delivery still
|
|
@@ -2596,6 +2833,7 @@ async function deliverAcceptedAnswers(
|
|
|
2596
2833
|
})
|
|
2597
2834
|
const outcome: HookOutcome = { notes: [...notes, ...(delivered.notes ?? [])] }
|
|
2598
2835
|
if (delivered.stdout !== undefined) outcome.stdout = delivered.stdout
|
|
2836
|
+
if (delivered.commitStdout !== undefined) outcome.commitStdout = delivered.commitStdout
|
|
2599
2837
|
if (delivered.log !== undefined) outcome.log = delivered.log
|
|
2600
2838
|
return outcome
|
|
2601
2839
|
}
|
|
@@ -2692,13 +2930,10 @@ async function handleClaimedStop(
|
|
|
2692
2930
|
const recoverableLive = live.filter(
|
|
2693
2931
|
(entry) => !permanentFailures.has(entry.request_id!),
|
|
2694
2932
|
)
|
|
2695
|
-
// A spent owner has already
|
|
2696
|
-
// questions back into `escalate` starts a fresh ceiling
|
|
2697
|
-
// per-session claim
|
|
2698
|
-
//
|
|
2699
|
-
// ran as claimed-elsewhere). Late answers still ride the short poll above
|
|
2700
|
-
// and every later turn. Independent questions whose lease is still running
|
|
2701
|
-
// keep their successor wait.
|
|
2933
|
+
// A spent owner has already reached the end of the answer window it owned.
|
|
2934
|
+
// Passing those questions back into `escalate` starts a fresh ceiling and
|
|
2935
|
+
// can monopolize the per-session claim after expiry. Independent questions
|
|
2936
|
+
// whose lease is still running keep their successor wait.
|
|
2702
2937
|
const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()))
|
|
2703
2938
|
liveToEscalate = stillOwned
|
|
2704
2939
|
if (unasked.length === 0) {
|
|
@@ -2712,7 +2947,7 @@ async function handleClaimedStop(
|
|
|
2712
2947
|
owner_spent: true,
|
|
2713
2948
|
})
|
|
2714
2949
|
notes.push(
|
|
2715
|
-
`${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still
|
|
2950
|
+
`${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still recorded after the previous answer owner ended; a later turn will reconcile ${recoverableLive.length === 1 ? 'it' : 'them'} without starting another full-window wait`,
|
|
2716
2951
|
)
|
|
2717
2952
|
return { notes }
|
|
2718
2953
|
}
|
|
@@ -2807,16 +3042,7 @@ async function escalate(
|
|
|
2807
3042
|
// The questions still owe the user their terminal-first window before
|
|
2808
3043
|
// anything reaches their devices — measured from the oldest registration,
|
|
2809
3044
|
// because that is the question that has waited longest.
|
|
2810
|
-
const
|
|
2811
|
-
const existingDeadlines = alreadyLive.flatMap((entry) =>
|
|
2812
|
-
entry.reply_deadline_at === undefined || entry.reply_deadline_at <= startedAt
|
|
2813
|
-
? []
|
|
2814
|
-
: [entry.reply_deadline_at],
|
|
2815
|
-
)
|
|
2816
|
-
const ceilingAt = Math.min(
|
|
2817
|
-
hardDeadlineAt,
|
|
2818
|
-
...(existingDeadlines.length === 0 ? [] : existingDeadlines),
|
|
2819
|
-
)
|
|
3045
|
+
const ceilingAt = hardDeadlineAt
|
|
2820
3046
|
if (unasked.length > 0 && alreadyLive.length === 0) {
|
|
2821
3047
|
const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
|
|
2822
3048
|
await awaitTerminalFirstWindow(ctx, oldest, ceilingAt)
|
|
@@ -2830,26 +3056,21 @@ async function escalate(
|
|
|
2830
3056
|
// Phase one: every registered question reaches the user's devices, each as
|
|
2831
3057
|
// its own notification — one ask never stands in for another.
|
|
2832
3058
|
const submitted: PendingQuestion[] = []
|
|
3059
|
+
const admissionAnswers: AnsweredPending[] = []
|
|
2833
3060
|
for (const entry of unasked) {
|
|
2834
|
-
//
|
|
2835
|
-
//
|
|
2836
|
-
//
|
|
2837
|
-
// stopped being answerable about eight minutes later, whatever the user
|
|
2838
|
-
// had configured.
|
|
2839
|
-
//
|
|
2840
|
-
// That was right when the waiter was the only way an answer came back. It
|
|
2841
|
-
// is not any more: a late answer is found by the next turn's poll or
|
|
2842
|
-
// replayed from the journal, so this owner giving up listening says
|
|
2843
|
-
// nothing about whether the user's answer is still wanted.
|
|
3061
|
+
// The service owns how long the answer is accepted. This process begins
|
|
3062
|
+
// before submission, so its larger maximum-window budget includes startup
|
|
3063
|
+
// headroom and remains alive through the complete committed window.
|
|
2844
3064
|
const replyWindowSeconds = ctx.config.reply_window_seconds.value
|
|
2845
|
-
if (
|
|
3065
|
+
if (
|
|
3066
|
+
ceilingAt - ctx.now() <
|
|
3067
|
+
(replyWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000
|
|
3068
|
+
) {
|
|
2846
3069
|
notes.push(
|
|
2847
|
-
'too little
|
|
3070
|
+
'too little owner lifetime remains for the complete configured answer window; leaving this question frozen for a successor owner',
|
|
2848
3071
|
)
|
|
2849
3072
|
continue
|
|
2850
3073
|
}
|
|
2851
|
-
// The owner still stops listening at its own ceiling. Only the span the
|
|
2852
|
-
// server accepts answers into changed.
|
|
2853
3074
|
const ownerDeadlineAt = ceilingAt
|
|
2854
3075
|
const questions = pendingQuestions(entry)
|
|
2855
3076
|
const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd)
|
|
@@ -2903,16 +3124,19 @@ async function escalate(
|
|
|
2903
3124
|
})
|
|
2904
3125
|
}
|
|
2905
3126
|
if (ctx.now() >= intent.owner_deadline_at) {
|
|
2906
|
-
notes.push('the
|
|
3127
|
+
notes.push('the owner lifetime ended before submission; preserving the frozen intent')
|
|
2907
3128
|
continue
|
|
2908
3129
|
}
|
|
2909
|
-
|
|
2910
|
-
if (beforeSubmitSeconds < MIN_REPLY_WINDOW_SECONDS) {
|
|
3130
|
+
if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
|
|
2911
3131
|
notes.push(
|
|
2912
|
-
'
|
|
3132
|
+
'setup consumed the admission allowance; preserving the frozen intent instead of publishing an answer window this owner cannot observe completely',
|
|
2913
3133
|
)
|
|
2914
3134
|
continue
|
|
2915
3135
|
}
|
|
3136
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
3137
|
+
notes.push('the Agent Session ended before submission; preserving no live observer')
|
|
3138
|
+
continue
|
|
3139
|
+
}
|
|
2916
3140
|
let receipt: SubmissionReceipt | undefined
|
|
2917
3141
|
let admissionConfirmed = false
|
|
2918
3142
|
try {
|
|
@@ -2963,6 +3187,16 @@ async function escalate(
|
|
|
2963
3187
|
return { ...current, pending: next }
|
|
2964
3188
|
})
|
|
2965
3189
|
try {
|
|
3190
|
+
if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
|
|
3191
|
+
notes.push(
|
|
3192
|
+
'draft recovery consumed the admission allowance; preserving the frozen intent for a successor owner',
|
|
3193
|
+
)
|
|
3194
|
+
continue
|
|
3195
|
+
}
|
|
3196
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
3197
|
+
notes.push('the Agent Session ended before recovered submission')
|
|
3198
|
+
continue
|
|
3199
|
+
}
|
|
2966
3200
|
receipt = await submitQuestion(ctx, intent)
|
|
2967
3201
|
admissionConfirmed = true
|
|
2968
3202
|
} catch (retryErr) {
|
|
@@ -3027,17 +3261,9 @@ async function escalate(
|
|
|
3027
3261
|
request_id: intent.request_id,
|
|
3028
3262
|
collapse_key: intent.collapse_key,
|
|
3029
3263
|
device_ids: intent.device_ids,
|
|
3030
|
-
//
|
|
3031
|
-
//
|
|
3032
|
-
//
|
|
3033
|
-
// waiter's ceiling: a question asked with a day-long window was swept as
|
|
3034
|
-
// stale about eight minutes later and retired, so the answer given over
|
|
3035
|
-
// lunch met a closed question.
|
|
3036
|
-
//
|
|
3037
|
-
// The reply window is how long the server accepts an answer. This
|
|
3038
|
-
// process still clamps its own wait to `hardDeadlineAt`. Recording the
|
|
3039
|
-
// owner lease separately is what stops the *next* Stop from treating
|
|
3040
|
-
// the remaining window as a fresh waiter ceiling.
|
|
3264
|
+
// The server's committed answer deadline is authoritative. The local
|
|
3265
|
+
// owner deadline starts earlier and includes startup headroom, so it may
|
|
3266
|
+
// be later but must never be earlier.
|
|
3041
3267
|
reply_deadline_at: Number.isFinite(committedReplyDeadline)
|
|
3042
3268
|
? committedReplyDeadline
|
|
3043
3269
|
: intent.owner_deadline_at,
|
|
@@ -3045,7 +3271,56 @@ async function escalate(
|
|
|
3045
3271
|
}
|
|
3046
3272
|
if (intent.draft.source === undefined) delete live.source
|
|
3047
3273
|
delete live.submission
|
|
3048
|
-
|
|
3274
|
+
if (live.reply_deadline_at! > live.owner_deadline_at!) {
|
|
3275
|
+
const response = await finalizeReplies(ctx, live.request_id!)
|
|
3276
|
+
if (response === null) {
|
|
3277
|
+
// Closing was unreachable, so the question is still potentially live.
|
|
3278
|
+
// Preserve it in the exact session instead of demoting it to the orphan
|
|
3279
|
+
// retirement queue, whose later close has no route for an answer.
|
|
3280
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
3281
|
+
const list = pendingList(current)
|
|
3282
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
3283
|
+
if (index < 0) {
|
|
3284
|
+
// Terminal input removed this registration while the anomalous
|
|
3285
|
+
// close was in flight. Preserve retirement identity exactly as
|
|
3286
|
+
// normal submit promotion does; the observer must not become the
|
|
3287
|
+
// only remaining record of a live card.
|
|
3288
|
+
const retirement = retiringQuestion(
|
|
3289
|
+
live,
|
|
3290
|
+
'answered_elsewhere',
|
|
3291
|
+
envelope.cwd,
|
|
3292
|
+
)!
|
|
3293
|
+
const retiring = [...(current.retiring ?? [])]
|
|
3294
|
+
if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
3295
|
+
retiring.push(retirement)
|
|
3296
|
+
}
|
|
3297
|
+
return { ...current, retiring }
|
|
3298
|
+
}
|
|
3299
|
+
const next = [...list]
|
|
3300
|
+
next[index] = live
|
|
3301
|
+
return { ...current, pending: next }
|
|
3302
|
+
})
|
|
3303
|
+
submitted.push(live)
|
|
3304
|
+
notes.push(
|
|
3305
|
+
'the server committed an answer deadline beyond this process owner and immediate closure was unreachable; preserving the live question for exact-session recovery',
|
|
3306
|
+
)
|
|
3307
|
+
continue
|
|
3308
|
+
} else if (response.replies.length > 0) {
|
|
3309
|
+
admissionAnswers.push({
|
|
3310
|
+
pending: live,
|
|
3311
|
+
reply: response.replies.at(-1)!,
|
|
3312
|
+
replies: response.replies,
|
|
3313
|
+
agent_acknowledgement_required: response.agent_acknowledgement_required,
|
|
3314
|
+
agent_acknowledgement_text_required:
|
|
3315
|
+
response.agent_acknowledgement_text_required,
|
|
3316
|
+
})
|
|
3317
|
+
}
|
|
3318
|
+
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
3319
|
+
notes.push(
|
|
3320
|
+
'the server committed an answer deadline beyond this process owner; closed the anomalous window instead of abandoning it early',
|
|
3321
|
+
)
|
|
3322
|
+
continue
|
|
3323
|
+
}
|
|
3049
3324
|
// Record what is now live on the user's devices BEFORE any wait. If we
|
|
3050
3325
|
// only learned these ids afterwards, a question that timed out would
|
|
3051
3326
|
// leave no trace, and the user returning to the terminal could never
|
|
@@ -3073,6 +3348,30 @@ async function escalate(
|
|
|
3073
3348
|
return { ...current, retiring }
|
|
3074
3349
|
})
|
|
3075
3350
|
}
|
|
3351
|
+
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
3352
|
+
// SessionEnd may have raced either the submit or promotion. Its snapshot
|
|
3353
|
+
// usually already queued the frozen intent; this idempotent add closes
|
|
3354
|
+
// the other ordering where the request committed just after cleanup.
|
|
3355
|
+
orphanRetirements(
|
|
3356
|
+
ctx.env,
|
|
3357
|
+
[retiringQuestion(live, 'expired', envelope.cwd)!],
|
|
3358
|
+
ctx.now(),
|
|
3359
|
+
)
|
|
3360
|
+
notes.push('the Agent Session ended during submission; queued the question for retirement')
|
|
3361
|
+
continue
|
|
3362
|
+
}
|
|
3363
|
+
submitted.push(live)
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
if (admissionAnswers.length > 0) {
|
|
3367
|
+
const accepted = stageAcceptedAnswers(
|
|
3368
|
+
ctx,
|
|
3369
|
+
sessionId,
|
|
3370
|
+
admissionAnswers,
|
|
3371
|
+
pendingList(readSessionState(sessionId, ctx.env)).length,
|
|
3372
|
+
)
|
|
3373
|
+
for (const answer of admissionAnswers) reportAnswer(ctx, notes, answer, false)
|
|
3374
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
3076
3375
|
}
|
|
3077
3376
|
|
|
3078
3377
|
const staleLive = alreadyLive.filter(
|
|
@@ -3115,52 +3414,73 @@ async function escalate(
|
|
|
3115
3414
|
]
|
|
3116
3415
|
if (waitingOn.length === 0) return { notes }
|
|
3117
3416
|
|
|
3118
|
-
// Phase two:
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
timeoutSeconds,
|
|
3129
|
-
)
|
|
3130
|
-
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
|
|
3131
|
-
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
3132
|
-
|
|
3133
|
-
if (waited.byRequest.size === 0) {
|
|
3134
|
-
// This owner is done listening. That used to mean the question was over:
|
|
3135
|
-
// it closed the window server-side and retired the record, on the reasoning
|
|
3136
|
-
// that no later answer could reach the same agent turn.
|
|
3137
|
-
//
|
|
3138
|
-
// The reaching was never the point — the *accepting* is. A question whose
|
|
3139
|
-
// window is still open is polled again by the next prompt and the next
|
|
3140
|
-
// Stop, and an answer found there is journaled and replayed. Closing here
|
|
3141
|
-
// capped every question at this owner's ceiling however long a window the
|
|
3142
|
-
// user had configured, which is what made a day-long window mean eight
|
|
3143
|
-
// minutes.
|
|
3144
|
-
//
|
|
3145
|
-
// So: finalize only what the server has genuinely stopped accepting, and
|
|
3146
|
-
// leave the rest live for the turn that comes next. A session that truly
|
|
3147
|
-
// ends still retires its questions — that is what the session-end hook is
|
|
3148
|
-
// for, and it is the honest place for it.
|
|
3149
|
-
const expired = waitingOn.filter(
|
|
3150
|
-
(entry) =>
|
|
3151
|
-
entry.reply_deadline_at === undefined ||
|
|
3152
|
-
entry.reply_deadline_at <= ownerDeadline ||
|
|
3153
|
-
// A permanent rejection is the server saying this question can never be
|
|
3154
|
-
// answered, whatever its window says. Leaving it open would strand a
|
|
3155
|
-
// dead question on the user's devices.
|
|
3156
|
-
waited.permanentFailures.has(entry.request_id!),
|
|
3417
|
+
// Phase two: keep the same owner alive across every committed answer window.
|
|
3418
|
+
// Different questions may expire at different times, so finalize each one at
|
|
3419
|
+
// its own server deadline and continue waiting on the rest. Reserving time by
|
|
3420
|
+
// closing early would make the advertised reply window untrue.
|
|
3421
|
+
let activeWaiting = waitingOn
|
|
3422
|
+
let timeoutSeconds = 0
|
|
3423
|
+
let waited: Awaited<ReturnType<typeof waitForAnyReply>>
|
|
3424
|
+
for (;;) {
|
|
3425
|
+
const nextReplyDeadline = Math.min(
|
|
3426
|
+
...activeWaiting.map((entry) => entry.reply_deadline_at!),
|
|
3157
3427
|
)
|
|
3158
|
-
const
|
|
3159
|
-
|
|
3428
|
+
const waitDeadline = Math.min(ceilingAt, nextReplyDeadline)
|
|
3429
|
+
timeoutSeconds = Math.max(0, Math.ceil((waitDeadline - ctx.now()) / 1000))
|
|
3430
|
+
waited = await waitForAnyReply(
|
|
3431
|
+
ctx,
|
|
3432
|
+
activeWaiting.map((entry) => entry.request_id!),
|
|
3433
|
+
timeoutSeconds,
|
|
3434
|
+
() =>
|
|
3435
|
+
waiterInterruption(
|
|
3436
|
+
sessionId,
|
|
3437
|
+
ctx.env,
|
|
3438
|
+
activeWaiting.map((entry) => entry.request_id!),
|
|
3439
|
+
),
|
|
3440
|
+
)
|
|
3441
|
+
if (waited.interrupted !== null) {
|
|
3442
|
+
notes.push(
|
|
3443
|
+
waited.interrupted === 'new-question'
|
|
3444
|
+
? 'yielding the answer owner so a newly registered question can be sent now'
|
|
3445
|
+
: 'answer ownership ended locally; stopping this observer without delivering into a closed session',
|
|
3446
|
+
)
|
|
3447
|
+
return { notes }
|
|
3448
|
+
}
|
|
3449
|
+
const currentRequestIds = new Set(
|
|
3450
|
+
pendingList(readSessionState(sessionId, ctx.env)).flatMap((entry) =>
|
|
3451
|
+
[entry.request_id, entry.submission?.request_id].filter(
|
|
3452
|
+
(requestId): requestId is string => requestId !== undefined,
|
|
3453
|
+
),
|
|
3454
|
+
),
|
|
3455
|
+
)
|
|
3456
|
+
const removedDuringPoll = activeWaiting.filter(
|
|
3457
|
+
(entry) => !currentRequestIds.has(entry.request_id!),
|
|
3458
|
+
)
|
|
3459
|
+
for (const entry of removedDuringPoll) {
|
|
3460
|
+
waited.byRequest.delete(entry.request_id!)
|
|
3461
|
+
waited.permanentFailures.delete(entry.request_id!)
|
|
3462
|
+
}
|
|
3463
|
+
if (removedDuringPoll.length > 0) {
|
|
3160
3464
|
notes.push(
|
|
3161
|
-
`${
|
|
3465
|
+
`${removedDuringPoll.length} question${removedDuringPoll.length === 1 ? ' changed' : 's changed'} outside this observer; continuing with the independently live questions`,
|
|
3162
3466
|
)
|
|
3467
|
+
activeWaiting = activeWaiting.filter((entry) => currentRequestIds.has(entry.request_id!))
|
|
3468
|
+
if (activeWaiting.length === 0) return { notes }
|
|
3163
3469
|
}
|
|
3470
|
+
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
|
|
3471
|
+
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
3472
|
+
if (waited.byRequest.size > 0) break
|
|
3473
|
+
|
|
3474
|
+
// A permanent rejection is authoritative immediately. Time-based expiry is
|
|
3475
|
+
// authoritative only when this owner reached that question's committed
|
|
3476
|
+
// server deadline; reaching the process ceiling first preserves the record.
|
|
3477
|
+
const expired = activeWaiting.filter(
|
|
3478
|
+
(entry) =>
|
|
3479
|
+
entry.reply_deadline_at === undefined ||
|
|
3480
|
+
entry.reply_deadline_at <= waitDeadline ||
|
|
3481
|
+
waited.permanentFailures.has(entry.request_id!),
|
|
3482
|
+
)
|
|
3483
|
+
const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry))
|
|
3164
3484
|
const finalized = await finalizePendings(ctx, expired)
|
|
3165
3485
|
const finalAnswers = finalized
|
|
3166
3486
|
.map(finalizedAnswer)
|
|
@@ -3186,6 +3506,11 @@ async function escalate(
|
|
|
3186
3506
|
for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
|
|
3187
3507
|
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
3188
3508
|
}
|
|
3509
|
+
if (stillAnswerable.length > 0 && waitDeadline < ceilingAt) {
|
|
3510
|
+
activeWaiting = stillAnswerable
|
|
3511
|
+
continue
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3189
3514
|
const requestIdSummary = summarizeRequestIds(waitingOn)
|
|
3190
3515
|
ctx.log?.info('hook.answer', {
|
|
3191
3516
|
answered: false,
|
|
@@ -3195,17 +3520,21 @@ async function escalate(
|
|
|
3195
3520
|
permanent_failures: waited.permanentFailures.size,
|
|
3196
3521
|
waited_seconds: timeoutSeconds,
|
|
3197
3522
|
})
|
|
3198
|
-
if (
|
|
3523
|
+
if (stillAnswerable.length > 0) {
|
|
3524
|
+
notes.push(
|
|
3525
|
+
`${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' remains' : 's remain'} answerable after the process owner ended; preserving ${stillAnswerable.length === 1 ? 'it' : 'them'} for recovery`,
|
|
3526
|
+
)
|
|
3527
|
+
} else if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
|
|
3199
3528
|
notes.push(
|
|
3200
3529
|
waited.degraded
|
|
3201
|
-
? 'could not reach the server before the
|
|
3530
|
+
? 'could not reach the server before the answer deadline; expired questions were retired so no answer can be lost later'
|
|
3202
3531
|
: 'no answer in time; the question expired with its continuation owner',
|
|
3203
3532
|
)
|
|
3204
3533
|
}
|
|
3205
3534
|
return { notes }
|
|
3206
3535
|
}
|
|
3207
3536
|
|
|
3208
|
-
const polledAnswered =
|
|
3537
|
+
const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id!))
|
|
3209
3538
|
const finalizedAnswered = await finalizePendings(ctx, polledAnswered)
|
|
3210
3539
|
const answered: AnsweredPending[] = []
|
|
3211
3540
|
for (const finalized of finalizedAnswered) {
|
|
@@ -3236,7 +3565,10 @@ async function escalate(
|
|
|
3236
3565
|
ctx,
|
|
3237
3566
|
sessionId,
|
|
3238
3567
|
answered,
|
|
3239
|
-
|
|
3568
|
+
Math.max(
|
|
3569
|
+
0,
|
|
3570
|
+
pendingList(readSessionState(sessionId, ctx.env)).length - answered.length,
|
|
3571
|
+
),
|
|
3240
3572
|
)
|
|
3241
3573
|
for (const answer of answered) reportAnswer(ctx, notes, answer, false)
|
|
3242
3574
|
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
@@ -3262,14 +3594,26 @@ export function handleSessionEnd(
|
|
|
3262
3594
|
const notes: string[] = []
|
|
3263
3595
|
const sessionId = envelope.session_id
|
|
3264
3596
|
if (!sessionId) return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } }
|
|
3597
|
+
// Publish cancellation before reading or clearing anything. In-flight Stop
|
|
3598
|
+
// writers use the same session lock, so none can recreate state after this.
|
|
3599
|
+
markSessionEnded(sessionId, env, now)
|
|
3265
3600
|
if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
|
|
3266
3601
|
|
|
3267
3602
|
const state = readSessionState(sessionId, env)
|
|
3268
3603
|
const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
|
|
3269
|
-
|
|
3604
|
+
const retirementCandidates = [
|
|
3605
|
+
...pendingList(state),
|
|
3606
|
+
...(state.accepted?.answers.map((entry) => entry.pending) ?? []),
|
|
3607
|
+
]
|
|
3608
|
+
for (const entry of retirementCandidates) {
|
|
3270
3609
|
try {
|
|
3271
3610
|
const orphan = retiringQuestion(entry, 'expired', envelope.cwd)
|
|
3272
|
-
if (
|
|
3611
|
+
if (
|
|
3612
|
+
orphan !== null &&
|
|
3613
|
+
!orphans.some((candidate) => candidate.request_id === orphan.request_id)
|
|
3614
|
+
) {
|
|
3615
|
+
orphans.push(orphan)
|
|
3616
|
+
}
|
|
3273
3617
|
} catch (err) {
|
|
3274
3618
|
notes.push(err instanceof Error ? err.message : String(err))
|
|
3275
3619
|
// Preserve the only identifiers instead of turning an explicit corrupt
|