cawdev-cli 1.0.9-beta → 1.0.11-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/background-shells.mjs +45 -3
- package/lib/roadmap-format.mjs +15 -1
- package/lib/runner-config.mjs +1 -1
- package/mcp/README.md +12 -2
- package/mcp/server.mjs +124 -10
- package/package.json +1 -1
- package/runner/README.md +38 -2
- package/runner/attach.mjs +211 -25
- package/runner/git-host.mjs +263 -0
- package/runner/runner.mjs +521 -126
- package/runner/split.mjs +77 -5
- package/runner/workspace-check.mjs +78 -0
package/runner/runner.mjs
CHANGED
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
import { homedir, tmpdir } from 'node:os';
|
|
20
20
|
|
|
21
21
|
import { join, resolve } from 'node:path';
|
|
22
|
+
import { spawnFailureSaid, workspaceProblem, workspaceProblemSaid } from './workspace-check.mjs';
|
|
22
23
|
import { changedLines } from './changed-lines.mjs';
|
|
24
|
+
import { adapterFor, hostFor, repoOf } from './git-host.mjs';
|
|
23
25
|
import { describeTurn, totalsOf } from '../lib/usage.mjs';
|
|
24
26
|
import { withinCeiling } from '../lib/tool-rules.mjs';
|
|
25
27
|
import { serveControl } from './control.mjs';
|
|
@@ -219,6 +221,10 @@ const DEFAULTS = {
|
|
|
219
221
|
// and is not sure it deserves one. A writer, like issue_file: a read-only
|
|
220
222
|
// stage is refused it by shape and not by this list.
|
|
221
223
|
'mcp__cawdev__backlog_file',
|
|
224
|
+
// R368. Ticks one item of the card's Done-when checklist as built. A
|
|
225
|
+
// writer, like the three above: it is not in CAWDEV_READS, so a read-only
|
|
226
|
+
// stage is refused it by shape and not by this list.
|
|
227
|
+
'mcp__cawdev__done_when_tick',
|
|
222
228
|
// R96's rounds. An interview is the one profile that cannot work without
|
|
223
229
|
// these, and they were missing for the same reason as the two above.
|
|
224
230
|
'mcp__cawdev__ask_group',
|
|
@@ -1632,6 +1638,59 @@ async function stashAfterCancel(config, run, cwd) {
|
|
|
1632
1638
|
* request will target the wrong place with nothing saying so. With no
|
|
1633
1639
|
* `baseBranch` every path here is byte for byte what it was before R260.
|
|
1634
1640
|
*/
|
|
1641
|
+
/** Read-only copies in use: path → how many sessions are reading in it. */
|
|
1642
|
+
const reading = new Map();
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Where a session with no branch reads the code — a plan, a verify, an ask.
|
|
1646
|
+
*
|
|
1647
|
+
* It takes no checkout (R70), so it used to run in the project's FIRST
|
|
1648
|
+
* checkout as that checkout happened to be: on whatever branch the last run
|
|
1649
|
+
* left there, however old — a cancelled CTO Interview left `cto-interview`
|
|
1650
|
+
* checked out two hundred commits behind, and every plan after it read that
|
|
1651
|
+
* code. Moving that checkout is not the answer: a coding run may be working
|
|
1652
|
+
* in it this minute. So a session with no branch reads a copy of its own:
|
|
1653
|
+
* `<first checkout>-read`, a DETACHED worktree of it — sharing its objects,
|
|
1654
|
+
* touching neither its branch nor its tree — brought to origin's default
|
|
1655
|
+
* branch whenever nobody is reading in it. One per project rather than one
|
|
1656
|
+
* per run, because the CLI keys a resumable conversation on its directory
|
|
1657
|
+
* (R69) and an ASK carried on in a different folder would start over.
|
|
1658
|
+
* Detached, because `resetToOrigin` checks the default branch out in the
|
|
1659
|
+
* checkout and git refuses a branch checked out twice. Anything failing here
|
|
1660
|
+
* leaves the session where it always ran, and says why.
|
|
1661
|
+
*/
|
|
1662
|
+
async function readingCopy(source, defaultBranch) {
|
|
1663
|
+
const main = defaultBranch || 'main';
|
|
1664
|
+
const tip = `origin/${main}`;
|
|
1665
|
+
const read = `${source.replace(/\/+$/, '')}-read`;
|
|
1666
|
+
const inUse = reading.get(read) ?? 0;
|
|
1667
|
+
try {
|
|
1668
|
+
const there = await access(join(read, '.git')).then(() => true, () => false);
|
|
1669
|
+
await git(source, ['fetch', '--quiet', 'origin', main]).catch(() => undefined);
|
|
1670
|
+
if (!there) {
|
|
1671
|
+
await git(source, ['worktree', 'prune']).catch(() => undefined);
|
|
1672
|
+
await git(source, ['worktree', 'add', '--quiet', '--detach', read, tip]);
|
|
1673
|
+
} else if (!inUse) {
|
|
1674
|
+
await git(read, ['checkout', '--quiet', '--force', '--detach', tip]);
|
|
1675
|
+
}
|
|
1676
|
+
reading.set(read, inUse + 1);
|
|
1677
|
+
const at = (await git(read, ['rev-parse', '--short', 'HEAD'])).trim();
|
|
1678
|
+
return {
|
|
1679
|
+
path: read,
|
|
1680
|
+
said: inUse
|
|
1681
|
+
? `reading ${read} at ${at} — another session is reading there, so it was not moved`
|
|
1682
|
+
: `reading ${tip} at ${at}, in ${read}`,
|
|
1683
|
+
release: () => reading.set(read, Math.max(0, (reading.get(read) ?? 1) - 1)),
|
|
1684
|
+
};
|
|
1685
|
+
} catch (failure) {
|
|
1686
|
+
return {
|
|
1687
|
+
path: source,
|
|
1688
|
+
said: `reading ${source} as it is — no reading copy: ${failure.message.split('\n')[0]}`,
|
|
1689
|
+
release: () => undefined,
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1635
1694
|
async function prepareWorkingCopy(path, branch, defaultBranch, allowDirty, baseBranch = null) {
|
|
1636
1695
|
await access(join(path, '.git')).catch(() => {
|
|
1637
1696
|
throw new Error(`${path} is not a git repository.`);
|
|
@@ -1930,6 +1989,16 @@ async function surveyWorkingCopies(config) {
|
|
|
1930
1989
|
for (const [slug, project] of Object.entries(config.projects)) {
|
|
1931
1990
|
for (const path of project.workspaces) {
|
|
1932
1991
|
const held = heldBy(path);
|
|
1992
|
+
const problem = await workspaceProblem(path).catch(() => null);
|
|
1993
|
+
if (problem) {
|
|
1994
|
+
survey.push({
|
|
1995
|
+
project: slug,
|
|
1996
|
+
path,
|
|
1997
|
+
runId: held ?? null,
|
|
1998
|
+
unreadable: workspaceProblemSaid(slug, problem, { first: project.workspaces[0] }),
|
|
1999
|
+
});
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
1933
2002
|
try {
|
|
1934
2003
|
await access(join(path, '.git'));
|
|
1935
2004
|
const porcelain = await git(path, ['status', '--porcelain']);
|
|
@@ -1981,6 +2050,52 @@ async function looksClean(path) {
|
|
|
1981
2050
|
return porcelain === null ? null : porcelain === '';
|
|
1982
2051
|
}
|
|
1983
2052
|
|
|
2053
|
+
/**
|
|
2054
|
+
* `workspaceProblem` for `path`, asked once per `problems` Map. The Map lives
|
|
2055
|
+
* for one poll pass, so a folder cloned meanwhile is picked up without a restart.
|
|
2056
|
+
*/
|
|
2057
|
+
async function problemOf(problems, path) {
|
|
2058
|
+
if (!problems.has(path)) problems.set(path, await workspaceProblem(path));
|
|
2059
|
+
return problems.get(path);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* The checkout a question about the project is read from — R377. The first
|
|
2064
|
+
* workspace nobody holds and that can be used; else null, and the caller
|
|
2065
|
+
* falls back to `project.path` as it always did. A missing `workspaces[0]` must not make the project read as
|
|
2066
|
+
* broken while `[1]` is fine.
|
|
2067
|
+
*/
|
|
2068
|
+
async function usableWorkspace(project, held, problems, { reading = false } = {}) {
|
|
2069
|
+
const usable = [];
|
|
2070
|
+
for (const path of project.workspaces) {
|
|
2071
|
+
const trouble = await problemOf(problems, path);
|
|
2072
|
+
// A reader has always been able to run in a folder with no `.git` — it
|
|
2073
|
+
// reads the files as they are (`readingCopy`) — so only a folder that is
|
|
2074
|
+
// not there at all stops it.
|
|
2075
|
+
if (!trouble || (reading && trouble.kind === 'not-git')) usable.push(path);
|
|
2076
|
+
}
|
|
2077
|
+
return usable.find((path) => !held.has(path)) ?? null;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
/**
|
|
2081
|
+
* One log line per workspace that cannot be used — R377. Said at boot and on
|
|
2082
|
+
* SIGHUP so the operator hears it before a run is queued behind it.
|
|
2083
|
+
* Returns how many there were.
|
|
2084
|
+
*/
|
|
2085
|
+
async function sayWhichWorkspacesCannotBeUsed(config) {
|
|
2086
|
+
let found = 0;
|
|
2087
|
+
for (const [slug, project] of Object.entries(config.projects)) {
|
|
2088
|
+
for (const path of project.workspaces) {
|
|
2089
|
+
const problem = await workspaceProblem(path).catch(() => null);
|
|
2090
|
+
if (problem) {
|
|
2091
|
+
found += 1;
|
|
2092
|
+
log(` ${workspaceProblemSaid(slug, problem, { first: project.workspaces[0] })}`);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
return found;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
1984
2099
|
/**
|
|
1985
2100
|
* What every checkout on this machine holds — R87.
|
|
1986
2101
|
*
|
|
@@ -2002,6 +2117,15 @@ async function surveyWorkspaces(config) {
|
|
|
2002
2117
|
const readings = [];
|
|
2003
2118
|
for (const [slug, project] of Object.entries(config.projects)) {
|
|
2004
2119
|
for (const path of project.workspaces) {
|
|
2120
|
+
const problem = await workspaceProblem(path).catch(() => null);
|
|
2121
|
+
if (problem) {
|
|
2122
|
+
readings.push({
|
|
2123
|
+
path,
|
|
2124
|
+
projectSlug: slug,
|
|
2125
|
+
error: workspaceProblemSaid(slug, problem, { first: project.workspaces[0] }),
|
|
2126
|
+
});
|
|
2127
|
+
continue;
|
|
2128
|
+
}
|
|
2005
2129
|
try {
|
|
2006
2130
|
await access(join(path, '.git'));
|
|
2007
2131
|
const branch = (await git(path, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
@@ -2358,9 +2482,10 @@ async function surveyGit(config) {
|
|
|
2358
2482
|
// session's branch with its work uncommitted. Reading the pool's spare
|
|
2359
2483
|
// gives the honest picture of the default branch instead.
|
|
2360
2484
|
const held = heldWorkspaces();
|
|
2361
|
-
const free = project
|
|
2485
|
+
const free = await usableWorkspace(project, held, new Map());
|
|
2362
2486
|
const path = free ?? resolve(project.path);
|
|
2363
|
-
|
|
2487
|
+
// Busy means no free USABLE workspace.
|
|
2488
|
+
const busy = free === null;
|
|
2364
2489
|
|
|
2365
2490
|
const reading = await surveyProjectGit(path, { fetch: !busy }).catch((failure) => ({
|
|
2366
2491
|
error: failure.message.split('\n')[0],
|
|
@@ -3341,7 +3466,7 @@ async function perform(config, run, cwd, action) {
|
|
|
3341
3466
|
({ ok, result } = await openPullRequest(cwd, run, action.message));
|
|
3342
3467
|
} else if (action.kind === 'MERGE') {
|
|
3343
3468
|
log(` MERGING ${run.branch} into ${run.baseBranch ?? 'the default branch'}${byRule(action)} — nobody is reading this diff`);
|
|
3344
|
-
({ ok, result } = await mergePullRequest(cwd, run.branch));
|
|
3469
|
+
({ ok, result } = await mergePullRequest(cwd, run.branch, { declared: run.gitHost ?? null }));
|
|
3345
3470
|
} else {
|
|
3346
3471
|
result = `This runner does not know how to ${action.kind}.`;
|
|
3347
3472
|
}
|
|
@@ -3628,6 +3753,7 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3628
3753
|
// squashes as it always did. The claim says which.
|
|
3629
3754
|
({ ok, result, failure: failureKind } = await mergePullRequest(cwd, branch, {
|
|
3630
3755
|
method: request.mergeMethod ?? null,
|
|
3756
|
+
declared: request.gitHost ?? null,
|
|
3631
3757
|
}));
|
|
3632
3758
|
}
|
|
3633
3759
|
} else if (request.kind === 'TAG') {
|
|
@@ -3669,6 +3795,7 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3669
3795
|
result = pushed.failed;
|
|
3670
3796
|
} else {
|
|
3671
3797
|
({ ok, result } = await openPullRequestFor(cwd, {
|
|
3798
|
+
declared: request.gitHost ?? null,
|
|
3672
3799
|
branch,
|
|
3673
3800
|
base,
|
|
3674
3801
|
title: request.title?.trim() || branch,
|
|
@@ -3697,6 +3824,19 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3697
3824
|
log(` cutting ${name} on origin from ${request.defaultBranch || 'main'}`);
|
|
3698
3825
|
({ ok, result } = await cutBranchOnOrigin(cwd, name, request.defaultBranch));
|
|
3699
3826
|
}
|
|
3827
|
+
} else if (request.kind === 'CREATE_BRANCH') {
|
|
3828
|
+
// R379. Put a named branch on origin, cut from another branch's tip. Unlike
|
|
3829
|
+
// CUT_BRANCH an existing name is a REFUSAL: the person asked for a new
|
|
3830
|
+
// branch, and one that is already there may be somebody else's work.
|
|
3831
|
+
// Touches no working tree: the push names two refs and HEAD is neither.
|
|
3832
|
+
const name = (request.message ?? '').trim();
|
|
3833
|
+
const from = (request.fromBranch ?? '').trim();
|
|
3834
|
+
if (!name || !from) {
|
|
3835
|
+
result = 'A new branch has to name itself and the branch to cut it from. Nothing was created.';
|
|
3836
|
+
} else {
|
|
3837
|
+
log(` creating ${name} on origin from ${from} — asked for from the console`);
|
|
3838
|
+
({ ok, result } = await createBranchOnOrigin(cwd, name, from));
|
|
3839
|
+
}
|
|
3700
3840
|
} else {
|
|
3701
3841
|
result = `This runner does not know how to ${request.kind}.`;
|
|
3702
3842
|
}
|
|
@@ -3863,27 +4003,30 @@ async function readPushState(cwd, branch) {
|
|
|
3863
4003
|
* already holds the credentials, so the runner answers and the platform never
|
|
3864
4004
|
* needs a token of its own.
|
|
3865
4005
|
*/
|
|
3866
|
-
async function findPullRequest(cwd, branch) {
|
|
3867
|
-
const
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
});
|
|
3872
|
-
let out = '';
|
|
3873
|
-
child.stdout.on('data', (chunk) => (out += chunk));
|
|
3874
|
-
child.on('error', () => resolvePromise(null));
|
|
3875
|
-
// `close`, not `exit` — see git() above.
|
|
3876
|
-
child.on('close', (code) => resolvePromise(code === 0 ? out.trim() : null));
|
|
3877
|
-
});
|
|
3878
|
-
if (viaGh) {
|
|
3879
|
-
return viaGh;
|
|
4006
|
+
async function findPullRequest(cwd, branch, { declared = null, base = null } = {}) {
|
|
4007
|
+
const { host, adapter, originUrl } = await hostOf(cwd, declared);
|
|
4008
|
+
const viaCli = await adapter.find(cwd, branch);
|
|
4009
|
+
if (viaCli) {
|
|
4010
|
+
return viaCli;
|
|
3880
4011
|
}
|
|
3881
4012
|
|
|
3882
|
-
// No
|
|
4013
|
+
// No CLI, or no PR yet. A compare URL is honest: it is where you would go to
|
|
3883
4014
|
// open one, and it is obviously not a claim that one exists.
|
|
3884
|
-
const
|
|
3885
|
-
const
|
|
3886
|
-
return
|
|
4015
|
+
const repo = repoOf(originUrl);
|
|
4016
|
+
const against = host === 'gitlab' && !base ? await defaultBranchOf(cwd) : base;
|
|
4017
|
+
return adapter.fallbackLink(repo, against, branch);
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
/**
|
|
4021
|
+
* Which host this checkout's origin is on, and the adapter that talks to it —
|
|
4022
|
+
* R379. `declared` is the project's own word (`GITHUB`/`GITLAB`), from the claim
|
|
4023
|
+
* or the request; with none, a GitLab-looking host is GitLab and everything else
|
|
4024
|
+
* is GitHub, as it has always been.
|
|
4025
|
+
*/
|
|
4026
|
+
async function hostOf(cwd, declared) {
|
|
4027
|
+
const originUrl = (await git(cwd, ['remote', 'get-url', 'origin']).catch(() => '')).trim();
|
|
4028
|
+
const host = hostFor({ declared, originUrl });
|
|
4029
|
+
return { host, adapter: adapterFor(host), originUrl };
|
|
3887
4030
|
}
|
|
3888
4031
|
|
|
3889
4032
|
// --- what a project's rules asked for (R40) ----------------------------------
|
|
@@ -3932,25 +4075,6 @@ async function firstRemote(cwd) {
|
|
|
3932
4075
|
return remotes.split('\n')[0]?.trim() || null;
|
|
3933
4076
|
}
|
|
3934
4077
|
|
|
3935
|
-
/** Runs `gh` and hands back everything it said, including on failure. */
|
|
3936
|
-
function gh(cwd, args) {
|
|
3937
|
-
return new Promise((resolvePromise) => {
|
|
3938
|
-
const child = spawn('gh', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
3939
|
-
let out = '';
|
|
3940
|
-
let err = '';
|
|
3941
|
-
child.stdout.on('data', (chunk) => (out += chunk));
|
|
3942
|
-
child.stderr.on('data', (chunk) => (err += chunk));
|
|
3943
|
-
child.on('error', (failure) => resolvePromise({
|
|
3944
|
-
code: -1,
|
|
3945
|
-
out: '',
|
|
3946
|
-
err: failure.code === 'ENOENT'
|
|
3947
|
-
? 'gh is not installed on this machine, so it cannot talk to the git host.'
|
|
3948
|
-
: failure.message,
|
|
3949
|
-
}));
|
|
3950
|
-
// `close`, not `exit` — see git() above.
|
|
3951
|
-
child.on('close', (code) => resolvePromise({ code, out: out.trim(), err: err.trim() }));
|
|
3952
|
-
});
|
|
3953
|
-
}
|
|
3954
4078
|
|
|
3955
4079
|
/**
|
|
3956
4080
|
* The branch, on the remote.
|
|
@@ -4030,6 +4154,7 @@ async function openPullRequest(cwd, run, title) {
|
|
|
4030
4154
|
}
|
|
4031
4155
|
|
|
4032
4156
|
return openPullRequestFor(cwd, {
|
|
4157
|
+
declared: run.gitHost ?? null,
|
|
4033
4158
|
branch: run.branch,
|
|
4034
4159
|
// R260. The sprint's branch, when the claim carried one; otherwise the
|
|
4035
4160
|
// argv is byte for byte what it was and the host picks its own default.
|
|
@@ -4100,44 +4225,40 @@ async function pushIfOriginLacks(cwd, branch) {
|
|
|
4100
4225
|
}
|
|
4101
4226
|
}
|
|
4102
4227
|
|
|
4103
|
-
async function openPullRequestFor(cwd, { branch, base, title, body }) {
|
|
4104
|
-
const
|
|
4228
|
+
async function openPullRequestFor(cwd, { branch, base, title, body, declared = null }) {
|
|
4229
|
+
const { adapter } = await hostOf(cwd, declared);
|
|
4230
|
+
const existing = await findPullRequest(cwd, branch, { declared, base }).catch(() => null);
|
|
4105
4231
|
if (existing && !existing.includes('/compare/')) {
|
|
4106
4232
|
return { ok: true, result: existing };
|
|
4107
4233
|
}
|
|
4108
4234
|
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
'--head', branch,
|
|
4112
|
-
...(base ? ['--base', base] : []),
|
|
4113
|
-
'--title', title,
|
|
4114
|
-
'--body', body,
|
|
4115
|
-
]);
|
|
4235
|
+
// R379: `gh pr create` or `glab mr create`; for GitHub it is the argv it always was.
|
|
4236
|
+
const opened = await adapter.create(cwd, { branch, base, title, body });
|
|
4116
4237
|
if (opened.code === 0 && opened.out) {
|
|
4117
|
-
//
|
|
4118
|
-
//
|
|
4119
|
-
//
|
|
4120
|
-
const printed =
|
|
4121
|
-
const confirmed = await findPullRequest(cwd, branch).catch(() => null);
|
|
4238
|
+
// The CLI prints the URL and the host is then asked whether it is really
|
|
4239
|
+
// there, because "asked for" is not "made", and a URL on the card that
|
|
4240
|
+
// 404s is worse than an honest failure.
|
|
4241
|
+
const printed = adapter.urlOf(opened);
|
|
4242
|
+
const confirmed = await findPullRequest(cwd, branch, { declared, base }).catch(() => null);
|
|
4122
4243
|
if (confirmed && !confirmed.includes('/compare/')) {
|
|
4123
4244
|
return { ok: true, result: confirmed };
|
|
4124
4245
|
}
|
|
4125
4246
|
return {
|
|
4126
4247
|
ok: false,
|
|
4127
|
-
result:
|
|
4248
|
+
result: `${adapter.bin} said it opened ${printed}, but the host does not show a ${adapter.noun} for ${branch}.`,
|
|
4128
4249
|
};
|
|
4129
4250
|
}
|
|
4130
4251
|
// The one failure somebody can fix in a minute, said with the fix — and the
|
|
4131
|
-
// link to open it by hand meanwhile, which `findPullRequest` builds without
|
|
4252
|
+
// link to open it by hand meanwhile, which `findPullRequest` builds without the CLI.
|
|
4132
4253
|
if (opened.code === -1 && /not installed/.test(opened.err)) {
|
|
4133
|
-
const compare = await findPullRequest(cwd, branch).catch(() => null);
|
|
4254
|
+
const compare = await findPullRequest(cwd, branch, { declared, base }).catch(() => null);
|
|
4134
4255
|
return {
|
|
4135
4256
|
ok: false,
|
|
4136
|
-
result: `${opened.err} Install it and sign in (
|
|
4257
|
+
result: `${opened.err} Install it and sign in (${adapter.installHint}), `
|
|
4137
4258
|
+ `then ask again${compare ? ` — or open it yourself: ${compare}` : '.'}`,
|
|
4138
4259
|
};
|
|
4139
4260
|
}
|
|
4140
|
-
return { ok: false, result: opened.err || opened.out || '
|
|
4261
|
+
return { ok: false, result: opened.err || opened.out || `${adapter.bin} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} create failed without saying why.` };
|
|
4141
4262
|
}
|
|
4142
4263
|
|
|
4143
4264
|
/**
|
|
@@ -4188,18 +4309,27 @@ function mergeabilityWaitMs() {
|
|
|
4188
4309
|
return Number.isFinite(configured) && configured > 0 ? configured : 5000;
|
|
4189
4310
|
}
|
|
4190
4311
|
|
|
4191
|
-
async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
4192
|
-
const
|
|
4312
|
+
async function mergePullRequest(cwd, branch, { method = null, declared = null } = {}) {
|
|
4313
|
+
const { adapter } = await hostOf(cwd, declared);
|
|
4314
|
+
const url = await findPullRequest(cwd, branch, { declared }).catch(() => null);
|
|
4193
4315
|
if (!url || url.includes('/compare/')) {
|
|
4316
|
+
// R379. On GitLab a missing `glab` looks exactly like "no merge request" —
|
|
4317
|
+
// the lookup that would have found it never ran. Said as what it is.
|
|
4318
|
+
if (adapter.name === 'gitlab') {
|
|
4319
|
+
const probe = await adapter.run(cwd, ['--version']);
|
|
4320
|
+
if (probe.code === -1) {
|
|
4321
|
+
return { ok: false, failure: 'UNREACHABLE', result: probe.err };
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
4194
4324
|
return {
|
|
4195
4325
|
ok: false,
|
|
4196
4326
|
failure: 'NO_PULL_REQUEST',
|
|
4197
4327
|
result: 'There is no pull request for this branch to merge. Nothing was merged.',
|
|
4198
4328
|
};
|
|
4199
4329
|
}
|
|
4200
|
-
await waitOutUnknownMergeability(cwd, url, branch);
|
|
4330
|
+
await waitOutUnknownMergeability(cwd, url, branch, adapter);
|
|
4201
4331
|
const mergeCommit = method === 'MERGE_COMMIT';
|
|
4202
|
-
const merged = await
|
|
4332
|
+
const merged = await adapter.merge(cwd, url, { mergeCommit });
|
|
4203
4333
|
if (merged.code === 0) {
|
|
4204
4334
|
// The URL on its own FIRST LINE — R134. The platform reads that line as the
|
|
4205
4335
|
// evidence a merge happened and puts it on the card as the card's `merge`
|
|
@@ -4216,10 +4346,10 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
4216
4346
|
ok: false,
|
|
4217
4347
|
// R155. WHICH kind of failure, so the console can offer Merge with an agent
|
|
4218
4348
|
// on the one an agent can fix and stay quiet on the three it cannot.
|
|
4219
|
-
failure: await classifyMergeFailure(cwd, url, merged),
|
|
4349
|
+
failure: await classifyMergeFailure(cwd, url, merged, adapter),
|
|
4220
4350
|
// Unchanged: `gh`'s own words still reach the card. The classification is a
|
|
4221
4351
|
// word beside them, never a replacement for them.
|
|
4222
|
-
result: merged.err || merged.out || '
|
|
4352
|
+
result: merged.err || merged.out || `${adapter.bin} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} merge failed without saying why.`,
|
|
4223
4353
|
};
|
|
4224
4354
|
}
|
|
4225
4355
|
|
|
@@ -4228,19 +4358,12 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
4228
4358
|
* bounded. See `mergePullRequest`. One log line if it waited at all, so a
|
|
4229
4359
|
* merge that took thirty seconds says why.
|
|
4230
4360
|
*/
|
|
4231
|
-
async function waitOutUnknownMergeability(cwd, url, branch) {
|
|
4361
|
+
async function waitOutUnknownMergeability(cwd, url, branch, adapter) {
|
|
4232
4362
|
let waited = 0;
|
|
4233
4363
|
for (let poll = 0; poll < MERGEABILITY_POLLS; poll++) {
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
try {
|
|
4238
|
-
view = JSON.parse(asked.out || '{}');
|
|
4239
|
-
} catch {
|
|
4240
|
-
// Not JSON — a gh that answered something else. Nothing to wait for.
|
|
4241
|
-
return;
|
|
4242
|
-
}
|
|
4243
|
-
if (view.mergeable !== 'UNKNOWN') {
|
|
4364
|
+
// `UNKNOWN` is the only answer worth waiting out; an error, something that
|
|
4365
|
+
// is not JSON or any other word proceeds at once (the adapter says which).
|
|
4366
|
+
if ((await adapter.mergeability(cwd, url)) !== 'UNKNOWN') {
|
|
4244
4367
|
if (waited) log(` ${branch}: mergeability known after ${waited}s`);
|
|
4245
4368
|
return;
|
|
4246
4369
|
}
|
|
@@ -4344,6 +4467,67 @@ async function cutBranchOnOrigin(cwd, name, defaultBranch) {
|
|
|
4344
4467
|
return { ok: true, result: `${tip}\n${name} cut from ${from} at ${tip.slice(0, 7)}.` };
|
|
4345
4468
|
}
|
|
4346
4469
|
|
|
4470
|
+
/**
|
|
4471
|
+
* Creates a branch on origin, cut from another — R379's `CREATE_BRANCH`.
|
|
4472
|
+
*
|
|
4473
|
+
* Order matters: the name is checked before anything is asked of the remote,
|
|
4474
|
+
* an existing name is refused whatever it points at, and the source is read
|
|
4475
|
+
* from `origin/<from>` after a fetch — what everyone else has, not this
|
|
4476
|
+
* checkout's idea of it. The sha is the FIRST LINE of an ok result, the way a
|
|
4477
|
+
* tag check reports it, because the platform does not take a machine's word.
|
|
4478
|
+
*/
|
|
4479
|
+
async function createBranchOnOrigin(cwd, name, from) {
|
|
4480
|
+
await git(cwd, ['fetch', '--prune', 'origin']).catch((failure) => {
|
|
4481
|
+
log(` fetch skipped: ${failure.message.split('\n')[0]}`);
|
|
4482
|
+
});
|
|
4483
|
+
try {
|
|
4484
|
+
await git(cwd, ['check-ref-format', '--branch', name]);
|
|
4485
|
+
// `@{-1}` passes check-ref-format --branch (it expands), but is no name.
|
|
4486
|
+
if (/[~^@]/.test(name)) throw new Error('not a name');
|
|
4487
|
+
} catch {
|
|
4488
|
+
return { ok: false, result: `\`${name}\` is not a branch name git accepts. Nothing was created.` };
|
|
4489
|
+
}
|
|
4490
|
+
|
|
4491
|
+
const ref = `refs/heads/${name}`;
|
|
4492
|
+
let there;
|
|
4493
|
+
try {
|
|
4494
|
+
there = await git(cwd, ['ls-remote', '--heads', 'origin', ref]);
|
|
4495
|
+
} catch (failure) {
|
|
4496
|
+
return { ok: false, result: `Could not ask the remote about ${name}: ${failure.message}` };
|
|
4497
|
+
}
|
|
4498
|
+
const already = there.split('\n').map((each) => each.trim()).filter(Boolean)
|
|
4499
|
+
.find((each) => each.endsWith(`\t${ref}`));
|
|
4500
|
+
if (already) {
|
|
4501
|
+
return {
|
|
4502
|
+
ok: false,
|
|
4503
|
+
result: `\`${name}\` is already on origin at ${already.split(/\s+/)[0].slice(0, 7)}. Nothing was created.`,
|
|
4504
|
+
};
|
|
4505
|
+
}
|
|
4506
|
+
|
|
4507
|
+
// `from` is a branch NAME: `main~3` or `main@{1}` would resolve and be pushed.
|
|
4508
|
+
try {
|
|
4509
|
+
await git(cwd, ['check-ref-format', '--branch', from]);
|
|
4510
|
+
} catch {
|
|
4511
|
+
return { ok: false, result: `\`${from}\` is not a branch name git accepts. Nothing was created.` };
|
|
4512
|
+
}
|
|
4513
|
+
if (/[~^@]/.test(from)) {
|
|
4514
|
+
return { ok: false, result: `\`${from}\` is not a branch name. Nothing was created.` };
|
|
4515
|
+
}
|
|
4516
|
+
const source = `origin/${from}`;
|
|
4517
|
+
let tip;
|
|
4518
|
+
try {
|
|
4519
|
+
tip = (await git(cwd, ['rev-parse', '--verify', `${source}^{commit}`])).trim();
|
|
4520
|
+
} catch {
|
|
4521
|
+
return { ok: false, result: `${source} is not on origin, so there is nothing to create \`${name}\` from.` };
|
|
4522
|
+
}
|
|
4523
|
+
try {
|
|
4524
|
+
await git(cwd, ['push', 'origin', `${source}:${ref}`]);
|
|
4525
|
+
} catch (failure) {
|
|
4526
|
+
return { ok: false, result: `Could not push ${name}: ${failure.message}` };
|
|
4527
|
+
}
|
|
4528
|
+
return { ok: true, result: `${tip}\n${name} created from ${from} at ${tip.slice(0, 7)}.` };
|
|
4529
|
+
}
|
|
4530
|
+
|
|
4347
4531
|
/**
|
|
4348
4532
|
* Why `gh pr merge` said no — R155, in one of five words.
|
|
4349
4533
|
*
|
|
@@ -4360,9 +4544,9 @@ async function cutBranchOnOrigin(cwd, name, defaultBranch) {
|
|
|
4360
4544
|
* as OTHER, and the console offers the action for OTHER for the same reason it
|
|
4361
4545
|
* offers it for a null — the cost of being wrong is one `git fetch`.
|
|
4362
4546
|
*/
|
|
4363
|
-
async function classifyMergeFailure(cwd, url, merged) {
|
|
4364
|
-
// No process at all, or the host was unreachable.
|
|
4365
|
-
// error as code -1, which covers "gh is not installed" as well.
|
|
4547
|
+
async function classifyMergeFailure(cwd, url, merged, adapter) {
|
|
4548
|
+
// No process at all, or the host was unreachable. The CLI runner reports a
|
|
4549
|
+
// spawn error as code -1, which covers "gh/glab is not installed" as well.
|
|
4366
4550
|
if (merged.code === -1 || /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|could not resolve host|network is unreachable/i
|
|
4367
4551
|
.test(`${merged.err} ${merged.out}`)) {
|
|
4368
4552
|
return 'UNREACHABLE';
|
|
@@ -4372,24 +4556,10 @@ async function classifyMergeFailure(cwd, url, merged) {
|
|
|
4372
4556
|
return 'NOT_PERMITTED';
|
|
4373
4557
|
}
|
|
4374
4558
|
|
|
4375
|
-
//
|
|
4376
|
-
//
|
|
4377
|
-
//
|
|
4378
|
-
|
|
4379
|
-
if (asked.code === 0) {
|
|
4380
|
-
try {
|
|
4381
|
-
const view = JSON.parse(asked.out || '{}');
|
|
4382
|
-
if (view.mergeable === 'CONFLICTING' || view.mergeStateStatus === 'DIRTY') {
|
|
4383
|
-
return 'CONFLICT';
|
|
4384
|
-
}
|
|
4385
|
-
if (view.mergeStateStatus === 'BLOCKED') {
|
|
4386
|
-
return 'NOT_PERMITTED';
|
|
4387
|
-
}
|
|
4388
|
-
} catch {
|
|
4389
|
-
// gh answered with something that is not JSON. Nothing to read.
|
|
4390
|
-
}
|
|
4391
|
-
}
|
|
4392
|
-
return 'OTHER';
|
|
4559
|
+
// Asked of the host's own CLI (R379: through the adapter). A failure to
|
|
4560
|
+
// answer is not an answer, so it falls through to OTHER rather than
|
|
4561
|
+
// inventing one.
|
|
4562
|
+
return (await adapter.refusal(cwd, url)) ?? 'OTHER';
|
|
4393
4563
|
}
|
|
4394
4564
|
|
|
4395
4565
|
/**
|
|
@@ -4492,6 +4662,63 @@ async function askGitHub(cwd, branch, prUrl) {
|
|
|
4492
4662
|
};
|
|
4493
4663
|
}
|
|
4494
4664
|
|
|
4665
|
+
/**
|
|
4666
|
+
* What `gh` says about a hand-taken branch's pull request — R346.
|
|
4667
|
+
*
|
|
4668
|
+
* `pr list --head`, not `pr view <branch>`: the latter stops finding the pull
|
|
4669
|
+
* request once the branch is deleted, and this matches on the pull request's own
|
|
4670
|
+
* head branch name, which does not change. Fork pull requests are dropped: a
|
|
4671
|
+
* fork's branch of the same name is not this branch.
|
|
4672
|
+
*
|
|
4673
|
+
* A merged pull request opened-after `openedAt` wins (latest merge first); a
|
|
4674
|
+
* reused branch name can bring back an older one, which the platform also
|
|
4675
|
+
* refuses. Otherwise the highest-numbered row speaks.
|
|
4676
|
+
*/
|
|
4677
|
+
async function askGitHubForBranch(cwd, branch, openedAt) {
|
|
4678
|
+
if (!branch) {
|
|
4679
|
+
return null;
|
|
4680
|
+
}
|
|
4681
|
+
const out = await new Promise((resolvePromise) => {
|
|
4682
|
+
const child = spawn(
|
|
4683
|
+
'gh',
|
|
4684
|
+
['pr', 'list', '--head', branch, '--state', 'all', '--limit', '10', '--json',
|
|
4685
|
+
'number,url,state,mergedAt,mergeCommit,baseRefName,headRefName,isCrossRepository'],
|
|
4686
|
+
{ cwd, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
4687
|
+
);
|
|
4688
|
+
let text = '';
|
|
4689
|
+
child.stdout.on('data', (chunk) => (text += chunk));
|
|
4690
|
+
child.on('error', () => resolvePromise(null));
|
|
4691
|
+
child.on('close', (code) => resolvePromise(code === 0 ? text.trim() : null));
|
|
4692
|
+
});
|
|
4693
|
+
const parsed = out ? safeJson(out) : null;
|
|
4694
|
+
if (!Array.isArray(parsed)) {
|
|
4695
|
+
return null;
|
|
4696
|
+
}
|
|
4697
|
+
const own = parsed.filter((row) => row && !row.isCrossRepository && row.headRefName === branch);
|
|
4698
|
+
if (!own.length) {
|
|
4699
|
+
return null;
|
|
4700
|
+
}
|
|
4701
|
+
const since = openedAt ? Date.parse(openedAt) : Number.NEGATIVE_INFINITY;
|
|
4702
|
+
const merged = own
|
|
4703
|
+
.filter((row) => row.state === 'MERGED' && Date.parse(row.mergedAt) >= since)
|
|
4704
|
+
.sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt));
|
|
4705
|
+
const chosen = merged[0] ?? [...own].sort((a, b) => b.number - a.number)[0];
|
|
4706
|
+
if (chosen.state === 'MERGED' && !merged.length) {
|
|
4707
|
+
return null; // Only a merge from before the item existed: not this item's.
|
|
4708
|
+
}
|
|
4709
|
+
const state = { OPEN: 'OPEN', MERGED: 'MERGED', CLOSED: 'CLOSED' }[chosen.state];
|
|
4710
|
+
if (!state) {
|
|
4711
|
+
return null;
|
|
4712
|
+
}
|
|
4713
|
+
return {
|
|
4714
|
+
state,
|
|
4715
|
+
prUrl: chosen.url ?? null,
|
|
4716
|
+
mergeCommit: chosen.mergeCommit?.oid ?? null,
|
|
4717
|
+
mergedAt: chosen.mergedAt ?? null,
|
|
4718
|
+
baseRefName: chosen.baseRefName ?? null,
|
|
4719
|
+
};
|
|
4720
|
+
}
|
|
4721
|
+
|
|
4495
4722
|
/**
|
|
4496
4723
|
* Whether the run's head is on the default branch, for any remote at all.
|
|
4497
4724
|
*
|
|
@@ -4609,13 +4836,18 @@ async function checkMerges(config) {
|
|
|
4609
4836
|
log(`could not ask which branches to check: ${failure.message}`);
|
|
4610
4837
|
return null;
|
|
4611
4838
|
});
|
|
4612
|
-
if (!pending?.length) {
|
|
4613
|
-
return;
|
|
4614
|
-
}
|
|
4615
|
-
|
|
4616
4839
|
// One fetch per project, not one per branch: twenty-five branches in one
|
|
4617
4840
|
// repository are twenty-five questions about the same set of refs.
|
|
4618
4841
|
const fetched = new Set();
|
|
4842
|
+
if (pending?.length) {
|
|
4843
|
+
await checkRunMerges(config, pending, fetched);
|
|
4844
|
+
}
|
|
4845
|
+
// R346: the hand-taken items, even when no run is pending.
|
|
4846
|
+
await checkWorkItemMerges(config, fetched);
|
|
4847
|
+
}
|
|
4848
|
+
|
|
4849
|
+
/** The run pass of `checkMerges`: one reading per pending run branch. */
|
|
4850
|
+
async function checkRunMerges(config, pending, fetched) {
|
|
4619
4851
|
const readings = [];
|
|
4620
4852
|
|
|
4621
4853
|
for (const branch of pending) {
|
|
@@ -4625,7 +4857,7 @@ async function checkMerges(config) {
|
|
|
4625
4857
|
}
|
|
4626
4858
|
// Same argument as the git survey: read a checkout nobody is working in.
|
|
4627
4859
|
const heldNow = heldWorkspaces();
|
|
4628
|
-
const cwd =
|
|
4860
|
+
const cwd = (await usableWorkspace(project, heldNow, new Map()))
|
|
4629
4861
|
?? resolve(project.path);
|
|
4630
4862
|
|
|
4631
4863
|
try {
|
|
@@ -4666,6 +4898,64 @@ async function checkMerges(config) {
|
|
|
4666
4898
|
}
|
|
4667
4899
|
}
|
|
4668
4900
|
|
|
4901
|
+
/**
|
|
4902
|
+
* R346: the hand-taken items. A squash or rebase merge leaves no head on the
|
|
4903
|
+
* default branch for the git reading to find, so ask `gh` about the pull
|
|
4904
|
+
* request. An older platform answers 404 and this returns in silence.
|
|
4905
|
+
*/
|
|
4906
|
+
async function checkWorkItemMerges(config, fetched) {
|
|
4907
|
+
const pending = await api(config, `/api/runners/${config.runnerId}/work-item-branches`)
|
|
4908
|
+
.catch(() => null);
|
|
4909
|
+
if (!Array.isArray(pending) || !pending.length) {
|
|
4910
|
+
return;
|
|
4911
|
+
}
|
|
4912
|
+
const readings = [];
|
|
4913
|
+
for (const item of pending) {
|
|
4914
|
+
const project = config.projects[item.projectSlug];
|
|
4915
|
+
if (!project) {
|
|
4916
|
+
// Not ours, but say so: an UNKNOWN only stamps the platform's rotation,
|
|
4917
|
+
// and silence would leave these rows first in the queue for ever.
|
|
4918
|
+
readings.push({ workItemId: item.workItemId, state: 'UNKNOWN' });
|
|
4919
|
+
continue;
|
|
4920
|
+
}
|
|
4921
|
+
const heldNow = heldWorkspaces();
|
|
4922
|
+
const cwd = (await usableWorkspace(project, heldNow, new Map()))
|
|
4923
|
+
?? resolve(project.path);
|
|
4924
|
+
try {
|
|
4925
|
+
await access(join(cwd, '.git'));
|
|
4926
|
+
} catch {
|
|
4927
|
+
readings.push({ workItemId: item.workItemId, state: 'UNKNOWN' });
|
|
4928
|
+
continue;
|
|
4929
|
+
}
|
|
4930
|
+
if (!fetched.has(cwd)) {
|
|
4931
|
+
fetched.add(cwd);
|
|
4932
|
+
await git(cwd, ['fetch', '--prune', 'origin']).catch(() => null);
|
|
4933
|
+
}
|
|
4934
|
+
const reading = await askGitHubForBranch(cwd, item.branch, item.openedAt).catch(() => null);
|
|
4935
|
+
readings.push({
|
|
4936
|
+
workItemId: item.workItemId,
|
|
4937
|
+
state: reading?.state ?? 'UNKNOWN',
|
|
4938
|
+
prUrl: reading?.prUrl ?? null,
|
|
4939
|
+
mergeCommit: reading?.mergeCommit ?? null,
|
|
4940
|
+
mergedAt: reading?.mergedAt ?? null,
|
|
4941
|
+
baseRefName: reading?.baseRefName ?? null,
|
|
4942
|
+
});
|
|
4943
|
+
}
|
|
4944
|
+
if (!readings.length) {
|
|
4945
|
+
return;
|
|
4946
|
+
}
|
|
4947
|
+
const recorded = await api(config, `/api/runners/${config.runnerId}/work-item-merges`, {
|
|
4948
|
+
method: 'POST',
|
|
4949
|
+
body: { readings },
|
|
4950
|
+
}).catch((failure) => {
|
|
4951
|
+
log(`could not record what became of the hand-taken branches: ${failure.message}`);
|
|
4952
|
+
return null;
|
|
4953
|
+
});
|
|
4954
|
+
if (recorded?.merged?.length) {
|
|
4955
|
+
log(`${recorded.merged.length} hand-taken branch(es) have merged`);
|
|
4956
|
+
}
|
|
4957
|
+
}
|
|
4958
|
+
|
|
4669
4959
|
/** Reads the run's history out of git and tells the platform. */
|
|
4670
4960
|
async function reportCommits(config, run, cwd, base) {
|
|
4671
4961
|
const commits = await readCommits(cwd, base).catch((failure) => {
|
|
@@ -4679,7 +4969,7 @@ async function reportCommits(config, run, cwd, base) {
|
|
|
4679
4969
|
// Only worth looking for a PR once something has been pushed: a branch that
|
|
4680
4970
|
// has never left the machine cannot have one.
|
|
4681
4971
|
const prUrl = pushState === 'PUSHED' || pushState === 'AHEAD'
|
|
4682
|
-
? await findPullRequest(cwd, run.branch).catch(() => null)
|
|
4972
|
+
? await findPullRequest(cwd, run.branch, { declared: run.gitHost ?? null, base: run.baseBranch ?? null }).catch(() => null)
|
|
4683
4973
|
: null;
|
|
4684
4974
|
|
|
4685
4975
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/commits`, {
|
|
@@ -5197,6 +5487,19 @@ function argsForProfile(agentArgs, profile, run, expertAgents = [], skills = [])
|
|
|
5197
5487
|
/** Longer than ask_user's own ten-minute wait — see the cawdev server of spawnCodex. */
|
|
5198
5488
|
const CAWDEV_TOOL_TIMEOUT_SEC = 660;
|
|
5199
5489
|
|
|
5490
|
+
/**
|
|
5491
|
+
* R368. cawdev reads a plan's `**Done when:**` list into the card's review
|
|
5492
|
+
* checklist, numbered in order, so a plan has to end in one that can be ticked.
|
|
5493
|
+
*/
|
|
5494
|
+
const DONE_WHEN_PLAN = 'End the plan with a `**Done when:**` list of `- [ ]` items, each one '
|
|
5495
|
+
+ 'condition somebody could check on its own — cawdev turns that list into the card\'s '
|
|
5496
|
+
+ 'review checklist, numbered in order.';
|
|
5497
|
+
|
|
5498
|
+
/** R368. The other half: the session that builds it says which items it built. */
|
|
5499
|
+
const DONE_WHEN_TICK = 'When an item under the plan\'s **Done when** is built and you have '
|
|
5500
|
+
+ 'checked it, call `done_when_tick` with its number and one line of evidence. Do not tick '
|
|
5501
|
+
+ 'what you did not check.';
|
|
5502
|
+
|
|
5200
5503
|
/**
|
|
5201
5504
|
* A decision that is the person's is ASKED, and the stage goes on with the
|
|
5202
5505
|
* answer. R19 and R24 (Codex) each met one — a plan the code had moved away
|
|
@@ -5216,7 +5519,9 @@ const ASK_DONT_STOP = 'If a decision is genuinely the person\'s — a scope the
|
|
|
5216
5519
|
*/
|
|
5217
5520
|
const WAIT_FOR_COMMANDS = 'This stage ends when your turn ends: run every command in the '
|
|
5218
5521
|
+ 'foreground and wait for it to finish — a build or a test suite left running in the '
|
|
5219
|
-
+ 'background is lost, and the stage is run again.'
|
|
5522
|
+
+ 'background is lost, and the stage is run again. The same goes for delegating: call '
|
|
5523
|
+
+ '`Agent` with `run_in_background: false` and wait for its answer — an agent still '
|
|
5524
|
+
+ 'working when your turn ends has its work lost, and the stage is run again.';
|
|
5220
5525
|
|
|
5221
5526
|
/**
|
|
5222
5527
|
* @param hasPlan whether the card has a plan of record — the same test
|
|
@@ -5230,7 +5535,8 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5230
5535
|
+ 'concretely: which files, what change in each, and how you will know it worked. '
|
|
5231
5536
|
+ 'What you report becomes the card\'s plan, and a DIFFERENT session — another '
|
|
5232
5537
|
+ 'process, with none of your context — will be handed it and told to carry it out. '
|
|
5233
|
-
+ 'Write it for them — with every decision in it already made. ' +
|
|
5538
|
+
+ 'Write it for them — with every decision in it already made. ' + DONE_WHEN_PLAN + ' '
|
|
5539
|
+
+ ASK_DONT_STOP,
|
|
5234
5540
|
VERIFY: 'Check the plan against what is actually there. Does every file it names exist? '
|
|
5235
5541
|
+ 'Does every interface it assumes still look like that? You cannot change anything — '
|
|
5236
5542
|
+ 'say what is wrong with the plan, or say plainly that it holds.',
|
|
@@ -5242,7 +5548,7 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5242
5548
|
? 'Do the work in the plan — it is under "The plan for this card" above, '
|
|
5243
5549
|
+ 'written by the plan phase and agreed. Do not quietly improvise a different change: '
|
|
5244
5550
|
+ 'somebody approved that plan. If it turns out to be wrong or incomplete, ask what to '
|
|
5245
|
-
+ 'do instead of stopping — and stop only if the answer is to stop. '
|
|
5551
|
+
+ 'do instead of stopping — and stop only if the answer is to stop. ' + DONE_WHEN_TICK + ' '
|
|
5246
5552
|
: 'This card has no plan, and it does not need one: it was started straight into '
|
|
5247
5553
|
+ 'development, so the CARD\'s text is the task. Work out what it asks — read the code '
|
|
5248
5554
|
+ 'it touches first — build it, and commit it on this branch. Never stop because a plan '
|
|
@@ -5267,7 +5573,8 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5267
5573
|
+ 'is one call away — `quality_get`, which reads this run\'s branch by default; a red '
|
|
5268
5574
|
+ 'gate with new vulnerabilities is a finding to report with the output, not a failure '
|
|
5269
5575
|
+ 'to hide. ' + WAIT_FOR_COMMANDS + ' Then commit the branch\'s changes — no stage '
|
|
5270
|
-
+ 'after this one writes code, and work left uncommitted is work nothing can push.'
|
|
5576
|
+
+ 'after this one writes code, and work left uncommitted is work nothing can push. '
|
|
5577
|
+
+ DONE_WHEN_TICK,
|
|
5271
5578
|
MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
|
|
5272
5579
|
+ 'tried and rejected and why, and anything that turned out to matter and would not be '
|
|
5273
5580
|
+ 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
|
|
@@ -5681,6 +5988,8 @@ card you created a moment ago. cawdev stores what you report as the card's plan
|
|
|
5681
5988
|
record when this phase ends, and that is the only copy anybody wants: two plans in
|
|
5682
5989
|
two places is how one of them goes stale.
|
|
5683
5990
|
|
|
5991
|
+
**End with a Done-when list.** ${DONE_WHEN_PLAN}
|
|
5992
|
+
|
|
5684
5993
|
Then \`report\` kind "done" with the plan itself.${asked}`;
|
|
5685
5994
|
}
|
|
5686
5995
|
|
|
@@ -6083,7 +6392,7 @@ function heldWorkspaces() {
|
|
|
6083
6392
|
* they read refs, never the tree, so which checkout they pick is a different
|
|
6084
6393
|
* question and dirtiness does not bear on it.
|
|
6085
6394
|
*/
|
|
6086
|
-
async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0) {
|
|
6395
|
+
async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0, problems = new Map()) {
|
|
6087
6396
|
const pinned = offered.workspace ?? null;
|
|
6088
6397
|
if (pinned) {
|
|
6089
6398
|
if (!project.workspaces.includes(pinned)) {
|
|
@@ -6092,6 +6401,15 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0)
|
|
|
6092
6401
|
// thing to say out loud rather than quietly ignore.
|
|
6093
6402
|
return { workspace: null, why: `its branch is in ${pinned}, which this machine does not serve` };
|
|
6094
6403
|
}
|
|
6404
|
+
// Before the busy check, so the reason is the folder's own sentence and not
|
|
6405
|
+
// prepareWorkingCopy's raw error.
|
|
6406
|
+
const trouble = await problemOf(problems, pinned);
|
|
6407
|
+
if (trouble) {
|
|
6408
|
+
return {
|
|
6409
|
+
workspace: null,
|
|
6410
|
+
why: workspaceProblemSaid(slug, trouble, { first: project.workspaces[0] }),
|
|
6411
|
+
};
|
|
6412
|
+
}
|
|
6095
6413
|
if (held.has(pinned)) {
|
|
6096
6414
|
return {
|
|
6097
6415
|
workspace: null,
|
|
@@ -6103,7 +6421,8 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0)
|
|
|
6103
6421
|
|
|
6104
6422
|
const waitForClean = offered.waitForClean === true;
|
|
6105
6423
|
const preferred = offered.preferredWorkspace ?? null;
|
|
6106
|
-
if (preferred && project.workspaces.includes(preferred) && !held.has(preferred)
|
|
6424
|
+
if (preferred && project.workspaces.includes(preferred) && !held.has(preferred)
|
|
6425
|
+
&& !(await problemOf(problems, preferred))) {
|
|
6107
6426
|
if (!waitForClean) {
|
|
6108
6427
|
return { workspace: preferred, why: null };
|
|
6109
6428
|
}
|
|
@@ -6111,13 +6430,29 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0)
|
|
|
6111
6430
|
if (probed.get(preferred) === true) return { workspace: preferred, why: null };
|
|
6112
6431
|
}
|
|
6113
6432
|
|
|
6114
|
-
const
|
|
6433
|
+
const broken = [];
|
|
6434
|
+
for (const path of project.workspaces) {
|
|
6435
|
+
const trouble = await problemOf(problems, path);
|
|
6436
|
+
if (trouble) broken.push(trouble);
|
|
6437
|
+
}
|
|
6438
|
+
const first = project.workspaces[0];
|
|
6439
|
+
const usable = project.workspaces.filter((path) => !broken.some((each) => each.path === path));
|
|
6440
|
+
if (!usable.length && broken.length) {
|
|
6441
|
+
// Nothing here can be used at all — said before "all busy", and without
|
|
6442
|
+
// trying to clone: `createAutoWorkspace` clones FROM the first workspace.
|
|
6443
|
+
return { workspace: null, why: workspaceProblemSaid(slug, broken[0], { first }) };
|
|
6444
|
+
}
|
|
6445
|
+
const free = usable.filter((path) => !held.has(path));
|
|
6115
6446
|
if (!free.length) {
|
|
6116
6447
|
// Every checkout is busy. Where the machine allows it, make one more
|
|
6117
6448
|
// rather than wait — the second coding session on a project starting at
|
|
6118
6449
|
// once is the whole point of `autoWorkspaces`.
|
|
6119
|
-
|
|
6120
|
-
const made =
|
|
6450
|
+
// A deleted clone must not use up the quota until `syncAutoWorkspaces` runs.
|
|
6451
|
+
const made = usable.filter((path) => isAutoWorkspace(first, path));
|
|
6452
|
+
const firstTrouble = broken.find((each) => each.path === first);
|
|
6453
|
+
if (firstTrouble && made.length < autoMax) {
|
|
6454
|
+
return { workspace: null, why: workspaceProblemSaid(slug, firstTrouble, { first }) };
|
|
6455
|
+
}
|
|
6121
6456
|
if (made.length < autoMax) {
|
|
6122
6457
|
try {
|
|
6123
6458
|
log(`every checkout of ${slug} is busy — cloning another beside ${first}`);
|
|
@@ -6239,12 +6574,12 @@ function snapshotRuns() {
|
|
|
6239
6574
|
return runs;
|
|
6240
6575
|
}
|
|
6241
6576
|
|
|
6242
|
-
async function startRun(config, offered, workspace) {
|
|
6577
|
+
async function startRun(config, offered, workspace, source = null) {
|
|
6243
6578
|
const run = offered.run;
|
|
6244
6579
|
const project = config.projects[run.projectSlug];
|
|
6245
6580
|
// The workspace this run was given, or — for a run that takes none — the
|
|
6246
6581
|
// first, which is where a question about the project gets read from.
|
|
6247
|
-
const path = workspace ?? project?.path;
|
|
6582
|
+
const path = workspace ?? source ?? project?.path;
|
|
6248
6583
|
let runToken;
|
|
6249
6584
|
let defaultBranch;
|
|
6250
6585
|
let allowDirty = false;
|
|
@@ -6315,6 +6650,8 @@ async function startRun(config, offered, workspace) {
|
|
|
6315
6650
|
// a reset, the code map and the brief read. Null on every run until a
|
|
6316
6651
|
// sprint has a branch — and then everything below is what it was.
|
|
6317
6652
|
run.baseBranch = claimed.baseBranch ?? null;
|
|
6653
|
+
// R379. The project's declared git host (GITHUB/GITLAB), or null to detect it.
|
|
6654
|
+
run.gitHost = claimed.gitHost ?? null;
|
|
6318
6655
|
if (run.baseBranch) {
|
|
6319
6656
|
log(` cut from ${run.baseBranch} (the sprint's branch)`);
|
|
6320
6657
|
}
|
|
@@ -6395,13 +6732,32 @@ async function startRun(config, offered, workspace) {
|
|
|
6395
6732
|
return;
|
|
6396
6733
|
}
|
|
6397
6734
|
|
|
6735
|
+
// Where the session runs: the checkout, or for a session with no branch
|
|
6736
|
+
// the project's reading copy (`readingCopy`).
|
|
6737
|
+
let cwd = path ? resolve(path) : path;
|
|
6738
|
+
let reader = null;
|
|
6398
6739
|
try {
|
|
6740
|
+
// R377. The folder was fine when the run was offered; it may not be now.
|
|
6741
|
+
// Checked before anything is prepared, so the run says what is wrong with
|
|
6742
|
+
// the folder instead of `is not a git repository` or `spawn claude ENOENT`.
|
|
6743
|
+
// One check covers every walked stage, which reuse this `cwd`.
|
|
6744
|
+
const trouble = path ? await workspaceProblem(resolve(path)) : null;
|
|
6745
|
+
if (trouble && !(trouble.kind === 'not-git' && !writesCodeProfile(run))) {
|
|
6746
|
+
throw new Error(workspaceProblemSaid(run.projectSlug, trouble, {
|
|
6747
|
+
first: project?.workspaces?.[0] ?? null,
|
|
6748
|
+
}));
|
|
6749
|
+
}
|
|
6399
6750
|
let branch = run.branch;
|
|
6400
6751
|
if (!writesCodeProfile(run)) {
|
|
6401
6752
|
// Nothing is prepared. It cuts no branch, and a dirty tree does not stop
|
|
6402
6753
|
// it, because it is not going to write to one — refusing here would make
|
|
6403
6754
|
// "what is R12 about?" unanswerable while somebody has edits open.
|
|
6404
|
-
log(` a ${run.profile.toLowerCase()} session: no branch, nothing
|
|
6755
|
+
log(` a ${run.profile.toLowerCase()} session: no branch, nothing cut`);
|
|
6756
|
+
if (path) {
|
|
6757
|
+
reader = await readingCopy(resolve(path), defaultBranch);
|
|
6758
|
+
cwd = reader.path;
|
|
6759
|
+
log(` ${reader.said}`);
|
|
6760
|
+
}
|
|
6405
6761
|
} else {
|
|
6406
6762
|
// Cleared BEFORE the checkout, and never when somebody deliberately
|
|
6407
6763
|
// started on top of their own uncommitted work — there the untracked
|
|
@@ -6439,7 +6795,7 @@ async function startRun(config, offered, workspace) {
|
|
|
6439
6795
|
}).catch((failure) => log(` could not report the merge: ${failure.message}`));
|
|
6440
6796
|
}
|
|
6441
6797
|
}
|
|
6442
|
-
log(` working copy ${path} is on ${branch}`);
|
|
6798
|
+
log(` working copy ${cwd ?? path} is on ${branch ?? 'no branch of its own'}`);
|
|
6443
6799
|
|
|
6444
6800
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
|
|
6445
6801
|
method: 'POST',
|
|
@@ -6461,7 +6817,7 @@ async function startRun(config, offered, workspace) {
|
|
|
6461
6817
|
return;
|
|
6462
6818
|
}
|
|
6463
6819
|
|
|
6464
|
-
const said = await walkLifecycle(config, run, runToken,
|
|
6820
|
+
const said = await walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
6465
6821
|
resume, mcpServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield);
|
|
6466
6822
|
|
|
6467
6823
|
if (merge) {
|
|
@@ -6480,6 +6836,7 @@ async function startRun(config, offered, workspace) {
|
|
|
6480
6836
|
body: { state: 'FAILED', summary: failure.message },
|
|
6481
6837
|
}).catch((second) => log(` could not record the failure: ${second.message}`));
|
|
6482
6838
|
} finally {
|
|
6839
|
+
reader?.release();
|
|
6483
6840
|
running.delete(run.id);
|
|
6484
6841
|
taken.delete(run.id);
|
|
6485
6842
|
// The transcript of a run that is over is on the platform, where it belongs.
|
|
@@ -6621,9 +6978,10 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6621
6978
|
if (before?.state === 'FAILED' && before.outcome) {
|
|
6622
6979
|
// The attempt that stopped, handed to the one about to run.
|
|
6623
6980
|
carried[`${stage.stage} (the attempt that stopped)`] = `${before.outcome}\n\n`
|
|
6624
|
-
+ 'This stage is being run again to finish that. Run the commands
|
|
6625
|
-
+ 'again IN THE FOREGROUND — not with run_in_background — wait for
|
|
6626
|
-
+ 'and read its output, then finish the stage properly.'
|
|
6981
|
+
+ 'This stage is being run again to finish that. Run the commands — and the agents — '
|
|
6982
|
+
+ 'it left running again IN THE FOREGROUND — not with run_in_background — wait for '
|
|
6983
|
+
+ 'each one to end and read its output, then finish the stage properly. Check the '
|
|
6984
|
+
+ 'checkout first: a background agent may have left part of its work there.';
|
|
6627
6985
|
}
|
|
6628
6986
|
// R305. What THIS stage's processes have used, across its attempts. Outside
|
|
6629
6987
|
// the attempt loop on purpose: `run_stage` is one row per stage and keeps
|
|
@@ -6741,7 +7099,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6741
7099
|
// /continue picks the run up HERE — the stages behind it stay pending.
|
|
6742
7100
|
if (last?.unfinishedShells?.length) {
|
|
6743
7101
|
const listed = last.unfinishedShells.map((command) => `\`${command.slice(0, 120)}\``).join(', ');
|
|
6744
|
-
const why = `The ${stage.stage} stage ended with ${last.unfinishedShells.length} command(s) `
|
|
7102
|
+
const why = `The ${stage.stage} stage ended with ${last.unfinishedShells.length} command(s) or agent(s) `
|
|
6745
7103
|
+ `still running in the background, or finished and never read: ${listed}. `
|
|
6746
7104
|
+ 'Nothing they proved was recorded — /continue runs this stage again and waits for them.';
|
|
6747
7105
|
log(` ${why}`);
|
|
@@ -7914,7 +8272,12 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7914
8272
|
prompts.stop();
|
|
7915
8273
|
workingCopy.stop();
|
|
7916
8274
|
await transcript.flush();
|
|
7917
|
-
|
|
8275
|
+
// Under a memory ceiling (`sessionScope`) the child is `systemd-run`, so a
|
|
8276
|
+
// missing `claude` arrives as its exit code and never as an ENOENT here.
|
|
8277
|
+
await finish(config, run, 'FAILED', spawnFailureSaid({
|
|
8278
|
+
failure, command: agentCmd, cwd, slug: run.projectSlug, label: 'the agent',
|
|
8279
|
+
first: config.projects[run.projectSlug]?.workspaces?.[0],
|
|
8280
|
+
}));
|
|
7918
8281
|
await rm(mcpDirectory, { recursive: true, force: true });
|
|
7919
8282
|
clearTimeout(idleTimer);
|
|
7920
8283
|
// A spawn that never happened has no exit code. The run is already
|
|
@@ -8584,7 +8947,10 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8584
8947
|
child.on('error', async (failure) => {
|
|
8585
8948
|
workingCopy.stop();
|
|
8586
8949
|
running.delete(run.id);
|
|
8587
|
-
const summary =
|
|
8950
|
+
const summary = spawnFailureSaid({
|
|
8951
|
+
failure, command: agyCmd, cwd, slug: run.projectSlug, label: 'Antigravity',
|
|
8952
|
+
first: config.projects[run.projectSlug]?.workspaces?.[0],
|
|
8953
|
+
});
|
|
8588
8954
|
await transcript.flush();
|
|
8589
8955
|
await finish(config, run, 'FAILED', summary);
|
|
8590
8956
|
resolvePromise(
|
|
@@ -8902,7 +9268,10 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8902
9268
|
child.on('error', async (failure) => {
|
|
8903
9269
|
workingCopy.stop();
|
|
8904
9270
|
running.delete(run.id);
|
|
8905
|
-
const summary =
|
|
9271
|
+
const summary = spawnFailureSaid({
|
|
9272
|
+
failure, command: codexCmd, cwd, slug: run.projectSlug, label: 'Codex',
|
|
9273
|
+
first: config.projects[run.projectSlug]?.workspaces?.[0],
|
|
9274
|
+
});
|
|
8906
9275
|
await transcript.flush();
|
|
8907
9276
|
await finish(config, run, 'FAILED', summary);
|
|
8908
9277
|
resolvePromise(stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined);
|
|
@@ -9186,6 +9555,12 @@ function capabilities(config) {
|
|
|
9186
9555
|
// that, so the platform tells it apart rather than letting one behave
|
|
9187
9556
|
// like the other.
|
|
9188
9557
|
waitForClean: true,
|
|
9558
|
+
// R379. This daemon knows `CREATE_BRANCH` (a branch put on origin, cut from
|
|
9559
|
+
// another branch) and can open, find and merge a GitLab merge request with
|
|
9560
|
+
// `glab`. An older daemon would fail the unknown kind, so the platform
|
|
9561
|
+
// hands the request only to a daemon that says so.
|
|
9562
|
+
createBranch: true,
|
|
9563
|
+
gitHosts: ['github', 'gitlab'],
|
|
9189
9564
|
});
|
|
9190
9565
|
}
|
|
9191
9566
|
|
|
@@ -9337,6 +9712,7 @@ async function main() {
|
|
|
9337
9712
|
log(`quality: ${slug} scans with ${project.quality.command} after TEST, token from $${project.quality.tokenEnv}`);
|
|
9338
9713
|
}
|
|
9339
9714
|
}
|
|
9715
|
+
await sayWhichWorkspacesCannotBeUsed(config);
|
|
9340
9716
|
await sayWhichServedProjectsTheTokenCannotReach(config);
|
|
9341
9717
|
|
|
9342
9718
|
// Frozen here, before anything can be claimed. A run that checks this very
|
|
@@ -9469,6 +9845,9 @@ async function main() {
|
|
|
9469
9845
|
// A checkout made on demand may have been dropped — the attached
|
|
9470
9846
|
// terminal's `/drop` deletes the directory and sends this signal.
|
|
9471
9847
|
await syncAutoWorkspaces(config.projects, heldWorkspaces());
|
|
9848
|
+
if (await sayWhichWorkspacesCannotBeUsed(config)) {
|
|
9849
|
+
log(' (the project list itself is read at start — restart the daemon after changing "workspaces")');
|
|
9850
|
+
}
|
|
9472
9851
|
Object.assign(config, resolved);
|
|
9473
9852
|
// R288: the hello describes the current state, not the booted one — an
|
|
9474
9853
|
// attach opened after a toggle would otherwise offer to turn on what is
|
|
@@ -9792,6 +10171,7 @@ async function main() {
|
|
|
9792
10171
|
// Several offers of one project ask `looksClean` on a shared checkout
|
|
9793
10172
|
// only once.
|
|
9794
10173
|
const probed = new Map();
|
|
10174
|
+
const problems = new Map();
|
|
9795
10175
|
|
|
9796
10176
|
let claimable = 0;
|
|
9797
10177
|
let skipped = 0;
|
|
@@ -9859,15 +10239,30 @@ async function main() {
|
|
|
9859
10239
|
// adding to `told.heldWorkspaces` mid-loop — is the same window that
|
|
9860
10240
|
// already exists between this poll and `startRun`'s own claim.
|
|
9861
10241
|
let workspace = null;
|
|
10242
|
+
let source = null;
|
|
9862
10243
|
if (writes) {
|
|
9863
10244
|
const picked = await pickWorkspace(slug, config.projects[slug], offered, held, probed,
|
|
9864
|
-
config.autoWorkspaces);
|
|
10245
|
+
config.autoWorkspaces, problems);
|
|
9865
10246
|
if (picked.why) {
|
|
9866
10247
|
noteQueued(offered.run, picked.why);
|
|
9867
10248
|
skipped += 1;
|
|
9868
10249
|
continue;
|
|
9869
10250
|
}
|
|
9870
10251
|
workspace = picked.workspace;
|
|
10252
|
+
} else {
|
|
10253
|
+
// Reads from a usable folder, and holds none: the source goes to
|
|
10254
|
+
// `startRun` on its own and never into `held` or `taken` (R70).
|
|
10255
|
+
const project = config.projects[slug];
|
|
10256
|
+
source = await usableWorkspace(project, new Set(), problems, { reading: true });
|
|
10257
|
+
if (!source) {
|
|
10258
|
+
const first = project.workspaces[0];
|
|
10259
|
+
const trouble = await problemOf(problems, first);
|
|
10260
|
+
noteQueued(offered.run, trouble
|
|
10261
|
+
? workspaceProblemSaid(slug, trouble, { first })
|
|
10262
|
+
: `${slug} has no workspace on this machine`);
|
|
10263
|
+
skipped += 1;
|
|
10264
|
+
continue;
|
|
10265
|
+
}
|
|
9871
10266
|
}
|
|
9872
10267
|
if (workspace) {
|
|
9873
10268
|
held.add(workspace);
|
|
@@ -9880,7 +10275,7 @@ async function main() {
|
|
|
9880
10275
|
});
|
|
9881
10276
|
noted.delete(offered.run.id);
|
|
9882
10277
|
claimable += 1;
|
|
9883
|
-
void startRun(config, offered, workspace);
|
|
10278
|
+
void startRun(config, offered, workspace, source);
|
|
9884
10279
|
}
|
|
9885
10280
|
|
|
9886
10281
|
// Forget runs that are no longer offered, so one that comes back around
|