@ctrl-spc/cs 0.7.11 → 0.7.13

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.
@@ -2,7 +2,7 @@
2
2
  * ═══ AGENT PANEL v3: the poll loop that answers a card. ═══
3
3
  *
4
4
  * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may import
5
- * it, WITH ONE NAMED EXCEPTION: `cli-v2/src/daemon.ts` imports `startPanel` from
5
+ * it, WITH ONE NAMED EXCEPTION: `cli-v2/src/presence.ts` imports `startPanel` from
6
6
  * here and nothing else, which is how `cs start` runs the panel. It is written
7
7
  * down in `conventions.md` and checked by `panel3-isolation.contract.test.mjs`;
8
8
  * `startPanel`'s own comment at the foot of this file carries the reasoning.
@@ -136,8 +136,8 @@ import { readableWriteError } from '../firewall.js';
136
136
  import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, landingOutcomeContext, presentedArtifactAnswerContext, resumePrompt, retryPrompt, standingRules, whatWasAttached, workingRules, OWNER_COMPLETION_RULES, } from './prompt.js';
137
137
  import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, loadOutputNames, outputOf, recordBaseProtection, standingRulesFor, withAskContent, } from './show.js';
138
138
  import { forgetSecrets, redactSecrets } from './secrets.js';
139
- import { sayListening, stopListening } from './presence.js';
140
- import { designatedCoordinator } from './coordinator.js';
139
+ import { sayListening, sayPollingProblem, stopListening } from './presence.js';
140
+ import { selectedHarness } from './coordinator.js';
141
141
  import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, worktreesOnThisMachine, } from './checkout.js';
142
142
  import { harness, startAgent } from './spawn.js';
143
143
  import { establishOwnerSession, listOwnerSessionIds, OWNER_SESSION_GRACE_MS, readOwnerSession, removeOwnerSession, validSessionUuid, writeOwnerSession, } from './session.js';
