|
| 1 | +# Durable Asks & Incidents |
| 2 | + |
| 3 | +*Design document for fixes to GitHub issues #200 (PM silent in AWAIT_USER) and #201 (architect silent in MONITORING).* |
| 4 | + |
| 5 | +## Problem Statement |
| 6 | + |
| 7 | +Maestro goes silent for hours when work stalls. Two root causes: |
| 8 | + |
| 9 | +1. **PM memory loss (#200):** PM sends ACTION REQUIRED via `chat_ask_user`, transitions to AWAIT_USER, and waits indefinitely. When the user eventually responds, PM has no structured memory of what's pending, so it gives stale or incorrect status. |
| 10 | + |
| 11 | +2. **Architect silence (#201):** When all coders die (watchdog kills) or stall, the architect loops in MONITORING every 30s checking for messages that will never arrive. No escalation to PM or user. |
| 12 | + |
| 13 | +## Design Principle |
| 14 | + |
| 15 | +"Waiting on user" should be a durable product-state object, not a timer problem. Timers compensate for missing memory; durable asks and incidents solve the actual bug. |
| 16 | + |
| 17 | +Timers are intentionally not part of this design. Edge-triggered communication (one notification per incident open/close) replaces polling and reminders. |
| 18 | + |
| 19 | +--- |
| 20 | + |
| 21 | +## Data Model |
| 22 | + |
| 23 | +### Incident |
| 24 | + |
| 25 | +An **incident** is an architect-owned operational blocker. The architect is the sole authority for opening and closing incidents. |
| 26 | + |
| 27 | +```go |
| 28 | +type Incident struct { |
| 29 | + ID string // incident-{kind}-{storyID|system}-{failureID|timestamp} |
| 30 | + Kind IncidentKind // story_blocked | clarification_needed | system_idle |
| 31 | + Scope string // "story" | "system" |
| 32 | + StoryID string // set for story-scoped incidents |
| 33 | + FailureID string // cross-reference to failure record |
| 34 | + Title string |
| 35 | + Summary string |
| 36 | + AffectedStoryIDs []string // for system_idle: which stories are stuck |
| 37 | + AllowedActions []IncidentAction // advisory in Phase 1 |
| 38 | + Blocking bool |
| 39 | + OpenedAt string |
| 40 | + ResolvedAt string |
| 41 | + Resolution string // how it was resolved |
| 42 | +} |
| 43 | +``` |
| 44 | + |
| 45 | +**Incident kinds:** |
| 46 | +- `story_blocked` — Story abandoned after exhausting retries. Scoped to a single story. |
| 47 | +- `clarification_needed` — Failure requires human input (credentials, unclear requirements). Scoped to a story. |
| 48 | +- `system_idle` — No active coders making progress but pending work exists. Scoped to system. |
| 49 | + |
| 50 | +### UserAsk |
| 51 | + |
| 52 | +A **UserAsk** is a PM-owned conversational obligation. Created when PM calls `chat_ask_user`, resolved when the user responds. |
| 53 | + |
| 54 | +```go |
| 55 | +type UserAsk struct { |
| 56 | + ID string // ask-{kind}-{timestamp} |
| 57 | + Prompt string |
| 58 | + Kind string // "interview_question" | "clarification" | "decision_required" |
| 59 | + RelatedIncidentID string // optional link to triggering incident |
| 60 | + OpenedAt string |
| 61 | + ResolvedAt string |
| 62 | +} |
| 63 | +``` |
| 64 | + |
| 65 | +**Constraint:** At most one active ask at a time. A new ask implicitly supersedes any prior unresolved ask. |
| 66 | + |
| 67 | +### IncidentAction |
| 68 | + |
| 69 | +Recovery actions a user can take. Advisory metadata in Phase 1. |
| 70 | + |
| 71 | +- `try_again` — Retry the failed story with same or edited content |
| 72 | +- `change_request` — Modify story requirements before retry |
| 73 | +- `skip` — Abandon the story permanently |
| 74 | +- `resume` — Signal that the blocking condition has been resolved externally |
| 75 | + |
| 76 | +--- |
| 77 | + |
| 78 | +## Ownership Rules |
| 79 | + |
| 80 | +These rules are the heart of the design: |
| 81 | + |
| 82 | +1. **Asks are PM-owned.** PM creates asks; user replies resolve asks. |
| 83 | +2. **Incidents are architect-owned.** Architect opens incidents; architect-side recovery closes incidents. |
| 84 | +3. **User replies do NOT automatically resolve incidents.** A user chatting with PM may provide information that PM relays to the architect, but the incident closes only when the architect observes recovery (story requeued, hold released, coder becomes active). |
| 85 | +4. **Architect does NOT create or resolve asks.** The PM decides when to ask the user for input. |
| 86 | + |
| 87 | +--- |
| 88 | + |
| 89 | +## Lifecycle Rules |
| 90 | + |
| 91 | +### Incident Lifecycle |
| 92 | + |
| 93 | +**Opening triggers:** |
| 94 | +| Kind | Trigger | Location | |
| 95 | +|------|---------|----------| |
| 96 | +| `story_blocked` | Story abandoned (exhausted retries, `!willRetry`) | `notifyPMOfBlockedStory` in `request.go` | |
| 97 | +| `clarification_needed` | Failure requires human input | `notifyPMOfClarificationNeeded` in `request.go` | |
| 98 | +| `system_idle` | No active coders + pending work + idle > 60s | `checkAndOpenIdleIncident` in `monitoring.go` | |
| 99 | + |
| 100 | +**Closing triggers:** |
| 101 | +| Kind | Trigger | Resolution value | |
| 102 | +|------|---------|-----------------| |
| 103 | +| `story_blocked` | Story status no longer `failed`/`on_hold` | `"story_requeued"` | |
| 104 | +| `clarification_needed` | Hold released via `repair_complete` | `"manual"` | |
| 105 | +| `system_idle` | Idle predicate false (coder becomes active) | `"work_resumed"` | |
| 106 | +| Any | All stories terminal | `"all_terminal"` | |
| 107 | + |
| 108 | +**Idle detection specifics:** |
| 109 | +- **Opening** uses a 60s debounce guard (2 heartbeats) to avoid false positives during dispatch transitions. |
| 110 | +- **Closing** is predicate-based only — no timing guard. Once work resumes, close immediately. |
| 111 | +- The `monitoringIdleSince` timestamp is a debounce guard, not durable business state. It is not persisted. |
| 112 | + |
| 113 | +### UserAsk Lifecycle |
| 114 | + |
| 115 | +**Opening:** PM calls `chat_ask_user` → `SignalAwaitUser` handler creates `UserAsk`. |
| 116 | + |
| 117 | +**Closing:** User sends a chat message while PM is in AWAIT_USER → current ask resolved. |
| 118 | + |
| 119 | +**Supersession:** If PM issues a new `chat_ask_user` before the prior ask is resolved, the new ask replaces the old one. Only one ask can be active. |
| 120 | + |
| 121 | +--- |
| 122 | + |
| 123 | +## Communication Pattern |
| 124 | + |
| 125 | +Incidents use edge-triggered, not level-triggered, communication: |
| 126 | + |
| 127 | +1. Architect opens incident → sends `incident_opened` payload to PM (once) |
| 128 | +2. PM stores incident in `openIncidents` and injects context into LLM |
| 129 | +3. Architect closes incident → sends `incident_resolved` payload to PM (once) |
| 130 | +4. PM removes incident from `openIncidents` |
| 131 | + |
| 132 | +No polling, no reminders, no timers. The PM's `maybeInjectPendingItemsSummary()` re-injects the summary only when the digest changes (hash-based deduplication), preventing context bloat from `handleWorking()` re-entry loops. |
| 133 | + |
| 134 | +--- |
| 135 | + |
| 136 | +## Persistence |
| 137 | + |
| 138 | +Both asks and incidents must survive process restart: |
| 139 | + |
| 140 | +- **PM:** `currentAsk` and `openIncidents` serialized as JSON in `PMState` (explicit DB columns via schema migration). |
| 141 | +- **Architect:** `openIncidents` serialized as JSON in `ArchitectState` (explicit DB column). |
| 142 | +- **State data mirroring:** Runtime state is also mirrored into state data keys (`StateKeyCurrentAsk`, `StateKeyOpenIncidents`) for FSM visibility through existing inspection tools. |
| 143 | + |
| 144 | +--- |
| 145 | + |
| 146 | +## Phase Boundaries |
| 147 | + |
| 148 | +### Phase 1 (this implementation) |
| 149 | + |
| 150 | +- Durable `UserAsk` and `Incident` models |
| 151 | +- `incident_opened` / `incident_resolved` payload kinds |
| 152 | +- Architect incident lifecycle (open on story_blocked/clarification, system_idle detection, reconciliation) |
| 153 | +- PM durable ask (singular, created on `chat_ask_user`, resolved on user reply) |
| 154 | +- PM mirrored incidents (stored on `incident_opened`, removed on `incident_resolved`) |
| 155 | +- Pending items summary injection with hash-based change detection |
| 156 | +- Persistence via explicit JSON columns (schema migration v22) |
| 157 | +- `AllowedActions` populated as advisory metadata |
| 158 | +- User acts through natural language via PM; PM routes to architect as needed |
| 159 | + |
| 160 | +### Phase 1.5 — `incident_action` tool with `resume` |
| 161 | + |
| 162 | +Adds a structured `incident_action` PM tool with `resume` as the only supported action. Closes the loop: PM detects incident → tells user → user says retry → PM calls tool → architect recovers. |
| 163 | + |
| 164 | +**Routing:** `PayloadKindIncidentAction` maps to `RequestKindExecution` (no new request kind). |
| 165 | + |
| 166 | +**Resume semantics per incident kind:** |
| 167 | + |
| 168 | +| Kind | Story Status | Recovery Action | |
| 169 | +|------|-------------|-----------------| |
| 170 | +| `system_idle` | N/A | Sweep orphaned dispatched stories (no live coder), resume dispatch | |
| 171 | +| `story_blocked` | `StatusFailed` | `RetryFailedStory` — reset to pending for fresh attempt (preserves attempt count) | |
| 172 | +| `story_blocked` | `StatusOnHold` | Release held stories by failure ID (same as repair_complete path) | |
| 173 | +| `clarification_needed` | `StatusOnHold` | Release held stories by failure ID, resume dispatch | |
| 174 | + |
| 175 | +**Key rules:** |
| 176 | +- Recovery executes *before* incident resolution. If recovery fails, incident stays open and PM gets a failure result. |
| 177 | +- When resuming by failure ID, *all* related incidents for that failure are resolved (not just the clicked one). A single prerequisite failure can open both `story_blocked` and `clarification_needed`. |
| 178 | +- Both PM (tool side) and architect (handler side) validate `resume` ∈ `AllowedActions`. |
| 179 | +- `incident_action_result` RESPONSE payload delivers typed success/failure back to PM. |
| 180 | +- Orphan recovery in `system_idle`: dispatched stories whose assigned coder is no longer active are requeued to pending before re-dispatching. |
| 181 | + |
| 182 | +### Phase 2: Full Action Semantics |
| 183 | + |
| 184 | +Implemented. Four actions on the `incident_action` tool: |
| 185 | + |
| 186 | +| Action | Description | |
| 187 | +|---|---| |
| 188 | +| `resume` | External blocker resolved — release held stories and re-dispatch | |
| 189 | +| `try_again` | Identical to `resume` (PM picks the word that fits the context) | |
| 190 | +| `skip` | Intentionally abandon a story — marks it `StatusSkipped` (new terminal state) | |
| 191 | +| `change_request` | Append user instructions to story content, reset retry budget, requeue | |
| 192 | + |
| 193 | +**Action × incident-kind matrix:** |
| 194 | + |
| 195 | +| Action | `system_idle` | `story_blocked` | `clarification_needed` | |
| 196 | +|---|---|---|---| |
| 197 | +| `resume` / `try_again` | orphan sweep + re-dispatch | Failed→retry; OnHold→release holds | release holds + re-dispatch | |
| 198 | +| `skip` | N/A | mark story skipped (no sibling release) | N/A | |
| 199 | +| `change_request` | N/A | StatusFailed only; append + reset + retry | N/A | |
| 200 | + |
| 201 | +**Key design decisions:** |
| 202 | + |
| 203 | +- **`StatusSkipped`** is a new terminal status distinct from `StatusFailed`. Terminal guards, `AllStoriesTerminal()`, deadlock detection, session summaries, and PM notifications all include it. `AllStoriesCompleted()` does *not* — skipped ≠ completed. Dependencies on a skipped story are never satisfied. |
| 204 | +- **Reverse-dependency gating:** `SkipStory` rejects if non-terminal stories depend on the target (they would become permanently unstartable). |
| 205 | +- **Skip does not release siblings:** Failure-group holds represent shared blockers. Skipping one story doesn't resolve the shared condition, so siblings stay on hold. Only `resume` releases failure groups. |
| 206 | +- **`change_request` restricted to `StatusFailed`:** For group-scoped incidents (`StatusOnHold`, `clarification_needed`), annotating one story and resuming the group would leave siblings without the annotation. `change_request` only operates on story-local (failed) incidents. |
| 207 | +- **AttemptCount reset:** `change_request` resets attempts to 0 — a user-supplied change is a new direction and deserves a fresh retry budget. |
| 208 | +- **Content annotation:** Appends `"## Change Request (User)"` section to story content, consistent with existing `"## Implementation Notes (Auto-generated)"` and `"## Failure Context (Auto-generated)"` patterns. |
| 209 | + |
| 210 | +--- |
| 211 | + |
| 212 | +## Relationship to Existing Notifications |
| 213 | + |
| 214 | +Phase 1 layers incidents on top of existing notifications. The existing `story_blocked` and `clarification_request` payloads continue to flow and inject immediate LLM context. The new `incident_opened` messages arrive separately and add durable state. Both coexist: |
| 215 | + |
| 216 | +- **Existing notification** → immediate context injection into PM's LLM conversation |
| 217 | +- **New incident** → durable state that survives resume and provides accurate status on re-entry |
| 218 | + |
| 219 | +This avoids a risky migration while still solving the core problem. Phase 2+ may consolidate. |
0 commit comments