@ctrl-spc/cs 0.7.0 → 0.7.2

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.
@@ -8,7 +8,7 @@
8
8
  * `startPanel`'s own comment at the foot of this file carries the reasoning.
9
9
  *
10
10
  * ---------------------------------------------------------------------------
11
- * WHAT THIS IS. `cs3 say` puts a card and a user turn on the record and returns,
11
+ * WHAT THIS IS. `cs say` puts a card and a user turn on the record and returns,
12
12
  * because ux.md requires the acknowledgement never to wait for a spawn. This is
13
13
  * the other half: the loop that notices the turn, leases it, writes a run row,
14
14
  * starts a real agent, and puts what that agent said back on the card.
@@ -132,18 +132,21 @@
132
132
  // and takes the leading comment with it, so a file whose first statement is
133
133
  // `import type` loses its v3 header in the published `dist/`.
134
134
  import { out, returned, signedInClient } from './client.js';
135
- import { answerPrompt, escalationPrompt, levelOnePrompt, readBackPrompt, resumePrompt, retryPrompt, } from './prompt.js';
136
- import { attachmentLine, loadAttachments, loadOutputNames, outputOf } from './show.js';
135
+ import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, landingOutcomeContext, presentedArtifactAnswerContext, resumePrompt, retryPrompt, standingRules, whatWasAttached, workingRules, } from './prompt.js';
136
+ import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, loadOutputNames, outputOf, recordBaseProtection, standingRulesFor, withAskContent, } from './show.js';
137
137
  import { forgetSecrets, redactSecrets } from './secrets.js';
138
138
  import { sayListening, stopListening } from './presence.js';
139
- import { checkoutForCodebase, hasCheckoutForCodebase } from './checkout.js';
139
+ import { designatedCoordinator } from './coordinator.js';
140
+ import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, worktreesOnThisMachine, } from './checkout.js';
140
141
  import { harness, startAgent } from './spawn.js';
142
+ import { establishOwnerSession, listOwnerSessionIds, OWNER_SESSION_GRACE_MS, readOwnerSession, removeOwnerSession, validSessionUuid, writeOwnerSession, } from './session.js';
141
143
  import { startToolsServer } from './tools.js';
144
+ import { listPanel3CodexOwnerHomeIds, removePanel3CodexOwnerHome, } from '../codex-home.js';
142
145
  import { getMachineIdentity, scratchDir } from '../config.js';
143
146
  import { listCodebases } from '../codebases.js';
144
- import { processIsAlive } from '../win-shell.js';
147
+ import { killTree, processIsAlive } from '../win-shell.js';
145
148
  import { hostname, uptime } from 'node:os';
146
- const USAGE = 'usage: cs3 run [--once]';
149
+ const USAGE = 'usage: run [--once]';
147
150
  /** How long between takes. Short, because it is the whole delay between a user
148
151
  * sending and a card showing an agent on it, and the take is one small indexed
149
152
  * read against the user's own rows. */
@@ -200,6 +203,84 @@ export const MAX_ATTEMPTS = 3;
200
203
  * because an agent asked for it, and its level comes off the row
201
204
  * `panel3_dispatch` wrote rather than from anything here. */
202
205
  const LEVEL = 1;
206
+ const artifactAnswer = (id, revision, selected) => {
207
+ if (!id || revision === null || revision === undefined || selected?.length !== 1)
208
+ return null;
209
+ const answer = selected[0]?.trim().toLowerCase();
210
+ return answer === 'approve' || answer === 'request changes'
211
+ ? { id, revision, answer }
212
+ : null;
213
+ };
214
+ export function deliveredArtifactAnswer(events, delivered) {
215
+ if (!delivered.mine || delivered.ask_id === null)
216
+ return null;
217
+ const event = events.find((candidate) => candidate.kind === 'question' && candidate.id === delivered.ask_id);
218
+ const answer = artifactAnswer(event?.relatedArtifactId, event?.relatedArtifactRevision, event?.artifactAnswer ? [event.artifactAnswer] : null);
219
+ return answer === null ? null : { questionId: delivered.ask_id, ...answer };
220
+ }
221
+ async function rearmedArtifactAnswer(client, askId) {
222
+ const asks = await withAskContent(client, await returned(client.from('panel3_asks').select(`id, ${ASK_CONTENT_COLUMNS}`).eq('id', askId), 'read', `question ${askId}`));
223
+ const ask = asks[0];
224
+ return ask === undefined
225
+ ? null
226
+ : artifactAnswer(ask.related_artifact_id, ask.related_artifact_revision, ask.selected_options);
227
+ }
228
+ const LAND = 'Put this work on the main branch';
229
+ const LEAVE = 'Leave it on its branch';
230
+ export async function landingOffer(client, askId) {
231
+ const asks = await withAskContent(client, await returned(client.from('panel3_asks').select(`id, offers_landing, ${ASK_CONTENT_COLUMNS}`).eq('id', askId), 'read', `question ${askId}`));
232
+ const ask = asks[0];
233
+ if (ask === undefined || ask.offers_landing !== true)
234
+ return null;
235
+ const selected = ask.selected_options ?? [];
236
+ if (selected.length !== 1)
237
+ return 'unclear';
238
+ return selected[0] === LAND ? 'land' : selected[0] === LEAVE ? 'leave' : 'unclear';
239
+ }
240
+ /**
241
+ * ═══ THE PRODUCT PERFORMS THE LANDING, HERE, BEFORE THE AGENT THAT WILL SPEAK
242
+ * ABOUT IT STARTS. ═══
243
+ *
244
+ * Not a tool, because a tool means an agent decides whether a yes takes effect,
245
+ * and an agent that finishes the card instead leaves a person who clicked yes
246
+ * with nothing. Not the poll's sweep, because the sweep cannot tell the agent
247
+ * what it did.
248
+ *
249
+ * ═══ AND A FAILURE DOES NOT END THE RUN. ═══ The agent has to be started to
250
+ * tell the person, so what happened is returned as the sentence it will be
251
+ * handed rather than written to `failed_because`.
252
+ */
253
+ export function landCardWork(where, outcome) {
254
+ const card = where.card;
255
+ if (outcome !== 'land')
256
+ return { outcome, branch: card?.branch ?? null, base: card?.base ?? null };
257
+ if (card === null) {
258
+ return { outcome: 'refused', because: 'This card has no working copy on this machine, so there is nothing to put back.' };
259
+ }
260
+ if (card.landing !== 'main') {
261
+ return {
262
+ outcome: 'refused',
263
+ because: `Finished work in ${card.codebaseName} does not go onto ${card.base}, so nothing `
264
+ + 'was merged. The work is on its branch.',
265
+ };
266
+ }
267
+ try {
268
+ /* THE COMMIT FIRST, AND ITS ABSENCE IS NOT A FAILURE. Between the answer and
269
+ this re-arm every run on the card has ended, so the sweep may already have
270
+ committed the copy and taken it away; `workingDirectory` has just rebuilt
271
+ it from the branch, and the only difference is that there is nothing left
272
+ to commit. */
273
+ commitCardWork(card.folder, `${card.codebaseName}'s copy of this card on branch ${card.branch}`);
274
+ mergeIntoBase(card.source, card.branch, card.base, card.codebaseName);
275
+ return { outcome: 'landed', branch: card.branch, base: card.base };
276
+ }
277
+ catch (error) {
278
+ return {
279
+ outcome: 'refused',
280
+ because: error instanceof Error ? error.message : String(error),
281
+ };
282
+ }
283
+ }
203
284
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
204
285
  /**
205
286
  * ═══ NOTHING IN THIS FILE STAMPS A COLUMN FROM THIS MACHINE'S CLOCK ANY MORE,
@@ -268,7 +349,7 @@ function byCard(taken) {
268
349
  * `levelOnePrompt` for the measured failure this closes.
269
350
  *
270
351
  * THE NAME IS RESOLVED, NOT READ OFF THE RECEIPT, through the one function
271
- * `cs3 show` uses for it. `label` is written when the thing is made and the
352
+ * `cs show` uses for it. `label` is written when the thing is made and the
272
353
  * coordinator may rename the thing on the next turn; printing the label then
273
354
  * hands the agent the name of a different, real object. One owner for that,
274
355
  * because two would eventually disagree about which name a card is showing.
@@ -320,6 +401,75 @@ export const briefFor = (title, turns, produced, attachments, codebases = null)
320
401
  identity: codebase.gitRemoteUrl,
321
402
  located: hasCheckoutForCodebase(codebase),
322
403
  })) ?? null);
404
+ /**
405
+ * ═══ THE MANDATE IN FRONT OF THE PROMPT, AT EVERY ONE OF THE FIVE STARTS. ═══
406
+ *
407
+ * 19/project-context-7b, contract point 1: everything written under Agent
408
+ * instructions reaches every panel agent, at levels 1, 2 and 3, before it acts.
409
+ * Five call sites start a process (`answerCard`, `startChild`, `resumeRun`,
410
+ * `startRearmed`, `activateOwner`), and this is the one place the block and a
411
+ * prompt are put together, so the wording and the ordering cannot drift between
412
+ * them. The READ is `standingRulesFor` and the WORDS are `standingRules`; this
413
+ * is only the join.
414
+ *
415
+ * ═══ AND IT IS APPLIED TO WHAT `startAgent` IS HANDED, NEVER TO WHAT
416
+ * `recordProcess` WRITES. ═══ Contract point 2 lives or dies on that
417
+ * distinction. `panel3_runs.brief` is the immutable brief and every respawn
418
+ * replays it, so a block folded into the stored brief would be a snapshot of the
419
+ * rules as they stood at dispatch, replayed on top of a fresh copy at every
420
+ * later start — two sets of rules in one prompt, the stale one first. Keeping it
421
+ * out means the stored brief is byte-identical with and without this feature,
422
+ * which is asserted rather than assumed.
423
+ *
424
+ * ═══ NO WRAPPER AROUND THE FIVE STARTS. ═══ `startAgent` is synchronous and the
425
+ * five sites have five different endings (see `run.ts`'s own note at the level 1
426
+ * start: `endRun` with a level fork at two of them, `giveUp` at two more). The
427
+ * READ therefore sits inside each site's existing `try`, taking that site's
428
+ * ending, so a failure to read the mandate ends the run the way that path
429
+ * already ends runs and never starts a process without it (contract point 3).
430
+ * What is shared is the loader and the builder — which is where drift would
431
+ * actually happen.
432
+ */
433
+ // Exported for the same reason `briefFor` is: the seam this feature crosses is
434
+ // the string handed to `startAgent`, and a test over `toolHandler('dispatch')`
435
+ // would prove nothing about it — `caller.dispatch` is injected, so it stops
436
+ // upstream of this composer.
437
+ export const withStandingRules = (documents, prompt,
438
+ /* ═══ OPTIONAL, SO THE FOUR SITES WITH A CODEBASE GAIN IT AND NOTHING ELSE
439
+ MOVES. ═══ worktrees-8's three sentences about the card's own copy join
440
+ here rather than in the brief, for the same reason the rules do: the brief
441
+ is immutable and replayed, and the branch is not known when it is composed.
442
+ Under the rules, because a standing rule the people wrote outranks a fact
443
+ about the machine. */
444
+ whereYouAre = [],
445
+ /* ═══ WHAT THE PERSON HAS ATTACHED, RIGHT NOW, FOR THE OWNER THAT LIVES FOR
446
+ THE WHOLE CARD. ═══ attaching-after-the-fact-10. Its own parameter rather
447
+ than folded into `whereYouAre`, which is worktrees-8's sentences about the
448
+ card's copy of the codebase: what is attached is not a fact about the
449
+ machine, and `where.block` is `[]` for a level 2 owner on a record-only
450
+ card, so folding in would produce a leading blank with no separator.
451
+
452
+ ═══ ABOVE THE RULES, BECAUSE THE RULES SAY IT OUTRANKS THEM. ═══
453
+ `standingRules`' own precedence line ranks what the person attached above
454
+ the rules; rendering it underneath would put it below a block saying it
455
+ wins. Already carries its own header and its own leading blank, which this
456
+ join absorbs the same way it absorbs `whereYouAre`'s. */
457
+ whatIsAttached = []) => {
458
+ /* `whatWasAttached` begins its array with a blank, since its two other
459
+ callers join it onto lines already above it. Here it is the first thing in
460
+ the prompt, and a prompt that opens on an empty line is a prompt with a
461
+ stray blank at the top, so the leading blanks are dropped and the separator
462
+ below is this join's own. */
463
+ const attached = [...whatIsAttached];
464
+ while (attached[0] === '')
465
+ attached.shift();
466
+ const block = [
467
+ ...(attached.length === 0 ? [] : [...attached, '']),
468
+ ...standingRules(documents),
469
+ ...(whereYouAre.length === 0 ? [] : ['', ...whereYouAre]),
470
+ ];
471
+ return block.length === 0 ? prompt : [...block, '', prompt].join('\n');
472
+ };
323
473
  // ---------------------------------------------------------------------------
324
474
  // WRITES. Every one goes through the shared guard in `client.ts`, which returns
