cawdev-cli 1.0.3-beta → 1.0.5-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 +27 -0
- package/lib/ansi.mjs +2 -0
- package/lib/codex.mjs +214 -0
- package/lib/harness-prompt.mjs +26 -7
- package/lib/runner-config.mjs +3 -2
- package/lib/stage-tools.mjs +33 -2
- package/lib/tool-line.mjs +19 -8
- package/mcp/README.md +1 -0
- package/mcp/server.mjs +150 -0
- package/package.json +2 -5
- package/runner/README.md +357 -6
- package/runner/attach.mjs +1269 -24
- package/runner/auto-workspaces.mjs +106 -0
- package/runner/bootstrap.mjs +37 -5
- package/runner/cawdev.mjs +49 -13
- package/runner/changed-lines.mjs +87 -0
- package/runner/configure.mjs +77 -3
- package/runner/runner.mjs +1529 -101
- package/runner/select.mjs +31 -4
- package/runner/split.mjs +830 -0
- package/runner/macbook-laptop.json +0 -48
package/runner/runner.mjs
CHANGED
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
// against your working copies; it should be a file you can read first.
|
|
13
13
|
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
15
16
|
import {
|
|
16
17
|
access, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile,
|
|
17
18
|
} from 'node:fs/promises';
|
|
18
19
|
import { homedir, tmpdir } from 'node:os';
|
|
19
20
|
|
|
20
21
|
import { join, resolve } from 'node:path';
|
|
22
|
+
import { changedLines } from './changed-lines.mjs';
|
|
21
23
|
import { describeTurn, totalsOf } from '../lib/usage.mjs';
|
|
22
24
|
import { withinCeiling } from '../lib/tool-rules.mjs';
|
|
23
25
|
import { serveControl } from './control.mjs';
|
|
@@ -33,12 +35,18 @@ import {
|
|
|
33
35
|
import { AI_CONFIG, harnessPrompt, readRepoConfig } from '../lib/harness-prompt.mjs';
|
|
34
36
|
import { loadToken, storedUrls } from './token-store.mjs';
|
|
35
37
|
import {
|
|
36
|
-
CARD_WRITE, CAWDEV_READS, GIT_READS, driftedFrom, readOnlyExpert,
|
|
38
|
+
CARD_WRITE, CAWDEV_READS, GIT_READS, beforeTheWorkExists, driftedFrom, readOnlyExpert,
|
|
39
|
+
toolsForStage,
|
|
37
40
|
} from '../lib/stage-tools.mjs';
|
|
38
41
|
import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
|
|
39
42
|
import { findSecret } from '../lib/secrets.mjs';
|
|
40
43
|
import { cliVersion, isBelow } from '../lib/version.mjs';
|
|
41
44
|
import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
|
|
45
|
+
import {
|
|
46
|
+
createAutoWorkspace, discoverAutoWorkspaces, isAutoWorkspace,
|
|
47
|
+
} from './auto-workspaces.mjs';
|
|
48
|
+
import { agentOfCommand } from '../lib/agents.mjs';
|
|
49
|
+
import { cawdevToolNames, codexArgs, translateCodexEvent } from '../lib/codex.mjs';
|
|
42
50
|
|
|
43
51
|
// --- configuration -----------------------------------------------------------
|
|
44
52
|
|
|
@@ -164,6 +172,13 @@ const DEFAULTS = {
|
|
|
164
172
|
// would be a question with one sensible answer.
|
|
165
173
|
'mcp__cawdev__code_map',
|
|
166
174
|
'mcp__cawdev__file_deps',
|
|
175
|
+
// R302. The SonarQube reading — a read, allowed by default like the map.
|
|
176
|
+
// Through READ_ONLY_CAWDEV this reaches REVIEW and AUDIT as the card
|
|
177
|
+
// asks, and ASK, SCOPE, MERGE, RELEASE and INTERVIEW as well: it is a
|
|
178
|
+
// read and harmless there, and subtracting it from five lists would be
|
|
179
|
+
// five lists to keep in step. PLAN is the one profile that drops it —
|
|
180
|
+
// `beforeTheWorkExists`, below — because the card says so.
|
|
181
|
+
'mcp__cawdev__quality_get',
|
|
167
182
|
'mcp__cawdev__roadmap_statuses',
|
|
168
183
|
'mcp__cawdev__roadmap_list',
|
|
169
184
|
'mcp__cawdev__roadmap_get',
|
|
@@ -397,6 +412,13 @@ async function readConfig() {
|
|
|
397
412
|
file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole,
|
|
398
413
|
/** Whether a run here may reach the operator's browser. Per project too. */
|
|
399
414
|
browser: file.browser ?? DEFAULTS.browser,
|
|
415
|
+
/**
|
|
416
|
+
* How many checkouts per project this machine may CLONE by itself when a
|
|
417
|
+
* coding run would otherwise wait for a free one — see auto-workspaces.mjs.
|
|
418
|
+
* Absent is none: an actuator that spends disk and a clone's time is
|
|
419
|
+
* switched on, never on by default.
|
|
420
|
+
*/
|
|
421
|
+
autoWorkspaces: autoWorkspacesOf(file.autoWorkspaces),
|
|
400
422
|
skillCache: file.skillCache ?? DEFAULTS.skillCache,
|
|
401
423
|
skillPrepareSeconds: file.skillPrepareSeconds ?? DEFAULTS.skillPrepareSeconds,
|
|
402
424
|
idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
|
|
@@ -463,6 +485,12 @@ async function readConfig() {
|
|
|
463
485
|
* "dycrypt": "/Users/you/code/dycrypt"
|
|
464
486
|
* "dycrypt": { "path": "…", "allowedTools": ["Bash(mvn *)", "mcp__roadmap"] }
|
|
465
487
|
* "cawdev": { "workspaces": ["/…/ws-1", "/…/ws-2", "/…/ws-3"] }
|
|
488
|
+
* "cawdev": { "workspaces": [...], "quality": { "command": "sonar-scanner", "tokenEnv": "SONAR_TOKEN" } }
|
|
489
|
+
*
|
|
490
|
+
* R300: the fourth form adds a `quality` block — see `qualityBlockOf` — and a
|
|
491
|
+
* project with one has its branches scanned after a coding run's TEST stage.
|
|
492
|
+
* Captured here at boot, not hot-swapped: adding or changing the block needs
|
|
493
|
+
* a restart, and `restartNeededFor` says so.
|
|
466
494
|
*
|
|
467
495
|
* R47: a bare path means ONE workspace, which is what every config meant
|
|
468
496
|
* before workspaces existed. Concurrency for a project is
|
|
@@ -471,6 +499,32 @@ async function readConfig() {
|
|
|
471
499
|
* which is the truth rather than "that project already has a run here" standing
|
|
472
500
|
* in for it.
|
|
473
501
|
*/
|
|
502
|
+
/** `autoWorkspaces` as a count: a whole number from 0, anything else is 0. */
|
|
503
|
+
function autoWorkspacesOf(value) {
|
|
504
|
+
const n = Number(value);
|
|
505
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The on-demand checkouts beside each project's first one, joined to its list
|
|
510
|
+
* — at boot, and again on SIGHUP, when one may have been made or dropped by
|
|
511
|
+
* somebody else. A checkout gone from disk leaves the list unless a run holds
|
|
512
|
+
* it; one that appeared joins it. The configured checkouts are never touched.
|
|
513
|
+
*/
|
|
514
|
+
async function syncAutoWorkspaces(projects, held = new Set()) {
|
|
515
|
+
for (const [slug, project] of Object.entries(projects)) {
|
|
516
|
+
const first = project.workspaces[0];
|
|
517
|
+
const found = await discoverAutoWorkspaces(first);
|
|
518
|
+
const kept = project.workspaces.filter((path) => !isAutoWorkspace(first, path)
|
|
519
|
+
|| found.includes(path) || held.has(path));
|
|
520
|
+
const added = found.filter((path) => !kept.includes(path));
|
|
521
|
+
const dropped = project.workspaces.filter((path) => !kept.includes(path));
|
|
522
|
+
project.workspaces = [...kept, ...added];
|
|
523
|
+
for (const path of added) log(` ${slug}: serving the checkout made on demand at ${path}`);
|
|
524
|
+
for (const path of dropped) log(` ${slug}: ${path} is gone — no longer serving it`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
474
528
|
function normaliseProjects(projects) {
|
|
475
529
|
const normalised = {};
|
|
476
530
|
for (const [slug, value] of Object.entries(projects)) {
|
|
@@ -508,11 +562,58 @@ function normaliseProjects(projects) {
|
|
|
508
562
|
// falls through to the machine's answer; `false` here refuses it for one
|
|
509
563
|
// project on a machine that otherwise allows it.
|
|
510
564
|
browser: settings.browser,
|
|
565
|
+
// R300: whether, and how, this project's branches are scanned with
|
|
566
|
+
// SonarQube after TEST. Null is the ordinary case and means never.
|
|
567
|
+
quality: qualityBlockOf(slug, settings.quality),
|
|
511
568
|
};
|
|
512
569
|
}
|
|
513
570
|
return normalised;
|
|
514
571
|
}
|
|
515
572
|
|
|
573
|
+
/**
|
|
574
|
+
* A project's `quality` block, normalised — R300.
|
|
575
|
+
*
|
|
576
|
+
* The scan is opt-in TWICE: here, on the machine, and on the project, which
|
|
577
|
+
* must have a SonarQube key (the claim carries it). A machine with no block
|
|
578
|
+
* never scans and never says so; a machine with one and no scanner on its
|
|
579
|
+
* PATH reports an `error` reading rather than nothing, so the page says the
|
|
580
|
+
* scan could not run instead of showing a branch nobody looked at.
|
|
581
|
+
*
|
|
582
|
+
* `tokenEnv` names the variable this machine holds the scanner token in. The
|
|
583
|
+
* platform never sends one: the project's SonarQube token (R304) is the
|
|
584
|
+
* platform's reader credential and stays there; the scanner's is the
|
|
585
|
+
* operator's and stays here. `url` overrides the platform's `sonarUrl` for
|
|
586
|
+
* a machine that reaches the server by another name (a LAN hostname).
|
|
587
|
+
*
|
|
588
|
+
* `whenBranchesRefused` is what to do on a server that refuses
|
|
589
|
+
* `sonar.branch.name` — Community Edition. The default, `skip`, reports an
|
|
590
|
+
* error saying so; `main` re-runs the scan as the project's MAIN analysis,
|
|
591
|
+
* which overwrites the project's dashboard with this branch's code and so
|
|
592
|
+
* makes the platform's project reading describe whichever branch scanned
|
|
593
|
+
* last; `project` — R316 — analyses the branch as its own scratch SonarQube
|
|
594
|
+
* project instead, `<key>:branch:<branch>-<hash>`, overwritten on each run,
|
|
595
|
+
* with "new" meaning a line the branch changed. Both are an operator's own
|
|
596
|
+
* opt-in — nothing opts in for them — and `project` additionally needs the
|
|
597
|
+
* scanner token to hold *Create Projects* on the server, and for the
|
|
598
|
+
* server's default project visibility to not be public (or this publishes
|
|
599
|
+
* the code).
|
|
600
|
+
*/
|
|
601
|
+
function qualityBlockOf(slug, block) {
|
|
602
|
+
if (!block) return null;
|
|
603
|
+
const when = block.whenBranchesRefused ?? 'skip';
|
|
604
|
+
if (when !== 'skip' && when !== 'main' && when !== 'project') {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`Project "${slug}": quality.whenBranchesRefused must be "skip", "main" or "project", not "${when}".`);
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
command: block.command ?? 'sonar-scanner',
|
|
610
|
+
tokenEnv: block.tokenEnv ?? 'SONAR_TOKEN',
|
|
611
|
+
url: block.url ?? null,
|
|
612
|
+
whenBranchesRefused: when,
|
|
613
|
+
timeoutSeconds: block.timeoutSeconds ?? 600,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
516
617
|
|
|
517
618
|
/**
|
|
518
619
|
* What THIS daemon knows about a skill, beyond what the platform sent — R76.
|
|
@@ -814,6 +915,11 @@ async function readUsage(config) {
|
|
|
814
915
|
const allWindows = [];
|
|
815
916
|
|
|
816
917
|
for (const agentCommand of config.agentCommands) {
|
|
918
|
+
// Codex has no `-p /usage`: its `-p` is a PROFILE, and the bare command is
|
|
919
|
+
// an interactive terminal. It reports no windows rather than a guess.
|
|
920
|
+
if (agentOfCommand(agentCommand) === 'codex') {
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
817
923
|
const said = await new Promise((resolve) => {
|
|
818
924
|
let out = '';
|
|
819
925
|
const child = spawn(agentCommand, ['-p', '/usage', '--output-format', 'text'], {
|
|
@@ -843,7 +949,7 @@ async function readUsage(config) {
|
|
|
843
949
|
const windows = parseUsage(said);
|
|
844
950
|
for (const each of windows) {
|
|
845
951
|
allWindows.push({
|
|
846
|
-
provider: agentCommand
|
|
952
|
+
provider: agentOfCommand(agentCommand),
|
|
847
953
|
window: each.kind,
|
|
848
954
|
model: each.model,
|
|
849
955
|
percentUsed: each.percent,
|
|
@@ -1305,10 +1411,17 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
|
|
|
1305
1411
|
notes.push(await prepareSkillIndex(config, skill, local, run, cwd, baseCommit));
|
|
1306
1412
|
}
|
|
1307
1413
|
attached.push({ skill, local });
|
|
1414
|
+
// R303. A required server says so where the person is looking, as the
|
|
1415
|
+
// expert line does. Whether a STAGE is actually asked for it is decided at
|
|
1416
|
+
// spawn — a read-only stage cannot call it and is not nudged for it.
|
|
1417
|
+
const must = skill.mode === 'REQUIRED'
|
|
1418
|
+
? ' This project REQUIRES it: if a stage finishes without calling one of its tools, the '
|
|
1419
|
+
+ 'session will be told.'
|
|
1420
|
+
: '';
|
|
1308
1421
|
notes.push(`${skill.name ?? skill.key} is available to this session as `
|
|
1309
1422
|
+ `${skill.toolPrefix ?? `mcp__${skill.serverName}`}. The first call still asks a person — `
|
|
1310
1423
|
+ `allow ${skill.toolPrefix ?? `mcp__${skill.serverName}`} for the session to cover the `
|
|
1311
|
-
+
|
|
1424
|
+
+ `rest.${must}`);
|
|
1312
1425
|
}
|
|
1313
1426
|
return { attached, notes };
|
|
1314
1427
|
}
|
|
@@ -1333,9 +1446,13 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
|
|
|
1333
1446
|
* It does not reproduce on macOS at any output size, so this is not something
|
|
1334
1447
|
* a local run will ever warn about. Do not put `exit` back.
|
|
1335
1448
|
*/
|
|
1336
|
-
function git(cwd, args) {
|
|
1449
|
+
function git(cwd, args, { timeoutMs = 0 } = {}) {
|
|
1337
1450
|
return new Promise((resolvePromise, reject) => {
|
|
1338
|
-
const child = spawn('git', args, {
|
|
1451
|
+
const child = spawn('git', args, {
|
|
1452
|
+
cwd,
|
|
1453
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1454
|
+
...(timeoutMs ? { timeout: timeoutMs } : {}),
|
|
1455
|
+
});
|
|
1339
1456
|
let out = '';
|
|
1340
1457
|
let err = '';
|
|
1341
1458
|
child.stdout.on('data', (chunk) => (out += chunk));
|
|
@@ -1768,11 +1885,12 @@ const SURVEY_FILE_CAP = 20;
|
|
|
1768
1885
|
* arrive at the console as the same answer.
|
|
1769
1886
|
*
|
|
1770
1887
|
* **The order of this list matters, and it is `project.workspaces` order.**
|
|
1771
|
-
* R71 has the console work out which checkout a run would be given by
|
|
1772
|
-
* the first entry here
|
|
1773
|
-
*
|
|
1774
|
-
* the wire. Sorting or de-duplicating this on the
|
|
1775
|
-
* warning onto a checkout the run is not headed for,
|
|
1888
|
+
* R71/i258 has the console work out which checkout a run would be given by
|
|
1889
|
+
* taking the first free entry here whose `dirty` is 0, falling back to the
|
|
1890
|
+
* first free entry if none is known clean — the queue gate's `pickWorkspace`,
|
|
1891
|
+
* read from the other side of the wire. Sorting or de-duplicating this on the
|
|
1892
|
+
* way out would move the warning onto a checkout the run is not headed for,
|
|
1893
|
+
* and nothing would fail.
|
|
1776
1894
|
*/
|
|
1777
1895
|
/**
|
|
1778
1896
|
* One porcelain line, split into its status and its path.
|
|
@@ -1826,6 +1944,31 @@ async function surveyWorkingCopies(config) {
|
|
|
1826
1944
|
return survey;
|
|
1827
1945
|
}
|
|
1828
1946
|
|
|
1947
|
+
/** Bounds the claim-time `git status` probe — see `looksClean`. */
|
|
1948
|
+
const CLEAN_PROBE_MS = 5000;
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Is `path`'s tree clean, read fresh, right now — i258.
|
|
1952
|
+
*
|
|
1953
|
+
* Three-valued on purpose: `true` clean, `false` dirty, `null` could not
|
|
1954
|
+
* tell (no `.git`, a timeout, any failure). `null` is grouped with dirty by
|
|
1955
|
+
* every caller — "I could not look" is not "it was clean" — so a probe that
|
|
1956
|
+
* cannot answer degrades to the old first-free behaviour rather than
|
|
1957
|
+
* starving the queue on a wedged filesystem.
|
|
1958
|
+
*
|
|
1959
|
+
* This does not reuse `surveyWorkingCopies`'s reading. That one runs on the
|
|
1960
|
+
* heartbeat and is not kept anywhere; caching it here would add a second
|
|
1961
|
+
* source of truth for a fact `pickWorkspace` is about to establish anyway,
|
|
1962
|
+
* and a 30-second-old reading reproduces R71/i258 in a smaller window.
|
|
1963
|
+
* `prepareWorkingCopy` runs the identical `git status --porcelain` seconds
|
|
1964
|
+
* after this one.
|
|
1965
|
+
*/
|
|
1966
|
+
async function looksClean(path) {
|
|
1967
|
+
const porcelain = await git(path, ['status', '--porcelain'], { timeoutMs: CLEAN_PROBE_MS })
|
|
1968
|
+
.catch(() => null);
|
|
1969
|
+
return porcelain === null ? null : porcelain === '';
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1829
1972
|
/**
|
|
1830
1973
|
* What every checkout on this machine holds — R87.
|
|
1831
1974
|
*
|
|
@@ -2239,6 +2382,376 @@ async function surveyGit(config) {
|
|
|
2239
2382
|
}
|
|
2240
2383
|
}
|
|
2241
2384
|
|
|
2385
|
+
/**
|
|
2386
|
+
* Scans the run's branch with SonarQube and tells the platform — R300.
|
|
2387
|
+
*
|
|
2388
|
+
* Runs AFTER the TEST stage settles and BEFORE the next stage begins, on the
|
|
2389
|
+
* workspace the run just wrote, and it is the DAEMON that runs it: a session
|
|
2390
|
+
* that reported its own gate could lie about it (RunnerLimit's argument), so
|
|
2391
|
+
* the scanner is a subprocess this process starts and nothing the spawned
|
|
2392
|
+
* `claude` can call. Only a staged run with a TEST stage gets here — a
|
|
2393
|
+
* lifecycle without one, an unstaged run, a run whose gate refused TEST twice
|
|
2394
|
+
* or one that reported done inside TEST is not scanned, because there was no
|
|
2395
|
+
* settled TEST stage to scan after.
|
|
2396
|
+
*
|
|
2397
|
+
* The shape is `surveyGit`'s: every failure is CAUGHT and reported as an
|
|
2398
|
+
* `error` reading, never thrown to the walk — a failed scan never fails a
|
|
2399
|
+
* run. The two silent exits are the two ways the feature is off: no `quality`
|
|
2400
|
+
* block on this machine (nothing is logged; the operator never asked), and no
|
|
2401
|
+
* key on the project (logged, because the machine did ask and the project is
|
|
2402
|
+
* what is missing). Everything after that reports SOMETHING, so that "could
|
|
2403
|
+
* not scan" and "did not scan" never reach the page as the same answer.
|
|
2404
|
+
*
|
|
2405
|
+
* What is scanned is the WORKING TREE, which after TEST may hold edits the
|
|
2406
|
+
* session had not committed; `headSha` is HEAD, and says where the tree
|
|
2407
|
+
* started rather than that the tree equals it.
|
|
2408
|
+
*
|
|
2409
|
+
* The scanner's working directory is a temp dir OUTSIDE the checkout,
|
|
2410
|
+
* passed as `-Dsonar.working.directory=`: `.scannerwork/` inside it would
|
|
2411
|
+
* show up in the working-copy watcher and in `resetWorkspace`'s `git clean`
|
|
2412
|
+
* list — R76's `.codegraph` lesson, see `excludeLocally`. The token goes in
|
|
2413
|
+
* the child's ENVIRONMENT and never on its argv, which `ps` would show.
|
|
2414
|
+
*
|
|
2415
|
+
* R316: when the project SAYS its server refuses branches (`run.sonarBranches
|
|
2416
|
+
* === 'REFUSED'`, and only when this machine scans the same server the claim
|
|
2417
|
+
* names — see `trimSlash`), the branch attempt is skipped outright and the
|
|
2418
|
+
* machine's `whenBranchesRefused` runs straight away. Otherwise the branch
|
|
2419
|
+
* attempt still runs first and the refusal is still learnt from its failure,
|
|
2420
|
+
* exactly as before R316 — a project that has not said, or a machine
|
|
2421
|
+
* scanning a different server, gets today's behaviour unchanged.
|
|
2422
|
+
*/
|
|
2423
|
+
async function scanQuality(config, run, cwd, branch) {
|
|
2424
|
+
const slug = run.projectSlug;
|
|
2425
|
+
const quality = config.projects[slug]?.quality;
|
|
2426
|
+
if (!quality) return;
|
|
2427
|
+
if (!run.sonarProjectKey) {
|
|
2428
|
+
log(` quality: ${slug} has no SonarQube key — not scanned`);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
const key = run.sonarProjectKey;
|
|
2432
|
+
const report = async (reading) => {
|
|
2433
|
+
await api(config, `/api/runners/${config.runnerId}/quality/${slug}`, {
|
|
2434
|
+
method: 'POST',
|
|
2435
|
+
body: { runId: run.id, branch, ...reading },
|
|
2436
|
+
}).catch((failure) => log(` could not report the quality of ${branch}: ${failure.message}`));
|
|
2437
|
+
if (reading.error) {
|
|
2438
|
+
log(` quality: could not scan ${branch}: ${reading.error}`);
|
|
2439
|
+
} else {
|
|
2440
|
+
const problems = (reading.newBugs ?? 0) + (reading.newVulnerabilities ?? 0)
|
|
2441
|
+
+ (reading.newSecurityHotspots ?? 0) + (reading.newCodeSmells ?? 0);
|
|
2442
|
+
log(` quality: ${branch} gate ${reading.gate ?? 'NONE'} · ${problems} new problem${problems === 1 ? '' : 's'}`
|
|
2443
|
+
+ (reading.warning ? ` · ${reading.warning}` : ''));
|
|
2444
|
+
}
|
|
2445
|
+
};
|
|
2446
|
+
|
|
2447
|
+
const url = quality.url ?? run.sonarUrl;
|
|
2448
|
+
if (!url) {
|
|
2449
|
+
return report({ error: 'No SonarQube server: the platform has none configured and the quality block names no url.' });
|
|
2450
|
+
}
|
|
2451
|
+
const token = process.env[quality.tokenEnv];
|
|
2452
|
+
if (!token) {
|
|
2453
|
+
return report({ error: `The scanner token is not set: export ${quality.tokenEnv} on this machine.` });
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
// R316: the project's own word on whether its server refuses branches is
|
|
2457
|
+
// trusted only when this machine scans THAT server — a machine whose own
|
|
2458
|
+
// `quality.url` names a different one (a LAN hostname the platform cannot
|
|
2459
|
+
// reach) knows nothing about what that server does, and tries the branch
|
|
2460
|
+
// scan first, exactly as if the project had said nothing.
|
|
2461
|
+
const claimSpeaks = !quality.url || trimSlash(quality.url) === trimSlash(run.sonarUrl ?? '');
|
|
2462
|
+
const knownRefused = claimSpeaks && run.sonarBranches === 'REFUSED';
|
|
2463
|
+
|
|
2464
|
+
let headSha = null;
|
|
2465
|
+
let workDir = null;
|
|
2466
|
+
try {
|
|
2467
|
+
headSha = await git(cwd, ['rev-parse', 'HEAD']);
|
|
2468
|
+
workDir = await mkdtemp(join(tmpdir(), 'cawdev-sonar-'));
|
|
2469
|
+
const deadline = Date.now() + quality.timeoutSeconds * 1000;
|
|
2470
|
+
const remaining = () => Math.max(1000, deadline - Date.now());
|
|
2471
|
+
const scannerArgs = (withBranch) => [
|
|
2472
|
+
`-Dsonar.host.url=${url}`,
|
|
2473
|
+
`-Dsonar.projectKey=${key}`,
|
|
2474
|
+
...(withBranch ? [`-Dsonar.branch.name=${branch}`] : []),
|
|
2475
|
+
`-Dsonar.scm.revision=${headSha}`,
|
|
2476
|
+
`-Dsonar.working.directory=${workDir}`,
|
|
2477
|
+
];
|
|
2478
|
+
const env = { ...process.env, SONAR_TOKEN: token };
|
|
2479
|
+
|
|
2480
|
+
if (knownRefused) {
|
|
2481
|
+
if (quality.whenBranchesRefused === 'skip') {
|
|
2482
|
+
return report({
|
|
2483
|
+
headSha,
|
|
2484
|
+
error: "This project's SonarQube server does not analyse branches (Community Edition, set on the "
|
|
2485
|
+
+ 'project). Set "whenBranchesRefused": "project" (a scratch SonarQube project per branch) or '
|
|
2486
|
+
+ '"whenBranchesRefused": "main" in this machine\'s quality block.',
|
|
2487
|
+
});
|
|
2488
|
+
}
|
|
2489
|
+
if (quality.whenBranchesRefused === 'project') {
|
|
2490
|
+
// `await`, not `return`: a rejection has to be caught by THIS
|
|
2491
|
+
// function's own try/catch, which `return somePromise` would skip —
|
|
2492
|
+
// control leaves before the promise settles.
|
|
2493
|
+
return await scanAsScratchProject(
|
|
2494
|
+
{ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
|
|
2495
|
+
}
|
|
2496
|
+
// 'main', and known refused: no reason to run the branch scan first.
|
|
2497
|
+
const ran = await runSkillCommand(quality.command, scannerArgs(false), { cwd, env, timeoutMs: remaining() });
|
|
2498
|
+
if (!ran.ok) {
|
|
2499
|
+
if (/ENOENT/.test(ran.said)) {
|
|
2500
|
+
return report({ headSha, error: `${quality.command} is not installed on this machine (spawn ENOENT).` });
|
|
2501
|
+
}
|
|
2502
|
+
return report({ headSha, error: ran.said.split('\n')[0] || 'the scanner failed and said nothing' });
|
|
2503
|
+
}
|
|
2504
|
+
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2505
|
+
const askServer = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
|
|
2506
|
+
const settled = await waitForAnalysis(askServer, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2507
|
+
return await reportBranchReading({
|
|
2508
|
+
ask: askServer, reportTask, settled, key, branch, analysedAs: 'MAIN',
|
|
2509
|
+
warning: "This server does not analyse branches; the branch was analysed as the project's main analysis.",
|
|
2510
|
+
headSha, report,
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// Today's behaviour: try the branch first, and learn what the server is
|
|
2515
|
+
// from a failure.
|
|
2516
|
+
let analysedAs = 'BRANCH';
|
|
2517
|
+
let warning = null;
|
|
2518
|
+
let ran = await runSkillCommand(quality.command, scannerArgs(true),
|
|
2519
|
+
{ cwd, env, timeoutMs: remaining() });
|
|
2520
|
+
if (!ran.ok) {
|
|
2521
|
+
if (/ENOENT/.test(ran.said)) {
|
|
2522
|
+
return report({ headSha, error: `${quality.command} is not installed on this machine (spawn ENOENT).` });
|
|
2523
|
+
}
|
|
2524
|
+
if (/sonar\.branch\.name.*(Developer Edition|above is required)/is.test(ran.said)) {
|
|
2525
|
+
if (quality.whenBranchesRefused === 'project') {
|
|
2526
|
+
return await scanAsScratchProject(
|
|
2527
|
+
{ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
|
|
2528
|
+
}
|
|
2529
|
+
if (quality.whenBranchesRefused !== 'main') {
|
|
2530
|
+
return report({
|
|
2531
|
+
headSha,
|
|
2532
|
+
error: 'This SonarQube server does not analyse branches (Community Edition). Set '
|
|
2533
|
+
+ '"whenBranchesRefused": "project" (a scratch SonarQube project per branch) or '
|
|
2534
|
+
+ '"whenBranchesRefused": "main" in the quality block to analyse it as the main analysis instead '
|
|
2535
|
+
+ "— or set this project's SonarQube-branches setting under Settings so this is known before the "
|
|
2536
|
+
+ 'scan runs.',
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
// The operator opted in: once more, as the project's main analysis.
|
|
2540
|
+
analysedAs = 'MAIN';
|
|
2541
|
+
warning = "This server does not analyse branches; the branch was analysed as the project's main analysis.";
|
|
2542
|
+
ran = await runSkillCommand(quality.command, scannerArgs(false),
|
|
2543
|
+
{ cwd, env, timeoutMs: remaining() });
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
if (!ran.ok) {
|
|
2547
|
+
return report({ headSha, error: ran.said.split('\n')[0] || 'the scanner failed and said nothing' });
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// What the scanner left behind: where the server queued the analysis.
|
|
2551
|
+
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2552
|
+
const askServer = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
|
|
2553
|
+
const settled = await waitForAnalysis(askServer, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2554
|
+
await reportBranchReading({ ask: askServer, reportTask, settled, key, branch, analysedAs, warning, headSha, report });
|
|
2555
|
+
} catch (failure) {
|
|
2556
|
+
await report({ headSha, error: failure.message.split('\n')[0] });
|
|
2557
|
+
} finally {
|
|
2558
|
+
if (workDir) {
|
|
2559
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/** Without a trailing slash, so two URLs that differ only by one still compare equal. */
|
|
2565
|
+
function trimSlash(url) {
|
|
2566
|
+
return url.replace(/\/$/, '');
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
/** A GET against the SonarQube server a scan just reported to, authorised with the scanner's own token. */
|
|
2570
|
+
function sonarAsker(serverUrl, token) {
|
|
2571
|
+
return async (path) => {
|
|
2572
|
+
const response = await fetch(`${serverUrl}${path}`, { headers: { authorization: `Bearer ${token}` } });
|
|
2573
|
+
if (!response.ok) {
|
|
2574
|
+
throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
|
|
2575
|
+
}
|
|
2576
|
+
return response.json();
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
/**
|
|
2581
|
+
* Waits for a queued analysis to settle — shared by every scan path, BRANCH,
|
|
2582
|
+
* MAIN and PROJECT alike. Throws rather than reports: every caller runs
|
|
2583
|
+
* inside `scanQuality`'s own try/catch, which turns it into an error reading.
|
|
2584
|
+
*/
|
|
2585
|
+
async function waitForAnalysis(ask, ceTaskId, deadline, timeoutSeconds) {
|
|
2586
|
+
let settled = null;
|
|
2587
|
+
while (Date.now() < deadline) {
|
|
2588
|
+
const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`);
|
|
2589
|
+
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
|
|
2590
|
+
settled = current;
|
|
2591
|
+
break;
|
|
2592
|
+
}
|
|
2593
|
+
await new Promise((wake) => setTimeout(wake, 3000));
|
|
2594
|
+
}
|
|
2595
|
+
if (!settled) {
|
|
2596
|
+
throw new Error(`SonarQube had not finished analysing after ${timeoutSeconds}s.`);
|
|
2597
|
+
}
|
|
2598
|
+
if (settled.status !== 'SUCCESS') {
|
|
2599
|
+
throw new Error(settled.errorMessage ?? `SonarQube ${settled.status.toLowerCase()} the analysis.`);
|
|
2600
|
+
}
|
|
2601
|
+
return settled;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
/** The gate and the new-code measures, for a BRANCH or MAIN reading — never for PROJECT, which has neither. */
|
|
2605
|
+
async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report }) {
|
|
2606
|
+
const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
|
|
2607
|
+
const metrics = 'new_bugs,new_vulnerabilities,new_security_hotspots,new_code_smells,new_coverage';
|
|
2608
|
+
const { component } = await ask(`/api/measures/component?component=${encodeURIComponent(key)}`
|
|
2609
|
+
+ `&metricKeys=${metrics}${analysedAs === 'BRANCH' ? `&branch=${encodeURIComponent(branch)}` : ''}`);
|
|
2610
|
+
const measure = (metric) => {
|
|
2611
|
+
const found = (component?.measures ?? []).find((each) => each.metric === metric);
|
|
2612
|
+
// New-code values sit in `period.value` — `periods[0].value` on older servers.
|
|
2613
|
+
const value = found?.period?.value ?? found?.periods?.[0]?.value ?? found?.value;
|
|
2614
|
+
return value === undefined || value === null || value === '' ? null : Number(value);
|
|
2615
|
+
};
|
|
2616
|
+
const integer = (metric) => {
|
|
2617
|
+
const value = measure(metric);
|
|
2618
|
+
return value === null ? null : Math.round(value);
|
|
2619
|
+
};
|
|
2620
|
+
|
|
2621
|
+
await report({
|
|
2622
|
+
headSha,
|
|
2623
|
+
gate: projectStatus?.status ?? 'NONE',
|
|
2624
|
+
analysedAs,
|
|
2625
|
+
newBugs: integer('new_bugs'),
|
|
2626
|
+
newVulnerabilities: integer('new_vulnerabilities'),
|
|
2627
|
+
newSecurityHotspots: integer('new_security_hotspots'),
|
|
2628
|
+
newCodeSmells: integer('new_code_smells'),
|
|
2629
|
+
newCoverage: measure('new_coverage'),
|
|
2630
|
+
dashboardUrl: reportTask.dashboardUrl ?? null,
|
|
2631
|
+
warning,
|
|
2632
|
+
});
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
/**
|
|
2636
|
+
* SonarQube's own pages, `ps=500` at a time, up to the server's `p × ps`
|
|
2637
|
+
* ceiling worth trying — R313's `LIST_PAGES` argument, in the scanner
|
|
2638
|
+
* instead of the reader. `paging.total` (hotspots) or `total` (issues) is
|
|
2639
|
+
* wherever the server put it.
|
|
2640
|
+
*/
|
|
2641
|
+
async function pagedSearch(ask, pathWithQuery, itemsKey) {
|
|
2642
|
+
const items = [];
|
|
2643
|
+
for (let page = 1; page <= 20; page += 1) {
|
|
2644
|
+
const body = await ask(`${pathWithQuery}&ps=500&p=${page}`);
|
|
2645
|
+
const list = body[itemsKey] ?? [];
|
|
2646
|
+
items.push(...list);
|
|
2647
|
+
const total = body.paging?.total ?? body.total ?? items.length;
|
|
2648
|
+
if (items.length >= total || list.length === 0) {
|
|
2649
|
+
break;
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
return items;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/** `sha1(text)`'s first six hex characters — enough to keep a scratch key readable and still collision-free per branch. */
|
|
2656
|
+
function shortHash(text) {
|
|
2657
|
+
return createHash('sha1').update(text).digest('hex').slice(0, 6);
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
/**
|
|
2661
|
+
* The Community Edition fallback that never touches the project's own
|
|
2662
|
+
* dashboard — R316: the branch is analysed as its own scratch SonarQube
|
|
2663
|
+
* project, `<key>:branch:<branch>-<hash>`, overwritten on each run of the
|
|
2664
|
+
* branch. A fresh project has no new-code period to compare against, so
|
|
2665
|
+
* "new" is redefined as "on a line this branch changed since it left its
|
|
2666
|
+
* base" — {@link changedLines} against the merge-base — and the gate is
|
|
2667
|
+
* always `NONE`: a quality gate over a whole scratch project would judge
|
|
2668
|
+
* code this branch did not write.
|
|
2669
|
+
*/
|
|
2670
|
+
async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report }) {
|
|
2671
|
+
const baseName = run.baseBranch ?? run.defaultBranch ?? 'main';
|
|
2672
|
+
const ref = await firstRef(cwd, [`origin/${baseName}`, baseName]);
|
|
2673
|
+
const mergeBase = ref ? await git(cwd, ['merge-base', 'HEAD', ref]).catch(() => null) : null;
|
|
2674
|
+
if (!mergeBase) {
|
|
2675
|
+
return report({ headSha, error: `could not find where ${branch} left ${baseName}` });
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
const scratchKey = `${key}:branch:${branch.replace(/[^A-Za-z0-9_.-]/g, '_')}-${shortHash(branch)}`;
|
|
2679
|
+
const env = { ...process.env, SONAR_TOKEN: token };
|
|
2680
|
+
const args = [
|
|
2681
|
+
`-Dsonar.host.url=${url}`,
|
|
2682
|
+
`-Dsonar.projectKey=${scratchKey}`,
|
|
2683
|
+
`-Dsonar.projectName=${key} · ${branch}`,
|
|
2684
|
+
`-Dsonar.scm.revision=${headSha}`,
|
|
2685
|
+
`-Dsonar.working.directory=${workDir}`,
|
|
2686
|
+
];
|
|
2687
|
+
const ran = await runSkillCommand(quality.command, args,
|
|
2688
|
+
{ cwd, env, timeoutMs: Math.max(1000, deadline - Date.now()) });
|
|
2689
|
+
if (!ran.ok) {
|
|
2690
|
+
if (/ENOENT/.test(ran.said)) {
|
|
2691
|
+
throw new Error(`${quality.command} is not installed on this machine (spawn ENOENT).`);
|
|
2692
|
+
}
|
|
2693
|
+
throw new Error(ran.said.split('\n')[0] || 'the scanner failed and said nothing');
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2697
|
+
const ask = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
|
|
2698
|
+
await waitForAnalysis(ask, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2699
|
+
|
|
2700
|
+
const changed = await changedLines(cwd, mergeBase);
|
|
2701
|
+
const counted = (component, line) => {
|
|
2702
|
+
const path = component.slice(scratchKey.length + 1);
|
|
2703
|
+
const entry = changed.get(path);
|
|
2704
|
+
if (!entry) return false;
|
|
2705
|
+
if (entry === 'ALL') return true;
|
|
2706
|
+
// A file-level finding (no line) counts when the file itself changed.
|
|
2707
|
+
if (line === undefined || line === null) return true;
|
|
2708
|
+
return entry.has(line);
|
|
2709
|
+
};
|
|
2710
|
+
|
|
2711
|
+
const encodedKey = encodeURIComponent(scratchKey);
|
|
2712
|
+
const issues = await pagedSearch(ask,
|
|
2713
|
+
`/api/issues/search?componentKeys=${encodedKey}&resolved=false&types=BUG,VULNERABILITY,CODE_SMELL`, 'issues');
|
|
2714
|
+
const hotspots = await pagedSearch(ask,
|
|
2715
|
+
`/api/hotspots/search?projectKey=${encodedKey}&status=TO_REVIEW`, 'hotspots');
|
|
2716
|
+
|
|
2717
|
+
let newBugs = 0;
|
|
2718
|
+
let newVulnerabilities = 0;
|
|
2719
|
+
let newCodeSmells = 0;
|
|
2720
|
+
for (const issue of issues) {
|
|
2721
|
+
if (!counted(issue.component, issue.line)) continue;
|
|
2722
|
+
if (issue.type === 'BUG') newBugs += 1;
|
|
2723
|
+
else if (issue.type === 'VULNERABILITY') newVulnerabilities += 1;
|
|
2724
|
+
else if (issue.type === 'CODE_SMELL') newCodeSmells += 1;
|
|
2725
|
+
}
|
|
2726
|
+
const newSecurityHotspots = hotspots.filter((hotspot) => counted(hotspot.component, hotspot.line)).length;
|
|
2727
|
+
|
|
2728
|
+
await report({
|
|
2729
|
+
headSha,
|
|
2730
|
+
gate: 'NONE',
|
|
2731
|
+
analysedAs: 'PROJECT',
|
|
2732
|
+
newBugs,
|
|
2733
|
+
newVulnerabilities,
|
|
2734
|
+
newSecurityHotspots,
|
|
2735
|
+
newCodeSmells,
|
|
2736
|
+
newCoverage: null,
|
|
2737
|
+
dashboardUrl: reportTask.dashboardUrl ?? null,
|
|
2738
|
+
warning: 'This server does not analyse branches; the branch was analysed as its own SonarQube project, '
|
|
2739
|
+
+ 'counting problems on the lines it changed. No gate was computed.',
|
|
2740
|
+
});
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
/** `key=value` lines — the shape of the scanner's `report-task.txt`. */
|
|
2744
|
+
function parseProperties(text) {
|
|
2745
|
+
const parsed = {};
|
|
2746
|
+
for (const line of text.split('\n')) {
|
|
2747
|
+
const at = line.indexOf('=');
|
|
2748
|
+
if (at > 0) {
|
|
2749
|
+
parsed[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
return parsed;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2242
2755
|
/**
|
|
2243
2756
|
* Reads and reports the code map, unless it would say the same thing again.
|
|
2244
2757
|
*
|
|
@@ -2445,7 +2958,10 @@ at what they might want next.
|
|
|
2445
2958
|
${briefLine(run)}
|
|
2446
2959
|
If a decision is genuinely theirs, use the cawdev MCP tool \`ask_user\` and wait.
|
|
2447
2960
|
|
|
2448
|
-
|
|
2961
|
+
${run.branch ? `When what they asked is done, commit it on ${run.branch} — work left
|
|
2962
|
+
uncommitted is work nothing can push or propose. Never commit to or push main.
|
|
2963
|
+
|
|
2964
|
+
` : ''}Their instruction:
|
|
2449
2965
|
|
|
2450
2966
|
${run.openingPrompt}`;
|
|
2451
2967
|
}
|
|
@@ -3091,7 +3607,11 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3091
3607
|
if (!branch) {
|
|
3092
3608
|
result = 'A merge has to name a branch. Nothing was merged.';
|
|
3093
3609
|
} else {
|
|
3094
|
-
|
|
3610
|
+
// R306: whose idea it was. A rule's merge lands with nobody reading
|
|
3611
|
+
// the diff, and the log is where an operator sees that happen.
|
|
3612
|
+
log(` MERGING ${branch} — ${request.requestedByRule
|
|
3613
|
+
? `by ${request.requestedByRule}, nobody is reading this diff`
|
|
3614
|
+
: 'asked for from the console'}`);
|
|
3095
3615
|
// R261. A sprint's branch lands with a merge commit; every other branch
|
|
3096
3616
|
// squashes as it always did. The claim says which.
|
|
3097
3617
|
({ ok, result, failure: failureKind } = await mergePullRequest(cwd, branch, {
|
|
@@ -3123,13 +3643,35 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3123
3643
|
} else {
|
|
3124
3644
|
const base = request.baseBranch ?? request.defaultBranch ?? 'main';
|
|
3125
3645
|
log(` OPENING a pull request for ${branch} → ${base} — asked for from the console`);
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3646
|
+
// A card's branch may never have left this machine — a session whose
|
|
3647
|
+
// project does not push, which is why somebody is asking for the pull
|
|
3648
|
+
// request by hand. A sprint's is on origin already and this is a no-op.
|
|
3649
|
+
const empty = await nothingToPropose(cwd, branch, base);
|
|
3650
|
+
if (empty) {
|
|
3651
|
+
ok = false;
|
|
3652
|
+
result = empty;
|
|
3653
|
+
}
|
|
3654
|
+
const pushed = empty ? { failed: empty } : await pushIfOriginLacks(cwd, branch);
|
|
3655
|
+
if (pushed.failed) {
|
|
3656
|
+
ok = false;
|
|
3657
|
+
result = pushed.failed;
|
|
3658
|
+
} else {
|
|
3659
|
+
({ ok, result } = await openPullRequestFor(cwd, {
|
|
3660
|
+
branch,
|
|
3661
|
+
base,
|
|
3662
|
+
title: request.title?.trim() || branch,
|
|
3663
|
+
// A sprint's title is its ref and name ("S1 Notifications"); a
|
|
3664
|
+
// card's is "R3 — …" (RunnerController reads either at claim).
|
|
3665
|
+
body: /^S\d+ /.test(request.title ?? '')
|
|
3666
|
+
? `Opened by cawdev for the sprint branch \`${branch}\` — ${request.title.trim()}.\n\n`
|
|
3667
|
+
+ `Merging it lands every card of the sprint on ${base}.`
|
|
3668
|
+
: `Opened by cawdev for \`${branch}\`${request.title ? ` — ${request.title.trim()}` : ''}.`,
|
|
3669
|
+
}));
|
|
3670
|
+
// The URL stays the FIRST line: the platform reads it off there.
|
|
3671
|
+
if (ok && pushed.said) {
|
|
3672
|
+
result = `${result}\n${pushed.said}`;
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3133
3675
|
}
|
|
3134
3676
|
} else if (request.kind === 'CUT_BRANCH') {
|
|
3135
3677
|
// R260. Put the sprint's branch on origin, from the default's tip. A branch
|
|
@@ -3188,8 +3730,12 @@ function reportWorkspaceRequest(config, request, ok, result, failure = null) {
|
|
|
3188
3730
|
* platform holds the tree as it now is by the time the console hears "done".
|
|
3189
3731
|
*/
|
|
3190
3732
|
function watchWorkspaceRequests(config, { afterTheTreeMoved = null } = {}) {
|
|
3191
|
-
|
|
3192
|
-
|
|
3733
|
+
// Read on EVERY request, never captured once: a checkout cloned on demand
|
|
3734
|
+
// (`autoWorkspaces`) joins `project.workspaces` long after this started, and
|
|
3735
|
+
// a set taken at boot refused every request about one — `/pr` on a session
|
|
3736
|
+
// that ran in `saasng-auto-2` came back "This runner does not serve …".
|
|
3737
|
+
const served = () => new Set(
|
|
3738
|
+
Object.values(config.projects).flatMap((project) => project.workspaces ?? []),
|
|
3193
3739
|
);
|
|
3194
3740
|
|
|
3195
3741
|
const poll = async () => {
|
|
@@ -3205,7 +3751,7 @@ function watchWorkspaceRequests(config, { afterTheTreeMoved = null } = {}) {
|
|
|
3205
3751
|
// The same guard, for the same reason: this poll is on an interval, so a
|
|
3206
3752
|
// throw becomes an unhandled rejection and the daemon stops.
|
|
3207
3753
|
for (const request of Array.isArray(requests) ? requests : []) {
|
|
3208
|
-
if (!served.has(request.path)) {
|
|
3754
|
+
if (!served().has(request.path)) {
|
|
3209
3755
|
await reportWorkspaceRequest(config, request, false,
|
|
3210
3756
|
`This runner does not serve ${request.path}.`);
|
|
3211
3757
|
continue;
|
|
@@ -3361,8 +3907,10 @@ function announceRules(rules) {
|
|
|
3361
3907
|
if (!on.length) return;
|
|
3362
3908
|
log(` this project's rules: ${on.join(', ')}`);
|
|
3363
3909
|
if (rules.autoMerge) {
|
|
3364
|
-
|
|
3365
|
-
|
|
3910
|
+
// R306: not "when this run finishes" any more — the platform asks for the
|
|
3911
|
+
// merge when the branch reaches Done on the development board.
|
|
3912
|
+
log(' !! auto_merge is ON: its branch will be merged when it reaches Done on the '
|
|
3913
|
+
+ 'board, with nobody reading it');
|
|
3366
3914
|
}
|
|
3367
3915
|
}
|
|
3368
3916
|
|
|
@@ -3428,6 +3976,35 @@ async function pushBranch(cwd, branch) {
|
|
|
3428
3976
|
* An existing pull request is a success, not a conflict — the rule wanted one
|
|
3429
3977
|
* to exist, and one does.
|
|
3430
3978
|
*/
|
|
3979
|
+
/**
|
|
3980
|
+
* Why there is nothing to open a pull request FOR, or null when there is.
|
|
3981
|
+
*
|
|
3982
|
+
* A branch with no commit beyond its base is not a pull request — GitHub says
|
|
3983
|
+
* "There isn't anything to compare" — and before this `/pr` pushed it anyway
|
|
3984
|
+
* and handed back the compare link, so the person went to GitHub to learn
|
|
3985
|
+
* what this machine already knew. The usual reason is work sitting
|
|
3986
|
+
* UNCOMMITTED in the checkout, so that is counted and said, with where.
|
|
3987
|
+
*
|
|
3988
|
+
* Asked BEFORE anything is pushed, on the OPEN_PR a person asks for (`/pr`).
|
|
3989
|
+
* Not on the `auto_pr` rule: a finished run that committed nothing gets gh's
|
|
3990
|
+
* own refusal there, as it always has. A count that cannot be made (no such
|
|
3991
|
+
* ref, no origin) is not a verdict: null, and the old path decides as it did.
|
|
3992
|
+
*/
|
|
3993
|
+
async function nothingToPropose(cwd, branch, base) {
|
|
3994
|
+
const has = (ref) => git(cwd, ['rev-parse', '--verify', '--quiet', ref]).then(() => true, () => false);
|
|
3995
|
+
const tip = (await has(branch)) ? branch : (await has(`origin/${branch}`)) ? `origin/${branch}` : null;
|
|
3996
|
+
const against = base ? `origin/${base}` : 'origin/HEAD';
|
|
3997
|
+
if (!tip || !(await has(against))) return null;
|
|
3998
|
+
const ahead = Number(await git(cwd, ['rev-list', '--count', `${against}..${tip}`]).catch(() => 'x'));
|
|
3999
|
+
if (!Number.isFinite(ahead) || ahead > 0) return null;
|
|
4000
|
+
const dirty = (await git(cwd, ['status', '--porcelain']).catch(() => '')).split('\n').filter(Boolean).length;
|
|
4001
|
+
const onIt = (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '')) === branch;
|
|
4002
|
+
return `${branch} has no commit beyond ${against.replace(/^origin\//, '')}, so there is nothing to propose.`
|
|
4003
|
+
+ (dirty && onIt
|
|
4004
|
+
? ` ${dirty} file(s) are uncommitted in ${cwd} — commit them, then ask again.`
|
|
4005
|
+
: ' Nothing was pushed.');
|
|
4006
|
+
}
|
|
4007
|
+
|
|
3431
4008
|
async function openPullRequest(cwd, run, title) {
|
|
3432
4009
|
const state = await readPushState(cwd, run.branch).catch(() => null);
|
|
3433
4010
|
if (state === 'NO_REMOTE') {
|
|
@@ -3466,6 +4043,51 @@ async function openPullRequest(cwd, run, title) {
|
|
|
3466
4043
|
* `--base` only when one is given: with no base the argv is byte for byte what
|
|
3467
4044
|
* it was before R260 and the host picks its own default.
|
|
3468
4045
|
*/
|
|
4046
|
+
/**
|
|
4047
|
+
* Pushes `branch` to origin when origin does not have it, or has an older
|
|
4048
|
+
* commit of it — and never otherwise.
|
|
4049
|
+
*
|
|
4050
|
+
* Before a pull request is opened for a branch a session wrote on this
|
|
4051
|
+
* machine: `gh pr create --head` names a branch on the HOST, and one that only
|
|
4052
|
+
* exists here fails with a message about the head that says nothing of why.
|
|
4053
|
+
* Only a fast-forward is pushed. Origin ahead, or diverged, is somebody else's
|
|
4054
|
+
* work on the branch, and pushing over it is not what "open the pull request"
|
|
4055
|
+
* asked for — that case goes to gh as it is and gh says what is wrong. Nothing
|
|
4056
|
+
* here moves the checkout: it is `<branch>:<branch>`, not the current HEAD.
|
|
4057
|
+
*
|
|
4058
|
+
* @returns `{ said }` when it pushed, `{}` when there was nothing to push, and
|
|
4059
|
+
* `{ failed }` when a push was needed and git refused it.
|
|
4060
|
+
*/
|
|
4061
|
+
async function pushIfOriginLacks(cwd, branch) {
|
|
4062
|
+
const local = await git(cwd, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
|
|
4063
|
+
.catch(() => null);
|
|
4064
|
+
if (!local) {
|
|
4065
|
+
return {};
|
|
4066
|
+
}
|
|
4067
|
+
const listed = await git(cwd, ['ls-remote', '--heads', 'origin', branch]).catch(() => null);
|
|
4068
|
+
if (listed === null) {
|
|
4069
|
+
return {};
|
|
4070
|
+
}
|
|
4071
|
+
const remote = listed.split(/\s+/)[0] || null;
|
|
4072
|
+
if (remote === local) {
|
|
4073
|
+
return {};
|
|
4074
|
+
}
|
|
4075
|
+
if (remote) {
|
|
4076
|
+
const behind = await git(cwd, ['merge-base', '--is-ancestor', remote, local])
|
|
4077
|
+
.then(() => true, () => false);
|
|
4078
|
+
if (!behind) {
|
|
4079
|
+
return {};
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
try {
|
|
4083
|
+
await git(cwd, ['push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], { timeoutMs: 120_000 });
|
|
4084
|
+
log(` pushed ${branch} to origin first`);
|
|
4085
|
+
return { said: `pushed ${branch} to origin first` };
|
|
4086
|
+
} catch (failure) {
|
|
4087
|
+
return { failed: `Could not push ${branch} before opening its pull request: ${failure.message}` };
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
|
|
3469
4091
|
async function openPullRequestFor(cwd, { branch, base, title, body }) {
|
|
3470
4092
|
const existing = await findPullRequest(cwd, branch).catch(() => null);
|
|
3471
4093
|
if (existing && !existing.includes('/compare/')) {
|
|
@@ -3480,8 +4102,28 @@ async function openPullRequestFor(cwd, { branch, base, title, body }) {
|
|
|
3480
4102
|
'--body', body,
|
|
3481
4103
|
]);
|
|
3482
4104
|
if (opened.code === 0 && opened.out) {
|
|
3483
|
-
// gh prints the URL and nothing else on success
|
|
3484
|
-
|
|
4105
|
+
// gh prints the URL and nothing else on success — and then the host is
|
|
4106
|
+
// asked whether it is really there, because "asked for" is not "made",
|
|
4107
|
+
// and a URL on the card that 404s is worse than an honest failure.
|
|
4108
|
+
const printed = opened.out.split('\n').pop().trim();
|
|
4109
|
+
const confirmed = await findPullRequest(cwd, branch).catch(() => null);
|
|
4110
|
+
if (confirmed && !confirmed.includes('/compare/')) {
|
|
4111
|
+
return { ok: true, result: confirmed };
|
|
4112
|
+
}
|
|
4113
|
+
return {
|
|
4114
|
+
ok: false,
|
|
4115
|
+
result: `gh said it opened ${printed}, but the host does not show a pull request for ${branch}.`,
|
|
4116
|
+
};
|
|
4117
|
+
}
|
|
4118
|
+
// The one failure somebody can fix in a minute, said with the fix — and the
|
|
4119
|
+
// link to open it by hand meanwhile, which `findPullRequest` builds without gh.
|
|
4120
|
+
if (opened.code === -1 && /not installed/.test(opened.err)) {
|
|
4121
|
+
const compare = await findPullRequest(cwd, branch).catch(() => null);
|
|
4122
|
+
return {
|
|
4123
|
+
ok: false,
|
|
4124
|
+
result: `${opened.err} Install it and sign in (brew install gh && gh auth login), `
|
|
4125
|
+
+ `then ask again${compare ? ` — or open it yourself: ${compare}` : '.'}`,
|
|
4126
|
+
};
|
|
3485
4127
|
}
|
|
3486
4128
|
return { ok: false, result: opened.err || opened.out || 'gh pr create failed without saying why.' };
|
|
3487
4129
|
}
|
|
@@ -3514,7 +4156,26 @@ async function openPullRequestFor(cwd, { branch, base, title, body }) {
|
|
|
3514
4156
|
* convention wants a merge commit for a release branch too, for the same
|
|
3515
4157
|
* reason; release branches are not worked through the development board
|
|
3516
4158
|
* today, and if that changes this is the line that has to change with it.)
|
|
4159
|
+
*
|
|
4160
|
+
* **It waits out GitHub's not-yet-computed mergeability — R306.** The host
|
|
4161
|
+
* computes whether a pull request can merge lazily, and `gh pr merge` seconds
|
|
4162
|
+
* after `gh pr create` — which is exactly when a rule's merge arrives on a
|
|
4163
|
+
* project nobody reads first — can fail for no reason a person can act on.
|
|
4164
|
+
* So before merging, `gh pr view --json mergeable` is asked, and while it says
|
|
4165
|
+
* `UNKNOWN` this waits, bounded (`MERGEABILITY_POLLS` × `mergeabilityWaitMs()`),
|
|
4166
|
+
* for the rule, the board's button and a person's press alike. Any other
|
|
4167
|
+
* answer — `MERGEABLE`, `CONFLICTING`, an error, something that is not JSON —
|
|
4168
|
+
* proceeds at once: the merge itself is what says whether it could, and
|
|
4169
|
+
* `classifyMergeFailure` is unchanged.
|
|
3517
4170
|
*/
|
|
4171
|
+
const MERGEABILITY_POLLS = 6;
|
|
4172
|
+
|
|
4173
|
+
/** Between two mergeability questions. Five seconds, unless the environment says otherwise — `gateTimeoutSeconds`'s shape. */
|
|
4174
|
+
function mergeabilityWaitMs() {
|
|
4175
|
+
const configured = Number(process.env.CAWDEV_MERGEABILITY_WAIT_MS);
|
|
4176
|
+
return Number.isFinite(configured) && configured > 0 ? configured : 5000;
|
|
4177
|
+
}
|
|
4178
|
+
|
|
3518
4179
|
async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
3519
4180
|
const url = await findPullRequest(cwd, branch).catch(() => null);
|
|
3520
4181
|
if (!url || url.includes('/compare/')) {
|
|
@@ -3524,6 +4185,7 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
3524
4185
|
result: 'There is no pull request for this branch to merge. Nothing was merged.',
|
|
3525
4186
|
};
|
|
3526
4187
|
}
|
|
4188
|
+
await waitOutUnknownMergeability(cwd, url, branch);
|
|
3527
4189
|
const mergeCommit = method === 'MERGE_COMMIT';
|
|
3528
4190
|
const merged = await gh(cwd, ['pr', 'merge', url, mergeCommit ? '--merge' : '--squash', '--delete-branch']);
|
|
3529
4191
|
if (merged.code === 0) {
|
|
@@ -3549,6 +4211,33 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
3549
4211
|
};
|
|
3550
4212
|
}
|
|
3551
4213
|
|
|
4214
|
+
/**
|
|
4215
|
+
* While the host has not yet said whether the pull request can merge, wait —
|
|
4216
|
+
* bounded. See `mergePullRequest`. One log line if it waited at all, so a
|
|
4217
|
+
* merge that took thirty seconds says why.
|
|
4218
|
+
*/
|
|
4219
|
+
async function waitOutUnknownMergeability(cwd, url, branch) {
|
|
4220
|
+
let waited = 0;
|
|
4221
|
+
for (let poll = 0; poll < MERGEABILITY_POLLS; poll++) {
|
|
4222
|
+
const asked = await gh(cwd, ['pr', 'view', url, '--json', 'mergeable,mergeStateStatus']);
|
|
4223
|
+
if (asked.code !== 0) return;
|
|
4224
|
+
let view;
|
|
4225
|
+
try {
|
|
4226
|
+
view = JSON.parse(asked.out || '{}');
|
|
4227
|
+
} catch {
|
|
4228
|
+
// Not JSON — a gh that answered something else. Nothing to wait for.
|
|
4229
|
+
return;
|
|
4230
|
+
}
|
|
4231
|
+
if (view.mergeable !== 'UNKNOWN') {
|
|
4232
|
+
if (waited) log(` ${branch}: mergeability known after ${waited}s`);
|
|
4233
|
+
return;
|
|
4234
|
+
}
|
|
4235
|
+
await sleep(mergeabilityWaitMs());
|
|
4236
|
+
waited += mergeabilityWaitMs() / 1000;
|
|
4237
|
+
}
|
|
4238
|
+
log(` ${branch}: the host still has not computed mergeability after ${waited}s; merging anyway`);
|
|
4239
|
+
}
|
|
4240
|
+
|
|
3552
4241
|
/**
|
|
3553
4242
|
* Whether a tag exists ON THE REMOTE — R156.
|
|
3554
4243
|
*
|
|
@@ -4272,8 +4961,11 @@ const PROFILE_TOOLS = {
|
|
|
4272
4961
|
// R198: a plan run on an ISSUE (a confirm) is handed exactly this list. What
|
|
4273
4962
|
// differs is one paragraph of prompt, not a tool — the "ended without a plan"
|
|
4274
4963
|
// note on the issue is the platform's to write, for the reason above.
|
|
4964
|
+
// R302. Less the reads that need the work to exist — `quality_get` — the
|
|
4965
|
+
// same narrowing `toolsForStage` applies to a walk's PLAN stage, by the same
|
|
4966
|
+
// name: a plan run spawned as one process comes through here and not there.
|
|
4275
4967
|
PLAN: (run) => [
|
|
4276
|
-
...READ_ONLY_CAWDEV,
|
|
4968
|
+
...beforeTheWorkExists(READ_ONLY_CAWDEV),
|
|
4277
4969
|
...READ_FILES,
|
|
4278
4970
|
...GIT_READS,
|
|
4279
4971
|
...(run?.entryNumber ? [] : [CARD_WRITE]),
|
|
@@ -4520,7 +5212,10 @@ function stagePrompt(stage, carried) {
|
|
|
4520
5212
|
+ '`git diff` to find out what the work actually changed. A test you could not run is '
|
|
4521
5213
|
+ 'a line in the testbook, not a failure of this stage.'
|
|
4522
5214
|
: 'Run what proves the work. Report what passed and what did not, with the output. A '
|
|
4523
|
-
+ 'failing test is a result, not a failure of this stage.'
|
|
5215
|
+
+ 'failing test is a result, not a failure of this stage. The branch\'s SonarQube gate '
|
|
5216
|
+
+ 'is one call away — `quality_get`, which reads this run\'s branch by default; a red '
|
|
5217
|
+
+ 'gate with new vulnerabilities is a finding to report with the output, not a failure '
|
|
5218
|
+
+ 'to hide.',
|
|
4524
5219
|
MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
|
|
4525
5220
|
+ 'tried and rejected and why, and anything that turned out to matter and would not be '
|
|
4526
5221
|
+ 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
|
|
@@ -4947,7 +5642,9 @@ matters — and stop there. Do not decide the verdict. That is the person's.
|
|
|
4947
5642
|
The findings go on the card and the person decides Done or Not done.
|
|
4948
5643
|
|
|
4949
5644
|
**Read the code.** Start with \`code_map\` and \`file_deps\` — cawdev has already
|
|
4950
|
-
mapped this repository. Then read the work with \`git_reads\` tools.
|
|
5645
|
+
mapped this repository. Then read the work with \`git_reads\` tools. \`quality_get\`
|
|
5646
|
+
gives the branch's SonarQube gate and the project's open findings; a new
|
|
5647
|
+
vulnerability belongs in your comment.
|
|
4951
5648
|
|
|
4952
5649
|
**Check against the Build: and Done when:.** Use \`roadmap_get\` to read the
|
|
4953
5650
|
card's own words, then check the code against them. Point at what is done and
|
|
@@ -5038,13 +5735,39 @@ function writeUserMessage(child, text) {
|
|
|
5038
5735
|
* and the qualified name is the one thing that must be exact. R147 was five
|
|
5039
5736
|
* bugs' worth of evidence that a name a session cannot use is a capability it
|
|
5040
5737
|
* does not have.
|
|
5738
|
+
*
|
|
5739
|
+
* <p>i257. `stillBackgrounded` names the ones that WERE delegated to but only
|
|
5740
|
+
* with a backgrounded `Agent` call — so "has not used it" would be false and
|
|
5741
|
+
* the fix would be wrong: telling a session to call something again that it
|
|
5742
|
+
* already called would read as broken, not as a nudge. Those get the specific
|
|
5743
|
+
* reason instead, because the generic "use it now" is the wrong instruction —
|
|
5744
|
+
* it already did, and doing it the same way again loses the result the same
|
|
5745
|
+
* way.
|
|
5041
5746
|
*/
|
|
5042
|
-
function nudgeText(stage, missing) {
|
|
5043
|
-
const
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5747
|
+
function nudgeText(stage, missing, stillBackgrounded = []) {
|
|
5748
|
+
const neverUsed = missing.filter((each) => !stillBackgrounded.includes(each));
|
|
5749
|
+
const clauses = [
|
|
5750
|
+
neverUsed.length
|
|
5751
|
+
? `has not used ${neverUsed.map((each) => `\`${each}\``).join(', ')}`
|
|
5752
|
+
: null,
|
|
5753
|
+
].filter(Boolean);
|
|
5754
|
+
const opening = clauses.length
|
|
5755
|
+
? `cawdev: this project requires ${missing.map((each) => `\`${each}\``).join(', ')}, and `
|
|
5756
|
+
+ `this ${stage.stage} stage ${clauses.join(', and ')}.`
|
|
5757
|
+
: `cawdev: this project requires ${missing.map((each) => `\`${each}\``).join(', ')}.`;
|
|
5758
|
+
const backgroundedNames = stillBackgrounded.map((each) => `\`${each}\``).join(', ');
|
|
5759
|
+
const backgroundedNote = stillBackgrounded.length
|
|
5760
|
+
? `\n\n${backgroundedNames} ${stillBackgrounded.length > 1 ? 'were' : 'was'} delegated to `
|
|
5761
|
+
+ 'with `Agent`, but in the background, and its result has not come back. This stage is '
|
|
5762
|
+
+ 'one process (R112): your turn ending is indistinguishable, to the daemon, from being '
|
|
5763
|
+
+ 'finished, so a backgrounded delegation still in flight when that happens is lost along '
|
|
5764
|
+
+ 'with anything gated on it. Call it again with `run_in_background: false` and wait for '
|
|
5765
|
+
+ 'it in this same turn before finishing.'
|
|
5766
|
+
: '';
|
|
5767
|
+
return `${opening}${backgroundedNote}\n\n`
|
|
5768
|
+
+ 'Use it now — `Skill` with `skill: "<name>"`, `Agent` with `subagent_type: "<name>"`, or '
|
|
5769
|
+
+ 'for an MCP server a call to one of its tools (`mcp__<server>__…`) — and then say what it '
|
|
5770
|
+
+ 'changed about your answer.\n\n'
|
|
5048
5771
|
+ 'If it genuinely does not apply to this work, say so in one sentence and stop. '
|
|
5049
5772
|
+ '**You will not be asked again.**';
|
|
5050
5773
|
}
|
|
@@ -5271,6 +5994,120 @@ function heldWorkspaces() {
|
|
|
5271
5994
|
return held;
|
|
5272
5995
|
}
|
|
5273
5996
|
|
|
5997
|
+
/**
|
|
5998
|
+
* Which checkout an offered coding run gets, or why it must keep waiting —
|
|
5999
|
+
* R71/i258.
|
|
6000
|
+
*
|
|
6001
|
+
* In order: a NAMED checkout (`offered.workspace`) is the only answer once
|
|
6002
|
+
* the branch is bound to it — served or busy, nothing else is considered. A
|
|
6003
|
+
* PREFERRED checkout (`offered.preferredWorkspace`) is taken when free,
|
|
6004
|
+
* dirty or not: it is the checkout the branch last ran in, so uncommitted
|
|
6005
|
+
* work there is plausibly that branch's own leftovers, and routing away from
|
|
6006
|
+
* it risks abandoning work rather than protecting it. That is pin-shaped
|
|
6007
|
+
* reasoning, and it is deliberate that cleanliness plays no part in this
|
|
6008
|
+
* rung — do not "fix" it in passing.
|
|
6009
|
+
*
|
|
6010
|
+
* The one exception is `offered.waitForClean` — R270. Once the person has
|
|
6011
|
+
* said "don't touch dirty", the idea that the leftovers are probably this
|
|
6012
|
+
* branch's own no longer applies: the preferred checkout is taken only when
|
|
6013
|
+
* it reads clean, and otherwise the run falls through to the free scan below
|
|
6014
|
+
* exactly as if it had no preference at all.
|
|
6015
|
+
*
|
|
6016
|
+
* Below that, a checkout is free game and cleanliness finally matters: the
|
|
6017
|
+
* first free path that reads CLEAN wins, and only when nothing free is known
|
|
6018
|
+
* clean does the first free path win regardless — today's behaviour,
|
|
6019
|
+
* unchanged, so the existing refusal in `prepareWorkingCopy` and the
|
|
6020
|
+
* console's *Start anyway* flow still work exactly as they did. With
|
|
6021
|
+
* `waitForClean` set, that last fallback does not happen: the run keeps
|
|
6022
|
+
* waiting instead of taking a dirty checkout.
|
|
6023
|
+
*
|
|
6024
|
+
* Cleanliness is read fresh, right here, rather than from
|
|
6025
|
+
* `surveyWorkingCopies`'s heartbeat reading — see `looksClean`. `probed` is a
|
|
6026
|
+
* `Map` the caller creates once per poll pass, so several offers of one
|
|
6027
|
+
* project only ask each checkout once.
|
|
6028
|
+
*
|
|
6029
|
+
* `surveyGit` and `checkMerges` use the same `workspaces.find((path) =>
|
|
6030
|
+
* !held.has(path))` idiom this replaces, and must NOT be unified with this:
|
|
6031
|
+
* they read refs, never the tree, so which checkout they pick is a different
|
|
6032
|
+
* question and dirtiness does not bear on it.
|
|
6033
|
+
*/
|
|
6034
|
+
async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0) {
|
|
6035
|
+
const pinned = offered.workspace ?? null;
|
|
6036
|
+
if (pinned) {
|
|
6037
|
+
if (!project.workspaces.includes(pinned)) {
|
|
6038
|
+
// Not one of ours. The platform pins a machine as well as a path, so
|
|
6039
|
+
// this means the config changed under a live branch — which is a
|
|
6040
|
+
// thing to say out loud rather than quietly ignore.
|
|
6041
|
+
return { workspace: null, why: `its branch is in ${pinned}, which this machine does not serve` };
|
|
6042
|
+
}
|
|
6043
|
+
if (held.has(pinned)) {
|
|
6044
|
+
return {
|
|
6045
|
+
workspace: null,
|
|
6046
|
+
why: `waiting for ${pinned}, which is busy — the branch has work only that checkout holds`,
|
|
6047
|
+
};
|
|
6048
|
+
}
|
|
6049
|
+
return { workspace: pinned, why: null };
|
|
6050
|
+
}
|
|
6051
|
+
|
|
6052
|
+
const waitForClean = offered.waitForClean === true;
|
|
6053
|
+
const preferred = offered.preferredWorkspace ?? null;
|
|
6054
|
+
if (preferred && project.workspaces.includes(preferred) && !held.has(preferred)) {
|
|
6055
|
+
if (!waitForClean) {
|
|
6056
|
+
return { workspace: preferred, why: null };
|
|
6057
|
+
}
|
|
6058
|
+
if (!probed.has(preferred)) probed.set(preferred, await looksClean(preferred));
|
|
6059
|
+
if (probed.get(preferred) === true) return { workspace: preferred, why: null };
|
|
6060
|
+
}
|
|
6061
|
+
|
|
6062
|
+
const free = project.workspaces.filter((path) => !held.has(path));
|
|
6063
|
+
if (!free.length) {
|
|
6064
|
+
// Every checkout is busy. Where the machine allows it, make one more
|
|
6065
|
+
// rather than wait — the second coding session on a project starting at
|
|
6066
|
+
// once is the whole point of `autoWorkspaces`.
|
|
6067
|
+
const first = project.workspaces[0];
|
|
6068
|
+
const made = project.workspaces.filter((path) => isAutoWorkspace(first, path));
|
|
6069
|
+
if (made.length < autoMax) {
|
|
6070
|
+
try {
|
|
6071
|
+
log(`every checkout of ${slug} is busy — cloning another beside ${first}`);
|
|
6072
|
+
const path = await createAutoWorkspace(first, project.workspaces);
|
|
6073
|
+
project.workspaces.push(path);
|
|
6074
|
+
log(` made ${path} (${made.length + 1} of ${autoMax} on demand)`);
|
|
6075
|
+
return { workspace: path, why: null };
|
|
6076
|
+
} catch (failure) {
|
|
6077
|
+
log(` could not make another checkout of ${slug}: ${failure.message}`);
|
|
6078
|
+
return { workspace: null, why: `no free workspace in ${slug}, and cloning another failed` };
|
|
6079
|
+
}
|
|
6080
|
+
}
|
|
6081
|
+
return {
|
|
6082
|
+
workspace: null,
|
|
6083
|
+
why: autoMax
|
|
6084
|
+
? `no free workspace in ${slug} (${project.workspaces.length} here, all busy, `
|
|
6085
|
+
+ `and the ${autoMax} this machine may make are made)`
|
|
6086
|
+
: `no free workspace in ${slug} (${project.workspaces.length} here, all busy)`,
|
|
6087
|
+
};
|
|
6088
|
+
}
|
|
6089
|
+
for (const path of free) {
|
|
6090
|
+
if (!probed.has(path)) probed.set(path, await looksClean(path));
|
|
6091
|
+
if (probed.get(path) === true) return { workspace: path, why: null };
|
|
6092
|
+
}
|
|
6093
|
+
// Nothing free reads clean — including anything `looksClean` could not read
|
|
6094
|
+
// at all, which groups with dirty rather than clean.
|
|
6095
|
+
if (waitForClean) {
|
|
6096
|
+
// R270: the person said to wait rather than go ahead on top of somebody
|
|
6097
|
+
// else's work. Keep waiting instead of falling back to the old
|
|
6098
|
+
// first-free rule below.
|
|
6099
|
+
return {
|
|
6100
|
+
workspace: null,
|
|
6101
|
+
why: `waiting for a clean checkout in ${slug} — every free one has uncommitted work or `
|
|
6102
|
+
+ 'could not be read; stash or commit in one, or wait for a running session to finish',
|
|
6103
|
+
};
|
|
6104
|
+
}
|
|
6105
|
+
// Fall back to the old first-free rule verbatim, so the refusal and *Start
|
|
6106
|
+
// anyway* flow still apply to exactly this workspace.
|
|
6107
|
+
log(`every free checkout in ${slug} has uncommitted work — taking ${free[0]}`);
|
|
6108
|
+
return { workspace: free[0], why: null };
|
|
6109
|
+
}
|
|
6110
|
+
|
|
5274
6111
|
function noteQueued(run, why) {
|
|
5275
6112
|
// Once per REASON, not once per run — R70, now that there are two gates a run
|
|
5276
6113
|
// can be held by. A run refused by the machine's cap and then, when a session
|
|
@@ -5312,6 +6149,10 @@ function snapshotRuns() {
|
|
|
5312
6149
|
// Which checkout it is in — R47. On a machine serving three, the label
|
|
5313
6150
|
// alone no longer says where the work is.
|
|
5314
6151
|
workspace: child.cawdevWorkspace ?? null,
|
|
6152
|
+
// Which CLI, and which model it is on — per STAGE on a walked run,
|
|
6153
|
+
// since each stage is its own process with its own model (R112).
|
|
6154
|
+
agent: child.cawdevAgent ?? null,
|
|
6155
|
+
model: child.cawdevModel ?? null,
|
|
5315
6156
|
startedAt: child.cawdevStartedAt ?? null,
|
|
5316
6157
|
});
|
|
5317
6158
|
}
|
|
@@ -5392,6 +6233,19 @@ async function startRun(config, offered, workspace) {
|
|
|
5392
6233
|
defaultBranch = claimed.defaultBranch;
|
|
5393
6234
|
// Whether somebody was shown the uncommitted work and started anyway.
|
|
5394
6235
|
allowDirty = claimed.allowDirty === true;
|
|
6236
|
+
// Or whether the uncommitted work is THIS BRANCH'S OWN: the platform named
|
|
6237
|
+
// the checkout (R86/R87 — a carried-on run's held workspace, or the next
|
|
6238
|
+
// run of a pinned work item) and it is already on the run's branch. R80
|
|
6239
|
+
// holds a failed run's checkout precisely so its work survives, and
|
|
6240
|
+
// refusing that work as "yours, commit it first" turned every Carry on
|
|
6241
|
+
// into a second failure with nothing in the console able to break the loop.
|
|
6242
|
+
if (!allowDirty && offered.workspace && path && writesCodeProfile(run) && run.branch) {
|
|
6243
|
+
const on = await git(resolve(path), ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '');
|
|
6244
|
+
if (on.trim() === run.branch) {
|
|
6245
|
+
allowDirty = true;
|
|
6246
|
+
log(` ${path} is on ${run.branch} already — its uncommitted work is this branch's, kept`);
|
|
6247
|
+
}
|
|
6248
|
+
}
|
|
5395
6249
|
// And what this project has decided happens when the run ends — R40. The
|
|
5396
6250
|
// daemon does not act on this: the platform queues the push, the pull
|
|
5397
6251
|
// request and the merge as actions and `settleActions` performs them. It is
|
|
@@ -5412,6 +6266,17 @@ async function startRun(config, offered, workspace) {
|
|
|
5412
6266
|
if (run.baseBranch) {
|
|
5413
6267
|
log(` cut from ${run.baseBranch} (the sprint's branch)`);
|
|
5414
6268
|
}
|
|
6269
|
+
// R300. Which SonarQube project this is and where — two FACTS the
|
|
6270
|
+
// scanner needs on its command line, learnt with the claim because
|
|
6271
|
+
// reading a project is session-only. No token comes with them; the
|
|
6272
|
+
// scanner runs with this machine's own, from the variable the `quality`
|
|
6273
|
+
// block names. Stored on the run the way `baseBranch` is, so the walk
|
|
6274
|
+
// reads them off `run` after TEST rather than through another parameter.
|
|
6275
|
+
run.sonarProjectKey = claimed.sonarProjectKey ?? null;
|
|
6276
|
+
run.sonarUrl = claimed.sonarUrl ?? null;
|
|
6277
|
+
// R316. Whether the project SAYS its server refuses branches — null is
|
|
6278
|
+
// "not sure, try the branch scan first", today's behaviour.
|
|
6279
|
+
run.sonarBranches = claimed.sonarBranches ?? null;
|
|
5415
6280
|
// R69. Whether this is the same conversation picked back up, and which one.
|
|
5416
6281
|
// Null on an ordinary claim, which is nearly all of them.
|
|
5417
6282
|
resume = claimed.resume ?? null;
|
|
@@ -5652,9 +6517,9 @@ async function skipRest(config, run, lifecycle, from, why) {
|
|
|
5652
6517
|
|
|
5653
6518
|
async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
5654
6519
|
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield) {
|
|
5655
|
-
const spawn = (stage, carried) => spawnAgent(config, run, runToken, cwd, baseCommit,
|
|
5656
|
-
resume, projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle,
|
|
5657
|
-
stage, carried);
|
|
6520
|
+
const spawn = (stage, carried, usage) => spawnAgent(config, run, runToken, cwd, baseCommit,
|
|
6521
|
+
workspace, resume, projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle,
|
|
6522
|
+
shield, stage, carried, usage);
|
|
5658
6523
|
|
|
5659
6524
|
// No lifecycle, or a resume: one process, exactly as before.
|
|
5660
6525
|
//
|
|
@@ -5671,8 +6536,34 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
5671
6536
|
const carried = {};
|
|
5672
6537
|
let last = null;
|
|
5673
6538
|
|
|
6539
|
+
// R305. What the RUN has been credited with so far. Each stage is its own
|
|
6540
|
+
// process with its own cumulative counter, so the base the next child adds
|
|
6541
|
+
// to must move — read once from the claim, it stayed at zero and the platform
|
|
6542
|
+
// ignored every stage after the first as a reading that went backwards, so a
|
|
6543
|
+
// five-stage run's stored total was its largest stage rather than the sum.
|
|
6544
|
+
const spent = { tokensIn: Number(run.tokensIn) || 0, tokensOut: Number(run.tokensOut) || 0 };
|
|
6545
|
+
// And what each STAGE was already credited with, for the same reason. A run
|
|
6546
|
+
// walked a second time in a fresh session — handed off to another machine,
|
|
6547
|
+
// or one that died before the CLI said its session id — re-walks the same
|
|
6548
|
+
// `run_stage` rows, and the claim's total already holds the first walk; a
|
|
6549
|
+
// stage counted from zero here would be kept as max(walk) by the platform
|
|
6550
|
+
// while the run kept the sum. Read once; an older platform, or a stub,
|
|
6551
|
+
// answers without the fields and the base is zero, which is today's number.
|
|
6552
|
+
const stored = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/stages`)
|
|
6553
|
+
.catch(() => []);
|
|
6554
|
+
const creditedTo = (name) => {
|
|
6555
|
+
const row = Array.isArray(stored) ? stored.find((each) => each?.stage === name) : null;
|
|
6556
|
+
return { tokensIn: Number(row?.tokensIn) || 0, tokensOut: Number(row?.tokensOut) || 0 };
|
|
6557
|
+
};
|
|
6558
|
+
|
|
5674
6559
|
for (let at = 0; at < lifecycle.length; at++) {
|
|
5675
6560
|
const stage = lifecycle[at];
|
|
6561
|
+
// R305. What THIS stage's processes have used, across its attempts. Outside
|
|
6562
|
+
// the attempt loop on purpose: `run_stage` is one row per stage and keeps
|
|
6563
|
+
// the larger reading, so a second attempt after a refused gate must report
|
|
6564
|
+
// the first attempt's total plus its own, or the row would show
|
|
6565
|
+
// max(attempt) and the stages would no longer sum to the run.
|
|
6566
|
+
const stageSpent = creditedTo(stage.stage);
|
|
5676
6567
|
// Two attempts at most, and the second only exists because a REFUSED gate
|
|
5677
6568
|
// should cost one cheap stage rather than a whole run. A third would be a
|
|
5678
6569
|
// loop nobody asked for.
|
|
@@ -5681,7 +6572,19 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
5681
6572
|
log(` ${stage.stage}${stage.model ? ` on ${stage.model}` : ''}`
|
|
5682
6573
|
+ `${attempt ? ' (again, after the gate refused it)' : ''}`);
|
|
5683
6574
|
|
|
5684
|
-
|
|
6575
|
+
// Both handed BY REFERENCE and moved below once the child is over. Safe
|
|
6576
|
+
// only because stages run one at a time: the child reads them at each
|
|
6577
|
+
// `result` event, and nothing else touches them while it lives.
|
|
6578
|
+
last = await spawn(stage, carried, { run: spent, stage: stageSpent });
|
|
6579
|
+
|
|
6580
|
+
// Credited before the exit is judged, so a stage that was killed or
|
|
6581
|
+
// skipped still counts what its `result` events said it used.
|
|
6582
|
+
if (last?.usage) {
|
|
6583
|
+
spent.tokensIn += last.usage.input;
|
|
6584
|
+
spent.tokensOut += last.usage.output;
|
|
6585
|
+
stageSpent.tokensIn += last.usage.input;
|
|
6586
|
+
stageSpent.tokensOut += last.usage.output;
|
|
6587
|
+
}
|
|
5685
6588
|
|
|
5686
6589
|
// A clean exit, OR a turn that ended. The second half is the half that
|
|
5687
6590
|
// matters now: `endSession` closes a stage's input when its turn is over
|
|
@@ -5798,6 +6701,15 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
5798
6701
|
|
|
5799
6702
|
carried[stage.stage] = last.text;
|
|
5800
6703
|
|
|
6704
|
+
// R300. The scanner, after TEST settles and before the next stage — on
|
|
6705
|
+
// the tree the tests just ran against, by the daemon and not the session.
|
|
6706
|
+
// AWAITED: the workspace is released in `perform`'s finally, and a
|
|
6707
|
+
// scanner still reading files when the next run's reset runs is the
|
|
6708
|
+
// hazard; the block's `timeoutSeconds` bounds the wait. It never throws.
|
|
6709
|
+
if (stage.stage === 'TEST' && run.branch && writesAnythingProfile(run)) {
|
|
6710
|
+
await scanQuality(config, run, cwd, run.branch);
|
|
6711
|
+
}
|
|
6712
|
+
|
|
5801
6713
|
// R108. The MEMORY stage's output IS the briefing, saved by the DAEMON
|
|
5802
6714
|
// rather than by the session calling a tool. One less thing to trust, and
|
|
5803
6715
|
// one less tool to hand a stage that should not be writing.
|
|
@@ -5908,7 +6820,11 @@ function gateTimeoutSeconds() {
|
|
|
5908
6820
|
async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
5909
6821
|
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
|
|
5910
6822
|
stage = null,
|
|
5911
|
-
carried = null
|
|
6823
|
+
carried = null,
|
|
6824
|
+
// R305. `{ run, stage }`: what the run and this stage had already used
|
|
6825
|
+
// before this child, as the walk counted them. Null on an unstaged run or
|
|
6826
|
+
// a resume, where the claim's own figure is the base and there is no stage.
|
|
6827
|
+
usage = null) {
|
|
5912
6828
|
// R51: what this machine will let a STORED rule cover. The project's rules
|
|
5913
6829
|
// are filtered through it before they go anywhere near a spawn, so the
|
|
5914
6830
|
// platform can narrow what runs here and never widen it.
|
|
@@ -5951,11 +6867,19 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
5951
6867
|
// checked against the real CLI by hand) that it is not this function's
|
|
5952
6868
|
// Claude-specific machinery with a different binary name; it is
|
|
5953
6869
|
// `spawnAntigravity`, entirely.
|
|
6870
|
+
// Codex's own shape — a real sandbox and a per-server tool list, but no
|
|
6871
|
+
// allow-list for the shell — is `spawnCodex`, for the same reason.
|
|
6872
|
+
if ((stage?.agent ?? run.agent) === 'codex') {
|
|
6873
|
+
const codexCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'codex') ?? 'codex';
|
|
6874
|
+
return spawnCodex(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
6875
|
+
skills, stageExperts, readOnlyStage, instincts, briefing, plan, lifecycle, stage, carried,
|
|
6876
|
+
codexCmd, granted, shield, usage);
|
|
6877
|
+
}
|
|
5954
6878
|
if ((stage?.agent ?? run.agent) === 'agy') {
|
|
5955
|
-
const agyCmd = config.agentCommands.find((each) => each
|
|
6879
|
+
const agyCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'agy') ?? 'agy';
|
|
5956
6880
|
return spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
5957
6881
|
skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
|
|
5958
|
-
agyCmd, granted);
|
|
6882
|
+
agyCmd, granted, usage);
|
|
5959
6883
|
}
|
|
5960
6884
|
|
|
5961
6885
|
const ceiling = [
|
|
@@ -6073,10 +6997,31 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6073
6997
|
// required HERE: requiring what the stage was deliberately not given is how
|
|
6074
6998
|
// this would come to widen what a run may do, which is the one thing it must
|
|
6075
6999
|
// not do. The mode is read after the narrowing, always.
|
|
7000
|
+
//
|
|
7001
|
+
// R303. An MCP server can be required too, and the entry comes from
|
|
7002
|
+
// `attached` — what this machine actually spawned — and NEVER from the
|
|
7003
|
+
// claim's list, for exactly the reason above: a server the machine refused
|
|
7004
|
+
// cannot be required of a session that has no such tool. And only when the
|
|
7005
|
+
// stage can reach it: a read-only stage is spawned with `listOnly`, which
|
|
7006
|
+
// strips the permission prompt, and `mcp__<server>__*` is never on its list —
|
|
7007
|
+
// so a PLAN stage told it MUST call `mcp__codegraph__…` would be nudged for
|
|
7008
|
+
// something it was deliberately not given. IMPLEMENT and TEST, and a run
|
|
7009
|
+
// with no lifecycle, keep the prompt and can answer the first call.
|
|
7010
|
+
//
|
|
7011
|
+
// Keyed `<kind>:<name>` on both sides — here and where `used` is filled —
|
|
7012
|
+
// so a server named like a skill cannot satisfy the other's requirement.
|
|
7013
|
+
const serversCanBeReached = !readOnlyStage;
|
|
6076
7014
|
const mustUse = new Set([
|
|
6077
|
-
...stageExperts.filter((each) => each.mode === 'REQUIRED')
|
|
6078
|
-
|
|
7015
|
+
...stageExperts.filter((each) => each.mode === 'REQUIRED')
|
|
7016
|
+
.map((each) => `EXPERT:${qualified(each.key)}`),
|
|
7017
|
+
...skills.filter((each) => each.mode === 'REQUIRED')
|
|
7018
|
+
.map((each) => `SKILL:${qualified(each.key)}`),
|
|
7019
|
+
...(serversCanBeReached ? attached : [])
|
|
7020
|
+
.filter(({ skill }) => skill.mode === 'REQUIRED')
|
|
7021
|
+
.map(({ skill }) => `MCP_SERVER:${skill.serverName}`),
|
|
6079
7022
|
]);
|
|
7023
|
+
/** The transcript's name for a requirement — what the nudge and the log say. */
|
|
7024
|
+
const nameOf = (entry) => entry.slice(entry.indexOf(':') + 1);
|
|
6080
7025
|
|
|
6081
7026
|
// R104/R105. The experts and skills the project turned on, as one plugin in
|
|
6082
7027
|
// the daemon's own directory — see writeRunPlugin for why not the checkout.
|
|
@@ -6216,6 +7161,14 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6216
7161
|
description: each.description ?? each.name ?? each.key,
|
|
6217
7162
|
required: each.mode === 'REQUIRED',
|
|
6218
7163
|
})),
|
|
7164
|
+
// R303. Only a REQUIRED server changes the prompt, and only where the
|
|
7165
|
+
// stage can reach it — the same gate `mustUse` applies, so the prompt and
|
|
7166
|
+
// the nudge never disagree about what a stage owes.
|
|
7167
|
+
mcpServers: attached.map(({ skill }) => ({
|
|
7168
|
+
name: skill.name ?? skill.key,
|
|
7169
|
+
prefix: skill.toolPrefix ?? `mcp__${skill.serverName}`,
|
|
7170
|
+
required: serversCanBeReached && skill.mode === 'REQUIRED',
|
|
7171
|
+
})),
|
|
6219
7172
|
cannotDelegate,
|
|
6220
7173
|
// R124. Null on everything but an implementation phase whose card has been
|
|
6221
7174
|
// planned, which the PLATFORM decides — the runner does not work out which
|
|
@@ -6260,10 +7213,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6260
7213
|
// Reaching here at all means Claude Code answers this run — the branch at
|
|
6261
7214
|
// the top of this function sent Antigravity to spawnAntigravity instead.
|
|
6262
7215
|
// Matched against what this machine actually lists (`config.agentCommands`)
|
|
6263
|
-
// by the
|
|
6264
|
-
//
|
|
6265
|
-
const agentCmd = config.agentCommands.find((each) =>
|
|
6266
|
-
??
|
|
7216
|
+
// by `agentOfCommand`, the rule `readUsage` uses too, so a machine whose list
|
|
7217
|
+
// is agy- or codex-first still gets Claude Code's own command.
|
|
7218
|
+
const agentCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'claude')
|
|
7219
|
+
?? 'claude';
|
|
6267
7220
|
|
|
6268
7221
|
const args = [
|
|
6269
7222
|
'--mcp-config',
|
|
@@ -6298,6 +7251,11 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6298
7251
|
});
|
|
6299
7252
|
|
|
6300
7253
|
child.cawdevProjectSlug = run.projectSlug;
|
|
7254
|
+
// Which CLI and which model — what advanced mode puts in a window's title.
|
|
7255
|
+
// The model asked for now; the one the CLI says it is on replaces it at
|
|
7256
|
+
// `init` (below), which is also the only answer when none was asked.
|
|
7257
|
+
child.cawdevAgent = 'claude';
|
|
7258
|
+
child.cawdevModel = model ?? null;
|
|
6301
7259
|
// The checkout this child holds, and what the queue serialises on. Null for
|
|
6302
7260
|
// a run that took none, which is what lets an ASK session run alongside
|
|
6303
7261
|
// three coding ones.
|
|
@@ -6345,6 +7303,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6345
7303
|
?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried) : '')));
|
|
6346
7304
|
|
|
6347
7305
|
let lastText = '';
|
|
7306
|
+
// R305. This child's own last reading, handed back to the walk so the next
|
|
7307
|
+
// stage's base can move.
|
|
7308
|
+
let childTotals = null;
|
|
6348
7309
|
// Whether the CLI ever said its turn was over. This, and not the exit code,
|
|
6349
7310
|
// is what says a STAGE did its work — see the failure branch in
|
|
6350
7311
|
// walkLifecycle, and endSession above for why the two came apart.
|
|
@@ -6354,6 +7315,17 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6354
7315
|
// asked of the platform: the answer is needed at the `result` event, and a
|
|
6355
7316
|
// question that has to cross the network cannot be answered in time.
|
|
6356
7317
|
const used = new Set();
|
|
7318
|
+
// i257. A required expert's `Agent(...)` line is written the instant the
|
|
7319
|
+
// call is MADE (R130), not when its result comes back — so `used` alone
|
|
7320
|
+
// cannot tell "delegated and waited" from "delegated and moved on". This
|
|
7321
|
+
// holds the qualified name of every required expert whose most recent
|
|
7322
|
+
// `Agent`/`Task` call was NOT `run_in_background: false` — added on that
|
|
7323
|
+
// call, and removed the moment the same name is called again WITH it. A
|
|
7324
|
+
// stage is one process and gets one turn (R112); a session that ends its
|
|
7325
|
+
// turn with a name still in here dispatched the delegation and did not
|
|
7326
|
+
// wait for it, which is exactly the shape that loses it when `endSession`
|
|
7327
|
+
// closes stdin below.
|
|
7328
|
+
const backgrounded = new Set();
|
|
6357
7329
|
// The last of what the CLI said on stderr, for the exit decision below.
|
|
6358
7330
|
// The usage-limit notice is written there, and a decision made on stdout
|
|
6359
7331
|
// alone would call every window a crash.
|
|
@@ -6453,7 +7425,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6453
7425
|
// R161. A required one says so where the person is looking. Only when the
|
|
6454
7426
|
// stage can actually reach it: `cannot` and this are mutually exclusive
|
|
6455
7427
|
// by construction, because `mustUse` is built from `stageExperts`.
|
|
6456
|
-
const must = mustUse.has(qualified(agent.key))
|
|
7428
|
+
const must = mustUse.has(`EXPERT:${qualified(agent.key)}`)
|
|
6457
7429
|
? ' This project REQUIRES it: if this stage finishes without using it, the session '
|
|
6458
7430
|
+ 'will be told.'
|
|
6459
7431
|
: '';
|
|
@@ -6464,7 +7436,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6464
7436
|
}
|
|
6465
7437
|
|
|
6466
7438
|
for (const skill of skills) {
|
|
6467
|
-
const must = mustUse.has(qualified(skill.key))
|
|
7439
|
+
const must = mustUse.has(`SKILL:${qualified(skill.key)}`)
|
|
6468
7440
|
? ' This project REQUIRES it: if this stage finishes without using it, the session '
|
|
6469
7441
|
+ 'will be told.'
|
|
6470
7442
|
: '';
|
|
@@ -6496,8 +7468,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6496
7468
|
|
|
6497
7469
|
// R76. What this run had already used before this child started. Zero on an
|
|
6498
7470
|
// ordinary run; on a resume it is what the earlier session spent, which the
|
|
6499
|
-
// claim carries on the run itself.
|
|
6500
|
-
|
|
7471
|
+
// claim carries on the run itself. R305: on a staged run the walk keeps the
|
|
7472
|
+
// count, because the claim's figure never moves and every stage after the
|
|
7473
|
+
// first would otherwise report from zero again.
|
|
7474
|
+
const usageBase = usage?.run ?? {
|
|
6501
7475
|
tokensIn: Number(run.tokensIn) || 0,
|
|
6502
7476
|
tokensOut: Number(run.tokensOut) || 0,
|
|
6503
7477
|
};
|
|
@@ -6513,6 +7487,70 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6513
7487
|
buffer = buffer.slice(newline + 1);
|
|
6514
7488
|
if (!line) continue;
|
|
6515
7489
|
const event = safeJson(line);
|
|
7490
|
+
|
|
7491
|
+
// i257. Read off the RAW event, before `linesOf` flattens it to a
|
|
7492
|
+
// string: `describeCall` (`../lib/tool-line.mjs`) never carries
|
|
7493
|
+
// `run_in_background` into the TOOL line it writes — deliberately not
|
|
7494
|
+
// extended for it, because that line is a parsing contract three
|
|
7495
|
+
// implementations share (`CapabilityUse.java`, `capability-tag.ts`),
|
|
7496
|
+
// and this is a daemon-internal question none of the three need
|
|
7497
|
+
// answered. So this is the one place that can still see the field.
|
|
7498
|
+
// Record-then-decide, ahead of the loop below that reacts to
|
|
7499
|
+
// `result`, so a `result` on this same line always sees this line's
|
|
7500
|
+
// own dispatches first.
|
|
7501
|
+
//
|
|
7502
|
+
// Only tracked for a REQUIRED expert. An optional one backgrounded is
|
|
7503
|
+
// the session's own call and nothing here gates on it.
|
|
7504
|
+
//
|
|
7505
|
+
// Known consequence of not extending the shared line: the platform's
|
|
7506
|
+
// own ledger (`RunCapabilityService.used`, called from
|
|
7507
|
+
// `RunService.appendOutput` on the same TOOL line and read by the run
|
|
7508
|
+
// panel) increments `RunCapability.uses` the instant the line is
|
|
7509
|
+
// written, exactly like `used` below — it has no way to learn a
|
|
7510
|
+
// dispatch was backgrounded, so a stage this nudge caught and that
|
|
7511
|
+
// then corrected itself still reads as an ordinary "required,
|
|
7512
|
+
// delegated to 1×" on the panel afterwards. That is this card's
|
|
7513
|
+
// trade-off, made on purpose: the daemon is the one place that can
|
|
7514
|
+
// still leave with the delegation unfinished, so it is the one place
|
|
7515
|
+
// that has to catch it before it does. Making the panel say so too is
|
|
7516
|
+
// a different card, not a reason to read it as ground truth here.
|
|
7517
|
+
//
|
|
7518
|
+
// The default-background assumption this rests on is not a guess:
|
|
7519
|
+
// i249 and i250 are both a `Agent(...)` call with no
|
|
7520
|
+
// `run_in_background` at all whose result never came back — this
|
|
7521
|
+
// project's own Agent tool backgrounds unless told `false`. If a
|
|
7522
|
+
// spawned CLI's `Agent` were ever synchronous by default, this would
|
|
7523
|
+
// nudge a stage that did nothing wrong — loud, not silent, and capped
|
|
7524
|
+
// at one wasted turn by `child.cawdevNudged` below.
|
|
7525
|
+
//
|
|
7526
|
+
// Keyed `EXPERT:<name>`, matching `mustUse`/`used`: a Set of bare
|
|
7527
|
+
// names would let a REQUIRED skill and a REQUIRED expert that share a
|
|
7528
|
+
// qualified name mark each other's requirement backgrounded.
|
|
7529
|
+
//
|
|
7530
|
+
// The last call to a name wins. A session that is awaited once and
|
|
7531
|
+
// backgrounded again later is reported missing on the second call —
|
|
7532
|
+
// "requirement met" and "something is in flight" are conflated on
|
|
7533
|
+
// purpose, because the second call is the one whose result this
|
|
7534
|
+
// stage's turn is actually at risk of losing.
|
|
7535
|
+
if (event?.type === 'assistant') {
|
|
7536
|
+
for (const part of event.message?.content ?? []) {
|
|
7537
|
+
if (part?.type !== 'tool_use' || (part.name !== 'Agent' && part.name !== 'Task')) {
|
|
7538
|
+
continue;
|
|
7539
|
+
}
|
|
7540
|
+
const who = typeof part.input?.subagent_type === 'string'
|
|
7541
|
+
? part.input.subagent_type : null;
|
|
7542
|
+
const entry = who ? `EXPERT:${who}` : null;
|
|
7543
|
+
if (!entry || !mustUse.has(entry)) {
|
|
7544
|
+
continue;
|
|
7545
|
+
}
|
|
7546
|
+
if (part.input?.run_in_background === false) {
|
|
7547
|
+
backgrounded.delete(entry);
|
|
7548
|
+
} else {
|
|
7549
|
+
backgrounded.add(entry);
|
|
7550
|
+
}
|
|
7551
|
+
}
|
|
7552
|
+
}
|
|
7553
|
+
|
|
6516
7554
|
if (event?.type === 'result') {
|
|
6517
7555
|
if (typeof event.result === 'string') {
|
|
6518
7556
|
lastText = event.result;
|
|
@@ -6578,7 +7616,18 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6578
7616
|
// lifecycle never reaches here — `endSession` is deliberately not
|
|
6579
7617
|
// called for one — so REQUIRED is prompt wording and a console row
|
|
6580
7618
|
// on ASK, ROADMAP and AUDIT, and nothing more.
|
|
6581
|
-
|
|
7619
|
+
// i257. `used` alone says "the name appeared in a TOOL line",
|
|
7620
|
+
// which is true the instant a required expert is DISPATCHED —
|
|
7621
|
+
// backgrounded or not. An entry still in `backgrounded` was
|
|
7622
|
+
// delegated but never waited for, so it counts as missing here
|
|
7623
|
+
// too. `stillBackgroundedEntries` keeps the `EXPERT:` prefix so it
|
|
7624
|
+
// can only ever match the Set it was built from; only its NAMES
|
|
7625
|
+
// (`nameOf`) are for display.
|
|
7626
|
+
const stillBackgroundedEntries = [...mustUse].filter((entry) => backgrounded.has(entry));
|
|
7627
|
+
const trulyMissingEntries = [...mustUse].filter((entry) => !used.has(entry));
|
|
7628
|
+
const missing = [...new Set([...trulyMissingEntries, ...stillBackgroundedEntries])]
|
|
7629
|
+
.map(nameOf);
|
|
7630
|
+
const stillBackgrounded = stillBackgroundedEntries.map(nameOf);
|
|
6582
7631
|
// Whether the input stays open for one more turn. NOT an early
|
|
6583
7632
|
// return out of this handler, which the obvious shape would be:
|
|
6584
7633
|
// this is the stdout reader's `while`, and returning from it would
|
|
@@ -6589,8 +7638,24 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6589
7638
|
// Once per process. A model that has decided cannot be looped:
|
|
6590
7639
|
// this is a nudge, not an argument.
|
|
6591
7640
|
child.cawdevNudged = true;
|
|
6592
|
-
|
|
6593
|
-
|
|
7641
|
+
// i257. "has not used" is not true of `stillBackgrounded` — it
|
|
7642
|
+
// WAS called, just not waited for — and this line is the half
|
|
7643
|
+
// of the nudge a person actually reads (`nudgeText` below goes
|
|
7644
|
+
// to the child's stdin, never to the transcript). This card
|
|
7645
|
+
// exists because a run once said nothing was done when work
|
|
7646
|
+
// really happened; a nudge that misdescribes its own trigger
|
|
7647
|
+
// would be the same mistake at smaller scale.
|
|
7648
|
+
const trulyMissingNames = trulyMissingEntries.filter((entry) => !backgrounded.has(entry))
|
|
7649
|
+
.map(nameOf);
|
|
7650
|
+
const clauses = [
|
|
7651
|
+
trulyMissingNames.length ? `has not used ${trulyMissingNames.join(', ')}` : null,
|
|
7652
|
+
stillBackgrounded.length
|
|
7653
|
+
? `delegated to ${stillBackgrounded.join(', ')} only in the background and never `
|
|
7654
|
+
+ 'waited for it'
|
|
7655
|
+
: null,
|
|
7656
|
+
].filter(Boolean);
|
|
7657
|
+
const line = `cawdev: this stage ${clauses.join(', and ')}, which this project `
|
|
7658
|
+
+ 'requires. Asking it once.';
|
|
6594
7659
|
log(` ${line}`);
|
|
6595
7660
|
transcript.push({ kind: 'SYSTEM', body: line });
|
|
6596
7661
|
// The return value is load-bearing. It is false when stdin is
|
|
@@ -6598,7 +7663,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6598
7663
|
// through to `endSession` is what stops a dead session from never
|
|
6599
7664
|
// being dismissed. That is R22's forty-three minutes arriving
|
|
6600
7665
|
// through a new door.
|
|
6601
|
-
nudged = writeUserMessage(child, nudgeText(stage, missing));
|
|
7666
|
+
nudged = writeUserMessage(child, nudgeText(stage, missing, stillBackgrounded));
|
|
6602
7667
|
if (nudged) {
|
|
6603
7668
|
// The turn runs again and the next `result` closes the input.
|
|
6604
7669
|
// This timer is the whole defence against the one deadlock this
|
|
@@ -6623,6 +7688,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6623
7688
|
if (event?.type === 'system' && event.subtype === 'init' && event.session_id) {
|
|
6624
7689
|
reportSessionId(config, run, event.session_id);
|
|
6625
7690
|
}
|
|
7691
|
+
if (event?.type === 'system' && event.subtype === 'init' && event.model) {
|
|
7692
|
+
child.cawdevModel = event.model;
|
|
7693
|
+
}
|
|
6626
7694
|
// R76. What the session has consumed, recorded rather than only
|
|
6627
7695
|
// printed. Caught here for the reason the session id is: the CLI says
|
|
6628
7696
|
// it on a `result` event and nowhere else.
|
|
@@ -6635,10 +7703,19 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6635
7703
|
if (event?.type === 'result') {
|
|
6636
7704
|
const totals = totalsOf(event);
|
|
6637
7705
|
if (totals) {
|
|
7706
|
+
childTotals = totals;
|
|
6638
7707
|
reportUsage(config, run, {
|
|
6639
7708
|
tokensIn: usageBase.tokensIn + totals.input,
|
|
6640
7709
|
tokensOut: usageBase.tokensOut + totals.output,
|
|
6641
7710
|
});
|
|
7711
|
+
// R305. The same reading, filed on the stage: its own base plus
|
|
7712
|
+
// this child, absolute for the stage across its attempts.
|
|
7713
|
+
if (stage && usage?.stage) {
|
|
7714
|
+
reportStageUsage(config, run, stage.stage, {
|
|
7715
|
+
tokensIn: usage.stage.tokensIn + totals.input,
|
|
7716
|
+
tokensOut: usage.stage.tokensOut + totals.output,
|
|
7717
|
+
});
|
|
7718
|
+
}
|
|
6642
7719
|
}
|
|
6643
7720
|
}
|
|
6644
7721
|
// R113. Anything at all counts as alive.
|
|
@@ -6682,7 +7759,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6682
7759
|
// already being walked. See ../lib/tool-line.mjs.
|
|
6683
7760
|
const reached = capabilityIn(recorded.body);
|
|
6684
7761
|
if (reached) {
|
|
6685
|
-
used.add(reached.key);
|
|
7762
|
+
used.add(`${reached.kind}:${reached.key}`);
|
|
6686
7763
|
}
|
|
6687
7764
|
}
|
|
6688
7765
|
if (stage && recorded.kind === 'TOOL') {
|
|
@@ -6815,7 +7892,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6815
7892
|
// R112. A stage hands its result back so the loop can store the plan,
|
|
6816
7893
|
// gate on it, and carry it into the next stage. A run with no lifecycle
|
|
6817
7894
|
// resolves with nothing, exactly as it always did.
|
|
6818
|
-
resolvePromise(stage
|
|
7895
|
+
resolvePromise(stage
|
|
7896
|
+
? { code, signal, text: lastText, turnEnded, usage: childTotals }
|
|
7897
|
+
: undefined);
|
|
6819
7898
|
});
|
|
6820
7899
|
});
|
|
6821
7900
|
}
|
|
@@ -7111,7 +8190,7 @@ function inlinedSkillsFor(skills) {
|
|
|
7111
8190
|
*/
|
|
7112
8191
|
async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
7113
8192
|
skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
|
|
7114
|
-
agyCmd, granted) {
|
|
8193
|
+
agyCmd, granted, usage = null) {
|
|
7115
8194
|
if (!granted.everything) {
|
|
7116
8195
|
const summary = 'This machine has not allowed everything, and Antigravity has no narrower '
|
|
7117
8196
|
+ 'permission mode — no per-tool allow-list exists on it, and even reading needs it. Turn '
|
|
@@ -7231,6 +8310,13 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7231
8310
|
|
|
7232
8311
|
return new Promise((resolvePromise) => {
|
|
7233
8312
|
let result = null;
|
|
8313
|
+
// R76 / R305: what the run had already used before this process, and what
|
|
8314
|
+
// this process itself reported — see the same pair on the Claude Code path.
|
|
8315
|
+
const usageBase = usage?.run ?? {
|
|
8316
|
+
tokensIn: Number(run.tokensIn) || 0,
|
|
8317
|
+
tokensOut: Number(run.tokensOut) || 0,
|
|
8318
|
+
};
|
|
8319
|
+
let childTotals = null;
|
|
7234
8320
|
let err = '';
|
|
7235
8321
|
let buffer = '';
|
|
7236
8322
|
const child = spawn(agyCmd, args, {
|
|
@@ -7240,6 +8326,8 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7240
8326
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7241
8327
|
});
|
|
7242
8328
|
child.cawdevProjectSlug = run.projectSlug;
|
|
8329
|
+
child.cawdevAgent = 'agy';
|
|
8330
|
+
child.cawdevModel = run.model ?? null;
|
|
7243
8331
|
// The existing cancellation sweep (`reapCancelled`) reads this map by
|
|
7244
8332
|
// run id and SIGTERMs whatever is in it — true for any child, so a
|
|
7245
8333
|
// cancelled Antigravity run stops exactly the way a cancelled Claude
|
|
@@ -7312,10 +8400,23 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7312
8400
|
} else if (event.event === 'result') {
|
|
7313
8401
|
result = event.result;
|
|
7314
8402
|
if (result?.usage) {
|
|
8403
|
+
// R305. On the same base as the Claude Code path: what the run had
|
|
8404
|
+
// already used before this process. Reported with no base until
|
|
8405
|
+
// now, so a resume or a second stage here counted from zero.
|
|
8406
|
+
childTotals = {
|
|
8407
|
+
input: Number(result.usage.input_tokens) || 0,
|
|
8408
|
+
output: Number(result.usage.output_tokens) || 0,
|
|
8409
|
+
};
|
|
7315
8410
|
reportUsage(config, run, {
|
|
7316
|
-
tokensIn:
|
|
7317
|
-
tokensOut:
|
|
8411
|
+
tokensIn: usageBase.tokensIn + childTotals.input,
|
|
8412
|
+
tokensOut: usageBase.tokensOut + childTotals.output,
|
|
7318
8413
|
});
|
|
8414
|
+
if (stage && usage?.stage) {
|
|
8415
|
+
reportStageUsage(config, run, stage.stage, {
|
|
8416
|
+
tokensIn: usage.stage.tokensIn + childTotals.input,
|
|
8417
|
+
tokensOut: usage.stage.tokensOut + childTotals.output,
|
|
8418
|
+
});
|
|
8419
|
+
}
|
|
7319
8420
|
}
|
|
7320
8421
|
}
|
|
7321
8422
|
}
|
|
@@ -7365,7 +8466,293 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7365
8466
|
if (!stage) {
|
|
7366
8467
|
await settleActions(config, run, cwd);
|
|
7367
8468
|
}
|
|
7368
|
-
resolvePromise(stage
|
|
8469
|
+
resolvePromise(stage
|
|
8470
|
+
? { code, signal: null, text, turnEnded, usage: childTotals }
|
|
8471
|
+
: undefined);
|
|
8472
|
+
});
|
|
8473
|
+
});
|
|
8474
|
+
}
|
|
8475
|
+
|
|
8476
|
+
/**
|
|
8477
|
+
* The cawdev tools a Codex process may call — the SAME list Claude Code's
|
|
8478
|
+
* `--allowedTools` would hold for this run and stage, without the prefix.
|
|
8479
|
+
*
|
|
8480
|
+
* <p>`enabled_tools` on cawdev's server is the one narrowing this CLI does as
|
|
8481
|
+
* precisely as Claude Code: a tool left out is not offered at all (checked by
|
|
8482
|
+
* hand). So the list is derived from `PROFILE_TOOLS` and `toolsForStage`
|
|
8483
|
+
* rather than chosen here — a second place deciding what a profile may do is
|
|
8484
|
+
* how the two agents would drift. Above all, `toolsForStage` takes `report`
|
|
8485
|
+
* away from every STAGE, whose "done" would end the run in the middle of the
|
|
8486
|
+
* walk.
|
|
8487
|
+
*
|
|
8488
|
+
* <p>Null — every tool the server has — only for a coding run with no
|
|
8489
|
+
* lifecycle, which can only be spawned where the machine allowed everything.
|
|
8490
|
+
*/
|
|
8491
|
+
function codexCawdevTools(run, stage, mayWrite) {
|
|
8492
|
+
const profile = PROFILE_TOOLS[run.profile];
|
|
8493
|
+
const profileList = profile ? (typeof profile === 'function' ? profile(run) : profile) : null;
|
|
8494
|
+
if (stage) {
|
|
8495
|
+
return cawdevToolNames(toolsForStage(stage.stage, profileList ?? CAWDEV_TOOLS, stage.testMode,
|
|
8496
|
+
{ writesItsOwnCard: writesItsOwnCard(run) }));
|
|
8497
|
+
}
|
|
8498
|
+
if (profileList) {
|
|
8499
|
+
return cawdevToolNames(profileList);
|
|
8500
|
+
}
|
|
8501
|
+
return mayWrite ? null : cawdevToolNames(READ_ONLY_CAWDEV);
|
|
8502
|
+
}
|
|
8503
|
+
|
|
8504
|
+
/**
|
|
8505
|
+
* OpenAI's Codex CLI — one `codex exec` per turn, like Antigravity, and the
|
|
8506
|
+
* command line, the stream and every flag's behaviour are `../lib/codex.mjs`,
|
|
8507
|
+
* checked against the real CLI.
|
|
8508
|
+
*
|
|
8509
|
+
* <p>What it has that Antigravity lacks is a SANDBOX the operating system
|
|
8510
|
+
* enforces, and that decides the permission question:
|
|
8511
|
+
*
|
|
8512
|
+
* <ul>
|
|
8513
|
+
* <li>A session that must change nothing — every profile but the coding
|
|
8514
|
+
* ones, and PLAN, VERIFY and MEMORY of any run — is spawned with
|
|
8515
|
+
* `--sandbox read-only`. Its shell can read and cannot write, and it does
|
|
8516
|
+
* not need the machine to have allowed anything: "cannot" is the kernel's
|
|
8517
|
+
* word here, not the prompt's. Only cawdev's server is attached, narrowed
|
|
8518
|
+
* to the profile's list by `codexCawdevTools`.
|
|
8519
|
+
* <li>A session that writes — CODE, INTERVIEW, MERGE, RELEASE, and their
|
|
8520
|
+
* IMPLEMENT and TEST — has to commit and push, which the workspace sandbox
|
|
8521
|
+
* refuses (`.git` and the network are outside it). There is no allow-list
|
|
8522
|
+
* for the shell on this CLI, so it runs with the sandbox bypassed, and
|
|
8523
|
+
* ONLY where the machine allowed everything (R126) — Antigravity's rule.
|
|
8524
|
+
* </ul>
|
|
8525
|
+
*
|
|
8526
|
+
* <p>Not reached: experts (nothing on this CLI loads cawdev's `agents/*.md`,
|
|
8527
|
+
* so the session is told it cannot delegate rather than shown names it cannot
|
|
8528
|
+
* call), R51's permission prompt and R110's command shield (both live in
|
|
8529
|
+
* `approve`, which Codex never calls) and R22's live prompting (the process
|
|
8530
|
+
* ends with its turn; a follow-up is a resume). Skills are inlined whole, as
|
|
8531
|
+
* for Antigravity. The transcript half of R110 — secrets taken out of a tool
|
|
8532
|
+
* result — does apply, since it runs here and not in the CLI.
|
|
8533
|
+
*/
|
|
8534
|
+
async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
8535
|
+
skills, stageExperts, readOnlyStage, instincts, briefing, plan, lifecycle, stage, carried,
|
|
8536
|
+
codexCmd, granted, shield, usage = null) {
|
|
8537
|
+
const failedBeforeSpawn = async (summary) => {
|
|
8538
|
+
log(` ${summary}`);
|
|
8539
|
+
await finish(config, run, 'FAILED', summary);
|
|
8540
|
+
return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
|
|
8541
|
+
};
|
|
8542
|
+
|
|
8543
|
+
const mayWrite = writesCodeProfile(run) && !readOnlyStage;
|
|
8544
|
+
if (mayWrite && !granted.everything) {
|
|
8545
|
+
return failedBeforeSpawn('This machine has not allowed everything, and a Codex session that '
|
|
8546
|
+
+ 'writes has no narrower mode — it must commit and push, which its sandbox refuses, and '
|
|
8547
|
+
+ 'there is no allow-list for its shell. Turn on "allow everything" for this machine, or use '
|
|
8548
|
+
+ 'Claude Code for this run. (Read-only runs and stages need nothing.)');
|
|
8549
|
+
}
|
|
8550
|
+
if (mayWrite) {
|
|
8551
|
+
log(' !! this machine allows EVERYTHING: this Codex session runs without a sandbox');
|
|
8552
|
+
}
|
|
8553
|
+
|
|
8554
|
+
const transcript = new Transcript(config, run);
|
|
8555
|
+
const serverPath = config.mcpServerPath ?? new URL('../mcp/server.mjs', import.meta.url).pathname;
|
|
8556
|
+
|
|
8557
|
+
// The token reaches cawdev's server through `env_vars` — from Codex's own
|
|
8558
|
+
// environment, never its command line — and is blanked for the shell.
|
|
8559
|
+
const cawdevEnv = { CAWDEV_URL: config.url, CAWDEV_TOKEN: runToken, CAWDEV_PROJECT: run.projectSlug };
|
|
8560
|
+
const childEnv = { ...process.env, ...cawdevEnv };
|
|
8561
|
+
|
|
8562
|
+
// By name, so a later server replaces an earlier one whole — the ordering
|
|
8563
|
+
// argument of the Claude Code path: the repository's own first, then R76's
|
|
8564
|
+
// registry, then cawdev's LAST, so nothing can shadow it and take its token.
|
|
8565
|
+
const servers = new Map();
|
|
8566
|
+
if (mayWrite) {
|
|
8567
|
+
for (const [name, server] of Object.entries(await projectMcpServers(cwd))) {
|
|
8568
|
+
if (!server?.command) {
|
|
8569
|
+
transcript.push({ kind: 'SYSTEM',
|
|
8570
|
+
body: `The project's MCP server "${name}" is not a command, and Codex is handed commands only — skipped.` });
|
|
8571
|
+
continue;
|
|
8572
|
+
}
|
|
8573
|
+
servers.set(name, { name, command: server.command, args: server.args, env: server.env });
|
|
8574
|
+
}
|
|
8575
|
+
const { attached, notes } = await resolveSkills(config, run, cwd, baseCommit, projectServers);
|
|
8576
|
+
for (const line of notes) {
|
|
8577
|
+
log(` ${line}`);
|
|
8578
|
+
transcript.push({ kind: 'SYSTEM', body: line });
|
|
8579
|
+
}
|
|
8580
|
+
for (const { skill, local } of attached) {
|
|
8581
|
+
Object.assign(childEnv, local.env ?? {});
|
|
8582
|
+
servers.set(skill.serverName, {
|
|
8583
|
+
name: skill.serverName, command: skill.command, args: skill.args,
|
|
8584
|
+
envVars: Object.keys(local.env ?? {}),
|
|
8585
|
+
});
|
|
8586
|
+
}
|
|
8587
|
+
}
|
|
8588
|
+
servers.set('cawdev', {
|
|
8589
|
+
name: 'cawdev',
|
|
8590
|
+
command: process.execPath,
|
|
8591
|
+
args: [serverPath],
|
|
8592
|
+
envVars: Object.keys(cawdevEnv),
|
|
8593
|
+
enabledTools: codexCawdevTools(run, stage, mayWrite),
|
|
8594
|
+
});
|
|
8595
|
+
|
|
8596
|
+
const cannotDelegate = stageExperts.length
|
|
8597
|
+
? ["This project has experts, and Codex has no way to be handed them: nothing on this CLI "
|
|
8598
|
+
+ "loads cawdev's expert agents. Do the work yourself; they are reached from Claude Code "
|
|
8599
|
+
+ 'and Antigravity runs.']
|
|
8600
|
+
: undefined;
|
|
8601
|
+
if (cannotDelegate) {
|
|
8602
|
+
transcript.push({ kind: 'SYSTEM', body: cannotDelegate[0] });
|
|
8603
|
+
}
|
|
8604
|
+
|
|
8605
|
+
const resuming = Boolean(resume?.agentSessionId);
|
|
8606
|
+
const promptText = resuming
|
|
8607
|
+
? (resume.prompt ?? '')
|
|
8608
|
+
: (promptFor(run)
|
|
8609
|
+
+ harnessPrompt({
|
|
8610
|
+
instincts, briefing, experts: [], skills: [], plan, lifecycle,
|
|
8611
|
+
repoConfig: await readRepoConfig(cwd),
|
|
8612
|
+
})
|
|
8613
|
+
+ inlinedSkillsFor(skills)
|
|
8614
|
+
+ expertsBlockFor([], cannotDelegate)
|
|
8615
|
+
+ (stage ? stagePrompt(stage, carried) : ''));
|
|
8616
|
+
|
|
8617
|
+
const model = stage?.model ?? run.model;
|
|
8618
|
+
const args = codexArgs({
|
|
8619
|
+
model,
|
|
8620
|
+
effort: run.effort,
|
|
8621
|
+
sandbox: mayWrite ? 'bypass' : 'read-only',
|
|
8622
|
+
resumeId: resuming ? resume.agentSessionId : null,
|
|
8623
|
+
servers: [...servers.values()],
|
|
8624
|
+
hideFromShell: Object.keys(cawdevEnv),
|
|
8625
|
+
});
|
|
8626
|
+
log(` spawning: ${codexCmd} ${args.join(' ')} <prompt on stdin, ${promptText.length} chars>`);
|
|
8627
|
+
|
|
8628
|
+
return new Promise((resolvePromise) => {
|
|
8629
|
+
// R76 / R305: what the run had already used before this process — see the
|
|
8630
|
+
// same pair on the Claude Code path.
|
|
8631
|
+
const usageBase = usage?.run ?? {
|
|
8632
|
+
tokensIn: Number(run.tokensIn) || 0,
|
|
8633
|
+
tokensOut: Number(run.tokensOut) || 0,
|
|
8634
|
+
};
|
|
8635
|
+
let childTotals = null;
|
|
8636
|
+
let ended = false;
|
|
8637
|
+
let failed = null;
|
|
8638
|
+
let lastError = null;
|
|
8639
|
+
let lastSaid = null;
|
|
8640
|
+
let err = '';
|
|
8641
|
+
let buffer = '';
|
|
8642
|
+
const child = spawn(codexCmd, args, {
|
|
8643
|
+
cwd,
|
|
8644
|
+
// Its own process group — see the same choice on the Claude Code path.
|
|
8645
|
+
detached: true,
|
|
8646
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
8647
|
+
env: childEnv,
|
|
8648
|
+
});
|
|
8649
|
+
child.cawdevProjectSlug = run.projectSlug;
|
|
8650
|
+
child.cawdevAgent = 'codex';
|
|
8651
|
+
child.cawdevModel = model ?? null;
|
|
8652
|
+
// `reapCancelled` SIGTERMs whatever is here, whichever CLI it is.
|
|
8653
|
+
running.set(run.id, child);
|
|
8654
|
+
child.stdin.on('error', () => {
|
|
8655
|
+
// A CLI that died before reading its prompt says why on stderr.
|
|
8656
|
+
});
|
|
8657
|
+
child.stdin.end(promptText);
|
|
8658
|
+
|
|
8659
|
+
child.stdout.on('data', (chunk) => {
|
|
8660
|
+
buffer += chunk;
|
|
8661
|
+
let newline;
|
|
8662
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
8663
|
+
const line = buffer.slice(0, newline);
|
|
8664
|
+
buffer = buffer.slice(newline + 1);
|
|
8665
|
+
if (!line.trim()) continue;
|
|
8666
|
+
let event;
|
|
8667
|
+
try {
|
|
8668
|
+
event = JSON.parse(line);
|
|
8669
|
+
} catch {
|
|
8670
|
+
transcript.push({ kind: 'SYSTEM', body: line.slice(0, 4000) });
|
|
8671
|
+
continue;
|
|
8672
|
+
}
|
|
8673
|
+
const read = translateCodexEvent(event);
|
|
8674
|
+
if (read.sessionId) {
|
|
8675
|
+
reportSessionId(config, run, read.sessionId);
|
|
8676
|
+
transcript.push({
|
|
8677
|
+
kind: 'SYSTEM',
|
|
8678
|
+
body: `session ${short(read.sessionId)} started${model ? ` on ${model}` : ''}`
|
|
8679
|
+
+ (mayWrite ? '' : ' (read-only sandbox)'),
|
|
8680
|
+
});
|
|
8681
|
+
}
|
|
8682
|
+
for (const recorded of read.lines) {
|
|
8683
|
+
// R110's transcript half, as on the Claude Code path: a credential
|
|
8684
|
+
// that came back from a command is replaced before it is stored.
|
|
8685
|
+
if (shield?.blockSecrets !== false && recorded.kind === 'TOOL_RESULT') {
|
|
8686
|
+
const found = findSecret(recorded.body);
|
|
8687
|
+
if (found) {
|
|
8688
|
+
recorded.body = `[cawdev removed ${found.name} from this result] ${found.redacted}`;
|
|
8689
|
+
api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/blocks`, {
|
|
8690
|
+
method: 'POST',
|
|
8691
|
+
body: { kind: 'SECRET', detail: `${found.name}: ${found.redacted}` },
|
|
8692
|
+
}).catch(() => {});
|
|
8693
|
+
}
|
|
8694
|
+
}
|
|
8695
|
+
if (recorded.kind === 'ASSISTANT') {
|
|
8696
|
+
lastSaid = recorded.body;
|
|
8697
|
+
}
|
|
8698
|
+
transcript.push(recorded);
|
|
8699
|
+
}
|
|
8700
|
+
if (read.error) {
|
|
8701
|
+
lastError = read.error;
|
|
8702
|
+
}
|
|
8703
|
+
if (read.failed) {
|
|
8704
|
+
failed = read.failed;
|
|
8705
|
+
}
|
|
8706
|
+
if (read.ended) {
|
|
8707
|
+
ended = true;
|
|
8708
|
+
childTotals = read.usage;
|
|
8709
|
+
reportUsage(config, run, {
|
|
8710
|
+
tokensIn: usageBase.tokensIn + childTotals.input,
|
|
8711
|
+
tokensOut: usageBase.tokensOut + childTotals.output,
|
|
8712
|
+
});
|
|
8713
|
+
if (stage && usage?.stage) {
|
|
8714
|
+
reportStageUsage(config, run, stage.stage, {
|
|
8715
|
+
tokensIn: usage.stage.tokensIn + childTotals.input,
|
|
8716
|
+
tokensOut: usage.stage.tokensOut + childTotals.output,
|
|
8717
|
+
});
|
|
8718
|
+
}
|
|
8719
|
+
}
|
|
8720
|
+
}
|
|
8721
|
+
});
|
|
8722
|
+
child.stderr.on('data', (chunk) => {
|
|
8723
|
+
const text = String(chunk).trim();
|
|
8724
|
+
if (text) {
|
|
8725
|
+
log(` agent stderr: ${text.slice(0, 400)}`);
|
|
8726
|
+
}
|
|
8727
|
+
err += chunk;
|
|
8728
|
+
});
|
|
8729
|
+
|
|
8730
|
+
child.on('error', async (failure) => {
|
|
8731
|
+
running.delete(run.id);
|
|
8732
|
+
const summary = `Could not spawn Codex: ${failure.message}`;
|
|
8733
|
+
await transcript.flush();
|
|
8734
|
+
await finish(config, run, 'FAILED', summary);
|
|
8735
|
+
resolvePromise(stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined);
|
|
8736
|
+
});
|
|
8737
|
+
|
|
8738
|
+
child.on('close', async (code) => {
|
|
8739
|
+
running.delete(run.id);
|
|
8740
|
+
await transcript.flush();
|
|
8741
|
+
const text = failed ?? lastSaid ?? lastError ?? (err.trim() || 'Codex produced no output.');
|
|
8742
|
+
// The agent normally ends the run itself with report(done|blocked); this
|
|
8743
|
+
// only catches a turn that produced nothing report could act on.
|
|
8744
|
+
const current = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}`)
|
|
8745
|
+
.catch(() => null);
|
|
8746
|
+
if (current?.live && !stage) {
|
|
8747
|
+
await finish(config, run, code === 0 && ended && !failed ? 'FINISHED' : 'FAILED', text);
|
|
8748
|
+
}
|
|
8749
|
+
// R112: not between stages — see the same line on the Claude Code path.
|
|
8750
|
+
if (!stage) {
|
|
8751
|
+
await settleActions(config, run, cwd);
|
|
8752
|
+
}
|
|
8753
|
+
resolvePromise(stage
|
|
8754
|
+
? { code, signal: null, text, turnEnded: ended && !failed, usage: childTotals }
|
|
8755
|
+
: undefined);
|
|
7369
8756
|
});
|
|
7370
8757
|
});
|
|
7371
8758
|
}
|
|
@@ -7399,6 +8786,19 @@ function reportUsage(config, run, totals) {
|
|
|
7399
8786
|
}).catch((failure) => log(` could not record what the session used: ${failure.message}`));
|
|
7400
8787
|
}
|
|
7401
8788
|
|
|
8789
|
+
/**
|
|
8790
|
+
* The same reading, for one STAGE — R305. Absolute for the stage across its
|
|
8791
|
+
* attempts, fire and forget for the reason `reportUsage` is, and the platform
|
|
8792
|
+
* keeps the largest reading here too.
|
|
8793
|
+
*/
|
|
8794
|
+
function reportStageUsage(config, run, stageName, totals) {
|
|
8795
|
+
api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/stages/${stageName}/usage`, {
|
|
8796
|
+
method: 'POST',
|
|
8797
|
+
body: totals,
|
|
8798
|
+
}).catch((failure) =>
|
|
8799
|
+
log(` could not record what the ${stageName} stage used: ${failure.message}`));
|
|
8800
|
+
}
|
|
8801
|
+
|
|
7402
8802
|
async function finish(config, run, state, summary) {
|
|
7403
8803
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
|
|
7404
8804
|
method: 'POST',
|
|
@@ -7480,6 +8880,14 @@ function capabilities(config) {
|
|
|
7480
8880
|
// offered only to a machine that says this, and a machine that does not
|
|
7481
8881
|
// is told why on the queue rather than handed a base it would ignore.
|
|
7482
8882
|
baseBranches: true,
|
|
8883
|
+
// R270. This daemon knows an offer can carry `waitForClean`, and honours it
|
|
8884
|
+
// in `pickWorkspace` — taking the preferred checkout only when clean, and
|
|
8885
|
+
// refusing to fall back to a dirty one at all. The same shape as
|
|
8886
|
+
// `baseBranches`: an older daemon that ignored the flag would take the
|
|
8887
|
+
// dirty checkout and fail a run the person explicitly queued to avoid
|
|
8888
|
+
// that, so the platform tells it apart rather than letting one behave
|
|
8889
|
+
// like the other.
|
|
8890
|
+
waitForClean: true,
|
|
7483
8891
|
});
|
|
7484
8892
|
}
|
|
7485
8893
|
|
|
@@ -7499,6 +8907,11 @@ function capabilities(config) {
|
|
|
7499
8907
|
async function probePermissionPrompt(config) {
|
|
7500
8908
|
let allGood = true;
|
|
7501
8909
|
for (const agentCommand of config.agentCommands) {
|
|
8910
|
+
// A Claude Code flag. Codex never calls a permission prompt — see
|
|
8911
|
+
// `spawnCodex` — and offering it this one would start its terminal UI.
|
|
8912
|
+
if (agentOfCommand(agentCommand) === 'codex') {
|
|
8913
|
+
continue;
|
|
8914
|
+
}
|
|
7502
8915
|
const said = await new Promise((done) => {
|
|
7503
8916
|
let text = '';
|
|
7504
8917
|
let child;
|
|
@@ -7558,6 +8971,9 @@ async function main() {
|
|
|
7558
8971
|
quiet = attaching;
|
|
7559
8972
|
|
|
7560
8973
|
const config = await readConfig();
|
|
8974
|
+
// The checkouts made on demand by an earlier run of this daemon, found
|
|
8975
|
+
// again by their name — nothing about them is in the config.
|
|
8976
|
+
await syncAutoWorkspaces(config.projects);
|
|
7561
8977
|
const myVersion = await cliVersion();
|
|
7562
8978
|
|
|
7563
8979
|
// R283. Checked before registering — a daemon too old to be safe should not
|
|
@@ -7617,6 +9033,11 @@ async function main() {
|
|
|
7617
9033
|
// The paths still go to the log — the banner says how many, this says
|
|
7618
9034
|
// which, and "which" is what you need when one of them is wrong.
|
|
7619
9035
|
log(`serving ${slug}: ${project.workspaces.join(', ')}`);
|
|
9036
|
+
// R300. Said before the first run, so the log says the feature is on
|
|
9037
|
+
// — and which variable the token is read from — before a scan is missed.
|
|
9038
|
+
if (project.quality) {
|
|
9039
|
+
log(`quality: ${slug} scans with ${project.quality.command} after TEST, token from $${project.quality.tokenEnv}`);
|
|
9040
|
+
}
|
|
7620
9041
|
}
|
|
7621
9042
|
await sayWhichServedProjectsTheTokenCannotReach(config);
|
|
7622
9043
|
|
|
@@ -7718,6 +9139,7 @@ async function main() {
|
|
|
7718
9139
|
const resolved = {
|
|
7719
9140
|
acceptsRulesFromConsole: file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole,
|
|
7720
9141
|
grantable: file.grantable ?? DEFAULTS.grantable,
|
|
9142
|
+
autoWorkspaces: autoWorkspacesOf(file.autoWorkspaces),
|
|
7721
9143
|
agentCommands: file.agentCommands
|
|
7722
9144
|
?? (file.agentCommand ? [file.agentCommand] : null)
|
|
7723
9145
|
?? DEFAULTS.agentCommands,
|
|
@@ -7726,7 +9148,11 @@ async function main() {
|
|
|
7726
9148
|
agentCommands: config.agentCommands,
|
|
7727
9149
|
grantable: config.grantable,
|
|
7728
9150
|
acceptsRulesFromConsole: config.acceptsRulesFromConsole,
|
|
9151
|
+
autoWorkspaces: config.autoWorkspaces,
|
|
7729
9152
|
}, resolved);
|
|
9153
|
+
// A checkout made on demand may have been dropped — the attached
|
|
9154
|
+
// terminal's `/drop` deletes the directory and sends this signal.
|
|
9155
|
+
await syncAutoWorkspaces(config.projects, heldWorkspaces());
|
|
7730
9156
|
Object.assign(config, resolved);
|
|
7731
9157
|
// R288: the hello describes the current state, not the booted one — an
|
|
7732
9158
|
// attach opened after a toggle would otherwise offer to turn on what is
|
|
@@ -7963,8 +9389,13 @@ async function main() {
|
|
|
7963
9389
|
// to lose three hours of work by leaning on the keyboard.
|
|
7964
9390
|
if (attaching) {
|
|
7965
9391
|
const { attach } = await import('./attach.mjs');
|
|
7966
|
-
|
|
9392
|
+
const argv = process.argv.slice(2);
|
|
9393
|
+
const typed = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : null;
|
|
9394
|
+
void attach(argv, {
|
|
7967
9395
|
socketPath: control?.path,
|
|
9396
|
+
// Where the person signs in: `--url`, else what this daemon is called —
|
|
9397
|
+
// `cawdev.mjs`'s `signInUrl` order. `urlFrom` alone never sees the config.
|
|
9398
|
+
url: (typed ?? config.url).replace(/\/+$/, ''),
|
|
7968
9399
|
// Its own daemon, so quitting means stopping: raised as a signal rather
|
|
7969
9400
|
// than an exit, so the run goes through the same goodbye and the same
|
|
7970
9401
|
// termination of children as a Ctrl-C would.
|
|
@@ -7997,6 +9428,22 @@ async function main() {
|
|
|
7997
9428
|
`/api/runners/${config.runnerId}/queue?wait=${config.pollSeconds}`,
|
|
7998
9429
|
);
|
|
7999
9430
|
|
|
9431
|
+
// Checked again HERE, not only at the top. The poll blocks for up to
|
|
9432
|
+
// pollSeconds, and a Ctrl-C during it flips `stopping` and begins the
|
|
9433
|
+
// goodbye — which is bounded but not instant. A poll that returns inside
|
|
9434
|
+
// that window would otherwise walk on to claim a run and spawn a
|
|
9435
|
+
// detached agent that outlives the exiting daemon, orphaned with a live
|
|
9436
|
+
// claim. The gap after this point is startRun's own: it reads `stopping`
|
|
9437
|
+
// nowhere, and a Ctrl-C between its claim and its spawn is still open.
|
|
9438
|
+
//
|
|
9439
|
+
// Also the loop's exit an analyser can see. The header's condition IS one
|
|
9440
|
+
// at runtime — `stopping` is a closed-over `let` read every iteration —
|
|
9441
|
+
// but its only writer is the signal handler, and i247 (S2189) is what a
|
|
9442
|
+
// loop with no visible way out looks like from outside.
|
|
9443
|
+
if (stopping) {
|
|
9444
|
+
break;
|
|
9445
|
+
}
|
|
9446
|
+
|
|
8000
9447
|
// ONE gate — R109. There were two; `maxSessions` bounded this machine
|
|
8001
9448
|
// and is gone, for the reason written where it used to be applied.
|
|
8002
9449
|
//
|
|
@@ -8019,6 +9466,10 @@ async function main() {
|
|
|
8019
9466
|
// was when the wait began — and a run that started during the wait was
|
|
8020
9467
|
// invisible. Two agents went into one checkout that way.
|
|
8021
9468
|
const held = heldWorkspaces();
|
|
9469
|
+
// One `git status` per checkout per pass, not per offer — i258.
|
|
9470
|
+
// Several offers of one project ask `looksClean` on a shared checkout
|
|
9471
|
+
// only once.
|
|
9472
|
+
const probed = new Map();
|
|
8022
9473
|
|
|
8023
9474
|
let claimable = 0;
|
|
8024
9475
|
let skipped = 0;
|
|
@@ -8077,47 +9528,24 @@ async function main() {
|
|
|
8077
9528
|
// the local ref already is. Taken when free, passed over when not: a
|
|
8078
9529
|
// busy preferred checkout is not worth waiting for, because origin has
|
|
8079
9530
|
// everything and prepareWorkingCopy brings a stale ref up to it.
|
|
9531
|
+
//
|
|
9532
|
+
// Below pinned and preferred, which checkout a free-for-the-taking
|
|
9533
|
+
// run gets now also weighs whether its tree is clean — R71/i258. The
|
|
9534
|
+
// `await` here can only make `held` more conservative while it waits:
|
|
9535
|
+
// claims are added by this loop alone, sequentially, and a run
|
|
9536
|
+
// finishing only frees a path. The one optimistic case — a heartbeat
|
|
9537
|
+
// adding to `told.heldWorkspaces` mid-loop — is the same window that
|
|
9538
|
+
// already exists between this poll and `startRun`'s own claim.
|
|
8080
9539
|
let workspace = null;
|
|
8081
9540
|
if (writes) {
|
|
8082
|
-
const
|
|
8083
|
-
|
|
8084
|
-
|
|
8085
|
-
|
|
8086
|
-
|
|
8087
|
-
|
|
8088
|
-
noteQueued(
|
|
8089
|
-
offered.run,
|
|
8090
|
-
`its branch is in ${pinned}, which this machine does not serve`,
|
|
8091
|
-
);
|
|
8092
|
-
skipped += 1;
|
|
8093
|
-
continue;
|
|
8094
|
-
}
|
|
8095
|
-
if (held.has(pinned)) {
|
|
8096
|
-
noteQueued(
|
|
8097
|
-
offered.run,
|
|
8098
|
-
`waiting for ${pinned}, which is busy — the branch has work only that checkout holds`,
|
|
8099
|
-
);
|
|
8100
|
-
skipped += 1;
|
|
8101
|
-
continue;
|
|
8102
|
-
}
|
|
8103
|
-
workspace = pinned;
|
|
8104
|
-
} else {
|
|
8105
|
-
const preferred = offered.preferredWorkspace ?? null;
|
|
8106
|
-
if (preferred && config.projects[slug].workspaces.includes(preferred) && !held.has(preferred)) {
|
|
8107
|
-
workspace = preferred;
|
|
8108
|
-
} else {
|
|
8109
|
-
workspace = config.projects[slug].workspaces.find((path) => !held.has(path)) ?? null;
|
|
8110
|
-
}
|
|
8111
|
-
if (!workspace) {
|
|
8112
|
-
const total = config.projects[slug].workspaces.length;
|
|
8113
|
-
noteQueued(
|
|
8114
|
-
offered.run,
|
|
8115
|
-
`no free workspace in ${slug} (${total} here, all busy)`,
|
|
8116
|
-
);
|
|
8117
|
-
skipped += 1;
|
|
8118
|
-
continue;
|
|
8119
|
-
}
|
|
9541
|
+
const picked = await pickWorkspace(slug, config.projects[slug], offered, held, probed,
|
|
9542
|
+
config.autoWorkspaces);
|
|
9543
|
+
if (picked.why) {
|
|
9544
|
+
noteQueued(offered.run, picked.why);
|
|
9545
|
+
skipped += 1;
|
|
9546
|
+
continue;
|
|
8120
9547
|
}
|
|
9548
|
+
workspace = picked.workspace;
|
|
8121
9549
|
}
|
|
8122
9550
|
if (workspace) {
|
|
8123
9551
|
held.add(workspace);
|