From dd4cf13a7960709fe9dc3c1131b17baec788146c Mon Sep 17 00:00:00 2001 From: d3cker Date: Thu, 17 Sep 2026 09:52:23 +0200 Subject: [PATCH] feat: cancel bot rounds without ending issue tracking --- AGENTS.md | 4 +- CHANGELOG.md | 6 ++ README.md | 4 +- docs/advanced.md | 35 ++++++++ docs/architecture.md | 11 +++ docs/bot-workflow.md | 93 +++++++++++++++++----- docs/installation.md | 6 ++ docs/runtime.md | 61 ++++++++++++-- prompts/bot.md | 9 +++ src/activity.ts | 13 ++- src/dispatcher.ts | 158 ++++++++++++++++++++++++++++++------- src/executor.ts | 27 ++++--- src/manage.ts | 8 +- src/plugins/github.ts | 2 + src/repository-report.ts | 6 +- src/rpc.ts | 2 + src/runtime-panel.ts | 18 +++-- src/setup.ts | 4 +- src/ui.ts | 37 +++++++-- test/core.test.ts | 153 +++++++++++++++++++++++++++++++++++ test/executor.test.ts | 21 +++++ test/runtime-panel.test.ts | 11 +++ test/ui.test.ts | 34 +++++++- 23 files changed, 628 insertions(+), 95 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a8d9fb1..c9a2965 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ steps, project setup, headless operation, and removal. | [docs/architecture.md](docs/architecture.md) | Component responsibilities, a short issue-to-PR overview, configuration ownership, scheduler ownership, and shared state. | Start here to understand how the system is divided before locating implementation code. | | [docs/bot-workflow.md](docs/bot-workflow.md) | Eight Mermaid diagrams and detailed implementation notes: startup and polling; discovery and routing; task phases; sessions and questions; media helpers; verification and publication; feedback, merging, and tab closure; status, retries, and recovery. Includes links to the source for each area. | Use for exact execution order, state transitions, checkpoint behavior, failure paths, and tracing a bot task from issue to merged PR. | | [docs/configuration.md](docs/configuration.md) | The standard `.opencode/automation.json` format, defaults, setup flags, configuration tracking across Git branches, authors, triggers, checks, base branches, model capabilities, media helpers, custom prompts, signatures, and auto-merge settings. | Use when adding or changing user-facing configuration, defaults, or setup examples. | -| [docs/runtime.md](docs/runtime.md) | User-visible behavior while the bot runs: GitHub questions and permission replies, branch selection, media inputs, prompt loading, follow-up comments, session tabs, runtime sidebar/status freshness, host repository inventory and discovery, local task closure, and routine management commands. | Use when changing issue conversations, session continuation, runtime tools, or TUI behavior. | +| [docs/runtime.md](docs/runtime.md) | User-visible behavior while the bot runs: GitHub questions and permission replies, branch selection, media inputs, prompt loading, follow-up comments, session tabs, runtime sidebar/status freshness, host repository inventory and discovery, local task closure, cancelling rounds while retaining tracking, and routine management commands. | Use when changing issue conversations, session continuation, runtime tools, or TUI behavior. | | [docs/advanced.md](docs/advanced.md) | Separate scheduler/dispatcher setup, multiple repositories, custom RPC jobs, full options, timeouts, management and retry commands, persistence, reconciliation, locks, and known limits. | Use for low-level configuration, operational troubleshooting, recovery, or ownership/concurrency changes. | | [docs/installation.md](docs/installation.md) | Loader registration, config-directory precedence, prerequisites, source installation, project-local installation, upgrade conflicts, testing on another machine, and migration limits. | Use when working on packaging, installers, registration, upgrades, or deployment troubleshooting. | | [docs/releases.md](docs/releases.md) | Feature-to-devel and devel-to-release PR checks, automatic patch versions, manual npm version/tag releases, exact changelog notes, publication recovery, README commits on release, automatic release-to-devel synchronization, and promotion PRs into protected main. | Use for CI triggers, versioning, packaging, GitHub Release publication, branch permissions, or recovery after a failed release. | @@ -62,7 +62,7 @@ the installation block without making remote writes. Keep its markers intact. and GitHub plugin entrypoints. `src/easy.ts` resolves standard project settings; `src/config.ts` defines the configuration schemas and route matching. - `src/dispatcher.ts` owns discovery, the durable task lifecycle, questions, - feedback rounds, publication coordination, retries, durable task closure, and merge polling. + feedback rounds, publication coordination, retries, durable task closure, round cancellation/resumption, and merge polling. `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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f175d1..39aeb7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ include the full version, for example `## 0.7.0-beta.1`. ### Added +- Cancel a single bot round while retaining issue/PR tracking, with durable stop + recovery, preserved draft worktrees and fresh worktrees for later feedback. + Resume tracking a locally closed task without replaying its abandoned round + or old comments. Expose both actions in `/bot` and the CLI; show historical + errors only in details after closing or cancelling. + - List configured repositories across the host with `opencode2-automation list` (`--json` for scripts) and `/bot` → **Repositories**. Show owner/checkout paths, base branches, timestamped dispatcher/scheduler status and concrete issue diff --git a/README.md b/README.md index 89c87e5..bfb9f15 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,9 @@ installations are not removed by `npm uninstall --global`. unavailable or stale readings are marked explicitly. See [runtime panel details](docs/runtime.md#runtime-status-sidebar). - **Task management:** `/bot` lets you open a session, inspect details, close idle - tabs, restart a stopped workflow, or stop sessions and end task tracking. Closing + tabs, restart a stopped workflow, **Cancel current round** while keeping issue + tracking, or stop sessions and end task tracking. **Resume issue tracking** + restores a closed task for future comments without replaying its old round. Closing tracking preserves all local work and history, works without a surviving GitHub issue/PR, and prevents rediscovery. See [task management](docs/runtime.md#manage-tasks-from-bot). - **Progress:** use `/bot` in the TUI, or the CLI's `status`, `scan`, `pause`, and diff --git a/docs/advanced.md b/docs/advanced.md index 54095ad..37185e8 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -264,3 +264,38 @@ 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. + + +## Round cancellation and resuming tracking + +Owner RPC methods `automation.github.cancelround` and +`automation.github.resumetracking` accept `{ "key": "owner/repository#123" }` +and return `{ "accepted": true }` when cancellation is queued. A no-op returns +`false`; invalid transitions and publication/merge in flight throw an actionable +error. Matching CLI commands work from the configured owner checkout. See +[runtime semantics](runtime.md#cancelling-one-round-while-keeping-tracking). + +Durable `cancellation` stores the request time and interruption error. Status +`cancelling` gates worker checkpoints, session prompts, runtime hooks, discovery +updates and publication; scans leave its cursor unchanged. A worker pass resumes +due cancellation after restart and retries failed interruption after 30 seconds. +It drains the selected worker and pending question posts before entering `watching`. +The queue keeps `cancelledRounds` with prior errors, questions, feedback, worktree, +session and verification/report snapshots. `controlVersion` increases on operator +transitions, so stale TUI events cannot undo explicit resumption. + +Watching continues PR-state discovery and accepts new authorized issue comments. +`publishedHead` retains the successfully published SHA and approval-window time +across rounds; merge checks use it while watching. No saved published head means +no automatic merge until the next successful publication. Old round checkpoints +are not treated as a new successful publication. Explicit resumption validates +GitHub objects and advances the comment cursor to the observed backlog without +queuing it; failed validation leaves tracking closed. + +The next round allocates `localBranch` and a new managed worktree, fetching the +existing remote PR head if applicable. The prior worktree and branch are never +reset, cleaned, deleted or force-pushed. Checks validate the new local branch; +push still targets the task's original remote `branch`. Normal publication and +verification guards apply, including ancestry of the pinned base. The archived +round is not replayed or published automatically. Existing queued feedback is +retained by cancellation, while resuming a closed task explicitly skips backlog. diff --git a/docs/architecture.md b/docs/architecture.md index 352f5e9..a3346e9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,6 +37,8 @@ loads a generic scheduler, a GitHub dispatcher, and a terminal UI component. 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. + After cancellation, use a fresh local branch/worktree from the published head, + preserving the abandoned worktree and the remote PR branch. 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 @@ -87,3 +89,12 @@ Operator task closure is a durable dispatcher operation: `/bot` sends the owner hooks, feedback execution and merge monitoring exclude them. Session/worktree data is retained. See [task management](runtime.md#manage-tasks-from-bot) for in-flight operation limits and the distinction from closing a TUI tab. + + +Round cancellation uses `automation.github.cancelround`: persist `cancelling`, +drain execution, archive the current round, then `watching`. Discovery continues +and future feedback starts a new session in an isolated worktree. Only a saved +published head can authorize auto-merge while watching. Explicit +`automation.github.resumetracking` restores a closed task for future comments, +skipping its abandoned round and closed-period backlog after validating GitHub +objects. Both operations preserve work; neither closes the GitHub PR or issue. diff --git a/docs/bot-workflow.md b/docs/bot-workflow.md index 7d8731f..9de4885 100644 --- a/docs/bot-workflow.md +++ b/docs/bot-workflow.md @@ -34,11 +34,11 @@ flowchart TD Scan --> Save[Persist result, failures and nextAt] Save --> Clock Due -->|No| Clock - Worker --> Closing[Resume due closing requests independently of active worker] - Closing --> Clear{Any closure still pending?} + Worker --> Closing[Resume due closing and cancelling requests independently of active worker] + Closing --> Clear{Any closure or cancellation pending?} Clear -->|Yes| Worker Clear -->|No| Recover[Probe eligible stopped sessions and recover unpublished questions] - Recover --> Round[Promote one done task with pending feedback to a new round] + Recover --> Round[Promote one done or watching task with pending feedback to a new round] Round --> Select[Choose ready or retry_wait task, saved running session first] Select --> Candidate{Candidate exists?} Candidate -->|No| Merge[Check eligible merges] @@ -114,9 +114,9 @@ Sources: [index.ts](../src/index.ts), [easy.ts](../src/easy.ts), ```mermaid flowchart TD - Scan[Scan each configured repository] --> PRs[Refresh tracked PRs excluding merged and locally closing or closed tasks] + Scan[Scan each configured repository] --> PRs[Refresh tracked PRs excluding merged and locally closing, closed or cancelling tasks] PRs --> Issues[List open issues and fetch missing actively tracked issues] - Issues --> Skip{PR entry, locally closing or closed task, or closed untracked issue?} + Issues --> Skip{PR entry, locally closing, closed or cancelling task, or closed untracked issue?} Skip -->|Yes| Ignore[Ignore entry] Skip -->|No| Comments[Read comments and filter authorized human comments without bot markers] Comments --> Tracked{Task already exists?} @@ -127,9 +127,9 @@ flowchart TD Answer -->|No| Feedback[Append remaining fresh comments to pendingFeedback] Remaining --> Feedback Feedback --> Cursor[Persist cursor from all observed comments and save queue] - Cursor --> Gate{Task done?} + Cursor --> Gate{Task done or watching?} Gate -->|Yes| Later[Next available worker pass may start a follow-up round] - Gate -->|No| Retain[Keep feedback until current round publishes] + Gate -->|No| Retain[Keep feedback until publication or cancellation completes] Tracked -->|No| Body[Match body route only for an authorized issue author] Body --> Found{Body route found?} Found -->|Yes| Queue[Persist queued / ready with initial authorized feedback] @@ -205,6 +205,13 @@ flowchart TD PB -->|Eligible retry| P Done -->|Pending authorized feedback| Round[Increment round, move feedback and reset per-round state] Round --> Q + Cancel[Operator confirms Cancel current round] --> Cancelling[Persist cancelling and block new execution] + Cancelling --> Archive[Interrupt and drain, archive round and preserve worktree] + Archive --> Watching[watching: no replay or publication] + Watching -->|New authorized feedback| Fresh[New local branch and worktree from published PR or pinned base] + Fresh --> Round + Closed -->|Explicit Resume issue tracking| Resume[Validate open GitHub objects, skip observed backlog] + Resume --> Cancelling Operator[Operator confirms Stop and close task] --> Closing[Persist closing at any saved phase] Closing --> Drain[Interrupt known sessions and drain current operation] Drain --> Closed[Persist closed and retain work and history] @@ -303,6 +310,12 @@ sequenceDiagram D->>S: Interrupt known task sessions and wait for idleness D->>D: Drain in-flight worker and persist closed Note over D,G: Preserve work and history, no GitHub closure request + else Operator cancels the round + U->>D: Confirm Cancel current round + D->>D: Persist cancelling and reject new execution checkpoints + D->>S: Interrupt saved sessions and wait for idleness + D->>D: Drain worker and question posts, archive round, enter watching + Note over D,G: Keep PR tracking and future feedback, preserve cancelled worktree else Owner is disposed Note over D,S: Release local wait without interrupting healthy execution Note over D: Replacement owner loads queue and rejoins saved session @@ -398,7 +411,7 @@ flowchart TD Wait -->|Completed| Result{Succeeded outcome and non-error final assistant with finish stop?} Result -->|No| Error Result -->|Yes| Return[Return findings to main session, keep main model unchanged] - Closing[Local tracking closing or closed] --> Deny[Reject helper registration and runtime lookup] + Closing[Local task closing, closed, cancelling or watching] --> Deny[Reject helper registration and runtime lookup] ``` Only the owning main bot session can delegate media; the helper-registration @@ -454,7 +467,9 @@ flowchart TD 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] - InFlight[Publication already in flight] --> Refuse[Reject close request and ask operator to retry after completion] + Cancel[Cancel round before publication starts] --> DrainRound[Persist cancelling, drain local work and reject publication] + DrainRound --> Watch[Archive work and watch for new comments] + InFlight[Publication already in flight] --> Refuse[Reject close or cancel request and retry after completion] ``` Resuming `running` validates the saved session first; retrying `verifying` runs @@ -508,14 +523,14 @@ Sources: [executor.ts — GitWorkspace.verify, push, title](../src/executor.ts), ```mermaid flowchart TD - Pending[Authorized comment enters pendingFeedback] --> Done{Current task done?} + Pending[Authorized comment enters pendingFeedback] --> Done{Current task done or watching?} Done -->|No| Keep[Retain comment while running, waiting or blocked] 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] + Done -->|Yes| Round[Next worker pass starts a new round, isolating worktree after cancellation] 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?} + Idle[Worker has no eligible execution task] --> Eligible{Auto-merge enabled and done or watching task with published head eligible?} Eligible -->|No| Later[Wait for a later worker pass] Eligible -->|Yes| Since{publishedAt exists?} Since -->|No| Window[Record current time as fresh approval window] @@ -526,7 +541,7 @@ flowchart TD Fresh -->|No| Detail[Read GitHub PR details] Detail --> Already{Already merged?} Already -->|Yes| Ack[Post or reconcile signed merge acknowledgement, persist merged and closed PR] - Already -->|No| Head{Open, non-draft PR with saved verified head?} + Already -->|No| Head{Open, non-draft PR with saved published head?} Head -->|No| Poll[Clear mergeError, set mergeNextAt at least 60 seconds later] Head -->|Yes| Review[Evaluate latest decisive reviews and exact approval comments] Review --> Author{No outstanding changes request and eligible approver has write, maintain or admin access?} @@ -542,9 +557,13 @@ flowchart TD Ack --> UI[Activity events and TUI polling every 10 seconds] Refresh --> UI Local[Task closure finishes with status closed] --> UI - Menu[bot menu: select issue or Repositories] --> Action[Open session, details, close tabs, restart workflow, stop and close task] + Menu[bot menu: select issue or Repositories] --> Action[Open session, details, close tabs, restart, cancel round, resume tracking, stop and close] Menu -->|Repositories| Repos[Read connected server inventory, choose repository, show timestamped details] Repos --> Observe[No task or scheduler mutation, no activation of other owners] + Action -->|Cancel current round| CancelRound[Confirm, stop round, retain PR and issue tracking] + Action -->|Resume issue tracking| Resume[Validate closed task, skip backlog and watch future comments] + CancelRound --> UI + Resume --> UI Action -->|Stop and close task| Confirm[Confirm stop and close, queue durable closing request] UI --> Busy{Associated tab busy?} Busy -->|Yes| Defer[Retry closure on a later snapshot] @@ -556,7 +575,8 @@ flowchart TD Missing --> Sidebar ``` -Merge eligibility requires `done`, a tracked nonclosed PR, a saved commit, no +Merge eligibility requires `done`, or `watching` with a saved `publishedHead`, +a tracked nonclosed PR, a saved published commit, no merged flag, no pending feedback, and an elapsed `mergeNextAt`. Missing `publishedAt` in an older queue starts a fresh approval window rather than using historical approval. Merge checks run when the worker has no execution task to @@ -583,10 +603,13 @@ An already-merged response can reconcile a previously lost merge response. Follow-up rounds reset analysis, question, current session, session-stop/recovery 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 +After cancellation, the next round preserves that worktree as history and creates +a new local branch/worktree from the remote PR head (or pinned base without a PR). +Other 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, -branch, and shared repository. A missing checkpoint directory blocks the task +local branch, and shared repository. The remote publication branch stays unchanged. +A missing checkpoint directory blocks the task without creating a replacement worktree. A follow-up creates a new main session, whereas an implementation-question reply or workflow recovery retains the current one. Comments received while working, @@ -599,7 +622,8 @@ It opens background task tabs when enabled and exposes `/bot` for task managemen and `/restartworkflow` for operator recovery in the owner project. Commands use owner-scoped RPC; they are not GitHub comment commands. Activity phases `merged` and `pr_closed` are display values, not new persisted execution phases. -Local task statuses `closing` and `closed` are durable and separate from PR state. +Local statuses `closing`/`closed` end tracking; `cancelling`/`watching` skip a round +while preserving tracking. They are durable and separate from GitHub PR state. Closure cleanup includes known earlier-round sessions and media helpers. Busy tabs wait until idle; cleanup does not delete sessions, interrupt work, or remove worktrees. A manually reopened tab is not repeatedly closed in the same TUI instance. @@ -632,7 +656,9 @@ An error normally preserves the phase so retry continues from its checkpoint. | `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. | -| `closed` | Tracking ended locally; preserve history and work, exclude discovery, runtime hooks, execution and merge monitoring. | +| `closed` | Tracking ended locally; preserve history and work, exclude discovery, runtime hooks, execution and merge monitoring until explicit resumption. | +| `cancelling` | Stop saved sessions and drain the selected round; retry interruption failure without publishing. | +| `watching` | Round cancelled; no automatic execution replay. Track PR state and new feedback, merge only against a saved published head. | ```mermaid flowchart TD @@ -678,6 +704,17 @@ flowchart TD Again -->|Failure| CloseError CloseError --> Interrupt Restart[Owner restart with saved closing request] --> Interrupt + CancelRound[Cancel current round] --> Publish{Publication or merge in flight?} + Publish -->|Yes| RejectClose + Publish -->|No| SaveCancel[Persist cancelling, block prompts, hooks and checkpoints] + SaveCancel --> DrainCancel[Interrupt sessions, drain worker and questions, interrupt again] + DrainCancel -->|Success| Watch[Archive round, clear live errors, enter watching] + DrainCancel -->|Failure| RetryCancel[Keep cancelling and error, retry after 30 seconds or owner restart] + RetryCancel --> DrainCancel + Watch -->|New feedback| NewRound[New round in fresh worktree, retain archived work] + NewRound --> Work + Closed -->|Resume issue tracking| Validate[Require open issue and any known PR, skip observed backlog] + Validate --> SaveCancel ``` - Task backoff is `min(3600, 5 * 2^attempts)` seconds, with the incremented @@ -729,6 +766,8 @@ service, resume a paused scheduler, or perform a scan itself. | Action | Saved phase and session | Effect | | --- | --- | --- | | Continue a stopped session in the TUI | Same session, `running` phase | Once successful and recognized by the probe, normal session validation, checks and publication resume automatically. | +| `/bot` → Cancel current round, or `cancelround KEY` | Archive round and retain PR tracking | Persist cancelling, drain work, then watch new feedback. Next round uses a fresh worktree. | +| `/bot` → Resume issue tracking, or `resumetracking KEY` | Preserve closed history and work | Validate GitHub objects, skip old backlog, stop saved sessions and watch future comments. | | `/bot`, select an issue, then Stop and close task | Keep phase, sessions, worktree, branch and PR | Persist closing, interrupt saved sessions and drain work, then close local tracking. No GitHub issue/PR close or deletion. | | `/bot`, select an issue, then Close session tabs | No checkpoint change | Close idle local tabs only, continue tracking. | | `/restartworkflow`, then select an issue | Same phase, session, worktree, branch and PR | Queue recovery for an eligible blocked/failed task. A stopped session may receive one continuation; verification/publication retries its saved stage. | @@ -754,7 +793,7 @@ Regression evidence: [core.test.ts](../test/core.test.ts), `/bot` also exposes the saved error and task identity before any operator action. Closing is independent of GitHub availability, issue state, PR state, route validity and pending questions. The durable `closed` record prevents the same issue key -from being rediscovered. Scans skip closing/closed records before PR, missing-issue +from being rediscovered until explicit Resume issue tracking. Scans skip closing/closed/cancelling records before PR, missing-issue and comment reads; late checkpoints and errors cannot reactivate them. There is no automatic deletion based on an ambiguous GitHub 404 response. @@ -766,7 +805,7 @@ or merge refuses closure admission. In-flight comments cannot be recalled. Error while stopping sessions remain visible as `closing`, retried after 30 seconds or from the menu. `accepted` acknowledges the request, not finished interruption. -The sidebar names up to three blocked/failed/closing tasks with their saved errors, +The sidebar names up to three blocked/failed/closing/cancelling tasks with their saved errors, excludes locally closed tasks from live queue counts, and shows a separate closing count. `/bot` retains all task records and their actions, including opening the saved conversation after closure. See [runtime management](runtime.md#manage-tasks-from-bot). @@ -774,3 +813,15 @@ saved conversation after closure. See [runtime management](runtime.md#manage-tas 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. + + +Round cancellation uses the same interrupt/drain discipline as closure but ends +in `watching`. It retains pending new feedback, clears the cancelled question and +live error, and archives the stopped round. Exact permission replies for archived +question IDs are ignored. Resume issue tracking is a separate explicit action for +closed tasks: validate the open issue/PR, skip already-observed backlog, then +cancel any saved execution and watch future comments. Historic errors remain in +Show details; closed/watching sidebar snapshots do not display them as live failures. +`controlVersion` and round ordering prevent late TUI events from reviving old work. +See [cancellation behavior](runtime.md#cancelling-one-round-while-keeping-tracking) +and [persistence details](advanced.md#round-cancellation-and-resuming-tracking). diff --git a/docs/installation.md b/docs/installation.md index c999320..5e70ea5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -117,6 +117,12 @@ Use a separate test repository when testing on another machine. Independent machines do not share queue ownership and can duplicate work on the same issues. This installation procedure does not migrate sessions, queues, or worktrees. +Back up shared automation state before an upgrade. Once round cancellation has +saved `cancelling` or `watching`, an older plugin that does not recognize those +statuses cannot read that queue. Do not downgrade against live newer state or +delete it to bypass validation; keep the newer plugin or restore a coordinated +backup while owners are stopped. + Restart the service only when work is idle. Then activate every configured owner again as shown in the README. Reopen TUI clients after UI updates to register new commands and action menus such as `/bot` task closure and `/restartworkflow`; merely reopening an old task tab does not diff --git a/docs/runtime.md b/docs/runtime.md index 1238325..75dd565 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -188,11 +188,13 @@ during execution, a pending question, or a blocked stage remain queued. Receivin one does not itself clear the current block. A mention in an authorized comment can also start work on an untracked issue. -Follow-up rounds reuse the worktree path saved in the queue, even if recovery +Normal follow-up rounds reuse the worktree path saved in the queue, even if recovery renamed its branch. Preparation, verification, and push validate that path as a worktree root directly inside the managed worktree directory, attached to the expected branch and repository. If the saved directory is missing, restore it before retrying; the bot does not create a replacement or discard existing work. +After explicit round cancellation, a new round deliberately uses a fresh worktree +while preserving the old one; see [cancelling rounds](#cancelling-one-round-while-keeping-tracking). Edits to existing comments and PR review comments are not supported. Closing the issue or closing/merging the PR blocks further rounds. @@ -242,7 +244,7 @@ configured repository, sharing the owner's scheduler information. The report includes dispatcher activity, last scan attempt completion (which can include failure), scheduler next-run timestamps, task counts and every open -blocked/failed/closing issue key and saved error. Active counts use the actual +blocked/failed/closing/cancelling issue key and saved error. Active counts use the actual active task; scheduled work is separate. Closed local tracking and closed/merged PR history do not inflate counts. A working dispatcher can have blocked tasks; inspect the task counts as well as the owner status. @@ -297,6 +299,14 @@ The same picker also offers **Repositories** for the host inventory: - **Close session tabs**: hide that task's idle tabs in this TUI only. Busy tabs remain open. Tracking and execution continue. - **Restart workflow**: request the same guarded recovery as `/restartworkflow`. +- **Cancel current round**: stop the current round without publishing it. Preserve + its session and worktree, then enter `watching` for new issue comments and PR + state. Existing queued feedback remains eligible; only the current round is + cancelled. **Retry cancelling round** retries an interruption failure. +- **Resume issue tracking**: available for a locally closed task. Validate that + the issue and any known PR are open, stop any saved sessions again, then watch + future comments. The stopped round and comments already present at this action's + GitHub read are skipped. It does not replay work or immediately publish. - **Stop and close task**: after confirmation, persist `closing`, interrupt known main, earlier-round and media sessions, wait for idleness and the task's in-flight worker operation, then persist `closed`. The menu offers **Retry closing task** @@ -310,8 +320,8 @@ unfinished work. `closed` here means **local tracking ended**, not PR closure. Closed tasks remain listed as history and their conversations can be reopened. The closed record prevents rediscovery and later comments from restarting the -same issue. Recovery/retry cannot reopen tracking; create a new issue for new -bot work. Runtime question/helper admission is disabled once closure is requested. +same issue. Recovery/retry cannot reopen tracking; use **Resume issue tracking** +explicitly to watch that issue again. Runtime question/helper admission is disabled once closure is requested. Related idle tabs close once when the task becomes `closed`. An already-started publication or automatic merge rejects closure with an explicit @@ -326,6 +336,47 @@ instead of restarting implementation. No success is reported while interruption has failed or the task's worker operation is still pending. Missing sessions are already stopped and do not block closure. +## Cancelling one round while keeping tracking + +**Cancel current round** differs from closing the task and from hiding its tab. +It persists `cancelling` before interrupting sessions, waits for in-flight local +work and question posts, interrupts again, and enters `watching` only after that +finishes. Failures remain visible and retry after 30 seconds; an owner restart +resumes cancellation. Publication or merge already in flight rejects cancellation +because remote effects cannot be rolled back. A comment already being posted can +still appear in GitHub. Cancellation does not revert previously pushed commits. + +The completed transition archives the round's session, worktree, question, +feedback, error, attempts and verification/report checkpoints. It clears the +current question, recovery request and live error, without deleting any files or +sessions. Late explicit `/allow ID` or `/deny ID` replies to archived permission +questions cannot restart the task. Other new authorized comments can request work. + +The next round starts in a **new worktree and local branch** from the published +PR branch, or the pinned base when no PR exists. Archived worktrees remain intact, +including dirty files and local commits; their changes are not automatically +included. Publication still targets the original remote branch and PR. The bot +receives only the new round's feedback and instructions not to replay cancelled +scope. Normal later rounds reuse the new worktree. Details show the last preserved +worktree; full cancellation history is retained in the queue. + +PR state monitoring continues while `watching`. Automatic merging uses only the +last saved published head, with normal approval checks. Legacy tasks without that +checkpoint still watch comments and PR state, but cannot auto-merge until another +round publishes successfully. No model runs while waiting for new feedback. + +Equivalent owner-checkout commands: + +```sh +opencode2-automation cancelround 'owner/repository#123' +opencode2-automation resumetracking 'owner/repository#123' +``` + +The sidebar shows **Watching issue — round cancelled** or **Tracking closed**. +Old failures appear under **Historical error** in `/bot` → **Show details**, not +as a live red error or failed-attempt count. Cancelling a round does not automatically +close the PR's tab; closing the tab remains an independent display action. + ## Runtime status sidebar The **BOT RUNTIME** section is appended to the existing right sidebar, preserving @@ -342,7 +393,7 @@ The panel shows: Pausing polling can coexist with an already-running scan or task. - Queue counts: ready/retry-wait excluding the active task, waiting for replies, blocked/failed and published tasks, excluding closed/merged PRs and locally closed tracking. Scheduled does not mean a model is executing. - Up to three attention rows identify blocked, failed or closing issue keys and + Up to three attention rows identify blocked, failed, closing or cancelling issue keys and saved errors. Pending closures have a separate count; use `/bot` for the full list. - Task details: issue, phase, round, observed main-session status, task/base branches, model, queued feedback, allocated media helper count for the current session, diff --git a/prompts/bot.md b/prompts/bot.md index f356390..d6bdd16 100644 --- a/prompts/bot.md +++ b/prompts/bot.md @@ -182,6 +182,15 @@ repository inspection in the implementation session. - Do not repeat an action with uncertain results until its state is reconciled. - Preserve question and permission boundaries after compaction or restart. +## Cancelled rounds + +An operator may cancel one round while keeping issue/PR tracking. Do not resume a +cancelled session or act on its former permission request. A later round receives +new feedback in a fresh worktree based on the published PR or pinned base; keep +that local branch and leave archived worktrees unchanged. Do not reapply the +cancelled scope or copy archived changes unless the new request asks for them. +The dispatcher still publishes to the existing remote PR branch. + ## Final report The dispatcher copies the final public text of a successfully completed session diff --git a/src/activity.ts b/src/activity.ts index f777e39..cbe75c4 100644 --- a/src/activity.ts +++ b/src/activity.ts @@ -7,6 +7,8 @@ export const Activity = z.object({ worktree: z.string().optional(), sessionReady: z.boolean(), error: z.string().optional(), prURL: z.string().optional(), prState: z.string().optional(), sessionIDs: z.array(z.string()).optional(), + controlVersion: z.number().optional(), localBranch: z.string().optional(), + historicalError: z.string().optional(), cancelledRound: z.number().optional(), cancelledWorktree: z.string().optional(), closeRequestedAt: z.number().optional(), closedAt: z.number().optional(), branch: z.string().optional(), baseBranch: z.string().optional(), model: z.string().optional(), attempts: z.number().optional(), nextAt: z.number().optional(), pendingFeedback: z.number().optional(), @@ -16,19 +18,26 @@ export const Activity = z.object({ }); export type Activity = z.infer; export function activityOf(task: Task): Activity { + const historical = task.status === "closed" || task.status === "watching"; + const error = task.cancellation?.error ?? task.closeError ?? task.error ?? task.mergeError; return { key: task.key, repo: task.repo, issueNumber: task.issue.number, round: task.round ?? 1, phase: task.merged ? "merged" : task.pr?.state === "closed" ? "pr_closed" : task.phase, status: task.status, sessionIDs: [...new Set([...task.sessionIDs ?? [], ...[task.previousSessionID, task.sessionID].filter((id): id is string => Boolean(id)), ...task.helpers?.map(h => h.id) ?? []])], ...(task.sessionID ? { sessionID: task.sessionID } : {}), ...(task.worktree ? { worktree: task.worktree } : {}), sessionReady: task.sessionReady ?? Boolean(task.promptAttempted), - ...(task.closeError || task.error || task.mergeError ? { error: task.closeError ?? task.error ?? task.mergeError } : {}), + ...(!historical && error ? { error } : task.status === "watching" && task.mergeError ? { error: task.mergeError } : {}), + ...(historical && (task.error ?? task.cancelledRounds?.at(-1)?.error) ? { historicalError: task.error ?? task.cancelledRounds?.at(-1)?.error } : {}), + ...(task.controlVersion !== undefined ? { controlVersion: task.controlVersion } : {}), + ...(task.cancelledRounds?.length ? { cancelledRound: task.cancelledRounds.at(-1)!.round } : {}), + ...(task.cancelledRounds?.at(-1)?.worktree ? { cancelledWorktree: task.cancelledRounds.at(-1)!.worktree } : {}), + ...(task.localBranch ? { localBranch: task.localBranch } : {}), ...(task.closeRequestedAt !== undefined ? { closeRequestedAt: task.closeRequestedAt } : {}), ...(task.closedAt !== undefined ? { closedAt: task.closedAt } : {}), ...(task.branch ? { branch: task.branch } : {}), ...(task.baseBranch ? { baseBranch: task.baseBranch } : {}), ...(task.route ? { model: `${task.route.model.providerID}/${task.route.model.id}` } : {}), - ...(task.attempts !== undefined ? { attempts: task.attempts } : {}), ...(task.nextAt !== undefined ? { nextAt: task.nextAt } : {}), pendingFeedback: task.pendingFeedback?.length ?? 0, + ...(!historical && task.attempts !== undefined ? { attempts: task.attempts } : {}), ...(task.nextAt !== undefined ? { nextAt: task.nextAt } : {}), pendingFeedback: task.pendingFeedback?.length ?? 0, helpers: task.helpers?.filter(h => h.parentID === task.sessionID).length ?? 0, recovery: Boolean(task.recovery), ...(task.question && !task.question.delivered ? { question: task.question.permission ? "permission" as const : task.question.purpose ?? "implementation" as const } : {}), ...(task.pr ? { prURL: task.pr.html_url, prState: task.pr.state, ...(task.pr.number ? { prNumber: task.pr.number } : {}) } : {}) }; diff --git a/src/dispatcher.ts b/src/dispatcher.ts index 1da39b8..b4f8414 100644 --- a/src/dispatcher.ts +++ b/src/dispatcher.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import { join } from "node:path"; import { z } from "zod"; import { type GithubOptions, type Repository, Route, matchRoute } from "./config.js"; import { GithubError, Issue, Comment, type Pull } from "./github.js"; @@ -15,8 +16,17 @@ export const PendingQuestion = z.object({ id: z.string(), text: z.string(), sess const Phase = z.enum(["queued", "analyzing", "commented", "running", "verifying", "publishing", "pr_opened"]); export const Task = z.object({ key: z.string(), repo: z.string(), issue: Issue, route: Route.optional(), - phase: Phase, status: z.enum(["ready", "retry_wait", "blocked", "failed", "done", "waiting", "closing", "closed"]), + phase: Phase, status: z.enum(["ready", "retry_wait", "blocked", "failed", "done", "waiting", "closing", "closed", "cancelling", "watching"]), attempts: z.number(), nextAt: z.number(), createdAt: z.number(), + controlVersion: z.number().optional(), + cancellation: z.object({ requestedAt: z.number(), error: z.string().optional() }).optional(), + cancelledRounds: z.array(z.object({ round: z.number(), at: z.number(), sessionID: z.string().optional(), + worktree: z.string().optional(), localBranch: z.string().optional(), error: z.string().optional(), attempts: z.number(), + question: PendingQuestion.optional(), feedback: z.array(Comment).optional(), commit: z.string().optional(), + checks: z.array(z.string()).optional(), completion: CompletionSummary.optional(), + })).optional(), + localBranch: z.string().optional(), + publishedHead: z.object({ commit: z.string(), at: z.number() }).optional(), closeRequestedAt: z.number().optional(), closedAt: z.number().optional(), closeError: z.string().optional(), analysis: z.string().optional(), commentID: z.number().optional(), analysisDecision: AnalysisDecision.optional(), @@ -49,7 +59,8 @@ export class SessionStopped extends Blocked {} export class WaitingForAnswer extends Error {} class TaskClosed extends Error {} const closing = (task: Task) => task.status === "closing" || task.status === "closed"; -function requireTracked(task: Task) { if (closing(task)) throw new TaskClosed("Task tracking has been closed"); } +const suspended = (task: Task) => closing(task) || task.status === "cancelling"; +function requireTracked(task: Task) { if (suspended(task)) throw new TaskClosed("Task tracking has been closed"); } function stoppedSession(task: Task) { // Recognize checkpoints from releases before sessionStopped was persisted. @@ -96,6 +107,7 @@ export class Dispatcher { private lastScanFinished?: number; private scanError?: string; private closures = new Map>(); + private cancellations = new Map>(); private publishing = new Set(); private questionPosts = new Map>(); constructor(private options: GithubOptions, private store: Store, private github: GithubPort, private executor: Executor, private signal: AbortSignal, private secrets: string[] = [], private now = Date.now, private notify: (activity: Activity) => Promise = async () => {}) {} @@ -104,7 +116,7 @@ export class Dispatcher { activity() { return this.queue.tasks.map(activityOf); } monitor(): DispatcherMonitor { return { ownerDirectory: this.options.ownerDirectory, - worker: this.signal.aborted ? "stopped" : this.maintenance || this.queue.tasks.some(t => t.status === "closing") ? "maintenance" : this.workerState, + worker: this.signal.aborted ? "stopped" : this.maintenance || this.queue.tasks.some(t => ["closing", "cancelling"].includes(t.status)) ? "maintenance" : this.workerState, scanning: Boolean(this.scanning), tasks: this.activity(), ...(this.activeTask ? { activeTask: this.activeTask } : {}), ...(this.lastScanStarted !== undefined ? { lastScanStarted: this.lastScanStarted } : {}), @@ -139,16 +151,16 @@ export class Dispatcher { for (const repo of this.options.repositories) { // Watch PR state independently of automatic merging, issue state, and // worker progress so manual closure/merge also reaches attached TUIs. - for (const task of this.queue.tasks.filter(t => t.repo === repo.repo && !closing(t) && t.pr && !t.merged)) { - if (closing(task)) continue; + for (const task of this.queue.tasks.filter(t => t.repo === repo.repo && !suspended(t) && t.pr && !t.merged)) { + if (suspended(task)) continue; try { const pr = await this.github.pull(repo.repo, task.pr!.number); const merged = pr.merged === true || Boolean(pr.merged_at); if (pr.state !== task.pr!.state || merged) await this.update(task, { pr, ...(merged ? { merged: true } : {}) }); - } catch (error) { if (!closing(task)) throw error; } + } catch (error) { if (!suspended(task)) throw error; } } const issues = await this.github.issues(repo.repo); - for (const tracked of this.queue.tasks.filter(t => t.repo === repo.repo && !closing(t))) { + for (const tracked of this.queue.tasks.filter(t => t.repo === repo.repo && !suspended(t))) { if (!issues.some(i => i.number === tracked.issue.number)) issues.push(await this.github.issue(repo.repo, tracked.issue.number)); } for (const issue of issues) { @@ -156,7 +168,7 @@ export class Dispatcher { if (issue.pull_request) { ignored++; continue; } const key = `${repo.repo.toLowerCase()}#${issue.number}`; const existing = this.queue.tasks.find(t => t.key === key); - if (existing && closing(existing)) { ignored++; continue; } + if (existing && suspended(existing)) { ignored++; continue; } if (!existing && issue.state !== "open") { ignored++; continue; } const comments = await this.github.comments(repo.repo, issue.number); // A person may share the posting account with the bot. Exclude marked @@ -166,9 +178,9 @@ export class Dispatcher { if (existing) { // For queues from older versions, comments after the bot's acknowledgement are new feedback. await this.serial.run(async () => { - if (closing(existing)) return; + if (suspended(existing)) return; const previousCursor = existing.commentCursor ?? existing.commentID ?? 0; - const fresh = authorized.filter(c => c.id > previousCursor); + const fresh = authorized.filter(c => c.id > previousCursor && !existing.cancelledRounds?.some(r => r.question?.permission && [`/allow ${r.question.id}`, `/deny ${r.question.id}`].includes(c.body.trim()))); let remaining = fresh; const q = existing.question; if (q && !q.answer && q.commentID && issue.state === "open") { @@ -213,10 +225,11 @@ export class Dispatcher { } private authorized(login: string, authors: string[]) { return authors.some(a => a.toLowerCase() === login.toLowerCase()); } tick(): Promise { + for (const task of this.queue.tasks.filter(t => t.status === "cancelling" && t.nextAt <= this.now())) this.startCancelling(task); for (const task of this.queue.tasks.filter(t => t.status === "closing" && t.nextAt <= this.now())) this.startClosing(task); if (this.maintenance) return Promise.resolve(); if (this.working) return this.working; - if (this.queue.tasks.some(t => t.status === "closing")) return Promise.resolve(); + if (this.queue.tasks.some(t => ["closing", "cancelling"].includes(t.status))) return Promise.resolve(); this.workerState = "reconciling"; this.working = this.workOnce().catch(error => { if (!(error instanceof TaskClosed)) throw error; }).finally(() => { this.working = undefined; this.workerState = "idle"; this.activeTask = undefined; }); return this.working; @@ -245,9 +258,14 @@ export class Dispatcher { catch (error) { if (this.signal.aborted) return; await this.update(pending, { error: redact(error, this.secrets), nextAt: this.now() + 60_000 }); } } await this.serial.run(async () => { - const finished = this.queue.tasks.find(t => t.status === "done" && t.pendingFeedback?.length); + const finished = this.queue.tasks.find(t => ["done", "watching"].includes(t.status) && t.pendingFeedback?.length); if (!finished) return; - Object.assign(finished, { round: (finished.round ?? 1) + 1, feedback: finished.pendingFeedback, pendingFeedback: [], previousSessionID: finished.sessionID, + const afterCancellation = finished.status === "watching"; + if (finished.status === "done" && finished.commit && finished.publishedAt) finished.publishedHead = { commit: finished.commit, at: finished.publishedAt }; + Object.assign(finished, { ...(afterCancellation ? { + localBranch: `automation/resume-${createHash("sha256").update(finished.key).digest("hex").slice(0, 12)}-r${(finished.round ?? 1) + 1}`, + worktree: undefined, + } : {}), 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 }); @@ -267,17 +285,27 @@ export class Dispatcher { if (!repo) throw new Blocked("Repository removed from configuration"); if (!task.route) throw new Blocked("No unambiguous execution route"); await this.update(task, { status: "ready", error: undefined }); - const followup = (task.round ?? 1) > 1; + // A cancelled publication may have created its PR before losing the + // response/checkpoint. Reconcile it before choosing a clean resume base. + if (task.cancelledRounds?.length && !task.pr && ["queued", "analyzing", "commented"].includes(task.phase)) { + const pr = await this.github.findPull(task.repo, task.branch); + if (pr) await this.update(task, { pr }); + else await this.update(task, { initialCompletion: undefined, publishedBody: undefined, prTitle: undefined, publishedHead: undefined }); + } + const laterRound = (task.round ?? 1) > 1; + const followup = Boolean(task.pr) || laterRound && !task.cancelledRounds?.length; if (["queued", "analyzing", "commented"].includes(task.phase)) { const latest = await this.github.issue(task.repo, task.issue.number); if (latest.state !== "open") throw new Blocked("Issue is closed; reopen it before continuing"); if (followup) { const pr = await this.github.findPull(task.repo, task.branch); if (!pr || pr.state !== "open") throw new Blocked("The original PR is closed or merged; reopen it or create a new issue"); - if (!task.feedback?.every(c => this.authorized(c.user.login, configuredRepo!.allowedAuthors))) throw new Blocked("Feedback author no longer authorized"); + } + if (laterRound) { + if (!task.feedback?.length || !task.feedback.every(c => this.authorized(c.user.login, configuredRepo!.allowedAuthors))) throw new Blocked("Feedback author no longer authorized"); } else if (task.source !== "comment" && !this.authorized(latest.user.login, repo.allowedAuthors)) throw new Blocked("Issue author no longer authorized"); - if (!followup && task.source === "comment" && !task.feedback?.some(c => this.authorized(c.user.login, configuredRepo!.allowedAuthors) && matchRoute(c.body, this.options.routes))) throw new Blocked("No authorized routing comment remains in the task"); - const route = followup || task.source === "comment" ? task.route : matchRoute(latest.body ?? "", this.options.routes); + if (!laterRound && task.source === "comment" && !task.feedback?.some(c => this.authorized(c.user.login, configuredRepo!.allowedAuthors) && matchRoute(c.body, this.options.routes))) throw new Blocked("No authorized routing comment remains in the task"); + const route = laterRound || task.source === "comment" ? task.route : matchRoute(latest.body ?? "", this.options.routes); if (!route) throw new Blocked("Routing tag removed"); if (task.analysis && (latest.body !== task.issue.body || latest.title !== task.issue.title || JSON.stringify(route) !== JSON.stringify(task.route))) throw new Blocked("Issue or route changed after analysis; review before restarting"); await this.update(task, { issue: latest, route }); @@ -338,10 +366,10 @@ export class Dispatcher { 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, { publishedBody: pr.state === "open" ? body : task.publishedBody, 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(), publishedHead: { commit: task.commit!, at: this.now() }, phase: "pr_opened", status: "done", attempts: 0 }); } } catch (error) { - if (this.signal.aborted || closing(task) || error instanceof TaskClosed) return; + if (this.signal.aborted || suspended(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 DescriptionConflict || error instanceof GithubError && [401, 404, 422].includes(error.status); @@ -410,22 +438,23 @@ export class Dispatcher { private async mergeOnce() { if (!this.options.autoMerge.enabled || !this.github.mergeApproved) return; for (const task of this.queue.tasks) { - if (task.status !== "done" || !task.pr || task.pr.state === "closed" || !task.commit || task.merged || task.pendingFeedback?.length || (task.mergeNextAt ?? 0) > this.now()) continue; + const head = task.status === "watching" ? task.publishedHead : task.commit ? { commit: task.commit, at: task.publishedAt } : undefined; + if (!["done", "watching"].includes(task.status) || !task.pr || task.pr.state === "closed" || !head || task.merged || task.pendingFeedback?.length || (task.mergeNextAt ?? 0) > this.now()) continue; const repo = this.options.repositories.find(r => r.repo === task.repo); if (!repo) continue; // Older queues start watching now; historical approvals must not trigger an unexpected merge. - if (!task.publishedAt) { await this.update(task, { publishedAt: this.now() }); continue; } + if (!head.at) { await this.update(task, { publishedAt: this.now() }); continue; } try { await this.scan(); // Pick up issue feedback before considering a completed task for merge. - if (closing(task) || task.pendingFeedback?.length || task.pr.state === "closed") continue; + if (suspended(task) || task.pendingFeedback?.length || task.pr.state === "closed") continue; this.publishing.add(task.key); - const merged = await this.github.mergeApproved(task.repo, task.pr.number, task.commit, task.publishedAt, repo.allowedAuthors, this.options.autoMerge); + const merged = await this.github.mergeApproved(task.repo, task.pr.number, head.commit, head.at, repo.allowedAuthors, this.options.autoMerge); if (merged) { await this.github.ensureComment(task.repo, task.pr.number, ``, "Pull request merged."); await this.update(task, { merged: true, pr: { ...task.pr, state: "closed" }, mergeError: undefined }); } else await this.update(task, { mergeError: undefined, mergeNextAt: this.now() + 60_000 }); } catch (error) { - if (this.signal.aborted) return; + if (this.signal.aborted || suspended(task)) return; await this.update(task, { mergeError: redact(error, this.secrets), mergeNextAt: Math.max(this.now() + 60_000, error instanceof GithubError ? error.retryAt ?? 0 : 0) }); } finally { this.publishing.delete(task.key); } @@ -433,7 +462,7 @@ export class Dispatcher { } runtime(sessionID: string) { const task = this.queue.tasks.find(t => t.sessionID === sessionID || t.helpers?.some(h => h.id === sessionID && h.parentID === t.sessionID)); - if (!task || closing(task)) return null; + if (!task || suspended(task) || task.status === "watching") return null; const result = JSON.parse(JSON.stringify(task)) as Task; const matching = Object.values(this.options.routes).filter(r => r.agent === task.route?.agent && r.model.id === task.route?.model.id && r.model.providerID === task.route?.model.providerID); const configured = matching.length === 1 ? matching[0] : undefined; @@ -473,7 +502,7 @@ export class Dispatcher { } async helper(sessionID: string, callID: string, capability: "vision" | "audio") { const task = this.queue.tasks.find(t => t.sessionID === sessionID); - if (!task || closing(task) || task.phase !== "running" || task.question && !task.question.delivered) throw new Error("No active main bot session available for delegation"); + if (!task || suspended(task) || task.status === "watching" || task.phase !== "running" || task.question && !task.question.delivered) throw new Error("No active main bot session available for delegation"); const id = `ses_${createHash("sha256").update(`${sessionID}:${callID}`).digest("hex").slice(0, 32)}`; await this.serial.run(async () => { requireTracked(task); @@ -493,7 +522,8 @@ export class Dispatcher { this.signal.throwIfAborted(); const task = this.queue.tasks.find(t => t.key === key); if (!task) throw new Error("Task not found in this project"); - if (closing(task)) throw new Error("Task tracking is closed; inspect its saved session or create a new issue"); + if (closing(task)) throw new Error("Task tracking is closed; use Resume issue tracking to watch future comments."); + if (task.status === "cancelling") throw new Error("Wait for round cancellation to finish."); if (task.question && !task.question.delivered) throw new Error("Answer the pending question or permission request in the GitHub issue first"); if (task.merged || task.pr?.state === "closed") throw new Error("The original PR is closed or merged; reopen it or create a new issue"); if (!["blocked", "failed"].includes(task.status)) return false; @@ -527,8 +557,9 @@ export class Dispatcher { const task = this.queue.tasks.find(t => t.key === key); if (!task) throw new Error("Task not found in this project"); if (task.status === "closed") return undefined; + if (task.status === "cancelling") throw new Error("Round cancellation is still in progress; wait before ending tracking."); if (this.publishing.has(key)) throw new Error("Publication or merge is already in flight. Wait for it to finish, then close the task."); - Object.assign(task, { status: "closing", closeRequestedAt: task.closeRequestedAt ?? this.now(), closeError: undefined, nextAt: this.now() }); + Object.assign(task, { controlVersion: (task.controlVersion ?? 0) + 1, status: "closing", closeRequestedAt: task.closeRequestedAt ?? this.now(), closeError: undefined, nextAt: this.now() }); await this.store.save(this.queue); return task; }); @@ -565,5 +596,74 @@ export class Dispatcher { })().catch(error => console.error("Task closure failed", redact(error, this.secrets))).finally(() => this.closures.delete(task.key)); this.closures.set(task.key, operation); } - async settle() { await Promise.allSettled([this.scanning, this.working, this.maintenance, ...this.closures.values()]); } + async cancelRound(key: string, resumeTracking = false) { + // Reopening tracking explicitly ignores the backlog accumulated while closed. + // Read it before changing state; failed validation leaves the closed task intact. + const found = this.queue.tasks.find(t => t.key === key); + if (!found) throw new Error("Task not found in this project"); + let cursor: number | undefined; + if (resumeTracking) { + if (found.status !== "closed") return false; + if ((await this.github.issue(found.repo, found.issue.number)).state !== "open") throw new Error("Reopen the GitHub issue before resuming tracking."); + if (found.pr && (await this.github.pull(found.repo, found.pr.number)).state !== "open") throw new Error("The PR is no longer open; tracking was not resumed."); + cursor = Math.max(found.commentCursor ?? 0, ...(await this.github.comments(found.repo, found.issue.number)).map(c => c.id)); + } + const task = await this.serial.run(async () => { + this.signal.throwIfAborted(); + if (resumeTracking && found.status !== "closed") return undefined; + if (!resumeTracking && ["done", "watching"].includes(found.status)) return undefined; + if (!resumeTracking && closing(found)) throw new Error("Task tracking is closed. Use Resume issue tracking to watch future comments without replaying this round."); + if (this.publishing.has(key)) throw new Error("Publication or merge is already in flight. Wait before cancelling; remote effects cannot be rolled back."); + if (found.status !== "cancelling") { + Object.assign(found, { status: "cancelling", controlVersion: (found.controlVersion ?? 0) + 1, + cancellation: { requestedAt: this.now() }, nextAt: this.now(), + ...(cursor !== undefined ? { commentCursor: cursor, pendingFeedback: [] } : {}), + }); + await this.store.save(this.queue); + } + return found; + }); + if (!task) return false; + await this.notify(activityOf(task)).catch(() => {}); + this.startCancelling(task); + return true; + } + private startCancelling(task: Task) { + if (this.cancellations.has(task.key) || this.signal.aborted) return; + const work = this.activeTask === task.key ? this.working : undefined; + const operation = (async () => { + try { + await this.executor.cancel(task, true); + await work; + await Promise.allSettled([...this.questionPosts].filter(([key]) => key.startsWith(`