cawdev-cli 1.0.5-beta → 1.0.7-beta
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/lib/agents.mjs +38 -0
- package/lib/background-shells.mjs +108 -0
- package/lib/codex.mjs +5 -0
- package/lib/roadmap-format.mjs +52 -0
- package/mcp/server.mjs +62 -11
- package/package.json +1 -1
- package/runner/README.md +191 -22
- package/runner/attach.mjs +708 -69
- package/runner/cawdev.mjs +17 -2
- package/runner/configure.mjs +34 -0
- package/runner/input.mjs +26 -9
- package/runner/prefs.mjs +52 -0
- package/runner/restart.mjs +191 -0
- package/runner/runner.mjs +323 -27
- package/runner/sign-in.mjs +11 -0
- package/runner/split.mjs +109 -16
package/runner/runner.mjs
CHANGED
|
@@ -40,13 +40,18 @@ import {
|
|
|
40
40
|
} from '../lib/stage-tools.mjs';
|
|
41
41
|
import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
|
|
42
42
|
import { findSecret } from '../lib/secrets.mjs';
|
|
43
|
+
import { BackgroundShells } from '../lib/background-shells.mjs';
|
|
43
44
|
import { cliVersion, isBelow } from '../lib/version.mjs';
|
|
44
45
|
import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
|
|
45
46
|
import {
|
|
46
47
|
createAutoWorkspace, discoverAutoWorkspaces, isAutoWorkspace,
|
|
47
48
|
} from './auto-workspaces.mjs';
|
|
48
|
-
import { agentOfCommand } from '../lib/agents.mjs';
|
|
49
|
+
import { agentOfCommand, needsEverything } from '../lib/agents.mjs';
|
|
49
50
|
import { cawdevToolNames, codexArgs, translateCodexEvent } from '../lib/codex.mjs';
|
|
51
|
+
import {
|
|
52
|
+
drivingPath, isAlive, killedByRestart, leftBehind, memoryMaxOf, probeScope, readDriving,
|
|
53
|
+
restartSummary, scoped, snapshotOf, writeDriving,
|
|
54
|
+
} from './restart.mjs';
|
|
50
55
|
|
|
51
56
|
// --- configuration -----------------------------------------------------------
|
|
52
57
|
|
|
@@ -419,6 +424,12 @@ async function readConfig() {
|
|
|
419
424
|
* switched on, never on by default.
|
|
420
425
|
*/
|
|
421
426
|
autoWorkspaces: autoWorkspacesOf(file.autoWorkspaces),
|
|
427
|
+
/**
|
|
428
|
+
* R339: the memory ceiling each agent session is spawned under, as
|
|
429
|
+
* systemd reads it (`6G`, `75%`) — Linux only, see restart.mjs. Absent is
|
|
430
|
+
* none. Raw here; `main` probes whether this machine can apply it.
|
|
431
|
+
*/
|
|
432
|
+
sessionMemoryMax: file.sessionMemoryMax ?? null,
|
|
422
433
|
skillCache: file.skillCache ?? DEFAULTS.skillCache,
|
|
423
434
|
skillPrepareSeconds: file.skillPrepareSeconds ?? DEFAULTS.skillPrepareSeconds,
|
|
424
435
|
idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
|
|
@@ -5182,21 +5193,60 @@ function argsForProfile(agentArgs, profile, run, expertAgents = [], skills = [])
|
|
|
5182
5193
|
* re-derived the plan from a précis would be planning again, which is the one
|
|
5183
5194
|
* thing the gate exists to stop it doing after somebody approved the first one.
|
|
5184
5195
|
*/
|
|
5185
|
-
|
|
5196
|
+
/** Longer than ask_user's own ten-minute wait — see the cawdev server of spawnCodex. */
|
|
5197
|
+
const CAWDEV_TOOL_TIMEOUT_SEC = 660;
|
|
5198
|
+
|
|
5199
|
+
/**
|
|
5200
|
+
* A decision that is the person's is ASKED, and the stage goes on with the
|
|
5201
|
+
* answer. R19 and R24 (Codex) each met one — a plan the code had moved away
|
|
5202
|
+
* from, a plan whose review left the scope open — and ended the stage saying
|
|
5203
|
+
* so, the run FINISHED with nothing built and the question in a summary
|
|
5204
|
+
* nobody was asked. Claude asked; the wording let Codex stop instead.
|
|
5205
|
+
*/
|
|
5206
|
+
const ASK_DONT_STOP = 'If a decision is genuinely the person\'s — a scope the card leaves open, '
|
|
5207
|
+
+ 'a plan that no longer matches the code, a trade-off with no right answer — ASK it with '
|
|
5208
|
+
+ '`ask_user` (a round of questions if there are several), wait for the answer, and carry on '
|
|
5209
|
+
+ 'with it. Never end this stage on an open question: a question left in your report is one '
|
|
5210
|
+
+ 'nobody was asked.';
|
|
5211
|
+
|
|
5212
|
+
/**
|
|
5213
|
+
* A stage is ONE turn, and it ends the moment the turn does — so a command left
|
|
5214
|
+
* running in the background is a result lost (background-shells.mjs).
|
|
5215
|
+
*/
|
|
5216
|
+
const WAIT_FOR_COMMANDS = 'This stage ends when your turn ends: run every command in the '
|
|
5217
|
+
+ 'foreground and wait for it to finish — a build or a test suite left running in the '
|
|
5218
|
+
+ 'background is lost, and the stage is run again.';
|
|
5219
|
+
|
|
5220
|
+
/**
|
|
5221
|
+
* @param hasPlan whether the card has a plan of record — the same test
|
|
5222
|
+
* harnessPrompt uses to draw "The plan for this card" (`plan?.body`), so the
|
|
5223
|
+
* instruction and the block it points at cannot disagree
|
|
5224
|
+
*/
|
|
5225
|
+
function stagePrompt(stage, carried, hasPlan = true) {
|
|
5186
5226
|
const asked = {
|
|
5187
5227
|
PLAN: 'Work out what to do and WRITE THE PLAN. Do not change anything — you have no '
|
|
5188
5228
|
+ 'tool that can, so do not spend turns discovering that. Read what you need, then say, '
|
|
5189
5229
|
+ 'concretely: which files, what change in each, and how you will know it worked. '
|
|
5190
5230
|
+ 'What you report becomes the card\'s plan, and a DIFFERENT session — another '
|
|
5191
5231
|
+ 'process, with none of your context — will be handed it and told to carry it out. '
|
|
5192
|
-
+ 'Write it for them.',
|
|
5232
|
+
+ 'Write it for them — with every decision in it already made. ' + ASK_DONT_STOP,
|
|
5193
5233
|
VERIFY: 'Check the plan against what is actually there. Does every file it names exist? '
|
|
5194
5234
|
+ 'Does every interface it assumes still look like that? You cannot change anything — '
|
|
5195
5235
|
+ 'say what is wrong with the plan, or say plainly that it holds.',
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5236
|
+
// With a plan, the plan is the task. Without one — `/new dev R21` on a
|
|
5237
|
+
// card nobody planned — the CARD is: the old wording pointed at a plan
|
|
5238
|
+
// section that was not there, and a careful session read "stop rather than
|
|
5239
|
+
// improvise" as "stop", ending R21 with nothing built and nothing to push.
|
|
5240
|
+
IMPLEMENT: (hasPlan
|
|
5241
|
+
? 'Do the work in the plan — it is under "The plan for this card" above, '
|
|
5242
|
+
+ 'written by the plan phase and agreed. Do not quietly improvise a different change: '
|
|
5243
|
+
+ 'somebody approved that plan. If it turns out to be wrong or incomplete, ask what to '
|
|
5244
|
+
+ 'do instead of stopping — and stop only if the answer is to stop. '
|
|
5245
|
+
: 'This card has no plan, and it does not need one: it was started straight into '
|
|
5246
|
+
+ 'development, so the CARD\'s text is the task. Work out what it asks — read the code '
|
|
5247
|
+
+ 'it touches first — build it, and commit it on this branch. Never stop because a plan '
|
|
5248
|
+
+ 'is missing. ')
|
|
5249
|
+
+ ASK_DONT_STOP + ' ' + WAIT_FOR_COMMANDS,
|
|
5200
5250
|
// R129. Two honest ways to do this stage, and the project chose one. The
|
|
5201
5251
|
// TESTBOOK wording says "you have no tool that could" for the PLAN
|
|
5202
5252
|
// prompt's reason: a session that has to discover a refusal spends turns
|
|
@@ -5215,7 +5265,8 @@ function stagePrompt(stage, carried) {
|
|
|
5215
5265
|
+ 'failing test is a result, not a failure of this stage. The branch\'s SonarQube gate '
|
|
5216
5266
|
+ 'is one call away — `quality_get`, which reads this run\'s branch by default; a red '
|
|
5217
5267
|
+ 'gate with new vulnerabilities is a finding to report with the output, not a failure '
|
|
5218
|
-
+ 'to hide.'
|
|
5268
|
+
+ 'to hide. ' + WAIT_FOR_COMMANDS + ' Then commit the branch\'s changes — no stage '
|
|
5269
|
+
+ 'after this one writes code, and work left uncommitted is work nothing can push.',
|
|
5219
5270
|
MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
|
|
5220
5271
|
+ 'tried and rejected and why, and anything that turned out to matter and would not be '
|
|
5221
5272
|
+ 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
|
|
@@ -6556,8 +6607,23 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6556
6607
|
return { tokensIn: Number(row?.tokensIn) || 0, tokensOut: Number(row?.tokensOut) || 0 };
|
|
6557
6608
|
};
|
|
6558
6609
|
|
|
6610
|
+
const storedRow = (name) => (Array.isArray(stored)
|
|
6611
|
+
? stored.find((each) => each?.stage === name) : null);
|
|
6612
|
+
|
|
6559
6613
|
for (let at = 0; at < lifecycle.length; at++) {
|
|
6560
6614
|
const stage = lifecycle[at];
|
|
6615
|
+
// Where a carried-on run resumes, and what its DONE stages said, is the
|
|
6616
|
+
// platform's (R263, `WorkflowService.resumeFrom`): the claim's lifecycle
|
|
6617
|
+
// starts at the first stage not DONE. What only the daemon knows is WHY
|
|
6618
|
+
// the stage about to run stopped last time — so that is handed to it.
|
|
6619
|
+
const before = storedRow(stage.stage);
|
|
6620
|
+
if (before?.state === 'FAILED' && before.outcome) {
|
|
6621
|
+
// The attempt that stopped, handed to the one about to run.
|
|
6622
|
+
carried[`${stage.stage} (the attempt that stopped)`] = `${before.outcome}\n\n`
|
|
6623
|
+
+ 'This stage is being run again to finish that. Run the commands it left running '
|
|
6624
|
+
+ 'again IN THE FOREGROUND — not with run_in_background — wait for each one to end '
|
|
6625
|
+
+ 'and read its output, then finish the stage properly.';
|
|
6626
|
+
}
|
|
6561
6627
|
// R305. What THIS stage's processes have used, across its attempts. Outside
|
|
6562
6628
|
// the attempt loop on purpose: `run_stage` is one row per stage and keeps
|
|
6563
6629
|
// the larger reading, so a second attempt after a refused gate must report
|
|
@@ -6668,6 +6734,21 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6668
6734
|
return;
|
|
6669
6735
|
}
|
|
6670
6736
|
|
|
6737
|
+
// The turn ended, but with commands it started in the background still
|
|
6738
|
+
// running, or finished and never read. Not done: the result is what the
|
|
6739
|
+
// stage was for. FAILED keeps the checkout and the session (R80), so
|
|
6740
|
+
// /continue picks the run up HERE — the stages behind it stay pending.
|
|
6741
|
+
if (last?.unfinishedShells?.length) {
|
|
6742
|
+
const listed = last.unfinishedShells.map((command) => `\`${command.slice(0, 120)}\``).join(', ');
|
|
6743
|
+
const why = `The ${stage.stage} stage ended with ${last.unfinishedShells.length} command(s) `
|
|
6744
|
+
+ `still running in the background, or finished and never read: ${listed}. `
|
|
6745
|
+
+ 'Nothing they proved was recorded — /continue runs this stage again and waits for them.';
|
|
6746
|
+
log(` ${why}`);
|
|
6747
|
+
await reportStage(config, run, stage.stage, 'report', { state: 'FAILED', outcome: why });
|
|
6748
|
+
await finish(config, run, 'FAILED', `${why}\n\n${last.text ?? ''}`.trim());
|
|
6749
|
+
return;
|
|
6750
|
+
}
|
|
6751
|
+
|
|
6671
6752
|
await reportStage(config, run, stage.stage, 'report', {
|
|
6672
6753
|
state: 'DONE',
|
|
6673
6754
|
// The PLAN's text IS the plan — the artefact R109 exists to store, and
|
|
@@ -6684,6 +6765,15 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6684
6765
|
if (decision.allowed) {
|
|
6685
6766
|
break;
|
|
6686
6767
|
}
|
|
6768
|
+
if (decision.expired) {
|
|
6769
|
+
// R339. Unread is not refused: re-running the stage would spend a
|
|
6770
|
+
// second stage on a plan nobody has looked at yet.
|
|
6771
|
+
const why = `Stopped at the ${stage.stage} gate: ${decision.reason}. Nothing was `
|
|
6772
|
+
+ 'refused — start the run again when somebody can read it.';
|
|
6773
|
+
await skipRest(config, run, lifecycle, at + 1, why);
|
|
6774
|
+
await finish(config, run, 'FINISHED', why);
|
|
6775
|
+
return;
|
|
6776
|
+
}
|
|
6687
6777
|
if (attempt === 1) {
|
|
6688
6778
|
const why = `Stopped at the ${stage.stage} gate: ${decision.reason ?? 'refused twice.'}`;
|
|
6689
6779
|
// The same trail R122 came from: a run that ends here never reaches the
|
|
@@ -6787,6 +6877,11 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
6787
6877
|
}
|
|
6788
6878
|
|
|
6789
6879
|
log(` waiting at the ${stage.stage} gate`);
|
|
6880
|
+
// R339: no deadline of its own by default. A gate is a person reading the
|
|
6881
|
+
// plan, the platform never expires one, and a run waiting on it is never
|
|
6882
|
+
// swept — a timer here turned "not read yet" into a refusal and re-ran the
|
|
6883
|
+
// stage, which is spend nobody asked for. An older platform that does
|
|
6884
|
+
// expire it still answers EXPIRED, which is handled below.
|
|
6790
6885
|
const deadline = Date.now() + gateTimeoutSeconds() * 1000;
|
|
6791
6886
|
while (Date.now() < deadline) {
|
|
6792
6887
|
const remaining = Math.ceil((deadline - Date.now()) / 1000);
|
|
@@ -6798,6 +6893,12 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
6798
6893
|
// the transport — a 204 and a `{state: 'PENDING'}` both mean "not yet",
|
|
6799
6894
|
// and depending on which one the platform chose would be depending on
|
|
6800
6895
|
// something nobody promised.
|
|
6896
|
+
if (decision?.state === 'EXPIRED') {
|
|
6897
|
+
// A platform from before R339. Nobody said no, so this is not a
|
|
6898
|
+
// refusal to re-run the stage over: the run stops at the gate.
|
|
6899
|
+
log(` the ${stage.stage} gate expired unread`);
|
|
6900
|
+
return { allowed: false, expired: true, reason: 'nobody read it in time' };
|
|
6901
|
+
}
|
|
6801
6902
|
if (decision && decision.state && decision.state !== 'PENDING') {
|
|
6802
6903
|
const allowed = decision.state === 'ALLOWED';
|
|
6803
6904
|
log(` the ${stage.stage} gate was ${allowed ? 'allowed' : 'refused'}`);
|
|
@@ -6808,13 +6909,29 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
6808
6909
|
return { allowed: false, reason: `waiting failed (${failure.message})` };
|
|
6809
6910
|
}
|
|
6810
6911
|
}
|
|
6811
|
-
return { allowed: false, reason: 'nobody answered in time' };
|
|
6912
|
+
return { allowed: false, expired: true, reason: 'nobody answered in time' };
|
|
6812
6913
|
}
|
|
6813
6914
|
|
|
6814
|
-
/**
|
|
6915
|
+
/**
|
|
6916
|
+
* How long a gate waits before the daemon gives up on it: for ever, unless a
|
|
6917
|
+
* test says otherwise — R339. The platform decides when nobody is coming.
|
|
6918
|
+
*/
|
|
6815
6919
|
function gateTimeoutSeconds() {
|
|
6816
6920
|
const configured = Number(process.env.CAWDEV_GATE_TIMEOUT_SECONDS);
|
|
6817
|
-
return Number.isFinite(configured) && configured > 0 ? configured :
|
|
6921
|
+
return Number.isFinite(configured) && configured > 0 ? configured : Infinity;
|
|
6922
|
+
}
|
|
6923
|
+
|
|
6924
|
+
/**
|
|
6925
|
+
* An agent session, spawned under the machine's memory ceiling when it has
|
|
6926
|
+
* one — R339. `systemd-run --scope` execs in place, so the pid, the group and
|
|
6927
|
+
* the pipes are the agent's own. `cawdevCommand` is what the next daemon reads
|
|
6928
|
+
* off the driving note to tell this session's orphan from a reused pid.
|
|
6929
|
+
*/
|
|
6930
|
+
function spawnScoped(config, command, args, options) {
|
|
6931
|
+
const line = scoped(command, args, config.sessionScope);
|
|
6932
|
+
const child = spawn(line.command, line.args, options);
|
|
6933
|
+
child.cawdevCommand = command;
|
|
6934
|
+
return child;
|
|
6818
6935
|
}
|
|
6819
6936
|
|
|
6820
6937
|
async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
@@ -7235,7 +7352,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7235
7352
|
log(` spawning: ${agentCmd} ${args.join(' ')} (prompt on stdin)`);
|
|
7236
7353
|
|
|
7237
7354
|
return new Promise((resolvePromise) => {
|
|
7238
|
-
const child =
|
|
7355
|
+
const child = spawnScoped(config, agentCmd, args, {
|
|
7239
7356
|
cwd,
|
|
7240
7357
|
// Its own process group, so cancelling can take down the whole tree
|
|
7241
7358
|
// rather than leaving orphaned children behind.
|
|
@@ -7300,7 +7417,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7300
7417
|
// sending it again is a repeat wearing a resume's clothes — R69's rule about
|
|
7301
7418
|
// the opening prompt, applied to the thing that now travels with it.
|
|
7302
7419
|
writeUserMessage(child, resume?.prompt
|
|
7303
|
-
?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried) : '')));
|
|
7420
|
+
?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : '')));
|
|
7304
7421
|
|
|
7305
7422
|
let lastText = '';
|
|
7306
7423
|
// R305. This child's own last reading, handed back to the walk so the next
|
|
@@ -7326,6 +7443,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7326
7443
|
// wait for it, which is exactly the shape that loses it when `endSession`
|
|
7327
7444
|
// closes stdin below.
|
|
7328
7445
|
const backgrounded = new Set();
|
|
7446
|
+
// Shell commands this child started in the background and never saw end
|
|
7447
|
+
// — the walk will not call a stage done with any of them left (see
|
|
7448
|
+
// background-shells.mjs).
|
|
7449
|
+
const shells = new BackgroundShells();
|
|
7329
7450
|
// The last of what the CLI said on stderr, for the exit decision below.
|
|
7330
7451
|
// The usage-limit notice is written there, and a decision made on stdout
|
|
7331
7452
|
// alone would call every window a crash.
|
|
@@ -7487,6 +7608,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7487
7608
|
buffer = buffer.slice(newline + 1);
|
|
7488
7609
|
if (!line) continue;
|
|
7489
7610
|
const event = safeJson(line);
|
|
7611
|
+
if (event) shells.observe(event);
|
|
7490
7612
|
|
|
7491
7613
|
// i257. Read off the RAW event, before `linesOf` flattens it to a
|
|
7492
7614
|
// string: `describeCall` (`../lib/tool-line.mjs`) never carries
|
|
@@ -7893,7 +8015,8 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7893
8015
|
// gate on it, and carry it into the next stage. A run with no lifecycle
|
|
7894
8016
|
// resolves with nothing, exactly as it always did.
|
|
7895
8017
|
resolvePromise(stage
|
|
7896
|
-
? { code, signal, text: lastText, turnEnded, usage: childTotals
|
|
8018
|
+
? { code, signal, text: lastText, turnEnded, usage: childTotals,
|
|
8019
|
+
unfinishedShells: shells.unfinished() }
|
|
7897
8020
|
: undefined);
|
|
7898
8021
|
});
|
|
7899
8022
|
});
|
|
@@ -8188,14 +8311,36 @@ function inlinedSkillsFor(skills) {
|
|
|
8188
8311
|
* tool a session delegates through — confirmed end-to-end by hand, a probe
|
|
8189
8312
|
* agent installed, invoked by name, and its answer returned correctly.
|
|
8190
8313
|
*/
|
|
8314
|
+
/** This machine, as a refusal names it: what it is called, where its config and console are. */
|
|
8315
|
+
function thisMachine(config) {
|
|
8316
|
+
return {
|
|
8317
|
+
name: config.name,
|
|
8318
|
+
acceptsConsoleRules: config.acceptsRulesFromConsole === true,
|
|
8319
|
+
configPath: config.configPath ?? null,
|
|
8320
|
+
consoleUrl: config.url,
|
|
8321
|
+
};
|
|
8322
|
+
}
|
|
8323
|
+
|
|
8324
|
+
/**
|
|
8325
|
+
* A refusal said on the run's own transcript, where the terminal and the run
|
|
8326
|
+
* page show it — the run's summary alone left a window reading "failed" and
|
|
8327
|
+
* nothing else, with the reason one page away.
|
|
8328
|
+
*/
|
|
8329
|
+
async function sayOnTheRun(config, run, text) {
|
|
8330
|
+
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/output`, {
|
|
8331
|
+
method: 'POST',
|
|
8332
|
+
body: { lines: text.split('\n').filter((body) => body.trim()).map((body) => ({ kind: 'ERROR', body })) },
|
|
8333
|
+
}).catch((failure) => log(` could not say it on the run: ${failure.message}`));
|
|
8334
|
+
}
|
|
8335
|
+
|
|
8191
8336
|
async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
8192
8337
|
skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
|
|
8193
8338
|
agyCmd, granted, usage = null) {
|
|
8194
8339
|
if (!granted.everything) {
|
|
8195
|
-
|
|
8196
|
-
|
|
8197
|
-
|
|
8198
|
-
|
|
8340
|
+
// Antigravity needs it even to read: no per-tool allow-list exists on it.
|
|
8341
|
+
const summary = needsEverything('agy', thisMachine(config), run, { readsWithout: false });
|
|
8342
|
+
log(` refusing to spawn Antigravity: ${summary.split('\n')[0]}`);
|
|
8343
|
+
await sayOnTheRun(config, run, summary);
|
|
8199
8344
|
await finish(config, run, 'FAILED', summary);
|
|
8200
8345
|
return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
|
|
8201
8346
|
}
|
|
@@ -8285,7 +8430,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8285
8430
|
const promptText = resuming
|
|
8286
8431
|
? resume.prompt
|
|
8287
8432
|
: (promptFor(run) + harness + skillsBlock + expertsBlock
|
|
8288
|
-
+ (stage ? stagePrompt(stage, carried) : ''));
|
|
8433
|
+
+ (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : ''));
|
|
8289
8434
|
|
|
8290
8435
|
const args = ['-p', promptText, '--output-format', 'stream-json', '--dangerously-skip-permissions'];
|
|
8291
8436
|
if (run.model) {
|
|
@@ -8319,7 +8464,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8319
8464
|
let childTotals = null;
|
|
8320
8465
|
let err = '';
|
|
8321
8466
|
let buffer = '';
|
|
8322
|
-
const child =
|
|
8467
|
+
const child = spawnScoped(config, agyCmd, args, {
|
|
8323
8468
|
cwd,
|
|
8324
8469
|
// Its own process group — see the same choice on the Claude Code path.
|
|
8325
8470
|
detached: true,
|
|
@@ -8334,6 +8479,12 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8334
8479
|
// Code one does, with no code of its own.
|
|
8335
8480
|
running.set(run.id, child);
|
|
8336
8481
|
|
|
8482
|
+
// What the console can see of the checkout, and the commits as they land —
|
|
8483
|
+
// the same watcher as the Claude Code path. Without it the platform never
|
|
8484
|
+
// hears of this run's commits, so the finish rules (R40) see a run that
|
|
8485
|
+
// wrote nothing and queue no push and no pull request.
|
|
8486
|
+
const workingCopy = watchWorkingCopy(config, run, cwd, baseCommit);
|
|
8487
|
+
|
|
8337
8488
|
// stream-json arrives in chunks that split mid-line, same as Claude
|
|
8338
8489
|
// Code's — buffer until a full line rather than assuming one chunk is
|
|
8339
8490
|
// one event.
|
|
@@ -8430,6 +8581,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8430
8581
|
});
|
|
8431
8582
|
|
|
8432
8583
|
child.on('error', async (failure) => {
|
|
8584
|
+
workingCopy.stop();
|
|
8433
8585
|
running.delete(run.id);
|
|
8434
8586
|
const summary = `Could not spawn Antigravity: ${failure.message}`;
|
|
8435
8587
|
await transcript.flush();
|
|
@@ -8439,6 +8591,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8439
8591
|
});
|
|
8440
8592
|
|
|
8441
8593
|
child.on('close', async (code) => {
|
|
8594
|
+
workingCopy.stop();
|
|
8442
8595
|
running.delete(run.id);
|
|
8443
8596
|
await transcript.flush();
|
|
8444
8597
|
const text = result?.response
|
|
@@ -8466,6 +8619,10 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8466
8619
|
if (!stage) {
|
|
8467
8620
|
await settleActions(config, run, cwd);
|
|
8468
8621
|
}
|
|
8622
|
+
// The last reading, as on the Claude Code path: after the agent has
|
|
8623
|
+
// stopped and after anything the rules did.
|
|
8624
|
+
await reportCommits(config, run, cwd, baseCommit);
|
|
8625
|
+
|
|
8469
8626
|
resolvePromise(stage
|
|
8470
8627
|
? { code, signal: null, text, turnEnded, usage: childTotals }
|
|
8471
8628
|
: undefined);
|
|
@@ -8535,17 +8692,18 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8535
8692
|
skills, stageExperts, readOnlyStage, instincts, briefing, plan, lifecycle, stage, carried,
|
|
8536
8693
|
codexCmd, granted, shield, usage = null) {
|
|
8537
8694
|
const failedBeforeSpawn = async (summary) => {
|
|
8538
|
-
log(` ${summary}`);
|
|
8695
|
+
log(` ${summary.split('\n')[0]}`);
|
|
8696
|
+
await sayOnTheRun(config, run, summary);
|
|
8539
8697
|
await finish(config, run, 'FAILED', summary);
|
|
8540
8698
|
return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
|
|
8541
8699
|
};
|
|
8542
8700
|
|
|
8543
8701
|
const mayWrite = writesCodeProfile(run) && !readOnlyStage;
|
|
8544
8702
|
if (mayWrite && !granted.everything) {
|
|
8545
|
-
|
|
8546
|
-
|
|
8547
|
-
|
|
8548
|
-
|
|
8703
|
+
// It must commit and push, which its sandbox refuses, and there is no
|
|
8704
|
+
// allow-list for its shell — so everything, or nothing. Read-only runs
|
|
8705
|
+
// and stages need nothing.
|
|
8706
|
+
return failedBeforeSpawn(needsEverything('codex', thisMachine(config), run));
|
|
8549
8707
|
}
|
|
8550
8708
|
if (mayWrite) {
|
|
8551
8709
|
log(' !! this machine allows EVERYTHING: this Codex session runs without a sandbox');
|
|
@@ -8591,6 +8749,12 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8591
8749
|
args: [serverPath],
|
|
8592
8750
|
envVars: Object.keys(cawdevEnv),
|
|
8593
8751
|
enabledTools: codexCawdevTools(run, stage, mayWrite),
|
|
8752
|
+
// Codex cuts an MCP call off after 60 s unless told otherwise, and
|
|
8753
|
+
// `ask_user` waits up to ten minutes for a person: a question asked from
|
|
8754
|
+
// Codex died before anybody could answer it, so it did not ask — it
|
|
8755
|
+
// stopped. Longer than ask_user's own wait (mcp/server.mjs), so the
|
|
8756
|
+
// question is answered or handed back as pending, never cut off.
|
|
8757
|
+
toolTimeoutSec: CAWDEV_TOOL_TIMEOUT_SEC,
|
|
8594
8758
|
});
|
|
8595
8759
|
|
|
8596
8760
|
const cannotDelegate = stageExperts.length
|
|
@@ -8612,7 +8776,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8612
8776
|
})
|
|
8613
8777
|
+ inlinedSkillsFor(skills)
|
|
8614
8778
|
+ expertsBlockFor([], cannotDelegate)
|
|
8615
|
-
+ (stage ? stagePrompt(stage, carried) : ''));
|
|
8779
|
+
+ (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : ''));
|
|
8616
8780
|
|
|
8617
8781
|
const model = stage?.model ?? run.model;
|
|
8618
8782
|
const args = codexArgs({
|
|
@@ -8639,7 +8803,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8639
8803
|
let lastSaid = null;
|
|
8640
8804
|
let err = '';
|
|
8641
8805
|
let buffer = '';
|
|
8642
|
-
const child =
|
|
8806
|
+
const child = spawnScoped(config, codexCmd, args, {
|
|
8643
8807
|
cwd,
|
|
8644
8808
|
// Its own process group — see the same choice on the Claude Code path.
|
|
8645
8809
|
detached: true,
|
|
@@ -8651,6 +8815,13 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8651
8815
|
child.cawdevModel = model ?? null;
|
|
8652
8816
|
// `reapCancelled` SIGTERMs whatever is here, whichever CLI it is.
|
|
8653
8817
|
running.set(run.id, child);
|
|
8818
|
+
|
|
8819
|
+
// What the console can see of the checkout, and the commits as they land —
|
|
8820
|
+
// the same watcher as the Claude Code path. Without it the platform never
|
|
8821
|
+
// hears of this run's commits, so the finish rules (R40) see a run that
|
|
8822
|
+
// wrote nothing and queue no push and no pull request.
|
|
8823
|
+
const workingCopy = watchWorkingCopy(config, run, cwd, baseCommit);
|
|
8824
|
+
|
|
8654
8825
|
child.stdin.on('error', () => {
|
|
8655
8826
|
// A CLI that died before reading its prompt says why on stderr.
|
|
8656
8827
|
});
|
|
@@ -8728,6 +8899,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8728
8899
|
});
|
|
8729
8900
|
|
|
8730
8901
|
child.on('error', async (failure) => {
|
|
8902
|
+
workingCopy.stop();
|
|
8731
8903
|
running.delete(run.id);
|
|
8732
8904
|
const summary = `Could not spawn Codex: ${failure.message}`;
|
|
8733
8905
|
await transcript.flush();
|
|
@@ -8736,6 +8908,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8736
8908
|
});
|
|
8737
8909
|
|
|
8738
8910
|
child.on('close', async (code) => {
|
|
8911
|
+
workingCopy.stop();
|
|
8739
8912
|
running.delete(run.id);
|
|
8740
8913
|
await transcript.flush();
|
|
8741
8914
|
const text = failed ?? lastSaid ?? lastError ?? (err.trim() || 'Codex produced no output.');
|
|
@@ -8750,6 +8923,10 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8750
8923
|
if (!stage) {
|
|
8751
8924
|
await settleActions(config, run, cwd);
|
|
8752
8925
|
}
|
|
8926
|
+
// The last reading, as on the Claude Code path: after the agent has
|
|
8927
|
+
// stopped and after anything the rules did.
|
|
8928
|
+
await reportCommits(config, run, cwd, baseCommit);
|
|
8929
|
+
|
|
8753
8930
|
resolvePromise(stage
|
|
8754
8931
|
? { code, signal: null, text, turnEnded: ended && !failed, usage: childTotals }
|
|
8755
8932
|
: undefined);
|
|
@@ -8808,6 +8985,101 @@ async function finish(config, run, state, summary) {
|
|
|
8808
8985
|
|
|
8809
8986
|
// --- cancellation ------------------------------------------------------------
|
|
8810
8987
|
|
|
8988
|
+
/**
|
|
8989
|
+
* What the daemon before this one was driving when it died — R339.
|
|
8990
|
+
*
|
|
8991
|
+
* Read off the note it kept (restart.mjs), and only when its writer is dead:
|
|
8992
|
+
* a live writer is a second daemon on this runner id. Each run the platform
|
|
8993
|
+
* still believes in is failed at once as DAEMON_RESTARTED, in words that name
|
|
8994
|
+
* the cause — where the stale sweep would have said, five minutes later, that
|
|
8995
|
+
* the runner "stopped driving it". A session the old daemon left running
|
|
8996
|
+
* unattended is stopped first: two sessions writing one checkout is worse
|
|
8997
|
+
* than either.
|
|
8998
|
+
*/
|
|
8999
|
+
async function reportWhatARestartKilled(config) {
|
|
9000
|
+
const path = drivingPath(config.runnerId);
|
|
9001
|
+
const previous = await readDriving(path);
|
|
9002
|
+
const behind = leftBehind(previous, process.pid, isAlive);
|
|
9003
|
+
for (const left of behind) {
|
|
9004
|
+
const run = await api(config, `/api/projects/${left.projectSlug}/runs/${left.id}`)
|
|
9005
|
+
.catch((failure) => {
|
|
9006
|
+
log(`could not read ${left.id} after the restart: ${failure.message}`);
|
|
9007
|
+
return null;
|
|
9008
|
+
});
|
|
9009
|
+
if (!killedByRestart(run, config.runnerId)) {
|
|
9010
|
+
continue;
|
|
9011
|
+
}
|
|
9012
|
+
const orphanStopped = await stopOrphan(left);
|
|
9013
|
+
log(`run ${left.id} was being driven when this daemon last died (pid ${previous.pid}); `
|
|
9014
|
+
+ 'reporting it');
|
|
9015
|
+
await api(config, `/api/projects/${left.projectSlug}/runs/${left.id}/transition`, {
|
|
9016
|
+
method: 'POST',
|
|
9017
|
+
body: {
|
|
9018
|
+
state: 'FAILED',
|
|
9019
|
+
failureReason: 'DAEMON_RESTARTED',
|
|
9020
|
+
summary: restartSummary({
|
|
9021
|
+
name: config.name, previousPid: previous.pid, pid: process.pid, orphanStopped,
|
|
9022
|
+
stopped: previous.stopped ?? null,
|
|
9023
|
+
}),
|
|
9024
|
+
},
|
|
9025
|
+
}).catch((failure) => log(` could not report it: ${failure.message}`));
|
|
9026
|
+
}
|
|
9027
|
+
await writeDriving(path, snapshotOf(process.pid, running))
|
|
9028
|
+
.catch((failure) => log(`could not write ${path}: ${failure.message}`));
|
|
9029
|
+
}
|
|
9030
|
+
|
|
9031
|
+
/**
|
|
9032
|
+
* The old daemon's session, if it outlived it — and only if the pid still
|
|
9033
|
+
* runs the command it was spawned with, because after a reboot that number
|
|
9034
|
+
* may be somebody else's process.
|
|
9035
|
+
*/
|
|
9036
|
+
async function stopOrphan(left) {
|
|
9037
|
+
if (!left.childPid || !left.command || !isAlive(left.childPid)) {
|
|
9038
|
+
return false;
|
|
9039
|
+
}
|
|
9040
|
+
const command = await new Promise((resolveCommand) => {
|
|
9041
|
+
const ps = spawn('ps', ['-o', 'command=', '-p', String(left.childPid)],
|
|
9042
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] });
|
|
9043
|
+
let out = '';
|
|
9044
|
+
ps.stdout.on('data', (chunk) => (out += chunk));
|
|
9045
|
+
ps.on('error', () => resolveCommand(''));
|
|
9046
|
+
ps.on('close', () => resolveCommand(out));
|
|
9047
|
+
});
|
|
9048
|
+
if (!command.includes(left.command)) {
|
|
9049
|
+
return false;
|
|
9050
|
+
}
|
|
9051
|
+
try {
|
|
9052
|
+
process.kill(-left.childPid, 'SIGTERM');
|
|
9053
|
+
log(` stopped the session it left running (pid ${left.childPid})`);
|
|
9054
|
+
return true;
|
|
9055
|
+
} catch (failure) {
|
|
9056
|
+
log(` could not stop the session it left running: ${failure.message}`);
|
|
9057
|
+
return false;
|
|
9058
|
+
}
|
|
9059
|
+
}
|
|
9060
|
+
|
|
9061
|
+
/**
|
|
9062
|
+
* Keeps the note current: rewritten whenever the set of runs being driven
|
|
9063
|
+
* changes, checked every second. A second is the window in which a death
|
|
9064
|
+
* goes unreported — and the stale sweep still catches that one.
|
|
9065
|
+
*/
|
|
9066
|
+
function keepTheDrivingNote(config) {
|
|
9067
|
+
const path = drivingPath(config.runnerId);
|
|
9068
|
+
let written = null;
|
|
9069
|
+
const timer = setInterval(() => {
|
|
9070
|
+
const snapshot = snapshotOf(process.pid, running);
|
|
9071
|
+
const text = JSON.stringify(snapshot);
|
|
9072
|
+
if (text === written) return;
|
|
9073
|
+
written = text;
|
|
9074
|
+
writeDriving(path, snapshot).catch((failure) => {
|
|
9075
|
+
written = null;
|
|
9076
|
+
log(`could not write ${path}: ${failure.message}`);
|
|
9077
|
+
});
|
|
9078
|
+
}, 1000);
|
|
9079
|
+
timer.unref?.();
|
|
9080
|
+
return timer;
|
|
9081
|
+
}
|
|
9082
|
+
|
|
8811
9083
|
/**
|
|
8812
9084
|
* A cancelled run reaches the runner on its next poll, and the child's whole
|
|
8813
9085
|
* process group goes with it — an agent that spawns a build should not leave it
|
|
@@ -9060,6 +9332,24 @@ async function main() {
|
|
|
9060
9332
|
// than buried under a session that then fails for a reason it explains.
|
|
9061
9333
|
await probePermissionPrompt(config);
|
|
9062
9334
|
|
|
9335
|
+
// R339. Whether this machine can put each session under a memory ceiling,
|
|
9336
|
+
// and what an earlier daemon on this runner left behind when it died —
|
|
9337
|
+
// both before anything is claimed: a run reported now is one the queue
|
|
9338
|
+
// must not hand the checkout of to somebody else first.
|
|
9339
|
+
const memoryMax = memoryMaxOf(config.sessionMemoryMax);
|
|
9340
|
+
if (config.sessionMemoryMax && !memoryMax) {
|
|
9341
|
+
log(`sessionMemoryMax "${config.sessionMemoryMax}" is not a size systemd reads (6G, 512M, 75%); ignored`);
|
|
9342
|
+
}
|
|
9343
|
+
const probed = await probeScope(memoryMax);
|
|
9344
|
+
config.sessionScope = probed.scope;
|
|
9345
|
+
if (probed.scope) {
|
|
9346
|
+
log(`each session runs under MemoryMax=${probed.scope.memoryMax} (systemd --${probed.scope.manager} scope)`);
|
|
9347
|
+
} else if (probed.why) {
|
|
9348
|
+
log(`sessionMemoryMax not applied: ${probed.why}`);
|
|
9349
|
+
}
|
|
9350
|
+
await reportWhatARestartKilled(config);
|
|
9351
|
+
const drivingNote = keepTheDrivingNote(config);
|
|
9352
|
+
|
|
9063
9353
|
// What this daemon SAYS it is, on every hello — R52's socket, R288's rule
|
|
9064
9354
|
// that it describes the current state. One object, sent as-is by
|
|
9065
9355
|
// `serveControl` and mutated by the SIGHUP handler below, so a terminal that
|
|
@@ -9347,6 +9637,12 @@ async function main() {
|
|
|
9347
9637
|
}
|
|
9348
9638
|
log('stopping; leaving any live run to the platform’s staleness sweep');
|
|
9349
9639
|
stopping = true;
|
|
9640
|
+
// R339. Said in the note before the sessions go, so the next daemon
|
|
9641
|
+
// tells a stop somebody asked for from a kill nobody did.
|
|
9642
|
+
clearInterval(drivingNote);
|
|
9643
|
+
await writeDriving(drivingPath(config.runnerId),
|
|
9644
|
+
{ ...snapshotOf(process.pid, running), stopped: signal })
|
|
9645
|
+
.catch(() => undefined);
|
|
9350
9646
|
clearInterval(heartbeat);
|
|
9351
9647
|
clearInterval(gitSurvey);
|
|
9352
9648
|
clearInterval(workspaceRequests);
|
package/runner/sign-in.mjs
CHANGED
|
@@ -26,6 +26,9 @@ export class Session {
|
|
|
26
26
|
this.url = String(url).replace(/\/+$/, '');
|
|
27
27
|
this.cookies = new Map();
|
|
28
28
|
this.email = null;
|
|
29
|
+
/** Called once when a signed-in session is answered 401 — expired, or
|
|
30
|
+
* revoked on the platform. The terminal offers `/login` from there. */
|
|
31
|
+
this.onExpired = null;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
get signedIn() {
|
|
@@ -63,6 +66,14 @@ export class Session {
|
|
|
63
66
|
});
|
|
64
67
|
this.#remember(response);
|
|
65
68
|
const text = await response.text();
|
|
69
|
+
if (response.status === 401 && this.email !== null) {
|
|
70
|
+
// The cookie stopped meaning anything: expired, signed out elsewhere, or
|
|
71
|
+
// the account revoked. Until this every action failed one at a time with
|
|
72
|
+
// a bare "HTTP 401" while the footer still named the person — so the
|
|
73
|
+
// state is put right HERE, where every request passes, and said once.
|
|
74
|
+
this.email = null;
|
|
75
|
+
this.onExpired?.();
|
|
76
|
+
}
|
|
66
77
|
if (!response.ok) {
|
|
67
78
|
const failure = new Error(messageIn(text) ?? `${method} ${path} failed: HTTP ${response.status}`);
|
|
68
79
|
failure.status = response.status;
|