@@ -894,8 +894,8 @@ async function codebaseOfRun(client, runId) {
894
894
  * ═══
895
895
  *
896
896
  * The card is the unit of isolation, so the branch is derived from the card and
897
- * every run on that card resolves to the same one. A run that finds a branch
898
- * already stamped by a sibling takes it rather than deriving again: the
897
+ * every run on that card in the same codebase resolves to the same one. A run
898
+ * that finds a branch already stamped by a sibling in that codebase takes it rather than deriving again: the
899
899
  * derivation is pure today and stops being pure the moment the pattern becomes
900
900
  * a setting, and one card on two branches is the defect this whole slice exists
901
901
  * to prevent.
@@ -921,6 +921,7 @@ async function cardWorktree(client, runId, codebase) {
921
921
  const sibling = row.branch !== null ? null : (await returned(client.from('panel3_runs')
922
922
  .select('branch, base')
923
923
  .eq('card_id', row.card_id)
924
+ .eq('codebase_id', codebase.id)
924
925
  .not('branch', 'is', null)
925
926
  .limit(1), 'read', `the branch card ${row.card_id} is working on`))[0] ?? null;
926
927
  const stamped = {
@@ -1012,8 +1013,8 @@ async function refreshBaseProtection(client, codebase, rules, source, base) {
1012
1013
  *
1013
1014
  * ═══ AND INSIDE `git rev-parse --git-dir`, WHICH IS THE PART THAT WAS MEASURED
1014
1015
  * RATHER THAN REASONED ABOUT. ═══ A folder in the working tree shows as `??`
1015
- * in `git status`, and `settleCardWorktree` commits unconditionally so a
1016
- * picture left there is a picture committed onto the card's branch. And
1016
+ * in `git status`, so an explicit landing would commit a picture left there
1017
+ * onto the card's branch. And
1017
1018
  * `.git/info/exclude` cannot be used: in a linked worktree `.git` is a FILE
1018
1019
  * holding a `gitdir:` pointer, and `git rev-parse --git-path info/exclude`
1019
1020
  * resolves to the SHARED repository's exclude file, so writing it would alter
@@ -1258,7 +1259,7 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
1258
1259
  return {
1259
1260
  runId: row.run_id,
1260
1261
  settled: settle(client, tools, machineId, level, row.run_id, row.run_card_id, started, true, processToken, isOwner && processToken
1261
- ? ownerSessionLifecycle(started, row.run_id, harness(), processToken)
1262
+ ? ownerSessionLifecycle(started, row.run_id, settings.harness ?? harness(), processToken)
1262
1263
  : undefined),
1263
1264
  };
1264
1265
  }
@@ -1460,44 +1461,12 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
1460
1461
  }
1461
1462
  if (!answer.ok) {
1462
1463
  await ownerSession?.failed();
1463
- /* ═══ A PROCESS THAT STARTED AND THEN DIED BADLY IS STARTED AGAIN. ═══
1464
- recovery-1/ux.md, Slice 3: "the agent's harness fails on something that
1465
- is nobody's fault: a rate limit, a dropped connection, a provider
1466
- outage. The product tries again rather than failing the card."
1467
-
1468
- `started.pid` IS THE WHOLE OF THE DISTINCTION, and it is a fact rather
1469
- than a reading of a string. What reaches here as `{ ok: false }` is
1470
- never a verdict about the work. An agent that ran and concluded it
1471
- could not do the thing exits 0 having said so, and that is an ANSWER,
1472
- written to the card below. So what is left to separate is a process that
1473
- ran and died from one that never existed, and `Started.pid` answers it:
1474
- `could not start claude on this machine: ENOENT` and `claude is not
1475
- installed` are facts about this machine that will be identical in two
1476
- seconds and are fixed by a person, while `claude exited 1: API Error:
1477
- 529 Overloaded` is what a rate limit, a dropped connection and a
1478
- provider outage all look like from here. plan-slice-3.md section 1.2
1479
- measured the alternative and rejected it: the provider's own words reach
1480
- this daemon as a 500 character tail of one of two streams, absent
1481
- entirely for a rate limit, and different again next release.
1482
-
1483
- WHAT THIS DELIBERATELY GIVES UP is that a genuinely deterministic
1484
- harness crash costs two extra spawns before the card fails. That is
1485
- bounded, and it is the price of not depending on a string.
1486
-
1487
- THE RESPAWN IS `resumeRun`, WHICH ALREADY EXISTS. ux.md: "two features
1488
- owning one respawn is how two respawns are born." Nothing new starts a
1489
- process here; the failure calls the sweep's own respawn, fenced by the
1490
- pid this daemon watched exit, and AWAITS it, so the daemon's one
1491
- `inFlight` entry covers the whole chain and `--once` waits for the last
1492
- attempt rather than exiting mid-retry.
1493
-
1494
- AND THE OLD RULING HERE IS AMENDED RATHER THAN DELETED. It read: "a
1495
- process that started and then failed is a failure of the work, not of
1496
- the machine: resuming it would put the same agent back on the same brief
1497
- to fail the same way, FOREVER." The half that survives is forever, and
1498
- the bound is what now provides it. */
1464
+ /* Retry a started process using the existing bounded recovery path,
1465
+ except when the harness identifies a permanent provider rejection.
1466
+ Repeating an invalid model/effort request cannot repair it. Unknown
1467
+ process deaths retain the existing recovery policy. */
1499
1468
  const attempts = await attemptsSoFar(client, runId);
1500
- if (started.pid !== null && attempts < MAX_ATTEMPTS) {
1469
+ if (answer.retryable !== false && started.pid !== null && attempts < MAX_ATTEMPTS) {
1501
1470
  out(`retry run ${runId} attempt ${attempts} of ${MAX_ATTEMPTS} died: ${answer.reason}`);
1502
1471
  const again = processToken === undefined
1503
1472
  ? await resumeRun(client, tools, machineId, runId, started.pid)
@@ -2034,7 +2003,13 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
2034
2003
  const candidate = await ownerCandidate(client, runId);
2035
2004
  if (!candidate)
2036
2005
  return null;
2037
- const machineHarness = harness();
2006
+ // Existing conversations retain their agent; an explicit hand-back follows
2007
+ // the new selection. Never reuse a native session across different agents.
2008
+ if (candidate.handed_back_at === null && candidate.machine_id !== machineId)
2009
+ return null;
2010
+ const machineHarness = candidate.handed_back_at === null && candidate.harness !== null
2011
+ ? harness({ CTRL_SPC_V3_AGENT: candidate.harness })
2012
+ : await selectedHarness(client, machineId);
2038
2013
  const resumeSessionId = resumableOwnerSessionId(candidate, machineId, machineHarness);
2039
2014
  /* ═══ THE EXISTENCE CHECK BEFORE THE CLAIM, AND THE COPY AFTER IT. ═══ This
2040
2015
  was the whole resolution, which was right while resolving meant reading a
@@ -2959,13 +2934,13 @@ export async function sweepFinishedWorktrees(client) {
2959
2934
  if (!cardIds.every((cardId) => over.has(cardId) && !live.has(cardId)))
2960
2935
  continue;
2961
2936
  try {
2962
- settleCardWorktree(one.folder);
2937
+ if (!settleCardWorktree(one.folder))
2938
+ continue;
2963
2939
  touched.add(one.codebaseId);
2964
2940
  out(`cleaned card copy for ${cardIds.join(', ')}`);
2965
2941
  }
2966
2942
  catch (error) {
2967
- /* SAID, NOT FATAL, AND NOT RETRIED HARDER. The work is committed or it is
2968
- not; either way the copy is still there and the next poll tries again.
2943
+ /* A failed cleanup leaves the copy in place; the next poll can retry.
2969
2944
  Taking the daemon's loop down over cleanup would stop every card. */
2970
2945
  said(`could not clean up a finished card's copy: ${error instanceof Error ? error.message : String(error)}`);
2971
2946
  }
@@ -2980,62 +2955,6 @@ export async function sweepFinishedWorktrees(client) {
2980
2955
  catch { /* it is held, which is safe */ }
2981
2956
  }
2982
2957
  }
2983
- /** The last mismatch state this daemon said out loud, so a poll every two
2984
- * seconds does not narrate the same standing fact thirty times a minute.
2985
- *
2986
- * ═══ SAID ONCE PER STATE, NOT ONCE PER PROCESS. ═══ The same discipline v2's
2987
- * `roleKey` (`orchestrator.ts`) uses for its own designation lines, and for the
2988
- * same reason: the person re-designates while the daemon is running, so
2989
- * "matching again" is a genuinely different state that they must hear about
2990
- * too, while the unchanged one repeated is noise that buries every useful
2991
- * line. Null means nothing has been said yet. */
2992
- let saidHarnessState = null;
2993
- /**
2994
- * ═══ THE DESIGNATION NAMES A HARNESS THIS DAEMON IS NOT, AND SOMEBODY HAS TO
2995
- * SAY SO. ═══
2996
- *
2997
- * The take's agent filter lives in SQL and answers with zero rows, which is
2998
- * right — a daemon may not act outside its designation — but zero rows is also
2999
- * what "nothing is waiting" looks like, and the two are indistinguishable from
3000
- * the outside. This is the one place they can be told apart, because it is the
3001
- * only place that holds BOTH facts at once: the harness this process actually
3002
- * runs, and the harness the person has designated.
3003
- *
3004
- * ═══ IT DOES NOT REFUSE, RETRY OR RE-READ THE HARNESS. ═══ `CTRL_SPC_V3_AGENT`
3005
- * is fixed for the life of the process by design (`spawn.ts`: reported, never
3006
- * chosen), and honouring a switch by silently spawning the other binary would
3007
- * attribute a whole session of results to a harness that never ran. So the
3008
- * daemon keeps polling, keeps taking whatever it legitimately may, and says the
3009
- * one true sentence about why this particular work is not moving.
3010
- *
3011
- * ═══ AND A FAILED READ IS NOT A MISMATCH. ═══ Constraint 4, the same rule
3012
- * `designatedCoordinator` states: a read that failed does not mean nobody is
3013
- * designated, and it must not produce a sentence claiming to know which harness
3014
- * was chosen. It throws, and the poll's own catch treats it as a poll that did
3015
- * not finish — the next one is two seconds away.
3016
- */
3017
- async function reportHarnessMismatch(client, machineId, machineHarness) {
3018
- const designation = await designatedCoordinator(client);
3019
- /* Nobody designated, or somebody else's machine: neither is this daemon
3020
- failing to honour anything. `panel3_take_turns` lets ANY machine take when
3021
- nothing is designated, and a designation naming another machine is that
3022
- machine's business — v2's own listener already says both of those
3023
- (`roleLine`'s 'none' and 'other-machine'), and repeating them here would be
3024
- two voices on one fact. */
3025
- const mismatched = designation !== null
3026
- && designation.machineId === machineId
3027
- && designation.agent !== machineHarness;
3028
- const state = mismatched ? `mismatch:${designation.agent}` : 'ok';
3029
- if (state === saidHarnessState)
3030
- return;
3031
- saidHarnessState = state;
3032
- if (!mismatched)
3033
- return;
3034
- said(`${designation.agent} is designated to coordinate on this machine, and this daemon runs `
3035
- + `${machineHarness}, so it will not pick work up. Nothing will act on what you send until `
3036
- + `this daemon is restarted with CTRL_SPC_V3_AGENT=${designation.agent}, or ${machineHarness} `
3037
- + 'is designated in the app.');
3038
- }
3039
2958
  /**
3040
2959
  * ═══ THE ONE PLACE THE TWO SHAPES OF CALLER BECOME ONE SHAPE. ═══
3041
2960
  *
@@ -3061,7 +2980,7 @@ async function reportHarnessMismatch(client, machineId, machineHarness) {
3061
2980
  export function clientReader(injected) {
3062
2981
  return typeof injected === 'function' ? injected : () => injected;
3063
2982
  }
3064
- export async function run(args, injected) {
2983
+ export async function run(args, injected, signal, lifecycle) {
3065
2984
  let once = false;
3066
2985
  for (const arg of args) {
3067
2986
  if (arg === '--once')
@@ -3090,14 +3009,8 @@ export async function run(args, injected) {
3090
3009
  captured in one place and not another. */
3091
3010
  const current = clientReader(injected ?? await signedInClient(true));
3092
3011
  const machineId = getMachineIdentity().id;
3093
- /* THE NAME AND THE HARNESS, RESOLVED ONCE, AT STARTUP, RATHER THAN PER POLL.
3094
- Both are properties of this machine for the life of this process: the
3095
- hostname does not change under it, and neither does `CTRL_SPC_V3_AGENT`.
3096
- Resolving `harness()` here means a machine misconfigured with a name this
3097
- build cannot spawn (constraint 7: reported, never chosen) fails before it
3098
- ever says it is listening, rather than on its first poll. */
3099
3012
  const machineName = hostname();
3100
- const machineHarness = harness();
3013
+ let listeningHarness = null;
3101
3014
  /* THE RUNS THIS PROCESS IS HOLDING RIGHT NOW, so recovery cannot declare its
3102
3015
  own live work dead in the moment before a pid is recorded. It covers THIS
3103
3016
  daemon only, which is why `PID_GRACE_MS` exists for the other ones. Keyed by
@@ -3151,7 +3064,10 @@ export async function run(args, injected) {
3151
3064
  if (!once && !injected) {
3152
3065
  for (const signal of ['SIGINT', 'SIGTERM']) {
3153
3066
  process.once(signal, () => {
3154
- void stopListening(current(), machineId, machineHarness).finally(() => {
3067
+ const stopped = listeningHarness === null
3068
+ ? Promise.resolve()
3069
+ : stopListening(current(), machineId, listeningHarness);
3070
+ void stopped.finally(() => {
3155
3071
  /* The exit code a signal is supposed to produce, and the reason it is
3156
3072
  said explicitly: `process.once` REPLACES node's default handler, so
3157
3073
  without this a Ctrl-C would leave the daemon polling forever. */
@@ -3160,201 +3076,223 @@ export async function run(args, injected) {
3160
3076
  });
3161
3077
  }
3162
3078
  }
