@ctrl-spc/cs 0.7.13 → 0.7.15

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.
@@ -138,19 +138,176 @@ import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, load
138
138
  import { forgetSecrets, redactSecrets } from './secrets.js';
139
139
  import { sayListening, sayPollingProblem, stopListening } from './presence.js';
140
140
  import { selectedHarness } from './coordinator.js';
141
- import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, worktreesOnThisMachine, } from './checkout.js';
141
+ import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, ownsReconciliation, prepareCardReconciliation, finishCardReconciliation, worktreesOnThisMachine, } from './checkout.js';
142
142
  import { harness, startAgent } from './spawn.js';
143
143
  import { establishOwnerSession, listOwnerSessionIds, OWNER_SESSION_GRACE_MS, readOwnerSession, removeOwnerSession, validSessionUuid, writeOwnerSession, } from './session.js';
144
- import { startToolsServer, PANEL3_IMAGES_BUCKET } from './tools.js';
144
+ import { startToolsServer, processActivationIsCurrent, PANEL3_IMAGES_BUCKET } from './tools.js';
145
145
  import { listPanel3CodexOwnerHomeIds, removePanel3CodexOwnerHome, } from '../codex-home.js';
146
146
  import { getMachineIdentity, scratchDir } from '../config.js';
147
147
  import { listCodebases } from '../codebases.js';
148
148
  import { killTree, processIsAlive } from '../win-shell.js';
149
+ import { randomUUID } from 'node:crypto';
149
150
  import { hostname, uptime } from 'node:os';
150
151
  import { execFileSync } from 'node:child_process';
151
152
  import { existsSync } from 'node:fs';
152
153
  import { mkdir, writeFile } from 'node:fs/promises';
153
154
  import { join } from 'node:path';
155
+ function recoveryAttempt(run) {
156
+ return { runId: run.id, processToken: run.process_token, startedAt: run.started_at, resumedAt: run.resumed_at };
157
+ }
158
+ async function startTrackedAgent(client, runId, preparation, work, ...args) {
159
+ if (!preparation)
160
+ return startAgent(...args);
161
+ const claimed = work?.claimAttempt(runId);
162
+ if (!claimed)
163
+ throw new Error('This run no longer belongs to the claimed attempt.');
164
+ preparation.setAttempt(claimed);
165
+ let preparationFailed = false;
166
+ let failureReason = '';
167
+ const execution = {
168
+ ...preparation.execution,
169
+ register: async (child, agent) => {
170
+ await preparation.execution.register(child, agent);
171
+ // An ordinary stop can reopen admission after a refusal. Every such
172
+ // wait needs a fresh cloud check before the prompt is authorized.
173
+ try {
174
+ let waited;
175
+ do {
176
+ const { data, error } = await client.from('panel3_runs')
177
+ .select('process_token,started_at,resumed_at,state,ended_at').eq('id', runId).single();
178
+ if (error)
179
+ throw error;
180
+ if (!data || data.state !== 'running' || data.ended_at !== null
181
+ || claimed.processToken !== (data.process_token ?? null) || claimed.startedAt !== data.started_at
182
+ || claimed.resumedAt !== (data.resumed_at ?? null)) {
183
+ throw new Error('This run no longer belongs to the claimed attempt.');
184
+ }
185
+ waited = await preparation.execution.waitForPromptAdmission();
186
+ } while (waited);
187
+ }
188
+ catch (error) {
189
+ if (!preparation.execution.interrupted()) {
190
+ const reason = error instanceof Error ? error.message : String(error);
191
+ preparation.deferFailure(reason, args[1]);
192
+ preparationFailed = true;
193
+ failureReason = reason;
194
+ }
195
+ throw error;
196
+ }
197
+ },
198
+ };
199
+ const started = startAgent(args[0], args[1], args[2], args[3], args[4], args[5], execution);
200
+ // Do not hold the claim barrier while registration waits for an ordinary
201
+ // stop to be refused. The original answer promises proven process closure.
202
+ const answered = started.answered.then(async (answer) => {
203
+ if (preparationFailed && !preparation.execution.interrupted()) {
204
+ await endRun(client, args[1], runId, claimed.cardId, failureReason, claimed);
205
+ preparation.execution.complete();
206
+ }
207
+ return answer;
208
+ });
209
+ void answered.catch(() => { }); // The caller records the PID before awaiting settlement.
210
+ return { ...started, answered, attempt: claimed, preparationFailed: () => preparationFailed };
211
+ }
212
+ function panelClaimAttempts(result) {
213
+ if (!Array.isArray(result))
214
+ throw new Error('The claim journal returned no complete result.');
215
+ const unique = new Map();
216
+ for (const row of result) {
217
+ const attempt = row?._attempt;
218
+ if (!attempt || typeof attempt.run_id !== 'string' || typeof attempt.card_id !== 'string' || typeof attempt.started_at !== 'string') {
219
+ throw new Error('The claim journal returned an incomplete run identity.');
220
+ }
221
+ unique.set(attempt.run_id, { runId: attempt.run_id, cardId: attempt.card_id, processToken: attempt.process_token ?? null,
222
+ pid: attempt.pid ?? null, startedAt: attempt.started_at, resumedAt: attempt.resumed_at ?? null,
223
+ observedPendingTurnIds: attempt.observed_pending_turn_ids ?? null });
224
+ }
225
+ return [...unique.values()];
226
+ }
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);
231
+ try {
232
+ const response = await client.rpc(action, { ...args, p_operation_id: operationId });
233
+ if (!response.error) {
234
+ const journal = await client.rpc('panel3_reconcile_claim', { p_machine_id: args.p_machine_id, p_operation_id: operationId });
235
+ if (journal.error)
236
+ throw journal.error;
237
+ const row = journal.data?.[0];
238
+ if (row?.outcome !== 'completed' || row.action !== action)
239
+ throw new Error('The claim was cancelled before it could start.');
240
+ work.recordRpc(operationId, panelClaimAttempts(row.result), false);
241
+ }
242
+ if (response.error)
243
+ work.deferRpc(operationId);
244
+ return { ...response, operationId };
245
+ }
246
+ catch (error) {
247
+ work.deferRpc(operationId);
248
+ throw error;
249
+ }
250
+ }
251
+ async function reconcilePanelInterruptions(client, machineId, work) {
252
+ const capability = await client.rpc('panel3_interrupt_machine_runs', { p_machine_id: machineId,
253
+ p_operation_id: randomUUID(), p_interrupted_at: new Date().toISOString(), p_attempts: [] });
254
+ if (capability.error)
255
+ throw capability.error;
256
+ for (const failure of work.failedPreparations()) {
257
+ await endRun(client, failure.level, failure.attempt.runId, failure.attempt.cardId, failure.reason, failure.attempt);
258
+ work.acknowledgePreparationFailure(failure.id);
259
+ }
260
+ for (const claim of work.pendingRpcs()) {
261
+ const journal = await client.rpc('panel3_reconcile_claim', { p_machine_id: machineId, p_operation_id: claim.id });
262
+ if (journal.error)
263
+ throw journal.error;
264
+ const row = journal.data?.[0];
265
+ if (row?.outcome === 'cancelled')
266
+ work.recordRpc(claim.id, [], true);
267
+ else if (row?.outcome === 'completed' && row.action === claim.action)
268
+ work.recordRpc(claim.id, panelClaimAttempts(row.result), true);
269
+ else
270
+ throw new Error('The pending claim could not be reconciled.');
271
+ }
272
+ if (work.legacyRequired()) {
273
+ const attempts = [];
274
+ for (let offset = 0;; offset += 500) {
275
+ const { data, error } = await client.from('panel3_runs')
276
+ .select('id,card_id,process_token,pid,started_at,resumed_at')
277
+ .eq('machine_id', machineId).is('ended_at', null).order('id').range(offset, offset + 499);
278
+ if (error)
279
+ throw error;
280
+ if (!Array.isArray(data))
281
+ throw new Error('The old run snapshot did not return a complete page.');
282
+ for (const row of data) {
283
+ if (typeof row.started_at !== 'string')
284
+ throw new Error('An old run has no exact attempt identity.');
285
+ attempts.push({ runId: row.id, cardId: row.card_id, processToken: row.process_token ?? null,
286
+ pid: row.pid ?? null, startedAt: row.started_at, resumedAt: row.resumed_at ?? null, observedPendingTurnIds: null });
287
+ }
288
+ if (data.length < 500)
289
+ break;
290
+ }
291
+ for (const attempt of attempts)
292
+ work.recordLegacy(attempt);
293
+ work.legacyComplete();
294
+ }
295
+ for (const receipt of work.receipts()) {
296
+ const attempt = receipt.attempt;
297
+ const { data, error } = await client.rpc('panel3_interrupt_machine_runs', {
298
+ p_machine_id: machineId, p_operation_id: receipt.operationId, p_interrupted_at: receipt.interruptedAt,
299
+ p_attempts: [{ run_id: attempt.runId, process_token: attempt.processToken, pid: receipt.pid ?? attempt.pid ?? null,
300
+ started_at: attempt.startedAt, resumed_at: attempt.resumedAt, observed_pending_turn_ids: attempt.observedPendingTurnIds }],
301
+ });
302
+ if (error)
303
+ throw error;
304
+ const result = data?.[0];
305
+ if (!result || result.run_id !== attempt.runId || !['interrupted', 'already_interrupted', 'continued', 'stale'].includes(result.outcome)) {
306
+ throw new Error('The interrupted run was not acknowledged. Work remains protected.');
307
+ }
308
+ work.acknowledge(receipt.id);
309
+ }
310
+ }
154
311
  const USAGE = 'usage: run [--once]';
155
312
  /** How long between takes. Short, because it is the whole delay between a user
156
313
  * sending and a card showing an agent on it, and the take is one small indexed
@@ -242,19 +399,9 @@ export async function landingOffer(client, askId) {
242
399
  return 'unclear';
243
400
  return selected[0] === LAND ? 'land' : selected[0] === LEAVE ? 'leave' : 'unclear';
244
401
  }
245
- /**
246
- * ═══ THE PRODUCT PERFORMS THE LANDING, HERE, BEFORE THE AGENT THAT WILL SPEAK
247
- * ABOUT IT STARTS. ═══
248
- *
249
- * Not a tool, because a tool means an agent decides whether a yes takes effect,
250
- * and an agent that finishes the card instead leaves a person who clicked yes
251
- * with nothing. Not the poll's sweep, because the sweep cannot tell the agent
252
- * what it did.
253
- *
254
- * ═══ AND A FAILURE DOES NOT END THE RUN. ═══ The agent has to be started to
255
- * tell the person, so what happened is returned as the sentence it will be
256
- * handed rather than written to `failed_because`.
257
- */
402
+ /** The initial approved landing is automatic. Conflict recovery returns here
403
+ * after the owner resolves and verifies the files through recover_landing.
404
+ * Failures are reported to the agent so the card can continue truthfully. */
258
405
  export function landCardWork(where, outcome) {
259
406
  const card = where.card;
260
407
  if (outcome !== 'land')
@@ -270,22 +417,80 @@ export function landCardWork(where, outcome) {
270
417
  };
271
418
  }
272
419
  try {
273
- /* THE COMMIT FIRST, AND ITS ABSENCE IS NOT A FAILURE. Between the answer and
274
- this re-arm every run on the card has ended, so the sweep may already have
275
- committed the copy and taken it away; `workingDirectory` has just rebuilt
276
- it from the branch, and the only difference is that there is nothing left
277
- to commit. */
420
+ if (ownsReconciliation(card)) {
421
+ return { outcome: 'reconciling', branch: card.branch, base: card.base, ...prepareCardReconciliation(card) };
422
+ }
423
+ // A repeated activation may recreate an already-committed working copy;
424
+ // in that case the commit helper has nothing left to do.
278
425
  commitCardWork(card.folder, `${card.codebaseName}'s copy of this card on branch ${card.branch}`);
279
426
  mergeIntoBase(card.source, card.branch, card.base, card.codebaseName);
280
427
  return { outcome: 'landed', branch: card.branch, base: card.base };
281
428
  }
282
429
  catch (error) {
430
+ if (error?.mergeConflicts) {
431
+ try {
432
+ return { outcome: 'reconciling', branch: card.branch, base: card.base, ...prepareCardReconciliation(card) };
433
+ }
434
+ catch (recoveryError) {
435
+ error = recoveryError;
436
+ }
437
+ }
283
438
  return {
284
439
  outcome: 'refused',
285
440
  because: error instanceof Error ? error.message : String(error),
286
441
  };
287
442
  }
288
443
  }
