From 21a07be823eb3f7a2b5ec70f11b030f0f134dc54 Mon Sep 17 00:00:00 2001 From: d3cker Date: Thu, 17 Sep 2026 09:16:11 +0200 Subject: [PATCH] fix: publish completion summaries in PR descriptions --- AGENTS.md | 3 +- CHANGELOG.md | 5 ++ README.md | 4 ++ docs/advanced.md | 34 +++++++++++++- docs/architecture.md | 8 +++- docs/bot-workflow.md | 71 ++++++++++++++++++++-------- docs/runtime.md | 22 +++++++++ prompts/bot.md | 6 +++ src/dispatcher.ts | 38 ++++++++++++--- src/executor.ts | 16 +++++++ src/github.ts | 21 ++++++++- src/pr-description.ts | 61 ++++++++++++++++++++++++ test/core.test.ts | 60 ++++++++++++++++++++++- test/executor.test.ts | 13 +++++ test/pr-description.test.ts | 94 +++++++++++++++++++++++++++++++++++++ 15 files changed, 425 insertions(+), 31 deletions(-) create mode 100644 src/pr-description.ts create mode 100644 test/pr-description.test.ts diff --git a/AGENTS.md b/AGENTS.md index 997a9a7..a8d9fb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,8 @@ the installation block without making remote writes. Keep its markers intact. `src/scheduler.ts` owns interval jobs; `src/state.ts` owns persistence and locks. - `src/executor.ts` owns analysis, base selection, worktrees, session execution, verification, and pushing. `src/analysis.ts` and `src/branch.ts` validate model - decisions. `src/github.ts` implements GitHub calls; `src/approval.ts` evaluates + decisions. `src/pr-description.ts` extracts final public reports and renders and + reconciles managed PR descriptions. `src/github.ts` implements GitHub calls; `src/approval.ts` evaluates approval candidates. - `src/runtime.ts`, `src/worker.ts`, and `src/bridge.ts` implement worker hooks, runtime installation, and communication with the owner. `src/prompt.ts` loads diff --git a/CHANGELOG.md b/CHANGELOG.md index 01fcdc6..3f175d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ include the full version, for example `## 0.7.0-beta.1`. ### Fixed +- Use the successful session's final completion report as the PR description instead + of its initial acknowledgement. Persist reports across restarts, retain the + original summary plus the latest follow-up, distinguish dispatcher checks from + agent-reported tests, and preserve manual notes outside the managed section. + - Reconcile timed-out or interrupted sessions completed manually after a blocked task or service restart. Verify and publish through the dispatcher, then process queued issue feedback on the same branch and PR, including legacy checkpoints. diff --git a/README.md b/README.md index ea5daf8..89c87e5 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,10 @@ installations are not removed by `npm uninstall --global`. `resume` commands from the target repository. Closing a PR closes its bot tabs while retaining session history. Authorized issue comments can continue work on an open PR without another mention, after the current round publishes. +- **PR descriptions:** the successful session's final summary appears in the PR, + with dispatcher checks listed separately. Follow-ups keep the original report + and replace **Latest update**. Keep manual notes outside the managed HTML markers. + See [PR descriptions](docs/runtime.md#pr-descriptions). - **Recovery:** completing a stopped bot session manually is detected by the dispatcher, which verifies and publishes before processing queued comments. Use `/restartworkflow` in the owner project's TUI or diff --git a/docs/advanced.md b/docs/advanced.md index 2d339db..54095ad 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -30,7 +30,7 @@ OpenCode service must be running for polling to work. | `stateDirectory` | Shared location for queues, locks, and worktrees. Keep it consistent across components and restarts. | | `repositories` | Repositories with existing local checkouts, default base branches, allowed authors, and checks. A natural-language request can override the base before work starts. | | `allowedAuthors` | GitHub users authorized to request work and approve merging. Merging also requires repository write access. | -| `checks` | Arrays of executable arguments, e.g. `[["npm", "test"]]`. `[]` skips automated tests and reports that in the PR. No implicit shell. | +| `checks` | Arrays of executable arguments, e.g. `[["npm", "test"]]`. `[]` skips dispatcher test commands; the PR distinguishes this from agent-reported tests. No implicit shell. | | `routes` | Maps full mentions to agents and models available in OpenCode. | | `routes[tag].capabilities` | Main model capabilities: `text`, `vision`, `audio`; omitted means text only. | | `routes[tag].mediaModel` | `{ model: { providerID, id }, capabilities: ["text", "vision"] }` for the media helper. | @@ -232,3 +232,35 @@ See [runtime management](runtime.md#manage-tasks-from-bot) for the UI and limits While a closure is pending, the dispatcher does not start another worker pass. An unrelated already-running task can finish; scanning continues for other tasks. The monitor reports task maintenance until closure completes. + + +## PR description recovery + +Task state stores `completion` (final public text or an explicit unavailable +reason, session, round, then verified commit and checks), `initialCompletion`, +and `publishedBody` (the last acknowledged managed section). New rounds clear +only the current completion. Older verifying/publishing tasks recover missing +summaries from their saved sessions; this does not rerun the model. Missing or +empty successful reports are marked unavailable, while transient reads retry. +Already completed or closed tasks are not bulk rewritten on upgrade. + +Publication reads the PR at the verified head and only replaces the managed HTML +marker region. Notes outside it are preserved. An exact known legacy body can be +replaced; unknown unmarked text is retained with the new section appended, since +it might contain manual edits. A later legacy round may not have enough saved +information to identify its old acknowledgement exactly. + +An edited/removed managed section, changed PR head, closed follow-up PR, or oversized +body blocks at `publishing`. Preserve your notes outside the markers and restore +the previous managed section from `publishedBody` in the task checkpoint (or PR +edit history), then use the normal workflow retry. Do not delete the queue or +restart implementation just to retry a description update. If a PATCH succeeded +but its response was lost, matching desired content is accepted without another +write. Body and head are reread before PATCH; edits after that final read cannot +be atomically excluded by this implementation. + +Each rendered report is limited to 22,000 UTF-8 bytes with an explicit truncation +notice; full saved text remains in task state and the session. Dispatcher check +text is limited to 8,000 bytes. The complete description, including retained +notes and signature, must fit within the automation limit of 60,000 bytes or +publication blocks without dropping notes. Titles are not regenerated on updates. diff --git a/docs/architecture.md b/docs/architecture.md index 5e5f226..352f5e9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,10 +31,14 @@ loads a generic scheduler, a GitHub dispatcher, and a terminal UI component. 3. Publish a signed acknowledgement, resolve the base branch, and pin that choice. 4. Create or reuse the task worktree and checkpoint the session identity before prompting the executor. The executor must not publish directly. -5. Validate session success, verify changes, then push and create or reconcile the - PR. Generate its title only if creating a PR without an already-saved title. +5. Validate session success and save the final public completion report. Verify + changes, bind the report to the verified commit, then push and create or reconcile + the PR with that report and separate dispatcher checks. Generate its title only + if creating a PR without an already-saved title. 6. After publication, process queued authorized issue comments as new rounds on the same worktree and branch, with a new main session and the existing open PR. + Keep the original PR report and update its Latest update section after pushing; + preserve manual notes outside the managed description. 7. Merge only after eligible approval of the published head, repository permission checks, and GitHub merge readiness checks. Post a signed acknowledgement. diff --git a/docs/bot-workflow.md b/docs/bot-workflow.md index b6e1b49..7d8731f 100644 --- a/docs/bot-workflow.md +++ b/docs/bot-workflow.md @@ -195,7 +195,8 @@ flowchart TD Stopped -->|Manual continuation succeeds and probe passes| R Stopped -->|Explicit restartworkflow| Recover[Persist recovery intent, rejoin the same session] Recover --> R - R -->|Validated success, no unresolved question| V[verifying: configured checks and commit] + R -->|Validated success, no unresolved question| Report[Persist final public assistant summary with session and round] + Report --> V[verifying: configured checks and commit] V --> P[publishing: reconcile or create PR, push when required] V -->|Failed check or Git consistency guard| VB[verifying / blocked] VB -->|Operator retries saved stage| V @@ -311,6 +312,7 @@ sequenceDiagram else Wait completes D->>S: Read context and final outcome alt Valid task marker, admitted recovery marker if required, and successful final assistant + D->>D: Save final public assistant text with session and round D->>D: Clear recovery state and advance to verifying else Unsuccessful final outcome or assistant D->>D: Save session-stop block for later reconciliation @@ -415,7 +417,8 @@ Source: [runtime.ts — inspect_media](../src/runtime.ts). ```mermaid flowchart TD - Start[Validated session success or retry of verifying phase] --> Identity[Require saved workspace and base, exact managed root, branch and shared repository] + Start[Validated session success or retry of verifying phase] --> Report[Reuse saved completion summary or recover from saved session] + Report --> Identity[Require saved workspace and base, exact managed root, branch and shared repository] Identity --> Base[Require baseSha ancestor of HEAD and no unresolved conflicts] Base --> Checks[Run configured checks sequentially, or none if list empty] Checks -->|Configured check fails| Block[blocked at saved phase, retain work] @@ -426,22 +429,28 @@ flowchart TD Identity -->|Explicit consistency guard fails| Block Base -->|Unresolved conflicts| Block Validate -->|Explicit consistency guard fails| Block - Validate -->|Pass| Save[Persist checks and exact commit SHA, phase publishing] - Retry[Retry saved publishing phase] --> Find - Save --> Find[Find branch PR including closed PRs] + Validate -->|Pass| Save[Persist summary, checks and exact commit SHA, phase publishing] + Retry[Retry saved publishing phase] --> Body + Save --> Body[Recover missing legacy summary, save original report and render body] + Body --> Find[Find branch PR including closed PRs] Find --> Follow{Follow-up round?} Follow -->|Yes| Open{Existing PR open?} Open -->|No| Block Open -->|Yes| Push[Validate origin, workspace, saved HEAD and clean tree, push exact SHA] Follow -->|No| Exists{PR already exists?} - Exists -->|Yes| Done[Record PR and publication time, pr_opened / done] + Exists -->|Yes| Closed{PR closed?} + Closed -->|Yes| Done[Record PR and publication time, pr_opened / done] + Closed -->|No| Description Exists -->|No| Title[Generate title only if no saved prTitle] Title --> Issue{Issue still open?} Issue -->|No| Block Issue -->|Yes| PushNew[Validate origin and workspace, push exact verified SHA] PushNew --> Create[Create or reconcile signed PR against pinned base] - Create --> Done - Push --> Done + Create --> Description[Read open PR at verified SHA, reconcile managed description] + Push --> Description + Description -->|Edited managed block, changed head or oversized body| Block + Description -->|Unchanged or update succeeds| Acknowledge[Persist published body checkpoint] + Acknowledge --> Done Failure[Other command, model or transport error] --> Policy[Keep current phase and apply retry policy in section 8] Close[Operator closes task before publishing starts] --> Drain[Finish in-flight local operation, reject next checkpoint] Drain --> Preserve[Do not publish, preserve existing local changes] @@ -455,15 +464,36 @@ pushed branch does not by itself make a task complete. The configured checks are command argument arrays. A failing configured check produces `blocked`. With no configured checks, only Git consistency checks run; -the PR explicitly states that automated tests were not run. Commit hooks changing +the PR explicitly says the dispatcher did not independently rerun agent-reported +tests. Commit hooks changing the recorded tree, a dirty worktree after commit, or no diff from the base block publication. Other command failures use the general error policy below. -The PR body contains the analysis, `Closes #N`, checks, session ID, and verified -commit SHA. Push uses `COMMIT:refs/heads/TASK_BRANCH` without force. The first -publication reconciles an existing branch PR by recording it without another -push; follow-ups require an open PR and push the new verified commit. Follow-ups -do not regenerate the existing PR title or body. +The PR body uses the final public text of the successful assistant response, +with its Markdown preserved, rather than the pre-work analysis acknowledgement. +The executor saves it with the session and round before verification; verification +adds the checks and exact commit to the same snapshot. No extra model call rewrites +the report. Reasoning, tools, and failed or unfinished responses are excluded. +Missing legacy snapshots are read from the saved session; a missing session or +empty response produces an explicit summary-unavailable notice, never analysis +as a fallback. Transport errors retain the stage for retry. + +The body keeps the original report and replaces one **Latest update** section on +follow-ups. Dispatcher checks appear separately from agent-reported tests, followed +by `Closes #N`, session, round and verified commit. Push uses +`COMMIT:refs/heads/TASK_BRANCH` without force. The first publication reconciles an +existing branch PR without another push; follow-ups require an open PR and push +the new verified commit before updating its description. The title is retained. +An already closed first-round PR is recorded without editing its description. + +A stable HTML marker pair encloses the bot-managed description. Notes outside +it are preserved; an edited or removed managed section blocks publication rather +than overwriting it. The last acknowledged body is checkpointed, so a lost update +response can be reconciled without duplicate sections. Exact legacy descriptions +can be replaced; otherwise unmarked content is retained and the managed section +appended. GitHub is reread before writing to detect concurrent edits, although +there is no atomic compare-and-swap across that read and write. See +[publication recovery and limits](advanced.md#pr-description-recovery) for details. Signed comments use stable `opencode2` markers; reconciliation looks for a marker posted by the authenticated account. This covers analysis acknowledgements, @@ -471,7 +501,8 @@ questions, and merge acknowledgements after a lost response. Sources: [executor.ts — GitWorkspace.verify, push, title](../src/executor.ts), [dispatcher.ts — publishing](../src/dispatcher.ts), -[github.ts — ensureComment, ensurePull](../src/github.ts). +[github.ts — ensureComment, ensurePull, updatePullBody](../src/github.ts), +[pr-description.ts — extraction, rendering and reconciliation](../src/pr-description.ts). ## 7. Feedback, merge approval, and tab closure @@ -482,7 +513,8 @@ flowchart TD Keep --> Recovery[Session recovery and publication must finish first] Recovery --> Done Done -->|Yes| Round[Next worker pass starts one new round on saved branch and worktree] - Round --> Guard[Require open issue, open original PR and authorized feedback, then analyze again] + Round --> Snapshot[Retain original report and published body, reset current completion] + Snapshot --> Guard[Require open issue, open original PR and authorized feedback, then analyze again] Idle[Worker has no eligible execution task] --> Eligible{Auto-merge enabled and done task eligible?} Eligible -->|No| Later[Wait for a later worker pass] Eligible -->|Yes| Since{publishedAt exists?} @@ -549,7 +581,8 @@ or a request failure records `mergeError`; error retries also respect GitHub tim An already-merged response can reconcile a previously lost merge response. Follow-up rounds reset analysis, question, current session, session-stop/recovery -state, checks, and commit; they retain the branch, worktree, pinned base, and previous session reference. +state, current completion summary, checks, and commit; they retain the original +report, last published body, branch, worktree, pinned base, and previous session reference. Preparation reuses the saved worktree path rather than deriving a new path from the branch name. A renamed branch can therefore retain its original directory. Preparation, verification, and push all check the managed path, exact Git root, @@ -595,7 +628,7 @@ An error normally preserves the phase so retry continues from its checkpoint. | `ready` | Eligible for worker selection when due. | | `waiting` | Awaiting an issue answer; no implementation or publication while unresolved. | | `retry_wait` | Transient failure; automatic retry after `nextAt`. | -| `blocked` | Explicit `Blocked` error or GitHub HTTP 401, 404, or 422; requires inspection/retry, except a stopped session completed manually is reconciled automatically. | +| `blocked` | Explicit `Blocked` or PR-description conflict, or GitHub HTTP 401, 404, or 422; requires inspection/retry, except a stopped session completed manually is reconciled automatically. | | `failed` | Other errors reached `maxAttempts`; operator recovery/retry required unless the checkpoint also qualifies as a stopped-session recovery candidate. | | `done` | PR publication/reconciliation completed; feedback and merge monitoring remain possible. | | `closing` | Operator requested end of tracking; interrupt sessions and drain in-flight work, retaining errors for retry. | @@ -606,7 +639,7 @@ flowchart TD Work[Execute saved phase] --> Result{Result?} Result -->|WaitingForAnswer| Wait[waiting, or ready if answer already arrived] Result -->|SessionStopped| Stop[running / blocked, sessionStopped true] - Result -->|Other Blocked or GitHub 401, 404, 422| Block[blocked at saved phase] + Result -->|Other Blocked, description conflict or GitHub 401, 404, 422| Block[blocked at saved phase] Result -->|Other failure below attempt limit| Retry[retry_wait at saved phase] Retry -->|nextAt elapsed| Work Result -->|Other failure at limit| Fail[failed at saved phase] diff --git a/docs/runtime.md b/docs/runtime.md index 84a2444..1238325 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -378,6 +378,28 @@ owner location and updated plugin are available there. Live worker/scan diagnost reset when the owner is recreated; task checkpoints and scheduler history remain durable as before. +## PR descriptions + +The PR contains the agent's final completion summary from the successful session, +with Markdown preserved, followed by dispatcher verification, the issue reference, +session, round and verified commit. The initial issue acknowledgement is not a +completion report. Agent-reported tests remain in the summary; the dispatcher +lists only checks it actually ran. With no configured test command it explicitly +states that agent-reported tests were not independently rerun. + +Follow-up rounds keep the original summary and replace a single **Latest update** +section after the new commit is pushed. The PR title stays unchanged. Reports are +saved before publication so a restart or lost GitHub response can reuse them. +If a legacy session is missing or has no successful final text, the description +states that its summary is unavailable. + +Put manual PR notes outside the `opencode2:pr-body` HTML markers (visible when +editing the description). Edits inside that section or removal of the markers +block further description updates to protect your changes. Inspect the task error, +resolve the conflict and retry publication; see +[description recovery](advanced.md#pr-description-recovery). Installing an update +does not automatically rewrite already completed or closed PRs. + ## Interrupted sessions and workflow recovery If you manually continue a timed-out or interrupted bot session in the TUI, diff --git a/prompts/bot.md b/prompts/bot.md index 34ce3ec..f356390 100644 --- a/prompts/bot.md +++ b/prompts/bot.md @@ -184,6 +184,12 @@ repository inspection in the implementation session. ## Final report +The dispatcher copies the final public text of a successfully completed session +into the PR description with Markdown preserved. Write a review-ready report of +completed work, not an acknowledgement or a promise to begin. On follow-ups, report +what changed in this round; the original report remains in the PR and this report +becomes its Latest update. Do not include private reasoning or raw tool transcripts. + Finish an implementation session with a concise English summary covering: - The behavior delivered. diff --git a/src/dispatcher.ts b/src/dispatcher.ts index 91bbbd7..1da39b8 100644 --- a/src/dispatcher.ts +++ b/src/dispatcher.ts @@ -6,6 +6,7 @@ import { Serial, redact, type Store } from "./state.js"; import { branchText, type BranchInput, type BaseChoice } from "./branch.js"; import { activityOf, type Activity } from "./activity.js"; import type { DispatcherMonitor } from "./monitor.js"; +import { CompletionSummary, DescriptionConflict, renderDescription } from "./pr-description.js"; import { AnalysisDecision } from "./analysis.js"; export const PendingQuestion = z.object({ id: z.string(), text: z.string(), sessionID: z.string().optional(), purpose: z.enum(["base", "analysis"]).optional(), commentID: z.number().optional(), @@ -35,6 +36,8 @@ export const Task = z.object({ previousSessionID: z.string().optional(), checks: z.array(z.string()).optional(), commit: z.string().optional(), publishedAt: z.number().optional(), merged: z.boolean().optional(), mergeError: z.string().optional(), mergeNextAt: z.number().optional(), + completion: CompletionSummary.optional(), initialCompletion: CompletionSummary.optional(), + publishedBody: z.string().optional(), prTitle: z.string().min(1).max(240).optional(), pr: z.object({ number: z.number(), html_url: z.string(), state: z.string() }).optional(), error: z.string().optional(), }); @@ -64,12 +67,14 @@ export interface GithubPort { ensureComment(repo: string, number: number, marker: string, body: string): Promise; findPull(repo: string, branch: string): Promise; pull(repo: string, number: number): Promise; + updatePullBody(repo: string, number: number, commit: string, key: string, body: string, previous?: string, legacy?: string): Promise; ensurePull(repo: string, branch: string, base: string, title: string, body: string): Promise; } export interface Executor { selectBase(task: Task, repo: Repository, inputs: BranchInput[]): Promise; hasBranch(repo: Repository, branch: string): Promise; analyze(task: Task): Promise; + summary(task: Task): Promise; title(task: Task): Promise; prepare(task: Task, repo: Repository): Promise<{ worktree: string; baseSha: string }>; run(task: Task, checkpoint: (patch: Partial) => Promise): Promise; @@ -245,7 +250,7 @@ export class Dispatcher { Object.assign(finished, { round: (finished.round ?? 1) + 1, feedback: finished.pendingFeedback, pendingFeedback: [], previousSessionID: finished.sessionID, phase: "queued", status: "ready", attempts: 0, nextAt: this.now(), analysis: undefined, commentID: undefined, analysisDecision: undefined, analysisDialogue: undefined, question: undefined, - sessionID: undefined, sessionReady: false, promptAttempted: false, sessionStopped: undefined, recovery: undefined, checks: undefined, commit: undefined, error: undefined }); + sessionID: undefined, sessionReady: false, promptAttempted: false, sessionStopped: undefined, recovery: undefined, completion: undefined, checks: undefined, commit: undefined, error: undefined }); await this.store.save(this.queue); }); const resumable = this.queue.tasks.filter(t => ["ready", "retry_wait"].includes(t.status)); @@ -298,16 +303,28 @@ export class Dispatcher { requireTracked(task); await this.executor.run(task, patch => this.update(task, patch)); if (task.question && !task.question.delivered) throw new WaitingForAnswer("Waiting for a reply in the GitHub issue"); + await this.captureCompletion(task); await this.update(task, { phase: "verifying", attempts: 0, sessionStopped: undefined, recovery: undefined }); } if (task.phase === "verifying") { requireTracked(task); + await this.captureCompletion(task); const result = await this.executor.verify(task, repo); - await this.update(task, { ...result, phase: "publishing", attempts: 0 }); + await this.update(task, { ...result, completion: { ...task.completion!, ...result }, phase: "publishing", attempts: 0 }); } if (task.phase === "publishing") { requireTracked(task); this.publishing.add(task.key); + await this.captureCompletion(task); + if (task.completion?.commit !== task.commit) await this.update(task, { completion: { ...task.completion!, commit: task.commit, checks: task.checks ?? [] } }); + if (!task.initialCompletion) { + const earlier = task.sessionIDs?.find(id => id !== task.sessionID) ?? task.previousSessionID; + const initial = !followup ? task.completion! : earlier + ? await this.executor.summary({ ...task, sessionID: earlier, round: undefined }) + : { unavailable: "The original completion session is not recorded." }; + await this.update(task, { initialCompletion: initial }); + } + const body = renderDescription(task.key, task.issue.number, task.initialCompletion!, task.completion!); let pr = await this.github.findPull(task.repo, task.branch); if (followup && (!pr || pr.state !== "open")) throw new Blocked("The original PR is no longer open; changes remain in the worktree"); if (followup && pr) await this.executor.push(task, repo); @@ -315,18 +332,27 @@ export class Dispatcher { if (!task.prTitle) await this.update(task, { prTitle: await this.executor.title(task) }); if ((await this.github.issue(task.repo, task.issue.number)).state !== "open") throw new Blocked("Issue closed before PR publication"); await this.executor.push(task, repo); - pr = await this.github.ensurePull(task.repo, task.branch, repo.baseBranch, task.prTitle!, `${task.analysis}\n\nCloses #${task.issue.number}\n\nChecks:\n${task.checks?.length ? task.checks.map(c => `- ${c}`).join("\n") : "- Automated tests were not run: no test command configured. Only Git consistency checks were performed."}\n\nOpenCode session: ${task.sessionID}\nCommit: ${task.commit}`); + pr = await this.github.ensurePull(task.repo, task.branch, repo.baseBranch, task.prTitle!, body); + } + if (pr.state === "open") { + const legacy = `${task.analysis}\n\nCloses #${task.issue.number}\n\nChecks:\n${task.checks?.length ? task.checks.map(c => `- ${c}`).join("\n") : "- Automated tests were not run: no test command configured. Only Git consistency checks were performed."}\n\nOpenCode session: ${task.sessionID}\nCommit: ${task.commit}`; + await this.github.updatePullBody(task.repo, pr.number, task.commit!, task.key, body, task.publishedBody, legacy); } - await this.update(task, { pr, publishedAt: this.now(), phase: "pr_opened", status: "done", attempts: 0 }); + await this.update(task, { publishedBody: pr.state === "open" ? body : task.publishedBody, pr, publishedAt: this.now(), phase: "pr_opened", status: "done", attempts: 0 }); } } catch (error) { if (this.signal.aborted || closing(task) || error instanceof TaskClosed) return; if (error instanceof WaitingForAnswer) { await this.update(task, { status: task.question?.answer ? "ready" : "waiting", error: undefined }); return; } const attempts = task.attempts + 1; - const blocked = error instanceof Blocked || error instanceof GithubError && [401, 404, 422].includes(error.status); + const blocked = error instanceof Blocked || error instanceof DescriptionConflict || error instanceof GithubError && [401, 404, 422].includes(error.status); await this.update(task, { attempts, sessionStopped: error instanceof SessionStopped, error: redact(error, this.secrets), status: blocked ? "blocked" : attempts >= this.options.maxAttempts ? "failed" : "retry_wait", nextAt: Math.max(this.now() + Math.min(3600, 5 * 2 ** attempts) * 1000, error instanceof GithubError ? error.retryAt ?? 0 : 0) }); } finally { this.publishing.delete(task.key); } } + private async captureCompletion(task: Task) { + if (task.completion?.sessionID === task.sessionID && task.completion?.round === (task.round ?? 1)) return; + const completion = await this.executor.summary(task); + await this.update(task, { completion: { ...completion, ...(task.sessionID ? { sessionID: task.sessionID } : {}), round: task.round ?? 1 } }); + } private async resolveAnalysis(task: Task, repo: Repository) { await this.update(task, { phase: "analyzing" }); if (task.analysisDialogue?.some(d => !this.authorized(d.answer.user.login, repo.allowedAuthors))) throw new Blocked("A clarification reply author is no longer authorized"); @@ -486,7 +512,7 @@ export class Dispatcher { if (!task || !["blocked", "failed"].includes(task.status)) return false; if (restartSession) { await this.executor.cancel(task); - await this.update(task, { sessionID: undefined, promptAttempted: undefined, phase: task.commentID ? "commented" : "queued", analysis: task.commentID ? task.analysis : undefined }); + await this.update(task, { sessionID: undefined, completion: undefined, promptAttempted: undefined, phase: task.commentID ? "commented" : "queued", analysis: task.commentID ? task.analysis : undefined }); } if (!task.route) { const latest = await this.github.issue(task.repo, task.issue.number); diff --git a/src/executor.ts b/src/executor.ts index 6d0eae5..13b4e55 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -5,6 +5,7 @@ import { dirname, join, resolve } from "node:path"; import { randomUUID, createHash } from "node:crypto"; import type { GithubOptions, Repository } from "./config.js"; import { botPrompt } from "./prompt.js"; +import { finalReport, type CompletionSummary } from "./pr-description.js"; import { analysisDecision } from "./analysis.js"; import { baseChoice, type BranchInput } from "./branch.js"; import { installWorkerPlugin } from "./worker.js"; @@ -114,6 +115,20 @@ export class OpenCodeExecutor implements Executor { this.ctx = { ...ctx, ...(ctx.session ? { session: cancellable(ctx.session) } : {}), ...(ctx.generate ? { generate: cancellable(ctx.generate) } : {}) }; this.git = new GitWorkspace(options.stateDirectory, commandRunner(signal, options.commandTimeoutSeconds * 1000, options.tokenEnv)); } + async summary(task: Task): Promise { + const identity = { ...(task.sessionID ? { sessionID: task.sessionID } : {}), ...(task.round ? { round: task.round } : {}) }; + if (!task.sessionID) return { ...identity, unavailable: "No completion session was saved." }; + try { + const request = { signal: AbortSignal.any([this.signal, AbortSignal.timeout(15_000)]) }; + const session = await this.ctx.session.get({ sessionID: task.sessionID }, request); + const messages = await this.ctx.session.context({ sessionID: task.sessionID }, request); + return { ...identity, ...finalReport(messages, session.outcome) }; + } catch (error) { + this.signal.throwIfAborted(); + if (isNotFound(error)) return { ...identity, unavailable: "The saved completion session is no longer available." }; + throw error; // Retry transient transport failures without losing an available report. + } + } async title(task: Task) { if (!task.route || !task.sessionID) throw new Blocked("Missing session for PR title assessment"); const request = { signal: AbortSignal.any([this.signal, AbortSignal.timeout(120_000)]) }; @@ -257,6 +272,7 @@ export class OpenCodeExecutor implements Executor { session = await sessions.get({ sessionID }, request); const last = messages.filter(m => m.type === "assistant").at(-1); if (session.outcome !== "succeeded" || !last || last.error || last.finish !== "stop") throw new SessionStopped("Session did not complete successfully; continue the session or use /restartworkflow"); + await checkpoint({ completion: { sessionID, round: task.round ?? 1, ...finalReport(messages, session.outcome) } }); } verify(task: Task, repo: Repository) { return this.git.verify(task, repo); } push(task: Task, repo: Repository) { return this.git.push(task, repo); } diff --git a/src/github.ts b/src/github.ts index 3e23f3a..0e16bc4 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { mergeDescription, assertDescriptionSize, DescriptionConflict } from "./pr-description.js"; import { Review, DatedComment, approvalAuthors } from "./approval.js"; import type { GithubOptions } from "./config.js"; @@ -64,7 +65,25 @@ export class Github { return Pull.parse(await this.request(`/repos/${repo}/pulls/${number}`)); } async ensurePull(repo: string, branch: string, base: string, title: string, body: string): Promise { - return await this.findPull(repo, branch) ?? Pull.parse(await this.request(`/repos/${repo}/pulls`, "POST", { head: branch, base, title, body: await this.signed(body) })); + const found = await this.findPull(repo, branch); + if (found) return found; + const signed = await this.signed(body); assertDescriptionSize(signed); + return Pull.parse(await this.request(`/repos/${repo}/pulls`, "POST", { head: branch, base, title, body: signed })); + } + async updatePullBody(repo: string, number: number, commit: string, key: string, body: string, previous?: string, legacy?: string) { + const schema = z.object({ state: z.string(), head: z.object({ sha: z.string() }), body: z.string().nullable() }); + const read = async () => schema.parse(await this.request(`/repos/${repo}/pulls/${number}`)); + const current = await read(); + if (current.state !== "open" || current.head.sha !== commit) throw new DescriptionConflict("PR is closed or its head differs from the verified commit. Inspect it before retrying publication."); + const signedLegacy = legacy === undefined ? undefined : await this.signed(legacy); + let next = mergeDescription(current.body ?? "", key, body, previous, signedLegacy); + // Sign a new body or an exact legacy replacement; retain existing signatures elsewhere. + if (!(current.body ?? "").includes(body) && (!current.body || current.body === signedLegacy)) next = await this.signed(next); + assertDescriptionSize(next); + if (next === current.body) return; + const fresh = await read(); + if (fresh.state !== "open" || fresh.head.sha !== commit || fresh.body !== current.body) throw new DescriptionConflict("PR changed while its description was being prepared. Retry after inspecting concurrent edits."); + await this.request(`/repos/${repo}/pulls/${number}`, "PATCH", { body: next }); } async mergeApproved(repo: string, number: number, commit: string, since: number, authors: string[], options: GithubOptions["autoMerge"]): Promise { const detail = z.object({ state: z.string(), merged: z.boolean(), draft: z.boolean(), head: z.object({ sha: z.string() }), mergeable: z.boolean().nullable(), mergeable_state: z.string() }); diff --git a/src/pr-description.ts b/src/pr-description.ts new file mode 100644 index 0000000..ae90484 --- /dev/null +++ b/src/pr-description.ts @@ -0,0 +1,61 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; + +export const CompletionSummary = z.object({ + sessionID: z.string().optional(), round: z.number().int().positive().optional(), + text: z.string().optional(), unavailable: z.string().optional(), + commit: z.string().optional(), checks: z.array(z.string()).optional(), +}); +export type CompletionSummary = z.infer; +export class DescriptionConflict extends Error {} + +export function finalReport(messages: unknown[], outcome: unknown): Pick { + const last = messages.filter((m): m is Record => Boolean(m && typeof m === "object" && "type" in m && m.type === "assistant")).at(-1); + if (outcome !== "succeeded" || !last || last.error || last.finish !== "stop") return { unavailable: "The saved session has no successfully completed final response." }; + const text = Array.isArray(last.content) ? last.content.filter(p => p?.type === "text" && typeof p.text === "string").map(p => p.text).join("\n\n").trim() : ""; + return text ? { text } : { unavailable: "The completed final response contains no text summary." }; +} +function bounded(text: string, bytes: number) { + if (Buffer.byteLength(text) <= bytes) return text; + return Buffer.from(text).subarray(0, bytes).toString("utf8").replace(/\uFFFD$/, "") + "\n\n[Truncated for the PR description. The full report remains in the OpenCode session.]"; +} +export function descriptionMarkers(key: string) { + const id = createHash("sha256").update(key).digest("hex").slice(0, 24); + return { start: ``, end: `` }; +} +function summaryText(summary: CompletionSummary) { + return bounded(summary.text ?? `Summary unavailable: ${summary.unavailable ?? "No saved completion report."}`, 22000) + .replaceAll("