cawdev-cli 1.0.9-beta → 1.0.11-beta

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