Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ include the full version, for example `## 0.7.0-beta.1`.

### Fixed

- Retry PR head propagation after a successful push when the remote branch still
matches the verified commit. Preserve a durable push checkpoint across restarts,
avoid repeating acknowledged pushes, and distinguish closed PRs from changed
branches while preserving manual description edits.

- Keep repository inventory RPC responses valid JSON when an owner has no runtime
snapshots. Isolate setup-test registries so validation never adds fixture
repositories to the operator's inventory.
Expand Down
19 changes: 15 additions & 4 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,10 @@ The monitor reports task maintenance until closure completes.

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
`publishedBody` (the last acknowledged managed section), and `pushedCommit` (the
successfully pushed verified SHA). New rounds clear the current completion and
push checkpoint while retaining the original report and published body.
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.
Expand All @@ -251,13 +253,22 @@ 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
After push, GitHub's PR head can temporarily lag behind its branch ref. Before
description reconciliation and again before PATCH, the plugin reads the remote
branch ref. If it matches the verified SHA but the PR head does not, publication
enters `retry_wait` and retries with the normal backoff, up to `maxAttempts`.
Retries reuse the saved report and verified commit, skipping a push already
recorded in `pushedCommit`, including after a restart. Exhausted retries become
`failed`; inspect the reported SHAs and use `/restartworkflow` after resolving the
problem. An unacknowledged push still uses normal non-force push reconciliation.

An edited/removed managed section, changed remote branch, 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
write. Body, PR head and branch ref are reread before PATCH; edits after those reads cannot
be atomically excluded by this implementation.

