@agent-native/core 0.220.0-nightly-20261024033422 → 0.220.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/a2a/handlers.js +7 -6
  2. package/dist/a2a/types.d.ts +1 -0
  3. package/dist/agent/engine/tool-call-journal-seed.js +24 -20
  4. package/dist/agent/execution-fence.d.ts +1 -1
  5. package/dist/agent/execution-fence.js +8 -1
  6. package/dist/agent/production-agent.js +31 -6
  7. package/dist/agent/reaped-run-thread.js +38 -3
  8. package/dist/agent/run-migrations.js +22 -0
  9. package/dist/agent/run-store.d.ts +19 -2
  10. package/dist/agent/run-store.fencing.worker.js +21 -1
  11. package/dist/agent/run-store.js +359 -35
  12. package/dist/agent/tool-approval-store.d.ts +13 -0
  13. package/dist/agent/tool-approval-store.js +64 -0
  14. package/dist/automation/index.d.ts +1 -1
  15. package/dist/client/background-agent-session.d.ts +4 -0
  16. package/dist/client/background-agent-session.js +17 -1
  17. package/dist/client/chat/runtime.js +38 -6
  18. package/dist/collab/routes.d.ts +2 -2
  19. package/dist/db/schema-snapshot.generated.js +14 -1
  20. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  21. package/dist/jobs/automation-recovery.d.ts +4 -0
  22. package/dist/jobs/automation-recovery.js +7 -0
  23. package/dist/jobs/background-automation-runner.js +19 -2
  24. package/dist/jobs/scheduler.js +3 -1
  25. package/dist/mcp/org-directory.d.ts +8 -7
  26. package/dist/mcp/org-directory.js +82 -1
  27. package/dist/notifications/routes.d.ts +1 -1
  28. package/dist/observability/routes.d.ts +7 -7
  29. package/dist/progress/routes.d.ts +1 -1
  30. package/dist/resources/handlers.d.ts +2 -2
  31. package/dist/secrets/routes.d.ts +10 -10
  32. package/dist/server/agent-chat-plugin.d.ts +10 -1
  33. package/dist/server/agent-chat-plugin.js +178 -11
  34. package/dist/server/agent-discovery.js +11 -49
  35. package/dist/server/agent-engine-default-model-route.d.ts +1 -1
  36. package/dist/server/agent-engine-ollama-models-route.d.ts +2 -2
  37. package/dist/usage/store.js +36 -33
  38. package/package.json +4 -4
@@ -1,6 +1,6 @@
1
1
  import { MAX_BACKGROUND_RUN_CONTINUATIONS, TURN_RUN_LEDGER_SLACK, } from "../app-config/run-lifecycle-invariants.js";
2
2
  import { isArtifactReceipt, } from "../artifacts/detect.js";
3
- import { getDbExec } from "../db/client.js";
3
+ import { getDbExec, getScopedTransactionDbExec } from "../db/client.js";
4
4
  import { ensureColumnExists, ensureTableExists } from "../db/ddl-guard.js";
5
5
  import { defineStore } from "../db/store-registry.js";
6
6
  import { fromMigrationEntries } from "../db/versioned-store-migrations.js";
@@ -192,7 +192,9 @@ export const agentRunsStore = defineStore({
192
192
  dispatch_payload TEXT,
193
193
  peak_rss_mb BIGINT,
194
194
  continuation_order BIGINT,
195
- thread_projection_pending BOOLEAN NOT NULL DEFAULT FALSE
195
+ thread_projection_pending BOOLEAN NOT NULL DEFAULT FALSE,
196
+ thread_projection_retry_order BIGINT,
197
+ thread_projection_error_code TEXT
196
198
  )
197
199
  `;
198
200
  const agentRunEventsCreateSql = `
@@ -257,6 +259,8 @@ export const agentRunsStore = defineStore({
257
259
  ["continuation_order", "BIGINT"],
258
260
  ["auto_continue_of", "TEXT"],
259
261
  ["thread_projection_pending", "BOOLEAN NOT NULL DEFAULT FALSE"],
262
+ ["thread_projection_retry_order", "BIGINT"],
263
+ ["thread_projection_error_code", "TEXT"],
260
264
  ]) {
261
265
  await ensureColumnExists("agent_runs", col, `ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS ${col} ${colType}`);
262
266
  }
@@ -286,6 +290,22 @@ export const agentRunsStore = defineStore({
286
290
  },
287
291
  },
288
292
  ...fromMigrationEntries(AGENT_RUN_MIGRATIONS_TABLE, AGENT_RUN_MIGRATIONS),
293
+ {
294
+ name: "thread-projection-retry-quarantine-runtime-schema",
295
+ run: async (exec) => {
296
+ await exec.execute({
297
+ sql: "ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS thread_projection_error_code TEXT",
298
+ });
299
+ await exec.execute({
300
+ sql: "CREATE SEQUENCE IF NOT EXISTS agent_run_projection_retry_order_seq",
301
+ });
302
+ await exec.execute({
303
+ sql: `CREATE INDEX IF NOT EXISTS agent_runs_thread_projection_retry_order_idx
304
+ ON agent_runs (thread_projection_retry_order ASC NULLS FIRST, completed_at ASC, id ASC)
305
+ WHERE thread_projection_pending IS TRUE`,
306
+ });
307
+ },
308
+ },
289
309
  ],
