Browse Source

docs: update codemaps for foreground-fallback grace window and fallback coordination

- foreground-fallback: retry-without-abort flow, wasFallbackRecent/grace window,
  inProgress clarification
- task-session-manager: wasFallbackRecent/isFallbackInProgress/onJobTerminal
  callbacks, early session.created registration, phantom job cleanup,
  fallback coordination section (issue #765)
Michael Henke 2 weeks ago
parent
commit
e14c1ad790
2 changed files with 43 additions and 12 deletions
  1. 17 7
      src/hooks/foreground-fallback/codemap.md
  2. 26 5
      src/hooks/task-session-manager/codemap.md

+ 17 - 7
src/hooks/foreground-fallback/codemap.md

@@ -16,7 +16,8 @@ Runtime model fallback system for foreground (interactive) agent sessions. When
   - `sessionModel`: Maps sessionID → current model string ("providerID/modelID")
   - `sessionAgent`: Maps sessionID → agent name
   - `sessionTried`: Maps sessionID → Set of models already attempted
-  - `inProgress`: Set of sessions with active fallback in flight
+  - `inProgress`: Set of sessions with active fallback in flight (strict — cleared in `finally` so the genuine fallback-response idle still reconciles)
+  - `lastFallbackAt`: Maps sessionID → timestamp of last accepted re-prompt; `wasFallbackRecent()` returns true within `FALLBACK_GRACE_WINDOW_MS` (5s), letting task-session-manager suppress spurious abort-induced cancellations without blocking the genuine idle (issue #765)
   - `lastTrigger`: Maps sessionID → timestamp for deduplication
 
 ### Fallback Chain Resolution
@@ -37,7 +38,9 @@ Runtime model fallback system for foreground (interactive) agent sessions. When
 ### State Management
 - **Deduplication window**: 5-second cooldown (`DEDUP_WINDOW_MS`) to prevent multiple triggers for same rate-limit event
 - **Session cleanup**: `session.deleted` event handler removes all per-session state to prevent memory leaks
-- **In-progress tracking**: Prevents concurrent fallback attempts on same session
+- **In-progress tracking**: Prevents concurrent fallback attempts on same session (`inProgress`, cleared in `finally`)
+- **Grace window**: `wasFallbackRecent()` stays true for 5s after a successful re-prompt — distinct from the strict in-flight flag — so late abort-induced `session.deleted` and cancelled/error status are suppressed while the genuine fallback-response idle still reconciles (issue #765)
+- **Retry-without-abort**: When `promptAsync` throws on a busy session (original rate-limited response still in flight), retries up to `FALLBACK_REPROMPT_RETRIES` (3) without aborting. Abort is kept as last resort only (avoids "Task cancelled" in parent, issue #765)
 
 ## Flow
 
@@ -49,17 +52,22 @@ ForegroundFallbackManager.handleEvent()
 Retryable error detection via isRetryableError()
-tryFallback(sessionID) [deduplicated, in-progress guarded]
+tryFallback(sessionID) or tryFallbackWithAbort(sessionID)
+    [deduplicated, in-progress guarded]
-Resolve fallback chain for session
+Abort current rate-limited prompt (with timeout) [tryFallbackWithAbort only]
-Abort current rate-limited prompt (with timeout)
+Resolve fallback chain for session
 Retrieve last user message from session history
-Re-prompt session with next model via promptAsync()
+execFallback(sessionID)
+  ├─ Re-prompt with next model via promptAsync()
+  │   (busy session: retry up to 3x without abort; abort only as last resort)
+  ├─ markFallbackDone(sessionID) — arms 5s grace window
+  └─ Update session state with new model
-Update session state with new model
+tryFallback/tryFallbackWithAbort finally: clear inProgress
 Log fallback event
 ```
@@ -69,6 +77,8 @@ Log fallback event
 2. **Message retrieval**: Queries session messages via `client.session.messages()` and finds last user message
 3. **Model switching**: Uses `parseModelReference()` to extract providerID/modelID from chain entry
 4. **Re-prompting**: Calls `promptAsync()` which queues prompt and returns immediately (non-blocking)
+5. **Retry-without-abort**: When `promptAsync` throws on a busy session, retries up to `FALLBACK_REPROMPT_RETRIES` (3) without aborting. Abort is last-resort only if the session never settles — avoids triggering "Task cancelled" in the parent orchestrator (issue #765)
+6. **Grace window**: `markFallbackDone()` records the time of successful re-prompt; `wasFallbackRecent()` exposes a 5s window for task-session-manager to suppress abort artifacts
 
 ## Integration
 

+ 26 - 5
src/hooks/task-session-manager/codemap.md

@@ -8,8 +8,8 @@ Manages V2 background job-board state for task execution and injected completion
 
 The directory follows a **Facade + Strategy** pattern where `index.ts` acts as the facade that composes and orchestrates behavior across three specialized strategy modules:
 
-- **index.ts**: Main facade that wires hooks into OpenCode's lifecycle and coordinates between the job board, pending calls, and task context tracking. Implements the plugin hook interface (`tool.execute.before`, `tool.execute.after`, `experimental.chat.messages.transform`, `event`).
-- **pending-call-tracker.ts**: Tracks in-flight task calls using a capped ordered map (`MAX_PENDING_TASK_CALLS`) to correlate launch output safely. Provides call ID generation, storage, retrieval, and cleanup for pending task invocations.
+- **index.ts**: Main facade that wires hooks into OpenCode's lifecycle and coordinates between the job board, pending calls, and task context tracking. Implements the plugin hook interface (`tool.execute.before`, `tool.execute.after`, `experimental.chat.messages.transform`, `event`). Accepts `wasFallbackRecent`, `isFallbackInProgress`, and `onJobTerminal` callbacks for coordination with `foreground-fallback`.
+- **pending-call-tracker.ts**: Tracks in-flight task calls using a capped ordered map (`MAX_PENDING_TASK_CALLS`) to correlate launch output safely. Provides call ID generation, storage, `findByParent`, `peekByParent` (used for early `session.created` registration, issue #765), and cleanup.
 - **task-context-tracker.ts**: Manages read context from child sessions with line-count and file caps. Stores context per task ID and provides pruning to prevent unbounded growth.
 
 All modules depend on `BackgroundJobBoard` from `src/utils/background-job-board.ts` as the single source of truth for active jobs, terminal unreconciled jobs, reusable completed sessions, aliases, read context, and LRU caps.
@@ -36,8 +36,10 @@ All modules depend on `BackgroundJobBoard` from `src/utils/background-job-board.
 2. **Task Launch (`tool.execute.after`)**
    - Registers task launches in the job board with task ID, parent session ID, agent type, and description
    - Parses task output to extract task ID, status, or launch information
+   - Suppresses spurious cancelled/error status within the fallback grace window (issue #765)
    - Adds read context to the job board for completed or terminal unreconciled tasks
    - Handles late-cancelled tasks by normalizing output and updating state accordingly
+   - Fires `onJobTerminal` when a task reaches a terminal state
 
 3. **Context Tracking**
    - Extracts read files from `read` tool outputs using `extractReadFiles`
@@ -46,24 +48,40 @@ All modules depend on `BackgroundJobBoard` from `src/utils/background-job-board.
 
 4. **Message Injection (`experimental.chat.messages.transform`)**
    - Injects a `<system-reminder>` part containing the `### Background Job Board` section into user messages for managed sessions
+   - Updates existing board parts in-place (replaces text on existing synthetic parts rather than creating duplicates)
    - Lists active, unreconciled, and reusable sessions
    - Remembers injected terminal jobs to reconcile them on parent idle events
+   - Fires `onJobTerminal` when an injected completion makes a job terminal
 
 5. **Lifecycle Events (`event`)**
-   - `session.created`: Adds new task IDs to pending managed set
-   - `session.idle` / `session.status` (idle): Reconciles injected terminal jobs for the parent session
+   - `session.created`: Adds new task IDs to pending managed set; performs early board registration via `peekByParent` when a `parentID` is present (issue #765)
+   - `session.idle` / `session.status` (idle): Reconciles injected terminal jobs for the parent session; skips idle reconciliation when `isFallbackInProgress`; fires `onJobTerminal` for terminal jobs
    - `session.status` (busy): Marks sessions as running from live session state
-   - `session.deleted`: Clears job state, child jobs, and pending call records for the session
+   - `session.status` (injected completion): Suppresses cancelled/error status within the fallback grace window; fires `onJobTerminal` for terminal state transitions
+   - `session.deleted`: Clears job state, child jobs, pending call records, and `terminalJobNotified` entries
+   - Phantom job cleanup: When `isFallbackInProgress`, drops the single phantom running job (exactly one other running job for the parent) during `session.created` nets
 
 ### Data & Control Flow
 
 ```
 User task call → tool.execute.before → PendingTaskCall created → task ID resolved/reused
+→ session.created → early board registration via peekByParent (issue #765)
 → tool.execute.after → BackgroundJobBoard.registerLaunch() → context extracted/added
+    │  (suppress cancelled/error within fallback grace window)
+    └─ onJobTerminal(parentSessionID) if terminal
 → Message transform → BackgroundJobBoard.formatForPrompt() injected as a system-reminder message part
+    │  (update existing board parts in-place; fire onJobTerminal for new terminals)
 → session.idle → reconcileInjectedTerminalJobs() → BackgroundJobBoard.markReconciled()
+    │  (skip if isFallbackInProgress; fire onJobTerminal)
+→ session.deleted → cleanup all per-session state
 ```
 
+### Fallback Coordination (issue #765)
+The hook coordinates with `foreground-fallback` via three callbacks:
+1. **`isFallbackInProgress(sessionID)`**: When true, idle events for the parent and children are skipped, and phantom jobs are cleaned up during `session.created` nets
+2. **`wasFallbackRecent(sessionID)`**: When true (within 5s of `markFallbackDone`), cancelled/error status updates from the abort are suppressed in both injected-completion and `tool.execute.after` paths — the job stays running so the genuine fallback-response idle can reconcile it
+3. **`onJobTerminal(parentSessionID)`**: Fires when a background job reaches terminal state via injected completion, `tool.execute.after`, or `session.idle` reconciliation — prompts the parent orchestrator with `(Background job completed.)` so it re-evaluates even when no child idle fires
+
 ## Integration
 
 ### Consumers
@@ -82,6 +100,9 @@ User task call → tool.execute.before → PendingTaskCall created → task ID r
 - `readContextMinLines`: Minimum lines to include in read context
 - `readContextMaxFiles`: Maximum files to include in read context
 - `shouldManageSession`: Predicate to determine which sessions are managed by this hook
+- `wasFallbackRecent?`: Callback to check if a fallback re-prompt was accepted within the grace window (5s). When true, cancelled/error status updates are suppressed (issue #765)
+- `isFallbackInProgress?`: Callback to check if a session is mid-fallback. When true, idle events are skipped and phantom jobs are cleaned up
+- `onJobTerminal?`: Callback fired when a background job reaches a terminal state; nudges the parent orchestrator via `promptAsync` to re-evaluate
 
 ### Events & Hooks