@cat-factory/server 0.311.3 → 0.313.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.
@@ -1,4 +1,4 @@
1
- import { failureKindFromHarnessCause } from '@cat-factory/kernel';
1
+ import { failureKindFromHarnessCause, runBestEffort } from '@cat-factory/kernel';
2
2
  import { isProxyableProvider } from '@cat-factory/agents';
3
3
  import { RunnerJobClient } from './RunnerJobClient.js';
4
4
  import { logger } from '../observability/logger.js';
@@ -10,31 +10,89 @@ const ADAPT_SYSTEM_PROMPT = 'You are a repository bootstrapper. You have a fresh
10
10
  'focused, idiomatic changes that match the existing structure. Do not invent ' +
11
11
  'unrelated features.';
12
12
  /**
13
- * The role prompt when writing a new service INTO an existing monorepo.
13
+ * The role prompt when writing a new service INTO an existing monorepo, told where its work
14
+ * lands.
14
15
  *
15
- * Distinct from the two below in the thing it keeps saying: the checkout is not the new
16
- * service's to reshape. The agent has the monorepo (writable, at a work branch) and the
17
- * reference template beside it as a read-only sibling, and its whole job is confined to one
18
- * new subdirectory plus the minimum registration the monorepo's own tooling needs. Every
19
- * cross-cutting choice it might otherwise make has already been made by a human and is stated
20
- * in the brief, so the prompt's job is to stop it re-deciding them.
16
+ * Distinct from the ones below in the thing it keeps saying: the checkout is not the new
17
+ * service's to reshape. The agent has the monorepo (writable) and the reference template beside
18
+ * it as a read-only sibling, and its whole job is confined to one new subdirectory plus the
19
+ * minimum registration the monorepo's own tooling needs. Every cross-cutting choice it might
20
+ * otherwise make has already been made by a human and is stated in the brief, so the prompt's
21
+ * job is to stop it re-deciding them.
22
+ *
23
+ * Two of its sentences are delivery-specific, and both are the kind that changes what the agent
24
+ * does. WHICH BRANCH it is on is the licence to commit loosely or not: under `direct_push` the
25
+ * harness checkpoints every commit straight to the branch every other service is built from, and
26
+ * an agent told it is on a "fresh work branch" is being told the opposite of that. And WHERE a
27
+ * deviation goes has to be somewhere that exists: routing it to a pull request description on a
28
+ * run that opens no pull request is how a caveat about a settled decision is silently lost.
29
+ */
30
+ function monorepoSystemPrompt(delivery) {
31
+ const checkout = delivery === 'pull_request'
32
+ ? 'Your working directory is the monorepo checkout, already on a fresh work branch. '
33
+ : "Your working directory is the monorepo checkout, on the monorepo's OWN DEFAULT BRANCH: " +
34
+ 'every commit you make is pushed to the branch every other service in it is built from, ' +
35
+ 'as you make it. So commit only work you would be willing to merge, keep the tree ' +
36
+ 'building at every commit, and never rewrite, revert or force-push history that was ' +
37
+ 'there before you. ';
38
+ const deviation = delivery === 'pull_request'
39
+ ? 'say so in the pull request description'
40
+ : 'say so in the commit message that makes the change';
41
+ return ('You are adding a NEW service to an existing monorepo. ' +
42
+ checkout +
43
+ 'A reference template repository is ' +
44
+ 'checked out READ-ONLY as a sibling directory beside it: read from it freely, copy from it ' +
45
+ 'where the brief says to, and never write to it. ' +
46
+ 'Create the new service in the subdirectory the brief names, and touch NOTHING else in the ' +
47
+ 'monorepo except the minimum registration its own tooling requires (a workspace list, a ' +
48
+ 'build-graph entry, a CI matrix entry). Modifying an existing service is out of scope, and ' +
49
+ 'so is reformatting, upgrading or "tidying" anything you did not add. ' +
50
+ 'The brief carries adoption decisions a human has already reviewed and settled: for each ' +
51
+ 'area they say whether the new service follows the monorepo or the template. Follow them ' +
52
+ 'exactly. Do not substitute your own preference for one of them, and if a decision cannot ' +
53
+ `be honoured as written, do the closest thing that respects it and ${deviation} rather than ` +
54
+ 'quietly taking the other side. ' +
55
+ 'Match the surrounding monorepo in everything the brief does NOT settle: its naming, its ' +
56
+ 'file layout, its dependency versions, its lint and test conventions. Leave the new service ' +
57
+ 'building and its tests passing.');
58
+ }
59
+ /**
60
+ * The role prompt when filling a NEW repository from a reference template, delivered as a pull
61
+ * request.
62
+ *
63
+ * Distinct from {@link ADAPT_SYSTEM_PROMPT} in what the checkout IS: there the agent works in a
64
+ * clone OF the template and reshapes it, here it works in the new repository (which holds only
65
+ * its initial README) with the template read-only beside it. Saying "adapt this in place" to an
66
+ * agent whose cwd is the empty repository is how a run ends with the template untouched and the
67
+ * repository still empty.
21
68
  */
