diff --git a/docs/plans/2026-09-10-issue-189-integrate-planning-phases-and-require-implementation-pull-requests.md b/docs/plans/2026-09-10-issue-189-integrate-planning-phases-and-require-implementation-pull-requests.md new file mode 100644 index 00000000..f6d13e59 --- /dev/null +++ b/docs/plans/2026-09-10-issue-189-integrate-planning-phases-and-require-implementation-pull-requests.md @@ -0,0 +1,1451 @@ +# Planning Phase Integration and Required Implementation Pull Requests Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Route fresh `run-once` Issue runs through `planning-pr-v1`, stop at +required planning reviews or `--plan-only`, and finish only after independently +validating a live implementation pull request. + +**Architecture:** Keep `runOneIssue()` as a small workflow router and move the +current orchestration unchanged behind a legacy entry point. A separate planning +pipeline acquires the strict issue lock, revalidates issue and state, and +invokes a focused phase coordinator built from issue #188's artifact, +publication, and reconciliation services. The implementation path records strict +local, remote, host, and ancestry proof before a planning-state-aware finish +wrapper can run handoff, hook, workspace cleanup, or lifecycle effects. + +**Tech Stack:** TypeScript, Node.js 22.19+, Node's `node:test` runner, Git CLI +through `CommandRunner`, provider-neutral `PullRequestHost`, GitHub `gh`, +Forgejo `tea`, strict `planning-pr-v1` JSON state, Prettier, ESLint, and +dependency-cruiser. + +**Spec:** +`docs/specs/2026-09-09-issue-189-integrate-planning-phases-and-require-implementation-pull-requests-design.md` + +## Prerequisite + +Issue #188 must be merged into the implementation base before Task 1. Its +current branch, +`origin/agent/issue-188-execute-and-publish-planning-pull-request-phases`, +defines the APIs this plan consumes: `runPlanningPhaseArtifacts()`, +`publishPlanningPhase()`, `reconcilePlanningPhase()`, `PlanningPublicationGit`, +`createPullRequestHost()`, and the publication-aware strict phase variants. +Rebase rather than reimplementing those services. If the merged API differs, +update call sites and type names mechanically while preserving the interfaces +and invariants below. + +## Global Constraints + +- Keep `runOneIssue()` as the exported facade. Route recognized unfinished + legacy state to the legacy pipeline, and route valid active `planning-pr-v1` + state or fresh eligible issues to the new planning pipeline. +- Never interpret malformed, unknown, or unsupported planning state as legacy or + fresh state. If planning and legacy state are both active for one issue, fail + closed before labels, Git, Pi, or host mutation. +- Select in this order: explicit `--issue`, active nonblocked planning state, + resumable legacy state with `in-progress`, then fresh ready issue. Within each + bucket, preserve configured priority-label order and break ties by issue + number. +- Treat selection before the planning lock as advisory. After acquiring the + ownership-ID lock, re-read the issue, planning state, and legacy state before + the first mutation. Retry a fresh-state race once with the authoritative saved + `runId`; never select another issue in the same Run attempt. +- Map an `active` lock conflict to `stopped/issue-locked` with no mutation. Map + `stale`, `unverifiable`, and `malformed` conflicts to blocked operator + conditions; never replace or remove the existing lock. +- Snapshot `workflow.specApproval.required` and `workflow.planApproval.required` + only when fresh state is created. Approval labels and issue artifact comments + do not authorize or advance the new workflow. +- Implement all four gate sequences exactly as recorded in + `planningPhasePlan()`. An implementation phase is never completed from an + empty `artifactKinds` array. +- Return `review-pending` for an open required spec or plan pull request. It is + a normal warning result and must precede a `plan-only` stop. +- For `--plan-only`, run and checkpoint missing planning artifacts assigned to + the implementation phase, but do not run implementation code, push the + implementation branch, or create its pull request. Do not create an + implementation workspace when no assigned artifact is missing. +- Preserve the implementation development-environment, todo, validation, review, + visual-evidence, Pi repair, and finalization gates. Pass a copied Git policy + with `allowDirectLand: false` and reject every `merged` result. +- Accept implementation success only after proving the exact target repository, + saved base branch, allowed push repository, owned head branch, equal + local/remote/host head OID, open status, canonical URL/reference, one exact + final implementation marker, effective top-level `Closes #`, base + ancestry, and reported-commit ancestry. +- Persist the validated implementation `pull-request-open` checkpoint before + cost publication, visual-evidence validation, handoff, cleanup hook, + worktree/branch removal, or done-label mutation. +- Preserve the implementation workspace and active planning state on validation + failure. Resume finish effects from exact strict checkpoints; never force + remove, reset, clean, or adopt uncertain local state. +- Keep legacy state, lease, labels, artifact comments, approval behavior, + direct-land policy, results, and finish ordering unchanged. +- Keep orchestration, state validation, pull-request validation, and output + formatting in separate modules. Prefer modules under roughly 200 meaningful + lines; split pure parsing/progress logic from effectful orchestration when a + module grows beyond that boundary. +- Do not add configuration keys, dependencies, installed skill-pack changes, + generated configuration, examples, or user documentation. Do not add live + provider acceptance scenarios. + +--- + +## File and Module Map + +### Strict workflow state + +- Modify `src/workflow/planning-state-types.ts` to give implementation phases + exact agent evidence, publication, finish checkpoints, and terminal + `implementation-pull-request` completion variants. +- Modify `src/workflow/planning-state-validation.ts` to parse those exact + shapes, forbid implementation evidence on `spec`/`plan`, and forbid + remote-base or merged-planning completion for `implementation`. +- Modify `src/workflow/planning-state-transitions.ts` to allow only the + implementation checkpoint sequence and immutable evidence. +- Modify `src/workflow/planning-state.test.ts` and + `src/workflow/planning-state-publication-regressions.test.ts` for exact + schema, progress-prefix, transition, cleanup, and recovery regressions. + +### Public result contract + +- Modify `src/cli/commands/run-once/types.ts` for internal `review-pending` and + `stopped` variants retaining `IssueSummary`. +- Modify `src/cli/commands/run-once/result-summary.ts`, `result-output.ts`, and + `terminal-result.ts` for redirected JSON, JSONL, warning rendering, and exit + code `0`. +- Modify their focused tests plus `src/cli/commands/run-once/args.test.ts`, + which exercises the exported summary helper from `main.ts`. + +### Pull-request proof and implementation execution + +- Create `src/workflow/planning-implementation-body.ts` and its test for the + effective top-level closing reference. +- Modify `src/workflow/planning-pull-request-validation.ts` and its test so the + shared identity/marker validator accepts `implementation` without changing + planning reconciliation semantics. +- Modify `src/cli/commands/run-once/prompts.ts` and `prompts.test.ts` to require + the exact implementation marker while leaving legacy prompts byte-for-byte + compatible when the new option is absent. +- Create `src/cli/commands/run-once/development-environment-agent.ts` and + `implementation-agent.ts` as state-neutral Pi cores; keep legacy state and + lifecycle effects in the existing wrappers. +- Create `src/cli/commands/run-once/implementation-task-progress.ts` for task + label normalization and progress-step switching, keeping the extracted + implementation core focused. +- Create `src/cli/commands/run-once/planning-implementation-validation.ts` and + its test for local, remote, host, URL, repository, branch, status, marker, + closing-reference, and ancestry proof. +- Create `src/cli/commands/run-once/planning-implementation.ts` and its test to + run the core, force pull-request-only policy, reject `merged`, and checkpoint + branch and validated pull-request evidence. + +### Planning orchestration and compatibility routing + +- Create `src/cli/commands/run-once/planning-finish.ts` and its test for strict + finish ordering and idempotent checkpoint resume. +- Create `src/cli/commands/run-once/planning-phase-coordinator.ts` and its test + for the four gate matrices, reconciliation, base satisfaction, review stops, + and `--plan-only`. +- Create `src/cli/commands/run-once/planning-selection.ts` and its test for + workflow-aware advisory priority and fail-closed state classification. +- Create `src/cli/commands/run-once/planning-pipeline.ts` and its test for lock + acquisition, post-lock revalidation, lifecycle effects, dependency + construction, and lock release. +- Create `src/cli/commands/run-once/pipeline-legacy.ts`; mechanically move the + current implementation there and leave `pipeline.ts` as the public router. +- Add `src/cli/commands/run-once/planning-pipeline-scenarios.test.ts` for full + recording-seam scenarios and extend existing legacy pipeline scenario tests + only where needed to prove the routing boundary. + +## Public Interfaces + +Use these names consistently across tasks: + +```ts +// src/workflow/planning-state-types.ts +export type PlanningImplementationVisualEvidence = Readonly<{ + screenshotPath: string; + caption?: string; + referencePaths?: readonly string[]; + url?: string; +}>; + +export type PlanningRunCostEvidence = Readonly<{ + stages: readonly Readonly<{ + stage: string; + models: readonly Readonly<{ + model: string; + promptTokens: number; + outputTokens: number; + estimatedCostUsd: number; + }>[]; + promptTokens: number; + outputTokens: number; + estimatedCostUsd: number; + }>[]; + promptTokens: number; + outputTokens: number; + estimatedCostUsd: number; +}>; + +export type PlanningImplementationAgentEvidence = Readonly<{ + status: "pr-created"; + prUrl: string; + branch: string; + commits: readonly string[]; + validation: readonly string[]; + reviewSummary?: string; + landingDecision?: string; + visualEvidence: readonly PlanningImplementationVisualEvidence[]; + runCostReport?: PlanningRunCostEvidence; +}>; + +export type PlanningImplementationFinishCheckpoints = Readonly<{ + costPublicationCompleted?: true; + visualEvidenceValidated?: true; + handoffCommentPosted?: true; + cleanupHookCompleted?: true; + doneLabelEnsured?: true; + doneLabelApplied?: true; +}>; + +export type ImplementationBranchPushedPlanningPhase = Readonly<{ + kind: "implementation"; + status: "branch-pushed"; + base: PlanningBaseEvidence; + workspace: PlanningWorkspaceOwnership<{ state: "ready" }>; + artifacts: readonly PlanningArtifactEvidence[]; + publication: PlanningPublicationEvidence; + implementation: PlanningImplementationAgentEvidence; +}>; + +export type ImplementationPullRequestOpenPlanningPhase = Readonly<{ + kind: "implementation"; + status: "pull-request-open"; + base: PlanningBaseEvidence; + workspace: PlanningWorkspaceOwnership; + artifacts: readonly PlanningArtifactEvidence[]; + publication: PlanningPublicationEvidence; + pullRequest: PlanningPullRequestEvidence; + implementation: PlanningImplementationAgentEvidence; + finish: PlanningImplementationFinishCheckpoints; +}>; + +export type ImplementationCompletePlanningPhase = Readonly<{ + kind: "implementation"; + status: "complete"; + base: PlanningBaseEvidence; + workspace: PlanningWorkspaceOwnership<{ + state: "removed"; + pushedHeadOid: string; + }>; + artifacts: readonly PlanningArtifactEvidence[]; + publication: PlanningPublicationEvidence; + pullRequest: PlanningPullRequestEvidence; + implementation: PlanningImplementationAgentEvidence; + finish: Required; + completion: Readonly<{ kind: "implementation-pull-request" }>; +}>; +``` + +`PlanningRunCostEvidence` mirrors the existing non-secret aggregate +`RunCostReport` fields using readonly arrays and finite nonnegative numbers. Add +explicit conversion functions in `planning-implementation.ts` and +`planning-finish.ts`; do not import CLI modules from `src/workflow/`. + +```ts +// src/cli/commands/run-once/planning-phase-coordinator.ts +export type PlanningCoordinatorOutcome = + | { + kind: "review-pending"; + state: PlanningStateV1; + phase: "spec" | "plan"; + prUrl: string; + } + | { + kind: "stopped"; + state: PlanningStateV1; + reason: "plan-only"; + nextPhase: "implementation"; + } + | { kind: "blocked"; state: PlanningStateV1; result: AgentIssueBlockedResult } + | { + kind: "complete"; + state: PlanningStateV1; + result: AgentIssuePrCreatedResult; + }; + +export async function coordinatePlanningPhases( + input: PlanningPhaseCoordinatorInput, +): Promise; +``` + +```ts +// src/cli/commands/run-once/planning-selection.ts +export type RunOnceWorkflowSelection = + | { kind: "none" } + | { kind: "invalid-planning-state"; issue: IssueSummary; reason: string } + | { kind: "planning"; issue: IssueSummary; state: PlanningStateV1 } + | { kind: "legacy"; issue: IssueSummary } + | { + kind: "fresh-planning"; + issue: IssueSummary; + initialState: PlanningStateV1; + }; + +export async function selectRunOnceWorkflow( + issues: readonly IssueSummary[], + config: AgentIssueConfig, + planningState: Pick, +): Promise; +``` + +```ts +// src/cli/commands/run-once/types.ts +export type AgentIssueReviewPendingResult = { + status: "review-pending"; + issue: IssueSummary; + phase: "spec" | "plan"; + prUrl: string; +}; + +export type AgentIssueStoppedResult = { + status: "stopped"; + issue: IssueSummary; + reason: "plan-only" | "issue-locked"; + nextPhase?: "implementation"; + specPath?: string; + planPath?: string; + branch?: string; + worktreePath?: string; +}; +``` + +## Testing Value Gate + +Every planned automated test proves behavior with meaningful regression value: + +- State tests protect reusable parsing, immutable evidence, progress ordering, + lock authorization, and destructive cleanup boundaries. +- Closing-reference, marker, URL, repository, and ancestry tests protect + security-sensitive pull-request identity and cannot be replaced by static + inspection. +- Coordinator and recording-pipeline tests protect gate sequencing, concurrency, + recovery, and the ordering of externally visible effects. +- Result tests protect redirected JSON, terminal, JSONL, and process exit-code + API contracts. +- Legacy scenario suites protect compatibility while the router is introduced. + +No new test should assert dependency versions, lockfile text, documentation +content, static configuration, or import spelling. Verify those directly with +`git diff`, `npm run build`, lint, type checks, architecture checks, and the +conditional Nix command in Task 10. + +--- + +## Task 1: Extend strict planning state for implementation and finish checkpoints + +**Files:** + +- Modify: `src/workflow/planning-state-types.ts` +- Modify: `src/workflow/planning-state-validation.ts` +- Modify: `src/workflow/planning-state-transitions.ts` +- Modify: `src/workflow/planning-state.test.ts` +- Modify: `src/workflow/planning-state-publication-regressions.test.ts` + +**Interfaces:** + +- Consumes: issue #188's `PlanningPublicationEvidence`, publication-aware phase + variants, `PlanningWorkspaceOwnership`, `PlanningPullRequestEvidence`, and + strict `PlanningStateStore.replace()` contract. +- Produces: the implementation evidence and checkpoint types in **Public + Interfaces**, plus transition rules consumed by Tasks 5 and 6. + +- [ ] **Step 1: Write failing strict-schema and transition tests** + +Add table-driven cases that prove: + +```ts +const forbidden = [ + { kind: "spec", status: "branch-pushed", implementation: agentEvidence }, + { kind: "plan", status: "pull-request-open", implementation: agentEvidence }, + { + kind: "implementation", + status: "complete", + completion: { kind: "remote-base" }, + }, + { + kind: "implementation", + status: "complete", + completion: { kind: "merged-pull-request" }, + }, +]; +``` + +Also assert that implementation `branch-pushed` accepts a final workspace head +newer than its planning-artifact commits while keeping those artifact paths, +sources, and commit OIDs immutable; Task 5's Git proof checks their ancestry. +`pull-request-open` requires exact publication, implementation, pull-request, +and empty finish evidence; finish checkpoints can only accumulate `true` fields +in the declared order; cleanup advances `ready -> worktree-removed -> removed`; +and terminal completion requires removed workspace plus every finish checkpoint. + +- [ ] **Step 2: Run the state tests and verify they fail** + +Run: + +```sh +node --test \ + src/workflow/planning-state.test.ts \ + src/workflow/planning-state-publication-regressions.test.ts +``` + +Expected: FAIL because the implementation evidence, finish checkpoints, and +terminal completion discriminator are not yet recognized. + +- [ ] **Step 3: Add exact implementation evidence types and validators** + +Implement the types shown in **Public Interfaces**, plus exact readonly visual +and run-cost evidence. Validate unknown keys, nonblank bounded single-line +identity fields, canonical relative evidence paths, full 40- or 64-character Git +OIDs, nonempty validation entries, finite nonnegative run-cost totals, and +cross-field equality: + +```ts +phase.implementation.branch === phase.workspace.identity.branch; +phase.implementation.prUrl === phase.pullRequest.url; // once PR-open +phase.publication.headOid === phase.workspace.headOid; +phase.workspace.cleanup.state === "removed"; // terminal only +``` + +Keep bodies, raw command output, credentials, state bytes, and Pi transcript +content out of durable evidence. + +- [ ] **Step 4: Implement phase-specific replacement rules** + +Permit exactly: + +```text +implementation workspace-ready + -> implementation branch-pushed + -> implementation pull-request-open/finish checkpoints + -> implementation complete/implementation-pull-request +``` + +Make agent, repository, branch, head, URL/reference, artifact, and validation +evidence immutable after first persistence. Allow only the saved cleanup +transition and one-way finish checkpoint additions; reject skipped checkpoints, +checkpoint removal, head changes after `branch-pushed`, and any planning-phase +implementation evidence. + +- [ ] **Step 5: Run the focused state tests** + +Run the command from Step 2. Expected: PASS, including issue #188 publication +regressions and the new implementation matrix. + +- [ ] **Step 6: Commit the state contract** + +```sh +git add \ + src/workflow/planning-state-types.ts \ + src/workflow/planning-state-validation.ts \ + src/workflow/planning-state-transitions.ts \ + src/workflow/planning-state.test.ts \ + src/workflow/planning-state-publication-regressions.test.ts +git commit -m "feat(workflow): checkpoint implementation pull requests" +``` + +## Task 2: Add review-pending and stopped public result contracts + +**Files:** + +- Modify: `src/cli/commands/run-once/types.ts` +- Modify: `src/cli/commands/run-once/result-summary.ts` +- Modify: `src/cli/commands/run-once/result-summary.test.ts` +- Modify: `src/cli/commands/run-once/result-output.ts` +- Modify: `src/cli/commands/run-once/result-output.test.ts` +- Modify: `src/cli/commands/run-once/terminal-result.ts` +- Modify: `src/cli/commands/run-once/terminal-result.test.ts` +- Modify: `src/cli/commands/run-once/args.test.ts` + +**Interfaces:** + +- Consumes: `AgentIssuePipelineResult`, existing log/session path propagation, + JSONL result events, and terminal layout primitives. +- Produces: the exact internal and redirected result variants from the spec; + Tasks 7 and 8 return these variants. + +- [ ] **Step 1: Write failing summary, terminal, JSONL, and exit tests** + +Construct full results and assert exact redirected forms: + +```ts +{ + status: "review-pending", + issueNumber: 189, + phase: "spec", + prUrl: "https://example.test/owner/repo/pull/12", +} + +{ + status: "stopped", + issueNumber: 189, + reason: "plan-only", + nextPhase: "implementation", + specPath: "docs/specs/issue-189.md", + planPath: "docs/plans/issue-189.md", + branch: "agent/issue-189-implementation", + worktreePath: ".worktrees/issue-189-implementation", +} +``` + +Assert optional `logPath` and `piSessionPath`, one compact redirected JSON +object, the same summary in the final JSONL event, warning severity, phase/PR +terminal text for review, reason/next phase/preserved paths for stops, and exit +code `0` for both new statuses. Retain code `1` for `blocked` and `error`. + +- [ ] **Step 2: Run focused output tests and verify they fail** + +```sh +node --test \ + src/cli/commands/run-once/result-summary.test.ts \ + src/cli/commands/run-once/result-output.test.ts \ + src/cli/commands/run-once/terminal-result.test.ts \ + src/cli/commands/run-once/args.test.ts +``` + +Expected: FAIL because the new discriminants are absent from the unions and +formatters. + +- [ ] **Step 3: Implement the exact result unions and summary mapping** + +Add the types from **Public Interfaces** to `AgentIssuePipelineResult` and these +summary variants: + +```ts +| { status: "review-pending"; issueNumber: number; phase: "spec" | "plan"; prUrl: string } +| { status: "stopped"; issueNumber: number; reason: "plan-only" | "issue-locked"; + nextPhase?: "implementation"; specPath?: string; planPath?: string; + branch?: string; worktreePath?: string } +``` + +Map only declared fields and normal optional log/session paths; do not serialize +`IssueSummary`, PR bodies, lock bytes, or internal state. + +- [ ] **Step 4: Implement terminal and exit behavior** + +Add `Review pending` and `Stopped` entries with warning severity. Render review +phase and pull request, and render stop reason plus optional next phase before +existing artifact/workspace sections. Make `exitCodeForRunOnceResult()` explicit +and exhaustive: approval-required, environment-not-ready, blocked, and error are +`1`; all established successes plus review-pending and stopped are `0`. + +- [ ] **Step 5: Run focused output tests** + +Run the command from Step 2. Expected: PASS with no changed expectations for +legacy statuses. + +- [ ] **Step 6: Commit the public contract** + +```sh +git add \ + src/cli/commands/run-once/types.ts \ + src/cli/commands/run-once/result-summary.ts \ + src/cli/commands/run-once/result-summary.test.ts \ + src/cli/commands/run-once/result-output.ts \ + src/cli/commands/run-once/result-output.test.ts \ + src/cli/commands/run-once/terminal-result.ts \ + src/cli/commands/run-once/terminal-result.test.ts \ + src/cli/commands/run-once/args.test.ts +git commit -m "feat(run-once): report planning review stops" +``` + +## Task 3: Validate implementation pull-request bodies and prompt requirements + +**Files:** + +- Create: `src/workflow/planning-implementation-body.ts` +- Create: `src/workflow/planning-implementation-body.test.ts` +- Modify: `src/workflow/planning-pull-request-validation.ts` +- Modify: `src/workflow/planning-pull-request-validation.test.ts` +- Modify: `src/cli/commands/run-once/prompts.ts` +- Modify: `src/cli/commands/run-once/prompts.test.ts` + +**Interfaces:** + +- Consumes: `parsePlanningPullRequestMarker()`, + `validatePlanningPullRequestSummary()`, `renderPlanningPullRequestMarker()`, + and `ImplementationPromptInput`. +- Produces: `assertImplementationClosingReference(body, issueNumber)`, shared + implementation marker/identity validation, and an optional prompt marker that + Task 5 supplies. + +- [ ] **Step 1: Write failing Markdown body tests** + +Cover one valid column-zero line and reject absent or example-only references: + +````ts +const accepted = `Summary\n\nCloses #189\n\n`; +const rejected = [ + "```md\nCloses #189\n```", + "> Closes #189", + " Closes #189", + "- Closes #189", + "Closes #190", + "Refs #189", +]; +```` + +Also cover CRLF input, fenced blocks using backticks and tildes, longer fence +closers, duplicate/malformed/unsupported/fenced markers, wrong issue or phase, +and the requirement that the one valid marker is the final nonblank top-level +line. + +- [ ] **Step 2: Write failing prompt and shared-validator tests** + +Assert the planning implementation prompt includes direct landing disabled, +`Closes #189`, and this exact final marker instruction and sample line: + +```text + +``` + +Assert the existing legacy prompt output is unchanged when no marker option is +provided. Extend the shared validator test matrix so `phase: "implementation"` +validates the same target/base/head/OID/URL/reference/marker identity as spec +and plan without forcing planning reconciliation to accept only open status. + +- [ ] **Step 3: Run focused tests and verify they fail** + +```sh +node --test \ + src/workflow/planning-implementation-body.test.ts \ + src/workflow/planning-pull-request-validation.test.ts \ + src/cli/commands/run-once/prompts.test.ts +``` + +Expected: FAIL because the closing-reference parser and implementation prompt +contract do not exist. + +- [ ] **Step 4: Implement the focused body validator** + +Export: + +```ts +export class PlanningImplementationBodyError extends Error { + readonly reason: "closing-reference"; +} + +export function assertImplementationClosingReference( + body: string, + issueNumber: number, +): void; +``` + +Normalize CRLF, track top-level backtick/tilde fences, ignore block quotes, +lists, and four-space/tab-indented code, and require a column-zero line exactly +equal to `Closes #${issueNumber}`. Keep marker parsing in the existing marker +module rather than duplicating it. + +- [ ] **Step 5: Extend shared validation and prompt rendering** + +Change the shared validator phase type to `PlanningPhaseKind`. Add +`requiredPullRequestMarker?: string` to `ImplementationPromptInput`; thread it +through PR creation instructions and the Markdown example, placing it as the +final nonblank line. The planning caller will pass the rendered marker and +`git.allowDirectLand: false`; legacy callers omit the option and retain their +current output and result contracts. + +- [ ] **Step 6: Run focused tests** + +Run the command from Step 3. Expected: PASS for pure Markdown, shared identity, +and both prompt modes. + +- [ ] **Step 7: Commit body and prompt validation** + +```sh +git add \ + src/workflow/planning-implementation-body.ts \ + src/workflow/planning-implementation-body.test.ts \ + src/workflow/planning-pull-request-validation.ts \ + src/workflow/planning-pull-request-validation.test.ts \ + src/cli/commands/run-once/prompts.ts \ + src/cli/commands/run-once/prompts.test.ts +git commit -m "feat(run-once): require implementation pull request markers" +``` + +## Task 4: Extract state-neutral development and implementation agent cores + +**Files:** + +- Create: `src/cli/commands/run-once/development-environment-agent.ts` +- Create: `src/cli/commands/run-once/development-environment-agent.test.ts` +- Create: `src/cli/commands/run-once/implementation-agent.ts` +- Create: `src/cli/commands/run-once/implementation-agent.test.ts` +- Create: `src/cli/commands/run-once/implementation-task-progress.ts` +- Modify: `src/cli/commands/run-once/development-environment-stage.ts` +- Modify: `src/cli/commands/run-once/pipeline-implementation.ts` +- Modify: `src/cli/commands/run-once/pipeline-development-environment.test.ts` +- Modify: `src/cli/commands/run-once/pipeline-implementation.test.ts` +- Modify: `src/cli/commands/run-once/pipeline-implementation-scenarios.test.ts` + +**Interfaces:** + +- Consumes: existing Pi profiles, prompt builders, todo readers, Pi repair, + progress callbacks, and visual-evidence result parsing. +- Produces: state-neutral cores returning typed results without labels, legacy + Run recovery state, comments, or finish effects; the existing wrappers retain + all old mutations. + +- [ ] **Step 1: Add characterization tests for legacy behavior** + +Record the current order and outputs for development ready/not-ready, Pi +blocked, `pr-created`, allowed/disallowed `merged`, saved implementation resume, +todo completeness, task progress, repair attempts, and error conversion. Assert +that the legacy wrapper still writes `worktreeReady`, maps not-ready labels, +invokes `blockIssue`, and enforces configured direct-land policy. + +- [ ] **Step 2: Run the legacy implementation tests** + +```sh +node --test \ + src/cli/commands/run-once/pipeline-development-environment.test.ts \ + src/cli/commands/run-once/pipeline-implementation.test.ts \ + src/cli/commands/run-once/pipeline-implementation-scenarios.test.ts +``` + +Expected: PASS before refactoring; these tests are the compatibility baseline. + +- [ ] **Step 3: Extract the development-environment agent** + +Move only profile selection, prompt construction, `runPiPrompt()`, and handoff +creation behind: + +```ts +export type DevelopmentEnvironmentAgentOutcome = + | { kind: "ready"; handoff: AgentIssueDevelopmentEnvironmentHandoff } + | { + kind: "not-ready"; + result: AgentIssueDevelopmentEnvironmentNotReadyResult; + }; + +export async function runDevelopmentEnvironmentAgent( + input: DevelopmentEnvironmentAgentInput, +): Promise; +``` + +Keep not-ready label changes, legacy `writeRunState()`, final progress, and +pipeline result construction in `development-environment-stage.ts`. + +- [ ] **Step 4: Extract implementation task progress and Pi execution** + +Move task-label normalization and step switching into a focused tracker created +by: + +```ts +export function createImplementationTaskProgress( + input: ImplementationTaskProgressInput, +): ImplementationTaskProgress; +``` + +Move development-environment invocation, implementation prompt/Pi execution, +repair, todo checks, and result parsing behind: + +```ts +export type ImplementationAgentOutcome = + | { + kind: "implemented"; + result: AgentIssuePrCreatedResult | AgentIssueMergedResult; + } + | { kind: "blocked"; result: AgentIssueBlockedResult } + | { + kind: "environment-not-ready"; + result: AgentIssueDevelopmentEnvironmentNotReadyResult; + }; + +export async function runImplementationAgent( + input: ImplementationAgentInput, +): Promise; +``` + +The input must accept explicit `git` policy and +`requiredPullRequestMarker?: string`; it must not read or write legacy Run +recovery state, mutate labels, post comments, clean workspaces, or decide +whether `merged` is allowed. + +- [ ] **Step 5: Rebuild the legacy wrapper around the cores** + +Keep `runPipelineImplementationStage()`'s public options/result unchanged. It +writes legacy state, constructs the existing resume context, maps not-ready and +blocked outcomes through existing lifecycle helpers, calls +`assertDirectLandAllowed()`, and returns the same `implemented` versus +`already-implemented` discriminants. + +- [ ] **Step 6: Run extraction and legacy tests** + +```sh +node --test \ + src/cli/commands/run-once/development-environment-agent.test.ts \ + src/cli/commands/run-once/implementation-agent.test.ts \ + src/cli/commands/run-once/pipeline-development-environment.test.ts \ + src/cli/commands/run-once/pipeline-implementation.test.ts \ + src/cli/commands/run-once/pipeline-implementation-scenarios.test.ts +``` + +Expected: PASS with the characterization assertions unchanged. + +- [ ] **Step 7: Commit the neutral cores** + +```sh +git add \ + src/cli/commands/run-once/development-environment-agent.ts \ + src/cli/commands/run-once/development-environment-agent.test.ts \ + src/cli/commands/run-once/implementation-agent.ts \ + src/cli/commands/run-once/implementation-agent.test.ts \ + src/cli/commands/run-once/implementation-task-progress.ts \ + src/cli/commands/run-once/development-environment-stage.ts \ + src/cli/commands/run-once/pipeline-implementation.ts \ + src/cli/commands/run-once/pipeline-development-environment.test.ts \ + src/cli/commands/run-once/pipeline-implementation.test.ts \ + src/cli/commands/run-once/pipeline-implementation-scenarios.test.ts +git commit -m "refactor(run-once): extract implementation agent core" +``` + +## Task 5: Run and independently validate the planning implementation phase + +**Files:** + +- Create: `src/cli/commands/run-once/planning-implementation-validation.ts` +- Create: `src/cli/commands/run-once/planning-implementation-validation.test.ts` +- Create: `src/cli/commands/run-once/planning-implementation.ts` +- Create: `src/cli/commands/run-once/planning-implementation.test.ts` + +**Interfaces:** + +- Consumes: Task 1's implementation state variants, Task 3's body/shared + validators, Task 4's neutral agent core, `PlanningWorkspaceLifecycle`, + `PlanningPublicationOperations`, `PullRequestHost`, + `PlanningStateStore.replace()`, and `resolvePipelineRunCost()`. +- Produces: branch-pushed and validated pull-request-open implementation + checkpoints consumed by Tasks 6 and 7. + +- [ ] **Step 1: Write failing pure/recording validation tests** + +Cover exact target repository, saved base branch, provider-allowed head +repository, owned branch, equal clean-local/remote/host head OID, open status, +canonical result URL/reference, implementation marker, closing reference, base +to head ancestry, saved workspace head to final head ancestry, and every +reported commit both descended from base and ancestor of head. Add one failing +case per reason and assert host transport, authentication, not-found, incomplete +discovery, malformed response, repository, status, marker, closing-reference, +remote-head, and ancestry errors remain distinguishable and do not expose PR +bodies. + +- [ ] **Step 2: Write failing implementation-stage checkpoint tests** + +Use recording agent, workspace, Git, host, state store, and lock seams. Assert: + +```text +resume exact workspace +run optional development environment +run implementation with allowDirectLand=false and exact marker +validate todos/local head/ancestry +observe exact remote head and repositories +persist branch-pushed +read and validate canonical open PR +persist pull-request-open +``` + +Assert `merged` becomes a blocked safety result regardless of configured policy; +wrong returned branch, dirty/missing workspace, changed remote head, invalid PR, +or state conflict produces zero finish/label/handoff/hook/cleanup calls and +preserves the workspace. + +- [ ] **Step 3: Run focused tests and verify they fail** + +```sh +node --test \ + src/cli/commands/run-once/planning-implementation-validation.test.ts \ + src/cli/commands/run-once/planning-implementation.test.ts +``` + +Expected: FAIL because the planning implementation path does not exist. + +- [ ] **Step 4: Implement local and live pull-request proof** + +Export a validator with this effect boundary: + +```ts +export async function validatePlanningImplementation( + input: PlanningImplementationValidationInput, +): Promise<{ + publication: PlanningPublicationEvidence; + pullRequest: PlanningPullRequestEvidence; + headOid: string; +}>; +``` + +Use `workspaces.inspect()` to prove the exact registered owned workspace is +ready and clean, `assertAncestor()` for base/head, saved workspace head/final +head, and both sides of every reported commit, `inspectRemoteHead()` for the +exact configured remote branch, repository resolvers plus +`assertPlanningPublicationRepositories()`, canonical URL parsing for the +returned PR number, `getPullRequest()` for exact readback, shared summary +validation with phase `implementation`, status `open`, and the focused +closing-reference validator. Do not infer not-found from message text. + +- [ ] **Step 5: Implement planning implementation execution and checkpoints** + +Export: + +```ts +export type PlanningImplementationOutcome = + | { kind: "validated"; state: PlanningStateV1 } + | { + kind: "blocked"; + state: PlanningStateV1; + result: AgentIssueBlockedResult; + }; + +export async function runPlanningImplementation( + input: PlanningImplementationInput, +): Promise; +``` + +For `workspace-ready`, resume exact ownership, run the neutral agent with copied +Git policy `{ ...configuredGit, allowDirectLand: false }` and the rendered +implementation marker, require `pr-created`, calculate and sanitize optional +run-cost evidence, prove local/remote head and reported commits, then persist +`branch-pushed`. For a resumed `branch-pushed`, skip Pi and validate the saved +canonical PR. Persist `pull-request-open` only after every +host/body/head/ancestry check succeeds. Never accept or adapt `merged`. + +- [ ] **Step 6: Run focused implementation tests** + +Run the command from Step 3. Expected: PASS, including zero post-validation side +effects on every rejection. + +- [ ] **Step 7: Commit implementation validation** + +```sh +git add \ + src/cli/commands/run-once/planning-implementation-validation.ts \ + src/cli/commands/run-once/planning-implementation-validation.test.ts \ + src/cli/commands/run-once/planning-implementation.ts \ + src/cli/commands/run-once/planning-implementation.test.ts +git commit -m "feat(run-once): validate implementation pull requests" +``` + +## Task 6: Finish only from durable validated implementation evidence + +**Files:** + +- Create: `src/cli/commands/run-once/planning-finish.ts` +- Create: `src/cli/commands/run-once/planning-finish.test.ts` +- Modify: `src/cli/commands/run-once/pipeline-comments.ts` +- Modify: `src/cli/commands/run-once/pipeline-comments.test.ts` + +**Interfaces:** + +- Consumes: Task 1's `ImplementationPullRequestOpenPlanningPhase`, existing cost + publication, visual evidence, handoff comment, cleanup hook, workspace + lifecycle, label helpers, state store, and issue lock. +- Produces: terminal strict implementation state and a public `pr-created` + result reconstructed only from durable evidence. + +- [ ] **Step 1: Write failing finish-order and resume tests** + +Record every state read/write and external effect. Assert exact order: + +```text +load validated pull-request-open +publish/handle run cost; checkpoint +validate committed visual evidence; checkpoint +post canonical PR handoff; checkpoint +run cleanup hook successfully; checkpoint +remove clean owned worktree; checkpoint worktree-removed +prove remote head and remove exact local branch; checkpoint removed +ensure done label; checkpoint +apply done/remove in-progress and blocker labels; checkpoint +persist complete/implementation-pull-request +return durable pr-created +``` + +Inject failure before and after each checkpoint and rerun. Completed effects +must not repeat; the next incomplete effect resumes. A failed hook, visual +validation, workspace cleanup, label mutation, state conflict, or lock conflict +must preserve the last state and perform no later effect. + +- [ ] **Step 2: Run focused finish tests and verify they fail** + +```sh +node --test \ + src/cli/commands/run-once/planning-finish.test.ts \ + src/cli/commands/run-once/pipeline-comments.test.ts +``` + +Expected: FAIL because no planning-state-aware finish path exists. + +- [ ] **Step 3: Add a durable-evidence handoff renderer** + +Keep the existing legacy `handoffComment()` unchanged. Add a narrow renderer +that receives the canonical durable PR URL, branch, plan path, validation, +review summary, and landing decision. It must not accept a raw Pi result or PR +body. + +- [ ] **Step 4: Implement the checkpointed finish wrapper** + +Export: + +```ts +export async function finishPlanningImplementation( + input: PlanningFinishInput, +): Promise<{ state: PlanningStateV1; result: AgentIssuePrCreatedResult }>; +``` + +Reject any phase other than validated implementation `pull-request-open` or a +fully terminal implementation phase. Use one strict `replace()` per checkpoint. +Treat cost publication as the established best-effort warning and durably mark +it handled; treat visual, handoff, hook, workspace, branch, label, state, and +lock failures as stopping failures. Consider any failed cleanup-hook result a +failure rather than continuing. Build the `AgentIssuePrCreatedResult` from +durable branch, canonical PR URL, commits, validation, review/landing, and +visual evidence. Return the terminal state alongside it so the coordinator and +pipeline can add durable artifact paths and the preserved worktree identity to +the public pipeline result. + +- [ ] **Step 5: Run focused finish tests** + +Run the command from Step 2. Expected: PASS for ordering, interruption recovery, +and legacy handoff compatibility. + +- [ ] **Step 6: Commit the strict finish path** + +```sh +git add \ + src/cli/commands/run-once/planning-finish.ts \ + src/cli/commands/run-once/planning-finish.test.ts \ + src/cli/commands/run-once/pipeline-comments.ts \ + src/cli/commands/run-once/pipeline-comments.test.ts +git commit -m "feat(run-once): checkpoint implementation finish effects" +``` + +## Task 7: Coordinate all planning gate combinations and explicit stops + +**Files:** + +- Create: `src/cli/commands/run-once/planning-phase-coordinator.ts` +- Create: `src/cli/commands/run-once/planning-phase-coordinator.test.ts` + +**Interfaces:** + +- Consumes: `planningPhasePlan()`, `PlanningRemoteBaseGit`, + `PlanningWorkspaceLifecycle`, issue #188 artifact/publisher/reconciler + services, Task 5's implementation runner, Task 6's finish wrapper, and strict + state replacement under the live lock. +- Produces: `PlanningCoordinatorOutcome` from **Public Interfaces**. + +- [ ] **Step 1: Write failing four-gate matrix tests** + +Use recording fakes and assert these exact sequences: + +```ts +const cases = [ + { + gates: [false, false], + sequence: [ + "implementation:spec", + "implementation:plan", + "implementation:code", + "implementation:pr", + ], + }, + { + gates: [true, false], + sequence: [ + "spec:pr", + "spec:merge", + "implementation:plan", + "implementation:code", + "implementation:pr", + ], + }, + { + gates: [false, true], + sequence: [ + "plan:spec", + "plan:plan", + "plan:pr", + "plan:merge", + "implementation:code", + "implementation:pr", + ], + }, + { + gates: [true, true], + sequence: [ + "spec:pr", + "spec:merge", + "plan:pr", + "plan:merge", + "implementation:code", + "implementation:pr", + ], + }, +]; +``` + +Every open required planning PR must return `review-pending` before any next +workspace or implementation call. A verified merge must fetch a new remote base +before preparing the next phase. + +- [ ] **Step 2: Add recovery, base-artifact, and plan-only tests** + +Cover `branch-pushed` and `pull-request-open` reconciliation before local +preparation; `closed-unmerged`, `missing`, and `ambiguous` blocked without +replacement; one base artifact satisfying assigned work; multiple candidates +blocking; implementation with empty `artifactKinds` still executing; and +approval labels/artifact comments having no effect. + +For `--plan-only`, prove review-pending precedence, implementation-carried +artifacts checkpoint before stop, zero code/push/PR calls, resume without the +flag, and no implementation workspace creation when no assigned planning +artifact is missing. + +- [ ] **Step 3: Run the coordinator test and verify it fails** + +```sh +node --test src/cli/commands/run-once/planning-phase-coordinator.test.ts +``` + +Expected: FAIL because the coordinator does not exist. + +- [ ] **Step 4: Implement strict-prefix phase coordination** + +Read the first noncomplete phase from validated state. Reconcile saved planning +publication states before preparation; continue only from `satisfied-by-base` or +verified merged completion. For pending work, fetch/pin base, resolve artifacts, +checkpoint remote-base completion only for `spec`/`plan`, prepare or resume the +exact owned workspace, run missing artifacts in assignment order, and publish +planning phases through issue #188. + +For implementation, enforce the special `--plan-only` boundary before workspace +creation when all assigned planning artifacts are already on base; otherwise +checkpoint artifacts then stop. Without `--plan-only`, call Task 5, then Task 6. +Return after review, explicit stop, blocker, or finish; loop only after durable +phase completion. + +- [ ] **Step 5: Keep coordinator failures typed and sanitized** + +Map only expected phase classifications to blocked results. Propagate transport, +state, lock, Git, and unexpected provider errors without parsing messages. +Include issue-facing reasons and concise questions, but never PR bodies, raw +command output, credentials, state bytes, or Pi transcript content. + +- [ ] **Step 6: Run coordinator and issue #188 service tests** + +```sh +node --test \ + src/cli/commands/run-once/planning-phase-coordinator.test.ts \ + src/cli/commands/run-once/planning-phase-artifacts.test.ts \ + src/cli/commands/run-once/planning-phase-publisher.test.ts \ + src/cli/commands/run-once/planning-phase-reconciler.test.ts \ + src/cli/commands/run-once/planning-phase-reconciler.real.test.ts +``` + +Expected: PASS with the coordinator composing rather than duplicating issue #188 +behavior. + +- [ ] **Step 7: Commit the phase coordinator** + +```sh +git add \ + src/cli/commands/run-once/planning-phase-coordinator.ts \ + src/cli/commands/run-once/planning-phase-coordinator.test.ts +git commit -m "feat(run-once): coordinate planning pull request phases" +``` + +## Task 8: Add workflow-aware selection, locking, and side-by-side routing + +**Files:** + +- Create: `src/cli/commands/run-once/planning-selection.ts` +- Create: `src/cli/commands/run-once/planning-selection.test.ts` +- Create: `src/cli/commands/run-once/planning-pipeline.ts` +- Create: `src/cli/commands/run-once/planning-pipeline.test.ts` +- Create: `src/cli/commands/run-once/pipeline-legacy.ts` +- Modify: `src/cli/commands/run-once/pipeline.ts` +- Modify: `src/cli/commands/run-once/selection.ts` +- Modify: `src/cli/commands/run-once/selection.test.ts` +- Modify: `src/cli/commands/run-once/pipeline-selection.test.ts` +- Modify: `src/cli/commands/run-once/pipeline-selection-scenarios.test.ts` +- Modify: `src/cli/commands/run/reset/reset.ts` +- Modify: `src/cli/commands/run/reset/reset.test.ts` + +**Interfaces:** + +- Consumes: Task 7's coordinator, planning state/store/lock, current legacy + recovery policy, host factories, lifecycle labels, configured priority order, + and the current reset-only leased entry point. +- Produces: `selectRunOnceWorkflow()`, `runPlanningIssue()`, and a small public + `pipeline.ts` router while preserving `runOneIssueAfterReset()` as legacy. + +- [ ] **Step 1: Write failing advisory selection tests** + +Use real temporary planning/legacy state files and explicit issue fixtures. +Assert explicit selection wins; active nonblocked planning outranks resumable +legacy and fresh ready; legacy outranks fresh; configured priority labels then +issue number order each bucket; review-pending and plan-only planning state stay +selectable through state plus `in-progress`; needs-info planning state is +skipped until ready is reapplied; malformed planning state is returned as +invalid rather than skipped; and active planning plus unfinished legacy for one +issue fails closed. + +- [ ] **Step 2: Write failing lock and post-lock tests** + +Record issue/state reads and every mutation. Assert state and issue are read +after lock acquisition before initialization, labels, comments, Git, Pi, or PR +calls. Two contenders must permit effects from exactly one owner. Assert active +conflict returns `stopped/issue-locked`; stale/unverifiable/malformed conflicts +block without takeover; provisional fresh `runId` retries once with a racing +authoritative state; a second identity change blocks; +title/state/open/eligibility changes stop before mutation; and release removes +only the same `ownershipId` in `finally`. + +- [ ] **Step 3: Run focused selection/pipeline tests and verify they fail** + +```sh +node --test \ + src/cli/commands/run-once/planning-selection.test.ts \ + src/cli/commands/run-once/planning-pipeline.test.ts \ + src/cli/commands/run-once/selection.test.ts \ + src/cli/commands/run-once/pipeline-selection.test.ts \ + src/cli/commands/run-once/pipeline-selection-scenarios.test.ts \ + src/cli/commands/run/reset/reset.test.ts +``` + +Expected: FAIL because workflow-aware selection and routing do not exist. + +- [ ] **Step 4: Implement deterministic workflow selection** + +Expose or add one pure priority comparator in `selection.ts`; do not duplicate +priority-label rules. In `planning-selection.ts`, read planning state for every +open candidate, classify malformed state with its path/reason, inspect legacy +state only for the current recovery policy, reject dual-active identity, and +return the exact `RunOnceWorkflowSelection` union. Fresh selection checks the +ready label and normal blocker exclusions but ignores approval labels for +authorization; active planning selection ignores review labels and allows +`in-progress` while still honoring genuine blocker labels. + +- [ ] **Step 5: Implement the locked planning pipeline** + +Construct default issue/PR hosts, `PlanningStateStore`, `PlanningRemoteBaseGit`, +`PlanningWorkspaceGit`, `PlanningPublicationGit`, and agent services behind +injectable dependency interfaces for recording tests. For fresh work, create an +in-memory state and acquire its lock before initialization. Re-read issue, +planning state, and legacy state; initialize state before applying `in-progress` +and removing ready; deduplicate the existing started comment by exact body; then +invoke the coordinator. Keep `in-progress` on review and stopped outcomes. Map +genuine blockers through a planning-aware needs-info label/comment helper +without writing legacy state. + +Release the owned lock in `finally`; if both work and release fail, throw an +`AggregateError` retaining both causes. Never release a conflicting lock. + +- [ ] **Step 6: Extract legacy orchestration and wire the facade** + +Move the current `runOneIssueInternal()` implementation mechanically to +`pipeline-legacy.ts`, preserving imports, options, lease recursion, and +observable ordering. Make `pipeline.ts` select once and dispatch the pinned +issue to legacy or planning. Route `runOneIssueAfterReset()` directly to the +legacy entry because reset compatibility is out of scope. Keep existing exports +and call signatures stable. + +- [ ] **Step 7: Run focused routing and reset tests** + +Run the command from Step 3. Expected: PASS, including unchanged legacy reset, +lease, selection, and approval-label assertions. + +- [ ] **Step 8: Commit workflow routing** + +```sh +git add \ + src/cli/commands/run-once/planning-selection.ts \ + src/cli/commands/run-once/planning-selection.test.ts \ + src/cli/commands/run-once/planning-pipeline.ts \ + src/cli/commands/run-once/planning-pipeline.test.ts \ + src/cli/commands/run-once/pipeline-legacy.ts \ + src/cli/commands/run-once/pipeline.ts \ + src/cli/commands/run-once/selection.ts \ + src/cli/commands/run-once/selection.test.ts \ + src/cli/commands/run-once/pipeline-selection.test.ts \ + src/cli/commands/run-once/pipeline-selection-scenarios.test.ts \ + src/cli/commands/run/reset/reset.ts \ + src/cli/commands/run/reset/reset.test.ts +git commit -m "feat(run-once): route planning pull request workflows" +``` + +## Task 9: Prove end-to-end safety ordering and legacy compatibility + +**Files:** + +- Create: `src/cli/commands/run-once/planning-pipeline-scenarios.test.ts` +- Verify unchanged: `src/cli/commands/run-once/pipeline-planning.test.ts` +- Verify unchanged: + `src/cli/commands/run-once/pipeline-implementation-scenarios.test.ts` +- Verify unchanged: + `src/cli/commands/run-once/pipeline-finish-scenarios.test.ts` +- Verify unchanged: `src/cli/commands/run-once/pipeline-recovery.test.ts` +- Verify unchanged: `src/cli/commands/run-once/pipeline.test.ts` + +**Interfaces:** + +- Consumes: the complete planning workflow from Tasks 1-8 and existing recording + command/host fixtures. +- Produces: scenario evidence that every acceptance criterion holds through the + public facade and that the legacy route remains behaviorally unchanged. + +- [ ] **Step 1: Add full planning workflow scenarios** + +Drive `runOneIssue()` through recording seams for all four gate snapshots, +separate Run attempts around each open planning PR, merged reconciliation with a +new fetched base, implementation PR validation, and final cleanup. Assert public +results, durable revisions, labels/comments, exact branch/worktree identity, +remote/head ancestry checks, final JSON summary data, and zero next-phase +effects at review gates. + +- [ ] **Step 2: Add failure-order scenarios** + +Inject active/stale/malformed locks; malformed/dual state; planning PR +closed/missing/ambiguous; direct merge; wrong target/base/head/status/marker/ +closing reference/URL; remote-head mismatch; non-descendant commits; validation +state-write failure; finish hook failure; cleanup conflict; and done-label +failure. For every implementation proof failure, assert no cost, visual, +handoff, hook, workspace, branch, or done-label effect begins. + +- [ ] **Step 3: Run new scenarios and verify meaningful failures before fixes** + +```sh +node --test src/cli/commands/run-once/planning-pipeline-scenarios.test.ts +``` + +Expected on first addition: any missing wiring or ordering assertion FAILS for a +specific external effect. Repair production code at the owning task boundary; do +not weaken the assertions. + +- [ ] **Step 4: Run the complete legacy run-once scenario set unchanged** + +```sh +npm run test:run-once +``` + +Expected: PASS. In particular, unfinished legacy state must retain prior labels, +artifact comments, approval behavior, worktree reuse, configured direct-land, +result statuses, handoff, cleanup ordering, and recovery/reset behavior. + +- [ ] **Step 5: Commit scenario coverage and any targeted regression fix** + +```sh +git add src/cli/commands/run-once/planning-pipeline-scenarios.test.ts +git add -u src/cli/commands/run-once +git diff --cached --check +git commit -m "test(run-once): cover planning workflow integration" +``` + +Expected: the staged diff contains the new behavior scenarios and only targeted +production/test corrections required by a failing scenario. + +## Task 10: Run final validation and audit issue scope + +**Files:** + +- Verify: all files changed by Tasks 1-9 +- Do not modify: dependency metadata, installed skills, generated config, + examples, or user documentation + +**Interfaces:** + +- Consumes: the complete implementation branch. +- Produces: reproducible validation evidence and a clean handoff-ready branch. + +- [ ] **Step 1: Run focused planning and pull-request suites** + +```sh +node --test \ + src/workflow/planning-state.test.ts \ + src/workflow/planning-state-publication-regressions.test.ts \ + src/workflow/planning-implementation-body.test.ts \ + src/workflow/planning-pull-request-validation.test.ts \ + src/cli/commands/run-once/planning-phase-coordinator.test.ts \ + src/cli/commands/run-once/planning-implementation-validation.test.ts \ + src/cli/commands/run-once/planning-implementation.test.ts \ + src/cli/commands/run-once/planning-finish.test.ts \ + src/cli/commands/run-once/planning-selection.test.ts \ + src/cli/commands/run-once/planning-pipeline.test.ts \ + src/cli/commands/run-once/planning-pipeline-scenarios.test.ts \ + src/cli/commands/run-once/result-summary.test.ts \ + src/cli/commands/run-once/result-output.test.ts \ + src/cli/commands/run-once/terminal-result.test.ts +``` + +Expected: all focused state, coordinator, concurrency, validation, finish, and +public-contract tests PASS. + +- [ ] **Step 2: Run the approved final validation commands exactly** + +```sh +npm run test:run-once +npm test +npm run build +npm run lint +npm run check:types +npm run check:architecture +git diff --check +``` + +Expected: every command exits with status `0`. + +- [ ] **Step 3: Apply the AGENTS.md dependency/Nix condition** + +```sh +if git diff --name-only origin/main...HEAD -- \ + package.json package-lock.json npm-shrinkwrap.json | grep -q .; then + nix build .#patchmill --print-build-logs +else + echo "Nix build skipped: npm dependency metadata unchanged" +fi +``` + +Expected for this issue: print the skip message. If retained dependency metadata +changed unexpectedly, the Nix build must run and exit `0`. + +- [ ] **Step 4: Audit scope and safety boundaries directly** + +```sh +git diff --name-only origin/main...HEAD +rg -n "allowDirectLand: false|phase=implementation|Closes #" \ + src/cli/commands/run-once src/workflow +rg -n "runCleanupHookScript|removeWorktree|removeBranch|applyLabels" \ + src/cli/commands/run-once/planning-finish.ts \ + src/cli/commands/run-once/planning-implementation.ts +``` + +Expected: no dependency, installed skill-pack, generated configuration, example, +or user-documentation changes; the implementation marker and forced +pull-request-only policy are present; validation persists before every finish +operation; and legacy finish remains in `pipeline-finish.ts` unchanged. + +- [ ] **Step 5: Inspect the final branch** + +```sh +git status --short +git log --oneline --decorate -12 +``` + +Expected: no uncommitted tracked changes, no `.pi/todos` files staged, and +focused Conventional Commits corresponding to Tasks 1-9. Do not create a live +GitHub or Forgejo pull request as part of automated validation. diff --git a/docs/specs/2026-09-09-issue-189-integrate-planning-phases-and-require-implementation-pull-requests-design.md b/docs/specs/2026-09-09-issue-189-integrate-planning-phases-and-require-implementation-pull-requests-design.md new file mode 100644 index 00000000..8b016ae9 --- /dev/null +++ b/docs/specs/2026-09-09-issue-189-integrate-planning-phases-and-require-implementation-pull-requests-design.md @@ -0,0 +1,588 @@ +# Issue 189 integrate planning phases and require implementation pull requests design + +## Status + +This specification awaits document review. Implementation remains dependent on +issue #188's planning phase runner, publisher, reconciler, prompt context, pull +request factory, and strict publication checkpoints being present on the target +branch. + +## Summary + +Patchmill will route new `run-once` Issue runs through the `planning-pr-v1` +workflow. A focused phase coordinator will compose the durable state, issue +lock, phase workspace, artifact runner, planning pull request publisher, and +reconciler delivered by issues #184 through #188. + +The coordinator will implement all four spec and plan gate combinations. It will +stop normally while a required planning pull request awaits human review, then +resume from durable state after that pull request merges. An explicit +`--plan-only` invocation will also have a first-class stopped result instead of +being inferred from an artifact result. + +The implementation phase will always receive `allowDirectLand: false`. Patchmill +will accept only an implementation `pr-created` result and will independently +validate the live pull request's repository, base, head, open status, ownership +marker, closing reference, and commit ancestry before persisting success or +starting finish-stage cleanup. + +Existing unfinished legacy Run recovery state will continue through the current +comment-and-label pipeline. This slice does not migrate legacy state or change +its behavior. + +## Goals + +- Select resumable `planning-pr-v1` Issue runs before legacy or new work. +- Protect each new workflow Run attempt with the ownership-ID issue lock. +- Re-read issue and state after lock acquisition before any side effect. +- Execute the exact phase sequence for every gate combination. +- Stop at every required human review without treating review as a failure. +- Preserve `--plan-only`'s explicit boundary before implementation code. +- Require an open, issue-closing, marked implementation pull request. +- Prove that the implementation pull request contains the expected branch head + descended from the phase base. +- Persist implementation pull request validation before any finish-stage + cleanup, handoff, or done-label transition. +- Keep redirected JSON, terminal output, run logs, and process exit codes + complete for the new outcomes. +- Leave active legacy workflow behavior unchanged. + +## Non-goals + +- Migrate existing legacy Run recovery state. +- Deprecate or remove `--plan-only`, `set-spec`, `set-plan`, approval-label + configuration, or artifact comments. +- Update installed skill packs, examples, generated configuration, or user + documentation. +- Remove old approval labels or artifact comments from issues. +- Change issue #188's planning pull request publication and reconciliation + rules. +- Automatically merge any pull request or replace a closed-unmerged pull + request. +- Add final live cross-provider acceptance scenarios. +- Add configuration keys or dependencies. + +## Approaches considered + +### Side-by-side workflow router and focused phase coordinator (chosen) + +Keep `runOneIssue()` as the public facade. Route active legacy state to the +existing pipeline and route valid planning state or fresh eligible issues to a +new `planning-pr-v1` pipeline. The new pipeline owns selection revalidation, +locking, phase coordination, implementation pull request proof, and durable +finish boundaries while composing issue #188's focused services. + +This approach makes the compatibility boundary explicit, prevents new state from +leaking into the permissive legacy store, and gives the new workflow one strict +source of recovery truth. + +### Retrofit the existing label-driven pipeline + +The existing pipeline already executes planning and implementation agents, but +it couples stage decisions to approval labels, artifact comments, legacy +checkpoints, and one reused issue workspace. Retrofitting it would require two +state models to authorize the same effects and would make the requirement that +legacy behavior remain unchanged difficult to prove. Reject this approach. + +### Validate the implementation pull request after the existing finish stage + +This would minimize orchestration changes, but the current finish stage can +publish handoff state, apply the done label, run cleanup hooks, and remove the +workspace before a host-backed pull request validation. A bad or unrelated +agent-returned URL could therefore bypass the new safety boundary. Reject this +approach. + +## Architecture + +### Workflow routing + +`src/cli/commands/run-once/pipeline.ts` remains the exported facade and +delegates to one of two internal paths: + +- the current legacy orchestration for an unfinished legacy state that the + current recovery policy recognizes; or +- the new planning pipeline for a valid `planning-pr-v1` state or a fresh + eligible issue with no unfinished legacy state. + +A planning state file is never interpreted as legacy state. An unknown, +malformed, or unsupported planning state blocks with its path and validation +reason; it does not fall back to a fresh or legacy run. If both workflows appear +active for one issue, routing fails closed before labels, Git, Pi, or host +mutation. + +The legacy path retains its existing lease, state, label, artifact-comment, +workspace, direct-land, result, and cleanup behavior. Refactoring needed to +expose the router must remain mechanical and be protected by the existing legacy +scenario tests. + +### New planning pipeline + +Add a focused planning entry point under `src/cli/commands/run-once/`, separate +from the legacy orchestration. It owns: + +1. advisory issue selection and workflow routing; +2. planning issue lock acquisition and post-lock revalidation; +3. fresh planning state initialization and lifecycle claiming; +4. phase coordinator construction; +5. mapping coordinator outcomes to public pipeline results; and +6. ownership-safe lock release in `finally`. + +The phase coordinator lives in a separate module. It decides which phase may run +and composes these existing services rather than duplicating them: + +- `PlanningStateStore` and `PlanningIssueLock`; +- `PlanningRemoteBaseGit` and `PlanningWorkspaceLifecycle`; +- `runPlanningPhaseArtifacts()`; +- `publishPlanningPhase()`; +- `reconcilePlanningPhase()`; +- `PullRequestHost`; and +- the planning publication Git operations. + +Provider commands, Git command sequences, state serialization, and Pi response +parsing remain outside the coordinator. + +## Selection, routing, and locking + +### Advisory priority + +Automatic selection uses these priority buckets in order: + +1. the issue named by `--issue`; +2. an open, nonblocked issue with active, nonterminal `planning-pr-v1` state; +3. an open issue with resumable legacy state and the configured `in-progress` + label; and +4. a new eligible issue with the configured ready label. + +Within a bucket, preserve configured priority-label order and then use the issue +number as the deterministic tie-breaker. A malformed planning state associated +with an open issue is reported instead of being skipped in favor of new work. + +Planning-state activity, not spec-review or plan-review labels, keeps a new run +selectable. The configured `in-progress` label remains on a planning run while a +pull request is under review or while `--plan-only` has stopped it. A genuine +blocker applies the normal `needs-info` lifecycle state and excludes the run +from automatic selection until a maintainer reapplies the configured ready +label; an explicit retry must pass the normal eligibility checks. + +Selection before locking is advisory. A process must not choose another issue +after losing the lock for its selected issue; it returns a stopped or blocking +result and lets a later invocation perform a fresh selection. + +### Lock acquisition + +For an existing planning run, lock acquisition uses the stored immutable +`runId`. For a fresh issue, Patchmill creates an in-memory initial state with a +new Run ID, then atomically acquires that issue's planning lock before +initializing the state file. + +After acquisition, Patchmill re-reads both the issue from the host and planning +state from disk. It verifies: + +- the issue number and title still match the selected identity; +- the issue remains open; +- a fresh issue remains eligible, or an existing planning run remains active; +- the state Run ID matches the lock Run ID; and +- no unfinished legacy state appeared during the selection race. + +If a competing process initialized state between advisory selection and lock +acquisition, Patchmill releases the provisional lock and retries once with the +now-authoritative Run ID. Any further identity change blocks rather than loops +or guesses. + +A fresh state is initialized before the issue is relabeled. A crash can +therefore leave selectable planning state, but cannot leave an `in-progress` +issue with no new-workflow identity. + +An `active` lock conflict maps to an explicit non-mutating stop because another +live process owns the work. `stale`, `unverifiable`, and `malformed` lock +conflicts remain blocking operator conditions. Patchmill never removes or +replaces an existing lock automatically. Release succeeds only when the on-disk +ownership ID still matches the attempt. + +## Phase coordination + +The immutable gate snapshot comes from `workflow.specApproval.required` and +`workflow.planApproval.required` when a fresh state is initialized. +Configuration changes do not alter an active Issue run. + +The coordinator reads the first noncomplete phase from the strict completed +prefix. It may advance through phases already satisfied by the fetched base or +through newly reconciled merges in one Run attempt, but it returns whenever the +current phase requires review, explicit stopping, human input, or error +handling. + +### Gate matrix + +| Spec gate | Plan gate | Coordinator behavior | +| --------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Disabled | Disabled | Create spec and plan in the implementation workspace when absent, then run implementation and require its pull request. | +| Enabled | Disabled | Publish and await the spec pull request; after merge, create the plan with implementation and require the implementation pull request. | +| Disabled | Enabled | Create spec then plan in one plan workspace, publish and await that planning pull request, then run implementation from the merged base. | +| Enabled | Enabled | Publish and await the spec pull request, then publish and await the plan pull request, then run implementation from the latest merged base. | + +A single unambiguous artifact already present on the pinned remote base +satisfies its assigned planning work. Multiple candidates block. An +implementation phase is never completed merely because its `artifactKinds` array +is empty. + +### Planning phases + +For `spec` and `plan` phases: + +1. Reconcile `branch-pushed` or `pull-request-open` state before preparing local + work. +2. Treat `satisfied-by-base` and a verified `merged` result as phase completion, + then continue to the next phase. +3. Map an open pull request to `review-pending` and stop normally. +4. Map `closed-unmerged`, `missing`, and `ambiguous` to a blocked Issue run; do + not create a replacement. +5. For a pending phase, fetch and pin the configured remote base, resolve + artifacts, create or resume the exact owned workspace when needed, run + missing artifacts in assignment order, publish through issue #188, and + classify the published pull request. + +No next phase workspace is created until the prior planning pull request is +merged and issue #188's checkpointed local cleanup is complete. + +### Explicit `--plan-only` boundary + +`--plan-only` remains accepted without adding deprecation behavior in this +slice. The coordinator performs all planning work permitted before +implementation, then returns `stopped` with reason `plan-only`. + +If ungated spec or plan work belongs in the implementation phase, the +coordinator may create the implementation workspace and checkpoint those +artifacts, but it does not run implementation code, push that phase, or create +its pull request. If the implementation phase owns no missing planning artifact, +the stop occurs before creating its workspace. + +A still-open required planning pull request takes precedence and returns +`review-pending`; it is not mislabeled as a plan-only stop. A later invocation +without `--plan-only` resumes the saved phase workspace or begins the pending +implementation phase. + +## Implementation phase + +### Implementation execution + +Add a planning-workflow implementation runner or extract a state-neutral core +from `pipeline-implementation.ts`. The existing legacy wrapper keeps its current +arguments and behavior. The planning wrapper: + +- runs inside the owned implementation phase workspace; +- receives spec and plan paths from the current phase and merged base; +- retains the development-environment, todo, validation, review, visual + evidence, and Pi repair gates; +- passes a copied Git policy with `allowDirectLand: false`, regardless of the + configured project value; +- requires the implementation pull request body to contain `Closes #` and + the exact final marker + ``; and +- accepts only the existing `pr-created` agent result. + +A `merged` result is a safety failure for `planning-pr-v1`. It is never adapted +into success, even when project configuration and a landing skill would allow +direct landing on the legacy path. + +Before host validation, Patchmill verifies that the returned branch is the owned +implementation branch, the workspace is clean, the final local head is a +descendant of the pinned phase base, and every reported implementation commit +exists on the ancestry path from that base to the final head. + +### Durable implementation checkpoints + +Refine the strict version-1 phase variants introduced by issue #188 so the +implementation phase can record, without weakening planning-phase invariants: + +- the sanitized `pr-created` agent evidence needed to reproduce the final + result; +- resolved target and head repositories; +- the final implementation head observed on the configured remote; +- the validated pull request reference and URL; +- visual-evidence and handoff checkpoints needed before workspace removal; +- the existing owned cleanup discriminator; and +- terminal implementation-pull-request completion. + +Implementation evidence is forbidden on `spec` and `plan` phases. It first +becomes durable with a `branch-pushed` implementation checkpoint after local and +remote head proof. A `pull-request-open` implementation checkpoint means that +the complete host validation below succeeded. It must not be possible to +transition to terminal completion, apply the done label, or remove the workspace +from an unvalidated variant. + +State replacements preserve the issue #187 rules: exact schemas, immutable Run +identity and gate sequence, one-step revisions, matching lock ownership, and +atomic complete-document replacement. + +## Implementation pull request validation + +Use the provider-neutral `PullRequestHost` and extend the shared issue #188 +planning pull request validator to accept the `implementation` phase. Add a +focused implementation-body validator rather than embedding Markdown matching in +the coordinator. + +Validation resolves current target and configured push-remote repositories, +observes the exact remote implementation branch head, reads the agent-returned +canonical pull request reference, and requires all of these facts: + +- the target repository equals the resolved target repository; +- the base branch equals the phase's saved configured base branch; +- the head repository equals the resolved configured push destination and is + allowed by the provider's repository rules; +- the head branch equals the owned implementation branch; +- the host head SHA, exact remote branch head, and clean local workspace head + are identical; +- the pull request status is `open`; +- the body contains exactly one valid final top-level `planning-pr-v1` marker + for this issue and the `implementation` phase; +- the body contains an effective top-level `Closes #` line outside code + fences, block quotes, and indented code; +- the phase's pinned base object is an ancestor of the pull request head; and +- every reported commit is a descendant of the phase base and an ancestor of the + pull request head. + +The returned URL must parse to the same canonical target repository and pull +request number reported by the host. Human-readable title or body prose does not +supply identity. Authentication, transport, incomplete discovery, malformed +response, identity, status, marker, closing-reference, remote-head, and ancestry +failures remain distinct and fail closed. + +Only after every check succeeds does Patchmill persist the validated +`pull-request-open` implementation checkpoint. + +## Finish ordering and lifecycle effects + +The new workflow uses a planning-state-aware finish wrapper. Existing finish +helpers may be extracted for reuse, but the legacy wrapper and its ordering +remain unchanged. + +The new finish order is: + +1. Load the durable validated implementation pull request checkpoint. +2. Publish run-cost information and validate any committed visual evidence. +3. Publish the implementation handoff comment with the canonical pull request. +4. Run the configured cleanup hook. +5. Remove the owned clean implementation worktree and checkpoint removal. +6. Remove the owned local branch only after exact remote-head proof and + checkpoint removal. +7. Ensure and apply the configured done label while removing `in-progress` and + blocker labels. +8. Persist terminal implementation-pull-request completion. +9. Return the existing `pr-created` public result from durable evidence. + +No step in this finish path starts from an unvalidated agent result. In +particular, cleanup hooks, worktree removal, branch removal, handoff +publication, and the done-label transition are unreachable before pull request +validation is durable. + +A validation failure preserves the implementation workspace and active state. A +cleanup failure preserves the last strict cleanup checkpoint and leaves the +Issue run selectable. The new workflow does not force-remove, reset, clean, or +silently adopt an uncertain workspace. + +## Public results and exit codes + +Add `review-pending` and `stopped` pipeline variants. Pipeline-internal variants +retain the full issue summary. `summarizeResult()` produces these stable +redirected forms: + +```ts +{ + status: "review-pending"; + issueNumber: number; + phase: "spec" | "plan"; + prUrl: string; +} + +{ + status: "stopped"; + issueNumber: number; + reason: "plan-only" | "issue-locked"; + nextPhase?: "implementation"; + specPath?: string; + planPath?: string; + branch?: string; + worktreePath?: string; +} +``` + +Both variants retain normal optional run-log and Pi-session paths. + +Result behavior is: + +| Status | Terminal severity | Exit code | Meaning | +| ------------------------------------------------------------------- | ----------------- | --------- | ------------------------------------------------------------ | +| `review-pending` | warning | `0` | Expected human merge gate; no failure occurred. | +| `stopped` / `plan-only` | warning | `0` | The requested planning-only boundary was reached. | +| `stopped` / `issue-locked` | warning | `0` | Another live process owns the selected Issue run. | +| validated `pr-created` | success | `0` | Implementation pull request validation and finish succeeded. | +| `blocked`, stale/unverifiable/malformed lock, or validation blocker | failure | `1` | Human or operator action is required. | +| `error` | failure | `1` | An unexpected or provider failure occurred. | + +Interactive terminal output names the phase and pull request for +`review-pending`. A stopped summary names its reason, next phase, and any +preserved artifacts or workspace. Redirected stdout remains one compact JSON +object. JSONL run logs end with the same summarized status. Existing status +rendering and exit codes remain unchanged. + +## Failure handling + +- An agent blocker uses the existing blocked result and lifecycle transition. +- An active issue lock returns `stopped/issue-locked` without mutation. +- A stale, unverifiable, or malformed lock reports its safe diagnostic and + requires operator action; no automatic takeover occurs. +- A post-lock eligibility or workflow-identity change stops before side effects. +- Planning review remains `review-pending`; it does not apply `needs-info`. +- Closed-unmerged, missing, or ambiguous planning pull requests block and are + not replaced. +- Non-not-found host failures preserve state and propagate; messages are not + parsed to infer absence. +- A direct-merge implementation result blocks before finish. +- Any implementation pull request proof failure preserves the owned workspace, + leaves the issue active, and skips every finish effect. +- State or lock conflicts stop all later remote or destructive effects. +- Default errors and serialized result data do not expose pull request bodies, + raw command output, credentials, state bytes, or Pi transcripts. + +## Affected components + +The implementation is expected to touch focused areas rather than add more +responsibility to the legacy pipeline: + +- `pipeline.ts` and selection helpers for routing and priority; +- a new planning pipeline and phase coordinator under + `src/cli/commands/run-once/`; +- a planning implementation runner and planning-state-aware finish wrapper; +- `planning-state-types.ts`, validation, and transitions for implementation and + finish checkpoints; +- prompt construction for the mandatory implementation marker and no-direct-land + policy; +- shared pull request validation plus a focused closing-reference validator; +- public pipeline/result summary types, terminal formatting, and exit mapping; + and +- focused coordinator, integration, state, validation, output, and legacy + regression tests. + +Keep orchestration, validation, state, and output modules separate. Split a +module before it grows substantially beyond roughly 200 meaningful lines. Do not +put provider-specific commands or strict-state parsing into the coordinator. + +## Verification strategy + +These tests pass the Testing Value Gate because they protect reusable state +transitions, concurrency, external side-effect ordering, pull request identity, +and public CLI contracts. + +### Coordinator and integration tests + +Use fake or recording state, lock, host, Git, workspace, and Pi seams to cover: + +- all four gate combinations end in the exact phase and pull request sequence; +- spec and plan pull requests stop with `review-pending` while open; +- a merged planning pull request starts the next phase from a newly fetched + remote base; +- base-satisfied planning artifacts skip empty pull requests; +- an implementation phase with no artifacts is not base-completed; +- `--plan-only` checkpoints implementation-carried artifacts and stops before + code, or stops before workspace creation when no artifact is missing; +- resuming without `--plan-only` continues the saved implementation phase; +- approval labels and artifact comments do not control or mutate a new run; +- active nonblocked planning state outranks legacy in-progress and fresh ready + work; +- deterministic priority and explicit issue selection are preserved; +- state and issue are re-read after the lock and before the first mutation; +- two contenders permit effects from exactly one lock owner; +- active lock contention returns an explicit stop, while other lock + classifications block without takeover; and +- malformed or conflicting workflow state never falls back to another path. + +### Implementation pull request tests + +Focused pure and recording tests cover every validation requirement: + +- exact target repository and base branch; +- allowed head repository, exact owned branch, and exact shared + local/remote/host head SHA; +- open status only; +- exact implementation marker issue and phase, including missing, malformed, + unsupported, and duplicate markers; +- an effective exact closing reference, excluding quoted or fenced examples; +- canonical result URL and reference identity; +- base-to-head and reported-commit ancestry; +- rejection of `merged` regardless of configured direct-land policy; and +- zero finish, label, handoff, hook, or cleanup calls before durable validation. + +Failure-injection tests assert that validation persistence precedes finish, that +cleanup checkpoints resume idempotently, and that a cleanup conflict preserves +state and local work. + +### Result and compatibility tests + +Extend result summary, redirected output, terminal formatting, JSONL +final-event, and `main()` tests for both new statuses and their exit codes. +Assert complete fields for each result without snapshots of static +documentation. + +Run the existing legacy planning, implementation, recovery, finish, terminal, +and pipeline scenario suites unchanged. Add routing tests proving unfinished +legacy state still uses its previous labels, comments, worktree, direct-land +policy, result statuses, and cleanup path. + +### Validation commands + +Focused implementation validation should run the new coordinator, state, +implementation validation, selection, pipeline, and result tests. Final +validation uses: + +```sh +npm run test:run-once +npm test +npm run build +npm run lint +npm run check:types +npm run check:architecture +git diff --check +``` + +No dependency change is planned, so a Nix build is not required. If +`package.json`, `package-lock.json`, or `npm-shrinkwrap.json` changes +unexpectedly, implementation must also run the repository-required Nix build. No +live GitHub or Forgejo pull request is created by automated validation. + +## Compatibility + +Fresh eligible issues use `planning-pr-v1`. Existing unfinished legacy state +continues to use the current workflow until separate compatibility work lands. +This slice does not make approval-label fields optional, emit deprecation +warnings, change deprecated commands, or update installed/user documentation. + +The new workflow ignores approval labels and issue-published planning artifacts +for phase authorization. It does not delete them. The legacy path continues to +consume and mutate them exactly as it does now. + +No glossary or ADR change is required. The design uses the established terms +Run-once workflow, Issue run, Run attempt, Run recovery state, and phase +workspace. + +## Acceptance criteria + +- Advisory selection prioritizes explicit, active planning, resumable legacy, + and fresh ready work in that order. +- Every new-workflow side effect follows issue-lock acquisition and + authoritative post-lock issue/state reads. +- All four gate snapshots produce the approved planning and implementation phase + sequence. +- Every open required planning pull request returns `review-pending` and stops + before the next phase. +- `--plan-only` returns an explicit stopped result without running + implementation code. +- The implementation prompt and runtime force direct landing off, and a `merged` + result cannot succeed. +- Implementation success requires a live open pull request with the exact + target, base, head repository, head branch, head SHA, issue/phase marker, + closing reference, and commit ancestry. +- Pull request validation is durably checkpointed before handoff, labels, + cleanup hooks, worktree removal, or branch removal. +- Redirected JSON, terminal summaries, JSONL results, and exit codes cover + `review-pending`, explicit stops, validated success, blockers, and errors. +- Active legacy Run recovery state retains its existing observable behavior. diff --git a/src/cli/commands/run-once/development-environment-agent.ts b/src/cli/commands/run-once/development-environment-agent.ts new file mode 100644 index 00000000..28ae69ba --- /dev/null +++ b/src/cli/commands/run-once/development-environment-agent.ts @@ -0,0 +1,108 @@ +import { resolve } from "node:path"; +import { + profileExtensionArgs, + runOnceDevelopmentEnvironmentPiProfile, +} from "../../../pi/resource-profiles.ts"; +import { parseDevelopmentEnvironmentResult, runPiPrompt } from "./pi.ts"; +import { buildDevelopmentEnvironmentPrompt } from "./prompts.ts"; +import type { + AgentIssueConfig, + AgentIssueDevelopmentEnvironmentHandoff, + AgentIssueDevelopmentEnvironmentNotReadyResult, + AgentIssueProgressEvent, + CommandRunner, + IssueSummary, + ProgressReporter, +} from "./types.ts"; + +export type DevelopmentEnvironmentAgentOutcome = + | { kind: "ready"; handoff: AgentIssueDevelopmentEnvironmentHandoff } + | { + kind: "not-ready"; + result: AgentIssueDevelopmentEnvironmentNotReadyResult; + }; + +export type DevelopmentEnvironmentAgentInput = { + runner: CommandRunner; + config: AgentIssueConfig; + issue: IssueSummary; + labels: string[]; + planPath: string; + branch: string; + worktreePath: string; + completedAt: string; + piAgentDir: string; + tokenUsageState: { total: number }; + progressReporter?: ProgressReporter | undefined; + streamPiOutput?: ((chunk: string) => void) | undefined; + verbosePiOutput?: boolean | undefined; + heartbeatMs?: number | undefined; + piSessionPath?: string | undefined; + progress: ( + level: AgentIssueProgressEvent["level"], + stage: string, + message: string, + extras?: Partial>, + ) => Promise; + runStep: (label: string, fn: () => Promise) => Promise; + observePi: ( + stage: "pi-development-environment", + ) => (observation: AgentIssueProgressEvent["observation"]) => Promise; +}; + +export async function runDevelopmentEnvironmentAgent( + input: DevelopmentEnvironmentAgentInput, +): Promise { + const developmentEnvironmentSkill = + input.config.skills.developmentEnvironment; + if (!developmentEnvironmentSkill) + throw new Error( + "Development environment stage requires skills.developmentEnvironment", + ); + const worktreeRoot = resolve(input.config.repoRoot, input.worktreePath); + const profile = runOnceDevelopmentEnvironmentPiProfile( + input.config.skills, + input.config.repoRoot, + ); + const result = await input.runStep("development environment", async () => { + await input.progress( + "info", + "development-environment", + "running development environment with pi", + { issueNumber: input.issue.number }, + ); + return runPiPrompt( + input.runner, + worktreeRoot, + buildDevelopmentEnvironmentPrompt({ + issue: { ...input.issue, labels: input.labels }, + planPath: input.planPath, + branch: input.branch, + worktreePath: input.worktreePath, + projectPolicy: input.config.projectPolicy, + skills: input.config.skills, + }), + { + progress: input.progressReporter, + stage: "pi-development-environment", + parseResult: parseDevelopmentEnvironmentResult, + skillPaths: profile.additionalSkillPaths, + extensionArgs: profileExtensionArgs(profile), + streamOutput: input.streamPiOutput, + issueNumber: input.issue.number, + repoRoot: worktreeRoot, + heartbeatMs: input.heartbeatMs, + tokenUsageState: input.tokenUsageState, + observeSession: true, + sessionRoot: input.piSessionPath, + verbosePiOutput: input.verbosePiOutput, + onObservation: input.observePi("pi-development-environment"), + taskContract: input.config.projectPolicy.pi.taskContract, + piAgentDir: input.piAgentDir, + }, + ); + }); + return result.status === "not-ready" + ? { kind: "not-ready", result } + : { kind: "ready", handoff: { ...result, completedAt: input.completedAt } }; +} diff --git a/src/cli/commands/run-once/development-environment-stage.ts b/src/cli/commands/run-once/development-environment-stage.ts index 428895f8..895202f0 100644 --- a/src/cli/commands/run-once/development-environment-stage.ts +++ b/src/cli/commands/run-once/development-environment-stage.ts @@ -1,12 +1,6 @@ -import { join } from "node:path"; -import { - profileExtensionArgs, - runOnceDevelopmentEnvironmentPiProfile, -} from "../../../pi/resource-profiles.ts"; import type { IssueHostProvider } from "../../../host/types.ts"; import { planLabelChange } from "../triage/labels.ts"; -import { parseDevelopmentEnvironmentResult, runPiPrompt } from "./pi.ts"; -import { buildDevelopmentEnvironmentPrompt } from "./prompts.ts"; +import { runDevelopmentEnvironmentAgent } from "./development-environment-agent.ts"; import { writeRunState } from "./run-state.ts"; import { retryableLabelsAfterDevelopmentEnvironmentFailure } from "./workflow-state.ts"; import type { @@ -33,37 +27,38 @@ type DevelopmentEnvironmentDetails = { worktreePath: string; }; -type DevelopmentEnvironmentStageOptions = DevelopmentEnvironmentDetails & { - runner: CommandRunner; - host: IssueHostProvider; - config: AgentIssueConfig; - issue: IssueSummary; - labels: string[]; - readyLabel: string; - inProgressLabel: string; - timestamp: string; - logPath?: string; - piSessionPath?: string; - streamPiOutput?: (chunk: string) => void; - verbosePiOutput?: boolean; - heartbeatMs?: number; - piAgentDir: string; - tokenUsageState: { total: number }; - progressReporter?: ProgressReporter | undefined; - progress: ( - level: AgentIssueProgressEvent["level"], - stage: string, - message: string, - extras?: Partial< - Pick - >, - ) => Promise; - runStep: (label: string, fn: () => Promise) => Promise; - observePi: ( - stage: "pi-development-environment", - ) => (observation: AgentIssueProgressEvent["observation"]) => Promise; - emitSimpleStep: (issueNumber: number, label: string) => Promise; -}; +export type DevelopmentEnvironmentStageOptions = + DevelopmentEnvironmentDetails & { + runner: CommandRunner; + host: IssueHostProvider; + config: AgentIssueConfig; + issue: IssueSummary; + labels: string[]; + readyLabel: string; + inProgressLabel: string; + timestamp: string; + logPath?: string; + piSessionPath?: string; + streamPiOutput?: (chunk: string) => void; + verbosePiOutput?: boolean; + heartbeatMs?: number; + piAgentDir: string; + tokenUsageState: { total: number }; + progressReporter?: ProgressReporter | undefined; + progress: ( + level: AgentIssueProgressEvent["level"], + stage: string, + message: string, + extras?: Partial< + Pick + >, + ) => Promise; + runStep: (label: string, fn: () => Promise) => Promise; + observePi: ( + stage: "pi-development-environment", + ) => (observation: AgentIssueProgressEvent["observation"]) => Promise; + emitSimpleStep: (issueNumber: number, label: string) => Promise; + }; function withLogPath( result: T, @@ -79,7 +74,7 @@ function withLogPath( }; } -async function developmentEnvironmentNotReady( +export async function developmentEnvironmentNotReady( options: DevelopmentEnvironmentStageOptions, result: Extract< AgentIssueDevelopmentEnvironmentResult, @@ -149,73 +144,30 @@ async function developmentEnvironmentNotReady( export async function runDevelopmentEnvironmentStage( options: DevelopmentEnvironmentStageOptions, ): Promise { - const developmentEnvironmentSkill = - options.config.skills.developmentEnvironment; - if (!developmentEnvironmentSkill) { - throw new Error( - "Development environment stage requires skills.developmentEnvironment", - ); - } - - const worktreeRoot = join(options.config.repoRoot, options.worktreePath); - const profile = runOnceDevelopmentEnvironmentPiProfile( - options.config.skills, - options.config.repoRoot, - ); - const result = await options.runStep( - "development environment", - async (): Promise => { - await options.progress( - "info", - "development-environment", - "running development environment with pi", - { issueNumber: options.issue.number }, - ); - return await runPiPrompt( - options.runner, - worktreeRoot, - buildDevelopmentEnvironmentPrompt({ - issue: { ...options.issue, labels: options.labels }, - planPath: options.planPath, - branch: options.branch, - worktreePath: options.worktreePath, - projectPolicy: options.config.projectPolicy, - skills: options.config.skills, - }), - { - progress: options.progressReporter, - stage: "pi-development-environment", - parseResult: parseDevelopmentEnvironmentResult, - skillPaths: profile.additionalSkillPaths, - extensionArgs: profileExtensionArgs(profile), - streamOutput: options.streamPiOutput, - issueNumber: options.issue.number, - repoRoot: worktreeRoot, - heartbeatMs: options.heartbeatMs, - tokenUsageState: options.tokenUsageState, - observeSession: true, - sessionRoot: options.piSessionPath, - verbosePiOutput: options.verbosePiOutput, - onObservation: options.observePi("pi-development-environment"), - taskContract: options.config.projectPolicy.pi.taskContract, - piAgentDir: options.piAgentDir, - }, - ); - }, - ); - - if (result.status === "not-ready") { + const result = await runDevelopmentEnvironmentAgent({ + runner: options.runner, + config: options.config, + issue: options.issue, + labels: options.labels, + planPath: options.planPath, + branch: options.branch, + worktreePath: options.worktreePath, + completedAt: options.timestamp, + piAgentDir: options.piAgentDir, + tokenUsageState: options.tokenUsageState, + progressReporter: options.progressReporter, + streamPiOutput: options.streamPiOutput, + verbosePiOutput: options.verbosePiOutput, + heartbeatMs: options.heartbeatMs, + piSessionPath: options.piSessionPath, + progress: options.progress, + runStep: options.runStep, + observePi: options.observePi, + }); + if (result.kind === "not-ready") return { kind: "not-ready", - result: await developmentEnvironmentNotReady(options, result), + result: await developmentEnvironmentNotReady(options, result.result), }; - } - - return { - kind: "ready", - handoff: { - ...result, - completedAt: options.timestamp, - }, - }; + return result; } diff --git a/src/cli/commands/run-once/implementation-agent.test.ts b/src/cli/commands/run-once/implementation-agent.test.ts new file mode 100644 index 00000000..54105111 --- /dev/null +++ b/src/cli/commands/run-once/implementation-agent.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; +import { createMockRunner } from "../../../../test-support/run-once/mock-runner.ts"; +import { makeConfig } from "../../../../test-support/run-once/pipeline-fixtures.ts"; +import { runImplementationAgent } from "./implementation-agent.ts"; + +test("keeps planning implementation todos in the primary repository", async () => { + const config = await makeConfig({ dryRun: false, execute: true }); + const worktreePath = ".worktrees/issue-189"; + await mkdir(join(config.repoRoot, worktreePath), { recursive: true }); + const planPath = "docs/plans/issue-189.md"; + await mkdir(join(config.repoRoot, "docs/plans"), { recursive: true }); + await writeFile(join(config.repoRoot, planPath), "# plan\n", "utf8"); + let todoPath: string | undefined; + const runner = createMockRunner(async (call) => { + if (call.command === "pi") { + todoPath = call.env?.PI_TODO_PATH; + return { + code: 0, + stdout: JSON.stringify({ + status: "merged", + mergeCommit: "a".repeat(40), + branch: "agent/issue-189", + commits: ["a".repeat(40)], + validation: ["npm test"], + }), + stderr: "", + }; + } + throw new Error(`unexpected ${call.command}`); + }); + const outcome = await runImplementationAgent({ + runner, + config: { + ...config, + skills: { ...config.skills, developmentEnvironment: undefined }, + }, + issue: { number: 189, title: "Core", body: "", labels: [], state: "open" }, + labels: [], + planPath, + branch: "agent/issue-189", + worktreePath, + worktree: { created: true, existingCommits: [] }, + git: { baseBranch: "main", remote: "origin", allowDirectLand: false }, + resume: { resumed: false }, + piAgentDir: join(config.repoRoot, ".patchmill/pi-agent"), + tokenUsageState: { total: 0 }, + completedAt: "2026-09-10T00:00:00.000Z", + taskContract: { + ...config.projectPolicy.pi.taskContract, + todoRoot: join(config.repoRoot, ".pi/todos"), + }, + progress: async () => {}, + runStep: async (_label, fn) => fn(), + stepStart: async () => {}, + stepComplete: async () => {}, + observePi: () => async () => {}, + }); + assert.equal(outcome.kind, "implemented"); + if (outcome.kind === "implemented") + assert.equal(outcome.result.status, "merged"); + assert.equal(todoPath, join(config.repoRoot, ".pi/todos")); +}); diff --git a/src/cli/commands/run-once/implementation-agent.ts b/src/cli/commands/run-once/implementation-agent.ts new file mode 100644 index 00000000..431dda89 --- /dev/null +++ b/src/cli/commands/run-once/implementation-agent.ts @@ -0,0 +1,211 @@ +import { resolve } from "node:path"; +import type { PatchmillPiTaskContract } from "../../../policy/task-contract.ts"; +import { + profileExtensionArgs, + runOnceImplementationPiProfile, +} from "../../../pi/resource-profiles.ts"; +import { assertIssueTodosComplete } from "./issue-todos.ts"; +import { createImplementationTaskProgress } from "./implementation-task-progress.ts"; +import { runDevelopmentEnvironmentAgent } from "./development-environment-agent.ts"; +import { runPiPrompt } from "./pi.ts"; +import { + buildImplementationPrompt, + buildImplementationRepairPrompt, +} from "./prompts.ts"; +import type { IssueWorktreeResult } from "./git.ts"; +import type { + AgentIssueBlockedResult, + AgentIssueDevelopmentEnvironmentNotReadyResult, + AgentIssueMergedResult, + AgentIssuePiResult, + AgentIssuePrCreatedResult, + AgentIssueRunState, + CommandRunner, + IssueSummary, +} from "./types.ts"; +import type { AgentIssueProgressEvent, ProgressReporter } from "./progress.ts"; +import type { AgentIssueConfig } from "./types.ts"; + +export type ImplementationAgentOutcome = + | { + kind: "implemented"; + result: AgentIssuePrCreatedResult | AgentIssueMergedResult; + } + | { kind: "blocked"; result: AgentIssueBlockedResult } + | { + kind: "environment-not-ready"; + result: AgentIssueDevelopmentEnvironmentNotReadyResult; + }; + +export type ImplementationAgentInput = { + runner: CommandRunner; + config: AgentIssueConfig; + issue: IssueSummary; + labels: string[]; + planPath: string; + branch: string; + worktreePath: string; + worktree: IssueWorktreeResult; + git: Parameters[0]["git"]; + resume: { + resumed: boolean; + existingState?: AgentIssueRunState | undefined; + }; + piAgentDir: string; + tokenUsageState: { total: number }; + completedAt: string; + progressReporter?: ProgressReporter | undefined; + streamPiOutput?: ((chunk: string) => void) | undefined; + verbosePiOutput?: boolean | undefined; + heartbeatMs?: number | undefined; + piSessionPath?: string | undefined; + requiredPullRequestMarker?: string | undefined; + /** Planning runs pin operator todo state outside their owned worktree. */ + taskContract?: PatchmillPiTaskContract | undefined; + progress: ( + level: AgentIssueProgressEvent["level"], + stage: string, + message: string, + extras?: Partial>, + ) => Promise; + runStep: (label: string, fn: () => Promise) => Promise; + stepStart: (label: string) => Promise; + stepComplete: (label: string) => Promise; + observePi: ( + stage: "pi-development-environment" | "pi-implementation", + ) => (observation: AgentIssueProgressEvent["observation"]) => Promise; +}; + +/** Executes Pi implementation gates without legacy run-state or lifecycle effects. */ +export async function runImplementationAgent( + input: ImplementationAgentInput, +): Promise { + const worktreeRoot = resolve(input.config.repoRoot, input.worktreePath); + let developmentEnvironment; + if (input.config.skills.developmentEnvironment) { + const environment = await runDevelopmentEnvironmentAgent({ + runner: input.runner, + config: input.config, + issue: input.issue, + labels: input.labels, + planPath: input.planPath, + branch: input.branch, + worktreePath: input.worktreePath, + completedAt: input.completedAt, + piAgentDir: input.piAgentDir, + tokenUsageState: input.tokenUsageState, + progressReporter: input.progressReporter, + streamPiOutput: input.streamPiOutput, + verbosePiOutput: input.verbosePiOutput, + heartbeatMs: input.heartbeatMs, + piSessionPath: input.piSessionPath, + progress: input.progress, + runStep: input.runStep, + observePi: input.observePi, + }); + if (environment.kind === "not-ready") + return { kind: "environment-not-ready", result: environment.result }; + developmentEnvironment = environment.handoff; + } + await input.progress( + "info", + "pi-implementation", + "running implementation with pi", + { + issueNumber: input.issue.number, + }, + ); + const taskContract = + input.taskContract ?? input.config.projectPolicy.pi.taskContract; + const taskProgress = await createImplementationTaskProgress({ + repoRoot: input.config.repoRoot, + worktreeRoot, + issueNumber: input.issue.number, + planPath: input.planPath, + taskContract, + stepStart: input.stepStart, + stepComplete: input.stepComplete, + }); + let result: AgentIssuePiResult | undefined; + try { + await taskProgress.start(); + const projectPolicy = { + ...input.config.projectPolicy, + pi: { ...input.config.projectPolicy.pi, taskContract }, + directLand: { + ...input.config.projectPolicy.directLand, + targetBranch: input.git.baseBranch, + }, + }; + const profile = runOnceImplementationPiProfile( + input.config.skills, + input.config.repoRoot, + ); + result = await runPiPrompt( + input.runner, + worktreeRoot, + buildImplementationPrompt({ + issue: { ...input.issue, labels: input.labels }, + planPath: input.planPath, + branch: input.branch, + worktreePath: input.worktreePath, + git: input.git, + projectPolicy, + skills: input.config.skills, + resume: { + resumed: input.resume.resumed, + worktreeCreated: input.worktree.created, + existingCommits: input.worktree.existingCommits, + priorBlockerReason: input.resume.existingState?.lastError, + priorBlockerQuestions: input.resume.existingState?.blockerQuestions, + priorValidation: input.resume.existingState?.validation, + }, + ...(developmentEnvironment === undefined + ? {} + : { developmentEnvironment }), + ...(input.requiredPullRequestMarker === undefined + ? {} + : { requiredPullRequestMarker: input.requiredPullRequestMarker }), + }), + { + progress: input.progressReporter, + stage: "pi-implementation", + skillPaths: profile.additionalSkillPaths, + extensionArgs: profileExtensionArgs(profile), + streamOutput: input.streamPiOutput, + issueNumber: input.issue.number, + repoRoot: worktreeRoot, + heartbeatMs: input.heartbeatMs, + tokenUsageState: input.tokenUsageState, + observeSession: true, + sessionRoot: input.piSessionPath, + verbosePiOutput: input.verbosePiOutput, + onObservation: async (observation) => { + await taskProgress.observe(observation); + await input.observePi("pi-implementation")(observation); + }, + taskContract: projectPolicy.pi.taskContract, + piAgentDir: input.piAgentDir, + repair: { + maxAttempts: 2, + buildPrompt: buildImplementationRepairPrompt, + }, + onTaskProgress: taskProgress.update, + }, + ); + } finally { + await taskProgress.finish(); + } + if (!result) throw new Error("Pi implementation completed without a result"); + if (result.status === "blocked") return { kind: "blocked", result }; + if (result.status !== "pr-created" && result.status !== "merged") + throw new Error( + `Expected pr-created or merged from Pi but received ${result.status}`, + ); + await assertIssueTodosComplete( + worktreeRoot, + input.issue.number, + taskContract, + ); + return { kind: "implemented", result }; +} diff --git a/src/cli/commands/run-once/implementation-landing-prompt.ts b/src/cli/commands/run-once/implementation-landing-prompt.ts new file mode 100644 index 00000000..0ad85163 --- /dev/null +++ b/src/cli/commands/run-once/implementation-landing-prompt.ts @@ -0,0 +1,142 @@ +export function renderPrCreationInstruction( + remote: string, + issueNumber: number, + requiredPullRequestMarker?: string, +): string { + return [ + `Push the branch to \`${remote}\` and open a pull request using the repository's configured host tooling. Include \`Closes #${issueNumber}\` in the pull request description/body.`, + "Use a multiline-safe PR body construction path so Markdown line breaks remain real newlines.", + 'For Forgejo/Gitea through `tea`, write the Markdown PR description to a temp file or here-doc first, then pass actual newline characters with `tea pulls create --description "$(cat "$file")"`.', + "Do not pass Markdown containing literal `\\n` escape text as the `tea --description` value.", + "For GitHub through `gh`, use a multiline-safe supported path such as `gh pr create --body-file`.", + "Example PR body shape:", + "```md", + "Summary", + "", + "- Implemented change summary.", + "", + "## Validation", + "", + "- npm test", + "", + "## Reviews", + "", + "- Review completed.", + "", + `Closes #${issueNumber}`, + ...(requiredPullRequestMarker === undefined + ? [] + : [requiredPullRequestMarker]), + "```", + ].join("\n"); +} + +function renderBlockedContract(): string { + return `Blocker contract: +If human input is required, stop safely, leave committed work as-is, keep the reason and questions concise enough to post directly as a \`needs-info\` comment, and return this exact JSON object as the final response: +{ + "status": "blocked", + "reason": "short reason", + "questions": [ + { + "question": "question a human must answer", + "recommendedAnswer": "recommended answer and reasoning" + } + ], + "commits": [""], + "validation": ["command and result summary"] +}`; +} + +function renderPrCreatedContract(branch: string): string { + return `Successful final response for human-review PR fallback: +Return this exact JSON object after PR handoff succeeds: +{ + "status": "pr-created", + "prUrl": "", + "branch": "${branch}", + "commits": [""], + "validation": ["command and result summary"], + "reviewSummary": "short reviewer/fix summary", + "landingDecision": "PR required: " +}`; +} + +export function renderLandingResultContracts(input: { + allowDirectLand: boolean; + hasLandingSkill: boolean; + targetBranch: string; + remote: string; + issueNumber: number; + branch: string; + requiredPullRequestMarker?: string; +}): string { + const { + allowDirectLand, + hasLandingSkill, + targetBranch, + remote, + issueNumber, + branch, + requiredPullRequestMarker, + } = input; + const prInstruction = renderPrCreationInstruction( + remote, + issueNumber, + requiredPullRequestMarker, + ); + if (!allowDirectLand) + return `Landing result contracts: +Direct squash-landing is disabled for this repository. +${prInstruction} +Do not land directly on \`${targetBranch}\`. + +If human review is required: +1. ${prInstruction} +2. Explain briefly why human review is required. +3. Return the \`pr-created\` final response. + +${renderBlockedContract()} + +${renderPrCreatedContract(branch)}`; + if (!hasLandingSkill) + return `Landing result contracts: +Direct squash-landing requires a configured landing skill for this repository. No landing skill is configured, so use PR fallback and do not land directly on \`${targetBranch}\`. + +If human review is required: +1. ${prInstruction} +2. Explain briefly why human review is required. +3. Return the \`pr-created\` final response. + +${renderBlockedContract()} + +${renderPrCreatedContract(branch)}`; + return `Landing result contracts: +If eligible for direct squash-land: +1. Update local \`${targetBranch}\` from the \`${remote}\` remote. +2. Squash-merge the implementation branch into \`${targetBranch}\`. +3. Create one Conventional Commit that references issue #${issueNumber}. +4. Push \`${targetBranch}\` to \`${remote}\` without force-pushing. +5. Return the \`merged\` final response. + +If human review is required: +1. ${prInstruction} +2. Explain briefly why human review is required. +3. Return the \`pr-created\` final response. + +${renderBlockedContract()} + +Successful final response for direct squash-land: +Return this exact JSON object after \`${targetBranch}\` is pushed successfully: +{ + "status": "merged", + "branch": "${branch}", + "mergeCommit": "", + "commits": [""], + "validation": ["command and result summary"], + "reviewSummary": "short reviewer/fix summary", + "landingDecision": "direct squash-landed: policy-approved change" +} + +${renderPrCreatedContract(branch)}`; +} diff --git a/src/cli/commands/run-once/implementation-task-progress.test.ts b/src/cli/commands/run-once/implementation-task-progress.test.ts new file mode 100644 index 00000000..02185170 --- /dev/null +++ b/src/cli/commands/run-once/implementation-task-progress.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { DEFAULT_PI_TASK_CONTRACT } from "../../../policy/task-contract.ts"; +import { createImplementationTaskProgress } from "./implementation-task-progress.ts"; + +test("switches runtime implementation tasks once and closes the active task", async () => { + const repoRoot = await mkdtemp( + join(tmpdir(), "patchmill-implementation-progress-"), + ); + const planPath = join(repoRoot, "plan.md"); + await writeFile(planPath, "# plan\n", "utf8"); + const events: string[] = []; + const progress = await createImplementationTaskProgress({ + repoRoot, + worktreeRoot: repoRoot, + issueNumber: 189, + planPath, + taskContract: DEFAULT_PI_TASK_CONTRACT, + stepStart: async (label) => events.push(`start:${label}`), + stepComplete: async (label) => events.push(`complete:${label}`), + }); + await progress.update({ current: 2, total: 3, label: "validate" }); + await progress.update({ current: 2, total: 3, label: "validate" }); + await progress.finish(); + assert.deepEqual(events, [ + "start:implement task 2/3 validate", + "complete:implement task 2/3 validate", + ]); +}); diff --git a/src/cli/commands/run-once/implementation-task-progress.ts b/src/cli/commands/run-once/implementation-task-progress.ts new file mode 100644 index 00000000..1eeee1b6 --- /dev/null +++ b/src/cli/commands/run-once/implementation-task-progress.ts @@ -0,0 +1,125 @@ +import { issueTodoProgress, readIssueTodoTasks } from "./issue-todos.ts"; +import { readPlanTaskLabels } from "./plan-tasks.ts"; +import type { AgentIssueProgressEvent } from "./progress.ts"; +import type { PatchmillPiTaskContract } from "../../../policy/task-contract.ts"; + +export type ImplementationTaskProgressInput = { + repoRoot: string; + worktreeRoot: string; + issueNumber: number; + planPath: string; + taskContract: PatchmillPiTaskContract; + stepStart: (label: string) => Promise; + stepComplete: (label: string) => Promise; +}; + +export type ImplementationTaskProgress = { + start: () => Promise; + observe: ( + observation: AgentIssueProgressEvent["observation"], + ) => Promise; + update: (progress: { + current: number; + total: number; + label?: string; + }) => Promise; + finish: () => Promise; +}; + +/** Tracks Pi's dynamic implementation tasks without retaining workflow state. */ +export async function createImplementationTaskProgress( + input: ImplementationTaskProgressInput, +): Promise { + const labels = await readPlanTaskLabels( + input.repoRoot, + input.planPath, + input.taskContract, + ); + let active: { current: number; total: number; label: string } | undefined; + let finalActive = false; + const finalLabel = "final review and landing"; + const labelFor = (current: number, runtimeLabel?: string) => + labels.find((task) => task.number === current)?.label ?? + runtimeLabel ?? + `task ${current}`; + const update = async (progress: { + current: number; + total: number; + label?: string; + }): Promise => { + const current = + labels.length === 0 + ? progress.current + : Math.min(Math.max(progress.current, 1), labels.length); + const total = labels.length === 0 ? progress.total : labels.length; + const label = labelFor(current, progress.label); + if ( + finalActive || + (active?.current === current && + active.total === total && + active.label === label) + ) + return; + if (active) + await input.stepComplete( + `implement task ${active.current}/${active.total} ${active.label}`, + ); + active = { current, total, label }; + await input.stepStart(`implement task ${current}/${total} ${label}`); + }; + const tasksComplete = async () => { + const tasks = await readIssueTodoTasks( + input.worktreeRoot, + input.issueNumber, + input.taskContract, + ); + return tasks.length > 0 && tasks.every((task) => task.done); + }; + const startFinal = async () => { + if (finalActive) return; + if (active) + await input.stepComplete( + `implement task ${active.current}/${active.total} ${active.label}`, + ); + active = undefined; + finalActive = true; + await input.stepStart(finalLabel); + }; + const refresh = async (startFinalWhenComplete = false) => { + if (startFinalWhenComplete && (await tasksComplete())) return startFinal(); + const progress = await issueTodoProgress( + input.worktreeRoot, + input.issueNumber, + input.taskContract, + ); + if (progress) await update(progress); + }; + return { + start: async () => { + if (labels.length > 0) + await update({ + current: 1, + total: labels.length, + ...(labels[0]?.label === undefined ? {} : { label: labels[0].label }), + }); + }, + observe: async (observation) => { + if ( + observation?.type === "tool-call" || + observation?.type === "subagent-progress" + ) + await refresh(true); + }, + update, + finish: async () => { + await refresh(); + if (active) + await input.stepComplete( + `implement task ${active.current}/${active.total} ${active.label}`, + ); + active = undefined; + if (finalActive) await input.stepComplete(finalLabel); + finalActive = false; + }, + }; +} diff --git a/src/cli/commands/run-once/pipeline-approval-workspace-preflight.test.ts b/src/cli/commands/run-once/pipeline-approval-workspace-preflight.test.ts index 31f96650..52cf778b 100644 --- a/src/cli/commands/run-once/pipeline-approval-workspace-preflight.test.ts +++ b/src/cli/commands/run-once/pipeline-approval-workspace-preflight.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import test from "node:test"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { formatPublishedArtifactComment } from "../../../workflow/artifacts/published-artifacts.ts"; import { writeRunState } from "./run-state.ts"; import { diff --git a/src/cli/commands/run-once/pipeline-development-environment.test.ts b/src/cli/commands/run-once/pipeline-development-environment.test.ts index b6881164..41994845 100644 --- a/src/cli/commands/run-once/pipeline-development-environment.test.ts +++ b/src/cli/commands/run-once/pipeline-development-environment.test.ts @@ -4,7 +4,7 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { DEFAULT_PATCHMILL_CONFIG } from "../../../config/defaults.ts"; import { readRunState, runStatePath, writeRunState } from "./run-state.ts"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { issue, issueListPayload, diff --git a/src/cli/commands/run-once/pipeline-failures-scenarios.test.ts b/src/cli/commands/run-once/pipeline-failures-scenarios.test.ts index 3dcc0dde..cd348bc0 100644 --- a/src/cli/commands/run-once/pipeline-failures-scenarios.test.ts +++ b/src/cli/commands/run-once/pipeline-failures-scenarios.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { DEFAULT_PATCHMILL_CONFIG } from "../../../config/defaults.ts"; import { createTriagePolicy } from "../../../policy/triage.ts"; import { runStatePath, writeRunState } from "./run-state.ts"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { DEFAULT_LABEL_NAMES, issue, diff --git a/src/cli/commands/run-once/pipeline-finish-scenarios.test.ts b/src/cli/commands/run-once/pipeline-finish-scenarios.test.ts index f1630519..1e79dbcb 100644 --- a/src/cli/commands/run-once/pipeline-finish-scenarios.test.ts +++ b/src/cli/commands/run-once/pipeline-finish-scenarios.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { DEFAULT_PATCHMILL_CONFIG } from "../../../config/defaults.ts"; import { createTriagePolicy } from "../../../policy/triage.ts"; import { runStatePath, writeRunState } from "./run-state.ts"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { DEFAULT_LABEL_NAMES, issue, diff --git a/src/cli/commands/run-once/pipeline-implementation-scenarios.test.ts b/src/cli/commands/run-once/pipeline-implementation-scenarios.test.ts index 0c988af7..d4402697 100644 --- a/src/cli/commands/run-once/pipeline-implementation-scenarios.test.ts +++ b/src/cli/commands/run-once/pipeline-implementation-scenarios.test.ts @@ -9,7 +9,7 @@ import { } from "../../../workflow/skill-pack.ts"; import { bundledVisualEvidenceSkillPath } from "../../../workflow/skills.ts"; import { runStatePath } from "./run-state.ts"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { assertNoLegacyProjectText } from "../../../../test-support/legacy-project-text.ts"; import { DEFAULT_LABEL_NAMES, diff --git a/src/cli/commands/run-once/pipeline-implementation.ts b/src/cli/commands/run-once/pipeline-implementation.ts index 03210f91..4b0fc74b 100644 --- a/src/cli/commands/run-once/pipeline-implementation.ts +++ b/src/cli/commands/run-once/pipeline-implementation.ts @@ -1,34 +1,15 @@ import { join } from "node:path"; -import { - profileExtensionArgs, - runOnceImplementationPiProfile, -} from "../../../pi/resource-profiles.ts"; -import { runDevelopmentEnvironmentStage } from "./development-environment-stage.ts"; -import { - assertIssueTodosComplete, - issueTodoProgress, - readIssueTodoTasks, -} from "./issue-todos.ts"; -import { runPiPrompt } from "./pi.ts"; -import { readPlanTaskLabels } from "./plan-tasks.ts"; -import { - buildImplementationPrompt, - buildImplementationRepairPrompt, -} from "./prompts.ts"; -import { writeRunState } from "./run-state.ts"; +import { developmentEnvironmentNotReady } from "./development-environment-stage.ts"; +import { runImplementationAgent } from "./implementation-agent.ts"; +import { assertIssueTodosComplete } from "./issue-todos.ts"; import { assertDirectLandAllowed, successfulImplementationFromState, } from "./pipeline-lifecycle.ts"; -import { - progress as emitProgress, - type PipelineProgressOptions, -} from "./pipeline-progress.ts"; -import type { AgentIssueProgressEvent, ProgressReporter } from "./progress.ts"; +import { writeRunState } from "./run-state.ts"; import type { IssueWorktreeResult } from "./git.ts"; import type { AgentIssueConfig, - AgentIssueDevelopmentEnvironmentHandoff, AgentIssueMergedResult, AgentIssuePiResult, AgentIssuePipelineResult, @@ -37,11 +18,15 @@ import type { CommandRunner, IssueSummary, } from "./types.ts"; +import type { AgentIssueProgressEvent, ProgressReporter } from "./progress.ts"; +import { + progress as emitProgress, + type PipelineProgressOptions, +} from "./pipeline-progress.ts"; export type PipelineSuccessfulImplementationResult = | AgentIssuePrCreatedResult | AgentIssueMergedResult; - export type PipelineImplementationStageResult = | { kind: "implemented"; @@ -55,10 +40,9 @@ export type PipelineImplementationStageResult = } | { kind: "blocked"; result: AgentIssuePipelineResult } | { kind: "unexpected"; error: Error }; - export type PipelineImplementationStageOptions = { runner: CommandRunner; - host: Parameters[0]["host"]; + host: Parameters[0]["host"]; config: AgentIssueConfig; issue: IssueSummary; labels: string[]; @@ -71,7 +55,7 @@ export type PipelineImplementationStageOptions = { branch: string | undefined; worktreePath: string; worktree: IssueWorktreeResult; - worktreeStrategy: Parameters[0]["git"]; + worktreeStrategy: Parameters[0]["git"]; existingState: AgentIssueRunState | undefined; resumableState: boolean; implementationCompleted: boolean | undefined; @@ -113,372 +97,119 @@ function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } +/** Legacy adapter: owns run state, issue lifecycle, and direct-land policy only. */ export async function runPipelineImplementationStage( options: PipelineImplementationStageOptions, ): Promise { try { - const { - runner, - host, - config, - issue, - readyLabel, - inProgressLabel, - specPath, - specCommit, - planPath, - planCommit, - branch, - worktreePath, - worktree, - worktreeStrategy, - existingState, - resumableState, - timestamp, - runOptions, - piAgentDir, - tokenUsageState, - progressReporter, - runStep, - stepStart, - stepComplete, - observePi, - emitSimpleStep, - } = options; const details = { - specPath, - specCommit, - planPath, - planCommit, - branch, - worktreePath, + specPath: options.specPath, + specCommit: options.specCommit, + planPath: options.planPath, + planCommit: options.planCommit, + branch: options.branch, + worktreePath: options.worktreePath, }; const alreadyImplemented = - resumableState && options.implementationCompleted; + options.resumableState && options.implementationCompleted; let implemented = alreadyImplemented - ? successfulImplementationFromState(existingState) + ? successfulImplementationFromState(options.existingState) : undefined; - if (implemented) { + if (implemented) assertDirectLandAllowed( implemented, - config, + options.config, "Saved implementation state", ); - } - const worktreeRoot = join(config.repoRoot, worktreePath); await writeRunState( - config.runStateDir, + options.config.runStateDir, { - issueNumber: issue.number, + issueNumber: options.issue.number, status: "implementing", - specPath, - specCommit, - planPath, - planCommit, - branch, - worktreePath, + ...details, checkpoints: { worktreeReady: true }, }, - timestamp, + options.timestamp, ); options.checkpoints.worktreeReady = true; - let developmentEnvironment: - | AgentIssueDevelopmentEnvironmentHandoff - | undefined; if (!implemented) { - if (!planPath || !branch) { + if (!options.planPath || !options.branch) throw new Error( - `Implementation requires a plan and branch for issue #${issue.number}`, + `Implementation requires a plan and branch for issue #${options.issue.number}`, ); - } - if (config.skills.developmentEnvironment) { - const developmentEnvironmentStage = - await runDevelopmentEnvironmentStage({ - runner, - host, - config, - issue, - labels: options.labels, - readyLabel, - inProgressLabel, - specPath, - specCommit, - planPath, - planCommit, - branch, - worktreePath, - timestamp, - ...(runOptions.logPath === undefined - ? {} - : { logPath: runOptions.logPath }), - ...(runOptions.piSessionPath === undefined - ? {} - : { piSessionPath: runOptions.piSessionPath }), - ...(runOptions.streamPiOutput === undefined - ? {} - : { streamPiOutput: runOptions.streamPiOutput }), - ...(runOptions.verbosePiOutput === undefined - ? {} - : { verbosePiOutput: runOptions.verbosePiOutput }), - ...(runOptions.heartbeatMs === undefined - ? {} - : { heartbeatMs: runOptions.heartbeatMs }), - piAgentDir, - tokenUsageState, - progressReporter, - progress: (level, stage, message, extras) => - emitProgress(runOptions, level, stage, message, extras), - runStep, - observePi, - emitSimpleStep, - }); - - if (developmentEnvironmentStage.kind === "not-ready") { - return { - kind: "blocked", - result: developmentEnvironmentStage.result, - }; - } - - developmentEnvironment = developmentEnvironmentStage.handoff; - } - - await emitProgress( - runOptions, - "info", - "pi-implementation", - "running implementation with pi", - { issueNumber: issue.number }, - ); - const taskContract = config.projectPolicy.pi.taskContract; - const planTaskLabels = await readPlanTaskLabels( - config.repoRoot, - planPath, - taskContract, - ); - let activeImplementationTask: - | { current: number; total: number; label: string } - | undefined; - let finalImplementationStepActive = false; - const finalImplementationStepLabel = "final review and landing"; - - const labelForTask = (current: number, runtimeLabel?: string): string => { - const planLabel = planTaskLabels.find( - (task) => task.number === current, - )?.label; - return planLabel ?? runtimeLabel ?? `task ${current}`; - }; - - const normalizeImplementationTaskProgress = ( - current: number, - total: number, - ): { current: number; total: number } => { - if (planTaskLabels.length === 0) return { current, total }; + const outcome = await runImplementationAgent({ + runner: options.runner, + config: options.config, + issue: options.issue, + labels: options.labels, + planPath: options.planPath, + branch: options.branch, + worktreePath: options.worktreePath, + worktree: options.worktree, + git: options.worktreeStrategy, + resume: { + resumed: options.resumableState, + existingState: options.existingState, + }, + piAgentDir: options.piAgentDir, + tokenUsageState: options.tokenUsageState, + completedAt: options.timestamp, + progressReporter: options.progressReporter, + streamPiOutput: options.runOptions.streamPiOutput, + verbosePiOutput: options.runOptions.verbosePiOutput, + heartbeatMs: options.runOptions.heartbeatMs, + piSessionPath: options.runOptions.piSessionPath, + progress: async (level, stage, message, extras) => + emitProgress(options.runOptions, level, stage, message, extras), + runStep: options.runStep, + stepStart: options.stepStart, + stepComplete: options.stepComplete, + observePi: options.observePi, + }); + if (outcome.kind === "environment-not-ready") return { - current: Math.min(Math.max(current, 1), planTaskLabels.length), - total: planTaskLabels.length, - }; - }; - - const switchImplementationTask = async ( - rawCurrent: number, - rawTotal: number, - runtimeLabel?: string, - ): Promise => { - const { current, total } = normalizeImplementationTaskProgress( - rawCurrent, - rawTotal, - ); - const label = labelForTask(current, runtimeLabel); - if ( - activeImplementationTask?.current === current && - activeImplementationTask.total === total && - activeImplementationTask.label === label - ) { - return; - } - if (finalImplementationStepActive) return; - if (activeImplementationTask) { - await stepComplete( - `implement task ${activeImplementationTask.current}/${activeImplementationTask.total} ${activeImplementationTask.label}`, - ); - } - activeImplementationTask = { current, total, label }; - await stepStart(`implement task ${current}/${total} ${label}`); - }; - - const startFinalImplementationStep = async (): Promise => { - if (finalImplementationStepActive) return; - if (activeImplementationTask) { - await stepComplete( - `implement task ${activeImplementationTask.current}/${activeImplementationTask.total} ${activeImplementationTask.label}`, - ); - activeImplementationTask = undefined; - } - finalImplementationStepActive = true; - await stepStart(finalImplementationStepLabel); - }; - - const issueTasksComplete = async (): Promise => { - const tasks = await readIssueTodoTasks( - worktreeRoot, - issue.number, - taskContract, - ); - return tasks.length > 0 && tasks.every((task) => task.done); - }; - - const refreshImplementationTask = async ( - refreshOptions: { startFinalWhenComplete?: boolean } = {}, - ): Promise => { - if ( - refreshOptions.startFinalWhenComplete && - (await issueTasksComplete()) - ) { - await startFinalImplementationStep(); - return; - } - const runtimeProgress = await issueTodoProgress( - worktreeRoot, - issue.number, - taskContract, - ); - if (runtimeProgress) { - await switchImplementationTask( - runtimeProgress.current, - runtimeProgress.total, - runtimeProgress.label, - ); - } - }; - - const observeImplementation = async ( - observation: AgentIssueProgressEvent["observation"], - ): Promise => { - if ( - observation?.type === "tool-call" || - observation?.type === "subagent-progress" - ) { - await refreshImplementationTask({ startFinalWhenComplete: true }); - } - await observePi("pi-implementation")(observation); - }; - - let piResult: AgentIssuePiResult | undefined; - try { - if (planTaskLabels.length > 0) { - await switchImplementationTask( - 1, - planTaskLabels.length, - planTaskLabels[0]?.label, - ); - } - - const projectPolicy = { - ...config.projectPolicy, - directLand: { - ...config.projectPolicy.directLand, - targetBranch: worktreeStrategy.baseBranch, - }, + kind: "blocked", + result: await developmentEnvironmentNotReady( + { + ...options, + planPath: options.planPath!, + branch: options.branch!, + progress: async (level, stage, message, extras) => + emitProgress(options.runOptions, level, stage, message, extras), + ...(options.runOptions.logPath === undefined + ? {} + : { logPath: options.runOptions.logPath }), + ...(options.runOptions.piSessionPath === undefined + ? {} + : { piSessionPath: options.runOptions.piSessionPath }), + ...(options.runOptions.streamPiOutput === undefined + ? {} + : { streamPiOutput: options.runOptions.streamPiOutput }), + ...(options.runOptions.verbosePiOutput === undefined + ? {} + : { verbosePiOutput: options.runOptions.verbosePiOutput }), + ...(options.runOptions.heartbeatMs === undefined + ? {} + : { heartbeatMs: options.runOptions.heartbeatMs }), + } satisfies Parameters[0], + outcome.result, + ), }; - const profile = runOnceImplementationPiProfile( - config.skills, - config.repoRoot, - ); - - piResult = await runPiPrompt( - runner, - worktreeRoot, - buildImplementationPrompt({ - issue: { ...issue, labels: options.labels }, - planPath, - branch, - worktreePath, - git: worktreeStrategy, - projectPolicy, - skills: config.skills, - resume: { - resumed: resumableState, - worktreeCreated: worktree.created, - existingCommits: worktree.existingCommits, - priorBlockerReason: existingState?.lastError, - priorBlockerQuestions: existingState?.blockerQuestions, - priorValidation: existingState?.validation, - }, - ...(developmentEnvironment === undefined - ? {} - : { developmentEnvironment }), - }), - { - progress: progressReporter, - stage: "pi-implementation", - skillPaths: profile.additionalSkillPaths, - extensionArgs: profileExtensionArgs(profile), - streamOutput: runOptions.streamPiOutput, - issueNumber: issue.number, - repoRoot: worktreeRoot, - heartbeatMs: runOptions.heartbeatMs, - tokenUsageState, - observeSession: true, - sessionRoot: runOptions.piSessionPath, - verbosePiOutput: runOptions.verbosePiOutput, - onObservation: observeImplementation, - taskContract: projectPolicy.pi.taskContract, - piAgentDir, - repair: { - maxAttempts: 2, - buildPrompt: buildImplementationRepairPrompt, - }, - onTaskProgress: async (taskProgress) => { - await switchImplementationTask( - taskProgress.current, - taskProgress.total, - taskProgress.label, - ); - }, - }, - ); - - await refreshImplementationTask(); - } finally { - if (activeImplementationTask) { - await stepComplete( - `implement task ${activeImplementationTask.current}/${activeImplementationTask.total} ${activeImplementationTask.label}`, - ); - activeImplementationTask = undefined; - } - if (finalImplementationStepActive) { - await stepComplete(finalImplementationStepLabel); - finalImplementationStepActive = false; - } - } - - if (!piResult) - throw new Error("Pi implementation completed without a result"); - if (piResult.status === "blocked") { + if (outcome.kind === "blocked") return { kind: "blocked", - result: await options.blockIssue(piResult, details), + result: await options.blockIssue(outcome.result, details), }; - } - if (piResult.status !== "pr-created" && piResult.status !== "merged") { - throw new Error( - `Expected pr-created or merged from Pi but received ${piResult.status}`, - ); - } - - assertDirectLandAllowed(piResult, config, "Pi"); - implemented = piResult; + implemented = outcome.result; + assertDirectLandAllowed(implemented, options.config, "Pi"); } - await assertIssueTodosComplete( - join(config.repoRoot, worktreePath), - issue.number, - config.projectPolicy.pi.taskContract, + join(options.config.repoRoot, options.worktreePath), + options.issue.number, + options.config.projectPolicy.pi.taskContract, ); - return { kind: alreadyImplemented ? "already-implemented" : "implemented", result: implemented, diff --git a/src/cli/commands/run-once/pipeline-landing.test.ts b/src/cli/commands/run-once/pipeline-landing.test.ts index 22620eef..a1403a76 100644 --- a/src/cli/commands/run-once/pipeline-landing.test.ts +++ b/src/cli/commands/run-once/pipeline-landing.test.ts @@ -4,7 +4,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { DEFAULT_PATCHMILL_CONFIG } from "../../../config/defaults.ts"; import { runStatePath } from "./run-state.ts"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { issue, issueListPayload, diff --git a/src/cli/commands/run-once/pipeline-legacy.ts b/src/cli/commands/run-once/pipeline-legacy.ts new file mode 100644 index 00000000..6a403cf8 --- /dev/null +++ b/src/cli/commands/run-once/pipeline-legacy.ts @@ -0,0 +1,956 @@ +import { join } from "node:path"; +import { localPiAgentDir } from "../init/pi-agent-settings.ts"; + +import { createRunOnceHostProvider } from "../../../host/factory.ts"; +import { planLabelChange } from "../triage/labels.ts"; +import { materializeIssueArtifactSources } from "./artifact-source-materialization.ts"; +import { assertApprovedArtifactsResolvable } from "./approval-artifact-preflight.ts"; +import { runArtifactSourceStage } from "./artifact-source-stage.ts"; +import type { ResolvedIssueArtifactSources } from "./artifact-sources.ts"; +import { ensureAutomationLabel } from "./automation-labels.ts"; +import { + assertCleanWorktree, + assertIssueBaseContainedInPrBase, + ensureIssueWorktree, + inspectIssueWorkspace, + type IssueWorktreeResult, +} from "./git.ts"; +import { + PlanningArtifactSafetyError, + resolvePlanningArtifacts, + type PlanningArtifactPolicy, +} from "./planning-artifacts.ts"; +import { advancePlanningStages } from "./stage-advancement.ts"; +import { + ApprovalRequiredError, + cleanupLabelsForImplementation, + resolveWorkflowState, +} from "./workflow-state.ts"; +import { + isResumableRunState, + readRunState, + replaceRunStateAfterReset, + writeRunState, +} from "./run-state.ts"; +import { selectIssueWithDiagnostics } from "./selection.ts"; +import { + createStepAccounting, + emitSimpleStep, + progress, + withLogPath, +} from "./pipeline-progress.ts"; +import { + AgentIssueSafetyError, + hasBlockedRunRecoveryState, + effectiveCheckpoints, + lifecycleLabels, + recoveryClaimLabels, + resetReceiptCheckpoints, + nextLabels, + workflowTransition, +} from "./pipeline-lifecycle.ts"; +import { + blockerComment, + blockerCommentKey, + startedComment, +} from "./pipeline-comments.ts"; +import { + cleanStatusIgnoredPaths, + configuredWorktreeStrategy, + expectedIssueWorkspace, + planningArtifactPolicyForWorkspace, +} from "./pipeline-workspace.ts"; +import { + emitSelectionDiagnostics, + loadSelectionIssues, + selectResumableIssue, +} from "./pipeline-selection.ts"; +import { hasFinishedPlanningWorkspaceState } from "./planning-selection.ts"; +import { blockIssue, unexpectedFailure } from "./pipeline-failures.ts"; +import { withIssueRunLease } from "./recovery-lease.ts"; +import { formatRunRecoveryDecision } from "./recovery.ts"; +import { + adoptLegacyRecoveryLease, + recoverBlockedWorkspace, +} from "./pipeline-recovery.ts"; +import { runPipelineImplementationStage } from "./pipeline-implementation.ts"; +import { runPipelineFinishStage } from "./pipeline-finish.ts"; +import { resolvePipelineRunCost } from "./pipeline-run-cost.ts"; +import { + runPiSessionPath, + type AgentIssueProgressEvent, + type ProgressReporter, +} from "./progress.ts"; +import type { + AgentIssueConfig, + AgentIssuePipelineResult, + AgentIssueRunState, + CommandRunner, + IssueSummary, +} from "./types.ts"; + +type PiOutputStream = (chunk: string) => void; + +export type RunOneIssueOptions = { + now?: Date | undefined; + progress?: ProgressReporter | undefined; + logPath?: string | undefined; + streamPiOutput?: PiOutputStream | undefined; + verbosePiOutput?: boolean | undefined; + heartbeatMs?: number | undefined; +}; +type LeasedRunOneIssueOptions = RunOneIssueOptions & { + lease?: import("./types.ts").IssueRunLease; + /** Internal selection pin; never exposed to ordinary callers. */ + leasedIssueNumber?: number; + reset?: { seed: import("./types.ts").RunResetSeed }; +}; + +export async function runLegacyOneIssue( + runner: CommandRunner, + config: AgentIssueConfig, + options: RunOneIssueOptions = {}, +): Promise { + return runLegacyOneIssueInternal(runner, config, options); +} + +/** Runs one already-selected legacy issue without returning to advisory selection. */ +export async function runLegacyOneIssueForSelection( + runner: CommandRunner, + config: AgentIssueConfig, + issueNumber: number, + options: RunOneIssueOptions = {}, +): Promise { + return runLegacyOneIssueInternal(runner, config, { + ...options, + leasedIssueNumber: issueNumber, + }); +} + +/** Reset uses this narrow leased entry point after it has archived state. */ +export async function runLegacyOneIssueAfterReset( + runner: CommandRunner, + config: AgentIssueConfig, + options: RunOneIssueOptions, + reset: { + lease: import("./types.ts").IssueRunLease; + seed: import("./types.ts").RunResetSeed; + }, +): Promise { + return runLegacyOneIssueInternal(runner, config, { + ...options, + lease: reset.lease, + reset: { seed: reset.seed }, + }); +} + +async function runLegacyOneIssueInternal( + runner: CommandRunner, + config: AgentIssueConfig, + options: LeasedRunOneIssueOptions = {}, +): Promise { + const host = createRunOnceHostProvider({ + runner, + repoRoot: config.repoRoot, + host: config.host, + }); + // Once an automatic selection has acquired its lease, re-read only that + // issue. Never perform another priority selection while holding a lease for + // a different issue. + const loadedIssues = + options.leasedIssueNumber === undefined + ? await loadSelectionIssues(host, config, options) + : [await host.viewIssue(options.leasedIssueNumber)]; + // A blocked saved attempt is never an implicit retry target. It needs an + // explicit issue acknowledgement and the normal recovery gate below. + const issues = + config.issueNumber === undefined + ? ( + await Promise.all( + loadedIssues.map(async (candidate) => ({ + candidate, + state: await readRunState(config.runStateDir, candidate.number), + })), + ) + ) + .filter(({ state }) => !hasBlockedRunRecoveryState(state)) + .map(({ candidate }) => candidate) + : loadedIssues; + let selected: { issue: IssueSummary; resumed: boolean } | undefined; + try { + selected = options.reset + ? (() => { + const resetIssue = issues.find( + (candidate) => candidate.number === config.issueNumber, + ); + if (!resetIssue) + throw new Error( + `Reset issue #${config.issueNumber ?? "?"} disappeared before pipeline entry`, + ); + return { issue: resetIssue, resumed: false }; + })() + : await selectResumableIssue(issues, config); + } catch (error) { + if (error instanceof ApprovalRequiredError) { + return withLogPath( + { + status: "approval-required", + issue: error.issue, + approvalKind: error.approvalKind, + missingLabel: error.missingLabel, + }, + options, + ); + } + throw error; + } + const issue = selected?.issue; + + if (!issue) { + if (config.issueNumber === undefined) { + const diagnostics = selectIssueWithDiagnostics(issues, { + readyLabel: lifecycleLabels(config).ready, + triagePolicy: config.triagePolicy, + approvalPolicy: config.approvalPolicy, + }); + await emitSelectionDiagnostics(diagnostics.rejections, options); + await progress( + options, + "info", + "select", + `no eligible issue found after considering ${diagnostics.consideredCount} open issues; see run log for skip details`, + ); + } else { + await progress(options, "info", "select", "no eligible issue found"); + } + return withLogPath({ status: "no-issue" }, options); + } + + // A reset has already archived and validated the prior attempt. Its seed is + // authoritative; do not let pre-reset checkpoints re-enter resume policy. + if (options.lease && options.lease.record.issueNumber !== issue.number) + throw new AgentIssueSafetyError( + `Run lease for issue #${options.lease.record.issueNumber} cannot process issue #${issue.number}`, + ); + if ( + options.leasedIssueNumber !== undefined && + options.leasedIssueNumber !== issue.number + ) + throw new AgentIssueSafetyError( + `Leased selection changed from issue #${options.leasedIssueNumber} to issue #${issue.number}`, + ); + + let existingState: AgentIssueRunState | undefined = options.reset + ? { + issueNumber: issue.number, + title: issue.title, + status: "claimed", + ...(options.reset.seed.specPath + ? { specPath: options.reset.seed.specPath } + : {}), + ...(options.reset.seed.specCommit + ? { specCommit: options.reset.seed.specCommit } + : {}), + ...(options.reset.seed.planPath + ? { planPath: options.reset.seed.planPath } + : {}), + ...(options.reset.seed.planCommit + ? { planCommit: options.reset.seed.planCommit } + : {}), + checkpoints: resetReceiptCheckpoints(options.reset.seed), + leaseProtocolVersion: 1, + createdAt: (options.now ?? new Date()).toISOString(), + updatedAt: (options.now ?? new Date()).toISOString(), + } + : await readRunState(config.runStateDir, issue.number); + // Every real attempt is serialized from selection through its final effect. + // A caller-supplied lease (reset) is borrowed and is therefore not released. + if (!config.dryRun && !options.lease) { + return withIssueRunLease( + { runStateDir: config.runStateDir, issueNumber: issue.number }, + (lease) => + runLegacyOneIssueInternal(runner, config, { + ...options, + lease, + leasedIssueNumber: issue.number, + }), + ); + } + if (!config.dryRun && options.lease) + existingState = await adoptLegacyRecoveryLease({ + config, + issueNumber: issue.number, + state: existingState, + lease: options.lease, + }); + const piAgentDir = localPiAgentDir(config.repoRoot); + const resumed = selected?.resumed ?? false; + const ordinaryResumableState = + resumed && !!existingState && isResumableRunState(existingState); + + await progress( + options, + "info", + "select", + `${resumed ? "resuming" : "selected"} #${issue.number} ${issue.title}`, + { issueNumber: issue.number }, + ); + await progress( + options, + "info", + "git", + "checking issue branch base containment", + { issueNumber: issue.number }, + ); + await assertIssueBaseContainedInPrBase( + runner, + config.repoRoot, + config.baseRef, + config.remote, + config.baseBranch, + ); + const timestamp = (options.now ?? new Date()).toISOString(); + const piSessionPath = runPiSessionPath( + config.runStateDir, + timestamp, + issue.number, + ); + const runOptions = { ...options, piSessionPath }; + if (config.dryRun) { + const { ready } = lifecycleLabels(config); + const state = resolveWorkflowState(issue.labels, { + readyLabel: ready, + policy: config.approvalPolicy, + }); + return withLogPath( + { + status: "dry-run", + issue, + transition: workflowTransition(state, config), + }, + runOptions, + ); + } + + const ignoredPaths = cleanStatusIgnoredPaths( + config, + runOptions.logPath === undefined ? {} : { logPath: runOptions.logPath }, + ); + // Typed recovery owns every blocked-state classification, including safely + // recreatable missing worktrees and branches. Do not pre-gate it here. + const blockedRecoveryResumable = hasBlockedRunRecoveryState(existingState); + const planningWorkspaceResumable = + hasFinishedPlanningWorkspaceState(existingState); + const resumableState = + ordinaryResumableState || + blockedRecoveryResumable || + planningWorkspaceResumable; + const resetStaleCheckpoints = !!existingState && !resumableState; + const checkpoints = { + ...(options.reset + ? resetReceiptCheckpoints(options.reset.seed) + : (effectiveCheckpoints(existingState?.checkpoints, resumableState) ?? + {})), + }; + + if ( + resetStaleCheckpoints && + (existingState?.branch || existingState?.worktreePath) + ) { + throw new AgentIssueSafetyError( + `Non-resumable run state for issue #${issue.number} has stale branch/worktree; clean up before starting a fresh run`, + ); + } + + const tokenUsageState = { total: 0 }; + let issueForRun = issue; + let resolvedArtifacts: ResolvedIssueArtifactSources = {}; + const stepAccounting = createStepAccounting({ + progress: runOptions.progress, + issueNumber: issue.number, + runStartedAtMs: (runOptions.now ?? new Date()).getTime(), + }); + const stepStart = stepAccounting.start; + const stepComplete = stepAccounting.complete; + const runStep = stepAccounting.run; + const observePi = + ( + stage: + | "pi-artifact-extraction" + | "pi-plan" + | "pi-development-environment" + | "pi-implementation", + ) => + async ( + observation: AgentIssueProgressEvent["observation"], + ): Promise => { + await stepAccounting.observe(stage, observation); + }; + await runOptions.progress?.event({ + time: timestamp, + level: "info", + stage: "run", + message: `issue #${issue.number} · ${issue.title}`, + issueNumber: issue.number, + step: { type: "run-start", issueNumber: issue.number, title: issue.title }, + }); + await emitSimpleStep(runOptions, issue.number, "select issue"); + const artifactSources = await runArtifactSourceStage({ + host, + config, + issue, + now: runOptions.now ?? new Date(), + progress: (level, stage, message, extras) => + progress(runOptions, level, stage, message, extras), + runStep, + }); + issueForRun = artifactSources.issue; + resolvedArtifacts = artifactSources.resolvedArtifacts; + let specPath: string | undefined; + let specCommit: string | undefined; + let planPath: string | undefined; + let planCommit: string | undefined; + let branch: string | undefined; + let worktreePath: string | undefined; + let ensuredWorktree: IssueWorktreeResult | undefined; + let artifactPolicy: PlanningArtifactPolicy | undefined; + const worktreeStrategy = configuredWorktreeStrategy(config); + const expectedWorkspace = expectedIssueWorkspace( + issue.number, + issue.title, + worktreeStrategy, + ); + const blockedRecovery = await recoverBlockedWorkspace({ + runner, + config, + issueNumber: issue.number, + existingState, + expectedWorkspace, + ignoredPaths, + resolvedArtifacts, + lease: options.lease, + }); + if ( + blockedRecovery?.decision.action === "resume" && + blockedRecovery.decision.assessment.worktree.ignoredEntries.length + ) { + const message = formatRunRecoveryDecision(blockedRecovery.decision); + await progress(runOptions, "info", "recovery", message, { + issueNumber: issue.number, + consoleMessage: message, + data: { + action: "resume", + mutationApplied: false, + ignoredEntries: + blockedRecovery.decision.assessment.worktree.ignoredEntries, + }, + }); + } + const assertExpectedWorkspaceIdentity = (): void => { + if ( + resumableState && + existingState?.branch && + existingState.branch !== expectedWorkspace.branch + ) { + throw new AgentIssueSafetyError( + `Saved branch ${existingState.branch} does not match expected branch ${expectedWorkspace.branch}`, + ); + } + if ( + resumableState && + existingState?.worktreePath && + existingState.worktreePath !== expectedWorkspace.worktreePath + ) { + throw new AgentIssueSafetyError( + `Saved worktree ${existingState.worktreePath} does not match expected worktree path ${expectedWorkspace.worktreePath}`, + ); + } + }; + const hasApprovalLabel = [ + config.approvalPolicy.specApproval.approvedLabel, + config.approvalPolicy.planApproval.approvedLabel, + ].some((label) => issueForRun.labels.includes(label)); + const artifactWorkspace = hasApprovalLabel + ? await (async () => { + assertExpectedWorkspaceIdentity(); + return await inspectIssueWorkspace(runner, config.repoRoot, { + branch: expectedWorkspace.branch, + worktreePath: + existingState?.worktreePath ?? expectedWorkspace.worktreePath, + }); + })() + : undefined; + const ensureIssueWorkspace = async (): Promise => { + if (ensuredWorktree) return ensuredWorktree; + assertExpectedWorkspaceIdentity(); + + const worktree = await ensureIssueWorktree( + runner, + config.repoRoot, + issue.number, + issue.title, + worktreeStrategy, + undefined, + ignoredPaths, + ); + await emitSimpleStep( + runOptions, + issue.number, + worktree.created ? "create worktree" : "reuse worktree", + ); + if ( + resumableState && + existingState?.branch && + existingState.branch !== worktree.branch + ) { + throw new AgentIssueSafetyError( + `Saved branch ${existingState.branch} does not match ensured worktree branch ${worktree.branch}`, + ); + } + if ( + resumableState && + existingState?.worktreePath && + existingState.worktreePath !== worktree.worktreePath + ) { + throw new AgentIssueSafetyError( + `Saved worktree ${existingState.worktreePath} does not match ensured worktree path ${worktree.worktreePath}`, + ); + } + branch = resumableState + ? (existingState?.branch ?? worktree.branch) + : worktree.branch; + worktreePath = resumableState + ? (existingState?.worktreePath ?? worktree.worktreePath) + : worktree.worktreePath; + await progress( + runOptions, + "info", + "worktree", + `${worktree.created ? "creating" : "reusing"} worktree ${worktreePath}`, + { issueNumber: issue.number }, + ); + ensuredWorktree = worktree; + return worktree; + }; + + const { ready, inProgress, done, needsInfo } = lifecycleLabels(config); + const approvedArtifactPreflight = await assertApprovedArtifactsResolvable({ + config, + issue: issueForRun, + existingState, + resolvedArtifacts, + now: runOptions.now ?? new Date(), + artifactWorkspace, + runner, + }); + await progress(runOptions, "info", "git", "checking repository status", { + issueNumber: issue.number, + }); + await assertCleanWorktree(runner, config.repoRoot, ignoredPaths); + if (artifactWorkspace?.kind === "branch") { + const workspace = await ensureIssueWorkspace(); + artifactPolicy = planningArtifactPolicyForWorkspace({ + config, + existingState, + resolvedArtifacts, + worktreePath: workspace.worktreePath, + allowGeneratedSpec: false, + allowGeneratedPlan: false, + }); + } else if (approvedArtifactPreflight) { + artifactPolicy = approvedArtifactPreflight.policy; + } + + const recoveringBlocked = + hasBlockedRunRecoveryState(existingState) || !!options.reset; + let labels = recoveryClaimLabels(issue.labels, { + ready, + inProgress, + needsInfo, + recoveringBlocked, + }); + if ( + recoveringBlocked || + !checkpoints.claimed || + (planningWorkspaceResumable && !issue.labels.includes(inProgress)) + ) { + await runStep("claim issue", async () => { + await progress( + runOptions, + "info", + "labels", + `ensuring ${inProgress} label exists`, + { issueNumber: issue.number }, + ); + await ensureAutomationLabel(host, config, inProgress); + await host.applyLabels( + planLabelChange(issue.number, issue.labels, labels), + ); + await progress( + runOptions, + "info", + "claim", + `claimed #${issue.number}: ${ready} -> ${inProgress}`, + { issueNumber: issue.number }, + ); + if (options.reset) { + await replaceRunStateAfterReset( + config.runStateDir, + { + issueNumber: issue.number, + title: issue.title, + seed: options.reset.seed, + }, + timestamp, + ); + } else { + if (recoveringBlocked && existingState?.lastError) { + const blocked = { + status: "blocked" as const, + reason: existingState.lastError, + questions: existingState.blockerQuestions ?? [], + commits: existingState.commits ?? [], + validation: existingState.validation ?? [], + }; + const body = blockerComment(blocked); + if (issue.comments?.some((comment) => comment.body === body)) + await writeRunState( + config.runStateDir, + { + issueNumber: issue.number, + title: issue.title, + status: existingState.status, + blockerCommentKeys: [blockerCommentKey(blocked)], + }, + timestamp, + ); + } + await writeRunState( + config.runStateDir, + { + issueNumber: issue.number, + title: issue.title, + status: "claimed", + checkpoints: { claimed: true }, + resetCheckpoints: resetStaleCheckpoints, + clearLastError: recoveringBlocked, + clearBlockerQuestions: recoveringBlocked, + leaseProtocolVersion: 1, + }, + timestamp, + ); + } + checkpoints.claimed = true; + }); + } + + try { + if (!checkpoints.startedCommentPosted) { + await host.commentIssue(issueForRun.number, startedComment(issueForRun)); + await writeRunState( + config.runStateDir, + { + issueNumber: issueForRun.number, + title: issueForRun.title, + status: "claimed", + checkpoints: { startedCommentPosted: true }, + }, + timestamp, + ); + checkpoints.startedCommentPosted = true; + } + + if ( + !artifactPolicy && + resumableState && + existingState && + (existingState.branch || existingState.worktreePath) && + !!(existingState.specPath || existingState.planPath) + ) { + const resumeWorktree = await ensureIssueWorkspace(); + const savedWorktreePath = + existingState.worktreePath ?? resumeWorktree.worktreePath; + artifactPolicy = planningArtifactPolicyForWorkspace({ + config, + existingState, + resolvedArtifacts, + worktreePath: savedWorktreePath, + allowGeneratedSpec: false, + allowGeneratedPlan: false, + }); + await progress( + runOptions, + "info", + "resume", + `reusing saved worktree for artifact lookup: ${savedWorktreePath}`, + { issueNumber: issue.number }, + ); + } + + if (artifactPolicy?.kind === "implementation-resume") { + const resumeArtifactPolicy = artifactPolicy; + const sourcesToMaterialize = { + ...(!resumeArtifactPolicy.saved.specPath && resolvedArtifacts.spec + ? { spec: resolvedArtifacts.spec } + : {}), + ...(!artifactPolicy.saved.planPath && resolvedArtifacts.plan + ? { plan: resolvedArtifacts.plan } + : {}), + }; + if (sourcesToMaterialize.spec || sourcesToMaterialize.plan) { + const materializedArtifacts = await runStep( + "materialize issue artifact sources", + async () => + materializeIssueArtifactSources({ + repoRoot: resumeArtifactPolicy.primary.repoRoot, + runner, + issueNumber: issueForRun.number, + sources: sourcesToMaterialize, + }), + ); + resolvedArtifacts = { + ...resolvedArtifacts, + ...materializedArtifacts, + }; + artifactPolicy = { + ...artifactPolicy, + explicit: resolvedArtifacts, + }; + } + if (!approvedArtifactPreflight) { + await resolvePlanningArtifacts({ + policy: artifactPolicy, + issue: issueForRun, + now: runOptions.now ?? new Date(), + }); + } + } else { + const artifactWorktree = + resolvedArtifacts.spec || resolvedArtifacts.plan + ? await ensureIssueWorkspace() + : undefined; + const artifactRepoRoot = artifactWorktree + ? join(config.repoRoot, artifactWorktree.worktreePath) + : config.repoRoot; + const materializedArtifacts = await runStep( + "materialize issue artifact sources", + async () => + materializeIssueArtifactSources({ + repoRoot: artifactRepoRoot, + runner, + issueNumber: issueForRun.number, + sources: resolvedArtifacts, + }), + ); + resolvedArtifacts = materializedArtifacts; + } + + if (!artifactPolicy && (resolvedArtifacts.spec || resolvedArtifacts.plan)) { + await writeRunState( + config.runStateDir, + { + issueNumber: issueForRun.number, + title: issueForRun.title, + status: "planning", + specPath: resolvedArtifacts.spec?.path, + specCommit: resolvedArtifacts.spec?.commit, + planPath: resolvedArtifacts.plan?.path, + planCommit: resolvedArtifacts.plan?.commit, + }, + timestamp, + ); + } + + const planningStages = await advancePlanningStages({ + runner, + host, + config, + issue: issueForRun, + labels, + ready, + inProgress, + needsInfo, + approvalGatesSatisfied: + ordinaryResumableState && existingState?.status === "implementing", + existingState, + resolvedArtifacts, + artifactPolicy, + ensurePlanningArtifactWorkspace: async () => { + const workspace = await ensureIssueWorkspace(); + return { + repoRoot: join(config.repoRoot, workspace.worktreePath), + branch: workspace.branch, + worktreePath: workspace.worktreePath, + }; + }, + checkpoints, + timestamp, + now: runOptions.now ?? new Date(), + runOptions, + piAgentDir, + tokenUsageState, + progress: (level, stage, message, extras) => + progress(runOptions, level, stage, message, extras), + runStep, + observePi, + emitSimpleStep: (issueNumber, label) => + emitSimpleStep(runOptions, issueNumber, label), + blockIssue: (result, details) => + blockIssue( + host, + config, + issueForRun, + labels, + result, + details, + timestamp, + runOptions, + ), + }); + if (planningStages.kind === "finished") { + return withLogPath(planningStages.result, runOptions); + } + + labels = planningStages.labels; + specPath = planningStages.specPath; + specCommit = planningStages.specCommit; + planPath = planningStages.planPath; + planCommit = planningStages.planCommit; + + const implementationLabels = nextLabels( + cleanupLabelsForImplementation(labels, { + readyLabel: ready, + policy: config.approvalPolicy, + }), + [], + [inProgress], + ); + if (implementationLabels.join("\0") !== labels.join("\0")) { + await host.applyLabels( + planLabelChange(issue.number, labels, implementationLabels), + ); + labels = implementationLabels; + } + + const worktree = await ensureIssueWorkspace(); + const implementationWorktreePath = worktreePath ?? worktree.worktreePath; + const implementationBranch = branch ?? worktree.branch; + if (!implementationBranch || !planPath) { + throw new AgentIssueSafetyError( + `Implementation requires a branch and plan for issue #${issue.number}`, + ); + } + const implementationStage = await runPipelineImplementationStage({ + runner, + host, + config, + issue, + labels, + readyLabel: ready, + inProgressLabel: inProgress, + specPath, + specCommit, + planPath, + planCommit, + branch: implementationBranch, + worktreePath: implementationWorktreePath, + worktree, + worktreeStrategy, + existingState, + resumableState, + implementationCompleted: checkpoints.implementationCompleted, + checkpoints, + timestamp, + runOptions, + piAgentDir, + tokenUsageState, + progressReporter: runOptions.progress, + runStep, + stepStart, + stepComplete, + observePi, + emitSimpleStep: (issueNumber, label) => + emitSimpleStep(runOptions, issueNumber, label), + blockIssue: (result, details) => + blockIssue( + host, + config, + issue, + labels, + result, + details, + timestamp, + runOptions, + ), + }); + + if (implementationStage.kind === "blocked") { + return withLogPath(implementationStage.result, runOptions); + } + if (implementationStage.kind === "unexpected") { + throw implementationStage.error; + } + labels = implementationStage.labels; + checkpoints.worktreeReady = true; + + const runCostReport = await resolvePipelineRunCost({ + implementationKind: implementationStage.kind, + implementationStatus: implementationStage.result.status, + piSessionPath: runOptions.piSessionPath, + persistedReport: existingState?.runCostReport, + warn: (message, error) => + progress(runOptions, "warning", "run-cost", message, { + issueNumber: issue.number, + data: error instanceof Error ? error.message : String(error ?? ""), + consoleMessage: `Warning: ${message}`, + }), + }); + + const finishStage = await runPipelineFinishStage({ + runner, + host, + config, + issue, + labels, + readyLabel: ready, + inProgressLabel: inProgress, + doneLabel: done, + needsInfoLabel: needsInfo, + checkpoints, + implemented: implementationStage.result, + runCostReport, + specPath, + specCommit, + planPath, + planCommit, + branch: implementationBranch, + worktreePath: implementationWorktreePath, + timestamp, + runOptions, + runStep, + }); + + if (finishStage.kind === "unexpected") { + throw finishStage.error; + } + + return finishStage.result; + } catch (error) { + if (error instanceof AgentIssueSafetyError) { + throw error; + } + if (error instanceof PlanningArtifactSafetyError) { + throw new AgentIssueSafetyError(error.message); + } + return unexpectedFailure( + host, + config, + issue, + checkpoints, + { specPath, specCommit, planPath, planCommit, branch, worktreePath }, + timestamp, + error, + runOptions, + ); + } +} diff --git a/src/cli/commands/run-once/pipeline-lifecycle.ts b/src/cli/commands/run-once/pipeline-lifecycle.ts index c1130e82..05d6e9ae 100644 --- a/src/cli/commands/run-once/pipeline-lifecycle.ts +++ b/src/cli/commands/run-once/pipeline-lifecycle.ts @@ -62,12 +62,16 @@ export function lifecycleLabels( done: string; needsInfo: string; } { - const triagePolicy = config.triagePolicy ?? DEFAULT_TRIAGE_POLICY; return { - ready: config.triagePolicy?.labels.ready ?? config.readyLabel, - inProgress: triagePolicy.labels.inProgress, - done: triagePolicy.labels.done, - needsInfo: triagePolicy.labels.needsInfo, + ready: config.triagePolicy?.labels?.ready ?? config.readyLabel, + inProgress: + config.triagePolicy?.labels?.inProgress ?? + DEFAULT_TRIAGE_POLICY.labels.inProgress, + done: + config.triagePolicy?.labels?.done ?? DEFAULT_TRIAGE_POLICY.labels.done, + needsInfo: + config.triagePolicy?.labels?.needsInfo ?? + DEFAULT_TRIAGE_POLICY.labels.needsInfo, }; } diff --git a/src/cli/commands/run-once/pipeline-planning.test.ts b/src/cli/commands/run-once/pipeline-planning.test.ts index e9745476..e5b151ed 100644 --- a/src/cli/commands/run-once/pipeline-planning.test.ts +++ b/src/cli/commands/run-once/pipeline-planning.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { runStatePath, writeRunState } from "./run-state.ts"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { configuredWorktreeStrategy, expectedIssueWorkspace, diff --git a/src/cli/commands/run-once/pipeline-progress-scenarios.test.ts b/src/cli/commands/run-once/pipeline-progress-scenarios.test.ts index ad96c7bb..58e9603d 100644 --- a/src/cli/commands/run-once/pipeline-progress-scenarios.test.ts +++ b/src/cli/commands/run-once/pipeline-progress-scenarios.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { AgentIssueConsoleProgressReporter } from "./console-progress.ts"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { compositeProgressReporter } from "./progress.ts"; import { writeRunOnceResult } from "./result-output.ts"; import { summarizeResult } from "./result-summary.ts"; diff --git a/src/cli/commands/run-once/pipeline-recovery-ignored-content.test.ts b/src/cli/commands/run-once/pipeline-recovery-ignored-content.test.ts index cedbb891..2f3aec6e 100644 --- a/src/cli/commands/run-once/pipeline-recovery-ignored-content.test.ts +++ b/src/cli/commands/run-once/pipeline-recovery-ignored-content.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFile, stat, writeFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; import test from "node:test"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { blockedRecoveryRunner, makeConfig, diff --git a/src/cli/commands/run-once/pipeline-selection-scenarios.test.ts b/src/cli/commands/run-once/pipeline-selection-scenarios.test.ts index 3013fb7e..bef1246e 100644 --- a/src/cli/commands/run-once/pipeline-selection-scenarios.test.ts +++ b/src/cli/commands/run-once/pipeline-selection-scenarios.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { runStatePath, writeRunState } from "./run-state.ts"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { selectResumableIssue } from "./pipeline-selection.ts"; import { DEFAULT_TRIAGE_POLICY } from "../triage/labels.ts"; import { JsonlProgressReporter } from "./progress.ts"; diff --git a/src/cli/commands/run-once/pipeline-workspace-scenarios.test.ts b/src/cli/commands/run-once/pipeline-workspace-scenarios.test.ts index c6ceea7c..035931c8 100644 --- a/src/cli/commands/run-once/pipeline-workspace-scenarios.test.ts +++ b/src/cli/commands/run-once/pipeline-workspace-scenarios.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { runStatePath, writeRunState } from "./run-state.ts"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { formatPublishedArtifactComment } from "../../../workflow/artifacts/published-artifacts.ts"; import { issue, diff --git a/src/cli/commands/run-once/pipeline-workspace.ts b/src/cli/commands/run-once/pipeline-workspace.ts index 1da61f3d..cdaf5af0 100644 --- a/src/cli/commands/run-once/pipeline-workspace.ts +++ b/src/cli/commands/run-once/pipeline-workspace.ts @@ -1,4 +1,4 @@ -import { isAbsolute, join, relative } from "node:path"; +import { isAbsolute, join, relative, resolve } from "node:path"; import { buildIssueBranchName, buildIssueWorktreePath, @@ -52,7 +52,7 @@ export function resumePlanningArtifactPolicy(input: { existingState: NonNullable>>; resolvedArtifacts: ResolvedIssueArtifactSources; }): PlanningArtifactPolicy { - const worktreeRoot = join(input.config.repoRoot, input.worktreePath); + const worktreeRoot = resolve(input.config.repoRoot, input.worktreePath); return { kind: "implementation-resume", primary: { @@ -122,7 +122,7 @@ export function planningArtifactPolicyForWorkspace(input: { resolvedArtifacts: input.resolvedArtifacts, allowGeneratedSpec: input.allowGeneratedSpec, allowGeneratedPlan: input.allowGeneratedPlan, - workspaceRoot: join(input.config.repoRoot, input.worktreePath), + workspaceRoot: resolve(input.config.repoRoot, input.worktreePath), }); } @@ -137,7 +137,7 @@ export function freshPlanningArtifactPolicy(input: { const worktreeRoot = input.workspaceRoot ?? (input.existingState?.worktreePath - ? join(input.config.repoRoot, input.existingState.worktreePath) + ? resolve(input.config.repoRoot, input.existingState.worktreePath) : undefined); const primaryRoot = worktreeRoot ?? input.config.repoRoot; diff --git a/src/cli/commands/run-once/pipeline.test.ts b/src/cli/commands/run-once/pipeline.test.ts index ac08dd22..4d3b038b 100644 --- a/src/cli/commands/run-once/pipeline.test.ts +++ b/src/cli/commands/run-once/pipeline.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { join } from "node:path"; import { mkdir, writeFile } from "node:fs/promises"; import { DEFAULT_PATCHMILL_CONFIG } from "../../../config/defaults.ts"; -import { runOneIssue } from "./pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "./pipeline-legacy.ts"; import { readRunState, writeRunState } from "./run-state.ts"; import { blockedRecoveryRunner, diff --git a/src/cli/commands/run-once/pipeline.ts b/src/cli/commands/run-once/pipeline.ts index e7328d96..9982bdc6 100644 --- a/src/cli/commands/run-once/pipeline.ts +++ b/src/cli/commands/run-once/pipeline.ts @@ -1,953 +1,96 @@ -import { join } from "node:path"; -import { localPiAgentDir } from "../init/pi-agent-settings.ts"; - import { createRunOnceHostProvider } from "../../../host/factory.ts"; -import { planLabelChange } from "../triage/labels.ts"; -import { materializeIssueArtifactSources } from "./artifact-source-materialization.ts"; -import { assertApprovedArtifactsResolvable } from "./approval-artifact-preflight.ts"; -import { runArtifactSourceStage } from "./artifact-source-stage.ts"; -import type { ResolvedIssueArtifactSources } from "./artifact-sources.ts"; -import { ensureAutomationLabel } from "./automation-labels.ts"; -import { - assertCleanWorktree, - assertIssueBaseContainedInPrBase, - ensureIssueWorktree, - inspectIssueWorkspace, - type IssueWorktreeResult, -} from "./git.ts"; -import { - PlanningArtifactSafetyError, - resolvePlanningArtifacts, - type PlanningArtifactPolicy, -} from "./planning-artifacts.ts"; -import { advancePlanningStages } from "./stage-advancement.ts"; -import { - ApprovalRequiredError, - cleanupLabelsForImplementation, - resolveWorkflowState, -} from "./workflow-state.ts"; -import { - isResumableRunState, - readRunState, - replaceRunStateAfterReset, - writeRunState, -} from "./run-state.ts"; -import { selectIssueWithDiagnostics } from "./selection.ts"; -import { - createStepAccounting, - emitSimpleStep, - progress, - withLogPath, -} from "./pipeline-progress.ts"; -import { - AgentIssueSafetyError, - hasBlockedRunRecoveryState, - effectiveCheckpoints, - lifecycleLabels, - recoveryClaimLabels, - resetReceiptCheckpoints, - nextLabels, - workflowTransition, -} from "./pipeline-lifecycle.ts"; -import { - blockerComment, - blockerCommentKey, - startedComment, -} from "./pipeline-comments.ts"; +import { PlanningStateStore } from "../../../workflow/planning-state-store.ts"; import { - cleanStatusIgnoredPaths, - configuredWorktreeStrategy, - expectedIssueWorkspace, - planningArtifactPolicyForWorkspace, -} from "./pipeline-workspace.ts"; -import { - emitSelectionDiagnostics, - loadSelectionIssues, - selectResumableIssue, -} from "./pipeline-selection.ts"; -import { blockIssue, unexpectedFailure } from "./pipeline-failures.ts"; -import { withIssueRunLease } from "./recovery-lease.ts"; -import { formatRunRecoveryDecision } from "./recovery.ts"; -import { - adoptLegacyRecoveryLease, - recoverBlockedWorkspace, -} from "./pipeline-recovery.ts"; -import { runPipelineImplementationStage } from "./pipeline-implementation.ts"; -import { runPipelineFinishStage } from "./pipeline-finish.ts"; -import { resolvePipelineRunCost } from "./pipeline-run-cost.ts"; -import { - runPiSessionPath, - type AgentIssueProgressEvent, - type ProgressReporter, -} from "./progress.ts"; + runLegacyOneIssue, + runLegacyOneIssueAfterReset, + runLegacyOneIssueForSelection, + type RunOneIssueOptions, +} from "./pipeline-legacy.ts"; +import { runPlanningWorkflow } from "./planning-pipeline.ts"; +import { selectRunOnceWorkflow } from "./planning-selection.ts"; +import { loadSelectionIssues } from "./pipeline-selection.ts"; +import { withLogPath } from "./pipeline-progress.ts"; import type { AgentIssueConfig, AgentIssuePipelineResult, - AgentIssueRunState, CommandRunner, - IssueSummary, } from "./types.ts"; -type PiOutputStream = (chunk: string) => void; - -export type RunOneIssueOptions = { - now?: Date | undefined; - progress?: ProgressReporter | undefined; - logPath?: string | undefined; - streamPiOutput?: PiOutputStream | undefined; - verbosePiOutput?: boolean | undefined; - heartbeatMs?: number | undefined; -}; -type LeasedRunOneIssueOptions = RunOneIssueOptions & { - lease?: import("./types.ts").IssueRunLease; - /** Internal selection pin; never exposed to ordinary callers. */ - leasedIssueNumber?: number; - reset?: { seed: import("./types.ts").RunResetSeed }; -}; - -function hasFinishedPlanningWorkspaceState( - state: AgentIssueRunState | undefined, -): boolean { - return ( - state?.status === "finished" && - state.implementationStatus === undefined && - !!(state.specPath || state.planPath) && - !!(state.branch || state.worktreePath) - ); -} +export type { RunOneIssueOptions } from "./pipeline-legacy.ts"; +/** Public facade that selects exactly once, then pins the chosen workflow. */ export async function runOneIssue( runner: CommandRunner, config: AgentIssueConfig, options: RunOneIssueOptions = {}, ): Promise { - return runOneIssueInternal(runner, config, options); -} - -/** Reset uses this narrow leased entry point after it has archived state. */ -export async function runOneIssueAfterReset( - runner: CommandRunner, - config: AgentIssueConfig, - options: RunOneIssueOptions, - reset: { - lease: import("./types.ts").IssueRunLease; - seed: import("./types.ts").RunResetSeed; - }, -): Promise { - return runOneIssueInternal(runner, config, { - ...options, - lease: reset.lease, - reset: { seed: reset.seed }, - }); -} - -async function runOneIssueInternal( - runner: CommandRunner, - config: AgentIssueConfig, - options: LeasedRunOneIssueOptions = {}, -): Promise { + // Preserve legacy dry-run output and its non-mutating diagnostic contract. + if (config.dryRun) return runLegacyOneIssue(runner, config, options); const host = createRunOnceHostProvider({ runner, repoRoot: config.repoRoot, host: config.host, }); - // Once an automatic selection has acquired its lease, re-read only that - // issue. Never perform another priority selection while holding a lease for - // a different issue. - const loadedIssues = - options.leasedIssueNumber === undefined - ? await loadSelectionIssues(host, config, options) - : [await host.viewIssue(options.leasedIssueNumber)]; - // A blocked saved attempt is never an implicit retry target. It needs an - // explicit issue acknowledgement and the normal recovery gate below. - const issues = - config.issueNumber === undefined - ? ( - await Promise.all( - loadedIssues.map(async (candidate) => ({ - candidate, - state: await readRunState(config.runStateDir, candidate.number), - })), - ) - ) - .filter(({ state }) => !hasBlockedRunRecoveryState(state)) - .map(({ candidate }) => candidate) - : loadedIssues; - let selected: { issue: IssueSummary; resumed: boolean } | undefined; - try { - selected = options.reset - ? (() => { - const resetIssue = issues.find( - (candidate) => candidate.number === config.issueNumber, - ); - if (!resetIssue) - throw new Error( - `Reset issue #${config.issueNumber ?? "?"} disappeared before pipeline entry`, - ); - return { issue: resetIssue, resumed: false }; - })() - : await selectResumableIssue(issues, config); - } catch (error) { - if (error instanceof ApprovalRequiredError) { + const issues = await loadSelectionIssues(host, config, options); + const selected = await selectRunOnceWorkflow( + issues, + config, + new PlanningStateStore(config.runStateDir), + ); + switch (selected.kind) { + case "none": + return withLogPath({ status: "no-issue" }, options); + case "invalid-planning-state": return withLogPath( { - status: "approval-required", - issue: error.issue, - approvalKind: error.approvalKind, - missingLabel: error.missingLabel, + status: "blocked", + issue: selected.issue, + reason: `planning-state-invalid: ${selected.reason}`, + questions: [], + commits: [], + validation: [], }, options, ); - } - throw error; - } - const issue = selected?.issue; - - if (!issue) { - if (config.issueNumber === undefined) { - const diagnostics = selectIssueWithDiagnostics(issues, { - readyLabel: lifecycleLabels(config).ready, - triagePolicy: config.triagePolicy, - approvalPolicy: config.approvalPolicy, - }); - await emitSelectionDiagnostics(diagnostics.rejections, options); - await progress( + case "legacy": + return runLegacyOneIssueForSelection( + runner, + config, + selected.issue.number, options, - "info", - "select", - `no eligible issue found after considering ${diagnostics.consideredCount} open issues; see run log for skip details`, ); - } else { - await progress(options, "info", "select", "no eligible issue found"); - } - return withLogPath({ status: "no-issue" }, options); - } - - // A reset has already archived and validated the prior attempt. Its seed is - // authoritative; do not let pre-reset checkpoints re-enter resume policy. - if (options.lease && options.lease.record.issueNumber !== issue.number) - throw new AgentIssueSafetyError( - `Run lease for issue #${options.lease.record.issueNumber} cannot process issue #${issue.number}`, - ); - if ( - options.leasedIssueNumber !== undefined && - options.leasedIssueNumber !== issue.number - ) - throw new AgentIssueSafetyError( - `Leased selection changed from issue #${options.leasedIssueNumber} to issue #${issue.number}`, - ); - - let existingState: AgentIssueRunState | undefined = options.reset - ? { - issueNumber: issue.number, - title: issue.title, - status: "claimed", - ...(options.reset.seed.specPath - ? { specPath: options.reset.seed.specPath } - : {}), - ...(options.reset.seed.specCommit - ? { specCommit: options.reset.seed.specCommit } - : {}), - ...(options.reset.seed.planPath - ? { planPath: options.reset.seed.planPath } - : {}), - ...(options.reset.seed.planCommit - ? { planCommit: options.reset.seed.planCommit } - : {}), - checkpoints: resetReceiptCheckpoints(options.reset.seed), - leaseProtocolVersion: 1, - createdAt: (options.now ?? new Date()).toISOString(), - updatedAt: (options.now ?? new Date()).toISOString(), - } - : await readRunState(config.runStateDir, issue.number); - // Every real attempt is serialized from selection through its final effect. - // A caller-supplied lease (reset) is borrowed and is therefore not released. - if (!config.dryRun && !options.lease) { - return withIssueRunLease( - { runStateDir: config.runStateDir, issueNumber: issue.number }, - (lease) => - runOneIssueInternal(runner, config, { - ...options, - lease, - leasedIssueNumber: issue.number, - }), - ); - } - if (!config.dryRun && options.lease) - existingState = await adoptLegacyRecoveryLease({ - config, - issueNumber: issue.number, - state: existingState, - lease: options.lease, - }); - const piAgentDir = localPiAgentDir(config.repoRoot); - const resumed = selected?.resumed ?? false; - const ordinaryResumableState = - resumed && !!existingState && isResumableRunState(existingState); - - await progress( - options, - "info", - "select", - `${resumed ? "resuming" : "selected"} #${issue.number} ${issue.title}`, - { issueNumber: issue.number }, - ); - await progress( - options, - "info", - "git", - "checking issue branch base containment", - { issueNumber: issue.number }, - ); - await assertIssueBaseContainedInPrBase( - runner, - config.repoRoot, - config.baseRef, - config.remote, - config.baseBranch, - ); - const timestamp = (options.now ?? new Date()).toISOString(); - const piSessionPath = runPiSessionPath( - config.runStateDir, - timestamp, - issue.number, - ); - const runOptions = { ...options, piSessionPath }; - if (config.dryRun) { - const { ready } = lifecycleLabels(config); - const state = resolveWorkflowState(issue.labels, { - readyLabel: ready, - policy: config.approvalPolicy, - }); - return withLogPath( - { - status: "dry-run", - issue, - transition: workflowTransition(state, config), - }, - runOptions, - ); - } - - const ignoredPaths = cleanStatusIgnoredPaths( - config, - runOptions.logPath === undefined ? {} : { logPath: runOptions.logPath }, - ); - // Typed recovery owns every blocked-state classification, including safely - // recreatable missing worktrees and branches. Do not pre-gate it here. - const blockedRecoveryResumable = hasBlockedRunRecoveryState(existingState); - const planningWorkspaceResumable = - hasFinishedPlanningWorkspaceState(existingState); - const resumableState = - ordinaryResumableState || - blockedRecoveryResumable || - planningWorkspaceResumable; - const resetStaleCheckpoints = !!existingState && !resumableState; - const checkpoints = { - ...(options.reset - ? resetReceiptCheckpoints(options.reset.seed) - : (effectiveCheckpoints(existingState?.checkpoints, resumableState) ?? - {})), - }; - - if ( - resetStaleCheckpoints && - (existingState?.branch || existingState?.worktreePath) - ) { - throw new AgentIssueSafetyError( - `Non-resumable run state for issue #${issue.number} has stale branch/worktree; clean up before starting a fresh run`, - ); - } - - const tokenUsageState = { total: 0 }; - let issueForRun = issue; - let resolvedArtifacts: ResolvedIssueArtifactSources = {}; - const stepAccounting = createStepAccounting({ - progress: runOptions.progress, - issueNumber: issue.number, - runStartedAtMs: (runOptions.now ?? new Date()).getTime(), - }); - const stepStart = stepAccounting.start; - const stepComplete = stepAccounting.complete; - const runStep = stepAccounting.run; - const observePi = - ( - stage: - | "pi-artifact-extraction" - | "pi-plan" - | "pi-development-environment" - | "pi-implementation", - ) => - async ( - observation: AgentIssueProgressEvent["observation"], - ): Promise => { - await stepAccounting.observe(stage, observation); - }; - await runOptions.progress?.event({ - time: timestamp, - level: "info", - stage: "run", - message: `issue #${issue.number} · ${issue.title}`, - issueNumber: issue.number, - step: { type: "run-start", issueNumber: issue.number, title: issue.title }, - }); - await emitSimpleStep(runOptions, issue.number, "select issue"); - const artifactSources = await runArtifactSourceStage({ - host, - config, - issue, - now: runOptions.now ?? new Date(), - progress: (level, stage, message, extras) => - progress(runOptions, level, stage, message, extras), - runStep, - }); - issueForRun = artifactSources.issue; - resolvedArtifacts = artifactSources.resolvedArtifacts; - let specPath: string | undefined; - let specCommit: string | undefined; - let planPath: string | undefined; - let planCommit: string | undefined; - let branch: string | undefined; - let worktreePath: string | undefined; - let ensuredWorktree: IssueWorktreeResult | undefined; - let artifactPolicy: PlanningArtifactPolicy | undefined; - const worktreeStrategy = configuredWorktreeStrategy(config); - const expectedWorkspace = expectedIssueWorkspace( - issue.number, - issue.title, - worktreeStrategy, - ); - const blockedRecovery = await recoverBlockedWorkspace({ - runner, - config, - issueNumber: issue.number, - existingState, - expectedWorkspace, - ignoredPaths, - resolvedArtifacts, - lease: options.lease, - }); - if ( - blockedRecovery?.decision.action === "resume" && - blockedRecovery.decision.assessment.worktree.ignoredEntries.length - ) { - const message = formatRunRecoveryDecision(blockedRecovery.decision); - await progress(runOptions, "info", "recovery", message, { - issueNumber: issue.number, - consoleMessage: message, - data: { - action: "resume", - mutationApplied: false, - ignoredEntries: - blockedRecovery.decision.assessment.worktree.ignoredEntries, - }, - }); - } - const assertExpectedWorkspaceIdentity = (): void => { - if ( - resumableState && - existingState?.branch && - existingState.branch !== expectedWorkspace.branch - ) { - throw new AgentIssueSafetyError( - `Saved branch ${existingState.branch} does not match expected branch ${expectedWorkspace.branch}`, - ); - } - if ( - resumableState && - existingState?.worktreePath && - existingState.worktreePath !== expectedWorkspace.worktreePath - ) { - throw new AgentIssueSafetyError( - `Saved worktree ${existingState.worktreePath} does not match expected worktree path ${expectedWorkspace.worktreePath}`, - ); - } - }; - const hasApprovalLabel = [ - config.approvalPolicy.specApproval.approvedLabel, - config.approvalPolicy.planApproval.approvedLabel, - ].some((label) => issueForRun.labels.includes(label)); - const artifactWorkspace = hasApprovalLabel - ? await (async () => { - assertExpectedWorkspaceIdentity(); - return await inspectIssueWorkspace(runner, config.repoRoot, { - branch: expectedWorkspace.branch, - worktreePath: - existingState?.worktreePath ?? expectedWorkspace.worktreePath, - }); - })() - : undefined; - const ensureIssueWorkspace = async (): Promise => { - if (ensuredWorktree) return ensuredWorktree; - assertExpectedWorkspaceIdentity(); - - const worktree = await ensureIssueWorktree( - runner, - config.repoRoot, - issue.number, - issue.title, - worktreeStrategy, - undefined, - ignoredPaths, - ); - await emitSimpleStep( - runOptions, - issue.number, - worktree.created ? "create worktree" : "reuse worktree", - ); - if ( - resumableState && - existingState?.branch && - existingState.branch !== worktree.branch - ) { - throw new AgentIssueSafetyError( - `Saved branch ${existingState.branch} does not match ensured worktree branch ${worktree.branch}`, - ); - } - if ( - resumableState && - existingState?.worktreePath && - existingState.worktreePath !== worktree.worktreePath - ) { - throw new AgentIssueSafetyError( - `Saved worktree ${existingState.worktreePath} does not match ensured worktree path ${worktree.worktreePath}`, - ); - } - branch = resumableState - ? (existingState?.branch ?? worktree.branch) - : worktree.branch; - worktreePath = resumableState - ? (existingState?.worktreePath ?? worktree.worktreePath) - : worktree.worktreePath; - await progress( - runOptions, - "info", - "worktree", - `${worktree.created ? "creating" : "reusing"} worktree ${worktreePath}`, - { issueNumber: issue.number }, - ); - ensuredWorktree = worktree; - return worktree; - }; - - const { ready, inProgress, done, needsInfo } = lifecycleLabels(config); - const approvedArtifactPreflight = await assertApprovedArtifactsResolvable({ - config, - issue: issueForRun, - existingState, - resolvedArtifacts, - now: runOptions.now ?? new Date(), - artifactWorkspace, - runner, - }); - await progress(runOptions, "info", "git", "checking repository status", { - issueNumber: issue.number, - }); - await assertCleanWorktree(runner, config.repoRoot, ignoredPaths); - if (artifactWorkspace?.kind === "branch") { - const workspace = await ensureIssueWorkspace(); - artifactPolicy = planningArtifactPolicyForWorkspace({ - config, - existingState, - resolvedArtifacts, - worktreePath: workspace.worktreePath, - allowGeneratedSpec: false, - allowGeneratedPlan: false, - }); - } else if (approvedArtifactPreflight) { - artifactPolicy = approvedArtifactPreflight.policy; - } - - const recoveringBlocked = - hasBlockedRunRecoveryState(existingState) || !!options.reset; - let labels = recoveryClaimLabels(issue.labels, { - ready, - inProgress, - needsInfo, - recoveringBlocked, - }); - if ( - recoveringBlocked || - !checkpoints.claimed || - (planningWorkspaceResumable && !issue.labels.includes(inProgress)) - ) { - await runStep("claim issue", async () => { - await progress( - runOptions, - "info", - "labels", - `ensuring ${inProgress} label exists`, - { issueNumber: issue.number }, - ); - await ensureAutomationLabel(host, config, inProgress); - await host.applyLabels( - planLabelChange(issue.number, issue.labels, labels), - ); - await progress( - runOptions, - "info", - "claim", - `claimed #${issue.number}: ${ready} -> ${inProgress}`, - { issueNumber: issue.number }, - ); - if (options.reset) { - await replaceRunStateAfterReset( - config.runStateDir, - { - issueNumber: issue.number, - title: issue.title, - seed: options.reset.seed, - }, - timestamp, - ); - } else { - if (recoveringBlocked && existingState?.lastError) { - const blocked = { - status: "blocked" as const, - reason: existingState.lastError, - questions: existingState.blockerQuestions ?? [], - commits: existingState.commits ?? [], - validation: existingState.validation ?? [], - }; - const body = blockerComment(blocked); - if (issue.comments?.some((comment) => comment.body === body)) - await writeRunState( - config.runStateDir, - { - issueNumber: issue.number, - title: issue.title, - status: existingState.status, - blockerCommentKeys: [blockerCommentKey(blocked)], - }, - timestamp, - ); - } - await writeRunState( - config.runStateDir, - { - issueNumber: issue.number, - title: issue.title, - status: "claimed", - checkpoints: { claimed: true }, - resetCheckpoints: resetStaleCheckpoints, - clearLastError: recoveringBlocked, - clearBlockerQuestions: recoveringBlocked, - leaseProtocolVersion: 1, - }, - timestamp, - ); - } - checkpoints.claimed = true; - }); - } - - try { - if (!checkpoints.startedCommentPosted) { - await host.commentIssue(issueForRun.number, startedComment(issueForRun)); - await writeRunState( - config.runStateDir, - { - issueNumber: issueForRun.number, - title: issueForRun.title, - status: "claimed", - checkpoints: { startedCommentPosted: true }, - }, - timestamp, - ); - checkpoints.startedCommentPosted = true; - } - - if ( - !artifactPolicy && - resumableState && - existingState && - (existingState.branch || existingState.worktreePath) && - !!(existingState.specPath || existingState.planPath) - ) { - const resumeWorktree = await ensureIssueWorkspace(); - const savedWorktreePath = - existingState.worktreePath ?? resumeWorktree.worktreePath; - artifactPolicy = planningArtifactPolicyForWorkspace({ + case "planning": + return runPlanningWorkflow({ + runner, config, - existingState, - resolvedArtifacts, - worktreePath: savedWorktreePath, - allowGeneratedSpec: false, - allowGeneratedPlan: false, + options, + issue: selected.issue, + state: selected.state, + expectedStatePresence: "present", + host, + }); + case "fresh-planning": + return runPlanningWorkflow({ + runner, + config, + options, + issue: selected.issue, + state: selected.initialState, + expectedStatePresence: "absent", + host, }); - await progress( - runOptions, - "info", - "resume", - `reusing saved worktree for artifact lookup: ${savedWorktreePath}`, - { issueNumber: issue.number }, - ); - } - - if (artifactPolicy?.kind === "implementation-resume") { - const resumeArtifactPolicy = artifactPolicy; - const sourcesToMaterialize = { - ...(!resumeArtifactPolicy.saved.specPath && resolvedArtifacts.spec - ? { spec: resolvedArtifacts.spec } - : {}), - ...(!artifactPolicy.saved.planPath && resolvedArtifacts.plan - ? { plan: resolvedArtifacts.plan } - : {}), - }; - if (sourcesToMaterialize.spec || sourcesToMaterialize.plan) { - const materializedArtifacts = await runStep( - "materialize issue artifact sources", - async () => - materializeIssueArtifactSources({ - repoRoot: resumeArtifactPolicy.primary.repoRoot, - runner, - issueNumber: issueForRun.number, - sources: sourcesToMaterialize, - }), - ); - resolvedArtifacts = { - ...resolvedArtifacts, - ...materializedArtifacts, - }; - artifactPolicy = { - ...artifactPolicy, - explicit: resolvedArtifacts, - }; - } - if (!approvedArtifactPreflight) { - await resolvePlanningArtifacts({ - policy: artifactPolicy, - issue: issueForRun, - now: runOptions.now ?? new Date(), - }); - } - } else { - const artifactWorktree = - resolvedArtifacts.spec || resolvedArtifacts.plan - ? await ensureIssueWorkspace() - : undefined; - const artifactRepoRoot = artifactWorktree - ? join(config.repoRoot, artifactWorktree.worktreePath) - : config.repoRoot; - const materializedArtifacts = await runStep( - "materialize issue artifact sources", - async () => - materializeIssueArtifactSources({ - repoRoot: artifactRepoRoot, - runner, - issueNumber: issueForRun.number, - sources: resolvedArtifacts, - }), - ); - resolvedArtifacts = materializedArtifacts; - } - - if (!artifactPolicy && (resolvedArtifacts.spec || resolvedArtifacts.plan)) { - await writeRunState( - config.runStateDir, - { - issueNumber: issueForRun.number, - title: issueForRun.title, - status: "planning", - specPath: resolvedArtifacts.spec?.path, - specCommit: resolvedArtifacts.spec?.commit, - planPath: resolvedArtifacts.plan?.path, - planCommit: resolvedArtifacts.plan?.commit, - }, - timestamp, - ); - } - - const planningStages = await advancePlanningStages({ - runner, - host, - config, - issue: issueForRun, - labels, - ready, - inProgress, - needsInfo, - approvalGatesSatisfied: - ordinaryResumableState && existingState?.status === "implementing", - existingState, - resolvedArtifacts, - artifactPolicy, - ensurePlanningArtifactWorkspace: async () => { - const workspace = await ensureIssueWorkspace(); - return { - repoRoot: join(config.repoRoot, workspace.worktreePath), - branch: workspace.branch, - worktreePath: workspace.worktreePath, - }; - }, - checkpoints, - timestamp, - now: runOptions.now ?? new Date(), - runOptions, - piAgentDir, - tokenUsageState, - progress: (level, stage, message, extras) => - progress(runOptions, level, stage, message, extras), - runStep, - observePi, - emitSimpleStep: (issueNumber, label) => - emitSimpleStep(runOptions, issueNumber, label), - blockIssue: (result, details) => - blockIssue( - host, - config, - issueForRun, - labels, - result, - details, - timestamp, - runOptions, - ), - }); - if (planningStages.kind === "finished") { - return withLogPath(planningStages.result, runOptions); - } - - labels = planningStages.labels; - specPath = planningStages.specPath; - specCommit = planningStages.specCommit; - planPath = planningStages.planPath; - planCommit = planningStages.planCommit; - - const implementationLabels = nextLabels( - cleanupLabelsForImplementation(labels, { - readyLabel: ready, - policy: config.approvalPolicy, - }), - [], - [inProgress], - ); - if (implementationLabels.join("\0") !== labels.join("\0")) { - await host.applyLabels( - planLabelChange(issue.number, labels, implementationLabels), - ); - labels = implementationLabels; - } - - const worktree = await ensureIssueWorkspace(); - const implementationWorktreePath = worktreePath ?? worktree.worktreePath; - const implementationBranch = branch ?? worktree.branch; - if (!implementationBranch || !planPath) { - throw new AgentIssueSafetyError( - `Implementation requires a branch and plan for issue #${issue.number}`, - ); - } - const implementationStage = await runPipelineImplementationStage({ - runner, - host, - config, - issue, - labels, - readyLabel: ready, - inProgressLabel: inProgress, - specPath, - specCommit, - planPath, - planCommit, - branch: implementationBranch, - worktreePath: implementationWorktreePath, - worktree, - worktreeStrategy, - existingState, - resumableState, - implementationCompleted: checkpoints.implementationCompleted, - checkpoints, - timestamp, - runOptions, - piAgentDir, - tokenUsageState, - progressReporter: runOptions.progress, - runStep, - stepStart, - stepComplete, - observePi, - emitSimpleStep: (issueNumber, label) => - emitSimpleStep(runOptions, issueNumber, label), - blockIssue: (result, details) => - blockIssue( - host, - config, - issue, - labels, - result, - details, - timestamp, - runOptions, - ), - }); - - if (implementationStage.kind === "blocked") { - return withLogPath(implementationStage.result, runOptions); - } - if (implementationStage.kind === "unexpected") { - throw implementationStage.error; - } - labels = implementationStage.labels; - checkpoints.worktreeReady = true; - - const runCostReport = await resolvePipelineRunCost({ - implementationKind: implementationStage.kind, - implementationStatus: implementationStage.result.status, - piSessionPath: runOptions.piSessionPath, - persistedReport: existingState?.runCostReport, - warn: (message, error) => - progress(runOptions, "warning", "run-cost", message, { - issueNumber: issue.number, - data: error instanceof Error ? error.message : String(error ?? ""), - consoleMessage: `Warning: ${message}`, - }), - }); - - const finishStage = await runPipelineFinishStage({ - runner, - host, - config, - issue, - labels, - readyLabel: ready, - inProgressLabel: inProgress, - doneLabel: done, - needsInfoLabel: needsInfo, - checkpoints, - implemented: implementationStage.result, - runCostReport, - specPath, - specCommit, - planPath, - planCommit, - branch: implementationBranch, - worktreePath: implementationWorktreePath, - timestamp, - runOptions, - runStep, - }); - - if (finishStage.kind === "unexpected") { - throw finishStage.error; - } - - return finishStage.result; - } catch (error) { - if (error instanceof AgentIssueSafetyError) { - throw error; - } - if (error instanceof PlanningArtifactSafetyError) { - throw new AgentIssueSafetyError(error.message); - } - return unexpectedFailure( - host, - config, - issue, - checkpoints, - { specPath, specCommit, planPath, planCommit, branch, worktreePath }, - timestamp, - error, - runOptions, - ); } } + +/** Reset is intentionally pinned to the legacy recovery contract. */ +export async function runOneIssueAfterReset( + runner: CommandRunner, + config: AgentIssueConfig, + options: RunOneIssueOptions, + reset: { + lease: import("./types.ts").IssueRunLease; + seed: import("./types.ts").RunResetSeed; + }, +): Promise { + return runLegacyOneIssueAfterReset(runner, config, options, reset); +} diff --git a/src/cli/commands/run-once/planning-finish-effects.ts b/src/cli/commands/run-once/planning-finish-effects.ts new file mode 100644 index 00000000..fd48d2ef --- /dev/null +++ b/src/cli/commands/run-once/planning-finish-effects.ts @@ -0,0 +1,146 @@ +import { resolve } from "node:path"; +import { runCleanupHookScript } from "../../../pi/hooks.ts"; +import { renderPlanningPullRequestMarker } from "../../../workflow/planning-pull-request-markers.ts"; +import { ensureAutomationLabel } from "./automation-labels.ts"; +import { handoffComment } from "./pipeline-comments.ts"; +import { progress } from "./pipeline-progress.ts"; +import { applyPlanningDoneLabels } from "./planning-lifecycle-labels.ts"; +import { publishPlanningPrRunCost } from "./pr-cost-publication.ts"; +import { validateVisualEvidenceReferences } from "./visual-evidence.ts"; +import type { PlanningFinishInput } from "./planning-finish.ts"; +import type { PlanningStateV1 } from "../../../workflow/planning-state-types.ts"; +import type { RunOnceHostProvider } from "../../../host/types.ts"; +import { + artifactPath, + requiredImplementationFinishContext, +} from "./planning-runtime-state.ts"; +import type { AgentIssueConfig, CommandRunner, IssueSummary } from "./types.ts"; + +export type PlanningFinishEffectsInput = { + runner: CommandRunner; + config: AgentIssueConfig; + issue: IssueSummary; + labels: string[]; + readyLabel: string; + inProgressLabel: string; + doneLabel: string; + needsInfoLabel: string; + host: RunOnceHostProvider; + phaseIndex: number; + progressReporter?: Parameters[0]["progress"]; + now?: (() => Date) | undefined; +}; + +/** Builds finish effects exclusively from durable implementation evidence. */ +export function createPlanningFinishEffects( + input: PlanningFinishEffectsInput, +): ( + state: PlanningStateV1, +) => Omit< + PlanningFinishInput, + "state" | "phaseIndex" | "lock" | "stateStore" | "workspaces" +> { + return (durable) => { + const implementation = requiredImplementationFinishContext( + durable, + input.phaseIndex, + ); + return { + effects: { + publishCost: async () => { + const report = implementation.implementation.runCostReport; + if (report === undefined) return; + await publishPlanningPrRunCost({ + host: input.host, + prUrl: implementation.pullRequest.url, + marker: renderPlanningPullRequestMarker({ + issueNumber: durable.issueNumber, + phase: "implementation", + }), + issueNumber: durable.issueNumber, + report: { + stages: report.stages.map((stage) => ({ + ...stage, + models: stage.models.map((model) => ({ ...model })), + })), + promptTokens: report.promptTokens, + outputTokens: report.outputTokens, + estimatedCostUsd: report.estimatedCostUsd, + }, + }); + }, + validateVisualEvidence: async () => { + await validateVisualEvidenceReferences({ + repoRoot: resolve( + input.config.repoRoot, + implementation.workspace.identity.worktreePath, + ), + evidence: implementation.implementation.visualEvidence.map( + (item) => ({ + screenshotPath: item.screenshotPath, + ...(item.caption === undefined + ? {} + : { caption: item.caption }), + ...(item.referencePaths === undefined + ? {} + : { referencePaths: [...item.referencePaths] }), + ...(item.url === undefined ? {} : { url: item.url }), + }), + ), + runner: input.runner, + referenceScreenshotPaths: + input.config.projectPolicy.visualEvidence + .referenceScreenshotPaths, + }); + }, + postHandoff: async (result) => { + const body = handoffComment( + artifactPath(durable, "plan"), + result, + input.config.baseBranch, + ); + const current = await input.host.viewIssue(input.issue.number); + if (!current.comments?.some((comment) => comment.body === body)) + await input.host.commentIssue(input.issue.number, body); + }, + cleanupHook: async () => { + const results = await runCleanupHookScript( + input.runner, + input.config.repoRoot, + implementation.workspace.identity.worktreePath, + input.config.cleanupHook, + ); + if (results.some((result) => result.status === "failed")) + throw new Error("Planning cleanup hook failed"); + }, + ensureDoneLabel: () => + ensureAutomationLabel(input.host, input.config, input.doneLabel), + applyDoneLabels: () => + applyPlanningDoneLabels({ + host: input.host, + issueNumber: input.issue.number, + labels: { + ready: input.readyLabel, + inProgress: input.inProgressLabel, + needsInfo: input.needsInfoLabel, + done: input.doneLabel, + }, + }), + }, + onCostPublicationFailure: async (error) => + progress( + { progress: input.progressReporter }, + "warning", + "run-cost", + "Patchmill could not update the PR run-cost summary", + { + issueNumber: input.issue.number, + data: error instanceof Error ? error.message : String(error), + consoleMessage: + "Warning: Patchmill could not update the PR run-cost summary", + }, + ), + ...(input.now === undefined ? {} : { now: input.now }), + }; + }; +} diff --git a/src/cli/commands/run-once/planning-finish.test.ts b/src/cli/commands/run-once/planning-finish.test.ts new file mode 100644 index 00000000..134dbeba --- /dev/null +++ b/src/cli/commands/run-once/planning-finish.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertPlanningStateReplacement, + validatePlanningState, + type PlanningImplementationFinishCheckpoints, + type PlanningStateV1, +} from "../../../workflow/planning-state.ts"; +import { finishPlanningImplementation } from "./planning-finish.ts"; + +const oid = (v: string) => v.repeat(40); +function state( + finish: PlanningImplementationFinishCheckpoints = {}, + cleanup: "ready" | "worktree-removed" | "removed" = "ready", +): PlanningStateV1 { + const workspace = { + runId: "123e4567-e89b-42d3-a456-426614174000", + phase: "implementation" as const, + identity: { branch: "agent/189", worktreePath: ".worktrees/189" }, + remote: "origin", + baseBranch: "main", + baseOid: oid("a"), + headOid: oid("b"), + cleanup: + cleanup === "ready" + ? { state: "ready" as const } + : { state: cleanup, pushedHeadOid: oid("b") }, + }; + return validatePlanningState({ + version: 1, + workflowVersion: "planning-pr-v1", + runId: workspace.runId, + issueNumber: 189, + issueTitle: "Finish", + gates: { specRequired: false, planRequired: false }, + revision: 0, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + phases: [ + { + kind: "implementation" as const, + status: "pull-request-open" as const, + base: { + remote: "origin", + baseBranch: "main", + baseOid: oid("a"), + artifactCandidates: { spec: [], plan: [] }, + }, + workspace, + artifacts: [ + { + kind: "spec" as const, + path: "docs/specs/2026-01-01-issue-189-finish.md", + source: "workspace" as const, + commitOid: oid("b"), + }, + { + kind: "plan" as const, + path: "docs/plans/2026-01-01-issue-189-finish.md", + source: "workspace" as const, + commitOid: oid("b"), + }, + ], + publication: { + targetRepository: { + provider: "github-gh" as const, + host: "github.com", + owner: "a", + repository: "b", + }, + headRepository: { + provider: "github-gh" as const, + host: "github.com", + owner: "a", + repository: "b", + }, + baseBranch: "main", + headBranch: "agent/189", + headOid: oid("b"), + }, + pullRequest: { + reference: { + targetRepository: { + provider: "github-gh" as const, + host: "github.com", + owner: "a", + repository: "b", + }, + number: 1, + }, + url: "https://github.com/a/b/pull/1", + }, + implementation: { + status: "pr-created" as const, + prUrl: "https://github.com/a/b/pull/1", + branch: "agent/189", + commits: [oid("b")], + validation: ["npm test passed"], + visualEvidence: [], + }, + finish, + }, + ], + }); +} +function input(initial = state(), fail?: string) { + const events: string[] = []; + const warnings: string[] = []; + const checkpoints: PlanningStateV1[] = []; + let current = initial; + return { + events, + warnings, + checkpoints, + state: () => current, + value: { + state: initial, + phaseIndex: 0, + lock: {} as never, + stateStore: { + replace: async ({ next }: { next: PlanningStateV1 }) => { + const validated = validatePlanningState(next); + assertPlanningStateReplacement(current, validated); + current = validated; + checkpoints.push(validated); + return validated; + }, + }, + workspaces: { + removeWorktree: async () => { + events.push("worktree"); + }, + removeBranch: async () => { + events.push("branch"); + }, + }, + effects: Object.fromEntries( + [ + "publishCost", + "validateVisualEvidence", + "postHandoff", + "cleanupHook", + "ensureDoneLabel", + "applyDoneLabels", + ].map((name) => [ + name, + async () => { + events.push(name); + if (name === fail) throw new Error(name); + }, + ]), + ) as never, + // The cost publication failure is intentionally warning-only. + onCostPublicationFailure: async () => { + warnings.push("cost-publication"); + }, + }, + }; +} +test("finishes in durable external-effect and cleanup order", async () => { + const run = input(); + const result = await finishPlanningImplementation(run.value); + assert.equal(result.state.phases[0]?.status, "complete"); + assert.deepEqual(run.events, [ + "publishCost", + "validateVisualEvidence", + "postHandoff", + "cleanupHook", + "worktree", + "branch", + "ensureDoneLabel", + "applyDoneLabels", + ]); + assert.equal(run.checkpoints.length, 9); +}); +test("warns before checkpointing a best-effort cost publication failure", async () => { + const run = input(state(), "publishCost"); + await finishPlanningImplementation(run.value); + assert.deepEqual(run.warnings, ["cost-publication"]); + assert.equal(run.checkpoints[0]?.phases[0]?.status, "pull-request-open"); + assert.equal( + ( + run.checkpoints[0]?.phases[0] as { + finish?: { costPublicationCompleted?: boolean }; + } + ).finish?.costPublicationCompleted, + true, + ); +}); + +test("retries a failed cleanup hook from the last completed checkpoint", async () => { + const run = input(); + let attempts = 0; + run.value.effects.cleanupHook = async () => { + run.events.push("cleanupHook"); + if (attempts++ === 0) throw new Error("cleanup hook failed"); + }; + await assert.rejects( + finishPlanningImplementation(run.value), + /cleanup hook failed/, + ); + assert.deepEqual(run.events, [ + "publishCost", + "validateVisualEvidence", + "postHandoff", + "cleanupHook", + ]); + + const resumed = await finishPlanningImplementation({ + ...run.value, + state: run.state(), + }); + assert.equal(resumed.state.phases[0]?.status, "complete"); + assert.deepEqual(run.events, [ + "publishCost", + "validateVisualEvidence", + "postHandoff", + "cleanupHook", + "cleanupHook", + "worktree", + "branch", + "ensureDoneLabel", + "applyDoneLabels", + ]); +}); +test("effect failure prevents later effects", async () => { + const run = input(state(), "validateVisualEvidence"); + await assert.rejects( + finishPlanningImplementation(run.value), + /validateVisualEvidence/, + ); + assert.deepEqual(run.events, ["publishCost", "validateVisualEvidence"]); +}); diff --git a/src/cli/commands/run-once/planning-finish.ts b/src/cli/commands/run-once/planning-finish.ts new file mode 100644 index 00000000..c8b4d78b --- /dev/null +++ b/src/cli/commands/run-once/planning-finish.ts @@ -0,0 +1,173 @@ +import type { PlanningWorkspaceLifecycle } from "../../../git/planning-workspaces.ts"; +import type { PlanningIssueLock } from "../../../workflow/planning-issue-lock.ts"; +import { replacePlanningPhase } from "../../../workflow/planning-phase-replacement.ts"; +import type { PlanningStateStore } from "../../../workflow/planning-state-store.ts"; +import type { + ImplementationCompletePlanningPhase, + ImplementationPullRequestOpenPlanningPhase, + PlanningPhaseStateV1, + PlanningStateV1, +} from "../../../workflow/planning-state-types.ts"; +import { durableImplementationResult } from "./planning-runtime-state.ts"; +import type { AgentIssuePrCreatedResult } from "./types.ts"; + +export type PlanningFinishInput = { + state: PlanningStateV1; + phaseIndex: number; + lock: PlanningIssueLock; + stateStore: Pick; + workspaces: Pick< + PlanningWorkspaceLifecycle, + "removeWorktree" | "removeBranch" + >; + effects: { + publishCost: () => Promise; + validateVisualEvidence: () => Promise; + postHandoff: (result: AgentIssuePrCreatedResult) => Promise; + cleanupHook: () => Promise; + ensureDoneLabel: () => Promise; + applyDoneLabels: () => Promise; + }; + onCostPublicationFailure?: (error: unknown) => Promise; + now?: () => Date; +}; + +async function checkpoint( + input: PlanningFinishInput, + state: PlanningStateV1, + phase: PlanningPhaseStateV1, +): Promise { + return replacePlanningPhase({ + stateStore: input.stateStore, + lock: input.lock, + state, + phaseIndex: input.phaseIndex, + phase, + ...(input.now === undefined ? {} : { now: input.now }), + }); +} + +/** Applies one strict durable checkpoint after each finish effect. */ +export async function finishPlanningImplementation( + input: PlanningFinishInput, +): Promise<{ state: PlanningStateV1; result: AgentIssuePrCreatedResult }> { + let state = input.state; + const initial = state.phases[input.phaseIndex]; + if (initial?.kind !== "implementation") + throw new RangeError("Invalid implementation phase"); + if (initial.status === "complete") + return { state, result: durableImplementationResult(initial) }; + if (initial.status !== "pull-request-open") + throw new RangeError("Implementation pull request has not been validated"); + let phase: ImplementationPullRequestOpenPlanningPhase = initial; + const effect = async ( + key: keyof ImplementationPullRequestOpenPlanningPhase["finish"], + run: () => Promise, + ) => { + if (phase.finish[key] === true) return; + await run(); + phase = { ...phase, finish: { ...phase.finish, [key]: true } }; + state = await checkpoint(input, state, phase); + phase = state.phases[ + input.phaseIndex + ] as ImplementationPullRequestOpenPlanningPhase; + }; + // Cost publication is deliberately best-effort but still gets a handled checkpoint. + if (phase.finish.costPublicationCompleted !== true) { + try { + await input.effects.publishCost(); + } catch (error) { + await input.onCostPublicationFailure?.(error); + } + phase = { + ...phase, + finish: { ...phase.finish, costPublicationCompleted: true }, + }; + state = await checkpoint(input, state, phase); + phase = state.phases[ + input.phaseIndex + ] as ImplementationPullRequestOpenPlanningPhase; + } + await effect("visualEvidenceValidated", input.effects.validateVisualEvidence); + await effect("handoffCommentPosted", () => + input.effects.postHandoff(durableImplementationResult(phase)), + ); + if (phase.finish.cleanupHookCompleted !== true) { + await input.effects.cleanupHook(); + phase = { + ...phase, + finish: { ...phase.finish, cleanupHookCompleted: true }, + }; + state = await checkpoint(input, state, phase); + phase = state.phases[ + input.phaseIndex + ] as ImplementationPullRequestOpenPlanningPhase; + } + if (phase.workspace.cleanup.state === "ready") { + await input.workspaces.removeWorktree({ + runId: state.runId, + phase: "implementation", + workspace: { ...phase.workspace, cleanup: { state: "ready" } }, + }); + phase = { + ...phase, + workspace: { + ...phase.workspace, + cleanup: { + state: "worktree-removed", + pushedHeadOid: phase.workspace.headOid, + }, + }, + }; + state = await checkpoint(input, state, phase); + phase = state.phases[ + input.phaseIndex + ] as ImplementationPullRequestOpenPlanningPhase; + } + if (phase.workspace.cleanup.state === "worktree-removed") { + await input.workspaces.removeBranch({ + runId: state.runId, + phase: "implementation", + workspace: { + ...phase.workspace, + cleanup: { + state: "worktree-removed", + pushedHeadOid: phase.workspace.cleanup.pushedHeadOid, + }, + }, + }); + phase = { + ...phase, + workspace: { + ...phase.workspace, + cleanup: { + state: "removed", + pushedHeadOid: phase.workspace.cleanup.pushedHeadOid, + }, + }, + }; + state = await checkpoint(input, state, phase); + phase = state.phases[ + input.phaseIndex + ] as ImplementationPullRequestOpenPlanningPhase; + } + await effect("doneLabelEnsured", input.effects.ensureDoneLabel); + await effect("doneLabelApplied", input.effects.applyDoneLabels); + const complete = { + ...phase, + status: "complete" as const, + workspace: { + ...phase.workspace, + cleanup: { + state: "removed" as const, + pushedHeadOid: phase.workspace.headOid, + }, + }, + finish: phase.finish as Required< + ImplementationPullRequestOpenPlanningPhase["finish"] + >, + completion: { kind: "implementation-pull-request" as const }, + } as ImplementationCompletePlanningPhase; + state = await checkpoint(input, state, complete); + return { state, result: durableImplementationResult(complete) }; +} diff --git a/src/cli/commands/run-once/planning-implementation-adapter.ts b/src/cli/commands/run-once/planning-implementation-adapter.ts new file mode 100644 index 00000000..250bbfc1 --- /dev/null +++ b/src/cli/commands/run-once/planning-implementation-adapter.ts @@ -0,0 +1,134 @@ +import { resolve } from "node:path"; +import type { GitWorktreeStrategyConfig } from "../../../git/types.ts"; +import { runImplementationAgent } from "./implementation-agent.ts"; +import { resolvePipelineRunCost } from "./pipeline-run-cost.ts"; +import { createStepAccounting, progress } from "./pipeline-progress.ts"; +import type { PlanningImplementationInput } from "./planning-implementation.ts"; +import { artifactPath } from "./planning-runtime-state.ts"; +import type { AgentIssueConfig, CommandRunner, IssueSummary } from "./types.ts"; + +export type PlanningImplementationAdapterInput = { + runner: CommandRunner; + config: AgentIssueConfig; + issue: IssueSummary; + labels: string[]; + git: GitWorktreeStrategyConfig; + piAgentDir: string; + tokenUsageState: { total: number }; + progressReporter?: Parameters< + typeof runImplementationAgent + >[0]["progressReporter"]; + streamPiOutput?: ((chunk: string) => void) | undefined; + verbosePiOutput?: boolean | undefined; + heartbeatMs?: number | undefined; + piSessionPath?: string | undefined; + now?: (() => Date) | undefined; +}; + +/** Builds the exact implementation agent callback for planning-pr-v1. */ +export function createPlanningImplementationAdapter( + input: PlanningImplementationAdapterInput, +): Omit< + PlanningImplementationInput, + "state" | "phaseIndex" | "lock" | "stateStore" | "host" | "workspaces" | "git" +> { + const steps = createStepAccounting({ + progress: input.progressReporter, + issueNumber: input.issue.number, + }); + return { + configuredGit: input.git, + runAgent: async ({ + state, + phase, + git, + requiredPullRequestMarker, + workspaceCreated, + }) => { + const result = await runImplementationAgent({ + runner: input.runner, + config: input.config, + issue: input.issue, + labels: input.labels, + planPath: artifactPath(state, "plan"), + branch: phase.workspace.identity.branch, + worktreePath: phase.workspace.identity.worktreePath, + worktree: { + branch: phase.workspace.identity.branch, + worktreePath: phase.workspace.identity.worktreePath, + created: workspaceCreated, + hasExistingCommits: + phase.workspace.headOid !== phase.workspace.baseOid, + existingCommits: + phase.workspace.headOid !== phase.workspace.baseOid + ? [phase.workspace.headOid] + : [], + }, + git, + resume: { resumed: !workspaceCreated }, + piAgentDir: input.piAgentDir, + tokenUsageState: input.tokenUsageState, + completedAt: (input.now ?? (() => new Date()))().toISOString(), + progressReporter: input.progressReporter, + streamPiOutput: input.streamPiOutput, + verbosePiOutput: input.verbosePiOutput, + heartbeatMs: input.heartbeatMs, + piSessionPath: input.piSessionPath, + requiredPullRequestMarker, + taskContract: { + ...input.config.projectPolicy.pi.taskContract, + todoRoot: resolve( + input.config.repoRoot, + input.config.projectPolicy.pi.taskContract.todoRoot, + ), + }, + progress: (level, stage, message, extras) => + progress( + { progress: input.progressReporter }, + level, + stage, + message, + { + issueNumber: input.issue.number, + ...extras, + }, + ), + runStep: steps.run, + stepStart: steps.start, + stepComplete: steps.complete, + observePi: (stage) => async (observation) => + steps.observe(stage, observation), + }); + if (result.kind === "implemented" || result.kind === "blocked") + return result.result; + return { + status: "blocked", + reason: result.result.reason, + questions: [], + commits: [], + validation: [], + }; + }, + resolveRunCost: () => + resolvePipelineRunCost({ + implementationKind: "implemented", + implementationStatus: "pr-created", + ...(input.piSessionPath === undefined + ? {} + : { piSessionPath: input.piSessionPath }), + warn: (message, error) => + progress( + { progress: input.progressReporter }, + "warning", + "run-cost", + message, + { + issueNumber: input.issue.number, + data: + error instanceof Error ? error.message : String(error ?? ""), + }, + ), + }), + ...(input.now === undefined ? {} : { now: input.now }), + }; +} diff --git a/src/cli/commands/run-once/planning-implementation-ancestry.ts b/src/cli/commands/run-once/planning-implementation-ancestry.ts new file mode 100644 index 00000000..3a72e542 --- /dev/null +++ b/src/cli/commands/run-once/planning-implementation-ancestry.ts @@ -0,0 +1,60 @@ +import { + PlanningPublicationGitError, + type PlanningPublicationOperations, +} from "../../../git/planning-publication-git.ts"; +import type { PlanningStateV1 } from "../../../workflow/planning-state-types.ts"; + +export class PlanningImplementationAncestryError extends Error { + readonly reason = "ancestry" as const; + + constructor() { + super("Implementation commits are not on the expected ancestry path"); + this.name = "PlanningImplementationAncestryError"; + } +} + +export async function assertPlanningImplementationAncestry(input: { + state: PlanningStateV1; + baseOid: string; + savedHeadOid: string; + headOid: string; + commits: readonly string[]; + git: Pick; +}): Promise { + const workspaceArtifacts = input.state.phases.flatMap((phase) => + "artifacts" in phase + ? phase.artifacts + .filter((artifact) => artifact.source === "workspace") + .map((artifact) => artifact.commitOid) + : [], + ); + const commits = new Set([...input.commits, ...workspaceArtifacts]); + try { + await input.git.assertAncestor({ + ancestorOid: input.baseOid, + descendantOid: input.headOid, + }); + if (input.savedHeadOid !== input.headOid) + await input.git.assertAncestor({ + ancestorOid: input.savedHeadOid, + descendantOid: input.headOid, + }); + for (const commit of commits) { + await input.git.assertAncestor({ + ancestorOid: input.baseOid, + descendantOid: commit, + }); + await input.git.assertAncestor({ + ancestorOid: commit, + descendantOid: input.headOid, + }); + } + } catch (error) { + if ( + error instanceof PlanningPublicationGitError && + error.reason === "not-ancestor" + ) + throw new PlanningImplementationAncestryError(); + throw error; + } +} diff --git a/src/cli/commands/run-once/planning-implementation-base.ts b/src/cli/commands/run-once/planning-implementation-base.ts new file mode 100644 index 00000000..c5668687 --- /dev/null +++ b/src/cli/commands/run-once/planning-implementation-base.ts @@ -0,0 +1,65 @@ +import type { PlanningPublicationOperations } from "../../../git/planning-publication-git.ts"; +import type { PlanningRemoteBaseSnapshot } from "../../../git/planning-workspaces.ts"; +import type { PlanningStateV1 } from "../../../workflow/planning-state-types.ts"; + +export type PlanningImplementationBaseErrorReason = + | "prior-artifact-missing" + | "prior-artifact-ambiguous" + | "prior-artifact-mismatch"; + +/** A newly fetched implementation base no longer proves completed planning work. */ +export class PlanningImplementationBaseError extends Error { + readonly reason: PlanningImplementationBaseErrorReason; + + constructor(reason: PlanningImplementationBaseErrorReason) { + super(`Planning implementation base is unsafe: ${reason}`); + this.name = "PlanningImplementationBaseError"; + this.reason = reason; + } +} + +/** + * Verifies that the fetched implementation base still contains every completed + * planning artifact and retains all durable planning ancestry anchors. + */ +export async function assertPlanningImplementationBase(input: { + state: PlanningStateV1; + phaseIndex: number; + base: PlanningRemoteBaseSnapshot; + git: Pick< + PlanningPublicationOperations, + "assertAncestor" | "assertRegularFiles" + >; +}): Promise { + const anchors = new Set(); + const paths = new Set(); + for (const phase of input.state.phases.slice(0, input.phaseIndex)) { + if (phase.kind === "implementation" || phase.status !== "complete") + continue; + anchors.add(phase.base.baseOid); + if (phase.completion.kind === "merged-pull-request") { + anchors.add(phase.completion.mergeOid); + anchors.add(phase.completion.mergedBaseOid); + } + for (const artifact of phase.artifacts) { + const candidates = input.base.artifactCandidates[artifact.kind]; + if (candidates.length === 0) + throw new PlanningImplementationBaseError("prior-artifact-missing"); + if (candidates.length > 1) + throw new PlanningImplementationBaseError("prior-artifact-ambiguous"); + if (candidates[0] !== artifact.path) + throw new PlanningImplementationBaseError("prior-artifact-mismatch"); + paths.add(artifact.path); + } + } + for (const ancestorOid of anchors) + await input.git.assertAncestor({ + ancestorOid, + descendantOid: input.base.baseOid, + }); + if (paths.size > 0) + await input.git.assertRegularFiles({ + commitOid: input.base.baseOid, + paths: [...paths], + }); +} diff --git a/src/cli/commands/run-once/planning-implementation-validation.test.ts b/src/cli/commands/run-once/planning-implementation-validation.test.ts new file mode 100644 index 00000000..ef462f43 --- /dev/null +++ b/src/cli/commands/run-once/planning-implementation-validation.test.ts @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validatePlanningImplementation } from "./planning-implementation-validation.ts"; + +const oid = (value: string) => value.repeat(40); +const repository = { + provider: "github-gh" as const, + host: "github.com", + owner: "acme", + repository: "patchmill", +}; +const phase = { + kind: "implementation" as const, + status: "branch-pushed" as const, + base: { + remote: "origin", + baseBranch: "main", + baseOid: oid("a"), + artifactCandidates: { spec: [], plan: [] }, + }, + workspace: { + runId: "123e4567-e89b-42d3-a456-426614174000", + phase: "implementation" as const, + identity: { + branch: "planning/implementation", + worktreePath: ".worktrees/implementation", + }, + remote: "origin", + baseBranch: "main", + baseOid: oid("a"), + headOid: oid("b"), + cleanup: { state: "ready" as const }, + }, + artifacts: [], + publication: { + targetRepository: repository, + headRepository: repository, + baseBranch: "main", + headBranch: "planning/implementation", + headOid: oid("b"), + }, + implementation: { + status: "pr-created" as const, + prUrl: "https://github.com/acme/patchmill/pull/189", + branch: "planning/implementation", + commits: [oid("b")], + validation: ["npm test"], + visualEvidence: [], + }, +}; +const state = { issueNumber: 189, phases: [phase] } as never; +function input(status: "open" | "merged" = "open") { + return { + state, + phase, + workspaces: { + inspect: async () => ({ + state: "ready" as const, + identity: phase.workspace.identity, + headOid: oid("b"), + clean: true, + }), + }, + git: { + inspectRemoteHead: async () => ({ + state: "present" as const, + headOid: oid("b"), + }), + assertAncestor: async () => {}, + }, + host: { + id: "github-gh" as const, + resolveTargetRepositoryIdentity: async () => repository, + resolveRemoteRepositoryIdentity: async () => repository, + getPullRequest: async () => ({ + number: 189, + url: phase.implementation.prUrl, + targetRepository: repository, + baseBranch: "main", + headRepository: repository, + headBranch: "planning/implementation", + headSha: oid("b"), + body: "Closes #189\n\n", + ...(status === "open" + ? { status: "open" as const } + : { status: "merged" as const, mergeCommit: oid("c") }), + }), + }, + }; +} + +test("validates an exact open implementation pull request before finish", async () => { + const result = await validatePlanningImplementation(input()); + assert.equal(result.pullRequest.url, phase.implementation.prUrl); +}); + +test("fails closed for workspace, remote, and repository preconditions", async () => { + for (const [reason, configure] of [ + [ + "workspace", + (value: ReturnType) => { + value.workspaces.inspect = async () => ({ state: "missing" as const }); + }, + ], + [ + "workspace", + (value: ReturnType) => { + value.workspaces.inspect = async () => ({ + state: "ready" as const, + identity: phase.workspace.identity, + headOid: oid("b"), + clean: false, + }); + }, + ], + [ + "local-head", + (value: ReturnType) => { + value.phase = { + ...phase, + publication: { ...phase.publication, headOid: oid("c") }, + }; + }, + ], + [ + "remote-head", + (value: ReturnType) => { + value.git.inspectRemoteHead = async () => ({ + state: "missing" as const, + }); + }, + ], + [ + "remote-head", + (value: ReturnType) => { + value.git.inspectRemoteHead = async () => ({ + state: "present" as const, + headOid: oid("c"), + }); + }, + ], + [ + "target-repository", + (value: ReturnType) => { + value.host.resolveTargetRepositoryIdentity = async () => ({ + ...repository, + repository: "other", + }); + }, + ], + [ + "head-repository", + (value: ReturnType) => { + value.host.resolveRemoteRepositoryIdentity = async () => ({ + ...repository, + repository: "other", + }); + }, + ], + ] as const) { + const value = input(); + configure(value); + await assert.rejects( + validatePlanningImplementation(value), + new RegExp(reason, "u"), + ); + } +}); + +test("rejects a trailing-slash agent URL that differs from host readback", async () => { + const validation = input(); + validation.phase = { + ...phase, + implementation: { + ...phase.implementation, + prUrl: `${phase.implementation.prUrl}/`, + }, + }; + await assert.rejects(validatePlanningImplementation(validation), /url/); +}); + +test("canonicalizes a case-only host implementation URL", async () => { + const validation = input(); + validation.host.getPullRequest = async () => ({ + number: 189, + url: "https://github.com/Acme/Patchmill/pull/189", + targetRepository: repository, + baseBranch: "main", + headRepository: repository, + headBranch: "planning/implementation", + headSha: oid("b"), + body: "Closes #189\n\n", + status: "open" as const, + }); + const result = await validatePlanningImplementation(validation); + assert.equal(result.pullRequest.url, phase.implementation.prUrl); +}); + +test("rejects an implementation marker nested in an HTML comment", async () => { + const validation = input(); + validation.host.getPullRequest = async () => ({ + number: 189, + url: phase.implementation.prUrl, + targetRepository: repository, + baseBranch: "main", + headRepository: repository, + headBranch: "planning/implementation", + headSha: oid("b"), + body: "Closes #189\n\n", + status: "open" as const, + }); + await assert.rejects( + validatePlanningImplementation(validation), + /ownership-marker/, + ); +}); + +test("rejects an implementation pull request that is not open", async () => { + await assert.rejects( + validatePlanningImplementation(input("merged")), + /status/, + ); +}); + +test("proves workspace artifact commits from completed planning phases reach final head", async () => { + const calls: Array<{ ancestorOid: string; descendantOid: string }> = []; + const validation = input(); + validation.state = { + issueNumber: 189, + phases: [ + { + kind: "spec", + status: "complete", + artifacts: [ + { + kind: "spec", + path: "docs/specs/issue-189.md", + source: "workspace", + commitOid: oid("c"), + }, + ], + }, + phase, + ], + } as never; + validation.git.assertAncestor = async (call) => { + calls.push(call); + }; + await validatePlanningImplementation(validation); + assert.deepEqual(calls, [ + { ancestorOid: oid("a"), descendantOid: oid("b") }, + { ancestorOid: oid("a"), descendantOid: oid("b") }, + { ancestorOid: oid("b"), descendantOid: oid("b") }, + { ancestorOid: oid("a"), descendantOid: oid("c") }, + { ancestorOid: oid("c"), descendantOid: oid("b") }, + ]); +}); diff --git a/src/cli/commands/run-once/planning-implementation-validation.ts b/src/cli/commands/run-once/planning-implementation-validation.ts new file mode 100644 index 00000000..1ae36557 --- /dev/null +++ b/src/cli/commands/run-once/planning-implementation-validation.ts @@ -0,0 +1,142 @@ +import type { PlanningPublicationOperations } from "../../../git/planning-publication-git.ts"; +import type { PlanningWorkspaceLifecycle } from "../../../git/planning-workspaces.ts"; +import { + PullRequestNotFoundError, + sameRepositoryIdentity, + type PullRequestHost, +} from "../../../host/pull-requests.ts"; +import { parseCanonicalPullRequestUrl } from "../../../host/pull-request-reference.ts"; +import { assertImplementationClosingReference } from "../../../workflow/planning-implementation-body.ts"; +import { + assertPlanningImplementationAncestry, + PlanningImplementationAncestryError, +} from "./planning-implementation-ancestry.ts"; +import { + assertPlanningPublicationRepositories, + PlanningPullRequestValidationError, + validatePlanningPullRequestSummary, +} from "../../../workflow/planning-pull-request-validation.ts"; +import type { + ImplementationBranchPushedPlanningPhase, + PlanningPublicationEvidence, + PlanningPullRequestEvidence, + PlanningStateV1, +} from "../../../workflow/planning-state-types.ts"; + +export class PlanningImplementationValidationError extends Error { + readonly reason: string; + constructor(reason: string) { + super(`Implementation pull request is invalid: ${reason}`); + this.name = "PlanningImplementationValidationError"; + this.reason = reason; + } +} + +function fail(reason: string): never { + throw new PlanningImplementationValidationError(reason); +} + +export type PlanningImplementationValidationInput = { + state: PlanningStateV1; + phase: ImplementationBranchPushedPlanningPhase; + host: PullRequestHost; + workspaces: Pick; + git: Pick< + PlanningPublicationOperations, + "inspectRemoteHead" | "assertAncestor" + >; +}; + +/** Proves the exact local, remote, and host PR facts before finish effects. */ +export async function validatePlanningImplementation( + input: PlanningImplementationValidationInput, +): Promise<{ + publication: PlanningPublicationEvidence; + pullRequest: PlanningPullRequestEvidence; + headOid: string; +}> { + const { phase } = input; + const workspace = await input.workspaces.inspect(phase.workspace.identity); + if ( + workspace.state !== "ready" || + !workspace.clean || + workspace.headOid !== phase.workspace.headOid + ) + fail("workspace"); + if (workspace.headOid !== phase.publication.headOid) fail("local-head"); + const remoteHead = await input.git.inspectRemoteHead({ + remote: phase.workspace.remote, + branch: phase.workspace.identity.branch, + }); + if ( + remoteHead.state !== "present" || + remoteHead.headOid !== workspace.headOid + ) + fail("remote-head"); + const [targetRepository, headRepository] = await Promise.all([ + input.host.resolveTargetRepositoryIdentity(), + input.host.resolveRemoteRepositoryIdentity(phase.workspace.remote), + ]); + if ( + !sameRepositoryIdentity( + targetRepository, + phase.publication.targetRepository, + ) + ) + fail("target-repository"); + if (!sameRepositoryIdentity(headRepository, phase.publication.headRepository)) + fail("head-repository"); + assertPlanningPublicationRepositories(phase.publication); + const canonical = parseCanonicalPullRequestUrl( + phase.implementation.prUrl, + targetRepository, + ); + if (canonical === undefined) fail("url"); + const { number } = canonical.reference; + let validated: ReturnType; + try { + const summary = await input.host.getPullRequest({ + targetRepository, + number, + }); + validated = validatePlanningPullRequestSummary({ + summary, + issueNumber: input.state.issueNumber, + phase: "implementation", + publication: phase.publication, + expectedReference: { targetRepository, number }, + }); + } catch (error) { + if (error instanceof PullRequestNotFoundError) fail("missing"); + if (error instanceof PlanningPullRequestValidationError) fail(error.reason); + throw error; + } + if (phase.implementation.prUrl !== validated.url) fail("url"); + if (validated.summary.status !== "open") fail("status"); + try { + assertImplementationClosingReference( + validated.summary.body, + input.state.issueNumber, + ); + } catch { + fail("closing-reference"); + } + try { + await assertPlanningImplementationAncestry({ + state: input.state, + baseOid: phase.base.baseOid, + savedHeadOid: phase.workspace.headOid, + headOid: workspace.headOid, + commits: phase.implementation.commits, + git: input.git, + }); + } catch (error) { + if (error instanceof PlanningImplementationAncestryError) fail("ancestry"); + throw error; + } + return { + publication: phase.publication, + pullRequest: { reference: validated.reference, url: validated.url }, + headOid: workspace.headOid, + }; +} diff --git a/src/cli/commands/run-once/planning-implementation-workspace-recovery.ts b/src/cli/commands/run-once/planning-implementation-workspace-recovery.ts new file mode 100644 index 00000000..a417fe34 --- /dev/null +++ b/src/cli/commands/run-once/planning-implementation-workspace-recovery.ts @@ -0,0 +1,59 @@ +import { + PlanningPublicationGitError, + type PlanningPublicationOperations, +} from "../../../git/planning-publication-git.ts"; +import type { PlanningWorkspaceLifecycle } from "../../../git/planning-workspaces.ts"; +import type { + ImplementationWorkspaceReadyPlanningPhase, + PlanningStateV1, +} from "../../../workflow/planning-state-types.ts"; + +export type PostAgentWorkspaceRecoveryInput = Readonly<{ + state: PlanningStateV1; + phaseIndex: number; + phase: ImplementationWorkspaceReadyPlanningPhase; + workspaces: Pick; + git: Pick; + checkpoint: ( + phase: ImplementationWorkspaceReadyPlanningPhase, + ) => Promise; +}>; + +/** Adopts only a clean workspace head proven to descend from saved evidence. */ +export async function recoverPostAgentWorkspace( + input: PostAgentWorkspaceRecoveryInput, +) { + const workspace = await input.workspaces.inspect( + input.phase.workspace.identity, + ); + if (workspace.state !== "ready" || !workspace.clean) + return { kind: "unsafe" as const, state: input.state }; + if (workspace.headOid === input.phase.workspace.headOid) + return { + kind: "safe" as const, + state: input.state, + phase: input.phase, + workspace, + }; + try { + await input.git.assertAncestor({ + ancestorOid: input.phase.workspace.headOid, + descendantOid: workspace.headOid, + }); + } catch (error) { + if ( + error instanceof PlanningPublicationGitError && + error.reason === "not-ancestor" + ) + return { kind: "unsafe" as const, state: input.state }; + throw error; + } + const state = await input.checkpoint({ + ...input.phase, + workspace: { ...input.phase.workspace, headOid: workspace.headOid }, + }); + const phase = state.phases[input.phaseIndex]; + if (phase?.kind !== "implementation" || phase.status !== "workspace-ready") + throw new RangeError("Implementation workspace state changed"); + return { kind: "safe" as const, state, phase, workspace }; +} diff --git a/src/cli/commands/run-once/planning-implementation.test.ts b/src/cli/commands/run-once/planning-implementation.test.ts new file mode 100644 index 00000000..9e8424b2 --- /dev/null +++ b/src/cli/commands/run-once/planning-implementation.test.ts @@ -0,0 +1,716 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PlanningPublicationGitError } from "../../../git/planning-publication-git.ts"; +import { validatePlanningState } from "../../../workflow/planning-state.ts"; +import { runPlanningImplementation } from "./planning-implementation.ts"; + +const oid = (value: string) => value.repeat(40); +const repository = { + provider: "github-gh" as const, + host: "github.com", + owner: "acme", + repository: "patchmill", +}; + +function input(overrides: Record = {}) { + const phase = { + kind: "implementation" as const, + status: "workspace-ready" as const, + base: { + remote: "origin", + baseBranch: "main", + baseOid: oid("a"), + artifactCandidates: { spec: [], plan: [] }, + }, + workspace: { + runId: "123e4567-e89b-42d3-a456-426614174000", + phase: "implementation" as const, + identity: { branch: "agent/189", worktreePath: "/worktrees/189" }, + remote: "origin", + baseBranch: "main", + baseOid: oid("a"), + headOid: oid("a"), + cleanup: { state: "ready" as const }, + }, + artifacts: [], + }; + const state = { + version: 1, + workflowVersion: "planning-pr-v1", + runId: phase.workspace.runId, + issueNumber: 189, + issueTitle: "Implementation", + gates: { specRequired: false, planRequired: false }, + phases: [phase], + revision: 0, + createdAt: "2026-09-10T00:00:00.000Z", + updatedAt: "2026-09-10T00:00:00.000Z", + }; + return { + state, + phaseIndex: 0, + lock: {} as never, + stateStore: { replace: async ({ next }: { next: unknown }) => next }, + host: { + id: "github-gh" as const, + resolveTargetRepositoryIdentity: async () => repository, + resolveRemoteRepositoryIdentity: async () => repository, + getPullRequest: async () => { + throw new Error("host transport failure"); + }, + }, + workspaces: { + inspect: async () => ({ + state: "ready" as const, + identity: phase.workspace.identity, + headOid: oid("b"), + clean: true, + }), + }, + git: { + inspectRemoteHead: async () => ({ + state: "present" as const, + headOid: oid("b"), + }), + assertAncestor: async () => {}, + }, + configuredGit: { + remote: "origin", + baseBranch: "main", + allowDirectLand: true, + }, + runAgent: async () => ({ + status: "pr-created" as const, + prUrl: "https://github.com/acme/patchmill/pull/189", + branch: "agent/189", + commits: [oid("b")], + validation: [], + }), + ...overrides, + } as never; +} + +test("passes the post-prepare durable state and implementation workspace to the agent", async () => { + let received: + | { state: unknown; phase: unknown; git: Record } + | undefined; + const result = await runPlanningImplementation( + input({ + runAgent: async (agentInput: typeof received) => { + received = agentInput; + return { + status: "blocked", + reason: "stop", + questions: [], + commits: [], + validation: [], + }; + }, + }), + ); + assert.equal(result.kind, "blocked"); + assert.equal( + (received?.phase as { workspace: { identity: { worktreePath: string } } }) + .workspace.identity.worktreePath, + "/worktrees/189", + ); + assert.equal(received?.git.allowDirectLand, false); +}); + +test("blocks remote and base configuration drift before invoking a resumed implementation agent", async () => { + for (const configuredGit of [ + { remote: "changed-origin", baseBranch: "main", allowDirectLand: true }, + { remote: "origin", baseBranch: "changed-main", allowDirectLand: true }, + ]) { + let agentRuns = 0; + const result = await runPlanningImplementation( + input({ + configuredGit, + runAgent: async () => { + agentRuns += 1; + throw new Error("unexpected agent"); + }, + }), + ); + assert.equal(result.kind, "blocked"); + if (result.kind === "blocked") + assert.equal(result.result.reason, "implementation-configuration"); + assert.equal(agentRuns, 0); + } +}); + +test("validates a matching resumed implementation pull request with the host", async () => { + let agentRuns = 0; + let hostReads = 0; + const result = await runPlanningImplementation( + input({ + configuredGit: { + remote: "origin", + baseBranch: "main", + allowDirectLand: true, + }, + runAgent: async () => { + agentRuns += 1; + return { + status: "pr-created", + prUrl: "https://github.com/acme/patchmill/pull/189", + branch: "agent/189", + commits: [oid("b")], + validation: [], + }; + }, + host: { + id: "github-gh", + resolveTargetRepositoryIdentity: async () => repository, + resolveRemoteRepositoryIdentity: async () => repository, + getPullRequest: async () => { + hostReads += 1; + return { + number: 189, + url: "https://github.com/acme/patchmill/pull/189", + targetRepository: repository, + baseBranch: "main", + headRepository: repository, + headBranch: "agent/189", + headSha: oid("b"), + body: "Closes #189\n\n", + status: "open", + }; + }, + }, + }), + ); + assert.equal(result.kind, "validated"); + assert.equal(result.state.phases[0]?.status, "pull-request-open"); + assert.equal(agentRuns, 1); + assert.equal(hostReads, 1); +}); + +test("blocks runner pre-validation failures without a pull-request-open checkpoint", async () => { + for (const [name, overrides] of [ + [ + "missing workspace", + { + workspaces: { inspect: async () => ({ state: "missing" as const }) }, + }, + ], + [ + "dirty workspace", + { + workspaces: { + inspect: async () => ({ + state: "ready" as const, + identity: { branch: "agent/189", worktreePath: "/worktrees/189" }, + headOid: oid("b"), + clean: false, + }), + }, + }, + ], + [ + "missing remote head", + { + git: { + inspectRemoteHead: async () => ({ state: "missing" as const }), + assertAncestor: async () => {}, + }, + }, + ], + [ + "changed remote head", + { + git: { + inspectRemoteHead: async () => ({ + state: "present" as const, + headOid: oid("c"), + }), + assertAncestor: async () => {}, + }, + }, + ], + [ + "wrong result branch", + { + runAgent: async () => ({ + status: "pr-created" as const, + prUrl: "https://github.com/acme/patchmill/pull/189", + branch: "other", + commits: [oid("b")], + validation: [], + }), + }, + ], + ] as const) { + const replacements: unknown[] = []; + const result = await runPlanningImplementation( + input({ + ...overrides, + stateStore: { + replace: async ({ next }: { next: unknown }) => { + replacements.push(next); + return next; + }, + }, + }), + ); + assert.equal(result.kind, "blocked", name); + assert.equal( + replacements.some( + (next) => + (next as { phases?: Array<{ status?: string }> }).phases?.[0] + ?.status === "pull-request-open", + ), + false, + name, + ); + } +}); + +test("blocks direct merged agent results regardless configured landing policy", async () => { + for (const allowDirectLand of [true, false]) { + let replacements = 0; + let validations = 0; + const result = await runPlanningImplementation( + input({ + configuredGit: { + remote: "origin", + baseBranch: "main", + allowDirectLand, + }, + stateStore: { + replace: async ({ next }: { next: unknown }) => { + replacements += 1; + return next; + }, + }, + host: { + ...input().host, + getPullRequest: async () => { + validations += 1; + throw new Error("unexpected validation"); + }, + }, + runAgent: async () => ({ status: "merged" as const }), + }), + ); + assert.equal(result.kind, "blocked"); + if (result.kind === "blocked") + assert.equal(result.result.reason, "implementation-direct-merge"); + assert.equal(replacements, 1); + assert.equal(validations, 0); + } +}); + +test("propagates a branch-pushed checkpoint conflict without host continuation", async () => { + let validations = 0; + let replacements = 0; + await assert.rejects( + runPlanningImplementation( + input({ + stateStore: { + replace: async () => { + replacements += 1; + throw new Error("state conflict"); + }, + }, + host: { + ...input().host, + getPullRequest: async () => { + validations += 1; + throw new Error("unexpected validation"); + }, + }, + }), + ), + /state conflict/u, + ); + assert.equal(replacements, 1); + assert.equal(validations, 0); +}); + +test("blocks invalid successful agent evidence before PR validation", async () => { + let hostReads = 0; + const result = await runPlanningImplementation( + input({ + runAgent: async () => ({ + status: "pr-created", + prUrl: "https://github.com/acme/patchmill/pull/189", + branch: "agent/189", + commits: [], + validation: [], + }), + stateStore: { + replace: async ({ next }: { next: unknown }) => + validatePlanningState(next), + }, + host: { + ...input().host, + getPullRequest: async () => { + hostReads += 1; + throw new Error("unexpected host read"); + }, + }, + }), + ); + assert.equal(result.kind, "blocked"); + if (result.kind === "blocked") + assert.equal(result.result.reason, "implementation-evidence"); + assert.equal(hostReads, 0); +}); + +test("checkpoints clean committed blocker progress for resumption", async () => { + const checkpoints: Array<{ + phases: Array<{ workspace: { headOid: string } }>; + }> = []; + const result = await runPlanningImplementation( + input({ + runAgent: async () => ({ + status: "blocked", + reason: "pause", + questions: [], + commits: [], + validation: [], + }), + stateStore: { + replace: async ({ next }: { next: (typeof checkpoints)[number] }) => { + checkpoints.push(next); + return next; + }, + }, + }), + ); + assert.equal(result.kind, "blocked"); + assert.equal(checkpoints[0]?.phases[0]?.workspace.headOid, oid("b")); +}); + +test("blocks a resumed blocker when its saved head is not an ancestor", async () => { + const checkpoints: unknown[] = []; + const result = await runPlanningImplementation( + input({ + runAgent: async () => ({ + status: "blocked", + reason: "pause", + questions: [], + commits: [], + validation: [], + }), + git: { + inspectRemoteHead: async () => ({ state: "missing" as const }), + assertAncestor: async () => { + throw new PlanningPublicationGitError("merge-base", "not-ancestor"); + }, + }, + stateStore: { + replace: async ({ next }: { next: unknown }) => { + checkpoints.push(next); + return next; + }, + }, + }), + ); + assert.equal(result.kind, "blocked"); + if (result.kind === "blocked") + assert.equal(result.result.reason, "implementation-workspace"); + assert.deepEqual(checkpoints, []); +}); + +test("refuses dirty blocker progress without checkpointing", async () => { + const checkpoints: unknown[] = []; + const result = await runPlanningImplementation( + input({ + runAgent: async () => ({ + status: "blocked", + reason: "pause", + questions: [], + commits: [], + validation: [], + }), + workspaces: { + inspect: async () => ({ + state: "ready", + identity: { branch: "agent/189", worktreePath: "/worktrees/189" }, + headOid: oid("b"), + clean: false, + }), + }, + stateStore: { + replace: async ({ next }: { next: unknown }) => { + checkpoints.push(next); + return next; + }, + }, + }), + ); + assert.equal(result.kind, "blocked"); + if (result.kind === "blocked") + assert.equal(result.result.reason, "implementation-workspace"); + assert.deepEqual(checkpoints, []); +}); + +test("blocks unproven reported commits before branch-pushed evidence", async () => { + const checkpoints: unknown[] = []; + let inspectedRemote = false; + const result = await runPlanningImplementation( + input({ + git: { + inspectRemoteHead: async () => { + inspectedRemote = true; + return { state: "present" as const, headOid: oid("b") }; + }, + assertAncestor: async ({ ancestorOid, descendantOid }) => { + if (ancestorOid === oid("a") && descendantOid === oid("c")) + throw new PlanningPublicationGitError("merge-base", "not-ancestor"); + }, + }, + runAgent: async () => ({ + status: "pr-created" as const, + prUrl: "https://github.com/acme/patchmill/pull/189", + branch: "agent/189", + commits: [oid("c")], + validation: [], + }), + stateStore: { + replace: async ({ next }: { next: unknown }) => { + checkpoints.push(next); + return next; + }, + }, + }), + ); + assert.equal(result.kind, "blocked"); + if (result.kind === "blocked") + assert.equal(result.result.reason, "implementation-ancestry"); + assert.equal(checkpoints[0]?.phases[0]?.workspace.headOid, oid("b")); + assert.equal(inspectedRemote, false); +}); + +test("persists a sanitized fresh implementation run-cost report", async () => { + const checkpoints: Array<{ + phases: Array<{ implementation: { runCostReport?: unknown } }>; + }> = []; + await assert.rejects( + runPlanningImplementation( + input({ + resolveRunCost: async () => ({ + stages: [], + promptTokens: 2, + outputTokens: 3, + estimatedCostUsd: 0.4, + }), + stateStore: { + replace: async ({ next }: { next: (typeof checkpoints)[number] }) => { + checkpoints.push(next); + return next; + }, + }, + }), + ), + /host transport failure/, + ); + assert.deepEqual( + checkpoints.find((checkpoint) => checkpoint.phases[0]?.implementation) + ?.phases[0]?.implementation.runCostReport, + { + stages: [], + promptTokens: 2, + outputTokens: 3, + estimatedCostUsd: 0.4, + }, + ); +}); + +test("blocks a successful retry when its saved workspace head was rewritten", async () => { + let validated = false; + const result = await runPlanningImplementation( + input({ + git: { + inspectRemoteHead: async () => ({ + state: "present" as const, + headOid: oid("b"), + }), + assertAncestor: async ({ ancestorOid, descendantOid }) => { + if (ancestorOid === oid("a") && descendantOid === oid("b")) + throw new PlanningPublicationGitError("ancestry", "not-ancestor"); + }, + }, + host: { + id: "github-gh" as const, + resolveTargetRepositoryIdentity: async () => repository, + resolveRemoteRepositoryIdentity: async () => repository, + getPullRequest: async () => { + validated = true; + throw new Error("must not validate rewritten workspace"); + }, + }, + }), + ); + assert.equal(result.kind, "blocked"); + if (result.kind === "blocked") + assert.equal(result.result.reason, "implementation-workspace"); + assert.equal(validated, false); +}); + +test("checkpoints clean post-agent workspace progress before recoverable publication failures", async () => { + for (const [name, failure] of [ + ["wrong URL", "url"], + ["wrong branch", "branch"], + ["changed remote head", "remote-head"], + ] as const) { + let durable = input().state; + let attempts = 0; + const receivedHeads: string[] = []; + let pullRequestReads = 0; + const checkpoints: unknown[] = []; + const run = () => + runPlanningImplementation( + input({ + state: durable, + stateStore: { + replace: async ({ next }: { next: typeof durable }) => { + checkpoints.push(next); + durable = next; + return next; + }, + }, + git: { + inspectRemoteHead: async () => + failure === "remote-head" && attempts === 1 + ? { state: "present" as const, headOid: oid("c") } + : { state: "present" as const, headOid: oid("b") }, + assertAncestor: async () => {}, + }, + host: { + ...input().host, + getPullRequest: async () => { + pullRequestReads += 1; + return { + number: 189, + url: "https://github.com/acme/patchmill/pull/189", + targetRepository: repository, + baseBranch: "main", + headRepository: repository, + headBranch: "agent/189", + headSha: oid("b"), + body: "Closes #189\n\n", + status: "open" as const, + }; + }, + }, + runAgent: async ({ + phase, + }: { + phase: { workspace: { headOid: string } }; + }) => { + receivedHeads.push(phase.workspace.headOid); + attempts += 1; + return { + status: "pr-created" as const, + prUrl: + failure === "url" && attempts === 1 + ? "https://github.com/acme/other/pull/189" + : "https://github.com/acme/patchmill/pull/189", + branch: + failure === "branch" && attempts === 1 ? "other" : "agent/189", + commits: [oid("b")], + validation: [], + }; + }, + }), + ); + const failed = await run(); + assert.equal(failed.kind, "blocked", name); + assert.equal(durable.phases[0]?.status, "workspace-ready", name); + assert.equal(durable.phases[0]?.workspace.headOid, oid("b"), name); + assert.equal( + checkpoints.some( + (state) => + (state as { phases: Array<{ status: string }> }).phases[0]?.status === + "pull-request-open", + ), + false, + name, + ); + assert.equal(pullRequestReads, 0, name); + const repaired = await run(); + assert.equal(repaired.kind, "validated", name); + assert.equal(repaired.state.phases[0]?.status, "pull-request-open", name); + assert.deepEqual(receivedHeads, [oid("a"), oid("b")], name); + } +}); + +test("checkpoints clean workspace progress when the implementation agent throws", async () => { + let durable = input().state; + let attempts = 0; + const receivedHeads: string[] = []; + let pullRequestReads = 0; + const checkpoints: unknown[] = []; + const run = () => + runPlanningImplementation( + input({ + state: durable, + stateStore: { + replace: async ({ next }: { next: typeof durable }) => { + checkpoints.push(next); + durable = next; + return next; + }, + }, + host: { + ...input().host, + getPullRequest: async () => { + pullRequestReads += 1; + return { + number: 189, + url: "https://github.com/acme/patchmill/pull/189", + targetRepository: repository, + baseBranch: "main", + headRepository: repository, + headBranch: "agent/189", + headSha: oid("b"), + body: "Closes #189\n\n", + status: "open" as const, + }; + }, + }, + runAgent: async ({ + phase, + }: { + phase: { workspace: { headOid: string } }; + }) => { + receivedHeads.push(phase.workspace.headOid); + attempts += 1; + if (attempts === 1) throw new Error("todo completeness failed"); + return { + status: "pr-created" as const, + prUrl: "https://github.com/acme/patchmill/pull/189", + branch: "agent/189", + commits: [oid("b")], + validation: [], + }; + }, + }), + ); + await assert.rejects(run(), /todo completeness failed/u); + assert.equal(durable.phases[0]?.status, "workspace-ready"); + assert.equal(durable.phases[0]?.workspace.headOid, oid("b")); + assert.equal( + checkpoints.some( + (state) => + (state as { phases: Array<{ status: string }> }).phases[0]?.status === + "pull-request-open", + ), + false, + ); + assert.equal(pullRequestReads, 0); + const repaired = await run(); + assert.equal(repaired.kind, "validated"); + assert.deepEqual(receivedHeads, [oid("a"), oid("b")]); + assert.equal(repaired.state.phases[0]?.status, "pull-request-open"); +}); + +test("propagates host transport failures instead of converting them to validation blockers", async () => { + await assert.rejects( + runPlanningImplementation(input()), + /host transport failure/, + ); +}); diff --git a/src/cli/commands/run-once/planning-implementation.ts b/src/cli/commands/run-once/planning-implementation.ts new file mode 100644 index 00000000..70b1d142 --- /dev/null +++ b/src/cli/commands/run-once/planning-implementation.ts @@ -0,0 +1,328 @@ +import type { GitWorktreeStrategyConfig } from "../../../git/types.ts"; +import { parseCanonicalPullRequestUrl } from "../../../host/pull-request-reference.ts"; +import type { PlanningPublicationOperations } from "../../../git/planning-publication-git.ts"; +import type { PlanningWorkspaceLifecycle } from "../../../git/planning-workspaces.ts"; +import type { PullRequestHost } from "../../../host/pull-requests.ts"; +import { renderPlanningPullRequestMarker } from "../../../workflow/planning-pull-request-markers.ts"; +import type { PlanningIssueLock } from "../../../workflow/planning-issue-lock.ts"; +import { replacePlanningPhase } from "../../../workflow/planning-phase-replacement.ts"; +import { PlanningStateValidationError } from "../../../workflow/planning-state.ts"; +import type { PlanningStateStore } from "../../../workflow/planning-state-store.ts"; +import type { + ImplementationBranchPushedPlanningPhase, + ImplementationWorkspaceReadyPlanningPhase, + PlanningStateV1, +} from "../../../workflow/planning-state-types.ts"; +import type { RunCostReport } from "./run-cost.ts"; +import type { + AgentIssueBlockedResult, + AgentIssueMergedResult, + AgentIssuePrCreatedResult, +} from "./types.ts"; +import { + assertPlanningImplementationAncestry, + PlanningImplementationAncestryError, +} from "./planning-implementation-ancestry.ts"; +import { + PlanningImplementationValidationError, + validatePlanningImplementation, +} from "./planning-implementation-validation.ts"; +import { recoverPostAgentWorkspace } from "./planning-implementation-workspace-recovery.ts"; + +export type PlanningImplementationOutcome = + | { kind: "validated"; state: PlanningStateV1 } + | { + kind: "blocked"; + state: PlanningStateV1; + result: AgentIssueBlockedResult; + }; + +export type PlanningImplementationInput = { + state: PlanningStateV1; + phaseIndex: number; + lock: PlanningIssueLock; + stateStore: Pick; + host: PullRequestHost; + workspaces: Pick; + git: Pick< + PlanningPublicationOperations, + "inspectRemoteHead" | "assertAncestor" + >; + configuredGit: GitWorktreeStrategyConfig; + runAgent(input: { + state: PlanningStateV1; + phase: ImplementationWorkspaceReadyPlanningPhase; + git: GitWorktreeStrategyConfig; + requiredPullRequestMarker: string; + workspaceCreated: boolean; + }): Promise< + AgentIssuePrCreatedResult | AgentIssueMergedResult | AgentIssueBlockedResult + >; + resolveRunCost?: () => Promise; + workspaceCreated?: boolean; + now?: () => Date; +}; + +function blocked(reason: string): AgentIssueBlockedResult { + return { + status: "blocked", + reason, + questions: [], + commits: [], + validation: [], + }; +} + +function finiteNonnegative(value: number, field: string): number { + if (!Number.isFinite(value) || value < 0) + throw new RangeError(`Invalid planning run-cost ${field}`); + return value; +} + +function planningRunCost(report: RunCostReport) { + return { + stages: report.stages.map((stage) => ({ + stage: stage.stage, + models: stage.models.map((model) => ({ + model: model.model, + promptTokens: finiteNonnegative( + model.promptTokens, + "model promptTokens", + ), + outputTokens: finiteNonnegative( + model.outputTokens, + "model outputTokens", + ), + estimatedCostUsd: finiteNonnegative( + model.estimatedCostUsd, + "model estimatedCostUsd", + ), + })), + promptTokens: finiteNonnegative(stage.promptTokens, "stage promptTokens"), + outputTokens: finiteNonnegative(stage.outputTokens, "stage outputTokens"), + estimatedCostUsd: finiteNonnegative( + stage.estimatedCostUsd, + "stage estimatedCostUsd", + ), + })), + promptTokens: finiteNonnegative(report.promptTokens, "promptTokens"), + outputTokens: finiteNonnegative(report.outputTokens, "outputTokens"), + estimatedCostUsd: finiteNonnegative( + report.estimatedCostUsd, + "estimatedCostUsd", + ), + }; +} + +async function replace( + input: PlanningImplementationInput, + state: PlanningStateV1, + phase: PlanningStateV1["phases"][number], +): Promise { + return replacePlanningPhase({ + stateStore: input.stateStore, + lock: input.lock, + state, + phaseIndex: input.phaseIndex, + phase, + ...(input.now === undefined ? {} : { now: input.now }), + }); +} + +/** Runs the implementation agent only once, then durably validates its PR. */ +export async function runPlanningImplementation( + input: PlanningImplementationInput, +): Promise { + let state = input.state; + let phase = state.phases[input.phaseIndex]; + if (phase?.kind !== "implementation") + throw new RangeError("Invalid implementation phase"); + if (phase.status === "workspace-ready") { + if ( + input.configuredGit.remote !== phase.workspace.remote || + input.configuredGit.baseBranch !== phase.base.baseBranch + ) + return { + kind: "blocked", + state, + result: blocked("implementation-configuration"), + }; + let result: Awaited>; + try { + result = await input.runAgent({ + state, + phase, + git: { + ...input.configuredGit, + remote: phase.workspace.remote, + baseBranch: phase.base.baseBranch, + allowDirectLand: false, + }, + workspaceCreated: input.workspaceCreated ?? false, + requiredPullRequestMarker: renderPlanningPullRequestMarker({ + issueNumber: state.issueNumber, + phase: "implementation", + }), + }); + } catch (error) { + await recoverPostAgentWorkspace({ + state, + phaseIndex: input.phaseIndex, + phase, + workspaces: input.workspaces, + git: input.git, + checkpoint: (nextPhase) => replace(input, state, nextPhase), + }); + throw error; + } + const recovered = await recoverPostAgentWorkspace({ + state, + phaseIndex: input.phaseIndex, + phase, + workspaces: input.workspaces, + git: input.git, + checkpoint: (nextPhase) => replace(input, state, nextPhase), + }); + if (recovered.kind === "unsafe") + return { + kind: "blocked", + state: recovered.state, + result: blocked("implementation-workspace"), + }; + state = recovered.state; + phase = recovered.phase; + const workspace = recovered.workspace; + if (result.status === "blocked") return { kind: "blocked", state, result }; + if (result.status === "merged") + return { + kind: "blocked", + state, + result: blocked("implementation-direct-merge"), + }; + const runCostReport = await input.resolveRunCost?.(); + try { + await assertPlanningImplementationAncestry({ + state, + baseOid: phase.base.baseOid, + savedHeadOid: phase.workspace.headOid, + headOid: workspace.headOid, + commits: result.commits, + git: input.git, + }); + } catch (error) { + if (error instanceof PlanningImplementationAncestryError) + return { + kind: "blocked", + state, + result: blocked("implementation-ancestry"), + }; + throw error; + } + const remote = await input.git.inspectRemoteHead({ + remote: phase.workspace.remote, + branch: phase.workspace.identity.branch, + }); + if (remote.state !== "present" || remote.headOid !== workspace.headOid) + return { + kind: "blocked", + state, + result: blocked("implementation-remote-head"), + }; + const [targetRepository, headRepository] = await Promise.all([ + input.host.resolveTargetRepositoryIdentity(), + input.host.resolveRemoteRepositoryIdentity(phase.workspace.remote), + ]); + const canonicalPrUrl = parseCanonicalPullRequestUrl( + result.prUrl, + targetRepository, + ); + if (canonicalPrUrl === undefined) + return { kind: "blocked", state, result: blocked("implementation-url") }; + const branchPushed: ImplementationBranchPushedPlanningPhase = { + ...phase, + status: "branch-pushed", + workspace: { ...phase.workspace, headOid: workspace.headOid }, + publication: { + targetRepository, + headRepository, + baseBranch: phase.base.baseBranch, + headBranch: phase.workspace.identity.branch, + headOid: workspace.headOid, + }, + implementation: { + status: "pr-created", + prUrl: canonicalPrUrl.url, + branch: result.branch, + commits: result.commits, + validation: result.validation, + ...(result.reviewSummary === undefined + ? {} + : { reviewSummary: result.reviewSummary }), + ...(result.landingDecision === undefined + ? {} + : { landingDecision: result.landingDecision }), + ...(runCostReport === undefined + ? {} + : { runCostReport: planningRunCost(runCostReport) }), + visualEvidence: (result.visualEvidence ?? []).map((evidence) => ({ + screenshotPath: evidence.screenshotPath, + ...(evidence.caption === undefined + ? {} + : { caption: evidence.caption }), + ...(evidence.referencePaths === undefined + ? {} + : { referencePaths: evidence.referencePaths }), + ...(evidence.url === undefined ? {} : { url: evidence.url }), + })), + }, + }; + if ( + branchPushed.implementation.branch !== + branchPushed.workspace.identity.branch + ) + return { + kind: "blocked", + state, + result: blocked("implementation-branch"), + }; + try { + state = await replace(input, state, branchPushed); + } catch (error) { + if (error instanceof PlanningStateValidationError) + return { + kind: "blocked", + state, + result: blocked("implementation-evidence"), + }; + throw error; + } + phase = state.phases[input.phaseIndex]; + } + if (phase?.kind !== "implementation" || phase.status !== "branch-pushed") + throw new RangeError("Implementation phase is not ready for validation"); + try { + const validated = await validatePlanningImplementation({ + state, + phase, + host: input.host, + workspaces: input.workspaces, + git: input.git, + }); + state = await replace(input, state, { + ...phase, + status: "pull-request-open", + publication: validated.publication, + pullRequest: validated.pullRequest, + finish: {}, + }); + return { kind: "validated", state }; + } catch (error) { + if (error instanceof PlanningImplementationValidationError) + return { + kind: "blocked", + state, + result: blocked(error.message), + }; + throw error; + } +} diff --git a/src/cli/commands/run-once/planning-lifecycle-labels.test.ts b/src/cli/commands/run-once/planning-lifecycle-labels.test.ts new file mode 100644 index 00000000..c26c3b2c --- /dev/null +++ b/src/cli/commands/run-once/planning-lifecycle-labels.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { applyPlanningBlockedLabels } from "./planning-lifecycle-labels.ts"; + +test("planning blocker labels are calculated from a fresh issue read", async () => { + const applied: string[][] = []; + await applyPlanningBlockedLabels({ + host: { + viewIssue: async () => ({ + labels: [ + "agent-ready", + "agent-in-progress", + "needs-info", + "spec-review", + "spec-approved", + "plan-review", + "plan-approved", + "bug", + ], + }), + applyLabels: async (change) => { + applied.push(change.newLabels); + }, + }, + issueNumber: 189, + labels: { + ready: "agent-ready", + inProgress: "agent-in-progress", + needsInfo: "needs-info", + }, + }); + assert.deepEqual(applied, [ + [ + "needs-info", + "spec-review", + "spec-approved", + "plan-review", + "plan-approved", + "bug", + ].sort(), + ]); +}); diff --git a/src/cli/commands/run-once/planning-lifecycle-labels.ts b/src/cli/commands/run-once/planning-lifecycle-labels.ts new file mode 100644 index 00000000..753e81a0 --- /dev/null +++ b/src/cli/commands/run-once/planning-lifecycle-labels.ts @@ -0,0 +1,56 @@ +import { planLabelChange } from "../triage/labels.ts"; +import type { RunOnceHostProvider } from "../../../host/types.ts"; + +function nextLabels( + labels: readonly string[], + remove: readonly string[], + add: readonly string[], +): string[] { + const excluded = new Set(remove); + const next = labels.filter((label) => !excluded.has(label)); + for (const label of add) if (!next.includes(label)) next.push(label); + return next; +} + +export async function applyPlanningBlockedLabels(input: { + host: Pick; + issueNumber: number; + labels: { ready: string; inProgress: string; needsInfo: string }; +}): Promise { + const current = await input.host.viewIssue(input.issueNumber); + await input.host.applyLabels( + planLabelChange( + input.issueNumber, + current.labels, + nextLabels( + current.labels, + [input.labels.ready, input.labels.inProgress], + [input.labels.needsInfo], + ), + ), + ); +} + +export async function applyPlanningDoneLabels(input: { + host: Pick; + issueNumber: number; + labels: { + ready: string; + inProgress: string; + needsInfo: string; + done: string; + }; +}): Promise { + const current = await input.host.viewIssue(input.issueNumber); + await input.host.applyLabels( + planLabelChange( + input.issueNumber, + current.labels, + nextLabels( + current.labels, + [input.labels.ready, input.labels.inProgress, input.labels.needsInfo], + [input.labels.done], + ), + ), + ); +} diff --git a/src/cli/commands/run-once/planning-phase-artifacts.test.ts b/src/cli/commands/run-once/planning-phase-artifacts.test.ts index 67a83783..24760430 100644 --- a/src/cli/commands/run-once/planning-phase-artifacts.test.ts +++ b/src/cli/commands/run-once/planning-phase-artifacts.test.ts @@ -87,6 +87,62 @@ test("resolves mixed base candidates in planner order", () => { missing: ["plan"], }); }); +test("runs a missing earlier artifact after a later base artifact", async () => { + const result = await runPlanningPhaseArtifacts({ + issue, + phase: { + kind: "plan", + artifactKinds: ["spec", "plan"], + pullRequestRequired: true, + }, + current: { + kind: "plan", + status: "workspace-ready", + base: { + ...base, + artifactCandidates: { + spec: [], + plan: ["docs/plans/example-issue-188.md"], + }, + }, + workspace, + artifacts: [ + { + kind: "plan", + path: "docs/plans/example-issue-188.md", + source: "remote-base", + commitOid: oid("a"), + }, + ], + }, + repoRoot: "/repo", + specsDir: "/repo/docs/specs", + plansDir: "/repo/docs/plans", + artifactDate: new Date("2026-09-08"), + agent: { + async run({ kind }) { + assert.equal(kind, "spec"); + return { + status: "spec-created", + specPath: "docs/specs/2026-09-08-issue-188-example-design.md", + commit: oid("b"), + }; + }, + }, + git: { async verifyArtifactCommit() {} }, + checkpoint: async () => {}, + projectPolicy: DEFAULT_PATCHMILL_POLICY, + skills: DEFAULT_PATCHMILL_SKILLS, + triageLabels: { ready: "agent-ready", needsInfo: "needs-info" }, + }); + assert.equal(result.kind, "workspace-ready"); + if (result.kind === "workspace-ready") + assert.deepEqual( + result.phase.artifacts.map((artifact) => artifact.kind), + ["spec", "plan"], + ); +}); + test("blocks ambiguous base artifacts before agent execution", () => { assert.throws( () => @@ -174,7 +230,7 @@ test("checkpoints spec before running plan", async () => { if (result.kind === "workspace-ready") assert.deepEqual( result.phase.artifacts.map((item) => item.commitOid), - [oid("c"), oid("c")], + [oid("b"), oid("c")], ); }); @@ -240,7 +296,7 @@ test("resumes from a saved spec checkpoint without invoking spec again", async ( kind: "spec", path: "docs/specs/example.md", source: "workspace", - commitOid: oid("c"), + commitOid: oid("b"), }, { kind: "plan", diff --git a/src/cli/commands/run-once/planning-phase-artifacts.ts b/src/cli/commands/run-once/planning-phase-artifacts.ts index 597d2f60..695f7663 100644 --- a/src/cli/commands/run-once/planning-phase-artifacts.ts +++ b/src/cli/commands/run-once/planning-phase-artifacts.ts @@ -14,6 +14,7 @@ import type { PlanningArtifactKind, } from "../../../workflow/planning-pull-requests.ts"; import type { + ImplementationWorkspaceReadyPlanningPhase, PlanningArtifactEvidence, WorkspaceReadyPlanningPhase, } from "../../../workflow/planning-state-types.ts"; @@ -50,8 +51,11 @@ export interface PlanningArtifactAgent { prompt: string; }): Promise; } +export type PlanningArtifactWorkspacePhase = + | WorkspaceReadyPlanningPhase + | ImplementationWorkspaceReadyPlanningPhase; export type PlanningArtifactCheckpoint = ( - phase: WorkspaceReadyPlanningPhase, + phase: PlanningArtifactWorkspacePhase, ) => Promise; export function createPlanningArtifactAgent(input: { @@ -122,7 +126,7 @@ export function resolvePlanningPhaseArtifacts(input: { : { kind: "workspace-required", artifacts, missing }; } function mergedBaseSpecPath( - current: WorkspaceReadyPlanningPhase, + current: PlanningArtifactWorkspacePhase, ): string | undefined { const candidates = current.base.artifactCandidates.spec; return candidates.length === 1 ? candidates[0] : undefined; @@ -130,7 +134,7 @@ function mergedBaseSpecPath( function reviewContext( phase: PlannedPhase, kind: PlanningArtifactKind, - current: WorkspaceReadyPlanningPhase, + current: PlanningArtifactWorkspacePhase, ) { return phase.kind === "implementation" ? ("implementation-pull-request" as const) @@ -173,7 +177,7 @@ function resultFor( export async function runPlanningPhaseArtifacts(input: { issue: IssueSummary; phase: PlannedPhase; - current: WorkspaceReadyPlanningPhase; + current: PlanningArtifactWorkspacePhase; repoRoot: string; specsDir: string; plansDir: string; @@ -185,7 +189,7 @@ export async function runPlanningPhaseArtifacts(input: { skills: PatchmillSkillsConfig; triageLabels: PromptTriageLabels; }): Promise< - | { kind: "workspace-ready"; phase: WorkspaceReadyPlanningPhase } + | { kind: "workspace-ready"; phase: PlanningArtifactWorkspacePhase } | { kind: "blocked"; result: AgentIssueBlockedResult } > { let current = input.current; @@ -257,9 +261,9 @@ export async function runPlanningPhaseArtifacts(input: { ...current.artifacts.filter( (artifact) => artifact.source === "remote-base", ), - ...current.artifacts - .filter((artifact) => artifact.source === "workspace") - .map((artifact) => ({ ...artifact, commitOid: result.commit })), + ...current.artifacts.filter( + (artifact) => artifact.source === "workspace", + ), { kind, path, source: "workspace" as const, commitOid: result.commit }, ].sort( (left, right) => diff --git a/src/cli/commands/run-once/planning-phase-coordinator.test.ts b/src/cli/commands/run-once/planning-phase-coordinator.test.ts new file mode 100644 index 00000000..02c222b6 --- /dev/null +++ b/src/cli/commands/run-once/planning-phase-coordinator.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { coordinatePlanningPhases } from "./planning-phase-coordinator.ts"; + +const state = (gates: { specRequired: boolean; planRequired: boolean }) => + ({ + issueNumber: 189, + gates, + phases: (gates.specRequired && gates.planRequired + ? ["spec", "plan", "implementation"] + : gates.specRequired + ? ["spec", "implementation"] + : gates.planRequired + ? ["plan", "implementation"] + : ["implementation"] + ).map((kind) => ({ kind, status: "pending" })), + }) as never; + +test("coordinates every gate plan in order and stops at an open planning review", async () => { + for (const gates of [ + { specRequired: false, planRequired: false }, + { specRequired: true, planRequired: false }, + { specRequired: false, planRequired: true }, + { specRequired: true, planRequired: true }, + ]) { + const seen: string[] = []; + const result = await coordinatePlanningPhases({ + state: state(gates), + issue: { number: 189 } as never, + runPlanningPhase: async ({ phase }) => { + seen.push(phase.kind); + return phase.kind === "implementation" + ? { + kind: "complete", + state: state(gates), + result: { status: "pr-created" } as never, + } + : { + kind: "review-pending", + state: state(gates), + prUrl: "https://example.test/pr/1", + }; + }, + }); + const expectedFirst = gates.specRequired + ? "spec" + : gates.planRequired + ? "plan" + : "implementation"; + assert.equal(seen[0], expectedFirst); + assert.equal( + result.kind, + expectedFirst === "implementation" ? "complete" : "review-pending", + ); + } +}); + +test("delegates plan-only implementation artifacts to the phase runner", async () => { + let calls = 0; + const result = await coordinatePlanningPhases({ + state: state({ specRequired: false, planRequired: false }), + issue: { number: 189 } as never, + planOnly: true, + runPlanningPhase: async ({ planOnly }) => { + calls += 1; + assert.equal(planOnly, true); + return { + kind: "stopped", + state: state({ specRequired: false, planRequired: false }), + reason: "plan-only", + }; + }, + }); + assert.equal(result.kind, "stopped"); + assert.equal(calls, 1); +}); diff --git a/src/cli/commands/run-once/planning-phase-coordinator.ts b/src/cli/commands/run-once/planning-phase-coordinator.ts new file mode 100644 index 00000000..a54dd884 --- /dev/null +++ b/src/cli/commands/run-once/planning-phase-coordinator.ts @@ -0,0 +1,89 @@ +import { planningPhasePlan } from "../../../workflow/planning-pull-requests.ts"; +import type { PlanningStateV1 } from "../../../workflow/planning-state-types.ts"; +import type { + AgentIssueBlockedResult, + AgentIssuePrCreatedResult, + IssueSummary, +} from "./types.ts"; +import type { PlanningPhaseRunnerOutcome } from "./planning-phase-runner.ts"; + +export type PlanningCoordinatorOutcome = + | { + kind: "review-pending"; + state: PlanningStateV1; + phase: "spec" | "plan"; + prUrl: string; + } + | { + kind: "stopped"; + state: PlanningStateV1; + reason: "plan-only"; + nextPhase: "implementation"; + } + | { kind: "blocked"; state: PlanningStateV1; result: AgentIssueBlockedResult } + | { + kind: "complete"; + state: PlanningStateV1; + result: AgentIssuePrCreatedResult; + }; + +export type PlanningPhaseCoordinatorInput = { + state: PlanningStateV1; + issue: IssueSummary; + planOnly?: boolean; + /** + * Effect boundary composed by the planning pipeline from issue #188's + * reconciler, artifact runner, publisher, implementation validator, and + * finish wrapper. A completed planning phase returns a revised durable state + * so this coordinator can advance only through the strict completed prefix. + */ + runPlanningPhase(input: { + state: PlanningStateV1; + phaseIndex: number; + phase: ReturnType[number]; + planOnly: boolean; + }): Promise; +}; + +export async function coordinatePlanningPhases( + input: PlanningPhaseCoordinatorInput, +): Promise { + let state = input.state; + const plan = planningPhasePlan(state.gates); + for (;;) { + const phaseIndex = state.phases.findIndex( + (phase) => phase.status !== "complete", + ); + if (phaseIndex < 0) + throw new Error("Planning state has no implementation result"); + const phase = plan[phaseIndex]; + if (phase === undefined) + throw new Error("Planning phase plan is inconsistent"); + const outcome = await input.runPlanningPhase({ + state, + phaseIndex, + phase, + planOnly: input.planOnly === true, + }); + if (outcome.kind === "review-pending") { + if (phase.kind === "implementation") + throw new Error("Implementation pull request cannot be review-pending"); + return { + kind: "review-pending", + state: outcome.state, + phase: phase.kind, + prUrl: outcome.prUrl, + }; + } + if (outcome.kind === "blocked") return outcome; + if (outcome.kind === "stopped") + return { + kind: "stopped", + state: outcome.state, + reason: outcome.reason, + nextPhase: "implementation", + }; + if (outcome.kind === "complete") return outcome; + state = outcome.state; + } +} diff --git a/src/cli/commands/run-once/planning-phase-publisher.ts b/src/cli/commands/run-once/planning-phase-publisher.ts index 23fc9ace..ec454e1d 100644 --- a/src/cli/commands/run-once/planning-phase-publisher.ts +++ b/src/cli/commands/run-once/planning-phase-publisher.ts @@ -10,6 +10,7 @@ import { planningPullRequestTitle, } from "../../../workflow/planning-pull-requests.ts"; import type { PlanningIssueLock } from "../../../workflow/planning-issue-lock.ts"; +import { replacePlanningPhase } from "../../../workflow/planning-phase-replacement.ts"; import type { PlanningStateStore } from "../../../workflow/planning-state-store.ts"; import type { PlanningPhaseStateV1, @@ -32,21 +33,6 @@ export type PlanningPhasePublicationResult = state: PlanningStateV1; pullRequests: readonly PullRequestSummary[]; }>; -function nextState( - state: PlanningStateV1, - phaseIndex: number, - phase: PlanningPhaseStateV1, - now: () => Date, -): PlanningStateV1 { - return { - ...state, - revision: state.revision + 1, - updatedAt: now().toISOString(), - phases: state.phases.map((item, index) => - index === phaseIndex ? phase : item, - ), - }; -} async function replace(input: { state: PlanningStateV1; phaseIndex: number; @@ -55,14 +41,7 @@ async function replace(input: { stateStore: Pick; now: () => Date; }): Promise { - const next = nextState(input.state, input.phaseIndex, input.phase, input.now); - return input.stateStore.replace({ - issueNumber: input.state.issueNumber, - expectedRunId: input.state.runId, - expectedRevision: input.state.revision, - next, - lock: input.lock, - }); + return replacePlanningPhase(input); } export async function publishPlanningPhase(input: { state: PlanningStateV1; diff --git a/src/cli/commands/run-once/planning-phase-reconciler.ts b/src/cli/commands/run-once/planning-phase-reconciler.ts index 7f8234cb..10b55147 100644 --- a/src/cli/commands/run-once/planning-phase-reconciler.ts +++ b/src/cli/commands/run-once/planning-phase-reconciler.ts @@ -8,6 +8,7 @@ import { type PullRequestSummary, } from "../../../host/pull-requests.ts"; import type { PlanningIssueLock } from "../../../workflow/planning-issue-lock.ts"; +import { replacePlanningPhase } from "../../../workflow/planning-phase-replacement.ts"; import type { PlanningStateStore } from "../../../workflow/planning-state-store.ts"; import type { PlanningArtifactEvidence, @@ -28,21 +29,6 @@ export type PlanningPhaseReconciliation = | { kind: "closed-unmerged"; pullRequest: PullRequestSummary } | { kind: "missing"; reference?: PullRequestReference } | { kind: "ambiguous"; pullRequests: readonly PullRequestSummary[] }; -function nextState( - state: PlanningStateV1, - index: number, - phase: PlanningPhaseStateV1, - now: () => Date, -): PlanningStateV1 { - return { - ...state, - revision: state.revision + 1, - updatedAt: now().toISOString(), - phases: state.phases.map((item, offset) => - offset === index ? phase : item, - ), - }; -} async function replace( state: PlanningStateV1, index: number, @@ -51,13 +37,13 @@ async function replace( stateStore: Pick, now: () => Date, ): Promise { - const next = nextState(state, index, phase, now); - return stateStore.replace({ - issueNumber: state.issueNumber, - expectedRunId: state.runId, - expectedRevision: state.revision, - next, + return replacePlanningPhase({ + stateStore, lock, + state, + phaseIndex: index, + phase, + now, }); } export async function reconcilePlanningPhase(input: { diff --git a/src/cli/commands/run-once/planning-phase-runner-implementation.ts b/src/cli/commands/run-once/planning-phase-runner-implementation.ts new file mode 100644 index 00000000..ac3d95eb --- /dev/null +++ b/src/cli/commands/run-once/planning-phase-runner-implementation.ts @@ -0,0 +1,135 @@ +import type { + ImplementationWorkspaceReadyPlanningPhase, + PlanningStateV1, +} from "../../../workflow/planning-state-types.ts"; +import { assertPlanningImplementationBase } from "./planning-implementation-base.ts"; +import { PlanningPhaseArtifactError } from "./planning-phase-artifacts.ts"; +import { durableImplementationResult } from "./planning-runtime-state.ts"; +import { + blocked, + operations, + replacePhase, + runWorkspaceArtifacts, + type PlanningPhaseRunnerInput, + type PlanningPhaseRunnerOutcome, +} from "./planning-phase-runner-shared.ts"; + +async function fetchVerifiedBase( + input: PlanningPhaseRunnerInput, + state: PlanningStateV1, +) { + const base = await input.remoteBase.fetch({ + issueNumber: state.issueNumber, + remote: input.config.remote, + baseBranch: input.config.baseBranch, + }); + await assertPlanningImplementationBase({ + state, + phaseIndex: input.phaseIndex, + base, + git: input.publicationGit, + }); + return base; +} + +async function prepare( + input: PlanningPhaseRunnerInput, + state: PlanningStateV1, +): Promise { + const base = await fetchVerifiedBase(input, state); + const resolution = operations(input).resolveArtifacts({ + phase: input.phase, + base, + }); + if (resolution.kind === "satisfied-by-base" && input.planOnly) return state; + const workspace = await input.workspaces.prepare({ + runId: state.runId, + phase: "implementation", + identity: input.config.workspaceIdentity(input.phase), + base, + }); + const phase: ImplementationWorkspaceReadyPlanningPhase = { + kind: "implementation", + status: "workspace-ready", + base: workspace.base, + workspace: workspace.workspace, + artifacts: resolution.artifacts, + }; + return replacePhase(input, state, phase); +} + +export async function runPlanningImplementationPhase( + input: PlanningPhaseRunnerInput, +): Promise { + let state = input.state; + let workspaceCreated = false; + let phase = state.phases[input.phaseIndex]; + if (phase?.kind !== "implementation" || input.phase.kind !== "implementation") + throw new RangeError( + "Planning phase does not match durable implementation state", + ); + if (phase.status === "complete") + return { + kind: "complete", + state, + result: durableImplementationResult(phase), + }; + if (phase.status === "pending") { + try { + state = await prepare(input, state); + workspaceCreated = true; + } catch (error) { + if ( + error instanceof PlanningPhaseArtifactError && + error.reason === "ambiguous-base-artifact" + ) + return { kind: "blocked", state, result: blocked(error.reason) }; + throw error; + } + phase = state.phases[input.phaseIndex]; + if (input.planOnly && phase?.status === "pending") + return { kind: "stopped", state, reason: "plan-only" }; + } + if (phase?.kind !== "implementation") + throw new RangeError("Planning implementation state changed"); + if (phase.status === "workspace-ready") { + await fetchVerifiedBase(input, state); + const result = await runWorkspaceArtifacts(input, state); + if (result.kind !== "workspace-ready") return result; + state = result.state; + if (input.planOnly) return { kind: "stopped", state, reason: "plan-only" }; + } + phase = state.phases[input.phaseIndex]; + if (phase?.kind !== "implementation") + throw new RangeError("Planning implementation state changed"); + if (input.planOnly) return { kind: "stopped", state, reason: "plan-only" }; + if (phase.status === "workspace-ready" || phase.status === "branch-pushed") { + const implemented = await operations(input).runImplementation({ + ...input.implementation.implementation, + state, + phaseIndex: input.phaseIndex, + lock: input.lock, + stateStore: input.stateStore, + host: input.host, + workspaces: input.workspaces, + git: input.publicationGit, + workspaceCreated, + }); + if (implemented.kind === "blocked") + return { + kind: "blocked", + state: implemented.state, + result: implemented.result, + }; + state = implemented.state; + } + const finished = await operations(input).finishImplementation({ + ...input.implementation.finish(state), + state, + phaseIndex: input.phaseIndex, + lock: input.lock, + stateStore: input.stateStore, + workspaces: input.workspaces, + }); + return { kind: "complete", state: finished.state, result: finished.result }; +} diff --git a/src/cli/commands/run-once/planning-phase-runner-planning.ts b/src/cli/commands/run-once/planning-phase-runner-planning.ts new file mode 100644 index 00000000..c262975c --- /dev/null +++ b/src/cli/commands/run-once/planning-phase-runner-planning.ts @@ -0,0 +1,147 @@ +import type { + PlanningStateV1, + WorkspaceReadyPlanningPhase, +} from "../../../workflow/planning-state-types.ts"; +import { PlanningPhaseArtifactError } from "./planning-phase-artifacts.ts"; +import { + blocked, + operations, + replacePhase, + runWorkspaceArtifacts, + type PlanningPhaseRunnerInput, + type PlanningPhaseRunnerOutcome, +} from "./planning-phase-runner-shared.ts"; + +async function reconcile( + input: PlanningPhaseRunnerInput, + state: PlanningStateV1, +): Promise { + const result = await operations(input).reconcile({ + state, + phaseIndex: input.phaseIndex, + lock: input.lock, + stateStore: input.stateStore, + host: input.host, + remoteBase: input.remoteBase, + git: input.publicationGit, + workspaces: input.workspaces, + ...(input.now === undefined ? {} : { now: input.now }), + }); + switch (result.outcome.kind) { + case "review-pending": + return { + kind: "review-pending", + state: result.state, + prUrl: result.outcome.pullRequest.url, + }; + case "merged": + case "satisfied-by-base": + return { kind: "advanced", state: result.state }; + case "closed-unmerged": + case "missing": + case "ambiguous": + return { + kind: "blocked", + state: result.state, + result: blocked(`planning-pull-request-${result.outcome.kind}`), + }; + } +} + +async function prepare( + input: PlanningPhaseRunnerInput, + state: PlanningStateV1, +): Promise<{ state: PlanningStateV1; satisfied: boolean }> { + const base = await input.remoteBase.fetch({ + issueNumber: state.issueNumber, + remote: input.config.remote, + baseBranch: input.config.baseBranch, + }); + const resolution = operations(input).resolveArtifacts({ + phase: input.phase, + base, + }); + if (resolution.kind === "satisfied-by-base") + return { + state: await replacePhase(input, state, { + kind: input.phase.kind as "spec" | "plan", + status: "complete", + base, + artifacts: resolution.artifacts, + completion: { kind: "remote-base" }, + }), + satisfied: true, + }; + const prepared = await input.workspaces.prepare({ + runId: state.runId, + phase: input.phase.kind, + identity: input.config.workspaceIdentity(input.phase), + base, + }); + const phase: WorkspaceReadyPlanningPhase = { + kind: input.phase.kind as "spec" | "plan", + status: "workspace-ready", + base: prepared.base, + workspace: prepared.workspace, + artifacts: resolution.artifacts, + }; + return { state: await replacePhase(input, state, phase), satisfied: false }; +} + +export async function runPlanningSpecPlanPhase( + input: PlanningPhaseRunnerInput, +): Promise { + let state = input.state; + const phase = state.phases[input.phaseIndex]; + if ( + phase === undefined || + phase.kind !== input.phase.kind || + phase.kind === "implementation" + ) + throw new RangeError("Planning phase does not match durable state"); + if ( + phase.status === "branch-pushed" || + phase.status === "pull-request-open" || + phase.status === "complete" + ) + return reconcile(input, state); + if (phase.status === "pending") { + try { + const prepared = await prepare(input, state); + state = prepared.state; + if (prepared.satisfied) return { kind: "advanced", state }; + } catch (error) { + if ( + error instanceof PlanningPhaseArtifactError && + error.reason === "ambiguous-base-artifact" + ) + return { kind: "blocked", state, result: blocked(error.reason) }; + throw error; + } + } + const artifacts = await runWorkspaceArtifacts(input, state); + if (artifacts.kind !== "workspace-ready") return artifacts; + const published = await operations(input).publish({ + state: artifacts.state, + phaseIndex: input.phaseIndex, + lock: input.lock, + stateStore: input.stateStore, + host: input.host, + git: input.publicationGit, + workspaces: input.workspaces, + ...(input.now === undefined ? {} : { now: input.now }), + }); + if (published.kind === "ambiguous") + return { + kind: "blocked", + state: published.state, + result: blocked("planning-pull-request-ambiguous"), + }; + return published.pullRequest.status === "open" + ? { + kind: "review-pending", + state: published.state, + prUrl: published.pullRequest.url, + } + : reconcile(input, published.state); +} diff --git a/src/cli/commands/run-once/planning-phase-runner-shared.ts b/src/cli/commands/run-once/planning-phase-runner-shared.ts new file mode 100644 index 00000000..c3e60258 --- /dev/null +++ b/src/cli/commands/run-once/planning-phase-runner-shared.ts @@ -0,0 +1,208 @@ +import type { PlanningPublicationOperations } from "../../../git/planning-publication-git.ts"; +import type { PlanningRemoteBaseGit } from "../../../git/planning-remote-base.ts"; +import type { PlanningWorkspaceLifecycle } from "../../../git/planning-workspaces.ts"; +import type { PullRequestHost } from "../../../host/pull-requests.ts"; +import type { PatchmillProjectPolicy } from "../../../policy/types.ts"; +import type { PatchmillSkillsConfig } from "../../../workflow/skills.ts"; +import type { PlanningIssueLock } from "../../../workflow/planning-issue-lock.ts"; +import { replacePlanningPhase } from "../../../workflow/planning-phase-replacement.ts"; +import type { PlanningStateStore } from "../../../workflow/planning-state-store.ts"; +import type { PlanningStateV1 } from "../../../workflow/planning-state-types.ts"; +import type { PlannedPhase } from "../../../workflow/planning-pull-requests.ts"; +import { + runPlanningImplementation, + type PlanningImplementationInput, +} from "./planning-implementation.ts"; +import { + finishPlanningImplementation, + type PlanningFinishInput, +} from "./planning-finish.ts"; +import { + reconcilePlanningPhase, + type PlanningPhaseReconciliation, +} from "./planning-phase-reconciler.ts"; +import { + publishPlanningPhase, + type PlanningPhasePublicationResult, +} from "./planning-phase-publisher.ts"; +import { + resolvePlanningPhaseArtifacts, + runPlanningPhaseArtifacts, +} from "./planning-phase-artifacts.ts"; +import type { PlanningArtifactAgent } from "./planning-phase-artifacts.ts"; +import type { PromptTriageLabels } from "./prompts.ts"; +import type { + AgentIssueBlockedResult, + AgentIssuePrCreatedResult, + IssueSummary, +} from "./types.ts"; + +export type PlanningPhaseRunnerOutcome = + | { kind: "review-pending"; state: PlanningStateV1; prUrl: string } + | { kind: "stopped"; state: PlanningStateV1; reason: "plan-only" } + | { kind: "blocked"; state: PlanningStateV1; result: AgentIssueBlockedResult } + | { + kind: "complete"; + state: PlanningStateV1; + result: AgentIssuePrCreatedResult; + } + | { kind: "advanced"; state: PlanningStateV1 }; + +export type PlanningImplementationAdapter = Readonly<{ + implementation: Omit< + PlanningImplementationInput, + | "state" + | "phaseIndex" + | "lock" + | "stateStore" + | "host" + | "workspaces" + | "git" + >; + finish: ( + state: PlanningStateV1, + ) => Omit< + PlanningFinishInput, + "state" | "phaseIndex" | "lock" | "stateStore" | "workspaces" + >; +}>; + +type RunnerConfig = { + repoRoot: string; + remote: string; + baseBranch: string; + specsDir: string; + plansDir: string; + projectPolicy: PatchmillProjectPolicy; + skills: PatchmillSkillsConfig; + triageLabels: PromptTriageLabels; + workspaceIdentity: (phase: PlannedPhase) => { + branch: string; + worktreePath: string; + }; +}; + +export type PlanningPhaseRunnerOperations = { + reconcile: ( + input: Parameters[0], + ) => Promise< + Readonly<{ state: PlanningStateV1; outcome: PlanningPhaseReconciliation }> + >; + resolveArtifacts: typeof resolvePlanningPhaseArtifacts; + runArtifacts: typeof runPlanningPhaseArtifacts; + publish: ( + input: Parameters[0], + ) => Promise; + runImplementation: ( + input: PlanningImplementationInput, + ) => ReturnType; + finishImplementation: ( + input: PlanningFinishInput, + ) => ReturnType; +}; + +export type PlanningPhaseRunnerInput = { + state: PlanningStateV1; + phaseIndex: number; + phase: PlannedPhase; + issue: IssueSummary; + lock: PlanningIssueLock; + config: RunnerConfig; + stateStore: Pick; + host: PullRequestHost; + remoteBase: Pick; + publicationGit: PlanningPublicationOperations; + workspaces: PlanningWorkspaceLifecycle; + artifactAgent: PlanningArtifactAgent; + implementation: PlanningImplementationAdapter; + planOnly?: boolean; + artifactDate?: Date; + now?: () => Date; + operations?: Partial; +}; + +export function operations( + input: PlanningPhaseRunnerInput, +): PlanningPhaseRunnerOperations { + return { + reconcile: input.operations?.reconcile ?? reconcilePlanningPhase, + resolveArtifacts: + input.operations?.resolveArtifacts ?? resolvePlanningPhaseArtifacts, + runArtifacts: input.operations?.runArtifacts ?? runPlanningPhaseArtifacts, + publish: input.operations?.publish ?? publishPlanningPhase, + runImplementation: + input.operations?.runImplementation ?? runPlanningImplementation, + finishImplementation: + input.operations?.finishImplementation ?? finishPlanningImplementation, + }; +} + +export function blocked(reason: string): AgentIssueBlockedResult { + return { + status: "blocked", + reason, + questions: [], + commits: [], + validation: [], + }; +} + +export async function runWorkspaceArtifacts( + input: PlanningPhaseRunnerInput, + state: PlanningStateV1, +): Promise< + | PlanningPhaseRunnerOutcome + | { kind: "workspace-ready"; state: PlanningStateV1 } +> { + const current = state.phases[input.phaseIndex]; + if (current?.status !== "workspace-ready") + throw new RangeError("Planning phase workspace is not ready"); + const workspace = await input.workspaces.resume({ + runId: state.runId, + phase: current.kind, + identity: current.workspace.identity, + base: current.base, + saved: current.workspace, + }); + if (!workspace.clean) + return { + kind: "blocked", + state, + result: blocked("planning-workspace-dirty"), + }; + const artifacts = await operations(input).runArtifacts({ + issue: input.issue, + phase: input.phase, + current, + repoRoot: input.config.repoRoot, + specsDir: input.config.specsDir, + plansDir: input.config.plansDir, + artifactDate: input.artifactDate ?? (input.now ?? (() => new Date()))(), + agent: input.artifactAgent, + git: input.publicationGit, + checkpoint: async (next) => { + state = await replacePhase(input, state, next); + }, + projectPolicy: input.config.projectPolicy, + skills: input.config.skills, + triageLabels: input.config.triageLabels, + }); + return artifacts.kind === "blocked" + ? { kind: "blocked", state, result: artifacts.result } + : { kind: "workspace-ready", state }; +} + +export async function replacePhase( + input: PlanningPhaseRunnerInput, + state: PlanningStateV1, + phase: PlanningStateV1["phases"][number], +): Promise { + return replacePlanningPhase({ + stateStore: input.stateStore, + lock: input.lock, + state, + phaseIndex: input.phaseIndex, + phase, + ...(input.now === undefined ? {} : { now: input.now }), + }); +} diff --git a/src/cli/commands/run-once/planning-phase-runner.test.ts b/src/cli/commands/run-once/planning-phase-runner.test.ts new file mode 100644 index 00000000..5847cc19 --- /dev/null +++ b/src/cli/commands/run-once/planning-phase-runner.test.ts @@ -0,0 +1,930 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PlanningImplementationBaseError } from "./planning-implementation-base.ts"; +import { PlanningPhaseArtifactError } from "./planning-phase-artifacts.ts"; +import { PlanningPublicationGitError } from "../../../git/planning-publication-git.ts"; +import { runPlanningPhase } from "./planning-phase-runner.ts"; + +const oid = (character: string) => character.repeat(40); +const issue = { number: 189, title: "Planning runner", state: "open" } as never; +const base = { + remote: "origin", + baseBranch: "main", + baseOid: oid("a"), + artifactCandidates: { spec: [], plan: [] }, +}; +const workspace = { + runId: "123e4567-e89b-42d3-a456-426614174000", + phase: "implementation", + identity: { + branch: "agent/issue-189-implementation", + worktreePath: ".worktrees/issue-189-implementation", + }, + remote: "origin", + baseBranch: "main", + baseOid: oid("a"), + headOid: oid("a"), + cleanup: { state: "ready" }, +}; + +function state(phase: object) { + return { + version: 1, + workflowVersion: "planning-pr-v1", + runId: workspace.runId, + issueNumber: 189, + issueTitle: "Planning runner", + gates: { specRequired: false, planRequired: false }, + phases: [phase], + revision: 0, + createdAt: "2026-09-10T00:00:00.000Z", + updatedAt: "2026-09-10T00:00:00.000Z", + } as never; +} + +function input(overrides: Record = {}) { + const initial = state({ kind: "implementation", status: "pending" }); + return { + state: initial, + phaseIndex: 0, + phase: { + kind: "implementation", + artifactKinds: [], + pullRequestRequired: true, + }, + issue, + lock: {} as never, + config: { + repoRoot: "/repo", + remote: "origin", + baseBranch: "main", + specsDir: "docs/specs", + plansDir: "docs/plans", + projectPolicy: {} as never, + skills: {} as never, + triageLabels: { ready: "agent-ready", needsInfo: "needs-info" }, + workspaceIdentity: () => workspace.identity, + }, + stateStore: { + replace: async ({ next }: { next: unknown }) => next, + }, + host: {} as never, + remoteBase: { fetch: async () => base }, + publicationGit: {} as never, + workspaces: { + prepare: async () => ({ workspace, base }), + resume: async () => ({ state: "ready", headOid: oid("a"), clean: true }), + } as never, + artifactAgent: {} as never, + operations: { + reconcile: async () => ({ + state: initial, + outcome: { + kind: "review-pending", + pullRequest: { status: "open", url: "https://example.test/pr/1" }, + }, + }), + resolveArtifacts: () => ({ + kind: "workspace-required", + artifacts: [], + missing: [], + }), + runArtifacts: async ({ current }: { current: unknown }) => ({ + kind: "workspace-ready", + phase: current, + }), + publish: async () => { + throw new Error("unexpected publish"); + }, + runImplementation: async () => ({ kind: "validated", state: initial }), + finishImplementation: async () => ({ + state: initial, + result: { status: "pr-created" }, + }), + }, + now: () => new Date("2026-09-10T00:00:01.000Z"), + ...overrides, + } as never; +} + +test("keeps a later base artifact while preparing implementation work", async () => { + let artifacts: string[] | undefined; + const result = await runPlanningPhase( + input({ + planOnly: true, + phase: { + kind: "implementation", + artifactKinds: ["spec", "plan"], + pullRequestRequired: true, + }, + remoteBase: { + fetch: async () => ({ + ...base, + artifactCandidates: { + spec: [], + plan: ["docs/plans/example.md"], + }, + }), + }, + operations: { + resolveArtifacts: () => ({ + kind: "workspace-required", + artifacts: [ + { + kind: "plan", + path: "docs/plans/example.md", + source: "remote-base", + commitOid: oid("a"), + }, + ], + missing: ["spec"], + }), + runArtifacts: async ({ + current, + }: { + current: { artifacts: unknown[] }; + }) => { + artifacts = current.artifacts.map( + (artifact) => (artifact as { kind: string }).kind, + ); + return { kind: "workspace-ready", phase: current }; + }, + }, + }), + ); + assert.equal(result.kind, "stopped"); + assert.deepEqual(artifacts, ["plan"]); +}); + +test("blocks dirty resumed workspaces before planning or implementation agents", async () => { + for (const kind of ["spec", "plan", "implementation"] as const) { + let artifactRuns = 0; + let implementationRuns = 0; + const result = await runPlanningPhase( + input({ + state: state({ + kind, + status: "workspace-ready", + base, + workspace: { ...workspace, phase: kind }, + artifacts: [], + }), + phase: { kind, artifactKinds: [], pullRequestRequired: true }, + workspaces: { + resume: async () => ({ + state: "ready", + identity: workspace.identity, + headOid: oid("a"), + clean: false, + }), + } as never, + operations: { + runArtifacts: async () => { + artifactRuns += 1; + throw new Error("unexpected artifact agent"); + }, + runImplementation: async () => { + implementationRuns += 1; + throw new Error("unexpected implementation agent"); + }, + }, + }), + ); + assert.equal(result.kind, "blocked"); + if (result.kind === "blocked") + assert.equal(result.result.reason, "planning-workspace-dirty"); + assert.equal(artifactRuns, 0); + assert.equal(implementationRuns, 0); + } +}); + +test("reconciles a published planning branch before any workspace effect", async () => { + const published = state({ kind: "spec", status: "branch-pushed" }); + let prepared = false; + const result = await runPlanningPhase( + input({ + state: published, + phase: { + kind: "spec", + artifactKinds: ["spec"], + pullRequestRequired: true, + }, + workspaces: { + prepare: async () => ((prepared = true), {}), + resume: async () => ({}), + }, + operations: { + reconcile: async () => ({ + state: published, + outcome: { + kind: "review-pending", + pullRequest: { status: "open", url: "https://example.test/pr/1" }, + }, + }), + }, + }), + ); + assert.deepEqual(result, { + kind: "review-pending", + state: published, + prUrl: "https://example.test/pr/1", + }); + assert.equal(prepared, false); +}); + +test("maps a missing planning pull request to a sanitized blocker", async () => { + const published = state({ kind: "plan", status: "pull-request-open" }); + const result = await runPlanningPhase( + input({ + state: published, + phase: { + kind: "plan", + artifactKinds: ["plan"], + pullRequestRequired: true, + }, + operations: { + reconcile: async () => ({ + state: published, + outcome: { kind: "missing" }, + }), + }, + }), + ); + assert.deepEqual(result, { + kind: "blocked", + state: published, + result: { + status: "blocked", + reason: "planning-pull-request-missing", + questions: [], + commits: [], + validation: [], + }, + }); +}); + +test("blocks ambiguous base artifacts without starting a workspace", async () => { + let prepared = false; + const result = await runPlanningPhase( + input({ + phase: { + kind: "spec", + artifactKinds: ["spec"], + pullRequestRequired: true, + }, + state: state({ kind: "spec", status: "pending" }), + workspaces: { + prepare: async () => ((prepared = true), { workspace, base }), + } as never, + operations: { + resolveArtifacts: () => { + throw new PlanningPhaseArtifactError("ambiguous-base-artifact"); + }, + }, + }), + ); + // The production error is classified by its stable reason before Git effects. + assert.equal(prepared, false); + assert.equal(result.kind, "blocked"); +}); + +test("checkpoints a planning phase satisfied by the fetched base without a pull request", async () => { + const initial = state({ kind: "spec", status: "pending" }); + const replacements: unknown[] = []; + const result = await runPlanningPhase( + input({ + state: initial, + phase: { + kind: "spec", + artifactKinds: ["spec"], + pullRequestRequired: true, + }, + stateStore: { + replace: async ({ next }: { next: unknown }) => { + replacements.push(next); + return next; + }, + }, + operations: { + resolveArtifacts: () => ({ + kind: "satisfied-by-base", + artifacts: [ + { + kind: "spec", + path: "docs/specs/issue-189.md", + source: "remote-base", + commitOid: oid("a"), + }, + ], + }), + }, + }), + ); + assert.equal(result.kind, "advanced"); + assert.equal(replacements.length, 1); + assert.equal( + (replacements[0] as { phases: Array<{ status: string }> }).phases[0]! + .status, + "complete", + ); +}); + +test("plan-only stops before creating an implementation workspace when its artifacts are durable", async () => { + let prepared = false; + const result = await runPlanningPhase( + input({ + planOnly: true, + workspaces: { + prepare: async () => ((prepared = true), { workspace, base }), + } as never, + operations: { + resolveArtifacts: () => ({ + kind: "satisfied-by-base", + artifacts: [ + { + kind: "plan", + path: "docs/plans/issue-189.md", + source: "remote-base", + commitOid: oid("a"), + }, + ], + }), + }, + }), + ); + assert.equal(result.kind, "stopped"); + assert.equal(prepared, false); +}); + +function stateWithCompletedSpec() { + const artifactPath = "docs/specs/2026-09-10-issue-189-planning-runner.md"; + return { + ...state({ kind: "implementation", status: "pending" }), + gates: { specRequired: true, planRequired: false }, + phases: [ + { + kind: "spec", + status: "complete", + base: { + ...base, + artifactCandidates: { spec: [artifactPath], plan: [] }, + }, + artifacts: [ + { + kind: "spec", + path: artifactPath, + commitOid: oid("c"), + source: "remote-base", + }, + ], + completion: { + kind: "merged-pull-request", + mergeOid: oid("b"), + mergedBaseOid: oid("c"), + }, + }, + { kind: "implementation", status: "pending" }, + ], + } as never; +} + +test("fails closed before plan-only pause when a reviewed artifact is deleted, renamed, or ambiguous", async () => { + const artifactPath = "docs/specs/2026-09-10-issue-189-planning-runner.md"; + for (const [name, candidates, reason] of [ + ["deleted", [], "prior-artifact-missing"], + [ + "renamed", + ["docs/specs/2026-09-10-issue-189-renamed.md"], + "prior-artifact-mismatch", + ], + [ + "ambiguous", + [artifactPath, "docs/specs/2026-09-10-issue-189-competing.md"], + "prior-artifact-ambiguous", + ], + ] as const) { + let prepared = false; + await assert.rejects( + runPlanningPhase( + input({ + state: stateWithCompletedSpec(), + phaseIndex: 1, + planOnly: true, + remoteBase: { + fetch: async () => ({ + ...base, + baseOid: oid("d"), + artifactCandidates: { spec: candidates, plan: [] }, + }), + }, + publicationGit: { + assertAncestor: async () => undefined, + assertRegularFiles: async () => undefined, + }, + workspaces: { + prepare: async () => ((prepared = true), { workspace, base }), + }, + }), + ), + (error: unknown) => + error instanceof PlanningImplementationBaseError && + error.reason === reason, + name, + ); + assert.equal(prepared, false, name); + } +}); + +test("fails closed when reviewed planning base history is rewritten before implementation", async () => { + let prepared = false; + const ancestors: string[] = []; + await assert.rejects( + runPlanningPhase( + input({ + state: stateWithCompletedSpec(), + phaseIndex: 1, + remoteBase: { + fetch: async () => ({ + ...base, + baseOid: oid("d"), + artifactCandidates: { + spec: ["docs/specs/2026-09-10-issue-189-planning-runner.md"], + plan: [], + }, + }), + }, + publicationGit: { + assertAncestor: async ({ ancestorOid }: { ancestorOid: string }) => { + ancestors.push(ancestorOid); + if (ancestorOid === oid("c")) + throw new PlanningPublicationGitError("ancestry", "not-ancestor"); + }, + assertRegularFiles: async () => { + throw new Error("unexpected artifact verification"); + }, + }, + workspaces: { + prepare: async () => ((prepared = true), { workspace, base }), + }, + }), + ), + (error: unknown) => + error instanceof PlanningPublicationGitError && + error.reason === "not-ancestor", + ); + assert.deepEqual(ancestors, [oid("a"), oid("b"), oid("c")]); + assert.equal(prepared, false); +}); + +test("fails closed when a plan-only workspace resumes after planning artifact deletion", async () => { + const paused = stateWithCompletedSpec(); + paused.phases[1] = { + kind: "implementation", + status: "workspace-ready", + base, + workspace, + artifacts: [], + }; + let resumed = false; + await assert.rejects( + runPlanningPhase( + input({ + state: paused, + phaseIndex: 1, + remoteBase: { + fetch: async () => ({ + ...base, + baseOid: oid("d"), + artifactCandidates: { spec: [], plan: [] }, + }), + }, + publicationGit: { + assertAncestor: async () => { + throw new Error("unexpected ancestry verification"); + }, + assertRegularFiles: async () => { + throw new Error("unexpected artifact verification"); + }, + }, + workspaces: { + resume: async () => ((resumed = true), { clean: true }), + }, + }), + ), + (error: unknown) => + error instanceof PlanningImplementationBaseError && + error.reason === "prior-artifact-missing", + ); + assert.equal(resumed, false); +}); + +test("rechecks planning anchors before implementation resumes after a plan-only pause", async () => { + const paused = stateWithCompletedSpec(); + paused.phases[1] = { + kind: "implementation", + status: "workspace-ready", + base, + workspace, + artifacts: [], + }; + let resumed = false; + let implementationRuns = 0; + await assert.rejects( + runPlanningPhase( + input({ + state: paused, + phaseIndex: 1, + remoteBase: { + fetch: async () => ({ + ...base, + baseOid: oid("d"), + artifactCandidates: { + spec: ["docs/specs/2026-09-10-issue-189-planning-runner.md"], + plan: [], + }, + }), + }, + publicationGit: { + assertAncestor: async ({ ancestorOid }: { ancestorOid: string }) => { + if (ancestorOid === oid("c")) + throw new PlanningPublicationGitError("ancestry", "not-ancestor"); + }, + assertRegularFiles: async () => undefined, + }, + workspaces: { + resume: async () => ((resumed = true), { clean: true }), + }, + operations: { + runImplementation: async () => { + implementationRuns += 1; + throw new Error("unexpected implementation"); + }, + }, + }), + ), + (error: unknown) => + error instanceof PlanningPublicationGitError && + error.reason === "not-ancestor", + ); + assert.equal(resumed, false); + assert.equal(implementationRuns, 0); +}); + +test("verifies reviewed planning evidence before a plan-only implementation pause", async () => { + const calls: Array<{ kind: string; value: string | string[] }> = []; + const result = await runPlanningPhase( + input({ + state: stateWithCompletedSpec(), + phaseIndex: 1, + planOnly: true, + remoteBase: { + fetch: async () => ({ + ...base, + baseOid: oid("d"), + artifactCandidates: { + spec: ["docs/specs/2026-09-10-issue-189-planning-runner.md"], + plan: [], + }, + }), + }, + publicationGit: { + assertAncestor: async ({ ancestorOid }: { ancestorOid: string }) => { + calls.push({ kind: "ancestor", value: ancestorOid }); + }, + assertRegularFiles: async ({ paths }: { paths: string[] }) => { + calls.push({ kind: "regular", value: paths }); + }, + }, + operations: { + resolveArtifacts: () => ({ kind: "satisfied-by-base", artifacts: [] }), + }, + }), + ); + assert.equal(result.kind, "stopped"); + assert.deepEqual(calls, [ + { kind: "ancestor", value: oid("a") }, + { kind: "ancestor", value: oid("b") }, + { kind: "ancestor", value: oid("c") }, + { + kind: "regular", + value: ["docs/specs/2026-09-10-issue-189-planning-runner.md"], + }, + ]); +}); + +test("checkpoints implementation planning artifacts then stops before implementation code", async () => { + const initial = state({ + kind: "implementation", + status: "workspace-ready", + base, + workspace, + artifacts: [], + }); + let implementations = 0; + const result = await runPlanningPhase( + input({ + state: initial, + planOnly: true, + operations: { + runImplementation: async () => { + implementations += 1; + throw new Error("unexpected implementation"); + }, + }, + }), + ); + assert.equal(result.kind, "stopped"); + assert.equal(implementations, 0); +}); + +test("plan-only stops resumed branch-pushed and pull-request-open implementation phases", async () => { + for (const [name, phase] of [ + [ + "branch-pushed", + { + kind: "implementation", + status: "branch-pushed", + base, + workspace, + artifacts: [], + publication: {}, + implementation: {}, + }, + ], + [ + "pull-request-open", + { + kind: "implementation", + status: "pull-request-open", + base, + workspace, + artifacts: [], + publication: {}, + pullRequest: {}, + implementation: {}, + finish: {}, + }, + ], + ] as const) { + const initial = state(phase); + let implementationRuns = 0; + let finishRuns = 0; + const result = await runPlanningPhase( + input({ + state: initial, + planOnly: true, + operations: { + runImplementation: async () => { + implementationRuns += 1; + throw new Error("unexpected implementation"); + }, + finishImplementation: async () => { + finishRuns += 1; + throw new Error("unexpected finish"); + }, + }, + }), + ); + assert.deepEqual( + result, + { kind: "stopped", state: initial, reason: "plan-only" }, + name, + ); + assert.equal(implementationRuns, 0, name); + assert.equal(finishRuns, 0, name); + } +}); + +test("resumes branch-pushed and pull-request-open implementation phases without plan-only", async () => { + for (const [name, phase, expectedImplementationRuns] of [ + [ + "branch-pushed", + { + kind: "implementation", + status: "branch-pushed", + base, + workspace, + artifacts: [], + publication: {}, + implementation: {}, + }, + 1, + ], + [ + "pull-request-open", + { + kind: "implementation", + status: "pull-request-open", + base, + workspace, + artifacts: [], + publication: {}, + pullRequest: {}, + implementation: {}, + finish: {}, + }, + 0, + ], + ] as const) { + const initial = state(phase); + let implementationRuns = 0; + let finishRuns = 0; + const result = await runPlanningPhase( + input({ + state: initial, + implementation: { + implementation: {} as never, + finish: () => ({}) as never, + }, + operations: { + runImplementation: async () => { + implementationRuns += 1; + return { kind: "validated", state: initial }; + }, + finishImplementation: async () => { + finishRuns += 1; + return { state: initial, result: { status: "pr-created" } }; + }, + }, + }), + ); + assert.equal(result.kind, "complete", name); + assert.equal(implementationRuns, expectedImplementationRuns, name); + assert.equal(finishRuns, 1, name); + } +}); + +test("finishes implementation only after the implementation runner validates it", async () => { + const initial = state({ + kind: "implementation", + status: "workspace-ready", + base, + workspace, + artifacts: [], + }); + const validated = { + ...initial, + revision: 1, + phases: [ + { + kind: "implementation", + status: "pull-request-open", + base, + workspace, + artifacts: [], + publication: {}, + pullRequest: {}, + implementation: {}, + finish: {}, + }, + ], + } as never; + let finished = false; + const result = await runPlanningPhase( + input({ + state: initial, + implementation: { + implementation: {} as never, + finish: () => ({}) as never, + }, + operations: { + runImplementation: async () => ({ + kind: "validated", + state: validated, + }), + finishImplementation: async ({ + state: received, + }: { + state: unknown; + }) => { + finished = received === validated; + return { state: validated, result: { status: "pr-created" } }; + }, + }, + }), + ); + assert.equal(result.kind, "complete"); + assert.equal(finished, true); +}); + +test("returns durable terminal implementation result without rerunning implementation", async () => { + const terminal = state({ + kind: "implementation", + status: "complete", + workspace, + artifacts: [], + pullRequest: { url: "https://example.test/pr/189" }, + implementation: { + branch: workspace.identity.branch, + commits: ["abc123"], + validation: ["npm test"], + reviewSummary: "approved", + landingDecision: "no-direct-land", + visualEvidence: [ + { + screenshotPath: "artifacts/terminal.png", + caption: "terminal", + referencePaths: ["reference.png"], + url: "https://example.test/evidence", + }, + ], + }, + }); + let implementationRuns = 0; + let finishEffects = 0; + const result = await runPlanningPhase( + input({ + state: terminal, + implementation: { + implementation: {}, + finish: () => { + finishEffects += 1; + return {}; + }, + }, + operations: { + runImplementation: async () => ((implementationRuns += 1), {}), + finishImplementation: async ({ + state: durable, + }: { + state: unknown; + }) => ({ + state: durable, + result: { + status: "pr-created", + prUrl: "https://example.test/pr/189", + branch: workspace.identity.branch, + commits: [], + validation: [], + }, + }), + }, + }), + ); + assert.equal(result.kind, "complete"); + assert.deepEqual(result.result, { + status: "pr-created", + prUrl: "https://example.test/pr/189", + branch: workspace.identity.branch, + commits: ["abc123"], + validation: ["npm test"], + reviewSummary: "approved", + landingDecision: "no-direct-land", + visualEvidence: [ + { + screenshotPath: "artifacts/terminal.png", + caption: "terminal", + referencePaths: ["reference.png"], + url: "https://example.test/evidence", + }, + ], + }); + assert.notEqual( + result.result.visualEvidence[0]?.referencePaths, + terminal.phases[0]!.implementation.visualEvidence[0]!.referencePaths, + ); + assert.equal(implementationRuns, 0); + assert.equal(finishEffects, 0); +}); + +test("passes fresh versus resumed workspace context to implementation", async () => { + const seen: boolean[] = []; + const run = async (initial: unknown) => + runPlanningPhase( + input({ + state: initial, + implementation: { implementation: {}, finish: () => ({}) }, + operations: { + runImplementation: async ({ + state: durable, + workspaceCreated, + }: { + state: unknown; + workspaceCreated?: boolean; + }) => { + seen.push(workspaceCreated === true); + return { kind: "validated", state: durable }; + }, + finishImplementation: async ({ + state: durable, + }: { + state: unknown; + }) => ({ + state: durable, + result: { status: "pr-created" }, + }), + }, + }), + ); + await run(state({ kind: "implementation", status: "pending" })); + await run( + state({ + kind: "implementation", + status: "workspace-ready", + base, + workspace, + artifacts: [], + }), + ); + assert.deepEqual(seen, [true, false]); +}); diff --git a/src/cli/commands/run-once/planning-phase-runner.ts b/src/cli/commands/run-once/planning-phase-runner.ts new file mode 100644 index 00000000..87605e68 --- /dev/null +++ b/src/cli/commands/run-once/planning-phase-runner.ts @@ -0,0 +1,24 @@ +import { runPlanningImplementationPhase } from "./planning-phase-runner-implementation.ts"; +import { runPlanningSpecPlanPhase } from "./planning-phase-runner-planning.ts"; +import type { + PlanningPhaseRunnerInput, + PlanningPhaseRunnerOutcome, +} from "./planning-phase-runner-shared.ts"; + +export type { + PlanningImplementationAdapter, + PlanningPhaseRunnerInput, + PlanningPhaseRunnerOutcome, +} from "./planning-phase-runner-shared.ts"; + +/** Dispatches one durable phase to its planning or implementation flow. */ +export async function runPlanningPhase( + input: PlanningPhaseRunnerInput, +): Promise { + const phase = input.state.phases[input.phaseIndex]; + if (phase === undefined || phase.kind !== input.phase.kind) + throw new RangeError("Planning phase does not match durable state"); + return input.phase.kind === "implementation" + ? runPlanningImplementationPhase(input) + : runPlanningSpecPlanPhase(input); +} diff --git a/src/cli/commands/run-once/planning-pipeline-facade-recording.test.ts b/src/cli/commands/run-once/planning-pipeline-facade-recording.test.ts new file mode 100644 index 00000000..42190fbf --- /dev/null +++ b/src/cli/commands/run-once/planning-pipeline-facade-recording.test.ts @@ -0,0 +1,330 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { PlanningStateStore } from "../../../workflow/planning-state-store.ts"; +import { + approvalPolicy, + makeConfig, +} from "../../../../test-support/run-once/pipeline-fixtures.ts"; +import { + issue, + issueListPayload, + issueViewPayload, + labelListPayload, +} from "../../../../test-support/run-once/issue-fixtures.ts"; +import { promptPath } from "../../../../test-support/run-once/mock-runner.ts"; +import { runOneIssue } from "./pipeline.ts"; + +const run = promisify(execFile); +const now = new Date("2099-01-01T00:00:00.000Z"); + +async function git(cwd: string, args: string[]) { + try { + const result = await run("git", args, { cwd, encoding: "utf8" }); + return { code: 0, stdout: result.stdout, stderr: result.stderr }; + } catch (error) { + const failure = error as { + code?: number; + stdout?: string; + stderr?: string; + }; + return { + code: failure.code ?? 1, + stdout: failure.stdout ?? "", + stderr: failure.stderr ?? "", + }; + } +} + +function repository() { + return { + name: "patchmill", + full_name: "acme/patchmill", + owner: { login: "acme" }, + html_url: "https://forge.test/acme/patchmill", + }; +} + +type Pull = { + number: number; + branch: string; + body: string; + headOid: string; + merged?: boolean; + mergeOid?: string; +}; + +async function scenario( + gates: { specRequired: boolean; planRequired: boolean }, + invalidImplementation = false, +) { + const config = await makeConfig({ + dryRun: false, + execute: true, + allowDirectLand: true, + approvalPolicy: approvalPolicy(gates), + }); + // The public facade owns a real remote/base/worktree lifecycle; only Forgejo + // and Pi are recorded at their process boundary. + const remote = join(config.repoRoot, "remote.git"); + await mkdir(config.worktreeDir, { recursive: true }); + await git(config.repoRoot, ["init", "--initial-branch=main"]); + await git(config.repoRoot, ["config", "user.email", "test@example.test"]); + await git(config.repoRoot, ["config", "user.name", "Test"]); + await writeFile(join(config.repoRoot, "README.md"), "# test\n", "utf8"); + await writeFile(join(config.specsDir, ".gitkeep"), "", "utf8"); + await writeFile(join(config.plansDir, ".gitkeep"), "", "utf8"); + await git(config.repoRoot, ["add", "README.md", "docs"]); + await git(config.repoRoot, ["commit", "-m", "initial"]); + await git(config.repoRoot, ["init", "--bare", remote]); + await git(config.repoRoot, ["remote", "add", "origin", remote]); + await git(config.repoRoot, ["push", "-u", "origin", "main"]); + const selected = issue(189, ["agent-ready"], "Facade recording"); + const pulls: Pull[] = []; + let nextPull = 1; + const calls: Array<{ command: string; args: string[]; cwd?: string }> = []; + const runner = { + calls, + async run(command: string, args: string[], options: { cwd?: string } = {}) { + const call = + command === process.execPath + ? { command: "pi", args: args.slice(1), cwd: options.cwd } + : { command, args, cwd: options.cwd }; + calls.push(call); + if (call.command === "git") { + if (call.args[0] === "remote" && call.args[1] === "get-url") + return { + code: 0, + stdout: "https://forge.test/acme/patchmill.git\n", + stderr: "", + }; + const result = await git(call.cwd ?? config.repoRoot, call.args); + if ( + call.args[0] === "worktree" && + call.args[1] === "add" && + result.code !== 0 + ) + throw new Error(result.stderr); + return result; + } + if (call.command === "pi") { + const prompt = await readFile(promptPath(call.args), "utf8"); + const path = /"(?:specPath|planPath)"\s*:\s*"([^"]+)"/u.exec( + prompt, + )?.[1]; + if (path) { + await mkdir(dirname(join(call.cwd!, path)), { recursive: true }); + await writeFile(join(call.cwd!, path), "# artifact\n", "utf8"); + await git(call.cwd!, ["add", path]); + await git(call.cwd!, ["commit", "-m", "planning artifact"]); + const commit = await git(call.cwd!, ["rev-parse", "HEAD"]); + return { + code: 0, + stdout: JSON.stringify({ + status: path.includes("specs") ? "spec-created" : "plan-created", + ...(path.includes("specs") + ? { specPath: path } + : { planPath: path }), + commit: commit.stdout.trim(), + }), + stderr: "", + }; + } + await writeFile( + join(call.cwd!, "implementation.txt"), + "implemented\n", + "utf8", + ); + await git(call.cwd!, ["add", "implementation.txt"]); + await git(call.cwd!, ["commit", "-m", "implementation"]); + const branch = ( + await git(call.cwd!, ["branch", "--show-current"]) + ).stdout.trim(); + const headOid = ( + await git(call.cwd!, ["rev-parse", "HEAD"]) + ).stdout.trim(); + await git(call.cwd!, ["push", "origin", `HEAD:${branch}`]); + pulls.push({ + number: 99, + branch, + headOid, + body: invalidImplementation + ? "Closes #189" + : "Closes #189\n\n", + }); + return { + code: 0, + stdout: JSON.stringify({ + status: "pr-created", + prUrl: "https://forge.test/acme/patchmill/pulls/99", + branch, + commits: [headOid], + validation: ["npm test"], + }), + stderr: "", + }; + } + if (call.command === "tea" && call.args[0] === "issues") { + if (call.args[1] === "list") { + const page = call.args[call.args.indexOf("--page") + 1]; + return { + code: 0, + stdout: page === "1" ? issueListPayload([selected]) : "[]", + stderr: "", + }; + } + return { code: 0, stdout: issueViewPayload(selected), stderr: "" }; + } + if (call.command === "tea" && call.args[0] === "labels") + return { code: 0, stdout: labelListPayload(), stderr: "" }; + if (call.command === "tea" && !call.args.includes("api")) + return { code: 0, stdout: "", stderr: "" }; + if (call.command === "tea") { + const path = + call.args.find((value) => value.startsWith("/repos/")) ?? ""; + if (path.endsWith("/repos/{owner}/{repo}")) + return { code: 0, stdout: JSON.stringify(repository()), stderr: "" }; + if (path.includes("/pulls?")) + return { + code: 0, + stdout: JSON.stringify(pulls.map(pullPayload)), + stderr: "", + }; + if (/\/pulls\/\d+$/u.test(path)) { + const number = Number(path.split("/").at(-1)); + return { + code: 0, + stdout: JSON.stringify( + pullPayload(pulls.find((pull) => pull.number === number)!), + ), + stderr: "", + }; + } + if (path.endsWith("/pulls")) { + const head = call.args + .find((value) => value.startsWith("head="))! + .slice(5) + .split(":") + .at(-1)!; + const body = call.args + .find((value) => value.startsWith("body="))! + .slice(5); + const headOid = ( + await git(config.repoRoot, ["rev-parse", `refs/heads/${head}`]) + ).stdout.trim(); + const pull = { number: nextPull++, branch: head, body, headOid }; + pulls.push(pull); + return { + code: 0, + stdout: JSON.stringify(pullPayload(pull)), + stderr: "", + }; + } + } + throw new Error( + `unexpected command: ${call.command} ${call.args.join(" ")}`, + ); + }, + }; + function pullPayload(pull: Pull) { + return { + number: pull.number, + html_url: `https://forge.test/acme/patchmill/pulls/${pull.number}`, + body: pull.body, + state: pull.merged ? "closed" : "open", + merged: pull.merged === true, + merge_commit_sha: pull.mergeOid ?? null, + base: { ref: "main", repo: repository() }, + head: { ref: pull.branch, sha: pull.headOid, repo: repository() }, + }; + } + async function mergePlanningPull() { + const pull = pulls.find((item) => item.number !== 99 && !item.merged)!; + const merged = await git(config.repoRoot, [ + "merge", + "--no-ff", + `origin/${pull.branch}`, + "-m", + "merge planning", + ]); + assert.equal(merged.code, 0, merged.stderr); + pull.mergeOid = ( + await git(config.repoRoot, ["rev-parse", "HEAD"]) + ).stdout.trim(); + pull.merged = true; + await git(config.repoRoot, ["push", "origin", "main"]); + } + return { + config, + runner, + mergePlanningPull, + state: () => new PlanningStateStore(config.runStateDir).read(189), + }; +} + +test("runOneIssue records all planning gate sequences through merged-base and implementation finish", async () => { + for (const [gates, reviews] of [ + [{ specRequired: false, planRequired: false }, []], + [{ specRequired: true, planRequired: false }, ["spec"]], + [{ specRequired: false, planRequired: true }, ["plan"]], + [{ specRequired: true, planRequired: true }, ["spec", "plan"]], + ] as const) { + const subject = await scenario(gates); + try { + for (const phase of reviews) { + const review = await runOneIssue(subject.runner, subject.config, { + now, + }); + assert.equal(review.status, "review-pending"); + if (review.status === "review-pending") + assert.equal(review.phase, phase); + const saved = await subject.state(); + assert.equal( + saved?.phases.find((item) => item.kind === phase)?.status, + "pull-request-open", + ); + await subject.mergePlanningPull(); + } + const result = await runOneIssue(subject.runner, subject.config, { now }); + assert.equal(result.status, "pr-created"); + const saved = await subject.state(); + assert.equal(saved?.phases.at(-1)?.status, "complete"); + assert.ok(saved?.revision && saved.revision > reviews.length); + assert.equal( + subject.runner.calls.some( + (call) => call.command === "git" && call.args.includes("fetch"), + ), + true, + ); + } finally { + await rm(subject.config.repoRoot, { recursive: true, force: true }); + } + } +}); + +test("runOneIssue validation failure checkpoints before finish cleanup", async () => { + const subject = await scenario( + { specRequired: false, planRequired: false }, + true, + ); + try { + const result = await runOneIssue(subject.runner, subject.config, { now }); + assert.equal(result.status, "blocked"); + const saved = await subject.state(); + assert.equal(saved?.phases.at(-1)?.status, "branch-pushed"); + assert.equal( + subject.runner.calls.some( + (call) => + call.command === "git" && + call.args[0] === "worktree" && + call.args[1] === "remove", + ), + false, + ); + } finally { + await rm(subject.config.repoRoot, { recursive: true, force: true }); + } +}); diff --git a/src/cli/commands/run-once/planning-pipeline-facade.test.ts b/src/cli/commands/run-once/planning-pipeline-facade.test.ts new file mode 100644 index 00000000..c60c461b --- /dev/null +++ b/src/cli/commands/run-once/planning-pipeline-facade.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { hostname } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { planningIssueLockPath } from "../../../workflow/planning-issue-lock.ts"; +import { planningStatePath } from "../../../workflow/planning-state-store.ts"; +import { makeConfig } from "../../../../test-support/run-once/pipeline-fixtures.ts"; +import { + issue, + issueListPayload, + issueViewPayload, + labelListPayload, +} from "../../../../test-support/run-once/issue-fixtures.ts"; +import { createMockRunner } from "../../../../test-support/run-once/mock-runner.ts"; +import { runOneIssue } from "./pipeline.ts"; +import { writeRunState } from "./run-state.ts"; +import { exitCodeForRunOnceResult } from "./result-output.ts"; +import { summarizeResult } from "./result-summary.ts"; + +test("facade dispatches fresh planning selection to the planning lock boundary", async () => { + const config = await makeConfig({ dryRun: false, execute: true }); + const selected = issue(189, ["agent-ready"], "Fresh planning selection"); + const lockPath = planningIssueLockPath(config.runStateDir, selected.number); + await mkdir(join(config.runStateDir, "planning-pr-v1", "locks"), { + recursive: true, + }); + await writeFile( + lockPath, + `${JSON.stringify({ + version: 1, + issueNumber: selected.number, + runId: "123e4567-e89b-42d3-a456-426614174000", + ownershipId: "223e4567-e89b-42d3-a456-426614174000", + pid: process.pid, + hostname: hostname(), + acquiredAt: "2026-01-01T00:00:00.000Z", + })}\n`, + "utf8", + ); + const runner = createMockRunner((call) => { + if (call.command === "tea" && call.args[0] === "issues") { + const page = call.args[call.args.indexOf("--page") + 1]; + return { + code: 0, + stdout: page === "1" ? issueListPayload([selected]) : "[]", + stderr: "", + }; + } + throw new Error( + `unexpected command: ${call.command} ${call.args.join(" ")}`, + ); + }); + try { + const result = await runOneIssue(runner, config); + assert.equal(result.status, "stopped"); + if (result.status === "stopped") + assert.equal(result.reason, "issue-locked"); + assert.equal( + runner.calls.some((call) => call.args.includes("edit")), + false, + ); + } finally { + await rm(config.repoRoot, { recursive: true, force: true }); + } +}); + +test("facade routes an unfinished in-progress legacy run through legacy planning", async () => { + const config = await makeConfig({ + dryRun: false, + execute: true, + planOnly: true, + }); + const selected = issue(189, ["in-progress"], "Legacy planning selection"); + const planPath = "docs/plans/issue-189-legacy.md"; + await writeFile(join(config.repoRoot, planPath), "# plan\n", "utf8"); + await writeRunState(config.runStateDir, { + issueNumber: selected.number, + title: selected.title, + status: "planning", + planPath, + checkpoints: { claimed: true, startedCommentPosted: true }, + }); + const runner = createMockRunner((call) => { + if (call.command === "tea" && call.args[0] === "issues") { + if (call.args[1] === "list") { + const page = call.args[call.args.indexOf("--page") + 1]; + return { + code: 0, + stdout: page === "1" ? issueListPayload([selected]) : "[]", + stderr: "", + }; + } + return { code: 0, stdout: issueViewPayload(selected), stderr: "" }; + } + if ( + call.command === "tea" && + call.args[0] === "labels" && + call.args[1] === "list" + ) + return { code: 0, stdout: labelListPayload(), stderr: "" }; + if (call.command === "tea" && call.args[0] === "comment") + return { code: 0, stdout: "", stderr: "" }; + if (call.command === "git" && call.args[0] === "status") + return { code: 0, stdout: "", stderr: "" }; + if (call.command === "git" && call.args[0] === "worktree") + return { code: 0, stdout: "", stderr: "" }; + if (call.command === "git" && call.args[0] === "show-ref") + return { code: 1, stdout: "", stderr: "" }; + throw new Error( + `unexpected command: ${call.command} ${call.args.join(" ")}`, + ); + }); + try { + const result = await runOneIssue(runner, config); + assert.match(result.status, /^plan-(created|found)$/); + assert.equal(result.issue.number, selected.number); + assert.equal( + runner.calls.some( + (call) => + call.command === "tea" && + call.args[0] === "issues" && + call.args[1] !== "list", + ), + true, + ); + assert.equal( + runner.calls.some((call) => call.args.includes("planning-pr-v1")), + false, + ); + } finally { + await rm(config.repoRoot, { recursive: true, force: true }); + } +}); + +test("facade returns a blocked result for advisory malformed planning state", async () => { + const config = await makeConfig({ dryRun: false, execute: true }); + const selected = issue(189, ["agent-ready"], "Malformed planning state"); + const statePath = planningStatePath(config.runStateDir, selected.number); + await mkdir(join(config.runStateDir, "planning-pr-v1", "issues"), { + recursive: true, + }); + await writeFile(statePath, "{not-json", "utf8"); + const runner = createMockRunner((call) => { + if (call.command === "tea" && call.args[0] === "issues") { + const page = call.args[call.args.indexOf("--page") + 1]; + return { + code: 0, + stdout: page === "1" ? issueListPayload([selected]) : "[]", + stderr: "", + }; + } + throw new Error( + `unexpected command: ${call.command} ${call.args.join(" ")}`, + ); + }); + try { + const result = await runOneIssue(runner, config); + assert.equal(result.status, "blocked"); + if (result.status === "blocked") { + assert.match(result.reason, /planning-state-invalid/); + assert.match(result.reason, /invalid-json/); + assert.match(result.reason, /issue-189\.json/); + } + const summary = summarizeResult(result); + assert.equal(summary.status, "blocked"); + assert.equal(exitCodeForRunOnceResult(summary), 1); + assert.equal( + runner.calls.some((call) => call.args.includes("edit")), + false, + ); + } finally { + await rm(config.repoRoot, { recursive: true, force: true }); + } +}); diff --git a/src/cli/commands/run-once/planning-pipeline-scenarios.test.ts b/src/cli/commands/run-once/planning-pipeline-scenarios.test.ts new file mode 100644 index 00000000..139e38bc --- /dev/null +++ b/src/cli/commands/run-once/planning-pipeline-scenarios.test.ts @@ -0,0 +1,385 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { planningPhasePlan } from "../../../workflow/planning-pull-requests.ts"; +import { coordinatePlanningPhases } from "./planning-phase-coordinator.ts"; +import { runPlanningIssue } from "./planning-pipeline.ts"; +import { PlanningIssueLockConflictError } from "../../../workflow/planning-issue-lock.ts"; + +const issue = { number: 189, title: "Safety", state: "open" } as never; +function state(gates: { specRequired: boolean; planRequired: boolean }) { + return { + issueNumber: 189, + gates, + phases: planningPhasePlan(gates).map((phase) => ({ + kind: phase.kind, + status: "pending", + })), + } as never; +} + +test("all gate assignments preserve review stops across merged-phase attempts", async () => { + const cases = [ + { + gates: { specRequired: false, planRequired: false }, + sequence: ["implementation"], + }, + { + gates: { specRequired: true, planRequired: false }, + sequence: ["spec:review", "spec:merged", "implementation"], + }, + { + gates: { specRequired: false, planRequired: true }, + sequence: ["plan:review", "plan:merged", "implementation"], + }, + { + gates: { specRequired: true, planRequired: true }, + sequence: [ + "spec:review", + "spec:merged", + "plan:review", + "plan:merged", + "implementation", + ], + }, + ]; + for (const { gates, sequence } of cases) { + const calls: string[] = []; + const reviewed = new Set(); + let current = state(gates); + for (;;) { + const outcome = await coordinatePlanningPhases({ + state: current, + issue, + runPlanningPhase: async ({ + phase, + phaseIndex, + state: currentState, + }) => { + if (phase.kind === "implementation") { + calls.push("implementation"); + return { + kind: "complete", + state: currentState, + result: { status: "pr-created" } as never, + }; + } + if (!reviewed.has(phaseIndex)) { + reviewed.add(phaseIndex); + calls.push(`${phase.kind}:review`); + return { + kind: "review-pending", + state: currentState, + prUrl: `https://example.test/${phase.kind}`, + }; + } + calls.push(`${phase.kind}:merged`); + return { + kind: "advanced", + state: { + ...currentState, + phases: currentState.phases.map((saved, index) => + index === phaseIndex ? { ...saved, status: "complete" } : saved, + ), + }, + } as never; + }, + }); + current = outcome.state; + if (outcome.kind === "complete") break; + assert.equal(outcome.kind, "review-pending"); + } + assert.deepEqual(calls, sequence); + } +}); + +test("open planning reviews never execute later phase work", async () => { + const gates = { specRequired: true, planRequired: true }; + const calls: string[] = []; + const outcome = await coordinatePlanningPhases({ + state: state(gates), + issue, + runPlanningPhase: async ({ phase }) => { + calls.push(phase.kind); + return { + kind: "review-pending", + state: state(gates), + prUrl: "https://example.test/pr/1", + }; + }, + }); + assert.equal(outcome.kind, "review-pending"); + assert.deepEqual(calls, ["spec"]); +}); + +test("fresh production pipeline initializes before claim, comment, and coordinate", async () => { + const calls: string[] = []; + const fresh = state({ specRequired: false, planRequired: false }); + const result = await runPlanningIssue({ + issue: { + number: 189, + title: "Safety", + state: "open", + labels: ["agent-ready"], + } as never, + config: { readyLabel: "agent-ready" } as never, + state: fresh, + expectedStatePresence: "absent", + runStateDir: "/tmp/planning-scenarios", + stateStore: { + read: async () => undefined, + initialize: async () => { + calls.push("initialize"); + }, + }, + readIssue: async () => { + calls.push("read-issue"); + return { + number: 189, + title: "Safety", + state: "open", + labels: ["agent-ready"], + } as never; + }, + readLegacy: async () => { + calls.push("read-legacy"); + return undefined; + }, + mutate: async () => { + calls.push("claim-comment"); + return ["agent-in-progress"]; + }, + coordinate: async () => { + calls.push("coordinate"); + return { kind: "stopped", state: fresh, reason: "plan-only" } as never; + }, + acquire: async () => ({ record: { runId: fresh.runId } }) as never, + release: async () => { + calls.push("release"); + }, + }); + assert.equal(result.status, "coordinated"); + assert.deepEqual(calls, [ + "read-issue", + "read-legacy", + "initialize", + "claim-comment", + "coordinate", + "release", + ]); +}); + +test("existing planning run-id changes fail closed without mutation", async () => { + const initial = state({ specRequired: false, planRequired: false }); + const replacement = { + ...initial, + runId: "223e4567-e89b-42d3-a456-426614174000", + } as never; + const calls: string[] = []; + let reads = 0; + const result = await runPlanningIssue({ + issue: { + number: 189, + title: "Safety", + state: "open", + labels: ["agent-in-progress"], + } as never, + config: {} as never, + state: initial, + expectedStatePresence: "present", + runStateDir: "/tmp/planning-scenarios", + stateStore: { + read: async () => (++reads === 1 ? replacement : replacement), + initialize: async () => { + throw new Error("must not initialize active state"); + }, + }, + readIssue: async () => + ({ + number: 189, + title: "Safety", + state: "open", + labels: ["agent-in-progress"], + }) as never, + readLegacy: async () => undefined, + mutate: async () => { + calls.push("mutate"); + return []; + }, + coordinate: async () => { + calls.push("coordinate"); + return { kind: "stopped", state: replacement, reason: "done" } as never; + }, + acquire: async (_dir, identity) => { + calls.push(`acquire:${identity.runId}`); + return { record: { runId: identity.runId } } as never; + }, + release: async () => { + calls.push("release"); + }, + }); + assert.equal(result.status, "blocked"); + assert.deepEqual(calls, [`acquire:${initial.runId}`, "release"]); +}); + +test("fresh initialization races retry with the authoritative run ID", async () => { + const initial = state({ specRequired: false, planRequired: false }); + const replacement = { + ...initial, + runId: "223e4567-e89b-42d3-a456-426614174000", + } as never; + const calls: string[] = []; + const result = await runPlanningIssue({ + issue: { + number: 189, + title: "Safety", + state: "open", + labels: ["agent-ready"], + } as never, + config: { readyLabel: "agent-ready" } as never, + state: initial, + expectedStatePresence: "absent", + runStateDir: "/tmp/planning-scenarios", + stateStore: { + read: async () => replacement, + initialize: async () => { + throw new Error("racing state must not be initialized twice"); + }, + }, + readIssue: async () => + ({ + number: 189, + title: "Safety", + state: "open", + labels: ["agent-ready"], + }) as never, + readLegacy: async () => undefined, + mutate: async () => { + calls.push("mutate"); + return []; + }, + coordinate: async () => { + calls.push("coordinate"); + return { + kind: "stopped", + state: replacement, + reason: "plan-only", + } as never; + }, + acquire: async (_dir, identity) => { + calls.push(`acquire:${identity.runId}`); + return { record: { runId: identity.runId } } as never; + }, + release: async () => { + calls.push("release"); + }, + }); + assert.equal(result.status, "coordinated"); + assert.deepEqual(calls, [ + `acquire:${initial.runId}`, + "release", + `acquire:${replacement.runId}`, + "mutate", + "coordinate", + "release", + ]); +}); + +test("post-lock state for another issue blocks before mutation", async () => { + let mutated = false; + const initial = state({ specRequired: false, planRequired: false }); + const result = await runPlanningIssue({ + issue: { + number: 189, + title: "Safety", + state: "open", + labels: ["agent-ready"], + } as never, + config: { readyLabel: "agent-ready" } as never, + state: initial, + expectedStatePresence: "present", + runStateDir: "/tmp/planning-scenarios", + stateStore: { + read: async () => ({ ...initial, issueNumber: 190 }), + initialize: async () => { + throw new Error("unreachable"); + }, + }, + readIssue: async () => + ({ + number: 189, + title: "Safety", + state: "open", + labels: ["agent-ready"], + }) as never, + readLegacy: async () => undefined, + mutate: async () => { + mutated = true; + return []; + }, + coordinate: async () => { + throw new Error("unreachable"); + }, + acquire: async () => ({ record: { runId: initial.runId } }) as never, + release: async () => {}, + }); + assert.equal(result.status, "blocked"); + assert.equal(mutated, false); +}); + +test("active, stale, and malformed locks have distinct no-mutation outcomes", async () => { + for (const classification of ["active", "stale", "unverifiable"] as const) { + let mutated = false; + const result = await runPlanningIssue({ + issue: { + number: 189, + title: "Safety", + state: "open", + labels: [], + } as never, + config: {} as never, + state: state({ specRequired: false, planRequired: false }), + expectedStatePresence: "absent", + runStateDir: "/tmp/planning-scenarios", + stateStore: {} as never, + readIssue: async () => { + throw new Error("unreachable"); + }, + readLegacy: async () => undefined, + mutate: async () => { + mutated = true; + return []; + }, + coordinate: async () => { + throw new Error("unreachable"); + }, + acquire: async () => { + throw new PlanningIssueLockConflictError({ + classification, + path: "lock", + fingerprint: "", + }); + }, + }); + assert.equal(mutated, false); + assert.equal( + result.status, + classification === "active" ? "stopped" : "blocked", + ); + } +}); + +test("plan-only delegates implementation-carried artifact decisions to the runner", async () => { + let receivedPlanOnly = false; + const gates = { specRequired: false, planRequired: false }; + const outcome = await coordinatePlanningPhases({ + state: state(gates), + issue, + planOnly: true, + runPlanningPhase: async ({ planOnly }) => { + receivedPlanOnly = planOnly === true; + return { kind: "stopped", state: state(gates), reason: "plan-only" }; + }, + }); + assert.equal(outcome.kind, "stopped"); + assert.equal(receivedPlanOnly, true); +}); diff --git a/src/cli/commands/run-once/planning-pipeline.test.ts b/src/cli/commands/run-once/planning-pipeline.test.ts new file mode 100644 index 00000000..1c1f2ad5 --- /dev/null +++ b/src/cli/commands/run-once/planning-pipeline.test.ts @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PlanningIssueLockConflictError } from "../../../workflow/planning-issue-lock.ts"; +import { PlanningStateValidationError } from "../../../workflow/planning-state.ts"; +import { + planningIssueNeedsClaim, + runPlanningIssue, +} from "./planning-pipeline.ts"; + +test("does not reclaim an issue after its done-label checkpoint", () => { + assert.equal( + planningIssueNeedsClaim({ + issue: { + number: 189, + title: "Example", + state: "open", + labels: ["agent-done"], + } as never, + fresh: false, + state: { + phases: [ + { + kind: "implementation", + status: "pull-request-open", + finish: { doneLabelApplied: true }, + }, + ], + } as never, + labels: { + ready: "agent-ready", + inProgress: "agent-in-progress", + done: "agent-done", + }, + }), + false, + ); +}); + +test("does not reclaim a done-labeled finish awaiting its final checkpoint", () => { + assert.equal( + planningIssueNeedsClaim({ + issue: { + number: 189, + title: "Example", + state: "open", + labels: ["agent-done"], + } as never, + fresh: false, + state: { + phases: [ + { + kind: "implementation", + status: "pull-request-open", + workspace: { cleanup: { state: "removed" } }, + finish: { + visualEvidenceValidated: true, + handoffCommentPosted: true, + cleanupHookCompleted: true, + doneLabelEnsured: true, + }, + }, + ], + } as never, + labels: { + ready: "agent-ready", + inProgress: "agent-in-progress", + done: "agent-done", + }, + }), + false, + ); +}); + +test("returns stopped without mutation for an active planning lock", async () => { + let mutated = false; + const result = await runPlanningIssue({ + issue: { + number: 189, + title: "Example", + state: "open", + labels: [], + } as never, + state: { runId: "123e4567-e89b-42d3-a456-426614174000" } as never, + expectedStatePresence: "present", + runStateDir: "/tmp/state", + stateStore: {} as never, + readIssue: async () => { + throw new Error("unreachable"); + }, + readLegacy: async () => false, + mutate: async () => { + mutated = true; + }, + coordinate: async () => { + throw new Error("unreachable"); + }, + acquire: async () => { + throw new PlanningIssueLockConflictError({ + classification: "active", + path: "lock", + fingerprint: "", + }); + }, + }); + assert.equal(result.status, "stopped"); + assert.equal(mutated, false); +}); + +test("maps malformed post-lock planning state to a no-mutation blocker", async () => { + let mutated = false; + const result = await runPlanningIssue({ + issue: { + number: 189, + title: "Example", + state: "open", + labels: ["agent-in-progress"], + } as never, + state: { runId: "123e4567-e89b-42d3-a456-426614174000" } as never, + expectedStatePresence: "present", + runStateDir: "/tmp/state", + stateStore: { + read: async () => { + throw new PlanningStateValidationError( + "invalid-json", + "$", + "/tmp/state/issue-189.json", + ); + }, + }, + readIssue: async () => + ({ + number: 189, + title: "Example", + state: "open", + labels: ["agent-in-progress"], + }) as never, + readLegacy: async () => undefined, + mutate: async () => { + mutated = true; + return []; + }, + coordinate: async () => { + throw new Error("unreachable"); + }, + acquire: async () => + ({ record: { runId: "123e4567-e89b-42d3-a456-426614174000" } }) as never, + release: async () => {}, + }); + assert.equal(result.status, "blocked"); + if (result.status === "blocked") + assert.match(result.result.reason, /issue-189\.json.*invalid-json/); + assert.equal(mutated, false); +}); + +test("blocks active planning state that conflicts with blocked legacy recovery after locking", async () => { + let mutated = false; + let coordinated = false; + const active = { + runId: "123e4567-e89b-42d3-a456-426614174000", + issueNumber: 189, + phases: [{ status: "pending" }], + } as never; + const result = await runPlanningIssue({ + issue: { + number: 189, + title: "Example", + state: "open", + labels: ["agent-in-progress"], + } as never, + config: {} as never, + state: active, + expectedStatePresence: "present", + runStateDir: "/tmp/state", + stateStore: { read: async () => active, initialize: async () => {} }, + readIssue: async () => + ({ + number: 189, + title: "Example", + state: "open", + labels: ["agent-in-progress"], + }) as never, + readLegacy: async () => + ({ status: "blocked", lastError: "needs input" }) as never, + mutate: async () => { + mutated = true; + return []; + }, + coordinate: async () => { + coordinated = true; + return {} as never; + }, + acquire: async () => + ({ record: { runId: "123e4567-e89b-42d3-a456-426614174000" } }) as never, + release: async () => {}, + }); + assert.equal(result.status, "blocked"); + assert.equal(mutated, false); + assert.equal(coordinated, false); +}); + +test("blocks an active selection whose authoritative state disappears after locking", async () => { + let initialized = false; + let mutated = false; + let coordinated = false; + const result = await runPlanningIssue({ + issue: { + number: 189, + title: "Example", + state: "open", + labels: ["agent-in-progress"], + } as never, + state: { + runId: "123e4567-e89b-42d3-a456-426614174000", + phases: [{ status: "pending" }], + } as never, + expectedStatePresence: "present", + runStateDir: "/tmp/state", + stateStore: { + read: async () => undefined, + initialize: async () => { + initialized = true; + }, + }, + readIssue: async () => + ({ + number: 189, + title: "Example", + state: "open", + labels: ["agent-in-progress"], + }) as never, + readLegacy: async () => undefined, + mutate: async () => { + mutated = true; + return []; + }, + coordinate: async () => { + coordinated = true; + return {} as never; + }, + acquire: async () => + ({ record: { runId: "123e4567-e89b-42d3-a456-426614174000" } }) as never, + release: async () => {}, + }); + assert.equal(result.status, "blocked"); + assert.equal(initialized, false); + assert.equal(mutated, false); + assert.equal(coordinated, false); +}); diff --git a/src/cli/commands/run-once/planning-pipeline.ts b/src/cli/commands/run-once/planning-pipeline.ts new file mode 100644 index 00000000..4a7f68c0 --- /dev/null +++ b/src/cli/commands/run-once/planning-pipeline.ts @@ -0,0 +1,465 @@ +import { localPiAgentDir } from "../init/pi-agent-settings.ts"; +import { runPiSessionPath } from "./progress.ts"; +import { withLogPath } from "./pipeline-progress.ts"; +import { createRunOnceHostProvider } from "../../../host/factory.ts"; +import { + PlanningIssueLockConflictError, + acquirePlanningIssueLock, + releasePlanningIssueLock, + type PlanningIssueLock, +} from "../../../workflow/planning-issue-lock.ts"; +import { + PlanningStateStore, + planningStatePath, +} from "../../../workflow/planning-state-store.ts"; +import { + PlanningStateValidationError, + type PlanningStateV1, +} from "../../../workflow/planning-state.ts"; +import { ensureAutomationLabel } from "./automation-labels.ts"; +import { blockerComment, startedComment } from "./pipeline-comments.ts"; +import { lifecycleLabels } from "./pipeline-lifecycle.ts"; +import { createPlanningRuntime } from "./planning-runtime.ts"; +import { applyPlanningBlockedLabels } from "./planning-lifecycle-labels.ts"; +import { + legacyConflictsWithPlanning, + planningFinishReachedDoneLabelBoundary, + planningIssueEligible, + planningStateDiagnostic, +} from "./planning-selection.ts"; +import type { PlanningCoordinatorOutcome } from "./planning-phase-coordinator.ts"; +import { readRunState } from "./run-state.ts"; +import { planLabelChange } from "../triage/labels.ts"; +import type { RunOnceHostProvider } from "../../../host/types.ts"; +import type { + AgentIssueBlockedResult, + AgentIssueConfig, + AgentIssuePipelineResult, + AgentIssueStoppedResult, + CommandRunner, + IssueSummary, +} from "./types.ts"; +import type { RunOneIssueOptions } from "./pipeline-legacy.ts"; + +export type PlanningPipelineResult = + | AgentIssueStoppedResult + | { status: "blocked"; issue: IssueSummary; result: AgentIssueBlockedResult } + | { + status: "coordinated"; + issue: IssueSummary; + outcome: PlanningCoordinatorOutcome; + }; + +type PlanningIssueInput = { + issue: IssueSummary; + config: AgentIssueConfig; + state: PlanningStateV1; + /** Whether selection observed durable planning state before acquiring the lock. */ + expectedStatePresence: "present" | "absent"; + runStateDir: string; + stateStore: Pick; + readIssue: () => Promise; + readLegacy: () => Promise< + import("./types.ts").AgentIssueRunState | undefined + >; + eligible?: ( + issue: IssueSummary, + state: PlanningStateV1 | undefined, + ) => boolean; + mutate: ( + issue: IssueSummary, + fresh: boolean, + state: PlanningStateV1, + ) => Promise; + coordinate: ( + state: PlanningStateV1, + lock: PlanningIssueLock, + issue: IssueSummary, + labels: string[], + ) => Promise; + acquire?: typeof acquirePlanningIssueLock; + release?: typeof releasePlanningIssueLock; +}; + +function blocked(issue: IssueSummary, reason: string): PlanningPipelineResult { + return { + status: "blocked", + issue, + result: { + status: "blocked", + reason, + questions: [], + commits: [], + validation: [], + }, + }; +} + +export function planningIssueNeedsClaim(input: { + issue: IssueSummary; + fresh: boolean; + state: PlanningStateV1; + labels: Pick< + ReturnType, + "ready" | "inProgress" | "done" + >; +}): boolean { + const doneLabelAlreadyApplied = + input.issue.labels.includes(input.labels.done) && + planningFinishReachedDoneLabelBoundary(input.state); + return ( + !doneLabelAlreadyApplied && + (input.fresh || + input.issue.labels.includes(input.labels.ready) || + !input.issue.labels.includes(input.labels.inProgress)) + ); +} + +async function releaseOwnedPlanningLock(input: { + lock: PlanningIssueLock; + release: typeof releasePlanningIssueLock; + workFailure: unknown; +}): Promise { + try { + await input.release(input.lock); + } catch (releaseFailure) { + if (input.workFailure !== undefined) + throw new AggregateError( + [input.workFailure, releaseFailure], + "Planning issue work and lock release failed", + { cause: releaseFailure }, + ); + throw new Error("Planning lock release failed", { cause: releaseFailure }); + } +} + +/** + * Owns one planning issue attempt. Selection is advisory; every identity and + * workflow check is repeated after the ownership-ID lock is acquired. + */ +export async function runPlanningIssue( + input: PlanningIssueInput, +): Promise { + const acquire = input.acquire ?? acquirePlanningIssueLock; + const release = input.release ?? releasePlanningIssueLock; + let lock: PlanningIssueLock | undefined; + let workFailure: unknown; + try { + try { + lock = await acquire(input.runStateDir, { + issueNumber: input.issue.number, + runId: input.state.runId, + }); + } catch (error) { + if (error instanceof PlanningIssueLockConflictError) { + if (error.diagnostic.classification === "active") + return { + status: "stopped", + issue: input.issue, + reason: "issue-locked", + }; + return blocked( + input.issue, + `issue-lock-${error.diagnostic.classification}`, + ); + } + throw error; + } + let retriedAuthoritativeRun = false; + while (true) { + let issue: IssueSummary; + let saved: PlanningStateV1 | undefined; + let legacy: Awaited>; + try { + [issue, saved, legacy] = await Promise.all([ + input.readIssue(), + input.stateStore.read(input.issue.number), + input.readLegacy(), + ]); + } catch (error) { + if (error instanceof PlanningStateValidationError) + return blocked( + input.issue, + `planning-state-invalid: ${planningStateDiagnostic( + error, + planningStatePath(input.runStateDir, input.issue.number), + )}`, + ); + throw error; + } + // A planning selection owns a concrete durable state. Never turn a + // disappeared active selection into a fresh attempt after locking. + if ( + (input.expectedStatePresence === "present" && saved === undefined) || + (retriedAuthoritativeRun && saved === undefined) + ) + return blocked(input.issue, "planning-identity-changed"); + const current = saved ?? input.state; + const planningActive = current.phases.some( + (phase) => phase.status !== "complete", + ); + const legacyConflict = legacyConflictsWithPlanning(legacy); + const eligible = planningIssueEligible({ + issue, + config: input.config, + state: current, + activeOwnedWorkflow: saved !== undefined && planningActive, + }); + if ( + issue.number !== input.issue.number || + issue.title !== input.issue.title || + current.issueNumber !== input.issue.number || + issue.state !== "open" || + !planningActive || + legacyConflict || + !eligible || + (input.eligible !== undefined && !input.eligible(issue, saved)) + ) + return blocked(input.issue, "planning-identity-changed"); + if (saved !== undefined && saved.runId !== lock.record.runId) { + if (input.expectedStatePresence !== "absent" || retriedAuthoritativeRun) + return blocked(input.issue, "planning-identity-changed"); + const provisionalLock = lock; + lock = undefined; + await releaseOwnedPlanningLock({ + lock: provisionalLock, + release, + workFailure: undefined, + }); + retriedAuthoritativeRun = true; + try { + lock = await acquire(input.runStateDir, { + issueNumber: input.issue.number, + runId: saved.runId, + }); + } catch (error) { + if (error instanceof PlanningIssueLockConflictError) { + if (error.diagnostic.classification === "active") + return { + status: "stopped", + issue: input.issue, + reason: "issue-locked", + }; + return blocked( + input.issue, + `issue-lock-${error.diagnostic.classification}`, + ); + } + throw error; + } + continue; + } + if (current.runId !== lock.record.runId) + return blocked(input.issue, "planning-run-id-mismatch"); + const fresh = saved === undefined; + if (fresh) await input.stateStore.initialize({ state: current, lock }); + const labels = await input.mutate(issue, fresh, current); + return { + status: "coordinated", + issue, + outcome: await input.coordinate(current, lock, issue, labels), + }; + } + } catch (error) { + workFailure = error; + throw error; + } finally { + if (lock) await releaseOwnedPlanningLock({ lock, release, workFailure }); + } +} + +function statePaths(state: PlanningStateV1) { + const implementation = state.phases.find( + (phase) => phase.kind === "implementation", + ); + const artifacts = state.phases.flatMap((phase) => + "artifacts" in phase ? phase.artifacts : [], + ); + const workspace = + implementation && "workspace" in implementation + ? implementation.workspace + : undefined; + return { + ...(artifacts.find((artifact) => artifact.kind === "spec") + ? { + specPath: artifacts.find((artifact) => artifact.kind === "spec")! + .path, + } + : {}), + ...(artifacts.find((artifact) => artifact.kind === "plan") + ? { + planPath: artifacts.find((artifact) => artifact.kind === "plan")! + .path, + } + : {}), + ...(workspace === undefined + ? {} + : { + branch: workspace.identity.branch, + worktreePath: workspace.identity.worktreePath, + }), + }; +} + +function mapOutcome( + issue: IssueSummary, + outcome: PlanningCoordinatorOutcome, +): AgentIssuePipelineResult { + const paths = statePaths(outcome.state); + switch (outcome.kind) { + case "review-pending": + return { + status: "review-pending", + issue, + phase: outcome.phase, + prUrl: outcome.prUrl, + }; + case "stopped": + return { + status: "stopped", + issue, + reason: outcome.reason, + nextPhase: outcome.nextPhase, + ...paths, + }; + case "blocked": + return { issue, ...paths, ...outcome.result }; + case "complete": + if (!paths.planPath || !paths.branch || !paths.worktreePath) + throw new Error( + "Planning completion is missing durable implementation paths", + ); + return { + issue, + ...outcome.result, + planPath: paths.planPath, + branch: paths.branch, + worktreePath: paths.worktreePath, + ...(paths.specPath === undefined ? {} : { specPath: paths.specPath }), + }; + } +} + +/** Constructs the production planning pipeline around the strict state store. */ +export async function runPlanningWorkflow(input: { + runner: CommandRunner; + config: AgentIssueConfig; + options: RunOneIssueOptions; + issue: IssueSummary; + state: PlanningStateV1; + expectedStatePresence: "present" | "absent"; + host?: RunOnceHostProvider; +}): Promise { + const host = + input.host ?? + createRunOnceHostProvider({ + runner: input.runner, + repoRoot: input.config.repoRoot, + host: input.config.host, + }); + const stateStore = new PlanningStateStore(input.config.runStateDir); + const labels = lifecycleLabels(input.config); + const attemptTimestamp = (input.options.now ?? new Date()).toISOString(); + const piSessionPath = runPiSessionPath( + input.config.runStateDir, + attemptTimestamp, + input.issue.number, + ); + const runOptions = { ...input.options, piSessionPath }; + const planning = await runPlanningIssue({ + issue: input.issue, + config: input.config, + state: input.state, + expectedStatePresence: input.expectedStatePresence, + runStateDir: input.config.runStateDir, + stateStore, + readIssue: () => host.viewIssue(input.issue.number), + readLegacy: () => + readRunState(input.config.runStateDir, input.issue.number), + eligible: (issue, state) => + planningIssueEligible({ + issue, + config: input.config, + ...(state === undefined ? {} : { state }), + activeOwnedWorkflow: state !== undefined, + }) && + (state !== undefined || issue.labels.includes(input.config.readyLabel)), + mutate: async (issue, fresh, state) => { + const mustClaim = planningIssueNeedsClaim({ + issue, + fresh, + state, + labels, + }); + const claimedLabels = mustClaim + ? [ + ...issue.labels.filter( + (label) => label !== labels.ready && label !== labels.needsInfo, + ), + labels.inProgress, + ] + : [...issue.labels]; + if (mustClaim) { + await ensureAutomationLabel(host, input.config, labels.inProgress); + await host.applyLabels( + planLabelChange(issue.number, issue.labels, claimedLabels), + ); + } + const body = startedComment(issue); + if (!issue.comments?.some((comment) => comment.body === body)) + await host.commentIssue(issue.number, body); + return claimedLabels; + }, + coordinate: async (state, lock, issue, currentLabels) => { + const runtime = createPlanningRuntime({ + runner: input.runner, + config: input.config, + issue, + labels: currentLabels, + readyLabel: labels.ready, + inProgressLabel: labels.inProgress, + doneLabel: labels.done, + needsInfoLabel: labels.needsInfo, + piAgentDir: localPiAgentDir(input.config.repoRoot), + tokenUsageState: { total: 0 }, + progressReporter: input.options.progress, + streamPiOutput: input.options.streamPiOutput, + verbosePiOutput: input.options.verbosePiOutput, + heartbeatMs: input.options.heartbeatMs, + piSessionPath, + host, + ...(input.options.now === undefined + ? {} + : { now: () => input.options.now! }), + }); + const outcome = await runtime.coordinate(state, lock); + if (outcome.kind === "blocked") { + const body = blockerComment(outcome.result); + if (!issue.comments?.some((comment) => comment.body === body)) + await host.commentIssue(issue.number, body); + await ensureAutomationLabel(host, input.config, labels.needsInfo); + await applyPlanningBlockedLabels({ + host, + issueNumber: issue.number, + labels: { + ready: labels.ready, + inProgress: labels.inProgress, + needsInfo: labels.needsInfo, + }, + }); + } + return outcome; + }, + }); + if (planning.status === "coordinated") + return withLogPath( + mapOutcome(planning.issue, planning.outcome), + runOptions, + ); + if (planning.status === "blocked") + return withLogPath( + { issue: planning.issue, ...planning.result }, + runOptions, + ); + return withLogPath(planning, runOptions); +} diff --git a/src/cli/commands/run-once/planning-runtime-state.test.ts b/src/cli/commands/run-once/planning-runtime-state.test.ts new file mode 100644 index 00000000..ba130c3d --- /dev/null +++ b/src/cli/commands/run-once/planning-runtime-state.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + artifactPath, + requiredImplementationFinishContext, +} from "./planning-runtime-state.ts"; + +const state = { + phases: [ + { + kind: "spec", + status: "complete", + artifacts: [{ kind: "spec", path: "docs/specs/issue.md" }], + }, + { + kind: "implementation", + status: "pull-request-open", + artifacts: [{ kind: "plan", path: "docs/plans/issue.md" }], + implementation: {}, + pullRequest: {}, + workspace: {}, + }, + ], +} as never; + +test("selects one durable planning artifact and validated implementation finish phase", () => { + assert.equal(artifactPath(state, "plan"), "docs/plans/issue.md"); + assert.equal(requiredImplementationFinishContext(state, 1), state.phases[1]); +}); + +test("accepts terminal complete implementation evidence for idempotent finish", () => { + const terminal = { + ...state, + phases: [{ ...state.phases[1]!, status: "complete" }], + } as never; + assert.equal( + requiredImplementationFinishContext(terminal, 0), + terminal.phases[0], + ); +}); + +test("rejects missing artifacts and unvalidated finish phases", () => { + assert.throws(() => artifactPath(state, "planx" as never)); + assert.throws(() => requiredImplementationFinishContext(state, 0)); +}); diff --git a/src/cli/commands/run-once/planning-runtime-state.ts b/src/cli/commands/run-once/planning-runtime-state.ts new file mode 100644 index 00000000..c51cc51e --- /dev/null +++ b/src/cli/commands/run-once/planning-runtime-state.ts @@ -0,0 +1,70 @@ +import type { AgentIssuePrCreatedResult } from "./types.ts"; +import type { + ImplementationCompletePlanningPhase, + ImplementationPullRequestOpenPlanningPhase, + PlanningArtifactEvidence, + PlanningStateV1, +} from "../../../workflow/planning-state-types.ts"; + +/** Returns one unambiguous durable artifact path or fails before an effect. */ +export function artifactPath( + state: PlanningStateV1, + kind: PlanningArtifactEvidence["kind"], +): string { + const paths = state.phases.flatMap((phase) => + "artifacts" in phase + ? phase.artifacts + .filter((artifact) => artifact.kind === kind) + .map((artifact) => artifact.path) + : [], + ); + if (paths.length !== 1) + throw new Error(`Planning state requires exactly one durable ${kind} path`); + return paths[0]!; +} + +/** Selects the validated implementation evidence required for finish effects. */ +export function durableImplementationResult( + phase: + | ImplementationPullRequestOpenPlanningPhase + | ImplementationCompletePlanningPhase, +): AgentIssuePrCreatedResult { + return { + status: "pr-created", + prUrl: phase.pullRequest.url, + branch: phase.implementation.branch, + commits: [...phase.implementation.commits], + validation: [...phase.implementation.validation], + ...(phase.implementation.reviewSummary === undefined + ? {} + : { reviewSummary: phase.implementation.reviewSummary }), + ...(phase.implementation.landingDecision === undefined + ? {} + : { landingDecision: phase.implementation.landingDecision }), + visualEvidence: phase.implementation.visualEvidence.map((evidence) => ({ + screenshotPath: evidence.screenshotPath, + ...(evidence.caption === undefined ? {} : { caption: evidence.caption }), + ...(evidence.referencePaths === undefined + ? {} + : { referencePaths: [...evidence.referencePaths] }), + ...(evidence.url === undefined ? {} : { url: evidence.url }), + })), + }; +} + +export function requiredImplementationFinishContext( + state: PlanningStateV1, + phaseIndex: number, +): + | ImplementationPullRequestOpenPlanningPhase + | ImplementationCompletePlanningPhase { + const phase = state.phases[phaseIndex]; + if ( + phase?.kind !== "implementation" || + (phase.status !== "pull-request-open" && phase.status !== "complete") + ) + throw new Error( + "Planning finish requires a validated implementation pull request", + ); + return phase; +} diff --git a/src/cli/commands/run-once/planning-runtime.test.ts b/src/cli/commands/run-once/planning-runtime.test.ts new file mode 100644 index 00000000..6a40d30c --- /dev/null +++ b/src/cli/commands/run-once/planning-runtime.test.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { makeConfig } from "../../../../test-support/run-once/pipeline-fixtures.ts"; +import { createMockRunner } from "../../../../test-support/run-once/mock-runner.ts"; +import { createPlanningFinishEffects } from "./planning-finish-effects.ts"; +import { createPlanningRuntime } from "./planning-runtime.ts"; + +test("constructs a production planning coordinator callback without legacy state wiring", async () => { + const config = await makeConfig({ dryRun: false, execute: true }); + const runtime = createPlanningRuntime({ + runner: createMockRunner(async () => ({ code: 0, stdout: "", stderr: "" })), + config, + issue: { + number: 189, + title: "Runtime", + body: "", + labels: [], + state: "open", + }, + labels: ["agent-ready"], + readyLabel: "agent-ready", + inProgressLabel: "agent-in-progress", + doneLabel: "agent-done", + needsInfoLabel: "needs-info", + piAgentDir: ".patchmill/pi-agent", + tokenUsageState: { total: 0 }, + }); + assert.equal(typeof runtime.coordinate, "function"); +}); + +test("planning finish preserves legacy approval and review labels", async () => { + const config = await makeConfig({ dryRun: false, execute: true }); + const applied: string[][] = []; + const labels = [ + "agent-ready", + "agent-in-progress", + "needs-info", + config.approvalPolicy.specApproval.reviewLabel, + config.approvalPolicy.specApproval.approvedLabel, + config.approvalPolicy.planApproval.reviewLabel, + config.approvalPolicy.planApproval.approvedLabel, + "bug", + ]; + const host = { + viewIssue: async () => ({ labels }), + applyLabels: async (change: { newLabels: string[] }) => { + applied.push(change.newLabels); + }, + } as never; + const state = { + issueNumber: 189, + phases: [ + { + kind: "implementation", + status: "pull-request-open", + workspace: { identity: { worktreePath: ".worktrees/issue-189" } }, + artifacts: [{ kind: "plan", path: "docs/plans/issue-189.md" }], + pullRequest: {}, + implementation: { visualEvidence: [] }, + }, + ], + } as never; + const effects = createPlanningFinishEffects({ + runner: createMockRunner(async () => ({ code: 0, stdout: "", stderr: "" })), + config, + issue: { + number: 189, + title: "Runtime", + body: "", + labels: [], + state: "open", + }, + labels, + readyLabel: "agent-ready", + inProgressLabel: "agent-in-progress", + doneLabel: "agent-done", + needsInfoLabel: "needs-info", + host, + phaseIndex: 0, + })(state); + await effects.effects.applyDoneLabels(); + assert.deepEqual(applied, [ + [ + config.approvalPolicy.specApproval.reviewLabel, + config.approvalPolicy.specApproval.approvedLabel, + config.approvalPolicy.planApproval.reviewLabel, + config.approvalPolicy.planApproval.approvedLabel, + "bug", + "agent-done", + ].sort(), + ]); +}); + +test("planning finish removes lifecycle labels added after runtime construction", async () => { + const config = await makeConfig({ dryRun: false, execute: true }); + const applied: string[][] = []; + const currentLabels = [ + "agent-ready", + "agent-in-progress", + "needs-info", + config.approvalPolicy.specApproval.reviewLabel, + config.approvalPolicy.specApproval.approvedLabel, + config.approvalPolicy.planApproval.reviewLabel, + config.approvalPolicy.planApproval.approvedLabel, + "bug", + ]; + const host = { + viewIssue: async () => ({ labels: currentLabels }), + applyLabels: async (change: { newLabels: string[] }) => { + applied.push(change.newLabels); + }, + } as never; + const state = { + issueNumber: 189, + phases: [ + { + kind: "implementation", + status: "pull-request-open", + workspace: { identity: { worktreePath: ".worktrees/issue-189" } }, + artifacts: [{ kind: "plan", path: "docs/plans/issue-189.md" }], + pullRequest: {}, + implementation: { visualEvidence: [] }, + }, + ], + } as never; + const effects = createPlanningFinishEffects({ + runner: createMockRunner(async () => ({ code: 0, stdout: "", stderr: "" })), + config, + issue: { + number: 189, + title: "Runtime", + body: "", + labels: [], + state: "open", + }, + labels: ["stale-label"], + readyLabel: "agent-ready", + inProgressLabel: "agent-in-progress", + doneLabel: "agent-done", + needsInfoLabel: "needs-info", + host, + phaseIndex: 0, + })(state); + await effects.effects.applyDoneLabels(); + assert.deepEqual(applied, [ + [ + config.approvalPolicy.specApproval.reviewLabel, + config.approvalPolicy.specApproval.approvedLabel, + config.approvalPolicy.planApproval.reviewLabel, + config.approvalPolicy.planApproval.approvedLabel, + "bug", + "agent-done", + ].sort(), + ]); +}); + +test("finish effects deduplicate a durable implementation handoff", async () => { + const config = await makeConfig({ dryRun: false, execute: true }); + const comments: string[] = []; + const host = { + viewIssue: async () => ({ comments: comments.map((body) => ({ body })) }), + commentIssue: async (_issue: number, body: string) => { + comments.push(body); + }, + applyLabels: async () => {}, + listLabels: async () => [], + } as never; + const state = { + issueNumber: 189, + phases: [ + { + kind: "implementation", + status: "pull-request-open", + workspace: { identity: { worktreePath: ".worktrees/issue-189" } }, + artifacts: [{ kind: "plan", path: "docs/plans/issue-189.md" }], + pullRequest: {}, + implementation: { visualEvidence: [] }, + }, + ], + } as never; + const effects = createPlanningFinishEffects({ + runner: createMockRunner(async () => ({ code: 0, stdout: "", stderr: "" })), + config, + issue: { + number: 189, + title: "Runtime", + body: "", + labels: [], + state: "open", + }, + labels: ["agent-ready"], + readyLabel: "agent-ready", + inProgressLabel: "agent-in-progress", + doneLabel: "agent-done", + needsInfoLabel: "needs-info", + host, + phaseIndex: 0, + })(state); + await effects.effects.postHandoff({ + status: "pr-created", + prUrl: "https://example.test/pull/189", + branch: "agent/issue-189", + commits: [], + validation: [], + }); + await effects.effects.postHandoff({ + status: "pr-created", + prUrl: "https://example.test/pull/189", + branch: "agent/issue-189", + commits: [], + validation: [], + }); + assert.equal(comments.length, 1); +}); diff --git a/src/cli/commands/run-once/planning-runtime.ts b/src/cli/commands/run-once/planning-runtime.ts new file mode 100644 index 00000000..50337214 --- /dev/null +++ b/src/cli/commands/run-once/planning-runtime.ts @@ -0,0 +1,200 @@ +import { resolve } from "node:path"; +import { PlanningPublicationGit } from "../../../git/planning-publication-git.ts"; +import { PlanningRemoteBaseGit } from "../../../git/planning-remote-base.ts"; +import { PlanningWorkspaceGit } from "../../../git/planning-workspace-git.ts"; +import { + createPullRequestHost, + createRunOnceHostProvider, +} from "../../../host/factory.ts"; +import { PlanningStateStore } from "../../../workflow/planning-state-store.ts"; +import { phaseWorkspaceIdentity } from "../../../workflow/planning-pull-requests.ts"; +import type { PlanningIssueLock } from "../../../workflow/planning-issue-lock.ts"; +import type { PlanningStateV1 } from "../../../workflow/planning-state-types.ts"; +import { createPlanningArtifactAgent } from "./planning-phase-artifacts.ts"; +import { coordinatePlanningPhases } from "./planning-phase-coordinator.ts"; +import { runPlanningPhase } from "./planning-phase-runner.ts"; +import { createPlanningFinishEffects } from "./planning-finish-effects.ts"; +import { createPlanningImplementationAdapter } from "./planning-implementation-adapter.ts"; +import type { AgentIssueConfig, CommandRunner, IssueSummary } from "./types.ts"; +import type { RunOnceHostProvider } from "../../../host/types.ts"; + +export type PlanningRuntime = { + coordinate: ( + state: PlanningStateV1, + lock: PlanningIssueLock, + ) => ReturnType; +}; +export type PlanningRuntimeInput = { + runner: CommandRunner; + config: AgentIssueConfig; + issue: IssueSummary; + labels: string[]; + readyLabel: string; + inProgressLabel: string; + doneLabel: string; + needsInfoLabel: string; + piAgentDir: string; + tokenUsageState: { total: number }; + progressReporter?: Parameters< + typeof createPlanningImplementationAdapter + >[0]["progressReporter"]; + streamPiOutput?: ((chunk: string) => void) | undefined; + verbosePiOutput?: boolean | undefined; + heartbeatMs?: number | undefined; + piSessionPath?: string | undefined; + now?: () => Date; + host?: RunOnceHostProvider; +}; + +/** Constructs boring Git, host, and coordinator services for planning-pr-v1. */ +export function createPlanningRuntime( + input: PlanningRuntimeInput, +): PlanningRuntime { + const stateStore = new PlanningStateStore(input.config.runStateDir); + const remoteBase = new PlanningRemoteBaseGit({ + runner: input.runner, + repoRoot: input.config.repoRoot, + specsDir: input.config.specsDir, + plansDir: input.config.plansDir, + }); + const workspaces = new PlanningWorkspaceGit({ + runner: input.runner, + repoRoot: input.config.repoRoot, + worktreeRoot: resolve(input.config.repoRoot, input.config.worktreeDir), + }); + const publicationGit = new PlanningPublicationGit({ + runner: input.runner, + repoRoot: input.config.repoRoot, + }); + const prHost = createPullRequestHost({ + runner: input.runner, + repoRoot: input.config.repoRoot, + remote: input.config.remote, + host: input.config.host, + }); + const host = + input.host ?? + createRunOnceHostProvider({ + runner: input.runner, + repoRoot: input.config.repoRoot, + host: input.config.host, + }); + const artifactAgent = createPlanningArtifactAgent({ + runner: input.runner, + repoRoot: input.config.repoRoot, + skills: input.config.skills, + taskContract: input.config.projectPolicy.pi.taskContract, + issueNumber: input.issue.number, + runOptions: { + ...(input.piSessionPath === undefined + ? {} + : { sessionRoot: input.piSessionPath }), + ...(input.progressReporter === undefined + ? {} + : { progress: input.progressReporter }), + ...(input.streamPiOutput === undefined + ? {} + : { streamOutput: input.streamPiOutput }), + ...(input.verbosePiOutput === undefined + ? {} + : { verbosePiOutput: input.verbosePiOutput }), + ...(input.heartbeatMs === undefined + ? {} + : { heartbeatMs: input.heartbeatMs }), + tokenUsageState: input.tokenUsageState, + }, + }); + const git = { + baseBranch: input.config.baseBranch, + baseRef: input.config.baseRef, + remote: input.config.remote, + branchPrefix: input.config.branchPrefix, + worktreeDir: input.config.worktreeDir, + worktreePrefix: input.config.worktreePrefix, + slugLength: input.config.slugLength, + allowDirectLand: false, + }; + return { + coordinate: (state, lock) => + coordinatePlanningPhases({ + state, + issue: input.issue, + planOnly: input.config.planOnly, + runPlanningPhase: ({ state: current, phaseIndex, phase, planOnly }) => + runPlanningPhase({ + state: current, + phaseIndex, + phase, + planOnly, + issue: input.issue, + lock, + stateStore, + host: prHost, + remoteBase, + publicationGit, + workspaces, + artifactAgent, + config: { + repoRoot: input.config.repoRoot, + remote: input.config.remote, + baseBranch: input.config.baseBranch, + specsDir: input.config.specsDir, + plansDir: input.config.plansDir, + projectPolicy: input.config.projectPolicy, + skills: input.config.skills, + triageLabels: { + ready: input.readyLabel, + needsInfo: input.needsInfoLabel, + }, + workspaceIdentity: (planned) => { + const identity = phaseWorkspaceIdentity({ + issueNumber: input.issue.number, + title: input.issue.title, + phase: planned.kind, + strategy: git, + }); + return { + ...identity, + worktreePath: resolve( + input.config.repoRoot, + identity.worktreePath, + ), + }; + }, + }, + implementation: { + implementation: createPlanningImplementationAdapter({ + runner: input.runner, + config: input.config, + issue: input.issue, + labels: input.labels, + git, + piAgentDir: input.piAgentDir, + tokenUsageState: input.tokenUsageState, + progressReporter: input.progressReporter, + streamPiOutput: input.streamPiOutput, + verbosePiOutput: input.verbosePiOutput, + heartbeatMs: input.heartbeatMs, + piSessionPath: input.piSessionPath, + now: input.now, + }), + finish: createPlanningFinishEffects({ + runner: input.runner, + config: input.config, + issue: input.issue, + labels: input.labels, + readyLabel: input.readyLabel, + inProgressLabel: input.inProgressLabel, + doneLabel: input.doneLabel, + needsInfoLabel: input.needsInfoLabel, + host, + phaseIndex, + progressReporter: input.progressReporter, + now: input.now, + }), + }, + ...(input.now === undefined ? {} : { now: input.now }), + }), + }), + }; +} diff --git a/src/cli/commands/run-once/planning-selection.test.ts b/src/cli/commands/run-once/planning-selection.test.ts new file mode 100644 index 00000000..7a1075a3 --- /dev/null +++ b/src/cli/commands/run-once/planning-selection.test.ts @@ -0,0 +1,260 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + legacyActiveForIssue, + selectRunOnceWorkflow, +} from "./planning-selection.ts"; +import { writeRunState } from "./run-state.ts"; + +const issue = (number: number, labels: string[]) => ({ + number, + title: `Issue ${number}`, + state: "open", + labels, +}); +const config = { + runStateDir: "/tmp/no-planning-selection-state", + readyLabel: "agent-ready", + issueNumber: undefined, + approvalPolicy: { + specApproval: { required: false }, + planApproval: { required: false }, + }, + triagePolicy: { + runOnceSelection: { priorityOrder: ["priority:high", "priority:low"] }, + }, +} as never; + +test("prioritizes active planning over fresh ready work and reuses label ordering", async () => { + const active = { phases: [{ status: "pending" }] }; + const result = await selectRunOnceWorkflow( + [issue(4, ["agent-ready", "priority:low"]), issue(3, ["priority:high"])], + config, + { + path: () => "state", + read: async (number) => (number === 3 ? active : undefined), + } as never, + ); + assert.equal(result.kind, "planning"); + if (result.kind === "planning") assert.equal(result.issue.number, 3); +}); + +test("excludes blocked fresh work and honors configured legacy in-progress labels", async () => { + const custom = { + ...config, + triagePolicy: { + labels: { + ready: "queued", + inProgress: "claimed", + done: "closed", + needsInfo: "blocked", + }, + runOnceSelection: { + priorityOrder: [], + excludedLabels: ["unsuitable"], + }, + }, + } as never; + const result = await selectRunOnceWorkflow( + [issue(1, ["queued", "unsuitable"]), issue(2, ["claimed"])], + custom, + { path: () => "state", read: async () => undefined } as never, + ); + assert.equal(result.kind, "none"); +}); + +test("selects an ordinary in-progress legacy run despite configured exclusions", async () => { + const runStateDir = await mkdtemp(join(tmpdir(), "planning-selection-")); + try { + await writeRunState(runStateDir, { + issueNumber: 3, + title: "Issue 3", + status: "planning", + }); + const result = await selectRunOnceWorkflow( + [issue(3, ["in-progress", "unsuitable"]), issue(4, ["agent-ready"])], + { + ...config, + runStateDir, + triagePolicy: { + runOnceSelection: { + priorityOrder: [], + excludedLabels: ["unsuitable"], + }, + }, + } as never, + { path: () => "state", read: async () => undefined } as never, + ); + assert.equal(result.kind, "legacy"); + if (result.kind === "legacy") assert.equal(result.issue.number, 3); + } finally { + await rm(runStateDir, { recursive: true, force: true }); + } +}); + +test("keeps a finished legacy planning workspace on the legacy route", () => { + assert.equal( + legacyActiveForIssue(issue(3, ["agent-ready"]), config, { + status: "finished", + specPath: "docs/specs/issue-3.md", + branch: "agent/issue-3", + } as never), + true, + ); +}); + +test("selects a finished legacy planning workspace over fresh planning", async () => { + const runStateDir = await mkdtemp(join(tmpdir(), "planning-selection-")); + try { + await writeRunState(runStateDir, { + issueNumber: 3, + title: "Issue 3", + status: "finished", + specPath: "docs/specs/issue-3.md", + branch: "agent/issue-3", + }); + const result = await selectRunOnceWorkflow( + [issue(3, ["agent-ready"]), issue(4, ["agent-ready"])], + { ...config, runStateDir }, + { path: () => "state", read: async () => undefined } as never, + ); + assert.equal(result.kind, "legacy"); + if (result.kind === "legacy") assert.equal(result.issue.number, 3); + } finally { + await rm(runStateDir, { recursive: true, force: true }); + } +}); + +test("selects approval-resumed finished legacy planning workspaces", async () => { + const runStateDir = await mkdtemp(join(tmpdir(), "planning-selection-")); + try { + await writeRunState(runStateDir, { + issueNumber: 3, + title: "Issue 3", + status: "finished", + specPath: "docs/specs/issue-3.md", + branch: "agent/issue-3", + }); + const approvalConfig = { + ...config, + runStateDir, + approvalPolicy: { + specApproval: { + kind: "spec", + required: true, + reviewLabel: "spec-review", + approvedLabel: "spec-approved", + }, + planApproval: { + kind: "plan", + required: true, + reviewLabel: "plan-review", + approvedLabel: "plan-approved", + }, + }, + } as never; + for (const label of ["spec-approved", "plan-approved"]) { + const result = await selectRunOnceWorkflow( + [issue(3, [label]), issue(4, ["agent-ready"])], + approvalConfig, + { path: () => "state", read: async () => undefined } as never, + ); + assert.equal(result.kind, "legacy"); + if (result.kind === "legacy") assert.equal(result.issue.number, 3); + } + } finally { + await rm(runStateDir, { recursive: true, force: true }); + } +}); + +test("reserves blocked legacy recovery for an explicit ready retry", () => { + const blocked = { status: "blocked", lastError: "needs input" } as never; + assert.equal( + legacyActiveForIssue(issue(3, ["agent-ready"]), config, blocked), + false, + ); + assert.equal( + legacyActiveForIssue(issue(3, []), { ...config, issueNumber: 3 }, blocked), + true, + ); +}); + +test("does not let an automatic blocked legacy retry outrank fresh work", async () => { + const runStateDir = await mkdtemp(join(tmpdir(), "planning-selection-")); + try { + await writeRunState(runStateDir, { + issueNumber: 3, + title: "Issue 3", + status: "blocked", + lastError: "needs input", + }); + const blockedIssue = issue(3, ["agent-ready"]); + const freshIssue = issue(4, ["agent-ready"]); + const automatic = await selectRunOnceWorkflow( + [blockedIssue, freshIssue], + { ...config, runStateDir }, + { path: () => "state", read: async () => undefined } as never, + ); + assert.equal(automatic.kind, "fresh-planning"); + if (automatic.kind === "fresh-planning") + assert.equal(automatic.issue.number, 4); + const explicit = await selectRunOnceWorkflow( + [blockedIssue, freshIssue], + { ...config, runStateDir, issueNumber: 3 }, + { path: () => "state", read: async () => undefined } as never, + ); + assert.equal(explicit.kind, "legacy"); + const unacknowledged = await selectRunOnceWorkflow( + [issue(3, ["in-progress"])], + { ...config, runStateDir, issueNumber: 3 }, + { path: () => "state", read: async () => undefined } as never, + ); + assert.equal(unacknowledged.kind, "none"); + } finally { + await rm(runStateDir, { recursive: true, force: true }); + } +}); + +test("rejects active planning state that conflicts with blocked legacy recovery during automatic selection", async () => { + const runStateDir = await mkdtemp(join(tmpdir(), "planning-selection-")); + try { + await writeRunState(runStateDir, { + issueNumber: 3, + title: "Issue 3", + status: "blocked", + lastError: "needs input", + }); + const result = await selectRunOnceWorkflow( + [issue(3, ["in-progress"])], + { ...config, runStateDir }, + { + path: () => "state", + read: async () => ({ phases: [{ status: "pending" }] }), + } as never, + ); + assert.equal(result.kind, "invalid-planning-state"); + if (result.kind === "invalid-planning-state") + assert.equal(result.reason, "planning and legacy state are both active"); + } finally { + await rm(runStateDir, { recursive: true, force: true }); + } +}); + +test("returns malformed planning state rather than selecting fresh work", async () => { + const result = await selectRunOnceWorkflow( + [issue(3, ["agent-ready"])], + config, + { + path: () => "state", + read: async () => { + throw new Error("invalid-json"); + }, + } as never, + ); + assert.equal(result.kind, "invalid-planning-state"); + if (result.kind === "invalid-planning-state") + assert.match(result.reason, /state: planning state read failed/); +}); diff --git a/src/cli/commands/run-once/planning-selection.ts b/src/cli/commands/run-once/planning-selection.ts new file mode 100644 index 00000000..192c5b85 --- /dev/null +++ b/src/cli/commands/run-once/planning-selection.ts @@ -0,0 +1,248 @@ +import { + createPlanningState, + PlanningStateValidationError, + type PlanningStateV1, +} from "../../../workflow/planning-state.ts"; +import type { PlanningStateStore } from "../../../workflow/planning-state-store.ts"; +import { isResumableRunState, readRunState } from "./run-state.ts"; +import { + hasBlockedRunRecoveryState, + lifecycleLabels, +} from "./pipeline-lifecycle.ts"; +import { DEFAULT_TRIAGE_POLICY } from "../triage/labels.ts"; +import { + isActionableWorkflowState, + resolveWorkflowState, +} from "./workflow-state.ts"; +import { compareIssuesByPriority } from "./selection.ts"; +import type { AgentIssueConfig, IssueSummary } from "./types.ts"; + +export type RunOnceWorkflowSelection = + | { kind: "none" } + | { kind: "invalid-planning-state"; issue: IssueSummary; reason: string } + | { kind: "planning"; issue: IssueSummary; state: PlanningStateV1 } + | { kind: "legacy"; issue: IssueSummary } + | { + kind: "fresh-planning"; + issue: IssueSummary; + initialState: PlanningStateV1; + }; + +export function planningStateDiagnostic( + error: unknown, + fallbackPath: string, +): string { + if (error instanceof PlanningStateValidationError) + return `${error.statePath ?? fallbackPath}: ${error.reason} at ${error.path}`; + return `${fallbackPath}: planning state read failed`; +} + +function active(state: PlanningStateV1): boolean { + return state.phases.some((phase) => phase.status !== "complete"); +} + +/** Identifies finish recovery after the live done label may precede its checkpoint. */ +export function planningFinishReachedDoneLabelBoundary( + state: PlanningStateV1 | undefined, +): boolean { + return Boolean( + state?.phases.some( + (phase) => + "finish" in phase && + (phase.finish.doneLabelApplied === true || + (phase.workspace.cleanup.state === "removed" && + phase.finish.visualEvidenceValidated === true && + phase.finish.handoffCommentPosted === true && + phase.finish.cleanupHookCompleted === true && + phase.finish.doneLabelEnsured === true)), + ), + ); +} + +export function planningIssueEligible(input: { + issue: IssueSummary; + config: AgentIssueConfig; + state?: PlanningStateV1; + activeOwnedWorkflow: boolean; +}): boolean { + const { issue, config, state, activeOwnedWorkflow } = input; + const lifecycle = lifecycleLabels(config); + const excluded = + config.triagePolicy?.runOnceSelection?.excludedLabels ?? + DEFAULT_TRIAGE_POLICY.runOnceSelection.excludedLabels; + const doneCheckpoint = planningFinishReachedDoneLabelBoundary(state); + const blocked = issue.labels.filter((label) => { + if (activeOwnedWorkflow && label === lifecycle.inProgress) return false; + if (doneCheckpoint && label === lifecycle.done) return false; + return ( + label === lifecycle.done || + label === lifecycle.needsInfo || + excluded.includes(label) + ); + }); + if (blocked.length === 0) return true; + // Ready acknowledges only the lifecycle needs-info blocker for both retries. + return ( + activeOwnedWorkflow && + issue.labels.includes(lifecycle.ready) && + blocked.every((label) => label === lifecycle.needsInfo) + ); +} + +export function hasFinishedPlanningWorkspaceState( + state: Awaited>, +): boolean { + return Boolean( + state?.status === "finished" && + state.implementationStatus === undefined && + (state.specPath || state.planPath) && + (state.branch || state.worktreePath), + ); +} + +/** Identifies legacy recovery that must not coexist with active planning state. */ +export function legacyConflictsWithPlanning( + legacy: Awaited>, +): boolean { + return Boolean( + legacy && + (isResumableRunState(legacy) || + hasFinishedPlanningWorkspaceState(legacy) || + hasBlockedRunRecoveryState(legacy)), + ); +} + +/** Matches the legacy entry point's routing and explicit blocked-retry policy. */ +export function legacyActiveForIssue( + issue: IssueSummary, + config: AgentIssueConfig, + legacy: Awaited>, +): boolean { + if (!legacy) return false; + if (isResumableRunState(legacy) || hasFinishedPlanningWorkspaceState(legacy)) + return true; + return ( + config.issueNumber === issue.number && hasBlockedRunRecoveryState(legacy) + ); +} + +export async function selectRunOnceWorkflow( + issues: readonly IssueSummary[], + config: AgentIssueConfig, + planningState: Pick, +): Promise { + const choices: Array> = + []; + for (const issue of issues) { + if (config.issueNumber !== undefined && issue.number !== config.issueNumber) + continue; + if (issue.state !== "open") continue; + let state: PlanningStateV1 | undefined; + try { + state = await planningState.read(issue.number); + } catch (error) { + return { + kind: "invalid-planning-state", + issue, + reason: planningStateDiagnostic( + error, + planningState.path(issue.number), + ), + }; + } + const legacy = await readRunState(config.runStateDir, issue.number); + const legacyActive = legacyActiveForIssue(issue, config, legacy); + const legacyConflict = legacyConflictsWithPlanning(legacy); + const ordinaryLegacyResume = Boolean( + legacy && + isResumableRunState(legacy) && + issue.labels.includes(lifecycleLabels(config).inProgress), + ); + const blockedLegacyRetryAcknowledged = + !hasBlockedRunRecoveryState(legacy) || + (config.issueNumber === issue.number && + issue.labels.includes(lifecycleLabels(config).ready)); + if (state && active(state) && legacyConflict) + return { + kind: "invalid-planning-state", + issue, + reason: "planning and legacy state are both active", + }; + if ( + state && + active(state) && + planningIssueEligible({ + issue, + config, + state, + activeOwnedWorkflow: true, + }) + ) + choices.push({ kind: "planning", issue, state }); + else if ( + legacyActive && + blockedLegacyRetryAcknowledged && + (ordinaryLegacyResume || + planningIssueEligible({ + issue, + config, + activeOwnedWorkflow: true, + })) && + (issue.labels.includes(lifecycleLabels(config).inProgress) || + issue.labels.includes(lifecycleLabels(config).ready) || + (hasFinishedPlanningWorkspaceState(legacy) && + isActionableWorkflowState( + resolveWorkflowState(issue.labels, { + readyLabel: lifecycleLabels(config).ready, + policy: config.approvalPolicy, + }), + ))) + ) + choices.push({ kind: "legacy", issue }); + else if ( + !hasBlockedRunRecoveryState(legacy) && + issue.labels.includes(config.readyLabel) && + planningIssueEligible({ + issue, + config, + activeOwnedWorkflow: false, + }) + ) + choices.push({ + kind: "fresh-planning", + issue, + initialState: createPlanningState({ + issueNumber: issue.number, + issueTitle: issue.title, + gates: { + specRequired: config.approvalPolicy.specApproval.required, + planRequired: config.approvalPolicy.planApproval.required, + }, + }), + }); + } + if (config.issueNumber !== undefined) { + const selected = choices.find( + (choice) => choice.issue.number === config.issueNumber, + ); + return selected ?? { kind: "none" }; + } + const rank = (choice: Exclude) => + choice.kind === "planning" + ? 0 + : choice.kind === "legacy" + ? 1 + : choice.kind === "fresh-planning" + ? 2 + : -1; + choices.sort( + (left, right) => + rank(left) - rank(right) || + compareIssuesByPriority( + left.issue, + right.issue, + config.triagePolicy?.runOnceSelection.priorityOrder ?? [], + ), + ); + return choices[0] ?? { kind: "none" }; +} diff --git a/src/cli/commands/run-once/pr-cost-publication.test.ts b/src/cli/commands/run-once/pr-cost-publication.test.ts index b3f4103d..b81aa57c 100644 --- a/src/cli/commands/run-once/pr-cost-publication.test.ts +++ b/src/cli/commands/run-once/pr-cost-publication.test.ts @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { PullRequestBodyHostProvider } from "../../../host/types.ts"; -import { publishPrRunCost } from "./pr-cost-publication.ts"; +import { + publishPlanningPrRunCost, + publishPrRunCost, +} from "./pr-cost-publication.ts"; import { renderRunCostSection } from "./pr-cost-summary.ts"; import type { RunCostReport } from "./run-cost.ts"; @@ -11,7 +14,7 @@ const report: RunCostReport = { stage: "pi-plan", models: [ { - model: "gpt-5.5", + model: "gpt", promptTokens: 6, outputTokens: 4, estimatedCostUsd: 0.1, @@ -27,7 +30,7 @@ const report: RunCostReport = { estimatedCostUsd: 0.1, }; -test("publishPrRunCost updates a changed body and skips an already-current body", async () => { +test("publishPrRunCost retains legacy update and unchanged behavior", async () => { const updates: string[] = []; const host: PullRequestBodyHostProvider = { async readPullRequestBody() { @@ -40,28 +43,84 @@ test("publishPrRunCost updates a changed body and skips an already-current body" assert.equal( await publishPrRunCost({ host, - prUrl: "https://github.com/acme/repo/pull/42", + prUrl: "https://github.com/a/b/pull/1", report, }), "updated", ); - assert.equal(updates.length, 1); - assert.ok(updates[0]!.startsWith("Summary\n")); - + assert.ok(updates[0]?.startsWith("Summary\n")); const currentHost: PullRequestBodyHostProvider = { async readPullRequestBody() { return `Summary\n\n${renderRunCostSection(report)}`; }, async updatePullRequestBody() { - assert.fail("already-current body must not be edited"); + assert.fail("unchanged"); }, }; assert.equal( await publishPrRunCost({ host: currentHost, - prUrl: "https://github.com/acme/repo/pull/42", + prUrl: "https://github.com/a/b/pull/1", report, }), "unchanged", ); }); + +test("planning cost publication inserts and replaces before its final marker", async () => { + const marker = + ""; + let body = `Summary\n\nCloses #189\n\n${marker}\n`; + const updates: string[] = []; + const host: PullRequestBodyHostProvider = { + async readPullRequestBody() { + return body; + }, + async updatePullRequestBody(_, next) { + updates.push(next); + body = next; + }, + }; + await publishPlanningPrRunCost({ + host, + prUrl: "https://github.com/a/b/pull/1", + report, + marker, + issueNumber: 189, + }); + await publishPlanningPrRunCost({ + host, + prUrl: "https://github.com/a/b/pull/1", + report, + marker, + issueNumber: 189, + }); + assert.equal(body.trimEnd().split("\n").at(-1), marker); + assert.equal((body.match(/planning-pr-v1/g) ?? []).length, 1); + assert.equal((body.match(/patchmill-run-cost:start/g) ?? []).length, 1); + assert.equal(updates.length, 1); +}); + +test("planning cost publication preserves an effective closing reference", async () => { + const marker = + ""; + const body = `Summary\n\n\nCloses #189\n\n\n${marker}\n`; + const host: PullRequestBodyHostProvider = { + async readPullRequestBody() { + return body; + }, + async updatePullRequestBody() { + assert.fail("must not remove the sole closing reference"); + }, + }; + await assert.rejects( + publishPlanningPrRunCost({ + host, + prUrl: "https://github.com/a/b/pull/1", + report, + marker, + issueNumber: 189, + }), + /effective closing reference/, + ); +}); diff --git a/src/cli/commands/run-once/pr-cost-publication.ts b/src/cli/commands/run-once/pr-cost-publication.ts index 0f6030b3..5b51513a 100644 --- a/src/cli/commands/run-once/pr-cost-publication.ts +++ b/src/cli/commands/run-once/pr-cost-publication.ts @@ -1,6 +1,32 @@ import type { PullRequestBodyHostProvider } from "../../../host/types.ts"; -import { upsertRunCostSection } from "./pr-cost-summary.ts"; +import { + upsertPlanningRunCostSection, + upsertRunCostSection, +} from "./pr-cost-summary.ts"; +import { assertImplementationClosingReference } from "../../../workflow/planning-implementation-body.ts"; +import { parsePlanningPullRequestMarker } from "../../../workflow/planning-pull-request-markers.ts"; import type { RunCostReport } from "./run-cost.ts"; +export async function publishPlanningPrRunCost(options: { + host: PullRequestBodyHostProvider; + prUrl: string; + report: RunCostReport; + marker: string; + issueNumber: number; +}): Promise<"updated" | "unchanged"> { + const body = await options.host.readPullRequestBody(options.prUrl); + const next = upsertPlanningRunCostSection( + body, + options.report, + options.marker, + ); + if (parsePlanningPullRequestMarker(next) === undefined) + throw new Error("Planning marker is invalid after cost publication"); + assertImplementationClosingReference(next, options.issueNumber); + if (next === body) return "unchanged"; + await options.host.updatePullRequestBody(options.prUrl, next); + return "updated"; +} + export async function publishPrRunCost(options: { host: PullRequestBodyHostProvider; prUrl: string; diff --git a/src/cli/commands/run-once/pr-cost-summary.ts b/src/cli/commands/run-once/pr-cost-summary.ts index 23da2fd4..59cb94cb 100644 --- a/src/cli/commands/run-once/pr-cost-summary.ts +++ b/src/cli/commands/run-once/pr-cost-summary.ts @@ -89,3 +89,16 @@ export function upsertRunCostSection( throw new PrCostSummaryError("Malformed Patchmill run-cost markers"); return `${body.slice(0, start)}${section}${body.slice(end + END_MARKER.length)}`; } + +/** Keeps the strict planning ownership marker as the final nonblank line. */ +export function upsertPlanningRunCostSection( + body: string, + report: RunCostReport, + marker: string, +): string { + if (!body.trimEnd().endsWith(marker)) + throw new PrCostSummaryError("Planning marker is not final"); + const prefix = body.slice(0, body.lastIndexOf(marker)).trimEnd(); + const next = upsertRunCostSection(prefix, report).trimEnd(); + return `${next}\n\n${marker}\n`; +} diff --git a/src/cli/commands/run-once/prompts.test.ts b/src/cli/commands/run-once/prompts.test.ts index 6036182c..f50b52bc 100644 --- a/src/cli/commands/run-once/prompts.test.ts +++ b/src/cli/commands/run-once/prompts.test.ts @@ -1099,3 +1099,26 @@ test("buildImplementationRepairPrompt identifies when no unresolved subagent run }); assert.match(prompt, /No unresolved async subagent runs were detected/); }); + +test("buildImplementationPrompt renders an optional planning pull request marker", () => { + const prompt = buildImplementationPrompt({ + issue: { ...issue, number: 189 }, + planPath: "docs/plans/issue-189.md", + branch: "agent/issue-189-implementation", + worktreePath: ".worktrees/issue-189-implementation", + git: { baseBranch: "main", remote: "origin", allowDirectLand: false }, + projectPolicy: examplePolicy, + requiredPullRequestMarker: + "", + }); + assert.match(prompt, /Direct landing is disabled for this planning workflow/); + assert.match(prompt, /Closes #189/); + assert.match( + prompt, + //, + ); + assert.match( + prompt, + /Closes #189\n\n```/, + ); +}); diff --git a/src/cli/commands/run-once/prompts.ts b/src/cli/commands/run-once/prompts.ts index cd5e10d0..c747398b 100644 --- a/src/cli/commands/run-once/prompts.ts +++ b/src/cli/commands/run-once/prompts.ts @@ -1,3 +1,4 @@ +import { renderLandingResultContracts } from "./implementation-landing-prompt.ts"; import type { GitWorktreeStrategyConfig } from "../../../git/types.ts"; import type { PatchmillProjectPolicy } from "../../../policy/types.ts"; import type { PiRepairPromptInput } from "./pi-session-repair.ts"; @@ -75,6 +76,7 @@ export type ImplementationPromptInput = { skills?: PatchmillSkillsConfig; resume?: AgentIssueImplementationResumeContext; developmentEnvironment?: AgentIssueDevelopmentEnvironmentHandoff; + requiredPullRequestMarker?: string | undefined; }; export type DevelopmentEnvironmentPromptInput = { @@ -533,68 +535,6 @@ function renderVisualEvidenceDataSection( return lines.join("\n"); } -function renderPrCreationInstruction( - remote: string, - issueNumber: number, -): string { - return [ - `Push the branch to \`${remote}\` and open a pull request using the repository's configured host tooling. Include \`Closes #${issueNumber}\` in the pull request description/body.`, - "Use a multiline-safe PR body construction path so Markdown line breaks remain real newlines.", - 'For Forgejo/Gitea through `tea`, write the Markdown PR description to a temp file or here-doc first, then pass actual newline characters with `tea pulls create --description "$(cat "$file")"`.', - "Do not pass Markdown containing literal `\\n` escape text as the `tea --description` value.", - "For GitHub through `gh`, use a multiline-safe supported path such as `gh pr create --body-file`.", - "Example PR body shape:", - "```md", - "Summary", - "", - "- Implemented change summary.", - "", - "## Validation", - "", - "- npm test", - "", - "## Reviews", - "", - "- Review completed.", - "", - `Closes #${issueNumber}`, - "```", - ].join("\n"); -} - -function renderBlockedContract(): string { - return `Blocker contract: -If human input is required, stop safely, leave committed work as-is, keep the reason and questions concise enough to post directly as a \`needs-info\` comment, and return this exact JSON object as the final response: -{ - "status": "blocked", - "reason": "short reason", - "questions": [ - { - "question": "question a human must answer", - "recommendedAnswer": "recommended answer and reasoning" - } - ], - "commits": [""], - "validation": ["command and result summary"] -}`; -} - -function renderPrCreatedContract(branch: string): string { - const prUrlLabel = ""; - - return `Successful final response for human-review PR fallback: -Return this exact JSON object after PR handoff succeeds: -{ - "status": "pr-created", - "prUrl": "${prUrlLabel}", - "branch": "${branch}", - "commits": [""], - "validation": ["command and result summary"], - "reviewSummary": "short reviewer/fix summary", - "landingDecision": "PR required: " -}`; -} - function renderSubagentFinalizationGate(): string { return `Patchmill subagent finalization gate: Before returning any terminal result: @@ -605,84 +545,6 @@ Before returning any terminal result: Never return progress prose or promise to continue after the response. This non-interactive Pi invocation has no subsequent turn.`; } -function renderLandingResultContracts(input: { - allowDirectLand: boolean; - hasLandingSkill: boolean; - targetBranch: string; - remote: string; - issueNumber: number; - branch: string; -}): string { - const { - allowDirectLand, - hasLandingSkill, - targetBranch, - remote, - issueNumber, - branch, - } = input; - const prInstruction = renderPrCreationInstruction(remote, issueNumber); - - if (!allowDirectLand) { - return `Landing result contracts: -Direct squash-landing is disabled for this repository. -${prInstruction} -Do not land directly on \`${targetBranch}\`. - -If human review is required: -1. ${prInstruction} -2. Explain briefly why human review is required. -3. Return the \`pr-created\` final response. - -${renderBlockedContract()} - -${renderPrCreatedContract(branch)}`; - } - - if (!hasLandingSkill) { - return `Landing result contracts: -Direct squash-landing requires a configured landing skill for this repository. No landing skill is configured, so use PR fallback and do not land directly on \`${targetBranch}\`. - -If human review is required: -1. ${prInstruction} -2. Explain briefly why human review is required. -3. Return the \`pr-created\` final response. - -${renderBlockedContract()} - -${renderPrCreatedContract(branch)}`; - } - - return `Landing result contracts: -If eligible for direct squash-land: -1. Update local \`${targetBranch}\` from the \`${remote}\` remote. -2. Squash-merge the implementation branch into \`${targetBranch}\`. -3. Create one Conventional Commit that references issue #${issueNumber}. -4. Push \`${targetBranch}\` to \`${remote}\` without force-pushing. -5. Return the \`merged\` final response. - -If human review is required: -1. ${prInstruction} -2. Explain briefly why human review is required. -3. Return the \`pr-created\` final response. - -${renderBlockedContract()} - -Successful final response for direct squash-land: -Return this exact JSON object after \`${targetBranch}\` is pushed successfully: -{ - "status": "merged", - "branch": "${branch}", - "mergeCommit": "", - "commits": [""], - "validation": ["command and result summary"], - "reviewSummary": "short reviewer/fix summary", - "landingDecision": "direct squash-landed: policy-approved change" -} - -${renderPrCreatedContract(branch)}`; -} - function numberedWorkflow(steps: string[]): string { return steps .filter((step) => step.trim().length > 0) @@ -937,6 +799,7 @@ export function buildImplementationPrompt( projectPolicy, resume, developmentEnvironment, + requiredPullRequestMarker, } = input; const skills = input.skills ?? DEFAULT_PATCHMILL_SKILLS; @@ -949,6 +812,14 @@ export function buildImplementationPrompt( renderTestingValueGateStep(), "Follow the visual-change evidence requirements below whenever the issue changes visible UI.", "Apply the landing policy below. Follow its direct-land and PR handoff requirements, or report the exact blocker if PR creation is impossible.", + ...(requiredPullRequestMarker === undefined + ? [] + : [ + "Direct landing is disabled for this planning workflow. Create an implementation pull request whose body has a top-level `Closes #" + + issue.number + + "` line and ends with this exact ownership marker:", + requiredPullRequestMarker, + ]), ]; return `Implement ${formatIssueTarget(projectPolicy)} #${issue.number}: ${issue.title} @@ -991,6 +862,9 @@ ${renderLandingResultContracts({ remote: git.remote, issueNumber: issue.number, branch, + ...(requiredPullRequestMarker === undefined + ? {} + : { requiredPullRequestMarker }), })} `; } diff --git a/src/cli/commands/run-once/result-output.test.ts b/src/cli/commands/run-once/result-output.test.ts index 0947cc3c..262d0692 100644 --- a/src/cli/commands/run-once/result-output.test.ts +++ b/src/cli/commands/run-once/result-output.test.ts @@ -125,6 +125,28 @@ test("writes severity-specific complete JSONL result events", async () => { }, "warning", ], + [ + { + status: "review-pending", + issueNumber: 174, + phase: "spec", + prUrl: "https://example.test/pulls/174", + }, + "warning", + ], + [ + { + status: "stopped", + issueNumber: 174, + reason: "plan-only", + nextPhase: "implementation", + specPath: "docs/specs/174.md", + planPath: "docs/plans/174.md", + branch: "agent/issue-174", + worktreePath: ".worktrees/174", + }, + "warning", + ], [{ status: "error", error: "failed" }, "error"], ]; for (const [result, level] of cases) { @@ -158,6 +180,17 @@ test("preserves the exit-code contract for every status", () => { [{ status: "plan-found", issueNumber: 1, planPath: "plan.md" }, 0], [summary, 0], [{ ...summary, status: "merged", mergeCommit: "abc" }, 0], + [ + { + status: "review-pending", + issueNumber: 1, + phase: "plan", + prUrl: "https://example.test/pulls/1", + }, + 0, + ], + [{ status: "stopped", issueNumber: 1, reason: "plan-only" }, 0], + [{ status: "stopped", issueNumber: 1, reason: "issue-locked" }, 0], [ { status: "approval-required", diff --git a/src/cli/commands/run-once/result-output.ts b/src/cli/commands/run-once/result-output.ts index 4e6cb539..ca9d7404 100644 --- a/src/cli/commands/run-once/result-output.ts +++ b/src/cli/commands/run-once/result-output.ts @@ -20,12 +20,28 @@ export type WriteRunOnceResultOptions = { time?: Date | undefined; }; export function exitCodeForRunOnceResult(summary: RunOnceResultSummary): 0 | 1 { - return summary.status === "approval-required" || - summary.status === "development-environment-not-ready" || - summary.status === "blocked" || - summary.status === "error" - ? 1 - : 0; + switch (summary.status) { + case "no-issue": + case "dry-run": + case "spec-created": + case "spec-found": + case "plan-created": + case "plan-found": + case "pr-created": + case "merged": + case "review-pending": + case "stopped": + return 0; + case "approval-required": + case "development-environment-not-ready": + case "blocked": + case "error": + return 1; + default: { + const exhaustive: never = summary; + return exhaustive; + } + } } export async function writeRunOnceResult( diff --git a/src/cli/commands/run-once/result-summary.test.ts b/src/cli/commands/run-once/result-summary.test.ts index a3237a65..2593708d 100644 --- a/src/cli/commands/run-once/result-summary.test.ts +++ b/src/cli/commands/run-once/result-summary.test.ts @@ -76,3 +76,49 @@ test("summarizeErrorResult preserves aggregate causes and resolved log path", () }, ); }); + +test("summarizes planning review and explicit stops without leaking the issue", () => { + const issue = { + number: 189, + title: "Title", + body: "", + labels: [], + state: "open" as const, + }; + assert.deepEqual( + summarizeResult({ + status: "review-pending", + issue, + phase: "spec", + prUrl: "https://example.test/owner/repo/pull/12", + }), + { + status: "review-pending", + issueNumber: 189, + phase: "spec", + prUrl: "https://example.test/owner/repo/pull/12", + }, + ); + assert.deepEqual( + summarizeResult({ + status: "stopped", + issue, + reason: "plan-only", + nextPhase: "implementation", + specPath: "docs/specs/issue-189.md", + planPath: "docs/plans/issue-189.md", + branch: "agent/issue-189-implementation", + worktreePath: ".worktrees/issue-189-implementation", + }), + { + status: "stopped", + issueNumber: 189, + reason: "plan-only", + nextPhase: "implementation", + specPath: "docs/specs/issue-189.md", + planPath: "docs/plans/issue-189.md", + branch: "agent/issue-189-implementation", + worktreePath: ".worktrees/issue-189-implementation", + }, + ); +}); diff --git a/src/cli/commands/run-once/result-summary.ts b/src/cli/commands/run-once/result-summary.ts index a2211615..4e88654d 100644 --- a/src/cli/commands/run-once/result-summary.ts +++ b/src/cli/commands/run-once/result-summary.ts @@ -53,6 +53,22 @@ export type RunOncePipelineResultSummary = RunOnceResultLog & reviewSummary?: string | undefined; landingDecision?: string | undefined; } + | { + status: "review-pending"; + issueNumber: number; + phase: "spec" | "plan"; + prUrl: string; + } + | { + status: "stopped"; + issueNumber: number; + reason: "plan-only" | "issue-locked"; + nextPhase?: "implementation"; + specPath?: string; + planPath?: string; + branch?: string; + worktreePath?: string; + } | { status: "approval-required"; issueNumber: number; @@ -159,6 +175,30 @@ export function summarizeResult( landingDecision: result.landingDecision, ...withLogPath, }; + case "review-pending": + return { + status: result.status, + issueNumber: result.issue.number, + phase: result.phase, + prUrl: result.prUrl, + ...withLogPath, + }; + case "stopped": + return { + status: result.status, + issueNumber: result.issue.number, + reason: result.reason, + ...(result.nextPhase !== undefined + ? { nextPhase: result.nextPhase } + : {}), + ...(result.specPath !== undefined ? { specPath: result.specPath } : {}), + ...(result.planPath !== undefined ? { planPath: result.planPath } : {}), + ...(result.branch !== undefined ? { branch: result.branch } : {}), + ...(result.worktreePath !== undefined + ? { worktreePath: result.worktreePath } + : {}), + ...withLogPath, + }; case "approval-required": return { status: result.status, diff --git a/src/cli/commands/run-once/selection.ts b/src/cli/commands/run-once/selection.ts index ab60d424..9479914b 100644 --- a/src/cli/commands/run-once/selection.ts +++ b/src/cli/commands/run-once/selection.ts @@ -137,16 +137,25 @@ function rejectionForIssue( }; } +export function compareIssuesByPriority( + left: IssueSummary, + right: IssueSummary, + priorityLabels: readonly string[], +): number { + const priorityDifference = + priorityRank(left.labels, priorityLabels) - + priorityRank(right.labels, priorityLabels); + return priorityDifference !== 0 + ? priorityDifference + : left.number - right.number; +} + function compareIssues( left: IssueSummary, right: IssueSummary, options: ResolvedIssueSelectionOptions, ): number { - const priorityDifference = - priorityRank(left.labels, options.priorityLabels) - - priorityRank(right.labels, options.priorityLabels); - if (priorityDifference !== 0) return priorityDifference; - return left.number - right.number; + return compareIssuesByPriority(left, right, options.priorityLabels); } export function selectIssueWithDiagnostics( diff --git a/src/cli/commands/run-once/terminal-result.test.ts b/src/cli/commands/run-once/terminal-result.test.ts index bc7e7052..6842515c 100644 --- a/src/cli/commands/run-once/terminal-result.test.ts +++ b/src/cli/commands/run-once/terminal-result.test.ts @@ -65,6 +65,26 @@ test("renders ordered sections, vertical arrays, and header metrics", () => { assert.match(output, /^ {2}• def456$/mu); }); +test("renders stopped reason before preserved workspace and artifact details", () => { + const output = formatTerminalResult( + { + status: "stopped", + issueNumber: 189, + reason: "plan-only", + nextPhase: "implementation", + specPath: "docs/specs/issue-189.md", + planPath: "docs/plans/issue-189.md", + branch: "agent/issue-189-implementation", + worktreePath: ".worktrees/issue-189-implementation", + }, + { width: 100, color: false }, + ); + const reason = output.indexOf("Reason:"); + assert.ok(reason >= 0); + assert.ok(reason < output.indexOf("Issue and workspace")); + assert.ok(reason < output.indexOf("Artifacts")); +}); + test("wraps narrow output without truncating dynamic values", () => { const output = formatTerminalResult(summary, { width: 24, color: false }); for (const line of output.split("\n")) @@ -134,6 +154,19 @@ test("maps every status to its visible severity marker and label", () => { }, "✓ Merged", ], + [ + { + status: "review-pending", + issueNumber: 1, + phase: "spec", + prUrl: "https://example.test/pulls/1", + }, + "! Review pending", + ], + [ + { status: "stopped", issueNumber: 1, reason: "issue-locked" }, + "! Stopped", + ], [ { status: "approval-required", diff --git a/src/cli/commands/run-once/terminal-result.ts b/src/cli/commands/run-once/terminal-result.ts index 9eeafbe2..bb810803 100644 --- a/src/cli/commands/run-once/terminal-result.ts +++ b/src/cli/commands/run-once/terminal-result.ts @@ -28,6 +28,8 @@ const STATUS = { "plan-found": { label: "Implementation plan found", severity: "success" }, "pr-created": { label: "PR created", severity: "success" }, merged: { label: "Merged", severity: "success" }, + "review-pending": { label: "Review pending", severity: "warning" }, + stopped: { label: "Stopped", severity: "warning" }, "approval-required": { label: "Approval required", severity: "warning" }, "development-environment-not-ready": { label: "Development environment not ready", @@ -57,6 +59,21 @@ export function formatTerminalResult( options: TerminalResultOptions, ): string { const sections: TerminalSection[] = []; + if (summary.status === "stopped") + sections.push({ + heading: "Stopped", + blocks: [ + { + kind: "fields", + fields: [ + { label: "Reason", value: value(summary.reason) }, + ...(summary.nextPhase !== undefined + ? [{ label: "Next phase", value: value(summary.nextPhase) }] + : []), + ], + }, + ], + }); if ("prUrl" in summary && nonblank(summary.prUrl)) sections.push({ heading: "Pull request", @@ -113,6 +130,16 @@ export function formatTerminalResult( }, ], }); + if (summary.status === "review-pending") + sections.push({ + heading: "Review pending", + blocks: [ + { + kind: "fields", + fields: [{ label: "Phase", value: value(summary.phase) }], + }, + ], + }); if (summary.status === "approval-required") sections.push({ heading: "Approval", diff --git a/src/cli/commands/run-once/types.ts b/src/cli/commands/run-once/types.ts index bb15da38..f8abcab8 100644 --- a/src/cli/commands/run-once/types.ts +++ b/src/cli/commands/run-once/types.ts @@ -265,6 +265,23 @@ export type AgentIssuePrCreatedResult = { visualEvidence?: AgentIssueVisualEvidence[] | undefined; }; +export type AgentIssueReviewPendingResult = { + status: "review-pending"; + issue: IssueSummary; + phase: "spec" | "plan"; + prUrl: string; +}; +export type AgentIssueStoppedResult = { + status: "stopped"; + issue: IssueSummary; + reason: "plan-only" | "issue-locked"; + nextPhase?: "implementation" | undefined; + specPath?: string | undefined; + planPath?: string | undefined; + branch?: string | undefined; + worktreePath?: string | undefined; +}; + export type AgentIssueMergedResult = { status: "merged"; branch: string; @@ -303,6 +320,8 @@ export type AgentIssuePipelineResult = AgentIssuePipelineResultLog & planPath: string; } | AgentIssueApprovalRequiredResult + | AgentIssueReviewPendingResult + | AgentIssueStoppedResult | { status: "development-environment-not-ready"; issue: IssueSummary; diff --git a/src/cli/commands/run/reset/reset.ts b/src/cli/commands/run/reset/reset.ts index 11dd24e9..72f18d00 100644 --- a/src/cli/commands/run/reset/reset.ts +++ b/src/cli/commands/run/reset/reset.ts @@ -5,9 +5,9 @@ import { expectedIssueWorkspace, } from "../../run-once/pipeline-workspace.ts"; import { - runOneIssueAfterReset, + runLegacyOneIssueAfterReset, type RunOneIssueOptions, -} from "../../run-once/pipeline.ts"; +} from "../../run-once/pipeline-legacy.ts"; import { readRunStateSnapshot, validateRecoveryRunState, @@ -126,7 +126,7 @@ export type ResetIssueRunDependencies = { createHost: typeof createRunOnceHostProvider; archiveRecovery: typeof archiveRunRecovery; executeMutation: typeof executeRunRecoveryMutation; - runPipeline: typeof runOneIssueAfterReset; + runPipeline: typeof runLegacyOneIssueAfterReset; }; export async function resetIssueRun( runner: CommandRunner, @@ -243,7 +243,7 @@ export async function resetIssueRun( let pipelineResult; try { pipelineResult = await ( - dependencies.runPipeline ?? runOneIssueAfterReset + dependencies.runPipeline ?? runLegacyOneIssueAfterReset )(runner, config, options, { lease, seed: decision.seed }); } catch (error) { throw new ResetIssueRunRecoveryError( diff --git a/src/host/forgejo-pr-body.test.ts b/src/host/forgejo-pr-body.test.ts index 0de50af9..d3605d18 100644 --- a/src/host/forgejo-pr-body.test.ts +++ b/src/host/forgejo-pr-body.test.ts @@ -40,7 +40,7 @@ test("Forgejo PR body adapter reads the configured PR body", async () => { code: 0, stdout: JSON.stringify({ body: "Summary\n", - html_url: "https://git.example/acme/repo/pulls/42", + html_url: "https://GIT.EXAMPLE/Acme/Repo/pulls/42/", }), stderr: "", }; diff --git a/src/host/github-pr-body.test.ts b/src/host/github-pr-body.test.ts index 3f23f1d0..9a60fda4 100644 --- a/src/host/github-pr-body.test.ts +++ b/src/host/github-pr-body.test.ts @@ -20,7 +20,7 @@ test("GitHub PR body adapter reads the configured PR body", async () => { code: 0, stdout: JSON.stringify({ body: "Summary\n", - url: "https://github.com/acme/repo/pull/42", + url: "https://GITHUB.COM/Acme/Repo/pull/42/", }), stderr: "", }; diff --git a/src/host/pull-request-reference.test.ts b/src/host/pull-request-reference.test.ts index 88e2efbd..3f6f1663 100644 --- a/src/host/pull-request-reference.test.ts +++ b/src/host/pull-request-reference.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + parseCanonicalPullRequestUrl, parsePullRequestUrl, pullRequestNumber, sameCanonicalUrl, @@ -59,6 +60,74 @@ test("parsePullRequestUrl exposes canonical URL parts without accepting unsafe f assert.throws(() => parsePullRequestUrl(value, "pull")); }); +test("parseCanonicalPullRequestUrl normalizes default ports and preserves Forgejo ports", () => { + const github = { + provider: "github-gh" as const, + host: "github.com", + owner: "acme", + repository: "repo", + }; + assert.deepEqual( + parseCanonicalPullRequestUrl( + "https://GITHUB.COM:443/Acme/Repo/pull/42/", + github, + ), + { + reference: { targetRepository: github, number: 42 }, + url: "https://github.com/acme/repo/pull/42", + }, + ); + const forgejo = { + provider: "forgejo-tea" as const, + host: "forge.example:8443", + owner: "acme", + repository: "repo", + }; + assert.equal( + parseCanonicalPullRequestUrl( + "https://FORGE.EXAMPLE:8443/Acme/Repo/pulls/42/", + forgejo, + )?.url, + "https://forge.example:8443/acme/repo/pulls/42", + ); + assert.equal( + parseCanonicalPullRequestUrl( + "https://github.com/acme/repo/pulls/42", + github, + ), + undefined, + ); + assert.equal( + parseCanonicalPullRequestUrl( + "https://github.com/other/repo/pull/42", + github, + ), + undefined, + ); + assert.equal( + parseCanonicalPullRequestUrl("https://github.com/acme/repo/pull/43", github) + ?.reference.number, + 43, + ); +}); + +test("sameCanonicalUrl accepts canonical case-only repository URLs", () => { + assert.equal( + sameCanonicalUrl( + "https://github.com/acme/repo/pull/42", + "https://GITHUB.COM/Acme/Repo/pull/42/", + ), + true, + ); + assert.equal( + sameCanonicalUrl( + "https://forge.example:8443/acme/repo/pulls/42", + "https://FORGE.EXAMPLE:8443/Acme/Repo/pulls/42/", + ), + true, + ); +}); + test("sameCanonicalUrl rejects a different repository but permits one trailing slash", () => { assert.equal( sameCanonicalUrl( diff --git a/src/host/pull-request-reference.ts b/src/host/pull-request-reference.ts index d4e7500c..02d93dcc 100644 --- a/src/host/pull-request-reference.ts +++ b/src/host/pull-request-reference.ts @@ -1,3 +1,5 @@ +import { sameRepositoryIdentity } from "./pull-requests.ts"; + export type ParsedPullRequestUrl = Readonly<{ protocol: "http:" | "https:"; hostname: string; @@ -55,14 +57,98 @@ export function pullRequestNumber(prUrl: string, pathSegment: string): number { return parsePullRequestUrl(prUrl, pathSegment).number; } +export type CanonicalPullRequestUrl = Readonly<{ + reference: import("./pull-requests.ts").PullRequestReference; + url: string; +}>; + +/** Parses and canonicalizes a provider URL for one durable repository identity. */ +export function parseCanonicalPullRequestUrl( + prUrl: string, + targetRepository: import("./pull-requests.ts").RepositoryIdentity, +): CanonicalPullRequestUrl | undefined { + const segment = targetRepository.provider === "github-gh" ? "pull" : "pulls"; + try { + const parsed = parsePullRequestUrl(prUrl, segment); + const parsedAuthority = `${parsed.hostname}${ + parsed.port === "" ? "" : `:${parsed.port}` + }`; + if ( + !sameRepositoryIdentity( + { + provider: targetRepository.provider, + host: parsedAuthority, + owner: parsed.owner, + repository: parsed.repository, + }, + targetRepository, + ) + ) + return undefined; + const reference = { targetRepository, number: parsed.number }; + return { + reference, + url: `${parsed.protocol}//${targetRepository.host.toLowerCase()}/${targetRepository.owner.toLowerCase()}/${targetRepository.repository.toLowerCase()}/${segment}/${parsed.number}`, + }; + } catch { + return undefined; + } +} + +/** Canonicalizes one provider URL only when it identifies its durable reference. */ +export function canonicalPullRequestUrl( + prUrl: string, + reference: import("./pull-requests.ts").PullRequestReference, +): string | undefined { + const canonical = parseCanonicalPullRequestUrl( + prUrl, + reference.targetRepository, + ); + return canonical?.reference.number === reference.number + ? canonical.url + : undefined; +} + +export function pullRequestUrlMatchesReference( + prUrl: string, + reference: import("./pull-requests.ts").PullRequestReference, +): boolean { + return canonicalPullRequestUrl(prUrl, reference) !== undefined; +} + export function sameCanonicalUrl(left: string, right: string): boolean { - const a = parse(left), - b = parse(right); - if (!a || !b) return false; - const path = (url: URL) => url.pathname.replace(/\/$/u, ""); + const parseReference = (url: string) => { + for (const [provider, segment] of [ + ["github-gh", "pull"], + ["forgejo-tea", "pulls"], + ] as const) { + try { + return { provider, parsed: parsePullRequestUrl(url, segment) }; + } catch { + // Try the other supported provider shape. + } + } + return undefined; + }; + const a = parseReference(left); + const b = parseReference(right); + if (!a || !b || a.provider !== b.provider) return false; return ( - a.protocol === b.protocol && - a.host.toLowerCase() === b.host.toLowerCase() && - path(a) === path(b) + a.parsed.protocol === b.parsed.protocol && + a.parsed.number === b.parsed.number && + sameRepositoryIdentity( + { + provider: a.provider, + host: `${a.parsed.hostname}${a.parsed.port ? `:${a.parsed.port}` : ""}`, + owner: a.parsed.owner, + repository: a.parsed.repository, + }, + { + provider: b.provider, + host: `${b.parsed.hostname}${b.parsed.port ? `:${b.parsed.port}` : ""}`, + owner: b.parsed.owner, + repository: b.parsed.repository, + }, + ) ); } diff --git a/src/pi/hooks.ts b/src/pi/hooks.ts index 33999b3e..50542181 100644 --- a/src/pi/hooks.ts +++ b/src/pi/hooks.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { resolve } from "node:path"; import type { CommandRunner } from "../cli/commands/triage/types.ts"; export type PiHookResult = { @@ -35,7 +35,7 @@ export async function runCleanupHookScript( } const result = await runner.run("bash", [cleanupHook], { - cwd: join(repoRoot, worktreePath), + cwd: resolve(repoRoot, worktreePath), }); if (result.code !== 0) { diff --git a/src/workflow/planning-implementation-body.test.ts b/src/workflow/planning-implementation-body.test.ts new file mode 100644 index 00000000..f300763f --- /dev/null +++ b/src/workflow/planning-implementation-body.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertImplementationClosingReference, + PlanningImplementationBodyError, +} from "./planning-implementation-body.ts"; + +test("requires one effective top-level implementation closing reference", () => { + assert.doesNotThrow(() => + assertImplementationClosingReference("Summary\r\n\r\nCloses #189", 189), + ); + for (const body of [ + "```md\nCloses #189\n```", + "```lang~x\nCloses #189\n```", + " ```md\nCloses #189", + " ~~~md\nCloses #189", + "> Closes #189", + "> Example closing syntax:\nCloses #189", + " > Example closing syntax:\nCloses #189", + " Closes #189", + "- Closes #189", + " - Example closing syntax:\nCloses #189", + "1. Example closing syntax:\nCloses #189", + "1) Example closing syntax:\nCloses #189", + "- Example closing syntax:\nCloses #189", + "", + "\n", + "", + "
\nCloses #189\n
", + "
example\nCloses #189\n
", + "
\nCloses #189", + "\nCloses #189", + "
\nCloses #189", + "\nCloses #189", + "\nCloses #189", + "\nCloses #189", + "\nCloses #189", + "
example\nCloses #189\n
\nCloses #189", + ]) + assert.doesNotThrow(() => assertImplementationClosingReference(body, 189)); +}); diff --git a/src/workflow/planning-implementation-body.ts b/src/workflow/planning-implementation-body.ts new file mode 100644 index 00000000..7d62a8a9 --- /dev/null +++ b/src/workflow/planning-implementation-body.ts @@ -0,0 +1,53 @@ +import { markdownTopLevelLines } from "./planning-markdown-top-level-lines.ts"; + +export class PlanningImplementationBodyError extends Error { + readonly reason = "closing-reference" as const; + + constructor() { + super( + "Implementation pull request is missing an effective closing reference", + ); + this.name = "PlanningImplementationBodyError"; + } +} + +function thematicBreak(line: string): boolean { + if (/^(?:[ ]{4}|\t)/u.test(line)) return false; + const markers = line.replace(/^[ ]{0,3}/u, "").replace(/[ \t]/gu, ""); + return /^(?:\*{3,}|-{3,}|_{3,})$/u.test(markers); +} + +function lazyContainer(line: string): boolean { + if (thematicBreak(line)) return false; + return /^(?:[ ]{0,3}>|[ ]{0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+)/u.test(line); +} + +function effectiveTopLevelLines(body: string): readonly string[] { + const output: string[] = []; + let inLazyContainer = false; + for (const { line } of markdownTopLevelLines(body)) { + if (line.trim() === "") { + inLazyContainer = false; + output.push(line); + continue; + } + if (/^(?:[ \t]{4}|\t)/u.test(line)) continue; + if (lazyContainer(line)) { + inLazyContainer = true; + continue; + } + if (inLazyContainer) continue; + output.push(line); + } + return output; +} + +export function assertImplementationClosingReference( + body: string, + issueNumber: number, +): void { + if (!Number.isSafeInteger(issueNumber) || issueNumber < 1) + throw new RangeError("Issue number must be a positive safe integer"); + if (!effectiveTopLevelLines(body).includes(`Closes #${issueNumber}`)) + throw new PlanningImplementationBodyError(); +} diff --git a/src/workflow/planning-markdown-top-level-lines.ts b/src/workflow/planning-markdown-top-level-lines.ts new file mode 100644 index 00000000..3ba7a011 --- /dev/null +++ b/src/workflow/planning-markdown-top-level-lines.ts @@ -0,0 +1,99 @@ +export type MarkdownTopLevelLine = Readonly<{ + line: string; + index: number; +}>; + +type RawHtmlBlock = + | Readonly<{ kind: "blank" }> + | Readonly<{ kind: "terminator"; terminator: RegExp }>; + +const openingFencePattern = /^(`{3,}|~{3,})(.*)$/u; +const closingFencePattern = /^(`+|~+)[ \t]*$/u; +const rawTextTagPattern = + /^<(?script|style|pre|textarea)(?=[ \t/>]|$)[^>]*>/iu; +const blockTagPattern = + /^<(?:(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|ol|p|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul))(?=[ \t/>]|$)[^>]*>/iu; +const genericHtmlTagPattern = + /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[ \t]+[^<>]*)?\/?>[ \t]*$/u; + +function openingFence(line: string): string | undefined { + const match = line.match(openingFencePattern); + if (match === null) return undefined; + const delimiter = match[1]!; + return delimiter[0] === "`" && match[2]!.includes("`") + ? undefined + : delimiter; +} + +function rawHtmlBlock(line: string): RawHtmlBlock | undefined { + const rawTextTag = rawTextTagPattern.exec(line)?.groups?.tag; + if (rawTextTag !== undefined) + return { + kind: "terminator", + terminator: new RegExp(``, "iu"), + }; + if (line.startsWith("/u }; + if (line.startsWith("/u }; + if (line.startsWith("/u }; + if (/^/u }; + if (blockTagPattern.test(line) || genericHtmlTagPattern.test(line)) + return { kind: "blank" }; + return undefined; +} + +function endsRawHtmlBlock(block: RawHtmlBlock, line: string): boolean { + return block.kind === "blank" + ? line.trim() === "" + : block.terminator.test(line); +} + +/** Returns source lines that are outside fenced code and multiline raw HTML blocks. */ +export function markdownTopLevelLines( + body: string, + options: Readonly<{ includeStandaloneLine?: (line: string) => boolean }> = {}, +): readonly MarkdownTopLevelLine[] { + const output: MarkdownTopLevelLine[] = []; + const lines = body.replaceAll("\r\n", "\n").split("\n"); + let fence: string | undefined; + let htmlBlock: RawHtmlBlock | undefined; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + const topLevel = line.replace(/^[ ]{0,3}/u, ""); + if (fence !== undefined) { + const closingFence = topLevel.match(closingFencePattern)?.[1]; + if ( + closingFence !== undefined && + closingFence[0] === fence[0] && + closingFence.length >= fence.length + ) + fence = undefined; + continue; + } + if (htmlBlock !== undefined) { + if (endsRawHtmlBlock(htmlBlock, topLevel)) htmlBlock = undefined; + continue; + } + const opening = openingFence(topLevel); + if (opening !== undefined) { + fence = opening; + continue; + } + if (options.includeStandaloneLine?.(line) === true) { + output.push({ line, index }); + continue; + } + const rawBlock = rawHtmlBlock(topLevel); + if (rawBlock !== undefined) { + if (!endsRawHtmlBlock(rawBlock, topLevel)) { + htmlBlock = rawBlock; + continue; + } + if (rawBlock.kind === "terminator") output.push({ line, index }); + continue; + } + output.push({ line, index }); + } + return output; +} diff --git a/src/workflow/planning-phase-replacement.ts b/src/workflow/planning-phase-replacement.ts new file mode 100644 index 00000000..c7cba4aa --- /dev/null +++ b/src/workflow/planning-phase-replacement.ts @@ -0,0 +1,32 @@ +import type { PlanningIssueLock } from "./planning-issue-lock.ts"; +import type { PlanningStateStore } from "./planning-state-store.ts"; +import type { + PlanningPhaseStateV1, + PlanningStateV1, +} from "./planning-state-types.ts"; + +/** Replaces exactly one phase using the single durable planning revision edge. */ +export async function replacePlanningPhase(input: { + stateStore: Pick; + lock: PlanningIssueLock; + state: PlanningStateV1; + phaseIndex: number; + phase: PlanningPhaseStateV1; + now?: () => Date; +}): Promise { + const { state } = input; + return input.stateStore.replace({ + issueNumber: state.issueNumber, + expectedRunId: state.runId, + expectedRevision: state.revision, + lock: input.lock, + next: { + ...state, + revision: state.revision + 1, + updatedAt: (input.now ?? (() => new Date()))().toISOString(), + phases: state.phases.map((item, index) => + index === input.phaseIndex ? input.phase : item, + ), + }, + }); +} diff --git a/src/workflow/planning-publication-repositories.test.ts b/src/workflow/planning-publication-repositories.test.ts index e83ace0d..00254855 100644 --- a/src/workflow/planning-publication-repositories.test.ts +++ b/src/workflow/planning-publication-repositories.test.ts @@ -16,7 +16,8 @@ import { } from "./planning-pull-request-validation.ts"; import { renderPlanningPullRequestMarker } from "./planning-pull-request-markers.ts"; -const oid = "a".repeat(40); +const oid = "b".repeat(40); +const baseOid = "a".repeat(40); const github = { provider: "github-gh" as const, host: "github.com", @@ -98,7 +99,7 @@ test("state parsing and pull request validation agree on publication repositorie base: { remote: "origin", baseBranch: "main", - baseOid: oid, + baseOid, artifactCandidates: { spec: ["docs/specs/a.md"], plan: [] }, }, workspace: { @@ -110,7 +111,7 @@ test("state parsing and pull request validation agree on publication repositorie }, remote: "origin", baseBranch: "main", - baseOid: oid, + baseOid, headOid: oid, cleanup: { state: "ready" }, }, diff --git a/src/workflow/planning-pull-request-markers.ts b/src/workflow/planning-pull-request-markers.ts index a02faeed..a79adb2b 100644 --- a/src/workflow/planning-pull-request-markers.ts +++ b/src/workflow/planning-pull-request-markers.ts @@ -1,3 +1,5 @@ +import { markdownTopLevelLines } from "./planning-markdown-top-level-lines.ts"; + export const PLANNING_PR_WORKFLOW_VERSION = "planning-pr-v1" as const; export type PlanningPhaseKind = "spec" | "plan" | "implementation"; @@ -31,44 +33,15 @@ export function renderPlanningPullRequestMarker(input: { const markerPrefix = "$/u; -const openingFencePattern = /^(`{3,}|~{3,})(.*)$/u; -const closingFencePattern = /^(`+|~+)[ \t]*$/u; type MarkerLine = { line: string; index: number }; -function openingFence(line: string): string | undefined { - const match = line.match(openingFencePattern); - if (match === null) return undefined; - const delimiter = match[1]!; - return delimiter[0] === "`" && match[2]!.includes("`") - ? undefined - : delimiter; -} - -function topLevelMarkerLines(lines: readonly string[]): MarkerLine[] { - const markers: MarkerLine[] = []; - let fence: string | undefined; - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]!; - if (fence !== undefined) { - const closingFence = line.match(closingFencePattern)?.[1]; - if ( - closingFence !== undefined && - closingFence[0] === fence[0] && - closingFence.length >= fence.length - ) { - fence = undefined; - } - continue; - } - const opener = openingFence(line); - if (opener !== undefined) { - fence = opener; - } else if (line.startsWith(markerPrefix)) { - markers.push({ line, index }); - } - } - return markers; +function topLevelMarkerLines(body: string): MarkerLine[] { + return markdownTopLevelLines(body, { + includeStandaloneLine: (line) => line.startsWith(markerPrefix), + }).flatMap(({ line, index }) => + line.startsWith(markerPrefix) ? [{ line, index }] : [], + ); } function finalNonblankLineIndex(lines: readonly string[]): number { @@ -85,8 +58,8 @@ export function parsePlanningPullRequestMarker(body: string): phase: PlanningPhaseKind; } | undefined { - const lines = body.split("\n"); - const markers = topLevelMarkerLines(lines); + const lines = body.replaceAll("\r\n", "\n").split("\n"); + const markers = topLevelMarkerLines(body); if (markers.length === 0) return undefined; if (markers.length !== 1) { throw new PlanningPullRequestMarkerError("multiple markers", body); diff --git a/src/workflow/planning-pull-request-validation.test.ts b/src/workflow/planning-pull-request-validation.test.ts index dfb22e10..308014fb 100644 --- a/src/workflow/planning-pull-request-validation.test.ts +++ b/src/workflow/planning-pull-request-validation.test.ts @@ -42,6 +42,25 @@ test("accepts exactly matching owned planning pull requests", () => { 188, ); }); +test("rejects an implementation marker in a three-space indented code fence", () => { + assert.throws( + () => + validatePlanningPullRequestSummary({ + summary: { + ...summary, + headBranch: "planning/implementation", + body: `Closes #188\n\n \`\`\`md\n${renderPlanningPullRequestMarker({ issueNumber: 188, phase: "implementation" })}\n \`\`\``, + }, + issueNumber: 188, + phase: "implementation", + publication: { ...publication, headBranch: "planning/implementation" }, + }), + (error: unknown) => + error instanceof PlanningPullRequestValidationError && + error.reason === "ownership-marker", + ); +}); + test("rejects a marker mismatch without disclosing the body", () => { const secret = "https://token@example.test/private"; assert.throws( @@ -262,3 +281,17 @@ test("rejects expected-reference and URL shape mismatches without exposing confi ); } }); + +test("accepts implementation ownership identity without changing status semantics", () => { + assert.doesNotThrow(() => + validatePlanningPullRequestSummary({ + summary: { + ...summary, + body: `Summary\n\n${renderPlanningPullRequestMarker({ issueNumber: 188, phase: "implementation" })}`, + }, + issueNumber: 188, + phase: "implementation", + publication, + }), + ); +}); diff --git a/src/workflow/planning-pull-request-validation.ts b/src/workflow/planning-pull-request-validation.ts index 27cc927a..f606befd 100644 --- a/src/workflow/planning-pull-request-validation.ts +++ b/src/workflow/planning-pull-request-validation.ts @@ -3,12 +3,13 @@ import { type PullRequestReference, type PullRequestSummary, } from "../host/pull-requests.ts"; -import { parsePullRequestUrl } from "../host/pull-request-reference.ts"; +import { parseCanonicalPullRequestUrl } from "../host/pull-request-reference.ts"; import { parsePlanningPullRequestMarker, PlanningPullRequestMarkerError, } from "./planning-pull-request-markers.ts"; import type { PlanningPublicationEvidence } from "./planning-state-types.ts"; +import type { PlanningPhaseKind } from "./planning-pull-request-markers.ts"; import { assertPlanningPublicationRepositories as assertPublicationRepositories, PlanningPublicationRepositoryError, @@ -44,7 +45,7 @@ export function assertPlanningPublicationRepositories(input: { export function validatePlanningPullRequestSummary(input: { summary: PullRequestSummary; issueNumber: number; - phase: "spec" | "plan"; + phase: PlanningPhaseKind; publication: PlanningPublicationEvidence; expectedReference?: PullRequestReference; }): ValidatedPlanningPullRequest { @@ -75,23 +76,16 @@ export function validatePlanningPullRequestSummary(input: { marker.phase !== input.phase ) fail("ownership-marker"); - const segment = - summary.targetRepository.provider === "github-gh" ? "pull" : "pulls"; - const parsed = parsePullRequestUrl(summary.url, segment); + const canonical = parseCanonicalPullRequestUrl( + summary.url, + summary.targetRepository, + ); if ( - `${parsed.hostname}${parsed.port === "" ? "" : `:${parsed.port}`}`.toLowerCase() !== - summary.targetRepository.host.toLowerCase() || - parsed.owner.toLowerCase() !== - summary.targetRepository.owner.toLowerCase() || - parsed.repository.toLowerCase() !== - summary.targetRepository.repository.toLowerCase() || - parsed.number !== summary.number + canonical === undefined || + canonical.reference.number !== summary.number ) fail("url"); - const reference = { - targetRepository: summary.targetRepository, - number: summary.number, - }; + const reference = canonical.reference; if ( input.expectedReference !== undefined && (!sameRepositoryIdentity( @@ -101,7 +95,7 @@ export function validatePlanningPullRequestSummary(input: { reference.number !== input.expectedReference.number) ) fail("reference"); - return { summary, reference, url: summary.url }; + return { summary, reference, url: canonical.url }; } catch (error) { if (error instanceof PlanningPullRequestValidationError) throw error; if (error instanceof PlanningPullRequestMarkerError) diff --git a/src/workflow/planning-pull-requests.test.ts b/src/workflow/planning-pull-requests.test.ts index 697f4e1c..f1e0e43b 100644 --- a/src/workflow/planning-pull-requests.test.ts +++ b/src/workflow/planning-pull-requests.test.ts @@ -130,6 +130,21 @@ test("planning pull request marker renders and parses exact identity", () => { }); }); +test("marker parser accepts CRLF pull request bodies", () => { + const marker = renderPlanningPullRequestMarker({ + issueNumber: 184, + phase: "implementation", + }); + assert.deepEqual( + parsePlanningPullRequestMarker(`Summary\r\n\r\n${marker}\r\n`), + { + workflowVersion: "planning-pr-v1", + issueNumber: 184, + phase: "implementation", + }, + ); +}); + test("marker parser returns undefined when no planning marker exists", () => { assert.equal(parsePlanningPullRequestMarker("Refs #184"), undefined); }); @@ -220,6 +235,40 @@ test("marker parser keeps markers inside a top-level fence after container text" ); }); +test("marker parser ignores markers nested in raw HTML blocks", () => { + const marker = renderPlanningPullRequestMarker({ + issueNumber: 184, + phase: "implementation", + }); + + for (const body of [ + `\n${marker}`, + `
\ncontent\n\n${marker}`, + `\n${marker}`, + `\n${marker}`, + `\n${marker}`, + `
example\n
\n${marker}`, + ]) { + assert.deepEqual(parsePlanningPullRequestMarker(body), { + workflowVersion: "planning-pr-v1", + issueNumber: 184, + phase: "implementation", + }); + } +}); + test("marker parser rejects duplicate and invalid planning markers", () => { const invalidBodies = [ [ diff --git a/src/workflow/planning-state-codec.ts b/src/workflow/planning-state-codec.ts new file mode 100644 index 00000000..c48842b3 --- /dev/null +++ b/src/workflow/planning-state-codec.ts @@ -0,0 +1,120 @@ +import { + isPlanningArtifactPath, + isPlanningBranch, + isPlanningSingleLine, + planningOid, +} from "../git/planning-git-validation.ts"; +import type { PlanningPhaseKind } from "./planning-pull-request-markers.ts"; + +export const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; +export class PlanningStateValidationError extends Error { + readonly reason: string; + readonly path: string; + readonly statePath?: string; + constructor(reason: string, path: string, statePath?: string) { + super(`Planning state is invalid: ${reason} at ${path}`); + this.name = "PlanningStateValidationError"; + this.reason = reason; + this.path = path; + if (statePath !== undefined) this.statePath = statePath; + } +} +export const fail = (reason: string, path: string): never => { + throw new PlanningStateValidationError(reason, path); +}; +export const object = ( + value: unknown, + keys: readonly string[], + path: string, +): Record => objectWithOptionalKeys(value, keys, [], path); +export const objectWithOptionalKeys = ( + value: unknown, + requiredKeys: readonly string[], + optionalKeys: readonly string[], + path: string, +): Record => { + if (value === null || typeof value !== "object" || Array.isArray(value)) + fail("expected-object", path); + const result = value as Record; + const allowedKeys = [...requiredKeys, ...optionalKeys]; + for (const key of Object.keys(result)) + if (!allowedKeys.includes(key)) fail("unknown-key", `${path}.${key}`); + for (const key of requiredKeys) + if (!(key in result)) fail("missing-key", `${path}.${key}`); + return result; +}; +export const string = ( + value: unknown, + path: string, + reason = "invalid-string", +): string => (typeof value === "string" ? value : fail(reason, path)); +export const positive = (value: unknown, path: string): number => + Number.isSafeInteger(value) && (value as number) > 0 + ? (value as number) + : fail("invalid-positive-integer", path); +export const nonnegative = (value: unknown, path: string): number => + Number.isSafeInteger(value) && (value as number) >= 0 + ? (value as number) + : fail("invalid-nonnegative-integer", path); +export const oid = (value: unknown, path: string): string => { + const parsed = string(value, path); + return planningOid.test(parsed) ? parsed : fail("invalid-oid", path); +}; +export const singleLine = (value: unknown, path: string): string => { + const parsed = string(value, path); + return isPlanningSingleLine(parsed) ? parsed : fail("invalid-string", path); +}; +export const nonblankSingleLine = (value: unknown, path: string): string => { + const parsed = singleLine(value, path); + return parsed.trim().length > 0 ? parsed : fail("invalid-string", path); +}; +export const branch = (value: unknown, path: string): string => { + const parsed = string(value, path); + return isPlanningBranch(parsed) ? parsed : fail("invalid-branch", path); +}; +export const artifactPath = (value: unknown, path: string): string => { + const parsed = string(value, path); + return isPlanningArtifactPath(parsed) ? parsed : fail("invalid-path", path); +}; +export function timestamp(value: unknown, path: string): string { + const parsed = string(value, path); + return ISO_TIMESTAMP.test(parsed) && + !Number.isNaN(Date.parse(parsed)) && + new Date(parsed).toISOString() === parsed + ? parsed + : fail("invalid-timestamp", path); +} +export function phase(value: unknown, path: string): PlanningPhaseKind { + const parsed = string(value, path); + return parsed === "spec" || parsed === "plan" || parsed === "implementation" + ? parsed + : fail("invalid-phase", path); +} + +export function safeUrl(value: unknown, path: string): string { + const url = string(value, path); + try { + const parsedUrl = new URL(url); + if ( + (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") || + parsedUrl.username || + parsedUrl.password || + parsedUrl.search || + parsedUrl.hash || + /[\r\n]/u.test(url) + ) + fail("invalid-url", path); + } catch (error) { + if (error instanceof PlanningStateValidationError) throw error; + fail("invalid-url", path); + } + return url; +} + +export function nonnegativeFinite(value: unknown, path: string): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? value + : fail("invalid-nonnegative-number", path); +} diff --git a/src/workflow/planning-state-implementation-codec.ts b/src/workflow/planning-state-implementation-codec.ts new file mode 100644 index 00000000..513ae752 --- /dev/null +++ b/src/workflow/planning-state-implementation-codec.ts @@ -0,0 +1,413 @@ +import type { PlanningWorkspaceOwnership } from "../git/planning-workspaces.ts"; +import { planningImplementationFinishCheckpointKeys } from "./planning-state-types.ts"; +import type { + ImplementationBranchPushedPlanningPhase, + ImplementationCompletePlanningPhase, + ImplementationPullRequestOpenPlanningPhase, + ImplementationWorkspaceReadyPlanningPhase, + PlanningArtifactEvidence, + PlanningBaseEvidence, + PlanningImplementationFinishCheckpoints, + PlanningPublicationEvidence, + PlanningPullRequestEvidence, +} from "./planning-state-types.ts"; +import { + artifactPath, + branch, + fail, + nonblankSingleLine, + nonnegativeFinite, + object, + objectWithOptionalKeys, + oid, + safeUrl, + singleLine, +} from "./planning-state-codec.ts"; + +export function implementationRunCost(value: unknown, path: string) { + const parsed = object( + value, + ["stages", "promptTokens", "outputTokens", "estimatedCostUsd"], + path, + ); + if (!Array.isArray(parsed.stages)) fail("expected-array", `${path}.stages`); + const model = (entry: unknown, entryPath: string) => { + const parsedModel = object( + entry, + ["model", "promptTokens", "outputTokens", "estimatedCostUsd"], + entryPath, + ); + return { + model: singleLine(parsedModel.model, `${entryPath}.model`), + promptTokens: nonnegativeFinite( + parsedModel.promptTokens, + `${entryPath}.promptTokens`, + ), + outputTokens: nonnegativeFinite( + parsedModel.outputTokens, + `${entryPath}.outputTokens`, + ), + estimatedCostUsd: nonnegativeFinite( + parsedModel.estimatedCostUsd, + `${entryPath}.estimatedCostUsd`, + ), + }; + }; + return { + stages: (parsed.stages as unknown[]).map((entry, index) => { + const entryPath = `${path}.stages[${index}]`; + const stage = object( + entry, + ["stage", "models", "promptTokens", "outputTokens", "estimatedCostUsd"], + entryPath, + ); + const models = stage.models; + if (!Array.isArray(models)) fail("expected-array", `${entryPath}.models`); + const modelEntries = models as unknown[]; + return { + stage: singleLine(stage.stage, `${entryPath}.stage`), + models: modelEntries.map((modelEntry, modelIndex) => + model(modelEntry, `${entryPath}.models[${modelIndex}]`), + ), + promptTokens: nonnegativeFinite( + stage.promptTokens, + `${entryPath}.promptTokens`, + ), + outputTokens: nonnegativeFinite( + stage.outputTokens, + `${entryPath}.outputTokens`, + ), + estimatedCostUsd: nonnegativeFinite( + stage.estimatedCostUsd, + `${entryPath}.estimatedCostUsd`, + ), + }; + }), + promptTokens: nonnegativeFinite( + parsed.promptTokens, + `${path}.promptTokens`, + ), + outputTokens: nonnegativeFinite( + parsed.outputTokens, + `${path}.outputTokens`, + ), + estimatedCostUsd: nonnegativeFinite( + parsed.estimatedCostUsd, + `${path}.estimatedCostUsd`, + ), + }; +} +export function implementationEvidence(value: unknown, path: string) { + const parsed = objectWithOptionalKeys( + value, + ["status", "prUrl", "branch", "commits", "validation", "visualEvidence"], + ["reviewSummary", "landingDecision", "runCostReport"], + path, + ); + if (parsed.status !== "pr-created") + fail("invalid-implementation-status", `${path}.status`); + const entries = (items: unknown, itemPath: string) => { + if (!Array.isArray(items) || items.length === 0) + fail("expected-nonempty-array", itemPath); + return (items as unknown[]).map((entry: unknown, index: number) => + nonblankSingleLine(entry, `${itemPath}[${index}]`), + ); + }; + if (!Array.isArray(parsed.visualEvidence)) + fail("expected-array", `${path}.visualEvidence`); + return { + status: "pr-created" as const, + prUrl: safeUrl(parsed.prUrl, `${path}.prUrl`), + branch: branch(parsed.branch, `${path}.branch`), + commits: entries(parsed.commits, `${path}.commits`).map( + (entry: string, index: number) => oid(entry, `${path}.commits[${index}]`), + ), + validation: entries(parsed.validation, `${path}.validation`), + ...(parsed.reviewSummary === undefined + ? {} + : { + reviewSummary: singleLine( + parsed.reviewSummary, + `${path}.reviewSummary`, + ), + }), + ...(parsed.landingDecision === undefined + ? {} + : { + landingDecision: singleLine( + parsed.landingDecision, + `${path}.landingDecision`, + ), + }), + visualEvidence: (parsed.visualEvidence as unknown[]).map( + (entry: unknown, index: number) => { + const visualPath = `${path}.visualEvidence[${index}]`; + const visual = objectWithOptionalKeys( + entry, + ["screenshotPath"], + ["caption", "referencePaths", "url"], + visualPath, + ); + const references = visual.referencePaths; + if (references !== undefined && !Array.isArray(references)) + fail("expected-array", `${visualPath}.referencePaths`); + const referencePaths = + references === undefined + ? undefined + : (references as unknown[]).map((reference, referenceIndex) => + artifactPath( + reference, + `${visualPath}.referencePaths[${referenceIndex}]`, + ), + ); + if ( + referencePaths !== undefined && + new Set(referencePaths).size !== referencePaths.length + ) + fail("duplicate-value", `${visualPath}.referencePaths`); + return { + screenshotPath: artifactPath( + visual.screenshotPath, + `${visualPath}.screenshotPath`, + ), + ...(visual.caption === undefined + ? {} + : { caption: singleLine(visual.caption, `${visualPath}.caption`) }), + ...(referencePaths === undefined ? {} : { referencePaths }), + ...(visual.url === undefined + ? {} + : { url: safeUrl(visual.url, `${visualPath}.url`) }), + }; + }, + ), + ...(parsed.runCostReport === undefined + ? {} + : { + runCostReport: implementationRunCost( + parsed.runCostReport, + `${path}.runCostReport`, + ), + }), + }; +} +export function implementationFinish(value: unknown, path: string) { + const allowed = planningImplementationFinishCheckpointKeys; + if (value === null || typeof value !== "object" || Array.isArray(value)) + fail("expected-object", path); + const parsed = value as Record; + for (const key of Object.keys(parsed)) + if (!(allowed as readonly string[]).includes(key)) + fail("unknown-key", `${path}.${key}`); + for (const key of Object.keys(parsed)) + if (parsed[key] !== true) fail("invalid-checkpoint", `${path}.${key}`); + for (let index = 1; index < allowed.length; index += 1) + if ( + parsed[allowed[index]!] === true && + parsed[allowed[index - 1]!] !== true + ) + fail("skipped-finish-checkpoint", `${path}.${allowed[index]}`); + return parsed as PlanningImplementationFinishCheckpoints; +} + +/** Shared state decoders and failure behavior owned by the main state codec. */ +type PlanningImplementationPhaseCodecDependencies = Readonly<{ + base: (value: unknown, path: string) => PlanningBaseEvidence; + workspace: (value: unknown, path: string) => PlanningWorkspaceOwnership; + artifacts: ( + value: unknown, + path: string, + ) => readonly PlanningArtifactEvidence[]; + publication: (value: unknown, path: string) => PlanningPublicationEvidence; + pullRequest: (value: unknown, path: string) => PlanningPullRequestEvidence; + fail: (reason: string, path: string) => never; +}>; + +export function implementationPhaseState( + value: unknown, + status: string, + path: string, + dependencies: PlanningImplementationPhaseCodecDependencies, +): + | ImplementationWorkspaceReadyPlanningPhase + | ImplementationBranchPushedPlanningPhase + | ImplementationPullRequestOpenPlanningPhase + | ImplementationCompletePlanningPhase { + const { + artifacts, + base, + fail: failState, + publication, + pullRequest, + workspace, + } = dependencies; + if (status === "workspace-ready") { + const parsed = object( + value, + ["kind", "status", "base", "workspace", "artifacts"], + path, + ); + return { + kind: "implementation", + status, + base: base(parsed.base, `${path}.base`), + workspace: workspace( + parsed.workspace, + `${path}.workspace`, + ) as PlanningWorkspaceOwnership<{ state: "ready" }>, + artifacts: artifacts(parsed.artifacts, `${path}.artifacts`), + }; + } + if (status === "branch-pushed") { + const parsed = object( + value, + [ + "kind", + "status", + "base", + "workspace", + "artifacts", + "publication", + "implementation", + ], + path, + ); + const workspaceEvidence = workspace( + parsed.workspace, + `${path}.workspace`, + ) as PlanningWorkspaceOwnership<{ state: "ready" }>; + const publicationEvidence = publication( + parsed.publication, + `${path}.publication`, + ); + const evidence = implementationEvidence( + parsed.implementation, + `${path}.implementation`, + ); + if ( + evidence.branch !== workspaceEvidence.identity.branch || + publicationEvidence.headOid !== workspaceEvidence.headOid + ) + failState("implementation-mismatch", `${path}.implementation`); + return { + kind: "implementation", + status, + base: base(parsed.base, `${path}.base`), + workspace: workspaceEvidence, + artifacts: artifacts(parsed.artifacts, `${path}.artifacts`), + publication: publicationEvidence, + implementation: evidence, + }; + } + if (status === "pull-request-open") { + const parsed = object( + value, + [ + "kind", + "status", + "base", + "workspace", + "artifacts", + "publication", + "pullRequest", + "implementation", + "finish", + ], + path, + ); + const workspaceEvidence = workspace(parsed.workspace, `${path}.workspace`); + const publicationEvidence = publication( + parsed.publication, + `${path}.publication`, + ); + const pullRequestEvidence = pullRequest( + parsed.pullRequest, + `${path}.pullRequest`, + ); + const evidence = implementationEvidence( + parsed.implementation, + `${path}.implementation`, + ); + if ( + evidence.branch !== workspaceEvidence.identity.branch || + evidence.prUrl !== pullRequestEvidence.url || + publicationEvidence.headOid !== workspaceEvidence.headOid + ) + failState("implementation-mismatch", `${path}.implementation`); + return { + kind: "implementation", + status, + base: base(parsed.base, `${path}.base`), + workspace: workspaceEvidence, + artifacts: artifacts(parsed.artifacts, `${path}.artifacts`), + publication: publicationEvidence, + pullRequest: pullRequestEvidence, + implementation: evidence, + finish: implementationFinish(parsed.finish, `${path}.finish`), + }; + } + if (status !== "complete") failState("invalid-status", `${path}.status`); + const completion = (value as Record).completion as Record< + string, + unknown + >; + const parsed = object( + value, + completion?.kind === "remote-base" + ? ["kind", "status", "base", "artifacts", "completion"] + : [ + "kind", + "status", + "base", + "workspace", + "artifacts", + "publication", + "pullRequest", + "implementation", + "finish", + "completion", + ], + path, + ); + const terminal = object(parsed.completion, ["kind"], `${path}.completion`); + if (terminal.kind !== "implementation-pull-request") + failState("implementation-completion", `${path}.completion.kind`); + const workspaceEvidence = workspace( + parsed.workspace, + `${path}.workspace`, + ) as PlanningWorkspaceOwnership<{ state: "removed"; pushedHeadOid: string }>; + const publicationEvidence = publication( + parsed.publication, + `${path}.publication`, + ); + const pullRequestEvidence = pullRequest( + parsed.pullRequest, + `${path}.pullRequest`, + ); + const evidence = implementationEvidence( + parsed.implementation, + `${path}.implementation`, + ); + const finish = implementationFinish(parsed.finish, `${path}.finish`); + if ( + workspaceEvidence.cleanup.state !== "removed" || + evidence.branch !== workspaceEvidence.identity.branch || + evidence.prUrl !== pullRequestEvidence.url || + publicationEvidence.headOid !== workspaceEvidence.headOid || + !planningImplementationFinishCheckpointKeys.every( + (checkpoint) => finish[checkpoint] === true, + ) + ) + failState("implementation-mismatch", path); + return { + kind: "implementation", + status: "complete", + base: base(parsed.base, `${path}.base`), + workspace: workspaceEvidence, + artifacts: artifacts(parsed.artifacts, `${path}.artifacts`), + publication: publicationEvidence, + pullRequest: pullRequestEvidence, + implementation: evidence, + finish: finish as Required, + completion: { kind: "implementation-pull-request" }, + }; +} diff --git a/src/workflow/planning-state-publication-regressions.test.ts b/src/workflow/planning-state-publication-regressions.test.ts index 4ba90913..5955074f 100644 --- a/src/workflow/planning-state-publication-regressions.test.ts +++ b/src/workflow/planning-state-publication-regressions.test.ts @@ -346,6 +346,54 @@ test("rejects artifact, publication, cleanup, pull request, and phase-order cont invalid(outOfOrder, "progress-order", "$.phases[1]"); }); +test("requires durable pull request URLs to identify their exact reference", () => { + for (const [name, url] of [ + ["number", "https://github.com/acme/patchmill/pull/189"], + ["repository", "https://github.com/acme/other/pull/188"], + ]) { + const value = structuredClone(phase("pull-request-open")); + (value.pullRequest as { url: string }).url = url; + invalid( + document(value), + "pull-request-mismatch", + "$.phases[0].pullRequest", + ); + assert.ok(name); + } +}); + +test("accepts a non-default-port Forgejo durable pull request reference", () => { + const value = structuredClone(phase("pull-request-open")); + const forgejo = { + provider: "forgejo-tea", + host: "forge.test:8443", + owner: "acme", + repository: "patchmill", + }; + ( + value.publication as { targetRepository: unknown; headRepository: unknown } + ).targetRepository = forgejo; + ( + value.publication as { targetRepository: unknown; headRepository: unknown } + ).headRepository = forgejo; + ( + value.pullRequest as { + reference: { targetRepository: unknown }; + url: string; + } + ).reference.targetRepository = forgejo; + ( + value.pullRequest as { + reference: { targetRepository: unknown }; + url: string; + } + ).url = "https://forge.test:8443/acme/patchmill/pulls/188"; + assert.doesNotThrow(() => validatePlanningState(document(value))); + (value.pullRequest as { url: string }).url = + "https://FORGE.TEST:8443/Acme/Patchmill/pulls/188"; + assert.doesNotThrow(() => validatePlanningState(document(value))); +}); + test("requires planner artifact order and source-specific commit evidence", () => { const planBase = { ...base, @@ -385,6 +433,23 @@ test("requires planner artifact order and source-specific commit evidence", () = phases: [planPhase, { kind: "implementation", status: "pending" }], }; assert.doesNotThrow(() => validatePlanningState(value)); + const distinctWorkspaceArtifacts = structuredClone(value); + distinctWorkspaceArtifacts.phases[0].workspace.headOid = oid("d"); + distinctWorkspaceArtifacts.phases[0].artifacts = [ + { + kind: "spec", + path: "docs/specs/a.md", + source: "workspace", + commitOid: oid("c"), + }, + { + kind: "plan", + path: "docs/plans/a.md", + source: "workspace", + commitOid: oid("d"), + }, + ]; + assert.doesNotThrow(() => validatePlanningState(distinctWorkspaceArtifacts)); const reversed = structuredClone(value); reversed.phases[0].artifacts.reverse(); invalid(reversed, "artifact-kinds", "$.phases[0].artifacts"); diff --git a/src/workflow/planning-state-store.test.ts b/src/workflow/planning-state-store.test.ts index b9750268..84719672 100644 --- a/src/workflow/planning-state-store.test.ts +++ b/src/workflow/planning-state-store.test.ts @@ -18,6 +18,7 @@ import { import { PlanningStateValidationError, createPlanningState, + validatePlanningState, } from "./planning-state.ts"; import { PlanningStateConflictError, @@ -62,6 +63,104 @@ test("initializes and atomically replaces canonical planning state under owner l await rm(dir, { recursive: true, force: true }); } }); +test("atomically appends a later plan artifact without changing the earlier spec evidence", async () => { + const dir = await mkdtemp(join(tmpdir(), "planning-store-")); + const oid = (character: string) => character.repeat(40); + try { + const store = new PlanningStateStore(dir); + const lock = await acquirePlanningIssueLock(dir, { + issueNumber: 187, + runId, + }); + const initial = validatePlanningState({ + version: 1, + workflowVersion: "planning-pr-v1", + runId, + issueNumber: 187, + issueTitle: "Example", + gates: { specRequired: false, planRequired: true }, + revision: 0, + createdAt: "2026-09-07T12:00:00.000Z", + updatedAt: "2026-09-07T12:00:00.000Z", + phases: [ + { + kind: "plan", + status: "workspace-ready", + base: { + remote: "origin", + baseBranch: "main", + baseOid: oid("a"), + artifactCandidates: { spec: [], plan: [] }, + }, + workspace: { + runId, + phase: "plan", + identity: { + branch: "agent/issue-187-plan", + worktreePath: ".worktrees/issue-187-plan", + }, + remote: "origin", + baseBranch: "main", + baseOid: oid("a"), + headOid: oid("b"), + cleanup: { state: "ready" }, + }, + artifacts: [ + { + kind: "spec", + path: "docs/specs/issue-187.md", + source: "workspace", + commitOid: oid("b"), + }, + ], + }, + { kind: "implementation", status: "pending" }, + ], + }); + await store.initialize({ state: initial, lock }); + const next = validatePlanningState({ + ...initial, + revision: 1, + updatedAt: "2026-09-07T12:00:01.000Z", + phases: [ + { + ...initial.phases[0]!, + workspace: { + ...(initial.phases[0] as { workspace: object }).workspace, + headOid: oid("c"), + }, + artifacts: [ + ...(initial.phases[0] as { artifacts: object[] }).artifacts, + { + kind: "plan", + path: "docs/plans/issue-187.md", + source: "workspace", + commitOid: oid("c"), + }, + ], + }, + initial.phases[1]!, + ], + }); + const saved = await store.replace({ + issueNumber: 187, + expectedRunId: runId, + expectedRevision: 0, + next, + lock, + }); + const plan = saved.phases[0]!; + if (!("artifacts" in plan)) assert.fail("expected plan artifacts"); + assert.deepEqual( + plan.artifacts.map((artifact) => artifact.commitOid), + [oid("b"), oid("c")], + ); + await releasePlanningIssueLock(lock); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("malformed reads fail closed with the canonical state path", async () => { const dir = await mkdtemp(join(tmpdir(), "planning-store-")); try { diff --git a/src/workflow/planning-state-transitions.ts b/src/workflow/planning-state-transitions.ts index 544edcba..3b958d79 100644 --- a/src/workflow/planning-state-transitions.ts +++ b/src/workflow/planning-state-transitions.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from "node:util"; +import { planningImplementationFinishCheckpointKeys } from "./planning-state-types.ts"; import type { PlanningArtifactEvidence, PlanningPhaseStateV1, @@ -14,8 +15,22 @@ type PhaseName = | "pull-request-open-worktree-removed" | "pull-request-open-removed" | "complete-remote-base" - | "complete-merged"; + | "complete-merged" + | "implementation-workspace-ready" + | "implementation-branch-pushed" + | "implementation-pull-request-open-ready" + | "implementation-pull-request-open-worktree-removed" + | "implementation-pull-request-open-removed" + | "implementation-complete"; function phaseName(phase: PlanningPhaseStateV1): PhaseName { + if (phase.kind === "implementation") { + if (phase.status === "complete") return "implementation-complete"; + if (phase.status === "workspace-ready") + return "implementation-workspace-ready"; + if (phase.status === "branch-pushed") return "implementation-branch-pushed"; + if (phase.status === "pull-request-open") + return `implementation-pull-request-open-${phase.workspace.cleanup.state}`; + } if (phase.status === "complete") return phase.completion.kind === "remote-base" ? "complete-remote-base" @@ -24,7 +39,12 @@ function phaseName(phase: PlanningPhaseStateV1): PhaseName { return `pull-request-open-${phase.workspace.cleanup.state}`; } const allowed: Readonly> = { - pending: ["pending", "workspace-ready", "complete-remote-base"], + pending: [ + "pending", + "workspace-ready", + "complete-remote-base", + "implementation-workspace-ready", + ], "workspace-ready": ["workspace-ready", "branch-pushed"], "branch-pushed": ["branch-pushed", "pull-request-open-ready"], "pull-request-open-ready": [ @@ -38,6 +58,27 @@ const allowed: Readonly> = { "pull-request-open-removed": ["pull-request-open-removed", "complete-merged"], "complete-remote-base": ["complete-remote-base"], "complete-merged": ["complete-merged"], + "implementation-workspace-ready": [ + "implementation-workspace-ready", + "implementation-branch-pushed", + ], + "implementation-branch-pushed": [ + "implementation-branch-pushed", + "implementation-pull-request-open-ready", + ], + "implementation-pull-request-open-ready": [ + "implementation-pull-request-open-ready", + "implementation-pull-request-open-worktree-removed", + ], + "implementation-pull-request-open-worktree-removed": [ + "implementation-pull-request-open-worktree-removed", + "implementation-pull-request-open-removed", + ], + "implementation-pull-request-open-removed": [ + "implementation-pull-request-open-removed", + "implementation-complete", + ], + "implementation-complete": ["implementation-complete"], }; function fail(reason: string, index: number, suffix = ""): never { throw new PlanningStateValidationError(reason, `$.phases[${index}]${suffix}`); @@ -83,6 +124,27 @@ function assertWorkspace( return; fail("invalid-cleanup-transition", index, ".workspace.cleanup"); } +function assertImplementationFinish( + current: Record, + next: Record, + index: number, +): boolean { + const steps = planningImplementationFinishCheckpointKeys; + const additions = steps.filter( + (step) => current[step] !== true && next[step] === true, + ); + if (additions.length > 1) fail("invalid-finish-transition", index, ".finish"); + for (const [stepIndex, step] of steps.entries()) { + if (current[step] === true && next[step] !== true) + fail("immutable-evidence", index, ".finish"); + if (current[step] !== true && next[step] === true) { + for (const prior of steps.slice(0, stepIndex)) + if (next[prior] !== true) + fail("invalid-finish-transition", index, ".finish"); + } + } + return additions.length === 1; +} function assertArtifacts( current: readonly PlanningArtifactEvidence[], next: readonly PlanningArtifactEvidence[], @@ -95,18 +157,13 @@ function assertArtifacts( (!allowAppend && next.length !== current.length) ) fail("immutable-evidence", index); - for (let offset = 0; offset < current.length; offset += 1) { - const left = current[offset]!; - const right = next[offset]!; - if (left.kind !== right.kind || left.path !== right.path) + for (const left of current) { + const right = next.find((artifact) => artifact.kind === left.kind); + if (right === undefined || left.path !== right.path) fail("immutable-evidence", index); if (allowMergeConversion) continue; if (left.source !== right.source) fail("immutable-evidence", index); - if ( - left.commitOid !== right.commitOid && - !(allowAppend && left.source === "workspace") - ) - fail("immutable-evidence", index); + if (left.commitOid !== right.commitOid) fail("immutable-evidence", index); } } export function assertPlanningPhaseReplacement( @@ -132,6 +189,11 @@ export function assertPlanningPhaseReplacement( const appendingArtifact = next.status === "workspace-ready" && next.artifacts.length === current.artifacts.length + 1; + const implementationHeadAdvance = + current.kind === "implementation" && + (next.status === "branch-pushed" || + (next.status === "workspace-ready" && + next.workspace.headOid !== current.workspace.headOid)); if ( next.status === "workspace-ready" && next.artifacts.length > current.artifacts.length + 1 @@ -140,7 +202,7 @@ export function assertPlanningPhaseReplacement( assertWorkspace( current.workspace, next.workspace, - appendingArtifact, + appendingArtifact || implementationHeadAdvance, index, ); assertArtifacts( @@ -159,6 +221,16 @@ export function assertPlanningPhaseReplacement( same(current.workspace, next.workspace, index); same(current.artifacts, next.artifacts, index); same(current.publication, next.publication, index); + if (current.kind === "implementation") { + if (!("implementation" in next)) + fail("invalid-transition", index, ".implementation"); + same(current.implementation, next.implementation, index); + if ( + next.status === "pull-request-open" && + Object.keys(next.finish).length + ) + fail("invalid-finish-transition", index, ".finish"); + } return; } if (next.status === "pull-request-open") { @@ -167,6 +239,53 @@ export function assertPlanningPhaseReplacement( same(current.artifacts, next.artifacts, index); same(current.publication, next.publication, index); same(current.pullRequest, next.pullRequest, index); + if (current.kind === "implementation") { + if (!("implementation" in next) || !("finish" in next)) + fail("invalid-transition", index, ".implementation"); + same(current.implementation, next.implementation, index); + const finishAdvanced = assertImplementationFinish( + current.finish as Record, + next.finish as Record, + index, + ); + if ( + finishAdvanced && + current.workspace.cleanup.state !== next.workspace.cleanup.state + ) + fail("invalid-finish-transition", index, ".finish"); + if ( + next.workspace.cleanup.state !== "ready" && + next.finish.cleanupHookCompleted !== true + ) + fail("invalid-cleanup-transition", index, ".workspace.cleanup"); + if ( + next.finish.doneLabelEnsured === true && + next.workspace.cleanup.state !== "removed" + ) + fail("invalid-finish-transition", index, ".finish.doneLabelEnsured"); + if ( + next.finish.doneLabelApplied === true && + next.workspace.cleanup.state !== "removed" + ) + fail("invalid-finish-transition", index, ".finish.doneLabelApplied"); + } + return; + } + if (current.kind === "implementation") { + if ( + next.status !== "complete" || + !("implementation" in next) || + !("finish" in next) || + next.completion.kind !== "implementation-pull-request" + ) + fail("invalid-transition", index, ".status"); + same(current.base, next.base, index); + same(current.workspace, next.workspace, index); + same(current.publication, next.publication, index); + same(current.pullRequest, next.pullRequest, index); + same(current.implementation, next.implementation, index); + same(current.artifacts, next.artifacts, index); + same(current.finish, next.finish, index); return; } if ( diff --git a/src/workflow/planning-state-types.ts b/src/workflow/planning-state-types.ts index 787765c6..83762ff3 100644 --- a/src/workflow/planning-state-types.ts +++ b/src/workflow/planning-state-types.ts @@ -32,15 +32,65 @@ export type PlanningPullRequestEvidence = Readonly<{ url: string; }>; +export type PlanningImplementationVisualEvidence = Readonly<{ + screenshotPath: string; + caption?: string; + referencePaths?: readonly string[]; + url?: string; +}>; +export type PlanningRunCostEvidence = Readonly<{ + stages: readonly Readonly<{ + stage: string; + models: readonly Readonly<{ + model: string; + promptTokens: number; + outputTokens: number; + estimatedCostUsd: number; + }>[]; + promptTokens: number; + outputTokens: number; + estimatedCostUsd: number; + }>[]; + promptTokens: number; + outputTokens: number; + estimatedCostUsd: number; +}>; +export type PlanningImplementationAgentEvidence = Readonly<{ + status: "pr-created"; + prUrl: string; + branch: string; + commits: readonly string[]; + validation: readonly string[]; + reviewSummary?: string; + landingDecision?: string; + visualEvidence: readonly PlanningImplementationVisualEvidence[]; + runCostReport?: PlanningRunCostEvidence; +}>; +/** Ordered durable effects required before an implementation PR can be terminal. */ +export const planningImplementationFinishCheckpointKeys = [ + "costPublicationCompleted", + "visualEvidenceValidated", + "handoffCommentPosted", + "cleanupHookCompleted", + "doneLabelEnsured", + "doneLabelApplied", +] as const; +export type PlanningImplementationFinishCheckpoints = Readonly< + Partial< + Record<(typeof planningImplementationFinishCheckpointKeys)[number], true> + > +>; +type PlanningPhasePullRequestKind = "spec" | "plan"; + export type WorkspaceReadyPlanningPhase = Readonly<{ - kind: PlanningPhaseKind; + kind: PlanningPhasePullRequestKind; status: "workspace-ready"; base: PlanningBaseEvidence; workspace: PlanningWorkspaceOwnership<{ state: "ready" }>; artifacts: readonly PlanningArtifactEvidence[]; }>; export type BranchPushedPlanningPhase = Readonly<{ - kind: PlanningPhaseKind; + kind: PlanningPhasePullRequestKind; status: "branch-pushed"; base: PlanningBaseEvidence; workspace: PlanningWorkspaceOwnership<{ state: "ready" }>; @@ -48,7 +98,7 @@ export type BranchPushedPlanningPhase = Readonly<{ publication: PlanningPublicationEvidence; }>; export type PullRequestOpenPlanningPhase = Readonly<{ - kind: PlanningPhaseKind; + kind: PlanningPhasePullRequestKind; status: "pull-request-open"; base: PlanningBaseEvidence; workspace: PlanningWorkspaceOwnership; @@ -57,14 +107,14 @@ export type PullRequestOpenPlanningPhase = Readonly<{ pullRequest: PlanningPullRequestEvidence; }>; export type RemoteBaseCompletePlanningPhase = Readonly<{ - kind: PlanningPhaseKind; + kind: PlanningPhasePullRequestKind; status: "complete"; base: PlanningBaseEvidence; artifacts: readonly PlanningArtifactEvidence[]; completion: Readonly<{ kind: "remote-base" }>; }>; export type MergedPullRequestCompletePlanningPhase = Readonly<{ - kind: PlanningPhaseKind; + kind: PlanningPhasePullRequestKind; status: "complete"; base: PlanningBaseEvidence; workspace: PlanningWorkspaceOwnership<{ @@ -80,13 +130,59 @@ export type MergedPullRequestCompletePlanningPhase = Readonly<{ mergedBaseOid: string; }>; }>; +export type ImplementationWorkspaceReadyPlanningPhase = Readonly<{ + kind: "implementation"; + status: "workspace-ready"; + base: PlanningBaseEvidence; + workspace: PlanningWorkspaceOwnership<{ state: "ready" }>; + artifacts: readonly PlanningArtifactEvidence[]; +}>; +export type ImplementationBranchPushedPlanningPhase = Readonly<{ + kind: "implementation"; + status: "branch-pushed"; + base: PlanningBaseEvidence; + workspace: PlanningWorkspaceOwnership<{ state: "ready" }>; + artifacts: readonly PlanningArtifactEvidence[]; + publication: PlanningPublicationEvidence; + implementation: PlanningImplementationAgentEvidence; +}>; +export type ImplementationPullRequestOpenPlanningPhase = Readonly<{ + kind: "implementation"; + status: "pull-request-open"; + base: PlanningBaseEvidence; + workspace: PlanningWorkspaceOwnership; + artifacts: readonly PlanningArtifactEvidence[]; + publication: PlanningPublicationEvidence; + pullRequest: PlanningPullRequestEvidence; + implementation: PlanningImplementationAgentEvidence; + finish: PlanningImplementationFinishCheckpoints; +}>; +export type ImplementationCompletePlanningPhase = Readonly<{ + kind: "implementation"; + status: "complete"; + base: PlanningBaseEvidence; + workspace: PlanningWorkspaceOwnership<{ + state: "removed"; + pushedHeadOid: string; + }>; + artifacts: readonly PlanningArtifactEvidence[]; + publication: PlanningPublicationEvidence; + pullRequest: PlanningPullRequestEvidence; + implementation: PlanningImplementationAgentEvidence; + finish: Required; + completion: Readonly<{ kind: "implementation-pull-request" }>; +}>; export type PlanningPhaseStateV1 = | Readonly<{ kind: PlanningPhaseKind; status: "pending" }> | WorkspaceReadyPlanningPhase | BranchPushedPlanningPhase | PullRequestOpenPlanningPhase | RemoteBaseCompletePlanningPhase - | MergedPullRequestCompletePlanningPhase; + | MergedPullRequestCompletePlanningPhase + | ImplementationWorkspaceReadyPlanningPhase + | ImplementationBranchPushedPlanningPhase + | ImplementationPullRequestOpenPlanningPhase + | ImplementationCompletePlanningPhase; export type PlanningStateV1 = Readonly<{ version: 1; workflowVersion: "planning-pr-v1"; diff --git a/src/workflow/planning-state-validation.ts b/src/workflow/planning-state-validation.ts index df052e22..bcc35054 100644 --- a/src/workflow/planning-state-validation.ts +++ b/src/workflow/planning-state-validation.ts @@ -1,22 +1,14 @@ -import { - isPlanningArtifactPath, - isPlanningBranch, - isPlanningSingleLine, - planningOid, -} from "../git/planning-git-validation.ts"; import type { PlanningWorkspaceIdentity, PlanningWorkspaceOwnership, } from "../git/planning-workspaces.ts"; +import { pullRequestUrlMatchesReference } from "../host/pull-request-reference.ts"; import { sameRepositoryIdentity, type PullRequestReference, type RepositoryIdentity, } from "../host/pull-requests.ts"; -import { - PLANNING_PR_WORKFLOW_VERSION, - type PlanningPhaseKind, -} from "./planning-pull-request-markers.ts"; +import { PLANNING_PR_WORKFLOW_VERSION } from "./planning-pull-request-markers.ts"; import { planningPhasePlan, type PlanningArtifactKind, @@ -32,81 +24,24 @@ import { PlanningPublicationRepositoryError, } from "./planning-publication-repositories.ts"; -const UUID = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; -const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; -export class PlanningStateValidationError extends Error { - readonly reason: string; - readonly path: string; - readonly statePath?: string; - constructor(reason: string, path: string, statePath?: string) { - super(`Planning state is invalid: ${reason} at ${path}`); - this.name = "PlanningStateValidationError"; - this.reason = reason; - this.path = path; - if (statePath !== undefined) this.statePath = statePath; - } -} -const fail = (reason: string, path: string): never => { - throw new PlanningStateValidationError(reason, path); -}; -const object = ( - value: unknown, - keys: readonly string[], - path: string, -): Record => { - if (value === null || typeof value !== "object" || Array.isArray(value)) - fail("expected-object", path); - const result = value as Record; - for (const key of Object.keys(result)) - if (!keys.includes(key)) fail("unknown-key", `${path}.${key}`); - for (const key of keys) - if (!(key in result)) fail("missing-key", `${path}.${key}`); - return result; -}; -const string = ( - value: unknown, - path: string, - reason = "invalid-string", -): string => (typeof value === "string" ? value : fail(reason, path)); -const positive = (value: unknown, path: string): number => - Number.isSafeInteger(value) && (value as number) > 0 - ? (value as number) - : fail("invalid-positive-integer", path); -const nonnegative = (value: unknown, path: string): number => - Number.isSafeInteger(value) && (value as number) >= 0 - ? (value as number) - : fail("invalid-nonnegative-integer", path); -const oid = (value: unknown, path: string): string => { - const parsed = string(value, path); - return planningOid.test(parsed) ? parsed : fail("invalid-oid", path); -}; -const singleLine = (value: unknown, path: string): string => { - const parsed = string(value, path); - return isPlanningSingleLine(parsed) ? parsed : fail("invalid-string", path); -}; -const branch = (value: unknown, path: string): string => { - const parsed = string(value, path); - return isPlanningBranch(parsed) ? parsed : fail("invalid-branch", path); -}; -const artifactPath = (value: unknown, path: string): string => { - const parsed = string(value, path); - return isPlanningArtifactPath(parsed) ? parsed : fail("invalid-path", path); -}; -function timestamp(value: unknown, path: string): string { - const parsed = string(value, path); - return ISO_TIMESTAMP.test(parsed) && - !Number.isNaN(Date.parse(parsed)) && - new Date(parsed).toISOString() === parsed - ? parsed - : fail("invalid-timestamp", path); -} -function phase(value: unknown, path: string): PlanningPhaseKind { - const parsed = string(value, path); - return parsed === "spec" || parsed === "plan" || parsed === "implementation" - ? parsed - : fail("invalid-phase", path); -} +import { implementationPhaseState } from "./planning-state-implementation-codec.ts"; +import { + UUID, + artifactPath, + branch, + fail, + nonnegative, + object, + oid, + phase, + positive, + safeUrl, + singleLine, + string, + timestamp, +} from "./planning-state-codec.ts"; +export { PlanningStateValidationError } from "./planning-state-codec.ts"; + function repository(value: unknown, path: string): RepositoryIdentity { const parsed = object( value, @@ -298,22 +233,7 @@ function pullRequest(value: unknown, path: string) { ["targetRepository", "number"], `${path}.reference`, ); - const url = string(parsed.url, `${path}.url`); - try { - const parsedUrl = new URL(url); - if ( - (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") || - parsedUrl.username || - parsedUrl.password || - parsedUrl.search || - parsedUrl.hash || - /[\r\n]/u.test(url) - ) - fail("invalid-url", `${path}.url`); - } catch (error) { - if (error instanceof PlanningStateValidationError) throw error; - fail("invalid-url", `${path}.url`); - } + const url = safeUrl(parsed.url, `${path}.url`); return { reference: { targetRepository: repository( @@ -326,17 +246,43 @@ function pullRequest(value: unknown, path: string) { }; } function phaseState(value: unknown, path: string): PlanningPhaseStateV1 { + if (value === null || typeof value !== "object" || Array.isArray(value)) + object(value, [], path); const raw = value as Record; - const status = raw?.status; + const kind = phase(raw.kind, `${path}.kind`); + const status = string(raw.status, `${path}.status`); + if (status === "pending") { + object(value, ["kind", "status"], path); + return { kind, status }; + } + if (kind === "implementation") + return implementationPhaseState(value, status, path, { + base, + workspace, + artifacts, + publication, + pullRequest, + fail, + }); const keys = - status === "pending" - ? ["kind", "status"] - : status === "workspace-ready" - ? ["kind", "status", "base", "workspace", "artifacts"] - : status === "branch-pushed" - ? ["kind", "status", "base", "workspace", "artifacts", "publication"] - : status === "pull-request-open" - ? [ + status === "workspace-ready" + ? ["kind", "status", "base", "workspace", "artifacts"] + : status === "branch-pushed" + ? ["kind", "status", "base", "workspace", "artifacts", "publication"] + : status === "pull-request-open" + ? [ + "kind", + "status", + "base", + "workspace", + "artifacts", + "publication", + "pullRequest", + ] + : raw.completion && + (raw.completion as Record).kind === "remote-base" + ? ["kind", "status", "base", "artifacts", "completion"] + : [ "kind", "status", "base", @@ -344,28 +290,13 @@ function phaseState(value: unknown, path: string): PlanningPhaseStateV1 { "artifacts", "publication", "pullRequest", - ] - : raw?.completion && - (raw.completion as Record).kind === - "remote-base" - ? ["kind", "status", "base", "artifacts", "completion"] - : [ - "kind", - "status", - "base", - "workspace", - "artifacts", - "publication", - "pullRequest", - "completion", - ]; + "completion", + ]; const parsed = object(value, keys, path); - const kind = phase(parsed.kind, `${path}.kind`); - if (parsed.status === "pending") return { kind, status: "pending" }; - if (parsed.status === "workspace-ready") + if (status === "workspace-ready") return { kind, - status: "workspace-ready", + status, base: base(parsed.base, `${path}.base`), workspace: workspace( parsed.workspace, @@ -373,29 +304,33 @@ function phaseState(value: unknown, path: string): PlanningPhaseStateV1 { ) as PlanningWorkspaceOwnership<{ state: "ready" }>, artifacts: artifacts(parsed.artifacts, `${path}.artifacts`), }; - if (parsed.status === "branch-pushed") + if (status === "branch-pushed") { + const workspaceEvidence = workspace( + parsed.workspace, + `${path}.workspace`, + ) as PlanningWorkspaceOwnership<{ state: "ready" }>; return { kind, - status: "branch-pushed", + status, base: base(parsed.base, `${path}.base`), - workspace: workspace( - parsed.workspace, - `${path}.workspace`, - ) as PlanningWorkspaceOwnership<{ state: "ready" }>, + workspace: workspaceEvidence, artifacts: artifacts(parsed.artifacts, `${path}.artifacts`), publication: publication(parsed.publication, `${path}.publication`), }; - if (parsed.status === "pull-request-open") + } + if (status === "pull-request-open") { + const workspaceEvidence = workspace(parsed.workspace, `${path}.workspace`); return { kind, - status: "pull-request-open", + status, base: base(parsed.base, `${path}.base`), - workspace: workspace(parsed.workspace, `${path}.workspace`), + workspace: workspaceEvidence, artifacts: artifacts(parsed.artifacts, `${path}.artifacts`), publication: publication(parsed.publication, `${path}.publication`), pullRequest: pullRequest(parsed.pullRequest, `${path}.pullRequest`), }; - if (parsed.status !== "complete") fail("invalid-status", `${path}.status`); + } + if (status !== "complete") fail("invalid-status", `${path}.status`); const completion = parsed.completion as Record; if (completion?.kind === "remote-base") { object(completion, ["kind"], `${path}.completion`); @@ -445,11 +380,15 @@ function artifactEvidence( ): void { if (!("artifacts" in phase)) return; const complete = phase.status !== "workspace-ready"; - if ( - (complete && phase.artifacts.length !== assigned.length) || - phase.artifacts.some((artifact, index) => artifact.kind !== assigned[index]) - ) + if (complete && phase.artifacts.length !== assigned.length) fail("artifact-kinds", `${path}.artifacts`); + let previousAssignedIndex = -1; + for (const artifact of phase.artifacts) { + const assignedIndex = assigned.indexOf(artifact.kind); + if (assignedIndex <= previousAssignedIndex) + fail("artifact-kinds", `${path}.artifacts`); + previousAssignedIndex = assignedIndex; + } for (const [index, artifact] of phase.artifacts.entries()) { if ( phase.status === "complete" && @@ -468,7 +407,7 @@ function artifactEvidence( fail("remote-artifact-mismatch", `${path}.artifacts[${index}]`); } else if ( !("workspace" in phase) || - artifact.commitOid !== phase.workspace.headOid + artifact.commitOid === phase.base.baseOid ) fail("workspace-artifact-mismatch", `${path}.artifacts[${index}]`); } @@ -582,10 +521,14 @@ export function validatePlanningState(value: unknown): PlanningStateV1 { fail("publication-mismatch", `${path}.publication`); if ( "pullRequest" in item && - !sameRepositoryIdentity( + (!sameRepositoryIdentity( item.pullRequest.reference.targetRepository, item.publication.targetRepository, - ) + ) || + !pullRequestUrlMatchesReference( + item.pullRequest.url, + item.pullRequest.reference, + )) ) fail("pull-request-mismatch", `${path}.pullRequest`); if ( diff --git a/src/workflow/planning-state.test.ts b/src/workflow/planning-state.test.ts index 729f9384..d1476768 100644 --- a/src/workflow/planning-state.test.ts +++ b/src/workflow/planning-state.test.ts @@ -73,6 +73,68 @@ function state(phase: object, revision = 0) { }; } +test("accepts and preserves a non-prefix remote-base artifact subset", () => { + const planWorkspace = { + ...workspace(), + phase: "plan", + identity: { + branch: "planning/plan", + worktreePath: ".worktrees/188-plan", + }, + headOid: oid("a"), + }; + const current = validatePlanningState({ + version: 1, + workflowVersion: "planning-pr-v1", + runId, + issueNumber: 188, + issueTitle: "Example", + gates: { specRequired: false, planRequired: true }, + revision: 0, + createdAt: now, + updatedAt: now, + phases: [ + { + kind: "plan", + status: "workspace-ready", + base, + workspace: planWorkspace, + artifacts: [ + { + kind: "plan", + path: "docs/plans/example-issue-188.md", + source: "remote-base", + commitOid: oid("a"), + }, + ], + }, + { kind: "implementation", status: "pending" }, + ], + }); + const next = validatePlanningState({ + ...current, + revision: 1, + updatedAt: "2026-09-08T12:00:01.000Z", + phases: [ + { + ...current.phases[0], + workspace: { ...planWorkspace, headOid: oid("b") }, + artifacts: [ + { + kind: "spec", + path: "docs/specs/example-issue-188.md", + source: "workspace", + commitOid: oid("b"), + }, + ...current.phases[0].artifacts, + ], + }, + current.phases[1], + ], + }); + assert.doesNotThrow(() => assertPlanningStateReplacement(current, next)); +}); + test("round trips every durable planning publication checkpoint", () => { const documents = [ { kind: "spec", status: "pending" }, @@ -663,3 +725,442 @@ test("permits revision-stepped idempotence for pending and workspace-ready", () }; assert.doesNotThrow(() => assertPlanningStateReplacement(ready, readyRetry)); }); + +test("accepts implementation checkpoints only with immutable validated evidence", () => { + const implementationWorkspace = { + ...workspace(), + phase: "implementation", + identity: { + branch: "planning/implementation", + worktreePath: ".worktrees/188-implementation", + }, + headOid: oid("c"), + }; + const implementationPublication = { + ...publication, + headBranch: "planning/implementation", + headOid: oid("c"), + }; + const implementation = { + status: "pr-created", + prUrl: "https://github.com/acme/patchmill/pull/189", + branch: "planning/implementation", + commits: [oid("c")], + validation: ["npm test"], + visualEvidence: [], + }; + const implementationPullRequest = { + reference: { targetRepository: publication.targetRepository, number: 189 }, + url: "https://github.com/acme/patchmill/pull/189", + }; + const document = { + version: 1, + workflowVersion: "planning-pr-v1", + runId, + issueNumber: 188, + issueTitle: "Example", + gates: { specRequired: false, planRequired: false }, + phases: [ + { + kind: "implementation", + status: "pull-request-open", + base, + workspace: implementationWorkspace, + artifacts: [ + artifact("workspace", oid("c")), + { + kind: "plan", + path: "docs/plans/example-issue-188.md", + source: "workspace", + commitOid: oid("c"), + }, + ], + publication: implementationPublication, + pullRequest: implementationPullRequest, + implementation, + finish: {}, + }, + ], + revision: 0, + createdAt: now, + updatedAt: now, + }; + const initial = validatePlanningState(document); + assert.equal(initial.phases[0]!.status, "pull-request-open"); + const skippedFinish = structuredClone(document); + skippedFinish.revision = 1; + skippedFinish.updatedAt = "2026-09-08T12:00:01.000Z"; + skippedFinish.phases[0]!.finish = { + costPublicationCompleted: true, + visualEvidenceValidated: true, + handoffCommentPosted: true, + cleanupHookCompleted: true, + doneLabelEnsured: true, + doneLabelApplied: true, + }; + assert.throws( + () => + assertPlanningStateReplacement( + initial, + validatePlanningState(skippedFinish), + ), + PlanningStateValidationError, + ); + const cleanupBeforeHook = structuredClone(document); + cleanupBeforeHook.revision = 1; + cleanupBeforeHook.updatedAt = "2026-09-08T12:00:01.000Z"; + cleanupBeforeHook.phases[0]!.workspace.cleanup = { + state: "worktree-removed", + pushedHeadOid: oid("c"), + }; + assert.throws( + () => + assertPlanningStateReplacement( + initial, + validatePlanningState(cleanupBeforeHook), + ), + PlanningStateValidationError, + ); + const workspaceReady = structuredClone(document); + workspaceReady.phases[0]!.status = "workspace-ready"; + workspaceReady.phases[0]!.artifacts = [ + workspaceReady.phases[0]!.artifacts[0]!, + ]; + delete workspaceReady.phases[0]!.publication; + delete workspaceReady.phases[0]!.pullRequest; + delete workspaceReady.phases[0]!.implementation; + delete workspaceReady.phases[0]!.finish; + const appendedArtifact = structuredClone(workspaceReady); + appendedArtifact.revision = 1; + appendedArtifact.updatedAt = "2026-09-08T12:00:01.000Z"; + appendedArtifact.phases[0]!.workspace.headOid = oid("d"); + appendedArtifact.phases[0]!.artifacts[0]!.commitOid = oid("d"); + appendedArtifact.phases[0]!.artifacts.push({ + kind: "plan", + path: "docs/plans/example-issue-188.md", + source: "workspace", + commitOid: oid("d"), + }); + assert.throws( + () => + assertPlanningStateReplacement( + validatePlanningState(workspaceReady), + validatePlanningState(appendedArtifact), + ), + PlanningStateValidationError, + ); + const removed = structuredClone(document); + removed.phases[0]!.workspace.cleanup = { + state: "removed", + pushedHeadOid: oid("c"), + }; + removed.phases[0]!.finish = { + costPublicationCompleted: true, + visualEvidenceValidated: true, + handoffCommentPosted: true, + cleanupHookCompleted: true, + doneLabelEnsured: true, + doneLabelApplied: true, + }; + const terminal = structuredClone(removed); + terminal.revision = 1; + terminal.updatedAt = "2026-09-08T12:00:01.000Z"; + terminal.phases[0]!.status = "complete"; + terminal.phases[0]!.completion = { kind: "implementation-pull-request" }; + terminal.phases[0]!.artifacts[0]!.commitOid = oid("d"); + assert.throws( + () => + assertPlanningStateReplacement( + validatePlanningState(removed), + validatePlanningState(terminal), + ), + PlanningStateValidationError, + ); + const remoteBase = structuredClone(document); + remoteBase.phases[0]!.status = "complete"; + remoteBase.phases[0]!.completion = { kind: "remote-base" }; + delete remoteBase.phases[0]!.workspace; + delete remoteBase.phases[0]!.publication; + delete remoteBase.phases[0]!.pullRequest; + delete remoteBase.phases[0]!.implementation; + delete remoteBase.phases[0]!.finish; + assert.throws( + () => validatePlanningState(remoteBase), + (error: unknown) => + error instanceof PlanningStateValidationError && + error.reason === "implementation-completion", + ); +}); + +test("permits implementation workspace initialization and a newer branch-pushed head", () => { + const pending = validatePlanningState({ + version: 1, + workflowVersion: "planning-pr-v1", + runId, + issueNumber: 188, + issueTitle: "Example", + gates: { specRequired: false, planRequired: false }, + phases: [{ kind: "implementation", status: "pending" }], + revision: 0, + createdAt: now, + updatedAt: now, + }); + const workspaceReady = validatePlanningState({ + ...pending, + revision: 1, + updatedAt: "2026-09-08T12:00:01.000Z", + phases: [ + { + kind: "implementation", + status: "workspace-ready", + base, + workspace: { + ...workspace(), + phase: "implementation", + identity: { + branch: "planning/implementation", + worktreePath: ".worktrees/188-implementation", + }, + }, + artifacts: [ + artifact("workspace", oid("b")), + { + kind: "plan", + path: "docs/plans/example-issue-188.md", + source: "workspace", + commitOid: oid("b"), + }, + ], + }, + ], + }); + assert.doesNotThrow(() => + assertPlanningStateReplacement(pending, workspaceReady), + ); + const branchPushed = validatePlanningState({ + ...workspaceReady, + revision: 2, + updatedAt: "2026-09-08T12:00:02.000Z", + phases: [ + { + ...workspaceReady.phases[0]!, + status: "branch-pushed", + workspace: { + ...(workspaceReady.phases[0] as { workspace: object }).workspace, + headOid: oid("c"), + }, + publication: { + ...publication, + headBranch: "planning/implementation", + headOid: oid("c"), + }, + implementation: { + status: "pr-created", + prUrl: "https://github.com/acme/patchmill/pull/189", + branch: "planning/implementation", + commits: [oid("c")], + validation: ["npm test"], + visualEvidence: [], + }, + }, + ], + }); + assert.doesNotThrow(() => + assertPlanningStateReplacement(workspaceReady, branchPushed), + ); +}); + +test("requires an empty finish checkpoint when implementation PR validation opens", () => { + const current = validatePlanningState({ + version: 1, + workflowVersion: "planning-pr-v1", + runId, + issueNumber: 188, + issueTitle: "Example", + gates: { specRequired: false, planRequired: false }, + phases: [ + { + kind: "implementation", + status: "branch-pushed", + base, + workspace: { + ...workspace(), + phase: "implementation", + identity: { + branch: "planning/implementation", + worktreePath: ".worktrees/188-implementation", + }, + headOid: oid("c"), + }, + artifacts: [ + artifact("workspace", oid("c")), + { + kind: "plan", + path: "docs/plans/example-issue-188.md", + source: "workspace", + commitOid: oid("c"), + }, + ], + publication: { + ...publication, + headBranch: "planning/implementation", + headOid: oid("c"), + }, + implementation: { + status: "pr-created", + prUrl: "https://github.com/acme/patchmill/pull/189", + branch: "planning/implementation", + commits: [oid("c")], + validation: ["npm test"], + visualEvidence: [], + }, + }, + ], + revision: 0, + createdAt: now, + updatedAt: now, + }); + const next = validatePlanningState({ + ...current, + revision: 1, + updatedAt: "2026-09-08T12:00:01.000Z", + phases: [ + { + ...current.phases[0]!, + status: "pull-request-open", + pullRequest: { + reference: { + targetRepository: publication.targetRepository, + number: 189, + }, + url: "https://github.com/acme/patchmill/pull/189", + }, + finish: { costPublicationCompleted: true }, + }, + ], + }); + assert.throws( + () => assertPlanningStateReplacement(current, next), + PlanningStateValidationError, + ); + const preHookCheckpoint = structuredClone(next); + preHookCheckpoint.phases[0]!.finish = { + costPublicationCompleted: true, + visualEvidenceValidated: true, + handoffCommentPosted: true, + cleanupHookStarted: true, + }; + assert.throws( + () => validatePlanningState(preHookCheckpoint), + (error: unknown) => + error instanceof PlanningStateValidationError && + error.reason === "unknown-key" && + error.path.endsWith(".cleanupHookStarted"), + ); +}); + +test("preserves complete sanitized implementation agent evidence", () => { + const document = { + version: 1, + workflowVersion: "planning-pr-v1", + runId, + issueNumber: 188, + issueTitle: "Example", + gates: { specRequired: false, planRequired: false }, + phases: [ + { + kind: "implementation", + status: "branch-pushed", + base, + workspace: { + ...workspace(), + phase: "implementation", + identity: { + branch: "planning/implementation", + worktreePath: ".worktrees/188-implementation", + }, + headOid: oid("c"), + }, + artifacts: [ + artifact("workspace", oid("c")), + { + kind: "plan", + path: "docs/plans/example-issue-188.md", + source: "workspace", + commitOid: oid("c"), + }, + ], + publication: { + ...publication, + headBranch: "planning/implementation", + headOid: oid("c"), + }, + implementation: { + status: "pr-created", + prUrl: "https://github.com/acme/patchmill/pull/189", + branch: "planning/implementation", + commits: [oid("c")], + validation: ["npm test"], + reviewSummary: "reviewed", + landingDecision: "PR required", + visualEvidence: [ + { + screenshotPath: "docs/screenshots/result.png", + caption: "Result", + referencePaths: ["docs/screenshots/before.png"], + url: "https://example.test/evidence/189", + }, + ], + runCostReport: { + stages: [ + { + stage: "implementation", + models: [ + { + model: "example/model", + promptTokens: 1, + outputTokens: 2, + estimatedCostUsd: 0.03, + }, + ], + promptTokens: 1, + outputTokens: 2, + estimatedCostUsd: 0.03, + }, + ], + promptTokens: 1, + outputTokens: 2, + estimatedCostUsd: 0.03, + }, + }, + }, + ], + revision: 0, + createdAt: now, + updatedAt: now, + }; + const parsed = validatePlanningState(document); + assert.deepEqual( + (parsed.phases[0] as { implementation: unknown }).implementation, + document.phases[0]!.implementation, + ); + const blankValidation = structuredClone(document); + blankValidation.phases[0]!.implementation.validation = [" "]; + assert.throws( + () => validatePlanningState(blankValidation), + (error: unknown) => + error instanceof PlanningStateValidationError && + error.reason === "invalid-string" && + error.path === "$.phases[0].implementation.validation[0]", + ); + const unsafe = structuredClone(document); + unsafe.phases[0]!.implementation.prUrl = + "https://token@example.test/pull/189"; + assert.throws( + () => validatePlanningState(unsafe), + (error: unknown) => + error instanceof PlanningStateValidationError && + error.reason === "invalid-url" && + error.path === "$.phases[0].implementation.prUrl", + ); +}); diff --git a/src/workflow/skill-pack.ts b/src/workflow/skill-pack.ts index 7199d230..35a16988 100644 --- a/src/workflow/skill-pack.ts +++ b/src/workflow/skill-pack.ts @@ -1,4 +1,4 @@ -import { createHash, type BinaryLike } from "node:crypto"; +import { createHash } from "node:crypto"; import { BUNDLED_PATCHMILL_SKILLS, bundledSkillByKey, @@ -152,7 +152,10 @@ export function buildRecommendedProjectSkillConfig( }; } -export function hashContent(content: BinaryLike): string { +/** Matches the byte-view input accepted by createHash.update. */ +type HashContentInput = Parameters["update"]>[0]; + +export function hashContent(content: HashContentInput): string { return createHash("sha256").update(content).digest("hex"); } diff --git a/test-support/run-once/pipeline-fixtures.ts b/test-support/run-once/pipeline-fixtures.ts index 42626c76..27e09b18 100644 --- a/test-support/run-once/pipeline-fixtures.ts +++ b/test-support/run-once/pipeline-fixtures.ts @@ -5,7 +5,7 @@ import { DEFAULT_PATCHMILL_CONFIG } from "../../src/config/defaults.ts"; import { DEFAULT_PATCHMILL_POLICY } from "../../src/policy/defaults.ts"; import { createPatchmillLabelCatalog } from "../../src/policy/label-catalog.ts"; import { createWorkflowApprovalPolicy } from "../../src/workflow/approval-policy.ts"; -import { runOneIssue } from "../../src/cli/commands/run-once/pipeline.ts"; +import { runLegacyOneIssue as runOneIssue } from "../../src/cli/commands/run-once/pipeline-legacy.ts"; import { runStatePath, writeRunState,