taskplane 0.10.2 → 0.11.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.
package/README.md CHANGED
@@ -129,7 +129,7 @@ For a single task with full worktree isolation, dashboard, and reviews:
129
129
 
130
130
  This uses the same orchestrator infrastructure as a full batch — isolated worktree, orch branch, supervisor, dashboard, inline reviews — but for just one task.
131
131
 
132
- > **Note:** The `/task` command still exists for direct single-task execution in the current branch, but `/orch` is recommended for all workflows. `/task` does not provide worktree isolation, dashboard, or inline reviews.
132
+ > **Deprecated:** The `/task` command is deprecated and will be removed in a future major version. It does not provide worktree isolation, dashboard, or inline reviews. Use `/orch` for all workflows — including single-task execution.
133
133
 
134
134
  ## Commands
135
135
 
@@ -137,10 +137,10 @@ This uses the same orchestrator infrastructure as a full batch — isolated work
137
137
 
138
138
  | Command | Description |
139
139
  |---------|-------------|
140
- | `/task <path/to/PROMPT.md>` | Execute one task in the current branch/worktree |
141
- | `/task-status` | Show current task progress |
142
- | `/task-pause` | Pause after current worker iteration finishes |
143
- | `/task-resume` | Resume a paused task |
140
+ | `/task <path/to/PROMPT.md>` | ⚠️ **Deprecated.** Execute one task in the current branch/worktree. Use `/orch` instead. |
141
+ | `/task-status` | ⚠️ **Deprecated.** Show current task progress. Use `/orch-status` or dashboard. |
142
+ | `/task-pause` | ⚠️ **Deprecated.** Pause after current worker iteration finishes. Use `/orch-pause`. |
143
+ | `/task-resume` | ⚠️ **Deprecated.** Resume a paused task. Use `/orch-resume`. |
144
144
  | `/orch [<areas\|paths\|all>]` | No args: detect state & guide (onboarding, batch planning, etc.); with args: execute tasks via isolated worktrees |
145
145
  | `/orch-plan <areas\|paths\|all>` | Preview execution plan without running |
146
146
  | `/orch-status` | Show batch progress |
