spine-framework 0.4.2 → 0.4.4

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.
Files changed (46) hide show
  1. package/.framework/functions/_shared/agent-runner.ts +1000 -81
  2. package/.framework/functions/_shared/cil.ts +2 -1
  3. package/.framework/functions/_shared/db.ts +3 -6
  4. package/.framework/functions/_shared/kb.ts +61 -7
  5. package/.framework/functions/_shared/message-sequence.ts +53 -0
  6. package/.framework/functions/_shared/pipeline-runner.ts +150 -37
  7. package/.framework/functions/_shared/principal.ts +9 -6
  8. package/.framework/functions/_shared/trigger-engine.ts +149 -40
  9. package/.framework/functions/admin-data.ts +9 -3
  10. package/.framework/functions/ai-agents.ts +93 -27
  11. package/.framework/functions/pipeline-runner-background.ts +120 -0
  12. package/.framework/functions/system-cron.ts +10 -1
  13. package/.framework/functions/trigger-scheduler.ts +65 -5
  14. package/.framework/migrations/064_episode_leases.sql +196 -0
  15. package/.framework/migrations/065_execution_tables_updated_at.sql +20 -0
  16. package/.framework/migrations/066_episode_steal_thread_merge.sql +148 -0
  17. package/.framework/migrations/067_episode_lease_stale_steal.sql +93 -0
  18. package/.framework/migrations/068_cron_schedule_timer_rpcs.sql +251 -0
  19. package/.framework/schemas/spine.config.schema.json +62 -0
  20. package/.framework/src/App.tsx +3 -1
  21. package/.framework/src/components/admin/AdminSidebar.tsx +4 -2
  22. package/.framework/src/components/app-shell/GenericAppShell.tsx +3 -1
  23. package/.framework/src/components/auth/TurnstileWidget.tsx +9 -6
  24. package/.framework/src/components/kb/KBArticleEditor.tsx +49 -6
  25. package/.framework/src/components/layout/Layout.tsx +4 -2
  26. package/.framework/src/components/layout/Sidebar.tsx +4 -2
  27. package/.framework/src/hooks/useBranding.ts +47 -0
  28. package/.framework/src/lib/branding.ts +128 -0
  29. package/.framework/src/pages/NoAppPage.tsx +3 -1
  30. package/.framework/src/pages/SetupPage.tsx +5 -2
  31. package/.framework/src/pages/auth/AuthConfirmPage.tsx +3 -1
  32. package/.framework/src/pages/auth/ForgotPasswordPage.tsx +3 -1
  33. package/.framework/src/pages/auth/LoginPage.tsx +4 -2
  34. package/.framework/src/pages/auth/RegisterPage.tsx +3 -1
  35. package/.framework/templates/project.json +1 -0
  36. package/.framework/tests/unit/agent-runner.test.ts +17 -3
  37. package/.framework/tests/unit/episode-lease.test.ts +695 -0
  38. package/.framework/tests/unit/kb-ingest-lease.test.ts +19 -0
  39. package/.framework/tests/unit/pipeline-runner-background.test.ts +120 -0
  40. package/.framework/tests/unit/pipeline-runner.test.ts +153 -1
  41. package/.framework/tests/unit/principal-cache.test.ts +15 -0
  42. package/.framework/tests/unit/trigger-engine.test.ts +278 -91
  43. package/.framework/tests/unit/trigger-scheduler.test.ts +78 -0
  44. package/netlify.toml +5 -0
  45. package/package.json +1 -1
  46. package/templates/project/package-lock.json +2 -2
