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