taskplane 0.24.30 → 0.25.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.
@@ -98,34 +98,36 @@ export const SECTIONS: SectionDef[] = [
98
98
  { configPath: "orchestrator.orchestrator.worktreeLocation", label: "Worktree Location", control: "toggle", layer: "L1", fieldType: "enum", values: ["sibling", "subdirectory"], description: "Where lane worktree directories are created" },
99
99
  { configPath: "orchestrator.orchestrator.worktreePrefix", label: "Worktree Prefix", control: "input", layer: "L1", fieldType: "string", description: "Prefix for worktree directory names" },
100
100
  { configPath: "orchestrator.orchestrator.batchIdFormat", label: "Batch ID Format", control: "toggle", layer: "L1", fieldType: "enum", values: ["timestamp", "sequential"], description: "Batch ID format for logs/branch naming" },
101
- // spawn_mode removed from Orchestrator section — Runtime V2 is subprocess-only.
102
- // The user-facing spawn mode setting is under Worker (controls /task behavior).
103
101
  { configPath: "orchestrator.orchestrator.sessionPrefix", label: "Session Prefix", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "sessionPrefix", description: "Prefix for orchestrator session names" },
104
102
  { configPath: "orchestrator.orchestrator.operatorId", label: "Operator ID", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "operatorId", description: "Operator identifier (empty = auto-detect)" },
105
103
  { configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "picker", layer: "L1", fieldType: "enum", values: ["manual", "supervised", "auto"], description: "How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking." },
106
104
  ],
107
105
  },
