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/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.workspaces.find((candidate) => !held.has(candidate));
2486
+ const free = await usableWorkspace(project, held, new Map());
2419
2487
  const path = free ?? resolve(project.path);
2420
- const busy = free === undefined;
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 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;
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 gh, or no PR yet. A compare URL is honest: it is where you would go to
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 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;
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 existing = await findPullRequest(cwd, branch).catch(() => null);
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
- const opened = await gh(cwd, [
4167
- 'pr', 'create',
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
- // 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);
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: `gh said it opened ${printed}, but the host does not show a pull request for ${branch}.`,
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 gh.
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 (brew install gh && gh auth login), `
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 || 'gh pr create failed without saying why.' };
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 url = await findPullRequest(cwd, branch).catch(() => null);
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 gh(cwd, ['pr', 'merge', url, mergeCommit ? '--merge' : '--squash', '--delete-branch']);
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 || 'gh pr merge failed without saying why.',
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
- 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') {
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. `gh()` reports a spawn
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
- // 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';
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 = project.workspaces.find((candidate) => !heldNow.has(candidate))
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 = project.workspaces.find((candidate) => !heldNow.has(candidate))
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 free = project.workspaces.filter((path) => !held.has(path));
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
- const first = project.workspaces[0];
6314
- const made = project.workspaces.filter((path) => isAutoWorkspace(first, path));
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 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.';
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
- await finish(config, run, 'FAILED', `Could not spawn the agent: ${failure.message}`);
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 = `Could not spawn Antigravity: ${failure.message}`;
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 = `Could not spawn Codex: ${failure.message}`;
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