@@ -0,0 +1,695 @@
1
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/episode-lease.test.ts:module_header:module_header" type="module_header" identity="module_header" parent="none"]
2
+ // CIL[DOC-MACRO purpose="Unit tests for the atomic episode-lease arbitration in agent-runner"]
3
+ // CIL[DOC-RESPONSIBILITY responsibility="Exercise acquireEpisodeLease verdict mapping (claimed/held/error/malformed), kickAgentEpisode claim-vs-refuse behavior with legacy fallback, heartbeat fencing (EpisodeLeaseLost quiet abort), and terminal lease release in runAgent"]
4
+ // CIL[DOC-CONTEXT module="tests/unit/episode-lease" layer="test-unit" related_files=".framework/functions/_shared/agent-runner.ts,.framework/migrations/064_episode_leases.sql,.framework/tests/unit/agent-runner.test.ts,.framework/tests/unit/kb-ingest-lease.test.ts"]
5
+ // CIL[DOC-CONTRACT inputs="none" outputs="vitest assertions" errors="none" side_effects="Mocks _shared/db.ts adminDb (from + rpc), audit, outcomes, llm, admin-data create, integration-outbound, pipeline-runner, funnel"]
6
+ // CIL[DOC-MICRO logic_summary="Per-table thenable mock mirrors agent-runner.test.ts scaffolding; adminDb.rpc is programmed per-RPC-name so acquire/heartbeat/release outcomes can be driven independently" notable_decisions="kickAgentEpisode's detached void runAgent is real — config mocks keep it a quiet no-op run rather than spying on same-module internals"]
7
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
8
+ // CIL[DOC-STABILITY stability="stable"]
9
+ // CIL[DOC-AUDIENCE audience="test maintainers"]
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
11
+ import type { CoreContext } from '../../functions/_shared/middleware.ts'
12
+
13
+ vi.mock('../../functions/_shared/db.ts', () => ({
14
+ adminDb: { from: vi.fn(), rpc: vi.fn() }
15
+ }))
16
+
17
+ vi.mock('../../functions/_shared/audit.ts', () => ({
18
+ emitAudit: vi.fn().mockResolvedValue(undefined),
19
+ emitLog: vi.fn().mockResolvedValue(undefined),
20
+ emitSpan: vi.fn().mockResolvedValue(undefined)
21
+ }))
22
+
23
+ vi.mock('../../functions/_shared/outcomes.ts', () => ({
24
+ emitOutcome: vi.fn().mockResolvedValue(undefined)
25
+ }))
26
+
27
+ vi.mock('../../functions/_shared/llm.ts', () => ({
28
+ callOpenAIChat: vi.fn(),
29
+ callOpenAIEmbeddings: vi.fn().mockResolvedValue({ data: [{ embedding: [0.1, 0.2] }] })
30
+ }))
31
+
32
+ vi.mock('../../functions/admin-data.ts', () => ({
33
+ create: vi.fn()
34
+ }))
35
+
36
+ vi.mock('../../functions/integration-outbound.ts', () => ({
37
+ execute: vi.fn()
38
+ }))
39
+
40
+ vi.mock('../../functions/_shared/pipeline-runner.ts', () => ({
41
+ runPipeline: vi.fn()
42
+ }))
43
+
44
+ vi.mock('../../functions/_shared/funnel.ts', () => ({
45
+ emitSignal: vi.fn().mockResolvedValue(undefined),
46
+ accountDataFlag: vi.fn().mockResolvedValue(true)
47
+ }))
48
+
49
+ import { adminDb } from '../../functions/_shared/db.ts'
50
+ import { callOpenAIChat } from '../../functions/_shared/llm.ts'
51
+ import { create as createRecord } from '../../functions/admin-data.ts'
52
+ import {
53
+ acquireEpisodeLease,
54
+ claimAgentEpisode,
55
+ kickAgentEpisode,
56
+ runAgent,
57
+ } from '../../functions/_shared/agent-runner.ts'
58
+ // CIL[END stable_id="~/.framework/tests/unit/episode-lease.test.ts:module_header:module_header"]
59
+
60
+ const THREAD_ID = 'thread-1'
61
+ const AGENT_ID = 'agent-1'
62
+ const PROMPT_CONFIG_ID = 'prompt-1'
63
+ const LEASE_TOKEN = 'lease-token-1'
64
+
65
+ function makeCtx(overrides: Partial<CoreContext> = {}): CoreContext {
66
+ return {
67
+ principal: {
68
+ id: 'person-1',
69
+ type: 'human',
70
+ accountId: 'acct-1',
71
+ roles: ['member'],
72
+ provenance: { sourceType: 'jwt', createdBy: 'person-1', invokedAt: new Date().toISOString() }
73
+ },
74
+ accountId: 'acct-1',
75
+ db: {},
76
+ requestId: 'req-test',
77
+ appId: 'test-app',
78
+ ...overrides
79
+ } as CoreContext
80
+ }
81
+
82
+ function mockThread(overrides: Record<string, any> = {}) {
83
+ return {
84
+ id: THREAD_ID,
85
+ account_id: 'acct-1',
86
+ visibility: 'external',
87
+ data: {
88
+ agent_id: AGENT_ID,
89
+ prompt_config_id: PROMPT_CONFIG_ID
90
+ },
91
+ type: { slug: 'support_thread', design_schema: {} },
92
+ ...overrides
93
+ }
94
+ }
95
+
96
+ function mockAgent(overrides: Record<string, any> = {}) {
97
+ return {
98
+ id: AGENT_ID,
99
+ account_id: 'acct-1',
100
+ is_active: true,
101
+ system_prompt: 'You are a research assistant.',
102
+ model_config: {},
103
+ metadata: {},
104
+ ...overrides
105
+ }
106
+ }
107
+
108
+ function mockPromptConfig(overrides: Record<string, any> = {}) {
109
+ return {
110
+ id: PROMPT_CONFIG_ID,
111
+ app_id: 'test-app',
112
+ is_active: true,
113
+ is_multi_turn: true,
114
+ available_tools: ['search_knowledge', 'ask_user'],
115
+ metadata: { max_turns: 5 },
116
+ ...overrides
117
+ }
118
+ }
119
+
120
+ // Same scaffolding shape as agent-runner.test.ts — a thenable builder per
121
+ // table so resolveAgentConfig/messages/state writes resolve quietly.
122
+ // inboundRows feeds messages.limit() — replay detection reads the latest
123
+ // inbound created_at through that chain.
124
+ function setupAgentConfigMocks(
125
+ threadOverrides: Record<string, any> = {},
126
+ agentOverrides: Record<string, any> = {},
127
+ promptOverrides: Record<string, any> = {},
128
+ inboundRows: any[] = []
129
+ ) {
130
+ vi.mocked(adminDb.from).mockImplementation((table: string) => {
131
+ if (table === 'threads') {
132
+ return {
133
+ select: vi.fn().mockReturnThis(),
134
+ eq: vi.fn().mockReturnThis(),
135
+ single: vi.fn().mockResolvedValue({
136
+ data: mockThread(threadOverrides),
137
+ error: null
138
+ }),
139
+ update: vi.fn().mockReturnThis()
140
+ } as any
141
+ }
142
+ if (table === 'ai_agents') {
143
+ return {
144
+ select: vi.fn().mockReturnThis(),
145
+ eq: vi.fn().mockReturnThis(),
146
+ single: vi.fn().mockResolvedValue({
147
+ data: mockAgent(agentOverrides),
148
+ error: null
149
+ })
150
+ } as any
151
+ }
152
+ if (table === 'prompt_configs') {
153
+ return {
154
+ select: vi.fn().mockReturnThis(),
155
+ eq: vi.fn().mockReturnThis(),
156
+ single: vi.fn().mockResolvedValue({
157
+ data: mockPromptConfig(promptOverrides),
158
+ error: null
159
+ })
160
+ } as any
161
+ }
162
+ if (table === 'messages') {
163
+ return {
164
+ select: vi.fn().mockReturnThis(),
165
+ eq: vi.fn().mockReturnThis(),
166
+ order: vi.fn().mockReturnThis(),
167
+ limit: vi.fn().mockResolvedValue({ data: inboundRows, error: null }),
168
+ update: vi.fn().mockReturnThis(),
169
+ insert: vi.fn().mockReturnThis()
170
+ } as any
171
+ }
172
+ if (table === 'accounts') {
173
+ return {
174
+ select: vi.fn().mockReturnThis(),
175
+ eq: vi.fn().mockReturnThis(),
176
+ maybeSingle: vi.fn().mockResolvedValue({ data: { id: 'sys-acct' }, error: null })
177
+ } as any
178
+ }
179
+ if (table === 'apps') {
180
+ return {
181
+ select: vi.fn().mockReturnThis(),
182
+ eq: vi.fn().mockReturnThis(),
183
+ maybeSingle: vi.fn().mockResolvedValue({
184
+ data: {
185
+ id: 'app-1',
186
+ slug: 'test-app',
187
+ required_roles: [],
188
+ config: { knowledge: { account: true, platform: true } }
189
+ },
190
+ error: null
191
+ })
192
+ } as any
193
+ }
194
+ if (table === 'embeddings') {
195
+ const q: any = {
196
+ select: vi.fn(() => q),
197
+ in: vi.fn(() => q),
198
+ is: vi.fn(() => q),
199
+ not: vi.fn(() => q),
200
+ or: vi.fn(() => q),
201
+ textSearch: vi.fn(() => q),
202
+ limit: vi.fn(() => q),
203
+ then: (onFulfilled: any, onRejected: any) =>
204
+ Promise.resolve({ data: [], error: null }).then(onFulfilled, onRejected),
205
+ }
206
+ return q
207
+ }
208
+ return {
209
+ select: vi.fn().mockReturnThis(),
210
+ eq: vi.fn().mockReturnThis(),
211
+ in: vi.fn().mockReturnThis(),
212
+ order: vi.fn().mockReturnThis(),
213
+ limit: vi.fn().mockReturnThis(),
214
+ single: vi.fn().mockResolvedValue({ data: null, error: null }),
215
+ maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
216
+ update: vi.fn().mockReturnThis(),
217
+ insert: vi.fn().mockReturnThis(),
218
+ filter: vi.fn().mockReturnThis()
219
+ } as any
220
+ })
221
+ }
222
+
223
+ // Program adminDb.rpc per RPC name — episode-lease tests drive each
224
+ // primitive's outcome independently.
225
+ function mockRpc(handlers: Record<string, any>) {
226
+ vi.mocked(adminDb.rpc).mockImplementation((name: string) => {
227
+ const v = handlers[name]
228
+ return Promise.resolve(v instanceof Error ? { data: null, error: v } : { data: v, error: null }) as any
229
+ })
230
+ }
231
+
232
+ function mockFinalLlm(content = 'Final answer.') {
233
+ vi.mocked(callOpenAIChat).mockResolvedValue({
234
+ choices: [{
235
+ message: { content, tool_calls: undefined },
236
+ logprobs: { content: [{ logprob: -0.1 }] }
237
+ }],
238
+ model: 'gpt-4o'
239
+ } as any)
240
+ }
241
+
242
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/episode-lease.test.ts:describe_acquireEpisodeLease:block" type="block" identity="describe_acquireEpisodeLease" parent="none"]
243
+ // CIL[DOC-MACRO purpose="Test acquireEpisodeLease verdict mapping"]
244
+ // CIL[DOC-RESPONSIBILITY responsibility="Verify claimed/held/error/malformed RPC responses map to the documented return contract"]
245
+ describe('acquireEpisodeLease', () => {
246
+ beforeEach(() => vi.clearAllMocks())
247
+
248
+ it('returns claimed with lease_token on a fresh claim', async () => {
249
+ mockRpc({ acquire_episode_lease: { outcome: 'claimed', lease_token: LEASE_TOKEN } })
250
+ const r = await acquireEpisodeLease(THREAD_ID, 'acct-1', 'driver-a')
251
+ expect(r).toEqual({ outcome: 'claimed', lease_token: LEASE_TOKEN })
252
+ expect(vi.mocked(adminDb.rpc)).toHaveBeenCalledWith('acquire_episode_lease', expect.objectContaining({
253
+ p_thread_id: THREAD_ID,
254
+ p_account_id: 'acct-1',
255
+ p_driver_id: 'driver-a'
256
+ }))
257
+ })
258
+
259
+ it('returns held with the live holder driver_id', async () => {
260
+ mockRpc({ acquire_episode_lease: { outcome: 'held', holder_driver_id: 'driver-b' } })
261
+ const r = await acquireEpisodeLease(THREAD_ID, 'acct-1', 'driver-a')
262
+ expect(r).toEqual({ outcome: 'held', holder_driver_id: 'driver-b' })
263
+ })
264
+
265
+ it('fails open (null) when the RPC errors — un-migrated deployments', async () => {
266
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
267
+ mockRpc({ acquire_episode_lease: new Error('function acquire_episode_lease does not exist') })
268
+ const r = await acquireEpisodeLease(THREAD_ID, 'acct-1', 'driver-a')
269
+ expect(r).toBeNull()
270
+ })
271
+
272
+ it('fails open (null) on a malformed verdict', async () => {
273
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
274
+ mockRpc({ acquire_episode_lease: [] })
275
+ const r = await acquireEpisodeLease(THREAD_ID, 'acct-1', 'driver-a')
276
+ expect(r).toBeNull()
277
+ })
278
+
279
+ it('passes p_steal_detached to the RPC when the caller is request-bound', async () => {
280
+ mockRpc({ acquire_episode_lease: { outcome: 'claimed', lease_token: LEASE_TOKEN } })
281
+ const r = await acquireEpisodeLease(THREAD_ID, 'acct-1', 'driver-a', 90, true)
282
+ expect(r?.outcome).toBe('claimed')
283
+ expect(vi.mocked(adminDb.rpc)).toHaveBeenCalledWith('acquire_episode_lease', expect.objectContaining({
284
+ p_steal_detached: true
285
+ }))
286
+ })
287
+
288
+ it('defaults p_steal_detached to false — detached kicks never preempt a live holder', async () => {
289
+ mockRpc({ acquire_episode_lease: { outcome: 'held', holder_driver_id: 'driver-b' } })
290
+ await acquireEpisodeLease(THREAD_ID, 'acct-1', 'driver-a')
291
+ expect(vi.mocked(adminDb.rpc)).toHaveBeenCalledWith('acquire_episode_lease', expect.objectContaining({
292
+ p_steal_detached: false
293
+ }))
294
+ })
295
+
296
+ it('passes p_stale_seconds so the RPC can refuse a healthy detached holder', async () => {
297
+ mockRpc({ acquire_episode_lease: { outcome: 'held', holder_driver_id: 'detached:driver-b' } })
298
+ const r = await acquireEpisodeLease(THREAD_ID, 'acct-1', 'driver-a', 90, true)
299
+ expect(r?.outcome).toBe('held')
300
+ expect(vi.mocked(adminDb.rpc)).toHaveBeenCalledWith('acquire_episode_lease', expect.objectContaining({
301
+ p_steal_detached: true,
302
+ p_stale_seconds: 30
303
+ }))
304
+ })
305
+ })
306
+ // CIL[END stable_id="~/.framework/tests/unit/episode-lease.test.ts:describe_acquireEpisodeLease:block"]
307
+
308
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/episode-lease.test.ts:describe_kickAgentEpisode:block" type="block" identity="describe_kickAgentEpisode" parent="none"]
309
+ // CIL[DOC-MACRO purpose="Test kickAgentEpisode atomic claim and legacy fallback"]
310
+ // CIL[DOC-RESPONSIBILITY responsibility="Verify a held lease refuses without a state write, a claimed lease stamps driver_id and detaches the run, and an unavailable RPC degrades to the legacy heartbeat check"]
311
+ describe('kickAgentEpisode', () => {
312
+ beforeEach(() => vi.clearAllMocks())
313
+
314
+ it('refuses when the atomic lease is held by another driver — no state write, no run', async () => {
315
+ setupAgentConfigMocks()
316
+ mockRpc({ acquire_episode_lease: { outcome: 'held', holder_driver_id: 'driver-b' } })
317
+ const ctx = makeCtx()
318
+ const r = await kickAgentEpisode(THREAD_ID, 'Question?', ctx, 'driver-a', mockThread().data)
319
+ expect(r).toEqual({ claimed: false, driver_id: 'driver-b' })
320
+ // threads.update must not have run — a refused caller never stamps state.
321
+ const threadsBuilder = vi.mocked(adminDb.from).mock.results
322
+ .map((x: any) => x.value)
323
+ expect(
324
+ threadsBuilder.some((b: any) => b?.update?.mock?.calls?.length > 0)
325
+ ).toBe(false)
326
+ })
327
+
328
+ it('claims via the RPC, stamps the detached driver_id, and detaches the run', async () => {
329
+ setupAgentConfigMocks()
330
+ mockFinalLlm()
331
+ vi.mocked(createRecord).mockResolvedValue({ id: 'msg-1', content: 'Final answer.' } as any)
332
+ mockRpc({
333
+ acquire_episode_lease: { outcome: 'claimed', lease_token: LEASE_TOKEN },
334
+ merge_thread_data: true,
335
+ heartbeat_episode_lease: true,
336
+ release_episode_lease: true
337
+ })
338
+ const ctx = makeCtx()
339
+ const r = await kickAgentEpisode(THREAD_ID, 'Question?', ctx, 'driver-a', mockThread().data)
340
+ // Spawned drivers carry the 'detached:' prefix so request-bound
341
+ // callers can steal the lease without waiting out the TTL.
342
+ expect(r).toMatchObject({ claimed: true, driver_id: 'detached:driver-a' })
343
+ // The research_state stamp goes through the fenced merge RPC.
344
+ expect(vi.mocked(adminDb.rpc)).toHaveBeenCalledWith('merge_thread_data', expect.objectContaining({
345
+ p_thread_id: THREAD_ID,
346
+ p_lease_token: LEASE_TOKEN,
347
+ p_patch: expect.objectContaining({
348
+ research_state: expect.objectContaining({ driver_id: 'detached:driver-a' })
349
+ })
350
+ }))
351
+ // Intake narration is written synchronously inside the claim.
352
+ const narrations = vi.mocked(createRecord).mock.calls.filter(([, body]: any) =>
353
+ body?.data?.message_type === 'plan' && body?.data?.phase === 'intake'
354
+ )
355
+ expect(narrations).toHaveLength(1)
356
+ // The detached run proceeds against the mocked config — let it
357
+ // settle so its writes can't bleed into the next test's mocks.
358
+ await new Promise((res) => setTimeout(res, 250))
359
+ })
360
+
361
+ it('does not re-narrate when continuing an already-announced live episode', async () => {
362
+ const continuing = {
363
+ agent_id: AGENT_ID,
364
+ prompt_config_id: PROMPT_CONFIG_ID,
365
+ research_state: {
366
+ status: 'gathering',
367
+ driver_id: 'detached:driver-a',
368
+ driver_heartbeat_at: new Date().toISOString(),
369
+ intake_announced: true
370
+ }
371
+ }
372
+ setupAgentConfigMocks({ data: continuing })
373
+ mockFinalLlm()
374
+ vi.mocked(createRecord).mockResolvedValue({ id: 'msg-1' } as any)
375
+ mockRpc({
376
+ acquire_episode_lease: { outcome: 'claimed', lease_token: LEASE_TOKEN },
377
+ merge_thread_data: true,
378
+ heartbeat_episode_lease: true,
379
+ release_episode_lease: true
380
+ })
381
+ const ctx = makeCtx()
382
+ const r = await claimAgentEpisode(THREAD_ID, 'Question?', ctx, 'driver-a', continuing)
383
+ expect(r.claimed).toBe(true)
384
+ const narrations = vi.mocked(createRecord).mock.calls.filter(([, body]: any) =>
385
+ body?.data?.message_type === 'plan' && body?.data?.phase === 'intake'
386
+ )
387
+ expect(narrations).toHaveLength(0)
388
+ })
389
+
390
+ it('releases and reports already_done when the carried question was already answered', async () => {
391
+ // Latest inbound predates the terminal write — a re-delivered
392
+ // question, not a re-ask. This is the production duplicate path: a
393
+ // queued kick claiming the just-released lease.
394
+ const threadData = {
395
+ agent_id: AGENT_ID,
396
+ prompt_config_id: PROMPT_CONFIG_ID,
397
+ research_state: {
398
+ status: 'complete',
399
+ user_question: 'Question?',
400
+ completed_at: new Date(Date.now() - 5000).toISOString(),
401
+ driver_id: 'req:earlier'
402
+ }
403
+ }
404
+ setupAgentConfigMocks({ data: threadData }, {}, {}, [{ created_at: new Date(Date.now() - 30000).toISOString() }])
405
+ mockRpc({
406
+ acquire_episode_lease: { outcome: 'claimed', lease_token: LEASE_TOKEN },
407
+ release_episode_lease: true
408
+ })
409
+ const ctx = makeCtx()
410
+ const r = await claimAgentEpisode(THREAD_ID, 'Question?', ctx, 'kick-late', threadData)
411
+ expect(r).toMatchObject({ claimed: false, already_done: true })
412
+ expect(vi.mocked(adminDb.rpc)).toHaveBeenCalledWith('release_episode_lease', expect.objectContaining({
413
+ p_thread_id: THREAD_ID,
414
+ p_lease_token: LEASE_TOKEN
415
+ }))
416
+ // No state stamp and no intake narration for a replay.
417
+ expect(createRecord).not.toHaveBeenCalled()
418
+ const merges = vi.mocked(adminDb.rpc).mock.calls.filter(([n]: any) => n === 'merge_thread_data')
419
+ expect(merges).toHaveLength(0)
420
+ })
421
+
422
+ it('falls back to the legacy heartbeat check when the RPC is unavailable and a lease is live', async () => {
423
+ const liveState = {
424
+ agent_id: AGENT_ID,
425
+ prompt_config_id: PROMPT_CONFIG_ID,
426
+ research_state: {
427
+ status: 'gathering',
428
+ driver_id: 'driver-b',
429
+ driver_heartbeat_at: new Date().toISOString()
430
+ }
431
+ }
432
+ setupAgentConfigMocks({ data: liveState })
433
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
434
+ mockRpc({ acquire_episode_lease: new Error('does not exist') })
435
+ const ctx = makeCtx()
436
+ const r = await kickAgentEpisode(THREAD_ID, 'Question?', ctx, 'driver-a', liveState)
437
+ expect(r).toEqual({ claimed: false, driver_id: 'driver-b' })
438
+ })
439
+
440
+ it('claims through the legacy path when the RPC is unavailable and no lease is live', async () => {
441
+ setupAgentConfigMocks()
442
+ mockFinalLlm()
443
+ vi.mocked(createRecord).mockResolvedValue({ id: 'msg-1', content: 'Final answer.' } as any)
444
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
445
+ mockRpc({ acquire_episode_lease: new Error('does not exist') })
446
+ const ctx = makeCtx()
447
+ const r = await kickAgentEpisode(THREAD_ID, 'Question?', ctx, 'driver-a', mockThread().data)
448
+ expect(r).toMatchObject({ claimed: true, driver_id: 'detached:driver-a' })
449
+ await new Promise((res) => setTimeout(res, 250))
450
+ })
451
+ })
452
+ // CIL[END stable_id="~/.framework/tests/unit/episode-lease.test.ts:describe_kickAgentEpisode:block"]
453
+
454
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/episode-lease.test.ts:describe_runAgentFencing:block" type="block" identity="describe_runAgentFencing" parent="none"]
455
+ // CIL[DOC-MACRO purpose="Test runAgent lease heartbeat fencing and terminal release"]
456
+ // CIL[DOC-RESPONSIBILITY responsibility="Verify a false heartbeat aborts the run quietly without persisting a final message, and a terminal episode releases the lease token"]
457
+ describe('runAgent — lease fencing and release', () => {
458
+ beforeEach(() => vi.clearAllMocks())
459
+
460
+ it('aborts quietly when the heartbeat reports a takeover — no final write', async () => {
461
+ setupAgentConfigMocks()
462
+ mockFinalLlm()
463
+ vi.mocked(createRecord).mockResolvedValue({ id: 'msg-x', content: 'x' } as any)
464
+ mockRpc({ heartbeat_episode_lease: false, release_episode_lease: true })
465
+ const ctx = makeCtx()
466
+ const result = await runAgent(THREAD_ID, 'Question?', ctx, 'complete', 'driver-a', LEASE_TOKEN)
467
+ // Quiet abort: resolves (does not throw), and never persisted a
468
+ // customer-facing final — the takeover owner writes it.
469
+ expect(result).toBeUndefined()
470
+ const finalWrites = vi.mocked(createRecord).mock.calls.filter(([, body]: any) =>
471
+ body?.data?.message_type === 'final'
472
+ )
473
+ expect(finalWrites).toHaveLength(0)
474
+ // Token-gated release still attempted (warn-only when superseded).
475
+ expect(vi.mocked(adminDb.rpc)).toHaveBeenCalledWith('release_episode_lease', expect.objectContaining({
476
+ p_thread_id: THREAD_ID,
477
+ p_lease_token: LEASE_TOKEN
478
+ }))
479
+ })
480
+
481
+ it('releases the lease when the episode reaches a terminal state', async () => {
482
+ setupAgentConfigMocks()
483
+ mockFinalLlm()
484
+ vi.mocked(createRecord).mockResolvedValue({ id: 'msg-final', content: 'Final answer.' } as any)
485
+ mockRpc({ heartbeat_episode_lease: true, release_episode_lease: true })
486
+ const ctx = makeCtx()
487
+ const result = await runAgent(THREAD_ID, 'Question?', ctx, 'complete', 'driver-a', LEASE_TOKEN)
488
+ expect(result).toBeDefined()
489
+ expect(vi.mocked(adminDb.rpc)).toHaveBeenCalledWith('release_episode_lease', expect.objectContaining({
490
+ p_thread_id: THREAD_ID,
491
+ p_lease_token: LEASE_TOKEN
492
+ }))
493
+ })
494
+
495
+ it('drops a fenced research_state write — merge_thread_data false means the driver was displaced', async () => {
496
+ setupAgentConfigMocks()
497
+ mockFinalLlm()
498
+ vi.mocked(createRecord).mockResolvedValue({ id: 'msg-final', content: 'Final answer.' } as any)
499
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
500
+ mockRpc({
501
+ heartbeat_episode_lease: true,
502
+ merge_thread_data: false,
503
+ release_episode_lease: true
504
+ })
505
+ const ctx = makeCtx()
506
+ // The fenced checkpoint throws EpisodeLeaseLost — runAgent converts
507
+ // it to a quiet abort rather than an inference failure.
508
+ const result = await runAgent(THREAD_ID, 'Question?', ctx, 'complete', 'driver-a', LEASE_TOKEN)
509
+ expect(result).toBeUndefined()
510
+ expect(vi.mocked(console.warn)).toHaveBeenCalledWith(
511
+ expect.stringContaining('episode lease lost')
512
+ )
513
+ })
514
+
515
+ it('returns a partial result without releasing when the deadline budget expires mid-episode', async () => {
516
+ setupAgentConfigMocks()
517
+ // Every turn is a nonterminal tool call — without a deadline this
518
+ // would loop to max_turns.
519
+ vi.mocked(callOpenAIChat).mockResolvedValue({
520
+ choices: [{
521
+ message: {
522
+ content: '',
523
+ tool_calls: [{
524
+ id: 'call-1',
525
+ function: { name: 'search_knowledge', arguments: '{"query":"x"}' }
526
+ }]
527
+ }
528
+ }],
529
+ model: 'gpt-4o'
530
+ } as any)
531
+ vi.mocked(createRecord).mockResolvedValue({ id: 'msg-tool', content: 'Using search_knowledge...' } as any)
532
+ mockRpc({
533
+ heartbeat_episode_lease: true,
534
+ merge_thread_data: true,
535
+ release_episode_lease: true
536
+ })
537
+ const ctx = makeCtx()
538
+ const result = await runAgent(THREAD_ID, 'Question?', ctx, 'complete', 'driver-a', LEASE_TOKEN, { deadlineMs: -1 })
539
+ // Nonterminal partial return — the lease stays held so the caller's
540
+ // next step request continues the episode instead of double-driving.
541
+ expect(result).toBeDefined()
542
+ const releases = vi.mocked(adminDb.rpc).mock.calls.filter(([n]: any) => n === 'release_episode_lease')
543
+ expect(releases).toHaveLength(0)
544
+ const mergeCalls = vi.mocked(adminDb.rpc).mock.calls
545
+ .filter(([n]: any) => n === 'merge_thread_data')
546
+ .map(([, p]: any) => p?.p_patch?.research_state?.status)
547
+ expect(mergeCalls).not.toContain('failed')
548
+ expect(mergeCalls).not.toContain('complete')
549
+ })
550
+
551
+ it('keeps the lease on a nonterminal step return so the same driver renews it', async () => {
552
+ setupAgentConfigMocks({
553
+ data: {
554
+ agent_id: AGENT_ID,
555
+ prompt_config_id: PROMPT_CONFIG_ID,
556
+ research_state: {
557
+ status: 'gathering',
558
+ turn_count: 1,
559
+ max_turns: 5,
560
+ gathered_results: [{ tool: 'search_knowledge', id: 't0', result: { chunks: [] } }]
561
+ }
562
+ }
563
+ })
564
+ // Tool call turn → state stays 'gathering' (nonterminal).
565
+ vi.mocked(callOpenAIChat).mockResolvedValue({
566
+ choices: [{
567
+ message: {
568
+ content: '',
569
+ tool_calls: [{
570
+ id: 'call-1',
571
+ function: { name: 'search_knowledge', arguments: '{"query":"x"}' }
572
+ }]
573
+ }
574
+ }],
575
+ model: 'gpt-4o'
576
+ } as any)
577
+ vi.mocked(createRecord).mockResolvedValue({ id: 'msg-tool', content: 'Using search_knowledge...' } as any)
578
+ mockRpc({ heartbeat_episode_lease: true, release_episode_lease: true })
579
+ const ctx = makeCtx()
580
+ await runAgent(THREAD_ID, 'Question?', ctx, 'step', 'driver-a', LEASE_TOKEN)
581
+ const releases = vi.mocked(adminDb.rpc).mock.calls.filter(([n]: any) => n === 'release_episode_lease')
582
+ expect(releases).toHaveLength(0)
583
+ })
584
+
585
+ it('treats a re-delivered terminal question as a replay — returns without re-running', async () => {
586
+ setupAgentConfigMocks({
587
+ data: {
588
+ agent_id: AGENT_ID,
589
+ prompt_config_id: PROMPT_CONFIG_ID,
590
+ research_state: {
591
+ status: 'complete',
592
+ user_question: 'Question?',
593
+ completed_at: new Date(Date.now() - 5000).toISOString(),
594
+ turn_count: 1,
595
+ max_turns: 5,
596
+ gathered_results: []
597
+ }
598
+ }
599
+ }, {}, {}, [{ created_at: new Date(Date.now() - 30000).toISOString() }])
600
+ vi.mocked(createRecord).mockResolvedValue({ id: 'msg-x' } as any)
601
+ mockRpc({ heartbeat_episode_lease: true, merge_thread_data: true, release_episode_lease: true })
602
+ const ctx = makeCtx()
603
+ const result = await runAgent(THREAD_ID, 'Question?', ctx, 'complete', 'driver-a', LEASE_TOKEN)
604
+ expect(result).toBeUndefined()
605
+ // The replay no-ops before any turn: no inference, and no agent-side
606
+ // writes (an inbound persist by the caller path is legitimate).
607
+ expect(vi.mocked(callOpenAIChat)).not.toHaveBeenCalled()
608
+ const agentWrites = vi.mocked(createRecord).mock.calls.filter(([, body]: any) =>
609
+ ['final', 'plan', 'tool_call', 'escalation'].includes(body?.data?.message_type)
610
+ )
611
+ expect(agentWrites).toHaveLength(0)
612
+ expect(vi.mocked(adminDb.rpc)).toHaveBeenCalledWith('release_episode_lease', expect.objectContaining({
613
+ p_lease_token: LEASE_TOKEN
614
+ }))
615
+ })
616
+
617
+ it('runs a new episode when a same-text re-ask posts after completion', async () => {
618
+ // Latest inbound is NEWER than the terminal write — the user really
619
+ // asked again, so the reset path must fire.
620
+ setupAgentConfigMocks({
621
+ data: {
622
+ agent_id: AGENT_ID,
623
+ prompt_config_id: PROMPT_CONFIG_ID,
624
+ research_state: {
625
+ status: 'complete',
626
+ user_question: 'Question?',
627
+ completed_at: new Date(Date.now() - 30000).toISOString(),
628
+ turn_count: 1,
629
+ max_turns: 5,
630
+ gathered_results: []
631
+ }
632
+ }
633
+ }, {}, {}, [{ created_at: new Date().toISOString() }])
634
+ mockFinalLlm()
635
+ vi.mocked(createRecord).mockResolvedValue({ id: 'msg-final', content: 'Final answer.' } as any)
636
+ mockRpc({ heartbeat_episode_lease: true, merge_thread_data: true, release_episode_lease: true })
637
+ const ctx = makeCtx()
638
+ await runAgent(THREAD_ID, 'Question?', ctx, 'complete', 'driver-a', LEASE_TOKEN)
639
+ expect(vi.mocked(callOpenAIChat)).toHaveBeenCalled()
640
+ })
641
+ })
642
+ // CIL[END stable_id="~/.framework/tests/unit/episode-lease.test.ts:describe_runAgentFencing:block"]
643
+
644
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/episode-lease.test.ts:describe_humanOwnerGate:block" type="block" identity="describe_humanOwnerGate" parent="none"]
645
+ // CIL[DOC-MACRO purpose="Test the human-owner thread gate"]
646
+ // CIL[DOC-RESPONSIBILITY responsibility="Verify owner:'human' (and the legacy escalation_status:'pending' key) refuses episode claims, narrations, and inference — the semantic gate that stops frozen serverless resumes from re-entering human-owned threads"]
647
+ describe('human-owner gate', () => {
648
+ beforeEach(() => vi.clearAllMocks())
649
+
650
+ it('claimAgentEpisode refuses owner:human — no lease acquire, no state write, no narration', async () => {
651
+ setupAgentConfigMocks({
652
+ data: { agent_id: AGENT_ID, prompt_config_id: PROMPT_CONFIG_ID, owner: 'human' }
653
+ })
654
+ const ctx = makeCtx()
655
+ const r = await claimAgentEpisode(THREAD_ID, 'Question?', ctx, 'driver-a')
656
+ expect(r).toEqual({ claimed: false, already_done: true })
657
+ expect(vi.mocked(adminDb.rpc)).not.toHaveBeenCalledWith('acquire_episode_lease', expect.anything())
658
+ expect(vi.mocked(createRecord)).not.toHaveBeenCalled()
659
+ })
660
+
661
+ it('claimAgentEpisode refuses the legacy escalation_status:pending key', async () => {
662
+ setupAgentConfigMocks({
663
+ data: { agent_id: AGENT_ID, prompt_config_id: PROMPT_CONFIG_ID, escalation_status: 'pending' }
664
+ })
665
+ const ctx = makeCtx()
666
+ const r = await claimAgentEpisode(THREAD_ID, 'Question?', ctx, 'driver-a')
667
+ expect(r).toEqual({ claimed: false, already_done: true })
668
+ expect(vi.mocked(adminDb.rpc)).not.toHaveBeenCalledWith('acquire_episode_lease', expect.anything())
669
+ })
670
+
671
+ it('runAgent returns the prior outbound and never runs inference on a human-owned thread', async () => {
672
+ setupAgentConfigMocks(
673
+ { data: { agent_id: AGENT_ID, prompt_config_id: PROMPT_CONFIG_ID, owner: 'human' } },
674
+ {},
675
+ {},
676
+ [{ id: 'msg-prior', content: 'A human is on it.', data: { message_type: 'final' } }]
677
+ )
678
+ vi.mocked(createRecord).mockResolvedValue({ id: 'msg-in' } as any)
679
+ mockRpc({ heartbeat_episode_lease: true, release_episode_lease: true })
680
+ const ctx = makeCtx()
681
+ const result = await runAgent(THREAD_ID, 'Follow-up?', ctx, 'complete', 'driver-a', LEASE_TOKEN)
682
+ expect(result?.id).toBe('msg-prior')
683
+ expect(vi.mocked(callOpenAIChat)).not.toHaveBeenCalled()
684
+ // No agent narration or tool messages — the customer's own inbound
685
+ // persist is legitimate but no agent message_type may be written.
686
+ const agentWrites = vi.mocked(createRecord).mock.calls.filter(([, body]: any) =>
687
+ ['final', 'plan', 'tool_call', 'escalation'].includes(body?.data?.message_type)
688
+ )
689
+ expect(agentWrites).toHaveLength(0)
690
+ expect(vi.mocked(adminDb.rpc)).toHaveBeenCalledWith('release_episode_lease', expect.objectContaining({
691
+ p_lease_token: LEASE_TOKEN
692
+ }))
693
+ })
694
+ })
695
+ // CIL[END stable_id="~/.framework/tests/unit/episode-lease.test.ts:describe_humanOwnerGate:block"]