Skip to content

F: close a cancelled task's pull requests (#111) - #115

Merged
mchwang merged 37 commits into
mainfrom
feat/111-close-draft-on-cancel
Oct 4, 2026
Merged

mchwang merged 37 commits into
mainfrom
feat/111-close-draft-on-cancel

Conversation

@mchwang

@mchwang mchwang commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Closes #111. Stacked on #110 (base feat/103-publish-pr); retarget to main once #110 merges.

What it does

When a task is cancelled, codeboost closes every open pull request it opened for the task, draft or ready. The codeboost/… branch is kept, so a person can reopen a closed PR.

Cancel stays immediate. It is never refused and never waits for GitHub. If a publish is running, it is aborted with PublishCancelled and recorded as stopped. Once it has settled, the close runs.

PullRequestPublisher.closeAll:

  • It runs only for a cancelled task, and shares the per-task publish guard.
  • It first settles a lost opening or update through the publish's recovery, finding the PR by its marker. So a PR that GitHub opened during the aborted opening is recorded and closed.
  • Opened records are closed by number, because GitHub's list can lag behind a PR.
  • An abandoned opening's PR is found by its marker, adopted, then closed. Two PRs carrying one marker are refused, and neither is closed.
  • Every PR is attempted even when one fails. The problems are reported together: OpeningUnsettled, PullRequestMisplaced or a failure.
  • beforeClose re-checks the closing flags right before each close.

GhPullRequestGateway.close:

  • It reads the PR by number. A PR that is already closed counts as done.
  • An open PR is closed only while it is still from the task branch, in this repository, with the marker on its first line. Otherwise the gateway refuses and leaves it open.
  • It then sends PATCH state=closed and requires the answer to show the PR closed.

Outcome and retries. The outcome is recorded like a publish's, with action: 'close'. A close that is still owed runs:

  • at startup;
  • by itself once the settle time has passed, when an opening was still unsettled;
  • on the new close-pull-requests runner action, whose replay reports the outcome.

Triggers.

  • Cancel action: only the action's own cancel starts a close; a replayed or refused one starts nothing.
  • Cancel that stops an attempt first: the close starts when the run ends.
  • Retried attempt: the close starts when that attempt settles into cancelled.
  • GET /api/runner shows publish.closable.

Also fixed. In this vitest, toMatchObject({ x: /regex/ }) passes whatever x is. One existing assertion, runner-shutdown.test.ts:232, used that form, and now uses expect.stringMatching.

Decisions (user, 2026-10-03): close every open PR the task opened; keep the branch; cancel now and close afterwards; a durable outcome, retried at startup and by an action.

Tests

test/runner-publishing.test.ts (11 new) uses a real local bare remote and a fake GitHub. It covers:

  • a cancel in review closes the ready PR and keeps the branch;
  • a needs-human cancel closes the draft PR;
  • a cancel during a draft opening is immediate, records the publish as stopped, and recovers and closes the PR;
  • a failed close stays owed, the action retries it, and the replay reports the outcome;
  • shutdown during a close leaves it to the next start;
  • a PR the list doesn't show is closed by number;
  • a replayed or refused cancel starts nothing;
  • a close refused while the opening settles retries by itself and closes a PR GitHub showed late;
  • a task with no PR calls GitHub for nothing, and each action refuses the other's status.

test/publish.test.ts (8 new):

  • gateway: the PATCH and its read-back; never closing a PR that isn't the task's (branch, fork, null head repository, marker, number); the re-check before the close; an edited description after the close does not fail it;
  • publisher: only a cancelled task's PRs are closed; close by number; a moved PR is refused and left open; nothing is closed once shutdown begins between the read and the close.

Ten breaks were tried, and each failed a test: the cancel hook, the close after a stopped publish, the owed close, the close by number, the replay check, the retry timer, the re-check before the close, and three in the gateway.

Current local counts. Typecheck passes. The server-facing files pass (333 tests: start, shutdown, cli, production, lifecycle store, publishing and publish). A full run without the two Docker files had load timeouts, with the machine's load average about 166. Each of the 9 files affected passes when run alone.

Review rounds

Round Source Findings Outcome
0 self a vacuous regex assertion hid a wrong refusal for close-pull-requests; a leftover always-true PR check; a release hook that would have looped on a refused publish fixed
1 independent review agent close reported closed while a PR the list didn't show stayed open; doubled-marker advice told the person to close the task's PR; an OpeningUnsettled close never retried; a replayed cancel started a close; no re-check right before the PATCH; a landed close failed by an edited description; one PR's failure stopped the others; retry-started attempts not closed; closable stayed true; a wrong settled() comment; PublishCancelled → stopped untested; missing unit and race tests fixed
1 same a late abandoned PR after closed, or a PR a person reopens, is not looked at again; on upgrade, already-cancelled tasks' PRs are closed; the PATCH answer serves as the read-back documented limits, not changed
2 /code-review high the retry settle hook re-ran owed publishes; closable re-ran mode() fixed
3 /code-review high none —