@@ -2098,9 +2098,13 @@ export default function (pi: ExtensionAPI) {
2098
2098
 
2099
2099
  // Load reviewer agent definition
2100
2100
  const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
2101
- const reviewerModel = config.reviewer.model
2102
- || reviewerDef?.model
2103
- || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514");
2101
+ // TP-055: model fallback — use session model when TASKPLANE_MODEL_FALLBACK=1
2102
+ const reviewerModelFallback = process.env.TASKPLANE_MODEL_FALLBACK === "1";
2103
+ const reviewerModel = reviewerModelFallback
2104
+ ? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
2105
+ : (config.reviewer.model
2106
+ || reviewerDef?.model
2107
+ || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514"));
2104
2108
  const reviewerPrompt = reviewerDef?.systemPrompt
2105
2109
  || "You are a code reviewer. Read the request and write your review to the specified output file.";
2106
2110
  const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
@@ -2585,9 +2589,15 @@ export default function (pi: ExtensionAPI) {
2585
2589
  const basePrompt = workerDef?.systemPrompt || "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each.";
2586
2590
  const systemPrompt = basePrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
2587
2591
 
2588
- const model = config.worker.model
2589
- || workerDef?.model
2590
- || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514");
2592
+ // TP-055: When TASKPLANE_MODEL_FALLBACK=1 is set, skip configured model
2593
+ // and fall back to the session model. This is set by the orchestrator's
2594
+ // model fallback retry when the configured model becomes unavailable.
2595
+ const modelFallbackActive = process.env.TASKPLANE_MODEL_FALLBACK === "1";
2596
+ const model = modelFallbackActive
2597
+ ? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
2598
+ : (config.worker.model
2599
+ || workerDef?.model
2600
+ || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514"));
2591
2601
 
2592
2602
  const contextDocsList = task.contextDocs.length > 0
2593
2603
  ? "\n\nContext docs to read if needed:\n" + task.contextDocs.map(d => `- ${d}`).join("\n")
@@ -2904,7 +2914,11 @@ export default function (pi: ExtensionAPI) {
2904
2914
  writeFileSync(requestPath, request);
2905
2915
 
2906
2916
  const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
2907
- const reviewerModel = config.reviewer.model || reviewerDef?.model || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514");
2917
+ // TP-055: model fallback use session model when TASKPLANE_MODEL_FALLBACK=1
2918
+ const reviewerModelFallback2 = process.env.TASKPLANE_MODEL_FALLBACK === "1";
2919
+ const reviewerModel = reviewerModelFallback2
2920
+ ? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
2921
+ : (config.reviewer.model || reviewerDef?.model || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514"));
2908
2922
  const reviewerPrompt = reviewerDef?.systemPrompt || "You are a code reviewer. Read the request and write your review to the specified output file.";
2909
2923
  const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
2910
2924
 
@@ -3040,10 +3054,14 @@ export default function (pi: ExtensionAPI) {
3040
3054
  // Determine review model with fallback chain:
3041
3055
  // quality_gate.review_model → reviewer.model → agent def → default
3042
3056
  const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
3043
- const reviewModel = config.quality_gate.review_model
3044
- || config.reviewer.model
3045
- || reviewerDef?.model
3046
- || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514");
3057
+ // TP-055: model fallback — use session model when TASKPLANE_MODEL_FALLBACK=1
3058
+ const qgModelFallback = process.env.TASKPLANE_MODEL_FALLBACK === "1";
3059
+ const reviewModel = qgModelFallback
3060
+ ? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
3061
+ : (config.quality_gate.review_model
3062
+ || config.reviewer.model
3063
+ || reviewerDef?.model
3064
+ || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514"));
3047
3065
 
3048
3066
  const reviewerPrompt = reviewerDef?.systemPrompt
3049
3067
  || "You are a quality gate reviewer. Read the review request and write your JSON verdict to the specified file.";
@@ -3210,9 +3228,13 @@ export default function (pi: ExtensionAPI) {
3210
3228
 
3211
3229
  // Use worker model and tools for fix agent (it needs to edit code)
3212
3230
  const workerDef = loadAgentDef(ctx.cwd, "task-worker");
3213
- const fixModel = config.worker.model
3214
- || workerDef?.model
3215
- || "anthropic/claude-sonnet-4-20250514";
3231
+ // TP-055: model fallback — use session model when TASKPLANE_MODEL_FALLBACK=1
3232
+ const fixModelFallback = process.env.TASKPLANE_MODEL_FALLBACK === "1";
3233
+ const fixModel = fixModelFallback
3234
+ ? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
3235
+ : (config.worker.model
3236
+ || workerDef?.model
3237
+ || "anthropic/claude-sonnet-4-20250514");
3216
3238
 
3217
3239
  const basePrompt = workerDef?.systemPrompt
3218
3240
  || "You are a fix agent addressing quality gate findings. Read the feedback and make targeted code fixes.";
@@ -3405,9 +3427,15 @@ export default function (pi: ExtensionAPI) {
3405
3427
  }
3406
3428
 
3407
3429
  pi.registerCommand("task", {
3408
- description: "Start executing a task: /task <path/to/PROMPT.md>",
3430
+ description: "⚠️ [Deprecated] Start executing a task: /task <path/to/PROMPT.md>",
3409
3431
  handler: async (args, ctx) => {
3410
3432
  widgetCtx = ctx;
3433
+ ctx.ui.notify(
3434
+ "⚠️ /task is deprecated. Use /orch instead — it provides worktree isolation, " +
3435
+ "dashboard, inline reviews, and supervisor monitoring. " +
3436
+ "/task will be removed in a future major version.",
3437
+ "warning",
3438
+ );
3411
3439
  const promptPath = args?.trim();
3412
3440
  if (!promptPath) {
3413
3441
  ctx.ui.notify("Usage: /task <path/to/PROMPT.md>", "error");
@@ -3425,9 +3453,13 @@ export default function (pi: ExtensionAPI) {
3425
3453
  });
3426
3454
 
3427
3455
  pi.registerCommand("task-status", {
3428
- description: "Show current task progress",
3456
+ description: "⚠️ [Deprecated] Show current task progress",
3429
3457
  handler: async (_args, ctx) => {
3430
3458
  widgetCtx = ctx;
3459
+ ctx.ui.notify(
3460
+ "⚠️ /task-status is deprecated. Use the dashboard (`taskplane dashboard`) or `/orch-status` instead.",
3461
+ "warning",
3462
+ );
3431
3463
  if (!state.task) {
3432
3464
  ctx.ui.notify("No task loaded. Use /task <path/to/PROMPT.md>", "info");
3433
3465
  return;
@@ -3459,9 +3491,13 @@ export default function (pi: ExtensionAPI) {
3459
3491
  });
3460
3492
 
3461
3493
  pi.registerCommand("task-pause", {
3462
- description: "Pause task after current worker finishes",
3494
+ description: "⚠️ [Deprecated] Pause task after current worker finishes",
3463
3495
  handler: async (_args, ctx) => {
3464
3496
  widgetCtx = ctx;
3497
+ ctx.ui.notify(
3498
+ "⚠️ /task-pause is deprecated. Use `/orch-pause` instead.",
3499
+ "warning",
3500
+ );
3465
3501
  if (state.phase !== "running") {
3466
3502
  ctx.ui.notify("No task is running", "warning");
3467
3503
  return;
@@ -3473,9 +3509,13 @@ export default function (pi: ExtensionAPI) {
3473
3509
  });
3474
3510
 
3475
3511
  pi.registerCommand("task-resume", {
3476
- description: "Resume a paused task",
3512
+ description: "⚠️ [Deprecated] Resume a paused task",
3477
3513
  handler: async (_args, ctx) => {
3478
3514
  widgetCtx = ctx;
3515
+ ctx.ui.notify(
3516
+ "⚠️ /task-resume is deprecated. Use `/orch-resume` instead.",
3517
+ "warning",
3518
+ );
3479
3519
  if (state.phase !== "paused") {
3480
3520
  ctx.ui.notify("Task is not paused", "warning");
3481
3521
  return;
@@ -222,6 +222,9 @@ function mapTaskRunnerYaml(raw: any): Partial<TaskRunnerSection> {
222
222
  // Quality gate (structural — all keys are schema-defined)
223
223
  if (raw.quality_gate) result.qualityGate = convertStructuralKeys(raw.quality_gate);
224
224
 
225
+ // Model fallback (scalar — "inherit" or "fail")
226
+ if (raw.model_fallback) result.modelFallback = raw.model_fallback;
227
+
225
228
  return result;
226
229
  }
227
230
 
@@ -814,6 +817,7 @@ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts"
814
817
  task_areas: taskAreas,
815
818
  reference_docs: { ...config.taskRunner.referenceDocs },
816
819
  ...(hasTestingCommands ? { testing_commands: { ...testingCommands } } : {}),
820
+ model_fallback: config.taskRunner.modelFallback ?? "inherit",
817
821
  };
818
822
  }
819
823
 
@@ -170,6 +170,19 @@ export interface SelfDocTarget {
170
170
  */
171
171
  export type PassThreshold = "no_critical" | "no_important" | "all_clear";
172
172
 
173
+ /**
174
+ * Model fallback behavior when a configured agent model becomes unavailable mid-batch.
175
+ *
176
+ * - `"inherit"`: Fall back to the session model and retry (default). The task is
177
+ * retried without an explicit --model flag, so pi uses whatever model the
178
+ * session is configured with.
179
+ * - `"fail"`: Fail immediately — the normal failure/retry path handles the error
180
+ * without any model substitution.
181
+ *
182
+ * @since TP-055
183
+ */
184
+ export type ModelFallbackMode = "inherit" | "fail";
185
+
173
186
  /** Quality gate configuration — opt-in post-completion review */
174
187
  export interface QualityGateConfig {
175
188
  /** Enable quality gate review before .DONE creation (default: false) */
@@ -222,6 +235,16 @@ export interface TaskRunnerSection {
222
235
  protectedDocs: string[];
223
236
  /** Quality gate configuration — opt-in post-completion review */
224
237
  qualityGate: QualityGateConfig;
238
+ /**
239
+ * Model fallback behavior when a configured model becomes unavailable mid-batch.
240
+ *
241
+ * - `"inherit"` (default): Retry the task without an explicit model flag,
242
+ * falling back to the session model.
243
+ * - `"fail"`: Fail immediately without model substitution.
244
+ *
245
+ * @since TP-055
246
+ */
247
+ modelFallback: ModelFallbackMode;
225
248
  }
226
249
 
227
250
 
@@ -515,6 +538,7 @@ export const DEFAULT_TASK_RUNNER_SECTION: TaskRunnerSection = {
515
538
  maxFixCycles: 1,
516
539
  passThreshold: "no_critical",
517
540
  },
541
+ modelFallback: "inherit",
518
542
  };
519
543
 
520
544
  /** Default orchestrator section values */
@@ -45,6 +45,7 @@ export interface SessionTokenCounts {
45
45
  * |----------------------|------------------------------------------------------|
46
46
  * | `completed` | `.DONE` file found — task finished successfully |
47
47
  * | `api_error` | API returned error (auth, rate limit, overload) |
48
+ * | `model_access_error` | Model unavailable (401/403/429, model not found) |
48
49
  * | `context_overflow` | Hit context window limit (compactions + high ctx %) |
49
50
  * | `wall_clock_timeout` | Killed by task-runner's max_worker_minutes timer |
50
51
  * | `process_crash` | Non-zero exit code with no API error indicators |
@@ -56,6 +57,7 @@ export interface SessionTokenCounts {
56
57
  export type ExitClassification =
57
58
  | "completed"
58
59
  | "api_error"
60
+ | "model_access_error"
59
61
  | "context_overflow"
60
62
  | "wall_clock_timeout"
61
63
  | "process_crash"
@@ -70,6 +72,7 @@ export type ExitClassification =
70
72
  export const EXIT_CLASSIFICATIONS: readonly ExitClassification[] = [
71
73
  "completed",
72
74
  "api_error",
75
+ "model_access_error",
73
76
  "context_overflow",
74
77
  "wall_clock_timeout",
75
78
  "process_crash",
@@ -223,6 +226,51 @@ export interface TaskExitDiagnostic {
223
226
  */
224
227
  export const CONTEXT_OVERFLOW_THRESHOLD_PCT = 90;
225
228
 
229
+ /**
230
+ * Patterns that indicate a model access error (as opposed to a generic API error).
231
+ *
232
+ * These patterns match error messages from API providers when:
233
+ * - The model is not found or deprecated
234
+ * - Authentication/authorization fails (HTTP 401/403)
235
+ * - Rate limits are hit specifically for the model (HTTP 429)
236
+ * - API key is expired or invalid
237
+ *
238
+ * The patterns are case-insensitive and tested against the error string.
239
+ *
240
+ * @since TP-055
241
+ */
242
+ export const MODEL_ACCESS_ERROR_PATTERNS: readonly RegExp[] = [
243
+ /\b(?:401|403)\b/, // HTTP auth/forbidden status codes
244
+ /\b429\b/, // HTTP rate limit
245
+ /model[_ ]not[_ ]found/i, // Model not found
246
+ /model[_ ](?:is[_ ])?unavailable/i, // Model unavailable
247
+ /model[_ ](?:has[_ ]been[_ ])?deprecated/i, // Model deprecated
248
+ /api[_ ]key[_ ](?:expired|invalid|revoked)/i, // API key issues
249
+ /invalid[_ ]api[_ ]key/i, // Invalid API key (alternate phrasing)
250
+ /authentication[_ ](?:failed|error|required)/i, // Auth failures
251
+ /authorization[_ ](?:failed|error|denied)/i, // Authz failures
252
+ /access[_ ]denied/i, // Generic access denied
253
+ /permission[_ ]denied/i, // Permission denied
254
+ /quota[_ ]exceeded/i, // Quota exceeded
255
+ /rate[_ ]limit/i, // Rate limit (phrase)
256
+ /insufficient[_ ]quota/i, // Insufficient quota
257
+ ];
258
+
259
+ /**
260
+ * Test whether an error message indicates a model access error.
261
+ *
262
+ * Used by `classifyExit()` to distinguish model-specific failures from
263
+ * generic API errors, enabling targeted fallback to the session model.
264
+ *
265
+ * @param errorMessage - Error message to test
266
+ * @returns true if the error matches a model access pattern
267
+ * @since TP-055
268
+ */
269
+ export function isModelAccessError(errorMessage: string): boolean {
270
+ if (!errorMessage) return false;
271
+ return MODEL_ACCESS_ERROR_PATTERNS.some(pattern => pattern.test(errorMessage));
272
+ }
273
+
226
274
  /**
227
275
  * Classify a task session's exit into a deterministic category.
228
276
  *
@@ -232,22 +280,26 @@ export const CONTEXT_OVERFLOW_THRESHOLD_PCT = 90;
232
280
  *
233
281
  * **Classification precedence (highest → lowest):**
234
282
  *
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` |
283
+ * | Priority | Condition | Result |
284
+ * |----------|------------------------------------------------------|----------------------|
285
+ * | 1 | `.DONE` file found | `completed` |
286
+ * | 2a | Retries with model-access error pattern | `model_access_error` |
287
+ * | 2b | Retries present with final retry failed | `api_error` |
288
+ * | 2c | Error message has model-access pattern (no retries) | `model_access_error` |
289
+ * | 3 | Compactions > 0 AND contextPct ≥ 90% | `context_overflow` |
290
+ * | 3b | Task-runner explicitly context-killed | `context_overflow` |
291
+ * | 4 | Timer killed the session | `wall_clock_timeout` |
292
+ * | 5 | Non-zero exit code, no API error | `process_crash` |
293
+ * | 6 | No exit summary file (session vanished) | `session_vanished` |
294
+ * | 7 | Stall detected (no STATUS.md progress) | `stall_timeout` |
295
+ * | 8 | User manually killed the session | `user_killed` |
296
+ * | 9 | None of the above | `unknown` |
247
297
  *
248
298
  * **Tie-break rationale:**
249
299
  * - `.DONE` always wins because the task succeeded regardless of how messy
250
300
  * the session was (retries, compactions, etc.).
301
+ * - `model_access_error` beats generic `api_error` because it's more specific
302
+ * and enables targeted fallback (retry with session model).
251
303
  * - `api_error` beats `context_overflow` because API failures are more
252
304
  * actionable (auth fix, rate limit backoff).
253
305
  * - `wall_clock_timeout` beats `process_crash` because the timer kill
@@ -269,14 +321,24 @@ export function classifyExit(input: ExitClassificationInput): ExitClassification
269
321
  return "completed";
270
322
  }
271
323
 
272
- // 2. Retries present with final retry failedapi_error
324
+ // 2a. Retries present with model-access error patternmodel_access_error
325
+ // 2b. Retries present with final retry failed → api_error
273
326
  if (exitSummary?.retries && exitSummary.retries.length > 0) {
274
327
  const lastRetry = exitSummary.retries[exitSummary.retries.length - 1];
275
328
  if (!lastRetry.succeeded) {
329
+ // Check if the retry error indicates a model access issue
330
+ if (isModelAccessError(lastRetry.error)) {
331
+ return "model_access_error";
332
+ }
276
333
  return "api_error";
277
334
  }
278
335
  }
279
336
 
337
+ // 2c. Error message (no retries) indicates model access issue → model_access_error
338
+ if (exitSummary?.error && isModelAccessError(exitSummary.error)) {
339
+ return "model_access_error";
340
+ }
341
+
280
342
  // 3. Compactions > 0 AND high context utilization → context_overflow
281
343
  if (exitSummary && exitSummary.compactions > 0) {
282
344
  const effectivePct = contextPct ?? 0;
@@ -85,6 +85,7 @@ async function attemptWorkerCrashRetry(
85
85
  allTaskOutcomes: LaneTaskOutcome[],
86
86
  onNotify: (message: string, level: "info" | "warning" | "error") => void,
87
87
  stateRoot: string,
88
+ runnerConfig?: TaskRunnerConfig,
88
89
  ): Promise<{ retriedCount: number; succeededRetries: string[]; failedRetries: string[] }> {
89
90
  if (!batchState.resilience) {
90
91
  batchState.resilience = defaultResilienceState();
@@ -134,6 +135,14 @@ async function attemptWorkerCrashRetry(
134
135
  continue;
135
136
  }
136
137
 
138
+ // model_access_error is handled by attemptModelFallbackRetry() — skip here
139
+ if (classification === "model_access_error") {
140
+ execLog("batch", batchState.batchId,
141
+ `tier0: task ${taskId} classified as model_access_error — deferring to model fallback handler`,
142
+ );
143
+ continue;
144
+ }
145
+
137
146
  // Check retry budget
138
147
  const scopeKey = tier0ScopeKey("worker_crash", taskId, waveIdx);
139
148
  const currentCount = batchState.resilience.retryCountByScope[scopeKey] ?? 0;
@@ -333,6 +342,263 @@ async function attemptWorkerCrashRetry(
333
342
  return { retriedCount, succeededRetries, failedRetries };
334
343
  }
335
344
 
345
+ /**
346
+ * Attempt model fallback retry for tasks that failed with `model_access_error`.
347
+ *
348
+ * When a configured agent model becomes unavailable mid-batch (API key expired,
349
+ * rate limit, model deprecated, provider outage), this function retries the task
350
+ * with the session model by setting `TASKPLANE_MODEL_FALLBACK=1` env var. The
351
+ * task-runner reads this var and omits the explicit `--model` flag, causing pi
352
+ * to use the session's default model.
353
+ *
354
+ * Only runs when `runnerConfig.model_fallback === "inherit"` (the default). When
355
+ * set to `"fail"`, model access errors fall through to normal failure handling.
356
+ *
357
+ * Separate from `attemptWorkerCrashRetry()` because:
358
+ * - Uses a different recovery pattern (`model_fallback` vs `worker_crash`)
359
+ * - Requires env var injection to change the model behavior
360
+ * - Has its own retry budget
361
+ *
362
+ * @since TP-055
363
+ */
364
+ async function attemptModelFallbackRetry(
365
+ waveResult: WaveExecutionResult,
366
+ waveIdx: number,
367
+ batchState: OrchBatchRuntimeState,
368
+ orchConfig: OrchestratorConfig,
369
+ repoRoot: string,
370
+ workspaceConfig: WorkspaceConfig | null | undefined,
371
+ allTaskOutcomes: LaneTaskOutcome[],
372
+ onNotify: (message: string, level: "info" | "warning" | "error") => void,
373
+ stateRoot: string,
374
+ runnerConfig?: TaskRunnerConfig,
375
+ ): Promise<{ retriedCount: number; succeededRetries: string[]; failedRetries: string[] }> {
376
+ // Short-circuit: if model fallback is disabled, skip entirely
377
+ const modelFallbackMode = runnerConfig?.model_fallback ?? "inherit";
378
+ if (modelFallbackMode !== "inherit") {
379
+ return { retriedCount: 0, succeededRetries: [], failedRetries: [] };
380
+ }
381
+
382
+ if (!batchState.resilience) {
383
+ batchState.resilience = defaultResilienceState();
384
+ }
385
+
386
+ const budget = TIER0_RETRY_BUDGETS.model_fallback;
387
+ const succeededRetries: string[] = [];
388
+ const failedRetries: string[] = [];
389
+ let retriedCount = 0;
390
+
391
+ // Build a map from taskId → lane for re-execution
392
+ const taskToLane = new Map<string, AllocatedLane>();
393
+ for (const lane of waveResult.allocatedLanes) {
394
+ for (const task of lane.tasks) {
395
+ taskToLane.set(task.taskId, lane);
396
+ }
397
+ }
398
+
399
+ // Process only model_access_error tasks
400
+ for (const taskId of [...waveResult.failedTaskIds]) {
401
+ const lane = taskToLane.get(taskId);
402
+ if (!lane) continue;
403
+
404
+ const outcome = allTaskOutcomes.find(o => o.taskId === taskId);
405
+ if (!outcome) continue;
406
+
407
+ const classification = outcome.exitDiagnostic?.classification;
408
+ if (classification !== "model_access_error") continue;
409
+
410
+ // Check retry budget
411
+ const scopeKey = tier0ScopeKey("model_fallback", taskId, waveIdx);
412
+ const currentCount = batchState.resilience.retryCountByScope[scopeKey] ?? 0;
413
+ if (currentCount >= budget.maxRetries) {
414
+ execLog("batch", batchState.batchId,
415
+ `tier0: task ${taskId} model fallback retry budget exhausted (${currentCount}/${budget.maxRetries})`,
416
+ { scopeKey },
417
+ );
418
+ emitTier0Event(stateRoot, {
419
+ ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "model_fallback", currentCount, budget.maxRetries),
420
+ taskId,
421
+ laneNumber: lane.laneNumber,
422
+ repoId: lane.repoId ?? null,
423
+ classification,
424
+ error: `Model fallback retry budget exhausted for task ${taskId}`,
425
+ scopeKey,
426
+ affectedTaskIds: [taskId],
427
+ suggestion: `Task ${taskId} failed with model_access_error and model fallback retry exhausted. Check API key validity and model availability.`,
428
+ });
429
+ emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "model_fallback", currentCount, budget.maxRetries,
430
+ `Model fallback retry budget exhausted for task ${taskId}`, [taskId],
431
+ `Task ${taskId} failed with model_access_error and model fallback retry exhausted. Check API key validity and model availability.`,
432
+ { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey },
433
+ );
434
+ continue;
435
+ }
436
+
437
+ // Increment retry counter
438
+ batchState.resilience.retryCountByScope[scopeKey] = currentCount + 1;
439
+ retriedCount++;
440
+
441
+ const failedModel = outcome.exitDiagnostic?.errorMessage || "configured model";
442
+ execLog("batch", batchState.batchId,
443
+ `tier0: model fallback — retrying task ${taskId} without explicit model (${failedModel} unavailable)`,
444
+ { scopeKey, classification },
445
+ );
446
+ onNotify(
447
+ `🔄 Model fallback: Retrying task ${taskId} with session model (${failedModel} unavailable)`,
448
+ "info",
449
+ );
450
+
451
+ // Emit attempt event
452
+ emitTier0Event(stateRoot, {
453
+ ...buildTier0EventBase("tier0_recovery_attempt", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries),
454
+ taskId,
455
+ laneNumber: lane.laneNumber,
456
+ repoId: lane.repoId ?? null,
457
+ classification,
458
+ cooldownMs: budget.cooldownMs,
459
+ scopeKey,
460
+ });
461
+
462
+ // Cooldown before retry
463
+ if (budget.cooldownMs > 0) {
464
+ sleepSync(budget.cooldownMs);
465
+ }
466
+
467
+ // Find the specific AllocatedTask
468
+ const allocatedTask = lane.tasks.find(t => t.taskId === taskId);
469
+ if (!allocatedTask) continue;
470
+
471
+ // Re-execute with model fallback env var
472
+ const retryLane: AllocatedLane = {
473
+ ...lane,
474
+ tasks: [allocatedTask],
475
+ };
476
+
477
+ const isWsMode = !!workspaceConfig;
478
+ const wsRoot = workspaceConfig
479
+ ? resolve(workspaceConfig.configPath, "..", "..")
480
+ : undefined;
481
+
482
+ try {
483
+ const retryPauseSignal = { paused: false };
484
+ // Pass TASKPLANE_MODEL_FALLBACK=1 as extra env var to signal
485
+ // the task-runner to use the session model instead of configured model.
486
+ const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1" };
487
+ const retryResult = await executeLane(
488
+ retryLane,
489
+ orchConfig,
490
+ repoRoot,
491
+ retryPauseSignal,
492
+ wsRoot,
493
+ isWsMode,
494
+ modelFallbackEnv,
495
+ );
496
+
497
+ const retryOutcome = retryResult.tasks[0];
498
+ if (retryOutcome && retryOutcome.status === "succeeded") {
499
+ succeededRetries.push(taskId);
500
+
501
+ // Update waveResult: move from failed to succeeded
502
+ const failIdx = waveResult.failedTaskIds.indexOf(taskId);
503
+ if (failIdx !== -1) waveResult.failedTaskIds.splice(failIdx, 1);
504
+ waveResult.succeededTaskIds.push(taskId);
505
+
506
+ // Update lane results
507
+ for (const lr of waveResult.laneResults) {
508
+ const taskIdx = lr.tasks.findIndex(t => t.taskId === taskId);
509
+ if (taskIdx !== -1) {
510
+ lr.tasks[taskIdx] = retryOutcome;
511
+ break;
512
+ }
513
+ }
514
+
515
+ upsertTaskOutcome(allTaskOutcomes, retryOutcome);
516
+
517
+ execLog("batch", batchState.batchId,
518
+ `tier0: task ${taskId} model fallback retry succeeded`,
519
+ { scopeKey },
520
+ );
521
+ onNotify(
522
+ `✅ Model fallback: Task ${taskId} succeeded with session model`,
523
+ "info",
524
+ );
525
+
526
+ emitTier0Event(stateRoot, {
527
+ ...buildTier0EventBase("tier0_recovery_success", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries),
528
+ taskId,
529
+ laneNumber: lane.laneNumber,
530
+ repoId: lane.repoId ?? null,
531
+ classification,
532
+ resolution: `Task ${taskId} succeeded after falling back to session model`,
533
+ scopeKey,
534
+ });
535
+ } else {
536
+ failedRetries.push(taskId);
537
+ if (retryOutcome) {
538
+ upsertTaskOutcome(allTaskOutcomes, retryOutcome);
539
+ }
540
+ execLog("batch", batchState.batchId,
541
+ `tier0: task ${taskId} model fallback retry failed`,
542
+ { scopeKey, exitReason: retryOutcome?.exitReason },
543
+ );
544
+
545
+ const retryFailError = retryOutcome?.exitReason ?? `Task ${taskId} model fallback retry failed`;
546
+ emitTier0Event(stateRoot, {
547
+ ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries),
548
+ taskId,
549
+ laneNumber: lane.laneNumber,
550
+ repoId: lane.repoId ?? null,
551
+ classification,
552
+ error: retryFailError,
553
+ scopeKey,
554
+ affectedTaskIds: [taskId],
555
+ suggestion: `Task ${taskId} failed even with session model fallback. Investigate task logs.`,
556
+ });
557
+ emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries,
558
+ retryFailError, [taskId],
559
+ `Task ${taskId} failed even with session model fallback. Investigate task logs.`,
560
+ { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey },
561
+ );
562
+ }
563
+ } catch (err: unknown) {
564
+ failedRetries.push(taskId);
565
+ const errMsg = err instanceof Error ? err.message : String(err);
566
+ execLog("batch", batchState.batchId,
567
+ `tier0: task ${taskId} model fallback retry threw error: ${errMsg}`,
568
+ { scopeKey },
569
+ );
570
+ emitTier0Event(stateRoot, {
571
+ ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries),
572
+ taskId,
573
+ laneNumber: lane.laneNumber,
574
+ repoId: lane.repoId ?? null,
575
+ classification,
576
+ error: errMsg,
577
+ scopeKey,
578
+ affectedTaskIds: [taskId],
579
+ suggestion: `Model fallback retry for task ${taskId} threw an exception: ${errMsg}`,
580
+ });
581
+ emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries,
582
+ errMsg, [taskId],
583
+ `Model fallback retry for task ${taskId} threw an exception: ${errMsg}`,
584
+ { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey },
585
+ );
586
+ }
587
+ }
588
+
589
+ // Recalculate wave-level status if retries changed outcomes
590
+ if (succeededRetries.length > 0) {
591
+ if (waveResult.failedTaskIds.length === 0) {
592
+ waveResult.overallStatus = "succeeded";
593
+ waveResult.stoppedEarly = false;
594
+ } else if (waveResult.succeededTaskIds.length > 0) {
595
+ waveResult.overallStatus = "partial";
596
+ }
597
+ }
598
+
599
+ return { retriedCount, succeededRetries, failedRetries };
600
+ }
601
+
336
602
  /**
337
603
  * Attempt stale worktree recovery when lane allocation fails with ALLOC_WORKTREE_FAILED.
338
604
  *
@@ -885,6 +1151,40 @@ export async function executeOrchBatch(
885
1151
  }
886
1152
  }
887
1153
 
1154
+ // ── TP-055: Tier 0 — Model fallback retry ───────────────
1155
+ // Run model fallback BEFORE worker crash retry so that model_access_error
1156
+ // tasks are retried with session model first. Worker crash retry skips
1157
+ // model_access_error tasks (handled here instead).
1158
+ if (waveResult.failedTaskIds.length > 0) {
1159
+ const modelFallbackOutcome = await attemptModelFallbackRetry(
1160
+ waveResult,
1161
+ waveIdx,
1162
+ batchState,
1163
+ orchConfig,
1164
+ repoRoot,
1165
+ workspaceConfig,
1166
+ allTaskOutcomes,
1167
+ onNotify,
1168
+ stateRoot,
1169
+ runnerConfig,
1170
+ );
1171
+ if (modelFallbackOutcome.succeededRetries.length > 0) {
1172
+ // Recompute blocked tasks after model fallback successes
1173
+ if (waveResult.policyApplied === "skip-dependents" && waveResult.failedTaskIds.length > 0) {
1174
+ const recomputed = computeTransitiveDependents(
1175
+ new Set(waveResult.failedTaskIds),
1176
+ depGraph,
1177
+ );
1178
+ waveResult.blockedTaskIds = [...recomputed].sort();
1179
+ } else if (waveResult.failedTaskIds.length === 0) {
1180
+ waveResult.blockedTaskIds = [];
1181
+ }
1182
+ }
1183
+ if (modelFallbackOutcome.retriedCount > 0) {
1184
+ persistRuntimeState("tier0-model-fallback", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
1185
+ }
1186
+ }
1187
+
888
1188
  // ── TP-039: Tier 0 — Worker crash retry ─────────────────
889
1189
  // Run retry BEFORE accumulating counts and blocked tasks so that
890
1190
  // successfully retried tasks don't inflate failedTasks count and
@@ -683,6 +683,7 @@ export function spawnLaneSession(
683
683
  config: OrchestratorConfig,
684
684
  repoRoot: string,
685
685
  workspaceRoot?: string,
686
+ extraEnvVars?: Record<string, string>,
686
687
  ): void {
687
688
  const sessionName = lane.tmuxSessionName;
688
689
  const laneId = lane.laneId;
@@ -706,6 +707,9 @@ export function spawnLaneSession(
706
707
 
707
708
  // Build env vars
708
709
  const envVars = buildLaneEnvVars(lane, task.task.promptPath, repoRoot, workspaceRoot);
710
+ if (extraEnvVars) {
711
+ Object.assign(envVars, extraEnvVars);
712
+ }
709
713
 
710
714
  // Prepare per-task lane log path for post-mortem diagnostics
711
715
  const laneLogPath = resolveLaneLogPath(lane, task);
@@ -1017,6 +1021,7 @@ export async function executeLane(
1017
1021
  pauseSignal: { paused: boolean },
1018
1022
  workspaceRoot?: string,
1019
1023
  isWorkspaceMode?: boolean,
1024
+ extraEnvVars?: Record<string, string>,
1020
1025
  ): Promise<LaneExecutionResult> {
1021
1026
  const laneId = lane.laneId;
1022
1027
  const laneStartTime = Date.now();
@@ -1053,7 +1058,7 @@ export async function executeLane(
1053
1058
 
1054
1059
  try {
1055
1060
  // Spawn TMUX session
1056
- spawnLaneSession(lane, task, config, repoRoot, workspaceRoot);
1061
+ spawnLaneSession(lane, task, config, repoRoot, workspaceRoot, extraEnvVars);
1057
1062
 
1058
1063
  // Poll until completion
1059
1064
  const pollResult = await pollUntilTaskComplete(
@@ -134,6 +134,13 @@ export interface TaskRunnerConfig {
134
134
  reference_docs: Record<string, string>;
135
135
  /** Named testing/verification commands (e.g., { test: "npx vitest run" }). Used for baseline fingerprinting (TP-032). */
136
136
  testing_commands?: Record<string, string>;
137
+ /**
138
+ * Model fallback behavior when a configured model becomes unavailable mid-batch.
139
+ * - `"inherit"` (default): Retry without explicit model (session model fallback).
140
+ * - `"fail"`: No model substitution — normal failure path.
141
+ * @since TP-055
142
+ */
143
+ model_fallback?: "inherit" | "fail";
137
144
  }
138
145
 
139
146
  /** Result of a preflight check */
@@ -204,6 +211,7 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = {
204
211
  export const DEFAULT_TASK_RUNNER_CONFIG: TaskRunnerConfig = {
205
212
  task_areas: {},
206
213
  reference_docs: {},
214
+ model_fallback: "inherit",
207
215
  };
208
216
 
209
217
 
@@ -1384,7 +1392,8 @@ export const MERGE_FAILURE_CLASSIFICATIONS: readonly MergeFailureClassification[
1384
1392
  export type Tier0RecoveryPattern =
1385
1393
  | "worker_crash"
1386
1394
  | "stale_worktree"
1387
- | "cleanup_gate";
1395
+ | "cleanup_gate"
1396
+ | "model_fallback";
1388
1397
 
1389
1398
  /**
1390
1399
  * Exit classifications that are eligible for automatic Tier 0 retry.
@@ -1398,6 +1407,7 @@ export type Tier0RecoveryPattern =
1398
1407
  */
1399
1408
  export const TIER0_RETRYABLE_CLASSIFICATIONS: ReadonlySet<string> = new Set([
1400
1409
  "api_error",
1410
+ "model_access_error",
1401
1411
  "process_crash",
1402
1412
  "session_vanished",
1403
1413
  ]);
@@ -1444,6 +1454,11 @@ export const TIER0_RETRY_BUDGETS: Readonly<Record<Tier0RecoveryPattern, Tier0Ret
1444
1454
  cooldownMs: 2_000,
1445
1455
  backoffMultiplier: 1.0,
1446
1456
  },
1457
+ model_fallback: {
1458
+ maxRetries: 1,
1459
+ cooldownMs: 3_000,
1460
+ backoffMultiplier: 1.0,
1461
+ },
1447
1462
  };
1448
1463
 
1449
1464
  /**
@@ -1457,6 +1472,7 @@ export const TIER0_RETRY_BUDGETS: Readonly<Record<Tier0RecoveryPattern, Tier0Ret
1457
1472
  * @since TP-039
1458
1473
  */
1459
1474
  export type Tier0EscalationPattern = Tier0RecoveryPattern | "merge_timeout";
1475
+ // Note: model_fallback is already included via Tier0RecoveryPattern
1460
1476
 
1461
1477
  /**
1462
1478
  * Context payload emitted when Tier 0 retries are exhausted and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",