@cat-factory/orchestration 0.110.0 → 0.112.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 { ConflictError, DEFAULT_RISK_POLICY, failureKindFromHarnessCause, getErrorMessage, getErrorReason, getProvider, INITIATIVE_ANALYST_AGENT_KIND, INITIATIVE_COMMITTER_AGENT_KIND, INITIATIVE_PLANNER_AGENT_KIND, isAsyncAgentExecutor, NotFoundError, parseLocalModelId, recordGateAttempt, registeredGateFactories, registeredStepResolverFactories, requireProvider, RunContendedError, sameSubtasks, } from '@cat-factory/kernel';
1
+ import { ConflictError, DEFAULT_RISK_POLICY, failureKindFromHarnessCause, FIXER_AGENT_KIND, getErrorMessage, getErrorReason, getProvider, INITIATIVE_ANALYST_AGENT_KIND, INITIATIVE_COMMITTER_AGENT_KIND, INITIATIVE_PLANNER_AGENT_KIND, isAsyncAgentExecutor, NotFoundError, parseLocalModelId, recordGateAttempt, registeredGateFactories, registeredStepResolverFactories, requireProvider, RunContendedError, sameSubtasks, } from '@cat-factory/kernel';
2
2
  import { frameProfile, frontendOriginsForService, parseBlueprintService, parseSpecDoc, resolveAprioriWorkingBranch, } from '@cat-factory/contracts';
3
3
  import { blueprintPostOp, commitInitiativeTracker, FORK_PROPOSER_KIND, PR_REVIEWER_KIND, hasTrait, isCompanionKind, isContainerBackedCompanion, INTERVIEW_GATE_TRAIT, moduleSlug, runRepoOps, specPostOp, TASK_ESTIMATOR_AGENT_KIND, } from '@cat-factory/agents';
4
4
  import { DEPLOYER_AGENT_KIND, isDeployStep } from '@cat-factory/integrations';
@@ -19,6 +19,7 @@ import { MergeResolver } from './MergeResolver.js';
19
19
  import { ReviewGateController } from './ReviewGateController.js';
20
20
  import { ForkDecisionController } from './ForkDecisionController.js';
21
21
  import { PrReviewController, PR_REVIEW_STEP_KIND } from './PrReviewController.js';
22
+ import { buildPrReviewPost, renderPrReviewFixerFeedback } from './prReview.logic.js';
22
23
  import { DEFAULT_FORK_MAX_CHAT_TURNS, forkPhasePending, resolveForkTriState, shouldProposeForkAuto, } from './forkDecision.logic.js';
23
24
  import { RunStateMachine } from './RunStateMachine.js';
24
25
  import { StepGraph } from './StepGraph.js';
@@ -58,6 +59,19 @@ function parseRepoFromPullUrl(url) {
58
59
  return undefined;
59
60
  return { owner: match[1], repo: match[2] };
60
61
  }
62
+ /**
63
+ * The PR number a `review` task targets: the explicit `prNumber` field wins, else parse it from
64
+ * the `prUrl` (`…/pull/42` on GitHub, `…/merge_requests/42` on GitLab). Undefined when neither
65
+ * yields one — the PR-review `fix`/`post` resolutions then report the PR unresolvable.
66
+ */
67
+ function reviewPrNumber(block) {
68
+ const fields = block?.taskTypeFields;
69
+ if (typeof fields?.prNumber === 'number')
70
+ return fields.prNumber;
71
+ const url = fields?.prUrl?.trim();
72
+ const match = url ? /\/(?:pull|merge_requests)\/(\d+)/.exec(url) : null;
73
+ return match ? Number(match[1]) : undefined;
74
+ }
61
75
  /**
62
76
  * The `peerEnvUrls` provision input for the frame about to be provisioned: a comma-joined set of
63
77
  * `slug=url` pairs for every target frame whose env is ALREADY ready this run — so a later
@@ -215,7 +229,7 @@ export class RunDispatcher {
215
229
  * what the dispatch chain falls through to; all the deterministic / gate / inline-review
216
230
  * kinds are claimed earlier by their own handlers (see {@link buildStepHandlerRegistry}).
217
231
  */
