cawdev-cli 1.0.10-beta → 1.1.0
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/README.md +11 -1
- package/lib/background-shells.mjs +45 -3
- package/lib/roadmap-format.mjs +31 -8
- package/lib/stage-tools.mjs +2 -0
- package/mcp/README.md +44 -30
- package/mcp/server.mjs +113 -49
- package/package.json +1 -1
- package/runner/README.md +29 -27
- package/runner/attach.mjs +19 -8
- package/runner/git-host.mjs +263 -0
- package/runner/runner.mjs +324 -117
- package/runner/split.mjs +46 -6
- 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';
|
|
@@ -187,6 +189,7 @@ const DEFAULTS = {
|
|
|
187
189
|
'mcp__cawdev__quality_get',
|
|
188
190
|
'mcp__cawdev__roadmap_statuses',
|
|
189
191
|
'mcp__cawdev__roadmap_list',
|
|
192
|
+
'mcp__cawdev__sprint_list',
|
|
190
193
|
'mcp__cawdev__roadmap_get',
|
|
191
194
|
// The MCP server has served this since R37, its README documents it, and
|
|
192
195
|
// the REVIEW prompt below TELLS a session to file its findings with it —
|
|
@@ -1987,6 +1990,16 @@ async function surveyWorkingCopies(config) {
|
|
|
1987
1990
|
for (const [slug, project] of Object.entries(config.projects)) {
|
|
1988
1991
|
for (const path of project.workspaces) {
|
|
1989
1992
|
const held = heldBy(path);
|
|
1993
|
+
const problem = await workspaceProblem(path).catch(() => null);
|
|
1994
|
+
if (problem) {
|
|
1995
|
+
survey.push({
|
|
1996
|
+
project: slug,
|
|
1997
|
+
path,
|
|
1998
|
+
runId: held ?? null,
|
|
1999
|
+
unreadable: workspaceProblemSaid(slug, problem, { first: project.workspaces[0] }),
|
|
2000
|
+
});
|
|
2001
|
+
continue;
|
|
2002
|
+
}
|
|
1990
2003
|
try {
|
|
1991
2004
|
await access(join(path, '.git'));
|
|
1992
2005
|
const porcelain = await git(path, ['status', '--porcelain']);
|
|
@@ -2038,6 +2051,52 @@ async function looksClean(path) {
|
|
|
2038
2051
|
return porcelain === null ? null : porcelain === '';
|
|
2039
2052
|
}
|
|
2040
2053
|
|
|
2054
|
+
/**
|
|
2055
|
+
* `workspaceProblem` for `path`, asked once per `problems` Map. The Map lives
|
|
2056
|
+
* for one poll pass, so a folder cloned meanwhile is picked up without a restart.
|
|
2057
|
+
*/
|
|
2058
|
+
async function problemOf(problems, path) {
|
|
2059
|
+
if (!problems.has(path)) problems.set(path, await workspaceProblem(path));
|
|
2060
|
+
return problems.get(path);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* The checkout a question about the project is read from — R377. The first
|
|
2065
|
+
* workspace nobody holds and that can be used; else null, and the caller
|
|
2066
|
+
* falls back to `project.path` as it always did. A missing `workspaces[0]` must not make the project read as
|
|
2067
|
+
* broken while `[1]` is fine.
|
|
2068
|
+
*/
|
|
2069
|
+
async function usableWorkspace(project, held, problems, { reading = false } = {}) {
|
|
2070
|
+
const usable = [];
|
|
2071
|
+
for (const path of project.workspaces) {
|
|
2072
|
+
const trouble = await problemOf(problems, path);
|
|
2073
|
+
// A reader has always been able to run in a folder with no `.git` — it
|
|
2074
|
+
// reads the files as they are (`readingCopy`) — so only a folder that is
|
|
2075
|
+
// not there at all stops it.
|
|
2076
|
+
if (!trouble || (reading && trouble.kind === 'not-git')) usable.push(path);
|
|
2077
|
+
}
|
|
2078
|
+
return usable.find((path) => !held.has(path)) ?? null;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
/**
|
|
2082
|
+
* One log line per workspace that cannot be used — R377. Said at boot and on
|
|
2083
|
+
* SIGHUP so the operator hears it before a run is queued behind it.
|
|
2084
|
+
* Returns how many there were.
|
|
2085
|
+
*/
|
|
2086
|
+
async function sayWhichWorkspacesCannotBeUsed(config) {
|
|
2087
|
+
let found = 0;
|
|
2088
|
+
for (const [slug, project] of Object.entries(config.projects)) {
|
|
2089
|
+
for (const path of project.workspaces) {
|
|
2090
|
+
const problem = await workspaceProblem(path).catch(() => null);
|
|
2091
|
+
if (problem) {
|
|
2092
|
+
found += 1;
|
|
2093
|
+
log(` ${workspaceProblemSaid(slug, problem, { first: project.workspaces[0] })}`);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
return found;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2041
2100
|
/**
|
|
2042
2101
|
* What every checkout on this machine holds — R87.
|
|
2043
2102
|
*
|
|
@@ -2059,6 +2118,15 @@ async function surveyWorkspaces(config) {
|
|
|
2059
2118
|
const readings = [];
|
|
2060
2119
|
for (const [slug, project] of Object.entries(config.projects)) {
|
|
2061
2120
|
for (const path of project.workspaces) {
|
|
2121
|
+
const problem = await workspaceProblem(path).catch(() => null);
|
|
2122
|
+
if (problem) {
|
|
2123
|
+
readings.push({
|
|
2124
|
+
path,
|
|
2125
|
+
projectSlug: slug,
|
|
2126
|
+
error: workspaceProblemSaid(slug, problem, { first: project.workspaces[0] }),
|
|
2127
|
+
});
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
2062
2130
|
try {
|
|
2063
2131
|
await access(join(path, '.git'));
|
|
2064
2132
|
const branch = (await git(path, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
@@ -2415,9 +2483,10 @@ async function surveyGit(config) {
|
|
|
2415
2483
|
// session's branch with its work uncommitted. Reading the pool's spare
|
|
2416
2484
|
// gives the honest picture of the default branch instead.
|
|
2417
2485
|
const held = heldWorkspaces();
|
|
2418
|
-
const free = project
|
|
2486
|
+
const free = await usableWorkspace(project, held, new Map());
|
|
2419
2487
|
const path = free ?? resolve(project.path);
|
|
2420
|
-
|
|
2488
|
+
// Busy means no free USABLE workspace.
|
|
2489
|
+
const busy = free === null;
|
|
2421
2490
|
|
|
2422
2491
|
const reading = await surveyProjectGit(path, { fetch: !busy }).catch((failure) => ({
|
|
2423
2492
|
error: failure.message.split('\n')[0],
|
|
@@ -3398,7 +3467,7 @@ async function perform(config, run, cwd, action) {
|
|
|
3398
3467
|
({ ok, result } = await openPullRequest(cwd, run, action.message));
|
|
3399
3468
|
} else if (action.kind === 'MERGE') {
|
|
3400
3469
|
log(` MERGING ${run.branch} into ${run.baseBranch ?? 'the default branch'}${byRule(action)} — nobody is reading this diff`);
|
|
3401
|
-
({ ok, result } = await mergePullRequest(cwd, run.branch));
|
|
3470
|
+
({ ok, result } = await mergePullRequest(cwd, run.branch, { declared: run.gitHost ?? null }));
|
|
3402
3471
|
} else {
|
|
3403
3472
|
result = `This runner does not know how to ${action.kind}.`;
|
|
3404
3473
|
}
|
|
@@ -3685,6 +3754,7 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3685
3754
|
// squashes as it always did. The claim says which.
|
|
3686
3755
|
({ ok, result, failure: failureKind } = await mergePullRequest(cwd, branch, {
|
|
3687
3756
|
method: request.mergeMethod ?? null,
|
|
3757
|
+
declared: request.gitHost ?? null,
|
|
3688
3758
|
}));
|
|
3689
3759
|
}
|
|
3690
3760
|
} else if (request.kind === 'TAG') {
|
|
@@ -3726,6 +3796,7 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3726
3796
|
result = pushed.failed;
|
|
3727
3797
|
} else {
|
|
3728
3798
|
({ ok, result } = await openPullRequestFor(cwd, {
|
|
3799
|
+
declared: request.gitHost ?? null,
|
|
3729
3800
|
branch,
|
|
3730
3801
|
base,
|
|
3731
3802
|
title: request.title?.trim() || branch,
|
|
@@ -3754,6 +3825,19 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3754
3825
|
log(` cutting ${name} on origin from ${request.defaultBranch || 'main'}`);
|
|
3755
3826
|
({ ok, result } = await cutBranchOnOrigin(cwd, name, request.defaultBranch));
|
|
3756
3827
|
}
|
|
3828
|
+
} else if (request.kind === 'CREATE_BRANCH') {
|
|
3829
|
+
// R379. Put a named branch on origin, cut from another branch's tip. Unlike
|
|
3830
|
+
// CUT_BRANCH an existing name is a REFUSAL: the person asked for a new
|
|
3831
|
+
// branch, and one that is already there may be somebody else's work.
|
|
3832
|
+
// Touches no working tree: the push names two refs and HEAD is neither.
|
|
3833
|
+
const name = (request.message ?? '').trim();
|
|
3834
|
+
const from = (request.fromBranch ?? '').trim();
|
|
3835
|
+
if (!name || !from) {
|
|
3836
|
+
result = 'A new branch has to name itself and the branch to cut it from. Nothing was created.';
|
|
3837
|
+
} else {
|
|
3838
|
+
log(` creating ${name} on origin from ${from} — asked for from the console`);
|
|
3839
|
+
({ ok, result } = await createBranchOnOrigin(cwd, name, from));
|
|
3840
|
+
}
|
|
3757
3841
|
} else {
|
|
3758
3842
|
result = `This runner does not know how to ${request.kind}.`;
|
|
3759
3843
|
}
|
|
@@ -3920,27 +4004,30 @@ async function readPushState(cwd, branch) {
|
|
|
3920
4004
|
* already holds the credentials, so the runner answers and the platform never
|
|
3921
4005
|
* needs a token of its own.
|
|
3922
4006
|
*/
|
|
3923
|
-
async function findPullRequest(cwd, branch) {
|
|
3924
|
-
const
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
});
|
|
3929
|
-
let out = '';
|
|
3930
|
-
child.stdout.on('data', (chunk) => (out += chunk));
|
|
3931
|
-
child.on('error', () => resolvePromise(null));
|
|
3932
|
-
// `close`, not `exit` — see git() above.
|
|
3933
|
-
child.on('close', (code) => resolvePromise(code === 0 ? out.trim() : null));
|
|
3934
|
-
});
|
|
3935
|
-
if (viaGh) {
|
|
3936
|
-
return viaGh;
|
|
4007
|
+
async function findPullRequest(cwd, branch, { declared = null, base = null } = {}) {
|
|
4008
|
+
const { host, adapter, originUrl } = await hostOf(cwd, declared);
|
|
4009
|
+
const viaCli = await adapter.find(cwd, branch);
|
|
4010
|
+
if (viaCli) {
|
|
4011
|
+
return viaCli;
|
|
3937
4012
|
}
|
|
3938
4013
|
|
|
3939
|
-
// No
|
|
4014
|
+
// No CLI, or no PR yet. A compare URL is honest: it is where you would go to
|
|
3940
4015
|
// open one, and it is obviously not a claim that one exists.
|
|
3941
|
-
const
|
|
3942
|
-
const
|
|
3943
|
-
return
|
|
4016
|
+
const repo = repoOf(originUrl);
|
|
4017
|
+
const against = host === 'gitlab' && !base ? await defaultBranchOf(cwd) : base;
|
|
4018
|
+
return adapter.fallbackLink(repo, against, branch);
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
/**
|
|
4022
|
+
* Which host this checkout's origin is on, and the adapter that talks to it —
|
|
4023
|
+
* R379. `declared` is the project's own word (`GITHUB`/`GITLAB`), from the claim
|
|
4024
|
+
* or the request; with none, a GitLab-looking host is GitLab and everything else
|
|
4025
|
+
* is GitHub, as it has always been.
|
|
4026
|
+
*/
|
|
4027
|
+
async function hostOf(cwd, declared) {
|
|
4028
|
+
const originUrl = (await git(cwd, ['remote', 'get-url', 'origin']).catch(() => '')).trim();
|
|
4029
|
+
const host = hostFor({ declared, originUrl });
|
|
4030
|
+
return { host, adapter: adapterFor(host), originUrl };
|
|
3944
4031
|
}
|
|
3945
4032
|
|
|
3946
4033
|
// --- what a project's rules asked for (R40) ----------------------------------
|
|
@@ -3989,25 +4076,6 @@ async function firstRemote(cwd) {
|
|
|
3989
4076
|
return remotes.split('\n')[0]?.trim() || null;
|
|
3990
4077
|
}
|
|
3991
4078
|
|
|
3992
|
-
/** Runs `gh` and hands back everything it said, including on failure. */
|
|
3993
|
-
function gh(cwd, args) {
|
|
3994
|
-
return new Promise((resolvePromise) => {
|
|
3995
|
-
const child = spawn('gh', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
3996
|
-
let out = '';
|
|
3997
|
-
let err = '';
|
|
3998
|
-
child.stdout.on('data', (chunk) => (out += chunk));
|
|
3999
|
-
child.stderr.on('data', (chunk) => (err += chunk));
|
|
4000
|
-
child.on('error', (failure) => resolvePromise({
|
|
4001
|
-
code: -1,
|
|
4002
|
-
out: '',
|
|
4003
|
-
err: failure.code === 'ENOENT'
|
|
4004
|
-
? 'gh is not installed on this machine, so it cannot talk to the git host.'
|
|
4005
|
-
: failure.message,
|
|
4006
|
-
}));
|
|
4007
|
-
// `close`, not `exit` — see git() above.
|
|
4008
|
-
child.on('close', (code) => resolvePromise({ code, out: out.trim(), err: err.trim() }));
|
|
4009
|
-
});
|
|
4010
|
-
}
|
|
4011
4079
|
|
|
4012
4080
|
/**
|
|
4013
4081
|
* The branch, on the remote.
|
|
@@ -4087,6 +4155,7 @@ async function openPullRequest(cwd, run, title) {
|
|
|
4087
4155
|
}
|
|
4088
4156
|
|
|
4089
4157
|
return openPullRequestFor(cwd, {
|
|
4158
|
+
declared: run.gitHost ?? null,
|
|
4090
4159
|
branch: run.branch,
|
|
4091
4160
|
// R260. The sprint's branch, when the claim carried one; otherwise the
|
|
4092
4161
|
// argv is byte for byte what it was and the host picks its own default.
|
|
@@ -4157,44 +4226,40 @@ async function pushIfOriginLacks(cwd, branch) {
|
|
|
4157
4226
|
}
|
|
4158
4227
|
}
|
|
4159
4228
|
|
|
4160
|
-
async function openPullRequestFor(cwd, { branch, base, title, body }) {
|
|
4161
|
-
const
|
|
4229
|
+
async function openPullRequestFor(cwd, { branch, base, title, body, declared = null }) {
|
|
4230
|
+
const { adapter } = await hostOf(cwd, declared);
|
|
4231
|
+
const existing = await findPullRequest(cwd, branch, { declared, base }).catch(() => null);
|
|
4162
4232
|
if (existing && !existing.includes('/compare/')) {
|
|
4163
4233
|
return { ok: true, result: existing };
|
|
4164
4234
|
}
|
|
4165
4235
|
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
'--head', branch,
|
|
4169
|
-
...(base ? ['--base', base] : []),
|
|
4170
|
-
'--title', title,
|
|
4171
|
-
'--body', body,
|
|
4172
|
-
]);
|
|
4236
|
+
// R379: `gh pr create` or `glab mr create`; for GitHub it is the argv it always was.
|
|
4237
|
+
const opened = await adapter.create(cwd, { branch, base, title, body });
|
|
4173
4238
|
if (opened.code === 0 && opened.out) {
|
|
4174
|
-
//
|
|
4175
|
-
//
|
|
4176
|
-
//
|
|
4177
|
-
const printed =
|
|
4178
|
-
const confirmed = await findPullRequest(cwd, branch).catch(() => null);
|
|
4239
|
+
// The CLI prints the URL and the host is then asked whether it is really
|
|
4240
|
+
// there, because "asked for" is not "made", and a URL on the card that
|
|
4241
|
+
// 404s is worse than an honest failure.
|
|
4242
|
+
const printed = adapter.urlOf(opened);
|
|
4243
|
+
const confirmed = await findPullRequest(cwd, branch, { declared, base }).catch(() => null);
|
|
4179
4244
|
if (confirmed && !confirmed.includes('/compare/')) {
|
|
4180
4245
|
return { ok: true, result: confirmed };
|
|
4181
4246
|
}
|
|
4182
4247
|
return {
|
|
4183
4248
|
ok: false,
|
|
4184
|
-
result:
|
|
4249
|
+
result: `${adapter.bin} said it opened ${printed}, but the host does not show a ${adapter.noun} for ${branch}.`,
|
|
4185
4250
|
};
|
|
4186
4251
|
}
|
|
4187
4252
|
// The one failure somebody can fix in a minute, said with the fix — and the
|
|
4188
|
-
// link to open it by hand meanwhile, which `findPullRequest` builds without
|
|
4253
|
+
// link to open it by hand meanwhile, which `findPullRequest` builds without the CLI.
|
|
4189
4254
|
if (opened.code === -1 && /not installed/.test(opened.err)) {
|
|
4190
|
-
const compare = await findPullRequest(cwd, branch).catch(() => null);
|
|
4255
|
+
const compare = await findPullRequest(cwd, branch, { declared, base }).catch(() => null);
|
|
4191
4256
|
return {
|
|
4192
4257
|
ok: false,
|
|
4193
|
-
result: `${opened.err} Install it and sign in (
|
|
4258
|
+
result: `${opened.err} Install it and sign in (${adapter.installHint}), `
|
|
4194
4259
|
+ `then ask again${compare ? ` — or open it yourself: ${compare}` : '.'}`,
|
|
4195
4260
|
};
|
|
4196
4261
|
}
|
|
4197
|
-
return { ok: false, result: opened.err || opened.out || '
|
|
4262
|
+
return { ok: false, result: opened.err || opened.out || `${adapter.bin} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} create failed without saying why.` };
|
|
4198
4263
|
}
|
|
4199
4264
|
|
|
4200
4265
|
/**
|
|
@@ -4245,18 +4310,27 @@ function mergeabilityWaitMs() {
|
|
|
4245
4310
|
return Number.isFinite(configured) && configured > 0 ? configured : 5000;
|
|
4246
4311
|
}
|
|
4247
4312
|
|
|
4248
|
-
async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
4249
|
-
const
|
|
4313
|
+
async function mergePullRequest(cwd, branch, { method = null, declared = null } = {}) {
|
|
4314
|
+
const { adapter } = await hostOf(cwd, declared);
|
|
4315
|
+
const url = await findPullRequest(cwd, branch, { declared }).catch(() => null);
|
|
4250
4316
|
if (!url || url.includes('/compare/')) {
|
|
4317
|
+
// R379. On GitLab a missing `glab` looks exactly like "no merge request" —
|
|
4318
|
+
// the lookup that would have found it never ran. Said as what it is.
|
|
4319
|
+
if (adapter.name === 'gitlab') {
|
|
4320
|
+
const probe = await adapter.run(cwd, ['--version']);
|
|
4321
|
+
if (probe.code === -1) {
|
|
4322
|
+
return { ok: false, failure: 'UNREACHABLE', result: probe.err };
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4251
4325
|
return {
|
|
4252
4326
|
ok: false,
|
|
4253
4327
|
failure: 'NO_PULL_REQUEST',
|
|
4254
4328
|
result: 'There is no pull request for this branch to merge. Nothing was merged.',
|
|
4255
4329
|
};
|
|
4256
4330
|
}
|
|
4257
|
-
await waitOutUnknownMergeability(cwd, url, branch);
|
|
4331
|
+
await waitOutUnknownMergeability(cwd, url, branch, adapter);
|
|
4258
4332
|
const mergeCommit = method === 'MERGE_COMMIT';
|
|
4259
|
-
const merged = await
|
|
4333
|
+
const merged = await adapter.merge(cwd, url, { mergeCommit });
|
|
4260
4334
|
if (merged.code === 0) {
|
|
4261
4335
|
// The URL on its own FIRST LINE — R134. The platform reads that line as the
|
|
4262
4336
|
// evidence a merge happened and puts it on the card as the card's `merge`
|
|
@@ -4273,10 +4347,10 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
4273
4347
|
ok: false,
|
|
4274
4348
|
// R155. WHICH kind of failure, so the console can offer Merge with an agent
|
|
4275
4349
|
// on the one an agent can fix and stay quiet on the three it cannot.
|
|
4276
|
-
failure: await classifyMergeFailure(cwd, url, merged),
|
|
4350
|
+
failure: await classifyMergeFailure(cwd, url, merged, adapter),
|
|
4277
4351
|
// Unchanged: `gh`'s own words still reach the card. The classification is a
|
|
4278
4352
|
// word beside them, never a replacement for them.
|
|
4279
|
-
result: merged.err || merged.out || '
|
|
4353
|
+
result: merged.err || merged.out || `${adapter.bin} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} merge failed without saying why.`,
|
|
4280
4354
|
};
|
|
4281
4355
|
}
|
|
4282
4356
|
|
|
@@ -4285,19 +4359,12 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
4285
4359
|
* bounded. See `mergePullRequest`. One log line if it waited at all, so a
|
|
4286
4360
|
* merge that took thirty seconds says why.
|
|
4287
4361
|
*/
|
|
4288
|
-
async function waitOutUnknownMergeability(cwd, url, branch) {
|
|
4362
|
+
async function waitOutUnknownMergeability(cwd, url, branch, adapter) {
|
|
4289
4363
|
let waited = 0;
|
|
4290
4364
|
for (let poll = 0; poll < MERGEABILITY_POLLS; poll++) {
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
try {
|
|
4295
|
-
view = JSON.parse(asked.out || '{}');
|
|
4296
|
-
} catch {
|
|
4297
|
-
// Not JSON — a gh that answered something else. Nothing to wait for.
|
|
4298
|
-
return;
|
|
4299
|
-
}
|
|
4300
|
-
if (view.mergeable !== 'UNKNOWN') {
|
|
4365
|
+
// `UNKNOWN` is the only answer worth waiting out; an error, something that
|
|
4366
|
+
// is not JSON or any other word proceeds at once (the adapter says which).
|
|
4367
|
+
if ((await adapter.mergeability(cwd, url)) !== 'UNKNOWN') {
|
|
4301
4368
|
if (waited) log(` ${branch}: mergeability known after ${waited}s`);
|
|
4302
4369
|
return;
|
|
4303
4370
|
}
|
|
@@ -4401,6 +4468,67 @@ async function cutBranchOnOrigin(cwd, name, defaultBranch) {
|
|
|
4401
4468
|
return { ok: true, result: `${tip}\n${name} cut from ${from} at ${tip.slice(0, 7)}.` };
|
|
4402
4469
|
}
|
|
4403
4470
|
|
|
4471
|
+
/**
|
|
4472
|
+
* Creates a branch on origin, cut from another — R379's `CREATE_BRANCH`.
|
|
4473
|
+
*
|
|
4474
|
+
* Order matters: the name is checked before anything is asked of the remote,
|
|
4475
|
+
* an existing name is refused whatever it points at, and the source is read
|
|
4476
|
+
* from `origin/<from>` after a fetch — what everyone else has, not this
|
|
4477
|
+
* checkout's idea of it. The sha is the FIRST LINE of an ok result, the way a
|
|
4478
|
+
* tag check reports it, because the platform does not take a machine's word.
|
|
4479
|
+
*/
|
|
4480
|
+
async function createBranchOnOrigin(cwd, name, from) {
|
|
4481
|
+
await git(cwd, ['fetch', '--prune', 'origin']).catch((failure) => {
|
|
4482
|
+
log(` fetch skipped: ${failure.message.split('\n')[0]}`);
|
|
4483
|
+
});
|
|
4484
|
+
try {
|
|
4485
|
+
await git(cwd, ['check-ref-format', '--branch', name]);
|
|
4486
|
+
// `@{-1}` passes check-ref-format --branch (it expands), but is no name.
|
|
4487
|
+
if (/[~^@]/.test(name)) throw new Error('not a name');
|
|
4488
|
+
} catch {
|
|
4489
|
+
return { ok: false, result: `\`${name}\` is not a branch name git accepts. Nothing was created.` };
|
|
4490
|
+
}
|
|
4491
|
+
|
|
4492
|
+
const ref = `refs/heads/${name}`;
|
|
4493
|
+
let there;
|
|
4494
|
+
try {
|
|
4495
|
+
there = await git(cwd, ['ls-remote', '--heads', 'origin', ref]);
|
|
4496
|
+
} catch (failure) {
|
|
4497
|
+
return { ok: false, result: `Could not ask the remote about ${name}: ${failure.message}` };
|
|
4498
|
+
}
|
|
4499
|
+
const already = there.split('\n').map((each) => each.trim()).filter(Boolean)
|
|
4500
|
+
.find((each) => each.endsWith(`\t${ref}`));
|
|
4501
|
+
if (already) {
|
|
4502
|
+
return {
|
|
4503
|
+
ok: false,
|
|
4504
|
+
result: `\`${name}\` is already on origin at ${already.split(/\s+/)[0].slice(0, 7)}. Nothing was created.`,
|
|
4505
|
+
};
|
|
4506
|
+
}
|
|
4507
|
+
|
|
4508
|
+
// `from` is a branch NAME: `main~3` or `main@{1}` would resolve and be pushed.
|
|
4509
|
+
try {
|
|
4510
|
+
await git(cwd, ['check-ref-format', '--branch', from]);
|
|
4511
|
+
} catch {
|
|
4512
|
+
return { ok: false, result: `\`${from}\` is not a branch name git accepts. Nothing was created.` };
|
|
4513
|
+
}
|
|
4514
|
+
if (/[~^@]/.test(from)) {
|
|
4515
|
+
return { ok: false, result: `\`${from}\` is not a branch name. Nothing was created.` };
|
|
4516
|
+
}
|
|
4517
|
+
const source = `origin/${from}`;
|
|
4518
|
+
let tip;
|
|
4519
|
+
try {
|
|
4520
|
+
tip = (await git(cwd, ['rev-parse', '--verify', `${source}^{commit}`])).trim();
|
|
4521
|
+
} catch {
|
|
4522
|
+
return { ok: false, result: `${source} is not on origin, so there is nothing to create \`${name}\` from.` };
|
|
4523
|
+
}
|
|
4524
|
+
try {
|
|
4525
|
+
await git(cwd, ['push', 'origin', `${source}:${ref}`]);
|
|
4526
|
+
} catch (failure) {
|
|
4527
|
+
return { ok: false, result: `Could not push ${name}: ${failure.message}` };
|
|
4528
|
+
}
|
|
4529
|
+
return { ok: true, result: `${tip}\n${name} created from ${from} at ${tip.slice(0, 7)}.` };
|
|
4530
|
+
}
|
|
4531
|
+
|
|
4404
4532
|
/**
|
|
4405
4533
|
* Why `gh pr merge` said no — R155, in one of five words.
|
|
4406
4534
|
*
|
|
@@ -4417,9 +4545,9 @@ async function cutBranchOnOrigin(cwd, name, defaultBranch) {
|
|
|
4417
4545
|
* as OTHER, and the console offers the action for OTHER for the same reason it
|
|
4418
4546
|
* offers it for a null — the cost of being wrong is one `git fetch`.
|
|
4419
4547
|
*/
|
|
4420
|
-
async function classifyMergeFailure(cwd, url, merged) {
|
|
4421
|
-
// No process at all, or the host was unreachable.
|
|
4422
|
-
// error as code -1, which covers "gh is not installed" as well.
|
|
4548
|
+
async function classifyMergeFailure(cwd, url, merged, adapter) {
|
|
4549
|
+
// No process at all, or the host was unreachable. The CLI runner reports a
|
|
4550
|
+
// spawn error as code -1, which covers "gh/glab is not installed" as well.
|
|
4423
4551
|
if (merged.code === -1 || /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|could not resolve host|network is unreachable/i
|
|
4424
4552
|
.test(`${merged.err} ${merged.out}`)) {
|
|
4425
4553
|
return 'UNREACHABLE';
|
|
@@ -4429,24 +4557,10 @@ async function classifyMergeFailure(cwd, url, merged) {
|
|
|
4429
4557
|
return 'NOT_PERMITTED';
|
|
4430
4558
|
}
|
|
4431
4559
|
|
|
4432
|
-
//
|
|
4433
|
-
//
|
|
4434
|
-
//
|
|
4435
|
-
|
|
4436
|
-
if (asked.code === 0) {
|
|
4437
|
-
try {
|
|
4438
|
-
const view = JSON.parse(asked.out || '{}');
|
|
4439
|
-
if (view.mergeable === 'CONFLICTING' || view.mergeStateStatus === 'DIRTY') {
|
|
4440
|
-
return 'CONFLICT';
|
|
4441
|
-
}
|
|
4442
|
-
if (view.mergeStateStatus === 'BLOCKED') {
|
|
4443
|
-
return 'NOT_PERMITTED';
|
|
4444
|
-
}
|
|
4445
|
-
} catch {
|
|
4446
|
-
// gh answered with something that is not JSON. Nothing to read.
|
|
4447
|
-
}
|
|
4448
|
-
}
|
|
4449
|
-
return 'OTHER';
|
|
4560
|
+
// Asked of the host's own CLI (R379: through the adapter). A failure to
|
|
4561
|
+
// answer is not an answer, so it falls through to OTHER rather than
|
|
4562
|
+
// inventing one.
|
|
4563
|
+
return (await adapter.refusal(cwd, url)) ?? 'OTHER';
|
|
4450
4564
|
}
|
|
4451
4565
|
|
|
4452
4566
|
/**
|
|
@@ -4744,7 +4858,7 @@ async function checkRunMerges(config, pending, fetched) {
|
|
|
4744
4858
|
}
|
|
4745
4859
|
// Same argument as the git survey: read a checkout nobody is working in.
|
|
4746
4860
|
const heldNow = heldWorkspaces();
|
|
4747
|
-
const cwd =
|
|
4861
|
+
const cwd = (await usableWorkspace(project, heldNow, new Map()))
|
|
4748
4862
|
?? resolve(project.path);
|
|
4749
4863
|
|
|
4750
4864
|
try {
|
|
@@ -4806,7 +4920,7 @@ async function checkWorkItemMerges(config, fetched) {
|
|
|
4806
4920
|
continue;
|
|
4807
4921
|
}
|
|
4808
4922
|
const heldNow = heldWorkspaces();
|
|
4809
|
-
const cwd =
|
|
4923
|
+
const cwd = (await usableWorkspace(project, heldNow, new Map()))
|
|
4810
4924
|
?? resolve(project.path);
|
|
4811
4925
|
try {
|
|
4812
4926
|
await access(join(cwd, '.git'));
|
|
@@ -4856,7 +4970,7 @@ async function reportCommits(config, run, cwd, base) {
|
|
|
4856
4970
|
// Only worth looking for a PR once something has been pushed: a branch that
|
|
4857
4971
|
// has never left the machine cannot have one.
|
|
4858
4972
|
const prUrl = pushState === 'PUSHED' || pushState === 'AHEAD'
|
|
4859
|
-
? await findPullRequest(cwd, run.branch).catch(() => null)
|
|
4973
|
+
? await findPullRequest(cwd, run.branch, { declared: run.gitHost ?? null, base: run.baseBranch ?? null }).catch(() => null)
|
|
4860
4974
|
: null;
|
|
4861
4975
|
|
|
4862
4976
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/commits`, {
|
|
@@ -5406,7 +5520,9 @@ const ASK_DONT_STOP = 'If a decision is genuinely the person\'s — a scope the
|
|
|
5406
5520
|
*/
|
|
5407
5521
|
const WAIT_FOR_COMMANDS = 'This stage ends when your turn ends: run every command in the '
|
|
5408
5522
|
+ 'foreground and wait for it to finish — a build or a test suite left running in the '
|
|
5409
|
-
+ 'background is lost, and the stage is run again.'
|
|
5523
|
+
+ 'background is lost, and the stage is run again. The same goes for delegating: call '
|
|
5524
|
+
+ '`Agent` with `run_in_background: false` and wait for its answer — an agent still '
|
|
5525
|
+
+ 'working when your turn ends has its work lost, and the stage is run again.';
|
|
5410
5526
|
|
|
5411
5527
|
/**
|
|
5412
5528
|
* @param hasPlan whether the card has a plan of record — the same test
|
|
@@ -6277,7 +6393,7 @@ function heldWorkspaces() {
|
|
|
6277
6393
|
* they read refs, never the tree, so which checkout they pick is a different
|
|
6278
6394
|
* question and dirtiness does not bear on it.
|
|
6279
6395
|
*/
|
|
6280
|
-
async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0) {
|
|
6396
|
+
async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0, problems = new Map()) {
|
|
6281
6397
|
const pinned = offered.workspace ?? null;
|
|
6282
6398
|
if (pinned) {
|
|
6283
6399
|
if (!project.workspaces.includes(pinned)) {
|
|
@@ -6286,6 +6402,15 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0)
|
|
|
6286
6402
|
// thing to say out loud rather than quietly ignore.
|
|
6287
6403
|
return { workspace: null, why: `its branch is in ${pinned}, which this machine does not serve` };
|
|
6288
6404
|
}
|
|
6405
|
+
// Before the busy check, so the reason is the folder's own sentence and not
|
|
6406
|
+
// prepareWorkingCopy's raw error.
|
|
6407
|
+
const trouble = await problemOf(problems, pinned);
|
|
6408
|
+
if (trouble) {
|
|
6409
|
+
return {
|
|
6410
|
+
workspace: null,
|
|
6411
|
+
why: workspaceProblemSaid(slug, trouble, { first: project.workspaces[0] }),
|
|
6412
|
+
};
|
|
6413
|
+
}
|
|
6289
6414
|
if (held.has(pinned)) {
|
|
6290
6415
|
return {
|
|
6291
6416
|
workspace: null,
|
|
@@ -6297,7 +6422,8 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0)
|
|
|
6297
6422
|
|
|
6298
6423
|
const waitForClean = offered.waitForClean === true;
|
|
6299
6424
|
const preferred = offered.preferredWorkspace ?? null;
|
|
6300
|
-
if (preferred && project.workspaces.includes(preferred) && !held.has(preferred)
|
|
6425
|
+
if (preferred && project.workspaces.includes(preferred) && !held.has(preferred)
|
|
6426
|
+
&& !(await problemOf(problems, preferred))) {
|
|
6301
6427
|
if (!waitForClean) {
|
|
6302
6428
|
return { workspace: preferred, why: null };
|
|
6303
6429
|
}
|
|
@@ -6305,13 +6431,29 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0)
|
|
|
6305
6431
|
if (probed.get(preferred) === true) return { workspace: preferred, why: null };
|
|
6306
6432
|
}
|
|
6307
6433
|
|
|
6308
|
-
const
|
|
6434
|
+
const broken = [];
|
|
6435
|
+
for (const path of project.workspaces) {
|
|
6436
|
+
const trouble = await problemOf(problems, path);
|
|
6437
|
+
if (trouble) broken.push(trouble);
|
|
6438
|
+
}
|
|
6439
|
+
const first = project.workspaces[0];
|
|
6440
|
+
const usable = project.workspaces.filter((path) => !broken.some((each) => each.path === path));
|
|
6441
|
+
if (!usable.length && broken.length) {
|
|
6442
|
+
// Nothing here can be used at all — said before "all busy", and without
|
|
6443
|
+
// trying to clone: `createAutoWorkspace` clones FROM the first workspace.
|
|
6444
|
+
return { workspace: null, why: workspaceProblemSaid(slug, broken[0], { first }) };
|
|
6445
|
+
}
|
|
6446
|
+
const free = usable.filter((path) => !held.has(path));
|
|
6309
6447
|
if (!free.length) {
|
|
6310
6448
|
// Every checkout is busy. Where the machine allows it, make one more
|
|
6311
6449
|
// rather than wait — the second coding session on a project starting at
|
|
6312
6450
|
// once is the whole point of `autoWorkspaces`.
|
|
6313
|
-
|
|
6314
|
-
const made =
|
|
6451
|
+
// A deleted clone must not use up the quota until `syncAutoWorkspaces` runs.
|
|
6452
|
+
const made = usable.filter((path) => isAutoWorkspace(first, path));
|
|
6453
|
+
const firstTrouble = broken.find((each) => each.path === first);
|
|
6454
|
+
if (firstTrouble && made.length < autoMax) {
|
|
6455
|
+
return { workspace: null, why: workspaceProblemSaid(slug, firstTrouble, { first }) };
|
|
6456
|
+
}
|
|
6315
6457
|
if (made.length < autoMax) {
|
|
6316
6458
|
try {
|
|
6317
6459
|
log(`every checkout of ${slug} is busy — cloning another beside ${first}`);
|
|
@@ -6433,12 +6575,12 @@ function snapshotRuns() {
|
|
|
6433
6575
|
return runs;
|
|
6434
6576
|
}
|
|
6435
6577
|
|
|
6436
|
-
async function startRun(config, offered, workspace) {
|
|
6578
|
+
async function startRun(config, offered, workspace, source = null) {
|
|
6437
6579
|
const run = offered.run;
|
|
6438
6580
|
const project = config.projects[run.projectSlug];
|
|
6439
6581
|
// The workspace this run was given, or — for a run that takes none — the
|
|
6440
6582
|
// first, which is where a question about the project gets read from.
|
|
6441
|
-
const path = workspace ?? project?.path;
|
|
6583
|
+
const path = workspace ?? source ?? project?.path;
|
|
6442
6584
|
let runToken;
|
|
6443
6585
|
let defaultBranch;
|
|
6444
6586
|
let allowDirty = false;
|
|
@@ -6509,6 +6651,8 @@ async function startRun(config, offered, workspace) {
|
|
|
6509
6651
|
// a reset, the code map and the brief read. Null on every run until a
|
|
6510
6652
|
// sprint has a branch — and then everything below is what it was.
|
|
6511
6653
|
run.baseBranch = claimed.baseBranch ?? null;
|
|
6654
|
+
// R379. The project's declared git host (GITHUB/GITLAB), or null to detect it.
|
|
6655
|
+
run.gitHost = claimed.gitHost ?? null;
|
|
6512
6656
|
if (run.baseBranch) {
|
|
6513
6657
|
log(` cut from ${run.baseBranch} (the sprint's branch)`);
|
|
6514
6658
|
}
|
|
@@ -6594,6 +6738,16 @@ async function startRun(config, offered, workspace) {
|
|
|
6594
6738
|
let cwd = path ? resolve(path) : path;
|
|
6595
6739
|
let reader = null;
|
|
6596
6740
|
try {
|
|
6741
|
+
// R377. The folder was fine when the run was offered; it may not be now.
|
|
6742
|
+
// Checked before anything is prepared, so the run says what is wrong with
|
|
6743
|
+
// the folder instead of `is not a git repository` or `spawn claude ENOENT`.
|
|
6744
|
+
// One check covers every walked stage, which reuse this `cwd`.
|
|
6745
|
+
const trouble = path ? await workspaceProblem(resolve(path)) : null;
|
|
6746
|
+
if (trouble && !(trouble.kind === 'not-git' && !writesCodeProfile(run))) {
|
|
6747
|
+
throw new Error(workspaceProblemSaid(run.projectSlug, trouble, {
|
|
6748
|
+
first: project?.workspaces?.[0] ?? null,
|
|
6749
|
+
}));
|
|
6750
|
+
}
|
|
6597
6751
|
let branch = run.branch;
|
|
6598
6752
|
if (!writesCodeProfile(run)) {
|
|
6599
6753
|
// Nothing is prepared. It cuts no branch, and a dirty tree does not stop
|
|
@@ -6825,9 +6979,10 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6825
6979
|
if (before?.state === 'FAILED' && before.outcome) {
|
|
6826
6980
|
// The attempt that stopped, handed to the one about to run.
|
|
6827
6981
|
carried[`${stage.stage} (the attempt that stopped)`] = `${before.outcome}\n\n`
|
|
6828
|
-
+ 'This stage is being run again to finish that. Run the commands
|
|
6829
|
-
+ 'again IN THE FOREGROUND — not with run_in_background — wait for
|
|
6830
|
-
+ 'and read its output, then finish the stage properly.'
|
|
6982
|
+
+ 'This stage is being run again to finish that. Run the commands — and the agents — '
|
|
6983
|
+
+ 'it left running again IN THE FOREGROUND — not with run_in_background — wait for '
|
|
6984
|
+
+ 'each one to end and read its output, then finish the stage properly. Check the '
|
|
6985
|
+
+ 'checkout first: a background agent may have left part of its work there.';
|
|
6831
6986
|
}
|
|
6832
6987
|
// R305. What THIS stage's processes have used, across its attempts. Outside
|
|
6833
6988
|
// the attempt loop on purpose: `run_stage` is one row per stage and keeps
|
|
@@ -6945,7 +7100,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6945
7100
|
// /continue picks the run up HERE — the stages behind it stay pending.
|
|
6946
7101
|
if (last?.unfinishedShells?.length) {
|
|
6947
7102
|
const listed = last.unfinishedShells.map((command) => `\`${command.slice(0, 120)}\``).join(', ');
|
|
6948
|
-
const why = `The ${stage.stage} stage ended with ${last.unfinishedShells.length} command(s) `
|
|
7103
|
+
const why = `The ${stage.stage} stage ended with ${last.unfinishedShells.length} command(s) or agent(s) `
|
|
6949
7104
|
+ `still running in the background, or finished and never read: ${listed}. `
|
|
6950
7105
|
+ 'Nothing they proved was recorded — /continue runs this stage again and waits for them.';
|
|
6951
7106
|
log(` ${why}`);
|
|
@@ -8118,7 +8273,12 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
8118
8273
|
prompts.stop();
|
|
8119
8274
|
workingCopy.stop();
|
|
8120
8275
|
await transcript.flush();
|
|
8121
|
-
|
|
8276
|
+
// Under a memory ceiling (`sessionScope`) the child is `systemd-run`, so a
|
|
8277
|
+
// missing `claude` arrives as its exit code and never as an ENOENT here.
|
|
8278
|
+
await finish(config, run, 'FAILED', spawnFailureSaid({
|
|
8279
|
+
failure, command: agentCmd, cwd, slug: run.projectSlug, label: 'the agent',
|
|
8280
|
+
first: config.projects[run.projectSlug]?.workspaces?.[0],
|
|
8281
|
+
}));
|
|
8122
8282
|
await rm(mcpDirectory, { recursive: true, force: true });
|
|
8123
8283
|
clearTimeout(idleTimer);
|
|
8124
8284
|
// A spawn that never happened has no exit code. The run is already
|
|
@@ -8788,7 +8948,10 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8788
8948
|
child.on('error', async (failure) => {
|
|
8789
8949
|
workingCopy.stop();
|
|
8790
8950
|
running.delete(run.id);
|
|
8791
|
-
const summary =
|
|
8951
|
+
const summary = spawnFailureSaid({
|
|
8952
|
+
failure, command: agyCmd, cwd, slug: run.projectSlug, label: 'Antigravity',
|
|
8953
|
+
first: config.projects[run.projectSlug]?.workspaces?.[0],
|
|
8954
|
+
});
|
|
8792
8955
|
await transcript.flush();
|
|
8793
8956
|
await finish(config, run, 'FAILED', summary);
|
|
8794
8957
|
resolvePromise(
|
|
@@ -9106,7 +9269,10 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
9106
9269
|
child.on('error', async (failure) => {
|
|
9107
9270
|
workingCopy.stop();
|
|
9108
9271
|
running.delete(run.id);
|
|
9109
|
-
const summary =
|
|
9272
|
+
const summary = spawnFailureSaid({
|
|
9273
|
+
failure, command: codexCmd, cwd, slug: run.projectSlug, label: 'Codex',
|
|
9274
|
+
first: config.projects[run.projectSlug]?.workspaces?.[0],
|
|
9275
|
+
});
|
|
9110
9276
|
await transcript.flush();
|
|
9111
9277
|
await finish(config, run, 'FAILED', summary);
|
|
9112
9278
|
resolvePromise(stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined);
|
|
@@ -9390,6 +9556,12 @@ function capabilities(config) {
|
|
|
9390
9556
|
// that, so the platform tells it apart rather than letting one behave
|
|
9391
9557
|
// like the other.
|
|
9392
9558
|
waitForClean: true,
|
|
9559
|
+
// R379. This daemon knows `CREATE_BRANCH` (a branch put on origin, cut from
|
|
9560
|
+
// another branch) and can open, find and merge a GitLab merge request with
|
|
9561
|
+
// `glab`. An older daemon would fail the unknown kind, so the platform
|
|
9562
|
+
// hands the request only to a daemon that says so.
|
|
9563
|
+
createBranch: true,
|
|
9564
|
+
gitHosts: ['github', 'gitlab'],
|
|
9393
9565
|
});
|
|
9394
9566
|
}
|
|
9395
9567
|
|
|
@@ -9472,6 +9644,17 @@ async function main() {
|
|
|
9472
9644
|
// right way.
|
|
9473
9645
|
quiet = attaching;
|
|
9474
9646
|
|
|
9647
|
+
// A SIGHUP that arrives while booting — `cawdev config` run a moment after
|
|
9648
|
+
// the daemon started — is held, not fatal: Node's default for an unhandled
|
|
9649
|
+
// SIGHUP is to exit, and the real handler is only installed once
|
|
9650
|
+
// `rereadConfig` exists, well after the first lines are logged. It is
|
|
9651
|
+
// applied as soon as that handler is in place.
|
|
9652
|
+
let hupWhileBooting = false;
|
|
9653
|
+
const holdHup = () => {
|
|
9654
|
+
hupWhileBooting = true;
|
|
9655
|
+
};
|
|
9656
|
+
process.on('SIGHUP', holdHup);
|
|
9657
|
+
|
|
9475
9658
|
const config = await readConfig();
|
|
9476
9659
|
// The checkouts made on demand by an earlier run of this daemon, found
|
|
9477
9660
|
// again by their name — nothing about them is in the config.
|
|
@@ -9541,6 +9724,7 @@ async function main() {
|
|
|
9541
9724
|
log(`quality: ${slug} scans with ${project.quality.command} after TEST, token from $${project.quality.tokenEnv}`);
|
|
9542
9725
|
}
|
|
9543
9726
|
}
|
|
9727
|
+
await sayWhichWorkspacesCannotBeUsed(config);
|
|
9544
9728
|
await sayWhichServedProjectsTheTokenCannotReach(config);
|
|
9545
9729
|
|
|
9546
9730
|
// Frozen here, before anything can be claimed. A run that checks this very
|
|
@@ -9673,6 +9857,9 @@ async function main() {
|
|
|
9673
9857
|
// A checkout made on demand may have been dropped — the attached
|
|
9674
9858
|
// terminal's `/drop` deletes the directory and sends this signal.
|
|
9675
9859
|
await syncAutoWorkspaces(config.projects, heldWorkspaces());
|
|
9860
|
+
if (await sayWhichWorkspacesCannotBeUsed(config)) {
|
|
9861
|
+
log(' (the project list itself is read at start — restart the daemon after changing "workspaces")');
|
|
9862
|
+
}
|
|
9676
9863
|
Object.assign(config, resolved);
|
|
9677
9864
|
// R288: the hello describes the current state, not the booted one — an
|
|
9678
9865
|
// attach opened after a toggle would otherwise offer to turn on what is
|
|
@@ -9694,7 +9881,11 @@ async function main() {
|
|
|
9694
9881
|
// event type it does not know.
|
|
9695
9882
|
control?.publish({ type: 'runner', runner: advertised });
|
|
9696
9883
|
};
|
|
9884
|
+
process.off('SIGHUP', holdHup);
|
|
9697
9885
|
process.on('SIGHUP', () => rereadConfig('SIGHUP'));
|
|
9886
|
+
if (hupWhileBooting) {
|
|
9887
|
+
void rereadConfig('SIGHUP (sent while booting)');
|
|
9888
|
+
}
|
|
9698
9889
|
|
|
9699
9890
|
// The heartbeat's half of R294: the file's mtime, read once per beat, and a
|
|
9700
9891
|
// re-read when it moved. `stat` and nothing more when it did not, which is
|
|
@@ -9996,6 +10187,7 @@ async function main() {
|
|
|
9996
10187
|
// Several offers of one project ask `looksClean` on a shared checkout
|
|
9997
10188
|
// only once.
|
|
9998
10189
|
const probed = new Map();
|
|
10190
|
+
const problems = new Map();
|
|
9999
10191
|
|
|
10000
10192
|
let claimable = 0;
|
|
10001
10193
|
let skipped = 0;
|
|
@@ -10063,15 +10255,30 @@ async function main() {
|
|
|
10063
10255
|
// adding to `told.heldWorkspaces` mid-loop — is the same window that
|
|
10064
10256
|
// already exists between this poll and `startRun`'s own claim.
|
|
10065
10257
|
let workspace = null;
|
|
10258
|
+
let source = null;
|
|
10066
10259
|
if (writes) {
|
|
10067
10260
|
const picked = await pickWorkspace(slug, config.projects[slug], offered, held, probed,
|
|
10068
|
-
config.autoWorkspaces);
|
|
10261
|
+
config.autoWorkspaces, problems);
|
|
10069
10262
|
if (picked.why) {
|
|
10070
10263
|
noteQueued(offered.run, picked.why);
|
|
10071
10264
|
skipped += 1;
|
|
10072
10265
|
continue;
|
|
10073
10266
|
}
|
|
10074
10267
|
workspace = picked.workspace;
|
|
10268
|
+
} else {
|
|
10269
|
+
// Reads from a usable folder, and holds none: the source goes to
|
|
10270
|
+
// `startRun` on its own and never into `held` or `taken` (R70).
|
|
10271
|
+
const project = config.projects[slug];
|
|
10272
|
+
source = await usableWorkspace(project, new Set(), problems, { reading: true });
|
|
10273
|
+
if (!source) {
|
|
10274
|
+
const first = project.workspaces[0];
|
|
10275
|
+
const trouble = await problemOf(problems, first);
|
|
10276
|
+
noteQueued(offered.run, trouble
|
|
10277
|
+
? workspaceProblemSaid(slug, trouble, { first })
|
|
10278
|
+
: `${slug} has no workspace on this machine`);
|
|
10279
|
+
skipped += 1;
|
|
10280
|
+
continue;
|
|
10281
|
+
}
|
|
10075
10282
|
}
|
|
10076
10283
|
if (workspace) {
|
|
10077
10284
|
held.add(workspace);
|
|
@@ -10084,7 +10291,7 @@ async function main() {
|
|
|
10084
10291
|
});
|
|
10085
10292
|
noted.delete(offered.run.id);
|
|
10086
10293
|
claimable += 1;
|
|
10087
|
-
void startRun(config, offered, workspace);
|
|
10294
|
+
void startRun(config, offered, workspace, source);
|
|
10088
10295
|
}
|
|
10089
10296
|
|
|
10090
10297
|
// Forget runs that are no longer offered, so one that comes back around
|