@ctrl-spc/cs 0.7.15 → 0.7.16

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.
@@ -1,6 +1,8 @@
1
+ import { executionIdentity } from './agents.js';
2
+ import { CodexHomeFailure } from './codex-home.js';
1
3
  import { execFile, spawn as spawnChild } from 'node:child_process';
2
4
  import { randomUUID } from 'node:crypto';
3
- import { beginOwnedClaim, ownedWorkAllowed, hasOwnedWorkContext, ownedWorkerClaimedAt, ownedWorkerExecutionHeld, reserveOwnedWork, legacyHeldTodoIds, interruptionReceipts, acknowledgeInterruption, legacySnapshotNeeded, saveLegacySnapshot, legacyTodoHolds, releaseLegacyTodoHold, recordExitedInterruption, beginRpcClaim, deferRpcClaim, pendingRpcClaims, recordRpcClaims, finishRpcClaims } from './daemon-processes.js';
5
+ import { beginOwnedClaim, ownedWorkAllowed, hasOwnedWorkContext, pendingTerminalOutcomes, acknowledgeTerminalOutcome, updateTerminalOutcome, ownedWorkerClaimedAt, ownedWorkerExecutionHeld, reserveOwnedWork, legacyHeldTodoIds, interruptionReceipts, acknowledgeInterruption, legacySnapshotNeeded, saveLegacySnapshot, legacyTodoHolds, releaseLegacyTodoHold, recordExitedInterruption, beginRpcClaim, deferRpcClaim, pendingRpcClaims, recordRpcClaims, finishRpcClaims, recoveredRpcClaims, pendingRpcReference, retireRpcReference } from './daemon-processes.js';
4
6
  import { promisify } from 'node:util';
5
7
  import { agentPath, detectAgents } from './agents.js';
6
8
  /* 18a Slice 7: the two Windows spawn rules, in one module so a caller cannot
@@ -9,7 +11,7 @@ import { agentPath, detectAgents } from './agents.js';
9
11
  import { killTree, needsShell, processIsAlive, quoteForCmd, spawnOwnedProcess, releaseOwnedProcess } from './win-shell.js';
10
12
  export { quoteForCmd } from './win-shell.js';
11
13
  import { ORCHESTRATOR_MAX_CONCURRENT, ORCHESTRATOR_SPAWN_TIMEOUT_MS } from './env.js';
12
- import { agentDisplayName, plainFailureReason } from './failure-reason.js';
14
+ import { agentDisplayName, nativeFailureKind, failureMessage } from './failure-reason.js';
13
15
  /* 18c SLICE 9 (GAP 15): the two request-scoped releases. They live in mcp.ts
14
16
  beside the session-end routes and the reservations table's other writers, so
15
17
  there is ONE place that knows how a lease is let go, rather than two that can
@@ -200,7 +202,7 @@ export async function claimNextTodo(client, machineId, agent,
200
202
  because the claim must stay ONE statement (see below), and because a
201
203
  blocked unit is the OLDEST claimable row: released and re-claimed it would
202
204
  be picked first every tick, starving everything behind it. */
