@ctrl-spc/cs 0.7.11 → 0.7.12

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.
@@ -137,7 +137,7 @@ import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt,
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
139
  import { sayListening, stopListening } from './presence.js';
140
- import { designatedCoordinator } from './coordinator.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) {
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,138 +3076,146 @@ 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
+ /* ═══ ONE POLL FAILING IS NOT THE DAEMON FAILING. ═══ Every read and write
3082
+ here throws on a network or database error, by design (constraint 7), and
3083
+ until Slice 4 that threw straight out of `panel3/cli.js run` and exited the process.
3084
+ Inside `cs start` that is no longer an honest outcome twice over: it would
3085
+ take the v2 presence heartbeat down with it, and it would leave a stranded
3086
+ card printing `cs start` at a person who IS running `cs start`, which is
3087
+ the exact lie this slice exists to end. A dropped connection is a poll
3088
+ that did not happen; the next one is two seconds away.
3236
3089
 
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. */
3090
+ `--once` still fails loudly, because the acceptance harness reads the exit
3091
+ code and a swallowed failure there would make a broken suite look green. */
3092
+ try {
3093
+ /* ═══ THE USER'S STOP IS HONOURED BEFORE ANYTHING ELSE ON THE POLL. ═══ It
3094
+ is the only thing here that a person is waiting on, and the two takes
3095
+ below can spend the rest of the poll starting agents. Nothing else needs
3096
+ to run first: `panel3_stop_card` has already ended the runs, so recovery
3097
+ cannot see them and neither take can start them. */
3098
+ await killStopped(current(), machineId);
3099
+ /* Publish readiness before claiming work. A card with an untaken turn reads the same whether a daemon
3100
+ is two seconds away or nobody has one running; this row is the only place
3101
+ the difference exists. It is written before the takes rather than after
3102
+ so that a machine which is up but busy still reads as up. */
3103
+ // Resolve before claiming work. A failed read or missing binary must not
3104
+ // leave the previous agent advertised as ready for new assignments.
3105
+ let machineHarness;
3250
3106
  try {
3251
- hold(row.run_id, (await startRearmed(current(), tools, machineId, row)).settled);
3107
+ machineHarness = await selectedHarness(current(), machineId);
3252
3108
  }
3253
3109
  catch (error) {
3254
- said(`could not start run ${row.run_id} again: ${error instanceof Error ? error.message : String(error)}`);
3110
+ if (listeningHarness !== null) {
3111
+ await stopListening(current(), machineId, listeningHarness);
3112
+ listeningHarness = null;
3113
+ }
3114
+ throw error;
3115
+ }
3116
+ if (signal?.aborted)
3117
+ break;
3118
+ if (listeningHarness !== null && listeningHarness !== machineHarness) {
3119
+ await stopListening(current(), machineId, listeningHarness);
3120
+ }
3121
+ listeningHarness = machineHarness;
3122
+ await sayListening(current(), machineId, machineName, machineHarness);
3123
+ await reconcileOwnerSessions(current(), machineId, machineHarness, new Set(inFlight.keys()));
3124
+ await recoverStranded(current(), tools, machineId, new Set(inFlight.keys()), hold);
3125
+ /* ═══ AND THE COPIES OF CARDS THAT ARE OVER. ═══ After recovery,
3126
+ deliberately: a run this machine is about to resume is one whose card is
3127
+ not finished, and the sweep asks the record after recovery has had its
3128
+ say about what is really still running. */
3129
+ await sweepFinishedWorktrees(current());
3130
+ /* ═══ AND THEN WHAT SOMEBODY ELSE'S MACHINE WAS HOLDING, IF A PERSON HANDED
3131
+ IT BACK. ═══ AFTER the sweep, deliberately: this machine settles its own
3132
+ runs on local, pid-accurate evidence before it looks at anybody's, and a
3133
+ run of its own that was offered while it was quiet is dealt with there on
3134
+ the better evidence: resumed if its process is really gone, which spends
3135
+ the offer, and skipped if it is not, which is what `mine` then honours
3136
+ here. The set is rebuilt rather than reused because the sweep adds to it.
3137
+ BEFORE both takes, for the reason the sweep is: work that already exists,
3138
+ with a brief and a report behind it, comes before work that has not
3139
+ started. */
3140
+ await takeHandedBack(current(), tools, machineId, new Set(inFlight.keys()), hold);
3141
+ /* Every reason a conversational owner may continue is claimed together.
3142
+ The legacy turn and re-arm takes exclude named owners in the database. */
3143
+ await takeOwnerActivations(current(), tools, machineId, new Set(inFlight.keys()), hold);
3144
+ if (signal?.aborted)
3145
+ break;
3146
+ /* THE MACHINE ID GOES IN because the take writes the run row, and a run has
3147
+ to say where it is running: the exclusion is cross-machine and recovery is
3148
+ per-machine, so a row with nobody's machine on it could be neither. */
3149
+ const taken = await returned(current().rpc('panel3_take_turns', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'turns');
3150
+ /* ═══ THE OTHER KIND OF TAKEABLE WORK. ═══ ux.md's re-arm: an answered
3151
+ question makes the branch that asked it takeable again, and a question
3152
+ still walking up makes the run it reached takeable so that level gets its
3153
+ turn. Same poll, same machine id, same holding: only the reason a run is
3154
+ started differs, and the record decides that rather than this file.
3155
+ *
3156
+ AFTER THE TURNS ARE TAKEN, deliberately, because the take WRITES the run
3157
+ row it leases to. A person who answered and then typed something else has
3158
+ both waiting, and in this order the message's level 1 is already on the
3159
+ record, so the re-arm leaves that card's coordinator alone and comes back
3160
+ to it on a later poll rather than putting two of them on one card. The
3161
+ other order would decide the same question from a snapshot taken before
3162
+ the run existed. */
3163
+ const rearmed = await returned(current().rpc('panel3_take_rearms', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'runs that can carry on');
3164
+ for (const row of rearmed) {
3165
+ /* NOT AWAITED PAST THE SPAWN, exactly as a taken card is not: the work is a
3166
+ real agent and holding the poll open for it would put every other card
3167
+ behind it. A throw here is caught rather than taking the loop down,
3168
+ because one run that could not be started must not stop the others. */
3169
+ try {
3170
+ hold(row.run_id, (await startRearmed(current(), tools, machineId, row)).settled);
3171
+ }
3172
+ catch (error) {
3173
+ said(`could not start run ${row.run_id} again: ${error instanceof Error ? error.message : String(error)}`);
3174
+ }
3175
+ }
3176
+ for (const [cardId, turns] of byCard(taken)) {
3177
+ const runId = turns[0].run_id;
3178
+ out(`took card ${cardId} ${turns.length} turn${turns.length === 1 ? '' : 's'} run ${runId}`);
3179
+ /* NOT AWAITED HERE. A run is a real agent doing real work, and awaiting it
3180
+ in the poll would put every other card behind it — v2's own measured
3181
+ defect (env.ts, I21/I24: "a second request typed seconds after the first
3182
+ sat untouched until the first finished"). Nothing is needed to stop the
3183
+ same card being taken twice, because that is the database's job and it
3184
+ has already been done by the take. */
3185
+ // The catch inside `hold` is the last resort. A run left `running` by a
3186
+ // throw is not lost: its process has exited, so the next sweep reaps it
3187
+ // and the turns are answered again. It is there so the reason is printed
3188
+ // rather than becoming an unhandled rejection.
3189
+ hold(runId, answerCard(current(), tools, machineId, cardId, turns));
3190
+ }
3191
+ if (once) {
3192
+ /* ═══ UNTIL NOTHING IS LEFT, NOT ONCE OVER WHAT WAS THERE. ═══ A level 1
3193
+ run dispatches WHILE it is being waited on, so the child appears in
3194
+ `inFlight` after the first wait started. Closing then would kill the
3195
+ tools server under an agent that had just been started, and `--once`
3196
+ would report a card answered while the work on it was still going. */
3197
+ while (inFlight.size > 0)
3198
+ await Promise.all([...inFlight.values()]);
3199
+ return;
3255
3200
  }
3256
3201
  }
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;
3202
+ catch (error) {
3203
+ if (once)
3204
+ throw error;
3205
+ said(`this poll did not finish: ${error instanceof Error ? error.message : String(error)}`);
3287
3206
  }
3207
+ if (!signal?.aborted)
3208
+ await sleep(POLL_INTERVAL_MS);
3288
3209
  }
3289
- catch (error) {
3290
- if (once)
3291
- throw error;
3292
- said(`this poll did not finish: ${error instanceof Error ? error.message : String(error)}`);
3210
+ }
3211
+ finally {
3212
+ try {
3213
+ if (listeningHarness !== null)
3214
+ await stopListening(current(), machineId, listeningHarness);
3215
+ }
3216
+ finally {
3217
+ await tools.close();
3293
3218
  }
3294
- await sleep(POLL_INTERVAL_MS);
3295
3219
  }
3296
3220
  }
