cawdev-cli 1.0.4-beta → 1.0.6-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 +65 -0
- package/lib/ansi.mjs +2 -0
- package/lib/background-shells.mjs +108 -0
- package/lib/codex.mjs +219 -0
- package/lib/harness-prompt.mjs +14 -6
- package/lib/roadmap-format.mjs +52 -0
- package/lib/runner-config.mjs +3 -2
- package/mcp/server.mjs +53 -4
- package/package.json +1 -1
- package/runner/README.md +389 -11
- package/runner/attach.mjs +1838 -31
- package/runner/auto-workspaces.mjs +106 -0
- package/runner/bootstrap.mjs +37 -5
- package/runner/cawdev.mjs +45 -13
- package/runner/changed-lines.mjs +87 -0
- package/runner/configure.mjs +55 -5
- package/runner/input.mjs +21 -8
- package/runner/prefs.mjs +52 -0
- package/runner/runner.mjs +1256 -147
- package/runner/select.mjs +31 -4
- package/runner/split.mjs +923 -0
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';
|
|
@@ -38,8 +40,14 @@ import {
|
|
|
38
40
|
} from '../lib/stage-tools.mjs';
|
|
39
41
|
import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
|
|
40
42
|
import { findSecret } from '../lib/secrets.mjs';
|
|
43
|
+
import { BackgroundShells } from '../lib/background-shells.mjs';
|
|
41
44
|
import { cliVersion, isBelow } from '../lib/version.mjs';
|
|
42
45
|
import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
|
|
46
|
+
import {
|
|
47
|
+
createAutoWorkspace, discoverAutoWorkspaces, isAutoWorkspace,
|
|
48
|
+
} from './auto-workspaces.mjs';
|
|
49
|
+
import { agentOfCommand, needsEverything } from '../lib/agents.mjs';
|
|
50
|
+
import { cawdevToolNames, codexArgs, translateCodexEvent } from '../lib/codex.mjs';
|
|
43
51
|
|
|
44
52
|
// --- configuration -----------------------------------------------------------
|
|
45
53
|
|
|
@@ -405,6 +413,13 @@ async function readConfig() {
|
|
|
405
413
|
file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole,
|
|
406
414
|
/** Whether a run here may reach the operator's browser. Per project too. */
|
|
407
415
|
browser: file.browser ?? DEFAULTS.browser,
|
|
416
|
+
/**
|
|
417
|
+
* How many checkouts per project this machine may CLONE by itself when a
|
|
418
|
+
* coding run would otherwise wait for a free one — see auto-workspaces.mjs.
|
|
419
|
+
* Absent is none: an actuator that spends disk and a clone's time is
|
|
420
|
+
* switched on, never on by default.
|
|
421
|
+
*/
|
|
422
|
+
autoWorkspaces: autoWorkspacesOf(file.autoWorkspaces),
|
|
408
423
|
skillCache: file.skillCache ?? DEFAULTS.skillCache,
|
|
409
424
|
skillPrepareSeconds: file.skillPrepareSeconds ?? DEFAULTS.skillPrepareSeconds,
|
|
410
425
|
idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
|
|
@@ -485,6 +500,32 @@ async function readConfig() {
|
|
|
485
500
|
* which is the truth rather than "that project already has a run here" standing
|
|
486
501
|
* in for it.
|
|
487
502
|
*/
|
|
503
|
+
/** `autoWorkspaces` as a count: a whole number from 0, anything else is 0. */
|
|
504
|
+
function autoWorkspacesOf(value) {
|
|
505
|
+
const n = Number(value);
|
|
506
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The on-demand checkouts beside each project's first one, joined to its list
|
|
511
|
+
* — at boot, and again on SIGHUP, when one may have been made or dropped by
|
|
512
|
+
* somebody else. A checkout gone from disk leaves the list unless a run holds
|
|
513
|
+
* it; one that appeared joins it. The configured checkouts are never touched.
|
|
514
|
+
*/
|
|
515
|
+
async function syncAutoWorkspaces(projects, held = new Set()) {
|
|
516
|
+
for (const [slug, project] of Object.entries(projects)) {
|
|
517
|
+
const first = project.workspaces[0];
|
|
518
|
+
const found = await discoverAutoWorkspaces(first);
|
|
519
|
+
const kept = project.workspaces.filter((path) => !isAutoWorkspace(first, path)
|
|
520
|
+
|| found.includes(path) || held.has(path));
|
|
521
|
+
const added = found.filter((path) => !kept.includes(path));
|
|
522
|
+
const dropped = project.workspaces.filter((path) => !kept.includes(path));
|
|
523
|
+
project.workspaces = [...kept, ...added];
|
|
524
|
+
for (const path of added) log(` ${slug}: serving the checkout made on demand at ${path}`);
|
|
525
|
+
for (const path of dropped) log(` ${slug}: ${path} is gone — no longer serving it`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
488
529
|
function normaliseProjects(projects) {
|
|
489
530
|
const normalised = {};
|
|
490
531
|
for (const [slug, value] of Object.entries(projects)) {
|
|
@@ -550,13 +591,20 @@ function normaliseProjects(projects) {
|
|
|
550
591
|
* error saying so; `main` re-runs the scan as the project's MAIN analysis,
|
|
551
592
|
* which overwrites the project's dashboard with this branch's code and so
|
|
552
593
|
* makes the platform's project reading describe whichever branch scanned
|
|
553
|
-
* last
|
|
594
|
+
* last; `project` — R316 — analyses the branch as its own scratch SonarQube
|
|
595
|
+
* project instead, `<key>:branch:<branch>-<hash>`, overwritten on each run,
|
|
596
|
+
* with "new" meaning a line the branch changed. Both are an operator's own
|
|
597
|
+
* opt-in — nothing opts in for them — and `project` additionally needs the
|
|
598
|
+
* scanner token to hold *Create Projects* on the server, and for the
|
|
599
|
+
* server's default project visibility to not be public (or this publishes
|
|
600
|
+
* the code).
|
|
554
601
|
*/
|
|
555
602
|
function qualityBlockOf(slug, block) {
|
|
556
603
|
if (!block) return null;
|
|
557
604
|
const when = block.whenBranchesRefused ?? 'skip';
|
|
558
|
-
if (when !== 'skip' && when !== 'main') {
|
|
559
|
-
throw new Error(
|
|
605
|
+
if (when !== 'skip' && when !== 'main' && when !== 'project') {
|
|
606
|
+
throw new Error(
|
|
607
|
+
`Project "${slug}": quality.whenBranchesRefused must be "skip", "main" or "project", not "${when}".`);
|
|
560
608
|
}
|
|
561
609
|
return {
|
|
562
610
|
command: block.command ?? 'sonar-scanner',
|
|
@@ -868,6 +916,11 @@ async function readUsage(config) {
|
|
|
868
916
|
const allWindows = [];
|
|
869
917
|
|
|
870
918
|
for (const agentCommand of config.agentCommands) {
|
|
919
|
+
// Codex has no `-p /usage`: its `-p` is a PROFILE, and the bare command is
|
|
920
|
+
// an interactive terminal. It reports no windows rather than a guess.
|
|
921
|
+
if (agentOfCommand(agentCommand) === 'codex') {
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
871
924
|
const said = await new Promise((resolve) => {
|
|
872
925
|
let out = '';
|
|
873
926
|
const child = spawn(agentCommand, ['-p', '/usage', '--output-format', 'text'], {
|
|
@@ -897,7 +950,7 @@ async function readUsage(config) {
|
|
|
897
950
|
const windows = parseUsage(said);
|
|
898
951
|
for (const each of windows) {
|
|
899
952
|
allWindows.push({
|
|
900
|
-
provider: agentCommand
|
|
953
|
+
provider: agentOfCommand(agentCommand),
|
|
901
954
|
window: each.kind,
|
|
902
955
|
model: each.model,
|
|
903
956
|
percentUsed: each.percent,
|
|
@@ -1394,9 +1447,13 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
|
|
|
1394
1447
|
* It does not reproduce on macOS at any output size, so this is not something
|
|
1395
1448
|
* a local run will ever warn about. Do not put `exit` back.
|
|
1396
1449
|
*/
|
|
1397
|
-
function git(cwd, args) {
|
|
1450
|
+
function git(cwd, args, { timeoutMs = 0 } = {}) {
|
|
1398
1451
|
return new Promise((resolvePromise, reject) => {
|
|
1399
|
-
const child = spawn('git', args, {
|
|
1452
|
+
const child = spawn('git', args, {
|
|
1453
|
+
cwd,
|
|
1454
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1455
|
+
...(timeoutMs ? { timeout: timeoutMs } : {}),
|
|
1456
|
+
});
|
|
1400
1457
|
let out = '';
|
|
1401
1458
|
let err = '';
|
|
1402
1459
|
child.stdout.on('data', (chunk) => (out += chunk));
|
|
@@ -1829,11 +1886,12 @@ const SURVEY_FILE_CAP = 20;
|
|
|
1829
1886
|
* arrive at the console as the same answer.
|
|
1830
1887
|
*
|
|
1831
1888
|
* **The order of this list matters, and it is `project.workspaces` order.**
|
|
1832
|
-
* R71 has the console work out which checkout a run would be given by
|
|
1833
|
-
* the first entry here
|
|
1834
|
-
*
|
|
1835
|
-
* the wire. Sorting or de-duplicating this on the
|
|
1836
|
-
* warning onto a checkout the run is not headed for,
|
|
1889
|
+
* R71/i258 has the console work out which checkout a run would be given by
|
|
1890
|
+
* taking the first free entry here whose `dirty` is 0, falling back to the
|
|
1891
|
+
* first free entry if none is known clean — the queue gate's `pickWorkspace`,
|
|
1892
|
+
* read from the other side of the wire. Sorting or de-duplicating this on the
|
|
1893
|
+
* way out would move the warning onto a checkout the run is not headed for,
|
|
1894
|
+
* and nothing would fail.
|
|
1837
1895
|
*/
|
|
1838
1896
|
/**
|
|
1839
1897
|
* One porcelain line, split into its status and its path.
|
|
@@ -1887,6 +1945,31 @@ async function surveyWorkingCopies(config) {
|
|
|
1887
1945
|
return survey;
|
|
1888
1946
|
}
|
|
1889
1947
|
|
|
1948
|
+
/** Bounds the claim-time `git status` probe — see `looksClean`. */
|
|
1949
|
+
const CLEAN_PROBE_MS = 5000;
|
|
1950
|
+
|
|
1951
|
+
/**
|
|
1952
|
+
* Is `path`'s tree clean, read fresh, right now — i258.
|
|
1953
|
+
*
|
|
1954
|
+
* Three-valued on purpose: `true` clean, `false` dirty, `null` could not
|
|
1955
|
+
* tell (no `.git`, a timeout, any failure). `null` is grouped with dirty by
|
|
1956
|
+
* every caller — "I could not look" is not "it was clean" — so a probe that
|
|
1957
|
+
* cannot answer degrades to the old first-free behaviour rather than
|
|
1958
|
+
* starving the queue on a wedged filesystem.
|
|
1959
|
+
*
|
|
1960
|
+
* This does not reuse `surveyWorkingCopies`'s reading. That one runs on the
|
|
1961
|
+
* heartbeat and is not kept anywhere; caching it here would add a second
|
|
1962
|
+
* source of truth for a fact `pickWorkspace` is about to establish anyway,
|
|
1963
|
+
* and a 30-second-old reading reproduces R71/i258 in a smaller window.
|
|
1964
|
+
* `prepareWorkingCopy` runs the identical `git status --porcelain` seconds
|
|
1965
|
+
* after this one.
|
|
1966
|
+
*/
|
|
1967
|
+
async function looksClean(path) {
|
|
1968
|
+
const porcelain = await git(path, ['status', '--porcelain'], { timeoutMs: CLEAN_PROBE_MS })
|
|
1969
|
+
.catch(() => null);
|
|
1970
|
+
return porcelain === null ? null : porcelain === '';
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1890
1973
|
/**
|
|
1891
1974
|
* What every checkout on this machine holds — R87.
|
|
1892
1975
|
*
|
|
@@ -2329,6 +2412,14 @@ async function surveyGit(config) {
|
|
|
2329
2412
|
* show up in the working-copy watcher and in `resetWorkspace`'s `git clean`
|
|
2330
2413
|
* list — R76's `.codegraph` lesson, see `excludeLocally`. The token goes in
|
|
2331
2414
|
* the child's ENVIRONMENT and never on its argv, which `ps` would show.
|
|
2415
|
+
*
|
|
2416
|
+
* R316: when the project SAYS its server refuses branches (`run.sonarBranches
|
|
2417
|
+
* === 'REFUSED'`, and only when this machine scans the same server the claim
|
|
2418
|
+
* names — see `trimSlash`), the branch attempt is skipped outright and the
|
|
2419
|
+
* machine's `whenBranchesRefused` runs straight away. Otherwise the branch
|
|
2420
|
+
* attempt still runs first and the refusal is still learnt from its failure,
|
|
2421
|
+
* exactly as before R316 — a project that has not said, or a machine
|
|
2422
|
+
* scanning a different server, gets today's behaviour unchanged.
|
|
2332
2423
|
*/
|
|
2333
2424
|
async function scanQuality(config, run, cwd, branch) {
|
|
2334
2425
|
const slug = run.projectSlug;
|
|
@@ -2363,6 +2454,14 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2363
2454
|
return report({ error: `The scanner token is not set: export ${quality.tokenEnv} on this machine.` });
|
|
2364
2455
|
}
|
|
2365
2456
|
|
|
2457
|
+
// R316: the project's own word on whether its server refuses branches is
|
|
2458
|
+
// trusted only when this machine scans THAT server — a machine whose own
|
|
2459
|
+
// `quality.url` names a different one (a LAN hostname the platform cannot
|
|
2460
|
+
// reach) knows nothing about what that server does, and tries the branch
|
|
2461
|
+
// scan first, exactly as if the project had said nothing.
|
|
2462
|
+
const claimSpeaks = !quality.url || trimSlash(quality.url) === trimSlash(run.sonarUrl ?? '');
|
|
2463
|
+
const knownRefused = claimSpeaks && run.sonarBranches === 'REFUSED';
|
|
2464
|
+
|
|
2366
2465
|
let headSha = null;
|
|
2367
2466
|
let workDir = null;
|
|
2368
2467
|
try {
|
|
@@ -2379,6 +2478,42 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2379
2478
|
];
|
|
2380
2479
|
const env = { ...process.env, SONAR_TOKEN: token };
|
|
2381
2480
|
|
|
2481
|
+
if (knownRefused) {
|
|
2482
|
+
if (quality.whenBranchesRefused === 'skip') {
|
|
2483
|
+
return report({
|
|
2484
|
+
headSha,
|
|
2485
|
+
error: "This project's SonarQube server does not analyse branches (Community Edition, set on the "
|
|
2486
|
+
+ 'project). Set "whenBranchesRefused": "project" (a scratch SonarQube project per branch) or '
|
|
2487
|
+
+ '"whenBranchesRefused": "main" in this machine\'s quality block.',
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
if (quality.whenBranchesRefused === 'project') {
|
|
2491
|
+
// `await`, not `return`: a rejection has to be caught by THIS
|
|
2492
|
+
// function's own try/catch, which `return somePromise` would skip —
|
|
2493
|
+
// control leaves before the promise settles.
|
|
2494
|
+
return await scanAsScratchProject(
|
|
2495
|
+
{ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
|
|
2496
|
+
}
|
|
2497
|
+
// 'main', and known refused: no reason to run the branch scan first.
|
|
2498
|
+
const ran = await runSkillCommand(quality.command, scannerArgs(false), { cwd, env, timeoutMs: remaining() });
|
|
2499
|
+
if (!ran.ok) {
|
|
2500
|
+
if (/ENOENT/.test(ran.said)) {
|
|
2501
|
+
return report({ headSha, error: `${quality.command} is not installed on this machine (spawn ENOENT).` });
|
|
2502
|
+
}
|
|
2503
|
+
return report({ headSha, error: ran.said.split('\n')[0] || 'the scanner failed and said nothing' });
|
|
2504
|
+
}
|
|
2505
|
+
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2506
|
+
const askServer = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
|
|
2507
|
+
const settled = await waitForAnalysis(askServer, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2508
|
+
return await reportBranchReading({
|
|
2509
|
+
ask: askServer, reportTask, settled, key, branch, analysedAs: 'MAIN',
|
|
2510
|
+
warning: "This server does not analyse branches; the branch was analysed as the project's main analysis.",
|
|
2511
|
+
headSha, report,
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// Today's behaviour: try the branch first, and learn what the server is
|
|
2516
|
+
// from a failure.
|
|
2382
2517
|
let analysedAs = 'BRANCH';
|
|
2383
2518
|
let warning = null;
|
|
2384
2519
|
let ran = await runSkillCommand(quality.command, scannerArgs(true),
|
|
@@ -2388,11 +2523,18 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2388
2523
|
return report({ headSha, error: `${quality.command} is not installed on this machine (spawn ENOENT).` });
|
|
2389
2524
|
}
|
|
2390
2525
|
if (/sonar\.branch\.name.*(Developer Edition|above is required)/is.test(ran.said)) {
|
|
2526
|
+
if (quality.whenBranchesRefused === 'project') {
|
|
2527
|
+
return await scanAsScratchProject(
|
|
2528
|
+
{ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
|
|
2529
|
+
}
|
|
2391
2530
|
if (quality.whenBranchesRefused !== 'main') {
|
|
2392
2531
|
return report({
|
|
2393
2532
|
headSha,
|
|
2394
|
-
error: 'This SonarQube server does not analyse branches (Community Edition). '
|
|
2395
|
-
+ '
|
|
2533
|
+
error: 'This SonarQube server does not analyse branches (Community Edition). Set '
|
|
2534
|
+
+ '"whenBranchesRefused": "project" (a scratch SonarQube project per branch) or '
|
|
2535
|
+
+ '"whenBranchesRefused": "main" in the quality block to analyse it as the main analysis instead '
|
|
2536
|
+
+ "— or set this project's SonarQube-branches setting under Settings so this is known before the "
|
|
2537
|
+
+ 'scan runs.',
|
|
2396
2538
|
});
|
|
2397
2539
|
}
|
|
2398
2540
|
// The operator opted in: once more, as the project's main analysis.
|
|
@@ -2407,61 +2549,10 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2407
2549
|
}
|
|
2408
2550
|
|
|
2409
2551
|
// What the scanner left behind: where the server queued the analysis.
|
|
2410
|
-
const
|
|
2411
|
-
const
|
|
2412
|
-
const
|
|
2413
|
-
|
|
2414
|
-
const response = await fetch(`${serverUrl}${path}`, { headers });
|
|
2415
|
-
if (!response.ok) {
|
|
2416
|
-
throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
|
|
2417
|
-
}
|
|
2418
|
-
return response.json();
|
|
2419
|
-
};
|
|
2420
|
-
|
|
2421
|
-
// The analysis is queued on the server; wait for it to settle.
|
|
2422
|
-
let settled = null;
|
|
2423
|
-
while (Date.now() < deadline) {
|
|
2424
|
-
const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(task.ceTaskId)}`);
|
|
2425
|
-
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
|
|
2426
|
-
settled = current;
|
|
2427
|
-
break;
|
|
2428
|
-
}
|
|
2429
|
-
await new Promise((wake) => setTimeout(wake, 3000));
|
|
2430
|
-
}
|
|
2431
|
-
if (!settled) {
|
|
2432
|
-
return report({ headSha, error: `SonarQube had not finished analysing after ${quality.timeoutSeconds}s.` });
|
|
2433
|
-
}
|
|
2434
|
-
if (settled.status !== 'SUCCESS') {
|
|
2435
|
-
return report({ headSha, error: settled.errorMessage ?? `SonarQube ${settled.status.toLowerCase()} the analysis.` });
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
|
|
2439
|
-
const metrics = 'new_bugs,new_vulnerabilities,new_security_hotspots,new_code_smells,new_coverage';
|
|
2440
|
-
const { component } = await ask(`/api/measures/component?component=${encodeURIComponent(key)}`
|
|
2441
|
-
+ `&metricKeys=${metrics}${analysedAs === 'BRANCH' ? `&branch=${encodeURIComponent(branch)}` : ''}`);
|
|
2442
|
-
const measure = (metric) => {
|
|
2443
|
-
const found = (component?.measures ?? []).find((each) => each.metric === metric);
|
|
2444
|
-
// New-code values sit in `period.value` — `periods[0].value` on older servers.
|
|
2445
|
-
const value = found?.period?.value ?? found?.periods?.[0]?.value ?? found?.value;
|
|
2446
|
-
return value === undefined || value === null || value === '' ? null : Number(value);
|
|
2447
|
-
};
|
|
2448
|
-
const integer = (metric) => {
|
|
2449
|
-
const value = measure(metric);
|
|
2450
|
-
return value === null ? null : Math.round(value);
|
|
2451
|
-
};
|
|
2452
|
-
|
|
2453
|
-
await report({
|
|
2454
|
-
headSha,
|
|
2455
|
-
gate: projectStatus?.status ?? 'NONE',
|
|
2456
|
-
analysedAs,
|
|
2457
|
-
newBugs: integer('new_bugs'),
|
|
2458
|
-
newVulnerabilities: integer('new_vulnerabilities'),
|
|
2459
|
-
newSecurityHotspots: integer('new_security_hotspots'),
|
|
2460
|
-
newCodeSmells: integer('new_code_smells'),
|
|
2461
|
-
newCoverage: measure('new_coverage'),
|
|
2462
|
-
dashboardUrl: task.dashboardUrl ?? null,
|
|
2463
|
-
warning,
|
|
2464
|
-
});
|
|
2552
|
+
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2553
|
+
const askServer = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
|
|
2554
|
+
const settled = await waitForAnalysis(askServer, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2555
|
+
await reportBranchReading({ ask: askServer, reportTask, settled, key, branch, analysedAs, warning, headSha, report });
|
|
2465
2556
|
} catch (failure) {
|
|
2466
2557
|
await report({ headSha, error: failure.message.split('\n')[0] });
|
|
2467
2558
|
} finally {
|
|
@@ -2471,6 +2562,185 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2471
2562
|
}
|
|
2472
2563
|
}
|
|
2473
2564
|
|
|
2565
|
+
/** Without a trailing slash, so two URLs that differ only by one still compare equal. */
|
|
2566
|
+
function trimSlash(url) {
|
|
2567
|
+
return url.replace(/\/$/, '');
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
/** A GET against the SonarQube server a scan just reported to, authorised with the scanner's own token. */
|
|
2571
|
+
function sonarAsker(serverUrl, token) {
|
|
2572
|
+
return async (path) => {
|
|
2573
|
+
const response = await fetch(`${serverUrl}${path}`, { headers: { authorization: `Bearer ${token}` } });
|
|
2574
|
+
if (!response.ok) {
|
|
2575
|
+
throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
|
|
2576
|
+
}
|
|
2577
|
+
return response.json();
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/**
|
|
2582
|
+
* Waits for a queued analysis to settle — shared by every scan path, BRANCH,
|
|
2583
|
+
* MAIN and PROJECT alike. Throws rather than reports: every caller runs
|
|
2584
|
+
* inside `scanQuality`'s own try/catch, which turns it into an error reading.
|
|
2585
|
+
*/
|
|
2586
|
+
async function waitForAnalysis(ask, ceTaskId, deadline, timeoutSeconds) {
|
|
2587
|
+
let settled = null;
|
|
2588
|
+
while (Date.now() < deadline) {
|
|
2589
|
+
const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`);
|
|
2590
|
+
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
|
|
2591
|
+
settled = current;
|
|
2592
|
+
break;
|
|
2593
|
+
}
|
|
2594
|
+
await new Promise((wake) => setTimeout(wake, 3000));
|
|
2595
|
+
}
|
|
2596
|
+
if (!settled) {
|
|
2597
|
+
throw new Error(`SonarQube had not finished analysing after ${timeoutSeconds}s.`);
|
|
2598
|
+
}
|
|
2599
|
+
if (settled.status !== 'SUCCESS') {
|
|
2600
|
+
throw new Error(settled.errorMessage ?? `SonarQube ${settled.status.toLowerCase()} the analysis.`);
|
|
2601
|
+
}
|
|
2602
|
+
return settled;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
/** The gate and the new-code measures, for a BRANCH or MAIN reading — never for PROJECT, which has neither. */
|
|
2606
|
+
async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report }) {
|
|
2607
|
+
const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
|
|
2608
|
+
const metrics = 'new_bugs,new_vulnerabilities,new_security_hotspots,new_code_smells,new_coverage';
|
|
2609
|
+
const { component } = await ask(`/api/measures/component?component=${encodeURIComponent(key)}`
|
|
2610
|
+
+ `&metricKeys=${metrics}${analysedAs === 'BRANCH' ? `&branch=${encodeURIComponent(branch)}` : ''}`);
|
|
2611
|
+
const measure = (metric) => {
|
|
2612
|
+
const found = (component?.measures ?? []).find((each) => each.metric === metric);
|
|
2613
|
+
// New-code values sit in `period.value` — `periods[0].value` on older servers.
|
|
2614
|
+
const value = found?.period?.value ?? found?.periods?.[0]?.value ?? found?.value;
|
|
2615
|
+
return value === undefined || value === null || value === '' ? null : Number(value);
|
|
2616
|
+
};
|
|
2617
|
+
const integer = (metric) => {
|
|
2618
|
+
const value = measure(metric);
|
|
2619
|
+
return value === null ? null : Math.round(value);
|
|
2620
|
+
};
|
|
2621
|
+
|
|
2622
|
+
await report({
|
|
2623
|
+
headSha,
|
|
2624
|
+
gate: projectStatus?.status ?? 'NONE',
|
|
2625
|
+
analysedAs,
|
|
2626
|
+
newBugs: integer('new_bugs'),
|
|
2627
|
+
newVulnerabilities: integer('new_vulnerabilities'),
|
|
2628
|
+
newSecurityHotspots: integer('new_security_hotspots'),
|
|
2629
|
+
newCodeSmells: integer('new_code_smells'),
|
|
2630
|
+
newCoverage: measure('new_coverage'),
|
|
2631
|
+
dashboardUrl: reportTask.dashboardUrl ?? null,
|
|
2632
|
+
warning,
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
/**
|
|
2637
|
+
* SonarQube's own pages, `ps=500` at a time, up to the server's `p × ps`
|
|
2638
|
+
* ceiling worth trying — R313's `LIST_PAGES` argument, in the scanner
|
|
2639
|
+
* instead of the reader. `paging.total` (hotspots) or `total` (issues) is
|
|
2640
|
+
* wherever the server put it.
|
|
2641
|
+
*/
|
|
2642
|
+
async function pagedSearch(ask, pathWithQuery, itemsKey) {
|
|
2643
|
+
const items = [];
|
|
2644
|
+
for (let page = 1; page <= 20; page += 1) {
|
|
2645
|
+
const body = await ask(`${pathWithQuery}&ps=500&p=${page}`);
|
|
2646
|
+
const list = body[itemsKey] ?? [];
|
|
2647
|
+
items.push(...list);
|
|
2648
|
+
const total = body.paging?.total ?? body.total ?? items.length;
|
|
2649
|
+
if (items.length >= total || list.length === 0) {
|
|
2650
|
+
break;
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
return items;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
/** `sha1(text)`'s first six hex characters — enough to keep a scratch key readable and still collision-free per branch. */
|
|
2657
|
+
function shortHash(text) {
|
|
2658
|
+
return createHash('sha1').update(text).digest('hex').slice(0, 6);
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
/**
|
|
2662
|
+
* The Community Edition fallback that never touches the project's own
|
|
2663
|
+
* dashboard — R316: the branch is analysed as its own scratch SonarQube
|
|
2664
|
+
* project, `<key>:branch:<branch>-<hash>`, overwritten on each run of the
|
|
2665
|
+
* branch. A fresh project has no new-code period to compare against, so
|
|
2666
|
+
* "new" is redefined as "on a line this branch changed since it left its
|
|
2667
|
+
* base" — {@link changedLines} against the merge-base — and the gate is
|
|
2668
|
+
* always `NONE`: a quality gate over a whole scratch project would judge
|
|
2669
|
+
* code this branch did not write.
|
|
2670
|
+
*/
|
|
2671
|
+
async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report }) {
|
|
2672
|
+
const baseName = run.baseBranch ?? run.defaultBranch ?? 'main';
|
|
2673
|
+
const ref = await firstRef(cwd, [`origin/${baseName}`, baseName]);
|
|
2674
|
+
const mergeBase = ref ? await git(cwd, ['merge-base', 'HEAD', ref]).catch(() => null) : null;
|
|
2675
|
+
if (!mergeBase) {
|
|
2676
|
+
return report({ headSha, error: `could not find where ${branch} left ${baseName}` });
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
const scratchKey = `${key}:branch:${branch.replace(/[^A-Za-z0-9_.-]/g, '_')}-${shortHash(branch)}`;
|
|
2680
|
+
const env = { ...process.env, SONAR_TOKEN: token };
|
|
2681
|
+
const args = [
|
|
2682
|
+
`-Dsonar.host.url=${url}`,
|
|
2683
|
+
`-Dsonar.projectKey=${scratchKey}`,
|
|
2684
|
+
`-Dsonar.projectName=${key} · ${branch}`,
|
|
2685
|
+
`-Dsonar.scm.revision=${headSha}`,
|
|
2686
|
+
`-Dsonar.working.directory=${workDir}`,
|
|
2687
|
+
];
|
|
2688
|
+
const ran = await runSkillCommand(quality.command, args,
|
|
2689
|
+
{ cwd, env, timeoutMs: Math.max(1000, deadline - Date.now()) });
|
|
2690
|
+
if (!ran.ok) {
|
|
2691
|
+
if (/ENOENT/.test(ran.said)) {
|
|
2692
|
+
throw new Error(`${quality.command} is not installed on this machine (spawn ENOENT).`);
|
|
2693
|
+
}
|
|
2694
|
+
throw new Error(ran.said.split('\n')[0] || 'the scanner failed and said nothing');
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2698
|
+
const ask = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
|
|
2699
|
+
await waitForAnalysis(ask, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2700
|
+
|
|
2701
|
+
const changed = await changedLines(cwd, mergeBase);
|
|
2702
|
+
const counted = (component, line) => {
|
|
2703
|
+
const path = component.slice(scratchKey.length + 1);
|
|
2704
|
+
const entry = changed.get(path);
|
|
2705
|
+
if (!entry) return false;
|
|
2706
|
+
if (entry === 'ALL') return true;
|
|
2707
|
+
// A file-level finding (no line) counts when the file itself changed.
|
|
2708
|
+
if (line === undefined || line === null) return true;
|
|
2709
|
+
return entry.has(line);
|
|
2710
|
+
};
|
|
2711
|
+
|
|
2712
|
+
const encodedKey = encodeURIComponent(scratchKey);
|
|
2713
|
+
const issues = await pagedSearch(ask,
|
|
2714
|
+
`/api/issues/search?componentKeys=${encodedKey}&resolved=false&types=BUG,VULNERABILITY,CODE_SMELL`, 'issues');
|
|
2715
|
+
const hotspots = await pagedSearch(ask,
|
|
2716
|
+
`/api/hotspots/search?projectKey=${encodedKey}&status=TO_REVIEW`, 'hotspots');
|
|
2717
|
+
|
|
2718
|
+
let newBugs = 0;
|
|
2719
|
+
let newVulnerabilities = 0;
|
|
2720
|
+
let newCodeSmells = 0;
|
|
2721
|
+
for (const issue of issues) {
|
|
2722
|
+
if (!counted(issue.component, issue.line)) continue;
|
|
2723
|
+
if (issue.type === 'BUG') newBugs += 1;
|
|
2724
|
+
else if (issue.type === 'VULNERABILITY') newVulnerabilities += 1;
|
|
2725
|
+
else if (issue.type === 'CODE_SMELL') newCodeSmells += 1;
|
|
2726
|
+
}
|
|
2727
|
+
const newSecurityHotspots = hotspots.filter((hotspot) => counted(hotspot.component, hotspot.line)).length;
|
|
2728
|
+
|
|
2729
|
+
await report({
|
|
2730
|
+
headSha,
|
|
2731
|
+
gate: 'NONE',
|
|
2732
|
+
analysedAs: 'PROJECT',
|
|
2733
|
+
newBugs,
|
|
2734
|
+
newVulnerabilities,
|
|
2735
|
+
newSecurityHotspots,
|
|
2736
|
+
newCodeSmells,
|
|
2737
|
+
newCoverage: null,
|
|
2738
|
+
dashboardUrl: reportTask.dashboardUrl ?? null,
|
|
2739
|
+
warning: 'This server does not analyse branches; the branch was analysed as its own SonarQube project, '
|
|
2740
|
+
+ 'counting problems on the lines it changed. No gate was computed.',
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2474
2744
|
/** `key=value` lines — the shape of the scanner's `report-task.txt`. */
|
|
2475
2745
|
function parseProperties(text) {
|
|
2476
2746
|
const parsed = {};
|
|
@@ -2689,7 +2959,10 @@ at what they might want next.
|
|
|
2689
2959
|
${briefLine(run)}
|
|
2690
2960
|
If a decision is genuinely theirs, use the cawdev MCP tool \`ask_user\` and wait.
|
|
2691
2961
|
|
|
2692
|
-
|
|
2962
|
+
${run.branch ? `When what they asked is done, commit it on ${run.branch} — work left
|
|
2963
|
+
uncommitted is work nothing can push or propose. Never commit to or push main.
|
|
2964
|
+
|
|
2965
|
+
` : ''}Their instruction:
|
|
2693
2966
|
|
|
2694
2967
|
${run.openingPrompt}`;
|
|
2695
2968
|
}
|
|
@@ -3371,13 +3644,35 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3371
3644
|
} else {
|
|
3372
3645
|
const base = request.baseBranch ?? request.defaultBranch ?? 'main';
|
|
3373
3646
|
log(` OPENING a pull request for ${branch} → ${base} — asked for from the console`);
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3647
|
+
// A card's branch may never have left this machine — a session whose
|
|
3648
|
+
// project does not push, which is why somebody is asking for the pull
|
|
3649
|
+
// request by hand. A sprint's is on origin already and this is a no-op.
|
|
3650
|
+
const empty = await nothingToPropose(cwd, branch, base);
|
|
3651
|
+
if (empty) {
|
|
3652
|
+
ok = false;
|
|
3653
|
+
result = empty;
|
|
3654
|
+
}
|
|
3655
|
+
const pushed = empty ? { failed: empty } : await pushIfOriginLacks(cwd, branch);
|
|
3656
|
+
if (pushed.failed) {
|
|
3657
|
+
ok = false;
|
|
3658
|
+
result = pushed.failed;
|
|
3659
|
+
} else {
|
|
3660
|
+
({ ok, result } = await openPullRequestFor(cwd, {
|
|
3661
|
+
branch,
|
|
3662
|
+
base,
|
|
3663
|
+
title: request.title?.trim() || branch,
|
|
3664
|
+
// A sprint's title is its ref and name ("S1 Notifications"); a
|
|
3665
|
+
// card's is "R3 — …" (RunnerController reads either at claim).
|
|
3666
|
+
body: /^S\d+ /.test(request.title ?? '')
|
|
3667
|
+
? `Opened by cawdev for the sprint branch \`${branch}\` — ${request.title.trim()}.\n\n`
|
|
3668
|
+
+ `Merging it lands every card of the sprint on ${base}.`
|
|
3669
|
+
: `Opened by cawdev for \`${branch}\`${request.title ? ` — ${request.title.trim()}` : ''}.`,
|
|
3670
|
+
}));
|
|
3671
|
+
// The URL stays the FIRST line: the platform reads it off there.
|
|
3672
|
+
if (ok && pushed.said) {
|
|
3673
|
+
result = `${result}\n${pushed.said}`;
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3381
3676
|
}
|
|
3382
3677
|
} else if (request.kind === 'CUT_BRANCH') {
|
|
3383
3678
|
// R260. Put the sprint's branch on origin, from the default's tip. A branch
|
|
@@ -3436,8 +3731,12 @@ function reportWorkspaceRequest(config, request, ok, result, failure = null) {
|
|
|
3436
3731
|
* platform holds the tree as it now is by the time the console hears "done".
|
|
3437
3732
|
*/
|
|
3438
3733
|
function watchWorkspaceRequests(config, { afterTheTreeMoved = null } = {}) {
|
|
3439
|
-
|
|
3440
|
-
|
|
3734
|
+
// Read on EVERY request, never captured once: a checkout cloned on demand
|
|
3735
|
+
// (`autoWorkspaces`) joins `project.workspaces` long after this started, and
|
|
3736
|
+
// a set taken at boot refused every request about one — `/pr` on a session
|
|
3737
|
+
// that ran in `saasng-auto-2` came back "This runner does not serve …".
|
|
3738
|
+
const served = () => new Set(
|
|
3739
|
+
Object.values(config.projects).flatMap((project) => project.workspaces ?? []),
|
|
3441
3740
|
);
|
|
3442
3741
|
|
|
3443
3742
|
const poll = async () => {
|
|
@@ -3453,7 +3752,7 @@ function watchWorkspaceRequests(config, { afterTheTreeMoved = null } = {}) {
|
|
|
3453
3752
|
// The same guard, for the same reason: this poll is on an interval, so a
|
|
3454
3753
|
// throw becomes an unhandled rejection and the daemon stops.
|
|
3455
3754
|
for (const request of Array.isArray(requests) ? requests : []) {
|
|
3456
|
-
if (!served.has(request.path)) {
|
|
3755
|
+
if (!served().has(request.path)) {
|
|
3457
3756
|
await reportWorkspaceRequest(config, request, false,
|
|
3458
3757
|
`This runner does not serve ${request.path}.`);
|
|
3459
3758
|
continue;
|
|
@@ -3678,6 +3977,35 @@ async function pushBranch(cwd, branch) {
|
|
|
3678
3977
|
* An existing pull request is a success, not a conflict — the rule wanted one
|
|
3679
3978
|
* to exist, and one does.
|
|
3680
3979
|
*/
|
|
3980
|
+
/**
|
|
3981
|
+
* Why there is nothing to open a pull request FOR, or null when there is.
|
|
3982
|
+
*
|
|
3983
|
+
* A branch with no commit beyond its base is not a pull request — GitHub says
|
|
3984
|
+
* "There isn't anything to compare" — and before this `/pr` pushed it anyway
|
|
3985
|
+
* and handed back the compare link, so the person went to GitHub to learn
|
|
3986
|
+
* what this machine already knew. The usual reason is work sitting
|
|
3987
|
+
* UNCOMMITTED in the checkout, so that is counted and said, with where.
|
|
3988
|
+
*
|
|
3989
|
+
* Asked BEFORE anything is pushed, on the OPEN_PR a person asks for (`/pr`).
|
|
3990
|
+
* Not on the `auto_pr` rule: a finished run that committed nothing gets gh's
|
|
3991
|
+
* own refusal there, as it always has. A count that cannot be made (no such
|
|
3992
|
+
* ref, no origin) is not a verdict: null, and the old path decides as it did.
|
|
3993
|
+
*/
|
|
3994
|
+
async function nothingToPropose(cwd, branch, base) {
|
|
3995
|
+
const has = (ref) => git(cwd, ['rev-parse', '--verify', '--quiet', ref]).then(() => true, () => false);
|
|
3996
|
+
const tip = (await has(branch)) ? branch : (await has(`origin/${branch}`)) ? `origin/${branch}` : null;
|
|
3997
|
+
const against = base ? `origin/${base}` : 'origin/HEAD';
|
|
3998
|
+
if (!tip || !(await has(against))) return null;
|
|
3999
|
+
const ahead = Number(await git(cwd, ['rev-list', '--count', `${against}..${tip}`]).catch(() => 'x'));
|
|
4000
|
+
if (!Number.isFinite(ahead) || ahead > 0) return null;
|
|
4001
|
+
const dirty = (await git(cwd, ['status', '--porcelain']).catch(() => '')).split('\n').filter(Boolean).length;
|
|
4002
|
+
const onIt = (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '')) === branch;
|
|
4003
|
+
return `${branch} has no commit beyond ${against.replace(/^origin\//, '')}, so there is nothing to propose.`
|
|
4004
|
+
+ (dirty && onIt
|
|
4005
|
+
? ` ${dirty} file(s) are uncommitted in ${cwd} — commit them, then ask again.`
|
|
4006
|
+
: ' Nothing was pushed.');
|
|
4007
|
+
}
|
|
4008
|
+
|
|
3681
4009
|
async function openPullRequest(cwd, run, title) {
|
|
3682
4010
|
const state = await readPushState(cwd, run.branch).catch(() => null);
|
|
3683
4011
|
if (state === 'NO_REMOTE') {
|
|
@@ -3716,6 +4044,51 @@ async function openPullRequest(cwd, run, title) {
|
|
|
3716
4044
|
* `--base` only when one is given: with no base the argv is byte for byte what
|
|
3717
4045
|
* it was before R260 and the host picks its own default.
|
|
3718
4046
|
*/
|
|
4047
|
+
/**
|
|
4048
|
+
* Pushes `branch` to origin when origin does not have it, or has an older
|
|
4049
|
+
* commit of it — and never otherwise.
|
|
4050
|
+
*
|
|
4051
|
+
* Before a pull request is opened for a branch a session wrote on this
|
|
4052
|
+
* machine: `gh pr create --head` names a branch on the HOST, and one that only
|
|
4053
|
+
* exists here fails with a message about the head that says nothing of why.
|
|
4054
|
+
* Only a fast-forward is pushed. Origin ahead, or diverged, is somebody else's
|
|
4055
|
+
* work on the branch, and pushing over it is not what "open the pull request"
|
|
4056
|
+
* asked for — that case goes to gh as it is and gh says what is wrong. Nothing
|
|
4057
|
+
* here moves the checkout: it is `<branch>:<branch>`, not the current HEAD.
|
|
4058
|
+
*
|
|
4059
|
+
* @returns `{ said }` when it pushed, `{}` when there was nothing to push, and
|
|
4060
|
+
* `{ failed }` when a push was needed and git refused it.
|
|
4061
|
+
*/
|
|
4062
|
+
async function pushIfOriginLacks(cwd, branch) {
|
|
4063
|
+
const local = await git(cwd, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
|
|
4064
|
+
.catch(() => null);
|
|
4065
|
+
if (!local) {
|
|
4066
|
+
return {};
|
|
4067
|
+
}
|
|
4068
|
+
const listed = await git(cwd, ['ls-remote', '--heads', 'origin', branch]).catch(() => null);
|
|
4069
|
+
if (listed === null) {
|
|
4070
|
+
return {};
|
|
4071
|
+
}
|
|
4072
|
+
const remote = listed.split(/\s+/)[0] || null;
|
|
4073
|
+
if (remote === local) {
|
|
4074
|
+
return {};
|
|
4075
|
+
}
|
|
4076
|
+
if (remote) {
|
|
4077
|
+
const behind = await git(cwd, ['merge-base', '--is-ancestor', remote, local])
|
|
4078
|
+
.then(() => true, () => false);
|
|
4079
|
+
if (!behind) {
|
|
4080
|
+
return {};
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
try {
|
|
4084
|
+
await git(cwd, ['push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], { timeoutMs: 120_000 });
|
|
4085
|
+
log(` pushed ${branch} to origin first`);
|
|
4086
|
+
return { said: `pushed ${branch} to origin first` };
|
|
4087
|
+
} catch (failure) {
|
|
4088
|
+
return { failed: `Could not push ${branch} before opening its pull request: ${failure.message}` };
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
|
|
3719
4092
|
async function openPullRequestFor(cwd, { branch, base, title, body }) {
|
|
3720
4093
|
const existing = await findPullRequest(cwd, branch).catch(() => null);
|
|
3721
4094
|
if (existing && !existing.includes('/compare/')) {
|
|
@@ -3730,8 +4103,28 @@ async function openPullRequestFor(cwd, { branch, base, title, body }) {
|
|
|
3730
4103
|
'--body', body,
|
|
3731
4104
|
]);
|
|
3732
4105
|
if (opened.code === 0 && opened.out) {
|
|
3733
|
-
// gh prints the URL and nothing else on success
|
|
3734
|
-
|
|
4106
|
+
// gh prints the URL and nothing else on success — and then the host is
|
|
4107
|
+
// asked whether it is really there, because "asked for" is not "made",
|
|
4108
|
+
// and a URL on the card that 404s is worse than an honest failure.
|
|
4109
|
+
const printed = opened.out.split('\n').pop().trim();
|
|
4110
|
+
const confirmed = await findPullRequest(cwd, branch).catch(() => null);
|
|
4111
|
+
if (confirmed && !confirmed.includes('/compare/')) {
|
|
4112
|
+
return { ok: true, result: confirmed };
|
|
4113
|
+
}
|
|
4114
|
+
return {
|
|
4115
|
+
ok: false,
|
|
4116
|
+
result: `gh said it opened ${printed}, but the host does not show a pull request for ${branch}.`,
|
|
4117
|
+
};
|
|
4118
|
+
}
|
|
4119
|
+
// The one failure somebody can fix in a minute, said with the fix — and the
|
|
4120
|
+
// link to open it by hand meanwhile, which `findPullRequest` builds without gh.
|
|
4121
|
+
if (opened.code === -1 && /not installed/.test(opened.err)) {
|
|
4122
|
+
const compare = await findPullRequest(cwd, branch).catch(() => null);
|
|
4123
|
+
return {
|
|
4124
|
+
ok: false,
|
|
4125
|
+
result: `${opened.err} Install it and sign in (brew install gh && gh auth login), `
|
|
4126
|
+
+ `then ask again${compare ? ` — or open it yourself: ${compare}` : '.'}`,
|
|
4127
|
+
};
|
|
3735
4128
|
}
|
|
3736
4129
|
return { ok: false, result: opened.err || opened.out || 'gh pr create failed without saying why.' };
|
|
3737
4130
|
}
|
|
@@ -4790,21 +5183,60 @@ function argsForProfile(agentArgs, profile, run, expertAgents = [], skills = [])
|
|
|
4790
5183
|
* re-derived the plan from a précis would be planning again, which is the one
|
|
4791
5184
|
* thing the gate exists to stop it doing after somebody approved the first one.
|
|
4792
5185
|
*/
|
|
4793
|
-
|
|
5186
|
+
/** Longer than ask_user's own ten-minute wait — see the cawdev server of spawnCodex. */
|
|
5187
|
+
const CAWDEV_TOOL_TIMEOUT_SEC = 660;
|
|
5188
|
+
|
|
5189
|
+
/**
|
|
5190
|
+
* A decision that is the person's is ASKED, and the stage goes on with the
|
|
5191
|
+
* answer. R19 and R24 (Codex) each met one — a plan the code had moved away
|
|
5192
|
+
* from, a plan whose review left the scope open — and ended the stage saying
|
|
5193
|
+
* so, the run FINISHED with nothing built and the question in a summary
|
|
5194
|
+
* nobody was asked. Claude asked; the wording let Codex stop instead.
|
|
5195
|
+
*/
|
|
5196
|
+
const ASK_DONT_STOP = 'If a decision is genuinely the person\'s — a scope the card leaves open, '
|
|
5197
|
+
+ 'a plan that no longer matches the code, a trade-off with no right answer — ASK it with '
|
|
5198
|
+
+ '`ask_user` (a round of questions if there are several), wait for the answer, and carry on '
|
|
5199
|
+
+ 'with it. Never end this stage on an open question: a question left in your report is one '
|
|
5200
|
+
+ 'nobody was asked.';
|
|
5201
|
+
|
|
5202
|
+
/**
|
|
5203
|
+
* A stage is ONE turn, and it ends the moment the turn does — so a command left
|
|
5204
|
+
* running in the background is a result lost (background-shells.mjs).
|
|
5205
|
+
*/
|
|
5206
|
+
const WAIT_FOR_COMMANDS = 'This stage ends when your turn ends: run every command in the '
|
|
5207
|
+
+ 'foreground and wait for it to finish — a build or a test suite left running in the '
|
|
5208
|
+
+ 'background is lost, and the stage is run again.';
|
|
5209
|
+
|
|
5210
|
+
/**
|
|
5211
|
+
* @param hasPlan whether the card has a plan of record — the same test
|
|
5212
|
+
* harnessPrompt uses to draw "The plan for this card" (`plan?.body`), so the
|
|
5213
|
+
* instruction and the block it points at cannot disagree
|
|
5214
|
+
*/
|
|
5215
|
+
function stagePrompt(stage, carried, hasPlan = true) {
|
|
4794
5216
|
const asked = {
|
|
4795
5217
|
PLAN: 'Work out what to do and WRITE THE PLAN. Do not change anything — you have no '
|
|
4796
5218
|
+ 'tool that can, so do not spend turns discovering that. Read what you need, then say, '
|
|
4797
5219
|
+ 'concretely: which files, what change in each, and how you will know it worked. '
|
|
4798
5220
|
+ 'What you report becomes the card\'s plan, and a DIFFERENT session — another '
|
|
4799
5221
|
+ 'process, with none of your context — will be handed it and told to carry it out. '
|
|
4800
|
-
+ 'Write it for them.',
|
|
5222
|
+
+ 'Write it for them — with every decision in it already made. ' + ASK_DONT_STOP,
|
|
4801
5223
|
VERIFY: 'Check the plan against what is actually there. Does every file it names exist? '
|
|
4802
5224
|
+ 'Does every interface it assumes still look like that? You cannot change anything — '
|
|
4803
5225
|
+ 'say what is wrong with the plan, or say plainly that it holds.',
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
5226
|
+
// With a plan, the plan is the task. Without one — `/new dev R21` on a
|
|
5227
|
+
// card nobody planned — the CARD is: the old wording pointed at a plan
|
|
5228
|
+
// section that was not there, and a careful session read "stop rather than
|
|
5229
|
+
// improvise" as "stop", ending R21 with nothing built and nothing to push.
|
|
5230
|
+
IMPLEMENT: (hasPlan
|
|
5231
|
+
? 'Do the work in the plan — it is under "The plan for this card" above, '
|
|
5232
|
+
+ 'written by the plan phase and agreed. Do not quietly improvise a different change: '
|
|
5233
|
+
+ 'somebody approved that plan. If it turns out to be wrong or incomplete, ask what to '
|
|
5234
|
+
+ 'do instead of stopping — and stop only if the answer is to stop. '
|
|
5235
|
+
: 'This card has no plan, and it does not need one: it was started straight into '
|
|
5236
|
+
+ 'development, so the CARD\'s text is the task. Work out what it asks — read the code '
|
|
5237
|
+
+ 'it touches first — build it, and commit it on this branch. Never stop because a plan '
|
|
5238
|
+
+ 'is missing. ')
|
|
5239
|
+
+ ASK_DONT_STOP + ' ' + WAIT_FOR_COMMANDS,
|
|
4808
5240
|
// R129. Two honest ways to do this stage, and the project chose one. The
|
|
4809
5241
|
// TESTBOOK wording says "you have no tool that could" for the PLAN
|
|
4810
5242
|
// prompt's reason: a session that has to discover a refusal spends turns
|
|
@@ -4823,7 +5255,8 @@ function stagePrompt(stage, carried) {
|
|
|
4823
5255
|
+ 'failing test is a result, not a failure of this stage. The branch\'s SonarQube gate '
|
|
4824
5256
|
+ 'is one call away — `quality_get`, which reads this run\'s branch by default; a red '
|
|
4825
5257
|
+ 'gate with new vulnerabilities is a finding to report with the output, not a failure '
|
|
4826
|
-
+ 'to hide.'
|
|
5258
|
+
+ 'to hide. ' + WAIT_FOR_COMMANDS + ' Then commit the branch\'s changes — no stage '
|
|
5259
|
+
+ 'after this one writes code, and work left uncommitted is work nothing can push.',
|
|
4827
5260
|
MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
|
|
4828
5261
|
+ 'tried and rejected and why, and anything that turned out to matter and would not be '
|
|
4829
5262
|
+ 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
|
|
@@ -5343,11 +5776,36 @@ function writeUserMessage(child, text) {
|
|
|
5343
5776
|
* and the qualified name is the one thing that must be exact. R147 was five
|
|
5344
5777
|
* bugs' worth of evidence that a name a session cannot use is a capability it
|
|
5345
5778
|
* does not have.
|
|
5779
|
+
*
|
|
5780
|
+
* <p>i257. `stillBackgrounded` names the ones that WERE delegated to but only
|
|
5781
|
+
* with a backgrounded `Agent` call — so "has not used it" would be false and
|
|
5782
|
+
* the fix would be wrong: telling a session to call something again that it
|
|
5783
|
+
* already called would read as broken, not as a nudge. Those get the specific
|
|
5784
|
+
* reason instead, because the generic "use it now" is the wrong instruction —
|
|
5785
|
+
* it already did, and doing it the same way again loses the result the same
|
|
5786
|
+
* way.
|
|
5346
5787
|
*/
|
|
5347
|
-
function nudgeText(stage, missing) {
|
|
5348
|
-
const
|
|
5349
|
-
|
|
5350
|
-
|
|
5788
|
+
function nudgeText(stage, missing, stillBackgrounded = []) {
|
|
5789
|
+
const neverUsed = missing.filter((each) => !stillBackgrounded.includes(each));
|
|
5790
|
+
const clauses = [
|
|
5791
|
+
neverUsed.length
|
|
5792
|
+
? `has not used ${neverUsed.map((each) => `\`${each}\``).join(', ')}`
|
|
5793
|
+
: null,
|
|
5794
|
+
].filter(Boolean);
|
|
5795
|
+
const opening = clauses.length
|
|
5796
|
+
? `cawdev: this project requires ${missing.map((each) => `\`${each}\``).join(', ')}, and `
|
|
5797
|
+
+ `this ${stage.stage} stage ${clauses.join(', and ')}.`
|
|
5798
|
+
: `cawdev: this project requires ${missing.map((each) => `\`${each}\``).join(', ')}.`;
|
|
5799
|
+
const backgroundedNames = stillBackgrounded.map((each) => `\`${each}\``).join(', ');
|
|
5800
|
+
const backgroundedNote = stillBackgrounded.length
|
|
5801
|
+
? `\n\n${backgroundedNames} ${stillBackgrounded.length > 1 ? 'were' : 'was'} delegated to `
|
|
5802
|
+
+ 'with `Agent`, but in the background, and its result has not come back. This stage is '
|
|
5803
|
+
+ 'one process (R112): your turn ending is indistinguishable, to the daemon, from being '
|
|
5804
|
+
+ 'finished, so a backgrounded delegation still in flight when that happens is lost along '
|
|
5805
|
+
+ 'with anything gated on it. Call it again with `run_in_background: false` and wait for '
|
|
5806
|
+
+ 'it in this same turn before finishing.'
|
|
5807
|
+
: '';
|
|
5808
|
+
return `${opening}${backgroundedNote}\n\n`
|
|
5351
5809
|
+ 'Use it now — `Skill` with `skill: "<name>"`, `Agent` with `subagent_type: "<name>"`, or '
|
|
5352
5810
|
+ 'for an MCP server a call to one of its tools (`mcp__<server>__…`) — and then say what it '
|
|
5353
5811
|
+ 'changed about your answer.\n\n'
|
|
@@ -5577,6 +6035,120 @@ function heldWorkspaces() {
|
|
|
5577
6035
|
return held;
|
|
5578
6036
|
}
|
|
5579
6037
|
|
|
6038
|
+
/**
|
|
6039
|
+
* Which checkout an offered coding run gets, or why it must keep waiting —
|
|
6040
|
+
* R71/i258.
|
|
6041
|
+
*
|
|
6042
|
+
* In order: a NAMED checkout (`offered.workspace`) is the only answer once
|
|
6043
|
+
* the branch is bound to it — served or busy, nothing else is considered. A
|
|
6044
|
+
* PREFERRED checkout (`offered.preferredWorkspace`) is taken when free,
|
|
6045
|
+
* dirty or not: it is the checkout the branch last ran in, so uncommitted
|
|
6046
|
+
* work there is plausibly that branch's own leftovers, and routing away from
|
|
6047
|
+
* it risks abandoning work rather than protecting it. That is pin-shaped
|
|
6048
|
+
* reasoning, and it is deliberate that cleanliness plays no part in this
|
|
6049
|
+
* rung — do not "fix" it in passing.
|
|
6050
|
+
*
|
|
6051
|
+
* The one exception is `offered.waitForClean` — R270. Once the person has
|
|
6052
|
+
* said "don't touch dirty", the idea that the leftovers are probably this
|
|
6053
|
+
* branch's own no longer applies: the preferred checkout is taken only when
|
|
6054
|
+
* it reads clean, and otherwise the run falls through to the free scan below
|
|
6055
|
+
* exactly as if it had no preference at all.
|
|
6056
|
+
*
|
|
6057
|
+
* Below that, a checkout is free game and cleanliness finally matters: the
|
|
6058
|
+
* first free path that reads CLEAN wins, and only when nothing free is known
|
|
6059
|
+
* clean does the first free path win regardless — today's behaviour,
|
|
6060
|
+
* unchanged, so the existing refusal in `prepareWorkingCopy` and the
|
|
6061
|
+
* console's *Start anyway* flow still work exactly as they did. With
|
|
6062
|
+
* `waitForClean` set, that last fallback does not happen: the run keeps
|
|
6063
|
+
* waiting instead of taking a dirty checkout.
|
|
6064
|
+
*
|
|
6065
|
+
* Cleanliness is read fresh, right here, rather than from
|
|
6066
|
+
* `surveyWorkingCopies`'s heartbeat reading — see `looksClean`. `probed` is a
|
|
6067
|
+
* `Map` the caller creates once per poll pass, so several offers of one
|
|
6068
|
+
* project only ask each checkout once.
|
|
6069
|
+
*
|
|
6070
|
+
* `surveyGit` and `checkMerges` use the same `workspaces.find((path) =>
|
|
6071
|
+
* !held.has(path))` idiom this replaces, and must NOT be unified with this:
|
|
6072
|
+
* they read refs, never the tree, so which checkout they pick is a different
|
|
6073
|
+
* question and dirtiness does not bear on it.
|
|
6074
|
+
*/
|
|
6075
|
+
async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0) {
|
|
6076
|
+
const pinned = offered.workspace ?? null;
|
|
6077
|
+
if (pinned) {
|
|
6078
|
+
if (!project.workspaces.includes(pinned)) {
|
|
6079
|
+
// Not one of ours. The platform pins a machine as well as a path, so
|
|
6080
|
+
// this means the config changed under a live branch — which is a
|
|
6081
|
+
// thing to say out loud rather than quietly ignore.
|
|
6082
|
+
return { workspace: null, why: `its branch is in ${pinned}, which this machine does not serve` };
|
|
6083
|
+
}
|
|
6084
|
+
if (held.has(pinned)) {
|
|
6085
|
+
return {
|
|
6086
|
+
workspace: null,
|
|
6087
|
+
why: `waiting for ${pinned}, which is busy — the branch has work only that checkout holds`,
|
|
6088
|
+
};
|
|
6089
|
+
}
|
|
6090
|
+
return { workspace: pinned, why: null };
|
|
6091
|
+
}
|
|
6092
|
+
|
|
6093
|
+
const waitForClean = offered.waitForClean === true;
|
|
6094
|
+
const preferred = offered.preferredWorkspace ?? null;
|
|
6095
|
+
if (preferred && project.workspaces.includes(preferred) && !held.has(preferred)) {
|
|
6096
|
+
if (!waitForClean) {
|
|
6097
|
+
return { workspace: preferred, why: null };
|
|
6098
|
+
}
|
|
6099
|
+
if (!probed.has(preferred)) probed.set(preferred, await looksClean(preferred));
|
|
6100
|
+
if (probed.get(preferred) === true) return { workspace: preferred, why: null };
|
|
6101
|
+
}
|
|
6102
|
+
|
|
6103
|
+
const free = project.workspaces.filter((path) => !held.has(path));
|
|
6104
|
+
if (!free.length) {
|
|
6105
|
+
// Every checkout is busy. Where the machine allows it, make one more
|
|
6106
|
+
// rather than wait — the second coding session on a project starting at
|
|
6107
|
+
// once is the whole point of `autoWorkspaces`.
|
|
6108
|
+
const first = project.workspaces[0];
|
|
6109
|
+
const made = project.workspaces.filter((path) => isAutoWorkspace(first, path));
|
|
6110
|
+
if (made.length < autoMax) {
|
|
6111
|
+
try {
|
|
6112
|
+
log(`every checkout of ${slug} is busy — cloning another beside ${first}`);
|
|
6113
|
+
const path = await createAutoWorkspace(first, project.workspaces);
|
|
6114
|
+
project.workspaces.push(path);
|
|
6115
|
+
log(` made ${path} (${made.length + 1} of ${autoMax} on demand)`);
|
|
6116
|
+
return { workspace: path, why: null };
|
|
6117
|
+
} catch (failure) {
|
|
6118
|
+
log(` could not make another checkout of ${slug}: ${failure.message}`);
|
|
6119
|
+
return { workspace: null, why: `no free workspace in ${slug}, and cloning another failed` };
|
|
6120
|
+
}
|
|
6121
|
+
}
|
|
6122
|
+
return {
|
|
6123
|
+
workspace: null,
|
|
6124
|
+
why: autoMax
|
|
6125
|
+
? `no free workspace in ${slug} (${project.workspaces.length} here, all busy, `
|
|
6126
|
+
+ `and the ${autoMax} this machine may make are made)`
|
|
6127
|
+
: `no free workspace in ${slug} (${project.workspaces.length} here, all busy)`,
|
|
6128
|
+
};
|
|
6129
|
+
}
|
|
6130
|
+
for (const path of free) {
|
|
6131
|
+
if (!probed.has(path)) probed.set(path, await looksClean(path));
|
|
6132
|
+
if (probed.get(path) === true) return { workspace: path, why: null };
|
|
6133
|
+
}
|
|
6134
|
+
// Nothing free reads clean — including anything `looksClean` could not read
|
|
6135
|
+
// at all, which groups with dirty rather than clean.
|
|
6136
|
+
if (waitForClean) {
|
|
6137
|
+
// R270: the person said to wait rather than go ahead on top of somebody
|
|
6138
|
+
// else's work. Keep waiting instead of falling back to the old
|
|
6139
|
+
// first-free rule below.
|
|
6140
|
+
return {
|
|
6141
|
+
workspace: null,
|
|
6142
|
+
why: `waiting for a clean checkout in ${slug} — every free one has uncommitted work or `
|
|
6143
|
+
+ 'could not be read; stash or commit in one, or wait for a running session to finish',
|
|
6144
|
+
};
|
|
6145
|
+
}
|
|
6146
|
+
// Fall back to the old first-free rule verbatim, so the refusal and *Start
|
|
6147
|
+
// anyway* flow still apply to exactly this workspace.
|
|
6148
|
+
log(`every free checkout in ${slug} has uncommitted work — taking ${free[0]}`);
|
|
6149
|
+
return { workspace: free[0], why: null };
|
|
6150
|
+
}
|
|
6151
|
+
|
|
5580
6152
|
function noteQueued(run, why) {
|
|
5581
6153
|
// Once per REASON, not once per run — R70, now that there are two gates a run
|
|
5582
6154
|
// can be held by. A run refused by the machine's cap and then, when a session
|
|
@@ -5618,6 +6190,10 @@ function snapshotRuns() {
|
|
|
5618
6190
|
// Which checkout it is in — R47. On a machine serving three, the label
|
|
5619
6191
|
// alone no longer says where the work is.
|
|
5620
6192
|
workspace: child.cawdevWorkspace ?? null,
|
|
6193
|
+
// Which CLI, and which model it is on — per STAGE on a walked run,
|
|
6194
|
+
// since each stage is its own process with its own model (R112).
|
|
6195
|
+
agent: child.cawdevAgent ?? null,
|
|
6196
|
+
model: child.cawdevModel ?? null,
|
|
5621
6197
|
startedAt: child.cawdevStartedAt ?? null,
|
|
5622
6198
|
});
|
|
5623
6199
|
}
|
|
@@ -5698,6 +6274,19 @@ async function startRun(config, offered, workspace) {
|
|
|
5698
6274
|
defaultBranch = claimed.defaultBranch;
|
|
5699
6275
|
// Whether somebody was shown the uncommitted work and started anyway.
|
|
5700
6276
|
allowDirty = claimed.allowDirty === true;
|
|
6277
|
+
// Or whether the uncommitted work is THIS BRANCH'S OWN: the platform named
|
|
6278
|
+
// the checkout (R86/R87 — a carried-on run's held workspace, or the next
|
|
6279
|
+
// run of a pinned work item) and it is already on the run's branch. R80
|
|
6280
|
+
// holds a failed run's checkout precisely so its work survives, and
|
|
6281
|
+
// refusing that work as "yours, commit it first" turned every Carry on
|
|
6282
|
+
// into a second failure with nothing in the console able to break the loop.
|
|
6283
|
+
if (!allowDirty && offered.workspace && path && writesCodeProfile(run) && run.branch) {
|
|
6284
|
+
const on = await git(resolve(path), ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '');
|
|
6285
|
+
if (on.trim() === run.branch) {
|
|
6286
|
+
allowDirty = true;
|
|
6287
|
+
log(` ${path} is on ${run.branch} already — its uncommitted work is this branch's, kept`);
|
|
6288
|
+
}
|
|
6289
|
+
}
|
|
5701
6290
|
// And what this project has decided happens when the run ends — R40. The
|
|
5702
6291
|
// daemon does not act on this: the platform queues the push, the pull
|
|
5703
6292
|
// request and the merge as actions and `settleActions` performs them. It is
|
|
@@ -5726,6 +6315,9 @@ async function startRun(config, offered, workspace) {
|
|
|
5726
6315
|
// reads them off `run` after TEST rather than through another parameter.
|
|
5727
6316
|
run.sonarProjectKey = claimed.sonarProjectKey ?? null;
|
|
5728
6317
|
run.sonarUrl = claimed.sonarUrl ?? null;
|
|
6318
|
+
// R316. Whether the project SAYS its server refuses branches — null is
|
|
6319
|
+
// "not sure, try the branch scan first", today's behaviour.
|
|
6320
|
+
run.sonarBranches = claimed.sonarBranches ?? null;
|
|
5729
6321
|
// R69. Whether this is the same conversation picked back up, and which one.
|
|
5730
6322
|
// Null on an ordinary claim, which is nearly all of them.
|
|
5731
6323
|
resume = claimed.resume ?? null;
|
|
@@ -6005,8 +6597,23 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6005
6597
|
return { tokensIn: Number(row?.tokensIn) || 0, tokensOut: Number(row?.tokensOut) || 0 };
|
|
6006
6598
|
};
|
|
6007
6599
|
|
|
6600
|
+
const storedRow = (name) => (Array.isArray(stored)
|
|
6601
|
+
? stored.find((each) => each?.stage === name) : null);
|
|
6602
|
+
|
|
6008
6603
|
for (let at = 0; at < lifecycle.length; at++) {
|
|
6009
6604
|
const stage = lifecycle[at];
|
|
6605
|
+
// Where a carried-on run resumes, and what its DONE stages said, is the
|
|
6606
|
+
// platform's (R263, `WorkflowService.resumeFrom`): the claim's lifecycle
|
|
6607
|
+
// starts at the first stage not DONE. What only the daemon knows is WHY
|
|
6608
|
+
// the stage about to run stopped last time — so that is handed to it.
|
|
6609
|
+
const before = storedRow(stage.stage);
|
|
6610
|
+
if (before?.state === 'FAILED' && before.outcome) {
|
|
6611
|
+
// The attempt that stopped, handed to the one about to run.
|
|
6612
|
+
carried[`${stage.stage} (the attempt that stopped)`] = `${before.outcome}\n\n`
|
|
6613
|
+
+ 'This stage is being run again to finish that. Run the commands it left running '
|
|
6614
|
+
+ 'again IN THE FOREGROUND — not with run_in_background — wait for each one to end '
|
|
6615
|
+
+ 'and read its output, then finish the stage properly.';
|
|
6616
|
+
}
|
|
6010
6617
|
// R305. What THIS stage's processes have used, across its attempts. Outside
|
|
6011
6618
|
// the attempt loop on purpose: `run_stage` is one row per stage and keeps
|
|
6012
6619
|
// the larger reading, so a second attempt after a refused gate must report
|
|
@@ -6117,6 +6724,21 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6117
6724
|
return;
|
|
6118
6725
|
}
|
|
6119
6726
|
|
|
6727
|
+
// The turn ended, but with commands it started in the background still
|
|
6728
|
+
// running, or finished and never read. Not done: the result is what the
|
|
6729
|
+
// stage was for. FAILED keeps the checkout and the session (R80), so
|
|
6730
|
+
// /continue picks the run up HERE — the stages behind it stay pending.
|
|
6731
|
+
if (last?.unfinishedShells?.length) {
|
|
6732
|
+
const listed = last.unfinishedShells.map((command) => `\`${command.slice(0, 120)}\``).join(', ');
|
|
6733
|
+
const why = `The ${stage.stage} stage ended with ${last.unfinishedShells.length} command(s) `
|
|
6734
|
+
+ `still running in the background, or finished and never read: ${listed}. `
|
|
6735
|
+
+ 'Nothing they proved was recorded — /continue runs this stage again and waits for them.';
|
|
6736
|
+
log(` ${why}`);
|
|
6737
|
+
await reportStage(config, run, stage.stage, 'report', { state: 'FAILED', outcome: why });
|
|
6738
|
+
await finish(config, run, 'FAILED', `${why}\n\n${last.text ?? ''}`.trim());
|
|
6739
|
+
return;
|
|
6740
|
+
}
|
|
6741
|
+
|
|
6120
6742
|
await reportStage(config, run, stage.stage, 'report', {
|
|
6121
6743
|
state: 'DONE',
|
|
6122
6744
|
// The PLAN's text IS the plan — the artefact R109 exists to store, and
|
|
@@ -6316,8 +6938,16 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6316
6938
|
// checked against the real CLI by hand) that it is not this function's
|
|
6317
6939
|
// Claude-specific machinery with a different binary name; it is
|
|
6318
6940
|
// `spawnAntigravity`, entirely.
|
|
6941
|
+
// Codex's own shape — a real sandbox and a per-server tool list, but no
|
|
6942
|
+
// allow-list for the shell — is `spawnCodex`, for the same reason.
|
|
6943
|
+
if ((stage?.agent ?? run.agent) === 'codex') {
|
|
6944
|
+
const codexCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'codex') ?? 'codex';
|
|
6945
|
+
return spawnCodex(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
6946
|
+
skills, stageExperts, readOnlyStage, instincts, briefing, plan, lifecycle, stage, carried,
|
|
6947
|
+
codexCmd, granted, shield, usage);
|
|
6948
|
+
}
|
|
6319
6949
|
if ((stage?.agent ?? run.agent) === 'agy') {
|
|
6320
|
-
const agyCmd = config.agentCommands.find((each) => each
|
|
6950
|
+
const agyCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'agy') ?? 'agy';
|
|
6321
6951
|
return spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
6322
6952
|
skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
|
|
6323
6953
|
agyCmd, granted, usage);
|
|
@@ -6654,10 +7284,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6654
7284
|
// Reaching here at all means Claude Code answers this run — the branch at
|
|
6655
7285
|
// the top of this function sent Antigravity to spawnAntigravity instead.
|
|
6656
7286
|
// Matched against what this machine actually lists (`config.agentCommands`)
|
|
6657
|
-
// by the
|
|
6658
|
-
//
|
|
6659
|
-
const agentCmd = config.agentCommands.find((each) =>
|
|
6660
|
-
??
|
|
7287
|
+
// by `agentOfCommand`, the rule `readUsage` uses too, so a machine whose list
|
|
7288
|
+
// is agy- or codex-first still gets Claude Code's own command.
|
|
7289
|
+
const agentCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'claude')
|
|
7290
|
+
?? 'claude';
|
|
6661
7291
|
|
|
6662
7292
|
const args = [
|
|
6663
7293
|
'--mcp-config',
|
|
@@ -6692,6 +7322,11 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6692
7322
|
});
|
|
6693
7323
|
|
|
6694
7324
|
child.cawdevProjectSlug = run.projectSlug;
|
|
7325
|
+
// Which CLI and which model — what advanced mode puts in a window's title.
|
|
7326
|
+
// The model asked for now; the one the CLI says it is on replaces it at
|
|
7327
|
+
// `init` (below), which is also the only answer when none was asked.
|
|
7328
|
+
child.cawdevAgent = 'claude';
|
|
7329
|
+
child.cawdevModel = model ?? null;
|
|
6695
7330
|
// The checkout this child holds, and what the queue serialises on. Null for
|
|
6696
7331
|
// a run that took none, which is what lets an ASK session run alongside
|
|
6697
7332
|
// three coding ones.
|
|
@@ -6736,7 +7371,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6736
7371
|
// sending it again is a repeat wearing a resume's clothes — R69's rule about
|
|
6737
7372
|
// the opening prompt, applied to the thing that now travels with it.
|
|
6738
7373
|
writeUserMessage(child, resume?.prompt
|
|
6739
|
-
?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried) : '')));
|
|
7374
|
+
?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : '')));
|
|
6740
7375
|
|
|
6741
7376
|
let lastText = '';
|
|
6742
7377
|
// R305. This child's own last reading, handed back to the walk so the next
|
|
@@ -6751,6 +7386,21 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6751
7386
|
// asked of the platform: the answer is needed at the `result` event, and a
|
|
6752
7387
|
// question that has to cross the network cannot be answered in time.
|
|
6753
7388
|
const used = new Set();
|
|
7389
|
+
// i257. A required expert's `Agent(...)` line is written the instant the
|
|
7390
|
+
// call is MADE (R130), not when its result comes back — so `used` alone
|
|
7391
|
+
// cannot tell "delegated and waited" from "delegated and moved on". This
|
|
7392
|
+
// holds the qualified name of every required expert whose most recent
|
|
7393
|
+
// `Agent`/`Task` call was NOT `run_in_background: false` — added on that
|
|
7394
|
+
// call, and removed the moment the same name is called again WITH it. A
|
|
7395
|
+
// stage is one process and gets one turn (R112); a session that ends its
|
|
7396
|
+
// turn with a name still in here dispatched the delegation and did not
|
|
7397
|
+
// wait for it, which is exactly the shape that loses it when `endSession`
|
|
7398
|
+
// closes stdin below.
|
|
7399
|
+
const backgrounded = new Set();
|
|
7400
|
+
// Shell commands this child started in the background and never saw end
|
|
7401
|
+
// — the walk will not call a stage done with any of them left (see
|
|
7402
|
+
// background-shells.mjs).
|
|
7403
|
+
const shells = new BackgroundShells();
|
|
6754
7404
|
// The last of what the CLI said on stderr, for the exit decision below.
|
|
6755
7405
|
// The usage-limit notice is written there, and a decision made on stdout
|
|
6756
7406
|
// alone would call every window a crash.
|
|
@@ -6912,6 +7562,71 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6912
7562
|
buffer = buffer.slice(newline + 1);
|
|
6913
7563
|
if (!line) continue;
|
|
6914
7564
|
const event = safeJson(line);
|
|
7565
|
+
if (event) shells.observe(event);
|
|
7566
|
+
|
|
7567
|
+
// i257. Read off the RAW event, before `linesOf` flattens it to a
|
|
7568
|
+
// string: `describeCall` (`../lib/tool-line.mjs`) never carries
|
|
7569
|
+
// `run_in_background` into the TOOL line it writes — deliberately not
|
|
7570
|
+
// extended for it, because that line is a parsing contract three
|
|
7571
|
+
// implementations share (`CapabilityUse.java`, `capability-tag.ts`),
|
|
7572
|
+
// and this is a daemon-internal question none of the three need
|
|
7573
|
+
// answered. So this is the one place that can still see the field.
|
|
7574
|
+
// Record-then-decide, ahead of the loop below that reacts to
|
|
7575
|
+
// `result`, so a `result` on this same line always sees this line's
|
|
7576
|
+
// own dispatches first.
|
|
7577
|
+
//
|
|
7578
|
+
// Only tracked for a REQUIRED expert. An optional one backgrounded is
|
|
7579
|
+
// the session's own call and nothing here gates on it.
|
|
7580
|
+
//
|
|
7581
|
+
// Known consequence of not extending the shared line: the platform's
|
|
7582
|
+
// own ledger (`RunCapabilityService.used`, called from
|
|
7583
|
+
// `RunService.appendOutput` on the same TOOL line and read by the run
|
|
7584
|
+
// panel) increments `RunCapability.uses` the instant the line is
|
|
7585
|
+
// written, exactly like `used` below — it has no way to learn a
|
|
7586
|
+
// dispatch was backgrounded, so a stage this nudge caught and that
|
|
7587
|
+
// then corrected itself still reads as an ordinary "required,
|
|
7588
|
+
// delegated to 1×" on the panel afterwards. That is this card's
|
|
7589
|
+
// trade-off, made on purpose: the daemon is the one place that can
|
|
7590
|
+
// still leave with the delegation unfinished, so it is the one place
|
|
7591
|
+
// that has to catch it before it does. Making the panel say so too is
|
|
7592
|
+
// a different card, not a reason to read it as ground truth here.
|
|
7593
|
+
//
|
|
7594
|
+
// The default-background assumption this rests on is not a guess:
|
|
7595
|
+
// i249 and i250 are both a `Agent(...)` call with no
|
|
7596
|
+
// `run_in_background` at all whose result never came back — this
|
|
7597
|
+
// project's own Agent tool backgrounds unless told `false`. If a
|
|
7598
|
+
// spawned CLI's `Agent` were ever synchronous by default, this would
|
|
7599
|
+
// nudge a stage that did nothing wrong — loud, not silent, and capped
|
|
7600
|
+
// at one wasted turn by `child.cawdevNudged` below.
|
|
7601
|
+
//
|
|
7602
|
+
// Keyed `EXPERT:<name>`, matching `mustUse`/`used`: a Set of bare
|
|
7603
|
+
// names would let a REQUIRED skill and a REQUIRED expert that share a
|
|
7604
|
+
// qualified name mark each other's requirement backgrounded.
|
|
7605
|
+
//
|
|
7606
|
+
// The last call to a name wins. A session that is awaited once and
|
|
7607
|
+
// backgrounded again later is reported missing on the second call —
|
|
7608
|
+
// "requirement met" and "something is in flight" are conflated on
|
|
7609
|
+
// purpose, because the second call is the one whose result this
|
|
7610
|
+
// stage's turn is actually at risk of losing.
|
|
7611
|
+
if (event?.type === 'assistant') {
|
|
7612
|
+
for (const part of event.message?.content ?? []) {
|
|
7613
|
+
if (part?.type !== 'tool_use' || (part.name !== 'Agent' && part.name !== 'Task')) {
|
|
7614
|
+
continue;
|
|
7615
|
+
}
|
|
7616
|
+
const who = typeof part.input?.subagent_type === 'string'
|
|
7617
|
+
? part.input.subagent_type : null;
|
|
7618
|
+
const entry = who ? `EXPERT:${who}` : null;
|
|
7619
|
+
if (!entry || !mustUse.has(entry)) {
|
|
7620
|
+
continue;
|
|
7621
|
+
}
|
|
7622
|
+
if (part.input?.run_in_background === false) {
|
|
7623
|
+
backgrounded.delete(entry);
|
|
7624
|
+
} else {
|
|
7625
|
+
backgrounded.add(entry);
|
|
7626
|
+
}
|
|
7627
|
+
}
|
|
7628
|
+
}
|
|
7629
|
+
|
|
6915
7630
|
if (event?.type === 'result') {
|
|
6916
7631
|
if (typeof event.result === 'string') {
|
|
6917
7632
|
lastText = event.result;
|
|
@@ -6977,7 +7692,18 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6977
7692
|
// lifecycle never reaches here — `endSession` is deliberately not
|
|
6978
7693
|
// called for one — so REQUIRED is prompt wording and a console row
|
|
6979
7694
|
// on ASK, ROADMAP and AUDIT, and nothing more.
|
|
6980
|
-
|
|
7695
|
+
// i257. `used` alone says "the name appeared in a TOOL line",
|
|
7696
|
+
// which is true the instant a required expert is DISPATCHED —
|
|
7697
|
+
// backgrounded or not. An entry still in `backgrounded` was
|
|
7698
|
+
// delegated but never waited for, so it counts as missing here
|
|
7699
|
+
// too. `stillBackgroundedEntries` keeps the `EXPERT:` prefix so it
|
|
7700
|
+
// can only ever match the Set it was built from; only its NAMES
|
|
7701
|
+
// (`nameOf`) are for display.
|
|
7702
|
+
const stillBackgroundedEntries = [...mustUse].filter((entry) => backgrounded.has(entry));
|
|
7703
|
+
const trulyMissingEntries = [...mustUse].filter((entry) => !used.has(entry));
|
|
7704
|
+
const missing = [...new Set([...trulyMissingEntries, ...stillBackgroundedEntries])]
|
|
7705
|
+
.map(nameOf);
|
|
7706
|
+
const stillBackgrounded = stillBackgroundedEntries.map(nameOf);
|
|
6981
7707
|
// Whether the input stays open for one more turn. NOT an early
|
|
6982
7708
|
// return out of this handler, which the obvious shape would be:
|
|
6983
7709
|
// this is the stdout reader's `while`, and returning from it would
|
|
@@ -6988,8 +7714,24 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6988
7714
|
// Once per process. A model that has decided cannot be looped:
|
|
6989
7715
|
// this is a nudge, not an argument.
|
|
6990
7716
|
child.cawdevNudged = true;
|
|
6991
|
-
|
|
6992
|
-
|
|
7717
|
+
// i257. "has not used" is not true of `stillBackgrounded` — it
|
|
7718
|
+
// WAS called, just not waited for — and this line is the half
|
|
7719
|
+
// of the nudge a person actually reads (`nudgeText` below goes
|
|
7720
|
+
// to the child's stdin, never to the transcript). This card
|
|
7721
|
+
// exists because a run once said nothing was done when work
|
|
7722
|
+
// really happened; a nudge that misdescribes its own trigger
|
|
7723
|
+
// would be the same mistake at smaller scale.
|
|
7724
|
+
const trulyMissingNames = trulyMissingEntries.filter((entry) => !backgrounded.has(entry))
|
|
7725
|
+
.map(nameOf);
|
|
7726
|
+
const clauses = [
|
|
7727
|
+
trulyMissingNames.length ? `has not used ${trulyMissingNames.join(', ')}` : null,
|
|
7728
|
+
stillBackgrounded.length
|
|
7729
|
+
? `delegated to ${stillBackgrounded.join(', ')} only in the background and never `
|
|
7730
|
+
+ 'waited for it'
|
|
7731
|
+
: null,
|
|
7732
|
+
].filter(Boolean);
|
|
7733
|
+
const line = `cawdev: this stage ${clauses.join(', and ')}, which this project `
|
|
7734
|
+
+ 'requires. Asking it once.';
|
|
6993
7735
|
log(` ${line}`);
|
|
6994
7736
|
transcript.push({ kind: 'SYSTEM', body: line });
|
|
6995
7737
|
// The return value is load-bearing. It is false when stdin is
|
|
@@ -6997,7 +7739,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6997
7739
|
// through to `endSession` is what stops a dead session from never
|
|
6998
7740
|
// being dismissed. That is R22's forty-three minutes arriving
|
|
6999
7741
|
// through a new door.
|
|
7000
|
-
nudged = writeUserMessage(child, nudgeText(stage, missing));
|
|
7742
|
+
nudged = writeUserMessage(child, nudgeText(stage, missing, stillBackgrounded));
|
|
7001
7743
|
if (nudged) {
|
|
7002
7744
|
// The turn runs again and the next `result` closes the input.
|
|
7003
7745
|
// This timer is the whole defence against the one deadlock this
|
|
@@ -7022,6 +7764,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7022
7764
|
if (event?.type === 'system' && event.subtype === 'init' && event.session_id) {
|
|
7023
7765
|
reportSessionId(config, run, event.session_id);
|
|
7024
7766
|
}
|
|
7767
|
+
if (event?.type === 'system' && event.subtype === 'init' && event.model) {
|
|
7768
|
+
child.cawdevModel = event.model;
|
|
7769
|
+
}
|
|
7025
7770
|
// R76. What the session has consumed, recorded rather than only
|
|
7026
7771
|
// printed. Caught here for the reason the session id is: the CLI says
|
|
7027
7772
|
// it on a `result` event and nowhere else.
|
|
@@ -7224,7 +7969,8 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7224
7969
|
// gate on it, and carry it into the next stage. A run with no lifecycle
|
|
7225
7970
|
// resolves with nothing, exactly as it always did.
|
|
7226
7971
|
resolvePromise(stage
|
|
7227
|
-
? { code, signal, text: lastText, turnEnded, usage: childTotals
|
|
7972
|
+
? { code, signal, text: lastText, turnEnded, usage: childTotals,
|
|
7973
|
+
unfinishedShells: shells.unfinished() }
|
|
7228
7974
|
: undefined);
|
|
7229
7975
|
});
|
|
7230
7976
|
});
|
|
@@ -7519,14 +8265,36 @@ function inlinedSkillsFor(skills) {
|
|
|
7519
8265
|
* tool a session delegates through — confirmed end-to-end by hand, a probe
|
|
7520
8266
|
* agent installed, invoked by name, and its answer returned correctly.
|
|
7521
8267
|
*/
|
|
8268
|
+
/** This machine, as a refusal names it: what it is called, where its config and console are. */
|
|
8269
|
+
function thisMachine(config) {
|
|
8270
|
+
return {
|
|
8271
|
+
name: config.name,
|
|
8272
|
+
acceptsConsoleRules: config.acceptsRulesFromConsole === true,
|
|
8273
|
+
configPath: config.configPath ?? null,
|
|
8274
|
+
consoleUrl: config.url,
|
|
8275
|
+
};
|
|
8276
|
+
}
|
|
8277
|
+
|
|
8278
|
+
/**
|
|
8279
|
+
* A refusal said on the run's own transcript, where the terminal and the run
|
|
8280
|
+
* page show it — the run's summary alone left a window reading "failed" and
|
|
8281
|
+
* nothing else, with the reason one page away.
|
|
8282
|
+
*/
|
|
8283
|
+
async function sayOnTheRun(config, run, text) {
|
|
8284
|
+
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/output`, {
|
|
8285
|
+
method: 'POST',
|
|
8286
|
+
body: { lines: text.split('\n').filter((body) => body.trim()).map((body) => ({ kind: 'ERROR', body })) },
|
|
8287
|
+
}).catch((failure) => log(` could not say it on the run: ${failure.message}`));
|
|
8288
|
+
}
|
|
8289
|
+
|
|
7522
8290
|
async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
7523
8291
|
skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
|
|
7524
8292
|
agyCmd, granted, usage = null) {
|
|
7525
8293
|
if (!granted.everything) {
|
|
7526
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
7529
|
-
|
|
8294
|
+
// Antigravity needs it even to read: no per-tool allow-list exists on it.
|
|
8295
|
+
const summary = needsEverything('agy', thisMachine(config), run, { readsWithout: false });
|
|
8296
|
+
log(` refusing to spawn Antigravity: ${summary.split('\n')[0]}`);
|
|
8297
|
+
await sayOnTheRun(config, run, summary);
|
|
7530
8298
|
await finish(config, run, 'FAILED', summary);
|
|
7531
8299
|
return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
|
|
7532
8300
|
}
|
|
@@ -7616,7 +8384,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7616
8384
|
const promptText = resuming
|
|
7617
8385
|
? resume.prompt
|
|
7618
8386
|
: (promptFor(run) + harness + skillsBlock + expertsBlock
|
|
7619
|
-
+ (stage ? stagePrompt(stage, carried) : ''));
|
|
8387
|
+
+ (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : ''));
|
|
7620
8388
|
|
|
7621
8389
|
const args = ['-p', promptText, '--output-format', 'stream-json', '--dangerously-skip-permissions'];
|
|
7622
8390
|
if (run.model) {
|
|
@@ -7657,12 +8425,20 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7657
8425
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7658
8426
|
});
|
|
7659
8427
|
child.cawdevProjectSlug = run.projectSlug;
|
|
8428
|
+
child.cawdevAgent = 'agy';
|
|
8429
|
+
child.cawdevModel = run.model ?? null;
|
|
7660
8430
|
// The existing cancellation sweep (`reapCancelled`) reads this map by
|
|
7661
8431
|
// run id and SIGTERMs whatever is in it — true for any child, so a
|
|
7662
8432
|
// cancelled Antigravity run stops exactly the way a cancelled Claude
|
|
7663
8433
|
// Code one does, with no code of its own.
|
|
7664
8434
|
running.set(run.id, child);
|
|
7665
8435
|
|
|
8436
|
+
// What the console can see of the checkout, and the commits as they land —
|
|
8437
|
+
// the same watcher as the Claude Code path. Without it the platform never
|
|
8438
|
+
// hears of this run's commits, so the finish rules (R40) see a run that
|
|
8439
|
+
// wrote nothing and queue no push and no pull request.
|
|
8440
|
+
const workingCopy = watchWorkingCopy(config, run, cwd, baseCommit);
|
|
8441
|
+
|
|
7666
8442
|
// stream-json arrives in chunks that split mid-line, same as Claude
|
|
7667
8443
|
// Code's — buffer until a full line rather than assuming one chunk is
|
|
7668
8444
|
// one event.
|
|
@@ -7759,6 +8535,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7759
8535
|
});
|
|
7760
8536
|
|
|
7761
8537
|
child.on('error', async (failure) => {
|
|
8538
|
+
workingCopy.stop();
|
|
7762
8539
|
running.delete(run.id);
|
|
7763
8540
|
const summary = `Could not spawn Antigravity: ${failure.message}`;
|
|
7764
8541
|
await transcript.flush();
|
|
@@ -7768,6 +8545,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7768
8545
|
});
|
|
7769
8546
|
|
|
7770
8547
|
child.on('close', async (code) => {
|
|
8548
|
+
workingCopy.stop();
|
|
7771
8549
|
running.delete(run.id);
|
|
7772
8550
|
await transcript.flush();
|
|
7773
8551
|
const text = result?.response
|
|
@@ -7795,6 +8573,10 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7795
8573
|
if (!stage) {
|
|
7796
8574
|
await settleActions(config, run, cwd);
|
|
7797
8575
|
}
|
|
8576
|
+
// The last reading, as on the Claude Code path: after the agent has
|
|
8577
|
+
// stopped and after anything the rules did.
|
|
8578
|
+
await reportCommits(config, run, cwd, baseCommit);
|
|
8579
|
+
|
|
7798
8580
|
resolvePromise(stage
|
|
7799
8581
|
? { code, signal: null, text, turnEnded, usage: childTotals }
|
|
7800
8582
|
: undefined);
|
|
@@ -7802,6 +8584,310 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7802
8584
|
});
|
|
7803
8585
|
}
|
|
7804
8586
|
|
|
8587
|
+
/**
|
|
8588
|
+
* The cawdev tools a Codex process may call — the SAME list Claude Code's
|
|
8589
|
+
* `--allowedTools` would hold for this run and stage, without the prefix.
|
|
8590
|
+
*
|
|
8591
|
+
* <p>`enabled_tools` on cawdev's server is the one narrowing this CLI does as
|
|
8592
|
+
* precisely as Claude Code: a tool left out is not offered at all (checked by
|
|
8593
|
+
* hand). So the list is derived from `PROFILE_TOOLS` and `toolsForStage`
|
|
8594
|
+
* rather than chosen here — a second place deciding what a profile may do is
|
|
8595
|
+
* how the two agents would drift. Above all, `toolsForStage` takes `report`
|
|
8596
|
+
* away from every STAGE, whose "done" would end the run in the middle of the
|
|
8597
|
+
* walk.
|
|
8598
|
+
*
|
|
8599
|
+
* <p>Null — every tool the server has — only for a coding run with no
|
|
8600
|
+
* lifecycle, which can only be spawned where the machine allowed everything.
|
|
8601
|
+
*/
|
|
8602
|
+
function codexCawdevTools(run, stage, mayWrite) {
|
|
8603
|
+
const profile = PROFILE_TOOLS[run.profile];
|
|
8604
|
+
const profileList = profile ? (typeof profile === 'function' ? profile(run) : profile) : null;
|
|
8605
|
+
if (stage) {
|
|
8606
|
+
return cawdevToolNames(toolsForStage(stage.stage, profileList ?? CAWDEV_TOOLS, stage.testMode,
|
|
8607
|
+
{ writesItsOwnCard: writesItsOwnCard(run) }));
|
|
8608
|
+
}
|
|
8609
|
+
if (profileList) {
|
|
8610
|
+
return cawdevToolNames(profileList);
|
|
8611
|
+
}
|
|
8612
|
+
return mayWrite ? null : cawdevToolNames(READ_ONLY_CAWDEV);
|
|
8613
|
+
}
|
|
8614
|
+
|
|
8615
|
+
/**
|
|
8616
|
+
* OpenAI's Codex CLI — one `codex exec` per turn, like Antigravity, and the
|
|
8617
|
+
* command line, the stream and every flag's behaviour are `../lib/codex.mjs`,
|
|
8618
|
+
* checked against the real CLI.
|
|
8619
|
+
*
|
|
8620
|
+
* <p>What it has that Antigravity lacks is a SANDBOX the operating system
|
|
8621
|
+
* enforces, and that decides the permission question:
|
|
8622
|
+
*
|
|
8623
|
+
* <ul>
|
|
8624
|
+
* <li>A session that must change nothing — every profile but the coding
|
|
8625
|
+
* ones, and PLAN, VERIFY and MEMORY of any run — is spawned with
|
|
8626
|
+
* `--sandbox read-only`. Its shell can read and cannot write, and it does
|
|
8627
|
+
* not need the machine to have allowed anything: "cannot" is the kernel's
|
|
8628
|
+
* word here, not the prompt's. Only cawdev's server is attached, narrowed
|
|
8629
|
+
* to the profile's list by `codexCawdevTools`.
|
|
8630
|
+
* <li>A session that writes — CODE, INTERVIEW, MERGE, RELEASE, and their
|
|
8631
|
+
* IMPLEMENT and TEST — has to commit and push, which the workspace sandbox
|
|
8632
|
+
* refuses (`.git` and the network are outside it). There is no allow-list
|
|
8633
|
+
* for the shell on this CLI, so it runs with the sandbox bypassed, and
|
|
8634
|
+
* ONLY where the machine allowed everything (R126) — Antigravity's rule.
|
|
8635
|
+
* </ul>
|
|
8636
|
+
*
|
|
8637
|
+
* <p>Not reached: experts (nothing on this CLI loads cawdev's `agents/*.md`,
|
|
8638
|
+
* so the session is told it cannot delegate rather than shown names it cannot
|
|
8639
|
+
* call), R51's permission prompt and R110's command shield (both live in
|
|
8640
|
+
* `approve`, which Codex never calls) and R22's live prompting (the process
|
|
8641
|
+
* ends with its turn; a follow-up is a resume). Skills are inlined whole, as
|
|
8642
|
+
* for Antigravity. The transcript half of R110 — secrets taken out of a tool
|
|
8643
|
+
* result — does apply, since it runs here and not in the CLI.
|
|
8644
|
+
*/
|
|
8645
|
+
async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
8646
|
+
skills, stageExperts, readOnlyStage, instincts, briefing, plan, lifecycle, stage, carried,
|
|
8647
|
+
codexCmd, granted, shield, usage = null) {
|
|
8648
|
+
const failedBeforeSpawn = async (summary) => {
|
|
8649
|
+
log(` ${summary.split('\n')[0]}`);
|
|
8650
|
+
await sayOnTheRun(config, run, summary);
|
|
8651
|
+
await finish(config, run, 'FAILED', summary);
|
|
8652
|
+
return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
|
|
8653
|
+
};
|
|
8654
|
+
|
|
8655
|
+
const mayWrite = writesCodeProfile(run) && !readOnlyStage;
|
|
8656
|
+
if (mayWrite && !granted.everything) {
|
|
8657
|
+
// It must commit and push, which its sandbox refuses, and there is no
|
|
8658
|
+
// allow-list for its shell — so everything, or nothing. Read-only runs
|
|
8659
|
+
// and stages need nothing.
|
|
8660
|
+
return failedBeforeSpawn(needsEverything('codex', thisMachine(config), run));
|
|
8661
|
+
}
|
|
8662
|
+
if (mayWrite) {
|
|
8663
|
+
log(' !! this machine allows EVERYTHING: this Codex session runs without a sandbox');
|
|
8664
|
+
}
|
|
8665
|
+
|
|
8666
|
+
const transcript = new Transcript(config, run);
|
|
8667
|
+
const serverPath = config.mcpServerPath ?? new URL('../mcp/server.mjs', import.meta.url).pathname;
|
|
8668
|
+
|
|
8669
|
+
// The token reaches cawdev's server through `env_vars` — from Codex's own
|
|
8670
|
+
// environment, never its command line — and is blanked for the shell.
|
|
8671
|
+
const cawdevEnv = { CAWDEV_URL: config.url, CAWDEV_TOKEN: runToken, CAWDEV_PROJECT: run.projectSlug };
|
|
8672
|
+
const childEnv = { ...process.env, ...cawdevEnv };
|
|
8673
|
+
|
|
8674
|
+
// By name, so a later server replaces an earlier one whole — the ordering
|
|
8675
|
+
// argument of the Claude Code path: the repository's own first, then R76's
|
|
8676
|
+
// registry, then cawdev's LAST, so nothing can shadow it and take its token.
|
|
8677
|
+
const servers = new Map();
|
|
8678
|
+
if (mayWrite) {
|
|
8679
|
+
for (const [name, server] of Object.entries(await projectMcpServers(cwd))) {
|
|
8680
|
+
if (!server?.command) {
|
|
8681
|
+
transcript.push({ kind: 'SYSTEM',
|
|
8682
|
+
body: `The project's MCP server "${name}" is not a command, and Codex is handed commands only — skipped.` });
|
|
8683
|
+
continue;
|
|
8684
|
+
}
|
|
8685
|
+
servers.set(name, { name, command: server.command, args: server.args, env: server.env });
|
|
8686
|
+
}
|
|
8687
|
+
const { attached, notes } = await resolveSkills(config, run, cwd, baseCommit, projectServers);
|
|
8688
|
+
for (const line of notes) {
|
|
8689
|
+
log(` ${line}`);
|
|
8690
|
+
transcript.push({ kind: 'SYSTEM', body: line });
|
|
8691
|
+
}
|
|
8692
|
+
for (const { skill, local } of attached) {
|
|
8693
|
+
Object.assign(childEnv, local.env ?? {});
|
|
8694
|
+
servers.set(skill.serverName, {
|
|
8695
|
+
name: skill.serverName, command: skill.command, args: skill.args,
|
|
8696
|
+
envVars: Object.keys(local.env ?? {}),
|
|
8697
|
+
});
|
|
8698
|
+
}
|
|
8699
|
+
}
|
|
8700
|
+
servers.set('cawdev', {
|
|
8701
|
+
name: 'cawdev',
|
|
8702
|
+
command: process.execPath,
|
|
8703
|
+
args: [serverPath],
|
|
8704
|
+
envVars: Object.keys(cawdevEnv),
|
|
8705
|
+
enabledTools: codexCawdevTools(run, stage, mayWrite),
|
|
8706
|
+
// Codex cuts an MCP call off after 60 s unless told otherwise, and
|
|
8707
|
+
// `ask_user` waits up to ten minutes for a person: a question asked from
|
|
8708
|
+
// Codex died before anybody could answer it, so it did not ask — it
|
|
8709
|
+
// stopped. Longer than ask_user's own wait (mcp/server.mjs), so the
|
|
8710
|
+
// question is answered or handed back as pending, never cut off.
|
|
8711
|
+
toolTimeoutSec: CAWDEV_TOOL_TIMEOUT_SEC,
|
|
8712
|
+
});
|
|
8713
|
+
|
|
8714
|
+
const cannotDelegate = stageExperts.length
|
|
8715
|
+
? ["This project has experts, and Codex has no way to be handed them: nothing on this CLI "
|
|
8716
|
+
+ "loads cawdev's expert agents. Do the work yourself; they are reached from Claude Code "
|
|
8717
|
+
+ 'and Antigravity runs.']
|
|
8718
|
+
: undefined;
|
|
8719
|
+
if (cannotDelegate) {
|
|
8720
|
+
transcript.push({ kind: 'SYSTEM', body: cannotDelegate[0] });
|
|
8721
|
+
}
|
|
8722
|
+
|
|
8723
|
+
const resuming = Boolean(resume?.agentSessionId);
|
|
8724
|
+
const promptText = resuming
|
|
8725
|
+
? (resume.prompt ?? '')
|
|
8726
|
+
: (promptFor(run)
|
|
8727
|
+
+ harnessPrompt({
|
|
8728
|
+
instincts, briefing, experts: [], skills: [], plan, lifecycle,
|
|
8729
|
+
repoConfig: await readRepoConfig(cwd),
|
|
8730
|
+
})
|
|
8731
|
+
+ inlinedSkillsFor(skills)
|
|
8732
|
+
+ expertsBlockFor([], cannotDelegate)
|
|
8733
|
+
+ (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : ''));
|
|
8734
|
+
|
|
8735
|
+
const model = stage?.model ?? run.model;
|
|
8736
|
+
const args = codexArgs({
|
|
8737
|
+
model,
|
|
8738
|
+
effort: run.effort,
|
|
8739
|
+
sandbox: mayWrite ? 'bypass' : 'read-only',
|
|
8740
|
+
resumeId: resuming ? resume.agentSessionId : null,
|
|
8741
|
+
servers: [...servers.values()],
|
|
8742
|
+
hideFromShell: Object.keys(cawdevEnv),
|
|
8743
|
+
});
|
|
8744
|
+
log(` spawning: ${codexCmd} ${args.join(' ')} <prompt on stdin, ${promptText.length} chars>`);
|
|
8745
|
+
|
|
8746
|
+
return new Promise((resolvePromise) => {
|
|
8747
|
+
// R76 / R305: what the run had already used before this process — see the
|
|
8748
|
+
// same pair on the Claude Code path.
|
|
8749
|
+
const usageBase = usage?.run ?? {
|
|
8750
|
+
tokensIn: Number(run.tokensIn) || 0,
|
|
8751
|
+
tokensOut: Number(run.tokensOut) || 0,
|
|
8752
|
+
};
|
|
8753
|
+
let childTotals = null;
|
|
8754
|
+
let ended = false;
|
|
8755
|
+
let failed = null;
|
|
8756
|
+
let lastError = null;
|
|
8757
|
+
let lastSaid = null;
|
|
8758
|
+
let err = '';
|
|
8759
|
+
let buffer = '';
|
|
8760
|
+
const child = spawn(codexCmd, args, {
|
|
8761
|
+
cwd,
|
|
8762
|
+
// Its own process group — see the same choice on the Claude Code path.
|
|
8763
|
+
detached: true,
|
|
8764
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
8765
|
+
env: childEnv,
|
|
8766
|
+
});
|
|
8767
|
+
child.cawdevProjectSlug = run.projectSlug;
|
|
8768
|
+
child.cawdevAgent = 'codex';
|
|
8769
|
+
child.cawdevModel = model ?? null;
|
|
8770
|
+
// `reapCancelled` SIGTERMs whatever is here, whichever CLI it is.
|
|
8771
|
+
running.set(run.id, child);
|
|
8772
|
+
|
|
8773
|
+
// What the console can see of the checkout, and the commits as they land —
|
|
8774
|
+
// the same watcher as the Claude Code path. Without it the platform never
|
|
8775
|
+
// hears of this run's commits, so the finish rules (R40) see a run that
|
|
8776
|
+
// wrote nothing and queue no push and no pull request.
|
|
8777
|
+
const workingCopy = watchWorkingCopy(config, run, cwd, baseCommit);
|
|
8778
|
+
|
|
8779
|
+
child.stdin.on('error', () => {
|
|
8780
|
+
// A CLI that died before reading its prompt says why on stderr.
|
|
8781
|
+
});
|
|
8782
|
+
child.stdin.end(promptText);
|
|
8783
|
+
|
|
8784
|
+
child.stdout.on('data', (chunk) => {
|
|
8785
|
+
buffer += chunk;
|
|
8786
|
+
let newline;
|
|
8787
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
8788
|
+
const line = buffer.slice(0, newline);
|
|
8789
|
+
buffer = buffer.slice(newline + 1);
|
|
8790
|
+
if (!line.trim()) continue;
|
|
8791
|
+
let event;
|
|
8792
|
+
try {
|
|
8793
|
+
event = JSON.parse(line);
|
|
8794
|
+
} catch {
|
|
8795
|
+
transcript.push({ kind: 'SYSTEM', body: line.slice(0, 4000) });
|
|
8796
|
+
continue;
|
|
8797
|
+
}
|
|
8798
|
+
const read = translateCodexEvent(event);
|
|
8799
|
+
if (read.sessionId) {
|
|
8800
|
+
reportSessionId(config, run, read.sessionId);
|
|
8801
|
+
transcript.push({
|
|
8802
|
+
kind: 'SYSTEM',
|
|
8803
|
+
body: `session ${short(read.sessionId)} started${model ? ` on ${model}` : ''}`
|
|
8804
|
+
+ (mayWrite ? '' : ' (read-only sandbox)'),
|
|
8805
|
+
});
|
|
8806
|
+
}
|
|
8807
|
+
for (const recorded of read.lines) {
|
|
8808
|
+
// R110's transcript half, as on the Claude Code path: a credential
|
|
8809
|
+
// that came back from a command is replaced before it is stored.
|
|
8810
|
+
if (shield?.blockSecrets !== false && recorded.kind === 'TOOL_RESULT') {
|
|
8811
|
+
const found = findSecret(recorded.body);
|
|
8812
|
+
if (found) {
|
|
8813
|
+
recorded.body = `[cawdev removed ${found.name} from this result] ${found.redacted}`;
|
|
8814
|
+
api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/blocks`, {
|
|
8815
|
+
method: 'POST',
|
|
8816
|
+
body: { kind: 'SECRET', detail: `${found.name}: ${found.redacted}` },
|
|
8817
|
+
}).catch(() => {});
|
|
8818
|
+
}
|
|
8819
|
+
}
|
|
8820
|
+
if (recorded.kind === 'ASSISTANT') {
|
|
8821
|
+
lastSaid = recorded.body;
|
|
8822
|
+
}
|
|
8823
|
+
transcript.push(recorded);
|
|
8824
|
+
}
|
|
8825
|
+
if (read.error) {
|
|
8826
|
+
lastError = read.error;
|
|
8827
|
+
}
|
|
8828
|
+
if (read.failed) {
|
|
8829
|
+
failed = read.failed;
|
|
8830
|
+
}
|
|
8831
|
+
if (read.ended) {
|
|
8832
|
+
ended = true;
|
|
8833
|
+
childTotals = read.usage;
|
|
8834
|
+
reportUsage(config, run, {
|
|
8835
|
+
tokensIn: usageBase.tokensIn + childTotals.input,
|
|
8836
|
+
tokensOut: usageBase.tokensOut + childTotals.output,
|
|
8837
|
+
});
|
|
8838
|
+
if (stage && usage?.stage) {
|
|
8839
|
+
reportStageUsage(config, run, stage.stage, {
|
|
8840
|
+
tokensIn: usage.stage.tokensIn + childTotals.input,
|
|
8841
|
+
tokensOut: usage.stage.tokensOut + childTotals.output,
|
|
8842
|
+
});
|
|
8843
|
+
}
|
|
8844
|
+
}
|
|
8845
|
+
}
|
|
8846
|
+
});
|
|
8847
|
+
child.stderr.on('data', (chunk) => {
|
|
8848
|
+
const text = String(chunk).trim();
|
|
8849
|
+
if (text) {
|
|
8850
|
+
log(` agent stderr: ${text.slice(0, 400)}`);
|
|
8851
|
+
}
|
|
8852
|
+
err += chunk;
|
|
8853
|
+
});
|
|
8854
|
+
|
|
8855
|
+
child.on('error', async (failure) => {
|
|
8856
|
+
workingCopy.stop();
|
|
8857
|
+
running.delete(run.id);
|
|
8858
|
+
const summary = `Could not spawn Codex: ${failure.message}`;
|
|
8859
|
+
await transcript.flush();
|
|
8860
|
+
await finish(config, run, 'FAILED', summary);
|
|
8861
|
+
resolvePromise(stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined);
|
|
8862
|
+
});
|
|
8863
|
+
|
|
8864
|
+
child.on('close', async (code) => {
|
|
8865
|
+
workingCopy.stop();
|
|
8866
|
+
running.delete(run.id);
|
|
8867
|
+
await transcript.flush();
|
|
8868
|
+
const text = failed ?? lastSaid ?? lastError ?? (err.trim() || 'Codex produced no output.');
|
|
8869
|
+
// The agent normally ends the run itself with report(done|blocked); this
|
|
8870
|
+
// only catches a turn that produced nothing report could act on.
|
|
8871
|
+
const current = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}`)
|
|
8872
|
+
.catch(() => null);
|
|
8873
|
+
if (current?.live && !stage) {
|
|
8874
|
+
await finish(config, run, code === 0 && ended && !failed ? 'FINISHED' : 'FAILED', text);
|
|
8875
|
+
}
|
|
8876
|
+
// R112: not between stages — see the same line on the Claude Code path.
|
|
8877
|
+
if (!stage) {
|
|
8878
|
+
await settleActions(config, run, cwd);
|
|
8879
|
+
}
|
|
8880
|
+
// The last reading, as on the Claude Code path: after the agent has
|
|
8881
|
+
// stopped and after anything the rules did.
|
|
8882
|
+
await reportCommits(config, run, cwd, baseCommit);
|
|
8883
|
+
|
|
8884
|
+
resolvePromise(stage
|
|
8885
|
+
? { code, signal: null, text, turnEnded: ended && !failed, usage: childTotals }
|
|
8886
|
+
: undefined);
|
|
8887
|
+
});
|
|
8888
|
+
});
|
|
8889
|
+
}
|
|
8890
|
+
|
|
7805
8891
|
/**
|
|
7806
8892
|
* Tells the platform what the CLI calls this session — R69.
|
|
7807
8893
|
*
|
|
@@ -7925,6 +9011,14 @@ function capabilities(config) {
|
|
|
7925
9011
|
// offered only to a machine that says this, and a machine that does not
|
|
7926
9012
|
// is told why on the queue rather than handed a base it would ignore.
|
|
7927
9013
|
baseBranches: true,
|
|
9014
|
+
// R270. This daemon knows an offer can carry `waitForClean`, and honours it
|
|
9015
|
+
// in `pickWorkspace` — taking the preferred checkout only when clean, and
|
|
9016
|
+
// refusing to fall back to a dirty one at all. The same shape as
|
|
9017
|
+
// `baseBranches`: an older daemon that ignored the flag would take the
|
|
9018
|
+
// dirty checkout and fail a run the person explicitly queued to avoid
|
|
9019
|
+
// that, so the platform tells it apart rather than letting one behave
|
|
9020
|
+
// like the other.
|
|
9021
|
+
waitForClean: true,
|
|
7928
9022
|
});
|
|
7929
9023
|
}
|
|
7930
9024
|
|
|
@@ -7944,6 +9038,11 @@ function capabilities(config) {
|
|
|
7944
9038
|
async function probePermissionPrompt(config) {
|
|
7945
9039
|
let allGood = true;
|
|
7946
9040
|
for (const agentCommand of config.agentCommands) {
|
|
9041
|
+
// A Claude Code flag. Codex never calls a permission prompt — see
|
|
9042
|
+
// `spawnCodex` — and offering it this one would start its terminal UI.
|
|
9043
|
+
if (agentOfCommand(agentCommand) === 'codex') {
|
|
9044
|
+
continue;
|
|
9045
|
+
}
|
|
7947
9046
|
const said = await new Promise((done) => {
|
|
7948
9047
|
let text = '';
|
|
7949
9048
|
let child;
|
|
@@ -8003,6 +9102,9 @@ async function main() {
|
|
|
8003
9102
|
quiet = attaching;
|
|
8004
9103
|
|
|
8005
9104
|
const config = await readConfig();
|
|
9105
|
+
// The checkouts made on demand by an earlier run of this daemon, found
|
|
9106
|
+
// again by their name — nothing about them is in the config.
|
|
9107
|
+
await syncAutoWorkspaces(config.projects);
|
|
8006
9108
|
const myVersion = await cliVersion();
|
|
8007
9109
|
|
|
8008
9110
|
// R283. Checked before registering — a daemon too old to be safe should not
|
|
@@ -8168,6 +9270,7 @@ async function main() {
|
|
|
8168
9270
|
const resolved = {
|
|
8169
9271
|
acceptsRulesFromConsole: file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole,
|
|
8170
9272
|
grantable: file.grantable ?? DEFAULTS.grantable,
|
|
9273
|
+
autoWorkspaces: autoWorkspacesOf(file.autoWorkspaces),
|
|
8171
9274
|
agentCommands: file.agentCommands
|
|
8172
9275
|
?? (file.agentCommand ? [file.agentCommand] : null)
|
|
8173
9276
|
?? DEFAULTS.agentCommands,
|
|
@@ -8176,7 +9279,11 @@ async function main() {
|
|
|
8176
9279
|
agentCommands: config.agentCommands,
|
|
8177
9280
|
grantable: config.grantable,
|
|
8178
9281
|
acceptsRulesFromConsole: config.acceptsRulesFromConsole,
|
|
9282
|
+
autoWorkspaces: config.autoWorkspaces,
|
|
8179
9283
|
}, resolved);
|
|
9284
|
+
// A checkout made on demand may have been dropped — the attached
|
|
9285
|
+
// terminal's `/drop` deletes the directory and sends this signal.
|
|
9286
|
+
await syncAutoWorkspaces(config.projects, heldWorkspaces());
|
|
8180
9287
|
Object.assign(config, resolved);
|
|
8181
9288
|
// R288: the hello describes the current state, not the booted one — an
|
|
8182
9289
|
// attach opened after a toggle would otherwise offer to turn on what is
|
|
@@ -8413,8 +9520,13 @@ async function main() {
|
|
|
8413
9520
|
// to lose three hours of work by leaning on the keyboard.
|
|
8414
9521
|
if (attaching) {
|
|
8415
9522
|
const { attach } = await import('./attach.mjs');
|
|
8416
|
-
|
|
9523
|
+
const argv = process.argv.slice(2);
|
|
9524
|
+
const typed = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : null;
|
|
9525
|
+
void attach(argv, {
|
|
8417
9526
|
socketPath: control?.path,
|
|
9527
|
+
// Where the person signs in: `--url`, else what this daemon is called —
|
|
9528
|
+
// `cawdev.mjs`'s `signInUrl` order. `urlFrom` alone never sees the config.
|
|
9529
|
+
url: (typed ?? config.url).replace(/\/+$/, ''),
|
|
8418
9530
|
// Its own daemon, so quitting means stopping: raised as a signal rather
|
|
8419
9531
|
// than an exit, so the run goes through the same goodbye and the same
|
|
8420
9532
|
// termination of children as a Ctrl-C would.
|
|
@@ -8447,6 +9559,22 @@ async function main() {
|
|
|
8447
9559
|
`/api/runners/${config.runnerId}/queue?wait=${config.pollSeconds}`,
|
|
8448
9560
|
);
|
|
8449
9561
|
|
|
9562
|
+
// Checked again HERE, not only at the top. The poll blocks for up to
|
|
9563
|
+
// pollSeconds, and a Ctrl-C during it flips `stopping` and begins the
|
|
9564
|
+
// goodbye — which is bounded but not instant. A poll that returns inside
|
|
9565
|
+
// that window would otherwise walk on to claim a run and spawn a
|
|
9566
|
+
// detached agent that outlives the exiting daemon, orphaned with a live
|
|
9567
|
+
// claim. The gap after this point is startRun's own: it reads `stopping`
|
|
9568
|
+
// nowhere, and a Ctrl-C between its claim and its spawn is still open.
|
|
9569
|
+
//
|
|
9570
|
+
// Also the loop's exit an analyser can see. The header's condition IS one
|
|
9571
|
+
// at runtime — `stopping` is a closed-over `let` read every iteration —
|
|
9572
|
+
// but its only writer is the signal handler, and i247 (S2189) is what a
|
|
9573
|
+
// loop with no visible way out looks like from outside.
|
|
9574
|
+
if (stopping) {
|
|
9575
|
+
break;
|
|
9576
|
+
}
|
|
9577
|
+
|
|
8450
9578
|
// ONE gate — R109. There were two; `maxSessions` bounded this machine
|
|
8451
9579
|
// and is gone, for the reason written where it used to be applied.
|
|
8452
9580
|
//
|
|
@@ -8469,6 +9597,10 @@ async function main() {
|
|
|
8469
9597
|
// was when the wait began — and a run that started during the wait was
|
|
8470
9598
|
// invisible. Two agents went into one checkout that way.
|
|
8471
9599
|
const held = heldWorkspaces();
|
|
9600
|
+
// One `git status` per checkout per pass, not per offer — i258.
|
|
9601
|
+
// Several offers of one project ask `looksClean` on a shared checkout
|
|
9602
|
+
// only once.
|
|
9603
|
+
const probed = new Map();
|
|
8472
9604
|
|
|
8473
9605
|
let claimable = 0;
|
|
8474
9606
|
let skipped = 0;
|
|
@@ -8527,47 +9659,24 @@ async function main() {
|
|
|
8527
9659
|
// the local ref already is. Taken when free, passed over when not: a
|
|
8528
9660
|
// busy preferred checkout is not worth waiting for, because origin has
|
|
8529
9661
|
// everything and prepareWorkingCopy brings a stale ref up to it.
|
|
9662
|
+
//
|
|
9663
|
+
// Below pinned and preferred, which checkout a free-for-the-taking
|
|
9664
|
+
// run gets now also weighs whether its tree is clean — R71/i258. The
|
|
9665
|
+
// `await` here can only make `held` more conservative while it waits:
|
|
9666
|
+
// claims are added by this loop alone, sequentially, and a run
|
|
9667
|
+
// finishing only frees a path. The one optimistic case — a heartbeat
|
|
9668
|
+
// adding to `told.heldWorkspaces` mid-loop — is the same window that
|
|
9669
|
+
// already exists between this poll and `startRun`'s own claim.
|
|
8530
9670
|
let workspace = null;
|
|
8531
9671
|
if (writes) {
|
|
8532
|
-
const
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
|
|
8538
|
-
noteQueued(
|
|
8539
|
-
offered.run,
|
|
8540
|
-
`its branch is in ${pinned}, which this machine does not serve`,
|
|
8541
|
-
);
|
|
8542
|
-
skipped += 1;
|
|
8543
|
-
continue;
|
|
8544
|
-
}
|
|
8545
|
-
if (held.has(pinned)) {
|
|
8546
|
-
noteQueued(
|
|
8547
|
-
offered.run,
|
|
8548
|
-
`waiting for ${pinned}, which is busy — the branch has work only that checkout holds`,
|
|
8549
|
-
);
|
|
8550
|
-
skipped += 1;
|
|
8551
|
-
continue;
|
|
8552
|
-
}
|
|
8553
|
-
workspace = pinned;
|
|
8554
|
-
} else {
|
|
8555
|
-
const preferred = offered.preferredWorkspace ?? null;
|
|
8556
|
-
if (preferred && config.projects[slug].workspaces.includes(preferred) && !held.has(preferred)) {
|
|
8557
|
-
workspace = preferred;
|
|
8558
|
-
} else {
|
|
8559
|
-
workspace = config.projects[slug].workspaces.find((path) => !held.has(path)) ?? null;
|
|
8560
|
-
}
|
|
8561
|
-
if (!workspace) {
|
|
8562
|
-
const total = config.projects[slug].workspaces.length;
|
|
8563
|
-
noteQueued(
|
|
8564
|
-
offered.run,
|
|
8565
|
-
`no free workspace in ${slug} (${total} here, all busy)`,
|
|
8566
|
-
);
|
|
8567
|
-
skipped += 1;
|
|
8568
|
-
continue;
|
|
8569
|
-
}
|
|
9672
|
+
const picked = await pickWorkspace(slug, config.projects[slug], offered, held, probed,
|
|
9673
|
+
config.autoWorkspaces);
|
|
9674
|
+
if (picked.why) {
|
|
9675
|
+
noteQueued(offered.run, picked.why);
|
|
9676
|
+
skipped += 1;
|
|
9677
|
+
continue;
|
|
8570
9678
|
}
|
|
9679
|
+
workspace = picked.workspace;
|
|
8571
9680
|
}
|
|
8572
9681
|
if (workspace) {
|
|
8573
9682
|
held.add(workspace);
|