444
+ /** Recovery uses the current owner's recorded approval, never model-supplied
445
+ * paths or refs. Recheck it before resolving a working copy or mutating Git. */
446
+ export async function recoverLanding(client, machineId, runId, processToken, action) {
447
+ if (!await processActivationIsCurrent(client, runId, processToken)) {
448
+ throw new Error('This activation no longer owns the card. Nothing was merged.');
449
+ }
450
+ const runs = await returned(client.from('panel3_runs').select('card_id, machine_id, level, completion_requested_token')
451
+ .eq('id', runId).eq('process_token', processToken), 'read', 'the merge recovery owner');
452
+ const run = runs[0];
453
+ if (!run || run.level !== 2 || run.machine_id !== machineId) {
454
+ throw new Error('Merge recovery must run with this card’s conversation owner on its assigned machine. Nothing was merged.');
455
+ }
456
+ const offers = await returned(client.from('panel3_asks').select('id, run_id').eq('card_id', run.card_id)
457
+ .eq('offers_landing', true).order('created_at', { ascending: false }).limit(1), 'read', 'the latest merge approval');
458
+ const offer = offers[0];
459
+ if (!offer || offer.run_id !== runId || await landingOffer(client, offer.id) !== 'land') {
460
+ throw new Error('The latest ending choice does not approve a merge. Nothing was merged; use offer_ending after the work is verified.');
461
+ }
462
+ if (action === 'finish' && run.completion_requested_token !== processToken) {
463
+ throw new Error('Resolve and verify the work, then call write_report(work_complete=true) before finishing merge recovery.');
464
+ }
465
+ const activeWork = await returned(client.from('panel3_runs').select('id').eq('card_id', run.card_id).neq('id', runId)
466
+ .or('ended_at.is.null,pid.not.is.null'), 'read', 'other processes on this card');
467
+ if (activeWork.length > 0)
468
+ throw new Error('Wait for the other agents on this card to exit before recovering its merge. Nothing was merged.');
469
+ // Preparation or a failed retry is more work, not a completed assignment.
470
+ // Consume the completion marker so every new resolution must be verified.
471
+ let clearCompletion = client.from('panel3_runs')
472
+ .update({ completion_requested_token: null, completion_summary: null })
473
+ .eq('id', runId).eq('process_token', processToken).eq('state', 'running').is('ended_at', null);
474
+ if (action === 'finish')
475
+ clearCompletion = clearCompletion.eq('completion_requested_token', processToken);
476
+ const cleared = await returned(clearCompletion.select('id'), 'update', 'the merge recovery activation');
477
+ if (cleared.length !== 1 || !await processActivationIsCurrent(client, runId, processToken)) {
478
+ throw new Error('The card’s activation or verified work changed. Refresh the card, settle its work, and report verification again before retrying. Nothing was merged.');
479
+ }
480
+ const where = await workingDirectory(client, runId, 2, true);
481
+ const card = where.card;
482
+ if (!card || card.landing !== 'main') {
483
+ throw new Error('This card is not configured to merge onto its base branch. Nothing was merged.');
484
+ }
485
+ if (!await processActivationIsCurrent(client, runId, processToken)) {
486
+ throw new Error('This activation no longer owns the card. Nothing was merged.');
487
+ }
488
+ if (action === 'prepare') {
489
+ return { outcome: 'reconciling', branch: card.branch, base: card.base, ...prepareCardReconciliation(card) };
490
+ }
491
+ finishCardReconciliation(card);
492
+ return landCardWork(where, 'land');
493
+ }
289
494
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
290
495
  /**
291
496
  * ═══ NOTHING IN THIS FILE STAMPS A COLUMN FROM THIS MACHINE'S CLOCK ANY MORE,
@@ -502,18 +707,24 @@ pictures = []) => {
502
707
  * a dispatch knows the brief before the row exists and writes it there. Passing
503
708
  * it again would be rewriting a brief that ux.md fixes at dispatch.
504
709
  *
505
- * A failure here is NOT fatal. The agent is already running and killing it over
506
- * a bookkeeping write would cost the user the work. What is lost is precision:
507
- * the run has no pid, so once this daemon is gone recovery treats it as dead —
508
- * which errs towards answering again rather than stranding.
710
+ * A bookkeeping failure does not discard the locally tracked execution. Exact
711
+ * activation predicates prevent a delayed write from reviving an ended run or
712
+ * attaching this process to a later continuation.
509
713
  */
