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
@@ -1,4 +1,6 @@
1
1
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:module_header:module_header" type="module_header" identity="module_header" parent="~/.framework/functions/_shared/agent-runner.ts"]
2
+
3
+
2
4
  /**
3
5
  * @module agent-runner
4
6
  * @audience internal
@@ -77,6 +79,10 @@ import { createHash } from 'crypto'
77
79
 
78
80
 
79
81
 
82
+
83
+
84
+
85
+
80
86
 
81
87
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:buildContext:function" type="function" identity="buildContext" parent="~/.framework/functions/_shared/agent-runner.ts"]
82
88
  /**
@@ -226,6 +232,10 @@ async function buildContext(
226
232
 
227
233
 
228
234
 
235
+
236
+
237
+
238
+
229
239
 
230
240
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:DEFAULT_VECTOR_CONCURRENCY:constant" type="constant" identity="DEFAULT_VECTOR_CONCURRENCY" parent="~/.framework/functions/_shared/agent-runner.ts"]
231
241
  /**
@@ -271,6 +281,10 @@ const DEFAULT_VECTOR_CONCURRENCY = 3
271
281
 
272
282
 
273
283
 
284
+
285
+
286
+
287
+
274
288
 
275
289
 
276
290
 
@@ -320,6 +334,10 @@ const MAX_VECTOR_CONCURRENCY = 5
320
334
 
321
335
 
322
336
 
337
+
338
+
339
+
340
+
323
341
 
324
342
 
325
343
 
@@ -377,6 +395,10 @@ function resolveVectorConcurrency(value: unknown, itemCount: number): number {
377
395
 
378
396
 
379
397
 
398
+
399
+
400
+
401
+
380
402
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:mapWithConcurrency:function" type="function" identity="mapWithConcurrency" parent="~/.framework/functions/_shared/agent-runner.ts"]
381
403
  /**
382
404
  * Maps an array of items to results using a worker function with a specified level of concurrency to efficiently process multiple asynchronous operations in parallel while managing error handling.
@@ -461,6 +483,10 @@ async function mapWithConcurrency<T, R>(
461
483
 
462
484
 
463
485
 
486
+
487
+
488
+
489
+
464
490
 
465
491
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:retrieveKnowledge:function" type="function" identity="retrieveKnowledge" parent="~/.framework/functions/_shared/agent-runner.ts"]
466
492
  /**
@@ -793,6 +819,10 @@ export async function retrieveKnowledge(
793
819
 
794
820
 
795
821
 
822
+
823
+
824
+
825
+
796
826
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:lexicalSearchEmbeddings:function" type="function" identity="lexicalSearchEmbeddings" parent="~/.framework/functions/_shared/agent-runner.ts"]
797
827
  /**
798
828
  * Performs a lexical search on embeddings based on a query and specified filters. This function retrieves relevant documents from the database while considering account permissions and project associations.
@@ -871,6 +901,10 @@ async function lexicalSearchEmbeddings(
871
901
 
872
902
 
873
903
 
904
+
905
+
906
+
907
+
874
908
 
875
909
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:getConversationHistory:function" type="function" identity="getConversationHistory" parent="~/.framework/functions/_shared/agent-runner.ts"]
876
910
  /**
@@ -929,6 +963,10 @@ async function getConversationHistory(
929
963
 
930
964
 
931
965
 
966
+
967
+
968
+
969
+
932
970
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:recoverUserQuestion:function" type="function" identity="recoverUserQuestion" parent="~/.framework/functions/_shared/agent-runner.ts"]
933
971
  /**
934
972
  * Retrieves the most recent inbound user question from the database based on the provided thread ID. It updates the state with the question if it is not already set.
@@ -970,6 +1008,10 @@ async function recoverUserQuestion(threadId: string, state: ResearchState): Prom
970
1008
 
971
1009
 
972
1010
 
1011
+
1012
+
1013
+
1014
+
973
1015
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:callInference:function" type="function" identity="callInference" parent="~/.framework/functions/_shared/agent-runner.ts"]
974
1016
  /**
975
1017
  * The callInference function performs inference using a specified agent and context, returning the result of the inference process. It handles both live inference through an external API and a mock response when the API is unavailable.
@@ -1077,6 +1119,10 @@ async function callInference(
1077
1119
 
1078
1120
 
1079
1121
 
1122
+
1123
+
1124
+
1125
+
1080
1126
 
1081
1127
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeInferenceHandler:function" type="function" identity="executeInferenceHandler" parent="~/.framework/functions/_shared/agent-runner.ts"]
1082
1128
  /**
@@ -1148,6 +1194,10 @@ async function executeInferenceHandler(
1148
1194
 
1149
1195
 
1150
1196
 
1197
+
1198
+
1199
+
1200
+
1151
1201
 
1152
1202
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:dispatchTools:function" type="function" identity="dispatchTools" parent="~/.framework/functions/_shared/agent-runner.ts"]
1153
1203
  /**
@@ -1262,6 +1312,10 @@ async function dispatchTools(
1262
1312
 
1263
1313
 
1264
1314
 
1315
+
1316
+
1317
+
1318
+
1265
1319
 
1266
1320
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:cilDenied:function" type="function" identity="cilDenied" parent="~/.framework/functions/_shared/agent-runner.ts"]
1267
1321
  /**
@@ -1306,6 +1360,10 @@ function cilDenied(
1306
1360
 
1307
1361
 
1308
1362
 
1363
+
1364
+
1365
+
1366
+
1309
1367
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:cilAllowedProjectIds:function" type="function" identity="cilAllowedProjectIds" parent="~/.framework/functions/_shared/agent-runner.ts"]
1310
1368
  /**
1311
1369
  * Retrieves a list of project IDs that are allowed for the given run context, returning null if access is denied. This function is essential for determining which projects can be interacted with based on the provided configuration.
@@ -1344,6 +1402,10 @@ function cilAllowedProjectIds(
1344
1402
 
1345
1403
 
1346
1404
 
1405
+
1406
+
1407
+
1408
+
1347
1409
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:CilAccess:interface" type="interface" identity="CilAccess" parent="~/.framework/functions/_shared/agent-runner.ts"]
1348
1410
  /**
1349
1411
  * The CilAccess interface defines the structure for managing access control by specifying associated account and project identifiers. It exists to facilitate the organization and retrieval of access-related information within the module.
@@ -1373,6 +1435,10 @@ export interface CilAccess {
1373
1435
 
1374
1436
 
1375
1437
 
1438
+
1439
+
1440
+
1441
+
1376
1442
 
1377
1443
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:resolveCilAccess:function" type="function" identity="resolveCilAccess" parent="~/.framework/functions/_shared/agent-runner.ts"]
1378
1444
  /**
@@ -1423,6 +1489,10 @@ async function resolveCilAccess(
1423
1489
 
1424
1490
 
1425
1491
 
1492
+
1493
+
1494
+
1495
+
1426
1496
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:RetrievalPolicy:type" type="type" identity="RetrievalPolicy" parent="~/.framework/functions/_shared/agent-runner.ts"]
1427
1497
  /**
1428
1498
  * RetrievalPolicy defines the criteria for retrieving items based on various attributes such as security level and project IDs. It provides a structured way to enforce retrieval constraints in the agent runner module.
@@ -1471,6 +1541,10 @@ export type RetrievalPolicy = {
1471
1541
 
1472
1542
 
1473
1543
 
1544
+
1545
+
1546
+
1547
+
1474
1548
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:RetrievalCacheEntry:type" type="type" identity="RetrievalCacheEntry" parent="~/.framework/functions/_shared/agent-runner.ts"]
1475
1549
  /**
1476
1550
  * RetrievalCacheEntry represents an entry in a retrieval cache, storing documents and their similarity metrics. It is used to manage and evaluate cached retrieval results efficiently.
@@ -1487,6 +1561,10 @@ type RetrievalCacheEntry = {
1487
1561
  }
1488
1562
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:RetrievalCacheEntry:type"]
1489
1563
 
1564
+
1565
+
1566
+
1567
+
1490
1568
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:RetrievalCache:type" type="type" identity="RetrievalCache" parent="~/.framework/functions/_shared/agent-runner.ts"]
1491
1569
  /**
1492
1570
  * RetrievalCache is a type that maps string keys to RetrievalCacheEntry objects, facilitating efficient storage and retrieval of cached data entries. It exists to provide a structured way to manage cached retrieval results in the module.
@@ -1498,6 +1576,10 @@ type RetrievalCacheEntry = {
1498
1576
  export type RetrievalCache = Record<string, RetrievalCacheEntry>
1499
1577
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:RetrievalCache:type"]
1500
1578
 
1579
+
1580
+
1581
+
1582
+
1501
1583
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:RETRIEVAL_CACHE_MAX_ENTRIES:constant" type="constant" identity="RETRIEVAL_CACHE_MAX_ENTRIES" parent="~/.framework/functions/_shared/agent-runner.ts"]
1502
1584
  /**
1503
1585
  * RETRIEVAL_CACHE_MAX_ENTRIES defines the maximum number of entries allowed in the retrieval cache. This constant is used to limit the size of the cache for performance optimization.
@@ -1509,6 +1591,10 @@ export type RetrievalCache = Record<string, RetrievalCacheEntry>
1509
1591
  const RETRIEVAL_CACHE_MAX_ENTRIES = 4
1510
1592
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:RETRIEVAL_CACHE_MAX_ENTRIES:constant"]
1511
1593
 
1594
+
1595
+
1596
+
1597
+
1512
1598
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:normalizeRetrievalQuery:function" type="function" identity="normalizeRetrievalQuery" parent="~/.framework/functions/_shared/agent-runner.ts"]
1513
1599
  /**
1514
1600
  * Normalizes a retrieval query string by trimming whitespace, converting it to lowercase, and reducing multiple spaces to a single space. This ensures consistent formatting for query processing.
@@ -1525,6 +1611,10 @@ function normalizeRetrievalQuery(q: string): string {
1525
1611
  }
1526
1612
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:normalizeRetrievalQuery:function"]
1527
1613
 
1614
+
1615
+
1616
+
1617
+
1528
1618
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:retrievalCacheKey:function" type="function" identity="retrievalCacheKey" parent="~/.framework/functions/_shared/agent-runner.ts"]
1529
1619
  /**
1530
1620
  * Generates a unique cache key for retrieval operations based on the provided query and policy parameters. This key is used to optimize retrieval performance by caching results.
@@ -1563,6 +1653,10 @@ function retrievalCacheKey(
1563
1653
  }
1564
1654
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:retrievalCacheKey:function"]
1565
1655
 
1656
+
1657
+
1658
+
1659
+
1566
1660
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:retrievalCacheStore:function" type="function" identity="retrievalCacheStore" parent="~/.framework/functions/_shared/agent-runner.ts"]
1567
1661
  /**
1568
1662
  * Stores an entry in the retrieval cache, ensuring that the cache does not exceed a maximum number of entries by removing the oldest entries. This function helps manage memory usage and maintain the relevance of cached data.
@@ -1601,6 +1695,10 @@ function retrievalCacheStore(cache: RetrievalCache, key: string, entry: Retrieva
1601
1695
 
1602
1696
 
1603
1697
 
1698
+
1699
+
1700
+
1701
+
1604
1702
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:resolveRetrievalPolicy:function" type="function" identity="resolveRetrievalPolicy" parent="~/.framework/functions/_shared/agent-runner.ts"]
1605
1703
  /**
1606
1704
  * Resolves the retrieval policy based on the provided run context and its configuration, ensuring that only allowed project IDs are included in the final policy. This function is essential for maintaining access control in retrieval operations.
@@ -1660,6 +1758,10 @@ function resolveRetrievalPolicy(
1660
1758
 
1661
1759
 
1662
1760
 
1761
+
1762
+
1763
+
1764
+
1663
1765
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeToolAction:function" type="function" identity="executeToolAction" parent="~/.framework/functions/_shared/agent-runner.ts"]
1664
1766
  /**
1665
1767
  * Executes a specified tool action based on the provided parameters and context, serving as a dispatcher for various action handlers. This allows for flexible integration with different services and workflows.
@@ -1802,6 +1904,10 @@ export async function executeToolAction(
1802
1904
 
1803
1905
 
1804
1906
 
1907
+
1908
+
1909
+
1910
+
1805
1911
 
1806
1912
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeEscalateToHuman:function" type="function" identity="executeEscalateToHuman" parent="~/.framework/functions/_shared/agent-runner.ts"]
1807
1913
  /**
@@ -1849,11 +1955,11 @@ async function executeEscalateToHuman(
1849
1955
  .select('data')
1850
1956
  .eq('id', threadId)
1851
1957
  .single()
1852
- if (thread?.data?.escalation_status === 'pending') {
1958
+ if (isHumanOwned(thread?.data)) {
1853
1959
  return { escalated: true, already_escalated: true }
1854
1960
  }
1855
1961
  const confidence = typeof params?.confidence === 'number' ? params.confidence : 0
1856
- await handleEscalation(threadId, confidence, promptConfig, ctx, 'agent_requested')
1962
+ await handleEscalation(threadId, confidence, promptConfig, ctx, 'agent_requested', runCtx?.config?.leaseToken)
1857
1963
  return { escalated: true, already_escalated: false, reason: params?.reason || null }
1858
1964
  }
1859
1965
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:executeEscalateToHuman:function"]
@@ -1873,6 +1979,10 @@ async function executeEscalateToHuman(
1873
1979
 
1874
1980
 
1875
1981
 
1982
+
1983
+
1984
+
1985
+
1876
1986
 
1877
1987
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeSearchKnowledge:function" type="function" identity="executeSearchKnowledge" parent="~/.framework/functions/_shared/agent-runner.ts"]
1878
1988
  /**
@@ -1951,6 +2061,8 @@ async function executeSearchKnowledge(
1951
2061
 
1952
2062
 
1953
2063
 
2064
+
2065
+
1954
2066
  // Entities a model-driven query may read. Sensitive tables (api_keys,
1955
2067
  // integrations, triggers, pipelines, people, audit/logs) are excluded —
1956
2068
  // this tool exists for runtime records, not platform internals.
@@ -1961,6 +2073,8 @@ async function executeSearchKnowledge(
1961
2073
 
1962
2074
 
1963
2075
 
2076
+
2077
+
1964
2078
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:QUERY_ITEMS_ENTITIES:constant" type="constant" identity="QUERY_ITEMS_ENTITIES" parent="~/.framework/functions/_shared/agent-runner.ts"]
1965
2079
  /**
1966
2080
  * QUERY_ITEMS_ENTITIES is a Set that contains the names of various entity types used in the application. It provides a centralized collection of these entity names for easier management and reference throughout the module.
@@ -2005,6 +2119,10 @@ const QUERY_ITEMS_ENTITIES = new Set([
2005
2119
 
2006
2120
 
2007
2121
 
2122
+
2123
+
2124
+
2125
+
2008
2126
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeQueryItems:function" type="function" identity="executeQueryItems" parent="~/.framework/functions/_shared/agent-runner.ts"]
2009
2127
  /**
2010
2128
  * Executes a query to retrieve items from a specified entity in the database, applying optional filters and a limit on the number of results. This function ensures that the entity is valid and handles potential query errors.
@@ -2075,6 +2193,10 @@ async function executeQueryItems(
2075
2193
 
2076
2194
 
2077
2195
 
2196
+
2197
+
2198
+
2199
+
2078
2200
 
2079
2201
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeCreateRecord:function" type="function" identity="executeCreateRecord" parent="~/.framework/functions/_shared/agent-runner.ts"]
2080
2202
  /**
@@ -2145,6 +2267,10 @@ async function executeCreateRecord(
2145
2267
 
2146
2268
 
2147
2269
 
2270
+
2271
+
2272
+
2273
+
2148
2274
 
2149
2275
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeUpdateItem:function" type="function" identity="executeUpdateItem" parent="~/.framework/functions/_shared/agent-runner.ts"]
2150
2276
  /**
@@ -2212,6 +2338,10 @@ async function executeUpdateItem(
2212
2338
 
2213
2339
 
2214
2340
 
2341
+
2342
+
2343
+
2344
+
2215
2345
 
2216
2346
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeSendNotification:function" type="function" identity="executeSendNotification" parent="~/.framework/functions/_shared/agent-runner.ts"]
2217
2347
  /**
@@ -2285,6 +2415,10 @@ async function executeSendNotification(
2285
2415
 
2286
2416
 
2287
2417
 
2418
+
2419
+
2420
+
2421
+
2288
2422
 
2289
2423
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:handleEscalation:function" type="function" identity="handleEscalation" parent="~/.framework/functions/_shared/agent-runner.ts"]
2290
2424
  /**
@@ -2326,23 +2460,32 @@ async function handleEscalation(
2326
2460
  confidence: number,
2327
2461
  promptConfig: any,
2328
2462
  ctx: CoreContext,
2329
- escalationReason: string = 'low_confidence'
2463
+ escalationReason: string = 'low_confidence',
2464
+ leaseToken?: string
2330
2465
  ): Promise<void> {
2331
2466
  return withSpan(ctx, 'agent.escalation', async (ctx) => {
2332
- const { data: currentThread } = await adminDb
2333
- .from('threads')
2334
- .select('data')
2335
- .eq('id', threadId)
2336
- .single()
2337
- await adminDb
2338
- .from('threads')
2339
- .update({
2340
- data: {
2341
- ...(currentThread?.data || {}),
2342
- escalation_status: 'pending'
2343
- }
2344
- })
2345
- .eq('id', threadId)
2467
+ // Atomic key merge — a whole-blob read-modify-write here could clobber
2468
+ // a research_state checkpoint written meanwhile (and vice versa).
2469
+ // Lease-fenced when the caller holds the episode lease.
2470
+ const merged = await mergeThreadData(threadId, { owner: 'human' }, leaseToken)
2471
+ if (merged === false) throw new EpisodeLeaseLost(threadId)
2472
+ if (merged === null) {
2473
+ // RPC unavailable — legacy whole-blob write.
2474
+ const { data: currentThread } = await adminDb
2475
+ .from('threads')
2476
+ .select('data')
2477
+ .eq('id', threadId)
2478
+ .single()
2479
+ await adminDb
2480
+ .from('threads')
2481
+ .update({
2482
+ data: {
2483
+ ...(currentThread?.data || {}),
2484
+ owner: 'human'
2485
+ }
2486
+ })
2487
+ .eq('id', threadId)
2488
+ }
2346
2489
  const { data: thread } = await adminDb
2347
2490
  .from('threads')
2348
2491
  .select('target_type, target_id')
@@ -2438,6 +2581,10 @@ async function handleEscalation(
2438
2581
 
2439
2582
 
2440
2583
 
2584
+
2585
+
2586
+
2587
+
2441
2588
 
2442
2589
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:getReplyTypeSlug:function" type="function" identity="getReplyTypeSlug" parent="~/.framework/functions/_shared/agent-runner.ts"]
2443
2590
  /**
@@ -2488,6 +2635,8 @@ export function getReplyTypeSlug(threadType: any): string {
2488
2635
 
2489
2636
 
2490
2637
 
2638
+
2639
+
2491
2640
  /**
2492
2641
  * Saves a message to the database associated with a specific thread, determining its direction and visibility based on the message type. This function is essential for maintaining the state of conversations in the agent framework.
2493
2642
  *
@@ -2530,6 +2679,8 @@ export function getReplyTypeSlug(threadType: any): string {
2530
2679
  // stays the correctness backstop: an out-of-band writer (customer message
2531
2680
  // mid-episode, second instance) makes a stale counter surface as 23505, which
2532
2681
  // triggers a reseed + retry inside saveMessage.
2682
+
2683
+
2533
2684
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:threadSeqCounter:constant" type="constant" identity="threadSeqCounter" parent="~/.framework/functions/_shared/agent-runner.ts"]
2534
2685
  /**
2535
2686
  * The threadSeqCounter is a Map that associates string keys with numeric values, serving as a counter for thread sequences. It exists to efficiently track and manage the sequence numbers of threads in the agent runner module.
@@ -2540,6 +2691,10 @@ export function getReplyTypeSlug(threadType: any): string {
2540
2691
  */
