taskplane 0.5.12 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Exit classification types and logic for task diagnostics.
3
+ *
4
+ * Defines the structured `TaskExitDiagnostic` type that replaces
5
+ * free-text `exitReason` for deterministic retry decisions,
6
+ * cost tracking, and dashboard telemetry.
7
+ *
8
+ * @module orch/diagnostics
9
+ * @see docs/specifications/taskplane/resilience-and-diagnostics-roadmap.md §1b
10
+ */
11
+
12
+ // ── Token Counts (Diagnostics) ───────────────────────────────────────
13
+
14
+ /**
15
+ * Token usage breakdown for a single session.
16
+ *
17
+ * Matches the RPC exit-summary `tokens` shape: four count fields only.
18
+ * Cost is tracked separately as a top-level field on `ExitSummary` and
19
+ * `TaskExitDiagnostic`, not embedded in the token counts.
20
+ *
21
+ * This is distinct from `TokenCounts` in `types.ts` (which bundles
22
+ * `costUsd` for batch-history aggregation). Downstream consumers that
23
+ * need to convert can merge `{ ...sessionTokens, costUsd: cost }`.
24
+ */
25
+ export interface SessionTokenCounts {
26
+ /** Input tokens consumed */
27
+ input: number;
28
+ /** Output tokens generated */
29
+ output: number;
30
+ /** Tokens served from cache (read) */
31
+ cacheRead: number;
32
+ /** Tokens written to cache */
33
+ cacheWrite: number;
34
+ }
35
+
36
+ // ── Exit Classification ──────────────────────────────────────────────
37
+
38
+ /**
39
+ * All possible exit classifications for a task session.
40
+ *
41
+ * Each value maps to a specific failure mode that downstream consumers
42
+ * (retry logic, dashboard, cost reports) can branch on deterministically.
43
+ *
44
+ * | Classification | Meaning |
45
+ * |----------------------|------------------------------------------------------|
46
+ * | `completed` | `.DONE` file found — task finished successfully |
47
+ * | `api_error` | API returned error (auth, rate limit, overload) |
48
+ * | `context_overflow` | Hit context window limit (compactions + high ctx %) |
49
+ * | `wall_clock_timeout` | Killed by task-runner's max_worker_minutes timer |
50
+ * | `process_crash` | Non-zero exit code with no API error indicators |
51
+ * | `session_vanished` | Tmux session disappeared without exit summary |
52
+ * | `stall_timeout` | No STATUS.md progress for stall_timeout minutes |
53
+ * | `user_killed` | User manually killed the session (e.g., tmux kill) |
54
+ * | `unknown` | Could not determine cause |
55
+ */
56
+ export type ExitClassification =
57
+ | "completed"
58
+ | "api_error"
59
+ | "context_overflow"
60
+ | "wall_clock_timeout"
61
+ | "process_crash"
62
+ | "session_vanished"
63
+ | "stall_timeout"
64
+ | "user_killed"
65
+ | "unknown";
66
+
67
+ /**
68
+ * All classification values as a readonly array, for iteration and validation.
69
+ */
70
+ export const EXIT_CLASSIFICATIONS: readonly ExitClassification[] = [
71
+ "completed",
72
+ "api_error",
73
+ "context_overflow",
74
+ "wall_clock_timeout",
75
+ "process_crash",
76
+ "session_vanished",
77
+ "stall_timeout",
78
+ "user_killed",
79
+ "unknown",
80
+ ] as const;
81
+
82
+ // ── Retry Record ─────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * A single API retry event from the RPC wrapper's exit summary.
86
+ *
87
+ * Captured from `auto_retry_start/end` RPC events.
88
+ */
89
+ export interface RetryRecord {
90
+ /** Retry attempt number (1-indexed) */
91
+ attempt: number;
92
+ /** Error message that triggered the retry */
93
+ error: string;
94
+ /** Delay in milliseconds before retrying */
95
+ delayMs: number;
96
+ /** Whether the retry succeeded */
97
+ succeeded: boolean;
98
+ }
99
+
100
+ // ── Exit Summary ─────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Exit summary written by rpc-wrapper.mjs on process exit.
104
+ *
105
+ * This is the wrapper's output artifact — a JSON file capturing
106
+ * everything the wrapper observed during the session. The task-runner
107
+ * reads this to build `TaskExitDiagnostic`.
108
+ *
109
+ * **Field optionality rationale:**
110
+ * The wrapper initializes counters (toolCalls, compactions, durationSec,
111
+ * retries) at startup, so they are always present even on crash — these
112
+ * are required. Fields that depend on RPC event accumulation (tokens,
113
+ * cost, lastToolCall, error) are nullable — they may be absent if the
114
+ * process crashes before capturing any events. `exitCode` and
115
+ * `exitSignal` are optional (`?`) because the wrapper may crash before
116
+ * the Node exit handler fires, producing a partial JSON artifact that
117
+ * `JSON.parse()` succeeds on but lacks these fields.
118
+ *
119
+ * Consumers MUST use `typeof` guards on optional/nullable fields before
120
+ * branching (e.g., `typeof exitCode === "number"` rather than `!== null`).
121
+ */
122
+ export interface ExitSummary {
123
+ /** Process exit code. Optional — may be absent if wrapper crashes before exit handler fires. Null if killed by signal. */
124
+ exitCode?: number | null;
125
+ /** Signal that killed the process (e.g., "SIGTERM"). Optional — may be absent on crash. Null if clean exit. */
126
+ exitSignal?: string | null;
127
+ /** Accumulated token counts across all turns (null if no message_end events received) */
128
+ tokens: SessionTokenCounts | null;
129
+ /** Total cost in USD (null if no cost data received) */
130
+ cost: number | null;
131
+ /** Total tool calls made (initialized to 0 at startup) */
132
+ toolCalls: number;
133
+ /** API retry events observed (initialized to [] at startup) */
134
+ retries: RetryRecord[];
135
+ /** Number of context compactions observed (initialized to 0 at startup) */
136
+ compactions: number;
137
+ /** Wall-clock duration of the session in seconds (always written, even on crash) */
138
+ durationSec: number;
139
+ /** Last tool call description (e.g., "bash: npx vitest run"), null if no tools were called */
140
+ lastToolCall: string | null;
141
+ /** Error message if the session ended with an error, null on clean exit */
142
+ error: string | null;
143
+ }
144
+
145
+ // ── Classification Input ─────────────────────────────────────────────
146
+
147
+ /**
148
+ * Structured input to `classifyExit()`.
149
+ *
150
+ * Aggregates all signals needed for deterministic classification.
151
+ * Sources:
152
+ * - `exitSummary`: from rpc-wrapper.mjs exit summary JSON (null if file missing)
153
+ * - `doneFileFound`: from .DONE file presence check (task-runner)
154
+ * - `timerKilled`: true if task-runner's max_worker_minutes timer killed the session
155
+ * - `contextKilled`: true if the task-runner explicitly killed the session due to context limit
156
+ * - `stallDetected`: true if monitoring detected no STATUS.md progress
157
+ * - `userKilled`: true if user manually killed the session (e.g., /orch-abort, tmux kill)
158
+ * - `contextPct`: estimated context utilization % (0-100), null if unknown
159
+ *
160
+ * Design: single structured input object (not positional args) for
161
+ * extensibility as new signals are added in future phases.
162
+ */
163
+ export interface ExitClassificationInput {
164
+ /** Exit summary from rpc-wrapper.mjs. Null if the summary file was not found. */
165
+ exitSummary: ExitSummary | null;
166
+ /** Whether the .DONE file was found in the task folder */
167
+ doneFileFound: boolean;
168
+ /** Whether the task-runner's wall-clock timer killed the session */
169
+ timerKilled: boolean;
170
+ /** Whether the task-runner explicitly killed the session due to context limit (TP-026) */
171
+ contextKilled?: boolean;
172
+ /** Whether monitoring detected a stall (no STATUS.md progress) */
173
+ stallDetected: boolean;
174
+ /** Whether the user manually killed the session */
175
+ userKilled: boolean;
176
+ /** Estimated context utilization percentage (0-100), null if unknown */
177
+ contextPct: number | null;
178
+ }
179
+
180
+ // ── Task Exit Diagnostic ─────────────────────────────────────────────
181
+
182
+ /**
183
+ * Structured diagnostic for a task session's exit.
184
+ *
185
+ * Sits alongside the legacy `exitReason: string` on `LaneTaskOutcome`
186
+ * during the transition period (Phase 1). Promoted to canonical in
187
+ * schema v3 (Phase 3).
188
+ *
189
+ * Produced by calling `classifyExit()` after the session ends, then
190
+ * enriching with progress/context metadata from STATUS.md and git.
191
+ */
192
+ export interface TaskExitDiagnostic {
193
+ /** Deterministic exit classification */
194
+ classification: ExitClassification;
195
+ /** Process exit code (null if killed by signal or summary missing) */
196
+ exitCode: number | null;
197
+ /** Human-readable error message (null if clean exit) */
198
+ errorMessage: string | null;
199
+ /** Token usage breakdown (null if no summary available) */
200
+ tokensUsed: SessionTokenCounts | null;
201
+ /** Estimated context utilization percentage (0-100, null if unknown) */
202
+ contextPct: number | null;
203
+ /** Number of commits on the task branch (partial progress indicator) */
204
+ partialProgressCommits: number;
205
+ /** Branch name with partial progress (null if no branch) */
206
+ partialProgressBranch: string | null;
207
+ /** Wall-clock duration of the session in seconds */
208
+ durationSec: number;
209
+ /** Last known step number from STATUS.md (null if unparsed) */
210
+ lastKnownStep: number | null;
211
+ /** Last known checkbox text from STATUS.md (null if unparsed) */
212
+ lastKnownCheckbox: string | null;
213
+ /** Repo identifier ("default" in repo mode, repo key in workspace mode) */
214
+ repoId: string;
215
+ }
216
+
217
+ // ── Classification Logic ─────────────────────────────────────────────
218
+
219
+ /**
220
+ * Threshold for context utilization percentage to consider "high".
221
+ * Used in the `context_overflow` classification path:
222
+ * compactions > 0 AND contextPct >= this threshold → context_overflow.
223
+ */
224
+ export const CONTEXT_OVERFLOW_THRESHOLD_PCT = 90;
225
+
226
+ /**
227
+ * Classify a task session's exit into a deterministic category.
228
+ *
229
+ * Uses a strict precedence order — the first matching condition wins.
230
+ * This ensures deterministic results even when multiple signals are
231
+ * present (e.g., a session that was both stalled AND crashed).
232
+ *
233
+ * **Classification precedence (highest → lowest):**
234
+ *
235
+ * | Priority | Condition | Result |
236
+ * |----------|------------------------------------------------------|---------------------|
237
+ * | 1 | `.DONE` file found | `completed` |
238
+ * | 2 | Retries present with final retry failed | `api_error` |
239
+ * | 3 | Compactions > 0 AND contextPct ≥ 90% | `context_overflow` |
240
+ * | 3b | Task-runner explicitly context-killed | `context_overflow` |
241
+ * | 4 | Timer killed the session | `wall_clock_timeout`|
242
+ * | 5 | Non-zero exit code, no API error | `process_crash` |
243
+ * | 6 | No exit summary file (session vanished) | `session_vanished` |
244
+ * | 7 | Stall detected (no STATUS.md progress) | `stall_timeout` |
245
+ * | 8 | User manually killed the session | `user_killed` |
246
+ * | 9 | None of the above | `unknown` |
247
+ *
248
+ * **Tie-break rationale:**
249
+ * - `.DONE` always wins because the task succeeded regardless of how messy
250
+ * the session was (retries, compactions, etc.).
251
+ * - `api_error` beats `context_overflow` because API failures are more
252
+ * actionable (auth fix, rate limit backoff).
253
+ * - `wall_clock_timeout` beats `process_crash` because the timer kill
254
+ * explains the non-zero exit code.
255
+ * - `session_vanished` (no summary) is checked after exit-code-based
256
+ * paths because those require the summary to exist.
257
+ * - `stall_timeout` and `user_killed` are low-priority because they're
258
+ * external signals that may co-occur with other conditions.
259
+ *
260
+ * @param input - Aggregated signals from the session exit
261
+ * @returns The exit classification string
262
+ */
263
+ export function classifyExit(input: ExitClassificationInput): ExitClassification {
264
+ const { exitSummary, doneFileFound, timerKilled, stallDetected, userKilled, contextPct } = input;
265
+ const contextKilled = input.contextKilled ?? false;
266
+
267
+ // 1. .DONE file found → completed (task succeeded, regardless of session state)
268
+ if (doneFileFound) {
269
+ return "completed";
270
+ }
271
+
272
+ // 2. Retries present with final retry failed → api_error
273
+ if (exitSummary?.retries && exitSummary.retries.length > 0) {
274
+ const lastRetry = exitSummary.retries[exitSummary.retries.length - 1];
275
+ if (!lastRetry.succeeded) {
276
+ return "api_error";
277
+ }
278
+ }
279
+
280
+ // 3. Compactions > 0 AND high context utilization → context_overflow
281
+ if (exitSummary && exitSummary.compactions > 0) {
282
+ const effectivePct = contextPct ?? 0;
283
+ if (effectivePct >= CONTEXT_OVERFLOW_THRESHOLD_PCT) {
284
+ return "context_overflow";
285
+ }
286
+ }
287
+
288
+ // 3b. Task-runner explicitly killed session due to context limit → context_overflow
289
+ // Catches cases where exit summary is missing (wrapper crashed) or compactions=0
290
+ // but the task-runner's own context guard triggered the kill.
291
+ if (contextKilled) {
292
+ return "context_overflow";
293
+ }
294
+
295
+ // 4. Task-runner's wall-clock timer killed the session → wall_clock_timeout
296
+ if (timerKilled) {
297
+ return "wall_clock_timeout";
298
+ }
299
+
300
+ // 5. Non-zero exit code, no API error indicators → process_crash
301
+ // Guard with typeof to handle partial summaries where exitCode may be undefined
302
+ if (exitSummary && typeof exitSummary.exitCode === "number" && exitSummary.exitCode !== 0) {
303
+ return "process_crash";
304
+ }
305
+
306
+ // 6. No exit summary file found → session_vanished
307
+ if (exitSummary === null) {
308
+ return "session_vanished";
309
+ }
310
+
311
+ // 7. Stall detected (no STATUS.md progress) → stall_timeout
312
+ if (stallDetected) {
313
+ return "stall_timeout";
314
+ }
315
+
316
+ // 8. User manually killed the session → user_killed
317
+ if (userKilled) {
318
+ return "user_killed";
319
+ }
320
+
321
+ // 9. None of the above → unknown
322
+ return "unknown";
323
+ }