|
| 1 | +# Goal pause during compaction assessment |
| 2 | + |
| 3 | +- Date: 2026-08-25 |
| 4 | +- Status: implemented and verified |
| 5 | +- Severity: medium |
| 6 | +- Confidence: high for v2; high from source for the v1 custom-strategy path |
| 7 | + |
| 8 | +## Verdict |
| 9 | + |
| 10 | +The repeated cancellation bug is real in v2 when auto-compaction can run in the background. The |
| 11 | +recurring cancellation normally comes from the history-safety guard, not from the blocked step's |
| 12 | +abort signal. |
| 13 | + |
| 14 | +The proposed pause/resume fix is directionally correct, but reusing the current goal pause and |
| 15 | +`resumeGoal()` paths is not safe. The fix must preserve the live goal turn, make a below-block-ratio |
| 16 | +goal step wait for compaction, and resume only after a successful compaction with no later user, |
| 17 | +budget, goal, or restart override. |
| 18 | + |
| 19 | +Decision: |
| 20 | + |
| 21 | +- Fix auto-compaction only. Manual compaction already requires loop quiescence. |
| 22 | +- Fix v2 as the active production path. |
| 23 | +- Add v1 parity for custom `compactionStrategy` users, but use a v1-specific continuation launch. |
| 24 | +- Use the existing `goal.updated` TUI marker. Do not add a second goal-state path to the compaction |
| 25 | + component. |
| 26 | + |
| 27 | +## Findings |
| 28 | + |
| 29 | +### F1 — The repeated cancellation path is the unsafe-history guard |
| 30 | + |
| 31 | +When `loopControl.compactionTriggerRatio` is below `0.85`, v2 uses the lower value as the trigger |
| 32 | +ratio and keeps `0.85` as the block ratio. This enables non-blocking after-step checks |
| 33 | +(`packages/agent-core-v2/src/agent/fullCompaction/strategy.ts:62-64,91-100`). |
| 34 | + |
| 35 | +The repeating sequence is: |
| 36 | + |
| 37 | +1. `afterStep()` starts an auto-compaction without waiting for it |
| 38 | + (`fullCompactionService.ts:502-506`). |
| 39 | +2. The goal turn ends and `handleTurnEnded()` enqueues another continuation |
| 40 | + (`goalAgentRuntime.ts:658-691`). |
| 41 | +3. The loop materializes that continuation into context before its next step |
| 42 | + (`loopService.ts:807-821`). Its origin is `system_trigger/goal_continuation` |
| 43 | + (`goalAgentRuntime.ts:761-782`). |
| 44 | +4. The compaction result sees a new non-user tail. `historySafeToCompact()` rejects it |
| 45 | + (`fullCompactionService.ts:732-738,835-842`) because `system_trigger` messages are dropped |
| 46 | + (`compactionHandoff.ts:159-182`). |
| 47 | +5. Compaction emits `compaction.cancelled`. The still-large goal turn reaches the same boundary and |
| 48 | + starts the next compaction. |
| 49 | + |
| 50 | +A temporary contract test used the real v2 loop, goal, context, and compaction services and stubbed |
| 51 | +only the model boundary. It forced the same after-step `source: 'auto'` seam, observed three |
| 52 | +compaction attempts and two consecutive cancellations, and passed. The temporary file was removed |
| 53 | +after evidence capture. |
| 54 | + |
| 55 | +This path does not require the step signal to abort. `propagateBlockingAbort()` |
| 56 | +(`fullCompactionService.ts:551-558`) is a separate cancellation source for a compaction that is |
| 57 | +already blocking a step. |
| 58 | + |
| 59 | +### F2 — The default blocking path does not create the reported loop by itself |
| 60 | + |
| 61 | +The default trigger and block ratios are both `0.85`. `beforeStep()` starts compaction and waits in |
| 62 | +`block()` before the model step can continue (`fullCompactionService.ts:494-499,535-549`). Context |
| 63 | +overflow recovery also always waits (`fullCompactionService.ts:464-473`). The turn cannot end and |
| 64 | +the goal cannot enqueue another continuation while that wait is unresolved. |
| 65 | + |
| 66 | +If an external cancel, deadline, shutdown, or user interrupt aborts that step, the abort listener can |
| 67 | +cancel compaction. The same turn then ends abnormally and the goal runtime pauses it |
| 68 | +(`goalAgentRuntime.ts:719-735`). That is not the recurring continuation loop. |
| 69 | + |
| 70 | +Current regression evidence supports this distinction: the existing active-goal/default-compaction |
| 71 | +test completes successfully and reinjects the goal reminder before the post-compaction request. |
| 72 | + |
| 73 | +### F3 — A normal goal pause would cancel the compaction |
| 74 | + |
| 75 | +In v2, leaving `active` calls `cancelPendingContinuation()` unless the caller sets |
| 76 | +`preserveLiveContinuation` (`goalAgentRuntime.ts:883-904`). That function aborts the queued receipt |
| 77 | +or cancels its assigned loop turn (`goalAgentRuntime.ts:822-833`). For a blocking goal continuation, |
| 78 | +the turn signal then reaches `propagateBlockingAbort()` and cancels compaction. |
| 79 | + |
| 80 | +The compaction pause therefore needs a dedicated internal transition that uses the existing |
| 81 | +`preserveLiveContinuation` option. Calling the current public `pauseGoal()` or `pauseActiveGoal()` |
| 82 | +unchanged would reproduce the failure. |
| 83 | + |
| 84 | +A status pause is also insufficient when compaction starts in `beforeStep()` below the `0.85` block |
| 85 | +ratio. That same model step can still run and mutate history. The goal's before-step hook must wait |
| 86 | +for the active auto-compaction task before it calls the next hook. Register this ordering explicitly |
| 87 | +after the `full-compaction` hook. |
| 88 | + |
| 89 | +### F4 — Reusing `resumeGoal()` is not robust |
| 90 | + |
| 91 | +The v2 `resumeGoal()` method launches work only for actor `user` with `continueIfPaused` or |
| 92 | +`continueIfBlocked` (`goalAgentRuntime.ts:393-420`). A runtime actor changes the status to `active` |
| 93 | +but can leave an idle goal with no continuation. Pretending the runtime is the user gives incorrect |
| 94 | +telemetry and can set `resumeContinuation`; a later real interruption can then launch another turn |
| 95 | +from the cancelled-turn branch (`goalAgentRuntime.ts:663-672`). |
| 96 | + |
| 97 | +Use an internal compaction-success resume operation. It must consume one transient resume token and |
| 98 | +apply this state table: |
| 99 | + |
| 100 | +A user resume request while compaction is still live must mean "resume after successful compaction." |
| 101 | +It must keep the goal paused and must not launch a turn immediately. |
| 102 | + |
| 103 | +| Finish state | Required result | |
| 104 | +| --- | --- | |
| 105 | +| Success; same goal is still paused for this compaction | Set `active` as actor `runtime`. If a preserved turn is live, launch nothing. If the loop is idle with no pending continuation, launch exactly one continuation. | |
| 106 | +| Compaction cancel or failure | Stay paused. Replace the promise-to-resume reason with a truthful failure reason. | |
| 107 | +| User paused, cancelled, or replaced the goal during compaction | User intent wins. Consume the token and never launch stale work. | |
| 108 | +| A goal budget became final or the goal became blocked/complete | Do not resume. Preserve the newer terminal state. | |
| 109 | +| Process replay after an in-flight compaction | Do not auto-resume. The transient token is gone; replace the stale reason with an agent-restart pause reason. | |
| 110 | + |
| 111 | +The current goal fold only changes `terminalReason` when status changes |
| 112 | +(`goalAgentRuntime.ts:1361-1368`). The fix needs a narrow same-status reason update so user pause, |
| 113 | +compaction failure, and replay cannot leave the text "will resume" after auto-resume was cancelled. |
| 114 | + |
| 115 | +### F5 — v1 and TUI need different scope than proposed |
| 116 | + |
| 117 | +V1 accepts `loopControl.compactionTriggerRatio` in its schema, but its production |
| 118 | +`FullCompaction` constructor applies only `reservedContextSize` |
| 119 | +(`packages/agent-core/src/config/schema.ts:153-159`, |
| 120 | +`packages/agent-core/src/agent/compaction/full.ts:98-112`). Its default trigger and block ratios are |
| 121 | +equal, so normal v1 auto-compaction is synchronous. The reported background loop is reachable only |
| 122 | +through the public custom `compactionStrategy` option (`packages/agent-core/src/agent/index.ts:94,232`). |
| 123 | + |
| 124 | +That custom path has the same unsafe-tail check (`packages/agent-core/src/agent/compaction/full.ts:588-606`), |
| 125 | +but v1 has no independent continuation launcher. After a background compaction pause makes |
| 126 | +`driveGoal()` exit (`packages/agent-core/src/agent/turn/index.ts:471-536`), `resumeGoal()` only changes |
| 127 | +state. V1 must explicitly launch one continuation after successful compaction when no turn is active. |
| 128 | + |
| 129 | +The current TUI paths are under `apps/pythinker-code`, not the upstream app path. The live |
| 130 | +`goal.updated` handler already renders lifecycle markers (`session-event-handler.ts:751-801`). A |
| 131 | +pause reason with the `Paused` prefix renders as `Goal paused ...` |
| 132 | +(`components/messages/goal-markers.ts:153-159`), and `/goal status` also shows `terminalReason` |
| 133 | +(`components/messages/goal-panel.ts:131-167`). This already supplies the required feedback: |
| 134 | + |
| 135 | +> Goal paused because context compaction is in progress; it will resume after compaction completes |
| 136 | +
|
| 137 | +`CompactionComponent` can remain the generic compaction progress block. It does not receive reliable |
| 138 | +goal state in `compaction.started`, so adding combined copy there would duplicate lifecycle state. |
| 139 | + |
| 140 | +## Minimum v2 remediation contract |
| 141 | + |
| 142 | +1. Store a transient compaction-pause token with `goalId`. Set it synchronously on auto-compaction |
| 143 | + start, then perform an awaited, re-entrant-safe durable pause with actor `runtime` and |
| 144 | + `preserveLiveContinuation: true`. |
| 145 | +2. Gate every continuation launch while the token exists. In the goal before-step hook, wait for the |
| 146 | + active auto-compaction task after the `full-compaction` hook so a below-block-ratio step cannot |
| 147 | + race the summary. |
| 148 | +3. Use the compaction task promise as the authoritative outcome. Resume only on promise success; |
| 149 | + cancellation and failure remain paused. |
| 150 | +4. Add an internal guarded resume. Recheck goal ID, exact pause cause, current status, budget, live |
| 151 | + turn, pending receipt, and loop idle state. Consume the token before any launch. |
| 152 | +5. Let explicit user pause, cancel, and replace actions suppress automatic resume. A user resume |
| 153 | + request keeps the token but launches nothing until success. Normalize a persisted compaction |
| 154 | + pause after replay to a non-resuming reason. Support same-status reason replacement in the |
| 155 | + durable goal fold. |
| 156 | + |
| 157 | +Recommended constants: |
| 158 | + |
| 159 | +- Live pause: `Paused because context compaction is in progress; it will resume after compaction completes` |
| 160 | +- Failed finish: `Paused because context compaction did not complete` |
| 161 | +- Restart: `Paused because context compaction was interrupted by agent restart` |
| 162 | + |
| 163 | +## Required regression tests |
| 164 | + |
| 165 | +1. V2 after-step background auto-compaction pauses the goal, starts no continuation while running, |
| 166 | + completes once, resumes, and launches exactly one continuation. |
| 167 | +2. V2 before-step auto-compaction below the block ratio prevents the goal model request until |
| 168 | + compaction settles, then continues the preserved turn without a duplicate launch. |
| 169 | +3. Cancellation and summarizer failure leave the goal paused with truthful text and no API turn. |
| 170 | +4. User pause/cancel/replace, budget stop, duplicate finish, and process replay never auto-resume stale |
| 171 | + work. |
| 172 | +5. V1 custom-background-strategy parity and TUI pause/resume marker copy pass; v1 default synchronous |
| 173 | + behavior remains unchanged. |
| 174 | + |
| 175 | +## Implementation result |
| 176 | + |
| 177 | +V2 now pauses through the official `onWillCompact` hook and also observes the service's active task |
| 178 | +from its ordered before-step and after-step gates. The second path closes a hook-scheduling race in |
| 179 | +which another step hook can start compaction before the goal hook runs. The pause is durable, but its |
| 180 | +task identity and automatic-resume intent remain transient. Successful settlement rechecks goal ID, |
| 181 | +pause reason, status, budget, live turn, pending work, and loop idleness before resuming. |
| 182 | + |
| 183 | +V1 exposes start/finish task events from `FullCompaction`, pauses `GoalMode`, and waits at both turn |
| 184 | +step boundaries. The existing v1 goal driver continues its preserved turn after success. Both |
| 185 | +engines keep failures paused, defer an explicit resume until success, suppress stale resume after a |
| 186 | +user action, and replace a persisted live-compaction reason after process replay. |
| 187 | + |
| 188 | +The TUI uses the existing `goal.updated` lifecycle marker. It renders the required pause reason and |
| 189 | +does not duplicate goal state inside the generic compaction component. |
| 190 | + |
| 191 | +## Evidence run |
| 192 | + |
| 193 | +- RED: the v1 and v2 production-path tests first observed an active goal during compaction. The v1 |
| 194 | + reminder test also observed a stale paused reminder before the post-compaction active reminder. |
| 195 | +- Focused GREEN: v2 coordination 6/6, v2 goal 114/114, v2 goal operations 12/12, v1 compaction |
| 196 | + 63 passed with 1 skipped, v1 goal/injection/tools 66/66, and TUI goal markers 10/10. |
| 197 | +- Full GREEN: `packages/agent-core-v2` 347 files and 5,661 tests; `packages/agent-core` 228 files, |
| 198 | + 4,171 passed, 3 expected failures, 30 skipped, and 1 todo. |
| 199 | +- Static gates: v1/v2 `tsc` and `tsgo`, v2 import lint, repository no-comment check, root lint, and |
| 200 | + `git diff --check` exit 0. Root lint reports existing warnings and no errors. |
0 commit comments