@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,3 +1,4 @@
1
+ import { failureMessage } from '../failure-reason.js';
1
2
  /**
2
3
  * ═══ AGENT PANEL v3: the poll loop that answers a card. ═══
3
4
  *
@@ -137,7 +138,7 @@ import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt,
137
138
  import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, loadOutputNames, outputOf, recordBaseProtection, standingRulesFor, withAskContent, } from './show.js';
138
139
  import { forgetSecrets, redactSecrets } from './secrets.js';
139
140
  import { sayListening, sayPollingProblem, stopListening } from './presence.js';
140
- import { selectedHarness } from './coordinator.js';
141
+ import { selectedHarness, recoveryHarnesses } from './coordinator.js';
141
142
  import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, ownsReconciliation, prepareCardReconciliation, finishCardReconciliation, worktreesOnThisMachine, } from './checkout.js';
142
143
  import { harness, startAgent } from './spawn.js';
143
144
  import { establishOwnerSession, listOwnerSessionIds, OWNER_SESSION_GRACE_MS, readOwnerSession, removeOwnerSession, validSessionUuid, writeOwnerSession, } from './session.js';
@@ -155,10 +156,10 @@ import { join } from 'node:path';
155
156
  function recoveryAttempt(run) {
156
157
  return { runId: run.id, processToken: run.process_token, startedAt: run.started_at, resumedAt: run.resumed_at };
157
158
  }
158
- async function startTrackedAgent(client, runId, preparation, work, ...args) {
159
+ async function startTrackedAgent(client, runId, preparation, work, captured, ...args) {
159
160
  if (!preparation)
160
- return startAgent(...args);
161
- const claimed = work?.claimAttempt(runId);
161
+ return { ...startAgent(...args), attempt: captured };
162
+ const claimed = captured ?? work?.claimAttempt(runId);
162
163
  if (!claimed)
163
164
  throw new Error('This run no longer belongs to the claimed attempt.');
164
165
  preparation.setAttempt(claimed);
@@ -207,7 +208,7 @@ async function startTrackedAgent(client, runId, preparation, work, ...args) {
207
208
  return answer;
208
209
  });
209
210
  void answered.catch(() => { }); // The caller records the PID before awaiting settlement.
210
- return { ...started, answered, attempt: claimed, preparationFailed: () => preparationFailed };
211
+ return { ...started, answered, attempt: claimed, deferOutcome: preparation.deferOutcome, acknowledgeOutcome: preparation.acknowledgeOutcome, preparationFailed: () => preparationFailed };
211
212
  }
212
213
  function panelClaimAttempts(result) {
213
214
  if (!Array.isArray(result))
@@ -220,14 +221,15 @@ function panelClaimAttempts(result) {
220
221
  }
221
222
  unique.set(attempt.run_id, { runId: attempt.run_id, cardId: attempt.card_id, processToken: attempt.process_token ?? null,
222
223
  pid: attempt.pid ?? null, startedAt: attempt.started_at, resumedAt: attempt.resumed_at ?? null,
223
- observedPendingTurnIds: attempt.observed_pending_turn_ids ?? null });
224
+ observedPendingTurnIds: attempt.observed_pending_turn_ids ?? null, authRecoveryRunId: attempt.auth_recovery_run_id ?? null, harness: attempt.harness ?? null });
224
225
  }
225
226
  return [...unique.values()];
226
227
  }
227
- async function panelClaim(client, work, action, args) {
228
- if (!work)
229
- return { ...await client.rpc(action, args), operationId: undefined };
230
- const operationId = work.beginRpc(action);
228
+ function capturedPanelAttempt(rows, runId, work) {
229
+ return work?.claimAttempt(runId) ?? panelClaimAttempts(rows).find(attempt => attempt.runId === runId);
230
+ }
231
+ async function panelClaim(client, work, action, args, nativeContext) {
232
+ const operationId = work?.beginRpc(action, { ...args, ...(nativeContext ? { nativeContext } : {}) }) ?? randomUUID();
231
233
  try {
232
234
  const response = await client.rpc(action, { ...args, p_operation_id: operationId });
233
235
  if (!response.error) {
@@ -237,14 +239,16 @@ async function panelClaim(client, work, action, args) {
237
239
  const row = journal.data?.[0];
238
240
  if (row?.outcome !== 'completed' || row.action !== action)
239
241
  throw new Error('The claim was cancelled before it could start.');
240
- work.recordRpc(operationId, panelClaimAttempts(row.result), false);
242
+ const attempts = panelClaimAttempts(row.result);
243
+ work?.recordRpc(operationId, attempts, false, row.result);
244
+ return { ...response, data: row.result, operationId };
241
245
  }
242
246
  if (response.error)
243
- work.deferRpc(operationId);
247
+ work?.deferRpc(operationId);
244
248
  return { ...response, operationId };
245
249
  }
246
250
  catch (error) {
247
- work.deferRpc(operationId);
251
+ work?.deferRpc(operationId);
248
252
  throw error;
249
253
  }
250
254
  }
@@ -252,7 +256,23 @@ async function reconcilePanelInterruptions(client, machineId, work) {
252
256
  const capability = await client.rpc('panel3_interrupt_machine_runs', { p_machine_id: machineId,
253
257
  p_operation_id: randomUUID(), p_interrupted_at: new Date().toISOString(), p_attempts: [] });
254
258
  if (capability.error)
255
- throw capability.error;
259
+ throw new Error(`could not verify interruption recovery: ${capability.error.message}`);
260
+ for (const pending of work.pendingOutcomes()) {
261
+ const { attempt, outcome } = pending;
262
+ const { data, error } = await client.from('panel3_runs').select('started_at,resumed_at,process_token,state,ended_at,level,card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)').eq('id', attempt.runId).maybeSingle();
263
+ if (error)
264
+ throw new Error(`could not read the completed attempt: ${error.message}`);
265
+ if (data && data.started_at === attempt.startedAt && (data.resumed_at ?? null) === attempt.resumedAt
266
+ && (data.process_token ?? null) === attempt.processToken && data.ended_at === null) {
267
+ const card = (Array.isArray(data.card) ? data.card[0] : data.card);
268
+ const successfulToolEnding = !outcome.ok && outcome.failureKind !== 'authentication' && (data.state === 'asked' || (data.level === 1 && !!card?.conversation_run_id));
269
+ if (outcome.ok || successfulToolEnding)
270
+ await writeAnswer(client, attempt.runId, attempt.cardId, successfulToolEnding ? null : outcome.text, attempt.processToken ?? undefined, attempt);
271
+ else
272
+ await endRun(client, outcome.level ?? 1, attempt.runId, attempt.cardId, outcome.reason, attempt, outcome.failureKind);
273
+ }
274
+ work.acknowledgeOutcome(pending.id);
275
+ }
256
276
  for (const failure of work.failedPreparations()) {
257
277
  await endRun(client, failure.level, failure.attempt.runId, failure.attempt.cardId, failure.reason, failure.attempt);
258
278
  work.acknowledgePreparationFailure(failure.id);
@@ -260,12 +280,12 @@ async function reconcilePanelInterruptions(client, machineId, work) {
260
280
  for (const claim of work.pendingRpcs()) {
261
281
  const journal = await client.rpc('panel3_reconcile_claim', { p_machine_id: machineId, p_operation_id: claim.id });
262
282
  if (journal.error)
263
- throw journal.error;
283
+ throw new Error(`could not read the claim receipt: ${journal.error.message}`);
264
284
  const row = journal.data?.[0];
265
285
  if (row?.outcome === 'cancelled')
266
286
  work.recordRpc(claim.id, [], true);
267
287
  else if (row?.outcome === 'completed' && row.action === claim.action)
268
- work.recordRpc(claim.id, panelClaimAttempts(row.result), true);
288
+ work.recordRpc(claim.id, panelClaimAttempts(row.result), false, row.result);
269
289
  else
270
290
  throw new Error('The pending claim could not be reconciled.');
271
291
  }
@@ -742,11 +762,14 @@ async function recordProcess(client, runId, pid, brief, processToken, attempt) {
742
762
  * there first. It is a fact about the record rather than a failure, so it does
743
763
  * not go through `returned()`, exactly as the answer's own null does not.
744
764
  */
