@on-par/factory-core 0.9.4 → 0.9.5-next.2

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.
Files changed (178) hide show
  1. package/dist/checkers/base-health.js +2 -2
  2. package/dist/checkers/base-health.js.map +1 -1
  3. package/dist/checkers/baseline-cache.d.ts +6 -4
  4. package/dist/checkers/baseline-cache.d.ts.map +1 -1
  5. package/dist/checkers/baseline-cache.js +24 -7
  6. package/dist/checkers/baseline-cache.js.map +1 -1
  7. package/dist/checkers/baseline.d.ts.map +1 -1
  8. package/dist/checkers/baseline.js +4 -3
  9. package/dist/checkers/baseline.js.map +1 -1
  10. package/dist/checkers/constitution-preflight.d.ts +8 -0
  11. package/dist/checkers/constitution-preflight.d.ts.map +1 -0
  12. package/dist/checkers/constitution-preflight.js +71 -0
  13. package/dist/checkers/constitution-preflight.js.map +1 -0
  14. package/dist/checkers/design-smells.d.ts +1 -0
  15. package/dist/checkers/design-smells.d.ts.map +1 -1
  16. package/dist/checkers/design-smells.js +24 -5
  17. package/dist/checkers/design-smells.js.map +1 -1
  18. package/dist/checkers/index.d.ts +13 -2
  19. package/dist/checkers/index.d.ts.map +1 -1
  20. package/dist/checkers/index.js +73 -11
  21. package/dist/checkers/index.js.map +1 -1
  22. package/dist/config/config-lint.d.ts +28 -0
  23. package/dist/config/config-lint.d.ts.map +1 -0
  24. package/dist/config/config-lint.js +39 -0
  25. package/dist/config/config-lint.js.map +1 -0
  26. package/dist/config/config-schema-lint.d.ts +17 -0
  27. package/dist/config/config-schema-lint.d.ts.map +1 -0
  28. package/dist/config/config-schema-lint.js +104 -0
  29. package/dist/config/config-schema-lint.js.map +1 -0
  30. package/dist/config/example.js +1 -1
  31. package/dist/config/example.js.map +1 -1
  32. package/dist/config/index.d.ts +16 -2
  33. package/dist/config/index.d.ts.map +1 -1
  34. package/dist/config/index.js +37 -13
  35. package/dist/config/index.js.map +1 -1
  36. package/dist/config/repo.d.ts +3 -0
  37. package/dist/config/repo.d.ts.map +1 -1
  38. package/dist/config/repo.js +12 -1
  39. package/dist/config/repo.js.map +1 -1
  40. package/dist/config/template-tokens.d.ts +21 -0
  41. package/dist/config/template-tokens.d.ts.map +1 -0
  42. package/dist/config/template-tokens.js +62 -0
  43. package/dist/config/template-tokens.js.map +1 -0
  44. package/dist/daemon/run-worktree.d.ts +5 -1
  45. package/dist/daemon/run-worktree.d.ts.map +1 -1
  46. package/dist/daemon/run-worktree.js +5 -2
  47. package/dist/daemon/run-worktree.js.map +1 -1
  48. package/dist/events/kinds.d.ts +1 -1
  49. package/dist/events/kinds.d.ts.map +1 -1
  50. package/dist/events/kinds.js +6 -0
  51. package/dist/events/kinds.js.map +1 -1
  52. package/dist/harness/catalog.d.ts +3 -0
  53. package/dist/harness/catalog.d.ts.map +1 -1
  54. package/dist/harness/catalog.js +6 -0
  55. package/dist/harness/catalog.js.map +1 -1
  56. package/dist/internal.d.ts +30 -6
  57. package/dist/internal.d.ts.map +1 -1
  58. package/dist/internal.js +18 -5
  59. package/dist/internal.js.map +1 -1
  60. package/dist/kpis/defects.js +1 -1
  61. package/dist/kpis/defects.js.map +1 -1
  62. package/dist/models/index.d.ts +8 -0
  63. package/dist/models/index.d.ts.map +1 -1
  64. package/dist/models/index.js +32 -2
  65. package/dist/models/index.js.map +1 -1
  66. package/dist/phases/build.d.ts +2 -0
  67. package/dist/phases/build.d.ts.map +1 -1
  68. package/dist/phases/build.js +5 -2
  69. package/dist/phases/build.js.map +1 -1
  70. package/dist/phases/check.d.ts +7 -1
  71. package/dist/phases/check.d.ts.map +1 -1
  72. package/dist/phases/check.js +27 -7
  73. package/dist/phases/check.js.map +1 -1
  74. package/dist/phases/plan.d.ts +8 -3
  75. package/dist/phases/plan.d.ts.map +1 -1
  76. package/dist/phases/plan.js +74 -30
  77. package/dist/phases/plan.js.map +1 -1
  78. package/dist/phases/ship-stack.d.ts +49 -0
  79. package/dist/phases/ship-stack.d.ts.map +1 -0
  80. package/dist/phases/ship-stack.js +110 -0
  81. package/dist/phases/ship-stack.js.map +1 -0
  82. package/dist/phases/ship.d.ts +3 -0
  83. package/dist/phases/ship.d.ts.map +1 -1
  84. package/dist/phases/ship.js +1 -1
  85. package/dist/phases/ship.js.map +1 -1
  86. package/dist/phases/stack-check-failure.d.ts +42 -0
  87. package/dist/phases/stack-check-failure.d.ts.map +1 -0
  88. package/dist/phases/stack-check-failure.js +47 -0
  89. package/dist/phases/stack-check-failure.js.map +1 -0
  90. package/dist/phases/stack-fallback.d.ts +33 -0
  91. package/dist/phases/stack-fallback.d.ts.map +1 -0
  92. package/dist/phases/stack-fallback.js +47 -0
  93. package/dist/phases/stack-fallback.js.map +1 -0
  94. package/dist/phases/stack-review-change.d.ts +48 -0
  95. package/dist/phases/stack-review-change.d.ts.map +1 -0
  96. package/dist/phases/stack-review-change.js +73 -0
  97. package/dist/phases/stack-review-change.js.map +1 -0
  98. package/dist/queue/github-queue.d.ts +6 -0
  99. package/dist/queue/github-queue.d.ts.map +1 -1
  100. package/dist/queue/github-queue.js.map +1 -1
  101. package/dist/readiness/decompose.d.ts +4 -2
  102. package/dist/readiness/decompose.d.ts.map +1 -1
  103. package/dist/readiness/decompose.js +6 -2
  104. package/dist/readiness/decompose.js.map +1 -1
  105. package/dist/readiness/enrich.d.ts +6 -0
  106. package/dist/readiness/enrich.d.ts.map +1 -1
  107. package/dist/readiness/enrich.js +20 -1
  108. package/dist/readiness/enrich.js.map +1 -1
  109. package/dist/readiness/slice-gate.d.ts +5 -0
  110. package/dist/readiness/slice-gate.d.ts.map +1 -1
  111. package/dist/readiness/slice-gate.js +10 -4
  112. package/dist/readiness/slice-gate.js.map +1 -1
  113. package/dist/readiness/slice-plan-github.d.ts +25 -0
  114. package/dist/readiness/slice-plan-github.d.ts.map +1 -1
  115. package/dist/readiness/slice-plan-github.js +23 -1
  116. package/dist/readiness/slice-plan-github.js.map +1 -1
  117. package/dist/readiness/slice-plan.d.ts +29 -2
  118. package/dist/readiness/slice-plan.d.ts.map +1 -1
  119. package/dist/readiness/slice-plan.js +71 -4
  120. package/dist/readiness/slice-plan.js.map +1 -1
  121. package/dist/reports/benchmark-artifacts.d.ts +1 -1
  122. package/dist/reports/benchmark-artifacts.d.ts.map +1 -1
  123. package/dist/review/routing.d.ts +3 -1
  124. package/dist/review/routing.d.ts.map +1 -1
  125. package/dist/review/routing.js +21 -9
  126. package/dist/review/routing.js.map +1 -1
  127. package/dist/router/index.d.ts +10 -0
  128. package/dist/router/index.d.ts.map +1 -1
  129. package/dist/router/index.js +61 -13
  130. package/dist/router/index.js.map +1 -1
  131. package/dist/run/outcome.d.ts +3 -1
  132. package/dist/run/outcome.d.ts.map +1 -1
  133. package/dist/run/outcome.js.map +1 -1
  134. package/dist/run/resume-context.d.ts +51 -0
  135. package/dist/run/resume-context.d.ts.map +1 -1
  136. package/dist/run/resume-context.js +97 -0
  137. package/dist/run/resume-context.js.map +1 -1
  138. package/dist/run/run-issue.d.ts +19 -2
  139. package/dist/run/run-issue.d.ts.map +1 -1
  140. package/dist/run/run-issue.js +291 -192
  141. package/dist/run/run-issue.js.map +1 -1
  142. package/dist/sandbox/egress-proxy.d.ts +16 -0
  143. package/dist/sandbox/egress-proxy.d.ts.map +1 -1
  144. package/dist/sandbox/egress-proxy.js +91 -7
  145. package/dist/sandbox/egress-proxy.js.map +1 -1
  146. package/dist/sandbox/index.d.ts +2 -0
  147. package/dist/sandbox/index.d.ts.map +1 -1
  148. package/dist/sandbox/index.js +3 -1
  149. package/dist/sandbox/index.js.map +1 -1
  150. package/dist/sim/monte-carlo.js +1 -1
  151. package/dist/sim/monte-carlo.js.map +1 -1
  152. package/dist/steward/diagnose.d.ts +5 -1
  153. package/dist/steward/diagnose.d.ts.map +1 -1
  154. package/dist/steward/diagnose.js +13 -5
  155. package/dist/steward/diagnose.js.map +1 -1
  156. package/dist/steward/run.d.ts +2 -0
  157. package/dist/steward/run.d.ts.map +1 -1
  158. package/dist/steward/run.js +1 -0
  159. package/dist/steward/run.js.map +1 -1
  160. package/dist/types/index.d.ts +3 -1
  161. package/dist/types/index.d.ts.map +1 -1
  162. package/dist/usage/index.d.ts +19 -1
  163. package/dist/usage/index.d.ts.map +1 -1
  164. package/dist/usage/index.js +27 -1
  165. package/dist/usage/index.js.map +1 -1
  166. package/dist/utils/claude-config.d.ts +18 -1
  167. package/dist/utils/claude-config.d.ts.map +1 -1
  168. package/dist/utils/claude-config.js +48 -3
  169. package/dist/utils/claude-config.js.map +1 -1
  170. package/dist/utils/local-auth-record.d.ts +29 -0
  171. package/dist/utils/local-auth-record.d.ts.map +1 -0
  172. package/dist/utils/local-auth-record.js +92 -0
  173. package/dist/utils/local-auth-record.js.map +1 -0
  174. package/package.json +7 -7
  175. package/dist/daemon/repo-lane.d.ts +0 -5
  176. package/dist/daemon/repo-lane.d.ts.map +0 -1
  177. package/dist/daemon/repo-lane.js +0 -9
  178. package/dist/daemon/repo-lane.js.map +0 -1