Review-lesson audit. Each finding falls under an existing AGENTS.md rule:

  • read-back and "a later check must not fail a succeeded irreversible action" (the gateway close);
  • "Re-read immediately before an irreversible action" (beforeClose);
  • the idempotency rules (the replayed cancel);
  • "A test MUST fail when the feature it tests is broken" (the vacuous regex).

The vacuous toMatchObject regex is a tool quirk. It could become a new AGENTS.md rule if you want one; it is not added here.

🤖 Generated with Claude Code

mchwang and others added 2 commits October 3, 2026 15:34
TaskPublishing (runner/publishing.ts) publishes the task's PR with the real
publisher: GhAlreadyFixedGateway, GhPullRequestGateway and a GitBranchPusher
that pushes from the runner-owned repository to the configured repository on
GH_HOST (never a configured URL), overwriting only ledger-owned commits.

- Mode by status: a running task whose plan items have all run gets a ready
  PR; a task in needs human gets a draft PR with what sent it there.
- When: after a start/resume run ends; when a refused start/resume moves the
  task to needs human; on the new `publish` runner action; once at startup,
  after the CLI re-verifies the lock, if a publish is owed.
- Schema v10 `publish_outcomes`: the last outcome, shown as `publish` in
  GET /api/runner and replayed by the publish action. Tasks already running
  or in needs human at the upgrade are recorded as `not published`.
- Shutdown: publishes are aborted in step 1 and awaited before the Store
  write gate closes. Start, resume and a second publish are refused while a
  publish runs. Demos never publish.
- github.baseBranch is required with a runner block, checked before the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a task is cancelled, codeboost closes every open PR it opened for the
task, draft or ready, and keeps the codeboost/ branch.

- The cancel is never refused and never waits for GitHub. A publish in
  progress is aborted (PublishCancelled, recorded as stopped); the close
  runs once it settles, after recovering a lost opening by its marker.
- PullRequestPublisher.closeAll closes opened records by number (GitHub's
  list can lag), adopts and closes an abandoned opening's PR found by its
  marker, attempts every PR even when one fails, and re-checks the closing
  flags right before each close (beforeClose).
- GhPullRequestGateway.close reads the PR by number, closes an open one
  only while it is still from the task branch with its marker, and requires
  GitHub to show it closed.
- The outcome is recorded like a publish (action: close). An owed close
  runs at startup, after the settle time when an opening was unsettled, and
  on the new close-pull-requests runner action, whose replay reports it.
- Fix a vacuous bare-regex toMatchObject assertion in runner-shutdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Repository-mismatched PR records can be skipped while closure is recorded as successful, suppressing automatic retries.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Extends task publishing to close a cancelled task’s pull requests while keeping its branch and cancellation immediate.

Changes:

  • Adds guarded PR closing, opening recovery and retries.
  • Records close outcomes and exposes the retry action.
  • Adds regression tests and lifecycle documentation.
