cawdev-cli 1.0.10-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 +10 -0
- package/mcp/README.md +3 -2
- package/mcp/server.mjs +19 -3
- package/package.json +1 -1
- package/runner/README.md +7 -2
- package/runner/attach.mjs +19 -8
- package/runner/git-host.mjs +263 -0
- package/runner/runner.mjs +308 -117
- package/runner/split.mjs +45 -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';
|
|
@@ -1987,6 +1989,16 @@ async function surveyWorkingCopies(config) {
|
|
|
1987
1989
|
for (const [slug, project] of Object.entries(config.projects)) {
|
|
1988
1990
|
for (const path of project.workspaces) {
|
|
1989
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
|
+
}
|
|
1990
2002
|
try {
|
|
1991
2003
|
await access(join(path, '.git'));
|
|
1992
2004
|
const porcelain = await git(path, ['status', '--porcelain']);
|
|
@@ -2038,6 +2050,52 @@ async function looksClean(path) {
|
|
|
2038
2050
|
return porcelain === null ? null : porcelain === '';
|
|
2039
2051
|
}
|
|
2040
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
|
+
|
|
2041
2099
|
/**
|
|
2042
2100
|
* What every checkout on this machine holds — R87.
|
|
2043
2101
|
*
|
|
@@ -2059,6 +2117,15 @@ async function surveyWorkspaces(config) {
|
|
|
2059
2117
|
const readings = [];
|
|
2060
2118
|
for (const [slug, project] of Object.entries(config.projects)) {
|
|
2061
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
|
+
}
|
|
2062
2129
|
try {
|
|
2063
2130
|
await access(join(path, '.git'));
|
|
2064
2131
|
const branch = (await git(path, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
@@ -2415,9 +2482,10 @@ async function surveyGit(config) {
|
|
|
2415
2482
|
// session's branch with its work uncommitted. Reading the pool's spare
|
|
2416
2483
|
// gives the honest picture of the default branch instead.
|
|
2417
2484
|
const held = heldWorkspaces();
|
|
2418
|
-
const free = project
|
|
2485
|
+
const free = await usableWorkspace(project, held, new Map());
|
|
2419
2486
|
const path = free ?? resolve(project.path);
|
|
2420
|
-
|
|
2487
|
+
// Busy means no free USABLE workspace.
|
|
2488
|
+
const busy = free === null;
|
|
2421
2489
|
|
|
2422
2490
|
const reading = await surveyProjectGit(path, { fetch: !busy }).catch((failure) => ({
|
|
2423
2491
|
error: failure.message.split('\n')[0],
|
|
@@ -3398,7 +3466,7 @@ async function perform(config, run, cwd, action) {
|
|
|
3398
3466
|
({ ok, result } = await openPullRequest(cwd, run, action.message));
|
|
3399
3467
|
} else if (action.kind === 'MERGE') {
|
|
3400
3468
|
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));
|
|
3469
|
+
({ ok, result } = await mergePullRequest(cwd, run.branch, { declared: run.gitHost ?? null }));
|
|
3402
3470
|
} else {
|
|
3403
3471
|
result = `This runner does not know how to ${action.kind}.`;
|
|
3404
3472
|
}
|
|
@@ -3685,6 +3753,7 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3685
3753
|
// squashes as it always did. The claim says which.
|
|
3686
3754
|
({ ok, result, failure: failureKind } = await mergePullRequest(cwd, branch, {
|
|
3687
3755
|
method: request.mergeMethod ?? null,
|
|
3756
|
+
declared: request.gitHost ?? null,
|
|
3688
3757
|
}));
|
|
3689
3758
|
}
|
|
3690
3759
|
} else if (request.kind === 'TAG') {
|
|
@@ -3726,6 +3795,7 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3726
3795
|
result = pushed.failed;
|
|
3727
3796
|
} else {
|
|
3728
3797
|
({ ok, result } = await openPullRequestFor(cwd, {
|
|
3798
|
+
declared: request.gitHost ?? null,
|
|
3729
3799
|
branch,
|
|
3730
3800
|
base,
|
|
3731
3801
|
title: request.title?.trim() || branch,
|
|
@@ -3754,6 +3824,19 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3754
3824
|
log(` cutting ${name} on origin from ${request.defaultBranch || 'main'}`);
|
|
3755
3825
|
({ ok, result } = await cutBranchOnOrigin(cwd, name, request.defaultBranch));
|
|
3756
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
|
+
}
|
|
3757
3840
|
} else {
|
|
3758
3841
|
result = `This runner does not know how to ${request.kind}.`;
|
|
3759
3842
|
}
|
|
@@ -3920,27 +4003,30 @@ async function readPushState(cwd, branch) {
|
|
|
3920
4003
|
* already holds the credentials, so the runner answers and the platform never
|
|
3921
4004
|
* needs a token of its own.
|
|
3922
4005
|
*/
|
|
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;
|
|
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;
|
|
3937
4011
|
}
|
|
3938
4012
|
|
|
3939
|
-
// No
|
|
4013
|
+
// No CLI, or no PR yet. A compare URL is honest: it is where you would go to
|
|
3940
4014
|
// open one, and it is obviously not a claim that one exists.
|
|
3941
|
-
const
|
|
3942
|
-
const
|
|
3943
|
-
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 };
|
|
3944
4030
|
}
|
|
3945
4031
|
|
|
3946
4032
|
// --- what a project's rules asked for (R40) ----------------------------------
|
|
@@ -3989,25 +4075,6 @@ async function firstRemote(cwd) {
|
|
|
3989
4075
|
return remotes.split('\n')[0]?.trim() || null;
|
|
3990
4076
|
}
|
|
3991
4077
|
|
|
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
4078
|
|
|
4012
4079
|
/**
|
|
4013
4080
|
* The branch, on the remote.
|
|
@@ -4087,6 +4154,7 @@ async function openPullRequest(cwd, run, title) {
|
|
|
4087
4154
|
}
|
|
4088
4155
|
|
|
4089
4156
|
return openPullRequestFor(cwd, {
|
|
4157
|
+
declared: run.gitHost ?? null,
|
|
4090
4158
|
branch: run.branch,
|
|
4091
4159
|
// R260. The sprint's branch, when the claim carried one; otherwise the
|
|
4092
4160
|
// argv is byte for byte what it was and the host picks its own default.
|
|
@@ -4157,44 +4225,40 @@ async function pushIfOriginLacks(cwd, branch) {
|
|
|
4157
4225
|
}
|
|
4158
4226
|
}
|
|
4159
4227
|
|
|
4160
|
-
async function openPullRequestFor(cwd, { branch, base, title, body }) {
|
|
4161
|
-
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);
|
|
4162
4231
|
if (existing && !existing.includes('/compare/')) {
|
|
4163
4232
|
return { ok: true, result: existing };
|
|
4164
4233
|
}
|
|
4165
4234
|
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
'--head', branch,
|
|
4169
|
-
...(base ? ['--base', base] : []),
|
|
4170
|
-
'--title', title,
|
|
4171
|
-
'--body', body,
|
|
4172
|
-
]);
|
|
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 });
|
|
4173
4237
|
if (opened.code === 0 && opened.out) {
|
|
4174
|
-
//
|
|
4175
|
-
//
|
|
4176
|
-
//
|
|
4177
|
-
const printed =
|
|
4178
|
-
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);
|
|
4179
4243
|
if (confirmed && !confirmed.includes('/compare/')) {
|
|
4180
4244
|
return { ok: true, result: confirmed };
|
|
4181
4245
|
}
|
|
4182
4246
|
return {
|
|
4183
4247
|
ok: false,
|
|
4184
|
-
result:
|
|
4248
|
+
result: `${adapter.bin} said it opened ${printed}, but the host does not show a ${adapter.noun} for ${branch}.`,
|
|
4185
4249
|
};
|
|
4186
4250
|
}
|
|
4187
4251
|
// 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
|
|
4252
|
+
// link to open it by hand meanwhile, which `findPullRequest` builds without the CLI.
|
|
4189
4253
|
if (opened.code === -1 && /not installed/.test(opened.err)) {
|
|
4190
|
-
const compare = await findPullRequest(cwd, branch).catch(() => null);
|
|
4254
|
+
const compare = await findPullRequest(cwd, branch, { declared, base }).catch(() => null);
|
|
4191
4255
|
return {
|
|
4192
4256
|
ok: false,
|
|
4193
|
-
result: `${opened.err} Install it and sign in (
|
|
4257
|
+
result: `${opened.err} Install it and sign in (${adapter.installHint}), `
|
|
4194
4258
|
+ `then ask again${compare ? ` — or open it yourself: ${compare}` : '.'}`,
|
|
4195
4259
|
};
|
|
4196
4260
|
}
|
|
4197
|
-
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.` };
|
|
4198
4262
|
}
|
|
4199
4263
|
|
|
4200
4264
|
/**
|
|
@@ -4245,18 +4309,27 @@ function mergeabilityWaitMs() {
|
|
|
4245
4309
|
return Number.isFinite(configured) && configured > 0 ? configured : 5000;
|
|
4246
4310
|
}
|
|
4247
4311
|
|
|
4248
|
-
async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
4249
|
-
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);
|
|
4250
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
|
+
}
|
|
4251
4324
|
return {
|
|
4252
4325
|
ok: false,
|
|
4253
4326
|
failure: 'NO_PULL_REQUEST',
|
|
4254
4327
|
result: 'There is no pull request for this branch to merge. Nothing was merged.',
|
|
4255
4328
|
};
|
|
4256
4329
|
}
|
|
4257
|
-
await waitOutUnknownMergeability(cwd, url, branch);
|
|
4330
|
+
await waitOutUnknownMergeability(cwd, url, branch, adapter);
|
|
4258
4331
|
const mergeCommit = method === 'MERGE_COMMIT';
|
|
4259
|
-
const merged = await
|
|
4332
|
+
const merged = await adapter.merge(cwd, url, { mergeCommit });
|
|
4260
4333
|
if (merged.code === 0) {
|
|
4261
4334
|
// The URL on its own FIRST LINE — R134. The platform reads that line as the
|
|
4262
4335
|
// evidence a merge happened and puts it on the card as the card's `merge`
|
|
@@ -4273,10 +4346,10 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
4273
4346
|
ok: false,
|
|
4274
4347
|
// R155. WHICH kind of failure, so the console can offer Merge with an agent
|
|
4275
4348
|
// on the one an agent can fix and stay quiet on the three it cannot.
|
|
4276
|
-
failure: await classifyMergeFailure(cwd, url, merged),
|
|
4349
|
+
failure: await classifyMergeFailure(cwd, url, merged, adapter),
|
|
4277
4350
|
// Unchanged: `gh`'s own words still reach the card. The classification is a
|
|
4278
4351
|
// word beside them, never a replacement for them.
|
|
4279
|
-
result: merged.err || merged.out || '
|
|
4352
|
+
result: merged.err || merged.out || `${adapter.bin} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} merge failed without saying why.`,
|
|
4280
4353
|
};
|
|
4281
4354
|
}
|
|
4282
4355
|
|
|
@@ -4285,19 +4358,12 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
4285
4358
|
* bounded. See `mergePullRequest`. One log line if it waited at all, so a
|
|
4286
4359
|
* merge that took thirty seconds says why.
|
|
4287
4360
|
*/
|
|
4288
|
-
async function waitOutUnknownMergeability(cwd, url, branch) {
|
|
4361
|
+
async function waitOutUnknownMergeability(cwd, url, branch, adapter) {
|
|
4289
4362
|
let waited = 0;
|
|
4290
4363
|
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') {
|
|
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') {
|
|
4301
4367
|
if (waited) log(` ${branch}: mergeability known after ${waited}s`);
|
|
4302
4368
|
return;
|
|
4303
4369
|
}
|
|
@@ -4401,6 +4467,67 @@ async function cutBranchOnOrigin(cwd, name, defaultBranch) {
|
|
|
4401
4467
|
return { ok: true, result: `${tip}\n${name} cut from ${from} at ${tip.slice(0, 7)}.` };
|
|
4402
4468
|
}
|
|
4403
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
|
+
|
|
4404
4531
|
/**
|
|
4405
4532
|
* Why `gh pr merge` said no — R155, in one of five words.
|
|
4406
4533
|
*
|
|
@@ -4417,9 +4544,9 @@ async function cutBranchOnOrigin(cwd, name, defaultBranch) {
|
|
|
4417
4544
|
* as OTHER, and the console offers the action for OTHER for the same reason it
|
|
4418
4545
|
* offers it for a null — the cost of being wrong is one `git fetch`.
|
|
4419
4546
|
*/
|
|
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.
|
|
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.
|
|
4423
4550
|
if (merged.code === -1 || /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|could not resolve host|network is unreachable/i
|
|
4424
4551
|
.test(`${merged.err} ${merged.out}`)) {
|
|
4425
4552
|
return 'UNREACHABLE';
|
|
@@ -4429,24 +4556,10 @@ async function classifyMergeFailure(cwd, url, merged) {
|
|
|
4429
4556
|
return 'NOT_PERMITTED';
|
|
4430
4557
|
}
|
|
4431
4558
|
|
|
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';
|
|
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';
|
|
4450
4563
|
}
|
|
4451
4564
|
|
|
4452
4565
|
/**
|
|
@@ -4744,7 +4857,7 @@ async function checkRunMerges(config, pending, fetched) {
|
|
|
4744
4857
|
}
|
|
4745
4858
|
// Same argument as the git survey: read a checkout nobody is working in.
|
|
4746
4859
|
const heldNow = heldWorkspaces();
|
|
4747
|
-
const cwd =
|
|
4860
|
+
const cwd = (await usableWorkspace(project, heldNow, new Map()))
|
|
4748
4861
|
?? resolve(project.path);
|
|
4749
4862
|
|
|
4750
4863
|
try {
|
|
@@ -4806,7 +4919,7 @@ async function checkWorkItemMerges(config, fetched) {
|
|
|
4806
4919
|
continue;
|
|
4807
4920
|
}
|
|
4808
4921
|
const heldNow = heldWorkspaces();
|
|
4809
|
-
const cwd =
|
|
4922
|
+
const cwd = (await usableWorkspace(project, heldNow, new Map()))
|
|
4810
4923
|
?? resolve(project.path);
|
|
4811
4924
|
try {
|
|
4812
4925
|
await access(join(cwd, '.git'));
|
|
@@ -4856,7 +4969,7 @@ async function reportCommits(config, run, cwd, base) {
|
|
|
4856
4969
|
// Only worth looking for a PR once something has been pushed: a branch that
|
|
4857
4970
|
// has never left the machine cannot have one.
|
|
4858
4971
|
const prUrl = pushState === 'PUSHED' || pushState === 'AHEAD'
|
|
4859
|
-
? await findPullRequest(cwd, run.branch).catch(() => null)
|
|
4972
|
+
? await findPullRequest(cwd, run.branch, { declared: run.gitHost ?? null, base: run.baseBranch ?? null }).catch(() => null)
|
|
4860
4973
|
: null;
|
|
4861
4974
|
|
|
4862
4975
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/commits`, {
|
|
@@ -5406,7 +5519,9 @@ const ASK_DONT_STOP = 'If a decision is genuinely the person\'s — a scope the
|
|
|
5406
5519
|
*/
|
|
5407
5520
|
const WAIT_FOR_COMMANDS = 'This stage ends when your turn ends: run every command in the '
|
|
5408
5521
|
+ '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.'
|
|
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.';
|
|
5410
5525
|
|
|
5411
5526
|
/**
|
|
5412
5527
|
* @param hasPlan whether the card has a plan of record — the same test
|
|
@@ -6277,7 +6392,7 @@ function heldWorkspaces() {
|
|
|
6277
6392
|
* they read refs, never the tree, so which checkout they pick is a different
|
|
6278
6393
|
* question and dirtiness does not bear on it.
|
|
6279
6394
|
*/
|
|
6280
|
-
async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0) {
|
|
6395
|
+
async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0, problems = new Map()) {
|
|
6281
6396
|
const pinned = offered.workspace ?? null;
|
|
6282
6397
|
if (pinned) {
|
|
6283
6398
|
if (!project.workspaces.includes(pinned)) {
|
|
@@ -6286,6 +6401,15 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0)
|
|
|
6286
6401
|
// thing to say out loud rather than quietly ignore.
|
|
6287
6402
|
return { workspace: null, why: `its branch is in ${pinned}, which this machine does not serve` };
|
|
6288
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
|
+
}
|
|
6289
6413
|
if (held.has(pinned)) {
|
|
6290
6414
|
return {
|
|
6291
6415
|
workspace: null,
|
|
@@ -6297,7 +6421,8 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0)
|
|
|
6297
6421
|
|
|
6298
6422
|
const waitForClean = offered.waitForClean === true;
|
|
6299
6423
|
const preferred = offered.preferredWorkspace ?? null;
|
|
6300
|
-
if (preferred && project.workspaces.includes(preferred) && !held.has(preferred)
|
|
6424
|
+
if (preferred && project.workspaces.includes(preferred) && !held.has(preferred)
|
|
6425
|
+
&& !(await problemOf(problems, preferred))) {
|
|
6301
6426
|
if (!waitForClean) {
|
|
6302
6427
|
return { workspace: preferred, why: null };
|
|
6303
6428
|
}
|
|
@@ -6305,13 +6430,29 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0)
|
|
|
6305
6430
|
if (probed.get(preferred) === true) return { workspace: preferred, why: null };
|
|
6306
6431
|
}
|
|
6307
6432
|
|
|
6308
|
-
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));
|
|
6309
6446
|
if (!free.length) {
|
|
6310
6447
|
// Every checkout is busy. Where the machine allows it, make one more
|
|
6311
6448
|
// rather than wait — the second coding session on a project starting at
|
|
6312
6449
|
// once is the whole point of `autoWorkspaces`.
|
|
6313
|
-
|
|
6314
|
-
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
|
+
}
|
|
6315
6456
|
if (made.length < autoMax) {
|
|
6316
6457
|
try {
|
|
6317
6458
|
log(`every checkout of ${slug} is busy — cloning another beside ${first}`);
|
|
@@ -6433,12 +6574,12 @@ function snapshotRuns() {
|
|
|
6433
6574
|
return runs;
|
|
6434
6575
|
}
|
|
6435
6576
|
|
|
6436
|
-
async function startRun(config, offered, workspace) {
|
|
6577
|
+
async function startRun(config, offered, workspace, source = null) {
|
|
6437
6578
|
const run = offered.run;
|
|
6438
6579
|
const project = config.projects[run.projectSlug];
|
|
6439
6580
|
// The workspace this run was given, or — for a run that takes none — the
|
|
6440
6581
|
// first, which is where a question about the project gets read from.
|
|
6441
|
-
const path = workspace ?? project?.path;
|
|
6582
|
+
const path = workspace ?? source ?? project?.path;
|
|
6442
6583
|
let runToken;
|
|
6443
6584
|
let defaultBranch;
|
|
6444
6585
|
let allowDirty = false;
|
|
@@ -6509,6 +6650,8 @@ async function startRun(config, offered, workspace) {
|
|
|
6509
6650
|
// a reset, the code map and the brief read. Null on every run until a
|
|
6510
6651
|
// sprint has a branch — and then everything below is what it was.
|
|
6511
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;
|
|
6512
6655
|
if (run.baseBranch) {
|
|
6513
6656
|
log(` cut from ${run.baseBranch} (the sprint's branch)`);
|
|
6514
6657
|
}
|
|
@@ -6594,6 +6737,16 @@ async function startRun(config, offered, workspace) {
|
|
|
6594
6737
|
let cwd = path ? resolve(path) : path;
|
|
6595
6738
|
let reader = null;
|
|
6596
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
|
+
}
|
|
6597
6750
|
let branch = run.branch;
|
|
6598
6751
|
if (!writesCodeProfile(run)) {
|
|
6599
6752
|
// Nothing is prepared. It cuts no branch, and a dirty tree does not stop
|
|
@@ -6825,9 +6978,10 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6825
6978
|
if (before?.state === 'FAILED' && before.outcome) {
|
|
6826
6979
|
// The attempt that stopped, handed to the one about to run.
|
|
6827
6980
|
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.'
|
|
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.';
|
|
6831
6985
|
}
|
|
6832
6986
|
// R305. What THIS stage's processes have used, across its attempts. Outside
|
|
6833
6987
|
// the attempt loop on purpose: `run_stage` is one row per stage and keeps
|
|
@@ -6945,7 +7099,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6945
7099
|
// /continue picks the run up HERE — the stages behind it stay pending.
|
|
6946
7100
|
if (last?.unfinishedShells?.length) {
|
|
6947
7101
|
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) `
|
|
7102
|
+
const why = `The ${stage.stage} stage ended with ${last.unfinishedShells.length} command(s) or agent(s) `
|
|
6949
7103
|
+ `still running in the background, or finished and never read: ${listed}. `
|
|
6950
7104
|
+ 'Nothing they proved was recorded — /continue runs this stage again and waits for them.';
|
|
6951
7105
|
log(` ${why}`);
|
|
@@ -8118,7 +8272,12 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
8118
8272
|
prompts.stop();
|
|
8119
8273
|
workingCopy.stop();
|
|
8120
8274
|
await transcript.flush();
|
|
8121
|
-
|
|
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
|
+
}));
|
|
8122
8281
|
await rm(mcpDirectory, { recursive: true, force: true });
|
|
8123
8282
|
clearTimeout(idleTimer);
|
|
8124
8283
|
// A spawn that never happened has no exit code. The run is already
|
|
@@ -8788,7 +8947,10 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8788
8947
|
child.on('error', async (failure) => {
|
|
8789
8948
|
workingCopy.stop();
|
|
8790
8949
|
running.delete(run.id);
|
|
8791
|
-
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
|
+
});
|
|
8792
8954
|
await transcript.flush();
|
|
8793
8955
|
await finish(config, run, 'FAILED', summary);
|
|
8794
8956
|
resolvePromise(
|
|
@@ -9106,7 +9268,10 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
9106
9268
|
child.on('error', async (failure) => {
|
|
9107
9269
|
workingCopy.stop();
|
|
9108
9270
|
running.delete(run.id);
|
|
9109
|
-
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
|
+
});
|
|
9110
9275
|
await transcript.flush();
|
|
9111
9276
|
await finish(config, run, 'FAILED', summary);
|
|
9112
9277
|
resolvePromise(stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined);
|
|
@@ -9390,6 +9555,12 @@ function capabilities(config) {
|
|
|
9390
9555
|
// that, so the platform tells it apart rather than letting one behave
|
|
9391
9556
|
// like the other.
|
|
9392
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'],
|
|
9393
9564
|
});
|
|
9394
9565
|
}
|
|
9395
9566
|
|
|
@@ -9541,6 +9712,7 @@ async function main() {
|
|
|
9541
9712
|
log(`quality: ${slug} scans with ${project.quality.command} after TEST, token from $${project.quality.tokenEnv}`);
|
|
9542
9713
|
}
|
|
9543
9714
|
}
|
|
9715
|
+
await sayWhichWorkspacesCannotBeUsed(config);
|
|
9544
9716
|
await sayWhichServedProjectsTheTokenCannotReach(config);
|
|
9545
9717
|
|
|
9546
9718
|
// Frozen here, before anything can be claimed. A run that checks this very
|
|
@@ -9673,6 +9845,9 @@ async function main() {
|
|
|
9673
9845
|
// A checkout made on demand may have been dropped — the attached
|
|
9674
9846
|
// terminal's `/drop` deletes the directory and sends this signal.
|
|
9675
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
|
+
}
|
|
9676
9851
|
Object.assign(config, resolved);
|
|
9677
9852
|
// R288: the hello describes the current state, not the booted one — an
|
|
9678
9853
|
// attach opened after a toggle would otherwise offer to turn on what is
|
|
@@ -9996,6 +10171,7 @@ async function main() {
|
|
|
9996
10171
|
// Several offers of one project ask `looksClean` on a shared checkout
|
|
9997
10172
|
// only once.
|
|
9998
10173
|
const probed = new Map();
|
|
10174
|
+
const problems = new Map();
|
|
9999
10175
|
|
|
10000
10176
|
let claimable = 0;
|
|
10001
10177
|
let skipped = 0;
|
|
@@ -10063,15 +10239,30 @@ async function main() {
|
|
|
10063
10239
|
// adding to `told.heldWorkspaces` mid-loop — is the same window that
|
|
10064
10240
|
// already exists between this poll and `startRun`'s own claim.
|
|
10065
10241
|
let workspace = null;
|
|
10242
|
+
let source = null;
|
|
10066
10243
|
if (writes) {
|
|
10067
10244
|
const picked = await pickWorkspace(slug, config.projects[slug], offered, held, probed,
|
|
10068
|
-
config.autoWorkspaces);
|
|
10245
|
+
config.autoWorkspaces, problems);
|
|
10069
10246
|
if (picked.why) {
|
|
10070
10247
|
noteQueued(offered.run, picked.why);
|
|
10071
10248
|
skipped += 1;
|
|
10072
10249
|
continue;
|
|
10073
10250
|
}
|
|
10074
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
|
+
}
|
|
10075
10266
|
}
|
|
10076
10267
|
if (workspace) {
|
|
10077
10268
|
held.add(workspace);
|
|
@@ -10084,7 +10275,7 @@ async function main() {
|
|
|
10084
10275
|
});
|
|
10085
10276
|
noted.delete(offered.run.id);
|
|
10086
10277
|
claimable += 1;
|
|
10087
|
-
void startRun(config, offered, workspace);
|
|
10278
|
+
void startRun(config, offered, workspace, source);
|
|
10088
10279
|
}
|
|
10089
10280
|
|
|
10090
10281
|
// Forget runs that are no longer offered, so one that comes back around
|