2541
2692
  const threadSeqCounter = new Map<string, number>()
2542
2693
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:threadSeqCounter:constant"]
2694
+
2695
+
2696
+
2697
+
2543
2698
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:threadSeqSeeding:constant" type="constant" identity="threadSeqSeeding" parent="~/.framework/functions/_shared/agent-runner.ts"]
2544
2699
  /**
2545
2700
  * A map that associates string keys with promises that resolve to void. It is used to manage asynchronous operations related to thread sequencing in the agent runner module.
@@ -2551,6 +2706,10 @@ const threadSeqCounter = new Map<string, number>()
2551
2706
  const threadSeqSeeding = new Map<string, Promise<void>>()
2552
2707
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:threadSeqSeeding:constant"]
2553
2708
 
2709
+
2710
+
2711
+
2712
+
2554
2713
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:ensureSequenceCounter:function" type="function" identity="ensureSequenceCounter" parent="~/.framework/functions/_shared/agent-runner.ts"]
2555
2714
  /**
2556
2715
  * Ensures that a sequence counter for a given thread ID is initialized and updated based on the count of messages in the database. This function prevents duplicate initializations by using a seeding mechanism.
@@ -2581,8 +2740,12 @@ async function ensureSequenceCounter(threadId: string): Promise<void> {
2581
2740
  }
2582
2741
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:ensureSequenceCounter:function"]
2583
2742
 
2743
+
2744
+
2584
2745
  // Sync allocation — callers that batch writes (tool_call markers) allocate all
2585
2746
  // sequences up front so a Promise.all of inserts preserves display order.
2747
+
2748
+
2586
2749
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:allocSequence:function" type="function" identity="allocSequence" parent="~/.framework/functions/_shared/agent-runner.ts"]
2587
2750
  /**
2588
2751
  * Allocates a sequence number for a given thread ID, ensuring unique sequence numbers for each thread. This function is essential for managing thread-specific operations in a concurrent environment.
@@ -2601,6 +2764,10 @@ function allocSequence(threadId: string): number {
2601
2764
  }
2602
2765
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:allocSequence:function"]
2603
2766
 
2767
+
2768
+
2769
+
2770
+
2604
2771
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:saveMessage:function" type="function" identity="saveMessage" parent="~/.framework/functions/_shared/agent-runner.ts"]
2605
2772
  /**
2606
2773
  * Saves a message to a specified thread with relevant metadata. It ensures message sequence integrity and handles potential duplicate key errors during insertion.
@@ -2690,6 +2857,10 @@ async function saveMessage(
2690
2857
 
2691
2858
 
2692
2859
 
2860
+
2861
+
2862
+
2863
+
2693
2864
 
2694
2865
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:AgentConfig:interface" type="interface" identity="AgentConfig" parent="~/.framework/functions/_shared/agent-runner.ts"]
2695
2866
  /**
@@ -2722,6 +2893,8 @@ export interface AgentConfig {
2722
2893
  threadType: any
2723
2894
  /** Episode lease owner — stamped into research_state on every checkpoint. */
2724
2895
  driverToken?: string
2896
+ /** DB lease token from acquire_episode_lease — heartbeats per turn and fences takeover writes. */
2897
+ leaseToken?: string
2725
2898
  }
2726
2899
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:AgentConfig:interface"]
2727
2900
 