@@ -6,8 +6,10 @@
6
6
  // 3. the acquired Environment is released exactly once on every exit path.
7
7
  // 4. a failed environment lease degrades (appPort undefined) rather than parking the run.
8
8
  // 5. exactly one terminal event sequence is emitted per run, matching the returned RunOutcome
9
- // (including the non-park `released` exit for an environment-caused CHECK failure, #1928).
9
+ // (including the non-park `released` exit for an environment-caused CHECK failure, #1928; a config cause parks, #2583).
10
10
  // PLAN may be skipped by the planReuse port after an environment release (#2340).
11
+ // CHECK may be resumed on a kept worktree via request.resumeFrom after an environment release (#2564).
12
+ // A config preflight (#2586) rejects unfilled template tokens before base health and PLAN; any preflight failure parks with the `config` cause (#2587).
11
13
  //
12
14
  // Path-bound CLI concerns (Octokit, worktree/environment provisioning, the events sink,
13
15
  // approval/steering, and reporting hooks) are injected as ports so this file stays free of
@@ -18,6 +20,7 @@ import { laneEnv } from '../environment/index.js';
18
20
  import { buildPhase as buildPhaseDefault } from '../phases/build.js';
19
21
  import { checkPhase as checkPhaseDefault, remainingShowstoppers, renderEnvironmentReleaseComment, } from '../phases/check.js';