510
- async function recordProcess(client, runId, pid, brief, processToken) {
714
+ async function recordProcess(client, runId, pid, brief, processToken, attempt) {
511
715
  let query = client
512
716
  .from('panel3_runs')
513
717
  .update({ pid, ...(brief === undefined ? {} : { brief }) })
514
- .eq('id', runId);
718
+ .eq('id', runId)
719
+ .eq('state', 'running')
720
+ .is('ended_at', null);
515
721
  if (processToken !== undefined)
516
722
  query = query.eq('process_token', processToken);
723
+ if (attempt) {
724
+ query = query.eq('started_at', attempt.startedAt);
725
+ query = attempt.resumedAt === null ? query.is('resumed_at', null) : query.eq('resumed_at', attempt.resumedAt);
726
+ query = attempt.processToken === null ? query.is('process_token', null) : query.eq('process_token', attempt.processToken);
727
+ }
517
728
  const written = await returned(query.select('id'), 'record what is running', `run ${runId}`);
518
729
  if (written.length === 0)
519
730
  throw new Error(`could not record what is running for run ${runId}: its activation has ended`);
@@ -531,15 +742,21 @@ async function recordProcess(client, runId, pid, brief, processToken) {
531
742
  * there first. It is a fact about the record rather than a failure, so it does
532
743
  * not go through `returned()`, exactly as the answer's own null does not.
533
744
  */
534
- async function giveUp(client, runId, reason, processToken) {
745
+ async function giveUp(client, runId, reason, processToken, attempt) {
535
746
  const { data, error } = await client
536
747
  .rpc('panel3_give_up', {
537
748
  p_run_id: runId,
538
749
  p_reason: reason,
539
750
  ...(processToken === undefined ? {} : { p_process_token: processToken }),
751
+ ...(attempt ? { p_process_token: attempt.processToken, p_expected_attempt: {
752
+ started_at: attempt.startedAt, resumed_at: attempt.resumedAt,
753
+ } } : {}),
540
754
  });
541
755
  if (error)
542
756
  throw new Error(`could not give up run ${runId}: ${error.message}`);
757
+ if (attempt && data !== null && (!Number.isInteger(data) || data < 0)) {
758
+ throw new Error(`could not give up run ${runId}: no ending was acknowledged`);
759
+ }
543
760
  /* THE RUN IS OVER, so whatever it read is dropped. Every ending does this —
544
761
  here, `failRun` and `writeAnswer` — because a daemon stays up for days and
545
762
  has no business holding Tuesday's secret. */
@@ -780,76 +997,82 @@ export async function settingsForRun(client, runId) {
780
997
  * the level is what tells it how a run of this shape ends.
781
998
  */
782
999
  async function answerCard(client, tools, machineId, cardId, turns) {
783
- const runId = turns[0].run_id;
784
- /* BEFORE THE SPAWN, AND ITS FAILURE IS THE SPAWN'S FAILURE. The receipts are
785
- part of what the agent is sent, so a read that fails must not be papered
786
- over with an empty list: that reads as a card that has made nothing, which
787
- is how an agent creates a second epic beside the one it cannot see. The
788
- attachments read carries the same rule: a failed read here must not read
789
- as "nothing is attached", which is a different card than the one that was
790
- actually sent. */
791
- /* ═══ AND A FAILURE HERE ENDS THE RUN, RATHER THAN LEAVING IT RUNNING WITH NO
792
- PROCESS. ═══ The take already wrote the run row in the statement that leased
793
- the turns, so a throw between here and `startAgent` leaves a run reading
794
- `running` with a null pid and nothing on stderr the person can see.
795
- `recoverStranded` then reads that as a machine that went away, hands the
796
- message back, and `panel3_take_turns` leases it to A BRAND NEW RUN whose
797
- attempts start again at one — so a permanent failure, such as a read this
798
- build cannot make against the current schema, repeats forever while the card
799
- says Working and never says why. This is the sixth of `endRun`'s endings and
800
- the last one that was missing: `resumeRun` and `startRearmed` already end
801
- their two post-claim failures this way for exactly this reason.
802
- THE REASON IS SHAREABLE. All three reads are `returned()` calls against the
803
- database, whose messages name tables and columns and never a local path, so
804
- constraint 6 is satisfied without a level fork here. */
805
- let brief;
806
- /* WHERE IT RUNS AND WHAT IT IS TOLD ABOUT THE CODEBASES, THROUGH THE SAME
807
- SEAM AS EVERY OTHER SPAWN. A level 1 run works in an empty directory of the
808
- user's own, and it is the fifth start site rather than a special case: the
809
- generated git block reaches every level, and one place resolving it is what
810
- makes that true without four copies of the read. */
811
- let where;
812
- /* THE MANDATE IS READ IN THE SAME WINDOW AND UNDER THE SAME RULE. A launcher
813
- is an agent like any other and gets the project's standing rules before it
814
- decides anything, and a read that fails must not read as "this project has
815
- no rules" — that is a different project than the one the person is on. So it
816
- joins the three reads above inside this ending rather than beside it. */
817
- let rules;
818
- let settings;
819
- try {
820
- brief = briefFor(turns[0].card_title, turns, await receiptsFor(client, cardId), await attachmentsFor(client, cardId), await codebasesForRun(client, runId));
821
- rules = await standingRulesFor(client, runId);
822
- settings = await settingsForRun(client, runId);
823
- where = await workingDirectory(client, runId, LEVEL, false);
824
- }
825
- catch (error) {
826
- const why = error instanceof Error ? error.message : String(error);
827
- await endRun(client, LEVEL, runId, cardId, why);
828
- throw new Error(`NO AGENT IS RUNNING: ${why}`);
829
- }
830
- /* ═══ THE RUN ID IS ON THE URL, AND THAT IS THE WHOLE OF WHAT THE AGENT IS
831
- TOLD ABOUT ITS OWN STANDING. ═══ The tools server reads the level off the
832
- run row this id names, so the daemon does not tell the child what it may do
833
- and the child has nothing to claim. `LEVEL` below decides argv only — which
834
- of the harness's own tools the process gets — and the two can never disagree
835
- about the record, because only one of them consults it. */
836
- /* AN EMPTY DIRECTORY OF THE USER'S OWN, because level 1 has no code tool to
837
- use a real one with, and the daemon's inherited cwd under a launchd login
838
- item is the filesystem root. */
839
- const started = startAgent(withStandingRules(rules, brief, where.block), LEVEL, tools.urlFor(runId), where.cwd, undefined, settings);
840
- out(`run ${runId} level ${LEVEL} ${started.pid ? `pid ${started.pid}` : 'no process'}`);
1000
+ const preparation = tools.work?.prepare(turns[0].run_id);
841
1001
  try {
842
- /* THE BRIEF, NOT WHAT THE PROCESS WAS HANDED. The rules are current at the
843
- activation and the brief is immutable, so storing the composed string
844
- would freeze one inside the other and every respawn would replay it. */
845
- await recordProcess(client, runId, started.pid, brief);
1002
+ const runId = turns[0].run_id;
1003
+ /* BEFORE THE SPAWN, AND ITS FAILURE IS THE SPAWN'S FAILURE. The receipts are
1004
+ part of what the agent is sent, so a read that fails must not be papered
1005
+ over with an empty list: that reads as a card that has made nothing, which
1006
+ is how an agent creates a second epic beside the one it cannot see. The
1007
+ attachments read carries the same rule: a failed read here must not read
1008
+ as "nothing is attached", which is a different card than the one that was
1009
+ actually sent. */
1010
+ /* ═══ AND A FAILURE HERE ENDS THE RUN, RATHER THAN LEAVING IT RUNNING WITH NO
1011
+ PROCESS. ═══ The take already wrote the run row in the statement that leased
1012
+ the turns, so a throw between here and `startAgent` leaves a run reading
1013
+ `running` with a null pid and nothing on stderr the person can see.
1014
+ `recoverStranded` then reads that as a machine that went away, hands the
1015
+ message back, and `panel3_take_turns` leases it to A BRAND NEW RUN whose
1016
+ attempts start again at one — so a permanent failure, such as a read this
1017
+ build cannot make against the current schema, repeats forever while the card
1018
+ says Working and never says why. This is the sixth of `endRun`'s endings and
1019
+ the last one that was missing: `resumeRun` and `startRearmed` already end
1020
+ their two post-claim failures this way for exactly this reason.
1021
+ THE REASON IS SHAREABLE. All three reads are `returned()` calls against the
1022
+ database, whose messages name tables and columns and never a local path, so
1023
+ constraint 6 is satisfied without a level fork here. */
1024
+ let brief;
1025
+ /* WHERE IT RUNS AND WHAT IT IS TOLD ABOUT THE CODEBASES, THROUGH THE SAME
1026
+ SEAM AS EVERY OTHER SPAWN. A level 1 run works in an empty directory of the
1027
+ user's own, and it is the fifth start site rather than a special case: the
1028
+ generated git block reaches every level, and one place resolving it is what
1029
+ makes that true without four copies of the read. */
1030
+ let where;
1031
+ /* THE MANDATE IS READ IN THE SAME WINDOW AND UNDER THE SAME RULE. A launcher
1032
+ is an agent like any other and gets the project's standing rules before it
1033
+ decides anything, and a read that fails must not read as "this project has
1034
+ no rules" — that is a different project than the one the person is on. So it
1035
+ joins the three reads above inside this ending rather than beside it. */
1036
+ let rules;
1037
+ let settings;
1038
+ try {
1039
+ brief = briefFor(turns[0].card_title, turns, await receiptsFor(client, cardId), await attachmentsFor(client, cardId), await codebasesForRun(client, runId));
1040
+ rules = await standingRulesFor(client, runId);
1041
+ settings = await settingsForRun(client, runId);
1042
+ where = await workingDirectory(client, runId, LEVEL, false);
1043
+ }
1044
+ catch (error) {
1045
+ const why = error instanceof Error ? error.message : String(error);
1046
+ await endRun(client, LEVEL, runId, cardId, why);
1047
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
1048
+ }
1049
+ /* ═══ THE RUN ID IS ON THE URL, AND THAT IS THE WHOLE OF WHAT THE AGENT IS
1050
+ TOLD ABOUT ITS OWN STANDING. ═══ The tools server reads the level off the
1051
+ run row this id names, so the daemon does not tell the child what it may do
1052
+ and the child has nothing to claim. `LEVEL` below decides argv only — which
1053
+ of the harness's own tools the process gets — and the two can never disagree
1054
+ about the record, because only one of them consults it. */
1055
+ /* AN EMPTY DIRECTORY OF THE USER'S OWN, because level 1 has no code tool to
1056
+ use a real one with, and the daemon's inherited cwd under a launchd login
1057
+ 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);
1059
+ out(`run ${runId} level ${LEVEL} ${started.pid ? `pid ${started.pid}` : 'no process'}`);
1060
+ try {
1061
+ /* THE BRIEF, NOT WHAT THE PROCESS WAS HANDED. The rules are current at the
1062
+ activation and the brief is immutable, so storing the composed string
1063
+ would freeze one inside the other and every respawn would replay it. */
1064
+ await recordProcess(client, runId, started.pid, brief, undefined, started.attempt);
1065
+ }
1066
+ catch (error) {
1067
+ // Said, not fatal. See `recordProcess` for what this costs and why the
1068
+ // agent is not killed over it.
1069
+ said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
1070
+ }
1071
+ return settle(client, tools, machineId, LEVEL, runId, cardId, started, false);
846
1072
  }
847
- catch (error) {
848
- // Said, not fatal. See `recordProcess` for what this costs and why the
849
- // agent is not killed over it.
850
- said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
1073
+ finally {
1074
+ preparation?.finish();
851
1075
  }
852
- return settle(client, tools, machineId, LEVEL, runId, cardId, started, false);
853
1076
  }
854
1077
  /**
855
1078
  * WHICH PROJECT A RUN'S CARD IS FILED UNDER, or null when the card has none.
@@ -1177,91 +1400,104 @@ async function workingDirectory(client, runId, level, isOwner, knownCodebase) {
1177
1400
  * that satisfies it.
1178
1401
  */
1179
1402
  async function startChild(client, tools, machineId, parentRunId, brief, codebase, parentProcessToken, choice = {}) {
1180
- const { data, error } = await client.rpc('panel3_dispatch', {
1181
- p_parent_run_id: parentRunId,
1182
- p_brief: brief,
1183
- p_machine_id: machineId,
1184
- p_codebase_id: codebase?.id ?? null,
1185
- p_codebase_label: codebase?.name ?? null,
1186
- p_process_token: parentProcessToken ?? null,
1187
- p_model: choice.model ?? null,
1188
- p_effort: choice.effort ?? null,
1189
- });
1190
- if (error)
1191
- throw new Error(`could not start an agent under run ${parentRunId}: ${readableWriteError(error.message)}`);
1192
- const row = data?.[0];
1193
- if (!row) {
1194
- /* NOTHING WAS WRITTEN AND NOTHING IS RUNNING, and the two reasons are said
1195
- together because the caller cannot tell them apart from here and both mean
1196
- the same thing to it. */
1197
- throw new Error(`NO AGENT WAS STARTED and nothing was written: run ${parentRunId} has ended, is already as `
1198
- + 'deep as anything may be sent from, or this conversation already has its owner. Exit now.');
1199
- }
1200
- if (row.run_level !== 2 && row.run_level !== 3) {
1201
- /* UNREACHABLE, AND STILL SETTLED. `panel3_dispatch` writes `parent.level + 1`
1202
- from a parent it has just checked is below 3, so there is no level here
1203
- this cannot spawn. If that ever stops being true, the row exists and
1204
- nothing will ever start for it, and leaving it `running` would make a
1205
- recovery sweep wait out the pid grace window to conclude what is already
1206
- known. */
1207
- const why = `run ${row.run_id} was written at level ${row.run_level}, which cannot be spawned`;
1208
- await giveUp(client, row.run_id, why, row.process_token ?? undefined);
1209
- throw new Error(why);
1210
- }
1211
- const level = row.run_level;
1212
- let where;
1213
- let prompt;
1214
- let pictures;
1215
- /* READ AGAINST THE CHILD'S OWN RUN ROW, NOT THE PARENT'S. The row already
1216
- exists (`panel3_dispatch` wrote it above) and it carries this child's
1217
- codebase, which is what decides which codebase-scoped rules it is under. */
1218
- let rules;
1219
- let settings;
1220
- try {
1221
- where = await workingDirectory(client, row.run_id, level, level === 2, codebase);
1222
- prompt = level === 2
1223
- ? await initialOwnerPrompt(client, row.run_card_id, parentRunId, brief)
1224
- : brief;
1225
- rules = await standingRulesFor(client, row.run_id);
1226
- settings = await settingsForRun(client, row.run_id);
1227
- /* Inside this site's own try, for `standingRulesFor`'s reason: a failure to
1228
- assemble what the agent needs ends the run the way this path already ends
1229
- runs, rather than starting a process that is missing it. */
1230
- pictures = await picturesOnDisk(client, row.run_card_id, where, level);
1231
- }
1232
- catch (error) {
1233
- const why = error instanceof Error ? error.message : String(error);
1234
- await giveUp(client, row.run_id, why, row.process_token ?? undefined);
1235
- throw new Error(`NO AGENT IS RUNNING: ${why}`);
1236
- }
1237
- const processToken = row.process_token ?? undefined;
1238
- const isOwner = level === 2;
1239
- const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined, settings);
1240
- if (started.pid === null) {
1241
- /* THERE IS A ROW AND THERE IS NO PROCESS, which is the one shape the record
1242
- must never be left in quietly. The answer is already settled — nothing ran
1243
- — so the reason is read off it, the run is ended with that reason on it,
1244
- and the tool call fails saying no agent was started. */
1245
- const answer = await started.answered;
1246
- const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
1247
- await giveUp(client, row.run_id, reason, processToken);
1248
- throw new Error(`NO AGENT IS RUNNING: ${reason}`);
1249
- }
1250
- out(`dispatch run ${row.run_id} level ${level} under ${parentRunId} pid ${started.pid}`);
1403
+ let claimOperationId;
1404
+ const preparation = tools.work?.prepare();
1251
1405
  try {
1252
- await recordProcess(client, row.run_id, started.pid, undefined, processToken);
1406
+ const response = await panelClaim(client, tools.work, 'panel3_dispatch', {
1407
+ p_parent_run_id: parentRunId,
1408
+ p_brief: brief,
1409
+ p_machine_id: machineId,
1410
+ p_codebase_id: codebase?.id ?? null,
1411
+ p_codebase_label: codebase?.name ?? null,
1412
+ p_process_token: parentProcessToken ?? null,
1413
+ p_model: choice.model ?? null,
1414
+ p_effort: choice.effort ?? null,
1415
+ });
1416
+ const { data, error, operationId } = response;
1417
+ claimOperationId = operationId;
1418
+ if (error)
1419
+ throw new Error(`could not start an agent under run ${parentRunId}: ${readableWriteError(error.message)}`);
1420
+ const row = data?.[0];
1421
+ if (!row) {
1422
+ /* NOTHING WAS WRITTEN AND NOTHING IS RUNNING, and the two reasons are said
1423
+ together because the caller cannot tell them apart from here and both mean
1424
+ the same thing to it. */
1425
+ throw new Error(`NO AGENT WAS STARTED and nothing was written: run ${parentRunId} has ended, is already as `
1426
+ + 'deep as anything may be sent from, or this conversation already has its owner. Exit now.');
1427
+ }
1428
+ if (row.run_level !== 2 && row.run_level !== 3) {
1429
+ /* UNREACHABLE, AND STILL SETTLED. `panel3_dispatch` writes `parent.level + 1`
1430
+ from a parent it has just checked is below 3, so there is no level here
1431
+ this cannot spawn. If that ever stops being true, the row exists and
1432
+ nothing will ever start for it, and leaving it `running` would make a
1433
+ recovery sweep wait out the pid grace window to conclude what is already
1434
+ known. */
1435
+ 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);
1437
+ throw new Error(why);
1438
+ }
1439
+ const level = row.run_level;
1440
+ let where;
1441
+ let prompt;
1442
+ let pictures;
1443
+ /* READ AGAINST THE CHILD'S OWN RUN ROW, NOT THE PARENT'S. The row already
1444
+ exists (`panel3_dispatch` wrote it above) and it carries this child's
1445
+ codebase, which is what decides which codebase-scoped rules it is under. */
1446
+ let rules;
1447
+ let settings;
1448
+ try {
1449
+ where = await workingDirectory(client, row.run_id, level, level === 2, codebase);
1450
+ prompt = level === 2
1451
+ ? await initialOwnerPrompt(client, row.run_card_id, parentRunId, brief)
1452
+ : brief;
1453
+ rules = await standingRulesFor(client, row.run_id);
1454
+ settings = await settingsForRun(client, row.run_id);
1455
+ /* Inside this site's own try, for `standingRulesFor`'s reason: a failure to
1456
+ assemble what the agent needs ends the run the way this path already ends
1457
+ runs, rather than starting a process that is missing it. */
1458
+ pictures = await picturesOnDisk(client, row.run_card_id, where, level);
1459
+ }
1460
+ catch (error) {
1461
+ const why = error instanceof Error ? error.message : String(error);
1462
+ await giveUp(client, row.run_id, why, row.process_token ?? undefined);
1463
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
1464
+ }
1465
+ const processToken = row.process_token ?? undefined;
1466
+ 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);
1468
+ if (started.pid === null) {
1469
+ if (started.interrupted?.())
1470
+ throw new Error('Work was interrupted by the service command.');
1471
+ /* THERE IS A ROW AND THERE IS NO PROCESS, which is the one shape the record
1472
+ must never be left in quietly. The answer is already settled — nothing ran
1473
+ — so the reason is read off it, the run is ended with that reason on it,
1474
+ and the tool call fails saying no agent was started. */
1475
+ const answer = await started.answered;
1476
+ const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
1477
+ await giveUp(client, row.run_id, reason, processToken);
1478
+ throw new Error(`NO AGENT IS RUNNING: ${reason}`);
1479
+ }
1480
+ out(`dispatch run ${row.run_id} level ${level} under ${parentRunId} pid ${started.pid}`);
1481
+ try {
1482
+ await recordProcess(client, row.run_id, started.pid, undefined, processToken, started.attempt);
1483
+ }
1484
+ catch (error) {
1485
+ // Said, not fatal, exactly as at level 1: the agent is running and killing
1486
+ // it over a bookkeeping write would cost the user the work.
1487
+ said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
1488
+ }
1489
+ return {
1490
+ runId: row.run_id,
1491
+ settled: settle(client, tools, machineId, level, row.run_id, row.run_card_id, started, true, processToken, isOwner && processToken
1492
+ ? ownerSessionLifecycle(started, row.run_id, settings.harness ?? harness(), processToken)
1493
+ : undefined),
1494
+ };
1253
1495
  }
1254
- catch (error) {
1255
- // Said, not fatal, exactly as at level 1: the agent is running and killing
1256
- // it over a bookkeeping write would cost the user the work.
1257
- said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
1496
+ finally {
1497
+ preparation?.finish();
1498
+ if (claimOperationId)
1499
+ tools.work?.finishRpcs([claimOperationId]);
1258
1500
  }
1259
- return {
1260
- runId: row.run_id,
1261
- settled: settle(client, tools, machineId, level, row.run_id, row.run_card_id, started, true, processToken, isOwner && processToken
1262
- ? ownerSessionLifecycle(started, row.run_id, settings.harness ?? harness(), processToken)
1263
- : undefined),
1264
- };
1265
1501
  }
1266
1502
  /**
1267
1503
  * HOW MANY PROCESSES HAVE BEEN STARTED FOR THIS RUN, counting the first.
@@ -1329,9 +1565,21 @@ async function attemptsSoFar(client, runId) {
1329
1565
  * Returns whether THIS call is what ended it. False is a fact about the record,
1330
1566
  * not a failure: the run had already ended, and the caller says so.
1331
1567
  */
1332
- async function endRun(client, level, runId, cardId, why) {
1568
+ async function endRun(client, level, runId, cardId, why, attempt) {
1333
1569
  if (level !== 1)
1334
- return (await giveUp(client, runId, why)) !== null;
1570
+ return (await giveUp(client, runId, why, undefined, attempt)) !== null;
1571
+ if (attempt) {
1572
+ const { data, error } = await client.rpc('panel3_end_run', {
1573
+ p_run_id: runId, p_reason: why, p_process_token: attempt.processToken,
1574
+ p_expected_attempt: { started_at: attempt.startedAt, resumed_at: attempt.resumedAt },
1575
+ });
1576
+ if (error)
1577
+ throw new Error(`could not end run ${runId}: ${error.message}`);
1578
+ if (typeof data !== 'boolean')
1579
+ throw new Error(`could not end run ${runId}: no ending was acknowledged`);
1580
+ forgetSecrets(runId);
1581
+ return data;
1582
+ }
1335
1583
  /* ═══ THE RUN FIRST, AND THE CARD ONLY IF THIS RUN WAS STILL THE CARD'S TO
1336
1584
  FAIL. ═══
1337
1585
  The card used to be written first, and the argument for that was a daemon
@@ -1416,6 +1664,8 @@ function ownerSessionLifecycle(started, ownerId, ownerHarness, processToken, exp
1416
1664
  }
1417
1665
  function settle(client, tools, machineId, level, runId, cardId, started, speaksToTheCard = true, processToken, ownerSession) {
1418
1666
  return started.answered.then(async (answer) => {
1667
+ if (started.interrupted?.() || started.preparationFailed?.())
1668
+ return;
1419
1669
  /* ═══ A RUN THAT STOPPED TO ASK DID NOT DIE, WHATEVER THE HARNESS PRINTED
1420
1670
  ON ITS WAY OUT. ═══
1421
1671
 
@@ -1527,7 +1777,7 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
1527
1777
  await ownerSession?.established();
1528
1778
  else
1529
1779
  await ownerSession?.failed();
1530
- });
1780
+ }).then(() => { started.completed?.(); });
1531
1781
  }
1532
1782
  /**
1533
1783
  * ═══ ONE RUN, STARTED AGAIN AS ITSELF, WITH WHAT IT WAS SENT AND WHAT IT HAD
@@ -1591,7 +1841,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1591
1841
  both halves of the argument: a machine with no checkout must not take a run
1592
1842
  it cannot start, and the level a retry needs the answer for is not known
1593
1843
  until the claim returns.
1594
-
1844
+ *
1595
1845
  ═══ IT IS THE EXISTENCE CHECK AND NOTHING MORE, WHICH IS WHAT CHANGED IN
1596
1846
  worktrees-8. ═══ Resolving the working copy now CREATES a branch, a folder
1597
1847
  and a row write, and every poll tick that loses the claim race would leave
@@ -1600,126 +1850,138 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1600
1850
  this position was ever asking, and the copy is made after the claim. */
1601
1851
  if (afterPid === null)
1602
1852
  checkoutForCodebase(await codebaseOfRun(client, runId), hostname());
1603
- const { data, error } = await client.rpc('panel3_resume', {
1604
- p_run_id: runId,
1605
- p_machine_id: machineId,
1606
- p_after_pid: afterPid,
1607
- });
1608
- if (error)
1609
- throw new Error(`could not start run ${runId} again: ${error.message}`);
1610
- const claimed = data?.[0];
1611
- if (!claimed)
1612
- return null;
1613
- const level = claimed.run_level === 1 ? 1 : claimed.run_level === 2 ? 2 : 3;
1614
- if (claimed.run_level !== level) {
1615
- /* UNREACHABLE, AND STILL SETTLED, exactly as in `startChild`: the level
1616
- column is checked at three, so there is no level here this cannot spawn.
1617
- The claim has already happened, so leaving it would strand the run for a
1618
- whole grace window before anything looked at it again. */
1619
- const why = `run ${runId} is at level ${claimed.run_level}, which cannot be spawned`;
1620
- await giveUp(client, runId, why);
1621
- throw new Error(why);
1622
- }
1623
- /* ONE RESOLUTION FOR BOTH PATHS, AFTER THE CLAIM. It used to fork on whether
1624
- the sweep had already resolved a folder before the claim; since the copy is
1625
- the card's own and making it writes, both paths make it here, in the branch
1626
- that can end the run when it cannot be made. */
1627
- let where;
1628
- try {
1629
- where = await workingDirectory(client, runId, level, false);
1630
- }
1631
- catch (error) {
1632
- /* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
1633
- ═══ Constraint 6, and `startRearmed`'s own handling of the same two
1634
- calls: `worktreeForCard()` is already careful about this and says so;
1635
- `scratchDir()` is not, because it is `mkdirSync`, whose EACCES and
1636
- ENOTDIR messages name the directory they failed on. So the machine's own
1637
- error is kept for stderr and the record is told only what is true and
1638
- shareable. */
1639
- const stderrOnly = error instanceof Error ? error.message : String(error);
1640
- const why = level === 1
1641
- ? 'this machine could not make the empty directory this runs in'
1642
- : stderrOnly;
1643
- /* ═══ AND IT ENDS THE WAY A RUN OF THIS LEVEL ENDS. ═══ It was
1644
- `panel3_give_up` outright, which was right while only a dispatched run
1645
- could reach this function and is a leak now that the retry brings level
1646
- 1 here: handing the person's message back mints a new run with its
1647
- attempts at one, which is the bound the retry is under, undone by the
1648
- one path that could not start. See `endRun`. */
1649
- await endRun(client, level, runId, claimed.run_card_id, why);
1650
- throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? stderrOnly : why}`);
1651
- }
1652
- /* WHAT IT SENT OTHERS TO DO, FROM THE RECORD. Level 3 has no `dispatch`, so it
1653
- has no children to have: null says that, where an empty list would say it
1654
- chose to send nobody.
1655
-
1656
- ═══ AND LEVEL 1 HAS THEM TOO, WHICH ONLY THE RETRY CAN REACH. ═══ This read
1657
- used to be `level === 2`, which was correct only because nothing at level 1
1658
- ever got here. Left alone it would hand a coordinator a null child list, and
1659
- a coordinator that came back to no children dispatches its workers a second
1660
- time: ux.md's single most expensive failure. `startRearmed` reads it the
1661
- same way, for the same reason. */
1662
- const children = level === 3 ? null : await childrenOf(client, runId);
1663
- /* ═══ READ AGAIN ON EVERY START, WHICH IS THE WHOLE OF CONTRACT POINT 2. ═══
1664
- A resumed or retried run is handed the rules AS THEY STAND NOW, not as they
1665
- stood when it first began: a rule edited while the conversation was running
1666
- governs the rest of it, and a rule deleted while it was running stops
1667
- applying to it. That is only true because this read happens here rather than
1668
- once, at the top of the run's life.
1669
-
1670
- ═══ AND IT ENDS THE WAY THIS PATH ALREADY ENDS. ═══ The claim has already
1671
- happened, so a throw here would leave a run reading `running` with no
1672
- process. `endRun` with the level fork is this path's own ending (see the cwd
1673
- branch above for why level 1 may not simply be given up on), and the reason
1674
- is a `returned()` message naming tables and columns, which carries no local
1675
- path and is therefore shareable. */
1676
- let rules;
1677
- let settings;
1678
- let pictures;
1853
+ let claimOperationId;
1854
+ const preparation = tools.work?.prepare();
1679
1855
  try {
1680
- rules = await standingRulesFor(client, runId);
1681
- settings = await settingsForRun(client, runId);
1682
- /* ═══ WRITTEN AGAIN ON EVERY START, LIKE THE RULES. ═══ A resumed process is
1683
- a NEW process with a new copy of the working directory, so the files a
1684
- previous one was handed are not there any more, and the stored brief this
1685
- path replays cannot carry a path that was not known when it was written. */
1686
- pictures = await picturesOnDisk(client, claimed.run_card_id, where, level);
1687
- }
1688
- catch (error) {
1689
- const why = error instanceof Error ? error.message : String(error);
1690
- await endRun(client, level, runId, claimed.run_card_id, why);
1691
- throw new Error(`NO AGENT IS RUNNING: ${why}`);
1692
- }
1693
- const started = startAgent(
1694
- /* ═══ WHY IT DIED IS WHAT DIFFERS, AND IT IS TOLD THE TRUTH ABOUT IT. ═══
1695
- `resumePrompt` opens by saying the machine went down, which is true of the
1696
- sweep and false of a retry: the daemon that watched this harness exit is
1697
- still running. See `retryPrompt`. */
1698
- withStandingRules(rules, afterPid === null
1699
- ? resumePrompt(claimed.run_brief, claimed.run_report, children)
1700
- : retryPrompt(claimed.run_brief, claimed.run_report, children), where.block, [], pictures), level, tools.urlFor(runId), where.cwd, undefined, settings);
1701
- if (started.pid === null) {
1702
- /* THE CLAIM HAPPENED AND NO PROCESS DID, which is the one shape the record
1703
- must never be left in quietly. Same handling as a dispatch that could not
1704
- start: the reason is read off the settled answer and the run is ended with
1705
- it. */
1706
- const answer = await started.answered;
1707
- const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
1708
- // The same level fork, for the same reason. See `endRun`.
1709
- await endRun(client, level, runId, claimed.run_card_id, reason);
1710
- throw new Error(`NO AGENT IS RUNNING: ${reason}`);
1711
- }
1712
- out(`resume run ${runId} level ${level} pid ${started.pid} `
1713
- + `${claimed.run_report === null ? 'no report to carry' : 'carrying its report'}`);
1714
- try {
1715
- await recordProcess(client, runId, started.pid);
1856
+ const response = await panelClaim(client, tools.work, 'panel3_resume', {
1857
+ p_run_id: runId,
1858
+ p_machine_id: machineId,
1859
+ p_after_pid: afterPid,
1860
+ });
1861
+ const { data, error, operationId } = response;
1862
+ claimOperationId = operationId;
1863
+ if (error)
1864
+ throw new Error(`could not start run ${runId} again: ${error.message}`);
1865
+ const claimed = data?.[0];
1866
+ if (!claimed)
1867
+ return null;
1868
+ const level = claimed.run_level === 1 ? 1 : claimed.run_level === 2 ? 2 : 3;
1869
+ if (claimed.run_level !== level) {
1870
+ /* UNREACHABLE, AND STILL SETTLED, exactly as in `startChild`: the level
1871
+ column is checked at three, so there is no level here this cannot spawn.
1872
+ The claim has already happened, so leaving it would strand the run for a
1873
+ whole grace window before anything looked at it again. */
1874
+ const why = `run ${runId} is at level ${claimed.run_level}, which cannot be spawned`;
1875
+ await giveUp(client, runId, why);
1876
+ throw new Error(why);
1877
+ }
1878
+ /* ONE RESOLUTION FOR BOTH PATHS, AFTER THE CLAIM. It used to fork on whether
1879
+ the sweep had already resolved a folder before the claim; since the copy is
1880
+ the card's own and making it writes, both paths make it here, in the branch
1881
+ that can end the run when it cannot be made. */
1882
+ let where;
1883
+ try {
1884
+ where = await workingDirectory(client, runId, level, false);
1885
+ }
1886
+ catch (error) {
1887
+ /* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
1888
+ ═══ Constraint 6, and `startRearmed`'s own handling of the same two
1889
+ calls: `worktreeForCard()` is already careful about this and says so;
1890
+ `scratchDir()` is not, because it is `mkdirSync`, whose EACCES and
1891
+ ENOTDIR messages name the directory they failed on. So the machine's own
1892
+ error is kept for stderr and the record is told only what is true and
1893
+ shareable. */
1894
+ const stderrOnly = error instanceof Error ? error.message : String(error);
1895
+ const why = level === 1
1896
+ ? 'this machine could not make the empty directory this runs in'
1897
+ : stderrOnly;
1898
+ /* ═══ AND IT ENDS THE WAY A RUN OF THIS LEVEL ENDS. ═══ It was
1899
+ `panel3_give_up` outright, which was right while only a dispatched run
1900
+ could reach this function and is a leak now that the retry brings level
1901
+ 1 here: handing the person's message back mints a new run with its
1902
+ attempts at one, which is the bound the retry is under, undone by the
1903
+ one path that could not start. See `endRun`. */
1904
+ await endRun(client, level, runId, claimed.run_card_id, why);
1905
+ throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? stderrOnly : why}`);
1906
+ }
1907
+ /* WHAT IT SENT OTHERS TO DO, FROM THE RECORD. Level 3 has no `dispatch`, so it
1908
+ has no children to have: null says that, where an empty list would say it
1909
+ chose to send nobody.
1910
+ *
1911
+ ═══ AND LEVEL 1 HAS THEM TOO, WHICH ONLY THE RETRY CAN REACH. ═══ This read
1912
+ used to be `level === 2`, which was correct only because nothing at level 1
1913
+ ever got here. Left alone it would hand a coordinator a null child list, and
1914
+ a coordinator that came back to no children dispatches its workers a second
1915
+ time: ux.md's single most expensive failure. `startRearmed` reads it the
1916
+ same way, for the same reason. */
1917
+ const children = level === 3 ? null : await childrenOf(client, runId);
1918
+ /* ═══ READ AGAIN ON EVERY START, WHICH IS THE WHOLE OF CONTRACT POINT 2. ═══
1919
+ A resumed or retried run is handed the rules AS THEY STAND NOW, not as they
1920
+ stood when it first began: a rule edited while the conversation was running
1921
+ governs the rest of it, and a rule deleted while it was running stops
1922
+ applying to it. That is only true because this read happens here rather than
1923
+ once, at the top of the run's life.
1924
+ *
1925
+ ═══ AND IT ENDS THE WAY THIS PATH ALREADY ENDS. ═══ The claim has already
1926
+ happened, so a throw here would leave a run reading `running` with no
1927
+ process. `endRun` with the level fork is this path's own ending (see the cwd
1928
+ branch above for why level 1 may not simply be given up on), and the reason
1929
+ is a `returned()` message naming tables and columns, which carries no local
1930
+ path and is therefore shareable. */
1931
+ let rules;
1932
+ let settings;
1933
+ let pictures;
1934
+ try {
1935
+ rules = await standingRulesFor(client, runId);
1936
+ settings = await settingsForRun(client, runId);
1937
+ /* ═══ WRITTEN AGAIN ON EVERY START, LIKE THE RULES. ═══ A resumed process is
1938
+ a NEW process with a new copy of the working directory, so the files a
1939
+ previous one was handed are not there any more, and the stored brief this
1940
+ path replays cannot carry a path that was not known when it was written. */
1941
+ pictures = await picturesOnDisk(client, claimed.run_card_id, where, level);
1942
+ }
1943
+ catch (error) {
1944
+ const why = error instanceof Error ? error.message : String(error);
1945
+ await endRun(client, level, runId, claimed.run_card_id, why);
1946
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
1947
+ }
1948
+ /* ═══ WHY IT DIED IS WHAT DIFFERS, AND IT IS TOLD THE TRUTH ABOUT IT. ═══
1949
+ `resumePrompt` opens by saying the machine went down, which is true of the
1950
+ sweep and false of a retry: the daemon that watched this harness exit is
1951
+ still running. See `retryPrompt`. */
1952
+ const started = await startTrackedAgent(client, runId, preparation, tools.work, withStandingRules(rules, afterPid === null
1953
+ ? resumePrompt(claimed.run_brief, claimed.run_report, children)
1954
+ : retryPrompt(claimed.run_brief, claimed.run_report, children), where.block, [], pictures), level, tools.urlFor(runId), where.cwd, undefined, settings);
1955
+ if (started.pid === null) {
1956
+ if (started.interrupted?.())
1957
+ throw new Error('Work was interrupted by the service command.');
1958
+ /* THE CLAIM HAPPENED AND NO PROCESS DID, which is the one shape the record
1959
+ must never be left in quietly. Same handling as a dispatch that could not
1960
+ start: the reason is read off the settled answer and the run is ended with
1961
+ it. */
1962
+ const answer = await started.answered;
1963
+ const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
1964
+ // The same level fork, for the same reason. See `endRun`.
1965
+ await endRun(client, level, runId, claimed.run_card_id, reason);
1966
+ throw new Error(`NO AGENT IS RUNNING: ${reason}`);
1967
+ }
1968
+ out(`resume run ${runId} level ${level} pid ${started.pid} `
1969
+ + `${claimed.run_report === null ? 'no report to carry' : 'carrying its report'}`);
1970
+ try {
1971
+ await recordProcess(client, runId, started.pid, undefined, undefined, started.attempt);
1972
+ }
1973
+ catch (error) {
1974
+ // Said, not fatal, as everywhere else: the agent is running and killing it
1975
+ // over a bookkeeping write would cost the user the work a second time.
1976
+ said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
1977
+ }
1978
+ return { settled: settle(client, tools, machineId, level, runId, claimed.run_card_id, started) };
1716
1979
  }
1717
- catch (error) {
1718
- // Said, not fatal, as everywhere else: the agent is running and killing it
1719
- // over a bookkeeping write would cost the user the work a second time.
1720
- said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
1980
+ finally {
1981
+ preparation?.finish();
1982
+ if (claimOperationId)
1983
+ tools.work?.finishRpcs([claimOperationId]);
1721
1984
  }
1722
- return { settled: settle(client, tools, machineId, level, runId, claimed.run_card_id, started) };
1723
1985
  }
1724
1986
  /**
1725
1987
  * ═══ ONE RUN, STARTED AGAIN BECAUSE SOMETHING IT WAS WAITING ON EXISTS NOW. ═══
@@ -1748,105 +2010,113 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1748
2010
  * and in `cs show`, rather than quietly hoping the next poll finds it.
1749
2011
  */
1750
2012
  async function startRearmed(client, tools, machineId, row) {
1751
- const level = row.run_level === 1 ? 1 : row.run_level === 2 ? 2 : 3;
1752
- if (row.run_level !== level) {
1753
- const why = `run ${row.run_id} is at level ${row.run_level}, which cannot be spawned`;
1754
- await giveUp(client, row.run_id, why);
1755
- throw new Error(why);
1756
- }
1757
- let where;
1758
- try {
1759
- where = await workingDirectory(client, row.run_id, level, false);
1760
- }
1761
- catch (error) {
1762
- /* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
1763
- ═══ Constraint 6. `workingCopy()` is already careful about this and says
1764
- so; `scratchDir()` is not — it is `mkdirSync`, whose EACCES and ENOTDIR
1765
- messages name the directory they failed on — and `failed_because` is a
1766
- column `cs show` prints. So the machine's own error is kept for stderr
1767
- and the record is told only what is true and shareable. */
1768
- const said = error instanceof Error ? error.message : String(error);
1769
- const why = level === 1
1770
- ? 'this machine could not make the empty directory this runs in'
1771
- : said;
1772
- // The level fork, which this path needs for the same reason `resumeRun`'s
1773
- // two do: a re-arm serves level 1, and a level 1 run holds the person's
1774
- // message. See `endRun`.
1775
- await endRun(client, level, row.run_id, row.run_card_id, why);
1776
- throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? said : why}`);
1777
- }
1778
- /* WHAT IT SENT OTHERS TO DO AND WHAT THEY WROTE, FROM THE RECORD, AS LATE AS
1779
- POSSIBLE. Level 3 has no `dispatch`, so it has no children to have: null
1780
- says that, where an empty list would say it chose to send nobody. */
1781
- const children = level === 3 ? null : await childrenOf(client, row.run_id);
1782
- const deliveredArtifact = row.ask_id !== null && row.mine
1783
- ? await rearmedArtifactAnswer(client, row.ask_id)
1784
- : null;
1785
- /* ═══ THE MERGE HAPPENS HERE, OFF THE MARK ON THE ASK, BEFORE THE AGENT THAT
1786
- WILL SPEAK ABOUT IT IS STARTED. ═══ The re-arm is the last thing that runs
1787
- before the spawn, which is why it is the only place the outcome can reach
1788
- the prompt. See `landCardWork`. */
1789
- const offered = row.ask_id !== null && row.mine
1790
- ? await landingOffer(client, row.ask_id)
1791
- : null;
1792
- const landing = offered === null ? null : landCardWork(where, offered);
1793
- /* ═══ THREE REASONS, AND THE ROW SAYS WHICH. ═══ No question is ux.md's third
1794
- re-arm: everybody it sent has finished, and it is started to read them back.
1795
- `children` cannot be null on that path — only a run with children is ever
1796
- claimed for it — and the prompt takes the list rather than the maybe-list so
1797
- that is a fact of the signature rather than of a comment. */
1798
- const prompt = row.ask_id === null
1799
- ? readBackPrompt(row.run_brief, row.run_report, children ?? [])
1800
- : row.mine
1801
- ? answerPrompt(row.run_brief, row.run_report, children, row.question ?? '', row.answer ?? '', deliveredArtifact, landing)
1802
- : escalationPrompt(row.run_brief, row.run_report, children, row.ask_id, row.question ?? '');
1803
- /* CURRENT AT THIS ACTIVATION, exactly as on the resume path, and ended the
1804
- same way: the re-arm's claim has already happened, so a failure here ends
1805
- the run with its reason rather than leaving it claimed with no process. */
1806
- let rules;
1807
- let settings;
1808
- let pictures;
2013
+ const preparation = tools.work?.prepare(row.run_id);
1809
2014
  try {
1810
- rules = await standingRulesFor(client, row.run_id);
1811
- settings = await settingsForRun(client, row.run_id);
1812
- pictures = await picturesOnDisk(client, row.run_card_id, where, level);
1813
- }
1814
- catch (error) {
1815
- const why = error instanceof Error ? error.message : String(error);
1816
- await endRun(client, level, row.run_id, row.run_card_id, why);
1817
- throw new Error(`NO AGENT IS RUNNING: ${why}`);
1818
- }
1819
- const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id), where.cwd, undefined, settings);
1820
- if (started.pid === null) {
1821
- const answer = await started.answered;
1822
- const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
1823
- // The same level fork, for the same reason. See `endRun`.
1824
- await endRun(client, level, row.run_id, row.run_card_id, reason);
1825
- throw new Error(`NO AGENT IS RUNNING: ${reason}`);
1826
- }
1827
- out(`rearm run ${row.run_id} level ${level} pid ${started.pid} `
1828
- + `${row.ask_id === null
1829
- ? 'to read back everybody it sent'
2015
+ const level = row.run_level === 1 ? 1 : row.run_level === 2 ? 2 : 3;
2016
+ if (row.run_level !== level) {
2017
+ const why = `run ${row.run_id} is at level ${row.run_level}, which cannot be spawned`;
2018
+ await giveUp(client, row.run_id, why);
2019
+ throw new Error(why);
2020
+ }
2021
+ let where;
2022
+ try {
2023
+ where = await workingDirectory(client, row.run_id, level, false);
2024
+ }
2025
+ catch (error) {
2026
+ /* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
2027
+ ═══ Constraint 6. `workingCopy()` is already careful about this and says
2028
+ so; `scratchDir()` is not it is `mkdirSync`, whose EACCES and ENOTDIR
2029
+ messages name the directory they failed on — and `failed_because` is a
2030
+ column `cs show` prints. So the machine's own error is kept for stderr
2031
+ and the record is told only what is true and shareable. */
2032
+ const said = error instanceof Error ? error.message : String(error);
2033
+ const why = level === 1
2034
+ ? 'this machine could not make the empty directory this runs in'
2035
+ : said;
2036
+ // The level fork, which this path needs for the same reason `resumeRun`'s
2037
+ // two do: a re-arm serves level 1, and a level 1 run holds the person's
2038
+ // message. See `endRun`.
2039
+ await endRun(client, level, row.run_id, row.run_card_id, why);
2040
+ throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? said : why}`);
2041
+ }
2042
+ /* WHAT IT SENT OTHERS TO DO AND WHAT THEY WROTE, FROM THE RECORD, AS LATE AS
2043
+ POSSIBLE. Level 3 has no `dispatch`, so it has no children to have: null
2044
+ says that, where an empty list would say it chose to send nobody. */
2045
+ const children = level === 3 ? null : await childrenOf(client, row.run_id);
2046
+ const deliveredArtifact = row.ask_id !== null && row.mine
2047
+ ? await rearmedArtifactAnswer(client, row.ask_id)
2048
+ : null;
2049
+ /* ═══ THE MERGE HAPPENS HERE, OFF THE MARK ON THE ASK, BEFORE THE AGENT THAT
2050
+ WILL SPEAK ABOUT IT IS STARTED. ═══ The re-arm is the last thing that runs
2051
+ before the spawn, which is why it is the only place the outcome can reach
2052
+ the prompt. See `landCardWork`. */
2053
+ const offered = row.ask_id !== null && row.mine
2054
+ ? await landingOffer(client, row.ask_id)
2055
+ : null;
2056
+ const landing = offered === null ? null : landCardWork(where, offered);
2057
+ /* ═══ THREE REASONS, AND THE ROW SAYS WHICH. ═══ No question is ux.md's third
2058
+ re-arm: everybody it sent has finished, and it is started to read them back.
2059
+ `children` cannot be null on that path — only a run with children is ever
2060
+ claimed for it — and the prompt takes the list rather than the maybe-list so
2061
+ that is a fact of the signature rather than of a comment. */
2062
+ const prompt = row.ask_id === null
2063
+ ? readBackPrompt(row.run_brief, row.run_report, children ?? [])
1830
2064
  : row.mine
1831
- ? 'with the answer to its own question'
1832
- : 'with a question it has to settle'}`);
1833
- try {
1834
- await recordProcess(client, row.run_id, started.pid);
2065
+ ? answerPrompt(row.run_brief, row.run_report, children, row.question ?? '', row.answer ?? '', deliveredArtifact, landing)
2066
+ : escalationPrompt(row.run_brief, row.run_report, children, row.ask_id, row.question ?? '');
2067
+ /* CURRENT AT THIS ACTIVATION, exactly as on the resume path, and ended the
2068
+ same way: the re-arm's claim has already happened, so a failure here ends
2069
+ the run with its reason rather than leaving it claimed with no process. */
2070
+ let rules;
2071
+ let settings;
2072
+ let pictures;
2073
+ try {
2074
+ rules = await standingRulesFor(client, row.run_id);
2075
+ settings = await settingsForRun(client, row.run_id);
2076
+ pictures = await picturesOnDisk(client, row.run_card_id, where, level);
2077
+ }
2078
+ catch (error) {
2079
+ const why = error instanceof Error ? error.message : String(error);
2080
+ await endRun(client, level, row.run_id, row.run_card_id, why);
2081
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
2082
+ }
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);
2084
+ if (started.pid === null) {
2085
+ if (started.interrupted?.())
2086
+ throw new Error('Work was interrupted by the service command.');
2087
+ const answer = await started.answered;
2088
+ const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
2089
+ // The same level fork, for the same reason. See `endRun`.
2090
+ await endRun(client, level, row.run_id, row.run_card_id, reason);
2091
+ throw new Error(`NO AGENT IS RUNNING: ${reason}`);
2092
+ }
2093
+ out(`rearm run ${row.run_id} level ${level} pid ${started.pid} `
2094
+ + `${row.ask_id === null
2095
+ ? 'to read back everybody it sent'
2096
+ : row.mine
2097
+ ? 'with the answer to its own question'
2098
+ : 'with a question it has to settle'}`);
2099
+ try {
2100
+ await recordProcess(client, row.run_id, started.pid, undefined, undefined, started.attempt);
2101
+ }
2102
+ catch (error) {
2103
+ // Said, not fatal, as everywhere else: the agent is running and killing it
2104
+ // over a bookkeeping write would cost the user the work.
2105
+ said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
2106
+ }
2107
+ /* ═══ AND IT SPEAKS TO THE CARD ONLY IF IT IS DOING THE PERSON'S WORK. ═══
2108
+ Exactly the distinction ux.md draws: a run carrying on with its own work has
2109
+ something to say when it finishes, and a run started only to settle somebody
2110
+ else's question does not. A run started to read back everybody it sent is
2111
+ the first kind and the clearest case of it — that reply IS the answer to the
2112
+ request. See `settle`. */
2113
+ return {
2114
+ settled: settle(client, tools, machineId, level, row.run_id, row.run_card_id, started, row.ask_id === null || !!row.mine),
2115
+ };
2116
+ }
2117
+ finally {
2118
+ preparation?.finish();
1835
2119
  }
1836
- catch (error) {
1837
- // Said, not fatal, as everywhere else: the agent is running and killing it
1838
- // over a bookkeeping write would cost the user the work.
1839
- said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
1840
- }
1841
- /* ═══ AND IT SPEAKS TO THE CARD ONLY IF IT IS DOING THE PERSON'S WORK. ═══
1842
- Exactly the distinction ux.md draws: a run carrying on with its own work has
1843
- something to say when it finishes, and a run started only to settle somebody
1844
- else's question does not. A run started to read back everybody it sent is
1845
- the first kind and the clearest case of it — that reply IS the answer to the
1846
- request. See `settle`. */
1847
- return {
1848
- settled: settle(client, tools, machineId, level, row.run_id, row.run_card_id, started, row.ask_id === null || !!row.mine),
1849
- };
1850
2120
  }
1851
2121
  /**
1852
2122
  * The runs one run dispatched, AND WHAT EACH OF THEM WROTE, in the words the
@@ -2000,139 +2270,151 @@ export function resumableOwnerSessionId(candidate, machineId, machineHarness) {
2000
2270
  : undefined;
2001
2271
  }
2002
2272
  async function activateOwner(client, tools, machineId, runId, afterProcessToken = null, afterPid = null) {
2003
- const candidate = await ownerCandidate(client, runId);
2004
- if (!candidate)
2005
- return null;
2006
- // Existing conversations retain their agent; an explicit hand-back follows
2007
- // the new selection. Never reuse a native session across different agents.
2008
- if (candidate.handed_back_at === null && candidate.machine_id !== machineId)
2009
- return null;
2010
- const machineHarness = candidate.handed_back_at === null && candidate.harness !== null
2011
- ? harness({ CTRL_SPC_V3_AGENT: candidate.harness })
2012
- : await selectedHarness(client, machineId);
2013
- const resumeSessionId = resumableOwnerSessionId(candidate, machineId, machineHarness);
2014
- /* ═══ THE EXISTENCE CHECK BEFORE THE CLAIM, AND THE COPY AFTER IT. ═══ This
2015
- was the whole resolution, which was right while resolving meant reading a
2016
- folder out of a file. Since worktrees-8 it also CREATES one, and a daemon
2017
- that lost the activation race would leave a branch and a folder behind for
2018
- an owner it never activated. The refusal this position exists for — a
2019
- machine that does not have this codebase must not take the activation —
2020
- is unchanged, because it is the located checkout that answers it. */
2021
- if (candidate.codebase_id !== null) {
2022
- checkoutForCodebase(await codebaseOfRun(client, candidate.id), hostname());
2023
- }
2024
- const { data, error } = await client.rpc('panel3_take_owner_activation', {
2025
- p_run_id: runId,
2026
- p_machine_id: machineId,
2027
- p_agent: machineHarness,
2028
- p_after_process_token: afterProcessToken,
2029
- p_after_pid: afterPid,
2030
- });
2031
- if (error)
2032
- throw new Error(`could not activate conversation owner ${runId}: ${error.message}`);
2033
- const claimed = data?.[0];
2034
- if (!claimed)
2035
- return null;
2036
- const [events, children] = await Promise.all([
2037
- ownerConversation(client, claimed.run_card_id, new Set(claimed.turn_ids ?? [])),
2038
- ownerChildren(client, runId),
2039
- ]);
2040
- const currentArtifactAnswer = deliveredArtifactAnswer(events, claimed);
2041
- const delivered = claimed.ask_id === null ? null : {
2042
- id: claimed.ask_id,
2043
- question: claimed.question ?? '(question unavailable)',
2044
- answer: claimed.answer,
2045
- mine: claimed.mine === true,
2046
- artifactAnswer: currentArtifactAnswer,
2047
- };
2048
- /* ═══ AFTER THE CLAIM, SO THE ENDING IS THE ONE THIS PATH HAS. ═══ Every
2049
- failure below the claim ends the activation with `giveUp` and its process
2050
- token; a throw above it would merely be an activation that did not happen.
2051
-
2052
- ═══ AND IT MATTERS MOST HERE. ═══ This is the owner, which lives for the
2053
- whole card and whose native session is RESUMED, so it is the one agent that
2054
- can be running while a person edits or deletes a rule. Reading at every
2055
- activation is what makes an edit govern the rest of the conversation, and
2056
- the block's own supersession sentence is what makes a DELETION take effect
2057
- in a session that still holds the older copy.
2058
-
2059
- ═══ AND IT IS BEFORE THE PROMPT SINCE worktrees-8 C1, because the prompt now
2060
- says what the product DID with the person's answer, and the landing needs
2061
- the card's copy. Nothing in the prompt depended on it before. */
2062
- let rules;
2063
- let settings;
2064
- /* THE CARD'S COPY IS MADE IN THE SAME WINDOW AND UNDER THE SAME ENDING, for
2065
- the reason the check above gives: it writes, so it happens after the claim,
2066
- and a failure to make it is an activation that ends rather than one that
2067
- sits `running` with no process. */
2068
- let where;
2069
- /* ═══ AND WHAT IS ATTACHED, READ IN THE SAME WINDOW AND UNDER THE SAME
2070
- ENDING. ═══ attaching-after-the-fact-10: a person may attach to a card that
2071
- is already running, and the owner's brief is immutable, so the only account
2072
- of attachments it would otherwise get is the one frozen at dispatch. Read
2073
- at every activation, exactly as the rules are, and for the same reason a
2074
- failed read ends the activation rather than continuing: "nothing is
2075
- attached" is a different card from the one the person sent.
2076
-
2077
- THE CODEBASE LINES ARE DROPPED. `whatWasAttached` partitions those into a
2078
- section whose own text says "its one codebase is named separately below",
2079
- a forward reference to something `workBrief` supplies and an owner
2080
- activation does not. This block carries the PERSON's attachments; where the
2081
- owner is working is `where.block`'s answer. */
2082
- let attached;
2083
- let pictures;
2273
+ let claimOperationId;
2274
+ const preparation = tools.work?.prepare();
2084
2275
  try {
2085
- where = await ownerDirectory(client, candidate);
2086
- rules = await standingRulesFor(client, runId);
2087
- settings = await settingsForRun(client, runId);
2088
- attached = whatWasAttached((await attachmentsFor(client, claimed.run_card_id))
2089
- .filter((line) => !line.startsWith('codebase ')));
2090
- /* ═══ THE OWNER'S OWN ACTIVATION, WHICH IS WHERE MOST PICTURES ARRIVE. ═══
2091
- The person sends one and this is the run that reads it. Written on every
2092
- activation rather than once, so an owner resumed into an existing native
2093
- conversation is told again about a directory a previous process wrote. */
2094
- pictures = await picturesOnDisk(client, claimed.run_card_id, where, 2);
2095
- }
2096
- catch (error) {
2097
- const why = error instanceof Error ? error.message : String(error);
2098
- await giveUp(client, runId, why, claimed.process_token);
2099
- throw new Error(`NO AGENT IS RUNNING: ${why}`);
2100
- }
2101
- /* THE MERGE, OFF THE MARK ON THE ASK, BEFORE THE AGENT THAT WILL SPEAK ABOUT
2102
- IT IS STARTED. The owner reaches an answer by this route as often as by the
2103
- re-arm, which is why the artifact answer is read on both and this is too. */
2104
- const offered = claimed.ask_id !== null && claimed.mine === true
2105
- ? await landingOffer(client, claimed.ask_id)
2106
- : null;
2107
- const landing = offered === null ? null : landCardWork(where, offered);
2108
- const prompt = resumeSessionId
2109
- ? ownerContinuationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered, landing)
2110
- : ownerActivationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered !== null && !delivered.mine
2111
- ? { id: delivered.id, question: delivered.question }
2112
- : null, currentArtifactAnswer,
2276
+ const candidate = await ownerCandidate(client, runId);
2277
+ if (!candidate)
2278
+ return null;
2279
+ // Existing conversations retain their agent; an explicit hand-back follows
2280
+ // the new selection. Never reuse a native session across different agents.
2281
+ if (candidate.handed_back_at === null && candidate.machine_id !== machineId)
2282
+ return null;
2283
+ const machineHarness = candidate.handed_back_at === null && candidate.harness !== null
2284
+ ? harness({ CTRL_SPC_V3_AGENT: candidate.harness })
2285
+ : await selectedHarness(client, machineId);
2286
+ const resumeSessionId = resumableOwnerSessionId(candidate, machineId, machineHarness);
2287
+ /* ═══ THE EXISTENCE CHECK BEFORE THE CLAIM, AND THE COPY AFTER IT. ═══ This
2288
+ was the whole resolution, which was right while resolving meant reading a
2289
+ folder out of a file. Since worktrees-8 it also CREATES one, and a daemon
2290
+ that lost the activation race would leave a branch and a folder behind for
2291
+ an owner it never activated. The refusal this position exists for — a
2292
+ machine that does not have this codebase must not take the activation
2293
+ is unchanged, because it is the located checkout that answers it. */
2294
+ if (candidate.codebase_id !== null) {
2295
+ checkoutForCodebase(await codebaseOfRun(client, candidate.id), hostname());
2296
+ }
2297
+ const response = await panelClaim(client, tools.work, 'panel3_take_owner_activation', {
2298
+ p_run_id: runId,
2299
+ p_machine_id: machineId,
2300
+ p_agent: machineHarness,
2301
+ p_after_process_token: afterProcessToken,
2302
+ p_after_pid: afterPid,
2303
+ });
2304
+ const { data, error, operationId } = response;
2305
+ claimOperationId = operationId;
2306
+ if (error)
2307
+ throw new Error(`could not activate conversation owner ${runId}: ${error.message}`);
2308
+ const claimed = data?.[0];
2309
+ if (!claimed)
2310
+ return null;
2311
+ const [events, children] = await Promise.all([
2312
+ ownerConversation(client, claimed.run_card_id, new Set(claimed.turn_ids ?? [])),
2313
+ ownerChildren(client, runId),
2314
+ ]);
2315
+ const currentArtifactAnswer = deliveredArtifactAnswer(events, claimed);
2316
+ const delivered = claimed.ask_id === null ? null : {
2317
+ id: claimed.ask_id,
2318
+ question: claimed.question ?? '(question unavailable)',
2319
+ answer: claimed.answer,
2320
+ mine: claimed.mine === true,
2321
+ artifactAnswer: currentArtifactAnswer,
2322
+ };
2323
+ /* ═══ AFTER THE CLAIM, SO THE ENDING IS THE ONE THIS PATH HAS. ═══ Every
2324
+ failure below the claim ends the activation with `giveUp` and its process
2325
+ token; a throw above it would merely be an activation that did not happen.
2326
+ *
2327
+ ═══ AND IT MATTERS MOST HERE. ═══ This is the owner, which lives for the
2328
+ whole card and whose native session is RESUMED, so it is the one agent that
2329
+ can be running while a person edits or deletes a rule. Reading at every
2330
+ activation is what makes an edit govern the rest of the conversation, and
2331
+ the block's own supersession sentence is what makes a DELETION take effect
2332
+ in a session that still holds the older copy.
2333
+ *
2334
+ ═══ AND IT IS BEFORE THE PROMPT SINCE worktrees-8 C1, because the prompt now
2335
+ says what the product DID with the person's answer, and the landing needs
2336
+ the card's copy. Nothing in the prompt depended on it before. */
2337
+ let rules;
2338
+ let settings;
2339
+ /* THE CARD'S COPY IS MADE IN THE SAME WINDOW AND UNDER THE SAME ENDING, for
2340
+ the reason the check above gives: it writes, so it happens after the claim,
2341
+ and a failure to make it is an activation that ends rather than one that
2342
+ sits `running` with no process. */
2343
+ let where;
2344
+ /* ═══ AND WHAT IS ATTACHED, READ IN THE SAME WINDOW AND UNDER THE SAME
2345
+ ENDING. ═══ attaching-after-the-fact-10: a person may attach to a card that
2346
+ is already running, and the owner's brief is immutable, so the only account
2347
+ of attachments it would otherwise get is the one frozen at dispatch. Read
2348
+ at every activation, exactly as the rules are, and for the same reason a
2349
+ failed read ends the activation rather than continuing: "nothing is
2350
+ attached" is a different card from the one the person sent.
2351
+ *
2352
+ THE CODEBASE LINES ARE DROPPED. `whatWasAttached` partitions those into a
2353
+ section whose own text says "its one codebase is named separately below",
2354
+ a forward reference to something `workBrief` supplies and an owner
2355
+ activation does not. This block carries the PERSON's attachments; where the
2356
+ owner is working is `where.block`'s answer. */
2357
+ let attached;
2358
+ let pictures;
2359
+ try {
2360
+ where = await ownerDirectory(client, candidate);
2361
+ rules = await standingRulesFor(client, runId);
2362
+ settings = await settingsForRun(client, runId);
2363
+ attached = whatWasAttached((await attachmentsFor(client, claimed.run_card_id))
2364
+ .filter((line) => !line.startsWith('codebase ')));
2365
+ /* ═══ THE OWNER'S OWN ACTIVATION, WHICH IS WHERE MOST PICTURES ARRIVE. ═══
2366
+ The person sends one and this is the run that reads it. Written on every
2367
+ activation rather than once, so an owner resumed into an existing native
2368
+ conversation is told again about a directory a previous process wrote. */
2369
+ pictures = await picturesOnDisk(client, claimed.run_card_id, where, 2);
2370
+ }
2371
+ catch (error) {
2372
+ const why = error instanceof Error ? error.message : String(error);
2373
+ await giveUp(client, runId, why, claimed.process_token);
2374
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
2375
+ }
2376
+ /* THE MERGE, OFF THE MARK ON THE ASK, BEFORE THE AGENT THAT WILL SPEAK ABOUT
2377
+ IT IS STARTED. The owner reaches an answer by this route as often as by the
2378
+ re-arm, which is why the artifact answer is read on both and this is too. */
2379
+ const offered = claimed.ask_id !== null && claimed.mine === true
2380
+ ? await landingOffer(client, claimed.ask_id)
2381
+ : null;
2382
+ const landing = offered === null ? null : landCardWork(where, offered);
2113
2383
  /* ═══ A PROCESS OF ITS OWN ENDED BEFORE IT FINISHED. ═══ `afterPid` is the
2114
2384
  fact, and it is non-null on all three paths that follow one: a harness
2115
2385
  that crashed, a machine that went down, and now a person's correction.
2116
2386
  The sentences it adds say what to do and never why, because those three
2117
2387
  are not the same event and `prompt.ts` exists to stop an agent being
2118
2388
  told an untrue reason for its own restart. */
2119
- afterPid !== null, landing);
2120
- const started = startAgent(withStandingRules(rules, prompt, where.block, attached, pictures), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) }, settings);
2121
- if (started.pid === null) {
2122
- const answer = await started.answered;
2123
- const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
2124
- await giveUp(client, runId, reason, claimed.process_token);
2125
- throw new Error(`NO AGENT IS RUNNING: ${reason}`);
2126
- }
2127
- try {
2128
- await recordProcess(client, runId, started.pid, undefined, claimed.process_token);
2389
+ const prompt = resumeSessionId
2390
+ ? ownerContinuationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered, landing)
2391
+ : ownerActivationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered !== null && !delivered.mine
2392
+ ? { id: delivered.id, question: delivered.question }
2393
+ : 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);
2395
+ if (started.pid === null) {
2396
+ if (started.interrupted?.())
2397
+ throw new Error('Work was interrupted by the service command.');
2398
+ const answer = await started.answered;
2399
+ const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
2400
+ await giveUp(client, runId, reason, claimed.process_token);
2401
+ throw new Error(`NO AGENT IS RUNNING: ${reason}`);
2402
+ }
2403
+ try {
2404
+ await recordProcess(client, runId, started.pid, undefined, claimed.process_token, started.attempt);
2405
+ }
2406
+ catch (error) {
2407
+ said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
2408
+ }
2409
+ return {
2410
+ settled: settle(client, tools, machineId, 2, runId, claimed.run_card_id, started, true, claimed.process_token, ownerSessionLifecycle(started, runId, machineHarness, claimed.process_token, resumeSessionId)),
2411
+ };
2129
2412
  }