325
475
  // the rows or throws. `.select()` on each is what makes that possible: a
@@ -344,12 +494,16 @@ export const briefFor = (title, turns, produced, attachments, codebases = null)
344
494
  * the run has no pid, so once this daemon is gone recovery treats it as dead —
345
495
  * which errs towards answering again rather than stranding.
346
496
  */
347
- async function recordProcess(client, runId, pid, brief) {
348
- await returned(client
497
+ async function recordProcess(client, runId, pid, brief, processToken) {
498
+ let query = client
349
499
  .from('panel3_runs')
350
500
  .update({ pid, ...(brief === undefined ? {} : { brief }) })
351
- .eq('id', runId)
352
- .select('id'), 'record what is running', `run ${runId}`);
501
+ .eq('id', runId);
502
+ if (processToken !== undefined)
503
+ query = query.eq('process_token', processToken);
504
+ const written = await returned(query.select('id'), 'record what is running', `run ${runId}`);
505
+ if (written.length === 0)
506
+ throw new Error(`could not record what is running for run ${runId}: its activation has ended`);
353
507
  }
354
508
  /**
355
509
  * ═══ A RUN GIVEN UP ON: ENDED, ITS TURNS HANDED BACK, ITS CARD IN HAND. ═══
@@ -364,15 +518,19 @@ async function recordProcess(client, runId, pid, brief) {
364
518
  * there first. It is a fact about the record rather than a failure, so it does
365
519
  * not go through `returned()`, exactly as the answer's own null does not.
366
520
  */
367
- async function giveUp(client, runId, reason) {
521
+ async function giveUp(client, runId, reason, processToken) {
368
522
  const { data, error } = await client
369
- .rpc('panel3_give_up', { p_run_id: runId, p_reason: reason });
523
+ .rpc('panel3_give_up', {
524
+ p_run_id: runId,
525
+ p_reason: reason,
526
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
527
+ });
370
528
  if (error)
371
529
  throw new Error(`could not give up run ${runId}: ${error.message}`);
372
530
  /* THE RUN IS OVER, so whatever it read is dropped. Every ending does this —
373
531
  here, `failRun` and `writeAnswer` — because a daemon stays up for days and
374
532
  has no business holding Tuesday's secret. */
375
- forgetSecrets(runId);
533
+ forgetSecrets(processToken === undefined ? runId : `${runId}:${processToken}`);
376
534
  return data;
377
535
  }
378
536
  /**
@@ -411,7 +569,7 @@ async function giveUp(client, runId, reason) {
411
569
  * a run can be the last thing live on it — but no turn is written, exactly as
412
570
  * none is written for a run that stopped to ask.
413
571
  */
414
- async function writeAnswer(client, runId, cardId, text) {
572
+ async function writeAnswer(client, runId, cardId, text, processToken) {
415
573
  /* ═══ THE SECOND OF THE TWO CHOKEPOINTS THE CREDENTIAL RULE RESTS ON. ═══
416
574
  This is the ONE path an agent's own words take to a hosted row — a turn on
417
575
  the card at levels 1 and 2, and a level 3's `panel3_runs.report`, which
@@ -419,7 +577,11 @@ async function writeAnswer(client, runId, cardId, text) {
419
577
  `buildServer`. Identity for a run that read no credential, so every existing
420
578
  exact-string assertion is byte-identical. */
421
579
  const { data: turnId, error } = await client
422
- .rpc('panel3_answer', { p_run_id: runId, p_body: text === null ? null : redactSecrets(runId, text) });
580
+ .rpc('panel3_answer', {
581
+ p_run_id: runId,
582
+ p_body: text === null ? null : redactSecrets(processToken === undefined ? runId : `${runId}:${processToken}`, text),
583
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
584
+ });
423
585
  if (error) {
424
586
  /* Thrown, not settled as failed. The run stays `running` with a process
425
587
  that has now exited, so recovery reaps it: at level 1 the turns are
@@ -433,7 +595,7 @@ async function writeAnswer(client, runId, cardId, text) {
433
595
  credential again through `get_credential`. What it is handed to start from —
434
596
  its own report, and the card — went through this same substitution, so there
435
597
  is nothing left for a stale entry to protect. */
436
- forgetSecrets(runId);
598
+ forgetSecrets(processToken === undefined ? runId : `${runId}:${processToken}`);
437
599
  if (turnId === null) {
438
600
  /* NO TURN WAS WRITTEN, and there are FOUR reasons, which mean different
439
601
  things and must not be printed as one line.
@@ -469,6 +631,9 @@ async function writeAnswer(client, runId, cardId, text) {
469
631
  else if (run.state !== 'finished') {
470
632
  said(`run ${runId} was ${run.state}, so its answer was not written to card ${cardId}`);
471
633
  }
634
+ else if (run.level === 1 && run.conversationOwnerId !== null) {
635
+ out(`settled card ${cardId} run ${runId} assigned the conversation, so it wrote nothing here`);
636
+ }
472
637
  else if (text === null) {
473
638
  out(`settled card ${cardId} run ${runId} answered a question, so it wrote nothing here`);
474
639
  }
@@ -481,16 +646,32 @@ async function writeAnswer(client, runId, cardId, text) {
481
646
  away. */
482
647
  said(`run ${runId} had already ended, so its answer was not written to card ${cardId}`);
483
648
  }
484
- return;
649
+ return ownerSettlementAccepted(run, processToken);
485
650
  }
486
651
  out(`answered card ${cardId} run ${runId} ${text?.length ?? 0} characters`);
652
+ return true;
653
+ }
654
+ export function ownerSettlementAccepted(run, processToken) {
655
+ return (run.state === 'asked' || run.state === 'finished')
656
+ && (processToken === undefined || run.processToken === processToken);
487
657
  }
488
658
  /** What the record says a run is now, and at what level. Read only to say the
489
659
  * right sentence about something that has already happened; nothing branches on
490
660
  * it that could have gone the other way. */
491
661
  async function runNow(client, runId) {
492
- const runs = await returned(client.from('panel3_runs').select('state, level').eq('id', runId), 'read', `the state of run ${runId}`);
493
- return runs[0] ?? { state: 'no longer on the record', level: null };
662
+ const runs = await returned(client
663
+ .from('panel3_runs')
664
+ .select('state, level, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
665
+ .eq('id', runId), 'read', `the state of run ${runId}`);
666
+ const run = runs[0];
667
+ return run
668
+ ? {
669
+ state: run.state,
670
+ level: run.level,
671
+ conversationOwnerId: run.card?.conversation_run_id ?? null,
672
+ processToken: run.process_token,
673
+ }
674
+ : { state: 'no longer on the record', level: null, conversationOwnerId: null, processToken: null };
494
675
  }
495
676
  /**
496
677
  * A run whose process failed, ended with the reason ON ITS OWN COLUMN, and its
@@ -499,7 +680,7 @@ async function runNow(client, runId) {
499
680
  * ═══ NEVER INTO `report`, WHICH IS THE RUN'S OWN WORDS. ═══ `report` is what a
500
681
  * respawn is handed, and this build starts settled runs again — an answered
501
682
  * question does exactly that — so a reason written there would destroy the state
502
- * the next attempt was going to resume from, and `cs3 show` would print the
683
+ * the next attempt was going to resume from, and `cs show` would print the
503
684
  * daemon's sentence under a heading that says the run wrote it. One owner per
504
685
  * column: the run writes `report`, whoever ended it writes `failed_because`.
505
686
  *
@@ -560,7 +741,7 @@ async function setCardState(client, cardId, state) {
560
741
  * ═══ THERE IS NOTHING TO UNDO HERE, AND THAT IS THE POINT. ═══ The take wrote
561
742
  * the run row in the same statement that leased the turns, so this function is
562
743
  * never in the position of holding a lease it cannot honour: whatever it fails
563
- * at, the row exists, `cs3 show` can see it, and recovery can reach it. The
744
+ * at, the row exists, `cs show` can see it, and recovery can reach it. The
564
745
  * earlier version wrote the row two round trips later and had to hand the turns
565
746
  * back on failure — which covered a thrown error and not a kill, and a kill in
566
747
  * that window stranded the card forever.
@@ -583,7 +764,43 @@ async function answerCard(client, tools, machineId, cardId, turns) {
583
764
  attachments read carries the same rule: a failed read here must not read
584
765
  as "nothing is attached", which is a different card than the one that was
585
766
  actually sent. */
586
- const brief = briefFor(turns[0].card_title, turns, await receiptsFor(client, cardId), await attachmentsFor(client, cardId), await codebasesForRun(client, runId));
767
+ /* ═══ AND A FAILURE HERE ENDS THE RUN, RATHER THAN LEAVING IT RUNNING WITH NO
768
+ PROCESS. ═══ The take already wrote the run row in the statement that leased
769
+ the turns, so a throw between here and `startAgent` leaves a run reading
770
+ `running` with a null pid and nothing on stderr the person can see.
771
+ `recoverStranded` then reads that as a machine that went away, hands the
772
+ message back, and `panel3_take_turns` leases it to A BRAND NEW RUN whose
773
+ attempts start again at one — so a permanent failure, such as a read this
774
+ build cannot make against the current schema, repeats forever while the card
775
+ says Working and never says why. This is the sixth of `endRun`'s endings and
776
+ the last one that was missing: `resumeRun` and `startRearmed` already end
777
+ their two post-claim failures this way for exactly this reason.
778
+ THE REASON IS SHAREABLE. All three reads are `returned()` calls against the
779
+ database, whose messages name tables and columns and never a local path, so
780
+ constraint 6 is satisfied without a level fork here. */
781
+ let brief;
782
+ /* WHERE IT RUNS AND WHAT IT IS TOLD ABOUT THE CODEBASES, THROUGH THE SAME
783
+ SEAM AS EVERY OTHER SPAWN. A level 1 run works in an empty directory of the
784
+ user's own, and it is the fifth start site rather than a special case: the
785
+ generated git block reaches every level, and one place resolving it is what
786
+ makes that true without four copies of the read. */
787
+ let where;
788
+ /* THE MANDATE IS READ IN THE SAME WINDOW AND UNDER THE SAME RULE. A launcher
789
+ is an agent like any other and gets the project's standing rules before it
790
+ decides anything, and a read that fails must not read as "this project has
791
+ no rules" — that is a different project than the one the person is on. So it
792
+ joins the three reads above inside this ending rather than beside it. */
793
+ let rules;
794
+ try {
795
+ brief = briefFor(turns[0].card_title, turns, await receiptsFor(client, cardId), await attachmentsFor(client, cardId), await codebasesForRun(client, runId));
796
+ rules = await standingRulesFor(client, runId);
797
+ where = await workingDirectory(client, runId, LEVEL, false);
798
+ }
799
+ catch (error) {
800
+ const why = error instanceof Error ? error.message : String(error);
801
+ await endRun(client, LEVEL, runId, cardId, why);
802
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
803
+ }
587
804
  /* ═══ THE RUN ID IS ON THE URL, AND THAT IS THE WHOLE OF WHAT THE AGENT IS
588
805
  TOLD ABOUT ITS OWN STANDING. ═══ The tools server reads the level off the
589
806
  run row this id names, so the daemon does not tell the child what it may do
@@ -593,9 +810,12 @@ async function answerCard(client, tools, machineId, cardId, turns) {
593
810
  /* AN EMPTY DIRECTORY OF THE USER'S OWN, because level 1 has no code tool to
594
811
  use a real one with, and the daemon's inherited cwd under a launchd login
595
812
  item is the filesystem root. */
596
- const started = startAgent(brief, LEVEL, tools.urlFor(runId), scratchDir());
813
+ const started = startAgent(withStandingRules(rules, brief, where.block), LEVEL, tools.urlFor(runId), where.cwd);
597
814
  out(`run ${runId} level ${LEVEL} ${started.pid ? `pid ${started.pid}` : 'no process'}`);
598
815
  try {
816
+ /* THE BRIEF, NOT WHAT THE PROCESS WAS HANDED. The rules are current at the
817
+ activation and the brief is immutable, so storing the composed string
818
+ would freeze one inside the other and every respawn would replay it. */
599
819
  await recordProcess(client, runId, started.pid, brief);
600
820
  }
601
821
  catch (error) {
@@ -603,7 +823,7 @@ async function answerCard(client, tools, machineId, cardId, turns) {
603
823
  // agent is not killed over it.
604
824
  said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
605
825
  }
606
- return settle(client, tools, machineId, LEVEL, runId, cardId, started);
826
+ return settle(client, tools, machineId, LEVEL, runId, cardId, started, false);
607
827
  }
608
828
  /**
609
829
  * WHICH PROJECT A RUN'S CARD IS FILED UNDER, or null when the card has none.
@@ -620,7 +840,7 @@ async function answerCard(client, tools, machineId, cardId, turns) {
620
840
  * project's code.
621
841
  */
622
842
  async function projectOfRun(client, runId) {
623
- const rows = await returned(client.from('panel3_runs').select('card:panel3_cards(project_id)').eq('id', runId), 'read', 'which project this run belongs to');
843
+ const rows = await returned(client.from('panel3_runs').select('card:panel3_cards!panel3_runs_card_id_fkey(project_id)').eq('id', runId), 'read', 'which project this run belongs to');
624
844
  return rows[0]?.card?.project_id ?? null;
625
845
  }
626
846
  async function codebasesForRun(client, runId) {
@@ -630,7 +850,7 @@ async function codebasesForRun(client, runId) {
630
850
  async function codebaseOfRun(client, runId) {
631
851
  const rows = await returned(client
632
852
  .from('panel3_runs')
633
- .select('codebase_id, card:panel3_cards(project_id)')
853
+ .select('codebase_id, card:panel3_cards!panel3_runs_card_id_fkey(project_id)')
634
854
  .eq('id', runId), 'read', `which codebase run ${runId} belongs to`);
635
855
  const row = rows[0];
636
856
  if (!row?.codebase_id || !row.card?.project_id) {
@@ -643,6 +863,179 @@ async function codebaseOfRun(client, runId) {
643
863
  }
644
864
  return codebase;
645
865
  }
866
+ /**
867
+ * ═══ THE CARD'S OWN COPY, AND THE BRANCH IT IS ON, STAMPED BEFORE IT IS USED.
868
+ * ═══
869
+ *
870
+ * The card is the unit of isolation, so the branch is derived from the card and
871
+ * every run on that card resolves to the same one. A run that finds a branch
872
+ * already stamped by a sibling takes it rather than deriving again: the
873
+ * derivation is pure today and stops being pure the moment the pattern becomes
874
+ * a setting, and one card on two branches is the defect this whole slice exists
875
+ * to prevent.
876
+ *
877
+ * ═══ THE STAMP IS WRITTEN HERE, WHERE ITS FAILURE CAN STOP THE RUN. ═══ Not in
878
+ * `recordProcess`, whose failure is deliberately non-fatal at all five sites: a
879
+ * stamp allowed to silently not happen cannot carry "a resumed run returns to
880
+ * the same copy". This is before `startAgent`, so a failure to write it is a run
881
+ * that never started rather than a run whose copy nobody can find again.
882
+ */
883
+ async function cardWorktree(client, runId, codebase) {
884
+ const rows = await returned(client.from('panel3_runs')
885
+ .select('card_id, branch, base, card:panel3_cards!panel3_runs_card_id_fkey(title)')
886
+ .eq('id', runId), 'read', `which card and branch run ${runId} is on`);
887
+ const row = rows[0];
888
+ if (!row)
889
+ throw new Error(`run ${runId} is not on the record any more`);
890
+ /* THE CARD'S OWN ANSWER FIRST, from whichever of its runs has one. Asked only
891
+ when this run has none of its own, so a resumed run costs one read. The
892
+ BASE travels with the branch, in one row and one read: they were written
893
+ together and a card cut from `develop` may not be told later that it goes
894
+ back to `main` because a teammate changed the setting. */
895
+ const sibling = row.branch !== null ? null : (await returned(client.from('panel3_runs')
896
+ .select('branch, base')
897
+ .eq('card_id', row.card_id)
898
+ .not('branch', 'is', null)
899
+ .limit(1), 'read', `the branch card ${row.card_id} is working on`))[0] ?? null;
900
+ const stamped = {
901
+ branch: row.branch ?? sibling?.branch ?? null,
902
+ base: row.base ?? sibling?.base ?? null,
903
+ };
904
+ /* ═══ THE TEAM'S RULES, READ AT THE START AND NOT CARRIED. ═══ Same argument
905
+ as the standing rules beside them: a rule edited while a conversation is
906
+ running has to govern the rest of it, and the only way that is true is if
907
+ the read happens here rather than once, at dispatch. */
908
+ /* THE PROJECT COMES OFF THE RUN'S OWN CARD, which is where the codebase came
909
+ from too: the rules are the organization's, and the organization is reached
910
+ through the project rather than through the codebase row, which `panel3/`
911
+ may not name. */
912
+ const projectId = await projectOfRun(client, runId);
913
+ if (!projectId) {
914
+ /* UNREACHABLE THROUGH `codebaseOfRun`, WHICH REFUSES A RUN WITH NO PROJECT,
915
+ and said out loud rather than papered over with an empty id: the rules are
916
+ the organization's, and there is no organization without a project. */
917
+ throw new Error('This code work is not filed under a project, so its codebase has no rules.');
918
+ }
919
+ const rules = await gitRulesFor(client, codebase.id, projectId);
920
+ const { folder, branch, base, source } = worktreeForCard(codebase, row.card_id, row.card?.title ?? '', stamped, hostname(), { baseBranch: rules.base_branch, branchPattern: rules.branchPattern });
921
+ if (row.branch !== branch || row.base !== base) {
922
+ await returned(client.from('panel3_runs').update({ branch, base }).eq('id', runId).select('id'), 'record which branch this run is on', `run ${runId}`);
923
+ }
924
+ await refreshBaseProtection(client, codebase, rules, source, base);
925
+ return {
926
+ cwd: folder,
927
+ block: workingRules({
928
+ codebase: codebase.name,
929
+ branch,
930
+ base,
931
+ landing: rules.landing,
932
+ branchPattern: rules.branchPattern,
933
+ }),
934
+ /* ═══ HANDED BACK RATHER THAN DISCARDED. ═══ The landing needs the folder,
935
+ the branch, the base and the repository the copy was made from, all of
936
+ which have just been resolved here. Resolving them again at the ending
937
+ would be a second answer to one question, free to disagree with this one. */
938
+ card: { folder, branch, base, source, landing: rules.landing, codebaseName: codebase.name },
939
+ };
940
+ }
941
+ /**
942
+ * ═══ WHETHER THE BASE BRANCH IS PROTECTED, ASKED WHILE THIS MACHINE IS ALREADY
943
+ * HOLDING THE REPOSITORY OPEN. ═══
944
+ *
945
+ * The browser cannot answer it and the product stores no platform credential, so
946
+ * the machine with the checkout answers it through the developer's own `gh`.
947
+ * Asked only when the record has no answer, so a codebase whose protection is
948
+ * known costs nothing, and never turned into a SETTING: it is an observation
949
+ * that feeds the detected default, and `landing` stays null until a person
950
+ * chooses.
951
+ *
952
+ * ═══ SAID, NEVER FATAL. ═══ This is a fact about the remote, not about the
953
+ * work. A card that could not record an observation still runs.
954
+ */
955
+ async function refreshBaseProtection(client, codebase, rules, source, base) {
956
+ if (rules.base_protected !== null)
957
+ return;
958
+ try {
959
+ const observed = detectBaseProtection(source, base);
960
+ if (observed !== null)
961
+ await recordBaseProtection(client, codebase.id, observed);
962
+ }
963
+ catch (error) {
964
+ said(`could not check whether ${base} is protected: ${error instanceof Error ? error.message : String(error)}`);
965
+ }
966
+ }
967
+ /**
968
+ * ═══ WHAT THE COORDINATOR IS TOLD ABOUT EACH CODEBASE IT MAY SEND WORK TO. ═══
969
+ *
970
+ * Story 6 scenario 4: levels 1, 2 and 3 each receive the block verbatim. A level
971
+ * 1 run has no codebase of its own by design, and the block is codebase-scoped,
972
+ * so at level 1 it is rendered once PER REGISTERED CODEBASE. That is the same
973
+ * text rather than a summary of it, and it is what stops a coordinator writing a
974
+ * brief that contradicts the rules the worker it dispatches will be handed.
975
+ *
976
+ * ═══ AND IT CARRIES THE ONE QUESTION ONLY THE COORDINATOR CAN ASK. ═══ Story 3
977
+ * scenario 3: a codebase whose base branch is genuinely ambiguous has to be
978
+ * settled by a person, once, and the daemon cannot ask anybody. The coordinator
979
+ * can, it holds `ask_question` already, and it needs the fact BEFORE it
980
+ * dispatches. So the fact and the options arrive here, at the activation, beside
981
+ * the block.
982
+ *
983
+ * ═══ NOT THROUGH `briefFor`. ═══ That output is `panel3_runs.brief`, which is
984
+ * immutable and replayed at every respawn, so a coordinator resumed a week later
985
+ * would be told to ask about a branch somebody settled on Tuesday.
986
+ */
987
+ async function coordinatorGitBlock(client, runId) {
988
+ const projectId = await projectOfRun(client, runId);
989
+ /* A CARD FILED UNDER NO PROJECT HAS NO CODEBASES AND NO RULES, which is a
990
+ fact rather than a failure: level 1 answers plenty of cards that never touch
991
+ code. */
992
+ if (!projectId)
993
+ return [];
994
+ const codebases = await listCodebases(client, projectId);
995
+ if (codebases.length === 0)
996
+ return [];
997
+ const blocks = [];
998
+ for (const codebase of codebases) {
999
+ const rules = await gitRulesFor(client, codebase.id, projectId);
1000
+ /* A CODEBASE THIS MACHINE HAS NOT LOCATED ANSWERS NEITHER WAY, and is left
1001
+ out rather than guessed at: `briefFor` already tells the coordinator which
1002
+ codebases are located here, and inventing rules for one nobody can see
1003
+ would be a block about a repository this run cannot reach. */
1004
+ const state = baseBranchState(codebase, rules.base_branch);
1005
+ if (state === null)
1006
+ continue;
1007
+ blocks.push([
1008
+ ...workingRules({
1009
+ codebase: codebase.name,
1010
+ branch: null,
1011
+ base: 'settled' in state ? state.settled : null,
1012
+ landing: rules.landing,
1013
+ branchPattern: rules.branchPattern,
1014
+ }),
1015
+ ...('candidates' in state ? [
1016
+ `Nobody has said which branch finished work in ${codebase.name} should go back to, and this`,
1017
+ 'machine cannot tell. Before you send anybody into it, ask the person with `ask_question`,',
1018
+ 'once, offering these as the options and using no git word except "branch":',
1019
+ state.candidates.join(', '),
1020
+ `Then record their answer with \`set_main_branch\` for codebase id ${codebase.id}. It is`,
1021
+ 'stored on the codebase, so nobody is asked again.',
1022
+ ] : []),
1023
+ ]);
1024
+ }
1025
+ return blocks.flatMap((block, index) => (index === 0 ? block : ['', ...block]));
1026
+ }
1027
+ /** One directory rule for every spawn whose level and ownership are known. */
1028
+ async function workingDirectory(client, runId, level, isOwner, knownCodebase) {
1029
+ if (level === 1) {
1030
+ return { cwd: scratchDir(), block: await coordinatorGitBlock(client, runId), card: null };
1031
+ }
1032
+ if (knownCodebase === null) {
1033
+ if (level === 2 && isOwner)
1034
+ return { cwd: scratchDir(), block: [], card: null };
1035
+ throw new Error('This code work does not name a registered project codebase.');
1036
+ }
1037
+ return cardWorktree(client, runId, knownCodebase ?? await codebaseOfRun(client, runId));
1038
+ }
646
1039
  /**
647
1040
  * ═══ ONE AGENT, STARTED UNDER ANOTHER THAT IS STILL RUNNING. ═══
648
1041
  *
@@ -658,13 +1051,14 @@ async function codebaseOfRun(client, runId) {
658
1051
  * Then the row, then the process, then the pid — constraint 8, in the only order
659
1052
  * that satisfies it.
660
1053
  */
661
- async function startChild(client, tools, machineId, parentRunId, brief, codebase) {
1054
+ async function startChild(client, tools, machineId, parentRunId, brief, codebase, parentProcessToken) {
662
1055
  const { data, error } = await client.rpc('panel3_dispatch', {
663
1056
  p_parent_run_id: parentRunId,
664
1057
  p_brief: brief,
665
1058
  p_machine_id: machineId,
666
- p_codebase_id: codebase.id,
667
- p_codebase_label: codebase.name,
1059
+ p_codebase_id: codebase?.id ?? null,
1060
+ p_codebase_label: codebase?.name ?? null,
1061
+ ...(parentProcessToken === undefined ? {} : { p_process_token: parentProcessToken }),
668
1062
  });
669
1063
  if (error)
670
1064
  throw new Error(`could not start an agent under run ${parentRunId}: ${error.message}`);
@@ -673,8 +1067,8 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
673
1067
  /* NOTHING WAS WRITTEN AND NOTHING IS RUNNING, and the two reasons are said
674
1068
  together because the caller cannot tell them apart from here and both mean
675
1069
  the same thing to it. */
676
- throw new Error(`NO AGENT WAS STARTED and nothing was written: run ${parentRunId} has ended, or it is already `
677
- + 'as deep as anything may be sent from.');
1070
+ throw new Error(`NO AGENT WAS STARTED and nothing was written: run ${parentRunId} has ended, is already as `
1071
+ + 'deep as anything may be sent from, or this conversation already has its owner. Exit now.');
678
1072
  }
679
1073
  if (row.run_level !== 2 && row.run_level !== 3) {
680
1074
  /* UNREACHABLE, AND STILL SETTLED. `panel3_dispatch` writes `parent.level + 1`
@@ -684,20 +1078,31 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
684
1078
  recovery sweep wait out the pid grace window to conclude what is already
685
1079
  known. */
686
1080
  const why = `run ${row.run_id} was written at level ${row.run_level}, which cannot be spawned`;
687
- await giveUp(client, row.run_id, why);
1081
+ await giveUp(client, row.run_id, why, row.process_token ?? undefined);
688
1082
  throw new Error(why);
689
1083
  }
690
1084
  const level = row.run_level;
691
- let cwd;
1085
+ let where;
1086
+ let prompt;
1087
+ /* READ AGAINST THE CHILD'S OWN RUN ROW, NOT THE PARENT'S. The row already
1088
+ exists (`panel3_dispatch` wrote it above) and it carries this child's
1089
+ codebase, which is what decides which codebase-scoped rules it is under. */
1090
+ let rules;
692
1091
  try {
693
- cwd = checkoutForCodebase(codebase, hostname());
1092
+ where = await workingDirectory(client, row.run_id, level, level === 2, codebase);
1093
+ prompt = level === 2
1094
+ ? await initialOwnerPrompt(client, row.run_card_id, parentRunId, brief)
1095
+ : brief;
1096
+ rules = await standingRulesFor(client, row.run_id);
694
1097
  }
695
1098
  catch (error) {
696
1099
  const why = error instanceof Error ? error.message : String(error);
697
- await giveUp(client, row.run_id, why);
1100
+ await giveUp(client, row.run_id, why, row.process_token ?? undefined);
698
1101
  throw new Error(`NO AGENT IS RUNNING: ${why}`);
699
1102
  }
700
- const started = startAgent(brief, level, tools.urlFor(row.run_id), cwd);
1103
+ const processToken = row.process_token ?? undefined;
1104
+ const isOwner = level === 2;
1105
+ const started = startAgent(withStandingRules(rules, prompt, where.block), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined);
701
1106
  if (started.pid === null) {
702
1107
  /* THERE IS A ROW AND THERE IS NO PROCESS, which is the one shape the record
703
1108
  must never be left in quietly. The answer is already settled — nothing ran
@@ -705,12 +1110,12 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
705
1110
  and the tool call fails saying no agent was started. */
706
1111
  const answer = await started.answered;
707
1112
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
708
- await giveUp(client, row.run_id, reason);
1113
+ await giveUp(client, row.run_id, reason, processToken);
709
1114
  throw new Error(`NO AGENT IS RUNNING: ${reason}`);
710
1115
  }
711
1116
  out(`dispatch run ${row.run_id} level ${level} under ${parentRunId} pid ${started.pid}`);
712
1117
  try {
713
- await recordProcess(client, row.run_id, started.pid);
1118
+ await recordProcess(client, row.run_id, started.pid, undefined, processToken);
714
1119
  }
715
1120
  catch (error) {
716
1121
  // Said, not fatal, exactly as at level 1: the agent is running and killing
@@ -719,7 +1124,9 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
719
1124
  }
720
1125
  return {
721
1126
  runId: row.run_id,
722
- settled: settle(client, tools, machineId, level, row.run_id, row.run_card_id, started),
1127
+ settled: settle(client, tools, machineId, level, row.run_id, row.run_card_id, started, true, processToken, isOwner && processToken
1128
+ ? ownerSessionLifecycle(started, row.run_id, harness(), processToken)
1129
+ : undefined),
723
1130
  };
724
1131
  }
725
1132
  /**
@@ -763,11 +1170,13 @@ async function attemptsSoFar(client, runId) {
763
1170
  * ends through `panel3_end_run`, which hands no turns back, and the card says
764
1171
  * failed.
765
1172
  *
766
- * ═══ AND THE FORK LIVES HERE AND NOWHERE ELSE. ═══ FIVE places end a run this
767
- * way: the settle, and the two failures after the claim in each of `resumeRun`
768
- * and `startRearmed`. A copy of this rule in each is five places for it to
769
- * drift, and it drifted the first time it could: those two functions hold the
770
- * same pair of endings and only one pair was looked at.
1173
+ * ═══ AND THE FORK LIVES HERE AND NOWHERE ELSE. ═══ SIX places end a run this
1174
+ * way: the settle, the two failures after the claim in each of `resumeRun` and
1175
+ * `startRearmed`, and the pre-spawn failure in `answerCard`. A copy of this rule
1176
+ * in each is six places for it to drift, and it drifted the first time it could:
1177
+ * those two functions hold the same pair of endings and only one pair was looked
1178
+ * at. `answerCard`'s was missing outright, which is how a build that could not
1179
+ * read a card's project turned every take into a silent take/recover loop.
771
1180
  *
772
1181
  * ALL FIVE CAN SEE LEVEL 1. `resumeRun` serving the retry is what opened its
773
1182
  * two. `startRearmed`'s were always reachable, because a re-arm has always
@@ -834,7 +1243,44 @@ async function endRun(client, level, runId, cardId, why) {
834
1243
  * cannot end the same way. What `answerCard` always did is what level 1 still
835
1244
  * does, and the only thing that has moved is where it is written.
836
1245
  */
837
- function settle(client, tools, machineId, level, runId, cardId, started, speaksToTheCard = true) {
1246
+ function ownerSessionLifecycle(started, ownerId, ownerHarness, processToken, expectedSessionId) {
1247
+ const pending = started.session.then((nativeSessionId) => {
1248
+ if (nativeSessionId === null)
1249
+ return false;
1250
+ if (expectedSessionId && nativeSessionId !== expectedSessionId) {
1251
+ said(`the resumed harness returned a different conversation for owner ${ownerId}; it will not be reused`);
1252
+ return false;
1253
+ }
1254
+ try {
1255
+ writeOwnerSession({
1256
+ ownerId,
1257
+ harness: ownerHarness,
1258
+ nativeSessionId,
1259
+ processToken,
1260
+ state: 'pending',
1261
+ });
1262
+ return true;
1263
+ }
1264
+ catch (error) {
1265
+ said(`could not save the local conversation for owner ${ownerId}: ${error instanceof Error ? error.message : String(error)}`);
1266
+ return false;
1267
+ }
1268
+ });
1269
+ // Start observing immediately; Codex reports thread.started before its answer.
1270
+ void pending;
1271
+ return {
1272
+ established: async () => {
1273
+ if (await pending)
1274
+ establishOwnerSession(ownerId, processToken);
1275
+ },
1276
+ failed: async () => {
1277
+ // A failed or rejected generation stays pending and therefore cannot be
1278
+ // resumed. Reconciliation is the sole owner of stable-state deletion.
1279
+ await pending;
1280
+ },
1281
+ };
1282
+ }
1283
+ function settle(client, tools, machineId, level, runId, cardId, started, speaksToTheCard = true, processToken, ownerSession) {
838
1284
  return started.answered.then(async (answer) => {
839
1285
  /* ═══ A RUN THAT STOPPED TO ASK DID NOT DIE, WHATEVER THE HARNESS PRINTED
840
1286
  ON ITS WAY OUT. ═══
@@ -860,11 +1306,27 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
860
1306
  into an ordinary ending, and then handed to `writeAnswer` — which already
861
1307
  owns this case and already prints its sentence — rather than to a second
862
1308
  ending written here beside it. */
863
- if (!answer.ok && (await runNow(client, runId)).state === 'asked') {
864
- await writeAnswer(client, runId, cardId, null);
865
- return;
1309
+ if (!answer.ok) {
1310
+ const run = await runNow(client, runId);
1311
+ if (run.state === 'asked') {
1312
+ const accepted = await writeAnswer(client, runId, cardId, null, processToken);
1313
+ if (accepted)
1314
+ await ownerSession?.established();
1315
+ else
1316
+ await ownerSession?.failed();
1317
+ return;
1318
+ }
1319
+ /* Level 1's only successful result is the owner pointer written by
1320
+ dispatch. Its stdout is deliberately hidden, and Codex may therefore
1321
+ exit cleanly without an agent message after the tool succeeds. The
1322
+ record, not prose the launcher was told not to write, decides success. */
1323
+ if (level === 1 && run.conversationOwnerId !== null) {
1324
+ await writeAnswer(client, runId, cardId, null, processToken);
1325
+ return;
1326
+ }
866
1327
  }
867
1328
  if (!answer.ok) {
1329
+ await ownerSession?.failed();
868
1330
  /* ═══ A PROCESS THAT STARTED AND THEN DIED BADLY IS STARTED AGAIN. ═══
869
1331
  recovery-1/ux.md, Slice 3: "the agent's harness fails on something that
870
1332
  is nobody's fault: a rate limit, a dropped connection, a provider
@@ -904,7 +1366,9 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
904
1366
  const attempts = await attemptsSoFar(client, runId);
905
1367
  if (started.pid !== null && attempts < MAX_ATTEMPTS) {
906
1368
  out(`retry run ${runId} attempt ${attempts} of ${MAX_ATTEMPTS} died: ${answer.reason}`);
907
- const again = await resumeRun(client, tools, machineId, runId, started.pid);
1369
+ const again = processToken === undefined
1370
+ ? await resumeRun(client, tools, machineId, runId, started.pid)
1371
+ : await activateOwner(client, tools, machineId, runId, processToken, started.pid);
908
1372
  if (again)
909
1373
  return again.settled;
910
1374
  /* ═══ THE CLAIM MATCHED NOTHING, AND THAT IS NOT ALWAYS SOMEBODY ELSE
@@ -937,7 +1401,9 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
937
1401
  both already read, so the count reaches a person from one place rather
938
1402
  than two. */
939
1403
  const why = attempts > 1 ? `${answer.reason} (after ${attempts} attempts)` : answer.reason;
940
- const ended = await endRun(client, level, runId, cardId, why);
1404
+ const ended = processToken === undefined
1405
+ ? await endRun(client, level, runId, cardId, why)
1406
+ : (await giveUp(client, runId, why, processToken)) !== null;
941
1407
  if (!ended) {
942
1408
  /* IT WAS ALREADY SETTLED, by recovery, which decided this process was
943
1409
  gone before it said so itself, or by the person's Stop. Nothing was
@@ -954,7 +1420,11 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
954
1420
  /* ═══ ITS OWN WORDS, ONTO THE CARD, THROUGH THE SAME STATEMENT EVERY LEVEL
955
1421
  USES. ═══ Nothing reads them, nothing shortens them and nothing waits to
956
1422
  approve them: ux.md's "whoever did the work writes the answer". */
957
- await writeAnswer(client, runId, cardId, speaksToTheCard ? answer.text : null);
1423
+ const accepted = await writeAnswer(client, runId, cardId, speaksToTheCard ? answer.text : null, processToken);
1424
+ if (accepted)
1425
+ await ownerSession?.established();
1426
+ else
1427
+ await ownerSession?.failed();
958
1428
  });
959
1429
  }
960
1430
  /**
@@ -1014,14 +1484,20 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
1014
1484
  * about the failure it was holding.
1015
1485
  */
1016
1486
  async function resumeRun(client, tools, machineId, runId, afterPid) {
1017
- /* ═══ THE SWEEP RESOLVES ITS WORKING COPY BEFORE THE CLAIM AND THE RETRY
1018
- CANNOT, AND `afterPid` IS THE WHOLE OF WHAT DECIDES IT. ═══ Said once, here.
1019
- See the header for both halves of the argument: a machine with no checkout
1020
- must not take a run it cannot start, and the level a retry needs the answer
1021
- for is not known until the claim returns. */
1022
- const sweptCwd = afterPid === null
1023
- ? checkoutForCodebase(await codebaseOfRun(client, runId), hostname())
1024
- : null;
1487
+ /* ═══ THE SWEEP CHECKS BEFORE THE CLAIM AND THE RETRY CANNOT, AND `afterPid`
1488
+ IS THE WHOLE OF WHAT DECIDES IT. ═══ Said once, here. See the header for
1489
+ both halves of the argument: a machine with no checkout must not take a run
1490
+ it cannot start, and the level a retry needs the answer for is not known
1491
+ until the claim returns.
1492
+
1493
+ ═══ IT IS THE EXISTENCE CHECK AND NOTHING MORE, WHICH IS WHAT CHANGED IN
1494
+ worktrees-8. ═══ Resolving the working copy now CREATES a branch, a folder
1495
+ and a row write, and every poll tick that loses the claim race would leave
1496
+ that debris behind for a run it never took. So the pre-claim question stays
1497
+ "does this machine have this codebase at all", which is the only question
1498
+ this position was ever asking, and the copy is made after the claim. */
1499
+ if (afterPid === null)
1500
+ checkoutForCodebase(await codebaseOfRun(client, runId), hostname());
1025
1501
  const { data, error } = await client.rpc('panel3_resume', {
1026
1502
  p_run_id: runId,
1027
1503
  p_machine_id: machineId,
@@ -1042,41 +1518,34 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1042
1518
  await giveUp(client, runId, why);
1043
1519
  throw new Error(why);
1044
1520
  }
1045
- /* NON-NULL EXACTLY WHEN THE SWEEP RESOLVED ONE, which is the rule stated at
1046
- the assignment above. Branching on the VALUE rather than testing `afterPid`
1047
- a second time is what keeps the resolution and its use from being able to
1048
- disagree: whatever the sweep resolved is what the sweep runs in. */
1049
- let cwd;
1050
- if (sweptCwd !== null) {
1051
- cwd = sweptCwd;
1521
+ /* ONE RESOLUTION FOR BOTH PATHS, AFTER THE CLAIM. It used to fork on whether
1522
+ the sweep had already resolved a folder before the claim; since the copy is
1523
+ the card's own and making it writes, both paths make it here, in the branch
1524
+ that can end the run when it cannot be made. */
1525
+ let where;
1526
+ try {
1527
+ where = await workingDirectory(client, runId, level, false);
1052
1528
  }
1053
- else {
1054
- try {
1055
- cwd = level === 1
1056
- ? scratchDir()
1057
- : checkoutForCodebase(await codebaseOfRun(client, runId), hostname());
1058
- }
1059
- catch (error) {
1060
- /* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
1061
- ═══ Constraint 6, and `startRearmed`'s own handling of the same two
1062
- calls: `workingCopy()` is already careful about this and says so;
1063
- `scratchDir()` is not, because it is `mkdirSync`, whose EACCES and
1064
- ENOTDIR messages name the directory they failed on. So the machine's own
1065
- error is kept for stderr and the record is told only what is true and
1066
- shareable. */
1067
- const stderrOnly = error instanceof Error ? error.message : String(error);
1068
- const why = level === 1
1069
- ? 'this machine could not make the empty directory this runs in'
1070
- : stderrOnly;
1071
- /* ═══ AND IT ENDS THE WAY A RUN OF THIS LEVEL ENDS. ═══ It was
1072
- `panel3_give_up` outright, which was right while only a dispatched run
1073
- could reach this function and is a leak now that the retry brings level
1074
- 1 here: handing the person's message back mints a new run with its
1075
- attempts at one, which is the bound the retry is under, undone by the
1076
- one path that could not start. See `endRun`. */
1077
- await endRun(client, level, runId, claimed.run_card_id, why);
1078
- throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? stderrOnly : why}`);
1079
- }
1529
+ catch (error) {
1530
+ /* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
1531
+ ═══ Constraint 6, and `startRearmed`'s own handling of the same two
1532
+ calls: `worktreeForCard()` is already careful about this and says so;
1533
+ `scratchDir()` is not, because it is `mkdirSync`, whose EACCES and
1534
+ ENOTDIR messages name the directory they failed on. So the machine's own
1535
+ error is kept for stderr and the record is told only what is true and
1536
+ shareable. */
1537
+ const stderrOnly = error instanceof Error ? error.message : String(error);
1538
+ const why = level === 1
1539
+ ? 'this machine could not make the empty directory this runs in'
1540
+ : stderrOnly;
1541
+ /* ═══ AND IT ENDS THE WAY A RUN OF THIS LEVEL ENDS. ═══ It was
1542
+ `panel3_give_up` outright, which was right while only a dispatched run
1543
+ could reach this function and is a leak now that the retry brings level
1544
+ 1 here: handing the person's message back mints a new run with its
1545
+ attempts at one, which is the bound the retry is under, undone by the
1546
+ one path that could not start. See `endRun`. */
1547
+ await endRun(client, level, runId, claimed.run_card_id, why);
1548
+ throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? stderrOnly : why}`);
1080
1549
  }
1081
1550
  /* WHAT IT SENT OTHERS TO DO, FROM THE RECORD. Level 3 has no `dispatch`, so it
1082
1551
  has no children to have: null says that, where an empty list would say it
@@ -1089,14 +1558,36 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1089
1558
  time: ux.md's single most expensive failure. `startRearmed` reads it the
1090
1559
  same way, for the same reason. */
1091
1560
  const children = level === 3 ? null : await childrenOf(client, runId);
1561
+ /* ═══ READ AGAIN ON EVERY START, WHICH IS THE WHOLE OF CONTRACT POINT 2. ═══
1562
+ A resumed or retried run is handed the rules AS THEY STAND NOW, not as they
1563
+ stood when it first began: a rule edited while the conversation was running
1564
+ governs the rest of it, and a rule deleted while it was running stops
1565
+ applying to it. That is only true because this read happens here rather than
1566
+ once, at the top of the run's life.
1567
+
1568
+ ═══ AND IT ENDS THE WAY THIS PATH ALREADY ENDS. ═══ The claim has already
1569
+ happened, so a throw here would leave a run reading `running` with no
1570
+ process. `endRun` with the level fork is this path's own ending (see the cwd
1571
+ branch above for why level 1 may not simply be given up on), and the reason
1572
+ is a `returned()` message naming tables and columns, which carries no local
1573
+ path and is therefore shareable. */
1574
+ let rules;
1575
+ try {
1576
+ rules = await standingRulesFor(client, runId);
1577
+ }
1578
+ catch (error) {
1579
+ const why = error instanceof Error ? error.message : String(error);
1580
+ await endRun(client, level, runId, claimed.run_card_id, why);
1581
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
1582
+ }
1092
1583
  const started = startAgent(
1093
1584
  /* ═══ WHY IT DIED IS WHAT DIFFERS, AND IT IS TOLD THE TRUTH ABOUT IT. ═══
1094
1585
  `resumePrompt` opens by saying the machine went down, which is true of the
1095
1586
  sweep and false of a retry: the daemon that watched this harness exit is
1096
1587
  still running. See `retryPrompt`. */
1097
- afterPid === null
1588
+ withStandingRules(rules, afterPid === null
1098
1589
  ? resumePrompt(claimed.run_brief, claimed.run_report, children)
1099
- : retryPrompt(claimed.run_brief, claimed.run_report, children), level, tools.urlFor(runId), cwd);
1590
+ : retryPrompt(claimed.run_brief, claimed.run_report, children), where.block), level, tools.urlFor(runId), where.cwd);
1100
1591
  if (started.pid === null) {
1101
1592
  /* THE CLAIM HAPPENED AND NO PROCESS DID, which is the one shape the record
1102
1593
  must never be left in quietly. Same handling as a dispatch that could not
@@ -1144,7 +1635,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1144
1635
  * what stops one question, or one set of finished workers, spawning forever. The cost is that this cannot decline afterwards: a run claimed and not
1145
1636
  * started would sit `running` with no process and its question already spent. So
1146
1637
  * every failure below ends the run with its reason, which is visible on the card
1147
- * and in `cs3 show`, rather than quietly hoping the next poll finds it.
1638
+ * and in `cs show`, rather than quietly hoping the next poll finds it.
1148
1639
  */
1149
1640
  async function startRearmed(client, tools, machineId, row) {
1150
1641
  const level = row.run_level === 1 ? 1 : row.run_level === 2 ? 2 : 3;
@@ -1153,18 +1644,16 @@ async function startRearmed(client, tools, machineId, row) {
1153
1644
  await giveUp(client, row.run_id, why);
1154
1645
  throw new Error(why);
1155
1646
  }
1156
- let cwd;
1647
+ let where;
1157
1648
  try {
1158
- cwd = level === 1
1159
- ? scratchDir()
1160
- : checkoutForCodebase(await codebaseOfRun(client, row.run_id), hostname());
1649
+ where = await workingDirectory(client, row.run_id, level, false);
1161
1650
  }
1162
1651
  catch (error) {
1163
1652
  /* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
1164
1653
  ═══ Constraint 6. `workingCopy()` is already careful about this and says
1165
1654
  so; `scratchDir()` is not — it is `mkdirSync`, whose EACCES and ENOTDIR
1166
1655
  messages name the directory they failed on — and `failed_because` is a
1167
- column `cs3 show` prints. So the machine's own error is kept for stderr
1656
+ column `cs show` prints. So the machine's own error is kept for stderr
1168
1657
  and the record is told only what is true and shareable. */
1169
1658
  const said = error instanceof Error ? error.message : String(error);
1170
1659
  const why = level === 1
@@ -1180,6 +1669,17 @@ async function startRearmed(client, tools, machineId, row) {
1180
1669
  POSSIBLE. Level 3 has no `dispatch`, so it has no children to have: null
1181
1670
  says that, where an empty list would say it chose to send nobody. */
1182
1671
  const children = level === 3 ? null : await childrenOf(client, row.run_id);
1672
+ const deliveredArtifact = row.ask_id !== null && row.mine
1673
+ ? await rearmedArtifactAnswer(client, row.ask_id)
1674
+ : null;
1675
+ /* ═══ THE MERGE HAPPENS HERE, OFF THE MARK ON THE ASK, BEFORE THE AGENT THAT
1676
+ WILL SPEAK ABOUT IT IS STARTED. ═══ The re-arm is the last thing that runs
1677
+ before the spawn, which is why it is the only place the outcome can reach
1678
+ the prompt. See `landCardWork`. */
1679
+ const offered = row.ask_id !== null && row.mine
1680
+ ? await landingOffer(client, row.ask_id)
1681
+ : null;
1682
+ const landing = offered === null ? null : landCardWork(where, offered);
1183
1683
  /* ═══ THREE REASONS, AND THE ROW SAYS WHICH. ═══ No question is ux.md's third
1184
1684
  re-arm: everybody it sent has finished, and it is started to read them back.
1185
1685
  `children` cannot be null on that path — only a run with children is ever
@@ -1188,9 +1688,21 @@ async function startRearmed(client, tools, machineId, row) {
1188
1688
  const prompt = row.ask_id === null
1189
1689
  ? readBackPrompt(row.run_brief, row.run_report, children ?? [])
1190
1690
  : row.mine
1191
- ? answerPrompt(row.run_brief, row.run_report, children, row.question ?? '', row.answer ?? '')
1691
+ ? answerPrompt(row.run_brief, row.run_report, children, row.question ?? '', row.answer ?? '', deliveredArtifact, landing)
1192
1692
  : escalationPrompt(row.run_brief, row.run_report, children, row.ask_id, row.question ?? '');
1193
- const started = startAgent(prompt, level, tools.urlFor(row.run_id), cwd);
1693
+ /* CURRENT AT THIS ACTIVATION, exactly as on the resume path, and ended the
1694
+ same way: the re-arm's claim has already happened, so a failure here ends
1695
+ the run with its reason rather than leaving it claimed with no process. */
1696
+ let rules;
1697
+ try {
1698
+ rules = await standingRulesFor(client, row.run_id);
1699
+ }
1700
+ catch (error) {
1701
+ const why = error instanceof Error ? error.message : String(error);
1702
+ await endRun(client, level, row.run_id, row.run_card_id, why);
1703
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
1704
+ }
1705
+ const started = startAgent(withStandingRules(rules, prompt, where.block), level, tools.urlFor(row.run_id), where.cwd);
1194
1706
  if (started.pid === null) {
1195
1707
  const answer = await started.answered;
1196
1708
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
@@ -1263,6 +1775,374 @@ async function childrenOf(client, runId) {
1263
1775
  : c.report.split('\n').map((l) => ` ${l}`)),
1264
1776
  ]);
1265
1777
  }
1778
+ /** Private owner context: enough to continue or stop work, without hierarchy or activity text. */
1779
+ async function ownerChildren(client, runId) {
1780
+ const children = await returned(client.from('panel3_runs').select('id, state, report, failed_because')
1781
+ .eq('parent_run_id', runId).order('started_at'), 'read', `what owner ${runId} sent others to do`);
1782
+ return children.map((child) => [
1783
+ `${child.id} ${child.state}`,
1784
+ ...(child.failed_because ? [`ended: ${child.failed_because}`] : []),
1785
+ child.report ?? 'no report',
1786
+ ].join('\n'));
1787
+ }
1788
+ async function ownerConversation(client, cardId, leased, initialLeaseRunId) {
1789
+ const [turns, rawAsks] = await Promise.all([
1790
+ returned(client.from('panel3_turns').select('id, role, body, created_at, lease_id')
1791
+ .eq('card_id', cardId).order('created_at'), 'read', `the visible conversation on card ${cardId}`),
1792
+ returned(client.from('panel3_asks').select(`id, created_at, ${ASK_CONTENT_COLUMNS}`)
1793
+ .eq('card_id', cardId).not('decision_id', 'is', null).order('created_at'), 'read', `the visible questions on card ${cardId}`),
1794
+ ]);
1795
+ const asks = await withAskContent(client, rawAsks);
1796
+ const events = [
1797
+ ...turns.map((turn) => ({
1798
+ at: turn.created_at,
1799
+ kind: turn.role,
1800
+ id: turn.id,
1801
+ body: turn.body,
1802
+ needsReply: turn.role === 'user'
1803
+ && (leased.has(turn.id) || turn.lease_id === initialLeaseRunId),
1804
+ })),
1805
+ ...asks.map((ask) => ({
1806
+ at: ask.created_at,
1807
+ kind: 'question',
1808
+ id: ask.id,
1809
+ body: ask.question ?? '(question unavailable)',
1810
+ answer: ask.answer,
1811
+ relatedArtifactId: ask.related_artifact_id,
1812
+ relatedArtifactRevision: ask.related_artifact_revision,
1813
+ artifactAnswer: artifactAnswer(ask.related_artifact_id, ask.related_artifact_revision, ask.selected_options)?.answer ?? null,
1814
+ })),
1815
+ ];
1816
+ return events.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
1817
+ }
1818
+ /** The first owner gets the same durable context as every later activation. */
1819
+ export async function initialOwnerPrompt(client, cardId, launcherRunId, brief) {
1820
+ const events = await ownerConversation(client, cardId, new Set(), launcherRunId);
1821
+ return ownerActivationPrompt(brief, null, events, [], null);
1822
+ }
1823
+ /** Only facts delivered by this activation enter an already-resumed native
1824
+ * conversation. Older visible turns are already in that conversation. */
1825
+ export function ownerContinuationPrompt(brief, report, events, children, delivered, landing = null) {
1826
+ const messages = events.filter((event) => event.kind === 'user' && event.needsReply);
1827
+ return [
1828
+ 'CONTINUE THE CTRL+SPC CONVERSATION YOU ALREADY OWN.',
1829
+ 'Use only the new facts below as the new turn. Keep coordinating the whole card and speak as CTRL+SPC.',
1830
+ '',
1831
+ 'NEW USER MESSAGES',
1832
+ ...(messages.length === 0
1833
+ ? ['None.']
1834
+ : messages.flatMap((event) => [`Turn ${event.id}`, event.body])),
1835
+ ...(delivered === null ? [] : delivered.mine ? [
1836
+ '',
1837
+ 'ANSWER TO YOUR QUESTION',
1838
+ `Question ${delivered.id}: ${delivered.question}`,
1839
+ `Answer: ${delivered.answer ?? '(no answer text)'}`,
1840
+ ...presentedArtifactAnswerContext(delivered.artifactAnswer),
1841
+ ...landingOutcomeContext(landing),
1842
+ ] : [
1843
+ '',
1844
+ 'A WORKER NEEDS YOUR DECISION',
1845
+ `Question ${delivered.id}: ${delivered.question}`,
1846
+ 'Answer it with answer_escalation if you can. If the person must decide, ask them directly.',
1847
+ ]),
1848
+ '',
1849
+ 'CURRENT PRIVATE WORKER SNAPSHOT — THIS REPLACES THE PREVIOUS SNAPSHOT',
1850
+ ...(children.length === 0 ? ['You have sent nobody.'] : children),
1851
+ '',
1852
+ 'YOUR CURRENT DURABLE REPORT',
1853
+ report ?? 'Nothing yet.',
1854
+ '',
1855
+ 'YOUR IMMUTABLE RESPONSIBILITY',
1856
+ brief,
1857
+ ].join('\n');
1858
+ }
1859
+ async function ownerCandidate(client, runId) {
1860
+ const candidates = await returned(client.from('panel3_runs')
1861
+ .select('id, card_id, codebase_id, machine_id, harness, state, ended_at, attempts, pid, started_at, resumed_at, process_token, handed_back_at, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id, state)')
1862
+ .eq('id', runId), 'read', `conversation owner ${runId}`);
1863
+ const candidate = candidates[0];
1864
+ return candidate?.card?.conversation_run_id === candidate?.id ? candidate : null;
1865
+ }
1866
+ async function ownerDirectory(client, candidate) {
1867
+ return workingDirectory(client, candidate.id, 2, true, candidate.codebase_id === null ? null : undefined);
1868
+ }
1869
+ export function resumableOwnerSessionId(candidate, machineId, machineHarness) {
1870
+ const local = readOwnerSession(candidate.id);
1871
+ return candidate.machine_id === machineId
1872
+ && candidate.harness === machineHarness
1873
+ && local?.state === 'established'
1874
+ && local.harness === machineHarness
1875
+ && local.processToken === candidate.process_token
1876
+ ? local.nativeSessionId
1877
+ : undefined;
1878
+ }
1879
+ async function activateOwner(client, tools, machineId, runId, afterProcessToken = null, afterPid = null) {
1880
+ const candidate = await ownerCandidate(client, runId);
1881
+ if (!candidate)
1882
+ return null;
1883
+ const machineHarness = harness();
1884
+ const resumeSessionId = resumableOwnerSessionId(candidate, machineId, machineHarness);
1885
+ /* ═══ THE EXISTENCE CHECK BEFORE THE CLAIM, AND THE COPY AFTER IT. ═══ This
1886
+ was the whole resolution, which was right while resolving meant reading a
1887
+ folder out of a file. Since worktrees-8 it also CREATES one, and a daemon
1888
+ that lost the activation race would leave a branch and a folder behind for
1889
+ an owner it never activated. The refusal this position exists for — a
1890
+ machine that does not have this codebase must not take the activation —
1891
+ is unchanged, because it is the located checkout that answers it. */
1892
+ if (candidate.codebase_id !== null) {
1893
+ checkoutForCodebase(await codebaseOfRun(client, candidate.id), hostname());
1894
+ }
1895
+ const { data, error } = await client.rpc('panel3_take_owner_activation', {
1896
+ p_run_id: runId,
1897
+ p_machine_id: machineId,
1898
+ p_agent: machineHarness,
1899
+ p_after_process_token: afterProcessToken,
1900
+ p_after_pid: afterPid,
1901
+ });
1902
+ if (error)
1903
+ throw new Error(`could not activate conversation owner ${runId}: ${error.message}`);
1904
+ const claimed = data?.[0];
1905
+ if (!claimed)
1906
+ return null;
1907
+ const [events, children] = await Promise.all([
1908
+ ownerConversation(client, claimed.run_card_id, new Set(claimed.turn_ids ?? [])),
1909
+ ownerChildren(client, runId),
1910
+ ]);
1911
+ const currentArtifactAnswer = deliveredArtifactAnswer(events, claimed);
1912
+ const delivered = claimed.ask_id === null ? null : {
1913
+ id: claimed.ask_id,
1914
+ question: claimed.question ?? '(question unavailable)',
1915
+ answer: claimed.answer,
1916
+ mine: claimed.mine === true,
1917
+ artifactAnswer: currentArtifactAnswer,
1918
+ };
1919
+ /* ═══ AFTER THE CLAIM, SO THE ENDING IS THE ONE THIS PATH HAS. ═══ Every
1920
+ failure below the claim ends the activation with `giveUp` and its process
1921
+ token; a throw above it would merely be an activation that did not happen.
1922
+
1923
+ ═══ AND IT MATTERS MOST HERE. ═══ This is the owner, which lives for the
1924
+ whole card and whose native session is RESUMED, so it is the one agent that
1925
+ can be running while a person edits or deletes a rule. Reading at every
1926
+ activation is what makes an edit govern the rest of the conversation, and
1927
+ the block's own supersession sentence is what makes a DELETION take effect
1928
+ in a session that still holds the older copy.
1929
+
1930
+ ═══ AND IT IS BEFORE THE PROMPT SINCE worktrees-8 C1, because the prompt now
1931
+ says what the product DID with the person's answer, and the landing needs
1932
+ the card's copy. Nothing in the prompt depended on it before. */
1933
+ let rules;
1934
+ /* THE CARD'S COPY IS MADE IN THE SAME WINDOW AND UNDER THE SAME ENDING, for
1935
+ the reason the check above gives: it writes, so it happens after the claim,
1936
+ and a failure to make it is an activation that ends rather than one that
1937
+ sits `running` with no process. */
1938
+ let where;
1939
+ /* ═══ AND WHAT IS ATTACHED, READ IN THE SAME WINDOW AND UNDER THE SAME
1940
+ ENDING. ═══ attaching-after-the-fact-10: a person may attach to a card that
1941
+ is already running, and the owner's brief is immutable, so the only account
1942
+ of attachments it would otherwise get is the one frozen at dispatch. Read
1943
+ at every activation, exactly as the rules are, and for the same reason a
1944
+ failed read ends the activation rather than continuing: "nothing is
1945
+ attached" is a different card from the one the person sent.
1946
+
1947
+ THE CODEBASE LINES ARE DROPPED. `whatWasAttached` partitions those into a
1948
+ section whose own text says "its one codebase is named separately below",
1949
+ a forward reference to something `workBrief` supplies and an owner
1950
+ activation does not. This block carries the PERSON's attachments; where the
1951
+ owner is working is `where.block`'s answer. */
1952
+ let attached;
1953
+ try {
1954
+ where = await ownerDirectory(client, candidate);
1955
+ rules = await standingRulesFor(client, runId);
1956
+ attached = whatWasAttached((await attachmentsFor(client, claimed.run_card_id))
1957
+ .filter((line) => !line.startsWith('codebase ')));
1958
+ }
1959
+ catch (error) {
1960
+ const why = error instanceof Error ? error.message : String(error);
1961
+ await giveUp(client, runId, why, claimed.process_token);
1962
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
1963
+ }
1964
+ /* THE MERGE, OFF THE MARK ON THE ASK, BEFORE THE AGENT THAT WILL SPEAK ABOUT
1965
+ IT IS STARTED. The owner reaches an answer by this route as often as by the
1966
+ re-arm, which is why the artifact answer is read on both and this is too. */
1967
+ const offered = claimed.ask_id !== null && claimed.mine === true
1968
+ ? await landingOffer(client, claimed.ask_id)
1969
+ : null;
1970
+ const landing = offered === null ? null : landCardWork(where, offered);
1971
+ const prompt = resumeSessionId
1972
+ ? ownerContinuationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered, landing)
1973
+ : ownerActivationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered !== null && !delivered.mine
1974
+ ? { id: delivered.id, question: delivered.question }
1975
+ : null, currentArtifactAnswer,
1976
+ /* ═══ A PROCESS OF ITS OWN ENDED BEFORE IT FINISHED. ═══ `afterPid` is the
1977
+ fact, and it is non-null on all three paths that follow one: a harness
1978
+ that crashed, a machine that went down, and now a person's correction.
1979
+ The sentences it adds say what to do and never why, because those three
1980
+ are not the same event and `prompt.ts` exists to stop an agent being
1981
+ told an untrue reason for its own restart. */
1982
+ afterPid !== null, landing);
1983
+ const started = startAgent(withStandingRules(rules, prompt, where.block, attached), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) });
1984
+ if (started.pid === null) {
1985
+ const answer = await started.answered;
1986
+ const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
1987
+ await giveUp(client, runId, reason, claimed.process_token);
1988
+ throw new Error(`NO AGENT IS RUNNING: ${reason}`);
1989
+ }
1990
+ try {
1991
+ await recordProcess(client, runId, started.pid, undefined, claimed.process_token);
1992
+ }
1993
+ catch (error) {
1994
+ said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
1995
+ }
1996
+ return {
1997
+ settled: settle(client, tools, machineId, 2, runId, claimed.run_card_id, started, true, claimed.process_token, ownerSessionLifecycle(started, runId, machineHarness, claimed.process_token, resumeSessionId)),
1998
+ };
1999
+ }
2000
+ /** The only owner rows the normal poll may try, including an explicit hand-back move. */
2001
+ export function ownerPollClaim(candidate) {
2002
+ if (candidate.state === 'running') {
2003
+ if (candidate.handed_back_at === null || candidate.process_token === null)
2004
+ return null;
2005
+ return { afterProcessToken: candidate.process_token, afterPid: null };
2006
+ }
2007
+ if (candidate.state === 'finished' || candidate.state === 'asked') {
2008
+ return { afterProcessToken: null, afterPid: null };
2009
+ }
2010
+ return null;
2011
+ }
2012
+ /**
2013
+ * ═══ THE PROCESS A PERSON'S CORRECTION CANNOT HAVE REACHED. ═══
2014
+ *
2015
+ * redirect-5/ux.md: stop ends work, and changing its direction mid-flight did
2016
+ * not exist. ux.md's mechanism is "redirect is stop plus respawn", and the
2017
+ * respawn half is already built: a process that dies is started again as itself
2018
+ * by `settle`, and the claim it goes through leases every unaddressed person
2019
+ * turn on its way. **So the only thing missing was the ending**, and this is it.
2020
+ *
2021
+ * ═══ IT IS A MIRROR OF THE RETRY CLAIM, AND MUST BE CHANGED WITH IT. ═══
2022
+ * `20260914090000`'s retry branch requires the token, `running`, `ended_at` null,
2023
+ * the pid, and `attempts < 3`. The kill is the ONE irreversible act in this file:
2024
+ * every other predicate here pre-filters an RPC that can refuse harmlessly, and
2025
+ * a claim refused AFTER a kill falls through `settle` to `giveUp`, which turns a
2026
+ * person's correction into a FAILED CARD. So every condition below is either
2027
+ * that mirror, or a product rule with a named cost.
2028
+ *
2029
+ * Two things are NOT here, because neither is a property of the row: whether
2030
+ * this run owns its card, and whether this daemon holds its process. Both are
2031
+ * the caller's, and `mine` is what makes the respawn possible at all.
2032
+ */
2033
+ export function redirectedProcess(candidate, machineId,
2034
+ /** The newest unaddressed person turn per card. MAX, never first-seen: a turn
2035
+ * left unaddressed while a question was open would otherwise pin its card
2036
+ * below `resumed_at` for good and nothing on it could ever redirect. */
2037
+ waiting, cardId, booted) {
2038
+ // The claim's own five, mirrored.
2039
+ if (candidate.state !== 'running' || candidate.ended_at !== null)
2040
+ return null;
2041
+ if (candidate.attempts >= MAX_ATTEMPTS)
2042
+ return null;
2043
+ // The person moved this card to another machine. Killing here makes `settle`
2044
+ // retry locally and the claim clears `handed_back_at`, silently reversing them.
2045
+ if (candidate.handed_back_at !== null)
2046
+ return null;
2047
+ // `mine` is not enough: a takeover moves the row while this daemon still holds
2048
+ // a promise for it, and the claim then refuses on the machine predicate.
2049
+ if (candidate.machine_id !== machineId)
2050
+ return null;
2051
+ /* ═══ A CARD THAT NEEDS THE PERSON IS NOT A CARD THEY ARE REDIRECTING. ═══
2052
+ The claim refuses to lease turns while a person's question is open, so a
2053
+ kill here would destroy the work and NOT deliver the correction. The card's
2054
+ state is written from a character-identical predicate by the
2055
+ `panel3_prepare_user_turn` trigger on every person message, so it is the
2056
+ same fact, one read earlier. */
2057
+ if (candidate.card_state !== 'working')
2058
+ return null;
2059
+ /* ═══ AND IT HAS TO BE A MESSAGE THIS PROCESS CANNOT HAVE SEEN. ═══ The rule
2060
+ `panel3_take_turns` already applies one level up. Measured against the
2061
+ attempt that is running NOW, which is also what stops a replacement being
2062
+ killed by the very message that caused it. */
2063
+ const said = waiting.get(cardId);
2064
+ if (said === undefined)
2065
+ return null;
2066
+ if (new Date(said).getTime() <= new Date(candidate.resumed_at ?? candidate.started_at).getTime()) {
2067
+ return null;
2068
+ }
2069
+ return runProcessIsAlive(candidate, booted) ? candidate.pid : null;
2070
+ }
2071
+ export function pendingOwnerSessionWithinGrace(candidate, now = Date.now()) {
2072
+ const local = readOwnerSession(candidate.id);
2073
+ return !!local
2074
+ && local.state === 'pending'
2075
+ && local.harness === candidate.harness
2076
+ && local.processToken === candidate.process_token
2077
+ && now - new Date(local.updatedAt).getTime() < OWNER_SESSION_GRACE_MS;
2078
+ }
2079
+ async function takeOwnerActivations(client, tools, machineId, mine, hold) {
2080
+ /* ═══ THE MESSAGES NOBODY HAS TAKEN, READ BEFORE THE RUNS, DELIBERATELY. ═══
2081
+ A claim landing between these two reads stamps a `resumed_at` NEWER than
2082
+ every turn it just leased, so the redirect below refuses and nothing is
2083
+ killed. In the other order the same claim leaves a stale `resumed_at` beside
2084
+ a turn that now reads unaddressed, and the daemon kills the replacement it
2085
+ has just started. One narrow indexed read (`panel3_turns_takeable_idx`),
2086
+ unconditional because that ordering is the whole point of it. */
2087
+ const outstanding = await returned(client.from('panel3_turns')
2088
+ .select('card_id, created_at')
2089
+ .eq('role', 'user')
2090
+ .is('addressed_at', null), 'read', 'the messages nobody has taken yet');
2091
+ const waiting = new Map();
2092
+ for (const turn of outstanding) {
2093
+ // THE NEWEST PER CARD. See `redirectedProcess`: first-wins would pin a card
2094
+ // below its own `resumed_at` for good.
2095
+ const held = waiting.get(turn.card_id);
2096
+ if (held === undefined || turn.created_at > held)
2097
+ waiting.set(turn.card_id, turn.created_at);
2098
+ }
2099
+ const candidates = await returned(client.from('panel3_runs')
2100
+ .select('id, card_id, codebase_id, machine_id, harness, state, ended_at, attempts, pid, started_at, resumed_at, process_token, handed_back_at, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id, state)')
2101
+ .in('state', ['finished', 'asked', 'running']), 'read', 'conversation owners that may be ready');
2102
+ for (const candidate of candidates) {
2103
+ if (candidate.card?.conversation_run_id !== candidate.id)
2104
+ continue;
2105
+ /* ═══ A RUN THIS DAEMON IS HOLDING IS THE ONLY ONE A CORRECTION CAN
2106
+ REDIRECT. ═══ Not because of the claim, which is not made here at all, but
2107
+ because of what happens AFTER the kill: the respawn is the dying process's
2108
+ own `settle`, and only the daemon that started it has one. Kill anything
2109
+ else and there is a dead process, a row still reading `running` and a card
2110
+ still saying `working`, with nothing local to retry it. */
2111
+ if (mine.has(candidate.id)) {
2112
+ const pid = redirectedProcess({ ...candidate, card_state: candidate.card?.state ?? null }, machineId, waiting, candidate.card_id, bootedAt());
2113
+ if (pid !== null) {
2114
+ killTree({ pid, kill: (signal) => process.kill(pid, signal) });
2115
+ /* ═══ SIGNALLED, NOT KILLED, AND THE WORD IS THE POINT. ═══ `killTree`
2116
+ swallows a refused signal on both platforms, so saying "killed" would
2117
+ claim a death this daemon never observed. A process that survives is
2118
+ still running, still held, and its turn is still unaddressed and still
2119
+ newer than `resumed_at`, so the next poll signals it again — the same
2120
+ rule `killStopped` states two hundred lines below. */
2121
+ out(`redirect card ${candidate.card_id} run ${candidate.id} signalled pid ${pid}`);
2122
+ }
2123
+ continue;
2124
+ }
2125
+ if (pendingOwnerSessionWithinGrace(candidate))
2126
+ continue;
2127
+ if (candidate.handed_back_at !== null
2128
+ && candidate.machine_id === machineId
2129
+ && (runProcessIsAlive(candidate, bootedAt())
2130
+ || (candidate.pid === null
2131
+ && Date.now() - new Date(candidate.resumed_at ?? candidate.started_at).getTime() < PID_GRACE_MS)))
2132
+ continue;
2133
+ const claim = ownerPollClaim(candidate);
2134
+ if (!claim)
2135
+ continue;
2136
+ try {
2137
+ const activated = await activateOwner(client, tools, machineId, candidate.id, claim.afterProcessToken, claim.afterPid);
2138
+ if (activated)
2139
+ hold(candidate.id, activated.settled);
2140
+ }
2141
+ catch (error) {
2142
+ said(`could not activate owner ${candidate.id}: ${error instanceof Error ? error.message : String(error)}`);
2143
+ }
2144
+ }
2145
+ }
1266
2146
  // ---------------------------------------------------------------------------
1267
2147
  /**
1268
2148
  * ═══ THE TWO ENDS THE PERSON CAUSES, WHICH ARE THE TWO THIS DAEMON KILLS. ═══
@@ -1300,6 +2180,46 @@ function runProcessIsAlive(run, booted) {
1300
2180
  return false;
1301
2181
  return processIsAlive(run.pid);
1302
2182
  }
2183
+ /** Reconcile only CTRL+SPC-owned local session state. Native Claude and Windows
2184
+ * Codex transcripts live in harness-owned locations and are deliberately not
2185
+ * touched here. A current owner row, live PID, in-flight process, or fresh
2186
+ * pid-null claim always defers cleanup. Stable state is removed only after the
2187
+ * owner row disappears, becomes terminal, or no longer owns its card. */
2188
+ export async function reconcileOwnerSessions(client, machineId, _machineHarness, inFlightOwnerIds = new Set()) {
2189
+ const mappingIds = listOwnerSessionIds();
2190
+ const homeIds = listPanel3CodexOwnerHomeIds();
2191
+ const all = [...new Set([...mappingIds, ...homeIds])];
2192
+ const invalid = all.filter((id) => !validSessionUuid(id));
2193
+ for (const id of invalid)
2194
+ removePanel3CodexOwnerHome(id);
2195
+ const ids = all.filter(validSessionUuid);
2196
+ if (ids.length === 0)
2197
+ return;
2198
+ const rows = await returned(client.from('panel3_runs')
2199
+ .select('id, machine_id, harness, state, pid, started_at, resumed_at, ended_at, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
2200
+ .in('id', ids), 'read', 'which local conversation sessions still have an owner');
2201
+ const byId = new Map(rows.map((row) => [row.id, row]));
2202
+ for (const id of ids) {
2203
+ if (inFlightOwnerIds.has(id))
2204
+ continue;
2205
+ const row = byId.get(id);
2206
+ const localProcessInUse = !!row
2207
+ && row.machine_id === machineId
2208
+ && ((row.pid !== null && processIsAlive(row.pid))
2209
+ || (row.state === 'running'
2210
+ && row.ended_at === null
2211
+ && row.pid === null
2212
+ && Date.now() - new Date(row.resumed_at ?? row.started_at).getTime() < PID_GRACE_MS));
2213
+ const rowStillOwnsConversation = !!row
2214
+ && row.card?.conversation_run_id === id
2215
+ && ['running', 'asked', 'finished'].includes(row.state);
2216
+ if (localProcessInUse || rowStillOwnsConversation)
2217
+ continue;
2218
+ removeOwnerSession(id);
2219
+ if (homeIds.includes(id))
2220
+ removePanel3CodexOwnerHome(id);
2221
+ }
2222
+ }
1303
2223
  /**
1304
2224
  * ═══ THE USER STOPPED IT, OR THEIR NEXT MESSAGE REPLACED IT, SO THE PROCESS
1305
2225
  * STOPS. ═══
@@ -1441,7 +2361,7 @@ async function killStopped(client, machineId) {
1441
2361
  async function recoverStranded(client, tools, machineId, mine, hold) {
1442
2362
  const live = await returned(client
1443
2363
  .from('panel3_runs')
1444
- .select('id, card_id, parent_run_id, state, pid, started_at, resumed_at')
2364
+ .select('id, card_id, parent_run_id, state, pid, started_at, resumed_at, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
1445
2365
  .eq('machine_id', machineId)
1446
2366
  /* ═══ AND A RUN THAT STOPPED TO ASK IS SWEPT TOO, WHICH IS WHAT MAKES THE
1447
2367
  RE-ARM'S OWN PREDICATE SAFE. ═══ `panel3_take_rearms` will only start a run
@@ -1468,6 +2388,23 @@ async function recoverStranded(client, tools, machineId, mine, hold) {
1468
2388
  continue;
1469
2389
  if (runProcessIsAlive(run, booted))
1470
2390
  continue;
2391
+ const isOwner = run.card?.conversation_run_id === run.id && run.process_token !== null;
2392
+ if (isOwner) {
2393
+ try {
2394
+ if (run.state === 'asked') {
2395
+ await writeAnswer(client, run.id, run.card_id, null, run.process_token ?? undefined);
2396
+ }
2397
+ else {
2398
+ const resumed = await activateOwner(client, tools, machineId, run.id, run.process_token, run.pid);
2399
+ if (resumed)
2400
+ hold(run.id, resumed.settled);
2401
+ }
2402
+ }
2403
+ catch (error) {
2404
+ said(`could not recover owner ${run.id}: ${error instanceof Error ? error.message : String(error)}`);
2405
+ }
2406
+ continue;
2407
+ }
1471
2408
  if (run.state === 'asked') {
1472
2409
  /* ═══ ITS END IS STAMPED AND ITS TURNS ARE LEFT ALONE. ═══ This run is not
1473
2410
  handed back and is not started again from here: its continuation is the
@@ -1662,7 +2599,7 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
1662
2599
  whether its pid means anything here. See the header. */
1663
2600
  const offered = await returned(client
1664
2601
  .from('panel3_runs')
1665
- .select('id, card_id, machine_id, pid, started_at, resumed_at')
2602
+ .select('id, card_id, machine_id, pid, started_at, resumed_at, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
1666
2603
  .not('handed_back_at', 'is', null)
1667
2604
  /* WHAT THE OFFER MEANS, RESTATED IN THE READ. `panel3_hand_back` only ever
1668
2605
  stamps a dispatched run that is still going, and `panel3_resume` refuses
@@ -1708,7 +2645,9 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
1708
2645
  continue;
1709
2646
  }
1710
2647
  try {
1711
- const resumed = await resumeRun(client, tools, machineId, run.id, null);
2648
+ const resumed = run.card?.conversation_run_id === run.id && run.process_token !== null
2649
+ ? await activateOwner(client, tools, machineId, run.id, run.process_token, null)
2650
+ : await resumeRun(client, tools, machineId, run.id, null);
1712
2651
  if (resumed)
1713
2652
  hold(run.id, resumed.settled);
1714
2653
  else
@@ -1729,7 +2668,7 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
1729
2668
  *
1730
2669
  * ═══ ONE LOOP, TWO CALLERS, AND THAT IS THE WHOLE OF recovery-1 SLICE 4. ═══
1731
2670
  * `startPanel` below is `cs start`'s entry and hands in the session the
1732
- * installed CLI already holds. `cs3 run` is the acceptance harness's entry and
2671
+ * installed CLI already holds. `panel3/cli.js run` is the acceptance harness's entry and
1733
2672
  * signs in from the environment against its own isolated account. Neither is a
1734
2673
  * copy of the other: a second poll loop would be two things leasing the same
1735
2674
  * turns with two ideas of what recovery may reap.
@@ -1738,6 +2677,135 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
1738
2677
  * already has one. Its presence is also what says THIS PROCESS IS NOT v3's, so
1739
2678
  * the signals belong to somebody else — see the handler block below.
1740
2679
  */
2680
+ /**
2681
+ * ═══ THE CARD IS OVER, SO ITS COPY GOES — AND THE BRANCH STAYS. ═══
2682
+ *
2683
+ * A SWEEP ON THE DAEMON'S OWN LOOP RATHER THAN A HOOK ON A RUN ENDING, and the
2684
+ * reason is that there is no place the daemon learns a card ended. The `done`
2685
+ * transition happens inside SQL, and `settle` fires per PROCESS at every level:
2686
+ * a level 3 worker exiting while its level 2 owner is still editing the same
2687
+ * tree would take the tree out from under it. So the question is asked of the
2688
+ * record, of every run on the card, and a card with anything still running is
2689
+ * passed over.
2690
+ *
2691
+ * ═══ THE FILESYSTEM IS ASKED FIRST, SO A MACHINE WITH NO COPIES ASKS NOTHING.
2692
+ * ═══ And the folders it finds are what bounds the query: only the codebases
2693
+ * this machine actually holds a copy for.
2694
+ *
2695
+ * A folder no row explains is LEFT ALONE. Nothing here deletes something it
2696
+ * cannot account for.
2697
+ */
2698
+ async function sweepFinishedWorktrees(client) {
2699
+ const held = worktreesOnThisMachine();
2700
+ if (held.length === 0)
2701
+ return;
2702
+ /* ═══ ASKED IN TWO STEPS, BECAUSE THE TWO QUESTIONS ARE NOT THE SAME ONE.
2703
+ ═══ WHICH CARD a folder belongs to is answered by the runs that carry a
2704
+ branch, which are the runs in a codebase. WHETHER THAT CARD IS STILL BEING
2705
+ WORKED is answered by ALL of its runs, and its level 1 coordinator has no
2706
+ codebase at all — so a single query filtered by codebase would call a card
2707
+ finished while the agent coordinating it was still going.
2708
+
2709
+ Neither is filtered to this machine. The FOLDERS are this machine's, and
2710
+ only a run here can be inside one, but "the card is over" is a fact about
2711
+ the card, and a card dispatched across machines has runs elsewhere. */
2712
+ const stamped = await returned(client.from('panel3_runs')
2713
+ .select('card_id, codebase_id, branch')
2714
+ .in('codebase_id', [...new Set(held.map((one) => one.codebaseId))])
2715
+ .not('branch', 'is', null), 'read', 'which cards the copies on this machine belong to');
2716
+ const cardOfFolder = new Map(held.map((one) => [one.folder, [...new Set(stamped
2717
+ .filter((row) => (row.codebase_id === one.codebaseId && row.branch !== null && folderIsBranch(one.slug, row.branch)))
2718
+ .map((row) => row.card_id))]]));
2719
+ const cards = [...new Set([...cardOfFolder.values()].flat())];
2720
+ if (cards.length === 0)
2721
+ return;
2722
+ const live = new Set((await returned(client.from('panel3_runs')
2723
+ .select('card_id')
2724
+ .in('card_id', cards)
2725
+ .is('ended_at', null), 'read', 'which of those cards still have an agent going')).map((row) => row.card_id));
2726
+ const touched = new Set();
2727
+ for (const one of held) {
2728
+ const cardIds = cardOfFolder.get(one.folder) ?? [];
2729
+ if (cardIds.length === 0 || cardIds.some((cardId) => live.has(cardId)))
2730
+ continue;
2731
+ try {
2732
+ settleCardWorktree(one.folder);
2733
+ touched.add(one.codebaseId);
2734
+ out(`cleaned card copy for ${cardIds.join(', ')}`);
2735
+ }
2736
+ catch (error) {
2737
+ /* SAID, NOT FATAL, AND NOT RETRIED HARDER. The work is committed or it is
2738
+ not; either way the copy is still there and the next poll tries again.
2739
+ Taking the daemon's loop down over cleanup would stop every card. */
2740
+ said(`could not clean up a finished card's copy: ${error instanceof Error ? error.message : String(error)}`);
2741
+ }
2742
+ }
2743
+ /* THE BASE LOCK LAST, AND ONLY WHERE SOMETHING WAS REMOVED. It is what makes
2744
+ the refusal real for every other card in that codebase, so it survives
2745
+ until none of them is left. */
2746
+ for (const codebaseId of touched) {
2747
+ try {
2748
+ releaseBaseBranch(codebaseId);
2749
+ }
2750
+ catch { /* it is held, which is safe */ }
2751
+ }
2752
+ }
2753
+ /** The last mismatch state this daemon said out loud, so a poll every two
2754
+ * seconds does not narrate the same standing fact thirty times a minute.
2755
+ *
2756
+ * ═══ SAID ONCE PER STATE, NOT ONCE PER PROCESS. ═══ The same discipline v2's
2757
+ * `roleKey` (`orchestrator.ts`) uses for its own designation lines, and for the
2758
+ * same reason: the person re-designates while the daemon is running, so
2759
+ * "matching again" is a genuinely different state that they must hear about
2760
+ * too, while the unchanged one repeated is noise that buries every useful
2761
+ * line. Null means nothing has been said yet. */
2762
+ let saidHarnessState = null;
2763
+ /**
2764
+ * ═══ THE DESIGNATION NAMES A HARNESS THIS DAEMON IS NOT, AND SOMEBODY HAS TO
2765
+ * SAY SO. ═══
2766
+ *
2767
+ * The take's agent filter lives in SQL and answers with zero rows, which is
2768
+ * right — a daemon may not act outside its designation — but zero rows is also
2769
+ * what "nothing is waiting" looks like, and the two are indistinguishable from
2770
+ * the outside. This is the one place they can be told apart, because it is the
2771
+ * only place that holds BOTH facts at once: the harness this process actually
2772
+ * runs, and the harness the person has designated.
2773
+ *
2774
+ * ═══ IT DOES NOT REFUSE, RETRY OR RE-READ THE HARNESS. ═══ `CTRL_SPC_V3_AGENT`
2775
+ * is fixed for the life of the process by design (`spawn.ts`: reported, never
2776
+ * chosen), and honouring a switch by silently spawning the other binary would
2777
+ * attribute a whole session of results to a harness that never ran. So the
2778
+ * daemon keeps polling, keeps taking whatever it legitimately may, and says the
2779
+ * one true sentence about why this particular work is not moving.
2780
+ *
2781
+ * ═══ AND A FAILED READ IS NOT A MISMATCH. ═══ Constraint 4, the same rule
2782
+ * `designatedCoordinator` states: a read that failed does not mean nobody is
2783
+ * designated, and it must not produce a sentence claiming to know which harness
2784
+ * was chosen. It throws, and the poll's own catch treats it as a poll that did
2785
+ * not finish — the next one is two seconds away.
2786
+ */
2787
+ async function reportHarnessMismatch(client, machineId, machineHarness) {
2788
+ const designation = await designatedCoordinator(client);
2789
+ /* Nobody designated, or somebody else's machine: neither is this daemon
2790
+ failing to honour anything. `panel3_take_turns` lets ANY machine take when
2791
+ nothing is designated, and a designation naming another machine is that
2792
+ machine's business — v2's own listener already says both of those
2793
+ (`roleLine`'s 'none' and 'other-machine'), and repeating them here would be
2794
+ two voices on one fact. */
2795
+ const mismatched = designation !== null
2796
+ && designation.machineId === machineId
2797
+ && designation.agent !== machineHarness;
2798
+ const state = mismatched ? `mismatch:${designation.agent}` : 'ok';
2799
+ if (state === saidHarnessState)
2800
+ return;
2801
+ saidHarnessState = state;
2802
+ if (!mismatched)
2803
+ return;
2804
+ said(`${designation.agent} is designated to coordinate on this machine, and this daemon runs `
2805
+ + `${machineHarness}, so it will not pick work up. Nothing will act on what you send until `
2806
+ + `this daemon is restarted with CTRL_SPC_V3_AGENT=${designation.agent}, or ${machineHarness} `
2807
+ + 'is designated in the app.');
2808
+ }
1741
2809
  export async function run(args, injected) {
1742
2810
  let once = false;
1743
2811
  for (const arg of args) {
@@ -1794,8 +2862,8 @@ export async function run(args, injected) {
1794
2862
  `tools` is referenced inside the callback it is being given, which is safe
1795
2863
  for the plain reason that the callback can only run once a request has
1796
2864
  arrived at a server that by then exists. */
1797
- const tools = await startToolsServer(client, async (parentRunId, brief, codebase) => {
1798
- const child = await startChild(client, tools, machineId, parentRunId, brief, codebase);
2865
+ const tools = await startToolsServer(client, async (parentRunId, brief, codebase, processToken) => {
2866
+ const child = await startChild(client, tools, machineId, parentRunId, brief, codebase, processToken);
1799
2867
  hold(child.runId, child.settled);
1800
2868
  return { runId: child.runId };
1801
2869
  });
@@ -1834,7 +2902,7 @@ export async function run(args, injected) {
1834
2902
  for (;;) {
1835
2903
  /* ═══ ONE POLL FAILING IS NOT THE DAEMON FAILING. ═══ Every read and write
1836
2904
  here throws on a network or database error, by design (constraint 7), and
1837
- until Slice 4 that threw straight out of `cs3 run` and exited the process.
2905
+ until Slice 4 that threw straight out of `panel3/cli.js run` and exited the process.
1838
2906
  Inside `cs start` that is no longer an honest outcome twice over: it would
1839
2907
  take the v2 presence heartbeat down with it, and it would leave a stranded
1840
2908
  card printing `cs start` at a person who IS running `cs start`, which is
@@ -1856,7 +2924,13 @@ export async function run(args, injected) {
1856
2924
  to run first: `panel3_stop_card` has already ended the runs, so recovery
1857
2925
  cannot see them and neither take can start them. */
1858
2926
  await killStopped(client, machineId);
2927
+ await reconcileOwnerSessions(client, machineId, machineHarness, new Set(inFlight.keys()));
1859
2928
  await recoverStranded(client, tools, machineId, new Set(inFlight.keys()), hold);
2929
+ /* ═══ AND THE COPIES OF CARDS THAT ARE OVER. ═══ After recovery,
2930
+ deliberately: a run this machine is about to resume is one whose card is
2931
+ not finished, and the sweep asks the record after recovery has had its
2932
+ say about what is really still running. */
2933
+ await sweepFinishedWorktrees(client);
1860
2934
  /* ═══ AND THEN WHAT SOMEBODY ELSE'S MACHINE WAS HOLDING, IF A PERSON HANDED
1861
2935
  IT BACK. ═══ AFTER the sweep, deliberately: this machine settles its own
1862
2936
  runs on local, pid-accurate evidence before it looks at anybody's, and a
@@ -1868,6 +2942,27 @@ export async function run(args, injected) {
1868
2942
  with a brief and a report behind it, comes before work that has not
1869
2943
  started. */
1870
2944
  await takeHandedBack(client, tools, machineId, new Set(inFlight.keys()), hold);
2945
+ /* Every reason a conversational owner may continue is claimed together.
2946
+ The legacy turn and re-arm takes exclude named owners in the database. */
2947
+ await takeOwnerActivations(client, tools, machineId, new Set(inFlight.keys()), hold);
2948
+ /* ═══ AND IF THE DESIGNATION NAMES A HARNESS THIS DAEMON IS NOT, IT SAYS
2949
+ SO BEFORE TAKING NOTHING. ═══
2950
+ `panel3_take_turns` and `panel3_take_rearms` compare `p_agent` against
2951
+ `cliv2_orchestrator_preference` in SQL, and a mismatch is not an error
2952
+ there — it is zero rows, which is correct and which is also exactly what
2953
+ a quiet machine with no work waiting looks like. Proven on the record
2954
+ (.bugs/.resolved/20260826-daemon-ignores-the-harness-switch): with the
2955
+ designation on `codex` and this daemon on `claude`, a waiting turn was
2956
+ never claimed for over two minutes, the daemon printed nothing, and the
2957
+ card sat under `Working` with a healthy machine online.
2958
+
2959
+ `harness()` is read once at startup and cannot change under a running
2960
+ process (see `machineHarness` above), while the designation is a chip a
2961
+ person clicks in the app at any moment. So the two CAN disagree, and
2962
+ ux.md's forbidden state — "nothing will act on it and nothing on screen
2963
+ says so" — is reached the moment they do. This is the daemon's own
2964
+ screen saying so. */
2965
+ await reportHarnessMismatch(client, machineId, machineHarness);
1871
2966
  /* THE MACHINE ID GOES IN because the take writes the run row, and a run has
1872
2967
  to say where it is running: the exclusion is cross-machine and recovery is
1873
2968
  per-machine, so a row with nobody's machine on it could be neither. */