taskplane 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +2 -20
- package/bin/taskplane.mjs +706 -0
- package/dashboard/public/app.js +900 -0
- package/dashboard/public/index.html +92 -0
- package/dashboard/public/style.css +924 -0
- package/dashboard/server.cjs +531 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/extensions/task-runner.ts +1923 -0
- package/extensions/taskplane/abort.ts +466 -0
- package/extensions/taskplane/config.ts +102 -0
- package/extensions/taskplane/discovery.ts +988 -0
- package/extensions/taskplane/engine.ts +758 -0
- package/extensions/taskplane/execution.ts +1752 -0
- package/extensions/taskplane/extension.ts +577 -0
- package/extensions/taskplane/formatting.ts +718 -0
- package/extensions/taskplane/git.ts +38 -0
- package/extensions/taskplane/index.ts +22 -0
- package/extensions/taskplane/merge.ts +795 -0
- package/extensions/taskplane/messages.ts +134 -0
- package/extensions/taskplane/persistence.ts +1121 -0
- package/extensions/taskplane/resume.ts +1092 -0
- package/extensions/taskplane/sessions.ts +92 -0
- package/extensions/taskplane/types.ts +1514 -0
- package/extensions/taskplane/waves.ts +900 -0
- package/extensions/taskplane/worktree.ts +1624 -0
- package/package.json +48 -3
- package/skills/create-taskplane-task/SKILL.md +326 -0
- package/skills/create-taskplane-task/references/context-template.md +78 -0
- package/skills/create-taskplane-task/references/prompt-template.md +246 -0
- package/templates/agents/task-merger.md +256 -0
- package/templates/agents/task-reviewer.md +81 -0
- package/templates/agents/task-worker.md +140 -0
- package/templates/config/task-orchestrator.yaml +89 -0
- package/templates/config/task-runner.yaml +99 -0
- package/templates/tasks/CONTEXT.md +31 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_ORCHESTRATOR_CONFIG,
|
|
7
|
+
DEFAULT_TASK_RUNNER_CONFIG,
|
|
8
|
+
ORCH_MESSAGES,
|
|
9
|
+
computeWaveAssignments,
|
|
10
|
+
createOrchWidget,
|
|
11
|
+
deleteBatchState,
|
|
12
|
+
detectOrphanSessions,
|
|
13
|
+
executeAbort,
|
|
14
|
+
executeLane,
|
|
15
|
+
executeOrchBatch,
|
|
16
|
+
formatDependencyGraph,
|
|
17
|
+
formatDiscoveryResults,
|
|
18
|
+
formatOrchSessions,
|
|
19
|
+
formatPreflightResults,
|
|
20
|
+
formatWavePlan,
|
|
21
|
+
freshOrchBatchState,
|
|
22
|
+
listOrchSessions,
|
|
23
|
+
loadBatchState,
|
|
24
|
+
loadOrchestratorConfig,
|
|
25
|
+
loadTaskRunnerConfig,
|
|
26
|
+
parseOrchSessionNames,
|
|
27
|
+
resumeOrchBatch,
|
|
28
|
+
runDiscovery,
|
|
29
|
+
runPreflight,
|
|
30
|
+
} from "./index.ts";
|
|
31
|
+
import type {
|
|
32
|
+
AbortMode,
|
|
33
|
+
MonitorState,
|
|
34
|
+
OrchestratorConfig,
|
|
35
|
+
PersistedBatchState,
|
|
36
|
+
TaskRunnerConfig,
|
|
37
|
+
} from "./index.ts";
|
|
38
|
+
|
|
39
|
+
// ── Extension ────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export default function (pi: ExtensionAPI) {
|
|
42
|
+
let orchBatchState = freshOrchBatchState();
|
|
43
|
+
let orchConfig: OrchestratorConfig = { ...DEFAULT_ORCHESTRATOR_CONFIG };
|
|
44
|
+
let runnerConfig: TaskRunnerConfig = { ...DEFAULT_TASK_RUNNER_CONFIG };
|
|
45
|
+
let orchWidgetCtx: ExtensionContext | undefined;
|
|
46
|
+
let latestMonitorState: MonitorState | null = null;
|
|
47
|
+
|
|
48
|
+
// ── Widget Rendering ─────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function updateOrchWidget() {
|
|
51
|
+
if (!orchWidgetCtx) return;
|
|
52
|
+
const ctx = orchWidgetCtx;
|
|
53
|
+
const prefix = orchConfig.orchestrator.tmux_prefix;
|
|
54
|
+
|
|
55
|
+
ctx.ui.setWidget(
|
|
56
|
+
"task-orchestrator",
|
|
57
|
+
createOrchWidget(
|
|
58
|
+
() => orchBatchState,
|
|
59
|
+
() => latestMonitorState,
|
|
60
|
+
prefix,
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Commands ─────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
pi.registerCommand("orch", {
|
|
68
|
+
description: "Start batch execution: /orch <areas|paths|all>",
|
|
69
|
+
handler: async (args, ctx) => {
|
|
70
|
+
if (!args?.trim()) {
|
|
71
|
+
ctx.ui.notify(
|
|
72
|
+
"Usage: /orch <areas|paths|all>\n\n" +
|
|
73
|
+
"Examples:\n" +
|
|
74
|
+
" /orch all Run all pending tasks\n" +
|
|
75
|
+
" /orch time-off performance-management Run specific areas\n" +
|
|
76
|
+
" /orch path/to/tasks Scan directory\n" +
|
|
77
|
+
" /orch path/to/PROMPT.md Single task with isolation",
|
|
78
|
+
"info",
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Prevent concurrent batch execution (merging is an active state)
|
|
84
|
+
if (orchBatchState.phase !== "idle" && orchBatchState.phase !== "completed" && orchBatchState.phase !== "failed" && orchBatchState.phase !== "stopped") {
|
|
85
|
+
ctx.ui.notify(
|
|
86
|
+
`⚠️ A batch is already ${orchBatchState.phase} (${orchBatchState.batchId}). ` +
|
|
87
|
+
`Use /orch-pause to pause or wait for completion.`,
|
|
88
|
+
"warning",
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Orphan detection (TS-009 Step 3) ─────────────────────
|
|
94
|
+
const orphanResult = detectOrphanSessions(
|
|
95
|
+
orchConfig.orchestrator.tmux_prefix,
|
|
96
|
+
ctx.cwd,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
switch (orphanResult.recommendedAction) {
|
|
100
|
+
case "resume": {
|
|
101
|
+
// Safety net: if the persisted phase is not actually resumable (e.g. "failed",
|
|
102
|
+
// "stopped") — which can happen when the batch crashed after writing a terminal
|
|
103
|
+
// phase but before /orch-abort cleaned up — auto-delete the state file and
|
|
104
|
+
// fall through to start fresh rather than blocking the user with a catch-22.
|
|
105
|
+
const resumablePhases = ["paused", "executing", "merging"];
|
|
106
|
+
const phase = orphanResult.loadedState?.phase ?? "";
|
|
107
|
+
const hasOrphans = orphanResult.orphanSessions.length > 0;
|
|
108
|
+
if (!hasOrphans && !resumablePhases.includes(phase)) {
|
|
109
|
+
try { deleteBatchState(ctx.cwd); } catch { /* best effort */ }
|
|
110
|
+
ctx.ui.notify(
|
|
111
|
+
`🧹 Cleared non-resumable stale batch (${orphanResult.loadedState?.batchId}, phase=${phase}). Starting fresh.`,
|
|
112
|
+
"info",
|
|
113
|
+
);
|
|
114
|
+
break; // fall through to start a new batch
|
|
115
|
+
}
|
|
116
|
+
// Genuinely resumable or has live orphan sessions — prompt user
|
|
117
|
+
ctx.ui.notify(orphanResult.userMessage, "warning");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case "abort-orphans":
|
|
122
|
+
// Orphan sessions without usable state
|
|
123
|
+
ctx.ui.notify(orphanResult.userMessage, "warning");
|
|
124
|
+
return;
|
|
125
|
+
|
|
126
|
+
case "cleanup-stale":
|
|
127
|
+
// No orphans + stale/invalid state file — auto-delete and continue
|
|
128
|
+
try {
|
|
129
|
+
deleteBatchState(ctx.cwd);
|
|
130
|
+
} catch {
|
|
131
|
+
// Best-effort cleanup — proceed even if delete fails
|
|
132
|
+
}
|
|
133
|
+
if (orphanResult.userMessage) {
|
|
134
|
+
ctx.ui.notify(orphanResult.userMessage, "info");
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
|
|
138
|
+
case "start-fresh":
|
|
139
|
+
// No orphans, no state file — proceed normally
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Reset batch state for new execution
|
|
144
|
+
orchBatchState = freshOrchBatchState();
|
|
145
|
+
latestMonitorState = null;
|
|
146
|
+
updateOrchWidget();
|
|
147
|
+
|
|
148
|
+
await executeOrchBatch(
|
|
149
|
+
args,
|
|
150
|
+
orchConfig,
|
|
151
|
+
runnerConfig,
|
|
152
|
+
ctx.cwd,
|
|
153
|
+
orchBatchState,
|
|
154
|
+
(message, level) => {
|
|
155
|
+
ctx.ui.notify(message, level);
|
|
156
|
+
updateOrchWidget(); // Refresh widget on every phase message
|
|
157
|
+
},
|
|
158
|
+
(monState: MonitorState) => {
|
|
159
|
+
const changed = !latestMonitorState ||
|
|
160
|
+
latestMonitorState.totalDone !== monState.totalDone ||
|
|
161
|
+
latestMonitorState.totalFailed !== monState.totalFailed ||
|
|
162
|
+
latestMonitorState.lanes.some((l, i) =>
|
|
163
|
+
l.currentTaskId !== monState.lanes[i]?.currentTaskId ||
|
|
164
|
+
l.currentStep !== monState.lanes[i]?.currentStep ||
|
|
165
|
+
l.completedChecks !== monState.lanes[i]?.completedChecks,
|
|
166
|
+
);
|
|
167
|
+
latestMonitorState = monState;
|
|
168
|
+
if (changed) updateOrchWidget(); // Only refresh on actual state change
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Final widget update after batch completes
|
|
173
|
+
updateOrchWidget();
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
pi.registerCommand("orch-plan", {
|
|
178
|
+
description: "Preview execution plan: /orch-plan <areas|paths|all> [--refresh]",
|
|
179
|
+
handler: async (args, ctx) => {
|
|
180
|
+
if (!args?.trim()) {
|
|
181
|
+
ctx.ui.notify(
|
|
182
|
+
"Usage: /orch-plan <areas|paths|all> [--refresh]\n\n" +
|
|
183
|
+
"Shows the execution plan (tasks, waves, lane assignments)\n" +
|
|
184
|
+
"without actually executing anything.\n\n" +
|
|
185
|
+
"Options:\n" +
|
|
186
|
+
" --refresh Force re-scan of areas (bypass dependency cache)\n\n" +
|
|
187
|
+
"Examples:\n" +
|
|
188
|
+
" /orch-plan all\n" +
|
|
189
|
+
" /orch-plan time-off notifications\n" +
|
|
190
|
+
" /orch-plan docs/task-management/domains/time-off/tasks\n" +
|
|
191
|
+
" /orch-plan all --refresh",
|
|
192
|
+
"info",
|
|
193
|
+
);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Parse --refresh flag
|
|
198
|
+
const hasRefresh = /--refresh/.test(args);
|
|
199
|
+
const cleanArgs = args.replace(/--refresh/g, "").trim();
|
|
200
|
+
if (!cleanArgs) {
|
|
201
|
+
ctx.ui.notify(
|
|
202
|
+
"Usage: /orch-plan <areas|paths|all> [--refresh]\n" +
|
|
203
|
+
"Error: target argument required (e.g., 'all', area name, or path)",
|
|
204
|
+
"error",
|
|
205
|
+
);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (hasRefresh) {
|
|
209
|
+
ctx.ui.notify("🔄 Refresh mode: re-scanning all areas (cache bypassed)", "info");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Section 1: Preflight ─────────────────────────────────
|
|
213
|
+
const preflight = runPreflight(orchConfig);
|
|
214
|
+
ctx.ui.notify(formatPreflightResults(preflight), preflight.passed ? "info" : "error");
|
|
215
|
+
if (!preflight.passed) return;
|
|
216
|
+
|
|
217
|
+
// ── Section 2: Discovery ─────────────────────────────────
|
|
218
|
+
const discovery = runDiscovery(cleanArgs, runnerConfig.task_areas, ctx.cwd, {
|
|
219
|
+
refreshDependencies: hasRefresh,
|
|
220
|
+
dependencySource: orchConfig.dependencies.source,
|
|
221
|
+
useDependencyCache: orchConfig.dependencies.cache,
|
|
222
|
+
});
|
|
223
|
+
ctx.ui.notify(formatDiscoveryResults(discovery), discovery.errors.length > 0 ? "warning" : "info");
|
|
224
|
+
|
|
225
|
+
// Check for fatal errors
|
|
226
|
+
const fatalErrors = discovery.errors.filter(
|
|
227
|
+
(e) =>
|
|
228
|
+
e.code === "DUPLICATE_ID" ||
|
|
229
|
+
e.code === "DEP_UNRESOLVED" ||
|
|
230
|
+
e.code === "DEP_PENDING" ||
|
|
231
|
+
e.code === "DEP_AMBIGUOUS" ||
|
|
232
|
+
e.code === "PARSE_MISSING_ID",
|
|
233
|
+
);
|
|
234
|
+
if (fatalErrors.length > 0) {
|
|
235
|
+
ctx.ui.notify("❌ Cannot compute plan due to discovery errors above.", "error");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (discovery.pending.size === 0) {
|
|
240
|
+
ctx.ui.notify("No pending tasks found. Nothing to plan.", "info");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Section 3: Dependency Graph ──────────────────────────
|
|
245
|
+
ctx.ui.notify(
|
|
246
|
+
formatDependencyGraph(discovery.pending, discovery.completed),
|
|
247
|
+
"info",
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// ── Section 4: Waves + Estimate ──────────────────────────
|
|
251
|
+
// Uses computeWaveAssignments pipeline only — NO re-parsing
|
|
252
|
+
const waveResult = computeWaveAssignments(
|
|
253
|
+
discovery.pending,
|
|
254
|
+
discovery.completed,
|
|
255
|
+
orchConfig,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
ctx.ui.notify(
|
|
259
|
+
formatWavePlan(waveResult, orchConfig.assignment.size_weights),
|
|
260
|
+
waveResult.errors.length > 0 ? "error" : "info",
|
|
261
|
+
);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
pi.registerCommand("orch-status", {
|
|
266
|
+
description: "Show current batch progress",
|
|
267
|
+
handler: async (_args, ctx) => {
|
|
268
|
+
if (orchBatchState.phase === "idle") {
|
|
269
|
+
ctx.ui.notify("No batch is running. Use /orch <areas|paths|all> to start.", "info");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const elapsedSec = orchBatchState.endedAt
|
|
274
|
+
? Math.round((orchBatchState.endedAt - orchBatchState.startedAt) / 1000)
|
|
275
|
+
: Math.round((Date.now() - orchBatchState.startedAt) / 1000);
|
|
276
|
+
|
|
277
|
+
const lines: string[] = [
|
|
278
|
+
`📊 Batch ${orchBatchState.batchId} — ${orchBatchState.phase}`,
|
|
279
|
+
` Wave: ${orchBatchState.currentWaveIndex + 1}/${orchBatchState.totalWaves}`,
|
|
280
|
+
` Tasks: ${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ${orchBatchState.skippedTasks} skipped, ${orchBatchState.blockedTasks} blocked / ${orchBatchState.totalTasks} total`,
|
|
281
|
+
` Elapsed: ${elapsedSec}s`,
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
if (orchBatchState.errors.length > 0) {
|
|
285
|
+
lines.push(` Errors: ${orchBatchState.errors.length}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
pi.registerCommand("orch-pause", {
|
|
293
|
+
description: "Pause batch after current tasks finish",
|
|
294
|
+
handler: async (_args, ctx) => {
|
|
295
|
+
if (orchBatchState.phase === "idle" || orchBatchState.phase === "completed" || orchBatchState.phase === "failed" || orchBatchState.phase === "stopped") {
|
|
296
|
+
ctx.ui.notify(ORCH_MESSAGES.pauseNoBatch(), "warning");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (orchBatchState.phase === "paused" || orchBatchState.pauseSignal.paused) {
|
|
300
|
+
ctx.ui.notify(ORCH_MESSAGES.pauseAlreadyPaused(orchBatchState.batchId), "warning");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Set pause signal — executeLane() checks this between tasks
|
|
304
|
+
orchBatchState.pauseSignal.paused = true;
|
|
305
|
+
ctx.ui.notify(ORCH_MESSAGES.pauseActivated(orchBatchState.batchId), "info");
|
|
306
|
+
updateOrchWidget();
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
pi.registerCommand("orch-resume", {
|
|
311
|
+
description: "Resume a paused or interrupted batch",
|
|
312
|
+
handler: async (_args, ctx) => {
|
|
313
|
+
// Prevent resume if a batch is actively running
|
|
314
|
+
if (orchBatchState.phase === "executing" || orchBatchState.phase === "merging" || orchBatchState.phase === "planning") {
|
|
315
|
+
ctx.ui.notify(
|
|
316
|
+
`⚠️ A batch is currently ${orchBatchState.phase} (${orchBatchState.batchId}). Cannot resume.`,
|
|
317
|
+
"warning",
|
|
318
|
+
);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Reset batch state for resume
|
|
323
|
+
orchBatchState = freshOrchBatchState();
|
|
324
|
+
latestMonitorState = null;
|
|
325
|
+
updateOrchWidget();
|
|
326
|
+
|
|
327
|
+
await resumeOrchBatch(
|
|
328
|
+
orchConfig,
|
|
329
|
+
runnerConfig,
|
|
330
|
+
ctx.cwd,
|
|
331
|
+
orchBatchState,
|
|
332
|
+
(message, level) => {
|
|
333
|
+
ctx.ui.notify(message, level);
|
|
334
|
+
updateOrchWidget();
|
|
335
|
+
},
|
|
336
|
+
(monState: MonitorState) => {
|
|
337
|
+
latestMonitorState = monState;
|
|
338
|
+
updateOrchWidget();
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// Final widget update
|
|
343
|
+
updateOrchWidget();
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
pi.registerCommand("orch-abort", {
|
|
348
|
+
description: "Abort batch: /orch-abort [--hard]",
|
|
349
|
+
handler: async (args, ctx) => {
|
|
350
|
+
const hard = args?.trim() === "--hard";
|
|
351
|
+
const mode: AbortMode = hard ? "hard" : "graceful";
|
|
352
|
+
const prefix = orchConfig.orchestrator.tmux_prefix;
|
|
353
|
+
const gracePeriodMs = orchConfig.orchestrator.abort_grace_period * 1000;
|
|
354
|
+
|
|
355
|
+
// Check for active in-memory batch
|
|
356
|
+
const hasActiveBatch = orchBatchState.phase !== "idle" &&
|
|
357
|
+
orchBatchState.phase !== "completed" &&
|
|
358
|
+
orchBatchState.phase !== "failed" &&
|
|
359
|
+
orchBatchState.phase !== "stopped";
|
|
360
|
+
|
|
361
|
+
// Also check for persisted state (abort can work on orphaned batches too)
|
|
362
|
+
let persistedState: PersistedBatchState | null = null;
|
|
363
|
+
try {
|
|
364
|
+
persistedState = loadBatchState(ctx.cwd);
|
|
365
|
+
} catch {
|
|
366
|
+
// Ignore — we may still have in-memory state or orphan sessions
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// If no in-memory batch AND no persisted state, check for orphan sessions
|
|
370
|
+
if (!hasActiveBatch && !persistedState) {
|
|
371
|
+
// Last chance: check for orphan sessions
|
|
372
|
+
const sessionNames = parseOrchSessionNames(
|
|
373
|
+
(() => {
|
|
374
|
+
try {
|
|
375
|
+
return execSync('tmux list-sessions -F "#{session_name}"', {
|
|
376
|
+
encoding: "utf-8",
|
|
377
|
+
timeout: 5000,
|
|
378
|
+
});
|
|
379
|
+
} catch {
|
|
380
|
+
return "";
|
|
381
|
+
}
|
|
382
|
+
})(),
|
|
383
|
+
prefix,
|
|
384
|
+
);
|
|
385
|
+
if (sessionNames.length === 0) {
|
|
386
|
+
ctx.ui.notify(ORCH_MESSAGES.abortNoBatch(), "warning");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// If orphan sessions exist, proceed with abort (will kill them)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const batchId = orchBatchState.batchId || persistedState?.batchId || "unknown";
|
|
393
|
+
|
|
394
|
+
// Notify user of abort start
|
|
395
|
+
if (mode === "graceful") {
|
|
396
|
+
const sessionCount = orchBatchState.currentLanes.length || persistedState?.tasks.length || 0;
|
|
397
|
+
ctx.ui.notify(ORCH_MESSAGES.abortGracefulStarting(batchId, sessionCount), "info");
|
|
398
|
+
ctx.ui.notify(
|
|
399
|
+
ORCH_MESSAGES.abortGracefulWaiting(batchId, orchConfig.orchestrator.abort_grace_period),
|
|
400
|
+
"info",
|
|
401
|
+
);
|
|
402
|
+
} else {
|
|
403
|
+
const sessionCount = orchBatchState.currentLanes.length || persistedState?.tasks.length || 0;
|
|
404
|
+
ctx.ui.notify(ORCH_MESSAGES.abortHardStarting(batchId, sessionCount), "info");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Execute abort
|
|
408
|
+
const result = await executeAbort(
|
|
409
|
+
mode,
|
|
410
|
+
prefix,
|
|
411
|
+
ctx.cwd,
|
|
412
|
+
orchBatchState,
|
|
413
|
+
persistedState,
|
|
414
|
+
gracePeriodMs,
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// Update in-memory batch state
|
|
418
|
+
orchBatchState.phase = "stopped";
|
|
419
|
+
orchBatchState.endedAt = result.durationMs + Date.now() - result.durationMs; // Use actual time
|
|
420
|
+
updateOrchWidget();
|
|
421
|
+
|
|
422
|
+
// Notify results
|
|
423
|
+
const durationSec = Math.round(result.durationMs / 1000);
|
|
424
|
+
if (mode === "graceful") {
|
|
425
|
+
const forceKilled = result.sessionsKilled - result.gracefulExits;
|
|
426
|
+
if (forceKilled > 0) {
|
|
427
|
+
ctx.ui.notify(
|
|
428
|
+
ORCH_MESSAGES.abortGracefulForceKill(forceKilled),
|
|
429
|
+
"warning",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
ctx.ui.notify(
|
|
433
|
+
ORCH_MESSAGES.abortGracefulComplete(batchId, result.gracefulExits, forceKilled, durationSec),
|
|
434
|
+
"info",
|
|
435
|
+
);
|
|
436
|
+
} else {
|
|
437
|
+
ctx.ui.notify(
|
|
438
|
+
ORCH_MESSAGES.abortHardComplete(batchId, result.sessionsKilled, durationSec),
|
|
439
|
+
"info",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Report errors if any
|
|
444
|
+
if (result.errors.length > 0) {
|
|
445
|
+
const errorDetails = result.errors.map(e => ` • [${e.code}] ${e.message}`).join("\n");
|
|
446
|
+
ctx.ui.notify(
|
|
447
|
+
`${ORCH_MESSAGES.abortPartialFailure(result.errors.length)}\n${errorDetails}`,
|
|
448
|
+
"warning",
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Final message
|
|
453
|
+
ctx.ui.notify(
|
|
454
|
+
ORCH_MESSAGES.abortComplete(mode, result.sessionsKilled),
|
|
455
|
+
"info",
|
|
456
|
+
);
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
pi.registerCommand("orch-deps", {
|
|
461
|
+
description: "Show dependency graph: /orch-deps <areas|paths|all> [--refresh] [--task <id>]",
|
|
462
|
+
handler: async (args, ctx) => {
|
|
463
|
+
if (!args?.trim()) {
|
|
464
|
+
ctx.ui.notify(
|
|
465
|
+
"Usage: /orch-deps <areas|paths|all> [--refresh] [--task <id>]\n\n" +
|
|
466
|
+
"Shows the dependency graph for tasks in the specified areas.\n\n" +
|
|
467
|
+
"Options:\n" +
|
|
468
|
+
" --refresh Force re-scan of areas (bypass dependency cache)\n" +
|
|
469
|
+
" --task <id> Show dependencies for a single task only\n\n" +
|
|
470
|
+
"Examples:\n" +
|
|
471
|
+
" /orch-deps all\n" +
|
|
472
|
+
" /orch-deps all --task TO-014\n" +
|
|
473
|
+
" /orch-deps time-off --refresh\n" +
|
|
474
|
+
" /orch-deps all --task COMP-006 --refresh",
|
|
475
|
+
"info",
|
|
476
|
+
);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Parse --refresh flag
|
|
481
|
+
const hasRefresh = /--refresh/.test(args);
|
|
482
|
+
|
|
483
|
+
// Parse --task <id> flag
|
|
484
|
+
let filterTaskId: string | undefined;
|
|
485
|
+
const taskMatch = args.match(/--task\s+([A-Z]+-\d+)/i);
|
|
486
|
+
if (taskMatch) {
|
|
487
|
+
filterTaskId = taskMatch[1].toUpperCase();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Strip flags to get clean area/path arguments
|
|
491
|
+
let cleanArgs = args
|
|
492
|
+
.replace(/--refresh/g, "")
|
|
493
|
+
.replace(/--task\s+[A-Z]+-\d+/gi, "")
|
|
494
|
+
.trim();
|
|
495
|
+
|
|
496
|
+
if (!cleanArgs) {
|
|
497
|
+
ctx.ui.notify(
|
|
498
|
+
"Usage: /orch-deps <areas|paths|all> [--refresh] [--task <id>]\n" +
|
|
499
|
+
"Error: target argument required (e.g., 'all', area name, or path)",
|
|
500
|
+
"error",
|
|
501
|
+
);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (hasRefresh) {
|
|
506
|
+
ctx.ui.notify("🔄 Refresh mode: re-scanning all areas (dependency cache bypassed)", "info");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Run discovery (no preflight needed for deps view)
|
|
510
|
+
const discovery = runDiscovery(cleanArgs, runnerConfig.task_areas, ctx.cwd, {
|
|
511
|
+
refreshDependencies: hasRefresh,
|
|
512
|
+
dependencySource: orchConfig.dependencies.source,
|
|
513
|
+
useDependencyCache: orchConfig.dependencies.cache,
|
|
514
|
+
});
|
|
515
|
+
ctx.ui.notify(
|
|
516
|
+
formatDiscoveryResults(discovery),
|
|
517
|
+
discovery.errors.length > 0 ? "warning" : "info",
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
// Show dependency graph (full or filtered)
|
|
521
|
+
if (discovery.pending.size > 0) {
|
|
522
|
+
ctx.ui.notify(
|
|
523
|
+
formatDependencyGraph(
|
|
524
|
+
discovery.pending,
|
|
525
|
+
discovery.completed,
|
|
526
|
+
filterTaskId,
|
|
527
|
+
),
|
|
528
|
+
"info",
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
pi.registerCommand("orch-sessions", {
|
|
535
|
+
description: "List active orchestrator TMUX sessions",
|
|
536
|
+
handler: async (_args, ctx) => {
|
|
537
|
+
const sessions = listOrchSessions(orchConfig.orchestrator.tmux_prefix, orchBatchState);
|
|
538
|
+
ctx.ui.notify(formatOrchSessions(sessions), "info");
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ── Session Lifecycle ────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
545
|
+
// Load configs
|
|
546
|
+
orchConfig = loadOrchestratorConfig(ctx.cwd);
|
|
547
|
+
runnerConfig = loadTaskRunnerConfig(ctx.cwd);
|
|
548
|
+
|
|
549
|
+
// Store widget context for dashboard updates
|
|
550
|
+
orchWidgetCtx = ctx;
|
|
551
|
+
|
|
552
|
+
// Set status line
|
|
553
|
+
const areaCount = Object.keys(runnerConfig.task_areas).length;
|
|
554
|
+
ctx.ui.setStatus(
|
|
555
|
+
"task-orchestrator",
|
|
556
|
+
`🔀 Orchestrator · ${areaCount} areas · ${orchConfig.orchestrator.max_lanes} lanes`,
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
// Register initial dashboard widget (idle state)
|
|
560
|
+
updateOrchWidget();
|
|
561
|
+
|
|
562
|
+
// Notify user of available commands
|
|
563
|
+
ctx.ui.notify(
|
|
564
|
+
"Task Orchestrator ready\n\n" +
|
|
565
|
+
`Config: ${orchConfig.orchestrator.max_lanes} lanes, ` +
|
|
566
|
+
`${orchConfig.orchestrator.spawn_mode} mode, ` +
|
|
567
|
+
`${orchConfig.dependencies.source} deps\n` +
|
|
568
|
+
`Areas: ${areaCount} registered\n\n` +
|
|
569
|
+
"/orch <areas|all> Start batch execution\n" +
|
|
570
|
+
"/orch-plan <areas|all> Preview execution plan\n" +
|
|
571
|
+
"/orch-deps <areas|all> Show dependency graph\n" +
|
|
572
|
+
"/orch-sessions List TMUX sessions",
|
|
573
|
+
"info",
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|