3297
3221
  // ---------------------------------------------------------------------------
@@ -3337,24 +3261,10 @@ export async function run(args, injected) {
3337
3261
  * heartbeating rather than killing the process around it.
3338
3262
  */
3339
3263
  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) => {
3264
+ const controller = new AbortController();
3265
+ const running = run([], injected, controller.signal).catch((error) => {
3354
3266
  said(`the agent panel stopped polling: ${error instanceof Error ? error.message : String(error)}`);
3355
3267
  });
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) };
3268
+ // The loop owns its current presence row, including shutdown after a switch.
3269
+ return { stop: async () => { controller.abort(); await running; } };
3360
3270
  }
@@ -322,6 +322,15 @@ export function codexAnswer(stdout, exitCode = 0, stderr = '') {
322
322
  }
323
323
  }
324
324
  if (failure !== null) {
325
+ // Codex wraps provider errors in turn.failed.error.message. A rejected
326
+ // request cannot recover by sending the same model and effort again.
327
+ try {
328
+ const provider = JSON.parse(failure);
329
+ if (provider?.status === 400 && provider?.error?.type === 'invalid_request_error') {
330
+ return { ok: false, reason: `codex could not finish the turn: ${provider.error.message ?? failure}`, retryable: false };
331
+ }
332
+ }
333
+ catch { /* Ordinary native failure text keeps the existing recovery policy. */ }
325
334
  return { ok: false, reason: `codex could not finish the turn${failure ? `: ${failure}` : ''}` };
326
335
  }
