@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.
- package/dist/codebases.js +26 -0
- package/dist/panel3/checkout.js +9 -32
- package/dist/panel3/codex-models.js +96 -0
- package/dist/panel3/coordinator.js +15 -0
- package/dist/panel3/prompt.js +28 -8
- package/dist/panel3/run.js +163 -253
- package/dist/panel3/spawn.js +24 -15
- package/dist/panel3/tools.js +115 -32
- package/dist/workflow-tool-mentions.js +1 -0
- package/dist/workflows.js +7 -6
- package/package.json +2 -2
package/dist/panel3/run.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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`,
|
|
1016
|
-
*
|
|
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
|
-
/*
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
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
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
/*
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
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
|
-
|
|
3107
|
+
machineHarness = await selectedHarness(current(), machineId);
|
|
3252
3108
|
}
|
|
3253
3109
|
catch (error) {
|
|
3254
|
-
|
|
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
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
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
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
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
|
|
3341
|
-
|
|
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
|
-
|
|
3357
|
-
|
|
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
|
}
|
package/dist/panel3/spawn.js
CHANGED
|
@@ -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
|
|
562
|
-
finish(
|
|
563
|
-
|
|
564
|
-
|
|
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
|