108
106
  {
109
- name: "Dependencies",
107
+ name: "Agent: Supervisor",
110
108
  fields: [
111
- { configPath: "orchestrator.dependencies.source", label: "Dep Source", control: "toggle", layer: "L1", fieldType: "enum", values: ["prompt", "agent"], description: "Dependency extraction source" },
112
- { configPath: "orchestrator.dependencies.cache", label: "Dep Cache", control: "toggle", layer: "L1", fieldType: "boolean", values: ["true", "false"], description: "Cache dependency analysis results" },
109
+ { configPath: "orchestrator.supervisor.model", label: "Supervisor Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "supervisorModel", description: "Supervisor model (inherit = use session model)" },
110
+ { configPath: "orchestrator.supervisor.autonomy", label: "Autonomy Level", control: "picker", layer: "L1", fieldType: "enum", values: ["interactive", "supervised", "autonomous"], description: "Recovery action confirmation behavior" },
113
111
  ],
114
112
  },
115
113
  {
116
- name: "Assignment",
114
+ name: "Agent: Worker",
117
115
  fields: [
118
- { configPath: "orchestrator.assignment.strategy", label: "Strategy", control: "toggle", layer: "L1", fieldType: "enum", values: ["affinity-first", "round-robin", "load-balanced"], description: "Lane assignment strategy" },
116
+ { configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (inherit = use session model)" },
117
+ { configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" },
118
+ { configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "picker", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
119
119
  ],
120
120
  },
121
121
  {
122
- name: "Pre-Warm",
122
+ name: "Agent: Reviewer",
123
123
  fields: [
124
- { configPath: "orchestrator.preWarm.autoDetect", label: "Auto-Detect", control: "toggle", layer: "L1", fieldType: "boolean", values: ["true", "false"], description: "Enable automatic pre-warm command detection" },
124
+ { configPath: "taskRunner.reviewer.model", label: "Reviewer Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "reviewerModel", description: "Reviewer model (inherit = use session model)" },
125
+ { configPath: "taskRunner.reviewer.tools", label: "Reviewer Tools", control: "input", layer: "L1", fieldType: "string", description: "Reviewer tool allowlist" },
126
+ { configPath: "taskRunner.reviewer.thinking", label: "Reviewer Thinking", control: "picker", layer: "L1", fieldType: "string", description: "Reviewer thinking mode" },
125
127
  ],
126
128
  },
127
129
  {
128
- name: "Merge",
130
+ name: "Agent: Merge",
129
131
  fields: [
130
132
  { configPath: "orchestrator.merge.model", label: "Merge Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "mergeModel", description: "Merge-agent model (inherit = use session model)" },
131
133
  { configPath: "orchestrator.merge.tools", label: "Merge Tools", control: "input", layer: "L1", fieldType: "string", description: "Merge-agent tool allowlist" },
@@ -134,6 +136,18 @@ export const SECTIONS: SectionDef[] = [
134
136
  { configPath: "orchestrator.merge.timeoutMinutes", label: "Merge Timeout (minutes)", control: "input", layer: "L1", fieldType: "number", description: "Max time for merge agent to complete. Increase for large batches (default: 10)" },
135
137
  ],
136
138
  },
139
+ {
140
+ name: "Context Limits",
141
+ fields: [
142
+ { configPath: "taskRunner.context.workerContextWindow", label: "Context Window", control: "input", layer: "L1", fieldType: "number", description: "Worker context window size" },
143
+ { configPath: "taskRunner.context.warnPercent", label: "Warn %", control: "input", layer: "L1", fieldType: "number", description: "Context utilization warn threshold (%)" },
144
+ { configPath: "taskRunner.context.killPercent", label: "Kill %", control: "input", layer: "L1", fieldType: "number", description: "Context utilization hard-stop threshold (%)" },
145
+ { configPath: "taskRunner.context.maxWorkerIterations", label: "Max Iterations", control: "input", layer: "L1", fieldType: "number", description: "Max worker iterations per step" },
146
+ { configPath: "taskRunner.context.maxReviewCycles", label: "Max Review Cycles", control: "input", layer: "L1", fieldType: "number", description: "Max revise loops per review stage" },
147
+ { configPath: "taskRunner.context.noProgressLimit", label: "No Progress Limit", control: "input", layer: "L1", fieldType: "number", description: "Max no-progress iterations before failure" },
148
+ { configPath: "taskRunner.context.maxWorkerMinutes", label: "Max Worker Min (ctx)", control: "input", layer: "L1", fieldType: "number", optional: true, description: "Per-worker wall-clock cap (minutes, empty = no cap)" },
149
+ ],
150
+ },
137
151
  {
138
152
  name: "Failure Policy",
139
153
  fields: [
@@ -145,46 +159,28 @@ export const SECTIONS: SectionDef[] = [
145
159
  ],
146
160
  },
147
161
  {
148
- name: "Monitoring",
149
- fields: [
150
- { configPath: "orchestrator.monitoring.pollInterval", label: "Poll Interval (sec)", control: "input", layer: "L1", fieldType: "number", description: "Poll interval for lane/task monitoring (seconds)" },
151
- ],
152
- },
153
- {
154
- name: "Supervisor",
162
+ name: "Dependencies",
155
163
  fields: [
156
- { configPath: "orchestrator.supervisor.model", label: "Supervisor Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "supervisorModel", description: "Supervisor model (inherit = use session model)" },
157
- { configPath: "orchestrator.supervisor.autonomy", label: "Autonomy Level", control: "picker", layer: "L1", fieldType: "enum", values: ["interactive", "supervised", "autonomous"], description: "Recovery action confirmation behavior" },
164
+ { configPath: "orchestrator.dependencies.source", label: "Dep Source", control: "toggle", layer: "L1", fieldType: "enum", values: ["prompt", "agent"], description: "Dependency extraction source" },
165
+ { configPath: "orchestrator.dependencies.cache", label: "Dep Cache", control: "toggle", layer: "L1", fieldType: "boolean", values: ["true", "false"], description: "Cache dependency analysis results" },
158
166
  ],
159
167
  },
160
168
  {
161
- name: "Worker",
169
+ name: "Assignment",
162
170
  fields: [
163
- { configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (inherit = use session model)" },
164
- { configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" },
165
- { configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "picker", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
166
- // spawnMode removed — /task is deprecated, Runtime V2 is subprocess-only.
167
- // { configPath: "taskRunner.worker.spawnMode" ... } was here.
171
+ { configPath: "orchestrator.assignment.strategy", label: "Strategy", control: "toggle", layer: "L1", fieldType: "enum", values: ["affinity-first", "round-robin", "load-balanced"], description: "Lane assignment strategy" },
168
172
  ],
169
173
  },
170
174
  {
171
- name: "Reviewer",
175
+ name: "Pre-Warm",
172
176
  fields: [
173
- { configPath: "taskRunner.reviewer.model", label: "Reviewer Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "reviewerModel", description: "Reviewer model (inherit = use session model)" },
174
- { configPath: "taskRunner.reviewer.tools", label: "Reviewer Tools", control: "input", layer: "L1", fieldType: "string", description: "Reviewer tool allowlist" },
175
- { configPath: "taskRunner.reviewer.thinking", label: "Reviewer Thinking", control: "picker", layer: "L1", fieldType: "string", description: "Reviewer thinking mode" },
177
+ { configPath: "orchestrator.preWarm.autoDetect", label: "Auto-Detect", control: "toggle", layer: "L1", fieldType: "boolean", values: ["true", "false"], description: "Enable automatic pre-warm command detection" },
176
178
  ],
177
179
  },
178
180
  {
179
- name: "Context Limits",
181
+ name: "Monitoring",
180
182
  fields: [
181
- { configPath: "taskRunner.context.workerContextWindow", label: "Context Window", control: "input", layer: "L1", fieldType: "number", description: "Worker context window size" },
182
- { configPath: "taskRunner.context.warnPercent", label: "Warn %", control: "input", layer: "L1", fieldType: "number", description: "Context utilization warn threshold (%)" },
183
- { configPath: "taskRunner.context.killPercent", label: "Kill %", control: "input", layer: "L1", fieldType: "number", description: "Context utilization hard-stop threshold (%)" },
184
- { configPath: "taskRunner.context.maxWorkerIterations", label: "Max Iterations", control: "input", layer: "L1", fieldType: "number", description: "Max worker iterations per step" },
185
- { configPath: "taskRunner.context.maxReviewCycles", label: "Max Review Cycles", control: "input", layer: "L1", fieldType: "number", description: "Max revise loops per review stage" },
186
- { configPath: "taskRunner.context.noProgressLimit", label: "No Progress Limit", control: "input", layer: "L1", fieldType: "number", description: "Max no-progress iterations before failure" },
187
- { configPath: "taskRunner.context.maxWorkerMinutes", label: "Max Worker Min (ctx)", control: "input", layer: "L1", fieldType: "number", optional: true, description: "Per-worker wall-clock cap (minutes, empty = no cap)" },
183
+ { configPath: "orchestrator.monitoring.pollInterval", label: "Poll Interval (sec)", control: "input", layer: "L1", fieldType: "number", description: "Poll interval for lane/task monitoring (seconds)" },
188
184
  ],
189
185
  },
190
186
  {
@@ -1166,9 +1162,10 @@ export async function openSettingsTui(
1166
1162
  ctx: ExtensionContext,
1167
1163
  configRoot: string,
1168
1164
  pointerConfigRoot?: string,
1165
+ onConfigChanged?: () => void,
1169
1166
  ): Promise<void> {
1170
1167
  // Load current config state — refreshed each time we return to the top level
1171
- await showSectionSelectorLoop(ctx, configRoot, pointerConfigRoot);
1168
+ await showSectionSelectorLoop(ctx, configRoot, pointerConfigRoot, onConfigChanged);
1172
1169
  }
1173
1170
 
1174
1171
  /**
@@ -1200,6 +1197,7 @@ async function showSectionSelectorLoop(
1200
1197
  ctx: ExtensionContext,
1201
1198
  configRoot: string,
1202
1199
  pointerConfigRoot?: string,
1200
+ onConfigChanged?: () => void,
1203
1201
  ): Promise<void> {
1204
1202
  while (true) {
1205
1203
  const state = loadConfigState(configRoot, pointerConfigRoot);
@@ -1257,7 +1255,7 @@ async function showSectionSelectorLoop(
1257
1255
  if (section.readOnly) {
1258
1256
  await showAdvancedSection(ctx, state.mergedConfig);
1259
1257
  } else {
1260
- await showSectionSettingsLoop(ctx, section, configRoot, pointerConfigRoot);
1258
+ await showSectionSettingsLoop(ctx, section, configRoot, pointerConfigRoot, onConfigChanged);
1261
1259
  }
1262
1260
  }
1263
1261
  }
@@ -1339,6 +1337,7 @@ async function showSectionSettingsLoop(
1339
1337
  section: SectionDef,
1340
1338
  configRoot: string,
1341
1339
  pointerConfigRoot?: string,
1340
+ onConfigChanged?: () => void,
1342
1341
  ): Promise<void> {
1343
1342
  while (true) {
1344
1343
  const state = loadConfigState(configRoot, pointerConfigRoot);
@@ -1438,9 +1437,13 @@ async function showSectionSettingsLoop(
1438
1437
  } else {
1439
1438
  writeGlobalPreference(toGlobalPreferencePath(field), typedValue);
1440
1439
  }
1440
+ // Notify caller to reload in-memory config from disk
1441
+ if (onConfigChanged) {
1442
+ try { onConfigChanged(); } catch { /* non-fatal */ }
1443
+ }
1444
+
1441
1445
  ctx.ui.notify(
1442
- `✅ ${field.label} updated.\n` +
1443
- `ℹ Restart session to apply changes.`,
1446
+ `✅ ${field.label} updated.`,
1444
1447
  "info",
1445
1448
  );
1446
1449
 
@@ -1494,9 +1497,38 @@ async function showSectionSettingsOnce(
1494
1497
  description: field.description,
1495
1498
  };
1496
1499
 
1497
- // Toggle fields get values array for cycling
1500
+ // Toggle fields: use cycling for 2 values (boolean-like), submenu for 3+
1498
1501
  if (field.control === "toggle" && field.values) {
1499
- item.values = field.values.map((v) => `${v} ${sourceBadge}`);
1502
+ if (field.values.length <= 2) {
1503
+ item.values = field.values.map((v) => `${v} ${sourceBadge}`);
1504
+ } else {
1505
+ // 3+ values: use a submenu so the user can pick any option,
1506
+ // not just cycle to the next one and immediately commit.
1507
+ item.submenu = (_currentValue: string, done: (selected?: string) => void) => {
1508
+ const selectItems: SelectItem[] = field.values!.map((v) => ({
1509
+ value: `${v} ${sourceBadge}`,
1510
+ label: v,
1511
+ }));
1512
+ // Return SelectList directly — Container doesn't forward
1513
+ // handleInput to children, which would freeze the TUI.
1514
+ const list = new SelectList(
1515
+ selectItems,
1516
+ Math.min(selectItems.length + 1, 10),
1517
+ {
1518
+ selectedPrefix: (t: string) => `\x1b[36m${t}\x1b[0m`,
1519
+ selectedText: (t: string) => `\x1b[36m${t}\x1b[0m`,
1520
+ description: (t: string) => `\x1b[2m${t}\x1b[0m`,
1521
+ scrollInfo: (t: string) => `\x1b[2m${t}\x1b[0m`,
1522
+ noMatch: (t: string) => `\x1b[33m${t}\x1b[0m`,
1523
+ },
1524
+ );
1525
+ const currentIdx = field.values!.indexOf(displayValue);
1526
+ if (currentIdx >= 0) list.setSelectedIndex(currentIdx);
1527
+ list.onSelect = (selected) => done(selected.value);
1528
+ list.onCancel = () => done();
1529
+ return list;
1530
+ };
1531
+ }
1500
1532
  }
1501
1533
 
1502
1534
  // Input fields: use a single-value cycling pattern instead of a submenu.
@@ -1649,15 +1681,16 @@ function truncateLine(text: string, width: number): string {
1649
1681
  */
1650
1682
  const SECTION_CONFIG_PREFIXES: Record<string, string[]> = {
1651
1683
  "Orchestrator": ["orchestrator.orchestrator"],
1684
+ "Agent: Supervisor": ["orchestrator.supervisor"],
1685
+ "Agent: Worker": ["taskRunner.worker"],
1686
+ "Agent: Reviewer": ["taskRunner.reviewer"],
1687
+ "Agent: Merge": ["orchestrator.merge"],
1688
+ "Context Limits": ["taskRunner.context"],
1689
+ "Failure Policy": ["orchestrator.failure"],
1652
1690
  "Dependencies": ["orchestrator.dependencies"],
1653
1691
  "Assignment": ["orchestrator.assignment"],
1654
1692
  "Pre-Warm": ["orchestrator.preWarm"],
1655
- "Merge": ["orchestrator.merge"],
1656
- "Failure Policy": ["orchestrator.failure"],
1657
1693
  "Monitoring": ["orchestrator.monitoring"],
1658
- "Worker": ["taskRunner.worker"],
1659
- "Reviewer": ["taskRunner.reviewer"],
1660
- "Context Limits": ["taskRunner.context"],
1661
1694
  };
1662
1695
 
1663
1696
  /**
@@ -397,19 +397,52 @@ export interface IntegrationPlan {
397
397
  failedTasks: number;
398
398
  }
399
399
 
400
+ /**
401
+ * Check whether the git repository has any remotes configured.
402
+ *
403
+ * Used by integration planning to determine if PR mode is possible.
404
+ * A repo without remotes cannot create pull requests.
405
+ *
406
+ * @param cwd - Working directory with the git repo
407
+ * @returns true if at least one remote is configured
408
+ *
409
+ * @since TP-149
410
+ */
411
+ export function hasGitRemotes(cwd: string): boolean {
412
+ try {
413
+ const result = execFileSync("git", ["remote"], {
414
+ encoding: "utf-8",
415
+ timeout: 5_000,
416
+ cwd,
417
+ stdio: ["pipe", "pipe", "pipe"],
418
+ });
419
+ return result.trim().length > 0;
420
+ } catch {
421
+ return false;
422
+ }
423
+ }
424
+
400
425
  /**
401
426
  * Build an integration plan based on the batch state and branch status.
402
427
  *
403
- * Mode selection logic:
404
- * 1. If base branch is protected PR mode (can't push directly)
405
- * 2. If branches have diverged merge mode (ff not possible)
406
- * 3. Otherwise ff mode (cleanest)
428
+ * Mode selection logic (TP-149):
429
+ * 1. Check if remotes exist (determines if PR mode is possible)
430
+ * 2. If base branch is confirmed protected AND remotes exist → PR mode
431
+ * 3. Try fast-forward first (cleanest, most common)
432
+ * 4. If FF not possible (diverged) → merge mode
433
+ *
434
+ * PR mode is only selected when protection is **confirmed** (not "unknown").
435
+ * When protection status is indeterminate (gh unavailable, auth issues),
436
+ * the plan prefers FF → merge over PR, since PR may also fail in that state.
437
+ * Repos without remotes skip protection checks and PR mode entirely.
407
438
  *
408
439
  * @param batchState - Runtime batch state (orchBranch, baseBranch, counts)
409
440
  * @param cwd - Working directory with the git repo
441
+ * @param protectionOverride - Injectable protection status for testing
410
442
  * @returns Integration plan, or null if integration is not possible
411
443
  *
412
444
  * @since TP-043
445
+ * @modified TP-149 — Reordered to FF → merge → PR; check remotes first
413
446
  */
414
447
  export function buildIntegrationPlan(
415
448
  batchState: OrchBatchRuntimeState,
@@ -428,39 +461,21 @@ export function buildIntegrationPlan(
428
461
  const baseBranch = batchState.baseBranch;
429
462
  const batchId = batchState.batchId;
430
463
 
431
- // Step 1: Check branch protection (injectable for testing)
432
- const protection = protectionOverride ?? detectBranchProtection(baseBranch, cwd);
464
+ // Step 1: Check for remotes determines if PR mode is even possible (TP-149)
465
+ const remotes = hasGitRemotes(cwd);
433
466
 
434
- if (protection === "protected") {
435
- return {
436
- mode: "pr",
437
- orchBranch,
438
- baseBranch,
439
- batchId,
440
- branchProtection: protection,
441
- rationale: `Base branch \`${baseBranch}\` is protected — creating a pull request for review.`,
442
- succeededTasks: batchState.succeededTasks,
443
- failedTasks: batchState.failedTasks,
444
- };
445
- }
467
+ // Step 2: Determine protection status
468
+ // - Override: use as-is (test injection path)
469
+ // - Remotes exist: detect via gh API
470
+ // - No remotes: treat as unprotected (can't create PRs anyway)
471
+ const protection = protectionOverride
472
+ ?? (remotes ? detectBranchProtection(baseBranch, cwd) : "unprotected");
446
473
 
447
- if (protection === "unknown") {
448
- // Safe fallback: when protection status can't be determined
449
- // (gh CLI unavailable, no remote, etc.), default to PR mode
450
- // to avoid accidentally pushing to a protected branch.
451
- return {
452
- mode: "pr",
453
- orchBranch,
454
- baseBranch,
455
- batchId,
456
- branchProtection: protection,
457
- rationale: `Could not detect branch protection for \`${baseBranch}\` — defaulting to PR mode for safety.`,
458
- succeededTasks: batchState.succeededTasks,
459
- failedTasks: batchState.failedTasks,
460
- };
461
- }
474
+ // Step 3: Always try FF first, then merge, then PR (TP-149).
475
+ // Protected branches may still allow FF/merge via API tokens.
476
+ // PR is the last resort when direct merge is blocked.
462
477
 
463
- // Step 2: Check ff-ability (is baseBranch ancestor of orchBranch?)
478
+ // Step 3a: Try fast-forward first (cleanest, most common)
464
479
  try {
465
480
  execFileSync("git", ["merge-base", "--is-ancestor", baseBranch, orchBranch], {
466
481
  encoding: "utf-8",
@@ -480,7 +495,20 @@ export function buildIntegrationPlan(
480
495
  failedTasks: batchState.failedTasks,
481
496
  };
482
497
  } catch {
483
- // Branches have diverged — need merge commit
498
+ // Branches have diverged — need merge commit or PR
499
+ // Step 3c: If protected AND remotes exist, prefer PR (merge may be blocked by push protection)
500
+ if (protection === "protected" && remotes) {
501
+ return {
502
+ mode: "pr",
503
+ orchBranch,
504
+ baseBranch,
505
+ batchId,
506
+ branchProtection: protection,
507
+ rationale: `Branches diverged and \`${baseBranch}\` is protected — creating a pull request.`,
508
+ succeededTasks: batchState.succeededTasks,
509
+ failedTasks: batchState.failedTasks,
510
+ };
511
+ }
484
512
  return {
485
513
  mode: "merge",
486
514
  orchBranch,
@@ -923,7 +951,7 @@ export function triggerSupervisorIntegration(
923
951
  }],
924
952
  display: "Integration plan ready — awaiting operator confirmation",
925
953
  },
926
- { triggerTurn: true, deliverAs: "nextTurn" },
954
+ { triggerTurn: true },
927
955
  );
928
956
 
929
957
  // TP-043 R004: Defer summary until after integration completes (or operator declines).
@@ -2806,7 +2834,11 @@ export async function activateSupervisor(
2806
2834
  ],
2807
2835
  display: `Supervisor activated — ${routingContext.routingState}`,
2808
2836
  },
2809
- { triggerTurn: true, deliverAs: "nextTurn" },
2837
+ // triggerTurn starts an LLM turn immediately when the agent is idle.
2838
+ // Do NOT use deliverAs:"nextTurn" — that queues the message for a
2839
+ // future turn instead of starting one, causing the terminal to hang
2840
+ // until the user sends input.
2841
+ { triggerTurn: true },
2810
2842
  );
2811
2843
  return;
2812
2844
  }
@@ -2858,7 +2890,9 @@ export async function activateSupervisor(
2858
2890
  ],
2859
2891
  display: "Supervisor activated" + (batchState.batchId ? ` for batch ${batchState.batchId}` : ""),
2860
2892
  },
2861
- { triggerTurn: true, deliverAs: "nextTurn" },
2893
+ // triggerTurn starts an LLM turn immediately when the agent is idle.
2894
+ // Do NOT use deliverAs:"nextTurn" here — see routing path comment.
2895
+ { triggerTurn: true },
2862
2896
  );
2863
2897
  }
2864
2898
 
@@ -3004,7 +3038,7 @@ export async function transitionToRoutingMode(
3004
3038
  }],
3005
3039
  display: `Supervisor — ${routingContext.routingState}`,
3006
3040
  },
3007
- { triggerTurn: true, deliverAs: "nextTurn" },
3041
+ { triggerTurn: true },
3008
3042
  );
3009
3043
  }
3010
3044
 
@@ -4213,7 +4247,7 @@ export function startEventTailer(
4213
4247
  content: [{ type: "text", text }],
4214
4248
  display: text.replace(/\*\*/g, "").substring(0, 80),
4215
4249
  },
4216
- { triggerTurn: true, deliverAs: "nextTurn" },
4250
+ { triggerTurn: true },
4217
4251
  );
4218
4252
  };
4219
4253
 
@@ -3202,7 +3202,7 @@ export interface TokenCounts {
3202
3202
  export interface BatchTaskSummary {
3203
3203
  taskId: string;
3204
3204
  taskName: string;
3205
- status: "succeeded" | "failed" | "skipped" | "blocked" | "stalled";
3205
+ status: "succeeded" | "failed" | "skipped" | "blocked" | "stalled" | "pending";
3206
3206
  wave: number; // 1-based
3207
3207
  lane: number; // 1-based
3208
3208
  durationMs: number;
@@ -575,12 +575,20 @@ export function resolveBaseBranch(
575
575
  if (batchBaseBranch.startsWith("orch/") && repoId) {
576
576
  try {
577
577
  const check = runGit(["rev-parse", "--verify", `refs/heads/${batchBaseBranch}`], repoRoot);
578
- console.error(`[resolveBaseBranch] repoId=${repoId} batchBaseBranch=${batchBaseBranch} repoRoot=${repoRoot} check.ok=${check.ok}`);
579
578
  if (check.ok) {
580
579
  return batchBaseBranch;
581
580
  }
581
+ // TP-146: Orch branch exists as batch base but not in this repo.
582
+ // This means worktrees will branch from the repo's current HEAD
583
+ // instead of the orch branch, bypassing batch isolation.
584
+ console.error(
585
+ `[taskplane] resolveBaseBranch WARNING: orch branch "${batchBaseBranch}" not found in repo "${repoId}" at ${repoRoot} — falling back to repo HEAD. ` +
586
+ `This bypasses orch branch isolation. Ensure the orch branch was created in all workspace repos.`,
587
+ );
582
588
  } catch (err) {
583
- console.error(`[resolveBaseBranch] repoId=${repoId} batchBaseBranch=${batchBaseBranch} THREW: ${err}`);
589
+ console.error(
590
+ `[taskplane] resolveBaseBranch WARNING: orch branch check failed for repo "${repoId}" at ${repoRoot}: ${err}`,
591
+ );
584
592
  }
585
593
  }
586
594
 
@@ -963,6 +971,97 @@ export function assignTasksToLanes(
963
971
  }
964
972
 
965
973
 
974
+ // ── Global Lane Cap (TP-148) ─────────────────────────────────────────
975
+
976
+ /**
977
+ * Enforce a global lane cap across all repo groups.
978
+ *
979
+ * In workspace mode, each repo independently allocates up to `maxLanes`.
980
+ * This function reduces the total across all repos to fit within the
981
+ * global `maxLanes` budget by consolidating lanes in repos with the
982
+ * most headroom (most lanes relative to their minimum of 1).
983
+ *
984
+ * Algorithm:
985
+ * 1. If total lanes ≤ maxLanes, no-op.
986
+ * 2. Group lanes by repo, sort repos by lane count descending.
987
+ * 3. Iteratively remove the last lane from the repo with the most
988
+ * lanes, redistributing its tasks to the lightest remaining lane
989
+ * in that repo.
990
+ * 4. Stop when total ≤ maxLanes or all repos are at 1 lane.
991
+ * 5. Renumber global lanes sequentially.
992
+ *
993
+ * Mutates `entries` in place: removes excess entries and renumbers.
994
+ *
995
+ * @param entries - Global lane entries from per-repo allocation
996
+ * @param maxLanes - Global maximum lane count
997
+ */
998
+ export function enforceGlobalLaneCap(
999
+ entries: Array<{
1000
+ globalLane: number;
1001
+ localLane: number;
1002
+ repoId: string | undefined;
1003
+ assignments: LaneAssignment[];
1004
+ }>,
1005
+ maxLanes: number,
1006
+ ): void {
1007
+ if (entries.length <= maxLanes) return;
1008
+
1009
+ // Group entries by repoId
1010
+ const byRepo = new Map<string, typeof entries>();
1011
+ for (const entry of entries) {
1012
+ const key = entry.repoId ?? "";
1013
+ const group = byRepo.get(key) || [];
1014
+ group.push(entry);
1015
+ byRepo.set(key, group);
1016
+ }
1017
+
1018
+ let excess = entries.length - maxLanes;
1019
+
1020
+ while (excess > 0) {
1021
+ // Find the repo with the most lanes (ties broken by key for determinism)
1022
+ let bestKey = "";
1023
+ let bestCount = 0;
1024
+ for (const [key, group] of byRepo) {
1025
+ if (group.length > bestCount || (group.length === bestCount && key < bestKey)) {
1026
+ bestKey = key;
1027
+ bestCount = group.length;
1028
+ }
1029
+ }
1030
+
1031
+ // All repos at 1 lane — can't reduce further
1032
+ if (bestCount <= 1) break;
1033
+
1034
+ // Remove the last lane from this repo and redistribute its tasks
1035
+ const group = byRepo.get(bestKey)!;
1036
+ const removed = group.pop()!;
1037
+ // Merge into the first lane of the same repo (deterministic target)
1038
+ group[0].assignments.push(...removed.assignments);
1039
+ excess--;
1040
+ }
1041
+
1042
+ // Warn if cap could not be fully enforced (more repos than maxLanes)
1043
+ const finalTotal = [...byRepo.values()].reduce((sum, g) => sum + g.length, 0);
1044
+ if (finalTotal > maxLanes) {
1045
+ console.error(
1046
+ `[taskplane] warning: global maxLanes=${maxLanes} could not be enforced — ` +
1047
+ `${byRepo.size} repos each need at least 1 lane (total: ${finalTotal}). ` +
1048
+ `Increase maxLanes to at least ${byRepo.size} to avoid this.`,
1049
+ );
1050
+ }
1051
+
1052
+ // Rebuild entries array with sequential global lane numbers
1053
+ entries.length = 0;
1054
+ let globalLane = 1;
1055
+ for (const key of [...byRepo.keys()].sort()) {
1056
+ const group = byRepo.get(key)!;
1057
+ for (const entry of group) {
1058
+ entry.globalLane = globalLane++;
1059
+ entries.push(entry);
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+
966
1065
  /**
967
1066
  * Result of `allocateLanes()`.
968
1067
  *
@@ -1188,6 +1287,13 @@ export function allocateLanes(
1188
1287
  globalLaneOffset += sortedLocalLanes.length;
1189
1288
  }
1190
1289
 
1290
+ // ── Stage 2b: Enforce global lane cap (TP-148) ─────────────────
1291
+ // In workspace mode, each repo group independently allocates up to
1292
+ // maxLanes. If total lanes across all repos exceeds the global
1293
+ // maxLanes limit, reduce lanes in repos with the most headroom.
1294
+ // Preserves at least 1 lane per repo with tasks.
1295
+ enforceGlobalLaneCap(globalLaneEntries, config.orchestrator.max_lanes);
1296
+
1191
1297
  const laneCount = globalLaneEntries.length;
1192
1298
 
1193
1299
  if (laneCount === 0) {