@raidiant/notifai 9.2.2 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/README.md +8 -5
- package/dist/claude-wake.js +11 -0
- package/dist/claude-wake.js.map +1 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.js +9 -0
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-agent-sessions.d.ts +6 -0
- package/dist/commands-agent-sessions.js +51 -0
- package/dist/commands-agent-sessions.js.map +1 -0
- package/dist/commands-config.js +11 -4
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +1 -1
- package/dist/commands-doctor.js +83 -6
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +15 -2
- package/dist/commands-harness-context.js +32 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hook-shape.d.ts +4 -3
- package/dist/commands-hook-shape.js +19 -10
- package/dist/commands-hook-shape.js.map +1 -1
- package/dist/commands-hooks.d.ts +4 -3
- package/dist/commands-hooks.js +80 -23
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +12 -1
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-project.d.ts +4 -0
- package/dist/commands-project.js +41 -0
- package/dist/commands-project.js.map +1 -0
- package/dist/commands-send-support.js +0 -4
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +12 -6
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +58 -0
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands-sounds.d.ts +12 -0
- package/dist/commands-sounds.js +35 -0
- package/dist/commands-sounds.js.map +1 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +3 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +7 -4
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +6 -7
- package/dist/config.js +9 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +4 -1
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +3 -0
- package/dist/guidance-content.js +20 -0
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.js +3 -3
- package/dist/guidance-render.js.map +1 -1
- package/dist/harnesses.d.ts +49 -7
- package/dist/harnesses.js +90 -24
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +28 -52
- package/dist/hooks.js +393 -152
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +17 -39
- package/dist/install-hooks.js +21 -41
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +2 -2
- package/dist/orca-session-title.d.ts +2 -2
- package/dist/orca-session-title.js +3 -4
- package/dist/orca-session-title.js.map +1 -1
- package/dist/program.d.ts +6 -1
- package/dist/program.js +46 -5
- package/dist/program.js.map +1 -1
- package/dist/project-enablement.d.ts +15 -0
- package/dist/project-enablement.js +87 -0
- package/dist/project-enablement.js.map +1 -0
- package/dist/question-timing.d.ts +46 -0
- package/dist/question-timing.js +48 -0
- package/dist/question-timing.js.map +1 -0
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +2 -0
- package/dist/send.js +17 -15
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +1 -1
- package/dist/session-activation.js +3 -4
- package/dist/session-activation.js.map +1 -1
- package/dist/session-labels.d.ts +18 -2
- package/dist/session-labels.js +79 -8
- package/dist/session-labels.js.map +1 -1
- package/dist/sound-ref.d.ts +23 -0
- package/dist/sound-ref.js +28 -0
- package/dist/sound-ref.js.map +1 -0
- package/package.json +3 -3
- package/src/claude-wake.test.ts +1 -0
- package/src/claude-wake.ts +10 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +8 -0
- package/src/codex-wake.test.ts +1 -0
- package/src/codex-wake.ts +9 -0
- package/src/commands-agent-sessions.ts +72 -0
- package/src/commands-config.ts +10 -4
- package/src/commands-core.ts +1 -1
- package/src/commands-doctor.ts +96 -8
- package/src/commands-harness-context.ts +48 -2
- package/src/commands-hook-shape.ts +26 -10
- package/src/commands-hooks.ts +95 -31
- package/src/commands-init.ts +11 -1
- package/src/commands-project.ts +47 -0
- package/src/commands-send-support.ts +0 -4
- package/src/commands-send.ts +10 -5
- package/src/commands-skill.test.ts +44 -0
- package/src/commands-skill.ts +57 -0
- package/src/commands-sounds.test.ts +106 -0
- package/src/commands-sounds.ts +49 -0
- package/src/commands.test.ts +386 -23
- package/src/commands.ts +10 -0
- package/src/config-schema.test.ts +1 -0
- package/src/config-schema.ts +7 -4
- package/src/config.test.ts +44 -23
- package/src/config.ts +13 -15
- package/src/credentials.test.ts +20 -0
- package/src/credentials.ts +4 -1
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
- package/src/guidance-content.ts +22 -0
- package/src/guidance-render.ts +3 -3
- package/src/harness-integration.test.ts +212 -0
- package/src/harnesses.test.ts +107 -8
- package/src/harnesses.ts +111 -30
- package/src/hook-adapter.test.ts +27 -8
- package/src/hooks.test.ts +812 -83
- package/src/hooks.ts +529 -185
- package/src/install-hooks.test.ts +13 -16
- package/src/install-hooks.ts +40 -58
- package/src/invocation-context.ts +2 -2
- package/src/orca-session-title.test.ts +8 -49
- package/src/orca-session-title.ts +3 -5
- package/src/program.test.ts +45 -0
- package/src/program.ts +60 -4
- package/src/project-enablement.test.ts +55 -0
- package/src/project-enablement.ts +99 -0
- package/src/question-timing.ts +56 -0
- package/src/readiness.ts +8 -1
- package/src/send.ts +18 -20
- package/src/session-activation.ts +3 -4
- package/src/session-labels.test.ts +58 -9
- package/src/session-labels.ts +95 -12
- package/src/skill.test.ts +14 -3
- package/src/sound-ref.test.ts +31 -0
- package/src/sound-ref.ts +34 -0
- package/src/ui/config-view.test.ts +1 -0
package/src/hooks.test.ts
CHANGED
|
@@ -44,8 +44,10 @@ import {
|
|
|
44
44
|
import {
|
|
45
45
|
claimQuestionPush,
|
|
46
46
|
clearSessionState,
|
|
47
|
+
dropPendingQuestion,
|
|
47
48
|
drainRetirements,
|
|
48
49
|
drainOrphanRetirements,
|
|
50
|
+
handleSessionEnd,
|
|
49
51
|
runEscalationWaiter,
|
|
50
52
|
MAX_CONTINUATION_COUNT,
|
|
51
53
|
MAX_HELD_DELIVERIES,
|
|
@@ -61,16 +63,17 @@ import {
|
|
|
61
63
|
writeSessionState as persistSessionState,
|
|
62
64
|
type PendingQuestion,
|
|
63
65
|
type SessionState,
|
|
64
|
-
waiterCeilingSeconds,
|
|
65
|
-
WAITER_CEILING_SECONDS,
|
|
66
|
-
DETACHED_WAITER_CEILING_SECONDS,
|
|
67
66
|
} from './hooks.js'
|
|
68
67
|
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
|
|
69
|
-
import {
|
|
70
|
-
|
|
71
|
-
CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
|
|
72
|
-
} from './install-hooks.js'
|
|
68
|
+
import { QUESTION_STOP_TIMEOUT_SECONDS } from './install-hooks.js'
|
|
69
|
+
import { QUESTION_WAITER_CEILING_SECONDS } from './question-timing.js'
|
|
73
70
|
import { GUIDANCE_CONTEXT_MAX_BYTES } from './guidance-render.js'
|
|
71
|
+
import {
|
|
72
|
+
disableProject,
|
|
73
|
+
enableProject,
|
|
74
|
+
projectBinding,
|
|
75
|
+
projectEnabled,
|
|
76
|
+
} from './project-enablement.js'
|
|
74
77
|
|
|
75
78
|
/** New-format test fixtures always carry the canonical body explicitly. */
|
|
76
79
|
function registerQuestion(
|
|
@@ -322,6 +325,9 @@ function harness(replies: ReplyView[] = []): Harness {
|
|
|
322
325
|
// Virtual clock: sleeps advance it instead of costing wall time. A frozen
|
|
323
326
|
// clock would make the reply poll's deadline unreachable and spin forever.
|
|
324
327
|
let clock = NOW
|
|
328
|
+
const binding = projectBinding(root, env)
|
|
329
|
+
if (binding === null) throw new Error('test Project binding unavailable')
|
|
330
|
+
enableProject(binding, new Date(NOW))
|
|
325
331
|
return {
|
|
326
332
|
io,
|
|
327
333
|
recorder,
|
|
@@ -506,23 +512,12 @@ function writeGlobalConfig(h: Harness, toml: string): void {
|
|
|
506
512
|
const AWAY = NOW - 600_000
|
|
507
513
|
|
|
508
514
|
describe('the waiter wall clock', () => {
|
|
509
|
-
it('
|
|
510
|
-
|
|
511
|
-
// waiting. Detached, nothing is held, and the same number only shortens how
|
|
512
|
-
// long a direct wake stays possible.
|
|
513
|
-
expect(waiterCeilingSeconds(false)).toBe(WAITER_CEILING_SECONDS)
|
|
514
|
-
expect(waiterCeilingSeconds(true)).toBe(DETACHED_WAITER_CEILING_SECONDS)
|
|
515
|
-
expect(DETACHED_WAITER_CEILING_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
|
|
516
|
-
})
|
|
517
|
-
|
|
518
|
-
it('stays under the timeout the async Stop handler declares', () => {
|
|
519
|
-
// That declaration's kill is silent: past it the waiter vanishes and the
|
|
520
|
-
// answer the user already gave is never delivered.
|
|
521
|
-
expect(DETACHED_WAITER_CEILING_SECONDS).toBeLessThan(CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS)
|
|
515
|
+
it('keeps both ownership routes alive beyond the longest answer window', () => {
|
|
516
|
+
expect(QUESTION_WAITER_CEILING_SECONDS).toBeGreaterThan(REPLY_MAX_WINDOW_SECONDS)
|
|
522
517
|
})
|
|
523
518
|
|
|
524
|
-
it('
|
|
525
|
-
expect(
|
|
519
|
+
it('stays under the timeout every Question Routing Stop declares', () => {
|
|
520
|
+
expect(QUESTION_WAITER_CEILING_SECONDS).toBeLessThan(QUESTION_STOP_TIMEOUT_SECONDS)
|
|
526
521
|
})
|
|
527
522
|
})
|
|
528
523
|
|
|
@@ -566,8 +561,9 @@ describe('pushing a registered question', () => {
|
|
|
566
561
|
title: 'Ship it?',
|
|
567
562
|
body: 'Ship it?',
|
|
568
563
|
})
|
|
569
|
-
//
|
|
570
|
-
|
|
564
|
+
// Kind default sounds are resolved server-side; stamping one here would
|
|
565
|
+
// skip Account and Project maps.
|
|
566
|
+
expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBeUndefined()
|
|
571
567
|
})
|
|
572
568
|
|
|
573
569
|
it('routes a registered question to an answer-capable Android installation', async () => {
|
|
@@ -599,7 +595,7 @@ describe('pushing a registered question', () => {
|
|
|
599
595
|
mode: 'selected',
|
|
600
596
|
device_ids: ['dev_android'],
|
|
601
597
|
})
|
|
602
|
-
expect(h.recorder.submitted[0]?.draft.platform?.android?.sound).
|
|
598
|
+
expect(h.recorder.submitted[0]?.draft.platform?.android?.sound).toBeUndefined()
|
|
603
599
|
})
|
|
604
600
|
|
|
605
601
|
it('keeps sparse alt text paired with its original media item', async () => {
|
|
@@ -930,10 +926,9 @@ describe('terminal-first grace window', () => {
|
|
|
930
926
|
expect(h.recorder.submitted).toHaveLength(0)
|
|
931
927
|
})
|
|
932
928
|
|
|
933
|
-
it('
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
// exact requested millisecond, so the sleep deliberately overshoots.
|
|
929
|
+
it('keeps the longest grace inside the owner startup allowance', async () => {
|
|
930
|
+
// Real timers do not wake on the exact requested millisecond, so the sleep
|
|
931
|
+
// deliberately overshoots the supported six-minute preference.
|
|
937
932
|
const h = harness([reply({ text: 'Yes' })])
|
|
938
933
|
const virtualSleep = h.deps.sleep!
|
|
939
934
|
h.deps.sleep = async (milliseconds: number) => virtualSleep(milliseconds + 1)
|
|
@@ -1015,9 +1010,8 @@ describe('terminal-first grace window', () => {
|
|
|
1015
1010
|
return JSON.stringify({ session_id: 'transport-budget' })
|
|
1016
1011
|
})
|
|
1017
1012
|
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
// the blocking hosts' extra minute of declared headroom is there for.
|
|
1013
|
+
// This fixture commits a short eight-minute answer window. Transport and
|
|
1014
|
+
// the close fence still fit inside the Stop definition's teardown margin.
|
|
1021
1015
|
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 540_000)
|
|
1022
1016
|
expect(readSessionState('transport-budget', h.env).pending).toBeUndefined()
|
|
1023
1017
|
})
|
|
@@ -1214,6 +1208,74 @@ describe('OpenCode answer continuation', () => {
|
|
|
1214
1208
|
})
|
|
1215
1209
|
|
|
1216
1210
|
describe('the waiter owning one question to the end', () => {
|
|
1211
|
+
it('does not admit a max-window question after setup consumes its allowance', async () => {
|
|
1212
|
+
const h = harness([])
|
|
1213
|
+
writeGlobalConfig(h, `reply_window_seconds = ${REPLY_MAX_WINDOW_SECONDS}\n`)
|
|
1214
|
+
const factory = h.deps.clientFactory
|
|
1215
|
+
h.deps.clientFactory = () => {
|
|
1216
|
+
const client = factory!()
|
|
1217
|
+
return {
|
|
1218
|
+
...client,
|
|
1219
|
+
listDevices: async () => {
|
|
1220
|
+
h.advanceClock(10 * 60 * 1000 + 1)
|
|
1221
|
+
return client.listDevices()
|
|
1222
|
+
},
|
|
1223
|
+
} as ApiClient
|
|
1224
|
+
}
|
|
1225
|
+
writeSessionState('setup-overrun', h.env, { last_prompt_at: AWAY })
|
|
1226
|
+
registerQuestion('setup-overrun', h.env, { question: 'Deploy?' }, NOW)
|
|
1227
|
+
|
|
1228
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'setup-overrun' }))
|
|
1229
|
+
|
|
1230
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toEqual([])
|
|
1231
|
+
const pending = readSessionState('setup-overrun', h.env).pending?.[0]
|
|
1232
|
+
expect(pending?.request_id).toBeUndefined()
|
|
1233
|
+
expect(pending?.submission?.request_id).toBeDefined()
|
|
1234
|
+
expect(h.io.errLines.join('\n')).toContain('setup consumed the admission allowance')
|
|
1235
|
+
})
|
|
1236
|
+
|
|
1237
|
+
it('continues the same turn when the default one-day answer arrives just before expiry', async () => {
|
|
1238
|
+
const h = harness([])
|
|
1239
|
+
const answerAt = NOW + (86_400 - 10) * 1000
|
|
1240
|
+
h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
|
|
1241
|
+
const factory = h.deps.clientFactory
|
|
1242
|
+
h.deps.clientFactory = () => {
|
|
1243
|
+
const client = factory!()
|
|
1244
|
+
return {
|
|
1245
|
+
...client,
|
|
1246
|
+
replies: async (
|
|
1247
|
+
requestId: string,
|
|
1248
|
+
options: { waitSeconds: number; afterSeq: number },
|
|
1249
|
+
) => {
|
|
1250
|
+
const remaining = Math.max(0, answerAt - (h.deps.now?.() ?? NOW))
|
|
1251
|
+
h.advanceClock(Math.min(options.waitSeconds * 1000, remaining))
|
|
1252
|
+
const response = await client.replies(requestId, options)
|
|
1253
|
+
return {
|
|
1254
|
+
...response,
|
|
1255
|
+
replies:
|
|
1256
|
+
(h.deps.now?.() ?? NOW) >= answerAt
|
|
1257
|
+
? [reply({ text: 'Answer near the one-day edge' })]
|
|
1258
|
+
: [],
|
|
1259
|
+
}
|
|
1260
|
+
},
|
|
1261
|
+
} as ApiClient
|
|
1262
|
+
}
|
|
1263
|
+
writeSessionState('full-default-window', h.env, { last_prompt_at: AWAY })
|
|
1264
|
+
registerQuestion('full-default-window', h.env, { question: 'Deploy?' }, NOW)
|
|
1265
|
+
|
|
1266
|
+
await hookRunCommand(
|
|
1267
|
+
h.deps,
|
|
1268
|
+
'stop',
|
|
1269
|
+
stdin({ session_id: 'full-default-window' }),
|
|
1270
|
+
'codex',
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThan(8 * 60 * 1000)
|
|
1274
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain(
|
|
1275
|
+
'Answer near the one-day edge',
|
|
1276
|
+
)
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1217
1279
|
it('delivers a reply that commits while the server close fence is finalizing silence', async () => {
|
|
1218
1280
|
const h = harness([])
|
|
1219
1281
|
const factory = h.deps.clientFactory
|
|
@@ -1487,7 +1549,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1487
1549
|
})
|
|
1488
1550
|
})
|
|
1489
1551
|
|
|
1490
|
-
it('
|
|
1552
|
+
it('does not close silence before the committed answer deadline', async () => {
|
|
1491
1553
|
const h = harness([])
|
|
1492
1554
|
const factory = h.deps.clientFactory
|
|
1493
1555
|
h.deps.clientFactory = () => {
|
|
@@ -1496,8 +1558,8 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1496
1558
|
...client,
|
|
1497
1559
|
submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => ({
|
|
1498
1560
|
...(await client.submit(body, waitSeconds)),
|
|
1499
|
-
//
|
|
1500
|
-
//
|
|
1561
|
+
// Pin a short real-server deadline so the virtual clock proves the
|
|
1562
|
+
// waiter did not borrow even its close fence from the answer window.
|
|
1501
1563
|
reply_expires_at: new Date(NOW + 60_000).toISOString(),
|
|
1502
1564
|
}),
|
|
1503
1565
|
} as ApiClient
|
|
@@ -1507,12 +1569,99 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1507
1569
|
|
|
1508
1570
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
|
|
1509
1571
|
|
|
1510
|
-
expect(h.deps.now?.()).toBe(NOW +
|
|
1572
|
+
expect(h.deps.now?.()).toBe(NOW + 60_000)
|
|
1511
1573
|
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1512
1574
|
expect(readSessionState('deadline', h.env).pending).toBeUndefined()
|
|
1513
1575
|
expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
|
|
1514
1576
|
})
|
|
1515
1577
|
|
|
1578
|
+
it('closes a server window whose committed deadline exceeds its process owner', async () => {
|
|
1579
|
+
const h = harness([])
|
|
1580
|
+
h.recorder.replyExpiresAt = new Date(
|
|
1581
|
+
NOW + (QUESTION_WAITER_CEILING_SECONDS + 60) * 1000,
|
|
1582
|
+
).toISOString()
|
|
1583
|
+
writeSessionState('deadline-overrun', h.env, { last_prompt_at: AWAY })
|
|
1584
|
+
registerQuestion('deadline-overrun', h.env, { question: 'Deploy?' }, NOW)
|
|
1585
|
+
|
|
1586
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline-overrun' }))
|
|
1587
|
+
|
|
1588
|
+
expect(h.recorder.closed).toEqual([h.recorder.receipts[0]])
|
|
1589
|
+
expect(readSessionState('deadline-overrun', h.env).pending).toBeUndefined()
|
|
1590
|
+
expect(h.io.errLines.join('\n')).toContain('deadline beyond this process owner')
|
|
1591
|
+
})
|
|
1592
|
+
|
|
1593
|
+
it('keeps anomalous live state when immediate closure is unreachable', async () => {
|
|
1594
|
+
const h = harness([])
|
|
1595
|
+
h.recorder.failCloses = true
|
|
1596
|
+
h.recorder.replyExpiresAt = new Date(
|
|
1597
|
+
NOW + (QUESTION_WAITER_CEILING_SECONDS + 60) * 1000,
|
|
1598
|
+
).toISOString()
|
|
1599
|
+
writeSessionState('deadline-recovery', h.env, { last_prompt_at: AWAY })
|
|
1600
|
+
registerQuestion('deadline-recovery', h.env, { question: 'Deploy?' }, NOW)
|
|
1601
|
+
|
|
1602
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline-recovery' }))
|
|
1603
|
+
|
|
1604
|
+
const live = readSessionState('deadline-recovery', h.env).pending?.[0]
|
|
1605
|
+
expect(live?.request_id).toBe(h.recorder.receipts[0])
|
|
1606
|
+
expect(h.io.errLines.join('\n')).toContain('preserving the live question')
|
|
1607
|
+
|
|
1608
|
+
h.recorder.failCloses = false
|
|
1609
|
+
h.recorder.repliesFor = (requestId) =>
|
|
1610
|
+
requestId === live?.request_id ? [reply({ text: 'Recovered answer' })] : []
|
|
1611
|
+
h.io.outLines = []
|
|
1612
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline-recovery' }))
|
|
1613
|
+
|
|
1614
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Recovered answer')
|
|
1615
|
+
})
|
|
1616
|
+
|
|
1617
|
+
it('parks anomalous retirement when terminal input removes the frozen entry', async () => {
|
|
1618
|
+
const h = harness([])
|
|
1619
|
+
h.recorder.failCloses = true
|
|
1620
|
+
h.recorder.replyExpiresAt = new Date(
|
|
1621
|
+
NOW + (QUESTION_WAITER_CEILING_SECONDS + 60) * 1000,
|
|
1622
|
+
).toISOString()
|
|
1623
|
+
const factory = h.deps.clientFactory!
|
|
1624
|
+
let markClose: (() => void) | undefined
|
|
1625
|
+
const closeStarted = new Promise<void>((resolve) => {
|
|
1626
|
+
markClose = resolve
|
|
1627
|
+
})
|
|
1628
|
+
let releaseClose: (() => void) | undefined
|
|
1629
|
+
const heldClose = new Promise<void>((resolve) => {
|
|
1630
|
+
releaseClose = resolve
|
|
1631
|
+
})
|
|
1632
|
+
h.deps.clientFactory = (...args) => {
|
|
1633
|
+
const client = factory(...args)
|
|
1634
|
+
return {
|
|
1635
|
+
...client,
|
|
1636
|
+
closeReplies: async (requestId: string) => {
|
|
1637
|
+
markClose?.()
|
|
1638
|
+
await heldClose
|
|
1639
|
+
return client.closeReplies(requestId)
|
|
1640
|
+
},
|
|
1641
|
+
} as ApiClient
|
|
1642
|
+
}
|
|
1643
|
+
writeSessionState('deadline-prompt-race', h.env, { last_prompt_at: AWAY })
|
|
1644
|
+
registerQuestion('deadline-prompt-race', h.env, { question: 'Deploy?' }, NOW)
|
|
1645
|
+
|
|
1646
|
+
const stop = hookRunCommand(
|
|
1647
|
+
h.deps,
|
|
1648
|
+
'stop',
|
|
1649
|
+
stdin({ session_id: 'deadline-prompt-race' }),
|
|
1650
|
+
)
|
|
1651
|
+
await closeStarted
|
|
1652
|
+
const frozen = readSessionState('deadline-prompt-race', h.env).pending?.[0]
|
|
1653
|
+
if (frozen === undefined) throw new Error('expected frozen submission state')
|
|
1654
|
+
dropPendingQuestion('deadline-prompt-race', h.env, frozen)
|
|
1655
|
+
releaseClose?.()
|
|
1656
|
+
await stop
|
|
1657
|
+
|
|
1658
|
+
expect(
|
|
1659
|
+
readSessionState('deadline-prompt-race', h.env).retiring?.map(
|
|
1660
|
+
(entry) => entry.request_id,
|
|
1661
|
+
),
|
|
1662
|
+
).toEqual([h.recorder.receipts[0]])
|
|
1663
|
+
})
|
|
1664
|
+
|
|
1516
1665
|
it('charges sequential submission latency to one owner deadline, not to the answer window', async () => {
|
|
1517
1666
|
const h = harness([reply({ text: 'Done' })])
|
|
1518
1667
|
writeSessionState('latency', h.env, { last_prompt_at: AWAY })
|
|
@@ -1529,9 +1678,11 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1529
1678
|
// part of its own budget getting the question out. Both questions stay
|
|
1530
1679
|
// answerable for the configured window.
|
|
1531
1680
|
expect(windows).toEqual([86_400, 86_400])
|
|
1532
|
-
//
|
|
1533
|
-
//
|
|
1534
|
-
expect(h.deps.now?.()).toBeLessThanOrEqual(
|
|
1681
|
+
// Submission latency is charged to the one owner lifetime, without
|
|
1682
|
+
// shrinking either server answer window.
|
|
1683
|
+
expect(h.deps.now?.()).toBeLessThanOrEqual(
|
|
1684
|
+
NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
|
|
1685
|
+
)
|
|
1535
1686
|
})
|
|
1536
1687
|
|
|
1537
1688
|
it('re-arms the next Stop for an independently pending answer', async () => {
|
|
@@ -1590,7 +1741,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1590
1741
|
expect(h.deps.now?.()).toBeLessThan(started + 30_000)
|
|
1591
1742
|
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
|
|
1592
1743
|
expect(readSessionState('spent-owner', h.env).pending?.[0]?.request_id).toBe('req_old')
|
|
1593
|
-
expect(h.io.errLines.join('\n')).toContain('previous
|
|
1744
|
+
expect(h.io.errLines.join('\n')).toContain('previous answer owner ended')
|
|
1594
1745
|
})
|
|
1595
1746
|
|
|
1596
1747
|
it('does not re-arm a live question written before owner leases were persisted', async () => {
|
|
@@ -1619,7 +1770,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1619
1770
|
)
|
|
1620
1771
|
})
|
|
1621
1772
|
|
|
1622
|
-
it('pushes a newly registered question
|
|
1773
|
+
it('pushes and owns a newly registered question after a spent legacy waiter', async () => {
|
|
1623
1774
|
const h = harness([])
|
|
1624
1775
|
h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
|
|
1625
1776
|
writeSessionState('after-degraded', h.env, {
|
|
@@ -1643,8 +1794,9 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1643
1794
|
const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
|
|
1644
1795
|
expect(questions.map((entry) => entry.draft.presentation.body)).toEqual(['New question?'])
|
|
1645
1796
|
expect(readSessionState('after-degraded', h.env).pending?.map((entry) => entry.question)).toEqual(
|
|
1646
|
-
['Earlier question?'
|
|
1797
|
+
['Earlier question?'],
|
|
1647
1798
|
)
|
|
1799
|
+
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThan(8 * 60 * 1000)
|
|
1648
1800
|
})
|
|
1649
1801
|
|
|
1650
1802
|
it('still waits out a live owner lease for an independent answer', async () => {
|
|
@@ -2428,8 +2580,8 @@ describe('hostile input', () => {
|
|
|
2428
2580
|
mkdirSync(path.join(project, '.notifai'), { recursive: true })
|
|
2429
2581
|
writeFileSync(path.join(project, '.notifai', 'config.toml'), 'ask_grace_seconds = 99999\n')
|
|
2430
2582
|
const config = loadConfig({ cwd: project, env: h.env })
|
|
2431
|
-
// A committed repository file must not be able to
|
|
2432
|
-
//
|
|
2583
|
+
// A committed repository file must not be able to replace the supported
|
|
2584
|
+
// terminal-first preference with an arbitrary multi-hour hold.
|
|
2433
2585
|
expect(config.ask_grace_seconds.value).toBeLessThanOrEqual(360)
|
|
2434
2586
|
})
|
|
2435
2587
|
})
|
|
@@ -2632,7 +2784,31 @@ describe('ask registration', () => {
|
|
|
2632
2784
|
})
|
|
2633
2785
|
|
|
2634
2786
|
describe('session-start hook', () => {
|
|
2635
|
-
it('
|
|
2787
|
+
it('is completely silent when the Project is disabled', async () => {
|
|
2788
|
+
const h = harness()
|
|
2789
|
+
const binding = projectBinding(h.deps.cwd, h.env)
|
|
2790
|
+
if (binding === null) throw new Error('test Project binding unavailable')
|
|
2791
|
+
disableProject(binding)
|
|
2792
|
+
|
|
2793
|
+
for (const [event, hookHarness] of [
|
|
2794
|
+
['session-start', 'claude-code'],
|
|
2795
|
+
['subagent-start', 'codex'],
|
|
2796
|
+
['activation-stop', 'cursor'],
|
|
2797
|
+
] as const) {
|
|
2798
|
+
h.io.outLines = []
|
|
2799
|
+
await hookRunCommand(
|
|
2800
|
+
h.deps,
|
|
2801
|
+
event,
|
|
2802
|
+
stdin({ session_id: 'disabled', conversation_id: 'disabled', workspace_roots: [h.deps.cwd], loop_count: 0 }),
|
|
2803
|
+
hookHarness,
|
|
2804
|
+
)
|
|
2805
|
+
expect(h.io.outLines).toEqual([])
|
|
2806
|
+
}
|
|
2807
|
+
expect(projectEnabled(binding)).toBe(false)
|
|
2808
|
+
expect(readSessionState('disabled', h.env).started_at).toBeUndefined()
|
|
2809
|
+
})
|
|
2810
|
+
|
|
2811
|
+
it('adds proactive Notifai context for an enabled Project before machine authentication exists', async () => {
|
|
2636
2812
|
const h = harness()
|
|
2637
2813
|
h.deps.store.load = () => null
|
|
2638
2814
|
|
|
@@ -2651,7 +2827,7 @@ describe('session-start hook', () => {
|
|
|
2651
2827
|
expect(first.hookSpecificOutput).toMatchObject({
|
|
2652
2828
|
hookEventName: 'SessionStart',
|
|
2653
2829
|
})
|
|
2654
|
-
expect(first.hookSpecificOutput?.additionalContext).toMatch(/Notifai.*
|
|
2830
|
+
expect(first.hookSpecificOutput?.additionalContext).toMatch(/Notifai.*enabled.*Project/i)
|
|
2655
2831
|
expect(first.hookSpecificOutput?.additionalContext).toContain('# How to read this guidance')
|
|
2656
2832
|
expect(first.hookSpecificOutput?.additionalContext).toContain(
|
|
2657
2833
|
'<!-- notifai:guidance topic=when-to-notify from=shipped default -->',
|
|
@@ -2696,7 +2872,7 @@ describe('session-start hook', () => {
|
|
|
2696
2872
|
'cursor',
|
|
2697
2873
|
)
|
|
2698
2874
|
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
|
|
2699
|
-
additional_context: expect.stringMatching(/Notifai.*
|
|
2875
|
+
additional_context: expect.stringMatching(/Notifai.*enabled.*Project/i),
|
|
2700
2876
|
})
|
|
2701
2877
|
|
|
2702
2878
|
h.io.outLines = []
|
|
@@ -3999,6 +4175,111 @@ describe('two hooks racing one question', () => {
|
|
|
3999
4175
|
expect(h.io.errLines.join(" ")).toContain('already handling')
|
|
4000
4176
|
})
|
|
4001
4177
|
|
|
4178
|
+
it('waits past fifteen seconds for an older owner to hand off a newer ask', async () => {
|
|
4179
|
+
const h = harness([reply({ text: 'Continue' })])
|
|
4180
|
+
writeSessionState('race-long-handoff', h.env, { last_prompt_at: AWAY })
|
|
4181
|
+
registerQuestion('race-long-handoff', h.env, { question: 'Original?' }, NOW)
|
|
4182
|
+
expect(claimQuestionPush('race-long-handoff', h.env, Date.now())).toBe(true)
|
|
4183
|
+
h.advanceClock(1)
|
|
4184
|
+
registerQuestion(
|
|
4185
|
+
'race-long-handoff',
|
|
4186
|
+
h.env,
|
|
4187
|
+
{ question: 'New independent ask?' },
|
|
4188
|
+
NOW + 1,
|
|
4189
|
+
)
|
|
4190
|
+
const startedAt = h.deps.now!()
|
|
4191
|
+
let handedOff = false
|
|
4192
|
+
h.deps.sleep = async (milliseconds: number) => {
|
|
4193
|
+
h.advanceClock(milliseconds)
|
|
4194
|
+
if (!handedOff && h.deps.now!() - startedAt >= 20_000) {
|
|
4195
|
+
handedOff = true
|
|
4196
|
+
releaseQuestionPush('race-long-handoff', h.env)
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4200
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-long-handoff' }))
|
|
4201
|
+
|
|
4202
|
+
expect(handedOff).toBe(true)
|
|
4203
|
+
expect(h.deps.now!() - startedAt).toBeGreaterThanOrEqual(20_000)
|
|
4204
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(2)
|
|
4205
|
+
expect(h.io.errLines.join(' ')).not.toContain('claimed-elsewhere')
|
|
4206
|
+
})
|
|
4207
|
+
|
|
4208
|
+
it('waits through the holder teardown margin instead of stranding a boundary ask', async () => {
|
|
4209
|
+
const h = harness([reply({ text: 'Continue' })])
|
|
4210
|
+
writeSessionState('race-holder-deadline', h.env, { last_prompt_at: AWAY })
|
|
4211
|
+
registerQuestion('race-holder-deadline', h.env, { question: 'Original?' }, NOW)
|
|
4212
|
+
expect(
|
|
4213
|
+
claimQuestionPush(
|
|
4214
|
+
'race-holder-deadline',
|
|
4215
|
+
h.env,
|
|
4216
|
+
Date.now(),
|
|
4217
|
+
undefined,
|
|
4218
|
+
NOW + 20_000,
|
|
4219
|
+
),
|
|
4220
|
+
).toBe(true)
|
|
4221
|
+
h.advanceClock(1)
|
|
4222
|
+
registerQuestion(
|
|
4223
|
+
'race-holder-deadline',
|
|
4224
|
+
h.env,
|
|
4225
|
+
{ question: 'New?' },
|
|
4226
|
+
NOW + 1,
|
|
4227
|
+
)
|
|
4228
|
+
let released = false
|
|
4229
|
+
h.deps.sleep = async (milliseconds: number) => {
|
|
4230
|
+
h.advanceClock(milliseconds)
|
|
4231
|
+
if (!released && h.deps.now!() >= NOW + 25_000) {
|
|
4232
|
+
released = true
|
|
4233
|
+
releaseQuestionPush('race-holder-deadline', h.env)
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
|
|
4237
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-holder-deadline' }))
|
|
4238
|
+
|
|
4239
|
+
expect(released).toBe(true)
|
|
4240
|
+
expect(h.deps.now!()).toBeGreaterThanOrEqual(NOW + 25_000)
|
|
4241
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(2)
|
|
4242
|
+
})
|
|
4243
|
+
|
|
4244
|
+
it('stops waiting when another successor snapshots the new ask', async () => {
|
|
4245
|
+
const h = harness([])
|
|
4246
|
+
writeSessionState('race-resnapshot', h.env, { last_prompt_at: AWAY })
|
|
4247
|
+
registerQuestion('race-resnapshot', h.env, { question: 'Original?' }, NOW)
|
|
4248
|
+
expect(
|
|
4249
|
+
claimQuestionPush(
|
|
4250
|
+
'race-resnapshot',
|
|
4251
|
+
h.env,
|
|
4252
|
+
Date.now(),
|
|
4253
|
+
undefined,
|
|
4254
|
+
NOW + 60_000,
|
|
4255
|
+
),
|
|
4256
|
+
).toBe(true)
|
|
4257
|
+
h.advanceClock(1)
|
|
4258
|
+
registerQuestion('race-resnapshot', h.env, { question: 'New?' }, NOW + 1)
|
|
4259
|
+
const startedAt = h.deps.now!()
|
|
4260
|
+
let replacementClaimed = false
|
|
4261
|
+
h.deps.sleep = async (milliseconds: number) => {
|
|
4262
|
+
h.advanceClock(milliseconds)
|
|
4263
|
+
if (!replacementClaimed && h.deps.now!() - startedAt >= 20_000) {
|
|
4264
|
+
releaseQuestionPush('race-resnapshot', h.env)
|
|
4265
|
+
replacementClaimed = claimQuestionPush(
|
|
4266
|
+
'race-resnapshot',
|
|
4267
|
+
h.env,
|
|
4268
|
+
Date.now(),
|
|
4269
|
+
undefined,
|
|
4270
|
+
NOW + 40_000,
|
|
4271
|
+
)
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-resnapshot' }))
|
|
4276
|
+
|
|
4277
|
+
expect(replacementClaimed).toBe(true)
|
|
4278
|
+
expect(h.deps.now!() - startedAt).toBe(20_000)
|
|
4279
|
+
expect(h.recorder.submitted).toEqual([])
|
|
4280
|
+
releaseQuestionPush('race-resnapshot', h.env)
|
|
4281
|
+
})
|
|
4282
|
+
|
|
4002
4283
|
it('resumes the agent once when two Stops collect the same late answer', async () => {
|
|
4003
4284
|
const answers: ReplyView[] = []
|
|
4004
4285
|
const h = harness(answers)
|
|
@@ -4044,10 +4325,54 @@ describe('two hooks racing one question', () => {
|
|
|
4044
4325
|
expect(polls).toBe(1)
|
|
4045
4326
|
expect(resumes.filter((entry) => entry.decision === 'block')).toHaveLength(1)
|
|
4046
4327
|
})
|
|
4328
|
+
|
|
4329
|
+
it('hands the claim to a later Stop so its independent question is sent now', async () => {
|
|
4330
|
+
const h = harness([])
|
|
4331
|
+
h.deps.sleep = async (milliseconds: number) => {
|
|
4332
|
+
h.advanceClock(milliseconds)
|
|
4333
|
+
await new Promise<void>((resolve) => setImmediate(resolve))
|
|
4334
|
+
}
|
|
4335
|
+
h.recorder.repliesFor = (requestId) =>
|
|
4336
|
+
h.recorder.aliases.get(requestId) === 2
|
|
4337
|
+
? [reply({ text: 'Answer the newer question' })]
|
|
4338
|
+
: []
|
|
4339
|
+
let markFirstPoll: (() => void) | undefined
|
|
4340
|
+
const firstPoll = new Promise<void>((resolve) => {
|
|
4341
|
+
markFirstPoll = resolve
|
|
4342
|
+
})
|
|
4343
|
+
let releaseFirstPoll: (() => void) | undefined
|
|
4344
|
+
const heldFirstPoll = new Promise<void>((resolve) => {
|
|
4345
|
+
releaseFirstPoll = resolve
|
|
4346
|
+
})
|
|
4347
|
+
let hold = true
|
|
4348
|
+
h.recorder.beforeReplies = async () => {
|
|
4349
|
+
if (!hold) return
|
|
4350
|
+
hold = false
|
|
4351
|
+
markFirstPoll?.()
|
|
4352
|
+
await heldFirstPoll
|
|
4353
|
+
}
|
|
4354
|
+
writeSessionState('claim-handoff', h.env, { last_prompt_at: AWAY })
|
|
4355
|
+
registerQuestion('claim-handoff', h.env, { question: 'Older question?' }, NOW)
|
|
4356
|
+
|
|
4357
|
+
const firstStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'claim-handoff' }))
|
|
4358
|
+
await firstPoll
|
|
4359
|
+
registerQuestion('claim-handoff', h.env, { question: 'Newer question?' }, NOW + 1)
|
|
4360
|
+
const secondStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'claim-handoff' }))
|
|
4361
|
+
await new Promise<void>((resolve) => setImmediate(resolve))
|
|
4362
|
+
releaseFirstPoll?.()
|
|
4363
|
+
await Promise.all([firstStop, secondStop])
|
|
4364
|
+
|
|
4365
|
+
const sent = h.recorder.submitted
|
|
4366
|
+
.filter((entry) => isQuestionSubmit(entry))
|
|
4367
|
+
.map((entry) => entry.draft.presentation.body)
|
|
4368
|
+
expect(sent).toEqual(['Older question?', 'Newer question?'])
|
|
4369
|
+
expect(h.io.outLines.join('\n')).toContain('Answer the newer question')
|
|
4370
|
+
expect(h.io.errLines.join('\n')).toContain('yielding the answer owner')
|
|
4371
|
+
})
|
|
4047
4372
|
})
|
|
4048
4373
|
|
|
4049
4374
|
describe('question registration racing a Stop submission', () => {
|
|
4050
|
-
it('
|
|
4375
|
+
it('preserves both questions while the old owner yields to the new ask', async () => {
|
|
4051
4376
|
const h = harness([])
|
|
4052
4377
|
writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
|
|
4053
4378
|
registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
|
|
@@ -4058,19 +4383,19 @@ describe('question registration racing a Stop submission', () => {
|
|
|
4058
4383
|
|
|
4059
4384
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
|
|
4060
4385
|
|
|
4061
|
-
// The racing ask
|
|
4062
|
-
// question
|
|
4063
|
-
// cannot accept a phone answer after the hook has returned.
|
|
4386
|
+
// The racing ask is a handoff signal. This owner leaves the delivered old
|
|
4387
|
+
// question and the new unasked one intact for the successor Stop.
|
|
4064
4388
|
const state = readSessionState('submit-race', h.env)
|
|
4065
|
-
expect(state.pending?.map((entry) => entry.question)).toEqual([
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
)
|
|
4389
|
+
expect(state.pending?.map((entry) => entry.question)).toEqual([
|
|
4390
|
+
'Old question?',
|
|
4391
|
+
'New question?',
|
|
4392
|
+
])
|
|
4393
|
+
expect(state.pending?.[0]?.request_id).toBe(h.recorder.receipts[0])
|
|
4394
|
+
expect(state.pending?.[1]?.request_id).toBeUndefined()
|
|
4395
|
+
expect(h.io.errLines.join('\n')).toContain('yielding the answer owner')
|
|
4071
4396
|
})
|
|
4072
4397
|
|
|
4073
|
-
it('
|
|
4398
|
+
it('lets the successor owner collect an old answer without losing the newer question', async () => {
|
|
4074
4399
|
const h = harness([reply({ text: 'Old answer' })])
|
|
4075
4400
|
writeSessionState('answer-race', h.env, { last_prompt_at: AWAY })
|
|
4076
4401
|
registerQuestion('answer-race', h.env, { question: 'Old question?' }, NOW)
|
|
@@ -4079,6 +4404,7 @@ describe('question registration racing a Stop submission', () => {
|
|
|
4079
4404
|
registerQuestion('answer-race', h.env, { question: 'New question?' }, NOW + 1)
|
|
4080
4405
|
}
|
|
4081
4406
|
|
|
4407
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-race' }))
|
|
4082
4408
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-race' }))
|
|
4083
4409
|
|
|
4084
4410
|
const state = readSessionState('answer-race', h.env)
|
|
@@ -4171,6 +4497,194 @@ describe('Claude Code Stop wake route', () => {
|
|
|
4171
4497
|
expect(readSessionState('claude-route', h.env).accepted).toBeDefined()
|
|
4172
4498
|
})
|
|
4173
4499
|
|
|
4500
|
+
it('keeps the detached owner through the default one-day answer window', async () => {
|
|
4501
|
+
const h = harness([])
|
|
4502
|
+
const answerAt = NOW + (86_400 - 10) * 1000
|
|
4503
|
+
h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
|
|
4504
|
+
const factory = h.deps.clientFactory
|
|
4505
|
+
h.deps.clientFactory = () => {
|
|
4506
|
+
const client = factory!()
|
|
4507
|
+
return {
|
|
4508
|
+
...client,
|
|
4509
|
+
replies: async (
|
|
4510
|
+
requestId: string,
|
|
4511
|
+
options: { waitSeconds: number; afterSeq: number },
|
|
4512
|
+
) => {
|
|
4513
|
+
const remaining = Math.max(0, answerAt - (h.deps.now?.() ?? NOW))
|
|
4514
|
+
h.advanceClock(Math.min(options.waitSeconds * 1000, remaining))
|
|
4515
|
+
const response = await client.replies(requestId, options)
|
|
4516
|
+
return {
|
|
4517
|
+
...response,
|
|
4518
|
+
replies:
|
|
4519
|
+
(h.deps.now?.() ?? NOW) >= answerAt
|
|
4520
|
+
? [reply({ text: 'Wake near the one-day edge' })]
|
|
4521
|
+
: [],
|
|
4522
|
+
}
|
|
4523
|
+
},
|
|
4524
|
+
} as ApiClient
|
|
4525
|
+
}
|
|
4526
|
+
writeSessionState('claude-route', h.env, { last_prompt_at: AWAY })
|
|
4527
|
+
registerQuestion('claude-route', h.env, { question: 'Deploy?' }, NOW)
|
|
4528
|
+
const wake = claudeWake()
|
|
4529
|
+
|
|
4530
|
+
await hookRunCommand(
|
|
4531
|
+
{ ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
|
|
4532
|
+
'stop',
|
|
4533
|
+
stdin({ session_id: 'claude-route', cwd: '/tmp/claude-route' }),
|
|
4534
|
+
'claude-code',
|
|
4535
|
+
)
|
|
4536
|
+
|
|
4537
|
+
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThan(8 * 60 * 1000)
|
|
4538
|
+
expect(wake.sent).toHaveLength(1)
|
|
4539
|
+
expect(wake.sent[0]).toContain('Wake near the one-day edge')
|
|
4540
|
+
})
|
|
4541
|
+
|
|
4542
|
+
it('stops a detached observer when SessionEnd removes its ownership', async () => {
|
|
4543
|
+
const h = harness([reply({ text: 'Too late' })])
|
|
4544
|
+
let markPoll: (() => void) | undefined
|
|
4545
|
+
const pollStarted = new Promise<void>((resolve) => {
|
|
4546
|
+
markPoll = resolve
|
|
4547
|
+
})
|
|
4548
|
+
let releasePoll: (() => void) | undefined
|
|
4549
|
+
const heldPoll = new Promise<void>((resolve) => {
|
|
4550
|
+
releasePoll = resolve
|
|
4551
|
+
})
|
|
4552
|
+
h.recorder.beforeReplies = async () => {
|
|
4553
|
+
markPoll?.()
|
|
4554
|
+
await heldPoll
|
|
4555
|
+
}
|
|
4556
|
+
writeSessionState('claude-route', h.env, { last_prompt_at: AWAY })
|
|
4557
|
+
registerQuestion('claude-route', h.env, { question: 'Deploy?' }, NOW)
|
|
4558
|
+
const wake = claudeWake()
|
|
4559
|
+
const deps = { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 }
|
|
4560
|
+
|
|
4561
|
+
const stop = hookRunCommand(
|
|
4562
|
+
deps,
|
|
4563
|
+
'stop',
|
|
4564
|
+
stdin({ session_id: 'claude-route', cwd: '/tmp/claude-route' }),
|
|
4565
|
+
'claude-code',
|
|
4566
|
+
)
|
|
4567
|
+
await pollStarted
|
|
4568
|
+
await hookRunCommand(
|
|
4569
|
+
deps,
|
|
4570
|
+
'session-end',
|
|
4571
|
+
stdin({ session_id: 'claude-route', cwd: '/tmp/claude-route' }),
|
|
4572
|
+
'claude-code',
|
|
4573
|
+
)
|
|
4574
|
+
releasePoll?.()
|
|
4575
|
+
await stop
|
|
4576
|
+
|
|
4577
|
+
expect(wake.sent).toEqual([])
|
|
4578
|
+
expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
|
|
4579
|
+
expect(h.io.errLines.join('\n')).toContain('stopping this observer')
|
|
4580
|
+
expect(claimQuestionPush('claude-route', h.env)).toBe(true)
|
|
4581
|
+
releaseQuestionPush('claude-route', h.env)
|
|
4582
|
+
})
|
|
4583
|
+
|
|
4584
|
+
it('does not resurrect or wake an ended session after its close fence returns', async () => {
|
|
4585
|
+
const h = harness([reply({ text: 'Too late' })])
|
|
4586
|
+
const factory = h.deps.clientFactory!
|
|
4587
|
+
let markClose: (() => void) | undefined
|
|
4588
|
+
const closeStarted = new Promise<void>((resolve) => {
|
|
4589
|
+
markClose = resolve
|
|
4590
|
+
})
|
|
4591
|
+
let releaseClose: (() => void) | undefined
|
|
4592
|
+
const heldClose = new Promise<void>((resolve) => {
|
|
4593
|
+
releaseClose = resolve
|
|
4594
|
+
})
|
|
4595
|
+
h.deps.clientFactory = (...args) => {
|
|
4596
|
+
const client = factory(...args)
|
|
4597
|
+
return {
|
|
4598
|
+
...client,
|
|
4599
|
+
closeReplies: async (requestId: string) => {
|
|
4600
|
+
markClose?.()
|
|
4601
|
+
await heldClose
|
|
4602
|
+
return client.closeReplies(requestId)
|
|
4603
|
+
},
|
|
4604
|
+
} as ApiClient
|
|
4605
|
+
}
|
|
4606
|
+
writeSessionState('claude-fence-end', h.env, { last_prompt_at: AWAY })
|
|
4607
|
+
registerQuestion('claude-fence-end', h.env, { question: 'Deploy?' }, NOW)
|
|
4608
|
+
const wake = claudeWake()
|
|
4609
|
+
const deps = { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 }
|
|
4610
|
+
|
|
4611
|
+
const stop = hookRunCommand(
|
|
4612
|
+
deps,
|
|
4613
|
+
'stop',
|
|
4614
|
+
stdin({ session_id: 'claude-fence-end', cwd: '/tmp/claude-fence-end' }),
|
|
4615
|
+
'claude-code',
|
|
4616
|
+
)
|
|
4617
|
+
await closeStarted
|
|
4618
|
+
await hookRunCommand(
|
|
4619
|
+
deps,
|
|
4620
|
+
'session-end',
|
|
4621
|
+
stdin({ session_id: 'claude-fence-end', cwd: '/tmp/claude-fence-end' }),
|
|
4622
|
+
'claude-code',
|
|
4623
|
+
)
|
|
4624
|
+
releaseClose?.()
|
|
4625
|
+
await stop
|
|
4626
|
+
|
|
4627
|
+
expect(wake.sent).toEqual([])
|
|
4628
|
+
expect(readSessionState('claude-fence-end', h.env).accepted).toBeUndefined()
|
|
4629
|
+
expect(h.io.errLines.join('\n')).toContain('ended before answer delivery')
|
|
4630
|
+
})
|
|
4631
|
+
|
|
4632
|
+
it('globally retires a submission that commits after SessionEnd', async () => {
|
|
4633
|
+
const h = harness([])
|
|
4634
|
+
const factory = h.deps.clientFactory!
|
|
4635
|
+
let markSubmit: (() => void) | undefined
|
|
4636
|
+
const submitStarted = new Promise<void>((resolve) => {
|
|
4637
|
+
markSubmit = resolve
|
|
4638
|
+
})
|
|
4639
|
+
let releaseSubmit: (() => void) | undefined
|
|
4640
|
+
const heldSubmit = new Promise<void>((resolve) => {
|
|
4641
|
+
releaseSubmit = resolve
|
|
4642
|
+
})
|
|
4643
|
+
h.deps.clientFactory = (...args) => {
|
|
4644
|
+
const client = factory(...args)
|
|
4645
|
+
return {
|
|
4646
|
+
...client,
|
|
4647
|
+
submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => {
|
|
4648
|
+
if (body.draft.reply !== undefined) {
|
|
4649
|
+
markSubmit?.()
|
|
4650
|
+
await heldSubmit
|
|
4651
|
+
}
|
|
4652
|
+
return client.submit(body, waitSeconds)
|
|
4653
|
+
},
|
|
4654
|
+
} as ApiClient
|
|
4655
|
+
}
|
|
4656
|
+
writeSessionState('claude-submit-end', h.env, { last_prompt_at: AWAY })
|
|
4657
|
+
registerQuestion('claude-submit-end', h.env, { question: 'Deploy?' }, NOW)
|
|
4658
|
+
const wake = claudeWake()
|
|
4659
|
+
const deps = { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 }
|
|
4660
|
+
|
|
4661
|
+
const stop = hookRunCommand(
|
|
4662
|
+
deps,
|
|
4663
|
+
'stop',
|
|
4664
|
+
stdin({ session_id: 'claude-submit-end', cwd: '/tmp/claude-submit-end' }),
|
|
4665
|
+
'claude-code',
|
|
4666
|
+
)
|
|
4667
|
+
await submitStarted
|
|
4668
|
+
await hookRunCommand(
|
|
4669
|
+
deps,
|
|
4670
|
+
'session-end',
|
|
4671
|
+
stdin({ session_id: 'claude-submit-end', cwd: '/tmp/claude-submit-end' }),
|
|
4672
|
+
'claude-code',
|
|
4673
|
+
)
|
|
4674
|
+
releaseSubmit?.()
|
|
4675
|
+
await stop
|
|
4676
|
+
|
|
4677
|
+
const client = factory('https://test.notifai.invalid', 'Bearer test')
|
|
4678
|
+
await drainOrphanRetirements(
|
|
4679
|
+
{ client, config: loadConfig({ cwd: h.deps.cwd, env: h.env }) },
|
|
4680
|
+
h.env,
|
|
4681
|
+
NOW,
|
|
4682
|
+
)
|
|
4683
|
+
expect(wake.sent).toEqual([])
|
|
4684
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
4685
|
+
expect(readSessionState('claude-submit-end', h.env).pending).toBeUndefined()
|
|
4686
|
+
})
|
|
4687
|
+
|
|
4174
4688
|
/** One answer already accepted and journaled, ready for the next Stop. */
|
|
4175
4689
|
function journaledAnswer(
|
|
4176
4690
|
h: Harness,
|
|
@@ -4488,7 +5002,7 @@ describe('escalation waiter delivery seam', () => {
|
|
|
4488
5002
|
sessionId: 'waiter-route',
|
|
4489
5003
|
envelope: { session_id: 'waiter-route' },
|
|
4490
5004
|
route: { kind: 'hook-continuation', deliver },
|
|
4491
|
-
processDeadlineAt: NOW +
|
|
5005
|
+
processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
|
|
4492
5006
|
})
|
|
4493
5007
|
|
|
4494
5008
|
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
|
|
@@ -4548,7 +5062,7 @@ describe('escalation waiter delivery seam', () => {
|
|
|
4548
5062
|
return { stdout: JSON.stringify({ decision: 'block', reason: event.context }), notes: [] }
|
|
4549
5063
|
},
|
|
4550
5064
|
},
|
|
4551
|
-
processDeadlineAt: NOW +
|
|
5065
|
+
processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
|
|
4552
5066
|
})
|
|
4553
5067
|
|
|
4554
5068
|
expect(phases.filter((phase) => phase === 'grace').length).toBeGreaterThan(0)
|
|
@@ -4561,6 +5075,74 @@ describe('escalation waiter delivery seam', () => {
|
|
|
4561
5075
|
releaseQuestionPush('waiter-race-lifetime', h.env)
|
|
4562
5076
|
})
|
|
4563
5077
|
|
|
5078
|
+
it('keeps an unrelated answer when another owned question is retired mid-poll', async () => {
|
|
5079
|
+
const h = harness([])
|
|
5080
|
+
const first: PendingQuestion = {
|
|
5081
|
+
question: 'First?',
|
|
5082
|
+
body: 'First?',
|
|
5083
|
+
asked_at: NOW,
|
|
5084
|
+
request_id: 'req_partial_1',
|
|
5085
|
+
collapse_key: 'collapse-partial-1',
|
|
5086
|
+
device_ids: ['dev_iphone'],
|
|
5087
|
+
reply_deadline_at: NOW + 60_000,
|
|
5088
|
+
owner_deadline_at: NOW + 120_000,
|
|
5089
|
+
}
|
|
5090
|
+
const second: PendingQuestion = {
|
|
5091
|
+
question: 'Second?',
|
|
5092
|
+
body: 'Second?',
|
|
5093
|
+
asked_at: NOW,
|
|
5094
|
+
request_id: 'req_partial_2',
|
|
5095
|
+
collapse_key: 'collapse-partial-2',
|
|
5096
|
+
device_ids: ['dev_iphone'],
|
|
5097
|
+
reply_deadline_at: NOW + 60_000,
|
|
5098
|
+
owner_deadline_at: NOW + 120_000,
|
|
5099
|
+
}
|
|
5100
|
+
writeSessionState('waiter-partial', h.env, {
|
|
5101
|
+
last_prompt_at: AWAY,
|
|
5102
|
+
pending: [first, second],
|
|
5103
|
+
})
|
|
5104
|
+
const context = waiterContext(h)
|
|
5105
|
+
const polls = new Map<string, number>()
|
|
5106
|
+
let longPolls = 0
|
|
5107
|
+
let releaseLongPolls: (() => void) | undefined
|
|
5108
|
+
const heldLongPolls = new Promise<void>((resolve) => {
|
|
5109
|
+
releaseLongPolls = resolve
|
|
5110
|
+
})
|
|
5111
|
+
context.waitForFirstReply = async (requestId: string) => {
|
|
5112
|
+
const count = (polls.get(requestId) ?? 0) + 1
|
|
5113
|
+
polls.set(requestId, count)
|
|
5114
|
+
if (count === 1) return { replies: [], timedOut: true }
|
|
5115
|
+
longPolls += 1
|
|
5116
|
+
await heldLongPolls
|
|
5117
|
+
return {
|
|
5118
|
+
replies: requestId === second.request_id ? [reply({ text: 'Second answer' })] : [],
|
|
5119
|
+
timedOut: requestId !== second.request_id,
|
|
5120
|
+
}
|
|
5121
|
+
}
|
|
5122
|
+
const deliveries: string[] = []
|
|
5123
|
+
|
|
5124
|
+
const waiter = runEscalationWaiter(context, {
|
|
5125
|
+
sessionId: 'waiter-partial',
|
|
5126
|
+
envelope: { session_id: 'waiter-partial' },
|
|
5127
|
+
route: {
|
|
5128
|
+
kind: 'hook-continuation',
|
|
5129
|
+
deliver: async (event) => {
|
|
5130
|
+
deliveries.push(event.context)
|
|
5131
|
+
return { acknowledgement: 'stdout', stdout: event.context }
|
|
5132
|
+
},
|
|
5133
|
+
},
|
|
5134
|
+
processDeadlineAt: NOW + 120_000,
|
|
5135
|
+
})
|
|
5136
|
+
await waitUntil(() => longPolls === 2, 1_000, 'long reply polls did not start')
|
|
5137
|
+
dropPendingQuestion('waiter-partial', h.env, first)
|
|
5138
|
+
releaseLongPolls?.()
|
|
5139
|
+
await waiter
|
|
5140
|
+
|
|
5141
|
+
expect(deliveries).toHaveLength(1)
|
|
5142
|
+
expect(deliveries[0]).toContain('Second answer')
|
|
5143
|
+
expect(deliveries[0]).not.toContain('First?')
|
|
5144
|
+
})
|
|
5145
|
+
|
|
4564
5146
|
/** One accepted answer on the journal, whatever route is about to own it. */
|
|
4565
5147
|
function journaled(h: Harness, sessionId: string): void {
|
|
4566
5148
|
writeSessionState(sessionId, h.env, {
|
|
@@ -4583,6 +5165,138 @@ describe('escalation waiter delivery seam', () => {
|
|
|
4583
5165
|
})
|
|
4584
5166
|
}
|
|
4585
5167
|
|
|
5168
|
+
it('lets SessionEnd cancel at the final route commit boundary', async () => {
|
|
5169
|
+
const h = harness([])
|
|
5170
|
+
journaled(h, 'waiter-final-cancel')
|
|
5171
|
+
const context = waiterContext(h)
|
|
5172
|
+
let markRoute: (() => void) | undefined
|
|
5173
|
+
const routeStarted = new Promise<void>((resolve) => {
|
|
5174
|
+
markRoute = resolve
|
|
5175
|
+
})
|
|
5176
|
+
let releaseRoute: (() => void) | undefined
|
|
5177
|
+
const heldRoute = new Promise<void>((resolve) => {
|
|
5178
|
+
releaseRoute = resolve
|
|
5179
|
+
})
|
|
5180
|
+
let wrote = false
|
|
5181
|
+
|
|
5182
|
+
const waiter = runEscalationWaiter(context, {
|
|
5183
|
+
sessionId: 'waiter-final-cancel',
|
|
5184
|
+
envelope: { session_id: 'waiter-final-cancel' },
|
|
5185
|
+
route: {
|
|
5186
|
+
kind: 'inbox-socket',
|
|
5187
|
+
deliver: async (event) => {
|
|
5188
|
+
markRoute?.()
|
|
5189
|
+
await heldRoute
|
|
5190
|
+
if (event.commitDelivery()) wrote = true
|
|
5191
|
+
return { acknowledgement: wrote ? 'delivered' : 'held' }
|
|
5192
|
+
},
|
|
5193
|
+
},
|
|
5194
|
+
processDeadlineAt: NOW + 120_000,
|
|
5195
|
+
})
|
|
5196
|
+
await routeStarted
|
|
5197
|
+
await hookRunCommand(
|
|
5198
|
+
h.deps,
|
|
5199
|
+
'session-end',
|
|
5200
|
+
stdin({ session_id: 'waiter-final-cancel' }),
|
|
5201
|
+
'claude-code',
|
|
5202
|
+
)
|
|
5203
|
+
releaseRoute?.()
|
|
5204
|
+
await waiter
|
|
5205
|
+
|
|
5206
|
+
expect(wrote).toBe(false)
|
|
5207
|
+
})
|
|
5208
|
+
|
|
5209
|
+
it('fences production blocking stdout immediately before the harness write', async () => {
|
|
5210
|
+
const h = harness([reply({ text: 'Too late for stdout' })])
|
|
5211
|
+
writeSessionState('waiter-stdout-cancel', h.env, { last_prompt_at: AWAY })
|
|
5212
|
+
registerQuestion('waiter-stdout-cancel', h.env, { question: 'Deploy?' }, NOW)
|
|
5213
|
+
const originalErr = h.io.err.bind(h.io)
|
|
5214
|
+
let ended = false
|
|
5215
|
+
h.io.err = (line: string) => {
|
|
5216
|
+
originalErr(line)
|
|
5217
|
+
if (!ended && line.includes('answer from')) {
|
|
5218
|
+
ended = true
|
|
5219
|
+
handleSessionEnd(h.env, { session_id: 'waiter-stdout-cancel' }, NOW)
|
|
5220
|
+
}
|
|
5221
|
+
}
|
|
5222
|
+
|
|
5223
|
+
await hookRunCommand(
|
|
5224
|
+
h.deps,
|
|
5225
|
+
'stop',
|
|
5226
|
+
stdin({ session_id: 'waiter-stdout-cancel' }),
|
|
5227
|
+
'codex',
|
|
5228
|
+
)
|
|
5229
|
+
|
|
5230
|
+
expect(ended).toBe(true)
|
|
5231
|
+
expect(h.io.outLines).toEqual([])
|
|
5232
|
+
expect(h.io.errLines.join('\n')).toContain('no continuation was written')
|
|
5233
|
+
})
|
|
5234
|
+
|
|
5235
|
+
it('finishes a delivery that committed before SessionEnd without replaying it', async () => {
|
|
5236
|
+
const h = harness([])
|
|
5237
|
+
journaled(h, 'waiter-commit-first')
|
|
5238
|
+
const context = waiterContext(h)
|
|
5239
|
+
let markCommitted: (() => void) | undefined
|
|
5240
|
+
const committed = new Promise<void>((resolve) => {
|
|
5241
|
+
markCommitted = resolve
|
|
5242
|
+
})
|
|
5243
|
+
let releaseWrite: (() => void) | undefined
|
|
5244
|
+
const heldWrite = new Promise<void>((resolve) => {
|
|
5245
|
+
releaseWrite = resolve
|
|
5246
|
+
})
|
|
5247
|
+
let writes = 0
|
|
5248
|
+
|
|
5249
|
+
const waiter = runEscalationWaiter(context, {
|
|
5250
|
+
sessionId: 'waiter-commit-first',
|
|
5251
|
+
envelope: { session_id: 'waiter-commit-first' },
|
|
5252
|
+
route: {
|
|
5253
|
+
kind: 'inbox-socket',
|
|
5254
|
+
deliver: async (event) => {
|
|
5255
|
+
expect(event.commitDelivery()).toBe(true)
|
|
5256
|
+
markCommitted?.()
|
|
5257
|
+
await heldWrite
|
|
5258
|
+
writes += 1
|
|
5259
|
+
return { acknowledgement: 'delivered' }
|
|
5260
|
+
},
|
|
5261
|
+
},
|
|
5262
|
+
processDeadlineAt: NOW + 120_000,
|
|
5263
|
+
})
|
|
5264
|
+
await committed
|
|
5265
|
+
await hookRunCommand(
|
|
5266
|
+
h.deps,
|
|
5267
|
+
'session-end',
|
|
5268
|
+
stdin({ session_id: 'waiter-commit-first' }),
|
|
5269
|
+
'claude-code',
|
|
5270
|
+
)
|
|
5271
|
+
releaseWrite?.()
|
|
5272
|
+
await waiter
|
|
5273
|
+
|
|
5274
|
+
expect(writes).toBe(1)
|
|
5275
|
+
expect(readSessionState('waiter-commit-first', h.env).accepted?.delivered_at).toBe(
|
|
5276
|
+
NOW,
|
|
5277
|
+
)
|
|
5278
|
+
|
|
5279
|
+
await hookRunCommand(
|
|
5280
|
+
h.deps,
|
|
5281
|
+
'session-start',
|
|
5282
|
+
stdin({ session_id: 'waiter-commit-first' }),
|
|
5283
|
+
'claude-code',
|
|
5284
|
+
)
|
|
5285
|
+
await runEscalationWaiter(context, {
|
|
5286
|
+
sessionId: 'waiter-commit-first',
|
|
5287
|
+
envelope: { session_id: 'waiter-commit-first' },
|
|
5288
|
+
route: {
|
|
5289
|
+
kind: 'inbox-socket',
|
|
5290
|
+
deliver: async () => {
|
|
5291
|
+
writes += 1
|
|
5292
|
+
return { acknowledgement: 'delivered' }
|
|
5293
|
+
},
|
|
5294
|
+
},
|
|
5295
|
+
processDeadlineAt: NOW + 120_000,
|
|
5296
|
+
})
|
|
5297
|
+
expect(writes).toBe(1)
|
|
5298
|
+
})
|
|
5299
|
+
|
|
4586
5300
|
it('delivers an accepted answer once on a route that acknowledges its own write', async () => {
|
|
4587
5301
|
const h = harness([])
|
|
4588
5302
|
journaled(h, 'waiter-once')
|
|
@@ -4590,7 +5304,8 @@ describe('escalation waiter delivery seam', () => {
|
|
|
4590
5304
|
const deliveries: string[] = []
|
|
4591
5305
|
const route = {
|
|
4592
5306
|
kind: 'inbox-socket' as const,
|
|
4593
|
-
deliver: async (event: { context: string }) => {
|
|
5307
|
+
deliver: async (event: { context: string; commitDelivery(): boolean }) => {
|
|
5308
|
+
expect(event.commitDelivery()).toBe(true)
|
|
4594
5309
|
deliveries.push(event.context)
|
|
4595
5310
|
return {
|
|
4596
5311
|
notes: ['posted the accepted answer'],
|
|
@@ -4607,7 +5322,7 @@ describe('escalation waiter delivery seam', () => {
|
|
|
4607
5322
|
sessionId: 'waiter-once',
|
|
4608
5323
|
envelope: { session_id: 'waiter-once', stop_hook_active: false },
|
|
4609
5324
|
route,
|
|
4610
|
-
processDeadlineAt: NOW +
|
|
5325
|
+
processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
|
|
4611
5326
|
})
|
|
4612
5327
|
}
|
|
4613
5328
|
|
|
@@ -4616,6 +5331,25 @@ describe('escalation waiter delivery seam', () => {
|
|
|
4616
5331
|
expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
|
|
4617
5332
|
})
|
|
4618
5333
|
|
|
5334
|
+
it('does not settle a route that reports delivery without the SessionEnd commit', async () => {
|
|
5335
|
+
const h = harness([])
|
|
5336
|
+
journaled(h, 'waiter-uncommitted-route')
|
|
5337
|
+
const context = waiterContext(h)
|
|
5338
|
+
|
|
5339
|
+
const outcome = await runEscalationWaiter(context, {
|
|
5340
|
+
sessionId: 'waiter-uncommitted-route',
|
|
5341
|
+
envelope: { session_id: 'waiter-uncommitted-route' },
|
|
5342
|
+
route: {
|
|
5343
|
+
kind: 'inbox-socket',
|
|
5344
|
+
deliver: async () => ({ acknowledgement: 'delivered' }),
|
|
5345
|
+
},
|
|
5346
|
+
processDeadlineAt: NOW + 120_000,
|
|
5347
|
+
})
|
|
5348
|
+
|
|
5349
|
+
expect(readSessionState('waiter-uncommitted-route', h.env).accepted).toBeDefined()
|
|
5350
|
+
expect(outcome.notes.join('\n')).toContain('reported delivery without committing')
|
|
5351
|
+
})
|
|
5352
|
+
|
|
4619
5353
|
it('does not spend the delivery cap on turns where nothing was handed over', async () => {
|
|
4620
5354
|
// The bug: holds counted against MAX_CONTINUATION_COUNT, so three turns
|
|
4621
5355
|
// where a liveness probe could not reach a busy session were enough to
|
|
@@ -4635,7 +5369,7 @@ describe('escalation waiter delivery seam', () => {
|
|
|
4635
5369
|
kind: 'inbox-socket',
|
|
4636
5370
|
deliver: async () => ({ notes: [], acknowledgement: 'held' as const }),
|
|
4637
5371
|
},
|
|
4638
|
-
processDeadlineAt: NOW +
|
|
5372
|
+
processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
|
|
4639
5373
|
})
|
|
4640
5374
|
}
|
|
4641
5375
|
// Still journaled after more holds than the delivery cap allows.
|
|
@@ -4647,12 +5381,13 @@ describe('escalation waiter delivery seam', () => {
|
|
|
4647
5381
|
envelope: { session_id: sessionId, stop_hook_active: false },
|
|
4648
5382
|
route: {
|
|
4649
5383
|
kind: 'inbox-socket',
|
|
4650
|
-
deliver: async () => {
|
|
5384
|
+
deliver: async (event) => {
|
|
5385
|
+
expect(event.commitDelivery()).toBe(true)
|
|
4651
5386
|
handedOver += 1
|
|
4652
5387
|
return { notes: [], acknowledgement: 'delivered' as const }
|
|
4653
5388
|
},
|
|
4654
5389
|
},
|
|
4655
|
-
processDeadlineAt: NOW +
|
|
5390
|
+
processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
|
|
4656
5391
|
})
|
|
4657
5392
|
expect(handedOver).toBe(1)
|
|
4658
5393
|
})
|
|
@@ -4679,7 +5414,7 @@ describe('escalation waiter delivery seam', () => {
|
|
|
4679
5414
|
return { notes: [], acknowledgement: 'held' as const }
|
|
4680
5415
|
},
|
|
4681
5416
|
},
|
|
4682
|
-
processDeadlineAt: NOW +
|
|
5417
|
+
processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
|
|
4683
5418
|
})
|
|
4684
5419
|
notes.push(...outcome.notes)
|
|
4685
5420
|
}
|
|
@@ -4711,7 +5446,7 @@ describe('escalation waiter delivery seam', () => {
|
|
|
4711
5446
|
throw new Error('socket unavailable')
|
|
4712
5447
|
},
|
|
4713
5448
|
},
|
|
4714
|
-
processDeadlineAt: NOW +
|
|
5449
|
+
processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
|
|
4715
5450
|
}),
|
|
4716
5451
|
).rejects.toThrow('socket unavailable')
|
|
4717
5452
|
|
|
@@ -4778,12 +5513,8 @@ describe('session state across a prompt', () => {
|
|
|
4778
5513
|
})
|
|
4779
5514
|
})
|
|
4780
5515
|
|
|
4781
|
-
describe('a question
|
|
4782
|
-
it('
|
|
4783
|
-
// These were the same number until the window became a setting, and taking
|
|
4784
|
-
// the minimum capped every question at the waiter's ceiling: a day-long
|
|
4785
|
-
// window was swept as stale about eight minutes later and retired, so the
|
|
4786
|
-
// answer given over lunch met a closed question.
|
|
5516
|
+
describe('a question reaching the end of its answer window', () => {
|
|
5517
|
+
it('keeps the owner alive until the server stops accepting', async () => {
|
|
4787
5518
|
const h = harness([])
|
|
4788
5519
|
const aDayOut = NOW + 86_400_000
|
|
4789
5520
|
h.recorder.replyExpiresAt = new Date(aDayOut).toISOString()
|
|
@@ -4793,15 +5524,13 @@ describe('a question outliving the waiter that pushed it', () => {
|
|
|
4793
5524
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'outlive' }))
|
|
4794
5525
|
|
|
4795
5526
|
const state = readSessionState('outlive', h.env)
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
expect(
|
|
4802
|
-
expect(h.
|
|
4803
|
-
// And the waiter still stopped at its own ceiling rather than waiting a day.
|
|
4804
|
-
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
|
|
5527
|
+
expect(state.pending).toBeUndefined()
|
|
5528
|
+
expect(state.retiring).toEqual([
|
|
5529
|
+
expect.objectContaining({ request_id: h.recorder.receipts[0], state: 'expired' }),
|
|
5530
|
+
])
|
|
5531
|
+
expect(h.recorder.closed).toHaveLength(1)
|
|
5532
|
+
expect(h.deps.now?.()).toBeGreaterThan(NOW + 8 * 60 * 1000)
|
|
5533
|
+
expect(h.deps.now?.()).toBeLessThanOrEqual(aDayOut)
|
|
4805
5534
|
})
|
|
4806
5535
|
|
|
4807
5536
|
it('still closes a question the server has genuinely stopped accepting', async () => {
|
|
@@ -4812,7 +5541,7 @@ describe('a question outliving the waiter that pushed it', () => {
|
|
|
4812
5541
|
|
|
4813
5542
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'short-window' }))
|
|
4814
5543
|
|
|
4815
|
-
// Its window
|
|
5544
|
+
// Its short window closes under the same owner, so it is retired here
|
|
4816
5545
|
// rather than left for a turn that could never collect it.
|
|
4817
5546
|
expect(readSessionState('short-window', h.env).pending).toBeUndefined()
|
|
4818
5547
|
expect(h.recorder.closed.length).toBe(1)
|