File Description
web/​server.ts Wires cancellation, retries and close status.
test/​runner-shutdown.test.ts Corrects a regex assertion.
test/​runner-publishing.test.ts Adds cancellation and close integration tests.
test/​publish.test.ts Tests publisher and gateway close guards.
runner/​store.ts Persists and replays close outcomes.
runner/​publishing.ts Coordinates background closes and retries.
runner/​publish.ts Recovers and closes task PRs.
README.md Documents cancellation and close retries.
github/​pull-requests.ts Adds ownership-checked PR closing.
docs/​implementation/​runner-lifecycle.md Documents shutdown tracking for closes.
docs/​implementation/​pull-request-opening.md Documents close behavior and limits.
docs/​architecture.md Adds cancellation closing to publishing responsibilities.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread runner/publish.ts Outdated
mchwang and others added 3 commits October 3, 2026 16:30
…d of recording the close as done (#111)

A record whose repository differs from the configured one (a rename, a
transfer, a configuration change) was skipped, and closeAll succeeded, so
the close was recorded as closed and never retried. It is now reported as
PullRequestMisplaced, the close stays owed, and the records in this
repository are still closed. Copilot review on PR #115.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…env, CLI test

- A draft PR's problems name the current attempt's safety finding before a
  spent budget, so a budget that ran out later cannot hide the violation.
- The already-fixed and PR gateways take the setup's env, the same as the
  push, so GH_HOST and credentials agree; recorded errors are redacted with
  that env's token values (RunnerAssembly.env, TaskPublishing).
- A CLI test shows the startup publish runs after the lock is verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…11-close-draft-on-cancel

# Conflicts:
#	runner/publishing.ts
#	test/publish.test.ts
#	test/runner-publishing.test.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Irreversible GitHub actions span cancellation, recovery, retries and shutdown, with unresolved timing and regression coverage concerns.

Review effort: Balanced
Findings: 1 Medium severity · 1 Low severity

Open (2)
Resolved since last review (1)

Comment thread runner/publishing.ts Outdated
Comment thread runner/publish.ts
mchwang and others added 5 commits October 3, 2026 20:19
… feat/103-publish-pr

# Conflicts:
#	web/cli.ts
…doned-opening close (#111)

- A close refused while an opening settles was retried a full settle time
  after the refusal; it now waits only until that opening's deadline (its
  creation plus the settle time, on recovery's clock) plus a second
  (PullRequestPublisher.settleRemaining).
- Tests: settleRemaining against a fixed clock; an aged opening's close is
  retried at its deadline; an abandoned opening's PR is adopted and closed;
  two PRs carrying one abandoned marker are both left open and refused.

Copilot review on PR #115.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…103)

The publish action's ID lived only in memory, so a crash after its
'publishing' reply committed and before the publish recorded an outcome
left the replay saying 'publishing' for ever. A reply that still says
'publishing' now counts as unsettled: any later outcome of the task
settles it (one publish runs per task), and startup settles it as
'stopped' (interrupted) when no publish is owed. Copilot review on PR #110.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
#	runner/publishing.ts
#	runner/store.ts
#	web/server.ts
mchwang and others added 2 commits October 3, 2026 22:51
…ver record at startup (#103)

An automatic publish (after a run, at startup) had no durable in-flight
marker: a crash after its PR opened and moved the task to in review, but
before its outcome was written, left publish.last empty or stale for good,
since startup then saw nothing owed and no unsettled action. Every publish
now records 'publishing' before its first external call (and does not run
if that write fails); startup publishes again when one is owed and
otherwise settles a leftover 'publishing' record as 'stopped'. Copilot
review on PR #110 (previously missed finding).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
#	runner/publishing.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Unresolved crash-recovery behavior and gaps in lifecycle regression coverage require correction and final human review.

Review effort: Balanced
Findings: 2 Medium severity · 1 Low severity

Open (3)
Resolved since last review (2)

Comment thread runner/publish.ts
Comment thread runner/publishing.ts
Comment thread web/server.ts
mchwang and others added 8 commits October 3, 2026 22:58
…el during an attempt (#111)

- A publish or close action whose process stopped before its job recorded
  anything makes that job owed, whatever an earlier job settled (a close
  asked for after a PR was reopened is run at startup, not reported as
  stopped).
- Tests: a refused first PR does not stop the next one from closing; a
  cancel while an attempt runs answers at once, closes nothing until the
  attempt settles, then closes the PR; the reopened-PR crash/restart case.

Copilot review on PR #115.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lock before the lock (#103)

- A task whose earlier run owes a safety escalation or a scope pause (its
  write failed, or the process stopped after the item settled) is not
  finished work, but every item has run, so no later run would pay it and a
  ready PR would show unreviewed out-of-scope changes. Publishing refuses it
  (mode), and a run's end, startup and a refused publish action have the
  executor pay it first (ItemExecutor.owes/payOwed).
- The CLI refuses a runner with no github block before taking the lock, so
  the missing block is reported as itself, not as a lock conflict.

Copilot review on PR #110.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	runner/publishing.ts
- A ready publish needs every item of the current plan revision completed
  at that revision, checked in the Store's publish guard before every
  guarded write: a revision that adds an item during a publish's first
  GitHub lookups no longer gets a ready PR.
- Only a publish action refused because of owed work pays it; a refusal
  for a stale view, a busy task or a replay starts nothing.
- A task recorded as 'not published' at the v10 upgrade stays unpublished
  until a person asks, whatever changes since.
- A failure while paying owed work no longer stops startup from settling
  an in-flight record or a stuck reply.
- A publish refused while a lost opening settles is retried at that
  opening's deadline (settleRemaining).
- Startup settles a reply that is the only thing stuck with the outcome on
  record, leaving that record (and its version stamp) as it is.
- The owed-work scan runs only for a status that could publish, not on
  every poll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
#	runner/publish.ts
#	runner/publishing.ts
#	runner/store.ts
#	test/publish.test.ts
#	test/runner-publishing.test.ts
#	web/server.ts
- A publish that ends with an opening still in flight (its own reply was
  lost, not only a refusal while one settles) is retried at the opening's
  deadline; a list-lag OpeningUnsettled is retried after 30 s.
- 'not published' (v10) holds only until a run starts after the upgrade.
- Only the publish action's own owed-work refusal pays that work; a stale
  request or any replay applies nothing.
- A draft waits only for an owed safety finding, not an owed scope pause
  (which a needs-human task cannot record).
- startup() leaves a publish of this process alone; the stopped record it
  writes keeps the interrupted publish's draft flag; the retry decision
  cannot throw out of run().
- owes() reads only owed findings in memory and completed out-of-scope
  attempts (Store.scopeFindingHeads), not every attempt, on a poll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
#	runner/publishing.ts
#	web/server.ts
- Bound the automatic retry (AGENTS.md: back off recurring external
  polling): one timer per task; a lost opening is waited for only while its
  deadline is ahead, so one still stuck after it (two PRs carry its marker,
  the repository changed, GitHub keeps failing) no longer re-publishes
  every second; the list-lag retry runs once per chain.
- A person's start or resume that moves an upgraded task (v10 'not
  published') ends that hold, so a refused resume that sends it to needs
  human gets its draft.
- Owed work is paid only where a run would (running, queued) or a draft
  waits on it (needs human).
- The 'not published' tests space their timestamps, which are compared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mchwang and others added 13 commits October 3, 2026 23:51
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
#	runner/publishing.ts
#	web/server.ts
- Retry budget per chain (until a publish settles): at most one retry at a
  lost opening's deadline and one short retry, so a GitHub outage that
  fails every open no longer republishes every settle time for ever.
- An 'opened' outcome that left the PR or the task where the publish did
  not mean them (GitHub showed another head, so the PR was drafted and the
  task stayed running; a draft change failed) is recorded with
  `reconcile`: still owed, and given the short retry.
- A refusal just as an opening's deadline passes gets the short retry.
- The short retry's delay is a test seam (RunnerAssembly.shortRetryMs);
  tests now cover the list-lag retry, the outage bound and reconciling.
- Tests that proved less than they claimed: the owed-work failure test now
  reaches the failure; the CLI baseBranch half holds the lock; the CLI
  startup test stops only after the outcome is recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
#	runner/publishing.ts
#	runner/store.ts
#	test/runner-publishing.test.ts
- A run's end, a person's action or a restart starts a new retry chain with
  its own budget (AGENTS.md: reset on a lifecycle change or user action);
  a retry timer's own publish does not, so retries stay bounded.
- No short retry while a lost opening's deadline is still ahead (it would
  only be refused again).
- `reconcile` only while the task can still be published; the action reply
  carries it; the settleHead comment no longer overstates.
- The owed-work path of a refused publish action carries the person's
  intent (personAsked), like start/resume.
- publishOwed runs the lock check itself, first: no order of the CLI's
  lines can start a startup publish before the lock is verified.
- Tests: the one-short-retry bound, the new chain on a person's action, and
  the lock check running first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
#	runner/publishing.ts
#	runner/store.ts
#	web/server.ts
- A person's publish action drops a retry the old chain armed, so it cannot
  fire later on the new chain's budget (at most two automatic publishes per
  chain, not three).
- Tests for two round-5 fixes that no test covered: no reconcile for a task
  that can no longer be published, and the action reply carrying reconcile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	runner/publishing.ts
- Tests for the owed-safety-finding gate: a ready publish is refused while
  a finding is owed, the action pays it and the draft follows; a
  needs-human task's owed finding is paid at startup before its draft; a
  replay of that refusal pays nothing.
- The draft of a task whose owed finding was paid names that finding: the
  executor settles it in memory only, so TaskPublishing keeps the reason
  payOwed returned (it was lost before).
- A run's end starting a new chain drops the old chain's armed retry, as a
  person's action does (#newChain): one timer per task, tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	runner/publishing.ts
A paid safety finding names a draft only while the task stays in needs
human on the same current attempt; a later return has its own reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
#	runner/publishing.ts
A publish refused by a guard (a race) gets the one short retry; a draft
names a finding whose save failed even after the budget ran out; a
needs-human task pays only an owed finding, not a scope pause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/codeboost into feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
#	runner/publishing.ts
@mchwang
mchwang changed the base branch from feat/103-publish-pr to main October 4, 2026 16:15
… feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
… feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
… feat/111-close-draft-on-cancel

# Conflicts:
#	docs/implementation/pull-request-opening.md
#	runner/publishing.ts
#	test/runner-publishing.test.ts
@mchwang
mchwang merged commit f8041cc into main Oct 4, 2026
2 checks passed
@mchwang
mchwang deleted the feat/111-close-draft-on-cancel branch October 4, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

F: close a task's draft PR on cancel, including one opening during the cancel

2 participants