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.
- package/README.md +41 -48
- package/bin/taskplane.mjs +51 -147
- package/dashboard/public/app.js +32 -18
- package/dashboard/public/style.css +4 -5
- package/dashboard/server.cjs +3 -0
- package/extensions/taskplane/engine-worker.ts +5 -0
- package/extensions/taskplane/engine.ts +160 -6
- package/extensions/taskplane/execution.ts +38 -9
- package/extensions/taskplane/extension.ts +33 -1
- package/extensions/taskplane/lane-runner.ts +46 -1
- package/extensions/taskplane/settings-tui.ts +81 -48
- package/extensions/taskplane/supervisor.ts +74 -40
- package/extensions/taskplane/types.ts +1 -1
- package/extensions/taskplane/waves.ts +108 -2
- package/extensions/taskplane/worktree.ts +114 -0
- package/package.json +1 -1
- package/templates/agents/supervisor.md +1 -1
- package/templates/agents/task-worker.md +7 -0
- package/templates/config/task-orchestrator.yaml +0 -93
- package/templates/config/task-runner.yaml +0 -94
|
@@ -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: "
|
|
107
|
+
name: "Agent: Supervisor",
|
|
110
108
|
fields: [
|
|
111
|
-
{ configPath: "orchestrator.
|
|
112
|
-
{ configPath: "orchestrator.
|
|
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: "
|
|
114
|
+
name: "Agent: Worker",
|
|
117
115
|
fields: [
|
|
118
|
-
{ configPath: "
|
|
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: "
|
|
122
|
+
name: "Agent: Reviewer",
|
|
123
123
|
fields: [
|
|
124
|
-
{ configPath: "
|
|
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: "
|
|
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.
|
|
157
|
-
{ configPath: "orchestrator.
|
|
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: "
|
|
169
|
+
name: "Assignment",
|
|
162
170
|
fields: [
|
|
163
|
-
{ configPath: "
|
|
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: "
|
|
175
|
+
name: "Pre-Warm",
|
|
172
176
|
fields: [
|
|
173
|
-
{ configPath: "
|
|
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: "
|
|
181
|
+
name: "Monitoring",
|
|
180
182
|
fields: [
|
|
181
|
-
{ configPath: "
|
|
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
|
|
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
|
|
1500
|
+
// Toggle fields: use cycling for 2 values (boolean-like), submenu for 3+
|
|
1498
1501
|
if (field.control === "toggle" && field.values) {
|
|
1499
|
-
|
|
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.
|
|
405
|
-
* 2. If
|
|
406
|
-
* 3.
|
|
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
|
|
432
|
-
const
|
|
464
|
+
// Step 1: Check for remotes — determines if PR mode is even possible (TP-149)
|
|
465
|
+
const remotes = hasGitRemotes(cwd);
|
|
433
466
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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) {
|