218
- async handleAgentStep(ctx, dispatchKind) {
232
+ async handleAgentStep(ctx, dispatchKind, augmentContext) {
219
233
  const { workspaceId, instance, step, block, isFinalStep, options } = ctx;
220
234
  // Async (container) steps don't block: dispatch the job and park. The durable
221
235
  // driver polls `pollAgentJob` between sleeps so the run can span far longer
@@ -228,6 +242,10 @@ export class RunDispatcher {
228
242
  // job as a HELPER off the coder step (Phase A). The completion still records against the
229
243
  // coder step, and the fork-proposal interceptor keys on `step.agentKind` + the fork state.
230
244
  const context = await this.contextBuilder.buildContext(workspaceId, instance, step, isFinalStep, block, dispatchKind ? { agentKind: dispatchKind } : undefined);
245
+ // A caller re-dispatching this step under an overriding kind can fold extra context in
246
+ // (e.g. the PR-review `fix` resolution points the Fixer at the reviewed PR's head branch and
247
+ // hands it the selected findings). Runs before pre-ops / dispatch so the job body sees it.
248
+ augmentContext?.(context);
231
249
  // A registered custom kind's PRE-ops run deterministic backend repo work before the
232
250
  // agent dispatches (e.g. read a baseline `spec/` shard into the prompt). Gated on the
233
251
  // step not having dispatched yet so a Workflows replay (jobId already set) doesn't
@@ -2280,6 +2298,19 @@ export class RunDispatcher {
2280
2298
  canHandle: ({ step, block }) => forkPhasePending(step, resolveForkTriState(block.agentConfig)),
2281
2299
  handle: (ctx) => this.handleForkDecisionPhase(ctx),
2282
2300
  },
2301
+ // The PR deep-review RESOLUTION phase (PR 3): after the human resolved a parked review with
2302
+ // `fix` / `post`, `PrReviewController.resolve` re-armed this `pr-reviewer` step and woke the
2303
+ // driver. Claim it by the re-armed status so it re-dispatches as the Fixer (`fixing`) or
2304
+ // posts the selected findings as inline PR comments (`posting`) — never the generic
2305
+ // pr-reviewer clone the fallthrough would run. A `reviewing`/`awaiting_selection`/resolved
2306
+ // step falls through (this handler doesn't claim it). See {@link handlePrReviewResolution}.
2307
+ {
2308
+ kind: 'pr-review-resolution',
2309
+ order: 175,
2310
+ canHandle: ({ step }) => step.agentKind === PR_REVIEW_STEP_KIND &&
2311
+ (step.prReview?.status === 'fixing' || step.prReview?.status === 'posting'),
2312
+ handle: (ctx) => this.handlePrReviewResolution(ctx),
2313
+ },
2283
2314
  // The generic container/inline-agent step — claims every step no more-specific handler
2284
2315
  // did. Highest order so it always runs last. See {@link handleAgentStep}.
2285
2316
  {
@@ -2903,6 +2934,122 @@ export class RunDispatcher {
2903
2934
  // Dispatch (or re-attach to) the proposer as a helper off this coder step.
2904
2935
  return this.handleAgentStep(ctx, FORK_PROPOSER_KIND);
2905
2936
  }
2937
+ /**
2938
+ * Drive a re-armed PR-review step's RESOLUTION (PR 3). The human resolved a parked review with
2939
+ * `fix` or `post`; {@link PrReviewController.resolve} re-armed this step and woke the driver.
2940
+ * - `fixing`: dispatch the Fixer against the reviewed PR's head branch with the selected
2941
+ * findings folded in (parks on the job; its completion marks the review `done`).
2942
+ * - `posting`: publish the selected findings as inline PR review comments, then finish the step.
2943
+ */
2944
+ async handlePrReviewResolution(ctx) {
2945
+ const { step } = ctx;
2946
+ if (step.prReview?.status === 'posting')
2947
+ return this.postPrReview(ctx);
2948
+ return this.dispatchPrReviewFixer(ctx);
2949
+ }
2950
+ /**
2951
+ * Dispatch the Fixer for a PR-review `fix` resolution. A `review` task carries no own work
2952
+ * branch — it reviews an EXISTING PR — so resolve the PR's head branch (via the checkout-free
2953
+ * `RepoFiles`) and point the Fixer's clone/push at it: fold a synthetic `pullRequest` + an
2954
+ * apriori WORKING branch into the dispatch context so the shared `container-coding` +
2955
+ * `clone:{branch:'pr'}` fixer body clones + pushes that branch (no new PR), and hand it the
2956
+ * selected findings as a prior output (the same injection point the gate helpers use). Fails
2957
+ * the run loudly when the PR branch can't be resolved (nothing to push to) rather than pushing
2958
+ * blind. On a replay (jobId already set) it re-attaches without re-resolving.
2959
+ */
2960
+ async dispatchPrReviewFixer(ctx) {
2961
+ const { workspaceId, instance, step, block } = ctx;
2962
+ const review = step.prReview;
2963
+ const selected = (review.findings ?? []).filter((f) => review.selectedFindingIds?.includes(f.id));
2964
+ let headRef = null;
2965
+ let prNumber;
2966
+ if (!step.jobId) {
2967
+ prNumber = reviewPrNumber(block);
2968
+ const runRepo = prNumber != null ? await this.resolveRunRepoContext?.(workspaceId, block.id) : null;
2969
+ const repo = runRepo?.repo;
2970
+ headRef =
2971
+ prNumber != null && repo?.pullRequestHeadRef
2972
+ ? await this.runInitiatorScope(instance.initiatedBy, () => repo.pullRequestHeadRef(prNumber))
2973
+ : null;
2974
+ if (prNumber == null || !headRef) {
2975
+ return {
2976
+ kind: 'job_failed',
2977
+ failureKind: 'preflight',
2978
+ error: "Can't resolve the reviewed pull request's head branch to push fixes to. The " +
2979
+ "'fix' resolution needs a same-repo pull request on this service's linked repository " +
2980
+ '(a cross-repo or fork PR is not yet supported — post the findings as comments instead).',
2981
+ };
2982
+ }
2983
+ }
2984
+ const resolvedHeadRef = headRef;
2985
+ const resolvedPrNumber = prNumber;
2986
+ return this.handleAgentStep(ctx, FIXER_AGENT_KIND, (context) => {
2987
+ if (resolvedHeadRef && resolvedPrNumber != null) {
2988
+ context.block.pullRequest = {
2989
+ number: resolvedPrNumber,
2990
+ branch: resolvedHeadRef,
2991
+ url: review.prUrl ?? '',
2992
+ };
2993
+ // Build inside the PR head branch (probed, never created) so the work-branch machinery
2994
+ // targets it rather than minting a stray `cat-factory/<blockId>` off base.
2995
+ context.aprioriBranches = [{ name: resolvedHeadRef, mode: 'working' }];
2996
+ }
2997
+ context.priorOutputs = [
2998
+ ...context.priorOutputs,
2999
+ { agentKind: FIXER_AGENT_KIND, output: renderPrReviewFixerFeedback(selected) },
3000
+ ];
3001
+ });
3002
+ }
3003
+ /**
3004
+ * Post a PR-review `post` resolution: publish the human-selected findings as a single advisory
3005
+ * (`COMMENT`) inline review on the reviewed PR via the checkout-free `RepoFiles.createReview`,
3006
+ * then finish the step. At-most-once: the `pendingPrReviewPost` marker is consumed (cleared +
3007
+ * persisted) BEFORE the (side-effecting) post so a Workflows retry can't submit it twice. When
3008
+ * no VCS review write is wired (tests / no GitHub) the findings are still recorded and the step
3009
+ * finishes — the review pipeline never reaches this without GitHub in practice.
3010
+ *
3011
+ * If `createReview` itself throws (GitHub rejects the batched review — e.g. a finding anchored
3012
+ * to a line outside the PR diff 422s the WHOLE review — or a transient network/5xx error), the
3013
+ * marker is already consumed, so a driver retry would NOT re-post. Rather than silently
3014
+ * completing the step as `done` with nothing actually posted (the human would believe the
3015
+ * comments landed), fail the step LOUDLY so the failure surfaces on the board — mirroring the
3016
+ * `fix` resolution's loud preflight failure. `post` is a fallback resolution, so a hard failure
3017
+ * is visible and the human can re-run or choose `fix`/`finish` instead.
3018
+ */
3019
+ async postPrReview(ctx) {
3020
+ const { workspaceId, instance, step, block, isFinalStep } = ctx;
3021
+ const review = step.prReview;
3022
+ const selected = (review.findings ?? []).filter((f) => review.selectedFindingIds?.includes(f.id));
3023
+ let posted = false;
3024
+ if (step.pendingPrReviewPost) {
3025
+ step.pendingPrReviewPost = null;
3026
+ await this.runStateMachine.casPersist(workspaceId, instance);
3027
+ const prNumber = reviewPrNumber(block);
3028
+ const runRepo = prNumber != null ? await this.resolveRunRepoContext?.(workspaceId, block.id) : null;
3029
+ const repo = runRepo?.repo;
3030
+ if (prNumber != null && repo?.createReview) {
3031
+ try {
3032
+ await this.runInitiatorScope(instance.initiatedBy, () => repo.createReview(prNumber, buildPrReviewPost(selected, review.summary)));
3033
+ }
3034
+ catch (error) {
3035
+ return {
3036
+ kind: 'job_failed',
3037
+ failureKind: 'agent',
3038
+ error: `Failed to post the ${selected.length} selected finding` +
3039
+ `${selected.length === 1 ? '' : 's'} as a pull-request review: ${getErrorMessage(error)}. ` +
3040
+ 'GitHub rejects a review whose inline comment anchors a line outside the PR diff — ' +
3041
+ "try the 'fix' resolution, or re-run to post again.",
3042
+ };
3043
+ }
3044
+ posted = true;
3045
+ }
3046
+ }
3047
+ step.prReview = { ...review, status: 'done' };
3048
+ const output = posted
3049
+ ? `Posted ${selected.length} review comment${selected.length === 1 ? '' : 's'} to the pull request.`
3050
+ : `Recorded ${selected.length} selected finding${selected.length === 1 ? '' : 's'}.`;
3051
+ return this.recordStepResult(workspaceId, instance, step, isFinalStep, { output });
3052
+ }
2906
3053
  /** Read a run's active implementation-fork decision state, or null. */
2907
3054
  getForkDecision(workspaceId, executionId) {
2908
3055
  return this.forkDecisionController.getActive(workspaceId, executionId);