22
- const MONOREPO_SYSTEM_PROMPT = 'You are adding a NEW service to an existing monorepo. Your working directory is the ' +
23
- 'monorepo checkout, already on a fresh work branch. A reference template repository is ' +
24
- 'checked out READ-ONLY as a sibling directory beside it: read from it freely, copy from it ' +
25
- 'where the brief says to, and never write to it. ' +
26
- 'Create the new service in the subdirectory the brief names, and touch NOTHING else in the ' +
27
- 'monorepo except the minimum registration its own tooling requires (a workspace list, a ' +
28
- 'build-graph entry, a CI matrix entry). Modifying an existing service is out of scope, and ' +
29
- 'so is reformatting, upgrading or "tidying" anything you did not add. ' +
30
- 'The brief carries adoption decisions a human has already reviewed and settled: for each ' +
31
- 'area they say whether the new service follows the monorepo or the template. Follow them ' +
32
- 'exactly. Do not substitute your own preference for one of them, and if a decision cannot be ' +
33
- 'honoured as written, do the closest thing that respects it and say so in the pull request ' +
34
- 'description rather than quietly taking the other side. ' +
35
- 'Match the surrounding monorepo in everything the brief does NOT settle: its naming, its ' +
36
- 'file layout, its dependency versions, its lint and test conventions. Leave the new service ' +
37
- 'building and its tests passing.';
69
+ const PR_ADAPT_SYSTEM_PROMPT = 'You are a repository bootstrapper. Your working directory is a BRAND-NEW repository, ' +
70
+ 'holding nothing but the initial README/.gitignore/license it was created with, already on a ' +
71
+ 'fresh work branch. A reference architecture (a base/golden-template repository) is checked ' +
72
+ 'out READ-ONLY as a sibling directory beside it: read from it freely, copy what the new ' +
73
+ 'service needs into your working directory, and never write to it. ' +
74
+ 'Adapt what you copy into the new service per the instructions: rename packages/modules, ' +
75
+ 'leave out pieces that do not apply, write the README and metadata for THIS service, and ' +
76
+ 'leave the project building. Make focused, idiomatic changes that match the structure the ' +
77
+ 'template establishes. Do not invent unrelated features. ' +
78
+ 'Your work is delivered as a pull request a person reviews, so it must stand on its own: no ' +
79
+ 'placeholder files you meant to fill in later, and no references to paths that only exist in ' +
80
+ 'the template.';
81
+ /**
82
+ * The role prompt when scaffolding a new repository from scratch, delivered as a pull request.
83
+ *
84
+ * Same job as {@link SCAFFOLD_SYSTEM_PROMPT}, said to an agent that is NOT in an empty
85
+ * directory: the repository already carries its initial commit, which is what the pull request
86
+ * is opened against.
87
+ */
88
+ const PR_SCAFFOLD_SYSTEM_PROMPT = 'You are a repository bootstrapper. Your working directory is a BRAND-NEW repository, ' +
89
+ 'holding nothing but the initial README/.gitignore/license it was created with, already on a ' +
90
+ 'fresh work branch. Scaffold the service described in the instructions into it: create a ' +
91
+ 'sensible, idiomatic project layout with source files, a README, and the metadata and ' +
92
+ 'build/config files appropriate for the stack, leaving the project building. Keep the scope ' +
93
+ 'to what the instructions describe; do not invent unrelated features. ' +
94
+ 'Your work is delivered as a pull request a person reviews, so it must stand on its own: no ' +
95
+ 'placeholder files you meant to fill in later.';
38
96
  /** The role prompt when scaffolding a brand-new repository from scratch. */
39
97
  const SCAFFOLD_SYSTEM_PROMPT = 'You are a repository bootstrapper. You are working in an empty directory and ' +
40
98
  'must scaffold a brand-new repository from scratch per the instructions. Create ' +