2130
- catch (error) {
2131
- said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
2413
+ finally {
2414
+ preparation?.finish();
2415
+ if (claimOperationId)
2416
+ tools.work?.finishRpcs([claimOperationId]);
2132
2417
  }
2133
- return {
2134
- settled: settle(client, tools, machineId, 2, runId, claimed.run_card_id, started, true, claimed.process_token, ownerSessionLifecycle(started, runId, machineHarness, claimed.process_token, resumeSessionId)),
2135
- };
2136
2418
  }
2137
2419
  /** The only owner rows the normal poll may try, including an explicit hand-back move.
2138
2420
  *
@@ -2208,7 +2490,8 @@ export function redirectedProcess(candidate, machineId,
2208
2490
  /** The newest unaddressed person turn per card. MAX, never first-seen: a turn
2209
2491
  * left unaddressed while a question was open would otherwise pin its card
2210
2492
  * below `resumed_at` for good and nothing on it could ever redirect. */
2211
- waiting, cardId, booted) {
2493
+ waiting, cardId, booted, executionHeld = false) {
2494
+ // A dead transport can still own live tools in the durable execution boundary.
2212
2495
  // The claim's own five, mirrored.
2213
2496
  if (candidate.state !== 'running' || candidate.ended_at !== null)
2214
2497
  return null;
@@ -2240,7 +2523,7 @@ waiting, cardId, booted) {
2240
2523
  if (new Date(said).getTime() <= new Date(candidate.resumed_at ?? candidate.started_at).getTime()) {
2241
2524
  return null;
2242
2525
  }
2243
- return runProcessIsAlive(candidate, booted) ? candidate.pid : null;
2526
+ return executionHeld || runProcessIsAlive(candidate, booted) ? candidate.pid : null;
2244
2527
  }