203
- skipIds = []) {
205
+ skipIds = [], recoveryOnly = false) {
204
206
  /* THROUGH AN RPC, NOT PostgREST — and this is not a style preference, it is
205
207
  the fix for a bug that made the whole slice inert.
206
208
 
@@ -229,7 +231,7 @@ skipIds = []) {
229
231
  p_machine_id: machineId,
230
232
  p_agent: agent,
231
233
  p_skip_ids: skipIds,
232
- });
234
+ }, recoveryOnly);
233
235
  if (error)
234
236
  throw error;
235
237
  /* 18b Slice 2: the claim also carries the request's OWN project (id and name),
@@ -493,7 +495,7 @@ claimedAt) {
493
495
  if (error)
494
496
  throw error;
495
497
  if (!written?.length)
496
- return;
498
+ return false;
497
499
  /* 18c SLICE 1 — CLOSE OUT THE LAST ACTIVITY LINE.
498
500
  *
499
501
  * report_activity only ever flips the PREVIOUS 'doing' row to 'done' when a
@@ -517,6 +519,7 @@ claimedAt) {
517
519
  catch (err) {
518
520
  console.warn(`answerTodo: closing out the last activity line failed: ${err.message}`);
519
521
  }
522
+ return true;
520
523
  }
521
524
  /**
522
525
  * !Cleanup PHASE 6 (I15) — DID THIS WORKER ASK SOMETHING AND EXIT?
@@ -542,7 +545,7 @@ claimedAt) {
542
545
  * a card asking the user for something that does not exist, unclearable except
543
546
  * by Stop. A wrong `done` is recoverable by Retry; a phantom question is not.
544
547
  */
545
- export async function todoHasOpenAsk(client, todoId, warn) {
548
+ export async function todoHasOpenAsk(client, todoId, warn, strict = false) {
546
549
  try {
547
550
  const { data, error } = await client
548
551
  .from('cliv2_todo_asks')
@@ -554,11 +557,13 @@ export async function todoHasOpenAsk(client, todoId, warn) {
554
557
  throw new Error(error.message);
555
558
  if ((data ?? []).length > 0)
556
559
  return true;
557
- if (await todoHasOpenDecision(client, todoId, warn))
560
+ if (await todoHasOpenDecision(client, todoId, warn, strict))
558
561
  return true;
559
- return await todoHasOpenBlockedNotice(client, todoId, warn);
562
+ return await todoHasOpenBlockedNotice(client, todoId, warn, strict);
560
563
  }
561
564
  catch (err) {
565
+ if (strict)
566
+ throw err;
562
567
  warn(`[orchestrator] could not check for open questions: ${err.message}`);
563
568
  return false;
564
569
  }
@@ -582,7 +587,7 @@ export async function todoHasOpenAsk(client, todoId, warn) {
582
587
  * Best-effort and false on error, like its two siblings, and for the same
583
588
  * reason: a wrong `done` is recoverable by Retry, a phantom park is not.
584
589
  */
585
- export async function todoHasOpenBlockedNotice(client, todoId, warn) {
590
+ export async function todoHasOpenBlockedNotice(client, todoId, warn, strict = false) {
586
591
  try {
587
592
  const { data, error } = await client
588
593
  .from('cliv2_blocked_notices')
@@ -595,6 +600,8 @@ export async function todoHasOpenBlockedNotice(client, todoId, warn) {
595
600
  return (data ?? []).length > 0;
596
601
  }
597
602
  catch (err) {
603
+ if (strict)
604
+ throw err;
598
605
  warn(`[orchestrator] could not check for an open stop: ${err.message}`);
599
606
  return false;
600
607
  }
@@ -635,7 +642,7 @@ const PERMISSION_CATEGORY = 'cliv2_permission';
635
642
  * BEST-EFFORT, like every read in the tick. A failure returns false, which costs
636
643
  * a wrongly-finished card rather than a lost run.
637
644
  */
638
- export async function todoHasOpenDecision(client, todoId, warn) {
645
+ export async function todoHasOpenDecision(client, todoId, warn, strict = false) {
639
646
  try {
640
647
  const { data: links, error: linkError } = await client
641
648
  .from('cliv2_loose_todo_links')
@@ -667,6 +674,8 @@ export async function todoHasOpenDecision(client, todoId, warn) {
667
674
  return (data ?? []).length > 0;
668
675
  }
669
676
  catch (err) {
677
+ if (strict)
678
+ throw err;
670
679
  warn(`[orchestrator] could not check the work item's questions: ${err.message}`);
671
680
  return false;
672
681
  }
@@ -779,7 +788,7 @@ export async function todoRunLeftATrace(client, todoId, warn,
779
788
  * same answer for the scope gate and resolving it twice would let the two
780
789
  * disagree about which item this run is for. Null for a panel request with
781
790
  * no work item, which is the greenfield case. */
782
- workItemId = null) {
791
+ workItemId = null, strict = false) {
783
792
  try {
784
793
  /* ANY ONE IS ENOUGH, so the cheapest and most likely is asked first and the
785
794
  rest are skipped once one answers yes. A session is what the guidance
@@ -828,6 +837,8 @@ workItemId = null) {
828
837
  return (dropped.data?.dropped_activity ?? 0) > 0;
829
838
  }
830
839
  catch (err) {
840
+ if (strict)
841
+ throw err;
831
842
  /* See the block comment: towards `done`, so a transient read failure never
832
843
  discards a run that really did the work. */
833
844
  warn(`[orchestrator] could not check whether the run recorded anything: ${err.message}`);
@@ -866,9 +877,10 @@ claimedAt) {
866
877
  .update({ answer: trimmed || null, state: 'needs-input' })
867
878
  .eq('id', todoId)
868
879
  .in('state', ['working', 'needs-input']);
869
- const { error } = await (claimedAt ? write.eq('claimed_at', claimedAt) : write);
880
+ const { data, error } = await (claimedAt ? write.eq('claimed_at', claimedAt) : write).select('id');
870
881
  if (error)
871
882
  throw error;
883
+ return !!data?.length;
872
884
  }
873
885
  /**
874
886
  * !Cleanup PHASE 6 (I17) — HAS THE USER STOPPED THIS?
@@ -950,6 +962,41 @@ export const MAX_ATTEMPTS = 3;
950
962
  * so there is one count: a second read could land after another process had
951
963
  * touched the row, and a wait computed from a stale count is a wait the card's
952
964
  * own countdown would disagree with. */
965
+ /** The existing terminal write, also used for a nonretryable native refusal. */
966
+ async function failClaimedTodo(client, todoId, reason, expectedClaim, count) {
967
+ let attempts = count ?? 0;
968
+ if (count === undefined) {
969
+ const { data, error } = await client.from('cliv2_loose_todos').select('attempts,claimed_at').eq('id', todoId).maybeSingle();
970
+ if (error)
971
+ throw error;
972
+ if (!data || data.claimed_at !== expectedClaim)
973
+ return { fate: 'stale', attempts: data?.attempts ?? 0 };
974
+ attempts = Number(data.attempts ?? 0) + 1;
975
+ }
976
+ let failure = client
977
+ .from('cliv2_loose_todos')
978
+ .update({
979
+ state: 'failed',
980
+ attempts,
981
+ stopped_reason: reason.trim() || 'the work failed repeatedly',
982
+ claimed_machine_id: null,
983
+ claimed_agent: null,
984
+ claimed_at: null,
985
+ /* 18a SLICE 6: cleared at the bound. A wait on a terminal row would be
986
+ read by nothing and would outlive the row's own meaning; the only
987
+ route back is Retry, which means now. */
988
+ retry_after: null,
989
+ })
990
+ .eq('id', todoId)
991
+ .in('state', ['working', 'needs-input']);
992
+ failure = expectedClaim ? failure.eq('claimed_at', expectedClaim) : failure.is('claimed_at', null);
993
+ const { data: failed, error: failError } = await failure.select('id');
994
+ if (failError)
995
+ throw failError;
996
+ if (!failed?.length)
997
+ return { fate: 'stale', attempts: attempts - 1 };
998
+ return { fate: 'failed', attempts };
999
+ }
953
1000
  export async function releaseOrFail(client, todoId, reason,
954
1001
  /** 18a Slice 6: injectable so a test can place the wait exactly. */
955
1002
  nowMs = Date.now(),
@@ -974,31 +1021,8 @@ claimedAt) {
974
1021
  }
975
1022
  const attempts = (current?.attempts ?? 0) + 1;
976
1023
  const expectedClaim = claimedAt ?? current?.claimed_at ?? null;
977
- if (attempts >= MAX_ATTEMPTS) {
978
- let failure = client
979
- .from('cliv2_loose_todos')
980
- .update({
981
- state: 'failed',
982
- attempts,
983
- stopped_reason: reason.trim() || 'the work failed repeatedly',
984
- claimed_machine_id: null,
985
- claimed_agent: null,
986
- claimed_at: null,
987
- /* 18a SLICE 6: cleared at the bound. A wait on a terminal row would be
988
- read by nothing and would outlive the row's own meaning; the only
989
- route back is Retry, which means now. */
990
- retry_after: null,
991
- })
992
- .eq('id', todoId)
993
- .in('state', ['working', 'needs-input']);
994
- failure = expectedClaim ? failure.eq('claimed_at', expectedClaim) : failure.is('claimed_at', null);
995
- const { data: failed, error: failError } = await failure.select('id');
996
- if (failError)
997
- throw failError;
998
- if (!failed?.length)
999
- return { fate: 'stale', attempts: current?.attempts ?? 0 };
1000
- return { fate: 'failed', attempts };
1001
- }
1024
+ if (attempts >= MAX_ATTEMPTS)
1025
+ return failClaimedTodo(client, todoId, reason, expectedClaim, attempts);
1002
1026
  /* 18a SLICE 6: THE WAIT IS WRITTEN ON THE SAME STATEMENT AS THE COUNT, for
1003
1027
  the reason the count is on the same statement as the release: a wait that
1004
1028
  can fail on its own is a wait the daemon might not honour, and the failure
@@ -1270,7 +1294,7 @@ injectedSpawn,
1270
1294
  * worker's, and injected by the same convention (`deps.resolveContext`) so a
1271
1295
  * test can point it somewhere without `resolveClaimContext` growing
1272
1296
  * parameters for the suite's sake. */
1273
- resolveContext = (claim) => resolveClaimContext(client, claim, warn)) {
1297
+ resolveContext = (claim) => resolveClaimContext(client, claim, warn), observeHarness) {
1274
1298
  /* ═══ 18k SLICE 10b — NO TOOLS MEANS NO ANSWER, the tick's own rule. ═══
1275
1299
  An answerer now holds the worker's toolset, so one started while the tools
1276
1300
  server is down would have NONE of it: it could not read the work item, could
@@ -1315,14 +1339,14 @@ resolveContext = (claim) => resolveClaimContext(client, claim, warn)) {
1315
1339
  const endClaim = beginOwnedClaim();
1316
1340
  state.takeInFlight = true;
1317
1341
  try {
1318
- await takeRunMessagesInner(client, userId, machineId, agents, state, log, warn, spawn, resolveContext);
1342
+ await takeRunMessagesInner(client, userId, machineId, agents, state, log, warn, spawn, resolveContext, observeHarness);
1319
1343
  }
1320
1344
  finally {
1321
1345
  state.takeInFlight = false;
1322
1346
  endClaim();
1323
1347
  }
1324
1348
  }
1325
- async function takeRunMessagesInner(client, userId, machineId, agents, state, log, warn, spawn, resolveContext) {
1349
+ async function takeRunMessagesInner(client, userId, machineId, agents, state, log, warn, spawn, resolveContext, observeHarness) {
1326
1350
  /* ═══ 18k SLICE 10 — A MACHINE ANSWERS ITS OWN RUNS, DESIGNATED OR NOT. ═══
1327
1351
  Slice 7 gated this whole function on being the designated orchestrator, and
1328
1352
  that is wrong for work already claimed: the user may switch the toolbar to
@@ -1394,6 +1418,13 @@ async function takeRunMessagesInner(client, userId, machineId, agents, state, lo
1394
1418
  scoped take with an empty id list, which correctly returns nothing — so this
1395
1419
  is a saved round trip every ten seconds on every machine in the fleet that is
1396
1420
  not participating, not a safety property. The safety is the scope itself. */
1421
+ if (hasOwnedWorkContext()) {
1422
+ for (const claim of recoveredRpcClaims('cli').filter(row => row.action !== 'cliv2_claim_next_loose_todo')) {
1423
+ for (const ref of cliClaimReferences(claim.action, claim.result))
1424
+ if (ref.surface === 'reply' && pendingRpcReference(ref) && !state.answering.has(ref.todoId) && !ownTodoIds.includes(ref.todoId))
1425
+ ownTodoIds.push(ref.todoId);
1426
+ }
1427
+ }
1397
1428
  if (ownTodoIds.length === 0 && !designated)
1398
1429
  return;
1399
1430
  try {
@@ -1561,7 +1592,7 @@ async function takeRunMessagesInner(client, userId, machineId, agents, state, lo
1561
1592
  void answerRunMessages(client, todoId, group.messageIds, { agent: plan.agent, cwd: context.checkout, workingCopyElsewhere: plan.workingCopyElsewhere }, state, spawn, warn, { surface: 'reply', todoId,
1562
1593
  messageLeases: taken.filter((row) => row.todo_id === todoId).map((row) => ({ id: row.id, taken_at: row.taken_at })),
1563
1594
  grantLeases: granted.filter((row) => row.todo_id === todoId).map((row) => ({ id: row.id, resume_taken_at: row.resume_taken_at })),
1564
- observedPendingMessageIds: null }, machineId).catch(err => {
1595
+ observedPendingMessageIds: null }, machineId, undefined, observeHarness).catch(err => {
1565
1596
  warn(`[orchestrator] could not answer ${shortId(todoId)}: ${err.message}`);
1566
1597
  });
1567
1598
  }
@@ -1571,7 +1602,7 @@ async function takeRunMessagesInner(client, userId, machineId, agents, state, lo
1571
1602
  }
1572
1603
  finally {
1573
1604
  if (hasOwnedWorkContext())
1574
- finishRpcClaims(claimOperations);
1605
+ finishRpcClaims(claimOperations, true);
1575
1606
  }
1576
1607
  }
1577
1608
  /**
@@ -2207,7 +2238,7 @@ plan, state,
2207
2238
  /** 18k Slice 10b — THE WORKER'S OWN SPAWN SHAPE, not a responder's. There is no
2208
2239
  * second adapter any more: this is what `defaultSpawn(mcpServer)` returns, and
2209
2240
  * the answerer differs from a dispatch only in passing no `alsoRunningIn`. */
2210
- spawn, warn, attempt, machineId, owned) {
2241
+ spawn, warn, attempt, machineId, owned, observeHarness) {
2211
2242
  /* ═══ 18k SLICE 10b — ONE ANSWER PER RUN AT A TIME. ═══
2212
2243
  DEPTH, NOT THE PRIMARY DEFENCE. `takeRunMessagesInner` subtracts
2213
2244
  `state.answering` from the scoped take's id list, so a run this machine is
@@ -2237,8 +2268,9 @@ spawn, warn, attempt, machineId, owned) {
2237
2268
  owned = hasOwnedWorkContext() ? reserveOwnedWork(attempt, plan.agent) : undefined;
2238
2269
  state.answering.add(todoId);
2239
2270
  try {
2240
- const answered = await answerRunMessagesInner(client, todoId, messageIds, plan, state, spawn, warn, attempt, machineId, owned);
2241
- owned?.complete();
2271
+ const answered = await answerRunMessagesInner(client, todoId, messageIds, plan, state, spawn, warn, attempt, machineId, owned, observeHarness);
2272
+ if (answered)
2273
+ owned?.complete();
2242
2274
  return answered;
2243
2275
  }
2244
2276
  finally {
@@ -2246,18 +2278,49 @@ spawn, warn, attempt, machineId, owned) {
2246
2278
  owned?.finishPreparation();
2247
2279
  }
2248
2280
  }
2249
- async function answerRunMessagesInner(client, todoId, messageIds, plan, state, spawn, warn, attempt, machineId, owned) {
2281
+ async function settleReplyOutcome(client, machineId, attempt, outcome, onAccepted) {
2282
+ const todoId = attempt.todoId;
2283
+ const finished = await client.rpc('cliv2_finish_reply_attempt', {
2284
+ p_todo_id: todoId, p_machine_id: machineId, p_message_leases: attempt.messageLeases,
2285
+ p_grant_leases: attempt.grantLeases, p_body: outcome.text,
2286
+ ...(!outcome.ok && !outcome.stopped ? { p_failure_kind: outcome.failureKind ?? 'unknown' } : {}),
2287
+ });
2288
+ if (finished.error)
2289
+ throw finished.error;
2290
+ const receipt = finished.data?.[0]?.outcome;
2291
+ if (receipt === 'stale')
2292
+ return false;
2293
+ if (receipt === 'failed' || receipt === 'already_failed') {
2294
+ onAccepted?.();
2295
+ return false;
2296
+ }
2297
+ if (receipt !== 'accepted' && receipt !== 'already_settled')
2298
+ throw new Error('The reply acknowledgement was incomplete.');
2299
+ onAccepted?.();
2300
+ if (outcome.stopped && outcome.replyingToClaim)
2301
+ await releaseStoppedTodo(client, todoId, outcome.replyingToClaim);
2302
+ else if (outcome.workerIsLive && outcome.replyingToClaim) {
2303
+ const scheduled = await client.from('cliv2_loose_todos')
2304
+ .update({ claimed_machine_id: null, claimed_agent: null, claimed_at: null })
2305
+ .eq('id', todoId).eq('claimed_at', outcome.replyingToClaim).in('state', ['working', 'needs-input']);
2306
+ if (scheduled.error)
2307
+ throw scheduled.error;
2308
+ }
2309
+ return !outcome.stopped;
2310
+ }
2311
+ async function answerRunMessagesInner(client, todoId, messageIds, plan, state, spawn, warn, attempt, machineId, owned, observeHarness) {
2250
2312
  /* ALREADY GIVEN UP ON. Said ONCE, at the bound, and then silently skipped —
2251
2313
  the `reportTakeFailed` rule: a line every two minutes forever would bury
2252
2314
  every useful one. */
2253
- const attempts = Math.max(...messageIds.map(id => state.replyAttempts.get(id) ?? 0));
2315
+ const sourceIds = [...messageIds, ...(attempt?.grantLeases ?? []).map(grant => `grant:${grant.id}`)];
2316
+ const attempts = Math.max(0, ...sourceIds.map(id => state.replyAttempts.get(id) ?? 0));
2254
2317
  if (attempts >= MAX_REPLY_ATTEMPTS)
2255
2318
  return false;
2256
2319
  const fail = (reason) => {
2257
- for (const id of messageIds) {
2320
+ for (const id of sourceIds) {
2258
2321
  state.replyAttempts.set(id, (state.replyAttempts.get(id) ?? 0) + 1);
2259
2322
  }
2260
- const spent = Math.max(...messageIds.map(id => state.replyAttempts.get(id) ?? 0));
2323
+ const spent = Math.max(0, ...sourceIds.map(id => state.replyAttempts.get(id) ?? 0));
2261
2324
  if (spent >= MAX_REPLY_ATTEMPTS) {
2262
2325
  warn(`[orchestrator] gave up answering ${shortId(todoId)} after ${MAX_REPLY_ATTEMPTS} attempts: ${reason}`);
2263
2326
  }
@@ -2335,6 +2398,7 @@ async function answerRunMessagesInner(client, todoId, messageIds, plan, state, s
2335
2398
  attempt.observedPendingMessageIds = (pending.data ?? []).map((row) => row.id);
2336
2399
  owned?.setReference(attempt);
2337
2400
  }
2401
+ const harnessIdentity = executionIdentity(plan.agent);
2338
2402
  let spawnedPid = null;
2339
2403
  const result = await spawn(plan.agent, buildReplyPrompt(record, bodies, { hasCheckout: plan.cwd !== null, workingCopyElsewhere: plan.workingCopyElsewhere }, workerIsLive), undefined, plan.cwd, () => todoWasStopped(client, todoId), (pid) => {
2340
2404
  state.livePids.add(pid);
@@ -2346,40 +2410,34 @@ async function answerRunMessagesInner(client, todoId, messageIds, plan, state, s
2346
2410
  state.livePids.delete(spawnedPid);
2347
2411
  if (result.interrupted)
2348
2412
  return false;
2349
- if (!result.ok && !result.stopped) {
2350
- fail(result.error);
2351
- return false;
2352
- }
2353
- const body = result.stopped ? 'Stopped.' : result.output.trim();
2354
- if (!body) {
2355
- fail('it said nothing');
2413
+ const failureKind = result.failureKind ?? nativeFailureKind(result.saidTail ?? '');
2414
+ if (!result.ok && !result.stopped && failureKind === 'transient' && attempts + 1 < MAX_REPLY_ATTEMPTS) {
2415
+ fail(failureMessage(plan.agent, failureKind));
2416
+ owned?.complete();
2356
2417
  return false;
2357
2418
  }
2419
+ if (!result.ok && !result.stopped && failureKind === 'transient')
2420
+ fail(failureMessage(plan.agent, failureKind));
2421
+ const succeeded = result.ok && !!result.output.trim();
2422
+ const body = result.stopped ? 'Stopped.' : succeeded ? result.output.trim() : null;
2358
2423
  if (!attempt || !machineId)
2359
2424
  throw new Error('The reply has no exact source lease identity.');
2360
- const finished = await client.rpc('cliv2_finish_reply_attempt', {
2361
- p_todo_id: todoId, p_machine_id: machineId, p_message_leases: attempt.messageLeases,
2362
- p_grant_leases: attempt.grantLeases, p_body: body,
2425
+ const terminal = { surface: 'reply', completedAt: Date.now(), harnessIdentity, ok: succeeded, text: body, reason: succeeded ? '' : failureMessage(plan.agent, failureKind), failureKind: succeeded ? undefined : failureKind, stopped: !!result.stopped, replyingToClaim, workerIsLive: result.ok && workerIsLive };
2426
+ owned?.deferOutcome(terminal);
2427
+ // A durable terminal result is awaiting acknowledgement, never another native attempt.
2428
+ for (const id of sourceIds)
2429
+ state.replyAttempts.delete(id);
2430
+ const answered = await settleReplyOutcome(client, machineId, attempt, terminal, () => {
2431
+ if (succeeded && !result.stopped)
2432
+ observeHarness?.(plan.agent, 'authenticated', 'dispatch-success', terminal.completedAt, terminal.harnessIdentity);
2433
+ else if (failureKind === 'authentication')
2434
+ observeHarness?.(plan.agent, 'sign-in-required', 'provider-rejected', terminal.completedAt, terminal.harnessIdentity);
2363
2435
  });
2364
- if (finished.error)
2365
- throw finished.error;
2366
- const outcome = finished.data?.[0]?.outcome;
2367
- if (outcome === 'stale')
2368
- return false;
2369
- if (outcome !== 'accepted' && outcome !== 'already_settled')
2370
- throw new Error('The reply acknowledgement was incomplete.');
2371
- for (const id of messageIds)
2436
+ owned?.acknowledgeOutcome();
2437
+ owned?.complete();
2438
+ for (const id of sourceIds)
2372
2439
  state.replyAttempts.delete(id);
2373
- if (result.stopped && replyingToClaim)
2374
- await releaseStoppedTodo(client, todoId, replyingToClaim);
2375
- else if (workerIsLive && replyingToClaim) {
2376
- const scheduled = await client.from('cliv2_loose_todos')
2377
- .update({ claimed_machine_id: null, claimed_agent: null, claimed_at: null })
2378
- .eq('id', todoId).eq('claimed_at', replyingToClaim).in('state', ['working', 'needs-input']);
2379
- if (scheduled.error)
2380
- throw scheduled.error;
2381
- }
2382
- return !result.stopped;
2440
+ return answered;
2383
2441
  }
2384
2442
  /** 18k Slice 7 — say ONCE that the take is failing, not every 10 seconds.
2385
2443
  *
@@ -3708,10 +3766,8 @@ export async function spawnAgent(agent, prompt, timeoutMs = ORCHESTRATOR_SPAWN_T
3708
3766
  const codexHome = agent === 'claude'
3709
3767
  ? null
3710
3768
  : ensureCodexRunHome(mcpServer, runTodoId, runTodoId ? `${runTodoId}-${randomUUID().slice(0, 8)}` : null);
3711
- if (agent !== 'claude' && !codexHome) {
3712
- console.warn('[orchestrator] codex is running WITHOUT an isolated configuration: no signed-in codex ' +
3713
- 'credential to seed a per-run home. This worker inherits the MCP servers, instruction ' +
3714
- 'files and permission posture on this machine.');
3769
+ if (codexHome instanceof CodexHomeFailure) {
3770
+ return { ok: false, output: '', error: codexHome.message, failureKind: codexHome.kind };
3715
3771
  }
3716
3772
  /* Spread the parent's env rather than replacing it: codex needs PATH, HOME,
3717
3773
  the proxy variables and the user's shell environment to run at all. Only the
@@ -4126,7 +4182,7 @@ export async function spawnAgent(agent, prompt, timeoutMs = ORCHESTRATOR_SPAWN_T
4126
4182
  401 that means signed out. Each tailed separately so a chatty stdout
4127
4183
  cannot push stderr out of the window. */
4128
4184
  const saidTail = `${stdout.slice(-STDERR_TAIL_BYTES)}\n${stderrTail}`;
4129
- return settle({ ok: false, output: '', error: `${agent} ${how}`, saidTail });
4185
+ return settle({ ok: false, output: '', error: `${agent} ${how}`, saidTail, failureKind: nativeFailureKind(stdout, stderrTail) });
4130
4186
  }
4131
4187
  /* 18c SLICE 8 — CODEX CAN EXIT 0 ON A TURN THAT FAILED, so the exit code
4132
4188
  above is not the last word. Checked AFTER the code, because a non-zero
@@ -4136,7 +4192,7 @@ export async function spawnAgent(agent, prompt, timeoutMs = ORCHESTRATOR_SPAWN_T
4136
4192
  release path, like every other failure, so the unit is retried. */
4137
4193
  if (codexTurnFailed !== null) {
4138
4194
  const saidTail = `${stdout.slice(-STDERR_TAIL_BYTES)}\n${stderrTail}`;
4139
- return settle({ ok: false, output: '', error: `${agent} ${codexTurnFailed}`, saidTail });
4195
+ return settle({ ok: false, output: '', error: `${agent} ${codexTurnFailed}`, saidTail, failureKind: nativeFailureKind(stdout, stderrTail) });
4140
4196
  }
4141
4197
  /* PREFER WHAT WAS CAPTURED LIVE, FOR BOTH HARNESSES (18c Slice 8 — it used
4142
4198
  to be claude only). `stdout` is now the fallback only for a run that
@@ -4353,7 +4409,7 @@ export async function startWorker(client, todoId, machineId, agent, warn = conso
4353
4409
  /** Move a worker through its lifecycle. `died` REQUIRES a reason — the table's
4354
4410
  * check constraint refuses the row otherwise, deliberately: a failure the panel
4355
4411
  * cannot explain gives the user nothing to act on. */
4356
- export async function updateWorker(client, workerId, state, error = null, warn = console.warn) {
4412
+ export async function updateWorker(client, workerId, state, error = null, warn = console.warn, strict = false) {
4357
4413
  if (!workerId)
4358
4414
  return;
4359
4415
  try {
@@ -4370,6 +4426,8 @@ export async function updateWorker(client, workerId, state, error = null, warn =
4370
4426
  throw writeError;
4371
4427
  }
4372
4428
  catch (err) {
4429
+ if (strict)
4430
+ throw err;
4373
4431
  warn(`[orchestrator] could not update the worker: ${err.message}`);
4374
4432
  }
4375
4433
  }
@@ -5270,9 +5328,14 @@ export async function orchestratorTick(deps) {
5270
5328
  return 'busy';
5271
5329
  state.inFlight += 1;
5272
5330
  try {
5273
- await reconcileInterruptedWork(deps.client, deps.machineId);
5331
+ await reconcileInterruptedWork(deps.client, deps.machineId, deps.observeHarness);
5274
5332
  return await runTick(deps, state, now, log, warn);
5275
5333
  }
5334
+ catch (error) {
5335
+ // This supervising tick must survive an outage and retry its saved recovery.
5336
+ warn(`orchestrator poll failed, will retry: ${error instanceof Error ? error.message : JSON.stringify(error)}`);
5337
+ return 'failed';
5338
+ }
5276
5339
  finally {
5277
5340
  state.inFlight -= 1;
5278
5341
  }
@@ -5295,6 +5358,7 @@ async function runTick(deps, state, now, log, warn) {
5295
5358
  line is precisely what was wrong with it: a failing agent stopped the whole
5296
5359
  daemon rather than stopping itself. The wait is now per request, on the row,
5297
5360
  and the claim itself skips a request whose moment has not come. */
5361
+ const recovered = hasOwnedWorkContext() ? recoveredRpcClaims('cli').find(row => row.action === 'cliv2_claim_next_loose_todo' && agents.includes(row.args?.p_agent)) : undefined;
5298
5362
  let role;
5299
5363
  try {
5300
5364
  const { data, error } = await client
@@ -5311,8 +5375,12 @@ async function runTick(deps, state, now, log, warn) {
5311
5375
  // is not a designation, and guessing would be exactly the fleet-wide race
5312
5376
  // the machine check exists to prevent.
5313
5377
  warn(`orchestrator designation read failed, will retry: ${err.message}`);
5314
- return 'failed';
5378
+ if (!recovered)
5379
+ return 'failed';
5380
+ role = { kind: 'none' };
5315
5381
  }
5382
+ if (recovered)
5383
+ role = { kind: 'this-machine', agent: recovered.args.p_agent };
5316
5384
  reportRole(state, role, machineName, log);
5317
5385
  if (role.kind !== 'this-machine')
5318
5386
  return 'not-designated';
@@ -5383,7 +5451,7 @@ async function runTick(deps, state, now, log, warn) {
5383
5451
  for (;;) {
5384
5452
  let claim;
5385
5453
  try {
5386
- claim = await claimNextTodo(client, machineId, role.agent, [...skip, ...legacyHeldTodoIds()]);
5454
+ claim = await claimNextTodo(client, machineId, role.agent, [...skip, ...legacyHeldTodoIds()], !!recovered);
5387
5455
  if (claim?.claimOperationId)
5388
5456
  claimOperations.push(claim.claimOperationId);
5389
5457
  }
@@ -5465,7 +5533,7 @@ async function runTick(deps, state, now, log, warn) {
5465
5533
  }
5466
5534
  const dispatched = dispatchClaim(deps, state, now, log, warn, role, runClaim, context, chosen);
5467
5535
  if (hasOwnedWorkContext())
5468
- finishRpcClaims(claimOperations);
5536
+ finishRpcClaims(claimOperations, true);
5469
5537
  endClaim();
5470
5538
  return await dispatched;
5471
5539
  }
@@ -5497,7 +5565,7 @@ async function runTick(deps, state, now, log, warn) {
5497
5565
  }
5498
5566
  finally {
5499
5567
  if (hasOwnedWorkContext())
5500
- finishRpcClaims(claimOperations);
5568
+ finishRpcClaims(claimOperations, true);
5501
5569
  endClaim();
5502
5570
  }
5503
5571
  }
@@ -5554,6 +5622,57 @@ async function dispatchClaim(deps, state, now, log, warn, role, claim, context,
5554
5622
  forgetSecrets(claim.id);
5555
5623
  }
5556
5624
  }
5625
+ async function settleWorkerOutcome(client, workerId, claim, role, result, targeted, now, startedAt, log, warn, saved, persist, onAccepted) {
5626
+ let disposition = saved?.disposition;
5627
+ if (!disposition) {
5628
+ if (!result.ok) {
5629
+ const kind = result.failureKind ?? nativeFailureKind(result.saidTail ?? '');
5630
+ const reason = failureMessage(role.agent, kind);
5631
+ disposition = { worker: 'died', reason, todo: kind === 'transient' ? 'retry' : 'failed' };
5632
+ }
5633
+ else if (await todoHasOpenAsk(client, claim.id, warn, true)) {
5634
+ disposition = { worker: 'done', reason: '', todo: 'needs-input' };
5635
+ }
5636
+ else if (!await todoRunLeftATrace(client, claim.id, warn, targeted, true)) {
5637
+ disposition = { worker: 'died', reason: NOTHING_RECORDED_REASON, todo: 'retry' };
5638
+ }
5639
+ else
5640
+ disposition = { worker: 'done', reason: '', todo: 'done' };
5641
+ if (saved) {
5642
+ saved.disposition = disposition;
5643
+ persist?.(saved);
5644
+ }
5645
+ }
5646
+ if (disposition.todo === 'needs-input') {
5647
+ const accepted = await suspendTodoForAsk(client, claim.id, result.output, claim.claimedAt);
5648
+ if (accepted)
5649
+ onAccepted?.();
5650
+ await updateWorker(client, workerId, 'done', null, warn, true);
5651
+ log(`[orchestrator] ${shortId(claim.id)} is waiting on the user`);
5652
+ return 'answered';
5653
+ }
5654
+ if (disposition.todo === 'done') {
5655
+ const accepted = await answerTodo(client, claim.id, result.output, claim.claimedAt);
5656
+ if (accepted)
5657
+ onAccepted?.();
5658
+ await updateWorker(client, workerId, 'done', null, warn, true);
5659
+ log(`[orchestrator] answered ${shortId(claim.id)} in ${((now().getTime() - startedAt) / 1000).toFixed(1)}s`);
5660
+ return 'answered';
5661
+ }
5662
+ await updateWorker(client, workerId, 'died', disposition.reason, warn, true);
5663
+ const { fate, attempts } = disposition.todo === 'retry'
5664
+ ? await releaseOrFail(client, claim.id, disposition.reason, now().getTime(), claim.claimedAt)
5665
+ : await failClaimedTodo(client, claim.id, disposition.reason, claim.claimedAt ?? null);
5666
+ if (fate === 'stale')
5667
+ return 'nothing-to-do';
5668
+ onAccepted?.();
5669
+ if (fate === 'failed') {
5670
+ warn(`[orchestrator] ${shortId(claim.id)} failed: ${disposition.reason}`);
5671
+ return 'failed';
5672
+ }
5673
+ warn(`[orchestrator] retrying ${shortId(claim.id)} in ${Math.round(retryDelayMs(attempts) / 1000)}s`);
5674
+ return 'released';
5675
+ }
5557
5676
  async function dispatchClaimInner(deps, state, now, log, warn, role, claim,
5558
5677
  /* !Cleanup Phase 7 Slice 3 — RESOLVED BY THE CALLER, and passed in rather than
5559
5678
  resolved again here. `runTick` needs the same answer first, to know which
@@ -5669,6 +5788,7 @@ chosenCodebases = [], owned) {
5669
5788
  let workerId = null;
5670
5789
  /* !Cleanup Phase 6b (I43) — outside the try for the same reason: the `finally`
5671
5790
  has to remove it from `livePids` however this run ended. */
5791
+ let terminalDeferred = false;
5672
5792
  let spawnedPid = null;
5673
5793
  try {
5674
5794
  log(`[orchestrator] picked up "${claim.instruction}" (${shortId(claim.id)})`);
@@ -5855,115 +5975,35 @@ chosenCodebases = [], owned) {
5855
5975
  if (result.interrupted)
5856
5976
  return 'nothing-to-do';
5857
5977
  if (result.stopped) {
5858
- await updateWorker(client, workerId, 'died', 'stopped by the user', warn);
5978
+ terminalDeferred = !!owned;
5979
+ owned?.deferOutcome({ surface: 'worker', ok: false, text: null, reason: 'stopped by the user', stopped: true });
5980
+ await updateWorker(client, workerId, 'died', 'stopped by the user', warn, true);
5859
5981
  await releaseStoppedTodo(client, claim.id, claim.claimedAt);
5982
+ owned?.acknowledgeOutcome();
5983
+ terminalDeferred = false;
5860
5984
  log(`[orchestrator] ${shortId(claim.id)} was stopped by the user`);
5861
5985
  return 'released';
5862
5986
  }
5863
- if (!result.ok) {
5864
- /* THE WORKER DIED. Its row says so, WITH the reason, before the unit is
5865
- released so a panel watching this moment sees "worker stopped" rather
5866
- than a unit that silently went back to unclaimed. Order matters: the
5867
- release is what makes the todo claimable again, so anything that must
5868
- be true about the dead worker has to be written first. */
5869
- /* 18a SLICE 5: THE WORKER ROW CARRIES THE PLAIN REASON, because that row
5870
- is what the card reads BETWEEN attempts. `result.error` stays the log's
5871
- string; the stored one is the sentence. */
5872
- const plain = plainFailureReason(role.agent, result.error, result.saidTail ?? '');
5873
- await updateWorker(client, workerId, 'died', plain, warn);
5874
- /* ux.md state D — released, not left claimed and silent. !Cleanup Phase 6
5875
- (I18): released ONLY while attempts remain. At the bound this writes
5876
- `failed` with the reason instead, which is what stops the unit being
5877
- re-claimed forever while reading as Working. */
5878
- const { fate, attempts } = await releaseOrFail(client, claim.id, plain, now().getTime(), claim.claimedAt);
5879
- if (fate === 'stale')
5880
- return 'nothing-to-do';
5881
- if (fate === 'failed') {
5882
- warn(`[orchestrator] ${result.error} — ${shortId(claim.id)} failed after ${MAX_ATTEMPTS} attempts`);
5883
- return 'failed';
5884
- }
5885
- /* 18a SLICE 6: THIS REQUEST WAITS, AND NOTHING ELSE DOES. The wait went
5886
- onto the row with the attempt count; what is left here is saying so, in
5887
- the line that used to say only "releasing". */
5888
- const seconds = Math.round(retryDelayMs(attempts) / 1000);
5889
- warn(`[orchestrator] ${result.error}: retrying ${shortId(claim.id)} in ${seconds}s`);
5890
- return 'released';
5891
- }
5892
- /* !Cleanup PHASE 6 (I15) — A QUESTION IS NOT A FINISHED JOB.
5893
- The child exited cleanly, which until now meant `done` unconditionally.
5894
- But a one-shot headless run ASKS AND EXITS — that is the only way it can
5895
- handle a question — so the clean exit says nothing about whether the work
5896
- is over. If an ask is open on this unit, the truthful state is that it
5897
- needs the user, and the Inbox already has the question. */
5898
- if (await todoHasOpenAsk(client, claim.id, warn)) {
5899
- await suspendTodoForAsk(client, claim.id, result.output, claim.claimedAt);
5900
- await updateWorker(client, workerId, 'done', null, warn);
5901
- log(`[orchestrator] ${shortId(claim.id)} is waiting on the user`);
5902
- return 'answered';
5903
- }
5904
- /* A CONFIDENT ANSWER WITH NOTHING BEHIND IT IS NOT A FINISHED JOB.
5905
- See `todoRunLeftATrace` for the measurement: a codex worker that was
5906
- correctly configured, connected, and fetched the tool list still reported
5907
- that it had no tools, twice in three identical runs, and one reported
5908
- ATTACHING a screenshot it never attached. Every one of them exited 0 with
5909
- prose on stdout, which is all the two checks above look at, so every one
5910
- of them landed here and was written `done`.
5911
-
5912
- PLACED AFTER THE ASK CHECK, and that order is load-bearing. A run that
5913
- asked a question and exited has legitimately recorded something and is
5914
- already handled as `needs-input`; reaching this check first would relabel
5915
- the product's most important state as a failure.
5916
-
5917
- RELEASED FOR RETRY RATHER THAN FAILED OUTRIGHT, through the SAME
5918
- `releaseOrFail` the dead-worker path uses. This is intermittent by nature
5919
- (the third run of the identical prompt worked perfectly), so a retry is
5920
- very likely to succeed, and the attempt bound is what stops it looping
5921
- forever. At the bound the card says why, in words about what is KNOWN
5922
- (nothing was recorded) rather than repeating the agent's own account of a
5923
- run that has just been shown to be an unreliable narrator.
5924
-
5925
- `targeted` IS PASSED, and without it this check is blind to the primary
5926
- flow. A session opened on a work item has `todo_id` NULL by the table's
5927
- own one-anchor constraint, and the prompt tells the agent to open it that
5928
- way whenever there is an item, so a `todo_id`-only lookup could never
5929
- match a targeted run. See `todoRunLeftATrace`. It is the same value the
5930
- scope gate above already resolved, so the two cannot disagree about which
5931
- item this request is for. */
5932
- if (!await todoRunLeftATrace(client, claim.id, warn, targeted)) {
5933
- /* THE WORKER DIED WITH NO STEP, and that blank is load-bearing rather
5934
- than incidental. `interruptedRunStep` looks up the most recent `died`
5935
- worker on this request and `describeInterruptedRun` then tells the
5936
- REPLACEMENT agent that a previous attempt "stopped partway" and that
5937
- anything it did "IS STILL THERE". For a genuinely toolless run that is
5938
- true and useful. Clearing the step keeps the claim honest about the one
5939
- thing that is known: this run recorded nothing, so there is no last
5940
- action to hand on and nothing for the next agent to resume from. */
5941
- await updateWorker(client, workerId, 'died', NOTHING_RECORDED_REASON, warn);
5942
- const { fate, attempts } = await releaseOrFail(client, claim.id, NOTHING_RECORDED_REASON, now().getTime(), claim.claimedAt);
5943
- if (fate === 'stale')
5944
- return 'nothing-to-do';
5945
- if (fate === 'failed') {
5946
- warn(`[orchestrator] ${shortId(claim.id)} recorded nothing and failed after ${MAX_ATTEMPTS} attempts`);
5947
- return 'failed';
5948
- }
5949
- const seconds = Math.round(retryDelayMs(attempts) / 1000);
5950
- warn(`[orchestrator] ${shortId(claim.id)} recorded nothing: retrying in ${seconds}s`);
5951
- return 'released';
5952
- }
5953
- /* FENCED ON THE CLAIM THIS DISPATCH TOOK (18d Slice 4). If this run handed
5954
- its stage on, the claim is already cleared and a fresh worker is on the
5955
- row, so this write must do nothing rather than mark the request finished
5956
- over work that is still running. See answerTodo's own comment. */
5957
- await answerTodo(client, claim.id, result.output, claim.claimedAt);
5958
- // The worker finished and its answer is committed. Marked done AFTER the
5959
- // answer write, so a worker is never reported finished for a unit that has
5960
- // no answer on it.
5961
- await updateWorker(client, workerId, 'done', null, warn);
5962
- const seconds = ((now().getTime() - startedAt) / 1000).toFixed(1);
5963
- log(`[orchestrator] answered ${shortId(claim.id)} in ${seconds}s`);
5964
- return 'answered';
5987
+ terminalDeferred = !!owned;
5988
+ const terminal = { surface: 'worker', ok: result.ok, text: result.ok ? result.output : null,
5989
+ reason: result.ok ? '' : failureMessage(role.agent, result.failureKind ?? nativeFailureKind(result.saidTail ?? '')),
5990
+ failureKind: result.failureKind ?? nativeFailureKind(result.saidTail ?? ''), workItemId: targeted };
5991
+ owned?.deferOutcome(terminal);
5992
+ const settled = await settleWorkerOutcome(client, workerId, claim, role, result, targeted, now, startedAt, log, warn, terminal, owned?.deferOutcome, () => {
5993
+ if (result.ok)
5994
+ deps.observeHarness?.(role.agent, 'authenticated', 'dispatch-success');
5995
+ else if (result.failureKind === 'authentication')
5996
+ deps.observeHarness?.(role.agent, 'sign-in-required', 'provider-rejected');
5997
+ });
5998
+ owned?.acknowledgeOutcome();
5999
+ terminalDeferred = false;
6000
+ return settled;
5965
6001
  }
5966
6002
  catch (err) {
6003
+ if (terminalDeferred) {
6004
+ warn('The completed execution is waiting for its original account to acknowledge the result.');
6005
+ return 'failed';
6006
+ }
5967
6007
  // The answer write itself failed (network, RLS, a reverted migration). The
5968
6008
  // todo is CLAIMED and has no answer — the worst state — so release it here
5969
6009
  // too. A release that also fails is warned and the row is left claimed;
@@ -6036,10 +6076,59 @@ function cliClaimReferences(action, result) {
6036
6076
  throw new Error('The claim journal returned an unrecognized lease identity.');
6037
6077
  });
6038
6078
  }
6039
- async function cliClaim(client, action, args) {
6079
+ async function recoveredCliClaim(client, action, args) {
6080
+ const skip = (args.p_skip_ids ?? args.p_skip_todo_ids ?? []);
6081
+ for (const claim of recoveredRpcClaims('cli').filter(row => row.action === action)) {
6082
+ if (action === 'cliv2_claim_next_loose_todo' && claim.args?.p_agent !== args.p_agent)
6083
+ continue;
6084
+ const rows = [];
6085
+ for (const row of claim.result ?? []) {
6086
+ const ref = cliClaimReferences(action, [row])[0];
6087
+ if (ref.surface === 'panel' || !pendingRpcReference(ref))
6088
+ continue;
6089
+ if (skip.includes(ref.todoId) || Array.isArray(args.p_todo_ids) && !args.p_todo_ids.includes(ref.todoId))
6090
+ continue;
6091
+ let current = false;
6092
+ if (ref.surface === 'worker') {
6093
+ const read = await client.from('cliv2_loose_todos').select('state,claimed_at,claimed_machine_id').eq('id', ref.todoId).maybeSingle();
6094
+ if (read.error)
6095
+ throw read.error;
6096
+ current = !!read.data && read.data.claimed_at === ref.claimedAt && read.data.claimed_machine_id === args.p_machine_id && ['working', 'needs-input'].includes(read.data.state);
6097
+ }
6098
+ else if (action === 'cliv2_take_run_messages') {
6099
+ const source = ref.messageLeases[0];
6100
+ const read = await client.from('cliv2_run_messages').select('taken_at,taken_machine_id,answered_by,interrupted_at').eq('id', source.id).maybeSingle();
6101
+ if (read.error)
6102
+ throw read.error;
6103
+ current = !!read.data && read.data.taken_at === source.taken_at && read.data.taken_machine_id === args.p_machine_id && !read.data.answered_by && !read.data.interrupted_at;
6104
+ }
6105
+ else {
6106
+ const source = ref.grantLeases[0];
6107
+ const read = await client.from('cliv2_permission_requests').select('resume_taken_at,resume_machine_id,resume_answered_by,resume_interrupted_at,state').eq('id', source.id).maybeSingle();
6108
+ if (read.error)
6109
+ throw read.error;
6110
+ current = !!read.data && read.data.resume_taken_at === source.resume_taken_at && read.data.resume_machine_id === args.p_machine_id && !read.data.resume_answered_by && !read.data.resume_interrupted_at && read.data.state === 'granted';
6111
+ }
6112
+ if (current)
6113
+ rows.push(row);
6114
+ else
6115
+ retireRpcReference(ref);
6116
+ }
6117
+ if (rows.length)
6118
+ return { data: rows, error: null, operationId: claim.id };
6119
+ finishRpcClaims([claim.id], true);
6120
+ }
6121
+ return null;
6122
+ }
6123
+ async function cliClaim(client, action, args, recoveryOnly = false) {
6040
6124
  if (!hasOwnedWorkContext())
6041
6125
  return { ...await client.rpc(action, args), operationId: undefined };
6042
- const operationId = beginRpcClaim('cli', action);
6126
+ const recovered = await recoveredCliClaim(client, action, args);
6127
+ if (recovered)
6128
+ return recovered;
6129
+ if (recoveryOnly)
6130
+ return { data: [], error: null, operationId: undefined };
6131
+ const operationId = beginRpcClaim('cli', action, args);
6043
6132
  try {
6044
6133
  const response = await client.rpc(action, { ...args, p_operation_id: operationId });
6045
6134
  if (!response.error) {
@@ -6049,7 +6138,10 @@ async function cliClaim(client, action, args) {
6049
6138
  const row = journal.data?.[0];
6050
6139
  if (row?.outcome !== 'completed' || row.action !== action)
6051
6140
  throw new Error('The claim was cancelled before it could start.');
6052
- recordRpcClaims(operationId, cliClaimReferences(action, row.result));
6141
+ recordRpcClaims(operationId, cliClaimReferences(action, row.result), false, row.result);
6142
+ if (row.result.length === 0)
6143
+ finishRpcClaims([operationId]);
6144
+ return { ...response, data: row.result, operationId: row.result.length ? operationId : undefined };
6053
6145
  }
6054
6146
  if (response.error)
6055
6147
  deferRpcClaim(operationId);
@@ -6083,13 +6175,46 @@ async function legacyRows(client, table, columns, todoId) {
6083
6175
  throw new Error('The old work snapshot has no continuation identity.');
6084
6176
  }
6085
6177
  }
6086
- export async function reconcileInterruptedWork(client, machineId) {
6178
+ export async function reconcileInterruptedWork(client, machineId, observeHarness) {
6087
6179
  if (!hasOwnedWorkContext())
6088
6180
  return;
6089
6181
  const capability = await client.rpc('cliv2_interrupt_machine_work', { p_machine_id: machineId,
6090
6182
  p_operation_id: randomUUID(), p_interrupted_at: new Date().toISOString(), p_workers: [], p_replies: [] });
6091
6183
  if (capability.error)
6092
6184
  throw capability.error;
6185
+ for (const pending of pendingTerminalOutcomes()) {
6186
+ if (pending.reference.surface === 'reply') {
6187
+ await settleReplyOutcome(client, machineId, pending.reference, pending.outcome, () => {
6188
+ const outcome = pending.outcome;
6189
+ if (!outcome.completedAt || !outcome.harnessIdentity || !['claude', 'codex'].includes(pending.harness ?? '') || outcome.stopped)
6190
+ return;
6191
+ if (outcome.ok || outcome.failureKind === 'authentication')
6192
+ observeHarness?.(pending.harness, outcome.ok ? 'authenticated' : 'sign-in-required', outcome.ok ? 'dispatch-success' : 'provider-rejected', outcome.completedAt, outcome.harnessIdentity);
6193
+ });
6194
+ acknowledgeTerminalOutcome(pending.id);
6195
+ continue;
6196
+ }
6197
+ if (pending.reference.surface !== 'worker')
6198
+ continue;
6199
+ const ref = pending.reference;
6200
+ if (pending.outcome.stopped) {
6201
+ await updateWorker(client, ref.workerId, 'died', 'stopped by the user', message => console.warn(message), true);
6202
+ await releaseStoppedTodo(client, ref.todoId, ref.claimedAt);
6203
+ acknowledgeTerminalOutcome(pending.id);
6204
+ continue;
6205
+ }
6206
+ const { data, error } = await client.from('cliv2_loose_todos').select('claimed_at,state').eq('id', ref.todoId).maybeSingle();
6207
+ if (error)
6208
+ throw error;
6209
+ if (data?.claimed_at === ref.claimedAt && ['working', 'needs-input'].includes(data.state)) {
6210
+ await settleWorkerOutcome(client, ref.workerId, { id: ref.todoId, claimedAt: ref.claimedAt }, { agent: pending.harness ?? 'Agent' }, { ok: pending.outcome.ok, output: pending.outcome.text ?? '', error: pending.outcome.reason, failureKind: pending.outcome.failureKind }, pending.outcome.workItemId ?? null, () => new Date(), Date.now(), () => { }, message => console.warn(message), pending.outcome, value => updateTerminalOutcome(pending.id, value));
6211
+ }
6212
+ else {
6213
+ const disposition = pending.outcome.disposition;
6214
+ await updateWorker(client, ref.workerId, disposition?.worker ?? (pending.outcome.ok ? 'done' : 'died'), disposition?.reason || pending.outcome.reason || null, message => console.warn(message), true);
6215
+ }
6216
+ acknowledgeTerminalOutcome(pending.id);
6217
+ }
6093
6218
  for (const claim of pendingRpcClaims('cli')) {
6094
6219
  const journal = await client.rpc('cliv2_reconcile_claim', { p_machine_id: machineId, p_operation_id: claim.id });
6095
6220
  if (journal.error)
@@ -6098,7 +6223,7 @@ export async function reconcileInterruptedWork(client, machineId) {
6098
6223
  if (row?.outcome === 'cancelled')
6099
6224
  recordRpcClaims(claim.id, [], true);
6100
6225
  else if (row?.outcome === 'completed' && row.action === claim.action)
6101
- recordRpcClaims(claim.id, cliClaimReferences(claim.action, row.result), true);
6226
+ recordRpcClaims(claim.id, cliClaimReferences(claim.action, row.result), false, row.result);
6102
6227
  else
6103
6228
  throw new Error('The pending claim could not be reconciled.');
6104
6229
  }