@@ -2740,6 +2913,10 @@ export interface AgentConfig {
2740
2913
 
2741
2914
 
2742
2915
 
2916
+
2917
+
2918
+
2919
+
2743
2920
 
2744
2921
 
2745
2922
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:InferenceResult:interface" type="interface" identity="InferenceResult" parent="~/.framework/functions/_shared/agent-runner.ts"]
@@ -2789,6 +2966,10 @@ export interface InferenceResult {
2789
2966
 
2790
2967
 
2791
2968
 
2969
+
2970
+
2971
+
2972
+
2792
2973
 
2793
2974
 
2794
2975
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:ToolCall:interface" type="interface" identity="ToolCall" parent="~/.framework/functions/_shared/agent-runner.ts"]
@@ -2837,6 +3018,10 @@ export interface ToolCall {
2837
3018
 
2838
3019
 
2839
3020
 
3021
+
3022
+
3023
+
3024
+
2840
3025
 
2841
3026
 
2842
3027
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:ToolResult:interface" type="interface" identity="ToolResult" parent="~/.framework/functions/_shared/agent-runner.ts"]
@@ -2893,6 +3078,10 @@ export interface ToolResult {
2893
3078
 
2894
3079
 
2895
3080
 
3081
+
3082
+
3083
+
3084
+
2896
3085
 
2897
3086
 
2898
3087
 
@@ -3055,6 +3244,10 @@ const CIL_TOOLS: any[] = [
3055
3244
 
3056
3245
 
3057
3246
 
3247
+
3248
+
3249
+
3250
+
3058
3251
 
3059
3252
 
3060
3253
 
@@ -3094,6 +3287,10 @@ const CIL_TOOL_SLUGS = new Set(CIL_TOOLS.map(t => t.name))
3094
3287
 
3095
3288
 
3096
3289
 
3290
+
3291
+
3292
+
3293
+
3097
3294
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:CIL_ONLY_TOOL_SLUGS:constant" type="constant" identity="CIL_ONLY_TOOL_SLUGS" parent="~/.framework/functions/_shared/agent-runner.ts"]
3098
3295
  /**
3099
3296
  * CIL_ONLY_TOOL_SLUGS is a set that contains tool slugs prefixed with 'cil_' from the CIL_TOOL_SLUGS collection. It exists to filter and provide a specific subset of tool slugs for CIL-related operations.
@@ -3121,6 +3318,8 @@ const CIL_ONLY_TOOL_SLUGS = new Set([...CIL_TOOL_SLUGS].filter((n: string) => n.
3121
3318
 
3122
3319
 
3123
3320
 
3321
+
3322
+
3124
3323
  // Tools that count as "research" for the ask_user gate: every built-in CIL tool
3125
3324
  // except ask_user itself and the always-available escalate_to_human handoff.
3126
3325
 
@@ -3130,6 +3329,8 @@ const CIL_ONLY_TOOL_SLUGS = new Set([...CIL_TOOL_SLUGS].filter((n: string) => n.
3130
3329
 
3131
3330
 
3132
3331
 
3332
+
3333
+
3133
3334
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:RESEARCH_TOOLS:constant" type="constant" identity="RESEARCH_TOOLS" parent="~/.framework/functions/_shared/agent-runner.ts"]
3134
3335
  /**
3135
3336
  * RESEARCH_TOOLS is a filtered collection of tool names derived from CIL_TOOLS, excluding 'ask_user' and 'escalate_to_human'. It provides a focused set of tools for research purposes within the agent runner module.
@@ -3175,6 +3376,10 @@ const RESEARCH_TOOLS = new Set(
3175
3376
 
3176
3377
 
3177
3378
 
3379
+
3380
+
3381
+
3382
+
3178
3383
 
3179
3384
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:computeCilChunkHash:function" type="function" identity="computeCilChunkHash" parent="~/.framework/functions/_shared/agent-runner.ts"]
3180
3385
  /**
@@ -3224,6 +3429,10 @@ function computeCilChunkHash(content: string): string {
3224
3429
 
3225
3430
 
3226
3431
 
3432
+
3433
+
3434
+
3435
+
3227
3436
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:ResearchState:interface" type="interface" identity="ResearchState" parent="~/.framework/functions/_shared/agent-runner.ts"]
3228
3437
  /**
3229
3438
  * The ResearchState interface defines the structure for managing the state of a research process, including its status and relevant metadata. It facilitates tracking and updating the progress of research tasks within the application.
@@ -3269,6 +3478,13 @@ interface ResearchState {
3269
3478
  // on every checkpoint so other drivers refuse to interleave writes.
3270
3479
  driver_id?: string
3271
3480
  driver_heartbeat_at?: string
3481
+ // Set by the claim path when it writes the intake narration — runAgent
3482
+ // skips its own narration when the flag was stamped by the same driver.
3483
+ intake_announced?: boolean
3484
+ // Stamped when the episode reaches 'complete'/'failed' — lets a later
3485
+ // claim of the same question tell a replay (inbound predates the
3486
+ // terminal write) from a genuine same-text re-ask (inbound is newer).
3487
+ completed_at?: string
3272
3488
  }
3273
3489
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:ResearchState:interface"]
3274
3490
 
@@ -3287,6 +3503,10 @@ interface ResearchState {
3287
3503
 
3288
3504
 
3289
3505
 
3506
+
3507
+
3508
+
3509
+
3290
3510
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:getResearchState:function" type="function" identity="getResearchState" parent="~/.framework/functions/_shared/agent-runner.ts"]
3291
3511
  /**
3292
3512
  * Retrieves the current research state from a thread, returning a default state if none exists. This function ensures that a valid research state is always available for processing.
@@ -3348,6 +3568,10 @@ function getResearchState(thread: any): ResearchState {
3348
3568
 
3349
3569
 
3350
3570
 
3571
+
3572
+
3573
+
3574
+
3351
3575
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:computeCompositeConfidence:function" type="function" identity="computeCompositeConfidence" parent="~/.framework/functions/_shared/agent-runner.ts"]
3352
3576
  /**
3353
3577
  * Computes a composite confidence score based on a language model confidence and mean similarity. This function adjusts the score based on specific thresholds of mean similarity to ensure the output remains within a defined range.
@@ -3404,6 +3628,10 @@ function computeCompositeConfidence(llmConf: number, meanSimilarity: number): nu
3404
3628
 
3405
3629
 
3406
3630
 
3631
+
3632
+
3633
+
3634
+
3407
3635
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:EvidenceState:type" type="type" identity="EvidenceState" parent="~/.framework/functions/_shared/agent-runner.ts"]
3408
3636
  /**
3409
3637
  * EvidenceState defines the possible states of evidence in the context of verification processes. It provides a clear and consistent representation of evidence status throughout the module.
@@ -3446,6 +3674,8 @@ export type EvidenceState = 'verified' | 'evidence_found' | 'incomplete' | 'not_
3446
3674
 
3447
3675
 
3448
3676
 
3677
+
3678
+
3449
3679
  // Retrieval-time evidence state for a response. 'verified' is deliberately
3450
3680
  // unreachable here — it is reserved for downstream human approval or eval
3451
3681
  // confirmation; inference can never self-certify it.
@@ -3485,6 +3715,8 @@ export type EvidenceState = 'verified' | 'evidence_found' | 'incomplete' | 'not_
3485
3715
 
3486
3716
  // A single rank-1 similarity at or above this is decisive evidence on its own —
3487
3717
  // below it, a lone top hit could still be a low-signal lexical floater.
3718
+
3719
+
3488
3720
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:TOP_HIT_SIMILARITY:constant" type="constant" identity="TOP_HIT_SIMILARITY" parent="~/.framework/functions/_shared/agent-runner.ts"]
3489
3721
  /**
3490
3722
  * TOP_HIT_SIMILARITY defines a constant value used to determine the threshold for similarity in hit scoring. It exists to provide a standard reference point for comparison across the module.
@@ -3496,6 +3728,10 @@ export type EvidenceState = 'verified' | 'evidence_found' | 'incomplete' | 'not_
3496
3728
  const TOP_HIT_SIMILARITY = 0.5
3497
3729
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:TOP_HIT_SIMILARITY:constant"]
3498
3730
 
3731
+
3732
+
3733
+
3734
+
3499
3735
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:computeEvidenceState:function" type="function" identity="computeEvidenceState" parent="~/.framework/functions/_shared/agent-runner.ts"]
3500
3736
  /**
3501
3737
  * Computes the evidence state based on the provided documents and their mean similarity score. It determines whether evidence is found, incomplete, or not established based on specific thresholds.
@@ -3525,6 +3761,10 @@ export function computeEvidenceState(
3525
3761
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:computeEvidenceState:function"]
3526
3762
 
3527
3763
 
3764
+
3765
+
3766
+
3767
+
3528
3768
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:hasInRangeCitationMarker:function" type="function" identity="hasInRangeCitationMarker" parent="~/.framework/functions/_shared/agent-runner.ts"]
3529
3769
  /**
3530
3770
  * Determines if the provided content contains at least one citation marker that corresponds to a valid source in the retrieved sources array. This function is essential for ensuring that evidence is properly cited in the content.
@@ -3568,6 +3808,10 @@ export function hasInRangeCitationMarker(content: string, sourceCount: number):
3568
3808
 
3569
3809
 
3570
3810
 
3811
+
3812
+
3813
+
3814
+
3571
3815
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:extractCitedSources:function" type="function" identity="extractCitedSources" parent="~/.framework/functions/_shared/agent-runner.ts"]
3572
3816
  /**
3573
3817
  * Extracts cited sources from the provided content based on reference numbers, returning either the cited sources or all sources if none are found. This function helps in identifying and organizing referenced materials within a text.
@@ -3622,6 +3866,10 @@ export function extractCitedSources(content: string, sources: any[]): any[] {
3622
3866
 
3623
3867
 
3624
3868
 
3869
+
3870
+
3871
+
3872
+
3625
3873
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:ensureInlineCitations:function" type="function" identity="ensureInlineCitations" parent="~/.framework/functions/_shared/agent-runner.ts"]
3626
3874
  /**
3627
3875
  * Ensures that inline citations are present in the provided content based on the given sources. If no citations are found, it appends a list of sources to the content.
@@ -3665,6 +3913,10 @@ export function ensureInlineCitations(content: string, sources: any[]): string {
3665
3913
 
3666
3914
 
3667
3915
 
3916
+
3917
+
3918
+
3919
+
3668
3920
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:shapeRetrievedSource:function" type="function" identity="shapeRetrievedSource" parent="~/.framework/functions/_shared/agent-runner.ts"]
3669
3921
  /**
3670
3922
  * The function shapeRetrievedSource transforms a source object into a structured format containing specific fields. This standardization facilitates further processing of retrieved sources.
@@ -3720,9 +3972,13 @@ function shapeRetrievedSource(s: any): any {
3720
3972
 
3721
3973
 
3722
3974
 
3723
- // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:truncateForMessage:function" type="function" identity="truncateForMessage" parent="~/.framework/functions/_shared/agent-runner.ts"]
3724
- /**
3725
- * Truncate a value to a specified maximum character length for message display, ensuring that long values are shortened with a notice of truncation. This function is useful for maintaining readability in logs or messages where space is limited.
3975
+
3976
+
3977
+
3978
+
3979
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:truncateForMessage:function" type="function" identity="truncateForMessage" parent="~/.framework/functions/_shared/agent-runner.ts"]
3980
+ /**
3981
+ * Truncate a value to a specified maximum character length for message display, ensuring that long values are shortened with a notice of truncation. This function is useful for maintaining readability in logs or messages where space is limited.
3726
3982
  *
3727
3983
  * @inputSpec
3728
3984
  * value: any (required) — the value to be truncated.
@@ -3771,6 +4027,10 @@ function truncateForMessage(value: any, maxChars: number): any {
3771
4027
 
3772
4028
 
3773
4029
 
4030
+
4031
+
4032
+
4033
+
3774
4034
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:setResearchState:function" type="function" identity="setResearchState" parent="~/.framework/functions/_shared/agent-runner.ts"]
3775
4035
  /**
3776
4036
  * Updates the research state of a specified thread in the database, ensuring that the latest state is recorded along with the current data and a timestamp.
@@ -3804,13 +4064,27 @@ function truncateForMessage(value: any, maxChars: number): any {
3804
4064
  async function setResearchState(
3805
4065
  threadId: string,
3806
4066
  state: ResearchState,
3807
- currentData: any
4067
+ currentData: any,
4068
+ leaseToken?: string
3808
4069
  ): Promise<void> {
3809
4070
  // Mirror the write into the caller's in-memory thread data — multi-step
3810
4071
  // modes read getResearchState(config.thread) again on the next step and
3811
4072
  // must see the state just persisted, not a stale snapshot.
3812
4073
  if (currentData) currentData.research_state = state
3813
- await adminDb
4074
+ // Atomic key-set via merge_thread_data: the previous read-modify-write
4075
+ // of the whole `data` blob could resurrect a stale research_state over
4076
+ // a terminal checkpoint when a concurrent writer (escalation stamp,
4077
+ // message-trigger pipeline) landed in between. The lease token fences
4078
+ // the write — a displaced driver's stale checkpoint is dropped.
4079
+ const merged = await mergeThreadData(threadId, { research_state: state }, leaseToken)
4080
+ if (merged === true) return
4081
+ if (merged === false) {
4082
+ // A takeover displaced this driver mid-run — its checkpoint must not
4083
+ // overwrite the new owner's state.
4084
+ throw new EpisodeLeaseLost(threadId)
4085
+ }
4086
+ // RPC unavailable (un-migrated deployment) — legacy whole-blob write.
4087
+ const { error } = await adminDb
3814
4088
  .from('threads')
3815
4089
  .update({
3816
4090
  data: {
@@ -3820,6 +4094,7 @@ async function setResearchState(
3820
4094
  updated_at: new Date().toISOString()
3821
4095
  })
3822
4096
  .eq('id', threadId)
4097
+ if (error) console.error(`setResearchState failed for thread ${threadId}:`, error.message)
3823
4098
  }
3824
4099
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:setResearchState:function"]
3825
4100
 
@@ -3838,6 +4113,10 @@ async function setResearchState(
3838
4113
 
3839
4114
 
3840
4115
 
4116
+
4117
+
4118
+
4119
+
3841
4120
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:isEmptyToolResult:function" type="function" identity="isEmptyToolResult" parent="~/.framework/functions/_shared/agent-runner.ts"]
3842
4121
  /**
3843
4122
  * Determines whether a given tool result is empty or not by evaluating various data types and structures. This function is essential for validating the presence of meaningful data in tool results.
@@ -3895,6 +4174,10 @@ function isEmptyToolResult(result: any): boolean {
3895
4174
 
3896
4175
 
3897
4176
 
4177
+
4178
+
4179
+
4180
+
3898
4181
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:runAgentStep:function" type="function" identity="runAgentStep" parent="~/.framework/functions/_shared/agent-runner.ts"]
3899
4182
  /**
3900
4183
  * Executes a single step in the agent's processing sequence, managing user input and context while coordinating research and inference tasks. This function is essential for maintaining the state and flow of interactions within the agent's operational framework.
@@ -3960,11 +4243,17 @@ export async function runAgentStep(
3960
4243
  state.max_turns = maxTurns
3961
4244
  // Refresh the single-flight lease on every step checkpoint — a live
3962
4245
  // heartbeat tells other drivers (second mounts, step-mode fallbacks) to
3963
- // watch instead of interleaving writes.
4246
+ // watch instead of interleaving writes. The DB lease is the atomic
4247
+ // authority: a false heartbeat means an expired-lease takeover
4248
+ // displaced this driver, so its remaining writes are fenced off.
3964
4249
  if (config.driverToken) {
3965
4250
  state.driver_id = config.driverToken
3966
4251
  state.driver_heartbeat_at = new Date().toISOString()
3967
4252
  }
4253
+ if (config.leaseToken) {
4254
+ const owned = await heartbeatEpisodeLease(threadId, config.leaseToken)
4255
+ if (owned === false) throw new EpisodeLeaseLost(threadId)
4256
+ }
3968
4257
 
3969
4258
  // Retrieval is keyed on the user question, which is fixed for the whole
3970
4259
  // episode — reuse it across turns and across timeout-resumed steps
@@ -4003,6 +4292,13 @@ export async function runAgentStep(
4003
4292
  const inferenceAgent = researchAttempted || !agent.model_config?.tools?.length
4004
4293
  ? agent
4005
4294
  : { ...agent, model_config: { ...agent.model_config, tools: agent.model_config.tools.filter((t: any) => t.function?.name !== 'ask_user') } }
4295
+ // Heartbeat again after buildContext — it can take tens of seconds,
4296
+ // and stale-gated detached steals key on heartbeat age. One extra RPC
4297
+ // keeps a healthy long-build driver unstealable through inference.
4298
+ if (config.leaseToken) {
4299
+ const owned = await heartbeatEpisodeLease(threadId, config.leaseToken)
4300
+ if (owned === false) throw new EpisodeLeaseLost(threadId)
4301
+ }
4006
4302
  const inferenceResult = await callInference(context, inferenceAgent, promptConfig, ctx)
4007
4303
  const confidence = computeCompositeConfidence(inferenceResult.confidence ?? 0.70, meanSimilarity)
4008
4304
  const content = inferenceResult.content || ''
@@ -4075,6 +4371,23 @@ export async function runAgentStep(
4075
4371
  // the ticket is already in the human queue via handleEscalation.
4076
4372
  const escalated = toolResults.find((tr: any) => tr.tool === 'escalate_to_human' && tr.result?.escalated)
4077
4373
  if (escalated) {
4374
+ state.status = 'complete'
4375
+ state.completed_at = new Date().toISOString()
4376
+ await setResearchState(threadId, state, thread.data, config.leaseToken)
4377
+ if (escalated.result?.already_escalated) {
4378
+ // Re-escalation on an already human-owned thread — the handoff
4379
+ // already happened and was already narrated; emit no second
4380
+ // customer-facing final. Return the prior outbound message so
4381
+ // callers still get terminal content, never a duplicate.
4382
+ const { data: priorOutbound } = await adminDb
4383
+ .from('messages')
4384
+ .select('id, content, data')
4385
+ .eq('thread_id', threadId)
4386
+ .eq('direction', 'outbound')
4387
+ .order('created_at', { ascending: false })
4388
+ .limit(1)
4389
+ return { message: (Array.isArray(priorOutbound) ? priorOutbound[0] : priorOutbound) || null, state, confidence, escalated: true }
4390
+ }
4078
4391
  const escMsg = await saveMessage(threadId, "I've escalated this to a human support agent — they'll review your request and follow up shortly.", 'final', {
4079
4392
  escalated: true,
4080
4393
  escalation_notice: true,
@@ -4083,8 +4396,6 @@ export async function runAgentStep(
4083
4396
  prompt_config_id: promptConfig.id,
4084
4397
  sender_type: 'agent'
4085
4398
  }, ctx, replyTypeSlug, 'external')
4086
- state.status = 'complete'
4087
- await setResearchState(threadId, state, thread.data)
4088
4399
  return { message: escMsg, state, confidence, escalated: true }
4089
4400
  }
4090
4401
 
@@ -4098,12 +4409,12 @@ export async function runAgentStep(
4098
4409
  }, ctx, replyTypeSlug, 'external')
4099
4410
  state.status = 'awaiting_input'
4100
4411
  state.pending_question = question
4101
- await setResearchState(threadId, state, thread.data)
4412
+ await setResearchState(threadId, state, thread.data, config.leaseToken)
4102
4413
  return { message: askMsg, state }
4103
4414
  }
4104
4415
 
4105
4416
  state.status = 'gathering'
4106
- await setResearchState(threadId, state, thread.data)
4417
+ await setResearchState(threadId, state, thread.data, config.leaseToken)
4107
4418
  return { message: lastToolMessage, state }
4108
4419
  }
4109
4420
 
@@ -4130,7 +4441,7 @@ export async function runAgentStep(
4130
4441
  result: 'You attempted to ask the customer a question before any research tool ran this episode (research_attempted=false). Research first with search_knowledge and/or cil_chunk_lookup — an empty result still counts as an attempt — then answer, or ask once research has been attempted.',
4131
4442
  })
4132
4443
  state.status = 'gathering'
4133
- await setResearchState(threadId, state, thread.data)
4444
+ await setResearchState(threadId, state, thread.data, config.leaseToken)
4134
4445
  return { message: nudgeMsg, state }
4135
4446
  }
4136
4447
  const questionMsg = await saveMessage(threadId, content, 'awaiting_input', {
@@ -4141,7 +4452,7 @@ export async function runAgentStep(
4141
4452
  }, ctx, replyTypeSlug, 'external')
4142
4453
  state.status = 'awaiting_input'
4143
4454
  state.pending_question = content
4144
- await setResearchState(threadId, state, thread.data)
4455
+ await setResearchState(threadId, state, thread.data, config.leaseToken)
4145
4456
  return { message: questionMsg, state }
4146
4457
  }
4147
4458
 
@@ -4176,7 +4487,8 @@ export async function runAgentStep(
4176
4487
  sender_type: 'agent'
4177
4488
  }, ctx, replyTypeSlug, threadVisibility)
4178
4489
  state.status = 'complete'
4179
- await setResearchState(threadId, state, thread.data)
4490
+ state.completed_at = new Date().toISOString()
4491
+ await setResearchState(threadId, state, thread.data, config.leaseToken)
4180
4492
  // The message row comes back through createRecord → sanitizeRecordData,
4181
4493
  // which drops data.* keys the message type doesn't declare — so callers
4182
4494
  // must not read confidence/evidence_state off the returned record.
@@ -4200,6 +4512,10 @@ export async function runAgentStep(
4200
4512
 
4201
4513
 
4202
4514
 
4515
+
4516
+
4517
+
4518
+
4203
4519
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:runAgent:function" type="function" identity="runAgent" parent="~/.framework/functions/_shared/agent-runner.ts"]
4204
4520
  /**
4205
4521
  * Runs an agent to process a user's message and generate a response based on the agent's configuration and context. This function is essential for handling interactions with the agent in a conversational setting.
@@ -4239,18 +4555,36 @@ export async function runAgent(
4239
4555
  userMessage: string,
4240
4556
  ctx: CoreContext,
4241
4557
  mode: 'step' | 'complete' = 'complete',
4242
- driverToken?: string
4558
+ driverToken?: string,
4559
+ leaseToken?: string,
4560
+ opts?: { deadlineMs?: number }
4243
4561
  ): Promise<any> {
4244
4562
  return withSpan(ctx, 'agent.runAgent', async (ctx) => {
4245
4563
  const startTime = Date.now()
4564
+ const deadlineMs = opts?.deadlineMs
4246
4565
  let lastMessage: any
4247
4566
  let lastStep: Awaited<ReturnType<typeof runAgentStep>> | undefined
4567
+ // Terminal release: the holder frees the lease only when the episode
4568
+ // reaches a terminal state or aborts — a nonterminal step-mode return
4569
+ // keeps the lease so the same driver's next HTTP request renews it.
4570
+ const releaseIfOwned = async (terminal: boolean) => {
4571
+ if (leaseToken && terminal) await releaseEpisodeLease(threadId, leaseToken)
4572
+ }
4248
4573
  try {
4574
+ // Renew the lease before any prelude work — a detached driver that
4575
+ // was CPU-throttled between claim and run start would otherwise
4576
+ // risk its TTL expiring mid-prelude. A false heartbeat means a
4577
+ // steal/takeover already displaced us: abort before writing.
4578
+ if (leaseToken) {
4579
+ const owned = await heartbeatEpisodeLease(threadId, leaseToken)
4580
+ if (owned === false) throw new EpisodeLeaseLost(threadId)
4581
+ }
4249
4582
  const config = await resolveAgentConfig(threadId, ctx)
4250
4583
  if (!config) {
4251
4584
  throw new Error(`Could not resolve agent configuration for thread ${threadId}`)
4252
4585
  }
4253
4586
  if (driverToken) config.driverToken = driverToken
4587
+ if (leaseToken) config.leaseToken = leaseToken
4254
4588
  const { agent, promptConfig, thread, threadType } = config
4255
4589
  const replyTypeSlug = getReplyTypeSlug(threadType)
4256
4590
  const threadVisibility = thread?.visibility || 'external'
@@ -4278,6 +4612,22 @@ export async function runAgent(
4278
4612
  if (!userMessage?.trim() && !state.user_question) {
4279
4613
  await recoverUserQuestion(threadId, state)
4280
4614
  }
4615
+ // Human-owner gate — re-validated at run start, after config resolve
4616
+ // gives a fresh thread read. A driver that claimed before the handoff
4617
+ // but resumes after it (a frozen serverless invocation waking into a
4618
+ // changed world) must not narrate or run turns. The inbound row above
4619
+ // already persisted — the human sees the message; the agent stays out.
4620
+ if (isHumanOwned(thread?.data)) {
4621
+ const { data: priorOutbound } = await adminDb
4622
+ .from('messages')
4623
+ .select('id, content, data')
4624
+ .eq('thread_id', threadId)
4625
+ .eq('direction', 'outbound')
4626
+ .order('created_at', { ascending: false })
4627
+ .limit(1)
4628
+ await releaseIfOwned(true)
4629
+ return (Array.isArray(priorOutbound) ? priorOutbound[0] : priorOutbound) || lastMessage
4630
+ }
4281
4631
  const isMultiTurn = promptConfig.is_multi_turn !== false
4282
4632
  if (!isMultiTurn) {
4283
4633
  const { context, retrievedSources, meanSimilarity } = await buildContext(config, userMessage?.trim() || state.user_question || '', [], ctx)
@@ -4292,7 +4642,7 @@ export async function runAgent(
4292
4642
  confidence < (promptConfig.confidence_threshold || 0) &&
4293
4643
  evidenceState !== 'evidence_found'
4294
4644
  if (isEscalated) {
4295
- await handleEscalation(threadId, confidence, promptConfig, ctx)
4645
+ await handleEscalation(threadId, confidence, promptConfig, ctx, 'low_confidence', config.leaseToken)
4296
4646
  }
4297
4647
  // Same deterministic citation repair as the episode-mode path — an
4298
4648
  // evidence_found answer must always carry an in-range [n] marker.
@@ -4340,22 +4690,34 @@ export async function runAgent(
4340
4690
  confidence,
4341
4691
  escalated: isEscalated
4342
4692
  })
4693
+ await releaseIfOwned(true)
4343
4694
  return lastMessage
4344
4695
  }
4345
4696
 
4697
+ // A re-delivered copy of the question this episode already answered
4698
+ // (e.g. a late triage kick claiming the just-released lease) is not
4699
+ // a new episode — skipping the reset lets the terminal no-op below
4700
+ // return without re-running side-effect tools or duplicating finals.
4701
+ const questionReplay = await isAnsweredQuestionReplay(threadId, state, userMessage)
4702
+
4346
4703
  // When a new user message arrives after the previous research episode
4347
4704
  // reached a terminal state, start a fresh episode so the new question gets
4348
4705
  // its own turn budget and gathered_results. 'awaiting_input' is excluded so
4349
4706
  // the user's reply continues the pending clarification instead.
4350
- if (userMessage?.trim() && (state.status === 'complete' || state.status === 'failed')) {
4707
+ if (userMessage?.trim() && !questionReplay && (state.status === 'complete' || state.status === 'failed')) {
4351
4708
  state = {
4352
4709
  status: 'planning',
4353
4710
  turn_count: 0,
4354
4711
  max_turns: state.max_turns,
4355
4712
  gathered_results: [],
4713
+ // Carry intake/driver markers through the reset — the claim
4714
+ // path may already have narrated this new question's intake.
4715
+ intake_announced: state.intake_announced,
4716
+ driver_id: state.driver_id,
4717
+ driver_heartbeat_at: state.driver_heartbeat_at,
4356
4718
  }
4357
4719
  thread.data = { ...(thread.data || {}), research_state: state }
4358
- await setResearchState(threadId, state, thread.data)
4720
+ await setResearchState(threadId, state, thread.data, config.leaseToken)
4359
4721
  }
4360
4722
 
4361
4723
  const maxTurns = promptConfig.metadata?.max_turns ?? (promptConfig.metadata?.max_tool_iterations ?? 5) + 2
@@ -4364,7 +4726,12 @@ export async function runAgent(
4364
4726
  // start and the first inference result (~5–10s of buildContext + LLM
4365
4727
  // call). Internal-only: activity trails render it, transcript filters
4366
4728
  // skip it. Fire-and-forget so it never adds latency to the step.
4367
- if (state.turn_count === 0 && state.status === 'planning') {
4729
+ // Skipped when the claim path already narrated this intake for the
4730
+ // current driver (claimAgentEpisode writes it synchronously so the
4731
+ // UI has progress even while a detached driver is CPU-throttled).
4732
+ const alreadyAnnounced = state.intake_announced === true
4733
+ && !!driverToken && state.driver_id === driverToken
4734
+ if (state.turn_count === 0 && state.status === 'planning' && !alreadyAnnounced) {
4368
4735
  void saveMessage(threadId, 'Reviewing your question…', 'plan', {
4369
4736
  turn_count: 0,
4370
4737
  phase: 'intake',
@@ -4386,8 +4753,9 @@ export async function runAgent(
4386
4753
  // duplicate final message. Calls carrying a userMessage are unaffected:
4387
4754
  // 'complete'/'failed' were reset to a fresh episode above and
4388
4755
  // 'awaiting_input' replies continue the pending clarification.
4389
- if (!currentMessage?.trim() &&
4756
+ if ((!currentMessage?.trim() || questionReplay) &&
4390
4757
  (state.status === 'complete' || state.status === 'awaiting_input' || state.status === 'failed')) {
4758
+ await releaseIfOwned(true)
4391
4759
  return lastMessage
4392
4760
  }
4393
4761
  if (mode === 'step') {
@@ -4403,7 +4771,9 @@ export async function runAgent(
4403
4771
  }, ctx, replyTypeSlug, 'external')
4404
4772
  state.status = 'failed'
4405
4773
  state.error_message = `Research did not complete after ${state.turn_count} turns`
4406
- await setResearchState(threadId, state, thread.data)
4774
+ state.completed_at = new Date().toISOString()
4775
+ await setResearchState(threadId, state, thread.data, config.leaseToken)
4776
+ await releaseIfOwned(true)
4407
4777
  return failMsg
4408
4778
  }
4409
4779
  const step = await runAgentStep(threadId, currentMessage, ctx, config)
@@ -4423,7 +4793,7 @@ export async function runAgent(
4423
4793
  if (!finalEscalated &&
4424
4794
  finalConfidence < (promptConfig.confidence_threshold || 0) &&
4425
4795
  finalEvidence !== 'evidence_found') {
4426
- await handleEscalation(threadId, finalConfidence, promptConfig, ctx)
4796
+ await handleEscalation(threadId, finalConfidence, promptConfig, ctx, 'low_confidence', config.leaseToken)
4427
4797
  }
4428
4798
  await emitAudit(ctx, 'agent.inference.completed', {
4429
4799
  type: 'agent_message',
@@ -4442,10 +4812,28 @@ export async function runAgent(
4442
4812
  confidence: final.data?.confidence
4443
4813
  })
4444
4814
  }
4815
+ await releaseIfOwned(
4816
+ state.status === 'complete' || state.status === 'awaiting_input' || state.status === 'failed'
4817
+ )
4445
4818
  return lastMessage
4446
4819
  }
4447
4820
 
4448
- for (let i = state.turn_count; i < maxTurns; i++) {
4821
+ // opts.deadlineMs bounds a complete-mode run inside a single request
4822
+ // (episode mode runs inline so the first turns execute at full
4823
+ // request CPU instead of on a CPU-throttled detached driver). On
4824
+ // expiry the episode keeps its lease and the caller's next step
4825
+ // request continues it — not a failure, so the max-turns tail below
4826
+ // is skipped.
4827
+ let budgetExceeded = false
4828
+ const loopStartTurn = state.turn_count
4829
+ for (let i = loopStartTurn; i < maxTurns; i++) {
4830
+ // Always run at least one step (progress guarantee), then stop
4831
+ // once the wall-clock budget is spent. state.turn_count advances
4832
+ // inside runAgentStep, so compare against the captured baseline.
4833
+ if (deadlineMs && i > loopStartTurn && Date.now() - startTime > deadlineMs) {
4834
+ budgetExceeded = true
4835
+ break
4836
+ }
4449
4837
  const step = await runAgentStep(threadId, currentMessage, ctx, config)
4450
4838
  lastMessage = step.message || lastMessage
4451
4839
  state = step.state
@@ -4455,6 +4843,13 @@ export async function runAgent(
4455
4843
  }
4456
4844
  currentMessage = ''
4457
4845
  }
4846
+ if (budgetExceeded &&
4847
+ state.status !== 'complete' && state.status !== 'awaiting_input' && state.status !== 'failed') {
4848
+ // Nonterminal partial run — keep the lease. The driver (episode
4849
+ // POST caller or a step-loop request) resumes with the same
4850
+ // driver_token, which renews the lease rather than double-driving.
4851
+ return lastMessage
4852
+ }
4458
4853
  if (state.status === 'complete') {
4459
4854
  const final = lastMessage || {}
4460
4855
  // Same as the step-mode gate above: prefer the step's own computed
@@ -4466,7 +4861,7 @@ export async function runAgent(
4466
4861
  if (!finalEscalated &&
4467
4862
  finalConfidence < (promptConfig.confidence_threshold || 0) &&
4468
4863
  finalEvidence !== 'evidence_found') {
4469
- await handleEscalation(threadId, finalConfidence, promptConfig, ctx)
4864
+ await handleEscalation(threadId, finalConfidence, promptConfig, ctx, 'low_confidence', config.leaseToken)
4470
4865
  }
4471
4866
  await emitAudit(ctx, 'agent.inference.completed', {
4472
4867
  type: 'agent_message',
@@ -4493,11 +4888,23 @@ export async function runAgent(
4493
4888
  }, ctx, replyTypeSlug, 'external')
4494
4889
  state.status = 'failed'
4495
4890
  state.error_message = `Research did not complete after ${state.turn_count} turns`
4496
- await setResearchState(threadId, state, thread.data)
4891
+ state.completed_at = new Date().toISOString()
4892
+ await setResearchState(threadId, state, thread.data, config.leaseToken)
4497
4893
  lastMessage = failMsg
4498
4894
  }
4895
+ // The complete-mode tail is always terminal (complete /
4896
+ // awaiting_input / failed-after-max-turns).
4897
+ await releaseIfOwned(true)
4499
4898
  return lastMessage
4500
4899
  } catch (error: any) {
4900
+ if (error instanceof EpisodeLeaseLost) {
4901
+ // A takeover displaced this driver mid-run — quiet abort, not an
4902
+ // inference failure; the new owner writes the terminal message.
4903
+ console.warn(`[${ctx.requestId}] ${error.message} — aborting run; takeover owner continues`)
4904
+ await releaseIfOwned(true)
4905
+ return lastMessage
4906
+ }
4907
+ await releaseIfOwned(true)
4501
4908
  await emitAudit(ctx, 'agent.inference.failed', {
4502
4909
  type: 'agent_message',
4503
4910
  id: 'failed',
@@ -4531,6 +4938,10 @@ export async function runAgent(
4531
4938
 
4532
4939
 
4533
4940
 
4941
+
4942
+
4943
+
4944
+
4534
4945
 
4535
4946
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:resolveAgentConfig:function" type="function" identity="resolveAgentConfig" parent="~/.framework/functions/_shared/agent-runner.ts"]
4536
4947
  /**
@@ -4730,6 +5141,10 @@ export async function resolveAgentConfig(
4730
5141
 
4731
5142
 
4732
5143
 
5144
+
5145
+
5146
+
5147
+
4733
5148
 
4734
5149
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeAgentInference:function" type="function" identity="executeAgentInference" parent="~/.framework/functions/_shared/agent-runner.ts"]
4735
5150
  /**
@@ -4836,6 +5251,10 @@ async function executeAgentInference(
4836
5251
 
4837
5252
 
4838
5253
 
5254
+
5255
+
5256
+
5257
+
4839
5258
 
4840
5259
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeCilChunkLookup:function" type="function" identity="executeCilChunkLookup" parent="~/.framework/functions/_shared/agent-runner.ts"]
4841
5260
  /**
@@ -4972,6 +5391,8 @@ async function executeCilChunkLookup(params: any, ctx: CoreContext, cilAccess: C
4972
5391
 
4973
5392
 
4974
5393
 
5394
+
5395
+
4975
5396
  /**
4976
5397
  * Fetches a document based on the provided document ID and context, ensuring it adheres to project restrictions if specified. It retrieves the document's content and metadata, handling various data structures and types.
4977
5398
  *
@@ -4984,6 +5405,8 @@ async function executeCilChunkLookup(params: any, ctx: CoreContext, cilAccess: C
4984
5405
  * @outputSpec Promise<any> — returns an object containing document metadata and content.
4985
5406
  * @throws Error — document_id is required
4986
5407
  Error — Document not found: {document_id}
5408
+
5409
+
4987
5410
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeCilFetchDocument:function" type="function" identity="executeCilFetchDocument" parent="~/.framework/functions/_shared/agent-runner.ts"]
4988
5411
  * @sideEffects Database read operations to fetch document data.
4989
5412
  * @stability stable
@@ -5070,6 +5493,10 @@ async function executeCilFetchDocument(params: any, ctx: CoreContext, cilAccess:
5070
5493
 
5071
5494
 
5072
5495
 
5496
+
5497
+
5498
+
5499
+
5073
5500
 
5074
5501
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeCilGetContract:function" type="function" identity="executeCilGetContract" parent="~/.framework/functions/_shared/agent-runner.ts"]
5075
5502
  /**
@@ -5158,6 +5585,10 @@ async function executeCilGetContract(params: any, ctx: CoreContext, cilAccess: C
5158
5585
 
5159
5586
 
5160
5587
 
5588
+
5589
+
5590
+
5591
+
5161
5592
 
5162
5593
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeCilBlastRadius:function" type="function" identity="executeCilBlastRadius" parent="~/.framework/functions/_shared/agent-runner.ts"]
5163
5594
  /**
@@ -5299,6 +5730,10 @@ async function executeCilBlastRadius(params: any, ctx: CoreContext, cilAccess: C
5299
5730
 
5300
5731
 
5301
5732
 
5733
+
5734
+
5735
+
5736
+
5302
5737
 
5303
5738
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeCilHashValidate:function" type="function" identity="executeCilHashValidate" parent="~/.framework/functions/_shared/agent-runner.ts"]
5304
5739
  /**
@@ -5378,6 +5813,10 @@ async function executeCilHashValidate(params: any, ctx: CoreContext, cilAccess:
5378
5813
 
5379
5814
 
5380
5815
 
5816
+
5817
+
5818
+
5819
+
5381
5820
 
5382
5821
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeCilProjectStatus:function" type="function" identity="executeCilProjectStatus" parent="~/.framework/functions/_shared/agent-runner.ts"]
5383
5822
  /**
@@ -5486,6 +5925,10 @@ async function executeCilProjectStatus(params: any, ctx: CoreContext, cilAccess:
5486
5925
 
5487
5926
 
5488
5927
 
5928
+
5929
+
5930
+
5931
+
5489
5932
 
5490
5933
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeSearchLogs:function" type="function" identity="executeSearchLogs" parent="~/.framework/functions/_shared/agent-runner.ts"]
5491
5934
  /**
@@ -5548,6 +5991,10 @@ async function executeSearchLogs(params: any, ctx: CoreContext): Promise<any> {
5548
5991
 
5549
5992
 
5550
5993
 
5994
+
5995
+
5996
+
5997
+
5551
5998
 
5552
5999
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:executeSearchObservability:function" type="function" identity="executeSearchObservability" parent="~/.framework/functions/_shared/agent-runner.ts"]
5553
6000
  /**
@@ -5612,6 +6059,10 @@ async function executeSearchObservability(params: any, ctx: CoreContext): Promis
5612
6059
 
5613
6060
 
5614
6061
 
6062
+
6063
+
6064
+
6065
+
5615
6066
 
5616
6067
  // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:EPISODE_DRIVER_LEASE_MS:constant" type="constant" identity="EPISODE_DRIVER_LEASE_MS" parent="~/.framework/functions/_shared/agent-runner.ts"]
5617
6068
  /**
@@ -5624,76 +6075,544 @@ async function executeSearchObservability(params: any, ctx: CoreContext): Promis
5624
6075
  const EPISODE_DRIVER_LEASE_MS = 60000
5625
6076
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:EPISODE_DRIVER_LEASE_MS:constant"]
5626
6077
 
5627
- // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:kickAgentEpisode:function" type="function" identity="kickAgentEpisode" parent="~/.framework/functions/_shared/agent-runner.ts"]
6078
+
6079
+
6080
+
6081
+
6082
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:EPISODE_LEASE_TTL_SECONDS:constant" type="constant" identity="EPISODE_LEASE_TTL_SECONDS" parent="~/.framework/functions/_shared/agent-runner.ts"]
6083
+ /**
6084
+ * EPISODE_LEASE_TTL_SECONDS is the database-lease TTL a driver holds per
6085
+ * episode. Heartbeats extend it every turn; it must exceed the slowest
6086
+ * single turn (config resolution + buildContext + one inference call —
6087
+ * cold starts observed at ~45s) or a healthy driver would be fenced by
6088
+ * its own expiry.
6089
+ *
6090
+ * @sideEffects none
6091
+ * @stability stable
6092
+ * @cil-generated
6093
+ */
6094
+ const EPISODE_LEASE_TTL_SECONDS = 90
6095
+ // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:EPISODE_LEASE_TTL_SECONDS:constant"]
6096
+
6097
+
6098
+
6099
+
6100
+
6101
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:EPISODE_DETACHED_STALE_SECONDS:constant" type="constant" identity="EPISODE_DETACHED_STALE_SECONDS" parent="~/.framework/functions/_shared/agent-runner.ts"]
6102
+ /**
6103
+ * Heartbeat age (seconds) after which a 'detached:' lease holder counts as
6104
+ * stalled and may be stolen by a request-bound driver. A healthy detached
6105
+ * driver heartbeats at every turn boundary — the window tolerates one slow
6106
+ * turn (~13s inference + tools observed) without letting a routine episode
6107
+ * POST displace it mid-flight.
6108
+ */
6109
+ const EPISODE_DETACHED_STALE_SECONDS = 30
6110
+ // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:EPISODE_DETACHED_STALE_SECONDS:constant"]
6111
+
6112
+
6113
+
6114
+
6115
+
6116
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:EpisodeLeaseLost:class" type="class" identity="EpisodeLeaseLost" parent="~/.framework/functions/_shared/agent-runner.ts"]
6117
+ /**
6118
+ * EpisodeLeaseLost is thrown when a driver's lease_token no longer owns
6119
+ * the thread's episode_lease — i.e. an expired-lease takeover displaced
6120
+ * it mid-run. runAgent treats it as a quiet abort (the new owner
6121
+ * produces the terminal message), not an inference failure.
6122
+ *
6123
+ * @stability stable
6124
+ * @cil-generated
6125
+ */
6126
+ class EpisodeLeaseLost extends Error {
6127
+ constructor(threadId: string) {
6128
+ super(`episode lease lost for thread ${threadId}`)
6129
+ this.name = 'EpisodeLeaseLost'
6130
+ }
6131
+ }
6132
+ // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:EpisodeLeaseLost:class"]
6133
+
6134
+
6135
+
6136
+
6137
+
6138
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:acquireEpisodeLease:function" type="function" identity="acquireEpisodeLease" parent="~/.framework/functions/_shared/agent-runner.ts"]
6139
+ /**
6140
+ * Atomically claims the episode lease for a thread via the
6141
+ * acquire_episode_lease RPC (migration 063). Returns the RPC verdict:
6142
+ * 'claimed' with a lease_token the caller must present to
6143
+ * heartbeat/release, or 'held' with the live holder's driver_id.
6144
+ * Returns null when the RPC is unavailable (migration not applied) so
6145
+ * callers can fall back to the legacy research_state check.
6146
+ *
6147
+ * @inputSpec
6148
+ * threadId: string (required) — the thread whose episode is claimed.
6149
+ * accountId: string (required) — owning account for the lease row.
6150
+ * driverId: string (required) — the claiming driver's token.
6151
+ * ttlSeconds: number (optional) — lease TTL, default EPISODE_LEASE_TTL_SECONDS.
6152
+ * stealDetached: boolean (optional) — request-bound drivers pass true so a
6153
+ * live 'detached:'-prefixed holder (spawned background work) yields
6154
+ * immediately instead of forcing the caller to watch a throttled driver.
6155
+ * @outputSpec Promise<{outcome, lease_token?, holder_driver_id?} | null>
6156
+ * @sideEffects Inserts or updates public.episode_leases.
6157
+ * @stability stable
6158
+ * @calls adminDb.rpc('acquire_episode_lease')
6159
+ * @cil-generated
6160
+ */
6161
+ export async function acquireEpisodeLease(
6162
+ threadId: string,
6163
+ accountId: string,
6164
+ driverId: string,
6165
+ ttlSeconds: number = EPISODE_LEASE_TTL_SECONDS,
6166
+ stealDetached = false,
6167
+ staleSeconds: number = EPISODE_DETACHED_STALE_SECONDS
6168
+ ): Promise<{ outcome: 'claimed' | 'held'; lease_token?: string; holder_driver_id?: string } | null> {
6169
+ const { data, error } = await adminDb.rpc('acquire_episode_lease', {
6170
+ p_thread_id: threadId,
6171
+ p_account_id: accountId,
6172
+ p_driver_id: driverId,
6173
+ p_ttl_seconds: ttlSeconds,
6174
+ p_steal_detached: stealDetached,
6175
+ p_stale_seconds: staleSeconds
6176
+ })
6177
+ if (error) {
6178
+ // Fail-open: deployments that haven't applied 063 yet behave exactly
6179
+ // as before (legacy research_state check-then-write path).
6180
+ console.warn(`[episode-lease] acquire_episode_lease unavailable for ${threadId}: ${error.message}`)
6181
+ return null
6182
+ }
6183
+ if (!data || (data.outcome !== 'claimed' && data.outcome !== 'held')) {
6184
+ // Malformed verdict (unexpected RPC shape) — same fail-open path so
6185
+ // a surprising response never silently grants or refuses a driver.
6186
+ console.warn(`[episode-lease] acquire_episode_lease malformed response for ${threadId}`)
6187
+ return null
6188
+ }
6189
+ return data as { outcome: 'claimed' | 'held'; lease_token?: string; holder_driver_id?: string }
6190
+ }
6191
+ // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:acquireEpisodeLease:function"]
6192
+
6193
+
6194
+
6195
+
6196
+
6197
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:heartbeatEpisodeLease:function" type="function" identity="heartbeatEpisodeLease" parent="~/.framework/functions/_shared/agent-runner.ts"]
6198
+ /**
6199
+ * Extends the held lease's TTL. Returns true when the token still owns
6200
+ * the lease, false when a takeover displaced this driver (the caller
6201
+ * must stop writing — that false is the write fence), and null when the
6202
+ * RPC is unavailable (fail-open so un-migrated deployments keep working).
6203
+ *
6204
+ * @inputSpec
6205
+ * threadId: string (required) — the leased thread.
6206
+ * leaseToken: string (required) — token returned by acquire_episode_lease.
6207
+ * @outputSpec Promise<boolean | null>
6208
+ * @sideEffects Updates public.episode_leases.expires_at.
6209
+ * @stability stable
6210
+ * @calls adminDb.rpc('heartbeat_episode_lease')
6211
+ * @cil-generated
6212
+ */
6213
+ async function heartbeatEpisodeLease(
6214
+ threadId: string,
6215
+ leaseToken: string
6216
+ ): Promise<boolean | null> {
6217
+ const { data, error } = await adminDb.rpc('heartbeat_episode_lease', {
6218
+ p_thread_id: threadId,
6219
+ p_lease_token: leaseToken,
6220
+ p_ttl_seconds: EPISODE_LEASE_TTL_SECONDS
6221
+ })
6222
+ if (error) {
6223
+ console.warn(`[episode-lease] heartbeat_episode_lease unavailable for ${threadId}: ${error.message}`)
6224
+ return null
6225
+ }
6226
+ // Only an explicit false is the takeover fence — malformed responses
6227
+ // fail open rather than aborting a healthy driver.
6228
+ return data === false ? false : data === true ? true : null
6229
+ }
6230
+ // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:heartbeatEpisodeLease:function"]
6231
+
6232
+
6233
+
6234
+
6235
+
6236
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:mergeThreadData:function" type="function" identity="mergeThreadData" parent="~/.framework/functions/_shared/agent-runner.ts"]
6237
+ /**
6238
+ * Atomically shallow-merges a patch into threads.data via the
6239
+ * merge_thread_data RPC (migration 066). Unlike the read-modify-write
6240
+ * whole-blob update it replaces, the merge cannot resurrect stale keys:
6241
+ * a writer holding a pre-checkpoint snapshot can no longer clobber a
6242
+ * terminal research_state another writer persisted meanwhile.
6243
+ *
6244
+ * When leaseToken is provided the RPC drops the write if a
6245
+ * differently-tokened lease holds the thread — a displaced driver's
6246
+ * stale checkpoint becomes a no-op instead of overwriting the takeover
6247
+ * owner's terminal state.
6248
+ *
6249
+ * Returns true when written, false when fenced (or the thread is gone),
6250
+ * and null when the RPC is unavailable so callers can fall back to the
6251
+ * legacy whole-blob update on un-migrated deployments.
6252
+ *
6253
+ * @inputSpec
6254
+ * threadId: string (required) — the thread to patch.
6255
+ * patch: Record<string, any> (required) — top-level data keys to merge.
6256
+ * leaseToken: string (optional) — fence the write to this lease holder.
6257
+ * @outputSpec Promise<boolean | null>
6258
+ * @sideEffects Updates public.threads.data / updated_at.
6259
+ * @stability stable
6260
+ * @calls adminDb.rpc('merge_thread_data')
6261
+ * @cil-generated
6262
+ */
6263
+ async function mergeThreadData(
6264
+ threadId: string,
6265
+ patch: Record<string, any>,
6266
+ leaseToken?: string
6267
+ ): Promise<boolean | null> {
6268
+ const { data, error } = await adminDb.rpc('merge_thread_data', {
6269
+ p_thread_id: threadId,
6270
+ p_patch: patch,
6271
+ p_lease_token: leaseToken ?? null
6272
+ })
6273
+ if (error) {
6274
+ console.warn(`[episode-lease] merge_thread_data unavailable for ${threadId}: ${error.message}`)
6275
+ return null
6276
+ }
6277
+ return data === true ? true : data === false ? false : null
6278
+ }
6279
+ // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:mergeThreadData:function"]
6280
+
6281
+
6282
+
6283
+
6284
+
6285
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:releaseEpisodeLease:function" type="function" identity="releaseEpisodeLease" parent="~/.framework/functions/_shared/agent-runner.ts"]
6286
+ /**
6287
+ * Releases the held lease on terminal state (complete/awaiting_input/
6288
+ * failed) or abort. Token-gated: a displaced driver's release returns
6289
+ * false, which is warn-only — the takeover owner controls the lease now.
6290
+ * Errors are swallowed (fail-open) so a missing migration never breaks
6291
+ * an otherwise successful episode.
6292
+ *
6293
+ * @inputSpec
6294
+ * threadId: string (required) — the leased thread.
6295
+ * leaseToken: string (required) — token returned by acquire_episode_lease.
6296
+ * @outputSpec Promise<void>
6297
+ * @sideEffects Deletes the public.episode_leases row when the token matches.
6298
+ * @stability stable
6299
+ * @calls adminDb.rpc('release_episode_lease')
6300
+ * @cil-generated
6301
+ */
6302
+ async function releaseEpisodeLease(
6303
+ threadId: string,
6304
+ leaseToken: string
6305
+ ): Promise<void> {
6306
+ const { data, error } = await adminDb.rpc('release_episode_lease', {
6307
+ p_thread_id: threadId,
6308
+ p_lease_token: leaseToken
6309
+ })
6310
+ if (error) {
6311
+ console.warn(`[episode-lease] release_episode_lease unavailable for ${threadId}: ${error.message}`)
6312
+ return
6313
+ }
6314
+ if (data !== true) {
6315
+ console.warn(`[episode-lease] release_episode_lease fenced for ${threadId} — lease already superseded`)
6316
+ }
6317
+ }
6318
+ // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:releaseEpisodeLease:function"]
6319
+
6320
+
6321
+
6322
+
6323
+
6324
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:isAnsweredQuestionReplay:function" type="function" identity="isAnsweredQuestionReplay" parent="~/.framework/functions/_shared/agent-runner.ts"]
6325
+ /**
6326
+ * Tells a re-delivered question from a genuine re-ask. A late claim can
6327
+ * carry the same userMessage text the just-finished episode already
6328
+ * answered — e.g. a queued triage kick claiming the freshly-released
6329
+ * lease after the request driver completed inline. Without the check the
6330
+ * claimant stamps fresh state and runs a whole second episode (the
6331
+ * duplicate escalate→final cycle observed in production).
6332
+ *
6333
+ * Verdict needs all three: terminal state (complete/failed), the carried
6334
+ * user_question text-identical to this message, and the latest inbound
6335
+ * row predating the terminal write — a same-text re-ask posts a NEWER
6336
+ * inbound, which is a new episode, not a replay.
6337
+ *
6338
+ * @inputSpec
6339
+ * threadId: string — the episode thread.
6340
+ * state: ResearchState | undefined — the persisted research_state.
6341
+ * userMessage: string — the message this claim/run carries.
6342
+ * @outputSpec Promise<boolean>
6343
+ * @sideEffects Reads messages (latest inbound) only when the cheap checks pass.
6344
+ * @stability stable
6345
+ * @calls adminDb.from('messages')
6346
+ * @cil-generated
6347
+ */
6348
+ async function isAnsweredQuestionReplay(
6349
+ threadId: string,
6350
+ state: ResearchState | undefined,
6351
+ userMessage: string
6352
+ ): Promise<boolean> {
6353
+ const question = (userMessage || '').trim()
6354
+ if (!question || !state) return false
6355
+ if (state.status !== 'complete' && state.status !== 'failed') return false
6356
+ if ((state.user_question || '').trim() !== question) return false
6357
+ const terminalAt = Date.parse(state.completed_at || state.driver_heartbeat_at || '')
6358
+ const { data: lastInbound } = await adminDb
6359
+ .from('messages')
6360
+ .select('created_at')
6361
+ .eq('thread_id', threadId)
6362
+ .eq('direction', 'inbound')
6363
+ .order('created_at', { ascending: false })
6364
+ .limit(1)
6365
+ const inboundAt = Date.parse((Array.isArray(lastInbound) ? lastInbound[0] : lastInbound)?.created_at || '')
6366
+ // Unbounded comparisons can't prove a replay — run the episode rather
6367
+ // than risk suppressing a real question. A 2s skew covers clock drift
6368
+ // between the inbound insert and the terminal write in the same txn.
6369
+ return Number.isFinite(inboundAt) && Number.isFinite(terminalAt) && inboundAt <= terminalAt + 2000
6370
+ }
6371
+ // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:isAnsweredQuestionReplay:function"]
6372
+
6373
+
6374
+
6375
+
6376
+
6377
+
6378
+
6379
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:isHumanOwned:function" type="function" identity="isHumanOwned" parent="~/.framework/functions/_shared/agent-runner.ts"]
6380
+ // CIL[DOC-LEARNING topic="human_owner_gate" content="thread.data.owner:'human' is THE human/agent gate — while set, no agent episode may be claimed, narrated, or run. It is semantic ownership, orthogonal to the episode lease (who may write now). Both must hold for agent work: a free lease alone is not permission. The gate exists because serverless invocations freeze and resume into a changed world — a driver resuming after handoff found the lease free and re-escalated a thread waiting on a human (prod, Oct 2026)."]
6381
+ // CIL[DOC-NOTE type="caution" content="Core must NEVER gate on app-level item statuses (human_assigned, pending_resolution, human_action...) — core is domain-agnostic. Apps translate their workflow into the framework signal by stamping threads.data.owner:'human' themselves. The only core writer is handleEscalation."]
6382
+ /**
6383
+ * The human/agent ownership gate. `thread.data.owner === 'human'` means a
6384
+ * person owns this conversation and NO agent episode may be claimed,
6385
+ * narrated, or stepped — regardless of lease state. This is a semantic
6386
+ * gate, not a concurrency gate: the lease answers "who may write right
6387
+ * now", this answers "should any agent act at all".
6388
+ *
6389
+ * Writers:
6390
+ * - handleEscalation (escalate_to_human tool) — the agent hands off.
6391
+ * - App code — e.g. a human-reply or assign-to-me path stamping
6392
+ * owner:'human' on the thread when a person takes over.
6393
+ * Absence of the key means the thread is agent-eligible. The flag is never
6394
+ * auto-cleared: once a human owns a thread it stays human-owned (human-to-
6395
+ * human semantics); a new conversation means a new thread with fresh data.
6396
+ *
6397
+ * 'escalation_status: pending' is the pre-rename key — reads accept it so
6398
+ * rows written before the rename still gate correctly. New writes only
6399
+ * set 'owner'.
6400
+ *
6401
+ * @inputSpec
6402
+ * threadData: any — threads.data contents.
6403
+ * @outputSpec boolean — true when a human owns the thread.
6404
+ * @stability stable
6405
+ * @cil-generated
6406
+ */
6407
+ function isHumanOwned(threadData: any): boolean {
6408
+ return threadData?.owner === 'human' || threadData?.escalation_status === 'pending'
6409
+ }
6410
+ // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:isHumanOwned:function"]
6411
+
6412
+
6413
+
6414
+
6415
+
6416
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:claimAgentEpisode:function" type="function" identity="claimAgentEpisode" parent="~/.framework/functions/_shared/agent-runner.ts"]
5628
6417
  /**
5629
- * Initiates a new episode for an agent by updating the thread's state and running the agent with the provided user message. This function ensures that the agent is not already active before claiming the lease.
6418
+ * Atomically claims an episode for a driver: acquires the episode lease,
6419
+ * stamps research_state via the fenced merge RPC, and writes the intake
6420
+ * narration synchronously so the client has visible progress within the
6421
+ * request — not whenever a throttled detached driver gets CPU.
5630
6422
  *
5631
- * @macro Async function that manages agent episode initiation and state management.
5632
- * @micro Checks for an existing lease and updates the thread's state in the database if no active lease is found.
6423
+ * @macro Shared claim primitive for all episode entry paths.
6424
+ * @micro RPC claim → fenced research_state merge → synchronous intake narration → returns lease_token for the caller's run.
5633
6425
  * @inputSpec
5634
6426
  * threadId: string (required) — the unique identifier for the thread.
5635
6427
  * userMessage: string (required) — the message from the user to the agent.
5636
6428
  * ctx: CoreContext (required) — the context for the current request.
5637
6429
  * leaseId: string (required) — the identifier for the lease holder.
5638
6430
  * threadData: any (optional) — optional data associated with the thread.
5639
- * @outputSpec Promise<{ claimed: boolean; driver_id?: string }> — indicates whether the episode was claimed and optionally the driver ID.
5640
- * @sideEffects Writes to the database to update the thread's state and initiates an agent run.
6431
+ * opts.driverPrefix: string (optional) — e.g. 'detached:' marks stealable background drivers.
6432
+ * opts.stealDetached: boolean (optional) — take over a live 'detached:' holder.
6433
+ * @outputSpec Promise<{ claimed: boolean; driver_id?: string; lease_token?: string }> — claimed + the resolved driver id and lease token; on refusal driver_id is the live holder.
6434
+ * @sideEffects Writes episode_leases + threads.data.research_state + an intake 'plan' message.
5641
6435
  * @stability stable
5642
- * @calls adminDb.from('threads'), adminDb.update, runAgent
5643
- * @errorHandling logs errors from the agent run without throwing.
6436
+ * @calls acquireEpisodeLease, mergeThreadData, saveMessage
5644
6437
  * @cil-generated
5645
6438
  */
5646
- export async function kickAgentEpisode(
6439
+ export async function claimAgentEpisode(
5647
6440
  threadId: string,
5648
6441
  userMessage: string,
5649
6442
  ctx: CoreContext,
5650
6443
  leaseId: string,
5651
- threadData?: any
5652
- ): Promise<{ claimed: boolean; driver_id?: string }> {
5653
- let data = threadData
5654
- if (data === undefined) {
5655
- const { data: row } = await adminDb
5656
- .from('threads')
5657
- .select('data')
5658
- .eq('id', threadId)
5659
- .single()
5660
- data = row?.data || {}
6444
+ threadData?: any,
6445
+ opts?: { driverPrefix?: string; stealDetached?: boolean }
6446
+ ): Promise<{ claimed: boolean; driver_id?: string; lease_token?: string; already_done?: boolean }> {
6447
+ // CIL[DOC-NOTE type="ordering" content="The human-owner gate runs BEFORE the lease claim and from a fresh threads read — never the caller's threadData snapshot, which a frozen caller may have fetched before the handoff. Order matters: checking after claim would write an episode lease + narrate on a human-owned thread."]
6448
+ // Human-owner gate FIRST — before any lease or narration work, and always
6449
+ // from a fresh read rather than the caller's threadData snapshot: a
6450
+ // serverless invocation frozen between the caller's fetch and this claim
6451
+ // resumes into a world that may already be human-owned (observed in prod:
6452
+ // a frozen episode POST resumed after escalation, minted a second
6453
+ // episode, re-searched, and re-escalated a thread waiting on a human).
6454
+ const { data: ownerRow } = await adminDb
6455
+ .from('threads')
6456
+ .select('data')
6457
+ .eq('id', threadId)
6458
+ .single()
6459
+ if (isHumanOwned(ownerRow?.data)) {
6460
+ return { claimed: false, already_done: true }
6461
+ }
6462
+
6463
+ // Atomic claim — the episode_leases row carries a heartbeat from
6464
+ // this point on, so stale-episode detectors see a live driver through
6465
+ // the whole kick prelude (rate limit, injection scan, config resolve)
6466
+ // instead of declaring the episode dead at ~20s of silence.
6467
+ const driverId = (opts?.driverPrefix || '') + leaseId
6468
+ const claim = ctx.accountId
6469
+ ? await acquireEpisodeLease(threadId, ctx.accountId, driverId, EPISODE_LEASE_TTL_SECONDS, opts?.stealDetached ?? false)
6470
+ : null
6471
+ if (claim?.outcome === 'held') {
6472
+ return { claimed: false, driver_id: claim.holder_driver_id }
5661
6473
  }
6474
+
6475
+ // Prefer the fresh owner-read over a caller-provided snapshot — it can
6476
+ // only be newer, and state/agent fields live on the same column.
6477
+ const data = ownerRow?.data || threadData || {}
5662
6478
  const existingState = data?.research_state
5663
6479
  const heartbeatAt = Date.parse(existingState?.driver_heartbeat_at || '')
5664
6480
  const leaseHolder = existingState?.driver_id
5665
6481
  const leaseLive =
5666
6482
  !!leaseHolder &&
5667
- leaseHolder !== leaseId &&
6483
+ leaseHolder !== driverId &&
5668
6484
  !!existingState?.status &&
5669
6485
  !['complete', 'awaiting_input', 'failed'].includes(existingState.status) &&
5670
6486
  Number.isFinite(heartbeatAt) &&
5671
6487
  Date.now() - heartbeatAt < EPISODE_DRIVER_LEASE_MS
5672
6488
 
5673
- if (leaseLive) return { claimed: false, driver_id: leaseHolder }
5674
-
5675
- await adminDb
5676
- .from('threads')
5677
- .update({
5678
- data: {
5679
- ...(data || {}),
5680
- research_state: {
5681
- status: 'planning',
5682
- turn_count: 0,
5683
- max_turns: 5,
5684
- gathered_results: [],
5685
- ...(existingState || {}),
5686
- driver_id: leaseId,
5687
- driver_heartbeat_at: new Date().toISOString(),
6489
+ // claim === null means the RPC isn't deployed — fall back to the
6490
+ // legacy non-atomic check so un-migrated projects keep working.
6491
+ if (claim === null && leaseLive) {
6492
+ return { claimed: false, driver_id: leaseHolder }
6493
+ }
6494
+ // A claimed DB lease wins over a stale research_state mirror: another
6495
+ // caller can hold the JSON lease marker while the atomic lease is
6496
+ // free (e.g. it crashed before stamping) — the RPC verdict is
6497
+ // authoritative, this check only filters the legacy fallback above.
6498
+
6499
+ const leaseToken = claim?.lease_token
6500
+
6501
+ // Replay guard: a late claim carrying the question the episode already
6502
+ // answered (queued kick claiming the just-released lease) must not
6503
+ // stamp fresh state, narrate, or spawn a second episode — it releases
6504
+ // and reports already_done so the caller returns the existing result.
6505
+ if (await isAnsweredQuestionReplay(threadId, existingState, userMessage)) {
6506
+ if (leaseToken) await releaseEpisodeLease(threadId, leaseToken)
6507
+ return { claimed: false, already_done: true, driver_id: driverId }
6508
+ }
6509
+ const stampedState = {
6510
+ status: 'planning',
6511
+ turn_count: 0,
6512
+ max_turns: 5,
6513
+ gathered_results: [],
6514
+ ...(existingState || {}),
6515
+ driver_id: driverId,
6516
+ driver_heartbeat_at: new Date().toISOString(),
6517
+ // The intake narration below was written for this driver — the
6518
+ // runAgent narration gate keys on driver_id so a same-driver rerun
6519
+ // never double-narrates and a different driver narrates fresh.
6520
+ intake_announced: true,
6521
+ }
6522
+ // Atomic merge — a whole-blob write here could clobber sibling keys a
6523
+ // concurrent writer set between our read and update.
6524
+ const merged = await mergeThreadData(threadId, { research_state: stampedState }, leaseToken)
6525
+ if (merged === false) {
6526
+ // Stolen between claim and stamp — the new owner drives now.
6527
+ return { claimed: false }
6528
+ }
6529
+ if (merged === null) {
6530
+ const { error: stampErr } = await adminDb
6531
+ .from('threads')
6532
+ .update({
6533
+ data: {
6534
+ ...(data || {}),
6535
+ research_state: stampedState,
5688
6536
  },
5689
- },
5690
- })
5691
- .eq('id', threadId)
6537
+ })
6538
+ .eq('id', threadId)
6539
+ if (stampErr) console.error(`claimAgentEpisode research_state stamp failed for ${threadId}:`, stampErr.message)
6540
+ }
6541
+
6542
+ // Intake narration is written synchronously inside the request — the
6543
+ // client sees progress within ~1s instead of waiting on a detached
6544
+ // driver whose first message can take 30s+ under CPU throttling.
6545
+ // Skipped when continuing an already-announced live episode (a steal
6546
+ // takeover re-claims mid-episode — re-narrating would double the
6547
+ // intake message). Failure is cosmetic: warn-only so narration never
6548
+ // fails the claim.
6549
+ const isContinuation = !!existingState
6550
+ && !['complete', 'awaiting_input', 'failed'].includes(existingState.status)
6551
+ && existingState.intake_announced === true
6552
+ if (!isContinuation) {
6553
+ try {
6554
+ await saveMessage(threadId, 'Reviewing your question…', 'plan', {
6555
+ turn_count: 0,
6556
+ phase: 'intake',
6557
+ agent_id: data?.agent_id || null,
6558
+ prompt_config_id: data?.prompt_config_id || null
6559
+ }, ctx, 'message', 'internal')
6560
+ } catch (narrErr: any) {
6561
+ console.warn(`[${ctx.requestId}] intake narration failed for thread ${threadId}:`, narrErr?.message)
6562
+ }
6563
+ }
6564
+ return { claimed: true, driver_id: driverId, lease_token: leaseToken }
6565
+ }
6566
+ // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:claimAgentEpisode:function"]
6567
+
6568
+
6569
+
6570
+
6571
+
6572
+ // CIL[BEGIN stable_id="~/.framework/functions/_shared/agent-runner.ts:kickAgentEpisode:function" type="function" identity="kickAgentEpisode" parent="~/.framework/functions/_shared/agent-runner.ts"]
6573
+ /**
6574
+ * Claims the episode and spawns a detached complete-mode driver — the
6575
+ * fallback path for episodes with no live client (ticket-creation kicks,
6576
+ * trigger-fired starts). The 'detached:' driver prefix lets a
6577
+ * request-bound caller steal the lease instantly rather than waiting on
6578
+ * CPU-throttled background work.
6579
+ *
6580
+ * @micro claimAgentEpisode(driverPrefix 'detached:') then void runAgent(complete).
6581
+ * @inputSpec
6582
+ * threadId: string (required) — the unique identifier for the thread.
6583
+ * userMessage: string (required) — the message from the user to the agent.
6584
+ * ctx: CoreContext (required) — the context for the current request.
6585
+ * leaseId: string (required) — the identifier for the lease holder.
6586
+ * threadData: any (optional) — optional data associated with the thread.
6587
+ * @outputSpec Promise<{ claimed: boolean; driver_id?: string }> — indicates whether the episode was claimed and optionally the driver ID.
6588
+ * @sideEffects Writes to the database to update the thread's state and initiates an agent run.
6589
+ * @stability stable
6590
+ * @calls claimAgentEpisode, runAgent
6591
+ * @errorHandling logs errors from the agent run without throwing.
6592
+ * @cil-generated
6593
+ */
6594
+ export async function kickAgentEpisode(
6595
+ threadId: string,
6596
+ userMessage: string,
6597
+ ctx: CoreContext,
6598
+ leaseId: string,
6599
+ threadData?: any
6600
+ ): Promise<{ claimed: boolean; driver_id?: string; already_done?: boolean }> {
6601
+ // 'detached:' marks the holder as spawned background work — a
6602
+ // request-bound driver claiming with steal_detached takes the lease
6603
+ // over immediately instead of waiting out a throttled driver, and the
6604
+ // detached run is fenced off at its next heartbeat.
6605
+ const claim = await claimAgentEpisode(threadId, userMessage, ctx, leaseId, threadData, { driverPrefix: 'detached:' })
6606
+ if (!claim.claimed) {
6607
+ return { claimed: false, driver_id: claim.driver_id, already_done: claim.already_done }
6608
+ }
5692
6609
 
5693
- void runAgent(threadId, userMessage, ctx, 'complete', leaseId).catch(err =>
6610
+ void runAgent(threadId, userMessage, ctx, 'complete', claim.driver_id, claim.lease_token).catch(err =>
5694
6611
  console.error(`[${ctx.requestId}] detached episode run failed for thread ${threadId}:`, err)
5695
6612
  )
5696
- return { claimed: true }
6613
+ return { claimed: true, driver_id: claim.driver_id }
5697
6614
  }
5698
6615
  // CIL[END stable_id="~/.framework/functions/_shared/agent-runner.ts:kickAgentEpisode:function"]
5699
6616
 
6617
+
6618
+