Each rendered report is limited to 22,000 UTF-8 bytes with an explicit truncation
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ loads a generic scheduler, a GitHub dispatcher, and a terminal UI component.
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.
Checkpoint successful pushes. Retry a lagging PR head only while the remote
branch matches the verified commit; block actual branch changes or closure.
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.
After cancellation, use a fresh local branch/worktree from the published head,
Expand Down
48 changes: 36 additions & 12 deletions docs/bot-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ flowchart TD
V -->|Failed check or Git consistency guard| VB[verifying / blocked]
VB -->|Operator retries saved stage| V
P --> Done[pr_opened / done with PR and publishedAt]
P -->|Branch matches, PR head not yet updated| Lag[publishing / retry_wait within attempt limit]
Lag -->|Backoff elapsed, reuse saved push| P
P -->|Publication failure| PB[Retain publishing phase and apply error policy]
PB -->|Eligible retry| P
Done -->|Pending authorized feedback| Round[Increment round, move feedback and reset per-round state]
Expand Down Expand Up @@ -449,19 +451,27 @@ flowchart TD
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]
Open -->|Yes| Pushed{Saved pushedCommit matches verified SHA?}
Pushed -->|Yes| Description
Pushed -->|No| Push[Validate origin, workspace, saved HEAD and clean tree, push exact SHA]
Follow -->|No| Exists{PR already exists?}
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 --> Description[Read open PR at verified SHA, reconcile managed description]
Push --> Description
Description -->|Edited managed block, changed head or oversized body| Block
Issue -->|Yes| NewPushed{Saved pushedCommit matches verified SHA?}
NewPushed -->|Yes| Create
NewPushed -->|No| PushNew[Validate origin and workspace, push exact verified SHA]
PushNew --> SaveNew[Persist pushedCommit]
SaveNew --> Create[Create or reconcile signed PR against pinned base]
Create --> Description[Read open PR and remote branch, reconcile managed description]
Push --> SavePush[Persist pushedCommit]
SavePush --> Description
Description -->|Branch matches, PR head differs| Pending[Retry saved publication with backoff and attempt limit]
Pending --> Retry
Description -->|Closed PR, changed branch, edited managed block 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]
Expand All @@ -473,9 +483,11 @@ flowchart TD
```

Resuming `running` validates the saved session first; retrying `verifying` runs
checks again. Retrying `publishing` uses the saved verified SHA and requires the
worktree still to match it, rather than rerunning checks implicitly. An already
pushed branch does not by itself make a task complete.
checks again. Retrying `publishing` uses the saved verified SHA without rerunning
checks implicitly. If a push is still required, the worktree must still match
that SHA. After a checkpointed push, description reconciliation checks GitHub's
branch and PR instead; it does not publish later local edits. An already 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;
Expand All @@ -498,9 +510,21 @@ follow-ups. Dispatcher checks appear separately from agent-reported tests, follo
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.
the new verified commit before updating its description. A successful push saves
`pushedCommit`; retries skip that push when it matches the verified SHA, including
after an owner restart. New rounds clear this checkpoint. If push succeeded but
its response or checkpoint was lost, normal non-force push reconciliation still
applies. The title is retained.
An already closed first-round PR is recorded without editing its description.

Before reconciling the description and again before PATCH, compare the remote
branch ref with the verified SHA. If the branch matches but the PR head is stale,
`PullHeadPending` uses the normal backoff and `maxAttempts` policy at `publishing`.
It does not rerun implementation, verification, or a checkpointed push. A closed
PR or different branch SHA blocks with a distinct error; a stale PR view is not
permission to overwrite a changed branch. The guard also checks the branch when
the PR view already reports the expected SHA.

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
Expand Down Expand Up @@ -666,9 +690,9 @@ flowchart TD
Result -->|WaitingForAnswer| Wait[waiting, or ready if answer already arrived]
Result -->|SessionStopped| Stop[running / blocked, sessionStopped true]
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]
Result -->|PR head propagation or 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]
Result -->|PR head propagation or other failure at limit| Fail[failed at saved phase]
Stop --> Probe[On available worker pass, probe due saved session without unresolved question]
Legacy[Recognized legacy timeout or outcome block] --> Probe
Probe --> Complete{Matching location and task marker, succeeded outcome and valid final assistant?}
Expand Down
8 changes: 8 additions & 0 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,14 @@ durable as before.

## PR descriptions

After pushing a verified commit, GitHub may briefly show the previous commit in
the PR. If the remote branch already matches the verified commit, the bot waits
and automatically retries publication within its configured attempt limit. It
keeps the saved report and does not repeat implementation or a checkpointed push.
A changed remote branch or closed PR produces a separate blocking error. If the
propagation retries are exhausted, inspect the error and use `/restartworkflow`;
see [description recovery](advanced.md#pr-description-recovery).

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
Expand Down
13 changes: 9 additions & 4 deletions src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const Task = z.object({
source: z.enum(["issue", "comment"]).optional(),
feedback: z.array(Comment).optional(), pendingFeedback: z.array(Comment).optional(), commentCursor: z.number().optional(),
previousSessionID: z.string().optional(),
checks: z.array(z.string()).optional(), commit: z.string().optional(),
checks: z.array(z.string()).optional(), commit: z.string().optional(), pushedCommit: 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(),
Expand Down Expand Up @@ -268,7 +268,7 @@ export class Dispatcher {
} : {}), 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, completion: undefined, checks: undefined, commit: undefined, error: undefined });
sessionID: undefined, sessionReady: false, promptAttempted: false, sessionStopped: undefined, recovery: undefined, completion: undefined, checks: undefined, commit: undefined, pushedCommit: undefined, error: undefined });
await this.store.save(this.queue);
});
const resumable = this.queue.tasks.filter(t => ["ready", "retry_wait"].includes(t.status));
Expand Down Expand Up @@ -355,11 +355,16 @@ export class Dispatcher {
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);
const push = async (repository: Repository) => {
if (task.pushedCommit === task.commit) return;
await this.executor.push(task, repository);
await this.update(task, { pushedCommit: task.commit });
};
if (followup && pr) await push(repo);
if (!pr) {
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);
await push(repo);
pr = await this.github.ensurePull(task.repo, task.branch, repo.baseBranch, task.prTitle!, body);
}
if (pr.state === "open") {
Expand Down
15 changes: 12 additions & 3 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type Pull = z.infer<typeof Pull>;
export class GithubError extends Error {
constructor(readonly status: number, readonly retryAt?: number) { super(`GitHub HTTP ${status}`); }
}
export class PullHeadPending extends Error {}
export class Github {
private login?: string;
constructor(private token: string, private signal: AbortSignal, private fetcher: typeof fetch = fetch, private signature?: string) {}
Expand Down Expand Up @@ -71,18 +72,26 @@ export class Github {
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 schema = z.object({ state: z.string(), head: z.object({ sha: z.string(), ref: z.string() }), body: z.string().nullable() });
const read = async () => schema.parse(await this.request(`/repos/${repo}/pulls/${number}`));
const requireHead = async (pr: z.infer<typeof schema>) => {
if (pr.state !== "open") throw new DescriptionConflict(`PR #${number} is closed. Inspect it before retrying publication.`);
const ref = z.object({ object: z.object({ sha: z.string() }) }).parse(
await this.request(`/repos/${repo}/git/ref/heads/${encodeURIComponent(pr.head.ref)}`));
if (ref.object.sha !== commit) throw new DescriptionConflict(`PR #${number} branch changed: expected ${commit}, found ${ref.object.sha}. Inspect it before retrying publication.`);
if (pr.head.sha !== commit) throw new PullHeadPending(`Waiting for GitHub PR #${number} to reflect pushed commit ${commit}; its branch matches, but the PR reports ${pr.head.sha}. Publication will retry automatically within the configured attempt limit.`);
};
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.");
await requireHead(current);
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 requireHead(fresh);
if (fresh.body !== current.body) throw new DescriptionConflict("PR description changed while its update 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<boolean> {
Expand Down
Loading