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/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.workspaces.find((candidate) => !held.has(candidate));
2485
+ const free = await usableWorkspace(project, held, new Map());
2419
2486
  const path = free ?? resolve(project.path);
2420
- const busy = free === undefined;
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 viaGh = await new Promise((resolvePromise) => {
3925
- const child = spawn('gh', ['pr', 'view', branch, '--json', 'url', '--jq', '.url'], {
3926
- cwd,
3927
- stdio: ['ignore', 'pipe', 'ignore'],
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 gh, or no PR yet. A compare URL is honest: it is where you would go to
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 origin = await git(cwd, ['remote', 'get-url', 'origin']).catch(() => '');
3942
- const github = /github\.com[:/](.+?)(?:\.git)?$/.exec(origin.trim());
3943
- return github ? `https://github.com/${github[1]}/compare/${encodeURIComponent(branch)}` : null;
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 existing = await findPullRequest(cwd, branch).catch(() => null);
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
- const opened = await gh(cwd, [
4167
- 'pr', 'create',
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
- // gh prints the URL and nothing else on success — and then the host is
4175
- // asked whether it is really there, because "asked for" is not "made",
4176
- // and a URL on the card that 404s is worse than an honest failure.
4177
- const printed = opened.out.split('\n').pop().trim();
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: `gh said it opened ${printed}, but the host does not show a pull request for ${branch}.`,
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 gh.
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 (brew install gh && gh auth login), `
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 || 'gh pr create failed without saying why.' };
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 url = await findPullRequest(cwd, branch).catch(() => null);
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 gh(cwd, ['pr', 'merge', url, mergeCommit ? '--merge' : '--squash', '--delete-branch']);
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 || 'gh pr merge failed without saying why.',
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
- const asked = await gh(cwd, ['pr', 'view', url, '--json', 'mergeable,mergeStateStatus']);
4292
- if (asked.code !== 0) return;
4293
- let view;
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. `gh()` reports a spawn
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
- // The house style for asking gh a question — the same `--json` shape the merge
4433
- // pass already parses. A failure to answer is not an answer, so it falls
4434
- // through to OTHER rather than inventing one.
4435
- const asked = await gh(cwd, ['pr', 'view', url, '--json', 'mergeable,mergeStateStatus']);
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 = project.workspaces.find((candidate) => !heldNow.has(candidate))
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 = project.workspaces.find((candidate) => !heldNow.has(candidate))
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 free = project.workspaces.filter((path) => !held.has(path));
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
- const first = project.workspaces[0];
6314
- const made = project.workspaces.filter((path) => isAutoWorkspace(first, path));
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 it left running '
6829
- + 'again IN THE FOREGROUND — not with run_in_background — wait for each one to end '
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
- await finish(config, run, 'FAILED', `Could not spawn the agent: ${failure.message}`);
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 = `Could not spawn Antigravity: ${failure.message}`;
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 = `Could not spawn Codex: ${failure.message}`;
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