745
- async function giveUp(client, runId, reason, processToken, attempt) {
765
+ async function giveUp(client, runId, reason, processToken, attempt, failureKind) {
746
766
  const { data, error } = await client
747
767
  .rpc('panel3_give_up', {
748
768
  p_run_id: runId,
749
769
  p_reason: reason,
770
+ p_failure_kind: failureKind ?? null,
771
+ p_process_token: processToken ?? null,
772
+ p_expected_attempt: null,
750
773
  ...(processToken === undefined ? {} : { p_process_token: processToken }),
751
774
  ...(attempt ? { p_process_token: attempt.processToken, p_expected_attempt: {
752
775
  started_at: attempt.startedAt, resumed_at: attempt.resumedAt,
@@ -799,7 +822,7 @@ async function giveUp(client, runId, reason, processToken, attempt) {
799
822
  * a run can be the last thing live on it — but no turn is written, exactly as
800
823
  * none is written for a run that stopped to ask.
801
824
  */
802
- async function writeAnswer(client, runId, cardId, text, processToken) {
825
+ async function writeAnswer(client, runId, cardId, text, processToken, attempt) {
803
826
  /* ═══ THE SECOND OF THE TWO CHOKEPOINTS THE CREDENTIAL RULE RESTS ON. ═══
804
827
  This is the ONE path an agent's own words take to a hosted row — a turn on
805
828
  the card at levels 1 and 2, and a level 3's `panel3_runs.report`, which
@@ -809,6 +832,9 @@ async function writeAnswer(client, runId, cardId, text, processToken) {
809
832
  const { data: turnId, error } = await client
810
833
  .rpc('panel3_answer', {
811
834
  p_run_id: runId,
835
+ p_process_token: processToken ?? attempt?.processToken ?? null,
836
+ p_expected_attempt: attempt ? { started_at: attempt.startedAt, resumed_at: attempt.resumedAt } : null,
837
+ p_auth_recovery_run_id: attempt?.authRecoveryRunId ?? null,
812
838
  p_body: text === null ? null : redactSecrets(processToken === undefined ? runId : `${runId}:${processToken}`, text),
813
839
  ...(processToken === undefined ? {} : { p_process_token: processToken }),
814
840
  });
@@ -876,14 +902,15 @@ async function writeAnswer(client, runId, cardId, text, processToken) {
876
902
  away. */
877
903
  said(`run ${runId} had already ended, so its answer was not written to card ${cardId}`);
878
904
  }
879
- return ownerSettlementAccepted(run, processToken);
905
+ return ownerSettlementAccepted(run, processToken, attempt);
880
906
  }
881
907
  out(`answered card ${cardId} run ${runId} ${text?.length ?? 0} characters`);
882
908
  return true;
883
909
  }
884
- export function ownerSettlementAccepted(run, processToken) {
910
+ export function ownerSettlementAccepted(run, processToken, attempt) {
885
911
  return (run.state === 'asked' || run.state === 'finished')
886
- && (processToken === undefined || run.processToken === processToken);
912
+ && (processToken === undefined || run.processToken === processToken)
913
+ && (!attempt || run.processToken === attempt.processToken && run.startedAt === attempt.startedAt && (run.resumedAt ?? null) === attempt.resumedAt);
887
914
  }
888
915
  /** What the record says a run is now, and at what level. Read only to say the
889
916
  * right sentence about something that has already happened; nothing branches on
@@ -891,7 +918,7 @@ export function ownerSettlementAccepted(run, processToken) {
891
918
  async function runNow(client, runId) {
892
919
  const runs = await returned(client
893
920
  .from('panel3_runs')
894
- .select('state, level, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
921
+ .select('state, level, process_token, started_at, resumed_at, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
895
922
  .eq('id', runId), 'read', `the state of run ${runId}`);
896
923
  const run = runs[0];
897
924
  return run
@@ -899,9 +926,9 @@ async function runNow(client, runId) {
899
926
  state: run.state,
900
927
  level: run.level,
901
928
  conversationOwnerId: run.card?.conversation_run_id ?? null,
902
- processToken: run.process_token,
929
+ processToken: run.process_token, startedAt: run.started_at, resumedAt: run.resumed_at,
903
930
  }
904
- : { state: 'no longer on the record', level: null, conversationOwnerId: null, processToken: null };
931
+ : { state: 'no longer on the record', level: null, conversationOwnerId: null, processToken: null, startedAt: null, resumedAt: null };
905
932
  }
906
933
  /**
907
934
  * A run whose process failed, ended with the reason ON ITS OWN COLUMN, and its
@@ -1000,6 +1027,7 @@ async function answerCard(client, tools, machineId, cardId, turns) {
1000
1027
  const preparation = tools.work?.prepare(turns[0].run_id);
1001
1028
  try {
1002
1029
  const runId = turns[0].run_id;
1030
+ const attempt = capturedPanelAttempt(turns, runId, tools.work);
1003
1031
  /* BEFORE THE SPAWN, AND ITS FAILURE IS THE SPAWN'S FAILURE. The receipts are
1004
1032
  part of what the agent is sent, so a read that fails must not be papered
1005
1033
  over with an empty list: that reads as a card that has made nothing, which
@@ -1043,7 +1071,7 @@ async function answerCard(client, tools, machineId, cardId, turns) {
1043
1071
  }
1044
1072
  catch (error) {
1045
1073
  const why = error instanceof Error ? error.message : String(error);
1046
- await endRun(client, LEVEL, runId, cardId, why);
1074
+ await endRun(client, LEVEL, runId, cardId, why, attempt);
1047
1075
  throw new Error(`NO AGENT IS RUNNING: ${why}`);
1048
1076
  }
1049
1077
  /* ═══ THE RUN ID IS ON THE URL, AND THAT IS THE WHOLE OF WHAT THE AGENT IS
@@ -1055,7 +1083,7 @@ async function answerCard(client, tools, machineId, cardId, turns) {
1055
1083
  /* AN EMPTY DIRECTORY OF THE USER'S OWN, because level 1 has no code tool to
1056
1084
  use a real one with, and the daemon's inherited cwd under a launchd login
1057
1085
  item is the filesystem root. */
1058
- const started = await startTrackedAgent(client, runId, preparation, tools.work, withStandingRules(rules, brief, where.block), LEVEL, tools.urlFor(runId), where.cwd, undefined, settings);
1086
+ const started = await startTrackedAgent(client, runId, preparation, tools.work, attempt, withStandingRules(rules, brief, where.block), LEVEL, tools.urlFor(runId), where.cwd, undefined, settings);
1059
1087
  out(`run ${runId} level ${LEVEL} ${started.pid ? `pid ${started.pid}` : 'no process'}`);
1060
1088
  try {
1061
1089
  /* THE BRIEF, NOT WHAT THE PROCESS WAS HANDED. The rules are current at the
@@ -1399,11 +1427,11 @@ async function workingDirectory(client, runId, level, isOwner, knownCodebase) {
1399
1427
  * Then the row, then the process, then the pid — constraint 8, in the only order
1400
1428
  * that satisfies it.
1401
1429
  */
1402
- async function startChild(client, tools, machineId, parentRunId, brief, codebase, parentProcessToken, choice = {}) {
1430
+ async function startChild(client, tools, machineId, parentRunId, brief, codebase, parentProcessToken, choice = {}, recovered) {
1403
1431
  let claimOperationId;
1404
1432
  const preparation = tools.work?.prepare();
1405
1433
  try {
1406
- const response = await panelClaim(client, tools.work, 'panel3_dispatch', {
1434
+ const response = recovered ?? await panelClaim(client, tools.work, 'panel3_dispatch', {
1407
1435
  p_parent_run_id: parentRunId,
1408
1436
  p_brief: brief,
1409
1437
  p_machine_id: machineId,
@@ -1425,6 +1453,7 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
1425
1453
  throw new Error(`NO AGENT WAS STARTED and nothing was written: run ${parentRunId} has ended, is already as `
1426
1454
  + 'deep as anything may be sent from, or this conversation already has its owner. Exit now.');
1427
1455
  }
1456
+ const attempt = capturedPanelAttempt(data ?? [], row.run_id, tools.work);
1428
1457
  if (row.run_level !== 2 && row.run_level !== 3) {
1429
1458
  /* UNREACHABLE, AND STILL SETTLED. `panel3_dispatch` writes `parent.level + 1`
1430
1459
  from a parent it has just checked is below 3, so there is no level here
@@ -1433,7 +1462,7 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
1433
1462
  recovery sweep wait out the pid grace window to conclude what is already
1434
1463
  known. */
1435
1464
  const why = `run ${row.run_id} was written at level ${row.run_level}, which cannot be spawned`;
1436
- await giveUp(client, row.run_id, why, row.process_token ?? undefined);
1465
+ await giveUp(client, row.run_id, why, row.process_token ?? undefined, attempt);
1437
1466
  throw new Error(why);
1438
1467
  }
1439
1468
  const level = row.run_level;
@@ -1459,12 +1488,12 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
1459
1488
  }
1460
1489
  catch (error) {
1461
1490
  const why = error instanceof Error ? error.message : String(error);
1462
- await giveUp(client, row.run_id, why, row.process_token ?? undefined);
1491
+ await giveUp(client, row.run_id, why, row.process_token ?? undefined, attempt);
1463
1492
  throw new Error(`NO AGENT IS RUNNING: ${why}`);
1464
1493
  }
1465
1494
  const processToken = row.process_token ?? undefined;
1466
1495
  const isOwner = level === 2;
1467
- const started = await startTrackedAgent(client, row.run_id, preparation, tools.work, withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined, settings);
1496
+ const started = await startTrackedAgent(client, row.run_id, preparation, tools.work, attempt, withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined, settings);
1468
1497
  if (started.pid === null) {
1469
1498
  if (started.interrupted?.())
1470
1499
  throw new Error('Work was interrupted by the service command.');
@@ -1474,7 +1503,7 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
1474
1503
  and the tool call fails saying no agent was started. */
1475
1504
  const answer = await started.answered;
1476
1505
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
1477
- await giveUp(client, row.run_id, reason, processToken);
1506
+ await giveUp(client, row.run_id, reason, processToken, attempt);
1478
1507
  throw new Error(`NO AGENT IS RUNNING: ${reason}`);
1479
1508
  }
1480
1509
  out(`dispatch run ${row.run_id} level ${level} under ${parentRunId} pid ${started.pid}`);
@@ -1565,12 +1594,13 @@ async function attemptsSoFar(client, runId) {
1565
1594
  * Returns whether THIS call is what ended it. False is a fact about the record,
1566
1595
  * not a failure: the run had already ended, and the caller says so.
1567
1596
  */
1568
- async function endRun(client, level, runId, cardId, why, attempt) {
1597
+ async function endRun(client, level, runId, cardId, why, attempt, failureKind) {
1569
1598
  if (level !== 1)
1570
- return (await giveUp(client, runId, why, undefined, attempt)) !== null;
1599
+ return (await giveUp(client, runId, why, undefined, attempt, failureKind)) !== null;
1571
1600
  if (attempt) {
1572
1601
  const { data, error } = await client.rpc('panel3_end_run', {
1573
1602
  p_run_id: runId, p_reason: why, p_process_token: attempt.processToken,
1603
+ p_failure_kind: failureKind ?? null,
1574
1604
  p_expected_attempt: { started_at: attempt.startedAt, resumed_at: attempt.resumedAt },
1575
1605
  });
1576
1606
  if (error)
@@ -1666,6 +1696,11 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
1666
1696
  return started.answered.then(async (answer) => {
1667
1697
  if (started.interrupted?.() || started.preparationFailed?.())
1668
1698
  return;
1699
+ if (!answer.ok)
1700
+ answer = { ...answer, reason: failureMessage(started.attempt?.harness ?? harness(), answer.failureKind ?? 'unknown') };
1701
+ started.deferOutcome?.({ surface: 'panel', ok: answer.ok,
1702
+ text: answer.ok && speaksToTheCard ? redactSecrets(processToken === undefined ? runId : `${runId}:${processToken}`, answer.text) : null,
1703
+ reason: answer.ok ? '' : answer.reason, failureKind: answer.ok ? undefined : answer.failureKind, level });
1669
1704
  /* ═══ A RUN THAT STOPPED TO ASK DID NOT DIE, WHATEVER THE HARNESS PRINTED
1670
1705
  ON ITS WAY OUT. ═══
1671
1706
 
@@ -1690,12 +1725,20 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
1690
1725
  into an ordinary ending, and then handed to `writeAnswer` — which already
1691
1726
  owns this case and already prints its sentence — rather than to a second
1692
1727
  ending written here beside it. */
1693
- if (!answer.ok) {
1728
+ const observe = (authenticated) => {
1729
+ const selected = started.attempt?.harness;
1730
+ if (selected)
1731
+ tools.work?.observeHarness(selected, authenticated ? 'authenticated' : 'sign-in-required', authenticated ? 'dispatch-success' : 'provider-rejected');
1732
+ };
1733
+ if (!answer.ok && answer.failureKind !== 'authentication') {
1694
1734
  const run = await runNow(client, runId);
1695
1735
  if (run.state === 'asked') {
1696
- const accepted = await writeAnswer(client, runId, cardId, null, processToken);
1697
- if (accepted)
1736
+ started.deferOutcome?.({ surface: 'panel', ok: true, text: null, reason: '', level });
1737
+ const accepted = await writeAnswer(client, runId, cardId, null, processToken, started.attempt);
1738
+ if (accepted) {
1739
+ observe(true);
1698
1740
  await ownerSession?.established();
1741
+ }
1699
1742
  else
1700
1743
  await ownerSession?.failed();
1701
1744
  return;
@@ -1705,7 +1748,9 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
1705
1748
  exit cleanly without an agent message after the tool succeeds. The
1706
1749
  record, not prose the launcher was told not to write, decides success. */
1707
1750
  if (level === 1 && run.conversationOwnerId !== null) {
1708
- await writeAnswer(client, runId, cardId, null, processToken);
1751
+ started.deferOutcome?.({ surface: 'panel', ok: true, text: null, reason: '', level });
1752
+ if (await writeAnswer(client, runId, cardId, null, processToken, started.attempt))
1753
+ observe(true);
1709
1754
  return;
1710
1755
  }
1711
1756
  }
@@ -1716,7 +1761,7 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
1716
1761
  Repeating an invalid model/effort request cannot repair it. Unknown
1717
1762
  process deaths retain the existing recovery policy. */
1718
1763
  const attempts = await attemptsSoFar(client, runId);
1719
- if (answer.retryable !== false && started.pid !== null && attempts < MAX_ATTEMPTS) {
1764
+ if (answer.retryable === true && started.pid !== null && attempts < MAX_ATTEMPTS) {
1720
1765
  out(`retry run ${runId} attempt ${attempts} of ${MAX_ATTEMPTS} died: ${answer.reason}`);
1721
1766
  const again = processToken === undefined
1722
1767
  ? await resumeRun(client, tools, machineId, runId, started.pid)
@@ -1754,8 +1799,10 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
1754
1799
  than two. */
1755
1800
  const why = attempts > 1 ? `${answer.reason} (after ${attempts} attempts)` : answer.reason;
1756
1801
  const ended = processToken === undefined
1757
- ? await endRun(client, level, runId, cardId, why)
1758
- : (await giveUp(client, runId, why, processToken)) !== null;
1802
+ ? await endRun(client, level, runId, cardId, why, started.attempt, answer.failureKind)
1803
+ : (await giveUp(client, runId, why, processToken, started.attempt, answer.failureKind)) !== null;
1804
+ if (ended && answer.failureKind === 'authentication')
1805
+ observe(false);
1759
1806
  if (!ended) {
1760
1807
  /* IT WAS ALREADY SETTLED, by recovery, which decided this process was
1761
1808
  gone before it said so itself, or by the person's Stop. Nothing was
@@ -1772,12 +1819,14 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
1772
1819
  /* ═══ ITS OWN WORDS, ONTO THE CARD, THROUGH THE SAME STATEMENT EVERY LEVEL
1773
1820
  USES. ═══ Nothing reads them, nothing shortens them and nothing waits to
1774
1821
  approve them: ux.md's "whoever did the work writes the answer". */
1775
- const accepted = await writeAnswer(client, runId, cardId, speaksToTheCard ? answer.text : null, processToken);
1776
- if (accepted)
1822
+ const accepted = await writeAnswer(client, runId, cardId, speaksToTheCard ? answer.text : null, processToken, started.attempt);
1823
+ if (accepted) {
1824
+ observe(true);
1777
1825
  await ownerSession?.established();
1826
+ }
1778
1827
  else
1779
1828
  await ownerSession?.failed();
1780
- }).then(() => { started.completed?.(); });
1829
+ }).then(() => { started.acknowledgeOutcome?.(); started.completed?.(); });
1781
1830
  }
1782
1831
  /**
1783
1832
  * ═══ ONE RUN, STARTED AGAIN AS ITSELF, WITH WHAT IT WAS SENT AND WHAT IT HAD
@@ -1835,7 +1884,7 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
1835
1884
  * the record refuses a fourth, and the caller is the one that knows what to say
1836
1885
  * about the failure it was holding.
1837
1886
  */
1838
- async function resumeRun(client, tools, machineId, runId, afterPid) {
1887
+ async function resumeRun(client, tools, machineId, runId, afterPid, recovered) {
1839
1888
  /* ═══ THE SWEEP CHECKS BEFORE THE CLAIM AND THE RETRY CANNOT, AND `afterPid`
1840
1889
  IS THE WHOLE OF WHAT DECIDES IT. ═══ Said once, here. See the header for
1841
1890
  both halves of the argument: a machine with no checkout must not take a run
@@ -1853,7 +1902,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1853
1902
  let claimOperationId;
1854
1903
  const preparation = tools.work?.prepare();
1855
1904
  try {
1856
- const response = await panelClaim(client, tools.work, 'panel3_resume', {
1905
+ const response = recovered ?? await panelClaim(client, tools.work, 'panel3_resume', {
1857
1906
  p_run_id: runId,
1858
1907
  p_machine_id: machineId,
1859
1908
  p_after_pid: afterPid,
@@ -1865,6 +1914,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1865
1914
  const claimed = data?.[0];
1866
1915
  if (!claimed)
1867
1916
  return null;
1917
+ const attempt = capturedPanelAttempt(data ?? [], runId, tools.work);
1868
1918
  const level = claimed.run_level === 1 ? 1 : claimed.run_level === 2 ? 2 : 3;
1869
1919
  if (claimed.run_level !== level) {
1870
1920
  /* UNREACHABLE, AND STILL SETTLED, exactly as in `startChild`: the level
@@ -1872,7 +1922,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1872
1922
  The claim has already happened, so leaving it would strand the run for a
1873
1923
  whole grace window before anything looked at it again. */
1874
1924
  const why = `run ${runId} is at level ${claimed.run_level}, which cannot be spawned`;
1875
- await giveUp(client, runId, why);
1925
+ await giveUp(client, runId, why, undefined, attempt);
1876
1926
  throw new Error(why);
1877
1927
  }
1878
1928
  /* ONE RESOLUTION FOR BOTH PATHS, AFTER THE CLAIM. It used to fork on whether
@@ -1901,7 +1951,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1901
1951
  1 here: handing the person's message back mints a new run with its
1902
1952
  attempts at one, which is the bound the retry is under, undone by the
1903
1953
  one path that could not start. See `endRun`. */
1904
- await endRun(client, level, runId, claimed.run_card_id, why);
1954
+ await endRun(client, level, runId, claimed.run_card_id, why, attempt);
1905
1955
  throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? stderrOnly : why}`);
1906
1956
  }
1907
1957
  /* WHAT IT SENT OTHERS TO DO, FROM THE RECORD. Level 3 has no `dispatch`, so it
@@ -1942,14 +1992,14 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1942
1992
  }
1943
1993
  catch (error) {
1944
1994
  const why = error instanceof Error ? error.message : String(error);
1945
- await endRun(client, level, runId, claimed.run_card_id, why);
1995
+ await endRun(client, level, runId, claimed.run_card_id, why, attempt);
1946
1996
  throw new Error(`NO AGENT IS RUNNING: ${why}`);
1947
1997
  }
1948
1998
  /* ═══ WHY IT DIED IS WHAT DIFFERS, AND IT IS TOLD THE TRUTH ABOUT IT. ═══
1949
1999
  `resumePrompt` opens by saying the machine went down, which is true of the
1950
2000
  sweep and false of a retry: the daemon that watched this harness exit is
1951
2001
  still running. See `retryPrompt`. */
1952
- const started = await startTrackedAgent(client, runId, preparation, tools.work, withStandingRules(rules, afterPid === null
2002
+ const started = await startTrackedAgent(client, runId, preparation, tools.work, attempt, withStandingRules(rules, afterPid === null
1953
2003
  ? resumePrompt(claimed.run_brief, claimed.run_report, children)
1954
2004
  : retryPrompt(claimed.run_brief, claimed.run_report, children), where.block, [], pictures), level, tools.urlFor(runId), where.cwd, undefined, settings);
1955
2005
  if (started.pid === null) {
@@ -1962,7 +2012,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1962
2012
  const answer = await started.answered;
1963
2013
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
1964
2014
  // The same level fork, for the same reason. See `endRun`.
1965
- await endRun(client, level, runId, claimed.run_card_id, reason);
2015
+ await endRun(client, level, runId, claimed.run_card_id, reason, attempt);
1966
2016
  throw new Error(`NO AGENT IS RUNNING: ${reason}`);
1967
2017
  }
1968
2018
  out(`resume run ${runId} level ${level} pid ${started.pid} `
@@ -2012,10 +2062,11 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
2012
2062
  async function startRearmed(client, tools, machineId, row) {
2013
2063
  const preparation = tools.work?.prepare(row.run_id);
2014
2064
  try {
2065
+ const attempt = capturedPanelAttempt([row], row.run_id, tools.work);
2015
2066
  const level = row.run_level === 1 ? 1 : row.run_level === 2 ? 2 : 3;
2016
2067
  if (row.run_level !== level) {
2017
2068
  const why = `run ${row.run_id} is at level ${row.run_level}, which cannot be spawned`;
2018
- await giveUp(client, row.run_id, why);
2069
+ await giveUp(client, row.run_id, why, undefined, attempt);
2019
2070
  throw new Error(why);
2020
2071
  }
2021
2072
  let where;
@@ -2036,7 +2087,7 @@ async function startRearmed(client, tools, machineId, row) {
2036
2087
  // The level fork, which this path needs for the same reason `resumeRun`'s
2037
2088
  // two do: a re-arm serves level 1, and a level 1 run holds the person's
2038
2089
  // message. See `endRun`.
2039
- await endRun(client, level, row.run_id, row.run_card_id, why);
2090
+ await endRun(client, level, row.run_id, row.run_card_id, why, attempt);
2040
2091
  throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? said : why}`);
2041
2092
  }
2042
2093
  /* WHAT IT SENT OTHERS TO DO AND WHAT THEY WROTE, FROM THE RECORD, AS LATE AS
@@ -2077,17 +2128,17 @@ async function startRearmed(client, tools, machineId, row) {
2077
2128
  }
2078
2129
  catch (error) {
2079
2130
  const why = error instanceof Error ? error.message : String(error);
2080
- await endRun(client, level, row.run_id, row.run_card_id, why);
2131
+ await endRun(client, level, row.run_id, row.run_card_id, why, attempt);
2081
2132
  throw new Error(`NO AGENT IS RUNNING: ${why}`);
2082
2133
  }
2083
- const started = await startTrackedAgent(client, row.run_id, preparation, tools.work, withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id), where.cwd, undefined, settings);
2134
+ const started = await startTrackedAgent(client, row.run_id, preparation, tools.work, attempt, withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id), where.cwd, undefined, settings);
2084
2135
  if (started.pid === null) {
2085
2136
  if (started.interrupted?.())
2086
2137
  throw new Error('Work was interrupted by the service command.');
2087
2138
  const answer = await started.answered;
2088
2139
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
2089
2140
  // The same level fork, for the same reason. See `endRun`.
2090
- await endRun(client, level, row.run_id, row.run_card_id, reason);
2141
+ await endRun(client, level, row.run_id, row.run_card_id, reason, attempt);
2091
2142
  throw new Error(`NO AGENT IS RUNNING: ${reason}`);
2092
2143
  }
2093
2144
  out(`rearm run ${row.run_id} level ${level} pid ${started.pid} `
@@ -2269,7 +2320,7 @@ export function resumableOwnerSessionId(candidate, machineId, machineHarness) {
2269
2320
  ? local.nativeSessionId
2270
2321
  : undefined;
2271
2322
  }
2272
- async function activateOwner(client, tools, machineId, runId, afterProcessToken = null, afterPid = null) {
2323
+ async function activateOwner(client, tools, machineId, runId, afterProcessToken = null, afterPid = null, recovered) {
2273
2324
  let claimOperationId;
2274
2325
  const preparation = tools.work?.prepare();
2275
2326
  try {
@@ -2283,7 +2334,13 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
2283
2334
  const machineHarness = candidate.handed_back_at === null && candidate.harness !== null
2284
2335
  ? harness({ CTRL_SPC_V3_AGENT: candidate.harness })
2285
2336
  : await selectedHarness(client, machineId);
2286
- const resumeSessionId = resumableOwnerSessionId(candidate, machineId, machineHarness);
2337
+ if (recovered?.nativeContext && (recovered.nativeContext.harness !== machineHarness || recovered.nativeContext.processToken !== null && typeof recovered.nativeContext.processToken !== 'string' || recovered.nativeContext.nativeSessionId !== undefined && typeof recovered.nativeContext.nativeSessionId !== 'string'))
2338
+ throw new Error('The saved native conversation context is invalid.');
2339
+ const resumeSessionId = recovered?.nativeContext
2340
+ ? resumableOwnerSessionId({ ...candidate, process_token: recovered.nativeContext.processToken }, machineId, recovered.nativeContext.harness)
2341
+ : resumableOwnerSessionId(candidate, machineId, machineHarness);
2342
+ if (recovered?.nativeContext?.nativeSessionId && resumeSessionId !== recovered.nativeContext.nativeSessionId)
2343
+ throw new Error('The saved native conversation is no longer available for this attempt.');
2287
2344
  /* ═══ THE EXISTENCE CHECK BEFORE THE CLAIM, AND THE COPY AFTER IT. ═══ This
2288
2345
  was the whole resolution, which was right while resolving meant reading a
2289
2346
  folder out of a file. Since worktrees-8 it also CREATES one, and a daemon
@@ -2294,13 +2351,13 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
2294
2351
  if (candidate.codebase_id !== null) {
2295
2352
  checkoutForCodebase(await codebaseOfRun(client, candidate.id), hostname());
2296
2353
  }
2297
- const response = await panelClaim(client, tools.work, 'panel3_take_owner_activation', {
2354
+ const response = recovered ?? await panelClaim(client, tools.work, 'panel3_take_owner_activation', {
2298
2355
  p_run_id: runId,
2299
2356
  p_machine_id: machineId,
2300
2357
  p_agent: machineHarness,
2301
2358
  p_after_process_token: afterProcessToken,
2302
2359
  p_after_pid: afterPid,
2303
- });
2360
+ }, { nativeSessionId: resumeSessionId, processToken: candidate.process_token, harness: machineHarness });
2304
2361
  const { data, error, operationId } = response;
2305
2362
  claimOperationId = operationId;
2306
2363
  if (error)
@@ -2308,6 +2365,7 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
2308
2365
  const claimed = data?.[0];
2309
2366
  if (!claimed)
2310
2367
  return null;
2368
+ const attempt = capturedPanelAttempt(data ?? [], runId, tools.work);
2311
2369
  const [events, children] = await Promise.all([
2312
2370
  ownerConversation(client, claimed.run_card_id, new Set(claimed.turn_ids ?? [])),
2313
2371
  ownerChildren(client, runId),
@@ -2370,7 +2428,7 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
2370
2428
  }
2371
2429
  catch (error) {
2372
2430
  const why = error instanceof Error ? error.message : String(error);
2373
- await giveUp(client, runId, why, claimed.process_token);
2431
+ await giveUp(client, runId, why, claimed.process_token, attempt);
2374
2432
  throw new Error(`NO AGENT IS RUNNING: ${why}`);
2375
2433
  }
2376
2434
  /* THE MERGE, OFF THE MARK ON THE ASK, BEFORE THE AGENT THAT WILL SPEAK ABOUT
@@ -2391,13 +2449,13 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
2391
2449
  : ownerActivationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered !== null && !delivered.mine
2392
2450
  ? { id: delivered.id, question: delivered.question }
2393
2451
  : null, currentArtifactAnswer, afterPid !== null, landing);
2394
- const started = await startTrackedAgent(client, runId, preparation, tools.work, withStandingRules(rules, prompt, where.block, attached, pictures), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) }, settings);
2452
+ const started = await startTrackedAgent(client, runId, preparation, tools.work, attempt, withStandingRules(rules, prompt, where.block, attached, pictures), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) }, settings);
2395
2453
  if (started.pid === null) {
2396
2454
  if (started.interrupted?.())
2397
2455
  throw new Error('Work was interrupted by the service command.');
2398
2456
  const answer = await started.answered;
2399
2457
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
2400
- await giveUp(client, runId, reason, claimed.process_token);
2458
+ await giveUp(client, runId, reason, claimed.process_token, attempt);
2401
2459
  throw new Error(`NO AGENT IS RUNNING: ${reason}`);
2402
2460
  }
2403
2461
  try {
@@ -3324,6 +3382,7 @@ export async function run(args, injected, signal, lifecycle, work) {
3324
3382
  const machineId = getMachineIdentity().id;
3325
3383
  const machineName = hostname();
3326
3384
  let listeningHarness = null;
3385
+ const listeningRoutes = new Set();
3327
3386
  /* THE RUNS THIS PROCESS IS HOLDING RIGHT NOW, so recovery cannot declare its
3328
3387
  own live work dead in the moment before a pid is recorded. It covers THIS
3329
3388
  daemon only, which is why `PID_GRACE_MS` exists for the other ones. Keyed by
@@ -3349,9 +3408,10 @@ export async function run(args, injected, signal, lifecycle, work) {
3349
3408
  `tools` is referenced inside the callback it is being given, which is safe
3350
3409
  for the plain reason that the callback can only run once a request has
3351
3410
  arrived at a server that by then exists. */
3411
+ if (work && !work.cloudAllowed())
3412
+ throw new Error('Cloud operations are suspended for this sign-in.');
3352
3413
  if (work)
3353
3414
  await reconcilePanelInterruptions(current(), machineId, work);
3354
- lifecycle?.reconciled?.();
3355
3415
  const tools = await startToolsServer(current(), async (parentRunId, brief, codebase, processToken, choice) => {
3356
3416
  const endClaim = work?.beginClaim();
3357
3417
  try {
@@ -3418,9 +3478,18 @@ export async function run(args, injected, signal, lifecycle, work) {
3418
3478
  `--once` still fails loudly, because the acceptance harness reads the exit
3419
3479
  code and a swallowed failure there would make a broken suite look green. */
3420
3480
  try {
3481
+ if (work && !work.cloudAllowed()) {
3482
+ await sleep(POLL_INTERVAL_MS);
3483
+ continue;
3484
+ }
3421
3485
  if (work)
3422
3486
  await reconcilePanelInterruptions(current(), machineId, work);
3423
3487
  if (work && !work.allowed()) {
3488
+ // A successful read proves polling before recovery opens admission.
3489
+ const { error } = await current().from('panel3_cards').select('id').limit(1);
3490
+ if (error)
3491
+ throw error;
3492
+ lifecycle?.ready();
3424
3493
  await sleep(POLL_INTERVAL_MS);
3425
3494
  continue;
3426
3495
  }
@@ -3431,30 +3500,96 @@ export async function run(args, injected, signal, lifecycle, work) {
3431
3500
  to run first: `panel3_stop_card` has already ended the runs, so recovery
3432
3501
  cannot see them and neither take can start them. */
3433
3502
  await killStopped(current(), machineId, work);
3503
+ for (const claim of work?.recoveredClaims() ?? []) {
3504
+ const attempts = panelClaimAttempts(claim.result);
3505
+ const eligible = new Set();
3506
+ for (const attempt of attempts) {
3507
+ if (inFlight.has(attempt.runId))
3508
+ continue;
3509
+ const pending = work?.claimAttempt(attempt.runId);
3510
+ if (!pending)
3511
+ continue; // Preparation or an actual stop already owns it.
3512
+ const { data, error } = await current().from('panel3_runs')
3513
+ .select('started_at,resumed_at,process_token,state,ended_at').eq('id', attempt.runId).maybeSingle();
3514
+ if (error)
3515
+ throw error;
3516
+ if (data?.state === 'running' && data.ended_at === null && data.started_at === attempt.startedAt
3517
+ && (data.resumed_at ?? null) === attempt.resumedAt && (data.process_token ?? null) === attempt.processToken)
3518
+ eligible.add(attempt.runId);
3519
+ }
3520
+ const rows = (claim.result ?? []).filter((row) => eligible.has(row._attempt?.run_id ?? row.run_id));
3521
+ const response = { data: rows, error: null, operationId: claim.id, nativeContext: claim.args?.nativeContext };
3522
+ const args = claim.args ?? {};
3523
+ if (rows.length) {
3524
+ if (claim.action === 'panel3_take_turns') {
3525
+ for (const [cardId, turns] of byCard(rows))
3526
+ hold(turns[0].run_id, answerCard(current(), tools, machineId, cardId, turns));
3527
+ }
3528
+ else if (claim.action === 'panel3_take_rearms') {
3529
+ for (const row of rows) {
3530
+ const started = await startRearmed(current(), tools, machineId, row);
3531
+ hold(row.run_id, started.settled);
3532
+ }
3533
+ }
3534
+ else if (claim.action === 'panel3_dispatch') {
3535
+ const row = rows[0];
3536
+ const codebase = args.p_codebase_id ? await codebaseOfRun(current(), row.run_id) : null;
3537
+ const started = await startChild(current(), tools, machineId, String(args.p_parent_run_id), String(args.p_brief), codebase, typeof args.p_process_token === 'string' ? args.p_process_token : undefined, {}, response);
3538
+ hold(started.runId, started.settled);
3539
+ }
3540
+ else if (claim.action === 'panel3_resume') {
3541
+ const runId = String(args.p_run_id);
3542
+ const started = await resumeRun(current(), tools, machineId, runId, typeof args.p_after_pid === 'number' ? args.p_after_pid : null, response);
3543
+ if (started)
3544
+ hold(runId, started.settled);
3545
+ }
3546
+ else if (claim.action === 'panel3_take_owner_activation') {
3547
+ const runId = String(args.p_run_id);
3548
+ const started = await activateOwner(current(), tools, machineId, runId, typeof args.p_after_process_token === 'string' ? args.p_after_process_token : null, typeof args.p_after_pid === 'number' ? args.p_after_pid : null, response);
3549
+ if (started)
3550
+ hold(runId, started.settled);
3551
+ }
3552
+ else
3553
+ throw new Error('The saved panel claim has an unsupported action.');
3554
+ }
3555
+ work?.finishRpcs([claim.id]);
3556
+ }
3434
3557
  /* Publish readiness before claiming work. A card with an untaken turn reads the same whether a daemon
3435
3558
  is two seconds away or nobody has one running; this row is the only place
3436
3559
  the difference exists. It is written before the takes rather than after
3437
3560
  so that a machine which is up but busy still reads as up. */
3438
- // Resolve before claiming work. A failed read or missing binary must not
3439
- // leave the previous agent advertised as ready for new assignments.
3440
- let machineHarness;
3441
- try {
3442
- machineHarness = await selectedHarness(current(), machineId);
3561
+ const installed = recoveryHarnesses();
3562
+ for (const route of [...listeningRoutes]) {
3563
+ if (!installed.includes(route)) {
3564
+ await stopListening(current(), machineId, route);
3565
+ listeningRoutes.delete(route);
3566
+ }
3443
3567
  }
3444
- catch (error) {
3445
- if (listeningHarness !== null) {
3446
- await stopListening(current(), machineId, listeningHarness);
3447
- listeningHarness = null;
3568
+ // A primary selection failure cannot skip an installed recovery route.
3569
+ for (const route of installed) {
3570
+ try {
3571
+ const response = await panelClaim(current(), work, 'panel3_take_turns', { p_machine_id: machineId, p_agent: route, p_recovery_only: true });
3572
+ if (response.operationId)
3573
+ claimOperations.push(response.operationId);
3574
+ const taken = await returned(Promise.resolve(response), 'take', 'explicit authentication recovery');
3575
+ await sayListening(current(), machineId, machineName, route);
3576
+ await sayPollingProblem(current(), machineId, route, null);
3577
+ listeningRoutes.add(route);
3578
+ for (const [cardId, turns] of byCard(taken))
3579
+ hold(turns[0].run_id, answerCard(current(), tools, machineId, cardId, turns));
3580
+ }
3581
+ catch (error) {
3582
+ try {
3583
+ await sayPollingProblem(current(), machineId, route, 'poll_failed');
3584
+ }
3585
+ catch { /* The last successful observation expires. */ }
3586
+ said(`Authentication recovery polling failed for ${route}: ${error instanceof Error ? error.message : String(error)}`);
3448
3587
  }
3449
- throw error;
3450
3588
  }
3589
+ const machineHarness = await selectedHarness(current(), machineId);
3590
+ listeningHarness = machineHarness;
3451
3591
  if (signal?.aborted)
3452
3592
  break;
3453
- if (listeningHarness !== null && listeningHarness !== machineHarness) {
3454
- await stopListening(current(), machineId, listeningHarness);
3455
- }
3456
- listeningHarness = machineHarness;
3457
- await sayListening(current(), machineId, machineName, machineHarness);
3458
3593
  await reconcileOwnerSessions(current(), machineId, machineHarness, new Set(inFlight.keys()), work);
3459
3594
  await recoverStranded(current(), tools, machineId, new Set(inFlight.keys()), hold);
3460
3595
  /* ═══ AND THE COPIES OF CARDS THAT ARE OVER. ═══ After recovery,
@@ -3481,7 +3616,7 @@ export async function run(args, injected, signal, lifecycle, work) {
3481
3616
  /* THE MACHINE ID GOES IN because the take writes the run row, and a run has
3482
3617
  to say where it is running: the exclusion is cross-machine and recovery is
3483
3618
  per-machine, so a row with nobody's machine on it could be neither. */
3484
- const takenResponse = await panelClaim(current(), work, 'panel3_take_turns', { p_machine_id: machineId, p_agent: machineHarness });
3619
+ const takenResponse = await panelClaim(current(), work, 'panel3_take_turns', { p_machine_id: machineId, p_agent: machineHarness, p_recovery_only: false });
3485
3620
  if (takenResponse.operationId)
3486
3621
  claimOperations.push(takenResponse.operationId);
3487
3622
  const taken = await returned(Promise.resolve(takenResponse), 'take', 'turns');
@@ -3529,6 +3664,8 @@ export async function run(args, injected, signal, lifecycle, work) {
3529
3664
  // rather than becoming an unhandled rejection.
3530
3665
  hold(runId, answerCard(current(), tools, machineId, cardId, turns));
3531
3666
  }
3667
+ await sayListening(current(), machineId, machineName, machineHarness);
3668
+ listeningRoutes.add(machineHarness);
3532
3669
  await sayPollingProblem(current(), machineId, machineHarness, null);
3533
3670
  lifecycle?.ready();
3534
3671
  if (once) {
@@ -3565,8 +3702,8 @@ export async function run(args, injected, signal, lifecycle, work) {
3565
3702
  }
3566
3703
  finally {
3567
3704
  try {
3568
- if (listeningHarness !== null)
3569
- await stopListening(current(), machineId, listeningHarness);
3705
+ for (const route of listeningRoutes)
3706
+ await stopListening(current(), machineId, route);
3570
3707
  }
3571
3708
  finally {
3572
3709
  await tools.close();
@@ -3602,8 +3739,7 @@ export function startPanel(injected, work) {
3602
3739
  }
3603
3740
  return false;
3604
3741
  },
3605
- reconciled: () => resolveReady(),
3606
- ready: () => { if (!requested)
3742
+ ready: () => { resolveReady(); if (!requested)
3607
3743
  resolveRestart?.(); },
3608
3744
  }, work);
3609
3745
  }