3163
- for (;;) {
3164
- /* ═══ ONE POLL FAILING IS NOT THE DAEMON FAILING. ═══ Every read and write
3165
- here throws on a network or database error, by design (constraint 7), and
3166
- until Slice 4 that threw straight out of `panel3/cli.js run` and exited the process.
3167
- Inside `cs start` that is no longer an honest outcome twice over: it would
3168
- take the v2 presence heartbeat down with it, and it would leave a stranded
3169
- card printing `cs start` at a person who IS running `cs start`, which is
3170
- the exact lie this slice exists to end. A dropped connection is a poll
3171
- that did not happen; the next one is two seconds away.
3172
-
3173
- `--once` still fails loudly, because the acceptance harness reads the exit
3174
- code and a swallowed failure there would make a broken suite look green. */
3175
- try {
3176
- /* ═══ THE FIRST THING EVERY POLL, BECAUSE IT IS WHAT MAKES THE OTHER THINGS
3177
- LEGIBLE. ═══ A card with an untaken turn reads the same whether a daemon
3178
- is two seconds away or nobody has one running; this row is the only place
3179
- the difference exists. It is written before the takes rather than after
3180
- so that a machine which is up but busy still reads as up. */
3181
- await sayListening(current(), machineId, machineName, machineHarness);
3182
- /* ═══ THE USER'S STOP IS HONOURED BEFORE ANYTHING ELSE ON THE POLL. ═══ It
3183
- is the only thing here that a person is waiting on, and the two takes
3184
- below can spend the rest of the poll starting agents. Nothing else needs
3185
- to run first: `panel3_stop_card` has already ended the runs, so recovery
3186
- cannot see them and neither take can start them. */
3187
- await killStopped(current(), machineId);
3188
- await reconcileOwnerSessions(current(), machineId, machineHarness, new Set(inFlight.keys()));
3189
- await recoverStranded(current(), tools, machineId, new Set(inFlight.keys()), hold);
3190
- /* ═══ AND THE COPIES OF CARDS THAT ARE OVER. ═══ After recovery,
3191
- deliberately: a run this machine is about to resume is one whose card is
3192
- not finished, and the sweep asks the record after recovery has had its
3193
- say about what is really still running. */
3194
- await sweepFinishedWorktrees(current());
3195
- /* ═══ AND THEN WHAT SOMEBODY ELSE'S MACHINE WAS HOLDING, IF A PERSON HANDED
3196
- IT BACK. ═══ AFTER the sweep, deliberately: this machine settles its own
3197
- runs on local, pid-accurate evidence before it looks at anybody's, and a
3198
- run of its own that was offered while it was quiet is dealt with there on
3199
- the better evidence: resumed if its process is really gone, which spends
3200
- the offer, and skipped if it is not, which is what `mine` then honours
3201
- here. The set is rebuilt rather than reused because the sweep adds to it.
3202
- BEFORE both takes, for the reason the sweep is: work that already exists,
3203
- with a brief and a report behind it, comes before work that has not
3204
- started. */
3205
- await takeHandedBack(current(), tools, machineId, new Set(inFlight.keys()), hold);
3206
- /* Every reason a conversational owner may continue is claimed together.
3207
- The legacy turn and re-arm takes exclude named owners in the database. */
3208
- await takeOwnerActivations(current(), tools, machineId, new Set(inFlight.keys()), hold);
3209
- /* ═══ AND IF THE DESIGNATION NAMES A HARNESS THIS DAEMON IS NOT, IT SAYS
3210
- SO BEFORE TAKING NOTHING. ═══
3211
- `panel3_take_turns` and `panel3_take_rearms` compare `p_agent` against
3212
- `cliv2_orchestrator_preference` in SQL, and a mismatch is not an error
3213
- there — it is zero rows, which is correct and which is also exactly what
3214
- a quiet machine with no work waiting looks like. Proven on the record
3215
- (.bugs/.resolved/20260826-daemon-ignores-the-harness-switch): with the
3216
- designation on `codex` and this daemon on `claude`, a waiting turn was
3217
- never claimed for over two minutes, the daemon printed nothing, and the
3218
- card sat under `Working` with a healthy machine online.
3219
-
3220
- `harness()` is read once at startup and cannot change under a running
3221
- process (see `machineHarness` above), while the designation is a chip a
3222
- person clicks in the app at any moment. So the two CAN disagree, and
3223
- ux.md's forbidden state — "nothing will act on it and nothing on screen
3224
- says so" — is reached the moment they do. This is the daemon's own
3225
- screen saying so. */
3226
- await reportHarnessMismatch(current(), machineId, machineHarness);
3227
- /* THE MACHINE ID GOES IN because the take writes the run row, and a run has
3228
- to say where it is running: the exclusion is cross-machine and recovery is
3229
- per-machine, so a row with nobody's machine on it could be neither. */
3230
- const taken = await returned(current().rpc('panel3_take_turns', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'turns');
3231
- /* ═══ THE OTHER KIND OF TAKEABLE WORK. ═══ ux.md's re-arm: an answered
3232
- question makes the branch that asked it takeable again, and a question
3233
- still walking up makes the run it reached takeable so that level gets its
3234
- turn. Same poll, same machine id, same holding: only the reason a run is
3235
- started differs, and the record decides that rather than this file.
3079
+ try {
3080
+ while (!signal?.aborted) {
3081
+ // Restart only at a poll boundary, before any claims, with no live work.
3082
+ if (lifecycle && !lifecycle.beforePoll(inFlight.size))
3083
+ break;
3084
+ /* ═══ ONE POLL FAILING IS NOT THE DAEMON FAILING. ═══ Every read and write
3085
+ here throws on a network or database error, by design (constraint 7), and
3086
+ until Slice 4 that threw straight out of `panel3/cli.js run` and exited the process.
3087
+ Inside `cs start` that is no longer an honest outcome twice over: it would
3088
+ take the v2 presence heartbeat down with it, and it would leave a stranded
3089
+ card printing `cs start` at a person who IS running `cs start`, which is
3090
+ the exact lie this slice exists to end. A dropped connection is a poll
3091
+ that did not happen; the next one is two seconds away.
3236
3092
 
3237
- AFTER THE TURNS ARE TAKEN, deliberately, because the take WRITES the run
3238
- row it leases to. A person who answered and then typed something else has
3239
- both waiting, and in this order the message's level 1 is already on the
3240
- record, so the re-arm leaves that card's coordinator alone and comes back
3241
- to it on a later poll rather than putting two of them on one card. The
3242
- other order would decide the same question from a snapshot taken before
3243
- the run existed. */
3244
- const rearmed = await returned(current().rpc('panel3_take_rearms', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'runs that can carry on');
3245
- for (const row of rearmed) {
3246
- /* NOT AWAITED PAST THE SPAWN, exactly as a taken card is not: the work is a
3247
- real agent and holding the poll open for it would put every other card
3248
- behind it. A throw here is caught rather than taking the loop down,
3249
- because one run that could not be started must not stop the others. */
3093
+ `--once` still fails loudly, because the acceptance harness reads the exit
3094
+ code and a swallowed failure there would make a broken suite look green. */
3095
+ try {
3096
+ /* ═══ THE USER'S STOP IS HONOURED BEFORE ANYTHING ELSE ON THE POLL. ═══ It
3097
+ is the only thing here that a person is waiting on, and the two takes
3098
+ below can spend the rest of the poll starting agents. Nothing else needs
3099
+ to run first: `panel3_stop_card` has already ended the runs, so recovery
3100
+ cannot see them and neither take can start them. */
3101
+ await killStopped(current(), machineId);
3102
+ /* Publish readiness before claiming work. A card with an untaken turn reads the same whether a daemon
3103
+ is two seconds away or nobody has one running; this row is the only place
3104
+ the difference exists. It is written before the takes rather than after
3105
+ so that a machine which is up but busy still reads as up. */
3106
+ // Resolve before claiming work. A failed read or missing binary must not
3107
+ // leave the previous agent advertised as ready for new assignments.
3108
+ let machineHarness;
3250
3109
  try {
3251
- hold(row.run_id, (await startRearmed(current(), tools, machineId, row)).settled);
3110
+ machineHarness = await selectedHarness(current(), machineId);
3252
3111
  }
3253
3112
  catch (error) {
3254
- said(`could not start run ${row.run_id} again: ${error instanceof Error ? error.message : String(error)}`);
3113
+ if (listeningHarness !== null) {
3114
+ await stopListening(current(), machineId, listeningHarness);
3115
+ listeningHarness = null;
3116
+ }
3117
+ throw error;
3118
+ }
3119
+ if (signal?.aborted)
3120
+ break;
3121
+ if (listeningHarness !== null && listeningHarness !== machineHarness) {
3122
+ await stopListening(current(), machineId, listeningHarness);
3123
+ }
3124
+ listeningHarness = machineHarness;
3125
+ await sayListening(current(), machineId, machineName, machineHarness);
3126
+ await reconcileOwnerSessions(current(), machineId, machineHarness, new Set(inFlight.keys()));
3127
+ await recoverStranded(current(), tools, machineId, new Set(inFlight.keys()), hold);
3128
+ /* ═══ AND THE COPIES OF CARDS THAT ARE OVER. ═══ After recovery,
3129
+ deliberately: a run this machine is about to resume is one whose card is
3130
+ not finished, and the sweep asks the record after recovery has had its
3131
+ say about what is really still running. */
3132
+ await sweepFinishedWorktrees(current());
3133
+ /* ═══ AND THEN WHAT SOMEBODY ELSE'S MACHINE WAS HOLDING, IF A PERSON HANDED
3134
+ IT BACK. ═══ AFTER the sweep, deliberately: this machine settles its own
3135
+ runs on local, pid-accurate evidence before it looks at anybody's, and a
3136
+ run of its own that was offered while it was quiet is dealt with there on
3137
+ the better evidence: resumed if its process is really gone, which spends
3138
+ the offer, and skipped if it is not, which is what `mine` then honours
3139
+ here. The set is rebuilt rather than reused because the sweep adds to it.
3140
+ BEFORE both takes, for the reason the sweep is: work that already exists,
3141
+ with a brief and a report behind it, comes before work that has not
3142
+ started. */
3143
+ await takeHandedBack(current(), tools, machineId, new Set(inFlight.keys()), hold);
3144
+ /* Every reason a conversational owner may continue is claimed together.
3145
+ The legacy turn and re-arm takes exclude named owners in the database. */
3146
+ await takeOwnerActivations(current(), tools, machineId, new Set(inFlight.keys()), hold);
3147
+ if (signal?.aborted)
3148
+ break;
3149
+ /* THE MACHINE ID GOES IN because the take writes the run row, and a run has
3150
+ to say where it is running: the exclusion is cross-machine and recovery is
3151
+ per-machine, so a row with nobody's machine on it could be neither. */
3152
+ const taken = await returned(current().rpc('panel3_take_turns', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'turns');
3153
+ /* ═══ THE OTHER KIND OF TAKEABLE WORK. ═══ ux.md's re-arm: an answered
3154
+ question makes the branch that asked it takeable again, and a question
3155
+ still walking up makes the run it reached takeable so that level gets its
3156
+ turn. Same poll, same machine id, same holding: only the reason a run is
3157
+ started differs, and the record decides that rather than this file.
3158
+ *
3159
+ AFTER THE TURNS ARE TAKEN, deliberately, because the take WRITES the run
3160
+ row it leases to. A person who answered and then typed something else has
3161
+ both waiting, and in this order the message's level 1 is already on the
3162
+ record, so the re-arm leaves that card's coordinator alone and comes back
3163
+ to it on a later poll rather than putting two of them on one card. The
3164
+ other order would decide the same question from a snapshot taken before
3165
+ the run existed. */
3166
+ const rearmed = await returned(current().rpc('panel3_take_rearms', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'runs that can carry on');
3167
+ for (const row of rearmed) {
3168
+ /* NOT AWAITED PAST THE SPAWN, exactly as a taken card is not: the work is a
3169
+ real agent and holding the poll open for it would put every other card
3170
+ behind it. A throw here is caught rather than taking the loop down,
3171
+ because one run that could not be started must not stop the others. */
3172
+ try {
3173
+ hold(row.run_id, (await startRearmed(current(), tools, machineId, row)).settled);
3174
+ }
3175
+ catch (error) {
3176
+ said(`could not start run ${row.run_id} again: ${error instanceof Error ? error.message : String(error)}`);
3177
+ }
3178
+ }
3179
+ for (const [cardId, turns] of byCard(taken)) {
3180
+ const runId = turns[0].run_id;
3181
+ out(`took card ${cardId} ${turns.length} turn${turns.length === 1 ? '' : 's'} run ${runId}`);
3182
+ /* NOT AWAITED HERE. A run is a real agent doing real work, and awaiting it
3183
+ in the poll would put every other card behind it — v2's own measured
3184
+ defect (env.ts, I21/I24: "a second request typed seconds after the first
3185
+ sat untouched until the first finished"). Nothing is needed to stop the
3186
+ same card being taken twice, because that is the database's job and it
3187
+ has already been done by the take. */
3188
+ // The catch inside `hold` is the last resort. A run left `running` by a
3189
+ // throw is not lost: its process has exited, so the next sweep reaps it
3190
+ // and the turns are answered again. It is there so the reason is printed
3191
+ // rather than becoming an unhandled rejection.
3192
+ hold(runId, answerCard(current(), tools, machineId, cardId, turns));
3193
+ }
3194
+ await sayPollingProblem(current(), machineId, machineHarness, null);
3195
+ lifecycle?.ready();
3196
+ if (once) {
3197
+ /* ═══ UNTIL NOTHING IS LEFT, NOT ONCE OVER WHAT WAS THERE. ═══ A level 1
3198
+ run dispatches WHILE it is being waited on, so the child appears in
3199
+ `inFlight` after the first wait started. Closing then would kill the
3200
+ tools server under an agent that had just been started, and `--once`
3201
+ would report a card answered while the work on it was still going. */
3202
+ while (inFlight.size > 0)
3203
+ await Promise.all([...inFlight.values()]);
3204
+ return;
3255
3205
  }
3256
3206
  }
3257
- for (const [cardId, turns] of byCard(taken)) {
3258
- const runId = turns[0].run_id;
3259
- out(`took card ${cardId} ${turns.length} turn${turns.length === 1 ? '' : 's'} run ${runId}`);
3260
- /* NOT AWAITED HERE. A run is a real agent doing real work, and awaiting it
3261
- in the poll would put every other card behind it — v2's own measured
3262
- defect (env.ts, I21/I24: "a second request typed seconds after the first
3263
- sat untouched until the first finished"). Nothing is needed to stop the
3264
- same card being taken twice, because that is the database's job and it
3265
- has already been done by the take. */
3266
- // The catch inside `hold` is the last resort. A run left `running` by a
3267
- // throw is not lost: its process has exited, so the next sweep reaps it
3268
- // and the turns are answered again. It is there so the reason is printed
3269
- // rather than becoming an unhandled rejection.
3270
- hold(runId, answerCard(current(), tools, machineId, cardId, turns));
3271
- }
3272
- if (once) {
3273
- /* ═══ UNTIL NOTHING IS LEFT, NOT ONCE OVER WHAT WAS THERE. ═══ A level 1
3274
- run dispatches WHILE it is being waited on, so the child appears in
3275
- `inFlight` after the first wait started. Closing then would kill the
3276
- tools server under an agent that had just been started, and `--once`
3277
- would report a card answered while the work on it was still going. */
3278
- while (inFlight.size > 0)
3279
- await Promise.all([...inFlight.values()]);
3280
- // The listener would otherwise keep this process alive after its one poll
3281
- // was done, which is the whole point of `--once` being testable.
3282
- await tools.close();
3283
- // This machine is not listening any more, and it knows that here rather
3284
- // than fifteen seconds from now. Same reason as the signal handlers above.
3285
- await stopListening(current(), machineId, machineHarness);
3286
- return;
3207
+ catch (error) {
3208
+ if (once)
3209
+ throw error;
3210
+ said(`this poll did not finish: ${error instanceof Error ? error.message : String(error)}`);
3211
+ if (listeningHarness !== null) {
3212
+ try {
3213
+ await sayPollingProblem(current(), machineId, listeningHarness, 'poll_failed');
3214
+ }
3215
+ catch (reportError) {
3216
+ said(`worker health could not be reported: ${reportError instanceof Error ? reportError.message : String(reportError)}`);
3217
+ }
3218
+ }
3287
3219
  }
3220
+ if (!signal?.aborted)
3221
+ await sleep(POLL_INTERVAL_MS);
3288
3222
  }
3289
- catch (error) {
3290
- if (once)
3291
- throw error;
3292
- said(`this poll did not finish: ${error instanceof Error ? error.message : String(error)}`);
3223
+ }
3224
+ finally {
3225
+ try {
3226
+ if (listeningHarness !== null)
3227
+ await stopListening(current(), machineId, listeningHarness);
3228
+ }
3229
+ finally {
3230
+ await tools.close();
3293
3231
  }
3294
- await sleep(POLL_INTERVAL_MS);
3295
3232
  }
3296
3233
  }
3297
3234
  // ---------------------------------------------------------------------------
3298
- /**
3299
- * ═══ THE ONE THING `cs start` REACHES INTO `panel3/` FOR. ═══
3300
- *
3301
- * recovery-1 Slice 4, and Lane's ruling behind it (2026-08-21): *the user
3302
- * experience must never require them to launch or authenticate multiple CLIs.
3303
- * They launch the CLI with `cs start`.* Slices 1 to 3 print `cs start` on a
3304
- * stranded card and, until this function existed, that command started a daemon
3305
- * which polled no cards at all — the sentence named the right machine and the
3306
- * wrong command, and no rewording could fix it.
3307
- *
3308
- * ═══ IT IS A NAMED EXCEPTION TO THE ISOLATION RULE, NOT A HOLE IN IT. ═══
3309
- * `conventions.md` forbids anything outside `panel3/` importing anything inside
3310
- * it, and `panel3-isolation.contract.test.mjs` enforces it. `cli-v2/src/daemon.ts`
3311
- * is listed there with exactly ONE allowed specifier, `./panel3/run.js`, which is
3312
- * why this is a single entry point handing back a single closure rather than
3313
- * three exports the daemon would have to assemble. A second specifier fails the
3314
- * suite, which is the point: the rule is enumerated, never weakened.
3315
- *
3316
- * ═══ THE CLIENT IS THE CALLER'S, AND THAT IS THE WHOLE OF "ONE SIGN-IN". ═══
3317
- * `startPresence()` owns the client it built from the session `cs login` stored.
3318
- * This must never take one of its own: two clients in one process is two refresh
3319
- * loops on one rotating refresh token, both writing `session.json` through
3320
- * `getClient`'s `onAuthStateChange`, which is the hazard `client.ts` describes
3321
- * moved indoors.
3322
- *
3323
- * ═══ AND IT IS READ RATHER THAN HANDED OVER, BECAUSE PRESENCE REPLACES IT. ═══
3324
- * A `SupabaseClient` is still accepted, for the harness and for any caller
3325
- * holding exactly one. `cs start` passes a function instead, because presence's
3326
- * heartbeat rebuilds its client when the account on disk changes, and the panel
3327
- * holding the pre-rebuild one is the largest single share of the poll storm this
3328
- * widening closes. `stop` reads it through the same getter for the same reason:
3329
- * it is the last write this process makes, minutes or days after the getter was
3330
- * handed over, and a goodbye written with a refused token leaves the card saying
3331
- * an agent is working.
3332
- *
3333
- * ═══ IT DOES NOT BLOCK, AND IT DOES NOT TAKE THE DAEMON DOWN. ═══ The loop
3334
- * never returns, so awaiting it here would hang `cs start` before it printed a
3335
- * line. A throw that escapes the per-poll guard inside it is the panel stopping,
3336
- * which is said on the one stream a person reads and leaves v2 presence
3337
- * heartbeating rather than killing the process around it.
3338
- */
3235
+ /** Shared presence owns this supervisor and supplies its current authenticated
3236
+ * client. A stopped worker is restarted automatically. Explicit restarts happen
3237
+ * at an idle poll boundary and confirm only after a new worker completes a poll.
3238
+ * Sign-out stops the supervisor, so it cannot restart behind the user's back. */
3339
3239
  export function startPanel(injected) {
3340
- const current = clientReader(injected);
3341
- /* Both resolved HERE as well as inside the loop, deliberately: `stop` has to
3342
- name the same row the loop's heartbeat writes, and it must be able to do
3343
- that after the loop has thrown. They are the two facts about this machine
3344
- that cannot change under a running process — the hardware-derived id and
3345
- `CTRL_SPC_V3_AGENT` so reading them twice cannot disagree. */
3346
- const machineId = getMachineIdentity().id;
3347
- const machineHarness = harness();
3348
- /* THE GETTER, NOT `current()`. Passing the read itself is what keeps ONE
3349
- normalisation in this process: `run` calls `clientReader` on whatever it is
3350
- given, a getter passes through unchanged, and the loop reads the same live
3351
- client `stop` does. Passing `current()` here would resolve it once and hand
3352
- the loop the very snapshot this parameter was widened to stop producing. */
3353
- void run([], current).catch((error) => {
3354
- said(`the agent panel stopped polling: ${error instanceof Error ? error.message : String(error)}`);
3355
- });
3356
- /* `stopListening` is already best-effort and swallows its own failure: a
3357
- process on its way out must not hang or fail, and the freshness window
3358
- covers every way a machine can leave without getting here. */
3359
- return { stop: () => stopListening(current(), machineId, machineHarness) };
3240
+ const controller = new AbortController();
3241
+ let requested = false;
3242
+ let restarting = null;
3243
+ let resolveRestart = null;
3244
+ let rejectRestart = null;
3245
+ const running = (async () => {
3246
+ while (!controller.signal.aborted) {
3247
+ try {
3248
+ await run([], injected, controller.signal, {
3249
+ beforePoll: (active) => {
3250
+ if (!requested)
3251
+ return true;
3252
+ requested = false;
3253
+ if (active > 0) {
3254
+ rejectRestart?.(new Error('Work is still running on this machine. Stop the affected cards before restarting the worker.'));
3255
+ return true;
3256
+ }
3257
+ return false;
3258
+ },
3259
+ ready: () => { if (!requested)
3260
+ resolveRestart?.(); },
3261
+ });
3262
+ }
3263
+ catch (error) {
3264
+ said(`the agent panel stopped polling: ${error instanceof Error ? error.message : String(error)}`);
3265
+ // Startup failures must not leave an online machine with a dead worker.
3266
+ if (!controller.signal.aborted)
3267
+ await sleep(POLL_INTERVAL_MS);
3268
+ }
3269
+ }
3270
+ })();
3271
+ return {
3272
+ restart: () => {
3273
+ if (controller.signal.aborted)
3274
+ return Promise.reject(new Error('This machine is signing out. Open Companion and sign in again.'));
3275
+ if (restarting)
3276
+ return restarting;
3277
+ requested = true;
3278
+ let timeout;
3279
+ restarting = new Promise((resolve, reject) => {
3280
+ resolveRestart = resolve;
3281
+ rejectRestart = reject;
3282
+ timeout = setTimeout(() => reject(new Error('The worker did not reconnect. Open Companion on this machine, then try again.')), 20_000);
3283
+ }).finally(() => {
3284
+ clearTimeout(timeout);
3285
+ requested = false;
3286
+ restarting = null;
3287
+ resolveRestart = null;
3288
+ rejectRestart = null;
3289
+ });
3290
+ return restarting;
3291
+ },
3292
+ stop: async () => {
3293
+ controller.abort();
3294
+ rejectRestart?.(new Error('The machine disconnected before the worker restarted.'));
3295
+ await running;
3296
+ },
3297
+ };
3360
3298
  }