290
310
  });
291
311
  export function ensureRunTables() {
@@ -613,6 +633,101 @@ export async function markAgentExecutionStepDispatched(intent) {
613
633
  throw new Error(`Execution step ${intent.stepId} was not prepared`);
614
634
  }
615
635
  }
636
+ export async function resetAgentExecutionStepBeforeInvocation(intent) {
637
+ await ensureRunTables();
638
+ const expectedFence = {
639
+ threadId: intent.threadId,
640
+ turnId: intent.turnId,
641
+ runId: intent.runId,
642
+ generation: intent.generation,
643
+ };
644
+ try {
645
+ const result = await withAgentExecutionStepIntentFence(intent, (tx) => tx.execute({
646
+ sql: `UPDATE agent_execution_steps
647
+ SET status = 'prepared', dispatched_at = NULL,
648
+ updated_at = clock_timestamp()
649
+ WHERE thread_id = ? AND turn_id = ? AND step_id = ?
650
+ AND run_id = ? AND generation = CAST(? AS BIGINT)
651
+ AND status IN ('prepared', 'dispatched')`,
652
+ args: [
653
+ intent.threadId,
654
+ intent.turnId,
655
+ intent.stepId,
656
+ intent.runId,
657
+ intent.generation,
658
+ ],
659
+ }));
660
+ if (result.rowsAffected !== 1) {
661
+ throw new Error(`Execution step ${intent.stepId} was not reset before invocation`);
662
+ }
663
+ return;
664
+ }
665
+ catch (error) {
666
+ if (!(error instanceof StaleExecutionFenceError))
667
+ throw error;
668
+ const activeFence = currentExecutionFence();
669
+ if (!activeFence ||
670
+ activeFence.threadId !== expectedFence.threadId ||
671
+ activeFence.turnId !== expectedFence.turnId ||
672
+ activeFence.runId !== expectedFence.runId ||
673
+ activeFence.generation !== expectedFence.generation) {
674
+ throw error;
675
+ }
676
+ }
677
+ const reconcileWithCurrentLease = async (tx) => {
678
+ await lockAndAssertCurrentExecutionFences(tx, expectedFence);
679
+ const currentLease = await lockTurnExecutionLease(tx, intent.threadId, intent.turnId);
680
+ if (!currentLease?.isLive) {
681
+ throw new StaleExecutionFenceError(expectedFence);
682
+ }
683
+ const currentOwner = await tx.execute({
684
+ sql: `SELECT status, worker_generation FROM agent_runs
685
+ WHERE id = ? FOR UPDATE`,
686
+ args: [currentLease.ownerRunId],
687
+ });
688
+ const owner = currentOwner.rows[0];
689
+ const currentFence = {
690
+ threadId: intent.threadId,
691
+ turnId: intent.turnId,
692
+ runId: currentLease.ownerRunId,
693
+ generation: currentLease.generation,
694
+ };
695
+ if (owner?.status !== "running" ||
696
+ String(owner.worker_generation) !== currentLease.generation) {
697
+ throw new StaleExecutionFenceError(currentFence);
698
+ }
699
+ const reconciled = await tx.execute({
700
+ sql: `UPDATE agent_execution_steps
701
+ SET status = 'prepared', run_id = ?,
702
+ generation = CAST(? AS BIGINT), dispatched_at = NULL,
703
+ updated_at = clock_timestamp()
704
+ WHERE thread_id = ? AND turn_id = ? AND step_id = ?
705
+ AND run_id = ? AND generation = CAST(? AS BIGINT)
706
+ AND status IN ('prepared', 'dispatched', 'unknown')`,
707
+ args: [
708
+ currentLease.ownerRunId,
709
+ currentLease.generation,
710
+ intent.threadId,
711
+ intent.turnId,
712
+ intent.stepId,
713
+ intent.runId,
714
+ intent.generation,
715
+ ],
716
+ });
717
+ if (reconciled.rowsAffected !== 1) {
718
+ throw new Error(`Execution step ${intent.stepId} was not reconciled under the current lease`);
719
+ }
720
+ };
721
+ const client = getDbExec();
722
+ if (getScopedTransactionDbExec() === client) {
723
+ await reconcileWithCurrentLease(client);
724
+ return;
725
+ }
726
+ if (!client.transaction) {
727
+ throw new Error("Execution step reconciliation requires transaction support");
728
+ }
729
+ await client.transaction(reconcileWithCurrentLease);
730
+ }
616
731
  export async function completeAgentExecutionStep(intent, resultJson, errorJson) {
617
732
  await ensureRunTables();
618
733
  const result = await withFencedExecutionWrite((tx) => tx.execute({
@@ -714,16 +829,28 @@ export async function getCompletedAgentExecutionSteps(threadId, turnId, stepIds)
714
829
  AND step_id IN (${batch.map(() => "?").join(", ")})`,
715
830
  args: [threadId, turnId, ...batch],
716
831
  });
717
- for (const raw of rows.rows) {
832
+ for (const [batchRowIndex, raw] of rows.rows.entries()) {
833
+ const rowIndex = offset + batchRowIndex;
834
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
835
+ throw new AgentRunJournalUnreadableError(threadId, turnId, rowIndex, "invalid_execution_step", {
836
+ cause: new Error("Completed execution-step lookup returned an invalid row"),
837
+ });
838
+ }
718
839
  const row = raw;
719
840
  if (typeof row.step_id !== "string" || !batch.includes(row.step_id)) {
720
- throw new Error("Completed execution-step lookup returned an invalid step id");
841
+ throw new AgentRunJournalUnreadableError(threadId, turnId, rowIndex, "invalid_execution_step", {
842
+ cause: new Error("Completed execution-step lookup returned an invalid step id"),
843
+ });
721
844
  }
722
845
  if (typeof row.operation_id !== "string" ||
846
+ row.operation_id.trim().length === 0 ||
723
847
  typeof row.tool_name !== "string" ||
848
+ row.tool_name.trim().length === 0 ||
724
849
  typeof row.input_json !== "string" ||
725
850
  typeof row.result_json !== "string") {
726
- throw new Error(`Completed execution step ${row.step_id} has an incomplete stored intent or result`);
851
+ throw new AgentRunJournalUnreadableError(threadId, turnId, rowIndex, "invalid_execution_step", {
852
+ cause: new Error(`Completed execution step ${row.step_id} has an incomplete stored intent or result`),
853
+ });
727
854
  }
728
855
  completed.push({
729
856
  stepId: row.step_id,
@@ -986,7 +1113,7 @@ async function lockContinuationOrder(db, threadId, turnId) {
986
1113
  async function nextContinuationOrder(db, threadId, turnId) {
987
1114
  await lockContinuationOrder(db, threadId, turnId);
988
1115
  const { rows } = await db.execute({
989
- sql: `SELECT MAX(continuation_order) AS max_order
1116
+ sql: `SELECT MAX(COALESCE(continuation_order, 0)) AS max_order
990
1117
  FROM agent_runs
991
1118
  WHERE thread_id = ? AND COALESCE(turn_id, id) = ?`,
992
1119
  args: [threadId, turnId],
@@ -1056,6 +1183,31 @@ function terminalRunEventExclusionSql(runIdColumn = "id") {
1056
1183
  function livenessBasisSql() {
1057
1184
  return `(CASE WHEN COALESCE(last_progress_at, started_at) > COALESCE(heartbeat_at, started_at) THEN COALESCE(last_progress_at, started_at) ELSE COALESCE(heartbeat_at, started_at) END)`;
1058
1185
  }
1186
+ function earlierTurnProjectionPendingSql() {
1187
+ return `NOT EXISTS (
1188
+ SELECT 1 FROM agent_runs pending_projection
1189
+ WHERE pending_projection.thread_id = agent_runs.thread_id
1190
+ AND COALESCE(pending_projection.turn_id, pending_projection.id) =
1191
+ COALESCE(agent_runs.turn_id, agent_runs.id)
1192
+ AND pending_projection.id <> agent_runs.id
1193
+ AND pending_projection.thread_projection_pending IS TRUE
1194
+ AND (
1195
+ COALESCE(pending_projection.continuation_order, 0) <
1196
+ COALESCE(agent_runs.continuation_order, 0)
1197
+ OR (
1198
+ COALESCE(pending_projection.continuation_order, 0) =
1199
+ COALESCE(agent_runs.continuation_order, 0)
1200
+ AND (
1201
+ pending_projection.started_at < agent_runs.started_at
1202
+ OR (
1203
+ pending_projection.started_at = agent_runs.started_at
1204
+ AND pending_projection.id < agent_runs.id
1205
+ )
1206
+ )
1207
+ )
1208
+ )
1209
+ )`;
1210
+ }
1059
1211
  export async function claimBackgroundRun(runId) {
1060
1212
  await ensureRunTables();
1061
1213
  const client = getDbExec();
@@ -1064,7 +1216,8 @@ export async function claimBackgroundRun(runId) {
1064
1216
  SET dispatch_mode = 'background-processing'
1065
1217
  WHERE id = ?
1066
1218
  AND status = 'running'
1067
- AND dispatch_mode = 'background'`,
1219
+ AND dispatch_mode = 'background'
1220
+ AND ${earlierTurnProjectionPendingSql()}`,
1068
1221
  args: [runId],
1069
1222
  });
1070
1223
  return (rowsAffected ?? 0) > 0;
@@ -1115,6 +1268,7 @@ export async function listUnclaimedBackgroundRunIds() {
1115
1268
  sql: `SELECT id FROM agent_runs
1116
1269
  WHERE status = 'running'
1117
1270
  AND dispatch_mode = 'background'
1271
+ AND ${earlierTurnProjectionPendingSql()}
1118
1272
  AND COALESCE(heartbeat_at, started_at) < (CAST(? AS BIGINT) - ${UNCLAIMED_BACKGROUND_RUN_GRACE_MS})`,
1119
1273
  args: [Date.now()],
1120
1274
  });
@@ -1140,6 +1294,7 @@ export async function listUnclaimedBackgroundRunRows(options) {
1140
1294
  sql: `SELECT id, started_at, (dispatch_payload IS NOT NULL) AS has_dispatch_payload FROM agent_runs
1141
1295
  WHERE status = 'running'
1142
1296
  AND dispatch_mode = 'background'
1297
+ AND ${earlierTurnProjectionPendingSql()}
1143
1298
  AND COALESCE(heartbeat_at, started_at) < (CAST(? AS BIGINT) - ${UNCLAIMED_BACKGROUND_RUN_GRACE_MS})
1144
1299
  ORDER BY COALESCE(heartbeat_at, started_at) ASC, started_at ASC
1145
1300
  LIMIT ${limit}`,
@@ -1391,6 +1546,15 @@ export async function tryClaimRunSlot(threadId, runId, maxStaleMs, options) {
1391
1546
  };
1392
1547
  }
1393
1548
  }
1549
+ if (await options?.hasPendingApproval?.(tx)) {
1550
+ return {
1551
+ result: {
1552
+ claimed: false,
1553
+ activeRunId: null,
1554
+ approvalPending: true,
1555
+ },
1556
+ };
1557
+ }
1394
1558
  // Admitted under the thread's slot lock, so two tabs continuing the same
1395
1559
  // stop start one run and the count of continuations stays exact.
1396
1560
  if (continueOf) {
@@ -1723,6 +1887,7 @@ export async function reconcileTerminalRunFromEvents(runId, options = {}) {
1723
1887
  error_code = ?,
1724
1888
  error_detail = ?,
1725
1889
  terminal_reason = ?,
1890
+ thread_projection_retry_order = CASE WHEN ? THEN COALESCE(thread_projection_retry_order, nextval('agent_run_projection_retry_order_seq')) ELSE thread_projection_retry_order END,
1726
1891
  thread_projection_pending = CASE WHEN ? THEN TRUE ELSE thread_projection_pending END
1727
1892
  WHERE id = ?
1728
1893
  AND (
@@ -1737,6 +1902,7 @@ export async function reconcileTerminalRunFromEvents(runId, options = {}) {
1737
1902
  errorDetail,
1738
1903
  terminalReason,
1739
1904
  options.threadProjectionPending === true,
1905
+ options.threadProjectionPending === true,
1740
1906
  runId,
1741
1907
  STALE_RUN_ERROR_EVENT.errorCode,
1742
1908
  ...(allowMatchingTerminalRepair ? [status, terminalReason] : []),
@@ -2043,6 +2209,7 @@ async function reconcileTerminalRunInRecoveryTransaction(tx, runId) {
2043
2209
  error_detail = ?,
2044
2210
  terminal_reason = ?
2045
2211
  WHERE id = ? AND status = 'running'
2212
+ AND ${earlierTurnProjectionPendingSql()}
2046
2213
  ${ENDED_RUN_RETURNING}`,
2047
2214
  args: [
2048
2215
  status,
@@ -2075,6 +2242,7 @@ async function reapSingleStaleRun(runId, source, maxStaleMs) {
2075
2242
  terminal_reason = ?
2076
2243
  WHERE id = ?
2077
2244
  AND status = 'running'
2245
+ AND ${earlierTurnProjectionPendingSql()}
2078
2246
  AND ${terminalRunEventExclusionSql()}
2079
2247
  AND ${staleClause}
2080
2248
  ${ENDED_RUN_RETURNING}`;
@@ -2127,15 +2295,31 @@ async function reapSingleStaleRun(runId, source, maxStaleMs) {
2127
2295
  args: [runScope.thread_id, runScope.turn_id, runId, lease.generation],
2128
2296
  });
2129
2297
  }
2130
- if (generationMatches || (!lease && run.worker_generation == null)) {
2131
- await tx.execute({
2298
+ const executionLeaseChanged = !generationMatches && (lease !== null || run.worker_generation != null);
2299
+ const shouldProjectRepairedRun = generationMatches || (!lease && run.worker_generation == null);
2300
+ const outcome = isContinuationTerminalReason(repaired.ending.terminalReason) &&
2301
+ !executionLeaseChanged
2302
+ ? await attemptStaleRunRecovery(tx, runId)
2303
+ : null;
2304
+ if (shouldProjectRepairedRun) {
2305
+ const projection = await tx.execute({
2132
2306
  sql: `UPDATE agent_runs
2133
- SET thread_projection_pending = TRUE
2134
- WHERE id = ? AND status = 'errored'`,
2135
- args: [runId],
2307
+ SET thread_projection_pending = TRUE,
2308
+ thread_projection_retry_order = COALESCE(thread_projection_retry_order, nextval('agent_run_projection_retry_order_seq'))
2309
+ WHERE id = ? AND status = ?
2310
+ RETURNING id`,
2311
+ args: [runId, repaired.ending.status],
2136
2312
  });
2313
+ if ((projection.rowsAffected ?? projection.rows.length) !== 1) {
2314
+ throw new Error(`Repaired run ${runId} did not retain its thread projection retry marker`);
2315
+ }
2137
2316
  }
2138
- return { reaped: false, outcome: null, repaired };
2317
+ return {
2318
+ reaped: false,
2319
+ outcome,
2320
+ repaired,
2321
+ shouldProjectRepairedRun,
2322
+ };
2139
2323
  }
2140
2324
  const { rows, rowsAffected } = await tx.execute({
2141
2325
  sql: updateSql,
@@ -2161,7 +2345,8 @@ async function reapSingleStaleRun(runId, source, maxStaleMs) {
2161
2345
  if (outcome.outcome !== "execution_lease_changed") {
2162
2346
  const projection = await tx.execute({
2163
2347
  sql: `UPDATE agent_runs
2164
- SET thread_projection_pending = TRUE
2348
+ SET thread_projection_pending = TRUE,
2349
+ thread_projection_retry_order = COALESCE(thread_projection_retry_order, nextval('agent_run_projection_retry_order_seq'))
2165
2350
  WHERE id = ? AND status = 'errored'
2166
2351
  RETURNING id`,
2167
2352
  args: [runId],
@@ -2248,11 +2433,16 @@ async function reapSingleStaleRun(runId, source, maxStaleMs) {
2248
2433
  // first would have this older run's steps land after its, and take its
2249
2434
  // error and status.
2250
2435
  let projectionFailed = false;
2251
- if (reaped) {
2436
+ if ("repaired" in reapResult && reapResult.shouldProjectRepairedRun) {
2437
+ projectionFailed = !(await finalizeStaleRun(runId, source));
2438
+ }
2439
+ else if (reaped) {
2252
2440
  // A newer generation owns the turn, so it must remain the sole thread projector.
2253
2441
  projectionFailed = !(await finalizeStaleRun(runId, source, outcome?.outcome !== "execution_lease_changed"));
2254
2442
  }
2255
- if (reaped && outcome && outcome.outcome !== "not_background") {
2443
+ if ((reaped || "repaired" in reapResult) &&
2444
+ outcome &&
2445
+ outcome.outcome !== "not_background") {
2256
2446
  const outcomeDetail = outcome.outcome === "recovered"
2257
2447
  ? `recovered successorRunId=${outcome.successorRunId}`
2258
2448
  : `declined reason=${outcome.outcome}`;
@@ -2260,7 +2450,7 @@ async function reapSingleStaleRun(runId, source, maxStaleMs) {
2260
2450
  ? `${outcomeDetail} ${priorStageInfo}`
2261
2451
  : outcomeDetail;
2262
2452
  await recordRunDiagnostic(runId, RUN_DIAG_STAGE.staleRunRecoveryAttempted, forensics ? `${detail} ${forensics}` : detail).catch(() => { });
2263
- if (outcome.outcome === "recovered") {
2453
+ if (outcome.outcome === "recovered" && !projectionFailed) {
2264
2454
  attemptStaleRunRecoveryDispatch(outcome.successorRunId);
2265
2455
  }
2266
2456
  }
@@ -2298,6 +2488,7 @@ export async function reapUnclaimedBackgroundRun(runId) {
2298
2488
  AND status = 'running'
2299
2489
  AND dispatch_mode = 'background'
2300
2490
  AND COALESCE(heartbeat_at, started_at) < ?
2491
+ AND ${earlierTurnProjectionPendingSql()}
2301
2492
  ${ENDED_RUN_RETURNING}`,
2302
2493
  args: [
2303
2494
  completedAt,
@@ -2347,6 +2538,7 @@ export async function updateRunStatusIfRunning(runId, status, options = {}) {
2347
2538
  const terminalReason = options.terminalReason?.slice(0, 200);
2348
2539
  const { rowsAffected } = await withFencedExecutionWrite((tx) => tx.execute({
2349
2540
  sql: `UPDATE agent_runs SET status = ?, completed_at = ?, dispatch_payload = NULL,
2541
+ thread_projection_retry_order = CASE WHEN ? THEN COALESCE(thread_projection_retry_order, nextval('agent_run_projection_retry_order_seq')) ELSE thread_projection_retry_order END,
2350
2542
  thread_projection_pending = CASE WHEN ? THEN TRUE ELSE thread_projection_pending END
2351
2543
  ${terminalReason === undefined ? "" : ", terminal_reason = ?"}
2352
2544
  WHERE id = ? AND status = 'running'`,
@@ -2354,6 +2546,7 @@ export async function updateRunStatusIfRunning(runId, status, options = {}) {
2354
2546
  status,
2355
2547
  Date.now(),
2356
2548
  options.threadProjectionPending === true,
2549
+ options.threadProjectionPending === true,
2357
2550
  ...(terminalReason === undefined ? [] : [terminalReason]),
2358
2551
  runId,
2359
2552
  ],
@@ -3279,7 +3472,7 @@ export async function getRunById(runId) {
3279
3472
  await ensureRunTables();
3280
3473
  const client = getDbExec();
3281
3474
  const { rows } = await client.execute({
3282
- sql: `SELECT id, thread_id, status, started_at, error_code, error_detail, terminal_reason FROM agent_runs WHERE id = ?`,
3475
+ sql: `SELECT id, thread_id, status, started_at, error_code, error_detail, terminal_reason, thread_projection_error_code FROM agent_runs WHERE id = ?`,
3283
3476
  args: [runId],
3284
3477
  });
3285
3478
  if (rows.length === 0)
@@ -3293,6 +3486,7 @@ export async function getRunById(runId) {
3293
3486
  errorCode: r.error_code ?? null,
3294
3487
  errorDetail: r.error_detail ?? null,
3295
3488
  terminalReason: r.terminal_reason ?? null,
3489
+ threadProjectionErrorCode: r.thread_projection_error_code ?? null,
3296
3490
  };
3297
3491
  }
3298
3492
  /**
@@ -3328,8 +3522,21 @@ export async function readStoppedRunForThreadFold(runId) {
3328
3522
  if (!row || row.status === "running")
3329
3523
  return null;
3330
3524
  const events = [];
3331
- for (const { seq, eventData } of await getRunEventsSince(runId, 0)) {
3332
- events.push({ seq, event: JSON.parse(eventData) });
3525
+ for (const [rowIndex, { seq, eventData }] of (await getRunEventsSince(runId, 0)).entries()) {
3526
+ if (!Number.isSafeInteger(seq) || seq < 0) {
3527
+ throw new AgentRunJournalUnreadableError(row.thread_id, row.turn_id, rowIndex, "invalid_row");
3528
+ }
3529
+ let event;
3530
+ try {
3531
+ event = JSON.parse(eventData);
3532
+ }
3533
+ catch (cause) {
3534
+ throw new AgentRunJournalUnreadableError(row.thread_id, row.turn_id, rowIndex, "invalid_event_json", { cause });
3535
+ }
3536
+ if (!isReadableJournalEvent(event)) {
3537
+ throw new AgentRunJournalUnreadableError(row.thread_id, row.turn_id, rowIndex, "invalid_event");
3538
+ }
3539
+ events.push({ seq, event });
3333
3540
  }
3334
3541
  return {
3335
3542
  threadId: row.thread_id,
@@ -3481,7 +3688,7 @@ export async function listRunsForThread(threadId, options = {}) {
3481
3688
  ? ` AND id IN (${batch.map(() => "?").join(", ")})`
3482
3689
  : "";
3483
3690
  return {
3484
- sql: `SELECT id, thread_id, turn_id, status, started_at, heartbeat_at, completed_at, last_progress_at, error_code, abort_reason, dispatch_mode, terminal_reason, diag_stage, in_flight_since
3691
+ sql: `SELECT id, thread_id, turn_id, status, started_at, heartbeat_at, completed_at, last_progress_at, error_code, abort_reason, dispatch_mode, terminal_reason, diag_stage, thread_projection_error_code, in_flight_since
3485
3692
  FROM agent_runs
3486
3693
  WHERE thread_id = ?${runIdFilter}${batch ? "" : " ORDER BY started_at DESC LIMIT ?"}`,
3487
3694
  args: [threadId, ...(batch ?? []), ...(batch ? [] : [limit])],
@@ -3524,6 +3731,7 @@ export async function listRunsForThread(threadId, options = {}) {
3524
3731
  dispatchMode: row.dispatch_mode ?? null,
3525
3732
  terminalReason: row.terminal_reason ?? null,
3526
3733
  diagStage: row.diag_stage ?? null,
3734
+ threadProjectionErrorCode: row.thread_projection_error_code ?? null,
3527
3735
  inFlightSince: row.in_flight_since == null ? null : Number(row.in_flight_since),
3528
3736
  };
3529
3737
  });
@@ -3759,6 +3967,7 @@ export async function reapAllStaleRuns() {
3759
3967
  const scanned = await client.execute({
3760
3968
  sql: `SELECT id FROM agent_runs
3761
3969
  WHERE status = 'running'
3970
+ AND ${earlierTurnProjectionPendingSql()}
3762
3971
  AND ${livenessBasisSql()} < ${backgroundAwareStaleCutoffSql()}
3763
3972
  ORDER BY started_at ASC
3764
3973
  LIMIT ${REAP_ALL_STALE_BATCH_LIMIT + 1}`,
@@ -4138,17 +4347,7 @@ async function finalizeStaleRun(runId, source, projectThread = true) {
4138
4347
  if (!projectThread)
4139
4348
  return true;
4140
4349
  try {
4141
- const { foldReapedRunIntoThread } = await import("./reaped-run-thread.js");
4142
- const disposition = await foldReapedRunIntoThread(runId);
4143
- if (disposition === "retry")
4144
- return false;
4145
- await getDbExec().execute({
4146
- sql: `UPDATE agent_runs
4147
- SET thread_projection_pending = FALSE
4148
- WHERE id = ? AND thread_projection_pending IS TRUE`,
4149
- args: [runId],
4150
- });
4151
- return true;
4350
+ return (await projectRunThread(runId)) === "projected";
4152
4351
  }
4153
4352
  catch (error) {
4154
4353
  captureError(error, {
@@ -4162,6 +4361,122 @@ async function finalizeStaleRun(runId, source, projectThread = true) {
4162
4361
  return false;
4163
4362
  }
4164
4363
  }
4364
+ async function bumpRunThreadProjectionRetryOrder(runId) {
4365
+ await getDbExec().execute({
4366
+ sql: `UPDATE agent_runs
4367
+ SET thread_projection_retry_order = nextval('agent_run_projection_retry_order_seq')
4368
+ WHERE id = ? AND thread_projection_pending IS TRUE`,
4369
+ args: [runId],
4370
+ });
4371
+ }
4372
+ async function projectRunThread(runId) {
4373
+ const { foldReapedRunIntoThread } = await import("./reaped-run-thread.js");
4374
+ let disposition;
4375
+ try {
4376
+ disposition = await foldReapedRunIntoThread(runId);
4377
+ }
4378
+ catch (error) {
4379
+ if (error instanceof AgentRunJournalUnreadableError) {
4380
+ await getDbExec().execute({
4381
+ sql: `UPDATE agent_runs
4382
+ SET thread_projection_error_code = 'tool_call_journal_unreadable',
4383
+ thread_projection_retry_order = NULL
4384
+ WHERE id = ? AND thread_projection_pending IS TRUE`,
4385
+ args: [runId],
4386
+ });
4387
+ throw error;
4388
+ }
4389
+ try {
4390
+ await bumpRunThreadProjectionRetryOrder(runId);
4391
+ }
4392
+ catch (retryOrderError) {
4393
+ throw new AggregateError([error, retryOrderError], `Run ${runId} projection failed and its retry order could not be advanced`, { cause: error });
4394
+ }
4395
+ throw error;
4396
+ }
4397
+ if (disposition === "retry") {
4398
+ await bumpRunThreadProjectionRetryOrder(runId);
4399
+ return "deferred";
4400
+ }
4401
+ const client = getDbExec();
4402
+ if (!client.transaction) {
4403
+ throw new Error("Run thread projection completion requires transaction support");
4404
+ }
4405
+ await client.transaction(async (tx) => {
4406
+ const { rows } = await tx.execute({
4407
+ sql: `UPDATE agent_runs
4408
+ SET thread_projection_pending = FALSE,
4409
+ thread_projection_retry_order = NULL,
4410
+ thread_projection_error_code = NULL
4411
+ WHERE id = ? AND thread_projection_pending IS TRUE
4412
+ RETURNING id`,
4413
+ args: [runId],
4414
+ });
4415
+ if (rows.length === 0)
4416
+ return;
4417
+ // Give queued same-turn successors a dispatch window when their gate opens.
4418
+ await tx.execute({
4419
+ sql: `UPDATE agent_runs AS successor
4420
+ SET heartbeat_at = ?
4421
+ FROM agent_runs AS predecessor
4422
+ WHERE predecessor.id = ?
4423
+ AND successor.thread_id = predecessor.thread_id
4424
+ AND COALESCE(successor.turn_id, successor.id) =
4425
+ COALESCE(predecessor.turn_id, predecessor.id)
4426
+ AND successor.id <> predecessor.id
4427
+ AND successor.status = 'running'
4428
+ AND successor.dispatch_mode = 'background'
4429
+ AND (
4430
+ COALESCE(predecessor.continuation_order, 0) <
4431
+ COALESCE(successor.continuation_order, 0)
4432
+ OR (
4433
+ COALESCE(predecessor.continuation_order, 0) =
4434
+ COALESCE(successor.continuation_order, 0)
4435
+ AND (
4436
+ predecessor.started_at < successor.started_at
4437
+ OR (
4438
+ predecessor.started_at = successor.started_at
4439
+ AND predecessor.id < successor.id
4440
+ )
4441
+ )
4442
+ )
4443
+ )`,
4444
+ args: [Date.now(), runId],
4445
+ });
4446
+ });
4447
+ return "projected";
4448
+ }
4449
+ /** Retry after repairing persisted source data; until success, the marker keeps later same-turn runs gated. */
4450
+ export async function retryRunThreadProjection(runId) {
4451
+ await ensureRunTables();
4452
+ const retried = await getDbExec().execute({
4453
+ sql: `UPDATE agent_runs
4454
+ SET thread_projection_error_code = NULL
4455
+ WHERE id = ?
4456
+ AND thread_projection_pending IS TRUE
4457
+ AND ${earlierTurnProjectionPendingSql()}
4458
+ RETURNING id`,
4459
+ args: [runId],
4460
+ });
4461
+ if (retried.rows.length > 0) {
4462
+ return { state: await projectRunThread(runId) };
4463
+ }
4464
+ const { rows } = await getDbExec().execute({
4465
+ sql: `SELECT thread_projection_pending FROM agent_runs WHERE id = ?`,
4466
+ args: [runId],
4467
+ });
4468
+ // Outcome pruning retains every run whose projection marker is still set.
4469
+ if (rows.length === 0)
4470
+ return { state: "untracked" };
4471
+ const pending = rows[0]?.thread_projection_pending;
4472
+ if (pending === true || pending === "t" || pending === 1) {
4473
+ return { state: "deferred" };
4474
+ }
4475
+ if (pending === false || pending === "f" || pending === 0) {
4476
+ return { state: "already_projected" };
4477
+ }
4478
+ throw new Error(`Run ${runId} has an unreadable thread projection marker`);
4479
+ }
4165
4480
  class AbortTerminalEventWriteError extends Error {
4166
4481
  runId;
4167
4482
  constructor(runId, cause) {
@@ -4309,9 +4624,13 @@ async function appendTerminalRunEventInTransaction(tx, runId, event, replaceExis
4309
4624
  }
4310
4625
  async function retryPendingStaleRunThreadProjections(client) {
4311
4626
  const { rows } = await client.execute({
4312
- sql: `SELECT id FROM agent_runs
4627
+ sql: `SELECT id, thread_projection_error_code FROM agent_runs
4313
4628
  WHERE thread_projection_pending IS TRUE
4314
- ORDER BY completed_at ASC, id ASC
4629
+ AND (thread_projection_error_code IS NOT NULL
4630
+ OR ${earlierTurnProjectionPendingSql()})
4631
+ ORDER BY CASE WHEN thread_projection_error_code IS NULL THEN 0 ELSE 1 END,
4632
+ thread_projection_retry_order ASC NULLS FIRST,
4633
+ completed_at ASC, id ASC
4315
4634
  LIMIT ${REAP_ALL_STALE_BATCH_LIMIT + 1}`,
4316
4635
  args: [],
4317
4636
  });
@@ -4319,7 +4638,12 @@ async function retryPendingStaleRunThreadProjections(client) {
4319
4638
  const pending = truncated ? rows.slice(0, REAP_ALL_STALE_BATCH_LIMIT) : rows;
4320
4639
  let failed = 0;
4321
4640
  for (const row of pending) {
4322
- const runId = row.id;
4641
+ const projection = row;
4642
+ if (projection.thread_projection_error_code != null) {
4643
+ failed += 1;
4644
+ continue;
4645
+ }
4646
+ const runId = projection.id;
4323
4647
  if (typeof runId !== "string")
4324
4648
  continue;
4325
4649
  if (!(await finalizeStaleRun(runId, "stale-projection-retry")))
@@ -1,3 +1,4 @@
1
+ import type { DbExec } from "../db/client.js";
1
2
  export { hashAgentToolApprovalKey } from "./approval-key.js";
2
3
  export declare const agentToolApprovalsStore: import("../db/store-registry.js").Store;
3
4
  export declare function ensureAgentToolApprovalTable(): Promise<void>;
@@ -27,6 +28,18 @@ export interface AgentToolApprovalPolicyBinding {
27
28
  export declare class AgentToolApprovalNotFoundError extends Error {
28
29
  constructor();
29
30
  }
31
+ export declare function hasPendingAgentToolApproval(tx: Pick<DbExec, "execute">, input: {
32
+ ownerEmail: string;
33
+ orgId?: string | null;
34
+ threadId: string;
35
+ }): Promise<boolean>;
36
+ /** Denial is a durable resolution so queued turns can resume after a pause. */
37
+ export declare function denyAgentToolApproval(input: {
38
+ approvalId: string;
39
+ ownerEmail: string;
40
+ orgId?: string | null;
41
+ threadId: string;
42
+ }): Promise<"denied" | "already_resolved" | "not_applied">;
30
43
  /**
31
44
  * Saves "always allow" for one tool name. With `approvalId`, the grant takes
32
45
  * the revision of that approval row, the card the user clicked, so it never