From e319ed70107cf63e3c27688064e5097242442bc7 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 25 Oct 2026 14:29:09 -0400 Subject: [PATCH 1/8] Retry accepted reviews after GitHub capacity recovers Keep source verification deferred with its retry deadline instead of clearing accepted launch intent. Retry the same run after capacity returns, rechecking its current target and policy without duplicating automatic review claims. Expose waiting and cancellation in the dashboard, and conservatively recover saved pre-agent budget failures. Retain ordinary failures and stop authority. Fixes #4 --- docs/glossary/README.md | 9 + docs/glossary/terms.json | 9 + docs/ui/README.md | 8 +- docs/ui/entities.json | 185 +++++--- docs/ui/references.md | 5 + docs/ui/widgets.md | 8 +- docs/user-flows/catalog.json | 329 ++++++++++++- docs/user-flows/interact-agent.md | 11 +- docs/user-flows/launch-agent.md | 21 +- docs/user-flows/recover-cleanup.md | 20 +- docs/user-flows/sync-inbox.md | 17 +- internal/app/app.go | 3 + internal/app/continuation.go | 3 +- internal/app/launch_retry.go | 143 ++++++ internal/app/launch_retry_test.go | 736 +++++++++++++++++++++++++++++ internal/app/review_eligibility.go | 3 + internal/app/review_requests.go | 14 +- internal/app/runs.go | 55 ++- internal/app/tracking.go | 2 +- internal/app/work.go | 4 +- internal/domain/types.go | 1 + web/src/main.tsx | 56 ++- web/src/types.ts | 2 + web/tests/factory.spec.ts | 92 ++++ 24 files changed, 1611 insertions(+), 125 deletions(-) create mode 100644 internal/app/launch_retry.go create mode 100644 internal/app/launch_retry_test.go diff --git a/docs/glossary/README.md b/docs/glossary/README.md index a39b8dd..1e5566d 100644 --- a/docs/glossary/README.md +++ b/docs/glossary/README.md @@ -346,6 +346,15 @@ The current turn has finished and the retained session can receive follow-up. Active work has stopped with its session retained for continuation. + +## Waiting for GitHub + +`state-deferred` · state + +Accepted work awaiting an automatic retry after temporary GitHub request-budget, quota-reserve or rate-limit unavailability, with its reason and earliest retry time retained. + +Aliases: Deferred. + ## Failed diff --git a/docs/glossary/terms.json b/docs/glossary/terms.json index 36910d2..18e58de 100644 --- a/docs/glossary/terms.json +++ b/docs/glossary/terms.json @@ -344,6 +344,15 @@ "category": "state", "definition": "Active work has stopped with its session retained for continuation." }, + { + "id": "state-deferred", + "name": "Waiting for GitHub", + "aliases": [ + "Deferred" + ], + "category": "state", + "definition": "Accepted work awaiting an automatic retry after temporary GitHub request-budget, quota-reserve or rate-limit unavailability, with its reason and earliest retry time retained." + }, { "id": "state-failed", "name": "Failed", diff --git a/docs/ui/README.md b/docs/ui/README.md index 7ce2309..b3d5f23 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -474,10 +474,10 @@ No installed UI, authentication, service or browser-session change is implied. | `W-ITEM-HISTORY` | widget | [Item history widget](../glossary/README.md#ui-w-item-history), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior) | | `W-RUN-ROW` | widget | [Run row widget](../glossary/README.md#ui-w-run-row), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior) | | `W-RUN-EMPTY` | widget | [Run empty widget](../glossary/README.md#ui-w-run-empty), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior) | -| `W-RUN-CONTROLS` | widget | [Run controls widget](../glossary/README.md#ui-w-run-controls), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior) | +| `W-RUN-CONTROLS` | widget | [Run controls widget](../glossary/README.md#ui-w-run-controls), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior), [Waiting for GitHub](../glossary/README.md#state-deferred) | | `W-ATTENTION-NOTICE` | widget | [Attention notice widget](../glossary/README.md#ui-w-attention-notice), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior) | | `W-TRANSCRIPT` | widget | [Transcript widget](../glossary/README.md#ui-w-transcript), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior) | -| `W-MESSAGE` | widget | [Message widget](../glossary/README.md#ui-w-message), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior) | +| `W-MESSAGE` | widget | [Message widget](../glossary/README.md#ui-w-message), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior), [Waiting for GitHub](../glossary/README.md#state-deferred) | | `W-DIALOG-FRAME` | widget | [Dialog frame widget](../glossary/README.md#ui-w-dialog-frame), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior), [Keyboard focus](../glossary/README.md#keyboard-focus), [Modal dialog](../glossary/README.md#modal-dialog) | | `W-LAUNCH-EDITOR` | widget | [Launch editor widget](../glossary/README.md#ui-w-launch-editor), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior) | | `W-LAUNCH-REVIEW` | widget | [Launch review widget](../glossary/README.md#ui-w-launch-review), [Coverage gap](../glossary/README.md#coverage-gap), [Unverified behavior](../glossary/README.md#unverified-behavior) | @@ -585,9 +585,9 @@ No installed UI, authentication, service or browser-session change is implied. | `W-WAITING-ROW` | widget | [Waiting message widget](../glossary/README.md#ui-w-waiting-row) | | `W-NEW-CHAT` | widget | [First message widget](../glossary/README.md#ui-w-new-chat) | | `C-START-CHAT` | connection | [First message start connection](../glossary/README.md#ui-c-start-chat) | -| `W-WORK-STATUS` | widget | [Work status widget](../glossary/README.md#ui-w-work-status) | +| `W-WORK-STATUS` | widget | [Work status widget](../glossary/README.md#ui-w-work-status), [Waiting for GitHub](../glossary/README.md#state-deferred) | | `PAGE-WORKING` | page | [Working page](../glossary/README.md#ui-page-working) | -| `W-SESSION-CONSOLE` | widget | [Session console widget](../glossary/README.md#ui-w-session-console) | +| `W-SESSION-CONSOLE` | widget | [Session console widget](../glossary/README.md#ui-w-session-console), [Waiting for GitHub](../glossary/README.md#state-deferred) | | `W-QUESTION-RECOVERY` | widget | [Question recovery widget](../glossary/README.md#ui-w-question-recovery) | | `C-SESSION-CONSOLE` | connection | [Session console connection](../glossary/README.md#ui-c-session-console) | | `C-PIPELINE-SELECT` | connection | [Pipeline selection connection](../glossary/README.md#ui-c-pipeline-select) | diff --git a/docs/ui/entities.json b/docs/ui/entities.json index e1ac14e..734a022 100644 --- a/docs/ui/entities.json +++ b/docs/ui/entities.json @@ -1881,7 +1881,8 @@ "terms": [ "ui-w-run-controls", "coverage-gap", - "unverified-behavior" + "unverified-behavior", + "state-deferred" ], "contains": [], "elements": [ @@ -1927,7 +1928,8 @@ "state-idle", "state-interrupted", "state-failed", - "state-terminated" + "state-terminated", + "state-deferred" ], "implementation": [ { @@ -1983,7 +1985,10 @@ "UI-CONVERSATION-DETAILS", "UI-CONVERSATION-DETAILS", "UI-WAKE-MESSAGE", - "UI-TERMINATED-WORK" + "UI-TERMINATED-WORK", + "UI-GITHUB-DEFERRED", + "UI-FAILED-REVIEW", + "RUN-LAUNCH-DEFERRED-CANCEL" ], "gaps": [ { @@ -2011,7 +2016,7 @@ "go" ], "element_description": "**task-title** and visible **run-error**; **details** holds **profile-name**, **interrupt**, technical activity, **workspace**, **cleanup-effect** and **cleanup**. Activity appears only in the work item status widget. The selected or listed session lifecycle is displayed separately from aggregate item activity.", - "state_description": "No Resume control or standalone agent activity header. Interrupt is secondary while a turn is active; cleanup is secondary for idle/interrupted/failed sessions. Terminated source work retains its reason and clean-workspace cleanup; dirty refusal does not make that session resumable.", + "state_description": "No Resume control or standalone agent activity header. Interrupt is secondary while a turn is active; cleanup is secondary for idle/interrupted/failed sessions. Terminated source work retains its reason and clean-workspace cleanup; dirty refusal does not make that session resumable. Deferred pre-agent launches expose Cancel retry; interruption or cleanup cancels their retained retry even when no agent session is attached.", "interactions": "Explicit interrupt and cleanup refresh the snapshot on success or failure. Dirty cleanup preserves files and history; a later message restores the same session. Action errors and drafts remain visible." }, { @@ -2116,7 +2121,7 @@ "go" ], "element_description": "Whole-notice **answer** button; **blocking-label** or **continuing-label**.", - "state_description": "Repeated for pending requests belonging to the selected run. Explicitly nonblocking recovered text retains Question · agent continues; unconfirmed completion and unresolved clarification create no notice.", + "state_description": "Repeated for pending requests belonging to the selected run. Explicitly nonblocking recovered text retains Question \u00b7 agent continues; unconfirmed completion and unresolved clarification create no notice.", "interactions": "Opens `D-ATTENTION` for that request. Nonblocking requests do not imply a stopped agent. Retained message/text questions leave actionable attention individually after explicit same-run decision acknowledgement; partial and unverified questions remain available. Other-run publication does not retire them." }, { @@ -2258,7 +2263,8 @@ "terms": [ "ui-w-message", "coverage-gap", - "unverified-behavior" + "unverified-behavior", + "state-deferred" ], "contains": [], "elements": [ @@ -2276,7 +2282,8 @@ "state-running", "state-waiting", "state-idle", - "state-terminated" + "state-terminated", + "state-deferred" ], "implementation": [ { @@ -2337,7 +2344,9 @@ "UI-QUEUED-MESSAGE", "API-QUESTION-ACTIVE-TURN", "API-INACTIVE-QUESTION-FOLLOWUP", - "UI-CLARIFICATION" + "UI-CLARIFICATION", + "UI-GITHUB-DEFERRED", + "UI-FAILED-REVIEW" ], "gaps": [ { @@ -2354,8 +2363,8 @@ "tooling", "browser" ], - "element_description": "**text** “Message agent” textarea; **send** button.", - "state_description": "Send is disabled while submitting, for blank text, or in prepared/queued/starting/cleaned states. Idle/running/waiting/interrupted/failed sessions accept messages. Terminated sessions cannot accept messages or resume even if the source later reopens. Active question classification, clarification or form repair disables Send even with a filled draft. Unresolved classification permits explicit owner messaging.", + "element_description": "**text** \u201cMessage agent\u201d textarea; **send** button.", + "state_description": "Send is disabled while submitting, for blank text, or in prepared/queued/starting/deferred/cleaned states. Idle/running/waiting/interrupted/failed sessions accept messages. Terminated sessions cannot accept messages or resume even if the source later reopens. Active question classification, clarification or form repair disables Send even with a filled draft. Unresolved classification permits explicit owner messaging. A cancelled pre-agent launch without a retained thread also keeps the composer disabled.", "interactions": "Active messages require the displayed expected turn. A successful send records the message durably and clears only unchanged submitted text before agent delivery; failed acceptance preserves the draft. Background delivery restores the retained session using this submitted message as its first new turn and makes no GitHub preflight. The UI never calls Resume separately or automatically replays a mutation. Later delivery status remains visible in the conversation." }, { @@ -3934,7 +3943,7 @@ { "path": "internal/gateway/gateway.go", "anchor": "func (g *Gateway) Private(", - "purpose": "Private `/approve` handler; native form buttons are “Connect Code Factory” and “Cancel.” Request/nonce values are private hidden session fields and must not appear in examples/evidence." + "purpose": "Private `/approve` handler; native form buttons are \u201cConnect Code Factory\u201d and \u201cCancel.\u201d Request/nonce values are private hidden session fields and must not appear in examples/evidence." } ], "assertions": [ @@ -3957,7 +3966,7 @@ "tooling", "go" ], - "element_description": "**connection-heading**, **client-name**, **callback-destination**, **owner-access-warning**, **allow** “Connect Code Factory”, **deny** “Cancel”.", + "element_description": "**connection-heading**, **client-name**, **callback-destination**, **owner-access-warning**, **allow** \u201cConnect Code Factory\u201d, **deny** \u201cCancel\u201d.", "state_description": "Only a valid owner/private request sees the form. Expired, invalid or unrelated-owner requests return error pages.", "interactions": "Allow or Deny consumes the request and redirects to its registered host callback. No credential field." }, @@ -4669,7 +4678,7 @@ "go" ], "composition": "`W-RUN-ROW` repeated by run ID, or `W-RUN-EMPTY`", - "context": "Collapsed Work history inside the selected item’s Item details, containing only that item’s retained conversations and nested Draft launches." + "context": "Collapsed Work history inside the selected item\u2019s Item details, containing only that item\u2019s retained conversations and nested Draft launches." }, { "id": "P-RUN-DETAIL", @@ -5751,7 +5760,7 @@ { "path": "internal/gateway/gateway.go", "anchor": "func (g *Gateway) Private(", - "purpose": "Private `/approve` handler; native form buttons are “Connect Code Factory” and “Cancel.” Request/nonce values are private hidden session fields and must not appear in examples/evidence." + "purpose": "Private `/approve` handler; native form buttons are \u201cConnect Code Factory\u201d and \u201cCancel.\u201d Request/nonce values are private hidden session fields and must not appear in examples/evidence." } ], "assertions": [ @@ -6456,7 +6465,7 @@ { "path": "internal/gateway/gateway.go", "anchor": "func (g *Gateway) Private(", - "purpose": "Private `/approve` handler; native form buttons are “Connect Code Factory” and “Cancel.” Request/nonce values are private hidden session fields and must not appear in examples/evidence." + "purpose": "Private `/approve` handler; native form buttons are \u201cConnect Code Factory\u201d and \u201cCancel.\u201d Request/nonce values are private hidden session fields and must not appear in examples/evidence." } ], "assertions": [ @@ -6736,7 +6745,7 @@ "browser", "go" ], - "endpoint_description": "`W-INBOX-QUERY` → `P-INBOX-LIST`", + "endpoint_description": "`W-INBOX-QUERY` \u2192 `P-INBOX-LIST`", "trigger": "Search text and filter value.", "interactions": "Applies per-tab kind/ownership/review membership, filter and query. Pending issues exclude linked PRs; lists sort by priority then update time.", "from": [ @@ -6825,7 +6834,7 @@ "browser", "go" ], - "endpoint_description": "Work row → item conversation", + "endpoint_description": "Work row \u2192 item conversation", "trigger": "Click a row; item ID.", "interactions": "Loads and marks the displayed item revision read, continues the newest retained conversation or shows a prefilled first message. Never starts or prepares on selection.", "from": [ @@ -6922,7 +6931,7 @@ "browser", "go" ], - "endpoint_description": "`W-ITEM-ACTIONS` → persisted item state → shared snapshot indicators; detail history is a separately retained read.", + "endpoint_description": "`W-ITEM-ACTIONS` \u2192 persisted item state \u2192 shared snapshot indicators; detail history is a separately retained read.", "trigger": "Unread, acknowledge New or record action; item ID.", "interactions": "Submits item state changes, refreshes snapshot indicators and rereads selected detail/history on success. Independent read/New/action indicators and unrelated drafts remain unchanged. A deferred provider refresh leaves the displayed saved information unread until a successful retry.", "from": [ @@ -7005,7 +7014,7 @@ "go", "browser" ], - "endpoint_description": "`W-PRIORITY.override` → local priority state and displayed item", + "endpoint_description": "`W-PRIORITY.override` \u2192 local priority state and displayed item", "trigger": "Picker change; automatic clears the override.", "interactions": "Saves immediately, disables during submission and restores the saved value with inline error on failure.", "from": [ @@ -7097,7 +7106,7 @@ "go", "browser" ], - "endpoint_description": "`W-RELATIONSHIPS` → selected item's relationships/context", + "endpoint_description": "`W-RELATIONSHIPS` \u2192 selected item's relationships/context", "trigger": "Add submits `source` and `target`; Remove submits `source`, `target` and `remove: true`.", "interactions": "Submits local Add/Remove, then refreshes selected relationships without clearing unrelated drafts. A late earlier response cannot replace later selected detail.", "from": [ @@ -7179,7 +7188,7 @@ "tooling", "browser" ], - "endpoint_description": "`W-ITEM-ACTIONS.launch` → `D-LAUNCH`", + "endpoint_description": "`W-ITEM-ACTIONS.launch` \u2192 `D-LAUNCH`", "trigger": "Selected item entry.", "interactions": "New run under Item details prepares an optional proposal and opens its durable review. No workspace/process starts until Start.", "from": [ @@ -7259,7 +7268,7 @@ "tooling", "browser" ], - "endpoint_description": "`W-LAUNCH-EDITOR.profile` → its editable fields", + "endpoint_description": "`W-LAUNCH-EDITOR.profile` \u2192 its editable fields", "trigger": "Select a profile.", "interactions": "Replaces prompt/model/effort/skill drafts; keeps the current base draft.", "from": [ @@ -7351,9 +7360,9 @@ "browser", "go" ], - "endpoint_description": "Default launch or editor Review → prepared review", + "endpoint_description": "Default launch or editor Review \u2192 prepared review", "trigger": "Default launch, or explicit Review after customization.", - "interactions": "Default launch prepares immediately; optional edits reprepare explicitly. Preparation errors keep retry and Edit available without Start. PR review preparation rejects the signed-in account’s own PR and missing author/identity without creating a proposal.", + "interactions": "Default launch prepares immediately; optional edits reprepare explicitly. Preparation errors keep retry and Edit available without Start. PR review preparation rejects the signed-in account\u2019s own PR and missing author/identity without creating a proposal.", "from": [ "W-LAUNCH-EDITOR.review", "W-ITEM-ACTIONS.launch" @@ -7434,7 +7443,7 @@ "tooling", "browser" ], - "endpoint_description": "`W-LAUNCH-REVIEW.edit` → `P-LAUNCH-EDITOR`", + "endpoint_description": "`W-LAUNCH-REVIEW.edit` \u2192 `P-LAUNCH-EDITOR`", "trigger": "Edit launch click.", "interactions": "Returns to the editor. The preceding prepared record is retained, not modified/deleted.", "from": [ @@ -7538,7 +7547,7 @@ "browser", "go" ], - "endpoint_description": "`W-LAUNCH-REVIEW.start` → `PAGE-AGENTS`/`P-RUN-DETAIL`", + "endpoint_description": "`W-LAUNCH-REVIEW.start` \u2192 `PAGE-AGENTS`/`P-RUN-DETAIL`", "trigger": "Prepared run ID.", "interactions": "Explicit Start only. Error/lost response reads the same run; accepted work opens, still-prepared work permits retry, unknown status disables Start/Edit and offers Check start status. PR review Start rechecks the current author and identity and rejects self review without queuing work; the retained proposal remains available.", "from": [ @@ -7626,8 +7635,8 @@ "browser", "go" ], - "endpoint_description": "Retained run or draft row → conversation or review", - "trigger": "Retained conversation or draft row inside the owning item’s Work history.", + "endpoint_description": "Retained run or draft row \u2192 conversation or review", + "trigger": "Retained conversation or draft row inside the owning item\u2019s Work history.", "interactions": "Opens the selected retained conversation within the owning item, preserving the originating work queue Back context. Prepared rows reopen their existing immutable review without preparing or starting again.", "from": [ "W-RUN-ROW.select" @@ -7731,7 +7740,7 @@ "browser", "go" ], - "endpoint_description": "`W-RUN-CONTROLS` → selected run → Agents widgets", + "endpoint_description": "`W-RUN-CONTROLS` \u2192 selected run \u2192 Agents widgets", "trigger": "Explicit Interrupt, question-recovery Take over session, or Cleanup for the selected retained run.", "interactions": "Takeover cancels further form repair and opens the same session console. Cleanup preserves the existing clean-worktree boundary. Source closure or merge permanently terminates the session while retaining its history and workspace.", "from": [ @@ -7837,7 +7846,7 @@ "browser", "go" ], - "endpoint_description": "`W-MESSAGE` → selected run → `W-TRANSCRIPT`", + "endpoint_description": "`W-MESSAGE` \u2192 selected run \u2192 `W-TRANSCRIPT`", "trigger": "Message text and displayed current-turn context.", "interactions": "Send with current-turn identity while active. Acknowledge durable local queue acceptance before contacting the agent; background delivery restores the same retained session when needed. Saved source context is explicitly unverified; task-required GitHub reads belong to the agent. Accepted sends clear unchanged submitted drafts; rejected acceptance preserves them. Delivery uncertainty remains visible and is never automatically replayed. Retained message/text questions leave actionable attention individually after explicit same-run decision acknowledgement; partial and unverified questions remain available. Other-run publication does not retire them.", "from": [ @@ -7921,7 +7930,7 @@ "browser", "go" ], - "endpoint_description": "`W-ATTENTION-NOTICE[requestId]` → `D-ATTENTION`", + "endpoint_description": "`W-ATTENTION-NOTICE[requestId]` \u2192 `D-ATTENTION`", "trigger": "Notice click; request ID belonging to the selected run.", "interactions": "Opens that request without answering.", "from": [ @@ -8013,7 +8022,7 @@ "browser", "go" ], - "endpoint_description": "Messages request → owning run and attention dialog", + "endpoint_description": "Messages request \u2192 owning run and attention dialog", "trigger": "Select one pending request.", "interactions": "Selects that exact owning run and request; identifies item/profile and returns to that conversation after answering. Retained message/text questions leave actionable attention individually after explicit same-run decision acknowledgement; partial and unverified questions remain available. Other-run publication does not retire them.", "from": [ @@ -8098,7 +8107,7 @@ "browser", "go" ], - "endpoint_description": "Attention dialog → owning conversation", + "endpoint_description": "Attention dialog \u2192 owning conversation", "trigger": "Current attention's owning run ID.", "interactions": "Closes attention and opens the owning conversation without submitting an answer.", "from": [ @@ -8192,7 +8201,7 @@ "browser", "go" ], - "endpoint_description": "`W-QUESTIONS` / `W-APPROVAL` / `W-ELICITATION` → request/run state", + "endpoint_description": "`W-QUESTIONS` / `W-APPROVAL` / `W-ELICITATION` \u2192 request/run state", "trigger": "Response matched to current request ID.", "interactions": "Submits to the matching request. Success selects the owning run, refreshes and closes; a refresh failure is reported in the shell without replaying the answer. Answer failures stay in attention. Original approval payloads are preserved behind readable labels. Retained message/text questions leave actionable attention individually after explicit same-run decision acknowledgement; partial and unverified questions remain available. Other-run publication does not retire them.", "from": [ @@ -8282,7 +8291,7 @@ "tooling", "browser" ], - "endpoint_description": "Launch/attention close control → underlying page", + "endpoint_description": "Launch/attention close control \u2192 underlying page", "trigger": "Close click or Escape inside the active dialog.", "interactions": "Dismisses only that dialog and restores its connected opener when permitted. No attention answer or interrupt is sent.", "from": [ @@ -8367,7 +8376,7 @@ "browser", "go" ], - "endpoint_description": "`W-REPOSITORY-SETTINGS.save` → repository summary / sync inputs", + "endpoint_description": "`W-REPOSITORY-SETTINGS.save` \u2192 repository summary / sync inputs", "trigger": "Repository ID, checkout and default base.", "interactions": "Shows saving/success/error within the independent settings view. Successful saves remain there; other open task views receive refreshed shared data without navigation or draft reset.", "from": [ @@ -8455,7 +8464,7 @@ "browser", "go" ], - "endpoint_description": "`W-REPOSITORY-SETTINGS.discover-skills` → profile and launch skill choices", + "endpoint_description": "`W-REPOSITORY-SETTINGS.discover-skills` \u2192 profile and launch skill choices", "trigger": "Explicit skill discovery directory, initially the filesystem root.", "interactions": "Loads/discovers skills and refreshes shared choices without saving the repository first.", "from": [ @@ -8542,7 +8551,7 @@ "browser", "go" ], - "endpoint_description": "`W-POLICY-SETTINGS` → config / priority assessment inputs", + "endpoint_description": "`W-POLICY-SETTINGS` \u2192 config / priority assessment inputs", "trigger": "Label/priority rows, assessment prompt and notification setting; disabled Jira values are not editable here.", "interactions": "Submits configuration; later background assessment can update priority displays. Does not create a host subscription.", "from": [ @@ -8631,7 +8640,7 @@ "browser", "go" ], - "endpoint_description": "`W-PROFILE-SETTINGS` → future `W-LAUNCH-EDITOR`", + "endpoint_description": "`W-PROFILE-SETTINGS` \u2192 future `W-LAUNCH-EDITOR`", "trigger": "Saved profile draft.", "interactions": "Refreshes available/default launch settings; existing prepared snapshots stay unchanged.", "from": [ @@ -8720,7 +8729,7 @@ "browser", "go" ], - "endpoint_description": "`W-SKILL-SETTINGS` → profile/launch skill choices", + "endpoint_description": "`W-SKILL-SETTINGS` \u2192 profile/launch skill choices", "trigger": "Managed skill name/instructions.", "interactions": "Refreshes shared skills; immutable prepared skill snapshots stay unchanged.", "from": [ @@ -8806,7 +8815,7 @@ "tooling", "browser" ], - "endpoint_description": "`W-DIAGNOSTICS` → its result display", + "endpoint_description": "`W-DIAGNOSTICS` \u2192 its result display", "trigger": "Check connection click.", "interactions": "Shows local Checking feedback and then health results or an exact local error. No repair or page transition.", "from": [ @@ -8931,7 +8940,7 @@ "browser", "go" ], - "endpoint_description": "Shared updated state → Inbox/Agents/Settings summaries, shell counts", + "endpoint_description": "Shared updated state \u2192 Inbox/Agents/Settings summaries, shell counts", "trigger": "Successful polling or action refresh.", "interactions": "Refreshes shared snapshot after successful or failed actions, preserving action errors. Successful selected item mutations also trigger a separate guarded detail reread. Background work/attention updates do not save local drafts. Only an explicit allowlist of read operations retries transient transport errors. The exact host-wrapped McpServerError connection failure is recognized as transport; other tool/business errors and all mutations are never automatically replayed. Poll recovery clears only polling feedback, not action failures. Dashboard refreshes use item summaries; selected-item reads fetch full activity and raw checks separately. Known read calls carry a unique freshness marker so host reuse of an earlier response cannot freeze an empty journal page or saved snapshot. This marker does not change server state, source authority or mutation replay rules. Saved item detail and review reads return immediately with observation state. Pending observations reread through the bridge, preserving saved content. Freshness markers never force provider requests or bypass monitoring pauses, budgets and cooldowns. Provider query cache hits preserve original observation times. Post-turn publication verification uses live data through the same cache boundary; live failure retains prior saved observations with an error.", "from": [ @@ -9024,7 +9033,7 @@ "tooling", "browser" ], - "endpoint_description": "History pages → `W-TRANSCRIPT`", + "endpoint_description": "History pages \u2192 `W-TRANSCRIPT`", "trigger": "Selected run and current history cursor; optional reset to beginning.", "interactions": "Refreshes display only. Resetting history does not resume/replay agent work.", "from": [ @@ -9075,7 +9084,7 @@ "tooling", "go" ], - "endpoint_description": "Package page links → artifacts / external host setup", + "endpoint_description": "Package page links \u2192 artifacts / external host setup", "trigger": "Download or follow documented setup instructions.", "interactions": "External package downloads and OAuth connection guidance. Only a valid observed registration enables the primary browser package. Portable downloads stay advanced; no connection is fabricated.", "from": [ @@ -9103,7 +9112,7 @@ { "path": "internal/gateway/gateway.go", "anchor": "func (g *Gateway) Private(", - "purpose": "Private `/approve` handler; native form buttons are “Connect Code Factory” and “Cancel.” Request/nonce values are private hidden session fields and must not appear in examples/evidence." + "purpose": "Private `/approve` handler; native form buttons are \u201cConnect Code Factory\u201d and \u201cCancel.\u201d Request/nonce values are private hidden session fields and must not appear in examples/evidence." } ], "assertions": [ @@ -9126,7 +9135,7 @@ "tooling", "go" ], - "endpoint_description": "External host → `PAGE-OWNER-CONSENT` → registered host callback", + "endpoint_description": "External host \u2192 `PAGE-OWNER-CONSENT` \u2192 registered host callback", "trigger": "Valid private request; Allow or Deny.", "interactions": "External. Consumes request; authentication then occurs in host, not by navigating into a local inbox URL.", "from": [ @@ -9210,7 +9219,7 @@ "browser", "go" ], - "endpoint_description": "`W-ELICITATION.open-authorization` → external authorization page", + "endpoint_description": "`W-ELICITATION.open-authorization` \u2192 external authorization page", "trigger": "Request-supplied URL opened through host.", "interactions": "External. Continue reports the user's completion decision; it does not independently prove external success.", "from": [ @@ -9258,7 +9267,7 @@ "tooling", "go" ], - "endpoint_description": "`W-LOCAL-TUNNEL-KEY` → alternate local transport session", + "endpoint_description": "`W-LOCAL-TUNNEL-KEY` \u2192 alternate local transport session", "trigger": "Single-use private setup input.", "interactions": "Submits and removes form on success; does not navigate into Settings or activate Jira.", "from": [ @@ -9311,7 +9320,7 @@ "tooling", "go" ], - "endpoint_description": "`W-BROWSER-LAB` controls → isolated browser → screenshot/location", + "endpoint_description": "`W-BROWSER-LAB` controls \u2192 isolated browser \u2192 screenshot/location", "trigger": "Explicit lab navigation/click/key/scroll operations.", "interactions": "Developer utility feedback loop; never an Inbox/Agents page transition.", "from": [ @@ -10255,7 +10264,7 @@ "browser", "go" ], - "endpoint_description": "Conversation Back → originating work or Messages tab", + "endpoint_description": "Conversation Back \u2192 originating work or Messages tab", "trigger": "Back action on the record page.", "interactions": "Restores list query, scroll and row focus when the row exists; missing rows leave a usable focus fallback. Ordinary drafts remain keyed to their records.", "from": [ @@ -10469,7 +10478,7 @@ "browser", "go" ], - "endpoint_description": "Host open_settings invocation → independent settings document", + "endpoint_description": "Host open_settings invocation \u2192 independent settings document", "trigger": "The settings entry tool advertises its own content-specific resource and output template.", "interactions": "Opens configuration in its own view. The inbox entry and legacy resource remain inbox-only. Both surfaces reuse authenticated bridged tools without owner credentials or direct iframe localhost access.", "from": [ @@ -10767,7 +10776,7 @@ "tooling", "browser" ], - "endpoint_description": "First-message Start → retained run conversation", + "endpoint_description": "First-message Start \u2192 retained run conversation", "trigger": "Explicit Start with the visible message and authority.", "interactions": "Prepares then starts once; verifies the same run after an uncertain response. Preserves per-item draft and recovery state.", "from": [ @@ -10784,7 +10793,8 @@ "id": "W-WORK-STATUS", "kind": "widget", "terms": [ - "ui-w-work-status" + "ui-w-work-status", + "state-deferred" ], "contains": [], "elements": [ @@ -10794,7 +10804,8 @@ } ], "states": [ - "state-terminated" + "state-terminated", + "state-deferred" ], "implementation": [ { @@ -10806,6 +10817,11 @@ "path": "internal/app/runs.go", "anchor": "func (m *Manager) Recover(", "purpose": "Restore pending same-thread progress at startup while preserving completed, explicitly interrupted and input-waiting work." + }, + { + "path": "internal/app/launch_retry.go", + "anchor": "func (m *Manager) RetryDeferredLaunches(", + "purpose": "Retain scheduled launch state across daemon ticks/restart without duplicate execution." } ], "assertions": [ @@ -10816,7 +10832,11 @@ "API-WORKING", "UI-WORKING-MONITOR", "RUN-AUTO-CONTINUE", - "UI-TERMINATED-WORK" + "UI-TERMINATED-WORK", + "UI-GITHUB-DEFERRED", + "UI-FAILED-REVIEW", + "RUN-LAUNCH-DEFERRED", + "RUN-LAUNCH-DEFERRED-PROJECTION" ], "gaps": [ { @@ -10833,7 +10853,7 @@ "browser" ], "element_description": "Compact **value** in issue/PR metadata.", - "state_description": "Server aggregate across started sessions: Not started, Starting, Active, Waiting for you, Inactive, Failed, Interrupted or Session closed. Blocking attention outranks newer idle work. GitHub completion and runtime activity are independent. After daemon startup, pending sessions continue from their retained progress automatically. Completed idle work stays idle; explicit interrupts and unanswered questions stay stopped. A closed issue or closed/merged PR terminates its retained sessions and displays Stopped; history and workspaces remain available.", + "state_description": "Server aggregate across started sessions: Not started, Starting, Active, Waiting for GitHub, Waiting for you, Inactive, Failed, Interrupted or Session closed. Blocking attention outranks newer idle work. GitHub completion and runtime activity are independent. After daemon startup, pending sessions continue from their retained progress automatically. Completed idle work stays idle; explicit interrupts and unanswered questions stay stopped. A closed issue or closed/merged PR terminates its retained sessions and displays Stopped; history and workspaces remain available. Accepted launch preflight delayed by the request budget, quota reserve or rate limit displays Waiting for GitHub while its automatic retry remains pending.", "interactions": "Display only; no separate activity control or Resume action." }, { @@ -10872,7 +10892,8 @@ "id": "W-SESSION-CONSOLE", "kind": "widget", "terms": [ - "ui-w-session-console" + "ui-w-session-console", + "state-deferred" ], "contains": [], "elements": [ @@ -10881,7 +10902,9 @@ "term": "element-w-session-console-output" } ], - "states": [], + "states": [ + "state-deferred" + ], "implementation": [ { "path": "web/src/main.tsx", @@ -10890,7 +10913,9 @@ } ], "assertions": [ - "UI-SESSION-CONSOLE" + "UI-SESSION-CONSOLE", + "UI-GITHUB-DEFERRED", + "UI-FAILED-REVIEW" ], "gaps": [], "suites": [ @@ -10898,7 +10923,7 @@ "go" ], "element_description": "Paged messages, commands/output, plans, changes and errors from the selected retained session.", - "state_description": "Selected instead of the concise conversation; preserves per-run reply draft and existing paging bounds.", + "state_description": "Selected instead of the concise conversation; preserves per-run reply draft and existing paging bounds. Deferred work shows Waiting for GitHub, the retained provider reason and next retry time in both conversation and console views. Cancel retry stops the scheduled launch without creating an input request.", "interactions": "Uses the same answer, message and interrupt operations; does not open another process or terminal transport." }, { @@ -11639,7 +11664,7 @@ "browser", "go" ], - "endpoint_description": "Owner usage settings → persisted request limits and cached accounting", + "endpoint_description": "Owner usage settings \u2192 persisted request limits and cached accounting", "trigger": "Save system defaults or read cached Settings.", "interactions": "Validates and persists bounded request budget/reserve/poll interval; omitted fields from older clients retain their saved values. Budget-only changes preserve assessment policy identity.", "from": [ @@ -12239,7 +12264,7 @@ "id": "UI-ATTENTION-CONTEXT", "path": "web/tests/factory.spec.ts", "anchor": "global attention selects its owning run and returns to its conversation after answering", - "assertion": "Global dialog identifies title/profile; answer leaves owning conversation selected and next message targets its run. After answering the removed global shortcut, keyboard focus moves to the owning task heading. Cross-run attention entered from another item’s agent resets Back to the Agents list, avoiding the unrelated item." + "assertion": "Global dialog identifies title/profile; answer leaves owning conversation selected and next message targets its run. After answering the removed global shortcut, keyboard focus moves to the owning task heading. Cross-run attention entered from another item\u2019s agent resets Back to the Agents list, avoiding the unrelated item." }, { "id": "UI-START-UNKNOWN", @@ -12287,7 +12312,7 @@ "id": "UI-SINGLE-PAGE", "path": "web/tests/factory.spec.ts", "anchor": "single-page navigation preserves list context and drafts at", - "assertion": "1280px and 390px in both themes: no brand/header/footer/split/adjacent detail; full-width list; item replaces list; Back restores query/scroll/row focus; agent Back origins and per-record drafts remain without implicit work. Priority is inside the row selection button to the left of the title’s first line, with no duplicate metadata badge or narrow overflow; checked geometrically in both themes at 390/1280px with a long title." + "assertion": "1280px and 390px in both themes: no brand/header/footer/split/adjacent detail; full-width list; item replaces list; Back restores query/scroll/row focus; agent Back origins and per-record drafts remain without implicit work. Priority is inside the row selection button to the left of the title\u2019s first line, with no duplicate metadata badge or narrow overflow; checked geometrically in both themes at 390/1280px with a long title." }, { "id": "UI-PAGE-FALLBACK", @@ -12798,6 +12823,36 @@ "path": "web/tests/factory.spec.ts", "anchor": "cooldown without an error keeps a retry action and saved details unread until fresh", "assertion": "Error-free cooldown renders saved details, deferred status, optional retry time and the existing Retry control. Retrying while deferred keeps unread and the message draft; retrying after fresh data is allowed marks the displayed revision once, clears deferred feedback and retains the draft without launching work." + }, + { + "id": "UI-GITHUB-DEFERRED", + "path": "web/tests/factory.spec.ts", + "anchor": "GitHub-deferred review shows its retry reason and deadline and can cancel before a thread starts", + "assertion": "Working list and focused conversation show Waiting for GitHub, retained reason and localized deadline with exact datetime; no alert or owner question appears. A threadless run cannot send messages; Cancel retry sends one interrupt_run and leaves it inactive without Start/message mutations." + }, + { + "id": "UI-FAILED-REVIEW", + "path": "web/tests/factory.spec.ts", + "anchor": "failed reviews keep failure status and alert instead of GitHub retry controls", + "assertion": "An ordinary failed review keeps Failed in list and detail, displays its original alert, exposes no retry control and permits messaging its retained session." + }, + { + "id": "RUN-LAUNCH-DEFERRED", + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredReviewLaunchRespectsDeadlineAndRetriesExactlyOnce(", + "assertion": "Persist reason/deadline before workspace or agent startup; twelve concurrent due retries produce exactly one run, one automatic claim and one thread/turn start." + }, + { + "id": "RUN-LAUNCH-DEFERRED-CANCEL", + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchInterruptAndCleanupCancelAttachedAndDetachedRuns(", + "assertion": "Interrupt and cleanup clear retry deadline/pending authority for attached and detached launches; restart and later ticks start no agent." + }, + { + "id": "RUN-LAUNCH-DEFERRED-PROJECTION", + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchProjectionRetainsWorkingQueueAndRetryDeadline(", + "assertion": "Deferred work stays in Working with Waiting for GitHub; tracking retains launchRetryAt and cancellation clears the waiting projection." } ], "source_map": [ @@ -13027,7 +13082,7 @@ "path": "internal/gateway/gateway.go", "anchor": "func (g *Gateway) Private(", "entities": "`PAGE-OWNER-CONSENT`, `P-OWNER-CONSENT`, `W-OWNER-CONSENT`, `C-CONSENT`.", - "detail": "Private `/approve` handler; native form buttons are “Connect Code Factory” and “Cancel.” Request/nonce values are private hidden session fields and must not appear in examples/evidence.", + "detail": "Private `/approve` handler; native form buttons are \u201cConnect Code Factory\u201d and \u201cCancel.\u201d Request/nonce values are private hidden session fields and must not appear in examples/evidence.", "anchors": [ "func (g *Gateway) Private(" ], @@ -14154,7 +14209,7 @@ ], "headers": [ "ID", - "From → to", + "From \u2192 to", "Trigger and carried context", "Result/type" ] diff --git a/docs/ui/references.md b/docs/ui/references.md index b780d18..0c98152 100644 --- a/docs/ui/references.md +++ b/docs/ui/references.md @@ -188,6 +188,11 @@ These references identify existing assertions and explicit remaining gaps. | `API-INACTIVE-QUESTION-FOLLOWUP` | [internal/app/messages_test.go](../../internal/app/messages_test.go) — `func TestDisconnectedQuestionFollowupRestoresOneSubmittedTurn(` | After actual provider loss, TrackWork preserves historical currentRun.turnId but advertises an empty inactive continuation token. Passing its returned runId/expectedTurn to QueueMessage restores the same thread/workspace/profile/prompt with exactly one submitted question reply; stale active tokens reject, the question is answered and Vault retains the reply without original-task replay. | | `ADMIN-REMOVED-SKILL` | [internal/app/plugins_test.go](../../internal/app/plugins_test.go) — `func TestRemovedPluginSkillStaysUnavailableWhileCachedFilesRemain(` | Successful native removal/discovery omission disables cached unmanaged skill and blocks new Prepare while entrypoint files and prior prepared snapshots remain. Cancelled discovery preserves known availability; managed skill remains selectable and rediscovery re-enables. | | `UI-DETAIL-COOLDOWN-RETRY` | [web/tests/factory.spec.ts](../../web/tests/factory.spec.ts) — `cooldown without an error keeps a retry action and saved details unread until fresh` | Error-free cooldown renders saved details, deferred status, optional retry time and the existing Retry control. Retrying while deferred keeps unread and the message draft; retrying after fresh data is allowed marks the displayed revision once, clears deferred feedback and retains the draft without launching work. | +| `UI-GITHUB-DEFERRED` | [web/tests/factory.spec.ts](../../web/tests/factory.spec.ts) — `GitHub-deferred review shows its retry reason and deadline and can cancel before a thread starts` | Working list and focused conversation show Waiting for GitHub, retained reason and localized deadline with exact datetime; no alert or owner question appears. A threadless run cannot send messages; Cancel retry sends one interrupt_run and leaves it inactive without Start/message mutations. | +| `UI-FAILED-REVIEW` | [web/tests/factory.spec.ts](../../web/tests/factory.spec.ts) — `failed reviews keep failure status and alert instead of GitHub retry controls` | An ordinary failed review keeps Failed in list and detail, displays its original alert, exposes no retry control and permits messaging its retained session. | +| `RUN-LAUNCH-DEFERRED` | [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredReviewLaunchRespectsDeadlineAndRetriesExactlyOnce(` | Persist reason/deadline before workspace or agent startup; twelve concurrent due retries produce exactly one run, one automatic claim and one thread/turn start. | +| `RUN-LAUNCH-DEFERRED-CANCEL` | [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchInterruptAndCleanupCancelAttachedAndDetachedRuns(` | Interrupt and cleanup clear retry deadline/pending authority for attached and detached launches; restart and later ticks start no agent. | +| `RUN-LAUNCH-DEFERRED-PROJECTION` | [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchProjectionRetainsWorkingQueueAndRetryDeadline(` | Deferred work stays in Working with Waiting for GitHub; tracking retains launchRetryAt and cancellation clears the waiting projection. | ## Entity coverage diff --git a/docs/ui/widgets.md b/docs/ui/widgets.md index 22f0098..85e608d 100644 --- a/docs/ui/widgets.md +++ b/docs/ui/widgets.md @@ -45,14 +45,14 @@ Collections repeat by their item, run, request or configuration key. Settings | --- | --- | --- | --- | | `W-RUN-ROW` | Whole-row **select**, **profile** and creation time within the owning item; **draft-launches** disclosure for prepared rows. The selected or listed session lifecycle is displayed separately from aggregate item activity. | Retained conversations are scoped to the selected item and carry no activity badge. Prepared drafts appear newest first under Draft launches. | Selecting history changes the retained conversation within the item page. Selecting a draft reopens its existing immutable review. No selection starts work. | | `W-RUN-EMPTY` | **no-runs** empty-state message. | The selected item has no retained work history, or its conversation has no messages yet. | History stays attached to the selected work item. | -| `W-RUN-CONTROLS` | **task-title** and visible **run-error**; **details** holds **profile-name**, **interrupt**, technical activity, **workspace**, **cleanup-effect** and **cleanup**. Activity appears only in the work item status widget. The selected or listed session lifecycle is displayed separately from aggregate item activity. | No Resume control or standalone agent activity header. Interrupt is secondary while a turn is active; cleanup is secondary for idle/interrupted/failed sessions. Terminated source work retains its reason and clean-workspace cleanup; dirty refusal does not make that session resumable. | Explicit interrupt and cleanup refresh the snapshot on success or failure. Dirty cleanup preserves files and history; a later message restores the same session. Action errors and drafts remain visible. | +| `W-RUN-CONTROLS` | **task-title** and visible **run-error**; **details** holds **profile-name**, **interrupt**, technical activity, **workspace**, **cleanup-effect** and **cleanup**. Activity appears only in the work item status widget. The selected or listed session lifecycle is displayed separately from aggregate item activity. | No Resume control or standalone agent activity header. Interrupt is secondary while a turn is active; cleanup is secondary for idle/interrupted/failed sessions. Terminated source work retains its reason and clean-workspace cleanup; dirty refusal does not make that session resumable. Deferred pre-agent launches expose Cancel retry; interruption or cleanup cancels their retained retry even when no agent session is attached. | Explicit interrupt and cleanup refresh the snapshot on success or failure. Dirty cleanup preserves files and history; a later message restores the same session. Action errors and drafts remain visible. | | `W-ATTENTION-NOTICE` | Whole-notice **answer** button; **blocking-label** or **continuing-label**. | Repeated for pending requests belonging to the selected run. Explicitly nonblocking recovered text retains Question · agent continues; unconfirmed completion and unresolved clarification create no notice. | Opens `D-ATTENTION` for that request. Nonblocking requests do not imply a stopped agent. Retained message/text questions leave actionable attention individually after explicit same-run decision acknowledgement; partial and unverified questions remain available. Other-run publication does not retire them. | | `W-TRANSCRIPT` | Human-facing **live-region** Conversation with Agent/You messages and readable errors, repeated **block-label**/**block-text**, **waiting**, **loading-more**, **restart-history** and **read-error**. Commands, plans, diffs, internal summaries and raw output are **technical-activity** under Details; structured **error-details** are there too. | The normal conversation contains agent progress/final messages, user replies, saved-message delivery status and readable errors. Saved messages show queued, delivering, delivered or uncertain feedback; changes replace the same message status block. Delta updates merge by message ID, and completed text replaces partial text. A technical-only running stream shows Waiting for an agent update. Details is collapsed initially and stays closed through updates. Read failures retain output/drafts and clear local read feedback after recovery. Automatic repair/continuation inputs are labeled Question recovery and shown under Details or in the console; actual user answers retain their ordinary role. Completed owner and agent messages remain normal Conversation content across fetched journal pages; incomplete or malformed raw fragments remain technical activity under Details. | Details reveals retained technical activity without starting or changing agent work. Pending question/approval controls remain outside the collapsed data. History paging/reset and the 1,000-event retained limit remain unchanged. Session console reveals the full event stream using the same session and reply draft. | -| `W-MESSAGE` | **text** “Message agent” textarea; **send** button. | Send is disabled while submitting, for blank text, or in prepared/queued/starting/cleaned states. Idle/running/waiting/interrupted/failed sessions accept messages. Terminated sessions cannot accept messages or resume even if the source later reopens. Active question classification, clarification or form repair disables Send even with a filled draft. Unresolved classification permits explicit owner messaging. | Active messages require the displayed expected turn. A successful send records the message durably and clears only unchanged submitted text before agent delivery; failed acceptance preserves the draft. Background delivery restores the retained session using this submitted message as its first new turn and makes no GitHub preflight. The UI never calls Resume separately or automatically replays a mutation. Later delivery status remains visible in the conversation. | +| `W-MESSAGE` | **text** “Message agent” textarea; **send** button. | Send is disabled while submitting, for blank text, or in prepared/queued/starting/deferred/cleaned states. Idle/running/waiting/interrupted/failed sessions accept messages. Terminated sessions cannot accept messages or resume even if the source later reopens. Active question classification, clarification or form repair disables Send even with a filled draft. Unresolved classification permits explicit owner messaging. A cancelled pre-agent launch without a retained thread also keeps the composer disabled. | Active messages require the displayed expected turn. A successful send records the message durably and clears only unchanged submitted text before agent delivery; failed acceptance preserves the draft. Background delivery restores the retained session using this submitted message as its first new turn and makes no GitHub preflight. The UI never calls Resume separately or automatically replays a mutation. Later delivery status remains visible in the conversation. | | `W-WAITING-ROW` | Owning item **title**, identifier, request **question**, reply/approval **state** and **select**. | Blocking asks show Reply needed; nonblocking asks show that the agent continues. Stale, answered, cleaned and orphaned requests are excluded. | List unanswered native questions and approvals, structured message questions and textual fallback asks with their owning session. Live RPC requests require a matching connected session; async/text requests remain answerable through retained session recovery. Selection opens the exact answer dialog. | | `W-NEW-CHAT` | Prefilled editable **message**, profile/mode/permissions/publishing **authority**, explicit **start**, **check-status** and inline **error**. Selectable pipeline with displayed mode and authority. | Per-item draft, busy, prepared retry and uncertain states persist across navigation. Start is disabled while busy/unknown. Implementation is the default for issues/My PRs and PR review for Review PRs; unavailable defaults require selection. Changing a pipeline invalidates an old prepared proposal and retains an edited prompt. | Start prepares the visible message once and starts the retained proposal. Changed authority requires another explicit Start. Lost responses read that run before offering retry; late results do not navigate another item. Initial role choices use the repository override or system default. Preparation appends the configured repository review instructions for the selected review pipeline. | -| `W-WORK-STATUS` | Compact **value** in issue/PR metadata. | Server aggregate across started sessions: Not started, Starting, Active, Waiting for you, Inactive, Failed, Interrupted or Session closed. Blocking attention outranks newer idle work. GitHub completion and runtime activity are independent. After daemon startup, pending sessions continue from their retained progress automatically. Completed idle work stays idle; explicit interrupts and unanswered questions stay stopped. A closed issue or closed/merged PR terminates its retained sessions and displays Stopped; history and workspaces remain available. | Display only; no separate activity control or Resume action. | -| `W-SESSION-CONSOLE` | Paged messages, commands/output, plans, changes and errors from the selected retained session. | Selected instead of the concise conversation; preserves per-run reply draft and existing paging bounds. | Uses the same answer, message and interrupt operations; does not open another process or terminal transport. | +| `W-WORK-STATUS` | Compact **value** in issue/PR metadata. | Server aggregate across started sessions: Not started, Starting, Active, Waiting for GitHub, Waiting for you, Inactive, Failed, Interrupted or Session closed. Blocking attention outranks newer idle work. GitHub completion and runtime activity are independent. After daemon startup, pending sessions continue from their retained progress automatically. Completed idle work stays idle; explicit interrupts and unanswered questions stay stopped. A closed issue or closed/merged PR terminates its retained sessions and displays Stopped; history and workspaces remain available. Accepted launch preflight delayed by the request budget, quota reserve or rate limit displays Waiting for GitHub while its automatic retry remains pending. | Display only; no separate activity control or Resume action. | +| `W-SESSION-CONSOLE` | Paged messages, commands/output, plans, changes and errors from the selected retained session. | Selected instead of the concise conversation; preserves per-run reply draft and existing paging bounds. Deferred work shows Waiting for GitHub, the retained provider reason and next retry time in both conversation and console views. Cancel retry stops the scheduled launch without creating an input request. | Uses the same answer, message and interrupt operations; does not open another process or terminal transport. | | `W-QUESTION-RECOVERY` | Checking, clarification and native-form progress, shared attempt count and Take over session. | Classification failure asks the retained agent for question-only clarification. Clarification and native-form repair share at most two durably claimed attempts, including setup failure and restart. Only confirmed unanswered questions become text with original blocking status. Unresolved classification stops progress without adding attention. | Takeover interrupts/cancels further repair. Answers are claimed once and restore the original task mode. | diff --git a/docs/user-flows/catalog.json b/docs/user-flows/catalog.json index 4dde0e6..0cafab0 100644 --- a/docs/user-flows/catalog.json +++ b/docs/user-flows/catalog.json @@ -1298,6 +1298,11 @@ "path": "internal/providers/github_cache.go", "anchor": "type CachedGitHub struct {", "purpose": "Same-API persistent query cache with TTLs, scoped invalidation, bounded eviction, direct write-through and independent caller values." + }, + { + "path": "internal/app/launch_retry.go", + "anchor": "func (m *Manager) RetryDeferredLaunches(", + "purpose": "Persist deadline-based pre-agent launch deferral, conservative legacy PR-review recovery and automatic-policy revalidation without duplicate claims." } ], "tests": [ @@ -1791,6 +1796,59 @@ "S6" ], "purpose": "Separate database connections and transports cannot overlap managed HTTP; a cancelled waiting publication remains BeforeDispatch, exactly two attempts survive, and a later rate-limit deadline is preserved." + }, + { + "path": "web/tests/factory.spec.ts", + "anchor": "GitHub-deferred review shows its retry reason and deadline and can cancel before a thread starts", + "scenarios": [ + "S8" + ], + "purpose": "Working list and focused conversation show Waiting for GitHub, retained reason and localized deadline with exact datetime; no alert or owner question appears. A threadless run cannot send messages; Cancel retry sends one interrupt_run and leaves it inactive without Start/message mutations." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredReviewLaunchRespectsDeadlineAndRetriesExactlyOnce(", + "scenarios": [ + "S5", + "S8" + ], + "purpose": "Persist reason/deadline before workspace or agent startup; twelve concurrent due retries produce exactly one run, one automatic claim and one thread/turn start." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredReviewLaunchRetriesWhenRepositorySyncFails(", + "scenarios": [ + "S5", + "S8" + ], + "purpose": "Scheduler retries due automatic work despite a failed repository poll while retaining the repository failure." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredReviewLaunchRevalidatesLiveTargetAndPolicy(", + "scenarios": [ + "S5", + "S8", + "S10" + ], + "purpose": "Changed head, closure, draft, withdrawn request, author, profile and permanent source errors start no agent; disabled or paused policy holds without HTTP; claim count stays one." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchProjectionRetainsWorkingQueueAndRetryDeadline(", + "scenarios": [ + "S8" + ], + "purpose": "Deferred work stays in Working with Waiting for GitHub; tracking retains launchRetryAt and cancellation clears the waiting projection." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchRepeatedBudgetDenialKeepsAcceptedRun(", + "scenarios": [ + "S5", + "S8" + ], + "purpose": "Repeated due budget denial retains the same accepted run/claim and renewed deadline without HTTP or agent invocation." } ], "suites": [ @@ -1835,7 +1893,9 @@ "C-REVIEW-POLICY", "C-REPOSITORY-REVIEW-PROMPT", "W-GITHUB-USAGE", - "C-GITHUB-USAGE" + "C-GITHUB-USAGE", + "W-WORK-STATUS", + "W-SESSION-CONSOLE" ], "terms": [ "repository", @@ -1854,7 +1914,8 @@ "github-request-reserve", "github-review-notification", "published-review", - "state-terminated" + "state-terminated", + "state-deferred" ], "graph": { "start": "n0", @@ -1941,6 +2002,11 @@ "id": "live_query", "label": "Validate live data within the shared budget and update saved query state", "scenario": "S8" + }, + { + "id": "deferred_review_launch", + "label": "Retain accepted launch, reason and retry deadline; show Waiting for GitHub", + "scenario": "S8" } ], "edges": [ @@ -2088,6 +2154,21 @@ "from": "live_query", "to": "n3", "label": "failed validation; do not claim old success as current" + }, + { + "from": "source_lifecycle_check", + "to": "deferred_review_launch", + "label": "pre-agent budget, quota reserve or rate limit" + }, + { + "from": "deferred_review_launch", + "to": "source_lifecycle_check", + "label": "deadline reached after tick or restart; recheck current source and automatic policy" + }, + { + "from": "deferred_review_launch", + "to": "queue_review", + "label": "explicit cancellation; no agent launch" } ] } @@ -3451,6 +3532,11 @@ "path": "internal/app/review_eligibility.go", "anchor": "func validatePRReviewAuthor(", "purpose": "Reject own-author PR reviews and unknown author/identity before preparation, atomic Start and fresh source execution; retain review intent across profile-default changes." + }, + { + "path": "internal/app/launch_retry.go", + "anchor": "func (m *Manager) RetryDeferredLaunches(", + "purpose": "Persist deadline-based pre-agent launch deferral, conservative legacy PR-review recovery and automatic-policy revalidation without duplicate claims." } ], "tests": [ @@ -4043,6 +4129,75 @@ "S5" ], "purpose": "Repeated automatic eligibility reads only owning-run metadata: all four persisted busy states prevent claims and pipeline validation despite unrelated/private frozen payloads; idle ownership reaches normal pipeline validation without provider work." + }, + { + "path": "web/tests/factory.spec.ts", + "anchor": "GitHub-deferred review shows its retry reason and deadline and can cancel before a thread starts", + "scenarios": [ + "S8" + ], + "purpose": "Working list and focused conversation show Waiting for GitHub, retained reason and localized deadline with exact datetime; no alert or owner question appears. A threadless run cannot send messages; Cancel retry sends one interrupt_run and leaves it inactive without Start/message mutations." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredReviewLaunchRespectsDeadlineAndRetriesExactlyOnce(", + "scenarios": [ + "S5", + "S8" + ], + "purpose": "Persist reason/deadline before workspace or agent startup; twelve concurrent due retries produce exactly one run, one automatic claim and one thread/turn start." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredReviewLaunchRecoversBeforeAndAfterDeadline(", + "scenarios": [ + "S8" + ], + "purpose": "Restart before deadline makes no source request or agent call; due manual and automatic reviews start once and clear retry metadata." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredReviewLaunchRevalidatesLiveTargetAndPolicy(", + "scenarios": [ + "S5", + "S8" + ], + "purpose": "Changed head, closure, draft, withdrawn request, author, profile and permanent source errors start no agent; disabled or paused policy holds without HTTP; claim count stays one." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchRepeatedBudgetDenialKeepsAcceptedRun(", + "scenarios": [ + "S5", + "S8" + ], + "purpose": "Repeated due budget denial retains the same accepted run/claim and renewed deadline without HTTP or agent invocation." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestManualDeferredReviewRevalidatesPinnedHeadAndClosedSource(", + "scenarios": [ + "S6", + "S8" + ], + "purpose": "Due manual PR review rejects a changed pinned head or closed source before agent invocation and clears retry authority." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchCancelAndCloseDuringSourceVerification(", + "scenarios": [ + "S5", + "S8" + ], + "purpose": "Interrupt, manager close, policy disable and repository pause during blocked live verification prevent provider start; interrupt/close create no workspace." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchDoesNotRetryProviderExecutionFailures(", + "scenarios": [ + "S8" + ], + "purpose": "A typed rate-limit error after provider startup remains failed with no retry metadata and exactly the original two provider requests." } ], "suites": [ @@ -4112,7 +4267,9 @@ "W-REPOSITORY-SETTINGS", "W-SYSTEM-DEFAULTS", "C-REVIEW-POLICY", - "C-REPOSITORY-REVIEW-PROMPT" + "C-REPOSITORY-REVIEW-PROMPT", + "W-WORK-STATUS", + "W-SESSION-CONSOLE" ], "terms": [ "prepared-launch", @@ -4128,7 +4285,8 @@ "review-policy", "review-request-claim", "repository-review-prompt", - "state-terminated" + "state-terminated", + "state-deferred" ], "graph": { "start": "draft", @@ -4215,6 +4373,11 @@ "id": "source_lifecycle_terminal", "label": "Closed or merged source; terminate session and retain history/workspace", "terminal": true + }, + { + "id": "deferred_review_launch", + "label": "Retain accepted launch, reason and retry deadline; show Waiting for GitHub", + "scenario": "S8" } ], "edges": [ @@ -4370,6 +4533,21 @@ "from": "source_lifecycle_check", "to": "manual_error", "label": "fresh review author matches the signed-in account or cannot be verified" + }, + { + "from": "source_lifecycle_check", + "to": "deferred_review_launch", + "label": "pre-agent budget, quota reserve or rate limit" + }, + { + "from": "deferred_review_launch", + "to": "source_lifecycle_check", + "label": "deadline reached after tick or restart; recheck current source and automatic policy" + }, + { + "from": "deferred_review_launch", + "to": "retained", + "label": "explicit cancellation; no agent launch" } ] } @@ -4541,6 +4719,11 @@ "path": "internal/store/conversation.go", "anchor": "func (t *Tx) ConversationVersion(", "purpose": "Transaction guard counts ordinary owner/agent completions and opaque same-run completion chunks as possible conversation changes without importing omitted content as evidence." + }, + { + "path": "internal/app/launch_retry.go", + "anchor": "func (m *Manager) RetryDeferredLaunches(", + "purpose": "Persist deadline-based pre-agent launch deferral, conservative legacy PR-review recovery and automatic-policy revalidation without duplicate claims." } ], "tests": [ @@ -4963,6 +5146,38 @@ "S3" ], "purpose": "Takeover during provider-gated historical reconciliation invalidates the entire queued completion callback, preventing the subsequent stopped-turn classifier from creating fresh recovery or attention." + }, + { + "path": "web/tests/factory.spec.ts", + "anchor": "GitHub-deferred review shows its retry reason and deadline and can cancel before a thread starts", + "scenarios": [ + "S3" + ], + "purpose": "Working list and focused conversation show Waiting for GitHub, retained reason and localized deadline with exact datetime; no alert or owner question appears. A threadless run cannot send messages; Cancel retry sends one interrupt_run and leaves it inactive without Start/message mutations." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchInterruptAndCleanupCancelAttachedAndDetachedRuns(", + "scenarios": [ + "S3" + ], + "purpose": "Interrupt and cleanup clear retry deadline/pending authority for attached and detached launches; restart and later ticks start no agent." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchProjectionRetainsWorkingQueueAndRetryDeadline(", + "scenarios": [ + "S3" + ], + "purpose": "Deferred work stays in Working with Waiting for GitHub; tracking retains launchRetryAt and cancellation clears the waiting projection." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchCancelAndCloseDuringSourceVerification(", + "scenarios": [ + "S3" + ], + "purpose": "Interrupt, manager close, policy disable and repository pause during blocked live verification prevent provider start; interrupt/close create no workspace." } ], "suites": [ @@ -5009,7 +5224,8 @@ "unverified-behavior", "question-recovery", "session-console", - "state-terminated" + "state-terminated", + "state-deferred" ], "graph": { "start": "n0", @@ -5030,7 +5246,7 @@ }, { "id": "n3", - "label": "Interrupt active work and stale requests", + "label": "Interrupt active work or cancel deferred launch and stale requests", "scenario": "S3" }, { @@ -6545,6 +6761,11 @@ "path": "internal/app/questions.go", "anchor": "func (m *Manager) restoreStoppedTurn(", "purpose": "Restore successful provider-completed turn source outside lifecycle locks, revalidate current owner revision and run the existing guarded stopped-question policy without ordinary task replay." + }, + { + "path": "internal/app/launch_retry.go", + "anchor": "func (m *Manager) RetryDeferredLaunches(", + "purpose": "Persist deadline-based pre-agent launch deferral, conservative legacy PR-review recovery and automatic-policy revalidation without duplicate claims." } ], "tests": [ @@ -7145,6 +7366,74 @@ "S1" ], "purpose": "Incomplete or malformed completion chunks and complete user/tool payloads supply no stopped-question source; partial text cannot establish a question." + }, + { + "path": "web/tests/factory.spec.ts", + "anchor": "GitHub-deferred review shows its retry reason and deadline and can cancel before a thread starts", + "scenarios": [ + "S1", + "S3" + ], + "purpose": "Working list and focused conversation show Waiting for GitHub, retained reason and localized deadline with exact datetime; no alert or owner question appears. A threadless run cannot send messages; Cancel retry sends one interrupt_run and leaves it inactive without Start/message mutations." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredReviewLaunchRecoversBeforeAndAfterDeadline(", + "scenarios": [ + "S1" + ], + "purpose": "Restart before deadline makes no source request or agent call; due manual and automatic reviews start once and clear retry metadata." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredReviewLaunchRevalidatesLiveTargetAndPolicy(", + "scenarios": [ + "S1", + "S6" + ], + "purpose": "Changed head, closure, draft, withdrawn request, author, profile and permanent source errors start no agent; disabled or paused policy holds without HTTP; claim count stays one." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchInterruptAndCleanupCancelAttachedAndDetachedRuns(", + "scenarios": [ + "S1", + "S3" + ], + "purpose": "Interrupt and cleanup clear retry deadline/pending authority for attached and detached launches; restart and later ticks start no agent." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestLegacyBudgetReviewFailureRecoveryIsExactAndPreAgentOnly(", + "scenarios": [ + "S1" + ], + "purpose": "Only the three exact legacy PR-review preflight budget/reserve/rate messages recover and launch once; thread/turn, interrupted, non-review, permanent and lookalike agent errors remain failed." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestManualDeferredReviewRevalidatesPinnedHeadAndClosedSource(", + "scenarios": [ + "S1", + "S6" + ], + "purpose": "Due manual PR review rejects a changed pinned head or closed source before agent invocation and clears retry authority." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchCancelAndCloseDuringSourceVerification(", + "scenarios": [ + "S1" + ], + "purpose": "Interrupt, manager close, policy disable and repository pause during blocked live verification prevent provider start; interrupt/close create no workspace." + }, + { + "path": "internal/app/launch_retry_test.go", + "anchor": "func TestDeferredLaunchDoesNotRetryProviderExecutionFailures(", + "scenarios": [ + "S1" + ], + "purpose": "A typed rate-limit error after provider startup remains failed with no retry metadata and exactly the original two provider requests." } ], "suites": [ @@ -7205,7 +7494,8 @@ "unverified-behavior", "question-recovery", "session-console", - "state-terminated" + "state-terminated", + "state-deferred" ], "graph": { "start": "n0", @@ -7296,6 +7586,11 @@ "id": "source_lifecycle_terminal", "label": "Closed or merged source; terminate session and retain history/workspace", "terminal": true + }, + { + "id": "deferred_review_launch", + "label": "Restore deferred pre-agent launch and honor its retained retry deadline", + "scenario": "S1" } ], "edges": [ @@ -7413,6 +7708,26 @@ "from": "source_lifecycle_check", "to": "n0", "label": "open source; agent assesses fresh changes" + }, + { + "from": "n0", + "to": "deferred_review_launch", + "label": "deferred or exact recognized legacy preflight failure" + }, + { + "from": "deferred_review_launch", + "to": "source_lifecycle_check", + "label": "deadline reached; revalidate source and automatic policy" + }, + { + "from": "deferred_review_launch", + "to": "n3", + "label": "cleanup cancels retry even without attached session" + }, + { + "from": "deferred_review_launch", + "to": "startup_unchanged", + "label": "interrupt cancels retry even without attached session" } ] } diff --git a/docs/user-flows/interact-agent.md b/docs/user-flows/interact-agent.md index 20f3b67..4e9988d 100644 --- a/docs/user-flows/interact-agent.md +++ b/docs/user-flows/interact-agent.md @@ -29,7 +29,7 @@ Delivered messages and completed turns reconcile retained questions against expl - **S1** Follow-up after idle completes through the same run. - **S2** Active steering requires the current turn identity and rejects stale steering. -- **S3** Interrupt preserves the run and invalidates outstanding requests. +- **S3** Interrupt preserves the run and invalidates outstanding requests. Cancel retry also interrupts a deferred pre-agent PR review and clears its pending deadline even when no agent session is attached; scheduler ticks and restart leave it stopped. Conversation rendering keeps human-facing agent messages and your replies visible, including long messages retained across multiple transcript pages. Technical activity remains under Details, with structured error data there and the readable error message visible. Streaming updates preserve that distinction; a technical-only active stream shows a concise waiting status. Pending questions and approvals remain actionable outside Details. @@ -59,7 +59,7 @@ Take over also cancels recovery before its first question-only turn. Late questi ## Terminology and UI references -Glossary: [agent-run](../glossary/README.md#agent-run), [transcript](../glossary/README.md#transcript), [state-idle](../glossary/README.md#state-idle), [state-running](../glossary/README.md#state-running), [coverage-gap](../glossary/README.md#coverage-gap), [unverified-behavior](../glossary/README.md#unverified-behavior), [question-recovery](../glossary/README.md#question-recovery), [session-console](../glossary/README.md#session-console), [state-terminated](../glossary/README.md#state-terminated). +Glossary: [agent-run](../glossary/README.md#agent-run), [transcript](../glossary/README.md#transcript), [state-idle](../glossary/README.md#state-idle), [state-running](../glossary/README.md#state-running), [coverage-gap](../glossary/README.md#coverage-gap), [unverified-behavior](../glossary/README.md#unverified-behavior), [question-recovery](../glossary/README.md#question-recovery), [session-console](../glossary/README.md#session-console), [state-terminated](../glossary/README.md#state-terminated), [state-deferred](../glossary/README.md#state-deferred). UI entities: [PAGE-AGENTS](../ui/README.md#PAGE-AGENTS), [P-RUN-LIST](../ui/README.md#P-RUN-LIST), [P-RUN-DETAIL](../ui/README.md#P-RUN-DETAIL), [W-RUN-ROW](../ui/README.md#W-RUN-ROW), [W-RUN-EMPTY](../ui/README.md#W-RUN-EMPTY), [W-RUN-CONTROLS](../ui/README.md#W-RUN-CONTROLS), [C-SELECT-RUN](../ui/README.md#C-SELECT-RUN), [C-RUN-ACTION](../ui/README.md#C-RUN-ACTION), [W-TRANSCRIPT](../ui/README.md#W-TRANSCRIPT), [W-MESSAGE](../ui/README.md#W-MESSAGE), [C-TRANSCRIPT](../ui/README.md#C-TRANSCRIPT), [C-FOLLOW-UP](../ui/README.md#C-FOLLOW-UP), [PAGE-AGENT](../ui/README.md#PAGE-AGENT), [C-BACK](../ui/README.md#C-BACK), [W-WORK-STATUS](../ui/README.md#W-WORK-STATUS), [W-QUESTION-RECOVERY](../ui/README.md#W-QUESTION-RECOVERY), [W-SESSION-CONSOLE](../ui/README.md#W-SESSION-CONSOLE). ## Flow diagram @@ -69,7 +69,7 @@ flowchart TD N0["Read current status and transcript"] N1["Send follow-up when idle #40;S1#41;"] N2["Steer current active turn #40;S2#41;"] - N3["Interrupt active work and stale requests #40;S3#41;"] + N3["Interrupt active work or cancel deferred launch and stale requests #40;S3#41;"] N4["Reject outdated turn message"] N5["Send a message to restore inactive retained work"] N6(["Retained same-run conversation"]) @@ -144,6 +144,7 @@ flowchart TD - [internal/app/attention_reconciliation.go](../../internal/app/attention_reconciliation.go) — Background same-run per-question reconciliation after delivery, completion and startup. Requires exact owner decision and subsequent agent acknowledgement; uncertainty preserves attention, native approvals are excluded. (`func (m *Manager) reconcileAttention(`) - [internal/store/conversation.go](../../internal/store/conversation.go) — Bounded same-run redacted conversation evidence excludes tools, internal recovery input, old source messages and other runs. (`func (s *Store) ConversationEvidence(`) - [internal/store/conversation.go](../../internal/store/conversation.go) — Transaction guard counts ordinary owner/agent completions and opaque same-run completion chunks as possible conversation changes without importing omitted content as evidence. (`func (t *Tx) ConversationVersion(`) +- [internal/app/launch_retry.go](../../internal/app/launch_retry.go) — Persist deadline-based pre-agent launch deferral, conservative legacy PR-review recovery and automatic-policy revalidation without duplicate claims. (`func (m *Manager) RetryDeferredLaunches(`) ## Test references @@ -199,6 +200,10 @@ flowchart TD - [internal/app/question_classification_test.go](../../internal/app/question_classification_test.go) — `func TestTakeoverAfterClaimPreventsQueuedRepairContinuation(`; S3. Before and after acknowledgement, takeover finalizes the claimed async answer and prevents a causally released queued continuation before provider shutdown. Private missing-cancellation mutation fails claim and actual continuation assertions. - [internal/app/question_classification_test.go](../../internal/app/question_classification_test.go) — `func TestRetiredResumeCannotOverwriteOwnerReplacement(`; S3. An actual successful retained thread/resume response is held at its session lock; a staged lock-owned retirement/replacement transition proves the retired launch cannot overwrite the replacement's running state, turn or identity. - [internal/app/question_classification_test.go](../../internal/app/question_classification_test.go) — `func TestTakeoverInvalidatesEntireQueuedCompletion(`; S3. Takeover during provider-gated historical reconciliation invalidates the entire queued completion callback, preventing the subsequent stopped-turn classifier from creating fresh recovery or attention. +- [web/tests/factory.spec.ts](../../web/tests/factory.spec.ts) — `GitHub-deferred review shows its retry reason and deadline and can cancel before a thread starts`; S3. Working list and focused conversation show Waiting for GitHub, retained reason and localized deadline with exact datetime; no alert or owner question appears. A threadless run cannot send messages; Cancel retry sends one interrupt_run and leaves it inactive without Start/message mutations. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchInterruptAndCleanupCancelAttachedAndDetachedRuns(`; S3. Interrupt and cleanup clear retry deadline/pending authority for attached and detached launches; restart and later ticks start no agent. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchProjectionRetainsWorkingQueueAndRetryDeadline(`; S3. Deferred work stays in Working with Waiting for GitHub; tracking retains launchRetryAt and cancellation clears the waiting projection. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchCancelAndCloseDuringSourceVerification(`; S3. Interrupt, manager close, policy disable and repository pause during blocked live verification prevent provider start; interrupt/close create no workspace. ## Verification and gaps diff --git a/docs/user-flows/launch-agent.md b/docs/user-flows/launch-agent.md index 637bb83..092e247 100644 --- a/docs/user-flows/launch-agent.md +++ b/docs/user-flows/launch-agent.md @@ -21,7 +21,7 @@ Leaving the initial conversation retains its message and busy/uncertain state pe ## Scenarios -- **S8** Revalidate the current source before execution. Changed scope/head is supplied as untrusted context for the agent to assess against saved checkpoints. Unavailable source data prevents execution. A closed issue or closed/merged PR terminates the session without starting provider work. +- **S8** Revalidate the current source before execution. Changed scope/head is supplied as untrusted context for the agent to assess against saved checkpoints. Unavailable source data prevents execution. For accepted work, temporary request-budget, quota-reserve or rate-limit unavailability before agent startup retains a deferred launch with its reason and retry deadline. The interface shows Waiting for GitHub. Scheduler ticks and daemon restart honor that deadline, then verify live source state, pinned head and automatic policy before starting the same accepted run once. Other source or agent failures retain their ordinary explicit recovery behavior. A closed issue or closed/merged PR terminates the session without starting provider work. - **S1** Item selection opens a prefilled conversation without preparing or starting. Optional New run opens a prepared review; customization and reopening retained drafts never start an agent or create a workspace. - **S2** Explicit Start queues prepared work once and returns before completion; subsequent Start requests for that run are rejected. An uncertain response locates the accepted run before another action is offered. @@ -29,7 +29,7 @@ Leaving the initial conversation retains its message and busy/uncertain state pe - **S4** Select a pipeline before explicit Start, retaining an edited first message and reviewing the selected authority. -- **S5** A fresh requested PR can launch automatically only under an owner-enabled inherited/repository policy. It uses the configured review pipeline, honors its explicit execution and publishing authority, pins the current head, appends owner repository instructions and claims the request/head once. Queue-only, paused/stale/failed polls, drafts, other active agent work, unsafe profiles and changed policy do not launch. Restart never replays a claimed request. A newly observed request episode or new head can receive a new review. Manual launches keep their explicit Start behavior. +- **S5** A fresh requested PR can launch automatically only under an owner-enabled inherited/repository policy. It uses the configured review pipeline, honors its explicit execution and publishing authority, pins the current head, appends owner repository instructions and claims the request/head once. Queue-only, paused/stale/failed polls, drafts, other active agent work, unsafe profiles and changed policy do not launch. Restart never creates a second claim or agent for a claimed request; a deferred pre-agent launch can retry that same accepted claim after its deadline. A newly observed request episode or new head can receive a new review. Manual launches keep their explicit Start behavior. - **S6** The owner can request a manual PR review through the assistant using a synchronized run/item ID, GitHub URL or repository shorthand directly, without a preliminary discovery call. Ambiguous identifiers return candidates and prepare nothing. Preparation resolves repository, system and built-in review defaults in that order and retains the selected model, skills, pinned head and repository instructions. The assistant inspects the configured authority and starts the matching proposal under the owner's review request. Both read-only/ask and an explicitly selected full-access/never/authorized profile are supported. Proposal-only requests retain unstarted work. Reviews of the signed-in GitHub account’s own PR are rejected, comparing account names without case sensitivity. Missing author or identity data must be refreshed before review. These checks apply to dedicated and generic review launches. Preparation and atomic Start check saved identity and retained intent. Asynchronous execution checks the current authenticated account against the fresh source under the same credentials before creating a workspace or provider. Missing, unavailable or changed identity evidence stops that execution; Start still returns queued without waiting for network reads. Prepared review intent survives later default-profile changes; older standard review proposals also receive the check. Investigation and implementation of the owner’s PR remain available. Invalid targets, missing heads, unavailable profiles/skills or unsafe review authority return errors without preparing work. Preparation uses synchronized state without provider requests or workspace creation. An uncertain Start is checked against the same retained run before an explicit retry; manual review does not require enabling automatic monitoring. @@ -40,8 +40,8 @@ Review/implementation defaults resolve repository overrides before system defaul ## Terminology and UI references -Glossary: [prepared-launch](../glossary/README.md#prepared-launch), [profile](../glossary/README.md#profile), [skill](../glossary/README.md#skill), [workspace](../glossary/README.md#workspace), [agent-run](../glossary/README.md#agent-run), [coverage-gap](../glossary/README.md#coverage-gap), [unverified-behavior](../glossary/README.md#unverified-behavior), [keyboard-focus](../glossary/README.md#keyboard-focus), [modal-dialog](../glossary/README.md#modal-dialog), [agent-pipeline](../glossary/README.md#agent-pipeline), [review-policy](../glossary/README.md#review-policy), [review-request-claim](../glossary/README.md#review-request-claim), [repository-review-prompt](../glossary/README.md#repository-review-prompt), [state-terminated](../glossary/README.md#state-terminated). -UI entities: [D-LAUNCH](../ui/README.md#D-LAUNCH), [P-LAUNCH-FRAME](../ui/README.md#P-LAUNCH-FRAME), [P-LAUNCH-EDITOR](../ui/README.md#P-LAUNCH-EDITOR), [P-LAUNCH-REVIEW](../ui/README.md#P-LAUNCH-REVIEW), [W-DIALOG-FRAME](../ui/README.md#W-DIALOG-FRAME), [W-LAUNCH-EDITOR](../ui/README.md#W-LAUNCH-EDITOR), [W-LAUNCH-REVIEW](../ui/README.md#W-LAUNCH-REVIEW), [C-LAUNCH-OPEN](../ui/README.md#C-LAUNCH-OPEN), [C-LAUNCH-PROFILE](../ui/README.md#C-LAUNCH-PROFILE), [C-PREPARE](../ui/README.md#C-PREPARE), [C-LAUNCH-EDIT](../ui/README.md#C-LAUNCH-EDIT), [C-START](../ui/README.md#C-START), [C-DISMISS](../ui/README.md#C-DISMISS), [W-NEW-CHAT](../ui/README.md#W-NEW-CHAT), [P-NEW-CHAT](../ui/README.md#P-NEW-CHAT), [C-START-CHAT](../ui/README.md#C-START-CHAT), [C-PIPELINE-SELECT](../ui/README.md#C-PIPELINE-SELECT), [W-REPOSITORY-SETTINGS](../ui/README.md#W-REPOSITORY-SETTINGS), [W-SYSTEM-DEFAULTS](../ui/README.md#W-SYSTEM-DEFAULTS), [C-REVIEW-POLICY](../ui/README.md#C-REVIEW-POLICY), [C-REPOSITORY-REVIEW-PROMPT](../ui/README.md#C-REPOSITORY-REVIEW-PROMPT). +Glossary: [prepared-launch](../glossary/README.md#prepared-launch), [profile](../glossary/README.md#profile), [skill](../glossary/README.md#skill), [workspace](../glossary/README.md#workspace), [agent-run](../glossary/README.md#agent-run), [coverage-gap](../glossary/README.md#coverage-gap), [unverified-behavior](../glossary/README.md#unverified-behavior), [keyboard-focus](../glossary/README.md#keyboard-focus), [modal-dialog](../glossary/README.md#modal-dialog), [agent-pipeline](../glossary/README.md#agent-pipeline), [review-policy](../glossary/README.md#review-policy), [review-request-claim](../glossary/README.md#review-request-claim), [repository-review-prompt](../glossary/README.md#repository-review-prompt), [state-terminated](../glossary/README.md#state-terminated), [state-deferred](../glossary/README.md#state-deferred). +UI entities: [D-LAUNCH](../ui/README.md#D-LAUNCH), [P-LAUNCH-FRAME](../ui/README.md#P-LAUNCH-FRAME), [P-LAUNCH-EDITOR](../ui/README.md#P-LAUNCH-EDITOR), [P-LAUNCH-REVIEW](../ui/README.md#P-LAUNCH-REVIEW), [W-DIALOG-FRAME](../ui/README.md#W-DIALOG-FRAME), [W-LAUNCH-EDITOR](../ui/README.md#W-LAUNCH-EDITOR), [W-LAUNCH-REVIEW](../ui/README.md#W-LAUNCH-REVIEW), [C-LAUNCH-OPEN](../ui/README.md#C-LAUNCH-OPEN), [C-LAUNCH-PROFILE](../ui/README.md#C-LAUNCH-PROFILE), [C-PREPARE](../ui/README.md#C-PREPARE), [C-LAUNCH-EDIT](../ui/README.md#C-LAUNCH-EDIT), [C-START](../ui/README.md#C-START), [C-DISMISS](../ui/README.md#C-DISMISS), [W-NEW-CHAT](../ui/README.md#W-NEW-CHAT), [P-NEW-CHAT](../ui/README.md#P-NEW-CHAT), [C-START-CHAT](../ui/README.md#C-START-CHAT), [C-PIPELINE-SELECT](../ui/README.md#C-PIPELINE-SELECT), [W-REPOSITORY-SETTINGS](../ui/README.md#W-REPOSITORY-SETTINGS), [W-SYSTEM-DEFAULTS](../ui/README.md#W-SYSTEM-DEFAULTS), [C-REVIEW-POLICY](../ui/README.md#C-REVIEW-POLICY), [C-REPOSITORY-REVIEW-PROMPT](../ui/README.md#C-REPOSITORY-REVIEW-PROMPT), [W-WORK-STATUS](../ui/README.md#W-WORK-STATUS), [W-SESSION-CONSOLE](../ui/README.md#W-SESSION-CONSOLE). ## Flow diagram @@ -64,6 +64,7 @@ flowchart TD N14["Honor explicit full access, never command approvals and authorized task publishing #40;S7#41;"] N15["Verify current source and report changed scope or head before initial or automatic continuation #40;S8#41;"] N16(["Closed or merged source#59; terminate session and retain history/workspace"]) + N17["Retain accepted launch, reason and retry deadline#59; show Waiting for GitHub #40;S8#41;"] N0 --> N1 N0 -->|"customize a separate proposal"| N5 N5 -->|"reviewed Start"| N1 @@ -95,6 +96,9 @@ flowchart TD N15 -->|"open source#59; agent assesses fresh changes"| N0 N1 -->|"review author now matches the signed-in account"| N11 N15 -->|"fresh review author matches the signed-in account or cannot be verified"| N11 + N15 -->|"pre-agent budget, quota reserve or rate limit"| N17 + N17 -->|"deadline reached after tick or restart#59; recheck current source and automatic policy"| N15 + N17 -->|"explicit cancellation#59; no agent launch"| N6 ``` ## Implementation references @@ -142,6 +146,7 @@ flowchart TD - [internal/providers/continuation.go](../../internal/providers/continuation.go) — Targeted conditional GitHub source read bypasses local TTL while retaining shared budget and cooldown enforcement. (`func (g *GitHub) CurrentWorkItem(`) - [internal/app/tracking.go](../../internal/app/tracking.go) — Targeted saved-state reads, compact tracking, durable progress or asynchronous observation without delaying status. (`func (a *App) TrackWork(`) - [internal/app/review_eligibility.go](../../internal/app/review_eligibility.go) — Reject own-author PR reviews and unknown author/identity before preparation, atomic Start and fresh source execution; retain review intent across profile-default changes. (`func validatePRReviewAuthor(`) +- [internal/app/launch_retry.go](../../internal/app/launch_retry.go) — Persist deadline-based pre-agent launch deferral, conservative legacy PR-review recovery and automatic-policy revalidation without duplicate claims. (`func (m *Manager) RetryDeferredLaunches(`) ## Test references @@ -217,6 +222,14 @@ flowchart TD - [internal/app/plugins_test.go](../../internal/app/plugins_test.go) — `func TestRemovedPluginSkillStaysUnavailableWhileCachedFilesRemain(`; S3. Successful removal/discovery omission disables cached-file skill for new Prepare while frozen Run is unchanged; failed discovery preserves known skill, managed content remains and rediscovery re-enables. - [web/tests/factory.spec.ts](../../web/tests/factory.spec.ts) — `a late first-message Start cannot replace another item's draft`; S1, S2. With a delayed immutable accepted Start response and lagging saved reads, the Messages badge proves React processed the response before asserting another item and exact draft remain selected; private guard removal fails after this barrier. - [internal/app/review_requests_test.go](../../internal/app/review_requests_test.go) — `func TestAutomaticReviewBusyGuardReadsOnlyOwningRunMetadata(`; S5. Repeated automatic eligibility reads only owning-run metadata: all four persisted busy states prevent claims and pipeline validation despite unrelated/private frozen payloads; idle ownership reaches normal pipeline validation without provider work. +- [web/tests/factory.spec.ts](../../web/tests/factory.spec.ts) — `GitHub-deferred review shows its retry reason and deadline and can cancel before a thread starts`; S8. Working list and focused conversation show Waiting for GitHub, retained reason and localized deadline with exact datetime; no alert or owner question appears. A threadless run cannot send messages; Cancel retry sends one interrupt_run and leaves it inactive without Start/message mutations. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredReviewLaunchRespectsDeadlineAndRetriesExactlyOnce(`; S5, S8. Persist reason/deadline before workspace or agent startup; twelve concurrent due retries produce exactly one run, one automatic claim and one thread/turn start. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredReviewLaunchRecoversBeforeAndAfterDeadline(`; S8. Restart before deadline makes no source request or agent call; due manual and automatic reviews start once and clear retry metadata. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredReviewLaunchRevalidatesLiveTargetAndPolicy(`; S5, S8. Changed head, closure, draft, withdrawn request, author, profile and permanent source errors start no agent; disabled or paused policy holds without HTTP; claim count stays one. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchRepeatedBudgetDenialKeepsAcceptedRun(`; S5, S8. Repeated due budget denial retains the same accepted run/claim and renewed deadline without HTTP or agent invocation. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestManualDeferredReviewRevalidatesPinnedHeadAndClosedSource(`; S6, S8. Due manual PR review rejects a changed pinned head or closed source before agent invocation and clears retry authority. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchCancelAndCloseDuringSourceVerification(`; S5, S8. Interrupt, manager close, policy disable and repository pause during blocked live verification prevent provider start; interrupt/close create no workspace. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchDoesNotRetryProviderExecutionFailures(`; S8. A typed rate-limit error after provider startup remains failed with no retry metadata and exactly the original two provider requests. ## Verification and gaps diff --git a/docs/user-flows/recover-cleanup.md b/docs/user-flows/recover-cleanup.md index 1d9984a..24f4e54 100644 --- a/docs/user-flows/recover-cleanup.md +++ b/docs/user-flows/recover-cleanup.md @@ -23,9 +23,9 @@ Closing the view or transport does not stop work. Startup leaves completed idle - **S6** Before an initial start or automatic unfinished-work restoration, verify the current source item and report changed title, description, head, state and assignment context to the agent for assessment. Explicit message-driven restoration instead uses saved source context without a GitHub preflight; its agent performs task-required GitHub reads. Failed preflight reads keep automatic work stopped. A closed issue or closed/merged PR permanently terminates its sessions, retires questions and preserves history and workspaces; reopening the source does not revive them. Dirty cleanup cannot make terminal work resumable. -- **S1** Startup continues pending work from retained progress, preserves prepared and completed work, honors explicit interrupts and unanswered questions, stales expired native requests and isolates unavailable sessions. An interrupted question clarification resumes only through its bounded recovery path; an accepted recovery answer does not trigger automatic task continuation. Older unresolved diagnostics do not prevent newer owner-directed work from continuing. +- **S1** Startup continues pending work from retained progress, preserves prepared and completed work, honors explicit interrupts and unanswered questions, stales expired native requests and isolates unavailable sessions. An interrupted question clarification resumes only through its bounded recovery path; an accepted recovery answer does not trigger automatic task continuation. Older unresolved diagnostics do not prevent newer owner-directed work from continuing. Deferred launches retain their reason and retry deadline across restart and wait until due before a live source/head and automatic-policy check. Recovery also recognizes only the exact known legacy pre-agent GitHub budget/reserve/rate-limit failures; unrelated failed runs and failures after agent startup are not retried automatically. - **S2** Sending a message restores retained work and starts exactly that message instead of replaying its original task. -- **S3** Dirty cleanup retains the workspace after interrupting the retained session; continuation happens by sending a message. Clean stopped workspace cleanup retains history. +- **S3** Dirty cleanup retains the workspace after interrupting the retained session; continuation happens by sending a message. Clean stopped workspace cleanup retains history. Cleanup or interrupt of a deferred launch cancels its pending retry even without an attached agent session; later ticks and restart cannot resurrect it. - **S4** Render native and structured-message questions; classify stopped text and share at most two setup/clarification/form attempts, then offer only confirmed unanswered textual continuation and the same session console. Preserve answer claims and redaction across recovery. A restart between saving a fallback episode and presenting its question restores the unanswered question once, without replaying repair attempts or reopening a claimed answer. @@ -50,7 +50,7 @@ Cancelling automatic recovery does not establish that a genuine pending async qu ## Terminology and UI references -Glossary: [agent-run](../glossary/README.md#agent-run), [workspace](../glossary/README.md#workspace), [state-interrupted](../glossary/README.md#state-interrupted), [state-cleaned](../glossary/README.md#state-cleaned), [coverage-gap](../glossary/README.md#coverage-gap), [unverified-behavior](../glossary/README.md#unverified-behavior), [question-recovery](../glossary/README.md#question-recovery), [session-console](../glossary/README.md#session-console), [state-terminated](../glossary/README.md#state-terminated). +Glossary: [agent-run](../glossary/README.md#agent-run), [workspace](../glossary/README.md#workspace), [state-interrupted](../glossary/README.md#state-interrupted), [state-cleaned](../glossary/README.md#state-cleaned), [coverage-gap](../glossary/README.md#coverage-gap), [unverified-behavior](../glossary/README.md#unverified-behavior), [question-recovery](../glossary/README.md#question-recovery), [session-console](../glossary/README.md#session-console), [state-terminated](../glossary/README.md#state-terminated), [state-deferred](../glossary/README.md#state-deferred). UI entities: [PAGE-AGENTS](../ui/README.md#PAGE-AGENTS), [P-RUN-LIST](../ui/README.md#P-RUN-LIST), [P-RUN-DETAIL](../ui/README.md#P-RUN-DETAIL), [W-RUN-ROW](../ui/README.md#W-RUN-ROW), [W-RUN-EMPTY](../ui/README.md#W-RUN-EMPTY), [W-RUN-CONTROLS](../ui/README.md#W-RUN-CONTROLS), [C-SELECT-RUN](../ui/README.md#C-SELECT-RUN), [C-RUN-ACTION](../ui/README.md#C-RUN-ACTION), [PAGE-AGENT](../ui/README.md#PAGE-AGENT), [W-WORK-STATUS](../ui/README.md#W-WORK-STATUS), [W-QUESTION-RECOVERY](../ui/README.md#W-QUESTION-RECOVERY), [W-SESSION-CONSOLE](../ui/README.md#W-SESSION-CONSOLE). ## Flow diagram @@ -75,6 +75,7 @@ flowchart TD N15(["Continuation unavailable#59; retain work and report failure"]) N16["Verify current source and report changed scope or head before initial or automatic continuation #40;S6#41;"] N17(["Closed or merged source#59; terminate session and retain history/workspace"]) + N18["Restore deferred pre-agent launch and honor its retained retry deadline #40;S1#41;"] N0 -->|"send message"| N1 N1 --> N2 N0 -->|"cleanup stopped work"| N3 @@ -98,6 +99,10 @@ flowchart TD N0 -->|"start or continue retained work"| N16 N16 -->|"source closed or merged"| N17 N16 -->|"open source#59; agent assesses fresh changes"| N0 + N0 -->|"deferred or exact recognized legacy preflight failure"| N18 + N18 -->|"deadline reached#59; revalidate source and automatic policy"| N16 + N18 -->|"cleanup cancels retry even without attached session"| N3 + N18 -->|"interrupt cancels retry even without attached session"| N14 ``` ## Implementation references @@ -126,6 +131,7 @@ flowchart TD - [internal/app/tracking.go](../../internal/app/tracking.go) — Saved bounded tracking returns only active running/waiting concurrency tokens for follow-up; inactive retained turns remain historical identity in currentRun, with no remote read required. (`func (a *App) TrackWork(`) - [internal/store/progress.go](../../internal/store/progress.go) — Read the complete same-turn redacted final agent response from paged retained journal, reusing legacy chunk framing and excluding excerpts or unrelated sources. (`func (s *Store) TurnFinalText(`) - [internal/app/questions.go](../../internal/app/questions.go) — Restore successful provider-completed turn source outside lifecycle locks, revalidate current owner revision and run the existing guarded stopped-question policy without ordinary task replay. (`func (m *Manager) restoreStoppedTurn(`) +- [internal/app/launch_retry.go](../../internal/app/launch_retry.go) — Persist deadline-based pre-agent launch deferral, conservative legacy PR-review recovery and automatic-policy revalidation without duplicate claims. (`func (m *Manager) RetryDeferredLaunches(`) ## Test references @@ -202,6 +208,14 @@ flowchart TD - [internal/app/recovery_test.go](../../internal/app/recovery_test.go) — `func TestTakeoverCancelsRestoredCompletedTurnClassification(`; S1. Interrupt completes while restored completion classification is gated; releasing and joining that callback before daemon shutdown preserves cancelled recovery with no attention or question-only repair. - [internal/store/progress_test.go](../../internal/store/progress_test.go) — `func TestTurnFinalTextRestoresFullOwningResponseAcrossPages(`; S1. The full redacted owning agent response survives Unicode/escaped chunk framing across journal pages with explicit or started-boundary turn identity; other runs, older/newer turns, user/tool output and missing turn identity cannot replace it. - [internal/store/progress_test.go](../../internal/store/progress_test.go) — `func TestTurnFinalTextRejectsIncompleteAndNonAgentSources(`; S1. Incomplete or malformed completion chunks and complete user/tool payloads supply no stopped-question source; partial text cannot establish a question. +- [web/tests/factory.spec.ts](../../web/tests/factory.spec.ts) — `GitHub-deferred review shows its retry reason and deadline and can cancel before a thread starts`; S1, S3. Working list and focused conversation show Waiting for GitHub, retained reason and localized deadline with exact datetime; no alert or owner question appears. A threadless run cannot send messages; Cancel retry sends one interrupt_run and leaves it inactive without Start/message mutations. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredReviewLaunchRecoversBeforeAndAfterDeadline(`; S1. Restart before deadline makes no source request or agent call; due manual and automatic reviews start once and clear retry metadata. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredReviewLaunchRevalidatesLiveTargetAndPolicy(`; S1, S6. Changed head, closure, draft, withdrawn request, author, profile and permanent source errors start no agent; disabled or paused policy holds without HTTP; claim count stays one. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchInterruptAndCleanupCancelAttachedAndDetachedRuns(`; S1, S3. Interrupt and cleanup clear retry deadline/pending authority for attached and detached launches; restart and later ticks start no agent. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestLegacyBudgetReviewFailureRecoveryIsExactAndPreAgentOnly(`; S1. Only the three exact legacy PR-review preflight budget/reserve/rate messages recover and launch once; thread/turn, interrupted, non-review, permanent and lookalike agent errors remain failed. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestManualDeferredReviewRevalidatesPinnedHeadAndClosedSource(`; S1, S6. Due manual PR review rejects a changed pinned head or closed source before agent invocation and clears retry authority. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchCancelAndCloseDuringSourceVerification(`; S1. Interrupt, manager close, policy disable and repository pause during blocked live verification prevent provider start; interrupt/close create no workspace. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchDoesNotRetryProviderExecutionFailures(`; S1. A typed rate-limit error after provider startup remains failed with no retry metadata and exactly the original two provider requests. ## Verification and gaps diff --git a/docs/user-flows/sync-inbox.md b/docs/user-flows/sync-inbox.md index 254855a..a215c0a 100644 --- a/docs/user-flows/sync-inbox.md +++ b/docs/user-flows/sync-inbox.md @@ -36,15 +36,15 @@ Normal provider reads reuse successful saved query results until expiry or inval - **S6** Repository summary discovery does not fetch every item’s full history. Requests are serialized and bounded; failures retain successful progress, and reference backfill yields between passes. - **S7** Read and unread review notifications identify changed PRs only in configured active repositories. Unchanged hints are deduplicated across polling/restart; unsuccessful batches retain the notification cursor. Periodic repository reconciliation covers missing notifications. -- **S8** Scheduled, manual and selected-detail reads share a persistent rolling request budget and reported-quota reserve. Conditional responses, server polling intervals and primary/secondary cooldowns are respected across restart. Deferred reads retain records/checkpoints, and cached freshness cannot authorize stale automatic review. +- **S8** Scheduled, manual and selected-detail reads share a persistent rolling request budget and reported-quota reserve. Conditional responses, server polling intervals and primary/secondary cooldowns are respected across restart. Deferred reads retain records/checkpoints, and cached freshness cannot authorize stale automatic review. An accepted PR review delayed during pre-agent source verification retains its reason and retry deadline and displays Waiting for GitHub. Scheduler ticks and restart honor the deadline, then revalidate live source state/head and automatic policy before retrying that same review without another claim or duplicate agent. - **S9** A PR's posted reviews retain provider receipt links, author, verdict, reviewed commit, submission time, body and inline findings. Pending drafts are excluded. Completed PR turns trigger a read; routine reads share a one-minute cache and the GitHub request budget. Paused repositories and failed reads preserve prior successful observations with visible stale/error feedback; no read starts or publishes work. ## Terminology and UI references -Glossary: [repository](../glossary/README.md#repository), [inbox](../glossary/README.md#inbox), [work-item](../glossary/README.md#work-item), [state-new](../glossary/README.md#state-new), [state-unread](../glossary/README.md#state-unread), [coverage-gap](../glossary/README.md#coverage-gap), [unverified-behavior](../glossary/README.md#unverified-behavior), [working-queue](../glossary/README.md#working-queue), [implementation-relationship](../glossary/README.md#implementation-relationship), [review-policy](../glossary/README.md#review-policy), [review-request-claim](../glossary/README.md#review-request-claim), [repository-review-prompt](../glossary/README.md#repository-review-prompt), [github-request-budget](../glossary/README.md#github-request-budget), [github-request-reserve](../glossary/README.md#github-request-reserve), [github-review-notification](../glossary/README.md#github-review-notification), [published-review](../glossary/README.md#published-review), [state-terminated](../glossary/README.md#state-terminated). -UI entities: [PAGE-INBOX](../ui/README.md#PAGE-INBOX), [P-INBOX-QUERY](../ui/README.md#P-INBOX-QUERY), [P-INBOX-LIST](../ui/README.md#P-INBOX-LIST), [P-ITEM-DETAIL](../ui/README.md#P-ITEM-DETAIL), [W-INBOX-QUERY](../ui/README.md#W-INBOX-QUERY), [W-SYNC-HEALTH](../ui/README.md#W-SYNC-HEALTH), [W-ITEM-ROW](../ui/README.md#W-ITEM-ROW), [W-INBOX-EMPTY](../ui/README.md#W-INBOX-EMPTY), [W-ITEM-OVERVIEW](../ui/README.md#W-ITEM-OVERVIEW), [W-ITEM-ACTIONS](../ui/README.md#W-ITEM-ACTIONS), [W-ITEM-HISTORY](../ui/README.md#W-ITEM-HISTORY), [C-FILTER](../ui/README.md#C-FILTER), [C-SELECT-ITEM](../ui/README.md#C-SELECT-ITEM), [C-ITEM-STATE](../ui/README.md#C-ITEM-STATE), [W-RELATIONSHIPS](../ui/README.md#W-RELATIONSHIPS), [W-TICKET-CONTEXT](../ui/README.md#W-TICKET-CONTEXT), [W-PR-EVIDENCE](../ui/README.md#W-PR-EVIDENCE), [C-LINK](../ui/README.md#C-LINK), [PAGE-AGENT](../ui/README.md#PAGE-AGENT), [PAGE-WORKING](../ui/README.md#PAGE-WORKING), [W-REPOSITORY-SETTINGS](../ui/README.md#W-REPOSITORY-SETTINGS), [W-SYSTEM-DEFAULTS](../ui/README.md#W-SYSTEM-DEFAULTS), [C-REVIEW-POLICY](../ui/README.md#C-REVIEW-POLICY), [C-REPOSITORY-REVIEW-PROMPT](../ui/README.md#C-REPOSITORY-REVIEW-PROMPT), [W-GITHUB-USAGE](../ui/README.md#W-GITHUB-USAGE), [C-GITHUB-USAGE](../ui/README.md#C-GITHUB-USAGE). +Glossary: [repository](../glossary/README.md#repository), [inbox](../glossary/README.md#inbox), [work-item](../glossary/README.md#work-item), [state-new](../glossary/README.md#state-new), [state-unread](../glossary/README.md#state-unread), [coverage-gap](../glossary/README.md#coverage-gap), [unverified-behavior](../glossary/README.md#unverified-behavior), [working-queue](../glossary/README.md#working-queue), [implementation-relationship](../glossary/README.md#implementation-relationship), [review-policy](../glossary/README.md#review-policy), [review-request-claim](../glossary/README.md#review-request-claim), [repository-review-prompt](../glossary/README.md#repository-review-prompt), [github-request-budget](../glossary/README.md#github-request-budget), [github-request-reserve](../glossary/README.md#github-request-reserve), [github-review-notification](../glossary/README.md#github-review-notification), [published-review](../glossary/README.md#published-review), [state-terminated](../glossary/README.md#state-terminated), [state-deferred](../glossary/README.md#state-deferred). +UI entities: [PAGE-INBOX](../ui/README.md#PAGE-INBOX), [P-INBOX-QUERY](../ui/README.md#P-INBOX-QUERY), [P-INBOX-LIST](../ui/README.md#P-INBOX-LIST), [P-ITEM-DETAIL](../ui/README.md#P-ITEM-DETAIL), [W-INBOX-QUERY](../ui/README.md#W-INBOX-QUERY), [W-SYNC-HEALTH](../ui/README.md#W-SYNC-HEALTH), [W-ITEM-ROW](../ui/README.md#W-ITEM-ROW), [W-INBOX-EMPTY](../ui/README.md#W-INBOX-EMPTY), [W-ITEM-OVERVIEW](../ui/README.md#W-ITEM-OVERVIEW), [W-ITEM-ACTIONS](../ui/README.md#W-ITEM-ACTIONS), [W-ITEM-HISTORY](../ui/README.md#W-ITEM-HISTORY), [C-FILTER](../ui/README.md#C-FILTER), [C-SELECT-ITEM](../ui/README.md#C-SELECT-ITEM), [C-ITEM-STATE](../ui/README.md#C-ITEM-STATE), [W-RELATIONSHIPS](../ui/README.md#W-RELATIONSHIPS), [W-TICKET-CONTEXT](../ui/README.md#W-TICKET-CONTEXT), [W-PR-EVIDENCE](../ui/README.md#W-PR-EVIDENCE), [C-LINK](../ui/README.md#C-LINK), [PAGE-AGENT](../ui/README.md#PAGE-AGENT), [PAGE-WORKING](../ui/README.md#PAGE-WORKING), [W-REPOSITORY-SETTINGS](../ui/README.md#W-REPOSITORY-SETTINGS), [W-SYSTEM-DEFAULTS](../ui/README.md#W-SYSTEM-DEFAULTS), [C-REVIEW-POLICY](../ui/README.md#C-REVIEW-POLICY), [C-REPOSITORY-REVIEW-PROMPT](../ui/README.md#C-REPOSITORY-REVIEW-PROMPT), [W-GITHUB-USAGE](../ui/README.md#W-GITHUB-USAGE), [C-GITHUB-USAGE](../ui/README.md#C-GITHUB-USAGE), [W-WORK-STATUS](../ui/README.md#W-WORK-STATUS), [W-SESSION-CONSOLE](../ui/README.md#W-SESSION-CONSOLE). ## Flow diagram @@ -67,6 +67,7 @@ flowchart TD N14(["Closed or merged source#59; terminate session and retain history/workspace"]) N15["Reuse a successful unexpired scoped query or require current remote validation #40;S8#41;"] N16["Validate live data within the shared budget and update saved query state #40;S8#41;"] + N17["Retain accepted launch, reason and retry deadline#59; show Waiting for GitHub #40;S8#41;"] N0 --> N1 N1 -->|"success"| N2 N0 -->|"provider failure"| N3 @@ -96,6 +97,9 @@ flowchart TD N15 -->|"expired, invalidated or explicitly current query"| N16 N16 -->|"successful validation updates shared cache"| N2 N16 -->|"failed validation#59; do not claim old success as current"| N3 + N13 -->|"pre-agent budget, quota reserve or rate limit"| N17 + N17 -->|"deadline reached after tick or restart#59; recheck current source and automatic policy"| N13 + N17 -->|"explicit cancellation#59; no agent launch"| N8 ``` ## Implementation references @@ -124,6 +128,7 @@ flowchart TD - [internal/providers/continuation.go](../../internal/providers/continuation.go) — Targeted conditional GitHub source read bypasses local TTL while retaining shared budget and cooldown enforcement. (`func (g *GitHub) CurrentWorkItem(`) - [internal/providers/github_source.go](../../internal/providers/github_source.go) — Application-facing read interface and explicit cache/live freshness choice; live adapter scopes cache identity by endpoint and principal. (`type GitHubSource interface {`) - [internal/providers/github_cache.go](../../internal/providers/github_cache.go) — Same-API persistent query cache with TTLs, scoped invalidation, bounded eviction, direct write-through and independent caller values. (`type CachedGitHub struct {`) +- [internal/app/launch_retry.go](../../internal/app/launch_retry.go) — Persist deadline-based pre-agent launch deferral, conservative legacy PR-review recovery and automatic-policy revalidation without duplicate claims. (`func (m *Manager) RetryDeferredLaunches(`) ## Test references @@ -186,6 +191,12 @@ flowchart TD - [internal/app/review_requests_test.go](../../internal/app/review_requests_test.go) — `func TestAutomaticReviewBusyGuardReadsOnlyOwningRunMetadata(`; S5. Repeated automatic eligibility reads only owning-run metadata: all four persisted busy states prevent claims and pipeline validation despite unrelated/private frozen payloads; idle ownership reaches normal pipeline validation without provider work. - [internal/reports/github_optimization_test.go](../../internal/reports/github_optimization_test.go) — `func TestManagedInboxAndReportingSharePersistentRequestBudget(`; S3. A managed inbox read exhausts the shared persisted request budget; report reads and a separate publication transport fail before dispatch, retain the one consumed request and never create an issue. - [internal/providers/github_usage_test.go](../../internal/providers/github_usage_test.go) — `func TestManagedTransportsShareProcessGateAndPreserveCooldown(`; S3, S6. Separate database connections and transports cannot overlap managed HTTP; a cancelled waiting publication remains BeforeDispatch, exactly two attempts survive, and a later rate-limit deadline is preserved. +- [web/tests/factory.spec.ts](../../web/tests/factory.spec.ts) — `GitHub-deferred review shows its retry reason and deadline and can cancel before a thread starts`; S8. Working list and focused conversation show Waiting for GitHub, retained reason and localized deadline with exact datetime; no alert or owner question appears. A threadless run cannot send messages; Cancel retry sends one interrupt_run and leaves it inactive without Start/message mutations. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredReviewLaunchRespectsDeadlineAndRetriesExactlyOnce(`; S5, S8. Persist reason/deadline before workspace or agent startup; twelve concurrent due retries produce exactly one run, one automatic claim and one thread/turn start. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredReviewLaunchRetriesWhenRepositorySyncFails(`; S5, S8. Scheduler retries due automatic work despite a failed repository poll while retaining the repository failure. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredReviewLaunchRevalidatesLiveTargetAndPolicy(`; S5, S8, S10. Changed head, closure, draft, withdrawn request, author, profile and permanent source errors start no agent; disabled or paused policy holds without HTTP; claim count stays one. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchProjectionRetainsWorkingQueueAndRetryDeadline(`; S8. Deferred work stays in Working with Waiting for GitHub; tracking retains launchRetryAt and cancellation clears the waiting projection. +- [internal/app/launch_retry_test.go](../../internal/app/launch_retry_test.go) — `func TestDeferredLaunchRepeatedBudgetDenialKeepsAcceptedRun(`; S5, S8. Repeated due budget denial retains the same accepted run/claim and renewed deadline without HTTP or agent invocation. ## Verification and gaps diff --git a/internal/app/app.go b/internal/app/app.go index ffdf573..9f42775 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -637,6 +637,9 @@ func (a *App) syncLoop(ctx context.Context, ticks <-chan time.Time) { return } _ = a.sync(ctx, true) + if a.Runs != nil { + _ = a.Runs.RetryDeferredLaunches(ctx) + } // Reference backfill yields to the next repository poll and resumes from // its last attempted PR, rather than restarting a large reference list. references, cancel := context.WithTimeout(ctx, 10*time.Second) diff --git a/internal/app/continuation.go b/internal/app/continuation.go index a65fabc..613509b 100644 --- a/internal/app/continuation.go +++ b/internal/app/continuation.go @@ -13,7 +13,7 @@ import ( ) func sourceSnapshot(i domain.Item) *domain.Item { - return &domain.Item{ID: i.ID, Repository: i.Repository, Number: i.Number, Kind: i.Kind, Title: i.Title, Body: i.Body, State: i.State, URL: i.URL, Head: i.Head, Branch: i.Branch, Draft: i.Draft, Updated: i.Updated, Labels: i.Labels, Assignees: i.Assignees, ReviewRequests: i.ReviewRequests, ReviewTeams: i.ReviewTeams} + return &domain.Item{ID: i.ID, Repository: i.Repository, Number: i.Number, Kind: i.Kind, Title: i.Title, Body: i.Body, State: i.State, URL: i.URL, Author: i.Author, Head: i.Head, Branch: i.Branch, Draft: i.Draft, Updated: i.Updated, Labels: i.Labels, Assignees: i.Assignees, ReviewRequests: i.ReviewRequests, ReviewTeams: i.ReviewTeams} } func closedWork(i domain.Item) bool { return i.State == "closed" || i.State == "merged" } @@ -120,6 +120,7 @@ func (m *Manager) terminateLocked(ctx context.Context, id string, s *session, re } run.Status, run.Error, run.TurnID = "terminated", reason, "" run.ResumePending, run.InterruptRequested = false, true + run.LaunchRetryAt = time.Time{} if err := tx.Put("run", id, run); err != nil { return err } diff --git a/internal/app/launch_retry.go b/internal/app/launch_retry.go new file mode 100644 index 0000000..5398260 --- /dev/null +++ b/internal/app/launch_retry.go @@ -0,0 +1,143 @@ +package app + +import ( + "codefactory/internal/domain" + "codefactory/internal/providers" + "codefactory/internal/store" + "context" + "errors" + "time" +) + +// Only the managed source preflight can defer a launch. Provider execution and +// publication failures retain their ordinary, non-replayed failure semantics. +func (m *Manager) deferLaunch(s *session, cause error) bool { + var rate *providers.RateLimitError + if m.app.GitHub == nil || !m.app.GitHub.Managed() || !errors.As(cause, &rate) { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + if s.retired || s.run.InterruptRequested || s.run.ThreadID != "" || s.run.TurnID != "" { + return false + } + s.run.Status, s.run.Error, s.run.ResumePending = "deferred", cause.Error(), true + s.run.LaunchRetryAt = rate.Until.UTC() + if !s.run.LaunchRetryAt.After(time.Now()) { + s.run.LaunchRetryAt = time.Now().UTC().Add(SyncInterval) + } + if err := m.persist(s, "run.state"); err != nil { + s.run.Status, s.run.Error, s.run.ResumePending = "failed", err.Error(), false + s.run.LaunchRetryAt = time.Time{} + _ = m.persist(s, "run.state") + } + return true +} + +func legacyLaunchBudgetFailure(r domain.Run) bool { + if r.Status != "failed" || r.ThreadID != "" || r.TurnID != "" || r.InterruptRequested || !(r.PRReview || r.AutomaticReview || r.Profile.ID == "review") { + return false + } + const prefix = "cannot verify current work item; work remains stopped: " + for _, reason := range []string{ + "Code Factory GitHub budget reached; synchronization will retry", + "GitHub request reserve reached; synchronization will retry", + "GitHub rate limit; synchronization will retry", + } { + if r.Error == prefix+reason { + return true + } + } + return false +} + +func (m *Manager) recoverDeferredLaunch(ctx context.Context, r domain.Run) error { + if !legacyLaunchBudgetFailure(r) || m.app.GitHub == nil || !m.app.GitHub.Managed() { + return nil + } + var item domain.Item + if err := m.app.Store.Get(ctx, "item", r.ItemID, &item); err != nil { + return err + } + if item.Kind != "pr" { + return nil + } + usage, err := m.app.GitHub.UsageStatus(ctx) + if err != nil { + return err + } + return m.app.Store.Write(ctx, func(tx *store.Tx) error { + if err := tx.Get("run", r.ID, &r); err != nil { + return err + } + if !legacyLaunchBudgetFailure(r) { + return nil + } + r.Status, r.ResumePending, r.LaunchRetryAt = "deferred", true, usage.NextRequest.UTC() + if err := tx.Put("run", r.ID, r); err != nil { + return err + } + return tx.Event("run.state", r.ID, map[string]string{"id": r.ID, "status": r.Status}) + }) +} + +// RetryDeferredLaunches runs on the daemon's normal cadence even when repository +// synchronization fails or is not due. startRun serializes and persists the claim +// before launching, so concurrent polls and startup cannot replay the same run. +func (m *Manager) RetryDeferredLaunches(ctx context.Context) error { + if !m.adminGate.TryRLock() { + return nil + } + defer m.adminGate.RUnlock() + if m.app.GitHub == nil || !m.app.GitHub.Managed() { + return nil + } + runs, err := store.List[domain.Run](ctx, m.app.Store, "run") + if err != nil { + return err + } + for _, r := range runs { + if r.Status != "deferred" || !r.ResumePending || r.InterruptRequested || r.ThreadID != "" || r.TurnID != "" || time.Now().Before(r.LaunchRetryAt) { + continue + } + // Disabling or pausing the policy holds accepted work without fetching + // source or creating a new automatic review claim. + if r.AutomaticReview { + var item domain.Item + var repo domain.Repository + if err := m.app.Store.Get(ctx, "item", r.ItemID, &item); err != nil { + return err + } + if err := m.app.Store.Get(ctx, "repository", item.Repository, &repo); err != nil { + return err + } + config, err := m.app.Config(ctx) + if err != nil { + return err + } + if repo.Paused || !autoReviewEnabled(config, repo) { + continue + } + } + _, _, err := m.startRun(ctx, r.ID, false, "", true, nil) + if err != nil && (ctx.Err() != nil || m.ctx.Err() != nil) { + return err + } + } + return nil +} + +func (m *Manager) validateLaunchPolicy(ctx context.Context, r domain.Run, source domain.Item) error { + if r.ThreadID != "" || source.Kind != "pr" { + return nil + } + return m.app.Store.Write(ctx, func(tx *store.Tx) error { + if err := validatePRReviewRun(tx.Get, r, source); err != nil { + return err + } + if r.AutomaticReview { + return validateAutomaticPolicy(tx, r, source, false) + } + return nil + }) +} diff --git a/internal/app/launch_retry_test.go b/internal/app/launch_retry_test.go new file mode 100644 index 0000000..229e6be --- /dev/null +++ b/internal/app/launch_retry_test.go @@ -0,0 +1,736 @@ +package app + +import ( + "codefactory/internal/codex" + "codefactory/internal/domain" + "codefactory/internal/providers" + "codefactory/internal/store" + "codefactory/internal/testutil" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type launchRetrySource struct { + providers.GitHubSource + item domain.Item + syncFails atomic.Bool + currentEntered chan struct{} + currentRelease chan struct{} + currentOnce sync.Once +} + +func (s *launchRetrySource) CurrentWorkItem(ctx context.Context, item domain.Item) (domain.Item, error) { + if s.currentEntered != nil { + s.currentOnce.Do(func() { close(s.currentEntered) }) + select { + case <-s.currentRelease: + case <-ctx.Done(): + return domain.Item{}, ctx.Err() + } + } + return s.GitHubSource.CurrentWorkItem(ctx, item) +} + +func (s *launchRetrySource) User(context.Context) (string, error) { return "me", nil } +func (s *launchRetrySource) SyncIndex(context.Context, domain.Repository, bool) ([]domain.Item, time.Time, error) { + if s.syncFails.Load() { + return nil, time.Time{}, errors.New("fixture repository sync failed") + } + return []domain.Item{s.item}, time.Now().UTC(), nil +} +func (s *launchRetrySource) PullRequest(context.Context, string, int) (domain.Item, error) { + return s.item, nil +} + +func (s *launchRetrySource) PublishedReviews(context.Context, domain.Item) ([]domain.PublishedReview, time.Time, error) { + return nil, time.Now().UTC(), nil +} + +type launchRetryFixture struct { + a *App + pr domain.Item + run domain.Run + github *providers.GitHub + source *launchRetrySource + budget atomic.Int32 + calls atomic.Int32 + live atomic.Value + record string + status atomic.Int32 +} + +func deferredReviewFixture(t *testing.T, automatic bool) *launchRetryFixture { + t.Helper() + a, pr := reviewFixture(t) + t.Setenv("CF_FAKE_MODE", "hold") + f := &launchRetryFixture{a: a, pr: pr, record: filepath.Join(t.TempDir(), "calls.jsonl")} + f.live.Store(pr) + f.budget.Store(1) + f.status.Store(http.StatusOK) + t.Setenv("CF_FAKE_RECORD", f.record) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.calls.Add(1) + if r.URL.Path == "/user" { + fmt.Fprint(w, `{"login":"me"}`) + return + } + number := 2 + if r.URL.Path == "/repos/owner/repo/pulls/3" { + number = 3 + } else if r.URL.Path != "/repos/owner/repo/pulls/2" { + t.Errorf("unexpected HTTP request %s", r.URL.Path) + w.WriteHeader(404) + return + } + w.WriteHeader(int(f.status.Load())) + item := f.live.Load().(domain.Item) + reviewers := []map[string]string{} + for _, login := range item.ReviewRequests { + reviewers = append(reviewers, map[string]string{"login": login}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"number": number, "title": "PR", "state": item.State, "draft": item.Draft, "user": map[string]string{"login": item.Author}, "head": map[string]string{"sha": item.Head}, "requested_reviewers": reviewers}) + })) + t.Cleanup(server.Close) + f.github = &providers.GitHub{BaseURL: server.URL, Client: server.Client(), Token: func(context.Context) (string, error) { return "fixture", nil }} + f.github.ConfigureUsage(a.Store, func(context.Context) (providers.GitHubPolicy, error) { + return providers.GitHubPolicy{RequestsPerHour: int(f.budget.Load())}, nil + }) + f.source = &launchRetrySource{GitHubSource: f.github, item: pr} + a.GitHub = f.source + ctx := a.Runs.ctx + if _, err := f.github.User(ctx); err != nil { + t.Fatal(err) + } + if automatic { + c, _ := a.Config(ctx) + c.AutoReview = true + if err := a.SaveConfig(ctx, c); err != nil { + t.Fatal(err) + } + if err := a.ProcessReviewRequests(ctx); err != nil { + t.Fatal(err) + } + claims, _ := store.List[reviewClaim](ctx, a.Store, "review-claim") + if len(claims) != 1 { + t.Fatal("expected exactly one claim", claims) + } + if err := a.Store.Get(ctx, "run", claims[0].RunID, &f.run); err != nil { + t.Fatal(err) + } + } else { + var err error + f.run, err = a.Runs.PreparePRReview(ctx, pr.ID) + if err != nil { + t.Fatal(err) + } + if _, err = a.Runs.Start(ctx, f.run.ID, false); err != nil { + t.Fatal(err) + } + } + waitFor(t, func() bool { _ = a.Store.Get(ctx, "run", f.run.ID, &f.run); return f.run.Status == "deferred" }) + usage, err := f.github.UsageStatus(ctx) + if err != nil || !f.run.LaunchRetryAt.Equal(usage.NextRequest) || !f.run.LaunchRetryAt.After(time.Now()) || !f.run.ResumePending || f.run.ThreadID != "" || f.run.TurnID != "" || !strings.Contains(f.run.Error, "Code Factory GitHub budget reached") { + t.Fatalf("launch was not durably deferred: %+v usage=%+v err=%v", f.run, usage, err) + } + if f.calls.Load() != 1 { + t.Fatal("budget preflight performed HTTP", f.calls.Load()) + } + if _, err := os.Stat(f.record); !os.IsNotExist(err) { + t.Fatal("deferred launch started an agent", err) + } + if _, err := os.Stat(f.run.Worktree); !os.IsNotExist(err) { + t.Fatal("deferred launch created workspace", err) + } + return f +} +func (f *launchRetryFixture) expire(t *testing.T) { + t.Helper() + if err := f.a.Store.Get(context.Background(), "run", f.run.ID, &f.run); err != nil { + t.Fatal(err) + } + f.run.LaunchRetryAt = time.Now().UTC().Add(-time.Second) + if err := f.a.Store.Put(context.Background(), "run", f.run.ID, f.run, ""); err != nil { + t.Fatal(err) + } +} +func (f *launchRetryFixture) restart(t *testing.T) { + t.Helper() + f.a.Runs.Close() + f.a.Runs = NewManager(context.Background(), f.a, testutil.FakeCodex(t)) + if err := f.a.Runs.Recover(context.Background()); err != nil { + t.Fatal(err) + } +} +func (f *launchRetryFixture) assertNoAgent(t *testing.T) { + t.Helper() + if _, err := os.Stat(f.record); !os.IsNotExist(err) { + t.Fatal("unexpected provider invocation", err) + } +} +func (f *launchRetryFixture) assertSingleLaunch(t *testing.T) { + t.Helper() + waitFor(t, func() bool { + _ = f.a.Store.Get(context.Background(), "run", f.run.ID, &f.run) + return f.run.Status == "running" && f.run.TurnID != "" + }) + calls := recordedCalls(t, f.record) + if len(calls) != 2 || calls[0].Method != "thread/start" || calls[1].Method != "turn/start" { + t.Fatalf("accepted launch replayed or resumed; recorded calls=%d", len(calls)) + } + if !f.run.LaunchRetryAt.IsZero() || f.run.Error != "" || !f.run.ResumePending { + t.Fatal("retry metadata was not cleared", f.run) + } + runs, err := store.List[domain.Run](context.Background(), f.a.Store, "run") + if err != nil { + t.Fatal(err) + } + n := 0 + for _, r := range runs { + if r.ItemID == f.pr.ID { + n++ + } + } + if n != 1 { + t.Fatal("retry created duplicate runs", n) + } + if f.run.AutomaticReview && claimCount(t, f.a) != 1 { + t.Fatal("retry created duplicate automatic claim") + } +} + +func TestDeferredReviewLaunchRespectsDeadlineAndRetriesExactlyOnce(t *testing.T) { + for _, automatic := range []bool{false, true} { + t.Run(fmt.Sprint(automatic), func(t *testing.T) { + f := deferredReviewFixture(t, automatic) + ctx := context.Background() + f.budget.Store(100) + if err := f.a.Runs.RetryDeferredLaunches(ctx); err != nil { + t.Fatal(err) + } + if f.calls.Load() != 1 { + t.Fatal("retry ignored persisted deadline") + } + f.assertNoAgent(t) + // A successful repository poll must preserve the accepted run/claim. + if err := f.a.Sync(ctx); err != nil { + t.Fatal(err) + } + f.expire(t) + var wg sync.WaitGroup + for n := 0; n < 12; n++ { + wg.Add(1) + go func() { + defer wg.Done() + if err := f.a.Runs.RetryDeferredLaunches(ctx); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + f.assertSingleLaunch(t) + if err := f.a.Runs.RetryDeferredLaunches(ctx); err != nil { + t.Fatal(err) + } + if err := f.a.ProcessReviewRequests(ctx); err != nil { + t.Fatal(err) + } + f.assertSingleLaunch(t) + }) + } +} + +func TestDeferredReviewLaunchRecoversBeforeAndAfterDeadline(t *testing.T) { + for _, automatic := range []bool{false, true} { + for _, expired := range []bool{false, true} { + t.Run(fmt.Sprintf("automatic=%t/expired=%t", automatic, expired), func(t *testing.T) { + f := deferredReviewFixture(t, automatic) + f.budget.Store(100) + if expired { + f.expire(t) + } + f.restart(t) + if !expired { + if f.calls.Load() != 1 { + t.Fatal("restart ignored retry deadline") + } + f.assertNoAgent(t) + f.expire(t) + if err := f.a.Runs.RetryDeferredLaunches(context.Background()); err != nil { + t.Fatal(err) + } + } + f.assertSingleLaunch(t) + }) + } + } +} + +func TestDeferredReviewLaunchRetriesWhenRepositorySyncFails(t *testing.T) { + f := deferredReviewFixture(t, true) + f.budget.Store(100) + f.expire(t) + f.source.syncFails.Store(true) + var original domain.Repository + if err := f.a.Store.Get(context.Background(), "repository", f.pr.Repository, &original); err != nil { + t.Fatal(err) + } + original.LastSync = time.Now().UTC().Add(-time.Hour) + if err := f.a.Store.Put(context.Background(), "repository", original.ID, original, ""); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { defer close(done); f.a.syncLoop(ctx, make(chan time.Time)) }() + t.Cleanup(func() { cancel(); <-done }) + f.assertSingleLaunch(t) + var repo domain.Repository + if err := f.a.Store.Get(ctx, "repository", f.pr.Repository, &repo); err != nil || repo.Error == "" { + t.Fatal("test did not exercise failed repository sync", repo, err) + } +} + +func TestDeferredReviewLaunchRevalidatesLiveTargetAndPolicy(t *testing.T) { + for _, change := range []string{"head", "closed", "draft", "withdrawn", "disabled", "paused", "profile", "author", "permanent-preflight"} { + t.Run(change, func(t *testing.T) { + f := deferredReviewFixture(t, true) + ctx := context.Background() + fresh := f.pr + switch change { + case "head": + fresh.Head = "different-head" + case "closed": + fresh.State = "closed" + case "draft": + fresh.Draft = true + case "withdrawn": + fresh.ReviewRequests = nil + case "author": + fresh.Author = "me" + case "disabled": + c, _ := f.a.Config(ctx) + c.AutoReview = false + if err := f.a.SaveConfig(ctx, c); err != nil { + t.Fatal(err) + } + case "paused": + var repo domain.Repository + _ = f.a.Store.Get(ctx, "repository", f.pr.Repository, &repo) + repo.Paused = true + if err := f.a.SaveRepository(ctx, repo); err != nil { + t.Fatal(err) + } + case "profile": + var p domain.Profile + _ = f.a.Store.Get(ctx, "profile", "review", &p) + p.Prompt += " changed" + if err := f.a.SaveProfile(ctx, p); err != nil { + t.Fatal(err) + } + case "permanent-preflight": + f.status.Store(503) + } + f.live.Store(fresh) + f.budget.Store(100) + f.expire(t) + if err := f.a.Runs.RetryDeferredLaunches(ctx); err != nil { + t.Fatal(err) + } + if change == "disabled" || change == "paused" { + _ = f.a.Store.Get(ctx, "run", f.run.ID, &f.run) + if f.run.Status != "deferred" || f.calls.Load() != 1 { + t.Fatal("paused or disabled automatic launch was attempted", f.run, f.calls.Load()) + } + } else { + waitFor(t, func() bool { + _ = f.a.Store.Get(ctx, "run", f.run.ID, &f.run) + return f.run.Status == "failed" || f.run.Status == "terminated" + }) + if f.run.ResumePending || !f.run.LaunchRetryAt.IsZero() { + t.Fatal("terminal rejection retained retry authority", f.run) + } + if err := f.a.Runs.RetryDeferredLaunches(ctx); err != nil { + t.Fatal(err) + } + } + f.assertNoAgent(t) + if claimCount(t, f.a) != 1 { + t.Fatal("revalidation changed automatic claim") + } + }) + } +} + +func TestDeferredLaunchInterruptAndCleanupCancelAttachedAndDetachedRuns(t *testing.T) { + for _, detached := range []bool{false, true} { + for _, cleanup := range []bool{false, true} { + t.Run(fmt.Sprintf("detached=%t/cleanup=%t", detached, cleanup), func(t *testing.T) { + f := deferredReviewFixture(t, false) + if detached { + f.restart(t) + } + var err error + if cleanup { + err = f.a.Runs.Cleanup(context.Background(), f.run.ID) + } else { + err = f.a.Runs.Interrupt(context.Background(), f.run.ID) + } + if err != nil { + t.Fatal(err) + } + _ = f.a.Store.Get(context.Background(), "run", f.run.ID, &f.run) + if f.run.ResumePending || !f.run.InterruptRequested || !f.run.LaunchRetryAt.IsZero() || f.run.Status == "deferred" { + t.Fatal("cancel did not clear accepted launch", f.run) + } + f.budget.Store(100) + f.restart(t) + if err := f.a.Runs.RetryDeferredLaunches(context.Background()); err != nil { + t.Fatal(err) + } + f.assertNoAgent(t) + }) + } + } +} + +func TestLegacyBudgetReviewFailureRecoveryIsExactAndPreAgentOnly(t *testing.T) { + const prefix = "cannot verify current work item; work remains stopped: " + for _, which := range []string{"budget", "reserve", "rate", "permanent", "thread", "turn", "interrupted", "non-review", "agent-error", "lookalike"} { + t.Run(which, func(t *testing.T) { + f := deferredReviewFixture(t, false) + f.a.Runs.Close() + r := f.run + r.Status = "failed" + r.ResumePending = false + r.LaunchRetryAt = time.Time{} + r.Error = prefix + "Code Factory GitHub budget reached; synchronization will retry" + eligible := true + switch which { + case "reserve": + r.Error = prefix + "GitHub request reserve reached; synchronization will retry" + case "rate": + r.Error = prefix + "GitHub rate limit; synchronization will retry" + case "permanent": + r.Error = prefix + "GitHub unavailable (HTTP 503)" + eligible = false + case "thread": + r.ThreadID = "already-started" + eligible = false + case "turn": + r.TurnID = "already-started" + eligible = false + case "interrupted": + r.InterruptRequested = true + eligible = false + case "non-review": + r.PRReview = false + r.Profile.ID = "investigate" + eligible = false + case "agent-error": + r.Error = "Code Factory GitHub budget reached; synchronization will retry" + eligible = false + case "lookalike": + r.Error += " after agent failure" + eligible = false + } + if err := f.a.Store.Put(context.Background(), "run", r.ID, r, ""); err != nil { + t.Fatal(err) + } + f.a.Runs = NewManager(context.Background(), f.a, testutil.FakeCodex(t)) + if err := f.a.Runs.Recover(context.Background()); err != nil { + t.Fatal(err) + } + _ = f.a.Store.Get(context.Background(), "run", r.ID, &r) + if eligible { + usage, err := f.github.UsageStatus(context.Background()) + if err != nil || r.Status != "deferred" || !r.ResumePending || !r.LaunchRetryAt.Equal(usage.NextRequest) { + t.Fatal("legacy failure was not durably recovered", r, usage, err) + } + f.assertNoAgent(t) + f.budget.Store(100) + f.expire(t) + if err := f.a.Runs.RetryDeferredLaunches(context.Background()); err != nil { + t.Fatal(err) + } + f.assertSingleLaunch(t) + } else if r.Status != "failed" || r.ResumePending { + t.Fatal("unrelated failure gained retry authority", r) + } + if !eligible { + f.assertNoAgent(t) + } + }) + } +} + +func TestDeferredLaunchProjectionRetainsWorkingQueueAndRetryDeadline(t *testing.T) { + f := deferredReviewFixture(t, false) + entries, err := f.a.Inbox(context.Background(), "open") + if err != nil { + t.Fatal(err) + } + var found bool + for _, entry := range entries { + if entry.Item.ID == f.pr.ID { + found = true + if entry.Work.Status != "Waiting for GitHub" || !ContainsQueue(entry, "working") { + t.Fatal("deferred launch disappeared from working queue", entry) + } + } + } + if !found { + t.Fatal("review item absent") + } + if got := runSummary(f.run)["launchRetryAt"]; got != f.run.LaunchRetryAt { + t.Fatal("tracking omitted retry deadline", got) + } + if err := f.a.Runs.Interrupt(context.Background(), f.run.ID); err != nil { + t.Fatal(err) + } + entries, err = f.a.Inbox(context.Background(), "open") + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.Item.ID == f.pr.ID && entry.Work.Status == "Waiting for GitHub" { + t.Fatal("cancel retained waiting projection") + } + } +} + +func TestDeferredLaunchRepeatedBudgetDenialKeepsAcceptedRun(t *testing.T) { + f := deferredReviewFixture(t, true) + f.expire(t) + if err := f.a.Runs.RetryDeferredLaunches(context.Background()); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { + _ = f.a.Store.Get(context.Background(), "run", f.run.ID, &f.run) + return f.run.Status == "deferred" + }) + usage, err := f.github.UsageStatus(context.Background()) + if err != nil || !f.run.LaunchRetryAt.Equal(usage.NextRequest) || !f.run.ResumePending || f.calls.Load() != 1 { + t.Fatal("renewed budget denial lost deadline or performed HTTP", f.run, usage, err) + } + f.assertNoAgent(t) + if claimCount(t, f.a) != 1 { + t.Fatal("renewed deferral changed review claim") + } +} + +func TestManualDeferredReviewRevalidatesPinnedHeadAndClosedSource(t *testing.T) { + for _, closed := range []bool{false, true} { + t.Run(fmt.Sprint(closed), func(t *testing.T) { + f := deferredReviewFixture(t, false) + fresh := f.pr + if closed { + fresh.State = "closed" + } else { + fresh.Head = "changed-head" + } + f.live.Store(fresh) + f.budget.Store(100) + f.expire(t) + if err := f.a.Runs.RetryDeferredLaunches(context.Background()); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { + _ = f.a.Store.Get(context.Background(), "run", f.run.ID, &f.run) + return f.run.Status == "failed" || f.run.Status == "terminated" + }) + f.assertNoAgent(t) + if f.run.ResumePending || !f.run.LaunchRetryAt.IsZero() { + t.Fatal("stale or closed manual review remained retryable", f.run) + } + }) + } +} + +func TestDeferredLaunchCancelAndCloseDuringSourceVerification(t *testing.T) { + for _, action := range []string{"interrupt", "close", "disable", "pause"} { + t.Run(action, func(t *testing.T) { + f := deferredReviewFixture(t, true) + f.budget.Store(100) + f.expire(t) + f.source.currentEntered = make(chan struct{}) + f.source.currentRelease = make(chan struct{}) + if err := f.a.Runs.RetryDeferredLaunches(context.Background()); err != nil { + t.Fatal(err) + } + select { + case <-f.source.currentEntered: + case <-time.After(3 * time.Second): + t.Fatal("retry did not begin source verification") + } + var closer chan struct{} + switch action { + case "interrupt": + if err := f.a.Runs.Interrupt(context.Background(), f.run.ID); err != nil { + t.Fatal(err) + } + case "close": + closer = make(chan struct{}) + go func() { f.a.Runs.Close(); close(closer) }() + waitFor(t, func() bool { return f.a.Runs.ctx.Err() != nil }) + case "disable": + c, _ := f.a.Config(context.Background()) + c.AutoReview = false + if err := f.a.SaveConfig(context.Background(), c); err != nil { + t.Fatal(err) + } + case "pause": + var repo domain.Repository + _ = f.a.Store.Get(context.Background(), "repository", f.pr.Repository, &repo) + repo.Paused = true + if err := f.a.SaveRepository(context.Background(), repo); err != nil { + t.Fatal(err) + } + } + close(f.source.currentRelease) + if closer != nil { + <-closer + } else { + live, err := f.a.Runs.get(f.run.ID) + if err != nil { + t.Fatal(err) + } + select { + case <-live.started: + case <-time.After(3 * time.Second): + t.Fatal("cancelled retry did not finish") + } + } + f.assertNoAgent(t) + if action == "interrupt" || action == "close" { + if _, err := os.Stat(f.run.Worktree); !os.IsNotExist(err) { + t.Fatal("cancelled launch created workspace", err) + } + } + if err := f.a.Runs.RetryDeferredLaunches(context.Background()); err != nil && action != "close" { + t.Fatal(err) + } + f.assertNoAgent(t) + }) + } +} + +func TestDeferredLaunchDoesNotRetryProviderExecutionFailures(t *testing.T) { + f := deferredReviewFixture(t, false) + f.budget.Store(100) + f.expire(t) + if err := f.a.Runs.RetryDeferredLaunches(context.Background()); err != nil { + t.Fatal(err) + } + f.assertSingleLaunch(t) + live, err := f.a.Runs.get(f.run.ID) + if err != nil { + t.Fatal(err) + } + // Even a typed rate limit after provider startup is an execution failure, + // because replay could repeat work or an uncertain external publication. + f.a.Runs.fail(live, &providers.RateLimitError{Until: time.Now().UTC().Add(time.Hour)}) + if err := f.a.Runs.RetryDeferredLaunches(context.Background()); err != nil { + t.Fatal(err) + } + _ = f.a.Store.Get(context.Background(), "run", f.run.ID, &f.run) + if f.run.Status != "failed" || f.run.ResumePending || !f.run.LaunchRetryAt.IsZero() { + t.Fatal("provider failure gained retry authority", f.run) + } + if calls := recordedCalls(t, f.record); len(calls) != 2 { + t.Fatal("provider failure was replayed", len(calls)) + } +} + +func TestDeferredAutomaticLaunchesSerializeAndContinueAfterCompletion(t *testing.T) { + f := deferredReviewFixture(t, true) + ctx := context.Background() + other := f.pr + other.ID, other.Number, other.URL = "owner/repo#3", 3, "https://github.com/owner/repo/pull/3" + importOne(t, f.a, ctx, other) + var repo domain.Repository + if err := f.a.Store.Get(ctx, "repository", other.Repository, &repo); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("git", "-C", repo.Checkout, "push", "origin", other.Head+":refs/pull/3/head").CombinedOutput(); err != nil { + t.Fatalf("local PR head: %s %v", out, err) + } + if err := f.a.ProcessReviewRequests(ctx); err != nil { + t.Fatal(err) + } + claims, err := store.List[reviewClaim](ctx, f.a.Store, "review-claim") + if err != nil || len(claims) != 2 { + t.Fatal("second review was not accepted", claims, err) + } + var second domain.Run + for _, claim := range claims { + if claim.ItemID == other.ID { + if err := f.a.Store.Get(ctx, "run", claim.RunID, &second); err != nil { + t.Fatal(err) + } + } + } + waitFor(t, func() bool { _ = f.a.Store.Get(ctx, "run", second.ID, &second); return second.Status == "deferred" }) + f.expire(t) + second.LaunchRetryAt = f.run.LaunchRetryAt + if err := f.a.Store.Put(ctx, "run", second.ID, second, ""); err != nil { + t.Fatal(err) + } + f.budget.Store(100) + var wg sync.WaitGroup + for n := 0; n < 10; n++ { + wg.Add(1) + go func() { + defer wg.Done() + if err := f.a.Runs.RetryDeferredLaunches(ctx); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + var firstRunning, waiting domain.Run + waitFor(t, func() bool { + _ = f.a.Store.Get(ctx, "run", f.run.ID, &f.run) + _ = f.a.Store.Get(ctx, "run", second.ID, &second) + if f.run.Status == "running" && f.run.TurnID != "" && second.Status == "deferred" { + firstRunning, waiting = f.run, second + return true + } + if second.Status == "running" && second.TurnID != "" && f.run.Status == "deferred" { + firstRunning, waiting = second, f.run + return true + } + return false + }) + if calls := recordedCalls(t, f.record); len(calls) != 2 { + t.Fatal("deferred automatic reviews started concurrently", len(calls)) + } + live, err := f.a.Runs.get(firstRunning.ID) + if err != nil { + t.Fatal(err) + } + f.a.Runs.onMessage(live, codex.Message{Method: "turn/completed", Params: raw(map[string]any{"turn": map[string]string{"id": firstRunning.TurnID, "status": "completed"}})}) + if err := f.a.Runs.RetryDeferredLaunches(ctx); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { + _ = f.a.Store.Get(ctx, "run", waiting.ID, &waiting) + return waiting.Status == "running" && waiting.TurnID != "" + }) + if calls := recordedCalls(t, f.record); len(calls) != 4 { + t.Fatal("waiting automatic review was lost or repeated", len(calls)) + } + if claimCount(t, f.a) != 2 { + t.Fatal("retry changed automatic claims") + } +} diff --git a/internal/app/review_eligibility.go b/internal/app/review_eligibility.go index 01dfd04..f3d2fec 100644 --- a/internal/app/review_eligibility.go +++ b/internal/app/review_eligibility.go @@ -31,6 +31,9 @@ func validatePRReviewRun(get func(string, string, any) error, run domain.Run, it return err } if review { + if run.ThreadID == "" && run.Base != "" && item.Head != run.Base { + return fmt.Errorf("PR head changed from the pinned review target; prepare a new review") + } return validatePRReviewAuthor(get, item) } return nil diff --git a/internal/app/review_requests.go b/internal/app/review_requests.go index b0be21d..f0d1e15 100644 --- a/internal/app/review_requests.go +++ b/internal/app/review_requests.go @@ -233,14 +233,18 @@ func (a *App) ProcessReviewRequests(ctx context.Context) error { } func validateAutomaticStart(t *store.Tx, run domain.Run) error { - var repo domain.Repository - var config domain.Config var item domain.Item - var viewer domain.GitHubViewer - var profile domain.Profile if err := t.Get("item", run.ItemID, &item); err != nil { return err } + return validateAutomaticPolicy(t, run, item, true) +} + +func validateAutomaticPolicy(t *store.Tx, run domain.Run, item domain.Item, requireSync bool) error { + var repo domain.Repository + var config domain.Config + var viewer domain.GitHubViewer + var profile domain.Profile if err := t.Get("repository", item.Repository, &repo); err != nil { return err } @@ -253,7 +257,7 @@ func validateAutomaticStart(t *store.Tx, run domain.Run) error { if err := t.Get("profile", run.Profile.ID, &profile); err != nil { return err } - if repo.Paused || repo.Error != "" || !reviewFresh(config, repo, item) || !autoReviewEnabled(config, repo) || !requestedReview(item, viewer) || item.Head != run.Base || repo.ReviewPrompt != run.ReviewPromptSnapshot || reviewProfileID(config, repo) != run.Profile.ID || string(raw(profile)) != string(raw(run.Profile)) || !reviewProfileReady(profile) { + if repo.Paused || requireSync && (repo.Error != "" || !reviewFresh(config, repo, item)) || !autoReviewEnabled(config, repo) || !requestedReview(item, viewer) || item.Head != run.Base || repo.ReviewPrompt != run.ReviewPromptSnapshot || reviewProfileID(config, repo) != run.Profile.ID || string(raw(profile)) != string(raw(run.Profile)) || !reviewProfileReady(profile) { return fmt.Errorf("automatic review policy or target changed; inspect the retained launch") } return nil diff --git a/internal/app/runs.go b/internal/app/runs.go index 7bb2cd0..da0be0d 100644 --- a/internal/app/runs.go +++ b/internal/app/runs.go @@ -96,6 +96,9 @@ func (m *Manager) Recover(ctx context.Context) error { acceptedTaskStarts := map[string]bool{} attached := map[string]bool{} for _, r := range runs { + if err := m.recoverDeferredLaunch(ctx, r); err != nil { + return err + } if r.ResumePending && !r.InterruptRequested && r.TurnID == "" && (r.Status == "queued" || r.Status == "starting" || r.Status == "running") { acceptedTaskStarts[r.ID] = true @@ -307,7 +310,7 @@ func (m *Manager) Recover(ctx context.Context) error { id := run.ID m.background(func() { m.recheckAmbiguous(id, "", true) }) } - return nil + return m.RetryDeferredLaunches(ctx) } func (m *Manager) Close() { m.mu.Lock() @@ -597,16 +600,19 @@ func (m *Manager) startRun(ctx context.Context, id string, resume bool, message if err := t.Get("item", r.ItemID, &item); err != nil { return err } - if err := validatePRReviewRun(t.Get, r, item); err != nil { - return err + deferred := recovering && r.Status == "deferred" && r.ResumePending && !r.InterruptRequested && !time.Now().Before(r.LaunchRetryAt) + if !deferred { + if err := validatePRReviewRun(t.Get, r, item); err != nil { + return err + } } - if r.AutomaticReview && !resume { + if r.AutomaticReview && !resume && !deferred { if err := validateAutomaticStart(t, r); err != nil { return err } } detachedIdle := r.Status == "idle" && (old == nil || old.disconnected || old.retired) - if (!resume && r.Status != "prepared" && !(recovering && r.Status == "interrupted" && r.ResumePending)) || (resume && r.Status != "interrupted" && r.Status != "failed" && !(message != "" && detachedIdle)) { + if (!resume && r.Status != "prepared" && !(recovering && r.Status == "interrupted" && r.ResumePending) && !deferred) || (resume && r.Status != "interrupted" && r.Status != "failed" && !(message != "" && detachedIdle)) { return fmt.Errorf("run cannot start from %s", r.Status) } if resume && message != "" && recovery == nil && r.ThreadID == "" { @@ -640,6 +646,7 @@ func (m *Manager) startRun(ctx context.Context, id string, resume bool, message r.TurnID = "" } r.Error = "" + r.LaunchRetryAt = time.Time{} r.ResumePending = recovery == nil r.InterruptRequested = false if e := t.Put("run", id, r); e != nil { @@ -783,6 +790,7 @@ func (m *Manager) fail(s *session, e error) { s.run.Status = "failed" s.run.Error = e.Error() s.run.ResumePending = false + s.run.LaunchRetryAt = time.Time{} if s.recovery != nil && automaticRecovery(s.recovery) { s.recovery.DiagnosticError = clarificationUnavailable m.fallbackLocked(s, s.recovery) @@ -824,6 +832,9 @@ func (m *Manager) launch(s *session, resume bool, message string) { source, sourceContext, e = m.continuationContext(ctx, r) } if e != nil { + if message == "" && m.deferLaunch(s, e) { + return + } m.fail(s, e) return } @@ -837,6 +848,16 @@ func (m *Manager) launch(s *session, resume bool, message string) { } return } + if e := m.validateLaunchPolicy(ctx, r, *source); e != nil { + m.fail(s, e) + return + } + s.mu.Lock() + stopped := s.retired || m.ctx.Err() != nil + s.mu.Unlock() + if stopped { + return + } if _, e := codex.Version(ctx, m.CodexBinary()); e != nil { m.fail(s, e) return @@ -888,6 +909,12 @@ func (m *Manager) launch(s *session, resume bool, message string) { s.mu.Unlock() return } + // Recheck current policy after sandbox preparation, before creating a provider. + if e := m.validateLaunchPolicy(ctx, r, *source); e != nil { + s.mu.Unlock() + m.fail(s, e) + return + } c, e := codex.Start(s.ctx, m.CodexBinary(), sandboxArgs, func(msg codex.Message) { m.onMessage(s, msg) }) if e != nil { s.mu.Unlock() @@ -1465,12 +1492,22 @@ func (m *Manager) Interrupt(ctx context.Context, id string) error { } defer unlock() if s.clientless { + var r domain.Run + if err := m.app.Store.Get(ctx, "run", id, &r); err != nil { + return err + } + if r.Status == "deferred" { + r.Status, r.ResumePending, r.InterruptRequested = "interrupted", false, true + r.LaunchRetryAt = time.Time{} + s.run = r + return m.app.Store.Put(ctx, "run", id, r, "run.state") + } return m.cancelUnattachedRecovery(ctx, id) } s.ownerRevision++ m.cancelRecoveryLocked(s) if s.run.TurnID == "" || s.client == nil { - if s.recovery != nil && s.recovery.Stage == "cancelled" { + if s.run.Status == "deferred" || s.run.Status == "queued" || s.run.Status == "starting" || s.recovery != nil && s.recovery.Stage == "cancelled" { s.retired = true if s.cancel != nil { s.cancel() @@ -1478,7 +1515,8 @@ func (m *Manager) Interrupt(ctx context.Context, id string) error { if s.client != nil { _ = s.client.Close() } - s.run.Status = "interrupted" + s.run.Status, s.run.ResumePending, s.run.InterruptRequested = "interrupted", false, true + s.run.LaunchRetryAt = time.Time{} return m.persist(s, "run.state") } return nil @@ -1776,7 +1814,7 @@ func (m *Manager) Cleanup(ctx context.Context, id string) error { if e := m.app.Store.Get(ctx, "run", id, &r); e != nil { return e } - if r.Status != "interrupted" && r.Status != "failed" && r.Status != "idle" && r.Status != "terminated" { + if r.Status != "interrupted" && r.Status != "failed" && r.Status != "idle" && r.Status != "terminated" && r.Status != "deferred" { return fmt.Errorf("run must be stopped before cleanup") } if s != nil { @@ -1807,6 +1845,7 @@ func (m *Manager) Cleanup(ctx context.Context, id string) error { r.Status = "interrupted" } r.ResumePending = false + r.LaunchRetryAt = time.Time{} r.InterruptRequested = true if e := m.app.Store.Put(ctx, "run", id, r, "run.state"); e != nil { return e diff --git a/internal/app/tracking.go b/internal/app/tracking.go index 306c918..4ec345a 100644 --- a/internal/app/tracking.go +++ b/internal/app/tracking.go @@ -133,7 +133,7 @@ func LatestRun(runs []domain.Run) *domain.Run { return fallback } func runSummary(r domain.Run) map[string]any { - return map[string]any{"id": r.ID, "itemId": r.ItemID, "profileId": r.Profile.ID, "profileName": r.Profile.Name, "status": r.Status, "error": r.Error, "threadId": r.ThreadID, "turnId": r.TurnID, "created": r.Created, "base": r.Base, "resumePending": r.ResumePending, "interruptRequested": r.InterruptRequested} + return map[string]any{"id": r.ID, "itemId": r.ItemID, "profileId": r.Profile.ID, "profileName": r.Profile.Name, "status": r.Status, "error": r.Error, "threadId": r.ThreadID, "turnId": r.TurnID, "created": r.Created, "base": r.Base, "resumePending": r.ResumePending, "interruptRequested": r.InterruptRequested, "launchRetryAt": r.LaunchRetryAt} } func (a *App) TrackWork(ctx context.Context, in TrackInput) (map[string]any, error) { if in.Limit == 0 { diff --git a/internal/app/work.go b/internal/app/work.go index a8e004a..a08aaec 100644 --- a/internal/app/work.go +++ b/internal/app/work.go @@ -100,6 +100,8 @@ func (a *App) projectWorkRecords(ctx context.Context, items []domain.Item, links e.Work.RunIDs = append(e.Work.RunIDs, r.ID) state := "Inactive" switch r.Status { + case "deferred": + state = "Waiting for GitHub" case "queued", "starting": state = "Starting" case "running", "waiting": @@ -160,7 +162,7 @@ func (a *App) projectWorkRecords(ctx context.Context, items []domain.Item, links } func workRank(s string) int { - return map[string]int{"Not started": 0, "Session closed": 1, "Stopped": 1, "Inactive": 2, "Interrupted": 3, "Failed": 4, "Starting": 5, "Active": 6, "Waiting for you": 7}[s] + return map[string]int{"Not started": 0, "Session closed": 1, "Stopped": 1, "Inactive": 2, "Interrupted": 3, "Failed": 4, "Waiting for GitHub": 5, "Starting": 5, "Active": 6, "Waiting for you": 7}[s] } func summary(i domain.Item) domain.PullRequest { if i.PR != nil { diff --git a/internal/domain/types.go b/internal/domain/types.go index a9986de..cf8d336 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -224,6 +224,7 @@ type Run struct { Status string `json:"status"` Error string `json:"error"` ResumePending bool `json:"resumePending"` + LaunchRetryAt time.Time `json:"launchRetryAt"` InterruptRequested bool `json:"interruptRequested"` SourceSnapshot *Item `json:"sourceSnapshot,omitempty"` Created time.Time `json:"created"` diff --git a/web/src/main.tsx b/web/src/main.tsx index 47539f8..0e61700 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -47,17 +47,19 @@ function WorkStatus({ ? "Waiting for you" : !run || run.status === "prepared" ? "Not started" - : ["queued", "starting"].includes(run.status) - ? "Starting" - : ["running", "waiting"].includes(run.status) - ? "Active" - : run.status === "failed" - ? "Failed" - : run.status === "terminated" - ? "Stopped" - : run.status === "cleaned" - ? "Finished" - : "Inactive"); + : run.status === "deferred" + ? "Waiting for GitHub" + : ["queued", "starting"].includes(run.status) + ? "Starting" + : ["running", "waiting"].includes(run.status) + ? "Active" + : run.status === "failed" + ? "Failed" + : run.status === "terminated" + ? "Stopped" + : run.status === "cleaned" + ? "Finished" + : "Inactive"); return ( { let stop = false; let timer: ReturnType; @@ -2144,10 +2150,28 @@ function Agent({ a.blocking)} /> )} - {run.error &&

{run.error}

} + {deferred ? ( +
+

Waiting for GitHub

+ {run.error &&

{run.error}

} + {run.launchRetryAt && ( +

+ Next retry:{" "} + +

+ )} + +
+ ) : run.error ? ( +

{run.error}

+ ) : null} {embedded && (

- {run.profile.name} · {run.status} + {run.profile.name} · {deferred ? "Waiting for GitHub" : run.status}

)}
@@ -2240,6 +2264,7 @@ function Agent({
{ e.preventDefault(); + if (!canMessage || busy || !message.trim()) return; setBusy(true); const submitted = message; try { @@ -2263,6 +2288,7 @@ function Agent({ Message agent