@raidiant/notifai 10.1.1 → 10.1.2
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 +20 -0
- package/dist/commands-core.d.ts +1 -1
- package/dist/commands-init.js +9 -6
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +3 -0
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-skill.js +72 -54
- package/dist/commands-skill.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +9 -1
- package/dist/invocation-context.js.map +1 -1
- package/dist/native-skills.d.ts +13 -3
- package/dist/native-skills.js +51 -12
- package/dist/native-skills.js.map +1 -1
- package/dist/node-floor.d.ts +2 -1
- package/dist/node-floor.js +9 -5
- package/dist/node-floor.js.map +1 -1
- package/dist/orca-session-title.d.ts +5 -4
- package/dist/orca-session-title.js +32 -19
- package/dist/orca-session-title.js.map +1 -1
- package/dist/platform.d.ts +11 -17
- package/dist/platform.js +20 -23
- package/dist/platform.js.map +1 -1
- package/dist/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/setup-destinations.js +1 -1
- package/dist/setup-destinations.js.map +1 -1
- package/dist/skill-integrity.d.ts +49 -0
- package/dist/skill-integrity.js +154 -0
- package/dist/skill-integrity.js.map +1 -0
- package/dist/skill-source/manifest.json +16 -0
- package/dist/skill-source/notifai/SKILL.md +390 -0
- package/dist/skill-source/notifai/references/harness-setup.md +220 -0
- package/package.json +11 -14
- package/src/atomic-file.ts +0 -198
- package/src/claude-wake.test.ts +0 -321
- package/src/claude-wake.ts +0 -499
- package/src/cli-bin.test.ts +0 -176
- package/src/cli-bin.ts +0 -233
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/cli-update-contract.ts +0 -9
- package/src/cli-update.test.ts +0 -125
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -352
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -301
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -238
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -312
- package/src/commands-devices.ts +0 -180
- package/src/commands-doctor.ts +0 -1501
- package/src/commands-guidance.ts +0 -181
- package/src/commands-harness-context.ts +0 -207
- package/src/commands-hook-shape.ts +0 -69
- package/src/commands-hooks.ts +0 -2318
- package/src/commands-init.ts +0 -1055
- package/src/commands-io.ts +0 -108
- package/src/commands-logs.ts +0 -243
- package/src/commands-project.ts +0 -47
- package/src/commands-send-support.test.ts +0 -156
- package/src/commands-send-support.ts +0 -311
- package/src/commands-send.ts +0 -875
- package/src/commands-setup-proof.test.ts +0 -72
- package/src/commands-setup-proof.ts +0 -124
- package/src/commands-skill.test.ts +0 -44
- package/src/commands-skill.ts +0 -204
- package/src/commands-sounds.test.ts +0 -106
- package/src/commands-sounds.ts +0 -49
- package/src/commands-update.ts +0 -180
- package/src/commands.test.ts +0 -8297
- package/src/commands.ts +0 -77
- package/src/config-schema.test.ts +0 -153
- package/src/config-schema.ts +0 -349
- package/src/config.test.ts +0 -581
- package/src/config.ts +0 -475
- package/src/credentials.test.ts +0 -403
- package/src/credentials.ts +0 -403
- package/src/file-lock-process-worker.test.ts +0 -64
- package/src/file-lock.test.ts +0 -320
- package/src/file-lock.ts +0 -393
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
- package/src/generated-session-label.ts +0 -155
- package/src/guidance-content.ts +0 -278
- package/src/guidance-render.ts +0 -56
- package/src/guidance.test.ts +0 -327
- package/src/guidance.ts +0 -233
- package/src/harness-integration.test.ts +0 -294
- package/src/harnesses.test.ts +0 -149
- package/src/harnesses.ts +0 -164
- package/src/hook-adapter.test.ts +0 -496
- package/src/hook-adapter.ts +0 -424
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5824
- package/src/hooks.ts +0 -4011
- package/src/install-hooks.test.ts +0 -1614
- package/src/install-hooks.ts +0 -1501
- package/src/interactive.test.ts +0 -387
- package/src/interactive.ts +0 -858
- package/src/invocation-context.test.ts +0 -352
- package/src/invocation-context.ts +0 -197
- package/src/logging-process-worker.test.ts +0 -45
- package/src/logging.test.ts +0 -544
- package/src/logging.ts +0 -698
- package/src/main-run.ts +0 -45
- package/src/main.ts +0 -12
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/node-floor.test.ts +0 -27
- package/src/node-floor.ts +0 -37
- package/src/onboarding-docs.test.ts +0 -25
- package/src/openclaw-plugin.ts +0 -409
- package/src/opencode-plugin.ts +0 -298
- package/src/orca-session-title.test.ts +0 -54
- package/src/orca-session-title.ts +0 -103
- package/src/pack-feedback-slice.test.ts +0 -212
- package/src/pack-feedback-slice.ts +0 -187
- package/src/platform.test.ts +0 -152
- package/src/platform.ts +0 -173
- package/src/program.test.ts +0 -317
- package/src/program.ts +0 -842
- package/src/project-enablement.test.ts +0 -55
- package/src/project-enablement.ts +0 -99
- package/src/question-caps.test.ts +0 -49
- package/src/question-settlement-process.ts +0 -47
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -212
- package/src/release.ts +0 -51
- package/src/send-attempts.test.ts +0 -143
- package/src/send-attempts.ts +0 -188
- package/src/send.test.ts +0 -28
- package/src/send.ts +0 -436
- package/src/session-activation.ts +0 -61
- package/src/session-end-log-lock-worker.test.ts +0 -35
- package/src/session-labels.test.ts +0 -490
- package/src/session-labels.ts +0 -474
- package/src/setup-destinations.test.ts +0 -41
- package/src/setup-destinations.ts +0 -50
- package/src/skill.test.ts +0 -426
- package/src/sound-ref.test.ts +0 -31
- package/src/sound-ref.ts +0 -34
- package/src/trust-policy.test.ts +0 -701
- package/src/ui/banner.ts +0 -95
- package/src/ui/config-view.test.ts +0 -90
- package/src/ui/config-view.ts +0 -138
- package/src/ui/help.test.ts +0 -16
- package/src/ui/help.ts +0 -128
- package/src/ui/theme.test.ts +0 -92
- package/src/ui/theme.ts +0 -265
- package/src/url-policy.ts +0 -399
- package/tsconfig.json +0 -10
package/src/hooks.ts
DELETED
|
@@ -1,4011 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto'
|
|
2
|
-
import {
|
|
3
|
-
closeSync,
|
|
4
|
-
existsSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
openSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
readdirSync,
|
|
9
|
-
rmSync,
|
|
10
|
-
statSync,
|
|
11
|
-
writeFileSync,
|
|
12
|
-
} from 'node:fs'
|
|
13
|
-
import path from 'node:path'
|
|
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'
|
|
21
|
-
import type {
|
|
22
|
-
LifecycleEndState,
|
|
23
|
-
ListRepliesResponse,
|
|
24
|
-
MediaItemT,
|
|
25
|
-
NotificationDraftT,
|
|
26
|
-
QuestionT,
|
|
27
|
-
ReplyView,
|
|
28
|
-
SourceContextT,
|
|
29
|
-
SubmissionReceipt,
|
|
30
|
-
} from '@raidiant/notifai-protocol'
|
|
31
|
-
import {
|
|
32
|
-
ApiCallError,
|
|
33
|
-
isRetryableReplyPollError,
|
|
34
|
-
type ApiClient,
|
|
35
|
-
} from './client.js'
|
|
36
|
-
import {
|
|
37
|
-
loadConfig,
|
|
38
|
-
projectSessionPointerPath,
|
|
39
|
-
sanitizeSessionId,
|
|
40
|
-
sessionConfigPath,
|
|
41
|
-
stateDir,
|
|
42
|
-
type CliConfig,
|
|
43
|
-
} from './config.js'
|
|
44
|
-
import { buildDraft } from './send.js'
|
|
45
|
-
import { atomicWriteFileSync } from './atomic-file.js'
|
|
46
|
-
import { withFileLock } from './file-lock.js'
|
|
47
|
-
import { inferInvocationContext } from './invocation-context.js'
|
|
48
|
-
import type { Logger } from './logging.js'
|
|
49
|
-
import {
|
|
50
|
-
HARNESS_CAPABILITIES,
|
|
51
|
-
HOOK_INSTALLABLE_HARNESSES,
|
|
52
|
-
type DeliveryRoute,
|
|
53
|
-
type HookInstallableHarness,
|
|
54
|
-
} from './harnesses.js'
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Harness hook handlers.
|
|
58
|
-
*
|
|
59
|
-
* The supported harnesses expose the same useful lifecycle joints: a turn-end
|
|
60
|
-
* event and an event that fires when the user submits a prompt. Claude Code
|
|
61
|
-
* and Codex can continue directly from a turn-end answer. Harnesses without a
|
|
62
|
-
* proven exact-session continuation fail closed at question admission.
|
|
63
|
-
*
|
|
64
|
-
* Where the user is standing no longer decides anything here. It used to: the
|
|
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.
|
|
70
|
-
*/
|
|
71
|
-
|
|
72
|
-
/** Fields we read from harness hook JSON. Everything else is passed through. */
|
|
73
|
-
export interface HookEnvelope {
|
|
74
|
-
session_id?: string
|
|
75
|
-
cwd?: string
|
|
76
|
-
hook_event_name?: string
|
|
77
|
-
/** How a harness lifecycle began: startup, resume, clear, compact, or fork. */
|
|
78
|
-
source?: string
|
|
79
|
-
/** Set by the harness when this Stop follows a previous Stop continuation. */
|
|
80
|
-
stop_hook_active?: boolean
|
|
81
|
-
/** Cursor's stable per-conversation identifier. */
|
|
82
|
-
conversation_id?: string
|
|
83
|
-
/** Cursor's project roots; the first is the hook's configuration root. */
|
|
84
|
-
workspace_roots?: string[]
|
|
85
|
-
/** Cursor increments this after each stop-hook automatic follow-up. */
|
|
86
|
-
loop_count?: number
|
|
87
|
-
/** Cursor Stop completion state; cancellation must not auto-follow. */
|
|
88
|
-
status?: string
|
|
89
|
-
/** The prompt the user just submitted, when the harness includes it. */
|
|
90
|
-
prompt?: string
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export interface SessionState {
|
|
94
|
-
/** Harness that owns this exact lifecycle state. */
|
|
95
|
-
harness?: HookHarness
|
|
96
|
-
/** Checkout whose hook definition activated this session; lifecycle diagnostics only. */
|
|
97
|
-
activation_cwd?: string
|
|
98
|
-
/** Cursor's documented session context path is lossy; bounded first-Stop fallback journal. */
|
|
99
|
-
cursor_activation_claimed_at?: number
|
|
100
|
-
cursor_activation_confirmed_at?: number
|
|
101
|
-
/** Epoch ms of the user's last prompt in this session — our presence signal. */
|
|
102
|
-
last_prompt_at?: number
|
|
103
|
-
/** Epoch ms of the last observed Stop hook, distinct from prompt routing. */
|
|
104
|
-
last_stop_at?: number
|
|
105
|
-
/**
|
|
106
|
-
* Questions registered by `notifai ask`, in registration order, each
|
|
107
|
-
* awaiting escalation or its answer. A list, deliberately: registering a
|
|
108
|
-
* question never ends an earlier one. Superseding is reply semantics — a
|
|
109
|
-
* later reply corrects an earlier reply to the same question — never
|
|
110
|
-
* question semantics; the single-slot model silently discarded a live
|
|
111
|
-
* question the moment a second was registered (2026-08-09).
|
|
112
|
-
*/
|
|
113
|
-
pending?: PendingQuestion[]
|
|
114
|
-
/**
|
|
115
|
-
* Bounded terminal history for stable `q_...` lookup after the live queue is
|
|
116
|
-
* gone. It carries identities and lifecycle state only; question content
|
|
117
|
-
* remains in the ordinary pending/retirement records under their existing
|
|
118
|
-
* retention boundary.
|
|
119
|
-
*/
|
|
120
|
-
question_history?: QuestionHistoryEntry[]
|
|
121
|
-
/**
|
|
122
|
-
* Questions that have been delivered to the user's devices and are now dead,
|
|
123
|
-
* but whose retirement has not been confirmed yet.
|
|
124
|
-
*
|
|
125
|
-
* A retirement needs a network call and the moment we learn a question is
|
|
126
|
-
* dead is not always a moment we can make one — the user's return to the
|
|
127
|
-
* terminal is observed by a bare hook, and the machine may be offline.
|
|
128
|
-
* Dropping the ids there is how a delivered question becomes permanently
|
|
129
|
-
* unretirable, so they are parked here instead and every later hook with a
|
|
130
|
-
* client drains them. Retirement is idempotent, so a duplicate attempt
|
|
131
|
-
* costs nothing and a missed one costs a stale notification for ever.
|
|
132
|
-
*/
|
|
133
|
-
retiring?: RetiringQuestion[]
|
|
134
|
-
/**
|
|
135
|
-
* Tracks bounded Stop continuations so a follow-up ask is delivered once.
|
|
136
|
-
* `count` is how many answer generations have run consecutively without the
|
|
137
|
-
* user taking a turn themselves; their next prompt starts it over.
|
|
138
|
-
*/
|
|
139
|
-
continuation?: {
|
|
140
|
-
answered_at: number
|
|
141
|
-
count: number
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* A phone answer durably captured but not yet acknowledged. It stays here
|
|
145
|
-
* until a delivery is acknowledged — by the route's own write, or by the
|
|
146
|
-
* successor Stop of a blocking continuation — so a crash before the answer
|
|
147
|
-
* reaches the harness replays it instead of erasing it.
|
|
148
|
-
*/
|
|
149
|
-
accepted?: AcceptedAnswerDelivery
|
|
150
|
-
/**
|
|
151
|
-
* Required Agent Acknowledgements that the resumed agent still owes. This is
|
|
152
|
-
* separate from answer delivery: the answer journal may settle as soon as a
|
|
153
|
-
* harness accepts the continuation, while this obligation must survive until
|
|
154
|
-
* the service confirms the agent-authored follow-up exists.
|
|
155
|
-
*/
|
|
156
|
-
acknowledgement_due?: AcknowledgementDue[]
|
|
157
|
-
/** Consecutive turns this session has been held for an acknowledgement. */
|
|
158
|
-
acknowledgement_blocks?: number
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export interface AcknowledgementDue {
|
|
162
|
-
request_id: string
|
|
163
|
-
recorded_at: number
|
|
164
|
-
/**
|
|
165
|
-
* Whether this request's acknowledgement must carry text. Absent on state
|
|
166
|
-
* written before the obligation started recording it; treated as true, which
|
|
167
|
-
* is the default the service ships.
|
|
168
|
-
*/
|
|
169
|
-
text_required?: boolean
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
interface AcceptedAnswerDelivery {
|
|
173
|
-
answers: AnsweredPending[]
|
|
174
|
-
remaining: number
|
|
175
|
-
recorded_at: number
|
|
176
|
-
/**
|
|
177
|
-
* Epoch ms when a route's own write handed this answer to the harness, with
|
|
178
|
-
* the route that performed it. Recorded so that "this answer was delivered"
|
|
179
|
-
* is a fact on the journal rather than something inferred from a harness flag
|
|
180
|
-
* that only one route ever sets.
|
|
181
|
-
*/
|
|
182
|
-
delivered_at?: number
|
|
183
|
-
delivered_route?: string
|
|
184
|
-
/**
|
|
185
|
-
* How many times this answer has been handed to any route. The route-agnostic
|
|
186
|
-
* loop backstop: bounded by `MAX_CONTINUATION_COUNT` in one place every route
|
|
187
|
-
* passes through.
|
|
188
|
-
*/
|
|
189
|
-
delivery_attempts?: number
|
|
190
|
-
/**
|
|
191
|
-
* Linearization point between delivery and SessionEnd. Once recorded, the
|
|
192
|
-
* route began first; before it, SessionEnd cancellation wins.
|
|
193
|
-
*/
|
|
194
|
-
delivery_committed_at?: number
|
|
195
|
-
/** Turns this answer has been held without being handed to the agent. */
|
|
196
|
-
held_deliveries?: number
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** A delivered question awaiting its retirement push. */
|
|
200
|
-
export interface RetiringQuestion {
|
|
201
|
-
/** Stable local identity retained through remote retirement. */
|
|
202
|
-
question_id?: string
|
|
203
|
-
request_id: string
|
|
204
|
-
collapse_key: string
|
|
205
|
-
/** The Device Installations that actually received the question. */
|
|
206
|
-
device_ids: string[]
|
|
207
|
-
/** Shown if the companion has no history entry to correlate against. */
|
|
208
|
-
question: string
|
|
209
|
-
project?: string
|
|
210
|
-
source?: SourceContextT
|
|
211
|
-
state: LifecycleEndState
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* A retirement that outlived its session.
|
|
216
|
-
*
|
|
217
|
-
* Per-session parking assumes some later hook in the SAME session will hold a
|
|
218
|
-
* client, and `SessionEnd` is exactly where that assumption breaks: it may not
|
|
219
|
-
* touch the network, and no hook for that session ever fires again. Deleting
|
|
220
|
-
* the state there lost the only copy of the delivered question's ids, so the
|
|
221
|
-
* phone kept an answerable question nobody was listening to. These entries are
|
|
222
|
-
* moved to a machine-global queue instead, drained by whichever session's hook
|
|
223
|
-
* next holds a client.
|
|
224
|
-
*/
|
|
225
|
-
export interface OrphanRetirement extends RetiringQuestion {
|
|
226
|
-
/** Epoch ms when the entry was orphaned; entries beyond the TTL are dropped. */
|
|
227
|
-
enqueued_at: number
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* A retirement is only worth syncing while the question it retires could still
|
|
232
|
-
* be answered, so this has to outlive the longest reply window a user can
|
|
233
|
-
* choose — it is derived from that bound rather than from a guess about how
|
|
234
|
-
* long questions stay open, which is exactly the assumption that broke when
|
|
235
|
-
* the window stopped being one hour. Past it the companion shows the question
|
|
236
|
-
* as dead on next open anyway. Also the backstop that keeps an unreachable
|
|
237
|
-
* server from growing the queue for ever.
|
|
238
|
-
*/
|
|
239
|
-
const ORPHAN_TTL_MS = (REPLY_MAX_WINDOW_SECONDS + 3600) * 1000
|
|
240
|
-
|
|
241
|
-
/** More orphans than this means something is looping; keep the newest. */
|
|
242
|
-
const ORPHAN_QUEUE_CAP = 50
|
|
243
|
-
|
|
244
|
-
export interface PendingQuestion {
|
|
245
|
-
/** Stable local identity across racing state writers and submit recovery. */
|
|
246
|
-
question_id?: string
|
|
247
|
-
/** One-line summary: the single question's text, or the set's first. */
|
|
248
|
-
question: string
|
|
249
|
-
/**
|
|
250
|
-
* Epoch ms when `notifai ask` registered this. The grace window runs from
|
|
251
|
-
* here, not from the turn's end: a question the agent asked five minutes ago
|
|
252
|
-
* while it kept working has already served its wait in the terminal.
|
|
253
|
-
*/
|
|
254
|
-
asked_at?: number
|
|
255
|
-
/**
|
|
256
|
-
* The full question set as `notifai ask` validated it: generated ids,
|
|
257
|
-
* texts, choices, multi flags. What actually rides the push.
|
|
258
|
-
*/
|
|
259
|
-
questions?: QuestionT[]
|
|
260
|
-
/** Canonical Markdown body composed when the question was registered. */
|
|
261
|
-
body?: string
|
|
262
|
-
/** Final ordered media collection; uploads complete before registration. */
|
|
263
|
-
media?: MediaItemT[]
|
|
264
|
-
/** Final Project and Source Context frozen at registration. */
|
|
265
|
-
project?: string
|
|
266
|
-
source?: SourceContextT
|
|
267
|
-
/** Set once the question has actually been pushed, so it can be retired. */
|
|
268
|
-
request_id?: string
|
|
269
|
-
collapse_key?: string
|
|
270
|
-
/** Exact fanout of the live question; routing config may change afterwards. */
|
|
271
|
-
device_ids?: string[]
|
|
272
|
-
/** Absolute end of the server reply window. */
|
|
273
|
-
reply_deadline_at?: number
|
|
274
|
-
/**
|
|
275
|
-
* Absolute process-owner deadline. It begins before submission and includes
|
|
276
|
-
* startup headroom, so it must be later than `reply_deadline_at`. Absent on
|
|
277
|
-
* state written before ownership was persisted — treated as already spent
|
|
278
|
-
* instead of inventing a fresh multi-day claim.
|
|
279
|
-
*/
|
|
280
|
-
owner_deadline_at?: number
|
|
281
|
-
/** Frozen before the first network byte, so an ambiguous submit is replayable. */
|
|
282
|
-
submission?: PendingSubmissionIntent
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
export type QuestionDeliveryState =
|
|
286
|
-
| 'local'
|
|
287
|
-
| 'frozen'
|
|
288
|
-
| 'live'
|
|
289
|
-
| 'answered'
|
|
290
|
-
| 'withdrawn'
|
|
291
|
-
| 'retired'
|
|
292
|
-
|
|
293
|
-
type QuestionTerminalState = Extract<QuestionDeliveryState, 'answered' | 'withdrawn' | 'retired'>
|
|
294
|
-
|
|
295
|
-
export interface QuestionHistoryEntry {
|
|
296
|
-
question_id: string
|
|
297
|
-
state: QuestionTerminalState
|
|
298
|
-
/** Present only after local promotion proves the Notification Request exists. */
|
|
299
|
-
request_id?: string
|
|
300
|
-
/** Reserved idempotent identity that was never locally promoted. */
|
|
301
|
-
frozen_request_id?: string
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
export interface QuestionStateView {
|
|
305
|
-
question_id: string
|
|
306
|
-
state: QuestionDeliveryState
|
|
307
|
-
/** Null means a frozen submission may or may not have crossed the service boundary. */
|
|
308
|
-
submitted: boolean | null
|
|
309
|
-
request_id: string | null
|
|
310
|
-
frozen_request_id: string | null
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
export type QuestionStateLookup =
|
|
314
|
-
| { found: true; session_id: string; question: QuestionStateView }
|
|
315
|
-
| { found: false; ambiguous: boolean }
|
|
316
|
-
|
|
317
|
-
/** Keep immutable session grouping while replacing per-event Git location authoritatively. */
|
|
318
|
-
function sourceContextAtHookEvent(
|
|
319
|
-
source: SourceContextT | undefined,
|
|
320
|
-
cwd: string | undefined,
|
|
321
|
-
): SourceContextT | undefined {
|
|
322
|
-
const invocation = cwd === undefined ? null : inferInvocationContext(cwd)
|
|
323
|
-
const current: SourceContextT = {
|
|
324
|
-
...(source?.session_id !== undefined ? { session_id: source.session_id } : {}),
|
|
325
|
-
...(source?.session_label !== undefined ? { session_label: source.session_label } : {}),
|
|
326
|
-
...(source?.harness !== undefined ? { harness: source.harness } : {}),
|
|
327
|
-
...(invocation?.branch !== undefined ? { branch: invocation.branch } : {}),
|
|
328
|
-
...(invocation?.worktree !== undefined ? { worktree: invocation.worktree } : {}),
|
|
329
|
-
}
|
|
330
|
-
return Object.keys(current).length === 0 ? undefined : current
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
interface PendingSubmissionIntent {
|
|
334
|
-
request_id: string
|
|
335
|
-
idempotency_key: string
|
|
336
|
-
collapse_key: string
|
|
337
|
-
device_ids: string[]
|
|
338
|
-
draft: NotificationDraftT
|
|
339
|
-
owner_deadline_at: number
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function summarizeRequestIds(entries: readonly PendingQuestion[]): {
|
|
343
|
-
ids: string[]
|
|
344
|
-
display: string
|
|
345
|
-
} {
|
|
346
|
-
const ids = entries.flatMap((entry) =>
|
|
347
|
-
entry.request_id === undefined ? [] : [entry.request_id],
|
|
348
|
-
)
|
|
349
|
-
return { ids, display: ids.join(', ') }
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* The wire contract's shortest reply window. A question submitted with less
|
|
354
|
-
* than this is rejected outright, and accepting one would in any case let the
|
|
355
|
-
* server go on taking an answer after the waiter that owns it has returned.
|
|
356
|
-
*/
|
|
357
|
-
export const MIN_REPLY_WINDOW_SECONDS = 60
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* The terminal-first wait: the question sits in the terminal for
|
|
361
|
-
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
362
|
-
* devices.
|
|
363
|
-
*
|
|
364
|
-
* A plain timer, and nothing else. It once polled an idle signal so it could
|
|
365
|
-
* abandon the wait the moment the user touched the keyboard — necessary while
|
|
366
|
-
* the wait blocked the terminal, because a user wanting to answer locally was
|
|
367
|
-
* otherwise locked out of their own prompt. Presence no longer changes this
|
|
368
|
-
* deterministic timer; whether the harness holds or wakes is a separate route
|
|
369
|
-
* decision.
|
|
370
|
-
*
|
|
371
|
-
* The window is measured from registration, not from the turn's end: a
|
|
372
|
-
* question the agent asked five minutes ago while it kept working has already
|
|
373
|
-
* served its wait. A stamp from the future would otherwise consume the whole
|
|
374
|
-
* ceiling and leave no room to answer, so it starts no later than now.
|
|
375
|
-
*/
|
|
376
|
-
async function awaitTerminalFirstWindow(
|
|
377
|
-
ctx: HookContext,
|
|
378
|
-
askedAt: number,
|
|
379
|
-
ceilingAt: number,
|
|
380
|
-
): Promise<void> {
|
|
381
|
-
const graceSeconds = ctx.config.ask_grace_seconds.value
|
|
382
|
-
if (graceSeconds <= 0) return
|
|
383
|
-
const until = Math.min(Math.min(askedAt, ctx.now()) + graceSeconds * 1000, ceilingAt)
|
|
384
|
-
const remaining = until - ctx.now()
|
|
385
|
-
if (remaining > 0) await ctx.sleep(remaining)
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
export interface HookOutcome {
|
|
389
|
-
/** Written to stdout verbatim — the harness parses this as output. */
|
|
390
|
-
stdout?: string
|
|
391
|
-
/** Commit a blocking continuation immediately before the harness stdout write. */
|
|
392
|
-
commitStdout?: () => boolean
|
|
393
|
-
/** Whether stdout takes over the turn, rather than adding prompt context. */
|
|
394
|
-
decided?: boolean
|
|
395
|
-
/** Diagnostics; harnesses surface hook stderr in the transcript. */
|
|
396
|
-
notes: string[]
|
|
397
|
-
/** Structured lifecycle detail that belongs on hook.end without user text. */
|
|
398
|
-
log?: Record<string, unknown>
|
|
399
|
-
/** An unpushed registration survived UserPromptSubmit and needs its own owner. */
|
|
400
|
-
settlementRequired?: boolean
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/** An accepted continuation ready for whichever host owns the last meter. */
|
|
404
|
-
export interface ContinuationEvent {
|
|
405
|
-
context: string
|
|
406
|
-
answers: number
|
|
407
|
-
remaining: number
|
|
408
|
-
request_ids: string[]
|
|
409
|
-
journal_recorded_at: number
|
|
410
|
-
/**
|
|
411
|
-
* Must be called immediately before the route's irreversible harness write.
|
|
412
|
-
* It atomically orders that write against SessionEnd; false means cancellation
|
|
413
|
-
* won and the route must hand nothing over.
|
|
414
|
-
*/
|
|
415
|
-
commitDelivery(): boolean
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* How much a route's own return proves about where the answer ended up.
|
|
420
|
-
*
|
|
421
|
-
* Acknowledgement belongs to the delivery, not to the harness envelope: routes
|
|
422
|
-
* end in different places, so no single field on a hook payload can speak for
|
|
423
|
-
* all of them. `stop_hook_active` is the harness confirming that a *blocking
|
|
424
|
-
* continuation* was admitted; it stays false for ever on a route that starts a
|
|
425
|
-
* brand-new turn instead of continuing this one, so a journal keyed to it alone
|
|
426
|
-
* never settles and every later turn-end redelivers the same answer.
|
|
427
|
-
*
|
|
428
|
-
* - `delivered` — the route completed a write to the harness itself (an inbox
|
|
429
|
-
* socket, a cold resume). Delivery is not consumption: the write proves the
|
|
430
|
-
* harness accepted the message, never that the model acted on it. But nothing
|
|
431
|
-
* later will ever prove more, and redelivering an answer without end is
|
|
432
|
-
* strictly worse than settling on the write, so the journal settles here.
|
|
433
|
-
* - `stdout` — the answer is this process's stdout, which the harness reads only
|
|
434
|
-
* after this process exits. Nothing this process can write is proof, so the
|
|
435
|
-
* journal waits for the successor Stop's `stop_hook_active`, and a crash
|
|
436
|
-
* before stdout replays the answer instead of losing it.
|
|
437
|
-
* - `held` — the route handed nothing over; the journal replays the answer.
|
|
438
|
-
*/
|
|
439
|
-
export type DeliveryAcknowledgement = 'delivered' | 'stdout' | 'held'
|
|
440
|
-
|
|
441
|
-
/** What a route returns: the hook's outcome plus what the attempt proved. */
|
|
442
|
-
export interface DeliveryOutcome {
|
|
443
|
-
stdout?: string
|
|
444
|
-
/** Deferred SessionEnd fence for a blocking stdout continuation. */
|
|
445
|
-
commitStdout?: () => boolean
|
|
446
|
-
notes?: string[]
|
|
447
|
-
log?: Record<string, unknown>
|
|
448
|
-
acknowledgement: DeliveryAcknowledgement
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/** Host adapter injected into the waiter; no route is implemented by the waiter. */
|
|
452
|
-
export interface EscalationDeliveryRoute {
|
|
453
|
-
kind: Exclude<DeliveryRoute, 'unsupported'>
|
|
454
|
-
deliver(event: ContinuationEvent): Promise<DeliveryOutcome>
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
export interface EscalationWaiterOptions {
|
|
458
|
-
sessionId: string
|
|
459
|
-
envelope: HookEnvelope
|
|
460
|
-
route: EscalationDeliveryRoute
|
|
461
|
-
processDeadlineAt?: number
|
|
462
|
-
/** Stop owns diagnostics; a pre-Stop recovery owner must not impersonate it. */
|
|
463
|
-
recordStop?: boolean
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
467
|
-
return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`)
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* Durable cancellation for observers that can outlive the harness process.
|
|
472
|
-
*
|
|
473
|
-
* Absence of session state is not enough: SessionEnd deliberately deletes it,
|
|
474
|
-
* and an in-flight submit/fence could otherwise recreate it afterwards. The
|
|
475
|
-
* marker shares the session-state lock, giving every racing writer one total
|
|
476
|
-
* order: either its state lands before SessionEnd and is cleaned up there, or
|
|
477
|
-
* it observes this marker and must not write or deliver into the ended session.
|
|
478
|
-
*/
|
|
479
|
-
function sessionEndMarkerPath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
480
|
-
return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.ended`)
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function sessionHasEnded(sessionId: string, env: NodeJS.ProcessEnv): boolean {
|
|
484
|
-
return existsSync(sessionEndMarkerPath(sessionId, env))
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function markSessionEnded(sessionId: string, env: NodeJS.ProcessEnv, now: number): void {
|
|
488
|
-
const stateFile = sessionStatePath(sessionId, env)
|
|
489
|
-
withFileLock(`${stateFile}.lock`, () => {
|
|
490
|
-
atomicWriteFileSync(sessionEndMarkerPath(sessionId, env), `${now}\n`)
|
|
491
|
-
})
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function clearSessionEndMarker(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
495
|
-
const stateFile = sessionStatePath(sessionId, env)
|
|
496
|
-
withFileLock(`${stateFile}.lock`, () => {
|
|
497
|
-
rmSync(sessionEndMarkerPath(sessionId, env), { force: true })
|
|
498
|
-
})
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
export function readSessionState(sessionId: string, env: NodeJS.ProcessEnv): SessionState {
|
|
502
|
-
const file = sessionStatePath(sessionId, env)
|
|
503
|
-
if (!existsSync(file)) return {}
|
|
504
|
-
try {
|
|
505
|
-
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
|
|
506
|
-
if (typeof parsed !== 'object' || parsed === null) return {}
|
|
507
|
-
const state = { ...(parsed as SessionState & { session_id?: unknown }) }
|
|
508
|
-
delete state.session_id
|
|
509
|
-
return state
|
|
510
|
-
} catch {
|
|
511
|
-
// A corrupt marker must not wedge the harness; treat it as "no evidence",
|
|
512
|
-
// which fails closed onto ordinary terminal behaviour.
|
|
513
|
-
return {}
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Resolve a stable question or request id to the one exact session that owns it.
|
|
519
|
-
*
|
|
520
|
-
* This deliberately searches lifecycle state, not checkout pointers: question
|
|
521
|
-
* identity is global on this machine, while a linked worktree is only where a
|
|
522
|
-
* command happened to run. Ambiguous or corrupt matches fail closed.
|
|
523
|
-
*/
|
|
524
|
-
export function findOwningSession(
|
|
525
|
-
id: string,
|
|
526
|
-
env: NodeJS.ProcessEnv,
|
|
527
|
-
): { sessionId: string | null; ambiguous: boolean } {
|
|
528
|
-
const directory = path.join(stateDir(env), 'sessions')
|
|
529
|
-
if (!existsSync(directory)) return { sessionId: null, ambiguous: false }
|
|
530
|
-
const matches: string[] = []
|
|
531
|
-
for (const name of readdirSync(directory)) {
|
|
532
|
-
if (!name.endsWith('.json')) continue
|
|
533
|
-
let persisted: (SessionState & { session_id?: unknown }) | null = null
|
|
534
|
-
try {
|
|
535
|
-
const parsed: unknown = JSON.parse(readFileSync(path.join(directory, name), 'utf8'))
|
|
536
|
-
if (typeof parsed === 'object' && parsed !== null) {
|
|
537
|
-
persisted = parsed as SessionState & { session_id?: unknown }
|
|
538
|
-
}
|
|
539
|
-
} catch {
|
|
540
|
-
// Corrupt lifecycle state is not identity evidence.
|
|
541
|
-
}
|
|
542
|
-
if (persisted === null || typeof persisted.session_id !== 'string') continue
|
|
543
|
-
const sessionId = persisted.session_id
|
|
544
|
-
const state = persisted
|
|
545
|
-
const pendingMatch = pendingList(state).some(
|
|
546
|
-
(entry) => entry.question_id === id || entry.request_id === id,
|
|
547
|
-
)
|
|
548
|
-
const retiringMatch = (state.retiring ?? []).some(
|
|
549
|
-
(entry) => entry.question_id === id || entry.request_id === id,
|
|
550
|
-
)
|
|
551
|
-
const historyMatch = (state.question_history ?? []).some(
|
|
552
|
-
(entry) => entry.question_id === id || entry.request_id === id,
|
|
553
|
-
)
|
|
554
|
-
const acknowledgementMatch = (state.acknowledgement_due ?? []).some(
|
|
555
|
-
(entry) => entry.request_id === id,
|
|
556
|
-
)
|
|
557
|
-
const acceptedMatch = state.accepted?.answers.some(
|
|
558
|
-
({ pending }) => pending.question_id === id || pending.request_id === id,
|
|
559
|
-
) ?? false
|
|
560
|
-
if (pendingMatch || retiringMatch || historyMatch || acknowledgementMatch || acceptedMatch) {
|
|
561
|
-
matches.push(sessionId)
|
|
562
|
-
if (matches.length > 1) return { sessionId: null, ambiguous: true }
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
return { sessionId: matches[0] ?? null, ambiguous: false }
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
export function writeSessionState(
|
|
569
|
-
sessionId: string,
|
|
570
|
-
env: NodeJS.ProcessEnv,
|
|
571
|
-
state: SessionState,
|
|
572
|
-
): void {
|
|
573
|
-
const file = sessionStatePath(sessionId, env)
|
|
574
|
-
withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, sessionId, state))
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
export function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
578
|
-
const file = sessionStatePath(sessionId, env)
|
|
579
|
-
withFileLock(`${file}.lock`, () => {
|
|
580
|
-
rmSync(file, { force: true })
|
|
581
|
-
// The session override lives in a sibling file; leaving it behind meant a
|
|
582
|
-
// later session reusing the id silently inherited `ask_notifications = false`.
|
|
583
|
-
rmSync(sessionConfigPath(sessionId, env), { force: true })
|
|
584
|
-
})
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
function writeSessionStateUnlocked(file: string, sessionId: string, state: SessionState): void {
|
|
588
|
-
atomicWriteFileSync(file, `${JSON.stringify({ ...state, session_id: sessionId }, null, 2)}\n`)
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function updateSessionState(
|
|
592
|
-
sessionId: string,
|
|
593
|
-
env: NodeJS.ProcessEnv,
|
|
594
|
-
update: (current: SessionState) => SessionState,
|
|
595
|
-
): SessionState {
|
|
596
|
-
const file = sessionStatePath(sessionId, env)
|
|
597
|
-
return withFileLock(`${file}.lock`, () => {
|
|
598
|
-
const current = readSessionState(sessionId, env)
|
|
599
|
-
// SessionEnd and every asynchronous writer share this lock. Once the
|
|
600
|
-
// durable marker exists, no observer may recreate state for that session.
|
|
601
|
-
if (sessionHasEnded(sessionId, env)) return current
|
|
602
|
-
const next = update(current)
|
|
603
|
-
writeSessionStateUnlocked(file, sessionId, next)
|
|
604
|
-
return next
|
|
605
|
-
})
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/** Record the current installation when an owner session starts. */
|
|
609
|
-
export function recordSessionStart(
|
|
610
|
-
sessionId: string,
|
|
611
|
-
env: NodeJS.ProcessEnv,
|
|
612
|
-
harness?: HookHarness,
|
|
613
|
-
cwd?: string,
|
|
614
|
-
): void {
|
|
615
|
-
// Harnesses may reuse a session id only by explicitly starting that session
|
|
616
|
-
// again. That lifecycle edge is the sole authority for clearing cancellation.
|
|
617
|
-
clearSessionEndMarker(sessionId, env)
|
|
618
|
-
updateSessionState(sessionId, env, (current) => ({
|
|
619
|
-
...current,
|
|
620
|
-
...(harness === undefined ? {} : { harness }),
|
|
621
|
-
...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
|
|
622
|
-
}))
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
|
|
626
|
-
export function claimCursorStopActivation(
|
|
627
|
-
sessionId: string,
|
|
628
|
-
env: NodeJS.ProcessEnv,
|
|
629
|
-
now: number,
|
|
630
|
-
): boolean {
|
|
631
|
-
const claimGuardMs = 30_000
|
|
632
|
-
let claimed = false
|
|
633
|
-
updateSessionState(sessionId, env, (current) => {
|
|
634
|
-
if (current.cursor_activation_confirmed_at !== undefined) return current
|
|
635
|
-
if (
|
|
636
|
-
current.cursor_activation_claimed_at !== undefined &&
|
|
637
|
-
now - current.cursor_activation_claimed_at < claimGuardMs
|
|
638
|
-
) {
|
|
639
|
-
return current
|
|
640
|
-
}
|
|
641
|
-
claimed = true
|
|
642
|
-
return { ...current, cursor_activation_claimed_at: now }
|
|
643
|
-
})
|
|
644
|
-
return claimed
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/** Cursor's loop_count proves the previous Stop follow-up reached the host. */
|
|
648
|
-
export function confirmCursorStopActivation(
|
|
649
|
-
sessionId: string,
|
|
650
|
-
env: NodeJS.ProcessEnv,
|
|
651
|
-
now: number,
|
|
652
|
-
): boolean {
|
|
653
|
-
let owned = false
|
|
654
|
-
updateSessionState(sessionId, env, (current) => {
|
|
655
|
-
if (current.cursor_activation_confirmed_at !== undefined) {
|
|
656
|
-
owned = true
|
|
657
|
-
return current
|
|
658
|
-
}
|
|
659
|
-
if (current.cursor_activation_claimed_at === undefined) return current
|
|
660
|
-
owned = true
|
|
661
|
-
return { ...current, cursor_activation_confirmed_at: now }
|
|
662
|
-
})
|
|
663
|
-
return owned
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/** A fresh/cleared Cursor lifecycle makes its next Stop the fallback owner again. */
|
|
667
|
-
export function resetCursorStopActivation(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
668
|
-
updateSessionState(sessionId, env, (current) => {
|
|
669
|
-
if (
|
|
670
|
-
current.cursor_activation_claimed_at === undefined &&
|
|
671
|
-
current.cursor_activation_confirmed_at === undefined
|
|
672
|
-
) {
|
|
673
|
-
return current
|
|
674
|
-
}
|
|
675
|
-
const next = { ...current }
|
|
676
|
-
delete next.cursor_activation_claimed_at
|
|
677
|
-
delete next.cursor_activation_confirmed_at
|
|
678
|
-
return next
|
|
679
|
-
})
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
/**
|
|
683
|
-
* Session state a crashed harness left behind.
|
|
684
|
-
*
|
|
685
|
-
* `SessionEnd` removes session state and leaves a short-lived cancellation
|
|
686
|
-
* marker; a harness that crashes or is killed never reaches it. At roughly a
|
|
687
|
-
* hundred sessions a day that is tens of thousands of files a year, none of
|
|
688
|
-
* which anything reads.
|
|
689
|
-
*
|
|
690
|
-
* Opportunistic rather than scheduled: hooks are the only thing that runs, so
|
|
691
|
-
* a hook is where this has to live. It is rate-limited by its own stamp file
|
|
692
|
-
* so the common case costs one `stat`, not a directory walk — a Stop hook is
|
|
693
|
-
* on the critical path of every turn.
|
|
694
|
-
*/
|
|
695
|
-
const SESSION_PRUNE_AFTER_MS = 7 * 24 * 3600 * 1000
|
|
696
|
-
const SESSION_PRUNE_INTERVAL_MS = 24 * 3600 * 1000
|
|
697
|
-
|
|
698
|
-
export function pruneAbandonedSessions(
|
|
699
|
-
env: NodeJS.ProcessEnv,
|
|
700
|
-
now: number = Date.now(),
|
|
701
|
-
maxAgeMs: number = SESSION_PRUNE_AFTER_MS,
|
|
702
|
-
): number {
|
|
703
|
-
const directory = path.join(stateDir(env), 'sessions')
|
|
704
|
-
const stamp = path.join(stateDir(env), 'last-prune')
|
|
705
|
-
try {
|
|
706
|
-
if (existsSync(stamp) && now - statSync(stamp).mtimeMs < SESSION_PRUNE_INTERVAL_MS) return 0
|
|
707
|
-
mkdirSync(path.dirname(stamp), { recursive: true })
|
|
708
|
-
writeFileSync(stamp, '', { mode: 0o600 })
|
|
709
|
-
if (!existsSync(directory)) return 0
|
|
710
|
-
|
|
711
|
-
let removed = 0
|
|
712
|
-
for (const name of readdirSync(directory)) {
|
|
713
|
-
const file = path.join(directory, name)
|
|
714
|
-
try {
|
|
715
|
-
const age = now - statSync(file).mtimeMs
|
|
716
|
-
// A negative age means the clock moved, not that the file is old.
|
|
717
|
-
// Deleting live session state on an NTP correction would lose a
|
|
718
|
-
// question already on the user's phone.
|
|
719
|
-
if (age <= maxAgeMs) continue
|
|
720
|
-
rmSync(file, { force: true })
|
|
721
|
-
removed += 1
|
|
722
|
-
} catch {
|
|
723
|
-
// A file that vanished under us, or one we may not read. Neither is
|
|
724
|
-
// worth failing a hook for, and the next pass will see it again.
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
return removed
|
|
728
|
-
} catch {
|
|
729
|
-
// Housekeeping must never be the reason a turn fails.
|
|
730
|
-
return 0
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
/**
|
|
735
|
-
* One question, one push, even with two Stop hooks racing.
|
|
736
|
-
*
|
|
737
|
-
* Path-independent hook ownership stops the *usual* cause of two handlers
|
|
738
|
-
* firing, but it cannot stop every one — two harnesses in one directory, or an
|
|
739
|
-
* install this build does not recognise. Both processes would read the same
|
|
740
|
-
* pending question, see no `request_id`, and both escalate: one question, two
|
|
741
|
-
* notifications.
|
|
742
|
-
*
|
|
743
|
-
* Exclusive create is atomic on POSIX, so exactly one process gets the claim
|
|
744
|
-
* and the other steps aside. A short guard serializes stale replacement with
|
|
745
|
-
* contenders and releases; random ownership tokens keep an old holder from
|
|
746
|
-
* unlinking its replacement. A live PID owns the claim regardless of age; a
|
|
747
|
-
* known-dead PID is recoverable immediately. Only legacy/corrupt claims with
|
|
748
|
-
* no trustworthy PID fall back to an age limit.
|
|
749
|
-
*/
|
|
750
|
-
const CLAIM_TTL_MS = LEGACY_QUESTION_CLAIM_TTL_SECONDS * 1000
|
|
751
|
-
|
|
752
|
-
/**
|
|
753
|
-
* How long a crashed claim *guard* blocks the next hook.
|
|
754
|
-
*
|
|
755
|
-
* The claim itself may legitimately be held for the whole waiter, so it takes
|
|
756
|
-
* that ceiling. The guard is a short lock around the claim's own bookkeeping —
|
|
757
|
-
* a few file operations — and borrowing the waiter's ceiling for it meant a
|
|
758
|
-
* hook killed at exactly the wrong moment wedged this session's Stop path for
|
|
759
|
-
* eight minutes, for a critical section that never runs longer than a second.
|
|
760
|
-
*/
|
|
761
|
-
const CLAIM_GUARD_TTL_MS = 30_000
|
|
762
|
-
const heldClaims = new Map<string, string>()
|
|
763
|
-
|
|
764
|
-
export function claimQuestionPush(
|
|
765
|
-
sessionId: string,
|
|
766
|
-
env: NodeJS.ProcessEnv,
|
|
767
|
-
now: number = Date.now(),
|
|
768
|
-
beforeStaleReplace?: () => void,
|
|
769
|
-
ownerDeadlineAt?: number,
|
|
770
|
-
): boolean {
|
|
771
|
-
const file = claimPath(sessionId, env)
|
|
772
|
-
const guard = `${file}.guard`
|
|
773
|
-
const token = randomBytes(12).toString('base64url')
|
|
774
|
-
const pendingQuestionIds = pendingList(readSessionState(sessionId, env)).map(
|
|
775
|
-
claimQuestionIdentity,
|
|
776
|
-
)
|
|
777
|
-
mkdirSync(path.dirname(file), { recursive: true })
|
|
778
|
-
if (!acquireClaimGuard(guard)) return false
|
|
779
|
-
try {
|
|
780
|
-
try {
|
|
781
|
-
writeFileSync(
|
|
782
|
-
file,
|
|
783
|
-
`${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`,
|
|
784
|
-
{
|
|
785
|
-
mode: 0o600,
|
|
786
|
-
flag: 'wx',
|
|
787
|
-
},
|
|
788
|
-
)
|
|
789
|
-
heldClaims.set(file, token)
|
|
790
|
-
return true
|
|
791
|
-
} catch {
|
|
792
|
-
// Held. Break it only if whoever holds it cannot still be running.
|
|
793
|
-
try {
|
|
794
|
-
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown; pid?: unknown }
|
|
795
|
-
const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
|
|
796
|
-
if (typeof held.pid === 'number') {
|
|
797
|
-
if (processIsAlive(held.pid)) return false
|
|
798
|
-
} else if (age >= 0 && age < CLAIM_TTL_MS) {
|
|
799
|
-
return false
|
|
800
|
-
}
|
|
801
|
-
} catch {
|
|
802
|
-
// Unreadable or corrupt: treat as abandoned.
|
|
803
|
-
}
|
|
804
|
-
beforeStaleReplace?.()
|
|
805
|
-
rmSync(file, { force: true })
|
|
806
|
-
try {
|
|
807
|
-
writeFileSync(
|
|
808
|
-
file,
|
|
809
|
-
`${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`,
|
|
810
|
-
{
|
|
811
|
-
mode: 0o600,
|
|
812
|
-
flag: 'wx',
|
|
813
|
-
},
|
|
814
|
-
)
|
|
815
|
-
heldClaims.set(file, token)
|
|
816
|
-
return true
|
|
817
|
-
} catch {
|
|
818
|
-
return false
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
} finally {
|
|
822
|
-
rmSync(guard, { force: true })
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
function claimQuestionIdentity(entry: PendingQuestion): string {
|
|
827
|
-
return entry.question_id ?? `${entry.asked_at ?? 'legacy'}\u0000${entry.question}`
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
/** True only when the live owner could not have snapshotted this unpushed ask. */
|
|
831
|
-
function claimHandoffState(
|
|
832
|
-
sessionId: string,
|
|
833
|
-
env: NodeJS.ProcessEnv,
|
|
834
|
-
): { hasNewQuestion: boolean; ownerDeadlineAt?: number } {
|
|
835
|
-
const unasked = pendingList(readSessionState(sessionId, env)).filter(
|
|
836
|
-
(entry) => entry.request_id === undefined,
|
|
837
|
-
)
|
|
838
|
-
if (unasked.length === 0) return { hasNewQuestion: false }
|
|
839
|
-
try {
|
|
840
|
-
const held = JSON.parse(readFileSync(claimPath(sessionId, env), 'utf8')) as {
|
|
841
|
-
pending_question_ids?: unknown
|
|
842
|
-
owner_deadline_at?: unknown
|
|
843
|
-
}
|
|
844
|
-
if (!Array.isArray(held.pending_question_ids)) return { hasNewQuestion: false }
|
|
845
|
-
const snapshotted = new Set(
|
|
846
|
-
held.pending_question_ids.filter((entry): entry is string => typeof entry === 'string'),
|
|
847
|
-
)
|
|
848
|
-
return {
|
|
849
|
-
hasNewQuestion: unasked.some(
|
|
850
|
-
(entry) => !snapshotted.has(claimQuestionIdentity(entry)),
|
|
851
|
-
),
|
|
852
|
-
...(typeof held.owner_deadline_at === 'number' &&
|
|
853
|
-
Number.isFinite(held.owner_deadline_at)
|
|
854
|
-
? { ownerDeadlineAt: held.owner_deadline_at }
|
|
855
|
-
: {}),
|
|
856
|
-
}
|
|
857
|
-
} catch {
|
|
858
|
-
return { hasNewQuestion: false }
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
function processIsAlive(pid: number): boolean {
|
|
863
|
-
try {
|
|
864
|
-
process.kill(pid, 0)
|
|
865
|
-
return true
|
|
866
|
-
} catch (err) {
|
|
867
|
-
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
export function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
872
|
-
const file = claimPath(sessionId, env)
|
|
873
|
-
const token = heldClaims.get(file)
|
|
874
|
-
if (token === undefined) return
|
|
875
|
-
const guard = `${file}.guard`
|
|
876
|
-
if (!acquireClaimGuard(guard)) {
|
|
877
|
-
heldClaims.delete(file)
|
|
878
|
-
return
|
|
879
|
-
}
|
|
880
|
-
try {
|
|
881
|
-
try {
|
|
882
|
-
const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
|
|
883
|
-
if (held.token === token) rmSync(file, { force: true })
|
|
884
|
-
} catch {
|
|
885
|
-
// Missing or replaced claims are not ours to release.
|
|
886
|
-
}
|
|
887
|
-
} finally {
|
|
888
|
-
heldClaims.delete(file)
|
|
889
|
-
rmSync(guard, { force: true })
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
/** The guard serializes stale replacement and release; recover a crashed guard. */
|
|
894
|
-
function acquireClaimGuard(file: string): boolean {
|
|
895
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
896
|
-
try {
|
|
897
|
-
const handle = openSync(file, 'wx', 0o600)
|
|
898
|
-
closeSync(handle)
|
|
899
|
-
return true
|
|
900
|
-
} catch (err) {
|
|
901
|
-
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false
|
|
902
|
-
try {
|
|
903
|
-
if (Date.now() - statSync(file).mtimeMs < CLAIM_GUARD_TTL_MS) return false
|
|
904
|
-
} catch {
|
|
905
|
-
// A vanished/corrupt guard is safe to retry once.
|
|
906
|
-
}
|
|
907
|
-
rmSync(file, { force: true })
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
return false
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
914
|
-
return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.claim`)
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
/**
|
|
918
|
-
* A question is stored so a later hook can push it, and it reaches us from a
|
|
919
|
-
* shell command, so its size is whatever the agent typed. The push itself is
|
|
920
|
-
* bounded by the current 4096-byte push-provider envelopes; this bounds what
|
|
921
|
-
* sits on disk in the meantime, and keeps one runaway agent from writing
|
|
922
|
-
* megabytes per session.
|
|
923
|
-
*/
|
|
924
|
-
const MAX_STORED_QUESTION_CHARS = 2000
|
|
925
|
-
|
|
926
|
-
interface StoredProjectSessionPointer extends ProjectSessionPointer {
|
|
927
|
-
updatedAt: number
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
interface StoredProjectSessionDocument {
|
|
931
|
-
root: Record<string, unknown>
|
|
932
|
-
sessions: unknown[]
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
export interface ProjectSessionPointer {
|
|
936
|
-
sessionId: string
|
|
937
|
-
harness: HookHarness
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
function storedProjectSessionPointer(candidate: unknown): StoredProjectSessionPointer | null {
|
|
941
|
-
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return null
|
|
942
|
-
const entry = candidate as {
|
|
943
|
-
session_id?: unknown
|
|
944
|
-
updated_at?: unknown
|
|
945
|
-
harness?: unknown
|
|
946
|
-
}
|
|
947
|
-
if (typeof entry.session_id !== 'string' || entry.session_id === '') return null
|
|
948
|
-
if (typeof entry.updated_at !== 'number') return null
|
|
949
|
-
if (!(HOOK_INSTALLABLE_HARNESSES as readonly unknown[]).includes(entry.harness)) return null
|
|
950
|
-
return {
|
|
951
|
-
sessionId: entry.session_id,
|
|
952
|
-
updatedAt: entry.updated_at,
|
|
953
|
-
harness: entry.harness as HookHarness,
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
function readStoredProjectSessionDocument(file: string): StoredProjectSessionDocument {
|
|
958
|
-
if (!existsSync(file)) return { root: {}, sessions: [] }
|
|
959
|
-
try {
|
|
960
|
-
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
|
|
961
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
962
|
-
return { root: {}, sessions: [] }
|
|
963
|
-
}
|
|
964
|
-
const root = parsed as Record<string, unknown>
|
|
965
|
-
return {
|
|
966
|
-
root,
|
|
967
|
-
sessions: Array.isArray(root['sessions']) ? root['sessions'] : [],
|
|
968
|
-
}
|
|
969
|
-
} catch {
|
|
970
|
-
return { root: {}, sessions: [] }
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
/** Records every live session working in a directory, for `notifai ask`. */
|
|
975
|
-
export function writeProjectSession(
|
|
976
|
-
cwd: string,
|
|
977
|
-
env: NodeJS.ProcessEnv,
|
|
978
|
-
sessionId: string,
|
|
979
|
-
now: number,
|
|
980
|
-
harness: HookHarness,
|
|
981
|
-
): void {
|
|
982
|
-
const file = projectSessionPointerPath(cwd, env)
|
|
983
|
-
withFileLock(`${file}.lock`, () => {
|
|
984
|
-
const stored = readStoredProjectSessionDocument(file)
|
|
985
|
-
const existing: unknown[] = []
|
|
986
|
-
let refreshed: Record<string, unknown> = {}
|
|
987
|
-
for (const candidate of stored.sessions) {
|
|
988
|
-
const entry = storedProjectSessionPointer(candidate)
|
|
989
|
-
if (entry?.sessionId === sessionId) {
|
|
990
|
-
refreshed = { ...refreshed, ...(candidate as Record<string, unknown>) }
|
|
991
|
-
} else if (entry === null || now - entry.updatedAt <= 24 * 3600 * 1000) {
|
|
992
|
-
existing.push(candidate)
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
atomicWriteFileSync(
|
|
996
|
-
file,
|
|
997
|
-
`${JSON.stringify({
|
|
998
|
-
...stored.root,
|
|
999
|
-
sessions: [
|
|
1000
|
-
...existing,
|
|
1001
|
-
{ ...refreshed, session_id: sessionId, updated_at: now, harness },
|
|
1002
|
-
],
|
|
1003
|
-
})}\n`,
|
|
1004
|
-
)
|
|
1005
|
-
})
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
function readStoredProjectSessionPointers(file: string): StoredProjectSessionPointer[] {
|
|
1009
|
-
return readStoredProjectSessionDocument(file).sessions.flatMap(
|
|
1010
|
-
(candidate): StoredProjectSessionPointer[] => {
|
|
1011
|
-
const entry = storedProjectSessionPointer(candidate)
|
|
1012
|
-
return entry === null ? [] : [entry]
|
|
1013
|
-
},
|
|
1014
|
-
)
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
/**
|
|
1018
|
-
* Resolves the session working in `cwd`. Stale pointers are ignored rather than
|
|
1019
|
-
* trusted: an id left by a session that ended days ago would send the question
|
|
1020
|
-
* into state nothing is watching.
|
|
1021
|
-
*/
|
|
1022
|
-
export function readProjectSession(
|
|
1023
|
-
cwd: string,
|
|
1024
|
-
env: NodeJS.ProcessEnv,
|
|
1025
|
-
now: number,
|
|
1026
|
-
maxAgeMs = 24 * 3600 * 1000,
|
|
1027
|
-
): string | null {
|
|
1028
|
-
return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
/** A live pointer keeps when it was last written, so callers can rank by it. */
|
|
1032
|
-
export interface LiveProjectSessionPointer extends ProjectSessionPointer {
|
|
1033
|
-
updatedAt: number
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
/**
|
|
1037
|
-
* Every session that has fired a hook in this directory recently, newest first.
|
|
1038
|
-
*
|
|
1039
|
-
* This is the only local record of which harness sessions are actually alive
|
|
1040
|
-
* here, which makes it the tiebreaker when the environment alone cannot say
|
|
1041
|
-
* which harness owns the current shell.
|
|
1042
|
-
*/
|
|
1043
|
-
export function readLiveProjectSessionPointers(
|
|
1044
|
-
cwd: string,
|
|
1045
|
-
env: NodeJS.ProcessEnv,
|
|
1046
|
-
now: number,
|
|
1047
|
-
maxAgeMs = 24 * 3600 * 1000,
|
|
1048
|
-
): LiveProjectSessionPointer[] {
|
|
1049
|
-
const file = projectSessionPointerPath(cwd, env)
|
|
1050
|
-
return readStoredProjectSessionPointers(file)
|
|
1051
|
-
.filter((entry) => now - entry.updatedAt <= maxAgeMs)
|
|
1052
|
-
.filter((entry) => {
|
|
1053
|
-
try {
|
|
1054
|
-
// A pointer is routing evidence only while its session state still
|
|
1055
|
-
// exists and parses. SessionEnd and explicit cleanup therefore
|
|
1056
|
-
// invalidate it even when a crash prevented removal from this index.
|
|
1057
|
-
const sessionFile = sessionStatePath(entry.sessionId, env)
|
|
1058
|
-
if (!existsSync(sessionFile)) return false
|
|
1059
|
-
const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
|
|
1060
|
-
return typeof state === 'object' && state !== null && !Array.isArray(state)
|
|
1061
|
-
} catch {
|
|
1062
|
-
return false
|
|
1063
|
-
}
|
|
1064
|
-
})
|
|
1065
|
-
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
1066
|
-
.map((entry) => ({
|
|
1067
|
-
sessionId: entry.sessionId,
|
|
1068
|
-
harness: entry.harness,
|
|
1069
|
-
updatedAt: entry.updatedAt,
|
|
1070
|
-
}))
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
export function readProjectSessionPointer(
|
|
1074
|
-
cwd: string,
|
|
1075
|
-
env: NodeJS.ProcessEnv,
|
|
1076
|
-
now: number,
|
|
1077
|
-
maxAgeMs = 24 * 3600 * 1000,
|
|
1078
|
-
): ProjectSessionPointer | null {
|
|
1079
|
-
const latest = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs)[0]
|
|
1080
|
-
return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness }
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
export function readMatchingProjectSessionPointer(
|
|
1084
|
-
cwd: string,
|
|
1085
|
-
env: NodeJS.ProcessEnv,
|
|
1086
|
-
now: number,
|
|
1087
|
-
sessionId: string,
|
|
1088
|
-
harness: HookHarness,
|
|
1089
|
-
maxAgeMs = 24 * 3600 * 1000,
|
|
1090
|
-
): ProjectSessionPointer | null {
|
|
1091
|
-
const match = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs).find(
|
|
1092
|
-
(entry) => entry.sessionId === sessionId && entry.harness === harness,
|
|
1093
|
-
)
|
|
1094
|
-
return match === undefined ? null : { sessionId: match.sessionId, harness: match.harness }
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
function clearMatchingProjectSession(
|
|
1098
|
-
cwd: string,
|
|
1099
|
-
env: NodeJS.ProcessEnv,
|
|
1100
|
-
sessionId: string,
|
|
1101
|
-
): void {
|
|
1102
|
-
const file = projectSessionPointerPath(cwd, env)
|
|
1103
|
-
withFileLock(`${file}.lock`, () => {
|
|
1104
|
-
const stored = readStoredProjectSessionDocument(file)
|
|
1105
|
-
const remaining = stored.sessions.filter((candidate) => {
|
|
1106
|
-
const entry = storedProjectSessionPointer(candidate)
|
|
1107
|
-
return entry === null || entry.sessionId !== sessionId
|
|
1108
|
-
})
|
|
1109
|
-
const hasUnknownRootFields = Object.keys(stored.root).some((key) => key !== 'sessions')
|
|
1110
|
-
if (remaining.length === 0 && !hasUnknownRootFields) {
|
|
1111
|
-
rmSync(file, { force: true })
|
|
1112
|
-
return
|
|
1113
|
-
}
|
|
1114
|
-
atomicWriteFileSync(
|
|
1115
|
-
file,
|
|
1116
|
-
`${JSON.stringify({
|
|
1117
|
-
...stored.root,
|
|
1118
|
-
sessions: remaining,
|
|
1119
|
-
})}\n`,
|
|
1120
|
-
)
|
|
1121
|
-
})
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
export interface HookContext {
|
|
1125
|
-
client: ApiClient
|
|
1126
|
-
config: CliConfig
|
|
1127
|
-
env: NodeJS.ProcessEnv
|
|
1128
|
-
now: () => number
|
|
1129
|
-
/** Injected so tests advance a virtual clock instead of sleeping. */
|
|
1130
|
-
sleep: (milliseconds: number) => Promise<void>
|
|
1131
|
-
/** Bounded wait for the first reply; injected so tests do not sleep. */
|
|
1132
|
-
waitForFirstReply: (
|
|
1133
|
-
requestId: string,
|
|
1134
|
-
timeoutSeconds: number,
|
|
1135
|
-
) => Promise<{ replies: ReplyView[]; timedOut: boolean; degraded?: boolean }>
|
|
1136
|
-
/** The active harness selects the native continuation output adapter. */
|
|
1137
|
-
harness?: HookHarness
|
|
1138
|
-
/**
|
|
1139
|
-
* The local record. A hook's decisions are invisible from everywhere else —
|
|
1140
|
-
* its stderr belongs to the harness and its usual outcome is to do nothing —
|
|
1141
|
-
* so this is the only account of why a question did or did not travel.
|
|
1142
|
-
*/
|
|
1143
|
-
log?: Logger
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
/**
|
|
1147
|
-
* Record one gate decision.
|
|
1148
|
-
*
|
|
1149
|
-
* `verdict` is what happened to the question and `reason` is the closed-set
|
|
1150
|
-
* name of the rule that decided it, so an agent can ask "why was it held" with
|
|
1151
|
-
* a filter rather than by matching on prose. The prose still exists — it is what
|
|
1152
|
-
* the user sees in the terminal — but it is free to be rewritten, and a log a
|
|
1153
|
-
* filter cannot rely on is a log nobody filters.
|
|
1154
|
-
*/
|
|
1155
|
-
export const GATE_REASONS = [
|
|
1156
|
-
'no-session',
|
|
1157
|
-
'no-question',
|
|
1158
|
-
'answered',
|
|
1159
|
-
'continuation-repeat',
|
|
1160
|
-
'continuation-limit',
|
|
1161
|
-
'delivery-limit',
|
|
1162
|
-
'acknowledgement-required',
|
|
1163
|
-
'acknowledgement-abandoned',
|
|
1164
|
-
'harness-cannot-continue',
|
|
1165
|
-
'notifications-off',
|
|
1166
|
-
'claimed-elsewhere',
|
|
1167
|
-
'elapsed',
|
|
1168
|
-
'proceeding',
|
|
1169
|
-
] as const
|
|
1170
|
-
|
|
1171
|
-
/**
|
|
1172
|
-
* A runtime list rather than a bare type, so the guidance that tells agents to
|
|
1173
|
-
* filter on these can be tested against the set the code can actually emit.
|
|
1174
|
-
* `already-asked` and `no-devices` were declared here for a long time and never
|
|
1175
|
-
* emitted once; documenting them taught agents to filter for silence.
|
|
1176
|
-
*/
|
|
1177
|
-
export type GateReason = (typeof GATE_REASONS)[number]
|
|
1178
|
-
|
|
1179
|
-
function gate(
|
|
1180
|
-
ctx: HookContext,
|
|
1181
|
-
verdict: 'held' | 'proceeding',
|
|
1182
|
-
reason: GateReason,
|
|
1183
|
-
data: Record<string, unknown> = {},
|
|
1184
|
-
): void {
|
|
1185
|
-
ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data })
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
export type HookHarness = HookInstallableHarness
|
|
1189
|
-
|
|
1190
|
-
/**
|
|
1191
|
-
* One round of the waiter's reply poll. Short enough that several questions
|
|
1192
|
-
* still share one wall clock, long enough not to hammer the server.
|
|
1193
|
-
*/
|
|
1194
|
-
const REPLY_POLL_SECONDS = 5
|
|
1195
|
-
|
|
1196
|
-
/** Avoid hot-looping while a live observer cooperatively yields its claim. */
|
|
1197
|
-
const CLAIM_HANDOFF_POLL_MS = 1_000
|
|
1198
|
-
|
|
1199
|
-
/**
|
|
1200
|
-
* Push a question and block for the answer.
|
|
1201
|
-
*
|
|
1202
|
-
* An answered question is closed the moment this wait sees the answer: the
|
|
1203
|
-
* first device to answer claims the question and the others are told. Until
|
|
1204
|
-
* that close, a later reply is a correction — so what this returns as "the"
|
|
1205
|
-
* answer is always the latest reply, with the full stream alongside for
|
|
1206
|
-
* free-text answers given in parts. An unanswered question stays open,
|
|
1207
|
-
* because nobody has acted on the silence yet and the next turn can still
|
|
1208
|
-
* collect the answer with `notifai replies`.
|
|
1209
|
-
*/
|
|
1210
|
-
/**
|
|
1211
|
-
* Put one registered question on the user's devices. Submission only — the
|
|
1212
|
-
* wait happens once, across every live question, in the caller.
|
|
1213
|
-
*/
|
|
1214
|
-
async function prepareQuestionSubmission(
|
|
1215
|
-
ctx: HookContext,
|
|
1216
|
-
options: {
|
|
1217
|
-
title: string
|
|
1218
|
-
body: string
|
|
1219
|
-
questions: QuestionT[]
|
|
1220
|
-
media?: MediaItemT[] | undefined
|
|
1221
|
-
project?: string | undefined
|
|
1222
|
-
source?: SourceContextT | undefined
|
|
1223
|
-
/** How long the server keeps accepting an answer. */
|
|
1224
|
-
windowSeconds: number
|
|
1225
|
-
/** Absolute owner deadline persisted before submit. */
|
|
1226
|
-
ownerDeadlineAt: number
|
|
1227
|
-
},
|
|
1228
|
-
): Promise<PendingSubmissionIntent | { error: string }> {
|
|
1229
|
-
const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`
|
|
1230
|
-
// A draft carrying `reply` is rejected outright if it targets a device that
|
|
1231
|
-
// cannot answer, so resolve the healthy companion platforms explicitly.
|
|
1232
|
-
const answerable = await answerableDevices(ctx)
|
|
1233
|
-
if (answerable.length === 0) {
|
|
1234
|
-
return { error: 'no device can answer a question yet; leaving this to the terminal' }
|
|
1235
|
-
}
|
|
1236
|
-
const build = buildDraft(
|
|
1237
|
-
ctx.config,
|
|
1238
|
-
{
|
|
1239
|
-
title: options.title,
|
|
1240
|
-
body: options.body,
|
|
1241
|
-
lifecycle: { tier: 'needs_you' },
|
|
1242
|
-
...(options.project !== undefined ? { project: options.project } : {}),
|
|
1243
|
-
...(options.media !== undefined ? { media: options.media } : {}),
|
|
1244
|
-
device: answerable,
|
|
1245
|
-
reply: true,
|
|
1246
|
-
replyWindow: Math.max(MIN_REPLY_WINDOW_SECONDS, options.windowSeconds),
|
|
1247
|
-
questions: options.questions,
|
|
1248
|
-
collapseKey,
|
|
1249
|
-
},
|
|
1250
|
-
options.source === undefined ? {} : { source: options.source },
|
|
1251
|
-
)
|
|
1252
|
-
if (!build.ok) return { error: build.error }
|
|
1253
|
-
|
|
1254
|
-
return {
|
|
1255
|
-
request_id: `req_${randomBytes(18).toString('base64url')}`,
|
|
1256
|
-
idempotency_key: `hook-${randomBytes(12).toString('base64url')}`,
|
|
1257
|
-
collapse_key: collapseKey,
|
|
1258
|
-
device_ids: answerable,
|
|
1259
|
-
draft: build.draft,
|
|
1260
|
-
owner_deadline_at: options.ownerDeadlineAt,
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
async function submitQuestion(
|
|
1265
|
-
ctx: HookContext,
|
|
1266
|
-
intent: PendingSubmissionIntent,
|
|
1267
|
-
): Promise<SubmissionReceipt> {
|
|
1268
|
-
const receipt = await ctx.client.submit(
|
|
1269
|
-
{
|
|
1270
|
-
request_id: intent.request_id,
|
|
1271
|
-
idempotency_key: intent.idempotency_key,
|
|
1272
|
-
draft: intent.draft,
|
|
1273
|
-
},
|
|
1274
|
-
0,
|
|
1275
|
-
)
|
|
1276
|
-
if (receipt.request_id !== intent.request_id) {
|
|
1277
|
-
throw new Error(
|
|
1278
|
-
`server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`,
|
|
1279
|
-
)
|
|
1280
|
-
}
|
|
1281
|
-
return receipt
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
function canSubmitCompleteWindow(
|
|
1285
|
-
ctx: HookContext,
|
|
1286
|
-
intent: PendingSubmissionIntent,
|
|
1287
|
-
fallbackWindowSeconds: number,
|
|
1288
|
-
): boolean {
|
|
1289
|
-
const frozenWindowSeconds =
|
|
1290
|
-
intent.draft.reply?.expires_in_seconds ?? fallbackWindowSeconds
|
|
1291
|
-
return (
|
|
1292
|
-
intent.owner_deadline_at - ctx.now() >=
|
|
1293
|
-
(frozenWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000
|
|
1294
|
-
)
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
/**
|
|
1298
|
-
* The waiter's wait: one bounded poll across every live question at once.
|
|
1299
|
-
*
|
|
1300
|
-
* Each round polls all of them concurrently, so several questions cost the
|
|
1301
|
-
* same wall clock as one; the first round that finds any reply returns
|
|
1302
|
-
* everything found in that round, and whatever was not answered stays
|
|
1303
|
-
* registered. A question whose polling is permanently rejected drops out of
|
|
1304
|
-
* the round rather than failing the whole wait.
|
|
1305
|
-
*/
|
|
1306
|
-
async function waitForAnyReply(
|
|
1307
|
-
ctx: HookContext,
|
|
1308
|
-
requestIds: string[],
|
|
1309
|
-
timeoutSeconds: number,
|
|
1310
|
-
interruption?: () => 'ownership-ended' | 'new-question' | null,
|
|
1311
|
-
): Promise<{
|
|
1312
|
-
byRequest: Map<string, ReplyView[]>
|
|
1313
|
-
degraded: boolean
|
|
1314
|
-
permanentFailures: Map<string, string>
|
|
1315
|
-
interrupted: 'ownership-ended' | 'new-question' | null
|
|
1316
|
-
}> {
|
|
1317
|
-
const deadline = ctx.now() + timeoutSeconds * 1000
|
|
1318
|
-
let degraded = false
|
|
1319
|
-
let firstPoll = true
|
|
1320
|
-
const permanentFailures = new Map<string, string>()
|
|
1321
|
-
|
|
1322
|
-
if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
|
|
1323
|
-
return { byRequest: new Map(), degraded: false, permanentFailures, interrupted: null }
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
for (;;) {
|
|
1327
|
-
const beforePoll = interruption?.() ?? null
|
|
1328
|
-
if (beforePoll !== null) {
|
|
1329
|
-
return { byRequest: new Map(), degraded, permanentFailures, interrupted: beforePoll }
|
|
1330
|
-
}
|
|
1331
|
-
const remainingMs = Math.max(0, deadline - ctx.now())
|
|
1332
|
-
if (!firstPoll && remainingMs === 0) {
|
|
1333
|
-
return { byRequest: new Map(), degraded, permanentFailures, interrupted: null }
|
|
1334
|
-
}
|
|
1335
|
-
firstPoll = false
|
|
1336
|
-
const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)))
|
|
1337
|
-
const results = await Promise.all(
|
|
1338
|
-
requestIds
|
|
1339
|
-
.filter((requestId) => !permanentFailures.has(requestId))
|
|
1340
|
-
.map(async (requestId) => {
|
|
1341
|
-
try {
|
|
1342
|
-
return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) }
|
|
1343
|
-
} catch (err) {
|
|
1344
|
-
if (isRetryableReplyPollError(err)) {
|
|
1345
|
-
return { requestId, replies: [] as ReplyView[], degraded: true }
|
|
1346
|
-
}
|
|
1347
|
-
return {
|
|
1348
|
-
requestId,
|
|
1349
|
-
replies: [] as ReplyView[],
|
|
1350
|
-
degraded: false,
|
|
1351
|
-
permanentFailure: replyPollFailure(err),
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
}),
|
|
1355
|
-
)
|
|
1356
|
-
const byRequest = new Map<string, ReplyView[]>()
|
|
1357
|
-
for (const result of results) {
|
|
1358
|
-
degraded ||= result.degraded === true
|
|
1359
|
-
if (result.permanentFailure !== undefined) {
|
|
1360
|
-
permanentFailures.set(result.requestId, result.permanentFailure)
|
|
1361
|
-
}
|
|
1362
|
-
if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
|
|
1363
|
-
}
|
|
1364
|
-
const afterPoll = interruption?.() ?? null
|
|
1365
|
-
// SessionEnd or terminal-side retirement wins even if an in-flight poll
|
|
1366
|
-
// happened to return an answer. A newly registered question merely asks
|
|
1367
|
-
// this owner to yield; an answer already observed remains authoritative.
|
|
1368
|
-
if (afterPoll === 'ownership-ended') {
|
|
1369
|
-
return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll }
|
|
1370
|
-
}
|
|
1371
|
-
if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
|
|
1372
|
-
return { byRequest, degraded, permanentFailures, interrupted: null }
|
|
1373
|
-
}
|
|
1374
|
-
if (afterPoll === 'new-question') {
|
|
1375
|
-
return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll }
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
/**
|
|
1381
|
-
* Local lifecycle signal for a long-lived observer.
|
|
1382
|
-
*
|
|
1383
|
-
* SessionEnd and terminal-side retirement remove an owned request from pending
|
|
1384
|
-
* state; the detached process must stop instead of recreating that session.
|
|
1385
|
-
* A new unpushed question asks this owner to yield the session claim so the
|
|
1386
|
-
* successor Stop can submit it promptly.
|
|
1387
|
-
*/
|
|
1388
|
-
function waiterInterruption(
|
|
1389
|
-
sessionId: string,
|
|
1390
|
-
env: NodeJS.ProcessEnv,
|
|
1391
|
-
requestIds: readonly string[],
|
|
1392
|
-
): 'ownership-ended' | 'new-question' | null {
|
|
1393
|
-
if (sessionHasEnded(sessionId, env)) return 'ownership-ended'
|
|
1394
|
-
const pending = pendingList(readSessionState(sessionId, env))
|
|
1395
|
-
const owned = new Set(requestIds)
|
|
1396
|
-
// One question can be retired from the terminal while this observer owns
|
|
1397
|
-
// several. That is a per-question change, not whole-session cancellation;
|
|
1398
|
-
// the caller filters the changed set after each poll and keeps the rest.
|
|
1399
|
-
return pending.some(
|
|
1400
|
-
(entry) =>
|
|
1401
|
-
entry.request_id === undefined &&
|
|
1402
|
-
(entry.submission === undefined || !owned.has(entry.submission.request_id)),
|
|
1403
|
-
)
|
|
1404
|
-
? 'new-question'
|
|
1405
|
-
: null
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
/** A non-retryable replies fault, with enough server identity to act on it. */
|
|
1409
|
-
function replyPollFailure(err: unknown): string {
|
|
1410
|
-
return err instanceof ApiCallError
|
|
1411
|
-
? `${err.code} (HTTP ${err.status}): ${err.message}`
|
|
1412
|
-
: String(err)
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
function permanentReplyFailureNote(failures: Map<string, string>): string | null {
|
|
1416
|
-
if (failures.size === 0) return null
|
|
1417
|
-
const details = [...failures].map(([requestId, failure]) => `${requestId}: ${failure}`).join('; ')
|
|
1418
|
-
return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
/** Healthy Companion devices that advertise the current answer job. */
|
|
1422
|
-
async function answerableDevices(ctx: HookContext): Promise<string[]> {
|
|
1423
|
-
const configured = ctx.config.devices.value
|
|
1424
|
-
const { devices } = await ctx.client.listDevices()
|
|
1425
|
-
return devices
|
|
1426
|
-
.filter(
|
|
1427
|
-
(device) =>
|
|
1428
|
-
(device.platform === 'ios' ||
|
|
1429
|
-
device.platform === 'macos' ||
|
|
1430
|
-
device.platform === 'android') &&
|
|
1431
|
-
device.registration_healthy &&
|
|
1432
|
-
device.capabilities?.includes('answer') === true &&
|
|
1433
|
-
device.derived_status !== 'must_update',
|
|
1434
|
-
)
|
|
1435
|
-
.filter((device) => configured === null || configured.includes(device.device_id))
|
|
1436
|
-
.map((device) => device.device_id)
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
/**
|
|
1440
|
-
* Everything retirement needs. Narrower than a HookContext on purpose:
|
|
1441
|
-
* `notifai ask` supersedes the previous question and it is a plain command with
|
|
1442
|
-
* no hook payload, no idle probe and nothing to sleep for.
|
|
1443
|
-
*/
|
|
1444
|
-
export type RetireDeps = Pick<HookContext, 'client' | 'config'> & Pick<Partial<HookContext>, 'log'>
|
|
1445
|
-
|
|
1446
|
-
/**
|
|
1447
|
-
* Close is a server-side serialization fence: a successful response contains
|
|
1448
|
-
* every reply that committed before the window closed. `null` is deliberately
|
|
1449
|
-
* different from an empty reply set — it means ownership could not be proven
|
|
1450
|
-
* final and the local record must stay recoverable.
|
|
1451
|
-
*/
|
|
1452
|
-
async function finalizeReplies(
|
|
1453
|
-
ctx: RetireDeps,
|
|
1454
|
-
requestId: string,
|
|
1455
|
-
): Promise<ListRepliesResponse | null> {
|
|
1456
|
-
try {
|
|
1457
|
-
return await ctx.client.closeReplies(requestId)
|
|
1458
|
-
} catch {
|
|
1459
|
-
return null
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
/** Best effort only for already-journaled retirement debt. */
|
|
1464
|
-
async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
|
|
1465
|
-
await finalizeReplies(ctx, requestId)
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
/**
|
|
1469
|
-
* Close the reply window so the server can mint the retirement push. Client
|
|
1470
|
-
* submissions cannot carry `question_retired`; that event is reserved for
|
|
1471
|
-
* server-origin state syncs.
|
|
1472
|
-
*/
|
|
1473
|
-
async function proveRetirement(
|
|
1474
|
-
ctx: RetireDeps,
|
|
1475
|
-
requestId: string,
|
|
1476
|
-
): Promise<boolean> {
|
|
1477
|
-
const response = await finalizeReplies(ctx, requestId)
|
|
1478
|
-
const proven = response !== null
|
|
1479
|
-
ctx.log?.info('hook.retirement', {
|
|
1480
|
-
request_id: requestId,
|
|
1481
|
-
attempted: true,
|
|
1482
|
-
proven,
|
|
1483
|
-
})
|
|
1484
|
-
return proven
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
/**
|
|
1488
|
-
* Park a delivered question for retirement. Called at the moment we learn it is
|
|
1489
|
-
* dead, which is frequently not a moment we can reach the network.
|
|
1490
|
-
*
|
|
1491
|
-
* Nothing is parked for a question that never reached a device: with no
|
|
1492
|
-
* request_id there is nothing on any device to retire.
|
|
1493
|
-
*/
|
|
1494
|
-
export function parkForRetirement(
|
|
1495
|
-
sessionId: string,
|
|
1496
|
-
env: NodeJS.ProcessEnv,
|
|
1497
|
-
pending: PendingQuestion,
|
|
1498
|
-
state: LifecycleEndState,
|
|
1499
|
-
): void {
|
|
1500
|
-
const retirement = retiringQuestion(pending, state)
|
|
1501
|
-
if (retirement === null) return
|
|
1502
|
-
updateSessionState(sessionId, env, (current) => {
|
|
1503
|
-
const already = current.retiring ?? []
|
|
1504
|
-
const existing = already.findIndex((entry) => entry.request_id === retirement.request_id)
|
|
1505
|
-
if (existing < 0) {
|
|
1506
|
-
return rememberQuestionState(
|
|
1507
|
-
{ ...current, retiring: [...already, retirement] },
|
|
1508
|
-
pending,
|
|
1509
|
-
state === 'answered' ? 'answered' : 'retired',
|
|
1510
|
-
)
|
|
1511
|
-
}
|
|
1512
|
-
const retiring = [...already]
|
|
1513
|
-
// An observed answer is final truth and upgrades an earlier supersession
|
|
1514
|
-
// parked while the submission callback was racing a newer question.
|
|
1515
|
-
if (retirement.state === 'answered' && retiring[existing]!.state !== 'answered') {
|
|
1516
|
-
retiring[existing] = retirement
|
|
1517
|
-
}
|
|
1518
|
-
return rememberQuestionState(
|
|
1519
|
-
{ ...current, retiring },
|
|
1520
|
-
pending,
|
|
1521
|
-
state === 'answered' ? 'answered' : 'retired',
|
|
1522
|
-
)
|
|
1523
|
-
})
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
/**
|
|
1527
|
-
* Convert live question state into a complete retirement instruction.
|
|
1528
|
-
*
|
|
1529
|
-
* A request/collapse pair without the original Delivery targets is not safe to
|
|
1530
|
-
* send: re-resolving today's routing can miss a device that still shows the
|
|
1531
|
-
* question or retire a device that never received it. Fail explicitly and keep
|
|
1532
|
-
* the pending record instead of silently broadening or narrowing the fanout.
|
|
1533
|
-
*/
|
|
1534
|
-
function retiringQuestion(
|
|
1535
|
-
pending: PendingQuestion,
|
|
1536
|
-
state: LifecycleEndState,
|
|
1537
|
-
cwd?: string,
|
|
1538
|
-
): RetiringQuestion | null {
|
|
1539
|
-
// A submit journal is written before the first network byte. SessionEnd can
|
|
1540
|
-
// therefore race a request that the server accepts after local cleanup. Its
|
|
1541
|
-
// reserved request id and exact fanout are already sufficient retirement
|
|
1542
|
-
// identity, even though the ordinary live fields have not been promoted yet.
|
|
1543
|
-
const requestId = pending.request_id ?? pending.submission?.request_id
|
|
1544
|
-
const collapseKey = pending.collapse_key ?? pending.submission?.collapse_key
|
|
1545
|
-
const deviceIds = pending.device_ids ?? pending.submission?.device_ids
|
|
1546
|
-
const hasRequest = requestId !== undefined
|
|
1547
|
-
const hasCollapse = collapseKey !== undefined
|
|
1548
|
-
const hasDevices = deviceIds !== undefined && deviceIds.length > 0
|
|
1549
|
-
if (!hasRequest && !hasCollapse && !hasDevices) return null
|
|
1550
|
-
if (!hasRequest || !hasCollapse || !hasDevices) {
|
|
1551
|
-
throw new Error(
|
|
1552
|
-
'live question state is incomplete; refusing to retire it without request, collapse, and device identifiers',
|
|
1553
|
-
)
|
|
1554
|
-
}
|
|
1555
|
-
const source = sourceContextAtHookEvent(pending.source, cwd)
|
|
1556
|
-
return {
|
|
1557
|
-
...(pending.question_id !== undefined ? { question_id: pending.question_id } : {}),
|
|
1558
|
-
request_id: requestId!,
|
|
1559
|
-
collapse_key: collapseKey!,
|
|
1560
|
-
device_ids: [...deviceIds!],
|
|
1561
|
-
question: pending.question,
|
|
1562
|
-
...(pending.project !== undefined ? { project: pending.project } : {}),
|
|
1563
|
-
...(source !== undefined ? { source } : {}),
|
|
1564
|
-
state,
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
|
|
1568
|
-
const SHORT_CONFIRMATIONS = new Set(['done', 'yes', 'y', 'no', 'n', 'ok', 'okay'])
|
|
1569
|
-
|
|
1570
|
-
function normalizePrompt(value: string): string {
|
|
1571
|
-
return value.trim().replace(/\s+/g, ' ').toLowerCase()
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
function pendingChoiceLabels(pending: PendingQuestion): string[] {
|
|
1575
|
-
const fromQuestions = (pending.questions ?? []).flatMap((question) =>
|
|
1576
|
-
(question.choices ?? []).flatMap((choice) => [choice.label, choice.id]),
|
|
1577
|
-
)
|
|
1578
|
-
return fromQuestions.map(normalizePrompt).filter((label) => label.length > 0)
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
function pendingHasMatchingChoice(pending: PendingQuestion, normalizedPrompt: string): boolean {
|
|
1582
|
-
return pendingChoiceLabels(pending).includes(normalizedPrompt)
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
/**
|
|
1586
|
-
* Which outstanding questions this prompt plausibly answers.
|
|
1587
|
-
*
|
|
1588
|
-
* A unique closed-choice match is enough. A short confirmation such as "done"
|
|
1589
|
-
* matches only when exactly one question is outstanding. Long unrelated
|
|
1590
|
-
* prompts leave every question open for the reply window.
|
|
1591
|
-
*/
|
|
1592
|
-
export function pendingAnsweredByPrompt(
|
|
1593
|
-
prompt: string | undefined,
|
|
1594
|
-
pending: readonly PendingQuestion[],
|
|
1595
|
-
): PendingQuestion[] {
|
|
1596
|
-
const normalized = normalizePrompt(prompt ?? '')
|
|
1597
|
-
if (normalized.length === 0 || pending.length === 0) return []
|
|
1598
|
-
|
|
1599
|
-
const labelled = pending.filter((entry) => pendingHasMatchingChoice(entry, normalized))
|
|
1600
|
-
if (labelled.length === 1) return labelled
|
|
1601
|
-
if (labelled.length > 1) return []
|
|
1602
|
-
if (pending.length === 1 && SHORT_CONFIRMATIONS.has(normalized)) return [...pending]
|
|
1603
|
-
return []
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
/**
|
|
1607
|
-
* Send every parked retirement, and forget the ones that landed. A failure
|
|
1608
|
-
* leaves the entry in place for the next hook rather than losing it, which is
|
|
1609
|
-
* the whole reason the queue exists.
|
|
1610
|
-
*/
|
|
1611
|
-
export async function drainRetirements(
|
|
1612
|
-
ctx: RetireDeps,
|
|
1613
|
-
sessionId: string,
|
|
1614
|
-
env: NodeJS.ProcessEnv,
|
|
1615
|
-
): Promise<string[]> {
|
|
1616
|
-
const queue = readSessionState(sessionId, env).retiring ?? []
|
|
1617
|
-
if (queue.length === 0) return []
|
|
1618
|
-
|
|
1619
|
-
const retired: string[] = []
|
|
1620
|
-
for (const entry of queue) {
|
|
1621
|
-
const proven = await proveRetirement(ctx, entry.request_id)
|
|
1622
|
-
if (proven) {
|
|
1623
|
-
retired.push(entry.request_id)
|
|
1624
|
-
// Persist each success before touching the next entry. A later corrupt
|
|
1625
|
-
// record or interrupted process must not resurrect work already done.
|
|
1626
|
-
updateSessionState(sessionId, env, (current) => ({
|
|
1627
|
-
...current,
|
|
1628
|
-
retiring: (current.retiring ?? []).filter(
|
|
1629
|
-
(candidate) => candidate.request_id !== entry.request_id,
|
|
1630
|
-
),
|
|
1631
|
-
}))
|
|
1632
|
-
}
|
|
1633
|
-
}
|
|
1634
|
-
return retired
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
function orphanQueuePath(env: NodeJS.ProcessEnv): string {
|
|
1638
|
-
return path.join(stateDir(env), 'retire-queue.json')
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
function readOrphanQueue(env: NodeJS.ProcessEnv): OrphanRetirement[] {
|
|
1642
|
-
const file = orphanQueuePath(env)
|
|
1643
|
-
if (!existsSync(file)) return []
|
|
1644
|
-
try {
|
|
1645
|
-
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
|
|
1646
|
-
return Array.isArray(parsed) ? (parsed as OrphanRetirement[]) : []
|
|
1647
|
-
} catch {
|
|
1648
|
-
// Same stance as session state: corruption fails closed to "nothing queued".
|
|
1649
|
-
return []
|
|
1650
|
-
}
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
function writeOrphanQueueUnlocked(file: string, queue: OrphanRetirement[]): void {
|
|
1654
|
-
atomicWriteFileSync(file, `${JSON.stringify(queue, null, 2)}\n`)
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
function updateOrphanQueue(
|
|
1658
|
-
env: NodeJS.ProcessEnv,
|
|
1659
|
-
update: (current: OrphanRetirement[]) => OrphanRetirement[],
|
|
1660
|
-
): OrphanRetirement[] {
|
|
1661
|
-
const file = orphanQueuePath(env)
|
|
1662
|
-
return withFileLock(`${file}.lock`, () => {
|
|
1663
|
-
const next = update(readOrphanQueue(env))
|
|
1664
|
-
writeOrphanQueueUnlocked(file, next)
|
|
1665
|
-
return next
|
|
1666
|
-
})
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
/** Move retirements into the global queue; deduped so a retry costs nothing. */
|
|
1670
|
-
export function orphanRetirements(
|
|
1671
|
-
env: NodeJS.ProcessEnv,
|
|
1672
|
-
entries: RetiringQuestion[],
|
|
1673
|
-
now: number,
|
|
1674
|
-
): void {
|
|
1675
|
-
if (entries.length === 0) return
|
|
1676
|
-
updateOrphanQueue(env, (queue) => {
|
|
1677
|
-
const known = new Set(queue.map((entry) => entry.request_id))
|
|
1678
|
-
const added = entries
|
|
1679
|
-
.filter((entry) => !known.has(entry.request_id))
|
|
1680
|
-
.map((entry) => ({ ...entry, enqueued_at: now }))
|
|
1681
|
-
return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP)
|
|
1682
|
-
})
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
/**
|
|
1686
|
-
* Retire everything a dead session left behind. Failures stay queued for the
|
|
1687
|
-
* next holder of a client; entries past the TTL are dropped as already dead.
|
|
1688
|
-
*/
|
|
1689
|
-
export async function drainOrphanRetirements(
|
|
1690
|
-
ctx: RetireDeps,
|
|
1691
|
-
env: NodeJS.ProcessEnv,
|
|
1692
|
-
now: number,
|
|
1693
|
-
): Promise<string[]> {
|
|
1694
|
-
const queue = readOrphanQueue(env)
|
|
1695
|
-
if (queue.length === 0) return []
|
|
1696
|
-
|
|
1697
|
-
const done: string[] = []
|
|
1698
|
-
for (const entry of queue) {
|
|
1699
|
-
const age = now - entry.enqueued_at
|
|
1700
|
-
// A negative age is a clock jump, not a fresh entry; retiring is idempotent
|
|
1701
|
-
// and cheap, so treat it as due rather than letting it linger for ever.
|
|
1702
|
-
if (age > ORPHAN_TTL_MS) {
|
|
1703
|
-
done.push(entry.request_id)
|
|
1704
|
-
updateOrphanQueue(env, (current) =>
|
|
1705
|
-
current.filter((candidate) => candidate.request_id !== entry.request_id),
|
|
1706
|
-
)
|
|
1707
|
-
continue
|
|
1708
|
-
}
|
|
1709
|
-
const proven = await proveRetirement(ctx, entry.request_id)
|
|
1710
|
-
if (proven) {
|
|
1711
|
-
done.push(entry.request_id)
|
|
1712
|
-
updateOrphanQueue(env, (current) =>
|
|
1713
|
-
current.filter((candidate) => candidate.request_id !== entry.request_id),
|
|
1714
|
-
)
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
return done
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
const LATE_STOP_POLL_SECONDS = 4
|
|
1721
|
-
const LATE_PROMPT_POLL_SECONDS = 3
|
|
1722
|
-
/**
|
|
1723
|
-
* The one bound on answer deliveries, for every route.
|
|
1724
|
-
*
|
|
1725
|
-
* It caps two things that both used to be able to run away: how many times a
|
|
1726
|
-
* single accepted answer may be handed to a route before the journal gives up
|
|
1727
|
-
* on it, and how many answer-driven continuation generations one chain may
|
|
1728
|
-
* produce.
|
|
1729
|
-
*/
|
|
1730
|
-
export const MAX_CONTINUATION_COUNT = 3
|
|
1731
|
-
|
|
1732
|
-
/**
|
|
1733
|
-
* How many turns an answer may be *held* before it is settled unread.
|
|
1734
|
-
*
|
|
1735
|
-
* A hold is not a delivery: the route reported that it handed nothing over, so
|
|
1736
|
-
* the agent has not seen the answer and the wake-loop the continuation cap
|
|
1737
|
-
* exists to stop has not happened. Counting holds against that cap meant three
|
|
1738
|
-
* turns where a liveness probe could not reach a busy session were enough to
|
|
1739
|
-
* discard an answer the user had already given.
|
|
1740
|
-
*
|
|
1741
|
-
* It still has to end. A route that can never deliver would otherwise retry
|
|
1742
|
-
* every turn for the life of the session, so holds get their own, far looser
|
|
1743
|
-
* bound — loose because each one costs nothing and the answer is still wanted.
|
|
1744
|
-
*/
|
|
1745
|
-
export const MAX_HELD_DELIVERIES = 20
|
|
1746
|
-
|
|
1747
|
-
interface PendingPoll {
|
|
1748
|
-
/** The answer to act on: the latest reply, because a later one corrects. */
|
|
1749
|
-
reply: ReplyView | null
|
|
1750
|
-
/** Every reply in arrival order, for free-text answers given in parts. */
|
|
1751
|
-
replies: ReplyView[]
|
|
1752
|
-
degraded: boolean
|
|
1753
|
-
permanentFailure: string | null
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
/** One bounded recovery poll for a question that outlived its original wait. */
|
|
1757
|
-
async function pollPendingReply(
|
|
1758
|
-
ctx: HookContext,
|
|
1759
|
-
pending: PendingQuestion,
|
|
1760
|
-
timeoutSeconds: number,
|
|
1761
|
-
): Promise<PendingPoll> {
|
|
1762
|
-
if (pending.request_id === undefined) {
|
|
1763
|
-
return { reply: null, replies: [], degraded: false, permanentFailure: null }
|
|
1764
|
-
}
|
|
1765
|
-
try {
|
|
1766
|
-
const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
|
|
1767
|
-
return {
|
|
1768
|
-
reply: result.replies.at(-1) ?? null,
|
|
1769
|
-
replies: result.replies,
|
|
1770
|
-
degraded: result.degraded === true,
|
|
1771
|
-
permanentFailure: null,
|
|
1772
|
-
}
|
|
1773
|
-
} catch (err) {
|
|
1774
|
-
return isRetryableReplyPollError(err)
|
|
1775
|
-
? { reply: null, replies: [], degraded: true, permanentFailure: null }
|
|
1776
|
-
: { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) }
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
/** One answered registered question, with everything the agent needs to read it. */
|
|
1781
|
-
interface AnsweredPending {
|
|
1782
|
-
pending: PendingQuestion
|
|
1783
|
-
reply: ReplyView
|
|
1784
|
-
replies: ReplyView[]
|
|
1785
|
-
/** Immutable server snapshot for this request, known after a replies/close response. */
|
|
1786
|
-
agent_acknowledgement_required?: boolean | undefined
|
|
1787
|
-
/** Whether that acknowledgement must carry text; the account's snapshot. */
|
|
1788
|
-
agent_acknowledgement_text_required?: boolean | undefined
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
|
-
interface FinalizedPending {
|
|
1792
|
-
pending: PendingQuestion
|
|
1793
|
-
response: ListRepliesResponse | null
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
/** Close several windows concurrently without confusing failure with silence. */
|
|
1797
|
-
async function finalizePendings(
|
|
1798
|
-
ctx: HookContext,
|
|
1799
|
-
pending: PendingQuestion[],
|
|
1800
|
-
): Promise<FinalizedPending[]> {
|
|
1801
|
-
return Promise.all(
|
|
1802
|
-
pending.map(async (entry) => {
|
|
1803
|
-
const response = await finalizeReplies(ctx, entry.request_id!)
|
|
1804
|
-
ctx.log?.info('hook.retirement', {
|
|
1805
|
-
request_id: entry.request_id,
|
|
1806
|
-
attempted: true,
|
|
1807
|
-
proven: response !== null,
|
|
1808
|
-
})
|
|
1809
|
-
return { pending: entry, response }
|
|
1810
|
-
}),
|
|
1811
|
-
)
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
/** Convert a fenced final reply set into the exact answer handed to the model. */
|
|
1815
|
-
function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
|
|
1816
|
-
const replies = finalized.response?.replies ?? []
|
|
1817
|
-
const reply = replies.at(-1)
|
|
1818
|
-
return reply === undefined
|
|
1819
|
-
? null
|
|
1820
|
-
: {
|
|
1821
|
-
pending: finalized.pending,
|
|
1822
|
-
reply,
|
|
1823
|
-
replies,
|
|
1824
|
-
agent_acknowledgement_required:
|
|
1825
|
-
finalized.response?.agent_acknowledgement_required,
|
|
1826
|
-
agent_acknowledgement_text_required:
|
|
1827
|
-
finalized.response?.agent_acknowledgement_text_required,
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
|
|
1831
|
-
/** Persist and surface one answer without letting the two representations drift. */
|
|
1832
|
-
function reportAnswer(
|
|
1833
|
-
ctx: HookContext,
|
|
1834
|
-
notes: string[],
|
|
1835
|
-
answered: AnsweredPending,
|
|
1836
|
-
late: boolean,
|
|
1837
|
-
): void {
|
|
1838
|
-
ctx.log?.info('hook.answer', {
|
|
1839
|
-
answered: true,
|
|
1840
|
-
stage: 'queued',
|
|
1841
|
-
...(late ? { late: true } : {}),
|
|
1842
|
-
request_id: answered.pending.request_id,
|
|
1843
|
-
device: answered.reply.device_name,
|
|
1844
|
-
text_chars: answered.reply.text.length,
|
|
1845
|
-
})
|
|
1846
|
-
notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`)
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
/**
|
|
1850
|
-
* One bounded recovery poll across every delivered question, concurrently —
|
|
1851
|
-
* N questions must not multiply the hook's latency budget by N.
|
|
1852
|
-
*/
|
|
1853
|
-
async function pollPendingReplies(
|
|
1854
|
-
ctx: HookContext,
|
|
1855
|
-
live: PendingQuestion[],
|
|
1856
|
-
timeoutSeconds: number,
|
|
1857
|
-
): Promise<{
|
|
1858
|
-
answered: AnsweredPending[]
|
|
1859
|
-
transientTrouble: boolean
|
|
1860
|
-
permanentFailures: Map<string, string>
|
|
1861
|
-
}> {
|
|
1862
|
-
const polls = await Promise.all(
|
|
1863
|
-
live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)),
|
|
1864
|
-
)
|
|
1865
|
-
const answered: AnsweredPending[] = []
|
|
1866
|
-
let transientTrouble = false
|
|
1867
|
-
const permanentFailures = new Map<string, string>()
|
|
1868
|
-
for (const [index, poll] of polls.entries()) {
|
|
1869
|
-
if (poll.reply !== null) {
|
|
1870
|
-
answered.push({ pending: live[index]!, reply: poll.reply, replies: poll.replies })
|
|
1871
|
-
}
|
|
1872
|
-
if (poll.degraded) transientTrouble = true
|
|
1873
|
-
if (poll.permanentFailure !== null) {
|
|
1874
|
-
permanentFailures.set(live[index]!.request_id!, poll.permanentFailure)
|
|
1875
|
-
}
|
|
1876
|
-
}
|
|
1877
|
-
return { answered, transientTrouble, permanentFailures }
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
/** The registered-question queue. Anything but the current shape reads as empty. */
|
|
1881
|
-
function pendingList(state: SessionState): PendingQuestion[] {
|
|
1882
|
-
return Array.isArray(state.pending) ? state.pending : []
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
const QUESTION_HISTORY_CAP = 50
|
|
1886
|
-
|
|
1887
|
-
type QuestionIdentity = Pick<PendingQuestion, 'question_id' | 'request_id' | 'submission'>
|
|
1888
|
-
|
|
1889
|
-
/** Keep a bounded, content-free map from local identity to its terminal state. */
|
|
1890
|
-
function rememberQuestionState(
|
|
1891
|
-
current: SessionState,
|
|
1892
|
-
question: QuestionIdentity,
|
|
1893
|
-
state: QuestionTerminalState,
|
|
1894
|
-
): SessionState {
|
|
1895
|
-
if (question.question_id === undefined) return current
|
|
1896
|
-
const existing = (current.question_history ?? []).find(
|
|
1897
|
-
(entry) => entry.question_id === question.question_id,
|
|
1898
|
-
)
|
|
1899
|
-
const terminalState =
|
|
1900
|
-
existing?.state === 'withdrawn' || existing?.state === 'answered'
|
|
1901
|
-
? existing.state
|
|
1902
|
-
: state === 'answered'
|
|
1903
|
-
? 'answered'
|
|
1904
|
-
: state
|
|
1905
|
-
const requestId = question.request_id ?? existing?.request_id
|
|
1906
|
-
const frozenRequestId =
|
|
1907
|
-
requestId === undefined
|
|
1908
|
-
? question.submission?.request_id ?? existing?.frozen_request_id
|
|
1909
|
-
: undefined
|
|
1910
|
-
const record: QuestionHistoryEntry = {
|
|
1911
|
-
question_id: question.question_id,
|
|
1912
|
-
state: terminalState,
|
|
1913
|
-
...(requestId === undefined ? {} : { request_id: requestId }),
|
|
1914
|
-
...(frozenRequestId === undefined ? {} : { frozen_request_id: frozenRequestId }),
|
|
1915
|
-
}
|
|
1916
|
-
const history = [
|
|
1917
|
-
...(current.question_history ?? []).filter(
|
|
1918
|
-
(entry) => entry.question_id !== question.question_id,
|
|
1919
|
-
),
|
|
1920
|
-
record,
|
|
1921
|
-
].slice(-QUESTION_HISTORY_CAP)
|
|
1922
|
-
return { ...current, question_history: history }
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
function pendingQuestionView(entry: PendingQuestion): QuestionStateView | null {
|
|
1926
|
-
if (entry.question_id === undefined) return null
|
|
1927
|
-
if (entry.request_id !== undefined) {
|
|
1928
|
-
return {
|
|
1929
|
-
question_id: entry.question_id,
|
|
1930
|
-
state: 'live',
|
|
1931
|
-
submitted: true,
|
|
1932
|
-
request_id: entry.request_id,
|
|
1933
|
-
frozen_request_id: null,
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
|
-
if (entry.submission !== undefined) {
|
|
1937
|
-
return {
|
|
1938
|
-
question_id: entry.question_id,
|
|
1939
|
-
state: 'frozen',
|
|
1940
|
-
submitted: null,
|
|
1941
|
-
request_id: null,
|
|
1942
|
-
frozen_request_id: entry.submission.request_id,
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
1945
|
-
return {
|
|
1946
|
-
question_id: entry.question_id,
|
|
1947
|
-
state: 'local',
|
|
1948
|
-
submitted: false,
|
|
1949
|
-
request_id: null,
|
|
1950
|
-
frozen_request_id: null,
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
/** Read one stable local question identity without minting or submitting anything. */
|
|
1955
|
-
export function inspectQuestionState(
|
|
1956
|
-
questionId: string,
|
|
1957
|
-
env: NodeJS.ProcessEnv,
|
|
1958
|
-
): QuestionStateLookup {
|
|
1959
|
-
const owner = findOwningSession(questionId, env)
|
|
1960
|
-
if (owner.ambiguous || owner.sessionId === null) {
|
|
1961
|
-
return { found: false, ambiguous: owner.ambiguous }
|
|
1962
|
-
}
|
|
1963
|
-
const state = readSessionState(owner.sessionId, env)
|
|
1964
|
-
const pending = pendingList(state).find((entry) => entry.question_id === questionId)
|
|
1965
|
-
const pendingView = pending === undefined ? null : pendingQuestionView(pending)
|
|
1966
|
-
if (pendingView !== null) {
|
|
1967
|
-
return { found: true, session_id: owner.sessionId, question: pendingView }
|
|
1968
|
-
}
|
|
1969
|
-
const accepted = state.accepted?.answers.find(
|
|
1970
|
-
({ pending: entry }) => entry.question_id === questionId,
|
|
1971
|
-
)?.pending
|
|
1972
|
-
if (accepted?.question_id !== undefined) {
|
|
1973
|
-
return {
|
|
1974
|
-
found: true,
|
|
1975
|
-
session_id: owner.sessionId,
|
|
1976
|
-
question: {
|
|
1977
|
-
question_id: accepted.question_id,
|
|
1978
|
-
state: 'answered',
|
|
1979
|
-
submitted: true,
|
|
1980
|
-
request_id: accepted.request_id ?? null,
|
|
1981
|
-
frozen_request_id: null,
|
|
1982
|
-
},
|
|
1983
|
-
}
|
|
1984
|
-
}
|
|
1985
|
-
const retiring = (state.retiring ?? []).find((entry) => entry.question_id === questionId)
|
|
1986
|
-
if (retiring?.question_id !== undefined) {
|
|
1987
|
-
return {
|
|
1988
|
-
found: true,
|
|
1989
|
-
session_id: owner.sessionId,
|
|
1990
|
-
question: {
|
|
1991
|
-
question_id: retiring.question_id,
|
|
1992
|
-
state: retiring.state === 'answered' ? 'answered' : 'retired',
|
|
1993
|
-
submitted: true,
|
|
1994
|
-
request_id: retiring.request_id,
|
|
1995
|
-
frozen_request_id: null,
|
|
1996
|
-
},
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
1999
|
-
const history = (state.question_history ?? []).find(
|
|
2000
|
-
(entry) => entry.question_id === questionId,
|
|
2001
|
-
)
|
|
2002
|
-
if (history !== undefined) {
|
|
2003
|
-
return {
|
|
2004
|
-
found: true,
|
|
2005
|
-
session_id: owner.sessionId,
|
|
2006
|
-
question: {
|
|
2007
|
-
question_id: history.question_id,
|
|
2008
|
-
state: history.state,
|
|
2009
|
-
submitted:
|
|
2010
|
-
history.request_id !== undefined
|
|
2011
|
-
? true
|
|
2012
|
-
: history.frozen_request_id !== undefined
|
|
2013
|
-
? null
|
|
2014
|
-
: false,
|
|
2015
|
-
request_id: history.request_id ?? null,
|
|
2016
|
-
frozen_request_id: history.frozen_request_id ?? null,
|
|
2017
|
-
},
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
return { found: false, ambiguous: false }
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
|
-
/** Registration identity — the convention every racing writer compares by. */
|
|
2024
|
-
function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
|
|
2025
|
-
if (a.question_id !== undefined || b.question_id !== undefined) {
|
|
2026
|
-
return a.question_id !== undefined && a.question_id === b.question_id
|
|
2027
|
-
}
|
|
2028
|
-
return a.question === b.question && a.asked_at === b.asked_at
|
|
2029
|
-
}
|
|
2030
|
-
|
|
2031
|
-
/**
|
|
2032
|
-
* The frozen draft itself will never be accepted. Replaying it forever hides a
|
|
2033
|
-
* contract-skew (deleted fields, unexpected properties) as a recoverable wait.
|
|
2034
|
-
* Auth, conflicts, and timeouts are not this class.
|
|
2035
|
-
*/
|
|
2036
|
-
function isTerminalDraftRejection(err: ApiCallError): boolean {
|
|
2037
|
-
return err.status === 422 || (err.status === 400 && err.code === 'invalid_request')
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
|
-
export function dropPendingQuestion(
|
|
2041
|
-
sessionId: string,
|
|
2042
|
-
env: NodeJS.ProcessEnv,
|
|
2043
|
-
entry: PendingQuestion,
|
|
2044
|
-
terminalState: QuestionTerminalState = 'retired',
|
|
2045
|
-
): void {
|
|
2046
|
-
updateSessionState(sessionId, env, (current) => {
|
|
2047
|
-
const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry))
|
|
2048
|
-
const next: SessionState = { ...current }
|
|
2049
|
-
if (pending.length > 0) next.pending = pending
|
|
2050
|
-
else delete next.pending
|
|
2051
|
-
return rememberQuestionState(next, entry, terminalState)
|
|
2052
|
-
})
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
/**
|
|
2056
|
-
* Drop registrations that never reached a device, so a later Stop cannot push
|
|
2057
|
-
* them. A retirement push would be noise: there is nothing on any device to
|
|
2058
|
-
* withdraw. Returns the withdrawn entries in registration order.
|
|
2059
|
-
*/
|
|
2060
|
-
export function withdrawUnpushedQuestions(
|
|
2061
|
-
sessionId: string,
|
|
2062
|
-
env: NodeJS.ProcessEnv,
|
|
2063
|
-
): PendingQuestion[] {
|
|
2064
|
-
let withdrawn: PendingQuestion[] = []
|
|
2065
|
-
updateSessionState(sessionId, env, (current) => {
|
|
2066
|
-
const pending = pendingList(current)
|
|
2067
|
-
withdrawn = pending.filter((entry) => entry.request_id === undefined)
|
|
2068
|
-
if (withdrawn.length === 0) return current
|
|
2069
|
-
const remaining = pending.filter((entry) => entry.request_id !== undefined)
|
|
2070
|
-
const next: SessionState = { ...current }
|
|
2071
|
-
if (remaining.length > 0) next.pending = remaining
|
|
2072
|
-
else delete next.pending
|
|
2073
|
-
return withdrawn.reduce(
|
|
2074
|
-
(remembered, entry) => rememberQuestionState(remembered, entry, 'withdrawn'),
|
|
2075
|
-
next,
|
|
2076
|
-
)
|
|
2077
|
-
})
|
|
2078
|
-
return withdrawn
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
function clearFrozenSubmission(
|
|
2082
|
-
sessionId: string,
|
|
2083
|
-
env: NodeJS.ProcessEnv,
|
|
2084
|
-
entry: PendingQuestion,
|
|
2085
|
-
): void {
|
|
2086
|
-
updateSessionState(sessionId, env, (current) => {
|
|
2087
|
-
const list = pendingList(current)
|
|
2088
|
-
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
2089
|
-
if (index < 0) return current
|
|
2090
|
-
const next = [...list]
|
|
2091
|
-
const copy = { ...next[index]! }
|
|
2092
|
-
delete copy.submission
|
|
2093
|
-
next[index] = copy
|
|
2094
|
-
return { ...current, pending: next }
|
|
2095
|
-
})
|
|
2096
|
-
}
|
|
2097
|
-
|
|
2098
|
-
/** The question set this pending record pushes, however it was registered. */
|
|
2099
|
-
function pendingQuestions(pending: PendingQuestion): QuestionT[] {
|
|
2100
|
-
return pending.questions ?? [{ id: 'q1', text: pending.question }]
|
|
2101
|
-
}
|
|
2102
|
-
|
|
2103
|
-
/** Did any question in the set offer choices? Decides how replies combine. */
|
|
2104
|
-
function pendingHasChoices(pending: PendingQuestion): boolean {
|
|
2105
|
-
return pendingQuestions(pending).some((question) => question.choices !== undefined)
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
/**
|
|
2109
|
-
* The answer as the agent should read it. For a question with choices the
|
|
2110
|
-
* latest reply IS the answer — an earlier conflicting one was corrected by
|
|
2111
|
-
* it. Free-text answers can arrive in parts, and every part reaches the
|
|
2112
|
-
* agent in the order it was written, so it can tell expansion from
|
|
2113
|
-
* correction itself.
|
|
2114
|
-
*
|
|
2115
|
-
* The continuation repeats only identity the agent actually registered and
|
|
2116
|
-
* the answer the server accepted. It never invents trust, urgency, permission,
|
|
2117
|
-
* or approval claims for the transport to assert.
|
|
2118
|
-
*/
|
|
2119
|
-
function answerContext(answered: AnsweredPending): string {
|
|
2120
|
-
const { pending, replies } = answered
|
|
2121
|
-
const latest = replies.at(-1)
|
|
2122
|
-
if (latest === undefined) return 'Notifai — no answer was recorded.'
|
|
2123
|
-
const answeredQuestionIds = [...new Set(latest.answers.map((answer) => answer.question_id))]
|
|
2124
|
-
const identity =
|
|
2125
|
-
answeredQuestionIds.length === 0
|
|
2126
|
-
? ''
|
|
2127
|
-
: `question_id${answeredQuestionIds.length === 1 ? '' : 's'} ${answeredQuestionIds.join(', ')}, `
|
|
2128
|
-
const question = `question ${JSON.stringify(pending.question)}`
|
|
2129
|
-
if (replies.length === 1 || pendingHasChoices(pending)) {
|
|
2130
|
-
return `Notifai — ${identity}${question}; the user answered ${JSON.stringify(latest.text)}.`
|
|
2131
|
-
}
|
|
2132
|
-
const parts = replies.map((reply) => JSON.stringify(reply.text)).join(', then ')
|
|
2133
|
-
return (
|
|
2134
|
-
`Notifai — ${identity}${question}; the user answered in ${replies.length} parts, ` +
|
|
2135
|
-
`in the order written: ${parts}. Later parts extend or correct earlier ones.`
|
|
2136
|
-
)
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
/**
|
|
2140
|
-
* Every answer that has arrived, as one message. Several registered questions
|
|
2141
|
-
* may resolve in one hook pass; the agent reads them together, each answer
|
|
2142
|
-
* tied to the question that asked it, with a truthful note about anything
|
|
2143
|
-
* still waiting.
|
|
2144
|
-
*/
|
|
2145
|
-
function acknowledgementCommand(requestId: string, textRequired = true): string {
|
|
2146
|
-
return textRequired
|
|
2147
|
-
? `notifai acknowledge ${requestId} --text <text>`
|
|
2148
|
-
: `notifai acknowledge ${requestId}`
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
/**
|
|
2152
|
-
* The acknowledgement is owed either way; only its text is conditional. So the
|
|
2153
|
-
* instruction never says "you may skip this" — it says what to run.
|
|
2154
|
-
*/
|
|
2155
|
-
function acknowledgementDemand(textRequired: boolean): string {
|
|
2156
|
-
return textRequired
|
|
2157
|
-
? ' with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient'
|
|
2158
|
-
: ' exactly as shown; this account turned acknowledgement text off, so the receipt carries no words'
|
|
2159
|
-
}
|
|
2160
|
-
|
|
2161
|
-
function acknowledgementContext(answered: AnsweredPending[]): string {
|
|
2162
|
-
const due = answered.filter(
|
|
2163
|
-
(entry) =>
|
|
2164
|
-
entry.agent_acknowledgement_required === true && entry.pending.request_id !== undefined,
|
|
2165
|
-
)
|
|
2166
|
-
if (due.length === 0) {
|
|
2167
|
-
return ' Agent Acknowledgement is not required for the answered request(s).'
|
|
2168
|
-
}
|
|
2169
|
-
if (due.length === 1) {
|
|
2170
|
-
const entry = due[0]!
|
|
2171
|
-
const requestId = entry.pending.request_id!
|
|
2172
|
-
const textRequired = entry.agent_acknowledgement_text_required !== false
|
|
2173
|
-
return (
|
|
2174
|
-
` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
|
|
2175
|
-
`\`${acknowledgementCommand(requestId, textRequired)}\`${acknowledgementDemand(textRequired)}.`
|
|
2176
|
-
)
|
|
2177
|
-
}
|
|
2178
|
-
const anyTextRequired = due.some((entry) => entry.agent_acknowledgement_text_required !== false)
|
|
2179
|
-
const commands = due
|
|
2180
|
-
.map((entry) => {
|
|
2181
|
-
const requestId = entry.pending.request_id!
|
|
2182
|
-
return `- ${requestId}: \`${acknowledgementCommand(requestId, entry.agent_acknowledgement_text_required !== false)}\``
|
|
2183
|
-
})
|
|
2184
|
-
.join('\n')
|
|
2185
|
-
return (
|
|
2186
|
-
` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below${acknowledgementDemand(anyTextRequired)}:\n` +
|
|
2187
|
-
commands
|
|
2188
|
-
)
|
|
2189
|
-
}
|
|
2190
|
-
|
|
2191
|
-
function answersContext(answered: AnsweredPending[], remaining: number): string {
|
|
2192
|
-
const tail =
|
|
2193
|
-
remaining > 0
|
|
2194
|
-
? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
|
|
2195
|
-
: ''
|
|
2196
|
-
const guidance = acknowledgementContext(answered)
|
|
2197
|
-
if (answered.length === 1) {
|
|
2198
|
-
return answerContext(answered[0]!) + tail + guidance
|
|
2199
|
-
}
|
|
2200
|
-
const lines = answered.map(({ pending, replies }) => {
|
|
2201
|
-
const latest = replies.at(-1)!
|
|
2202
|
-
const answer =
|
|
2203
|
-
replies.length === 1 || pendingHasChoices(pending)
|
|
2204
|
-
? JSON.stringify(latest.text)
|
|
2205
|
-
: `${replies.map((reply) => JSON.stringify(reply.text)).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`
|
|
2206
|
-
const ids = [...new Set(latest.answers.map((entry) => entry.question_id))]
|
|
2207
|
-
const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `
|
|
2208
|
-
return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`
|
|
2209
|
-
})
|
|
2210
|
-
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}${guidance}`
|
|
2211
|
-
}
|
|
2212
|
-
|
|
2213
|
-
function stopAnswerOutput(context: string): string {
|
|
2214
|
-
return JSON.stringify({
|
|
2215
|
-
decision: 'block',
|
|
2216
|
-
reason: context,
|
|
2217
|
-
})
|
|
2218
|
-
}
|
|
2219
|
-
|
|
2220
|
-
/**
|
|
2221
|
-
* Close, truthfully retire, and forget every answered pending question in one
|
|
2222
|
-
* state write, without dropping any other retirement debt or any question
|
|
2223
|
-
* still waiting. Stop answers also open one bounded continuation generation;
|
|
2224
|
-
* UserPromptSubmit answers ride the user's new turn.
|
|
2225
|
-
*/
|
|
2226
|
-
function stageAcceptedAnswers(
|
|
2227
|
-
ctx: HookContext,
|
|
2228
|
-
sessionId: string,
|
|
2229
|
-
answered: AnsweredPending[],
|
|
2230
|
-
remaining: number,
|
|
2231
|
-
): AcceptedAnswerDelivery {
|
|
2232
|
-
const accepted: AcceptedAnswerDelivery = {
|
|
2233
|
-
answers: answered,
|
|
2234
|
-
remaining,
|
|
2235
|
-
recorded_at: ctx.now(),
|
|
2236
|
-
}
|
|
2237
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2238
|
-
const pendingRemaining = pendingList(current).filter(
|
|
2239
|
-
(entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
|
|
2240
|
-
)
|
|
2241
|
-
const acknowledgementDue = [...(current.acknowledgement_due ?? [])]
|
|
2242
|
-
for (const answer of answered) {
|
|
2243
|
-
const requestId = answer.pending.request_id
|
|
2244
|
-
if (
|
|
2245
|
-
answer.agent_acknowledgement_required === true &&
|
|
2246
|
-
requestId !== undefined &&
|
|
2247
|
-
!acknowledgementDue.some((entry) => entry.request_id === requestId)
|
|
2248
|
-
) {
|
|
2249
|
-
acknowledgementDue.push({
|
|
2250
|
-
request_id: requestId,
|
|
2251
|
-
recorded_at: accepted.recorded_at,
|
|
2252
|
-
text_required: answer.agent_acknowledgement_text_required !== false,
|
|
2253
|
-
})
|
|
2254
|
-
}
|
|
2255
|
-
}
|
|
2256
|
-
const next: SessionState = {
|
|
2257
|
-
...current,
|
|
2258
|
-
accepted,
|
|
2259
|
-
}
|
|
2260
|
-
if (pendingRemaining.length > 0) next.pending = pendingRemaining
|
|
2261
|
-
else delete next.pending
|
|
2262
|
-
if (acknowledgementDue.length > 0) next.acknowledgement_due = acknowledgementDue
|
|
2263
|
-
else delete next.acknowledgement_due
|
|
2264
|
-
return answered.reduce(
|
|
2265
|
-
(remembered, answer) => rememberQuestionState(remembered, answer.pending, 'answered'),
|
|
2266
|
-
next,
|
|
2267
|
-
)
|
|
2268
|
-
})
|
|
2269
|
-
return accepted
|
|
2270
|
-
}
|
|
2271
|
-
|
|
2272
|
-
/** Amend the live accepted record in place, without resurrecting a settled one. */
|
|
2273
|
-
function amendAcceptedAnswers(
|
|
2274
|
-
ctx: HookContext,
|
|
2275
|
-
sessionId: string,
|
|
2276
|
-
amend: (accepted: AcceptedAnswerDelivery) => AcceptedAnswerDelivery,
|
|
2277
|
-
): void {
|
|
2278
|
-
updateSessionState(sessionId, ctx.env, (current) =>
|
|
2279
|
-
current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) },
|
|
2280
|
-
)
|
|
2281
|
-
}
|
|
2282
|
-
|
|
2283
|
-
/**
|
|
2284
|
-
* Finish a route write that linearized before SessionEnd.
|
|
2285
|
-
*
|
|
2286
|
-
* Ordinary state writers stop at the ended marker. This one narrow completion
|
|
2287
|
-
* is allowed through because the journal's own `delivery_committed_at` proves
|
|
2288
|
-
* the irreversible write won the ordering first; without the matching finish,
|
|
2289
|
-
* a later SessionStart would replay a write that already reached the harness.
|
|
2290
|
-
*/
|
|
2291
|
-
function finishCommittedDelivery(
|
|
2292
|
-
ctx: HookContext,
|
|
2293
|
-
sessionId: string,
|
|
2294
|
-
accepted: AcceptedAnswerDelivery,
|
|
2295
|
-
deliveredRoute: string,
|
|
2296
|
-
): void {
|
|
2297
|
-
const file = sessionStatePath(sessionId, ctx.env)
|
|
2298
|
-
withFileLock(`${file}.lock`, () => {
|
|
2299
|
-
const current = readSessionState(sessionId, ctx.env)
|
|
2300
|
-
if (
|
|
2301
|
-
current.accepted?.recorded_at !== accepted.recorded_at ||
|
|
2302
|
-
current.accepted.delivery_committed_at === undefined
|
|
2303
|
-
) {
|
|
2304
|
-
return
|
|
2305
|
-
}
|
|
2306
|
-
writeSessionStateUnlocked(file, sessionId, {
|
|
2307
|
-
...current,
|
|
2308
|
-
accepted: {
|
|
2309
|
-
...current.accepted,
|
|
2310
|
-
delivered_at: ctx.now(),
|
|
2311
|
-
delivered_route: deliveredRoute,
|
|
2312
|
-
},
|
|
2313
|
-
})
|
|
2314
|
-
})
|
|
2315
|
-
}
|
|
2316
|
-
|
|
2317
|
-
export function clearAcknowledgementObligation(
|
|
2318
|
-
sessionId: string,
|
|
2319
|
-
env: NodeJS.ProcessEnv,
|
|
2320
|
-
requestId: string,
|
|
2321
|
-
): boolean {
|
|
2322
|
-
let cleared = false
|
|
2323
|
-
updateSessionState(sessionId, env, (current) => {
|
|
2324
|
-
const due = current.acknowledgement_due ?? []
|
|
2325
|
-
const remaining = due.filter((entry) => entry.request_id !== requestId)
|
|
2326
|
-
cleared = remaining.length !== due.length
|
|
2327
|
-
if (!cleared) return current
|
|
2328
|
-
const next = { ...current }
|
|
2329
|
-
if (remaining.length > 0) next.acknowledgement_due = remaining
|
|
2330
|
-
else delete next.acknowledgement_due
|
|
2331
|
-
return next
|
|
2332
|
-
})
|
|
2333
|
-
return cleared
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
|
-
function acknowledgementBlockContext(due: readonly AcknowledgementDue[]): string {
|
|
2337
|
-
const commands = due
|
|
2338
|
-
.map(
|
|
2339
|
-
(entry) =>
|
|
2340
|
-
`- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id, entry.text_required !== false)}\``,
|
|
2341
|
-
)
|
|
2342
|
-
.join('\n')
|
|
2343
|
-
const anyTextRequired = due.some((entry) => entry.text_required !== false)
|
|
2344
|
-
return (
|
|
2345
|
-
`Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
|
|
2346
|
-
`Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}${acknowledgementDemand(anyTextRequired)}:\n${commands}`
|
|
2347
|
-
)
|
|
2348
|
-
}
|
|
2349
|
-
|
|
2350
|
-
/**
|
|
2351
|
-
* How many turns in a row a session may be held waiting for an acknowledgement.
|
|
2352
|
-
*
|
|
2353
|
-
* The gate is the one place hooks deliberately break the fail-open rule, and it
|
|
2354
|
-
* had no bound at all: an agent that could not or would not acknowledge — or a
|
|
2355
|
-
* server that stayed unreachable, since an error here counts as unresolved —
|
|
2356
|
-
* held every turn of that session for ever. Blocking a user's agent
|
|
2357
|
-
* indefinitely does not get them an acknowledgement; it costs them the agent as
|
|
2358
|
-
* well as the acknowledgement.
|
|
2359
|
-
*/
|
|
2360
|
-
const MAX_ACKNOWLEDGEMENT_BLOCKS = 3
|
|
2361
|
-
|
|
2362
|
-
/**
|
|
2363
|
-
* Hold the turn for an outstanding acknowledgement, or give up and let it
|
|
2364
|
-
* through once holding has stopped being worth its cost.
|
|
2365
|
-
*
|
|
2366
|
-
* Returns the outcome that blocks the turn, or `null` to carry on.
|
|
2367
|
-
*/
|
|
2368
|
-
function holdForAcknowledgement(
|
|
2369
|
-
ctx: HookContext,
|
|
2370
|
-
sessionId: string,
|
|
2371
|
-
due: readonly AcknowledgementDue[],
|
|
2372
|
-
notes: string[],
|
|
2373
|
-
): HookOutcome | null {
|
|
2374
|
-
if (due.length === 0) return null
|
|
2375
|
-
const requestIds = due.map((entry) => entry.request_id)
|
|
2376
|
-
const blocks = (readSessionState(sessionId, ctx.env).acknowledgement_blocks ?? 0) + 1
|
|
2377
|
-
if (blocks > MAX_ACKNOWLEDGEMENT_BLOCKS) {
|
|
2378
|
-
// Drop the obligation with the reason recorded. The answer was already
|
|
2379
|
-
// delivered; what is lost is the agent's receipt for it, and the log is
|
|
2380
|
-
// where that loss stays visible.
|
|
2381
|
-
for (const requestId of requestIds) clearAcknowledgementObligation(sessionId, ctx.env, requestId)
|
|
2382
|
-
resetAcknowledgementBlocks(sessionId, ctx.env)
|
|
2383
|
-
gate(ctx, 'proceeding', 'acknowledgement-abandoned', {
|
|
2384
|
-
request_ids: requestIds,
|
|
2385
|
-
blocks: blocks - 1,
|
|
2386
|
-
limit: MAX_ACKNOWLEDGEMENT_BLOCKS,
|
|
2387
|
-
})
|
|
2388
|
-
notes.push(
|
|
2389
|
-
`no acknowledgement after ${MAX_ACKNOWLEDGEMENT_BLOCKS} turns; continuing without one rather than holding this session for ever`,
|
|
2390
|
-
)
|
|
2391
|
-
return null
|
|
2392
|
-
}
|
|
2393
|
-
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
2394
|
-
...current,
|
|
2395
|
-
acknowledgement_blocks: blocks,
|
|
2396
|
-
}))
|
|
2397
|
-
gate(ctx, 'held', 'acknowledgement-required', { request_ids: requestIds, blocks })
|
|
2398
|
-
return {
|
|
2399
|
-
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
2400
|
-
notes,
|
|
2401
|
-
log: { stage: 'acknowledgement-required', request_ids: requestIds },
|
|
2402
|
-
}
|
|
2403
|
-
}
|
|
2404
|
-
|
|
2405
|
-
/** A turn that was not held resets the streak; only consecutive holds count. */
|
|
2406
|
-
function resetAcknowledgementBlocks(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
2407
|
-
updateSessionState(sessionId, env, (current) => {
|
|
2408
|
-
if (current.acknowledgement_blocks === undefined) return current
|
|
2409
|
-
const next = { ...current }
|
|
2410
|
-
delete next.acknowledgement_blocks
|
|
2411
|
-
return next
|
|
2412
|
-
})
|
|
2413
|
-
}
|
|
2414
|
-
|
|
2415
|
-
async function reconcileAcknowledgementObligations(
|
|
2416
|
-
ctx: HookContext,
|
|
2417
|
-
sessionId: string,
|
|
2418
|
-
due: readonly AcknowledgementDue[],
|
|
2419
|
-
): Promise<AcknowledgementDue[]> {
|
|
2420
|
-
const unresolved: AcknowledgementDue[] = []
|
|
2421
|
-
for (const obligation of due) {
|
|
2422
|
-
try {
|
|
2423
|
-
const snapshot = await ctx.client.agentAcknowledgement(obligation.request_id, {
|
|
2424
|
-
waitSeconds: 0,
|
|
2425
|
-
})
|
|
2426
|
-
if (
|
|
2427
|
-
snapshot.agent_acknowledgement_required === false ||
|
|
2428
|
-
snapshot.agent_acknowledgement !== null
|
|
2429
|
-
) {
|
|
2430
|
-
clearAcknowledgementObligation(sessionId, ctx.env, obligation.request_id)
|
|
2431
|
-
} else {
|
|
2432
|
-
unresolved.push(obligation)
|
|
2433
|
-
}
|
|
2434
|
-
} catch (err) {
|
|
2435
|
-
unresolved.push(obligation)
|
|
2436
|
-
ctx.log?.error('hook.gate', {
|
|
2437
|
-
verdict: 'held',
|
|
2438
|
-
reason: 'acknowledgement-required',
|
|
2439
|
-
stage: 'reconcile-failed',
|
|
2440
|
-
request_id: obligation.request_id,
|
|
2441
|
-
message: err instanceof Error ? err.message : String(err),
|
|
2442
|
-
})
|
|
2443
|
-
}
|
|
2444
|
-
}
|
|
2445
|
-
return unresolved
|
|
2446
|
-
}
|
|
2447
|
-
|
|
2448
|
-
/**
|
|
2449
|
-
* Retire the answered questions and close the journal.
|
|
2450
|
-
*
|
|
2451
|
-
* Called once an answer's delivery is acknowledged — by the route's own write,
|
|
2452
|
-
* or by the successor Stop of a blocking continuation. The continuation counter
|
|
2453
|
-
* grows with each settled generation, so the cap that bounds chained
|
|
2454
|
-
* answer-to-question loops is a real count rather than a constant 1.
|
|
2455
|
-
*/
|
|
2456
|
-
function settleAcceptedAnswers(
|
|
2457
|
-
ctx: HookContext,
|
|
2458
|
-
sessionId: string,
|
|
2459
|
-
accepted: AcceptedAnswerDelivery,
|
|
2460
|
-
cwd?: string,
|
|
2461
|
-
): void {
|
|
2462
|
-
const retirements = accepted.answers
|
|
2463
|
-
.map(({ pending }) => retiringQuestion(pending, 'answered', cwd))
|
|
2464
|
-
.filter((entry): entry is RetiringQuestion => entry !== null)
|
|
2465
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2466
|
-
const retiring = [...(current.retiring ?? [])]
|
|
2467
|
-
for (const retirement of retirements) {
|
|
2468
|
-
const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id)
|
|
2469
|
-
if (existing < 0) retiring.push(retirement)
|
|
2470
|
-
else retiring[existing] = retirement
|
|
2471
|
-
}
|
|
2472
|
-
const next: SessionState = { ...current, retiring }
|
|
2473
|
-
delete next.accepted
|
|
2474
|
-
next.continuation = {
|
|
2475
|
-
answered_at: accepted.recorded_at,
|
|
2476
|
-
count: (current.continuation?.count ?? 0) + 1,
|
|
2477
|
-
}
|
|
2478
|
-
return next
|
|
2479
|
-
})
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
async function retirePendings(
|
|
2483
|
-
ctx: HookContext,
|
|
2484
|
-
envelope: HookEnvelope,
|
|
2485
|
-
sessionId: string,
|
|
2486
|
-
entries: PendingQuestion[],
|
|
2487
|
-
state: LifecycleEndState,
|
|
2488
|
-
): Promise<void> {
|
|
2489
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2490
|
-
const retiring = [...(current.retiring ?? [])]
|
|
2491
|
-
for (const entry of entries) {
|
|
2492
|
-
const retirement = retiringQuestion(entry, state, envelope.cwd)
|
|
2493
|
-
if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
|
|
2494
|
-
retiring.push(retirement)
|
|
2495
|
-
}
|
|
2496
|
-
}
|
|
2497
|
-
const pending = pendingList(current).filter(
|
|
2498
|
-
(candidate) => !entries.some((entry) => isSamePending(candidate, entry)),
|
|
2499
|
-
)
|
|
2500
|
-
const next: SessionState = { ...current }
|
|
2501
|
-
if (pending.length > 0) next.pending = pending
|
|
2502
|
-
else delete next.pending
|
|
2503
|
-
if (retiring.length > 0) next.retiring = retiring
|
|
2504
|
-
else delete next.retiring
|
|
2505
|
-
return entries.reduce(
|
|
2506
|
-
(remembered, entry) => rememberQuestionState(
|
|
2507
|
-
remembered,
|
|
2508
|
-
entry,
|
|
2509
|
-
state === 'answered' ? 'answered' : 'retired',
|
|
2510
|
-
),
|
|
2511
|
-
next,
|
|
2512
|
-
)
|
|
2513
|
-
})
|
|
2514
|
-
// Network retirement is deliberately deferred to a later no-question hook.
|
|
2515
|
-
// The state write above is the durable operation; blocking here creates a
|
|
2516
|
-
// crash window before an answer can reach harness stdout.
|
|
2517
|
-
}
|
|
2518
|
-
|
|
2519
|
-
// ---------------------------------------------------------------------------
|
|
2520
|
-
// UserPromptSubmit — the user is at the keyboard
|
|
2521
|
-
// ---------------------------------------------------------------------------
|
|
2522
|
-
|
|
2523
|
-
/**
|
|
2524
|
-
* Records presence. Retires a live question only when this prompt plausibly
|
|
2525
|
-
* answers it. Typing in the terminal is not by itself a resolution: the User
|
|
2526
|
-
* may be answering a different question on a device.
|
|
2527
|
-
*/
|
|
2528
|
-
export async function handleUserPromptSubmit(
|
|
2529
|
-
ctx: HookContext,
|
|
2530
|
-
envelope: HookEnvelope,
|
|
2531
|
-
): Promise<HookOutcome> {
|
|
2532
|
-
const notes: string[] = []
|
|
2533
|
-
const sessionId = envelope.session_id
|
|
2534
|
-
if (!sessionId) return { notes }
|
|
2535
|
-
|
|
2536
|
-
const state = readSessionState(sessionId, ctx.env)
|
|
2537
|
-
if (state.accepted !== undefined) {
|
|
2538
|
-
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
2539
|
-
...current,
|
|
2540
|
-
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
2541
|
-
last_prompt_at: ctx.now(),
|
|
2542
|
-
}))
|
|
2543
|
-
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
2544
|
-
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
|
|
2545
|
-
}
|
|
2546
|
-
notes.push(
|
|
2547
|
-
state.accepted.delivered_at === undefined
|
|
2548
|
-
? 'a device answer is safely journaled; the next Stop will deliver it'
|
|
2549
|
-
: 'a device answer has already been delivered; the next Stop will close it out',
|
|
2550
|
-
)
|
|
2551
|
-
return { notes }
|
|
2552
|
-
}
|
|
2553
|
-
const live = pendingList(state).filter((entry) => entry.request_id !== undefined)
|
|
2554
|
-
let lateAnswers: AnsweredPending[] = []
|
|
2555
|
-
if (live.length > 0) {
|
|
2556
|
-
const { answered, transientTrouble, permanentFailures } = await pollPendingReplies(
|
|
2557
|
-
ctx,
|
|
2558
|
-
live,
|
|
2559
|
-
LATE_PROMPT_POLL_SECONDS,
|
|
2560
|
-
)
|
|
2561
|
-
const finalized = await finalizePendings(
|
|
2562
|
-
ctx,
|
|
2563
|
-
answered.map((entry) => entry.pending),
|
|
2564
|
-
)
|
|
2565
|
-
lateAnswers = answered.map((entry) => {
|
|
2566
|
-
const response = finalized.find((candidate) =>
|
|
2567
|
-
isSamePending(candidate.pending, entry.pending),
|
|
2568
|
-
)?.response
|
|
2569
|
-
const replies = response?.replies.length ? response.replies : entry.replies
|
|
2570
|
-
if (response === null) {
|
|
2571
|
-
notes.push(
|
|
2572
|
-
`could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`,
|
|
2573
|
-
)
|
|
2574
|
-
}
|
|
2575
|
-
return {
|
|
2576
|
-
...entry,
|
|
2577
|
-
replies,
|
|
2578
|
-
reply: replies.at(-1)!,
|
|
2579
|
-
agent_acknowledgement_required:
|
|
2580
|
-
response?.agent_acknowledgement_required ??
|
|
2581
|
-
entry.agent_acknowledgement_required,
|
|
2582
|
-
agent_acknowledgement_text_required:
|
|
2583
|
-
response?.agent_acknowledgement_text_required ??
|
|
2584
|
-
entry.agent_acknowledgement_text_required,
|
|
2585
|
-
}
|
|
2586
|
-
})
|
|
2587
|
-
if (answered.length > 0) {
|
|
2588
|
-
stageAcceptedAnswers(
|
|
2589
|
-
ctx,
|
|
2590
|
-
sessionId,
|
|
2591
|
-
lateAnswers,
|
|
2592
|
-
0,
|
|
2593
|
-
)
|
|
2594
|
-
for (const answer of lateAnswers) reportAnswer(ctx, notes, answer, true)
|
|
2595
|
-
}
|
|
2596
|
-
if (transientTrouble) {
|
|
2597
|
-
notes.push('could not check every pending question for a late answer before the prompt')
|
|
2598
|
-
}
|
|
2599
|
-
const permanentFailure = permanentReplyFailureNote(permanentFailures)
|
|
2600
|
-
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
2601
|
-
}
|
|
2602
|
-
const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state))
|
|
2603
|
-
for (const entry of matched) {
|
|
2604
|
-
ctx.log?.info('hook.retirement', {
|
|
2605
|
-
request_id: entry.request_id,
|
|
2606
|
-
question_id: entry.question_id,
|
|
2607
|
-
reason: 'prompt-matched',
|
|
2608
|
-
attempted: entry.request_id !== undefined,
|
|
2609
|
-
proven: false,
|
|
2610
|
-
})
|
|
2611
|
-
}
|
|
2612
|
-
// Park before dropping `pending`. If the process dies between these writes,
|
|
2613
|
-
// the next hook sees both copies and dedupes them; the old order could die in
|
|
2614
|
-
// the gap after erasing the only request/collapse/device identifiers.
|
|
2615
|
-
const updated = updateSessionState(sessionId, ctx.env, (current) => {
|
|
2616
|
-
const retiring = [...(current.retiring ?? [])]
|
|
2617
|
-
const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current))
|
|
2618
|
-
const unmatched = pendingList(current).filter(
|
|
2619
|
-
(entry) => !matchedNow.some((item) => isSamePending(item, entry)),
|
|
2620
|
-
)
|
|
2621
|
-
for (const entry of matchedNow.filter((item) => item.request_id !== undefined)) {
|
|
2622
|
-
const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd)
|
|
2623
|
-
if (
|
|
2624
|
-
retirement !== null &&
|
|
2625
|
-
!retiring.some((parked) => parked.request_id === retirement.request_id)
|
|
2626
|
-
) {
|
|
2627
|
-
retiring.push(retirement)
|
|
2628
|
-
}
|
|
2629
|
-
}
|
|
2630
|
-
const unasked = unmatched.filter((entry) => entry.request_id === undefined)
|
|
2631
|
-
const stillLive = unmatched.filter((entry) => entry.request_id !== undefined)
|
|
2632
|
-
const remaining = [...stillLive, ...unasked]
|
|
2633
|
-
|
|
2634
|
-
// Start from the whole document so fields introduced by a newer CLI remain
|
|
2635
|
-
// attached while this older writer applies the prompt transition it knows.
|
|
2636
|
-
const next: SessionState = {
|
|
2637
|
-
...current,
|
|
2638
|
-
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
2639
|
-
last_prompt_at: ctx.now(),
|
|
2640
|
-
}
|
|
2641
|
-
if (remaining.length > 0) next.pending = remaining
|
|
2642
|
-
else delete next.pending
|
|
2643
|
-
if (retiring.length > 0) next.retiring = retiring
|
|
2644
|
-
else delete next.retiring
|
|
2645
|
-
|
|
2646
|
-
// The user typed, so whatever chain of answer-driven continuations was
|
|
2647
|
-
// running, a human has taken the turn and the consecutive count starts
|
|
2648
|
-
// over. Only a real prompt reaches here: the journal branch above returns
|
|
2649
|
-
// first, and a wake route's injected message always arrives while its own
|
|
2650
|
-
// answer is still journaled, so it can never pass for the user's presence.
|
|
2651
|
-
if (current.continuation !== undefined) {
|
|
2652
|
-
next.continuation = { ...current.continuation, count: 0 }
|
|
2653
|
-
}
|
|
2654
|
-
return matchedNow.reduce(
|
|
2655
|
-
(remembered, entry) => rememberQuestionState(
|
|
2656
|
-
remembered,
|
|
2657
|
-
entry,
|
|
2658
|
-
entry.request_id === undefined ? 'withdrawn' : 'retired',
|
|
2659
|
-
),
|
|
2660
|
-
next,
|
|
2661
|
-
)
|
|
2662
|
-
})
|
|
2663
|
-
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
2664
|
-
// session: an agent shell command gets no hook payload, and not every
|
|
2665
|
-
// harness exports an id in the same shape.
|
|
2666
|
-
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
2667
|
-
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
|
|
2668
|
-
}
|
|
2669
|
-
|
|
2670
|
-
if (lateAnswers.length > 0) {
|
|
2671
|
-
// UserPromptSubmit has no later hook field proving its stdout reached the
|
|
2672
|
-
// model. Keep the journal and let the next Stop use its acknowledged
|
|
2673
|
-
// continuation channel instead of recreating the crash-before-stdout gap.
|
|
2674
|
-
notes.push('the late device answer will continue the agent at this turn’s Stop')
|
|
2675
|
-
return {
|
|
2676
|
-
notes,
|
|
2677
|
-
settlementRequired: pendingList(updated).some((entry) => entry.request_id === undefined),
|
|
2678
|
-
}
|
|
2679
|
-
}
|
|
2680
|
-
const retired = await drainRetirements(ctx, sessionId, ctx.env)
|
|
2681
|
-
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
2682
|
-
const swept = [...retired, ...orphaned]
|
|
2683
|
-
if (swept.length > 0) {
|
|
2684
|
-
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
|
|
2685
|
-
}
|
|
2686
|
-
return {
|
|
2687
|
-
notes,
|
|
2688
|
-
settlementRequired: pendingList(updated).some((entry) => entry.request_id === undefined),
|
|
2689
|
-
}
|
|
2690
|
-
}
|
|
2691
|
-
|
|
2692
|
-
// ---------------------------------------------------------------------------
|
|
2693
|
-
// Stop — the turn ended; escalate a registered question
|
|
2694
|
-
// ---------------------------------------------------------------------------
|
|
2695
|
-
|
|
2696
|
-
/**
|
|
2697
|
-
* Only engages for a question the agent explicitly registered with
|
|
2698
|
-
* `notifai ask`. Guessing from the last assistant message was the alternative
|
|
2699
|
-
* and it is not worth it: a false positive here hijacks the terminal.
|
|
2700
|
-
*/
|
|
2701
|
-
export async function handleStop(
|
|
2702
|
-
ctx: HookContext,
|
|
2703
|
-
envelope: HookEnvelope,
|
|
2704
|
-
processDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000,
|
|
2705
|
-
route: EscalationDeliveryRoute = hookContinuationRoute(),
|
|
2706
|
-
recordStop = true,
|
|
2707
|
-
): Promise<HookOutcome> {
|
|
2708
|
-
const sessionId = envelope.session_id
|
|
2709
|
-
if (!sessionId) {
|
|
2710
|
-
gate(ctx, 'held', 'no-session', { stage: 'queued' })
|
|
2711
|
-
return { notes: [] }
|
|
2712
|
-
}
|
|
2713
|
-
return runEscalationWaiter(ctx, {
|
|
2714
|
-
sessionId,
|
|
2715
|
-
envelope,
|
|
2716
|
-
route,
|
|
2717
|
-
processDeadlineAt,
|
|
2718
|
-
recordStop,
|
|
2719
|
-
})
|
|
2720
|
-
}
|
|
2721
|
-
|
|
2722
|
-
/**
|
|
2723
|
-
* Acquire the per-session owner, waiting only when this Stop carries a
|
|
2724
|
-
* newly registered question. The live owner sees that state on its next reply
|
|
2725
|
-
* poll and yields; without this handoff the new question could sit unpushed for
|
|
2726
|
-
* the older question's complete answer window.
|
|
2727
|
-
*/
|
|
2728
|
-
async function acquireQuestionOwner(
|
|
2729
|
-
ctx: HookContext,
|
|
2730
|
-
sessionId: string,
|
|
2731
|
-
hardDeadlineAt: number,
|
|
2732
|
-
): Promise<boolean> {
|
|
2733
|
-
if (sessionHasEnded(sessionId, ctx.env)) return false
|
|
2734
|
-
if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt)) return true
|
|
2735
|
-
|
|
2736
|
-
while (ctx.now() < hardDeadlineAt) {
|
|
2737
|
-
const handoff = claimHandoffState(sessionId, ctx.env)
|
|
2738
|
-
if (!handoff.hasNewQuestion) return false
|
|
2739
|
-
const holderReleaseDeadlineAt = Math.min(
|
|
2740
|
-
hardDeadlineAt,
|
|
2741
|
-
handoff.ownerDeadlineAt === undefined
|
|
2742
|
-
? hardDeadlineAt
|
|
2743
|
-
: handoff.ownerDeadlineAt + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS * 1000,
|
|
2744
|
-
)
|
|
2745
|
-
if (ctx.now() >= holderReleaseDeadlineAt) return false
|
|
2746
|
-
await ctx.sleep(
|
|
2747
|
-
Math.min(CLAIM_HANDOFF_POLL_MS, holderReleaseDeadlineAt - ctx.now()),
|
|
2748
|
-
)
|
|
2749
|
-
if (sessionHasEnded(sessionId, ctx.env)) return false
|
|
2750
|
-
if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt)) return true
|
|
2751
|
-
}
|
|
2752
|
-
return false
|
|
2753
|
-
}
|
|
2754
|
-
|
|
2755
|
-
/**
|
|
2756
|
-
* Own one registered question pipeline from its terminal-first timer through
|
|
2757
|
-
* its final delivery or retirement. Hosts differ only in the injected route.
|
|
2758
|
-
*/
|
|
2759
|
-
export async function runEscalationWaiter(
|
|
2760
|
-
ctx: HookContext,
|
|
2761
|
-
options: EscalationWaiterOptions,
|
|
2762
|
-
): Promise<HookOutcome> {
|
|
2763
|
-
const notes: string[] = []
|
|
2764
|
-
const { sessionId, envelope } = options
|
|
2765
|
-
const hardDeadlineAt =
|
|
2766
|
-
options.processDeadlineAt ?? ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000
|
|
2767
|
-
|
|
2768
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
2769
|
-
notes.push('the Agent Session already ended; no answer observer was started')
|
|
2770
|
-
return { notes }
|
|
2771
|
-
}
|
|
2772
|
-
|
|
2773
|
-
// This event marker is diagnostic evidence in its own right. Record it
|
|
2774
|
-
// before every early return so doctor can distinguish a working Stop route
|
|
2775
|
-
// from the prompt-only state that previously looked healthy.
|
|
2776
|
-
if (options.recordStop !== false) {
|
|
2777
|
-
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
2778
|
-
...current,
|
|
2779
|
-
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
2780
|
-
last_stop_at: ctx.now(),
|
|
2781
|
-
}))
|
|
2782
|
-
}
|
|
2783
|
-
|
|
2784
|
-
// The waiter, not its host, owns this lease. It spans grace, submission,
|
|
2785
|
-
// polling, close fencing, route delivery, and every retirement path.
|
|
2786
|
-
// Real clock, deliberately, not `ctx.now` — the claim answers whether
|
|
2787
|
-
// another process is alive right now, which an injected clock cannot know.
|
|
2788
|
-
if (!(await acquireQuestionOwner(ctx, sessionId, hardDeadlineAt))) {
|
|
2789
|
-
gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' })
|
|
2790
|
-
notes.push('another hook is already handling this session; a newly registered question remains queued for the next owner')
|
|
2791
|
-
return { notes }
|
|
2792
|
-
}
|
|
2793
|
-
let settledAnswerThisPass = false
|
|
2794
|
-
try {
|
|
2795
|
-
let state = readSessionState(sessionId, ctx.env)
|
|
2796
|
-
if (state.accepted !== undefined) {
|
|
2797
|
-
const accepted = state.accepted
|
|
2798
|
-
if (envelope.stop_hook_active === true && pendingList(state).length > 0) {
|
|
2799
|
-
const due = await reconcileAcknowledgementObligations(
|
|
2800
|
-
ctx,
|
|
2801
|
-
sessionId,
|
|
2802
|
-
state.acknowledgement_due ?? [],
|
|
2803
|
-
)
|
|
2804
|
-
if (due.length > 0) {
|
|
2805
|
-
gate(ctx, 'held', 'acknowledgement-required', {
|
|
2806
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
2807
|
-
})
|
|
2808
|
-
return {
|
|
2809
|
-
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
2810
|
-
notes,
|
|
2811
|
-
log: {
|
|
2812
|
-
stage: 'acknowledgement-required',
|
|
2813
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
2814
|
-
},
|
|
2815
|
-
}
|
|
2816
|
-
}
|
|
2817
|
-
state = readSessionState(sessionId, ctx.env)
|
|
2818
|
-
}
|
|
2819
|
-
// Acknowledgement is per route, because routes end in different places.
|
|
2820
|
-
//
|
|
2821
|
-
// A blocking continuation ends at this process's stdout, which the
|
|
2822
|
-
// harness reads only after the process exits: the recursive Stop's
|
|
2823
|
-
// `stop_hook_active` is the acknowledgement, and persisting a separate
|
|
2824
|
-
// "offered" bit before stdout would recreate the crash gap this journal
|
|
2825
|
-
// exists to close. An out-of-band route ends at a write this process made
|
|
2826
|
-
// itself and recorded here. A socket write is not proof the model acted
|
|
2827
|
-
// on the answer — delivery is not consumption — but nothing later can
|
|
2828
|
-
// prove more, and an answer redelivered without end is strictly worse
|
|
2829
|
-
// than one settled on delivery. So a recorded delivery settles.
|
|
2830
|
-
const deliveryProven =
|
|
2831
|
-
accepted.delivered_at !== undefined || envelope.stop_hook_active === true
|
|
2832
|
-
if (!deliveryProven) {
|
|
2833
|
-
for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
|
|
2834
|
-
return await deliverAcceptedAnswers(
|
|
2835
|
-
ctx,
|
|
2836
|
-
sessionId,
|
|
2837
|
-
options.route,
|
|
2838
|
-
accepted,
|
|
2839
|
-
notes,
|
|
2840
|
-
envelope.cwd,
|
|
2841
|
-
)
|
|
2842
|
-
}
|
|
2843
|
-
settleAcceptedAnswers(ctx, sessionId, accepted, envelope.cwd)
|
|
2844
|
-
settledAnswerThisPass = true
|
|
2845
|
-
state = readSessionState(sessionId, ctx.env)
|
|
2846
|
-
const currentAcceptedIds = new Set(
|
|
2847
|
-
accepted.answers.flatMap(({ pending }) =>
|
|
2848
|
-
pending.request_id === undefined ? [] : [pending.request_id],
|
|
2849
|
-
),
|
|
2850
|
-
)
|
|
2851
|
-
const due = await reconcileAcknowledgementObligations(
|
|
2852
|
-
ctx,
|
|
2853
|
-
sessionId,
|
|
2854
|
-
(state.acknowledgement_due ?? []).filter((entry) =>
|
|
2855
|
-
currentAcceptedIds.has(entry.request_id),
|
|
2856
|
-
),
|
|
2857
|
-
)
|
|
2858
|
-
const held = holdForAcknowledgement(ctx, sessionId, due, notes)
|
|
2859
|
-
if (held !== null) return held
|
|
2860
|
-
state = readSessionState(sessionId, ctx.env)
|
|
2861
|
-
}
|
|
2862
|
-
|
|
2863
|
-
if (state.accepted === undefined && (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
2864
|
-
const due = await reconcileAcknowledgementObligations(
|
|
2865
|
-
ctx,
|
|
2866
|
-
sessionId,
|
|
2867
|
-
state.acknowledgement_due!,
|
|
2868
|
-
)
|
|
2869
|
-
const held = holdForAcknowledgement(ctx, sessionId, due, notes)
|
|
2870
|
-
if (held !== null) return held
|
|
2871
|
-
resetAcknowledgementBlocks(sessionId, ctx.env)
|
|
2872
|
-
state = readSessionState(sessionId, ctx.env)
|
|
2873
|
-
}
|
|
2874
|
-
|
|
2875
|
-
const pending = pendingList(state)
|
|
2876
|
-
if (pending.length === 0) {
|
|
2877
|
-
const swept = [
|
|
2878
|
-
...(await drainRetirements(ctx, sessionId, ctx.env)),
|
|
2879
|
-
...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
|
|
2880
|
-
]
|
|
2881
|
-
if (swept.length > 0) {
|
|
2882
|
-
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
|
|
2883
|
-
}
|
|
2884
|
-
gate(ctx, 'held', 'no-question', { stage: 'queued' })
|
|
2885
|
-
return { notes }
|
|
2886
|
-
}
|
|
2887
|
-
|
|
2888
|
-
return await handleClaimedStop(
|
|
2889
|
-
ctx,
|
|
2890
|
-
envelope,
|
|
2891
|
-
sessionId,
|
|
2892
|
-
state,
|
|
2893
|
-
pending,
|
|
2894
|
-
notes,
|
|
2895
|
-
hardDeadlineAt,
|
|
2896
|
-
options.route,
|
|
2897
|
-
// Every route's successor turn-end, named the same way: the harness flag
|
|
2898
|
-
// for a blocking continuation, and the settle above for a route that woke
|
|
2899
|
-
// a brand-new turn out of band.
|
|
2900
|
-
envelope.stop_hook_active === true || settledAnswerThisPass,
|
|
2901
|
-
)
|
|
2902
|
-
} finally {
|
|
2903
|
-
releaseQuestionPush(sessionId, ctx.env)
|
|
2904
|
-
}
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
/**
|
|
2908
|
-
* The blocking Stop hook's own last meter: the answer becomes this process's
|
|
2909
|
-
* stdout, and the harness admits it as a new user turn. Exported because a
|
|
2910
|
-
* host adapter that has other routes still falls back to this one.
|
|
2911
|
-
*/
|
|
2912
|
-
export function hookContinuationRoute(): EscalationDeliveryRoute {
|
|
2913
|
-
return {
|
|
2914
|
-
kind: 'hook-continuation',
|
|
2915
|
-
deliver: async (event) => ({
|
|
2916
|
-
stdout: stopAnswerOutput(event.context),
|
|
2917
|
-
commitStdout: event.commitDelivery,
|
|
2918
|
-
notes: [],
|
|
2919
|
-
// The harness reads this stdout after the process exits, so only the
|
|
2920
|
-
// successor Stop can acknowledge it.
|
|
2921
|
-
acknowledgement: 'stdout',
|
|
2922
|
-
}),
|
|
2923
|
-
}
|
|
2924
|
-
}
|
|
2925
|
-
|
|
2926
|
-
/**
|
|
2927
|
-
* Hand one accepted answer to a route and record what the attempt proved.
|
|
2928
|
-
*
|
|
2929
|
-
* Every delivery on every route passes through here, which is what makes the
|
|
2930
|
-
* cap below a backstop rather than a suggestion. A guard only one route
|
|
2931
|
-
* consults is not a backstop: that is how a single answer once reached a
|
|
2932
|
-
* session 250 times.
|
|
2933
|
-
*/
|
|
2934
|
-
async function deliverAcceptedAnswers(
|
|
2935
|
-
ctx: HookContext,
|
|
2936
|
-
sessionId: string,
|
|
2937
|
-
route: EscalationDeliveryRoute,
|
|
2938
|
-
accepted: AcceptedAnswerDelivery,
|
|
2939
|
-
notes: string[],
|
|
2940
|
-
cwd?: string,
|
|
2941
|
-
): Promise<HookOutcome> {
|
|
2942
|
-
const { answers: answered, remaining } = accepted
|
|
2943
|
-
const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
|
|
2944
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
2945
|
-
notes.push('the Agent Session ended before answer delivery; stopping this observer')
|
|
2946
|
-
return { notes }
|
|
2947
|
-
}
|
|
2948
|
-
const held = accepted.held_deliveries ?? 0
|
|
2949
|
-
if (held >= MAX_HELD_DELIVERIES) {
|
|
2950
|
-
gate(ctx, 'held', 'delivery-limit', {
|
|
2951
|
-
route: route.kind,
|
|
2952
|
-
held,
|
|
2953
|
-
limit: MAX_HELD_DELIVERIES,
|
|
2954
|
-
request_ids: requestIds,
|
|
2955
|
-
})
|
|
2956
|
-
settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
|
|
2957
|
-
notes.push(
|
|
2958
|
-
`no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`,
|
|
2959
|
-
)
|
|
2960
|
-
return { notes }
|
|
2961
|
-
}
|
|
2962
|
-
const attempt = (accepted.delivery_attempts ?? 0) + 1
|
|
2963
|
-
if (attempt > MAX_CONTINUATION_COUNT) {
|
|
2964
|
-
gate(ctx, 'held', 'delivery-limit', {
|
|
2965
|
-
route: route.kind,
|
|
2966
|
-
attempts: accepted.delivery_attempts ?? 0,
|
|
2967
|
-
limit: MAX_CONTINUATION_COUNT,
|
|
2968
|
-
request_ids: requestIds,
|
|
2969
|
-
})
|
|
2970
|
-
// Settle rather than journal for ever. The answer has been offered as often
|
|
2971
|
-
// as any working route could need, it is already in the notes of each of
|
|
2972
|
-
// those turns, and the questions it answered still deserve retirement.
|
|
2973
|
-
settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
|
|
2974
|
-
notes.push(
|
|
2975
|
-
`the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`,
|
|
2976
|
-
)
|
|
2977
|
-
return { notes }
|
|
2978
|
-
}
|
|
2979
|
-
// Count the attempt before making it: an attempt that dies mid-delivery still
|
|
2980
|
-
// happened, and a counter written afterwards could be evaded by crashing.
|
|
2981
|
-
amendAcceptedAnswers(ctx, sessionId, (current) => ({
|
|
2982
|
-
...current,
|
|
2983
|
-
delivery_attempts: attempt,
|
|
2984
|
-
}))
|
|
2985
|
-
// Linearize delivery after the journal write. If SessionEnd acquired the
|
|
2986
|
-
// shared state lock in between, its durable marker wins and no wake starts.
|
|
2987
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
2988
|
-
notes.push('the Agent Session ended before answer delivery; stopping this observer')
|
|
2989
|
-
return { notes }
|
|
2990
|
-
}
|
|
2991
|
-
ctx.log?.info('hook.gate', {
|
|
2992
|
-
verdict: 'proceeding',
|
|
2993
|
-
reason: 'answered',
|
|
2994
|
-
stage: 'routed',
|
|
2995
|
-
route: route.kind,
|
|
2996
|
-
answers: answered.length,
|
|
2997
|
-
request_ids: requestIds,
|
|
2998
|
-
journal_recorded_at: accepted.recorded_at,
|
|
2999
|
-
delivery_attempt: attempt,
|
|
3000
|
-
})
|
|
3001
|
-
let deliveryCommitted = false
|
|
3002
|
-
const commitDelivery = (): boolean => {
|
|
3003
|
-
if (deliveryCommitted) return true
|
|
3004
|
-
const file = sessionStatePath(sessionId, ctx.env)
|
|
3005
|
-
return withFileLock(`${file}.lock`, () => {
|
|
3006
|
-
if (sessionHasEnded(sessionId, ctx.env)) return false
|
|
3007
|
-
const current = readSessionState(sessionId, ctx.env)
|
|
3008
|
-
if (current.accepted === undefined) return false
|
|
3009
|
-
deliveryCommitted = true
|
|
3010
|
-
writeSessionStateUnlocked(file, sessionId, {
|
|
3011
|
-
...current,
|
|
3012
|
-
accepted: { ...current.accepted, delivery_committed_at: ctx.now() },
|
|
3013
|
-
})
|
|
3014
|
-
return true
|
|
3015
|
-
})
|
|
3016
|
-
}
|
|
3017
|
-
let delivered = await route.deliver({
|
|
3018
|
-
context: answersContext(answered, remaining),
|
|
3019
|
-
answers: answered.length,
|
|
3020
|
-
remaining,
|
|
3021
|
-
request_ids: requestIds,
|
|
3022
|
-
journal_recorded_at: accepted.recorded_at,
|
|
3023
|
-
commitDelivery,
|
|
3024
|
-
})
|
|
3025
|
-
if (delivered.acknowledgement === 'delivered' && !deliveryCommitted) {
|
|
3026
|
-
delivered = {
|
|
3027
|
-
notes: [
|
|
3028
|
-
...(delivered.notes ?? []),
|
|
3029
|
-
`the ${route.kind} route reported delivery without committing against SessionEnd; preserving the answer instead of accepting an unordered write`,
|
|
3030
|
-
],
|
|
3031
|
-
log: { route: route.kind, stage: 'queued', reason: 'delivery-not-committed' },
|
|
3032
|
-
acknowledgement: 'held',
|
|
3033
|
-
}
|
|
3034
|
-
}
|
|
3035
|
-
const deliveredRoute =
|
|
3036
|
-
typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind
|
|
3037
|
-
const deliveredStage =
|
|
3038
|
-
typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered'
|
|
3039
|
-
if (delivered.acknowledgement === 'delivered') {
|
|
3040
|
-
finishCommittedDelivery(ctx, sessionId, accepted, deliveredRoute)
|
|
3041
|
-
} else if (delivered.acknowledgement === 'held') {
|
|
3042
|
-
// Give the delivery attempt back and count the hold instead. The attempt
|
|
3043
|
-
// was counted before the call because one that dies mid-delivery still
|
|
3044
|
-
// happened and a counter written afterwards could be evaded by crashing —
|
|
3045
|
-
// but a route that *returns* `held` proves both that this process survived
|
|
3046
|
-
// and that nothing was handed over.
|
|
3047
|
-
amendAcceptedAnswers(ctx, sessionId, (current) => ({
|
|
3048
|
-
...current,
|
|
3049
|
-
delivery_attempts: accepted.delivery_attempts ?? 0,
|
|
3050
|
-
held_deliveries: (current.held_deliveries ?? 0) + 1,
|
|
3051
|
-
}))
|
|
3052
|
-
}
|
|
3053
|
-
ctx.log?.info('hook.answer', {
|
|
3054
|
-
answered: true,
|
|
3055
|
-
stage: deliveredStage,
|
|
3056
|
-
route: deliveredRoute,
|
|
3057
|
-
acknowledgement: delivered.acknowledgement,
|
|
3058
|
-
delivery_attempt: attempt,
|
|
3059
|
-
request_ids: requestIds,
|
|
3060
|
-
answers: answered.length,
|
|
3061
|
-
journal_recorded_at: accepted.recorded_at,
|
|
3062
|
-
...(delivered.log?.['reason'] === undefined ? {} : { reason: delivered.log['reason'] }),
|
|
3063
|
-
})
|
|
3064
|
-
const outcome: HookOutcome = { notes: [...notes, ...(delivered.notes ?? [])] }
|
|
3065
|
-
if (delivered.stdout !== undefined) outcome.stdout = delivered.stdout
|
|
3066
|
-
if (delivered.commitStdout !== undefined) outcome.commitStdout = delivered.commitStdout
|
|
3067
|
-
if (delivered.log !== undefined) outcome.log = delivered.log
|
|
3068
|
-
return outcome
|
|
3069
|
-
}
|
|
3070
|
-
|
|
3071
|
-
/** True while this Stop owner is still allowed a direct-wake wait. */
|
|
3072
|
-
function ownerLeaseActive(entry: PendingQuestion, now: number): boolean {
|
|
3073
|
-
return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now
|
|
3074
|
-
}
|
|
3075
|
-
|
|
3076
|
-
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
3077
|
-
async function handleClaimedStop(
|
|
3078
|
-
ctx: HookContext,
|
|
3079
|
-
envelope: HookEnvelope,
|
|
3080
|
-
sessionId: string,
|
|
3081
|
-
state: SessionState,
|
|
3082
|
-
pending: PendingQuestion[],
|
|
3083
|
-
notes: string[],
|
|
3084
|
-
hardDeadlineAt: number,
|
|
3085
|
-
route: EscalationDeliveryRoute,
|
|
3086
|
-
continuingFromAnswer: boolean,
|
|
3087
|
-
): Promise<HookOutcome> {
|
|
3088
|
-
const live = pending.filter((entry) => entry.request_id !== undefined)
|
|
3089
|
-
const unasked = pending.filter((entry) => entry.request_id === undefined)
|
|
3090
|
-
let liveToEscalate = live
|
|
3091
|
-
|
|
3092
|
-
if (
|
|
3093
|
-
ctx.harness !== undefined &&
|
|
3094
|
-
HARNESS_CAPABILITIES[ctx.harness].stopContinuation === 'unsupported'
|
|
3095
|
-
) {
|
|
3096
|
-
for (const entry of live) await closeQuietly(ctx, entry.request_id!)
|
|
3097
|
-
await retirePendings(ctx, envelope, sessionId, pending, 'expired')
|
|
3098
|
-
// Not `no-question`: there was a question, and this says so. Reading the
|
|
3099
|
-
// log to find out why an ask never travelled is the whole reason these
|
|
3100
|
-
// reasons exist, and the wrong one sent that reader looking for a question
|
|
3101
|
-
// that was right there.
|
|
3102
|
-
gate(ctx, 'held', 'harness-cannot-continue', { harness: ctx.harness })
|
|
3103
|
-
notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`)
|
|
3104
|
-
return { notes }
|
|
3105
|
-
}
|
|
3106
|
-
|
|
3107
|
-
if (live.length > 0) {
|
|
3108
|
-
const { answered, permanentFailures } = await pollPendingReplies(
|
|
3109
|
-
ctx,
|
|
3110
|
-
live,
|
|
3111
|
-
LATE_STOP_POLL_SECONDS,
|
|
3112
|
-
)
|
|
3113
|
-
const permanentFailure = permanentReplyFailureNote(permanentFailures)
|
|
3114
|
-
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
3115
|
-
const permanentlyRejected = live.filter((entry) =>
|
|
3116
|
-
permanentFailures.has(entry.request_id!),
|
|
3117
|
-
)
|
|
3118
|
-
await Promise.all(permanentlyRejected.map((entry) => closeQuietly(ctx, entry.request_id!)))
|
|
3119
|
-
if (permanentlyRejected.length > 0) {
|
|
3120
|
-
await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired')
|
|
3121
|
-
}
|
|
3122
|
-
if (answered.length > 0) {
|
|
3123
|
-
const finalized = await finalizePendings(
|
|
3124
|
-
ctx,
|
|
3125
|
-
answered.map((entry) => entry.pending),
|
|
3126
|
-
)
|
|
3127
|
-
const authoritative = answered.map((entry) => {
|
|
3128
|
-
const response = finalized.find((candidate) =>
|
|
3129
|
-
isSamePending(candidate.pending, entry.pending),
|
|
3130
|
-
)?.response
|
|
3131
|
-
const replies = response?.replies.length ? response.replies : entry.replies
|
|
3132
|
-
if (response === null) {
|
|
3133
|
-
notes.push(
|
|
3134
|
-
`could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`,
|
|
3135
|
-
)
|
|
3136
|
-
}
|
|
3137
|
-
return {
|
|
3138
|
-
...entry,
|
|
3139
|
-
replies,
|
|
3140
|
-
reply: replies.at(-1)!,
|
|
3141
|
-
agent_acknowledgement_required:
|
|
3142
|
-
response?.agent_acknowledgement_required ??
|
|
3143
|
-
entry.agent_acknowledgement_required,
|
|
3144
|
-
agent_acknowledgement_text_required:
|
|
3145
|
-
response?.agent_acknowledgement_text_required ??
|
|
3146
|
-
entry.agent_acknowledgement_text_required,
|
|
3147
|
-
}
|
|
3148
|
-
})
|
|
3149
|
-
const accepted = stageAcceptedAnswers(
|
|
3150
|
-
ctx,
|
|
3151
|
-
sessionId,
|
|
3152
|
-
authoritative,
|
|
3153
|
-
pending.length - authoritative.length - permanentlyRejected.length,
|
|
3154
|
-
)
|
|
3155
|
-
for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
|
|
3156
|
-
// Anything still unasked rides the next Stop: the agent is being resumed
|
|
3157
|
-
// with answers right now, and may not even need the rest afterwards.
|
|
3158
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
3159
|
-
}
|
|
3160
|
-
const recoverableLive = live.filter(
|
|
3161
|
-
(entry) => !permanentFailures.has(entry.request_id!),
|
|
3162
|
-
)
|
|
3163
|
-
// A spent owner has already reached the end of the answer window it owned.
|
|
3164
|
-
// Passing those questions back into `escalate` starts a fresh ceiling and
|
|
3165
|
-
// can monopolize the per-session claim after expiry. Independent questions
|
|
3166
|
-
// whose lease is still running keep their successor wait.
|
|
3167
|
-
const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()))
|
|
3168
|
-
liveToEscalate = stillOwned
|
|
3169
|
-
if (unasked.length === 0) {
|
|
3170
|
-
if (recoverableLive.length === 0) return { notes }
|
|
3171
|
-
if (stillOwned.length === 0) {
|
|
3172
|
-
const requestIdSummary = summarizeRequestIds(recoverableLive)
|
|
3173
|
-
ctx.log?.info('hook.answer', {
|
|
3174
|
-
answered: false,
|
|
3175
|
-
stage: 'queued',
|
|
3176
|
-
request_ids: requestIdSummary.ids,
|
|
3177
|
-
owner_spent: true,
|
|
3178
|
-
})
|
|
3179
|
-
notes.push(
|
|
3180
|
-
`${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`,
|
|
3181
|
-
)
|
|
3182
|
-
return { notes }
|
|
3183
|
-
}
|
|
3184
|
-
return await escalate(
|
|
3185
|
-
ctx,
|
|
3186
|
-
envelope,
|
|
3187
|
-
sessionId,
|
|
3188
|
-
[],
|
|
3189
|
-
stillOwned,
|
|
3190
|
-
notes,
|
|
3191
|
-
hardDeadlineAt,
|
|
3192
|
-
route,
|
|
3193
|
-
)
|
|
3194
|
-
}
|
|
3195
|
-
// Questions registered after the earlier push still owe the user their
|
|
3196
|
-
// notification; fall through and escalate just those, plus any live
|
|
3197
|
-
// question whose owner lease is still running.
|
|
3198
|
-
}
|
|
3199
|
-
|
|
3200
|
-
// A Stop answer may immediately produce a legitimate follow-up question.
|
|
3201
|
-
// Allow that new generation, but never re-run an old pending question and
|
|
3202
|
-
// never let answer continuations become an unbounded agent loop.
|
|
3203
|
-
if (envelope.stop_hook_active === true) {
|
|
3204
|
-
const continuation = state.continuation
|
|
3205
|
-
const isNew =
|
|
3206
|
-
continuation !== undefined &&
|
|
3207
|
-
unasked.some(
|
|
3208
|
-
(entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at,
|
|
3209
|
-
)
|
|
3210
|
-
if (!isNew) {
|
|
3211
|
-
gate(ctx, 'held', 'continuation-repeat')
|
|
3212
|
-
notes.push('already continuing from an answer; not asking again this turn')
|
|
3213
|
-
return { notes }
|
|
3214
|
-
}
|
|
3215
|
-
}
|
|
3216
|
-
// The cap is deliberately not keyed to `stop_hook_active`. That flag names one
|
|
3217
|
-
// route's continuation; a route that wakes a brand-new turn never sets it, and
|
|
3218
|
-
// a limit only one route consults bounds only that route. `continuingFromAnswer`
|
|
3219
|
-
// is the same event named for every route, so the chain of answer → follow-up
|
|
3220
|
-
// question → answer is bounded whatever delivered it.
|
|
3221
|
-
if (
|
|
3222
|
-
continuingFromAnswer &&
|
|
3223
|
-
state.continuation !== undefined &&
|
|
3224
|
-
state.continuation.count >= MAX_CONTINUATION_COUNT
|
|
3225
|
-
) {
|
|
3226
|
-
gate(ctx, 'held', 'continuation-limit', {
|
|
3227
|
-
count: state.continuation.count,
|
|
3228
|
-
limit: MAX_CONTINUATION_COUNT,
|
|
3229
|
-
route: route.kind,
|
|
3230
|
-
})
|
|
3231
|
-
notes.push(
|
|
3232
|
-
`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
|
|
3233
|
-
)
|
|
3234
|
-
return { notes }
|
|
3235
|
-
}
|
|
3236
|
-
// Silent to the user by design — they switched routing off, so saying so on
|
|
3237
|
-
// every turn would be nagging about their own setting. That silence is also
|
|
3238
|
-
// why it belongs in the log: from outside, "ask_notifications = false" and "a
|
|
3239
|
-
// bug ate my question" look exactly the same.
|
|
3240
|
-
if (!ctx.config.ask_notifications.value) {
|
|
3241
|
-
gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source })
|
|
3242
|
-
return { notes }
|
|
3243
|
-
}
|
|
3244
|
-
gate(ctx, 'proceeding', 'proceeding', {
|
|
3245
|
-
unasked: unasked.length,
|
|
3246
|
-
already_live: liveToEscalate.length,
|
|
3247
|
-
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
3248
|
-
})
|
|
3249
|
-
return await escalate(
|
|
3250
|
-
ctx,
|
|
3251
|
-
envelope,
|
|
3252
|
-
sessionId,
|
|
3253
|
-
unasked,
|
|
3254
|
-
liveToEscalate,
|
|
3255
|
-
notes,
|
|
3256
|
-
hardDeadlineAt,
|
|
3257
|
-
route,
|
|
3258
|
-
)
|
|
3259
|
-
}
|
|
3260
|
-
|
|
3261
|
-
/** The escalation itself, split out so the claim is released on every path. */
|
|
3262
|
-
async function escalate(
|
|
3263
|
-
ctx: HookContext,
|
|
3264
|
-
envelope: HookEnvelope,
|
|
3265
|
-
sessionId: string,
|
|
3266
|
-
unasked: PendingQuestion[],
|
|
3267
|
-
alreadyLive: PendingQuestion[],
|
|
3268
|
-
notes: string[],
|
|
3269
|
-
hardDeadlineAt: number,
|
|
3270
|
-
route: EscalationDeliveryRoute,
|
|
3271
|
-
): Promise<HookOutcome> {
|
|
3272
|
-
// The questions still owe the user their terminal-first window before
|
|
3273
|
-
// anything reaches their devices — measured from the oldest registration,
|
|
3274
|
-
// because that is the question that has waited longest.
|
|
3275
|
-
const ceilingAt = hardDeadlineAt
|
|
3276
|
-
if (unasked.length > 0 && alreadyLive.length === 0) {
|
|
3277
|
-
const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
|
|
3278
|
-
await awaitTerminalFirstWindow(ctx, oldest, ceilingAt)
|
|
3279
|
-
ctx.log?.debug('hook.gate', {
|
|
3280
|
-
verdict: 'grace',
|
|
3281
|
-
reason: 'elapsed' satisfies GateReason,
|
|
3282
|
-
waited_from: oldest,
|
|
3283
|
-
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
3284
|
-
})
|
|
3285
|
-
}
|
|
3286
|
-
// Phase one: every registered question reaches the user's devices, each as
|
|
3287
|
-
// its own notification — one ask never stands in for another.
|
|
3288
|
-
const submitted: PendingQuestion[] = []
|
|
3289
|
-
const admissionAnswers: AnsweredPending[] = []
|
|
3290
|
-
for (const entry of unasked) {
|
|
3291
|
-
// The service owns how long the answer is accepted. This process begins
|
|
3292
|
-
// before submission, so its larger maximum-window budget includes startup
|
|
3293
|
-
// headroom and remains alive through the complete committed window.
|
|
3294
|
-
const replyWindowSeconds = ctx.config.reply_window_seconds.value
|
|
3295
|
-
if (
|
|
3296
|
-
ceilingAt - ctx.now() <
|
|
3297
|
-
(replyWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000
|
|
3298
|
-
) {
|
|
3299
|
-
notes.push(
|
|
3300
|
-
'too little owner lifetime remains for the complete configured answer window; leaving this question frozen for a successor owner',
|
|
3301
|
-
)
|
|
3302
|
-
continue
|
|
3303
|
-
}
|
|
3304
|
-
const ownerDeadlineAt = ceilingAt
|
|
3305
|
-
const questions = pendingQuestions(entry)
|
|
3306
|
-
const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd)
|
|
3307
|
-
let intent = entry.submission
|
|
3308
|
-
if (intent === undefined) {
|
|
3309
|
-
if (entry.body === undefined) {
|
|
3310
|
-
notes.push('registered question has no canonical body; register it again with this CLI')
|
|
3311
|
-
continue
|
|
3312
|
-
}
|
|
3313
|
-
const prepared = await prepareQuestionSubmission(ctx, {
|
|
3314
|
-
// Type and Project have their own fields; the title is only substance.
|
|
3315
|
-
title: questions[0]!.text,
|
|
3316
|
-
body: entry.body,
|
|
3317
|
-
questions,
|
|
3318
|
-
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
3319
|
-
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
3320
|
-
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
3321
|
-
windowSeconds: replyWindowSeconds,
|
|
3322
|
-
ownerDeadlineAt,
|
|
3323
|
-
})
|
|
3324
|
-
if ('error' in prepared) {
|
|
3325
|
-
ctx.log?.error('hook.pushed', { ok: false, message: prepared.error })
|
|
3326
|
-
notes.push(prepared.error)
|
|
3327
|
-
continue
|
|
3328
|
-
}
|
|
3329
|
-
intent = prepared
|
|
3330
|
-
// Durable before submit. If the server commits and the response is lost,
|
|
3331
|
-
// the reserved request id still lets this owner poll and finalize the
|
|
3332
|
-
// exact card; a successor can also replay the frozen draft/key.
|
|
3333
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
3334
|
-
const list = pendingList(current)
|
|
3335
|
-
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
3336
|
-
if (index < 0) return current
|
|
3337
|
-
const next = [...list]
|
|
3338
|
-
next[index] = { ...next[index]!, submission: prepared }
|
|
3339
|
-
return { ...current, pending: next }
|
|
3340
|
-
})
|
|
3341
|
-
} else if (intent.owner_deadline_at <= ctx.now()) {
|
|
3342
|
-
// The frozen wire identity survives a crashed owner, but its local owner
|
|
3343
|
-
// lease does not. Re-arm only the local deadline; request id, key,
|
|
3344
|
-
// targets, and draft remain byte-identical for idempotent replay.
|
|
3345
|
-
intent = { ...intent, owner_deadline_at: ceilingAt }
|
|
3346
|
-
const rearmed = intent
|
|
3347
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
3348
|
-
const list = pendingList(current)
|
|
3349
|
-
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
3350
|
-
if (index < 0) return current
|
|
3351
|
-
const next = [...list]
|
|
3352
|
-
next[index] = { ...next[index]!, submission: rearmed }
|
|
3353
|
-
return { ...current, pending: next }
|
|
3354
|
-
})
|
|
3355
|
-
}
|
|
3356
|
-
if (ctx.now() >= intent.owner_deadline_at) {
|
|
3357
|
-
notes.push('the owner lifetime ended before submission; preserving the frozen intent')
|
|
3358
|
-
continue
|
|
3359
|
-
}
|
|
3360
|
-
if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
|
|
3361
|
-
notes.push(
|
|
3362
|
-
'setup consumed the admission allowance; preserving the frozen intent instead of publishing an answer window this owner cannot observe completely',
|
|
3363
|
-
)
|
|
3364
|
-
continue
|
|
3365
|
-
}
|
|
3366
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
3367
|
-
notes.push('the Agent Session ended before submission; preserving no live observer')
|
|
3368
|
-
continue
|
|
3369
|
-
}
|
|
3370
|
-
let receipt: SubmissionReceipt | undefined
|
|
3371
|
-
let admissionConfirmed = false
|
|
3372
|
-
try {
|
|
3373
|
-
receipt = await submitQuestion(ctx, intent)
|
|
3374
|
-
admissionConfirmed = true
|
|
3375
|
-
} catch (caught) {
|
|
3376
|
-
let err: unknown = caught
|
|
3377
|
-
if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
|
|
3378
|
-
ctx.log?.error('hook.pushed', {
|
|
3379
|
-
ok: false,
|
|
3380
|
-
request_id: intent.request_id,
|
|
3381
|
-
status: err.status,
|
|
3382
|
-
code: err.code,
|
|
3383
|
-
message: err.message,
|
|
3384
|
-
})
|
|
3385
|
-
if (entry.submission !== undefined) {
|
|
3386
|
-
notes.push(
|
|
3387
|
-
`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`,
|
|
3388
|
-
)
|
|
3389
|
-
clearFrozenSubmission(sessionId, ctx.env, entry)
|
|
3390
|
-
if (entry.body === undefined) {
|
|
3391
|
-
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
3392
|
-
notes.push('registered question has no canonical body; retired the frozen draft instead of retrying it')
|
|
3393
|
-
continue
|
|
3394
|
-
}
|
|
3395
|
-
const reminted = await prepareQuestionSubmission(ctx, {
|
|
3396
|
-
title: questions[0]!.text,
|
|
3397
|
-
body: entry.body,
|
|
3398
|
-
questions,
|
|
3399
|
-
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
3400
|
-
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
3401
|
-
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
3402
|
-
windowSeconds: replyWindowSeconds,
|
|
3403
|
-
ownerDeadlineAt,
|
|
3404
|
-
})
|
|
3405
|
-
if ('error' in reminted) {
|
|
3406
|
-
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
3407
|
-
notes.push(reminted.error)
|
|
3408
|
-
continue
|
|
3409
|
-
}
|
|
3410
|
-
intent = reminted
|
|
3411
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
3412
|
-
const list = pendingList(current)
|
|
3413
|
-
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
3414
|
-
if (index < 0) return current
|
|
3415
|
-
const next = [...list]
|
|
3416
|
-
next[index] = { ...next[index]!, submission: reminted }
|
|
3417
|
-
return { ...current, pending: next }
|
|
3418
|
-
})
|
|
3419
|
-
try {
|
|
3420
|
-
if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
|
|
3421
|
-
notes.push(
|
|
3422
|
-
'draft recovery consumed the admission allowance; preserving the frozen intent for a successor owner',
|
|
3423
|
-
)
|
|
3424
|
-
continue
|
|
3425
|
-
}
|
|
3426
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
3427
|
-
notes.push('the Agent Session ended before recovered submission')
|
|
3428
|
-
continue
|
|
3429
|
-
}
|
|
3430
|
-
receipt = await submitQuestion(ctx, intent)
|
|
3431
|
-
admissionConfirmed = true
|
|
3432
|
-
} catch (retryErr) {
|
|
3433
|
-
if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
|
|
3434
|
-
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
3435
|
-
notes.push(
|
|
3436
|
-
`reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`,
|
|
3437
|
-
)
|
|
3438
|
-
ctx.log?.error('hook.pushed', {
|
|
3439
|
-
ok: false,
|
|
3440
|
-
request_id: intent.request_id,
|
|
3441
|
-
status: retryErr.status,
|
|
3442
|
-
code: retryErr.code,
|
|
3443
|
-
message: retryErr.message,
|
|
3444
|
-
})
|
|
3445
|
-
continue
|
|
3446
|
-
}
|
|
3447
|
-
err = retryErr
|
|
3448
|
-
}
|
|
3449
|
-
} else {
|
|
3450
|
-
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
3451
|
-
notes.push(
|
|
3452
|
-
`question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`,
|
|
3453
|
-
)
|
|
3454
|
-
continue
|
|
3455
|
-
}
|
|
3456
|
-
}
|
|
3457
|
-
if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
|
|
3458
|
-
notes.push(
|
|
3459
|
-
`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`,
|
|
3460
|
-
)
|
|
3461
|
-
ctx.log?.error('hook.pushed', {
|
|
3462
|
-
ok: false,
|
|
3463
|
-
request_id: intent.request_id,
|
|
3464
|
-
status: err.status,
|
|
3465
|
-
code: err.code,
|
|
3466
|
-
message: err.message,
|
|
3467
|
-
})
|
|
3468
|
-
continue
|
|
3469
|
-
}
|
|
3470
|
-
if (!admissionConfirmed) {
|
|
3471
|
-
notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
|
|
3472
|
-
ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
|
|
3473
|
-
}
|
|
3474
|
-
}
|
|
3475
|
-
if (admissionConfirmed) {
|
|
3476
|
-
ctx.log?.info('hook.pushed', {
|
|
3477
|
-
ok: true,
|
|
3478
|
-
request_id: intent.request_id,
|
|
3479
|
-
devices: intent.device_ids.length,
|
|
3480
|
-
questions: questions.length,
|
|
3481
|
-
text_chars: questions[0]!.text.length,
|
|
3482
|
-
})
|
|
3483
|
-
}
|
|
3484
|
-
const committedReplyDeadline =
|
|
3485
|
-
receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
|
|
3486
|
-
? intent.owner_deadline_at
|
|
3487
|
-
: Date.parse(receipt.reply_expires_at)
|
|
3488
|
-
const live: PendingQuestion = {
|
|
3489
|
-
...entry,
|
|
3490
|
-
...(intent.draft.source !== undefined ? { source: intent.draft.source } : {}),
|
|
3491
|
-
request_id: intent.request_id,
|
|
3492
|
-
collapse_key: intent.collapse_key,
|
|
3493
|
-
device_ids: intent.device_ids,
|
|
3494
|
-
// The server's committed answer deadline is authoritative. The local
|
|
3495
|
-
// owner deadline starts earlier and includes startup headroom, so it may
|
|
3496
|
-
// be later but must never be earlier.
|
|
3497
|
-
reply_deadline_at: Number.isFinite(committedReplyDeadline)
|
|
3498
|
-
? committedReplyDeadline
|
|
3499
|
-
: intent.owner_deadline_at,
|
|
3500
|
-
owner_deadline_at: intent.owner_deadline_at,
|
|
3501
|
-
}
|
|
3502
|
-
if (intent.draft.source === undefined) delete live.source
|
|
3503
|
-
delete live.submission
|
|
3504
|
-
if (live.reply_deadline_at! > live.owner_deadline_at!) {
|
|
3505
|
-
const response = await finalizeReplies(ctx, live.request_id!)
|
|
3506
|
-
if (response === null) {
|
|
3507
|
-
// Closing was unreachable, so the question is still potentially live.
|
|
3508
|
-
// Preserve it in the exact session instead of demoting it to the orphan
|
|
3509
|
-
// retirement queue, whose later close has no route for an answer.
|
|
3510
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
3511
|
-
const list = pendingList(current)
|
|
3512
|
-
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
3513
|
-
if (index < 0) {
|
|
3514
|
-
// Terminal input removed this registration while the anomalous
|
|
3515
|
-
// close was in flight. Preserve retirement identity exactly as
|
|
3516
|
-
// normal submit promotion does; the observer must not become the
|
|
3517
|
-
// only remaining record of a live card.
|
|
3518
|
-
const retirement = retiringQuestion(
|
|
3519
|
-
live,
|
|
3520
|
-
'answered_elsewhere',
|
|
3521
|
-
envelope.cwd,
|
|
3522
|
-
)!
|
|
3523
|
-
const retiring = [...(current.retiring ?? [])]
|
|
3524
|
-
if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
3525
|
-
retiring.push(retirement)
|
|
3526
|
-
}
|
|
3527
|
-
return { ...current, retiring }
|
|
3528
|
-
}
|
|
3529
|
-
const next = [...list]
|
|
3530
|
-
next[index] = live
|
|
3531
|
-
return { ...current, pending: next }
|
|
3532
|
-
})
|
|
3533
|
-
submitted.push(live)
|
|
3534
|
-
notes.push(
|
|
3535
|
-
'the server committed an answer deadline beyond this process owner and immediate closure was unreachable; preserving the live question for exact-session recovery',
|
|
3536
|
-
)
|
|
3537
|
-
continue
|
|
3538
|
-
} else if (response.replies.length > 0) {
|
|
3539
|
-
admissionAnswers.push({
|
|
3540
|
-
pending: live,
|
|
3541
|
-
reply: response.replies.at(-1)!,
|
|
3542
|
-
replies: response.replies,
|
|
3543
|
-
agent_acknowledgement_required: response.agent_acknowledgement_required,
|
|
3544
|
-
agent_acknowledgement_text_required:
|
|
3545
|
-
response.agent_acknowledgement_text_required,
|
|
3546
|
-
})
|
|
3547
|
-
}
|
|
3548
|
-
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
3549
|
-
notes.push(
|
|
3550
|
-
'the server committed an answer deadline beyond this process owner; closed the anomalous window instead of abandoning it early',
|
|
3551
|
-
)
|
|
3552
|
-
continue
|
|
3553
|
-
}
|
|
3554
|
-
// Record what is now live on the user's devices BEFORE any wait. If we
|
|
3555
|
-
// only learned these ids afterwards, a question that timed out would
|
|
3556
|
-
// leave no trace, and the user returning to the terminal could never
|
|
3557
|
-
// retire it — the notification would stay answerable for an hour with
|
|
3558
|
-
// nobody listening.
|
|
3559
|
-
if (admissionConfirmed) {
|
|
3560
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
3561
|
-
const list = pendingList(current)
|
|
3562
|
-
const index = list.findIndex(
|
|
3563
|
-
(candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined,
|
|
3564
|
-
)
|
|
3565
|
-
if (index >= 0) {
|
|
3566
|
-
const next = [...list]
|
|
3567
|
-
next[index] = live
|
|
3568
|
-
return { ...current, pending: next }
|
|
3569
|
-
}
|
|
3570
|
-
// The entry vanished while the submit was in flight (the user's prompt
|
|
3571
|
-
// wiped the queue). The delivered notification must still be retirable,
|
|
3572
|
-
// so park it rather than lose its only identifiers.
|
|
3573
|
-
const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd)!
|
|
3574
|
-
const retiring = [...(current.retiring ?? [])]
|
|
3575
|
-
if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
3576
|
-
retiring.push(retirement)
|
|
3577
|
-
}
|
|
3578
|
-
return { ...current, retiring }
|
|
3579
|
-
})
|
|
3580
|
-
}
|
|
3581
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
3582
|
-
// SessionEnd may have raced either the submit or promotion. Its snapshot
|
|
3583
|
-
// usually already queued the frozen intent; this idempotent add closes
|
|
3584
|
-
// the other ordering where the request committed just after cleanup.
|
|
3585
|
-
orphanRetirements(
|
|
3586
|
-
ctx.env,
|
|
3587
|
-
[retiringQuestion(live, 'expired', envelope.cwd)!],
|
|
3588
|
-
ctx.now(),
|
|
3589
|
-
)
|
|
3590
|
-
notes.push('the Agent Session ended during submission; queued the question for retirement')
|
|
3591
|
-
continue
|
|
3592
|
-
}
|
|
3593
|
-
submitted.push(live)
|
|
3594
|
-
}
|
|
3595
|
-
|
|
3596
|
-
if (admissionAnswers.length > 0) {
|
|
3597
|
-
const accepted = stageAcceptedAnswers(
|
|
3598
|
-
ctx,
|
|
3599
|
-
sessionId,
|
|
3600
|
-
admissionAnswers,
|
|
3601
|
-
pendingList(readSessionState(sessionId, ctx.env)).length,
|
|
3602
|
-
)
|
|
3603
|
-
for (const answer of admissionAnswers) reportAnswer(ctx, notes, answer, false)
|
|
3604
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
3605
|
-
}
|
|
3606
|
-
|
|
3607
|
-
const staleLive = alreadyLive.filter(
|
|
3608
|
-
(entry) =>
|
|
3609
|
-
entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now(),
|
|
3610
|
-
)
|
|
3611
|
-
const finalizedStale = await finalizePendings(ctx, staleLive)
|
|
3612
|
-
const staleAnswers = finalizedStale
|
|
3613
|
-
.map(finalizedAnswer)
|
|
3614
|
-
.filter((entry): entry is AnsweredPending => entry !== null)
|
|
3615
|
-
const staleSilence = finalizedStale
|
|
3616
|
-
.filter((entry) => entry.response !== null && entry.response.replies.length === 0)
|
|
3617
|
-
.map((entry) => entry.pending)
|
|
3618
|
-
const staleUnproven = finalizedStale.filter((entry) => entry.response === null)
|
|
3619
|
-
if (staleSilence.length > 0) {
|
|
3620
|
-
await retirePendings(ctx, envelope, sessionId, staleSilence, 'expired')
|
|
3621
|
-
notes.push(
|
|
3622
|
-
`expired ${staleSilence.length} question${staleSilence.length === 1 ? '' : 's'} whose continuation owner had ended`,
|
|
3623
|
-
)
|
|
3624
|
-
}
|
|
3625
|
-
if (staleUnproven.length > 0) {
|
|
3626
|
-
notes.push(
|
|
3627
|
-
`could not finalize ${staleUnproven.length} expired question${staleUnproven.length === 1 ? '' : 's'}; preserving ownership for recovery`,
|
|
3628
|
-
)
|
|
3629
|
-
}
|
|
3630
|
-
if (staleAnswers.length > 0) {
|
|
3631
|
-
const remaining = Math.max(
|
|
3632
|
-
0,
|
|
3633
|
-
pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length,
|
|
3634
|
-
)
|
|
3635
|
-
const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
|
|
3636
|
-
for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
|
|
3637
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
3638
|
-
}
|
|
3639
|
-
const waitingOn = [
|
|
3640
|
-
...alreadyLive.filter(
|
|
3641
|
-
(entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now(),
|
|
3642
|
-
),
|
|
3643
|
-
...submitted,
|
|
3644
|
-
]
|
|
3645
|
-
if (waitingOn.length === 0) return { notes }
|
|
3646
|
-
|
|
3647
|
-
// Phase two: keep the same owner alive across every committed answer window.
|
|
3648
|
-
// Different questions may expire at different times, so finalize each one at
|
|
3649
|
-
// its own server deadline and continue waiting on the rest. Reserving time by
|
|
3650
|
-
// closing early would make the advertised reply window untrue.
|
|
3651
|
-
let activeWaiting = waitingOn
|
|
3652
|
-
let timeoutSeconds = 0
|
|
3653
|
-
let waited: Awaited<ReturnType<typeof waitForAnyReply>>
|
|
3654
|
-
for (;;) {
|
|
3655
|
-
const nextReplyDeadline = Math.min(
|
|
3656
|
-
...activeWaiting.map((entry) => entry.reply_deadline_at!),
|
|
3657
|
-
)
|
|
3658
|
-
const waitDeadline = Math.min(ceilingAt, nextReplyDeadline)
|
|
3659
|
-
timeoutSeconds = Math.max(0, Math.ceil((waitDeadline - ctx.now()) / 1000))
|
|
3660
|
-
waited = await waitForAnyReply(
|
|
3661
|
-
ctx,
|
|
3662
|
-
activeWaiting.map((entry) => entry.request_id!),
|
|
3663
|
-
timeoutSeconds,
|
|
3664
|
-
() =>
|
|
3665
|
-
waiterInterruption(
|
|
3666
|
-
sessionId,
|
|
3667
|
-
ctx.env,
|
|
3668
|
-
activeWaiting.map((entry) => entry.request_id!),
|
|
3669
|
-
),
|
|
3670
|
-
)
|
|
3671
|
-
if (waited.interrupted !== null) {
|
|
3672
|
-
notes.push(
|
|
3673
|
-
waited.interrupted === 'new-question'
|
|
3674
|
-
? 'yielding the answer owner so a newly registered question can be sent now'
|
|
3675
|
-
: 'answer ownership ended locally; stopping this observer without delivering into a closed session',
|
|
3676
|
-
)
|
|
3677
|
-
return { notes }
|
|
3678
|
-
}
|
|
3679
|
-
const currentRequestIds = new Set(
|
|
3680
|
-
pendingList(readSessionState(sessionId, ctx.env)).flatMap((entry) =>
|
|
3681
|
-
[entry.request_id, entry.submission?.request_id].filter(
|
|
3682
|
-
(requestId): requestId is string => requestId !== undefined,
|
|
3683
|
-
),
|
|
3684
|
-
),
|
|
3685
|
-
)
|
|
3686
|
-
const removedDuringPoll = activeWaiting.filter(
|
|
3687
|
-
(entry) => !currentRequestIds.has(entry.request_id!),
|
|
3688
|
-
)
|
|
3689
|
-
for (const entry of removedDuringPoll) {
|
|
3690
|
-
waited.byRequest.delete(entry.request_id!)
|
|
3691
|
-
waited.permanentFailures.delete(entry.request_id!)
|
|
3692
|
-
}
|
|
3693
|
-
if (removedDuringPoll.length > 0) {
|
|
3694
|
-
notes.push(
|
|
3695
|
-
`${removedDuringPoll.length} question${removedDuringPoll.length === 1 ? ' changed' : 's changed'} outside this observer; continuing with the independently live questions`,
|
|
3696
|
-
)
|
|
3697
|
-
activeWaiting = activeWaiting.filter((entry) => currentRequestIds.has(entry.request_id!))
|
|
3698
|
-
if (activeWaiting.length === 0) return { notes }
|
|
3699
|
-
}
|
|
3700
|
-
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
|
|
3701
|
-
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
3702
|
-
if (waited.byRequest.size > 0) break
|
|
3703
|
-
|
|
3704
|
-
// A permanent rejection is authoritative immediately. Time-based expiry is
|
|
3705
|
-
// authoritative only when this owner reached that question's committed
|
|
3706
|
-
// server deadline; reaching the process ceiling first preserves the record.
|
|
3707
|
-
const expired = activeWaiting.filter(
|
|
3708
|
-
(entry) =>
|
|
3709
|
-
entry.reply_deadline_at === undefined ||
|
|
3710
|
-
entry.reply_deadline_at <= waitDeadline ||
|
|
3711
|
-
waited.permanentFailures.has(entry.request_id!),
|
|
3712
|
-
)
|
|
3713
|
-
const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry))
|
|
3714
|
-
const finalized = await finalizePendings(ctx, expired)
|
|
3715
|
-
const finalAnswers = finalized
|
|
3716
|
-
.map(finalizedAnswer)
|
|
3717
|
-
.filter((entry): entry is AnsweredPending => entry !== null)
|
|
3718
|
-
const confirmedSilent = finalized
|
|
3719
|
-
.filter((entry) => entry.response !== null && entry.response.replies.length === 0)
|
|
3720
|
-
.map((entry) => entry.pending)
|
|
3721
|
-
const unproven = finalized.filter((entry) => entry.response === null)
|
|
3722
|
-
if (confirmedSilent.length > 0) {
|
|
3723
|
-
await retirePendings(ctx, envelope, sessionId, confirmedSilent, 'expired')
|
|
3724
|
-
}
|
|
3725
|
-
if (unproven.length > 0) {
|
|
3726
|
-
notes.push(
|
|
3727
|
-
`could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`,
|
|
3728
|
-
)
|
|
3729
|
-
}
|
|
3730
|
-
if (finalAnswers.length > 0) {
|
|
3731
|
-
const remaining = Math.max(
|
|
3732
|
-
0,
|
|
3733
|
-
pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length,
|
|
3734
|
-
)
|
|
3735
|
-
const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
|
|
3736
|
-
for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
|
|
3737
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
3738
|
-
}
|
|
3739
|
-
if (stillAnswerable.length > 0 && waitDeadline < ceilingAt) {
|
|
3740
|
-
activeWaiting = stillAnswerable
|
|
3741
|
-
continue
|
|
3742
|
-
}
|
|
3743
|
-
|
|
3744
|
-
const requestIdSummary = summarizeRequestIds(waitingOn)
|
|
3745
|
-
ctx.log?.info('hook.answer', {
|
|
3746
|
-
answered: false,
|
|
3747
|
-
stage: 'queued',
|
|
3748
|
-
request_ids: requestIdSummary.ids,
|
|
3749
|
-
degraded: waited.degraded,
|
|
3750
|
-
permanent_failures: waited.permanentFailures.size,
|
|
3751
|
-
waited_seconds: timeoutSeconds,
|
|
3752
|
-
})
|
|
3753
|
-
if (stillAnswerable.length > 0) {
|
|
3754
|
-
notes.push(
|
|
3755
|
-
`${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' remains' : 's remain'} answerable after the process owner ended; preserving ${stillAnswerable.length === 1 ? 'it' : 'them'} for recovery`,
|
|
3756
|
-
)
|
|
3757
|
-
} else if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
|
|
3758
|
-
notes.push(
|
|
3759
|
-
waited.degraded
|
|
3760
|
-
? 'could not reach the server before the answer deadline; expired questions were retired so no answer can be lost later'
|
|
3761
|
-
: 'no answer in time; the question expired with its continuation owner',
|
|
3762
|
-
)
|
|
3763
|
-
}
|
|
3764
|
-
return { notes }
|
|
3765
|
-
}
|
|
3766
|
-
|
|
3767
|
-
const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id!))
|
|
3768
|
-
const finalizedAnswered = await finalizePendings(ctx, polledAnswered)
|
|
3769
|
-
const answered: AnsweredPending[] = []
|
|
3770
|
-
for (const finalized of finalizedAnswered) {
|
|
3771
|
-
const entry = finalized.pending
|
|
3772
|
-
const replies = waited.byRequest.get(entry.request_id!)
|
|
3773
|
-
if (replies === undefined) continue
|
|
3774
|
-
// The fenced set wins because it includes corrections that committed
|
|
3775
|
-
// while close was waiting for earlier writers. If finalization itself was
|
|
3776
|
-
// unreachable, never discard the durable reply already observed.
|
|
3777
|
-
const finalReplies = finalized.response?.replies ?? []
|
|
3778
|
-
const authoritative = finalReplies.length > 0 ? finalReplies : replies
|
|
3779
|
-
answered.push({
|
|
3780
|
-
pending: entry,
|
|
3781
|
-
reply: authoritative.at(-1)!,
|
|
3782
|
-
replies: authoritative,
|
|
3783
|
-
agent_acknowledgement_required:
|
|
3784
|
-
finalized.response?.agent_acknowledgement_required,
|
|
3785
|
-
agent_acknowledgement_text_required:
|
|
3786
|
-
finalized.response?.agent_acknowledgement_text_required,
|
|
3787
|
-
})
|
|
3788
|
-
if (finalized.response === null) {
|
|
3789
|
-
notes.push(
|
|
3790
|
-
`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`,
|
|
3791
|
-
)
|
|
3792
|
-
}
|
|
3793
|
-
}
|
|
3794
|
-
const accepted = stageAcceptedAnswers(
|
|
3795
|
-
ctx,
|
|
3796
|
-
sessionId,
|
|
3797
|
-
answered,
|
|
3798
|
-
Math.max(
|
|
3799
|
-
0,
|
|
3800
|
-
pendingList(readSessionState(sessionId, ctx.env)).length - answered.length,
|
|
3801
|
-
),
|
|
3802
|
-
)
|
|
3803
|
-
for (const answer of answered) reportAnswer(ctx, notes, answer, false)
|
|
3804
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
3805
|
-
}
|
|
3806
|
-
|
|
3807
|
-
// ---------------------------------------------------------------------------
|
|
3808
|
-
// SessionEnd — local cleanup only
|
|
3809
|
-
// ---------------------------------------------------------------------------
|
|
3810
|
-
|
|
3811
|
-
/**
|
|
3812
|
-
* Claude Code gives SessionEnd hooks a 1.5-second shared budget and Codex 1
|
|
3813
|
-
* second, so this cannot make a network call. It drops the local marker — but
|
|
3814
|
-
* first moves anything still live on the user's devices into the global
|
|
3815
|
-
* retirement queue, because this file was the only record of those ids and no
|
|
3816
|
-
* hook for this session will ever run again. A question whose
|
|
3817
|
-
* agent just exited can receive no answer, so it is orphaned as `expired`.
|
|
3818
|
-
*/
|
|
3819
|
-
export function handleSessionEnd(
|
|
3820
|
-
env: NodeJS.ProcessEnv,
|
|
3821
|
-
envelope: HookEnvelope,
|
|
3822
|
-
now: number = Date.now(),
|
|
3823
|
-
): HookOutcome {
|
|
3824
|
-
const notes: string[] = []
|
|
3825
|
-
const sessionId = envelope.session_id
|
|
3826
|
-
if (!sessionId) return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } }
|
|
3827
|
-
// Publish cancellation before reading or clearing anything. In-flight Stop
|
|
3828
|
-
// writers use the same session lock, so none can recreate state after this.
|
|
3829
|
-
markSessionEnded(sessionId, env, now)
|
|
3830
|
-
if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
|
|
3831
|
-
|
|
3832
|
-
const state = readSessionState(sessionId, env)
|
|
3833
|
-
let stateWithHistory = state
|
|
3834
|
-
for (const entry of pendingList(state)) {
|
|
3835
|
-
stateWithHistory = rememberQuestionState(
|
|
3836
|
-
stateWithHistory,
|
|
3837
|
-
entry,
|
|
3838
|
-
entry.request_id === undefined && entry.submission === undefined ? 'withdrawn' : 'retired',
|
|
3839
|
-
)
|
|
3840
|
-
}
|
|
3841
|
-
for (const answer of state.accepted?.answers ?? []) {
|
|
3842
|
-
stateWithHistory = rememberQuestionState(stateWithHistory, answer.pending, 'answered')
|
|
3843
|
-
}
|
|
3844
|
-
for (const retirement of state.retiring ?? []) {
|
|
3845
|
-
stateWithHistory = rememberQuestionState(
|
|
3846
|
-
stateWithHistory,
|
|
3847
|
-
retirement,
|
|
3848
|
-
retirement.state === 'answered' ? 'answered' : 'retired',
|
|
3849
|
-
)
|
|
3850
|
-
}
|
|
3851
|
-
const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
|
|
3852
|
-
const retirementCandidates = [
|
|
3853
|
-
...pendingList(state),
|
|
3854
|
-
...(state.accepted?.answers.map((entry) => entry.pending) ?? []),
|
|
3855
|
-
]
|
|
3856
|
-
for (const entry of retirementCandidates) {
|
|
3857
|
-
try {
|
|
3858
|
-
const orphan = retiringQuestion(entry, 'expired', envelope.cwd)
|
|
3859
|
-
if (
|
|
3860
|
-
orphan !== null &&
|
|
3861
|
-
!orphans.some((candidate) => candidate.request_id === orphan.request_id)
|
|
3862
|
-
) {
|
|
3863
|
-
orphans.push(orphan)
|
|
3864
|
-
}
|
|
3865
|
-
} catch (err) {
|
|
3866
|
-
notes.push(err instanceof Error ? err.message : String(err))
|
|
3867
|
-
// Preserve the only identifiers instead of turning an explicit corrupt
|
|
3868
|
-
// state into an unretirable question during SessionEnd cleanup.
|
|
3869
|
-
return {
|
|
3870
|
-
notes,
|
|
3871
|
-
log: { outcome: 'preserved', reason: 'incomplete-question-identifiers' },
|
|
3872
|
-
}
|
|
3873
|
-
}
|
|
3874
|
-
}
|
|
3875
|
-
if (orphans.length > 0) {
|
|
3876
|
-
orphanRetirements(env, orphans, now)
|
|
3877
|
-
notes.push(
|
|
3878
|
-
`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
|
|
3879
|
-
)
|
|
3880
|
-
}
|
|
3881
|
-
if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
3882
|
-
const preserved: SessionState = { ...stateWithHistory }
|
|
3883
|
-
delete preserved.pending
|
|
3884
|
-
delete preserved.retiring
|
|
3885
|
-
delete preserved.acknowledgement_blocks
|
|
3886
|
-
if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
|
|
3887
|
-
delete preserved.acknowledgement_due
|
|
3888
|
-
}
|
|
3889
|
-
writeSessionState(sessionId, env, preserved)
|
|
3890
|
-
notes.push(
|
|
3891
|
-
state.accepted !== undefined
|
|
3892
|
-
? 'preserved an accepted device answer for this exact session to resume'
|
|
3893
|
-
: 'preserved required Agent Acknowledgement obligations for this exact session',
|
|
3894
|
-
)
|
|
3895
|
-
return {
|
|
3896
|
-
notes,
|
|
3897
|
-
log: {
|
|
3898
|
-
outcome: state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
|
|
3899
|
-
queued_retirements: orphans.length,
|
|
3900
|
-
accepted_answers: state.accepted?.answers.length ?? 0,
|
|
3901
|
-
acknowledgement_due: state.acknowledgement_due?.length ?? 0,
|
|
3902
|
-
},
|
|
3903
|
-
}
|
|
3904
|
-
}
|
|
3905
|
-
const history = stateWithHistory.question_history
|
|
3906
|
-
clearSessionState(sessionId, env)
|
|
3907
|
-
if ((history?.length ?? 0) > 0) {
|
|
3908
|
-
writeSessionState(sessionId, env, { question_history: history! })
|
|
3909
|
-
}
|
|
3910
|
-
return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } }
|
|
3911
|
-
}
|
|
3912
|
-
|
|
3913
|
-
// ---------------------------------------------------------------------------
|
|
3914
|
-
// ask — register a question for the turn's end
|
|
3915
|
-
// ---------------------------------------------------------------------------
|
|
3916
|
-
|
|
3917
|
-
/**
|
|
3918
|
-
* Registrations still waiting for their first push. More than this in one turn
|
|
3919
|
-
* means an agent is looping, not asking, and related questions belong in one
|
|
3920
|
-
* `ask --form` instead.
|
|
3921
|
-
*
|
|
3922
|
-
* Deliberately counts only unasked entries. A question already on the user's
|
|
3923
|
-
* devices is waiting on a person, not on the agent, and an answer stays
|
|
3924
|
-
* accepted for a day — so counting live questions here would have turned a
|
|
3925
|
-
* patient user into a reason the agent could no longer ask anything.
|
|
3926
|
-
*/
|
|
3927
|
-
export const MAX_PENDING_QUESTIONS = 4
|
|
3928
|
-
|
|
3929
|
-
/**
|
|
3930
|
-
* Everything this session has open at once, asked or not. The lock screen is
|
|
3931
|
-
* the shared resource being protected here, not the agent's turn.
|
|
3932
|
-
*/
|
|
3933
|
-
export const MAX_LIVE_QUESTIONS = 10
|
|
3934
|
-
|
|
3935
|
-
/**
|
|
3936
|
-
* A session may hold several registered questions at once: a new `ask` never
|
|
3937
|
-
* ends an earlier one. Each reaches the user as its own notification and is
|
|
3938
|
-
* answerable independently. Superseding is reply semantics — a later reply
|
|
3939
|
-
* corrects an earlier reply to the same question (that correction model lives
|
|
3940
|
-
* server-side) — never question semantics: the old single-slot model silently
|
|
3941
|
-
* discarded a question the moment a second was registered, and the user was
|
|
3942
|
-
* pointed at a notification that never existed (2026-08-09).
|
|
3943
|
-
*
|
|
3944
|
-
* The queue is keyed on the session, not the project: several agents may be
|
|
3945
|
-
* running in one project at once, and their questions must never interfere.
|
|
3946
|
-
*/
|
|
3947
|
-
export function registerQuestion(
|
|
3948
|
-
sessionId: string,
|
|
3949
|
-
env: NodeJS.ProcessEnv,
|
|
3950
|
-
question: PendingQuestion,
|
|
3951
|
-
now: number = Date.now(),
|
|
3952
|
-
): string {
|
|
3953
|
-
let full: 'unasked' | 'live' | null = null
|
|
3954
|
-
const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`
|
|
3955
|
-
updateSessionState(sessionId, env, (state) => {
|
|
3956
|
-
const pending = pendingList(state)
|
|
3957
|
-
const unasked = pending.filter((entry) => entry.request_id === undefined)
|
|
3958
|
-
if (unasked.length >= MAX_PENDING_QUESTIONS) {
|
|
3959
|
-
full = 'unasked'
|
|
3960
|
-
return state
|
|
3961
|
-
}
|
|
3962
|
-
if (pending.length >= MAX_LIVE_QUESTIONS) {
|
|
3963
|
-
full = 'live'
|
|
3964
|
-
return state
|
|
3965
|
-
}
|
|
3966
|
-
return {
|
|
3967
|
-
...state,
|
|
3968
|
-
pending: [
|
|
3969
|
-
...pending,
|
|
3970
|
-
{
|
|
3971
|
-
asked_at: now,
|
|
3972
|
-
...question,
|
|
3973
|
-
question_id: questionId,
|
|
3974
|
-
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|
|
3975
|
-
},
|
|
3976
|
-
],
|
|
3977
|
-
}
|
|
3978
|
-
})
|
|
3979
|
-
if (full === 'unasked') {
|
|
3980
|
-
throw new Error(
|
|
3981
|
-
`${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
|
|
3982
|
-
'Combine related questions into one `notifai ask --form` instead of registering more.',
|
|
3983
|
-
)
|
|
3984
|
-
}
|
|
3985
|
-
if (full === 'live') {
|
|
3986
|
-
throw new Error(
|
|
3987
|
-
`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
|
|
3988
|
-
'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.',
|
|
3989
|
-
)
|
|
3990
|
-
}
|
|
3991
|
-
return questionId
|
|
3992
|
-
}
|
|
3993
|
-
|
|
3994
|
-
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|
|
3995
|
-
export function parseHookInput(raw: string): HookEnvelope {
|
|
3996
|
-
if (raw.trim() === '') return {}
|
|
3997
|
-
try {
|
|
3998
|
-
const parsed: unknown = JSON.parse(raw)
|
|
3999
|
-
return typeof parsed === 'object' && parsed !== null ? (parsed as HookEnvelope) : {}
|
|
4000
|
-
} catch {
|
|
4001
|
-
return {}
|
|
4002
|
-
}
|
|
4003
|
-
}
|
|
4004
|
-
|
|
4005
|
-
export function resolveHookConfig(
|
|
4006
|
-
cwd: string,
|
|
4007
|
-
env: NodeJS.ProcessEnv,
|
|
4008
|
-
sessionId: string | undefined,
|
|
4009
|
-
): CliConfig {
|
|
4010
|
-
return loadConfig({ cwd, env, sessionId })
|
|
4011
|
-
}
|