2245
2528
  export function pendingOwnerSessionWithinGrace(candidate, now = Date.now()) {
2246
2529
  const local = readOwnerSession(candidate.id);
@@ -2283,9 +2566,17 @@ async function takeOwnerActivations(client, tools, machineId, mine, hold) {
2283
2566
  else and there is a dead process, a row still reading `running` and a card
2284
2567
  still saying `working`, with nothing local to retry it. */
2285
2568
  if (mine.has(candidate.id)) {
2286
- const pid = redirectedProcess({ ...candidate, card_state: candidate.card?.state ?? null }, machineId, waiting, candidate.card_id, bootedAt());
2569
+ const attempt = recoveryAttempt(candidate);
2570
+ const executionHeld = tools.work?.heldAttempt(attempt) ?? false;
2571
+ // A run ID alone cannot authorize stopping a newer activation.
2572
+ if (tools.work && !executionHeld)
2573
+ continue;
2574
+ const pid = redirectedProcess({ ...candidate, card_state: candidate.card?.state ?? null }, machineId, waiting, candidate.card_id, bootedAt(), executionHeld);
2287
2575
  if (pid !== null) {
2288
- killTree({ pid, kill: (signal) => process.kill(pid, signal) });
2576
+ if (executionHeld)
2577
+ await tools.work.stopHeldAttempt(attempt);
2578
+ else
2579
+ killTree({ pid, kill: (signal) => process.kill(pid, signal) });
2289
2580
  /* ═══ SIGNALLED, NOT KILLED, AND THE WORD IS THE POINT. ═══ `killTree`
2290
2581
  swallows a refused signal on both platforms, so saying "killed" would
2291
2582
  claim a death this daemon never observed. A process that survives is
@@ -2296,6 +2587,8 @@ async function takeOwnerActivations(client, tools, machineId, mine, hold) {
2296
2587
  }
2297
2588
  continue;
2298
2589
  }
2590
+ if (tools.work?.heldAttempt(recoveryAttempt(candidate)))
2591
+ continue;
2299
2592
  if (pendingOwnerSessionWithinGrace(candidate))
2300
2593
  continue;
2301
2594
  if (candidate.handed_back_at !== null
@@ -2359,7 +2652,7 @@ function runProcessIsAlive(run, booted) {
2359
2652
  * touched here. A current owner row, live PID, in-flight process, or fresh
2360
2653
  * pid-null claim always defers cleanup. Stable state is removed only after the
2361
2654
  * owner row disappears, becomes terminal, or no longer owns its card. */
2362
- export async function reconcileOwnerSessions(client, machineId, _machineHarness, inFlightOwnerIds = new Set()) {
2655
+ export async function reconcileOwnerSessions(client, machineId, _machineHarness, inFlightOwnerIds = new Set(), work) {
2363
2656
  const mappingIds = listOwnerSessionIds();
2364
2657
  const homeIds = listPanel3CodexOwnerHomeIds();
2365
2658
  const all = [...new Set([...mappingIds, ...homeIds])];
@@ -2376,7 +2669,11 @@ export async function reconcileOwnerSessions(client, machineId, _machineHarness,
2376
2669
  for (const id of ids) {
2377
2670
  if (inFlightOwnerIds.has(id))
2378
2671
  continue;
2672
+ if (work?.heldLocalOwner(id))
2673
+ continue;
2379
2674
  const row = byId.get(id);
2675
+ if (row && work?.heldAttempt(recoveryAttempt(row)))
2676
+ continue;
2380
2677
  const localProcessInUse = !!row
2381
2678
  && row.machine_id === machineId
2382
2679
  && ((row.pid !== null && processIsAlive(row.pid))
@@ -2429,10 +2726,10 @@ export async function reconcileOwnerSessions(client, machineId, _machineHarness,
2429
2726
  * the next poll tries again: clearing it after an EPERM would say this machine
2430
2727
  * has no process for a run whose agent is still working.
2431
2728
  */
2432
- async function killStopped(client, machineId) {
2729
+ async function killStopped(client, machineId, work) {
2433
2730
  const stopped = await returned(client
2434
2731
  .from('panel3_runs')
2435
- .select('id, card_id, pid, state, started_at, resumed_at')
2732
+ .select('id, card_id, pid, state, started_at, resumed_at, process_token')
2436
2733
  .eq('machine_id', machineId)
2437
2734
  .in('state', [...ENDED_BY_THE_PERSON, 'finished', 'failed'])
2438
2735
  .not('pid', 'is', null), 'read', 'the runs on this machine that are not coming back');
@@ -2440,7 +2737,16 @@ async function killStopped(client, machineId) {
2440
2737
  return;
2441
2738
  const booted = bootedAt();
2442
2739
  for (const run of stopped) {
2443
- if (runProcessIsAlive(run, booted)) {
2740
+ let stoppedByOwner = false;
2741
+ if (work?.heldAttempt(recoveryAttempt(run))) {
2742
+ if (!ENDED_BY_THE_PERSON.includes(run.state))
2743
+ continue;
2744
+ // The recorded bridge can be gone while its owned tools still run.
2745
+ // Only the durable owner can confirm that the complete execution ended.
2746
+ await work.stopHeldAttempt(recoveryAttempt(run));
2747
+ stoppedByOwner = true;
2748
+ }
2749
+ if (!stoppedByOwner && runProcessIsAlive(run, booted)) {
2444
2750
  if (!ENDED_BY_THE_PERSON.includes(run.state))
2445
2751
  continue;
2446
2752
  try {
@@ -2461,15 +2767,18 @@ async function killStopped(client, machineId) {
2461
2767
  continue;
2462
2768
  }
2463
2769
  try {
2464
- await returned(client
2770
+ let cleared = client
2465
2771
  .from('panel3_runs')
2466
2772
  .update({ pid: null })
2467
- // A finished owner may activate again between this read and write.
2468
- // Only acknowledge the exact process whose exit was observed.
2469
2773
  .eq('id', run.id)
2470
2774
  .eq('pid', run.pid)
2471
- .in('state', [...ENDED_BY_THE_PERSON, 'finished', 'failed'])
2472
- .select('id'), 'clear the process id of', `run ${run.id}`);
2775
+ .eq('started_at', run.started_at)
2776
+ .in('state', [...ENDED_BY_THE_PERSON, 'finished', 'failed']);
2777
+ cleared = run.process_token === null ? cleared.is('process_token', null) : cleared.eq('process_token', run.process_token);
2778
+ cleared = run.resumed_at === null ? cleared.is('resumed_at', null) : cleared.eq('resumed_at', run.resumed_at);
2779
+ await returned(
2780
+ // A finished owner may activate again while its old execution closes.
2781
+ cleared.select('id'), 'clear the process id of', `run ${run.id}`);
2473
2782
  }
2474
2783
  catch (error) {
2475
2784
  // Said, not fatal. The kill has already happened; this is bookkeeping, and
@@ -2554,6 +2863,8 @@ async function recoverStranded(client, tools, machineId, mine, hold) {
2554
2863
  for (const run of live) {
2555
2864
  if (mine.has(run.id))
2556
2865
  continue;
2866
+ if (tools.work?.heldAttempt(recoveryAttempt(run)))
2867
+ continue;
2557
2868
  // WHEN THE ATTEMPT NOW RUNNING BEGAN, which is the first one until a resume
2558
2869
  // says otherwise. See the header.
2559
2870
  const startedAt = new Date(run.resumed_at ?? run.started_at).getTime();
@@ -2790,6 +3101,8 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
2790
3101
  // ONCE, OUTSIDE THE LOOP. It is a property of this machine, not of a row.
2791
3102
  const booted = bootedAt();
2792
3103
  for (const run of offered) {
3104
+ if (tools.work?.heldAttempt(recoveryAttempt(run)))
3105
+ continue;
2793
3106
  if (mine.has(run.id)) {
2794
3107
  /* THIS DAEMON'S OWN LIVE WORK, OFFERED WHILE IT WAS BUSY BEING QUIET. See
2795
3108
  the header. Said rather than passed over in silence, because a machine
@@ -2980,7 +3293,7 @@ export async function sweepFinishedWorktrees(client) {
2980
3293
  export function clientReader(injected) {
2981
3294
  return typeof injected === 'function' ? injected : () => injected;
2982
3295
  }
2983
- export async function run(args, injected, signal, lifecycle) {
3296
+ export async function run(args, injected, signal, lifecycle, work) {
2984
3297
  let once = false;
2985
3298
  for (const arg of args) {
2986
3299
  if (arg === '--once')
@@ -3036,11 +3349,21 @@ export async function run(args, injected, signal, lifecycle) {
3036
3349
  `tools` is referenced inside the callback it is being given, which is safe
3037
3350
  for the plain reason that the callback can only run once a request has
3038
3351
  arrived at a server that by then exists. */
3352
+ if (work)
3353
+ await reconcilePanelInterruptions(current(), machineId, work);
3354
+ lifecycle?.reconciled?.();
3039
3355
  const tools = await startToolsServer(current(), async (parentRunId, brief, codebase, processToken, choice) => {
3040
- const child = await startChild(current(), tools, machineId, parentRunId, brief, codebase, processToken, choice);
3041
- hold(child.runId, child.settled);
3042
- return { runId: child.runId };
3043
- });
3356
+ const endClaim = work?.beginClaim();
3357
+ try {
3358
+ const child = await startChild(current(), tools, machineId, parentRunId, brief, codebase, processToken, choice);
3359
+ hold(child.runId, child.settled);
3360
+ return { runId: child.runId };
3361
+ }
3362
+ finally {
3363
+ endClaim?.();
3364
+ }
3365
+ }, (runId, processToken, action) => recoverLanding(current(), machineId, runId, processToken, action));
3366
+ tools.work = work;
3044
3367
  out(`daemon machine ${machineId}`);
3045
3368
  out(`tools ${tools.urlFor('<run-id>')}`);
3046
3369
  out(once ? 'mode one poll' : `mode polling every ${POLL_INTERVAL_MS / 1000}s, Ctrl-C to stop`);
@@ -3081,6 +3404,8 @@ export async function run(args, injected, signal, lifecycle) {
3081
3404
  // Restart only at a poll boundary, before any claims, with no live work.
3082
3405
  if (lifecycle && !lifecycle.beforePoll(inFlight.size))
3083
3406
  break;
3407
+ let endClaim;
3408
+ const claimOperations = [];
3084
3409
  /* ═══ ONE POLL FAILING IS NOT THE DAEMON FAILING. ═══ Every read and write
3085
3410
  here throws on a network or database error, by design (constraint 7), and
3086
3411
  until Slice 4 that threw straight out of `panel3/cli.js run` and exited the process.
@@ -3093,12 +3418,19 @@ export async function run(args, injected, signal, lifecycle) {
3093
3418
  `--once` still fails loudly, because the acceptance harness reads the exit
3094
3419
  code and a swallowed failure there would make a broken suite look green. */
3095
3420
  try {
3421
+ if (work)
3422
+ await reconcilePanelInterruptions(current(), machineId, work);
3423
+ if (work && !work.allowed()) {
3424
+ await sleep(POLL_INTERVAL_MS);
3425
+ continue;
3426
+ }
3427
+ endClaim = work?.beginClaim();
3096
3428
  /* ═══ THE USER'S STOP IS HONOURED BEFORE ANYTHING ELSE ON THE POLL. ═══ It
3097
3429
  is the only thing here that a person is waiting on, and the two takes
3098
3430
  below can spend the rest of the poll starting agents. Nothing else needs
3099
3431
  to run first: `panel3_stop_card` has already ended the runs, so recovery
3100
3432
  cannot see them and neither take can start them. */
3101
- await killStopped(current(), machineId);
3433
+ await killStopped(current(), machineId, work);
3102
3434
  /* Publish readiness before claiming work. A card with an untaken turn reads the same whether a daemon
3103
3435
  is two seconds away or nobody has one running; this row is the only place
3104
3436
  the difference exists. It is written before the takes rather than after
@@ -3123,7 +3455,7 @@ export async function run(args, injected, signal, lifecycle) {
3123
3455
  }
3124
3456
  listeningHarness = machineHarness;
3125
3457
  await sayListening(current(), machineId, machineName, machineHarness);
3126
- await reconcileOwnerSessions(current(), machineId, machineHarness, new Set(inFlight.keys()));
3458
+ await reconcileOwnerSessions(current(), machineId, machineHarness, new Set(inFlight.keys()), work);
3127
3459
  await recoverStranded(current(), tools, machineId, new Set(inFlight.keys()), hold);
3128
3460
  /* ═══ AND THE COPIES OF CARDS THAT ARE OVER. ═══ After recovery,
3129
3461
  deliberately: a run this machine is about to resume is one whose card is
@@ -3149,7 +3481,10 @@ export async function run(args, injected, signal, lifecycle) {
3149
3481
  /* THE MACHINE ID GOES IN because the take writes the run row, and a run has
3150
3482
  to say where it is running: the exclusion is cross-machine and recovery is
3151
3483
  per-machine, so a row with nobody's machine on it could be neither. */
3152
- const taken = await returned(current().rpc('panel3_take_turns', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'turns');
3484
+ const takenResponse = await panelClaim(current(), work, 'panel3_take_turns', { p_machine_id: machineId, p_agent: machineHarness });
3485
+ if (takenResponse.operationId)
3486
+ claimOperations.push(takenResponse.operationId);
3487
+ const taken = await returned(Promise.resolve(takenResponse), 'take', 'turns');
3153
3488
  /* ═══ THE OTHER KIND OF TAKEABLE WORK. ═══ ux.md's re-arm: an answered
3154
3489
  question makes the branch that asked it takeable again, and a question
3155
3490
  still walking up makes the run it reached takeable so that level gets its
@@ -3163,7 +3498,10 @@ export async function run(args, injected, signal, lifecycle) {
3163
3498
  to it on a later poll rather than putting two of them on one card. The
3164
3499
  other order would decide the same question from a snapshot taken before
3165
3500
  the run existed. */
3166
- const rearmed = await returned(current().rpc('panel3_take_rearms', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'runs that can carry on');
3501
+ const rearmedResponse = await panelClaim(current(), work, 'panel3_take_rearms', { p_machine_id: machineId, p_agent: machineHarness });
3502
+ if (rearmedResponse.operationId)
3503
+ claimOperations.push(rearmedResponse.operationId);
3504
+ const rearmed = await returned(Promise.resolve(rearmedResponse), 'take', 'runs that can carry on');
3167
3505
  for (const row of rearmed) {
3168
3506
  /* NOT AWAITED PAST THE SPAWN, exactly as a taken card is not: the work is a
3169
3507
  real agent and holding the poll open for it would put every other card
@@ -3217,6 +3555,10 @@ export async function run(args, injected, signal, lifecycle) {
3217
3555
  }
3218
3556
  }
3219
3557
  }
3558
+ finally {
3559
+ work?.finishRpcs(claimOperations);
3560
+ endClaim?.();
3561
+ }
3220
3562
  if (!signal?.aborted)
3221
3563
  await sleep(POLL_INTERVAL_MS);
3222
3564
  }
@@ -3236,8 +3578,12 @@ export async function run(args, injected, signal, lifecycle) {
3236
3578
  * client. A stopped worker is restarted automatically. Explicit restarts happen
3237
3579
  * at an idle poll boundary and confirm only after a new worker completes a poll.
3238
3580
  * Sign-out stops the supervisor, so it cannot restart behind the user's back. */
3239
- export function startPanel(injected) {
3581
+ export function startPanel(injected, work) {
3240
3582
  const controller = new AbortController();
3583
+ let resolveReady;
3584
+ let rejectReady;
3585
+ const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
3586
+ void ready.catch(() => { });
3241
3587
  let requested = false;
3242
3588
  let restarting = null;
3243
3589
  let resolveRestart = null;
@@ -3256,11 +3602,13 @@ export function startPanel(injected) {
3256
3602
  }
3257
3603
  return false;
3258
3604
  },
3605
+ reconciled: () => resolveReady(),
3259
3606
  ready: () => { if (!requested)
3260
3607
  resolveRestart?.(); },
3261
- });
3608
+ }, work);
3262
3609
  }
3263
3610
  catch (error) {
3611
+ rejectReady(error instanceof Error ? error : new Error(String(error)));
3264
3612
  said(`the agent panel stopped polling: ${error instanceof Error ? error.message : String(error)}`);
3265
3613
  // Startup failures must not leave an online machine with a dead worker.
3266
3614
  if (!controller.signal.aborted)
@@ -3269,6 +3617,7 @@ export function startPanel(injected) {
3269
3617
  }
3270
3618
  })();
3271
3619
  return {
3620
+ ready,
3272
3621
  restart: () => {
3273
3622
  if (controller.signal.aborted)
3274
3623
  return Promise.reject(new Error('This machine is signing out. Open Companion and sign in again.'));
@@ -3291,6 +3640,7 @@ export function startPanel(injected) {
3291
3640
  },
3292
3641
  stop: async () => {
3293
3642
  controller.abort();
3643
+ rejectReady(new Error('Cloud startup was cancelled by the service command.'));
3294
3644
  rejectRestart?.(new Error('The machine disconnected before the worker restarted.'));
3295
3645
  await running;
3296
3646
  },