@@ -100,6 +158,12 @@ export class ContainerRepoBootstrapper {
100
158
  * Pre-flight the target repo and dispatch the bootstrap container as a
101
159
  * background job (returns once accepted, like `/run`). Throws on a pre-flight
102
160
  * failure so the run fails fast before a board frame is created.
161
+ *
162
+ * THREE dispatch shapes, chosen by the target and the run's delivery. A new repository
163
+ * delivered by `direct_push` is the original one (adapt a clone, reinitialise, force-push);
164
+ * everything else is the ordinary coding shape (clone the writable target, template beside it
165
+ * read-only, work branch, pull request), because the target already holds a history nobody may
166
+ * reset: the monorepo's, or the initial commit a pull request has to be opened against.
103
167
  */
104
168
  async startBootstrap(request) {
105
169
  const log = logger.child({ jobId: request.jobId, workspaceId: request.workspaceId });
@@ -112,90 +176,37 @@ export class ContainerRepoBootstrapper {
112
176
  `(Workers AI, or a direct OpenAI-compatible provider); ` +
113
177
  `'${this.deps.model.provider}' is not supported.`);
114
178
  }
115
- // A monorepo run has a completely different pre-flight and a completely different push, so
116
- // it branches before the new-repo checks below: there is no repository to create, nothing to
117
- // be empty, and force-pushing a fresh history would destroy the target.
179
+ // A monorepo run has a completely different pre-flight, so it branches before the new-repo
180
+ // checks below: there is no repository to create and nothing to be empty.
118
181
  if (request.monorepo) {
119
182
  return await this.startMonorepoBootstrap(request, request.monorepo, installation);
120
183
  }
121
- // The target repo is created up front — by the user via GitHub's new-repo page,
122
- // or, for privileged-tier orgs (ADR 0005), programmatically via the create-repo
123
- // endpoint behind the modal's "Create repository" button. Resolve it under the
124
- // installation account to confirm it exists, is reachable by the App, and is
125
- // empty — the run pushes the bootstrapped contents as the initial commit.
184
+ const target = await this.preflightNewRepoTarget(request, installation, log);
185
+ if (request.delivery.mode === 'pull_request') {
186
+ return await this.startNewRepoPullRequest(request, target, installation, log);
187
+ }
126
188
  const owner = installation.accountLogin;
127
189
  const repoName = request.target.name;
128
- const ref = { owner, repo: repoName };
129
- log.info('bootstrap: pre-flighting target repo', { target: `${owner}/${repoName}` });
130
- let target;
131
- try {
132
- target = await this.deps.githubClient.getRepo(installation.installationId, ref);
133
- }
134
- catch {
135
- throw new Error(`Repository ${owner}/${repoName} was not found or is not accessible to the GitHub App. ` +
136
- `Create a repository named "${repoName}" under ${owner} (an initial README, .gitignore ` +
137
- `or license is fine), make sure the App is installed on it, then run bootstrap again.`);
138
- }
139
- // The repo being *readable* is not enough: bootstrapping ends in a force-push, so
140
- // the installation must have write access. A public repo the App can read but is
141
- // not granted (not in the App's selected-repos list, or the App lacks
142
- // contents:write) reads fine above but 403s on the container's push — pre-flight
143
- // it here so that case fails fast with an actionable message instead of failing
144
- // deep inside the run after a board frame has been created.
145
- if (!(await this.deps.githubClient.canPush(installation.installationId, ref))) {
146
- throw new Error(`The GitHub App can see ${owner}/${repoName} but does not have write access to it, so the ` +
147
- `bootstrapped commit cannot be pushed. Grant the App write access to this repository ` +
148
- `(GitHub → Settings → Applications → the cat-factory App → Configure → Repository access — ` +
149
- `add "${repoName}" or allow all repositories), or, in local mode, use a GitHub PAT that ` +
150
- `can push to it. Then run bootstrap again.`);
151
- }
152
- // The run replaces the repo's contents with a fresh single-commit history, so
153
- // the target must be empty — except that GitHub's create-repo page often
154
- // prepopulates a README, .gitignore and/or license. Those are throwaway
155
- // boilerplate, so tolerate a repo that holds *only* them (the push force-
156
- // overwrites them); reject anything with real content to avoid clobbering work.
157
- const rootEntries = await this.deps.githubClient.listRootEntries(installation.installationId, ref);
158
- const realContent = rootEntries.filter((entry) => !isBootstrapBoilerplate(entry));
159
- if (realContent.length > 0) {
160
- const sample = realContent
161
- .map((entry) => entry.path)
162
- .slice(0, 5)
163
- .join(', ');
164
- throw new Error(`Repository ${owner}/${repoName} already has content (${sample}). Bootstrapping replaces ` +
165
- `the repository's contents, so it needs an empty repository — or one prepopulated only ` +
166
- `with a README, .gitignore, license and/or AGENTS.md.`);
167
- }
168
- // Scoped to the one repo being bootstrapped: the run clones and force-pushes exactly this
169
- // target and touches nothing else, so there is no leg to widen for. `target` came from the
170
- // pre-flight `getRepo` above, which is why this costs no extra read.
190
+ // With a reference architecture the container clones + adapts it; without one
191
+ // it scaffolds an empty repo from the freeform instructions alone.
192
+ const reference = await this.resolveReferenceTemplate(request, installation.installationId, log);
193
+ // Scoped to the repos this run touches: the target it force-pushes, and the template it
194
+ // CLONES. The template belongs on the scope even though nothing pushes to it, because the
195
+ // clone runs on this same token: leaving it off works only for a public reference
196
+ // architecture and 404s on a private one, with nothing in the failure naming the cause.
197
+ // `target` came from the pre-flight `getRepo` above, which is why it costs no extra read.
171
198
  const ghToken = await this.deps.mintInstallationToken(installation.installationId, {
172
199
  executionId: request.containerJobId,
173
200
  workspaceId: request.workspaceId,
174
- repoIds: [String(target.githubId)],
201
+ repoIds: [String(target.githubId), ...(reference?.githubId ? [reference.githubId] : [])],
175
202
  });
176
203
  // Private-registry auth for the scaffolder's installs, exactly as the
177
204
  // implementation executor forwards it.
178
205
  const packageRegistries = (await this.deps.resolvePackageRegistries?.(request.workspaceId)) ?? [];
179
- const sessionToken = await this.deps.sessionService.mint({
180
- workspaceId: request.workspaceId,
181
- executionId: request.containerJobId,
182
- agentKind: 'architect',
183
- provider: this.deps.model.provider,
184
- model: this.deps.model.model,
185
- });
186
- const webBase = (this.deps.webBaseUrl ?? 'https://github.com').replace(/\/+$/, '');
206
+ const sessionToken = await this.mintSessionToken(request);
207
+ const webBase = this.webBase();
187
208
  const targetCloneUrl = `${webBase}/${owner}/${repoName}.git`;
188
- const defaultBranch = target.defaultBranch ?? 'main';
189
- // With a reference architecture the container clones + adapts it; without one
190
- // it scaffolds an empty repo from the freeform instructions alone.
191
- const reference = request.referenceRepo
192
- ? {
193
- owner: request.referenceRepo.owner,
194
- name: request.referenceRepo.name,
195
- cloneUrl: `${webBase}/${request.referenceRepo.owner}/${request.referenceRepo.name}.git`,
196
- baseBranch: 'main',
197
- }
198
- : undefined;
209
+ const defaultBranch = defaultBranchOf(target);
199
210
  const targetSpec = { owner, name: repoName, cloneUrl: targetCloneUrl, defaultBranch };
200
211
  // The generic agent `repo` is the clone source: the reference when adapting one, or the
201
212
  // (uncloned) target placeholder when scaffolding from scratch. The real push destination
@@ -209,12 +220,12 @@ export class ContainerRepoBootstrapper {
209
220
  }
210
221
  : { owner, name: repoName, baseBranch: defaultBranch, cloneUrl: targetCloneUrl };
211
222
  // Bootstrap dispatches the generic, manifest-driven `agent` kind in `coding` mode with a
212
- // `bootstrap` spec (the divergent force-push to a separate target repo) — the SAME path
223
+ // `bootstrap` spec (the divergent force-push to a separate target repo): the SAME path
213
224
  // every other built-in coding agent takes, with NO bespoke `/bootstrap` harness handler.
214
225
  const body = {
215
226
  jobId: request.containerJobId,
216
227
  // The run's correlation ids, so the container's own lines join to this bootstrap in the
217
- // backend's logs — the same fields `buildCommonBody` puts on an execution job. A bootstrap
228
+ // backend's logs: the same fields `buildCommonBody` puts on an execution job. A bootstrap
218
229
  // is a first-class agent run (one `agent_runs` table, one retry surface), so it must not be
219
230
  // the one agent-kind dispatch whose container logs cannot be joined to anything. Its run id
220
231
  // IS its job id: a bootstrap has no separate execution row, which is exactly what
@@ -232,7 +243,7 @@ export class ContainerRepoBootstrapper {
232
243
  // select a subscription harness. The job schema tolerates `harness` (shared
233
244
  // HarnessAuthFields), but bootstrap is the one container flow that always uses
234
245
  // the deployment's proxyable model rather than a workspace's pooled subscription
235
- // token — there is no per-block model selection on a not-yet-existing repo.
246
+ // token: there is no per-block model selection on a not-yet-existing repo.
236
247
  proxyBaseUrl: this.deps.proxyBaseUrl,
237
248
  // This backend serves the phase-tagged completions route (see `ContainerAgentExecutor`),
238
249
  // so a bootstrap's calls are attributed rather than landing in the unattributed slice.
@@ -242,9 +253,9 @@ export class ContainerRepoBootstrapper {
242
253
  ...(packageRegistries.length ? { packageRegistries } : {}),
243
254
  repo: repoSpec,
244
255
  branch: repoSpec.baseBranch,
245
- // Bootstrap always resets history to a single commit and force-pushes (the fresh
246
- // history shares no ancestor with the target repo's boilerplate); that is implicit
247
- // in the bootstrap flow, so no per-job flags are needed.
256
+ // This delivery always resets history to a single commit and force-pushes (the fresh
257
+ // history shares no ancestor with the target repo's boilerplate); that is what the
258
+ // `bootstrap` spec MEANS to the harness, so no per-job flags are needed.
248
259
  bootstrap: {
249
260
  target: targetSpec,
250
261
  ...(reference ? {} : { fromScratch: true }),
@@ -254,7 +265,7 @@ export class ContainerRepoBootstrapper {
254
265
  // Dispatch through the shared transport (keyed by job id), exactly like the
255
266
  // implementation executor: it hits the harness `POST /jobs` (kind `agent`), starts the
256
267
  // background job and returns once accepted; we then poll via the same transport.
257
- // Idempotent per job id — a replayed dispatch re-attaches rather than duplicating.
268
+ // Idempotent per job id: a replayed dispatch re-attaches rather than duplicating.
258
269
  log.info('bootstrap: dispatching container', {
259
270
  reference: reference ? `${reference.owner}/${reference.name}` : null,
260
271
  });
@@ -268,23 +279,122 @@ export class ContainerRepoBootstrapper {
268
279
  containerJobId: request.containerJobId,
269
280
  };
270
281
  }
282
+ /**
283
+ * Pre-flight the pre-created target repository of a NEW-REPO run: it exists, the App can
284
+ * write to it, and it holds no real content.
285
+ *
286
+ * The target repo is created up front: by the user via the host's new-repo page, or, for
287
+ * privileged-tier orgs (ADR 0005), programmatically via the create-repo endpoint behind the
288
+ * modal's "Create repository" button.
289
+ *
290
+ * The emptiness rule binds under both deliveries, because both WRITE a whole service into the
291
+ * repository: a force-push would clobber real content and a pull request would propose
292
+ * deleting it. What differs is the FLOOR: a pull request needs an initial commit to branch
293
+ * from, so that delivery also refuses a repository with none.
294
+ */
295
+ async preflightNewRepoTarget(request, installation, log) {
296
+ const owner = installation.accountLogin;
297
+ const repoName = request.target.name;
298
+ const ref = { owner, repo: repoName };
299
+ log.info('bootstrap: pre-flighting target repo', { target: `${owner}/${repoName}` });
300
+ let target;
301
+ try {
302
+ target = await this.deps.githubClient.getRepo(installation.installationId, ref);
303
+ }
304
+ catch {
305
+ throw new Error(`Repository ${owner}/${repoName} was not found or is not accessible to the GitHub App. ` +
306
+ `Create a repository named "${repoName}" under ${owner} (an initial README, .gitignore ` +
307
+ `or license is fine), make sure the App is installed on it, then run bootstrap again.`);
308
+ }
309
+ // The repo being *readable* is not enough: bootstrapping ends in a push, so the
310
+ // installation must have write access. A public repo the App can read but is
311
+ // not granted (not in the App's selected-repos list, or the App lacks
312
+ // contents:write) reads fine above but 403s on the container's push, so pre-flight
313
+ // it here: that case then fails fast with an actionable message instead of failing
314
+ // deep inside the run after a board frame has been created.
315
+ if (!(await this.deps.githubClient.canPush(installation.installationId, ref))) {
316
+ throw new Error(`The GitHub App can see ${owner}/${repoName} but does not have write access to it, so the ` +
317
+ `bootstrapped commit cannot be pushed. Grant the App write access to this repository ` +
318
+ `(GitHub → Settings → Applications → the cat-factory App → Configure → Repository access: ` +
319
+ `add "${repoName}" or allow all repositories), or, in local mode, use a GitHub PAT that ` +
320
+ `can push to it. Then run bootstrap again.`);
321
+ }
322
+ // The run writes a whole repository's worth of content, so the target must be
323
+ // empty, except that GitHub's create-repo page often prepopulates a README,
324
+ // .gitignore and/or license. Those are throwaway boilerplate, so tolerate a repo
325
+ // that holds *only* them; reject anything with real content to avoid clobbering work.
326
+ const rootEntries = await this.deps.githubClient.listRootEntries(installation.installationId, ref);
327
+ const realContent = rootEntries.filter((entry) => !isBootstrapBoilerplate(entry));
328
+ if (realContent.length > 0) {
329
+ const sample = realContent
330
+ .map((entry) => entry.path)
331
+ .slice(0, 5)
332
+ .join(', ');
333
+ throw new Error(`Repository ${owner}/${repoName} already has content (${sample}). Bootstrapping replaces ` +
334
+ `the repository's contents, so it needs an empty repository, or one prepopulated only ` +
335
+ `with a README, .gitignore, license and/or AGENTS.md.`);
336
+ }
337
+ // A pull request is opened BETWEEN two commits, so a repository holding none cannot take
338
+ // one: there is no default branch to clone, to branch from, or to target.
339
+ // `listRootEntries` answers `[]` for exactly that repository (the contents endpoint 404s
340
+ // where there is no commit), so an empty listing is the tell. Refused here, naming both
341
+ // ways out, rather than surfacing later as a clone failure that reads like an outage.
342
+ if (request.delivery.mode === 'pull_request' && rootEntries.length === 0) {
343
+ throw new Error(`Repository ${owner}/${repoName} has no commits yet, so there is no branch to open a ` +
344
+ `pull request against. Either create it with an initial commit (a README is enough), ` +
345
+ `or bootstrap it with "push directly", which writes the repository's first commit.`);
346
+ }
347
+ return target;
348
+ }
349
+ /**
350
+ * Dispatch a NEW-REPO run delivered as a pull request: the ordinary coding shape against the
351
+ * (already-initialised) target repository, with the reference template beside it as a
352
+ * READ-ONLY sibling checkout.
353
+ *
354
+ * Deliberately NOT the `bootstrap` spec, which is the whole reason this path exists
355
+ * separately: that spec reinitialises history and force-pushes, and a branch whose history
356
+ * shares no ancestor with the default branch is not something a pull request can be opened
357
+ * from. Here the target's own initial commit is the base, so the diff a reviewer reads is the
358
+ * service being added.
359
+ */
360
+ async startNewRepoPullRequest(request, target, installation, log) {
361
+ const owner = installation.accountLogin;
362
+ const repoName = request.target.name;
363
+ log.info('bootstrap(new-repo pr): dispatching container', { target: `${owner}/${repoName}` });
364
+ return await this.dispatchCodingShape(request, installation, log, {
365
+ repo: {
366
+ owner,
367
+ name: repoName,
368
+ baseBranch: defaultBranchOf(target),
369
+ cloneUrl: `${this.webBase()}/${owner}/${repoName}.git`,
370
+ },
371
+ repoGithubId: target.githubId,
372
+ systemPrompt: request.referenceRepo ? PR_ADAPT_SYSTEM_PROMPT : PR_SCAFFOLD_SYSTEM_PROMPT,
373
+ userPrompt: request.instructions ||
374
+ (request.referenceRepo
375
+ ? 'Adapt the reference architecture for the new service.'
376
+ : 'Scaffold a new repository for the service.'),
377
+ });
378
+ }
271
379
  /**
272
380
  * Dispatch a monorepo bootstrap's APPLY phase: an ordinary coding job on the monorepo, with
273
381
  * the reference template alongside it as a READ-ONLY sibling checkout.
274
382
  *
275
383
  * Deliberately the plain coding shape rather than a `bootstrap` spec, and that is the design:
276
- * the harness already knows how to clone a writable primary at a work branch, clone
277
- * `referenceRepos` beside it without ever branching or pushing them, and open one pull request
278
- * for the primary. A bespoke bootstrap mode here would be a second implementation of that with
279
- * one extra way to get the push wrong, against a repository that holds other people's code.
384
+ * the harness already knows how to clone a writable primary (at a work branch, or at the
385
+ * default branch it commits onto, per the run's delivery), clone `referenceRepos` beside it
386
+ * without ever branching or pushing them, and open one pull request for the primary when it
387
+ * was given one to open. A bespoke bootstrap mode here would be a second implementation of
388
+ * that with one extra way to get the push wrong, against a repository that holds other
389
+ * people's code.
280
390
  *
281
391
  * `repo.serviceDirectory` is what scopes the agent to the new subdirectory: the same field
282
392
  * every monorepo-service run rides, so the working-directory rule is stated in one place.
283
393
  *
284
394
  * Two pre-flights, both about the monorepo rather than about a new repo: the App must be able
285
395
  * to WRITE to it (a read-only grant reads fine and 403s on the push, after a board frame
286
- * exists), and the target directory must still be absent at dispatch time, because the orchestration
287
- * pre-flighted it before the survey, and a review can be settled days later.
396
+ * exists), and the target directory must still be absent at dispatch time, because the
397
+ * orchestration pre-flighted it before the survey and a review can be settled days later.
288
398
  */
289
399
  async startMonorepoBootstrap(request, monorepo, installation) {
290
400
  const log = logger.child({ jobId: request.jobId, workspaceId: request.workspaceId });
@@ -300,87 +410,150 @@ export class ContainerRepoBootstrapper {
300
410
  `to it, so the new service cannot be pushed. Grant the App write access to this ` +
301
411
  `repository, then retry.`);
302
412
  }
303
- const existing = await this.deps.githubClient.listDirectory(installation.installationId, ref, monorepo.directory, defaultBranchOf(target));
413
+ const existing = await this.deps.githubClient.listDirectory(installation.installationId, ref, monorepo.directory, defaultBranch);
304
414
  if (existing.length > 0) {
305
415
  throw new Error(`\`${monorepo.directory}\` already exists in ${monorepo.owner}/${monorepo.name}. It was ` +
306
416
  `empty when this bootstrap started; something has since created it. Pick a different ` +
307
417
  `directory and start a new bootstrap rather than writing over it.`);
308
418
  }
309
- const webBase = (this.deps.webBaseUrl ?? 'https://github.com').replace(/\/+$/, '');
310
- // Scoped to the repos this run touches: the monorepo it pushes to, and the template it
311
- // reads. A template outside the installation simply is not cloneable, which the harness
312
- // reports as a clone failure rather than silently running without it.
313
- const repoIds = [String(target.githubId)];
314
- const reference = request.referenceRepo;
315
- let referenceRepos = [];
316
- if (reference) {
317
- const templateRepo = await this.deps.githubClient
318
- .getRepo(installation.installationId, { owner: reference.owner, repo: reference.name })
319
- .catch(() => null);
320
- if (templateRepo)
321
- repoIds.push(String(templateRepo.githubId));
322
- referenceRepos = [
419
+ log.info('bootstrap(monorepo): dispatching container', {
420
+ directory: monorepo.directory,
421
+ delivery: request.delivery.mode,
422
+ });
423
+ return await this.dispatchCodingShape(request, installation, log, {
424
+ repo: {
425
+ owner: monorepo.owner,
426
+ name: monorepo.name,
427
+ baseBranch: defaultBranch,
428
+ cloneUrl: `${this.webBase()}/${monorepo.owner}/${monorepo.name}.git`,
429
+ serviceDirectory: monorepo.directory,
430
+ },
431
+ repoGithubId: target.githubId,
432
+ systemPrompt: monorepoSystemPrompt(request.delivery.mode),
433
+ userPrompt: request.instructions,
434
+ });
435
+ }
436
+ /**
437
+ * The dispatch both non-force-push shapes share: clone the writable target, fetch the
438
+ * reference template beside it read-only, and either open a work branch plus a pull request
439
+ * or commit onto the target's own default branch.
440
+ *
441
+ * ONE builder rather than one per target, because the delivery rule is the interesting part
442
+ * and a second copy of it is a second place for `newBranch` and `pr` to disagree: a body
443
+ * carrying a branch but no PR pushes work onto a branch nobody is told about.
444
+ */
445
+ async dispatchCodingShape(request, installation, log, spec) {
446
+ // Scoped to the repos this run touches: the one it pushes to, and the template it reads. A
447
+ // template outside the installation simply is not cloneable, which the harness reports as a
448
+ // clone failure rather than silently running without it.
449
+ const reference = await this.resolveReferenceTemplate(request, installation.installationId, log);
450
+ const repoIds = [
451
+ String(spec.repoGithubId),
452
+ ...(reference?.githubId ? [reference.githubId] : []),
453
+ ];
454
+ const referenceRepos = reference
455
+ ? [
323
456
  {
324
457
  repo: {
325
458
  owner: reference.owner,
326
459
  name: reference.name,
327
- baseBranch: templateRepo?.defaultBranch ?? 'main',
328
- cloneUrl: `${webBase}/${reference.owner}/${reference.name}.git`,
460
+ baseBranch: reference.baseBranch,
461
+ cloneUrl: reference.cloneUrl,
329
462
  },
330
463
  },
331
- ];
332
- }
464
+ ]
465
+ : [];
333
466
  const ghToken = await this.deps.mintInstallationToken(installation.installationId, {
334
467
  executionId: request.containerJobId,
335
468
  workspaceId: request.workspaceId,
336
469
  repoIds,
337
470
  });
338
471
  const packageRegistries = (await this.deps.resolvePackageRegistries?.(request.workspaceId)) ?? [];
339
- const sessionToken = await this.deps.sessionService.mint({
340
- workspaceId: request.workspaceId,
341
- executionId: request.containerJobId,
342
- agentKind: 'architect',
343
- provider: this.deps.model.provider,
344
- model: this.deps.model.model,
345
- });
472
+ const sessionToken = await this.mintSessionToken(request);
473
+ // The delivery decides the two fields TOGETHER: with `newBranch` and `pr` the harness pushes
474
+ // a work branch and opens one pull request; with neither it commits onto `branch`, the
475
+ // target's own default. Omitting only one of the pair is the bug this single site prevents.
476
+ const delivery = request.delivery.mode === 'pull_request'
477
+ ? { newBranch: request.delivery.branch, pr: request.delivery.pr }
478
+ : {};
346
479
  const body = {
347
480
  jobId: request.containerJobId,
348
481
  workspaceId: request.workspaceId,
349
482
  executionId: request.containerJobId,
350
483
  mode: 'coding',
351
- systemPrompt: MONOREPO_SYSTEM_PROMPT,
352
- userPrompt: request.instructions,
484
+ systemPrompt: spec.systemPrompt,
485
+ userPrompt: spec.userPrompt,
353
486
  model: this.deps.model.model,
354
487
  proxyBaseUrl: this.deps.proxyBaseUrl,
355
488
  proxyPhasePath: true,
356
489
  sessionToken,
357
490
  ghToken,
358
491
  ...(packageRegistries.length ? { packageRegistries } : {}),
359
- repo: {
360
- owner: monorepo.owner,
361
- name: monorepo.name,
362
- baseBranch: defaultBranch,
363
- cloneUrl: `${webBase}/${monorepo.owner}/${monorepo.name}.git`,
364
- serviceDirectory: monorepo.directory,
365
- },
366
- branch: defaultBranch,
367
- newBranch: monorepo.branch,
368
- pr: monorepo.pr,
492
+ repo: spec.repo,
493
+ branch: spec.repo.baseBranch,
494
+ ...delivery,
369
495
  ...(referenceRepos.length ? { referenceRepos } : {}),
370
496
  ...(this.deps.githubApiBase ? { githubApiBase: this.deps.githubApiBase } : {}),
371
497
  };
372
- log.info('bootstrap(monorepo): dispatching container', {
373
- branch: monorepo.branch,
498
+ await this.jobs.dispatch(request.workspaceId, { runId: request.jobId, jobId: request.containerJobId }, body, 'agent');
499
+ // The BRANCH is the field an operator needs first when a run reports done with no pull
500
+ // request (the orchestration then fails it as delivered nowhere) or faults mid-way under
501
+ // `direct_push`: it names where the commits are. Nothing else in the backend's logs does.
502
+ log.info('bootstrap: container accepted job', {
503
+ delivery: request.delivery.mode,
504
+ branch: request.delivery.mode === 'pull_request' ? request.delivery.branch : spec.repo.baseBranch,
374
505
  reference: reference ? `${reference.owner}/${reference.name}` : null,
375
506
  });
376
- await this.jobs.dispatch(request.workspaceId, { runId: request.jobId, jobId: request.containerJobId }, body, 'agent');
377
- log.info('bootstrap(monorepo): container accepted job');
378
507
  return {
379
508
  workspaceId: request.workspaceId,
380
509
  jobId: request.jobId,
381
510
  containerJobId: request.containerJobId,
382
511
  };
383
512
  }
513
+ /**
514
+ * Resolve the reference template a run reads from: its clone spec, plus the numeric id the
515
+ * run's installation token has to be scoped to.
516
+ *
517
+ * ONE resolution for both dispatch shapes, because both CLONE the template and both mint the
518
+ * token that clone runs on. Two copies is what left the force-push path granting only its push
519
+ * target while cloning the template with that same token, so a PRIVATE reference architecture
520
+ * 404s on clone under one delivery and works under the other, and what left it assuming the
521
+ * template's base branch was `main`.
522
+ *
523
+ * A template the installation cannot read resolves to no id and the conventional branch rather
524
+ * than refusing the run: the clone then fails inside the harness naming the repository, which
525
+ * is a better report than a pre-flight throw here, and the warning says which read failed.
526
+ */
527
+ async resolveReferenceTemplate(request, installationId, log) {
528
+ const reference = request.referenceRepo;
529
+ if (!reference)
530
+ return undefined;
531
+ const template = await runBestEffort(log, 'bootstrap: read reference template repo', () => this.deps.githubClient.getRepo(installationId, {
532
+ owner: reference.owner,
533
+ repo: reference.name,
534
+ }), { reference: `${reference.owner}/${reference.name}` });
535
+ return {
536
+ owner: reference.owner,
537
+ name: reference.name,
538
+ cloneUrl: `${this.webBase()}/${reference.owner}/${reference.name}.git`,
539
+ baseBranch: template ? defaultBranchOf(template) : 'main',
540
+ githubId: template ? String(template.githubId) : null,
541
+ };
542
+ }
543
+ /** The model-locked LLM-proxy session token a bootstrap container runs under. */
544
+ mintSessionToken(request) {
545
+ return this.deps.sessionService.mint({
546
+ workspaceId: request.workspaceId,
547
+ executionId: request.containerJobId,
548
+ agentKind: 'architect',
549
+ provider: this.deps.model.provider,
550
+ model: this.deps.model.model,
551
+ });
552
+ }
553
+ /** The host's web base, trailing slashes stripped, for building clone URLs. */
554
+ webBase() {
555
+ return (this.deps.webBaseUrl ?? 'https://github.com').replace(/\/+$/, '');
556
+ }
384
557
  /** Poll a dispatched bootstrap job, mapping the runner job view into an update. */
385
558
  async pollBootstrap(handle) {
386
559
  const view = await this.jobs.poll(handle.workspaceId, {
@@ -420,16 +593,20 @@ export class ContainerRepoBootstrapper {
420
593
  detail: view.detail ?? result.error,
421
594
  };
422
595
  }
423
- // A MONOREPO run's product is the pull request, and NOTHING else it could report stands in
424
- // for it: there is no repository it created, so building a `repoUrl` here would name one
425
- // that does not exist. A `prUrl` is the shape's own tell (the new-repo flow force-pushes a
426
- // default branch and never opens one), so it is reported alone, and a completed apply that
427
- // opened none reports neither, leaving the ORCHESTRATION to say what that means (it fails
428
- // the run: the service was delivered nowhere).
429
- if (result.prUrl)
430
- return { state: 'done', prUrl: result.prUrl };
431
- const outcome = await this.buildOutcome(handle, result.defaultBranch);
432
- return { state: 'done', outcome };
596
+ // What a completed run can NAME is decided by its target, never by whether a `prUrl` came
597
+ // back. A monorepo run created no repository, so building a `repoUrl` here would name one
598
+ // that does not exist; a new-repo run created one under either delivery, and under
599
+ // `pull_request` it has BOTH a repository and a pull request to report. Reading the record
600
+ // is what tells them apart: `prUrl`'s presence cannot, now that a new-repo run may carry
601
+ // one. A run that promised a pull request and reports none is left to the ORCHESTRATION to
602
+ // fail, which is where "delivered nowhere" is a statement about the run rather than about
603
+ // this poll.
604
+ const record = await this.requireRecord(handle);
605
+ const pr = result.prUrl ? { prUrl: result.prUrl } : {};
606
+ if (record.monorepo)
607
+ return { state: 'done', ...pr };
608
+ const outcome = await this.buildOutcome(handle, record.repoName, result.defaultBranch);
609
+ return { state: 'done', outcome, ...pr };
433
610
  }
434
611
  /**
435
612
  * Best-effort: reclaim the per-run container for a job. Releases through the same
@@ -474,23 +651,44 @@ export class ContainerRepoBootstrapper {
474
651
  });
475
652
  return { installationId: installation.installationId, githubId: repo.githubId };
476
653
  }
477
- /** Construct the success outcome from the installation + the recorded job's repo name. */
478
- async buildOutcome(handle, resultDefaultBranch) {
654
+ /**
655
+ * Construct the success outcome from the installation + the recorded job's repo name.
656
+ *
657
+ * `resultDefaultBranch` is what the HARNESS reported, and only the `bootstrap` spec reports one
658
+ * (it echoes back the branch it force-pushed, which it also created). The plain coding shape a
659
+ * `pull_request` run takes reports none, so the branch is READ off the target repository rather
660
+ * than defaulted to `main`: this field states which branch the work lands on, and a repository
661
+ * whose default is `master`, `trunk` or an org-wide choice would otherwise be recorded as a ref
662
+ * that does not exist. One read, on the terminal poll only. A failed read keeps the
663
+ * conventional fallback and says so in a warning, rather than failing a delivered run over the
664
+ * one field nothing reads back.
665
+ */
666
+ async buildOutcome(handle, repoName, resultDefaultBranch) {
479
667
  const installation = await this.deps.installationRepository.getByWorkspace(handle.workspaceId);
480
668
  if (!installation)
481
669
  throw new Error(`Workspace '${handle.workspaceId}' is not connected to GitHub`);
482
- const record = await this.deps.bootstrapJobRepository.get(handle.workspaceId, handle.jobId);
483
- if (!record)
484
- throw new Error(`Bootstrap job '${handle.jobId}' not found`);
485
670
  const owner = installation.accountLogin;
486
- const webBase = (this.deps.webBaseUrl ?? 'https://github.com').replace(/\/+$/, '');
671
+ const log = logger.child({ jobId: handle.jobId, workspaceId: handle.workspaceId });
672
+ const target = resultDefaultBranch
673
+ ? null
674
+ : await runBestEffort(log, 'bootstrap: read the target repo default branch', () => this.deps.githubClient.getRepo(installation.installationId, {
675
+ owner,
676
+ repo: repoName,
677
+ }), { repo: `${owner}/${repoName}` });
487
678
  return {
488
- repoUrl: `${webBase}/${owner}/${record.repoName}`,
679
+ repoUrl: `${this.webBase()}/${owner}/${repoName}`,
489
680
  owner,
490
- name: record.repoName,
491
- defaultBranch: resultDefaultBranch ?? 'main',
681
+ name: repoName,
682
+ defaultBranch: resultDefaultBranch ?? (target ? defaultBranchOf(target) : 'main'),
492
683
  };
493
684
  }
685
+ /** The run's stored row, which a poll addresses only by id. */
686
+ async requireRecord(handle) {
687
+ const record = await this.deps.bootstrapJobRepository.get(handle.workspaceId, handle.jobId);
688
+ if (!record)
689
+ throw new Error(`Bootstrap job '${handle.jobId}' not found`);
690
+ return record;
691
+ }
494
692
  }
495
693
  /** A repo's default branch, or the conventional fallback when the provider reported none. */
496
694
  function defaultBranchOf(repo) {