20
22
  import { describeConstitutionSource } from '../constitutions/index.js';
23
+ import { lintFactoryConfig } from '../config/config-lint.js';
21
24
  import { planPhase as planPhaseDefault } from '../phases/plan.js';
22
25
  import { shipPhase as shipPhaseDefault } from '../phases/ship.js';
23
26
  import { upsertSlicePlanComment } from '../readiness/slice-plan-github.js';
@@ -35,7 +38,7 @@ import { gateBuildOnBreaker, parseResetCooldownMs } from '../router/breaker.js';
35
38
  import { describeSteering } from '../steering/index.js';
36
39
  import { touchedFilesFrom } from './lane-file-guard.js';
37
40
  import { parkReasonFor } from './outcome.js';
38
- import { issueBodyHash } from './resume-context.js';
41
+ import { configParkMessage, formatPreservedBuildMessage, issueBodyHash, preservedFixTarget, } from './resume-context.js';
39
42
  function errorMessage(err) {
40
43
  return err instanceof Error ? err.message : String(err);
41
44
  }
@@ -278,6 +281,18 @@ export async function runIssue(request, policy, ports) {
278
281
  // later would let the build worker author the standards it is graded by.
279
282
  const constitution = ports.resolveConstitution();
280
283
  log('constitution', describeConstitutionSource(constitution, request.product));
284
+ // Pre-PLAN config preflight (#2585/#2586), shared with `factory doctor` via lintFactoryConfig (#2612).
285
+ // An unknown `checkers:` entry would only fail closed in CHECK, after PLAN and BUILD spent model calls;
286
+ // unfilled scaffold tokens would ship a placeholder. Read-only, no subprocess, runs for localOnly too.
287
+ // Any failing verdict parks with the `config` cause (#2587); checker findings come first in the message.
288
+ const lint = lintFactoryConfig({ constitution, configFile: request.repoConfigFile });
289
+ if (lint.verdict !== 'ok') {
290
+ return terminalParked('config', [
291
+ 'config preflight failed before PLAN:',
292
+ ...lint.findings.map((f) => f.message),
293
+ `then run factory unpark ${request.issue}`,
294
+ ].join('\n'));
295
+ }
281
296
  // Pre-PLAN base health (#2339): a red origin/main releases the issue before any model spend;
282
297
  // the CLI turns `released` into LaneEnvironmentPauseError, whose runLane branch writes the base hold.
283
298
  if (ports.checkBaseHealth && !request.localOnly) {
@@ -312,199 +327,229 @@ export async function runIssue(request, policy, ports) {
312
327
  // inside CHECK must diff against this same base rather than a moving target.
313
328
  runStartDiffBase = request.localOnly ? await captureDiffBase(ports.workspace.path) : undefined;
314
329
  // PLAN
315
- await setPhase('plan');
316
- const planModel = await preferFallbackWhenProviderIsOpen(request.modelPins.plan, request.modelPins.planFallback, 'PLAN');
317
- // A pinned build model determines the route (#1367): an explicit repo `route` still wins, but
318
- // without one the pin's harness decides, so PLAN cannot send an Anthropic-pinned build to Codex.
319
- // Two guards keep the pin from resurrecting a route the operator turned off: local-only mode
320
- // forces codex in PLAN and a pin must not undo that; and a codex pin while codex is disabled
321
- // cannot force codex — PLAN's codex→claude fallback applies and the compatibility check below
322
- // drops the pin, exactly as on main.
323
- let derivedRoute = request.preferredRoute || request.localOnly
324
- ? undefined
325
- : routeForBuildModel(ports.router.registryRef, request.modelPins.build);
326
- if (derivedRoute === 'codex' && request.codexDisabled) {
327
- log('model-override', `pinned build model ${request.modelPins.build} needs the codex route but codex is disabled — the pin cannot force it`);
328
- derivedRoute = undefined;
329
- }
330
- const pinnedRoute = request.preferredRoute ?? derivedRoute;
331
- if (derivedRoute) {
332
- log('model-override', `build route derived from pinned build model ${request.modelPins.build} → ${derivedRoute}`);
330
+ const planStartedMs = Date.now();
331
+ let planRoute;
332
+ let planSlice;
333
+ let buildDiffBase;
334
+ let builtRoute;
335
+ let builtModel;
336
+ let pinNote;
337
+ let buildMinutes;
338
+ if (request.resumeFrom) {
339
+ const resumed = request.resumeFrom;
340
+ ports.events('check')('check', `resuming #${request.issue} from CHECK on kept branch ${request.branch} — PLAN and BUILD skipped`);
341
+ route = resumed.route;
342
+ planRoute = resumed.route;
343
+ planSlice = undefined;
344
+ buildDiffBase = resumed.diffBase;
345
+ builtRoute = resumed.route;
346
+ builtModel = resumed.model;
347
+ pinNote = undefined;
348
+ buildMinutes = resumed.buildMinutes;
333
349
  }
334
- const reusablePlan = async () => {
335
- if (!ports.planReuse)
336
- return null;
337
- let hit = null;
338
- try {
339
- hit = await ports.planReuse.load(issueBodyHash(request.work.brief));
350
+ else {
351
+ await setPhase('plan');
352
+ const planModel = await preferFallbackWhenProviderIsOpen(request.modelPins.plan, request.modelPins.planFallback, 'PLAN');
353
+ // A pinned build model determines the route (#1367): an explicit repo `route` still wins, but
354
+ // without one the pin's harness decides, so PLAN cannot send an Anthropic-pinned build to Codex.
355
+ // Two guards keep the pin from resurrecting a route the operator turned off: local-only mode
356
+ // forces codex in PLAN and a pin must not undo that; and a codex pin while codex is disabled
357
+ // cannot force codex — PLAN's codex→claude fallback applies and the compatibility check below
358
+ // drops the pin, exactly as on main.
359
+ let derivedRoute = request.preferredRoute || request.localOnly
360
+ ? undefined
361
+ : routeForBuildModel(ports.router.registryRef, request.modelPins.build);
362
+ if (derivedRoute === 'codex' && request.codexDisabled) {
363
+ log('model-override', `pinned build model ${request.modelPins.build} needs the codex route but codex is disabled — the pin cannot force it`);
364
+ derivedRoute = undefined;
340
365
  }
341
- catch (err) {
342
- log('environment_warning', `could not read the plan-reuse record: ${errorMessage(err)} — planning`);
366
+ const pinnedRoute = request.preferredRoute ?? derivedRoute;
367
+ if (derivedRoute) {
368
+ log('model-override', `build route derived from pinned build model ${request.modelPins.build} → ${derivedRoute}`);
343
369
  }
344
- try {
345
- await ports.planReuse.clear();
370
+ const reusablePlan = async () => {
371
+ if (!ports.planReuse)
372
+ return null;
373
+ let hit = null;
374
+ try {
375
+ hit = await ports.planReuse.load(issueBodyHash(request.work.brief));
376
+ }
377
+ catch (err) {
378
+ log('environment_warning', `could not read the plan-reuse record: ${errorMessage(err)} — planning`);
379
+ }
380
+ try {
381
+ await ports.planReuse.clear();
382
+ }
383
+ catch (err) {
384
+ log('environment_warning', `could not clear the plan-reuse record: ${errorMessage(err)}`);
385
+ }
386
+ if (hit) {
387
+ ports.events('plan')('plan_reused', `reusing frozen plan ${request.specPath} for #${request.issue} — body unchanged since the environment release, no PLAN model call`);
388
+ }
389
+ return hit;
390
+ };
391
+ const plan = (await reusablePlan()) ??
392
+ (await planPhase({
393
+ issue: request.issue,
394
+ repo: request.repo,
395
+ worktree: ports.workspace.path,
396
+ specPath: request.specPath,
397
+ constitution,
398
+ router: ports.router,
399
+ octokit: ports.octokit,
400
+ log: ports.events('plan'),
401
+ timeoutSeconds: request.timeouts.plan,
402
+ modelOverride: planModel,
403
+ modelFallbacks: planModel === request.modelPins.plan && request.modelPins.planFallback
404
+ ? [request.modelPins.planFallback]
405
+ : undefined,
406
+ onProviderFailure: rememberProviderFailure,
407
+ branch: request.branch,
408
+ approvalGate: request.options.approvePlan ? ports.createApprovalGate?.() : undefined,
409
+ drainSteering: request.options.approvePlan ? ports.drainSteering : undefined,
410
+ codexDisabled: request.codexDisabled,
411
+ localOnly: request.localOnly,
412
+ laneId: request.lane,
413
+ workSource: request.workSource,
414
+ enforceReadiness: true,
415
+ fastPath: request.efficiency.fastPath,
416
+ enforceSizeGate: (request.sizeGateMode ?? 'file') !== 'off',
417
+ sizeGateMode: request.sizeGateMode,
418
+ maxSlices: request.maxSlices,
419
+ blockUnresolvedRegressions: request.blockUnresolvedRegressions,
420
+ updateIssueBodyOnEnrich: request.readinessUpdateIssueBody,
421
+ preferredRoute: pinnedRoute,
422
+ }));
423
+ route = plan.route;
424
+ if (!plan.ok) {
425
+ const decomposedChildren = plan.decomposed?.childIssues ?? [];
426
+ if (decomposedChildren.length > 0) {
427
+ await ports.onDecomposed?.(decomposedChildren);
428
+ const childList = decomposedChildren.map((n) => `#${n}`).join(', ');
429
+ return { state: 'escalated', reason: `issue #${request.issue} decomposed into ${childList}`, route };
430
+ }
431
+ if (plan.sliced) {
432
+ return terminalEscalated(`issue #${request.issue} sliced into ${plan.sliced.sliceCount} slice(s): ${plan.escalate ?? 'slice plan recorded'}`);
433
+ }
434
+ return terminalEscalated(`plan escalated: ${plan.escalate ?? 'unknown'}`);
346
435
  }
347
- catch (err) {
348
- log('environment_warning', `could not clear the plan-reuse record: ${errorMessage(err)}`);
436
+ const planBudget = await assertBudget('PLAN');
437
+ if (planBudget)
438
+ return planBudget;
439
+ // Same-file lane guard (#1515): before this run claims BUILD, make sure no other
440
+ // in-flight run in this repo already owns a file PLAN just said this run will touch.
441
+ if (ports.laneFileGuard && plan.designArtifact) {
442
+ const touchedFiles = touchedFilesFrom(plan.designArtifact);
443
+ const collision = await ports.laneFileGuard.findCollision(request.repo, request.issue, touchedFiles);
444
+ if (collision) {
445
+ return terminalParked('held', `issue held: #${request.issue} touches ${collision.file}, already claimed by in-flight issue #${collision.issue} — held until that lane finishes`);
446
+ }
447
+ if (touchedFiles.length > 0) {
448
+ await ports.laneFileGuard.register(request.repo, request.issue, touchedFiles);
449
+ laneFileClaimed = true;
450
+ }
349
451
  }
350
- if (hit) {
351
- ports.events('plan')('plan_reused', `reusing frozen plan ${request.specPath} for #${request.issue} — body unchanged since the environment release, no PLAN model call`);
452
+ // BUILD
453
+ await setPhase('build');
454
+ let buildSteering;
455
+ if (request.options.interactive) {
456
+ buildSteering = ports.drainSteering?.();
457
+ if (buildSteering && buildSteering.messages.length > 0) {
458
+ ports.events('build')('steering_applied', describeSteering(buildSteering));
459
+ }
352
460
  }
353
- return hit;
354
- };
355
- const plan = (await reusablePlan()) ??
356
- (await planPhase({
461
+ let breakerBlocked = false;
462
+ if (request.failover.enabled) {
463
+ const providers = [
464
+ ...new Set(ports.router
465
+ .resolveAll('build_codex')
466
+ .map((m) => ports.router.registryRef.get(m)?.provider)
467
+ .filter((p) => Boolean(p))),
468
+ ];
469
+ const gate = await gateBuildOnBreaker({ breaker: ports.breaker, providers, log: ports.events('build') });
470
+ breakerBlocked = gate.codexBlocked;
471
+ }
472
+ // PLAN already honors pinnedRoute and rewrites the spec; re-apply here so a plan port that
473
+ // ignores it (a daemon or a test double) still cannot route a pinned worker elsewhere.
474
+ let buildRoute = pinnedRoute ?? plan.route;
475
+ let buildModel = request.modelPins.build;
476
+ if (request.failover.enabled && buildRoute === 'claude') {
477
+ const primaryClaude = buildModel ?? ports.router.resolveAll('build_claude')[0];
478
+ const fallbackCodex = request.modelPins.buildFallback ?? ports.router.resolveAll('build_codex')[0];
479
+ const selected = await preferFallbackWhenProviderIsOpen(primaryClaude, fallbackCodex, 'BUILD');
480
+ if (selected && selected !== primaryClaude) {
481
+ buildRoute = 'codex';
482
+ buildModel = selected;
483
+ }
484
+ }
485
+ if (buildModel) {
486
+ const harnessId = ports.router.registryRef.getHarnessId(buildModel);
487
+ const compatible = buildRoute === 'codex'
488
+ ? ports.router.registryRef.isCodexModel(buildModel)
489
+ : buildRoute === 'opencode'
490
+ ? harnessId === 'opencode'
491
+ : harnessId === 'claude-cli';
492
+ if (!compatible) {
493
+ log('model_override_ignored', `build model ${buildModel} is incompatible with the ${buildRoute} route${buildRoute === request.preferredRoute ? ' pinned by .factory/config.json' : ''} — using that route's default worker`);
494
+ buildModel = undefined;
495
+ }
496
+ }
497
+ const build = await buildPhase({
498
+ runId,
357
499
  issue: request.issue,
358
500
  repo: request.repo,
359
501
  worktree: ports.workspace.path,
360
502
  specPath: request.specPath,
503
+ branch: request.branch,
361
504
  constitution,
505
+ route: buildRoute,
362
506
  router: ports.router,
363
- octokit: ports.octokit,
364
- log: ports.events('plan'),
365
- timeoutSeconds: request.timeouts.plan,
366
- modelOverride: planModel,
367
- modelFallbacks: planModel === request.modelPins.plan && request.modelPins.planFallback
368
- ? [request.modelPins.planFallback]
369
- : undefined,
507
+ log: ports.events('build'),
508
+ timeoutSeconds: request.timeouts.build,
509
+ skipCI: request.skipCI,
510
+ disablePublish: Boolean(request.localOnly),
511
+ publishFromBuild: request.publishFromBuild,
512
+ modelOverride: buildModel,
513
+ pinnedModel: buildModel !== undefined && buildModel === request.modelPins.build,
514
+ codexFallbackModel: request.modelPins.buildFallback ?? ports.router.resolveAll('build_codex')[0],
370
515
  onProviderFailure: rememberProviderFailure,
371
- branch: request.branch,
372
- approvalGate: request.options.approvePlan ? ports.createApprovalGate?.() : undefined,
373
- drainSteering: request.options.approvePlan ? ports.drainSteering : undefined,
374
- codexDisabled: request.codexDisabled,
516
+ sandbox: request.sandboxPolicy,
517
+ steering: buildSteering,
518
+ appPort,
519
+ appBaseUrl,
520
+ codexDisabled: request.codexDisabled || breakerBlocked,
375
521
  localOnly: request.localOnly,
522
+ autoFailover: { enabled: request.failover.enabled, fallbackModel: request.failover.fallbackModel },
523
+ onPgid,
376
524
  laneId: request.lane,
377
- workSource: request.workSource,
378
- enforceReadiness: true,
379
- fastPath: request.efficiency.fastPath,
380
- enforceSizeGate: (request.sizeGateMode ?? 'file') !== 'off',
381
- sizeGateMode: request.sizeGateMode,
382
- maxSlices: request.maxSlices,
383
- blockUnresolvedRegressions: request.blockUnresolvedRegressions,
384
- updateIssueBodyOnEnrich: request.readinessUpdateIssueBody,
385
- preferredRoute: pinnedRoute,
386
- }));
387
- route = plan.route;
388
- if (!plan.ok) {
389
- const decomposedChildren = plan.decomposed?.childIssues ?? [];
390
- if (decomposedChildren.length > 0) {
391
- await ports.onDecomposed?.(decomposedChildren);
392
- const childList = decomposedChildren.map((n) => `#${n}`).join(', ');
393
- return { state: 'escalated', reason: `issue #${request.issue} decomposed into ${childList}`, route };
394
- }
395
- if (plan.sliced) {
396
- return terminalEscalated(`issue #${request.issue} sliced into ${plan.sliced.sliceCount} slice(s): ${plan.escalate ?? 'slice plan recorded'}`);
397
- }
398
- return terminalEscalated(`plan escalated: ${plan.escalate ?? 'unknown'}`);
399
- }
400
- const planBudget = await assertBudget('PLAN');
401
- if (planBudget)
402
- return planBudget;
403
- // Same-file lane guard (#1515): before this run claims BUILD, make sure no other
404
- // in-flight run in this repo already owns a file PLAN just said this run will touch.
405
- if (ports.laneFileGuard && plan.designArtifact) {
406
- const touchedFiles = touchedFilesFrom(plan.designArtifact);
407
- const collision = await ports.laneFileGuard.findCollision(request.repo, request.issue, touchedFiles);
408
- if (collision) {
409
- return terminalParked('held', `issue held: #${request.issue} touches ${collision.file}, already claimed by in-flight issue #${collision.issue} — held until that lane finishes`);
410
- }
411
- if (touchedFiles.length > 0) {
412
- await ports.laneFileGuard.register(request.repo, request.issue, touchedFiles);
413
- laneFileClaimed = true;
414
- }
415
- }
416
- // BUILD
417
- await setPhase('build');
418
- let buildSteering;
419
- if (request.options.interactive) {
420
- buildSteering = ports.drainSteering?.();
421
- if (buildSteering && buildSteering.messages.length > 0) {
422
- ports.events('build')('steering_applied', describeSteering(buildSteering));
423
- }
424
- }
425
- let breakerBlocked = false;
426
- if (request.failover.enabled) {
427
- const providers = [
428
- ...new Set(ports.router
429
- .resolveAll('build_codex')
430
- .map((m) => ports.router.registryRef.get(m)?.provider)
431
- .filter((p) => Boolean(p))),
432
- ];
433
- const gate = await gateBuildOnBreaker({ breaker: ports.breaker, providers, log: ports.events('build') });
434
- breakerBlocked = gate.codexBlocked;
435
- }
436
- // PLAN already honors pinnedRoute and rewrites the spec; re-apply here so a plan port that
437
- // ignores it (a daemon or a test double) still cannot route a pinned worker elsewhere.
438
- let buildRoute = pinnedRoute ?? plan.route;
439
- let buildModel = request.modelPins.build;
440
- if (request.failover.enabled && buildRoute === 'claude') {
441
- const primaryClaude = buildModel ?? ports.router.resolveAll('build_claude')[0];
442
- const fallbackCodex = request.modelPins.buildFallback ?? ports.router.resolveAll('build_codex')[0];
443
- const selected = await preferFallbackWhenProviderIsOpen(primaryClaude, fallbackCodex, 'BUILD');
444
- if (selected && selected !== primaryClaude) {
445
- buildRoute = 'codex';
446
- buildModel = selected;
447
- }
448
- }
449
- if (buildModel) {
450
- const harnessId = ports.router.registryRef.getHarnessId(buildModel);
451
- const compatible = buildRoute === 'codex'
452
- ? ports.router.registryRef.isCodexModel(buildModel)
453
- : buildRoute === 'opencode'
454
- ? harnessId === 'opencode'
455
- : harnessId === 'claude-cli';
456
- if (!compatible) {
457
- log('model_override_ignored', `build model ${buildModel} is incompatible with the ${buildRoute} route${buildRoute === request.preferredRoute ? ' pinned by .factory/config.json' : ''} — using that route's default worker`);
458
- buildModel = undefined;
459
- }
460
- }
461
- const build = await buildPhase({
462
- runId,
463
- issue: request.issue,
464
- repo: request.repo,
465
- worktree: ports.workspace.path,
466
- specPath: request.specPath,
467
- branch: request.branch,
468
- constitution,
469
- route: buildRoute,
470
- router: ports.router,
471
- log: ports.events('build'),
472
- timeoutSeconds: request.timeouts.build,
473
- skipCI: request.skipCI,
474
- disablePublish: Boolean(request.localOnly),
475
- publishFromBuild: request.publishFromBuild,
476
- modelOverride: buildModel,
477
- pinnedModel: buildModel !== undefined && buildModel === request.modelPins.build,
478
- codexFallbackModel: request.modelPins.buildFallback ?? ports.router.resolveAll('build_codex')[0],
479
- onProviderFailure: rememberProviderFailure,
480
- sandbox: request.sandboxPolicy,
481
- steering: buildSteering,
482
- appPort,
483
- appBaseUrl,
484
- codexDisabled: request.codexDisabled || breakerBlocked,
485
- localOnly: request.localOnly,
486
- autoFailover: { enabled: request.failover.enabled, fallbackModel: request.failover.fallbackModel },
487
- onPgid,
488
- laneId: request.lane,
489
- });
490
- route = build.route;
491
- if (!build.ok) {
492
- if (build.reason === 'no_diff') {
493
- return terminalParked('fail', 'build produced no diff against the base ref — no implementation was produced');
494
- }
495
- if (build.reason === 'junk_only_diff') {
496
- return terminalParked('fail', 'build changed only generated/cache files (e.g. __pycache__) — no implementation was produced');
525
+ parentBaseRef: request.parentBaseRef,
526
+ });
527
+ route = build.route;
528
+ if (!build.ok) {
529
+ if (build.reason === 'no_diff') {
530
+ return terminalParked('fail', 'build produced no diff against the base ref — no implementation was produced');
531
+ }
532
+ if (build.reason === 'junk_only_diff') {
533
+ return terminalParked('fail', 'build changed only generated/cache files (e.g. __pycache__) — no implementation was produced');
534
+ }
535
+ return terminalEscalated(`build escalated: ${build.escalate ?? 'unknown'}`);
497
536
  }
498
- return terminalEscalated(`build escalated: ${build.escalate ?? 'unknown'}`);
537
+ const buildBudget = await assertBudget('BUILD');
538
+ if (buildBudget)
539
+ return buildBudget;
540
+ // #2511: say so when a permitted failover built with something other than the user's pin.
541
+ const pinnedBuild = request.modelPins.build;
542
+ pinNote =
543
+ pinnedBuild !== undefined && build.model !== pinnedBuild
544
+ ? `build pin ${pinnedBuild} not used (${build.failoverReason ?? 'overridden'}); built with ${build.model}`
545
+ : undefined;
546
+ planRoute = plan.route;
547
+ planSlice = plan.slice;
548
+ buildDiffBase = build.diffBase;
549
+ builtRoute = build.route;
550
+ builtModel = build.model;
551
+ buildMinutes = Math.max(1, Math.round((Date.now() - planStartedMs) / 60_000));
499
552
  }
500
- const buildBudget = await assertBudget('BUILD');
501
- if (buildBudget)
502
- return buildBudget;
503
- // #2511: say so when a permitted failover built with something other than the user's pin.
504
- const pinnedBuild = request.modelPins.build;
505
- const pinNote = pinnedBuild !== undefined && build.model !== pinnedBuild
506
- ? `build pin ${pinnedBuild} not used (${build.failoverReason ?? 'overridden'}); built with ${build.model}`
507
- : undefined;
508
553
  // CHECK
509
554
  await setPhase('check');
510
555
  const priorFailureSignature = await ports.reworkHistory?.priorSignature(request.issue);
@@ -527,9 +572,10 @@ export async function runIssue(request, policy, ports) {
527
572
  appBaseUrl,
528
573
  onPgid,
529
574
  priorFailureSignature,
530
- diffBase: request.localOnly ? runStartDiffBase : build.diffBase,
531
- reworkRoute: build.route,
532
- reworkModel: build.model,
575
+ diffBase: request.localOnly ? runStartDiffBase : buildDiffBase,
576
+ parentBaseRef: request.parentBaseRef,
577
+ reworkRoute: builtRoute,
578
+ reworkModel: builtModel,
533
579
  laneId: request.lane,
534
580
  onActivity: ports.onActivity,
535
581
  logsDir: request.logsDir,
@@ -561,12 +607,36 @@ export async function runIssue(request, policy, ports) {
561
607
  });
562
608
  }
563
609
  }
610
+ const clearCheckResume = async () => {
611
+ if (!request.resumeFrom)
612
+ return;
613
+ try {
614
+ await ports.checkResume?.clear();
615
+ }
616
+ catch (err) {
617
+ log('environment_warning', `could not clear the check-resume point: ${errorMessage(err)}`);
618
+ }
619
+ };
564
620
  const checkBudget = await assertBudget('CHECK');
565
621
  if (checkBudget)
566
622
  return checkBudget;
567
623
  if (!check.passed) {
568
624
  if (check.environment) {
569
625
  const env = check.environment;
626
+ if (env.cause === 'config') {
627
+ // A config failure fails the same way on every requeue (#2583): park with file, line and fix.
628
+ await clearCheckResume();
629
+ const failure = check.failureSignature !== undefined
630
+ ? { failureSignature: check.failureSignature, failingChecks: env.failingChecks }
631
+ : undefined;
632
+ return terminalParked('config', configParkMessage({
633
+ issue: request.issue,
634
+ failure: env,
635
+ summary: check.summary,
636
+ constitution,
637
+ configFile: request.repoConfigFile,
638
+ }), failure);
639
+ }
570
640
  if (!request.localOnly) {
571
641
  const [owner, repoName] = request.repo.split('/');
572
642
  try {
@@ -581,13 +651,38 @@ export async function runIssue(request, policy, ports) {
581
651
  log('environment_warning', `could not post the environment-failure comment: ${errorMessage(err)}`);
582
652
  }
583
653
  }
584
- log('environment-released', `every failing checker (${env.failingChecks.join(', ')}) also fails on base ${env.baseSha.slice(0, 8)} — no rework, releasing #${request.issue} to queued`);
585
- if (ports.planReuse && !plan.slice) {
654
+ let preserve = ports.checkResume !== undefined && !request.localOnly && !planSlice;
655
+ let fixTarget = '';
656
+ if (preserve && ports.checkResume) {
657
+ fixTarget = preservedFixTarget(env, constitution, request.repoConfigFile);
658
+ try {
659
+ await ports.checkResume.record({
660
+ issue: request.issue,
661
+ branch: request.branch,
662
+ worktree: ports.workspace.path,
663
+ diffBase: buildDiffBase,
664
+ route: builtRoute,
665
+ model: builtModel,
666
+ buildMinutes,
667
+ fixTarget,
668
+ preservedAt: new Date().toISOString(),
669
+ });
670
+ }
671
+ catch (err) {
672
+ log('environment_warning', `could not record the check-resume point: ${errorMessage(err)}`);
673
+ preserve = false;
674
+ }
675
+ }
676
+ log('environment-released', `every failing checker (${env.failingChecks.join(', ')}) also fails on base ${env.baseSha.slice(0, 8)} — no rework, releasing #${request.issue} to queued` +
677
+ (preserve
678
+ ? ` — ${formatPreservedBuildMessage({ issue: request.issue, branch: request.branch, buildMinutes, fixTarget })}`
679
+ : ''));
680
+ if (ports.planReuse && !planSlice && !request.resumeFrom) {
586
681
  try {
587
682
  await ports.planReuse.record({
588
683
  bodyHash: issueBodyHash(request.work.brief),
589
684
  baseSha: env.baseSha,
590
- route: plan.route,
685
+ route: planRoute,
591
686
  releasedAt: new Date().toISOString(),
592
687
  });
593
688
  }
@@ -604,6 +699,7 @@ export async function runIssue(request, policy, ports) {
604
699
  baseSha: env.baseSha,
605
700
  failingChecks: env.failingChecks,
606
701
  ...(check.failureSignature !== undefined ? { failureSignature: check.failureSignature } : {}),
702
+ ...(preserve ? { buildPreserved: true } : {}),
607
703
  };
608
704
  }
609
705
  const checkFailure = check.failureSignature !== undefined
@@ -625,8 +721,10 @@ export async function runIssue(request, policy, ports) {
625
721
  const parkedMessage = showstoppers.length > 0
626
722
  ? `${message}\nremaining showstoppers:\n${showstoppers.map((s) => `- ${s}`).join('\n')}`
627
723
  : message;
724
+ await clearCheckResume();
628
725
  return terminalParked(reason, parkedMessage, checkFailure);
629
726
  }
727
+ await clearCheckResume();
630
728
  // Clean check — clear stale cross-run history so a future, genuinely different
631
729
  // failure is not mistaken for a repeat of one already resolved.
632
730
  await ports.reworkHistory?.clear(request.issue);
@@ -654,7 +752,8 @@ export async function runIssue(request, policy, ports) {
654
752
  if (request.prClassifier) {
655
753
  reviewRouting = await resolveReviewRouting({
656
754
  worktree: ports.workspace.path,
657
- fallbackBaseRef: build.diffBase,
755
+ fallbackBaseRef: buildDiffBase,
756
+ parentBaseRef: request.parentBaseRef,
658
757
  rules: request.prClassifier.rules,
659
758
  readChanges: ports.readReviewFloorChanges,
660
759
  });
@@ -678,7 +777,7 @@ export async function runIssue(request, policy, ports) {
678
777
  // decided, so it cannot change the label or merge routing.
679
778
  const shadowInput = {
680
779
  worktree: ports.workspace.path,
681
- fallbackBaseRef: build.diffBase,
780
+ fallbackBaseRef: buildDiffBase,
682
781
  issueTitle: request.work.title,
683
782
  issueBody: request.work.brief,
684
783
  specPath: request.specPath,
@@ -735,7 +834,7 @@ export async function runIssue(request, policy, ports) {
735
834
  laneId: request.lane,
736
835
  reviewRouting,
737
836
  ...(pinNote ? { pinNotes: [pinNote] } : {}),
738
- slice: plan.slice ? { index: plan.slice.index, count: plan.slice.plan.slices.length } : undefined,
837
+ slice: planSlice ? { index: planSlice.index, count: planSlice.plan.slices.length } : undefined,
739
838
  });
740
839
  if (!ship.ok) {
741
840
  const reason = ship.denied ? 'escalate' : ship.ciOutcome === 'infra-failure' ? 'ci-infra' : 'fail';
@@ -746,8 +845,8 @@ export async function runIssue(request, policy, ports) {
746
845
  : 'ship phase failed';
747
846
  return terminalParked(reason, message);
748
847
  }
749
- if (plan.slice) {
750
- const { index, plan: slicePlan } = plan.slice;
848
+ if (planSlice) {
849
+ const { index, plan: slicePlan } = planSlice;
751
850
  const count = slicePlan.slices.length;
752
851
  if (ship.prNumber === undefined) {
753
852
  log('ship', `slice ${index}/${count} shipped with no PR number — slice plan not updated`);
@@ -759,9 +858,9 @@ export async function runIssue(request, policy, ports) {
759
858
  octokit: ports.octokit,
760
859
  repo: request.repo,
761
860
  issue: request.issue,
762
- plan: withSliceState(slicePlan, index, state, ship.prNumber),
861
+ plan: withSliceState(slicePlan, index, state, ship.prNumber, request.branch),
763
862
  });
764
- log('ship', `recorded slice ${index}/${count} as ${state} (PR #${ship.prNumber}) in the slice plan comment`);
863
+ log('ship', `recorded slice ${index}/${count} as ${state} (PR #${ship.prNumber}, branch ${request.branch}) in the slice plan comment`);
765
864
  }
766
865
  catch (err) {
767
866
  return terminalParked('fail', `slice ${index}/${count} PR #${ship.prNumber} opened but recording it in the slice plan comment failed: ${errorMessage(err)}`);
@@ -782,7 +881,7 @@ export async function runIssue(request, policy, ports) {
782
881
  branch: request.branch,
783
882
  reworkRounds,
784
883
  prNumber: ship.prNumber,
785
- ...(plan.slice ? { slice: { index: plan.slice.index, count: plan.slice.plan.slices.length } } : {}),
884
+ ...(planSlice ? { slice: { index: planSlice.index, count: planSlice.plan.slices.length } } : {}),
786
885
  };
787
886
  }
788
887
  catch (err) {