327
336
  if (exitCode !== 0) {
@@ -332,6 +341,17 @@ export function codexAnswer(stdout, exitCode = 0, stderr = '') {
332
341
  }
333
342
  return { ok: true, text: text.trim() };
334
343
  }
344
+ /** Claude prints provider/model rejections on stdout, even with a nonzero exit. */
345
+ export function claudeAnswer(stdout, exitCode = 0, stderr = '') {
346
+ if (/^\[claude-code:unrecognized_model\]/m.test(stdout + '\n' + stderr)) {
347
+ return { ok: false, retryable: false, reason: 'Claude rejected the selected model. Choose an available model and retry.' };
348
+ }
349
+ if (exitCode !== 0)
350
+ return { ok: false, reason: `claude exited ${exitCode}${tail(stderr) || tail(stdout)}` };
351
+ if (stdout.trim() === '')
352
+ return { ok: false, reason: `claude exited 0 and said nothing${tail(stderr)}` };
353
+ return { ok: true, text: stdout.trim() };
354
+ }
335
355
  /** Enough for any answer a person reads, and a ceiling so a runaway process
336
356
  * cannot exhaust this daemon's memory. */
337
357
  const MAX_OUTPUT_CHARS = 1_000_000;
@@ -547,22 +567,11 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
547
567
  // 'Reading prompt from stdin' on stderr, including on non-zero exits.
548
568
  finish(codexAnswer(stdout, code ?? 1, stderr));
549
569
  }
550
- else if (code !== 0) {
551
- /* STDOUT WHEN STDERR IS EMPTY, because `claude -p` prints its own
552
- failure on stdout and exits non-zero having written nothing to
553
- stderr. Reporting only "exited 1" would throw away the one sentence
554
- that says what went wrong. */
555
- finish({ ok: false, reason: `${agent} exited ${code}${tail(stderr) || tail(stdout)}` });
556
- }
557
- else if (stdout.trim() === '') {
558
- finish({ ok: false, reason: `${agent} exited 0 and said nothing${tail(stderr)}` });
559
- }
560
570
  else {
561
- const text = stdout.trim();
562
- finish({
563
- ok: true,
564
- text: truncated ? `${text}\n\n[cut off at ${MAX_OUTPUT_CHARS} characters]` : text,
565
- });
571
+ const answer = claudeAnswer(stdout, code ?? 1, stderr);
572
+ finish(answer.ok && truncated
573
+ ? { ...answer, text: `${answer.text}\n\n[cut off at ${MAX_OUTPUT_CHARS} characters]` }
574
+ : answer);
566
575
  }
567
576
  });
568
577
  /* THE PROMPT, AND THEN END OF INPUT, because `claude -p` reads stdin to the