taskplane 0.25.8 → 0.27.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 +4 -1
- package/bin/rpc-wrapper.mjs +0 -1
- package/bin/taskplane.mjs +13 -6
- package/dashboard/public/app.js +44 -1
- package/extensions/task-orchestrator.ts +1 -1
- package/extensions/taskplane/abort.ts +2 -1
- package/extensions/taskplane/agent-host.ts +100 -1
- package/extensions/taskplane/cleanup.ts +272 -10
- package/extensions/taskplane/config-loader.ts +53 -0
- package/extensions/taskplane/context-window.ts +66 -0
- package/extensions/taskplane/engine.ts +182 -47
- package/extensions/taskplane/execution.ts +250 -66
- package/extensions/taskplane/extension.ts +5125 -5105
- package/extensions/taskplane/formatting.ts +70 -11
- package/extensions/taskplane/git.ts +34 -0
- package/extensions/taskplane/lane-runner.ts +219 -10
- package/extensions/taskplane/merge.ts +3128 -2917
- package/extensions/taskplane/path-resolver.ts +1 -1
- package/extensions/taskplane/persistence.ts +3 -0
- package/extensions/taskplane/resume.ts +86 -30
- package/extensions/taskplane/sidecar-telemetry.ts +252 -0
- package/extensions/taskplane/supervisor-primer.md +57 -0
- package/extensions/taskplane/supervisor.ts +1 -1
- package/extensions/taskplane/task-executor-core.ts +2 -5
- package/extensions/taskplane/types.ts +27 -2
- package/package.json +1 -3
- package/templates/agents/task-worker.md +429 -387
- package/templates/tasks/CONTEXT.md +3 -4
- package/extensions/task-runner.ts +0 -2784
|
@@ -158,7 +158,7 @@ export function resolvePiCliPath(): string {
|
|
|
158
158
|
*
|
|
159
159
|
* @param repoRoot - Absolute path to the project root (used for local dev check)
|
|
160
160
|
* @param relPath - Relative path within the taskplane package, e.g.
|
|
161
|
-
* `"extensions/task-
|
|
161
|
+
* `"extensions/task-orchestrator.ts"` or `"templates/agents/task-worker.md"`
|
|
162
162
|
* @returns Absolute path to the resolved file. If not found in any location,
|
|
163
163
|
* returns the local path (`join(repoRoot, relPath)`) as a fallback — callers
|
|
164
164
|
* will fail at use time with a clear "file not found" error.
|
|
@@ -1359,6 +1359,9 @@ export function serializeBatchState(
|
|
|
1359
1359
|
endedAt: state.endedAt,
|
|
1360
1360
|
currentWaveIndex: state.currentWaveIndex,
|
|
1361
1361
|
totalWaves: state.totalWaves,
|
|
1362
|
+
// TP-166: Persist task-level wave metadata for correct display after resume
|
|
1363
|
+
...(state.taskLevelWaveCount != null ? { taskLevelWaveCount: state.taskLevelWaveCount } : {}),
|
|
1364
|
+
...(state.roundToTaskWave != null ? { roundToTaskWave: [...state.roundToTaskWave] } : {}),
|
|
1362
1365
|
wavePlan,
|
|
1363
1366
|
lanes: laneRecords,
|
|
1364
1367
|
tasks: taskRecords,
|
|
@@ -7,7 +7,7 @@ import { join } from "path";
|
|
|
7
7
|
|
|
8
8
|
import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
|
|
9
9
|
import { runDiscovery } from "./discovery.ts";
|
|
10
|
-
import { executeOrchBatch } from "./engine.ts";
|
|
10
|
+
import { executeOrchBatch, resolveDisplayWaveNumber } from "./engine.ts";
|
|
11
11
|
import { buildReviewerEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, resolveCanonicalTaskPaths } from "./execution.ts";
|
|
12
12
|
import type { MonitorUpdateCallback, RuntimeBackend } from "./execution.ts";
|
|
13
13
|
import { selectRuntimeBackend } from "./engine.ts";
|
|
@@ -158,9 +158,14 @@ export function reconstructAllocatedLanes(
|
|
|
158
158
|
if (persistedTask?.resolvedRepoId !== undefined) {
|
|
159
159
|
taskStub.resolvedRepoId = persistedTask.resolvedRepoId;
|
|
160
160
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
// TP-169: Always set taskFolder on stub, even if empty string.
|
|
162
|
+
// Previously, the falsy check `if (persistedTask?.taskFolder)` skipped
|
|
163
|
+
// empty-string values, leaving taskFolder as `undefined` on the stub.
|
|
164
|
+
// This caused crashes in buildExecutionUnit and merge code when
|
|
165
|
+
// accessing `task.task.taskFolder` on dynamically-expanded segments
|
|
166
|
+
// whose persisted records had taskFolder="" (the default from
|
|
167
|
+
// serializeBatchState before enrichment).
|
|
168
|
+
taskStub.taskFolder = persistedTask?.taskFolder ?? "";
|
|
164
169
|
if ((persistedTask as any)?.packetRepoId !== undefined) {
|
|
165
170
|
(taskStub as any).packetRepoId = (persistedTask as any).packetRepoId;
|
|
166
171
|
}
|
|
@@ -1324,6 +1329,10 @@ export async function resumeOrchBatch(
|
|
|
1324
1329
|
// Preserve pauseSignal if already set during "launching" phase (TP-040)
|
|
1325
1330
|
if (!batchState.pauseSignal?.paused) batchState.pauseSignal = { paused: false };
|
|
1326
1331
|
batchState.totalWaves = persistedState.totalWaves;
|
|
1332
|
+
// TP-166: Restore task-level wave metadata for correct display.
|
|
1333
|
+
// Normalize: fall back to totalWaves for pre-TP-166 state files.
|
|
1334
|
+
batchState.taskLevelWaveCount = persistedState.taskLevelWaveCount ?? persistedState.totalWaves;
|
|
1335
|
+
batchState.roundToTaskWave = persistedState.roundToTaskWave ? [...persistedState.roundToTaskWave] : undefined;
|
|
1327
1336
|
batchState.totalTasks = persistedState.totalTasks;
|
|
1328
1337
|
batchState.succeededTasks = resumePoint.completedTaskIds.length;
|
|
1329
1338
|
batchState.failedTasks = resumePoint.failedTaskIds.length;
|
|
@@ -1372,6 +1381,42 @@ export async function resumeOrchBatch(
|
|
|
1372
1381
|
batchState._extraFields = persistedState._extraFields;
|
|
1373
1382
|
}
|
|
1374
1383
|
|
|
1384
|
+
// ── 6b. TP-169: Verify orch branch exists in all workspace repos ────
|
|
1385
|
+
// During the original batch start, the orch branch was created in every
|
|
1386
|
+
// workspace repo. On resume, we verify it still exists. If it's missing
|
|
1387
|
+
// in any repo (e.g., deleted by user, corrupted), re-create it from the
|
|
1388
|
+
// repo's current branch so that worktree creation doesn't silently fall
|
|
1389
|
+
// back to the base branch, bypassing orch branch isolation.
|
|
1390
|
+
if (workspaceConfig && batchState.orchBranch) {
|
|
1391
|
+
for (const [repoId, repoConf] of workspaceConfig.repos) {
|
|
1392
|
+
const rRoot = repoConf.path;
|
|
1393
|
+
const check = runGit(["rev-parse", "--verify", `refs/heads/${batchState.orchBranch}`], rRoot);
|
|
1394
|
+
if (!check.ok) {
|
|
1395
|
+
// Orch branch missing in this repo — re-create from current HEAD
|
|
1396
|
+
const repoBranch = getCurrentBranch(rRoot) || "HEAD";
|
|
1397
|
+
const createRes = runGit(["branch", batchState.orchBranch, repoBranch], rRoot);
|
|
1398
|
+
if (createRes.ok) {
|
|
1399
|
+
execLog("resume", batchState.batchId, `re-created missing orch branch in ${repoId}`, {
|
|
1400
|
+
orchBranch: batchState.orchBranch,
|
|
1401
|
+
base: repoBranch,
|
|
1402
|
+
});
|
|
1403
|
+
onNotify(
|
|
1404
|
+
`⚠️ Orch branch "${batchState.orchBranch}" was missing in repo "${repoId}" — re-created from ${repoBranch}`,
|
|
1405
|
+
"warning",
|
|
1406
|
+
);
|
|
1407
|
+
} else {
|
|
1408
|
+
const errMsg = `Failed to re-create orch branch "${batchState.orchBranch}" in repo "${repoId}": ${createRes.stderr}. ` +
|
|
1409
|
+
`Cannot resume without orch branch isolation.`;
|
|
1410
|
+
execLog("resume", batchState.batchId, errMsg, {
|
|
1411
|
+
orchBranch: batchState.orchBranch,
|
|
1412
|
+
error: createRes.stderr,
|
|
1413
|
+
});
|
|
1414
|
+
throw new Error(errMsg);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1375
1420
|
// ── 7. Re-run discovery for ParsedTask metadata ──────────────
|
|
1376
1421
|
// We need fresh ParsedTask data (taskFolder, promptPath) for execution.
|
|
1377
1422
|
// Use "all" to discover all areas.
|
|
@@ -1767,12 +1812,17 @@ export async function resumeOrchBatch(
|
|
|
1767
1812
|
persistedState.tasks.map((task) => [task.taskId, task.status] as const),
|
|
1768
1813
|
);
|
|
1769
1814
|
|
|
1815
|
+
// TP-166: Use task-level wave metadata for correct display.
|
|
1816
|
+
const roundToTaskWave = batchState.roundToTaskWave;
|
|
1817
|
+
const taskLevelWaveCount = batchState.taskLevelWaveCount;
|
|
1818
|
+
|
|
1770
1819
|
for (let waveIdx = resumePoint.resumeWaveIndex; waveIdx < wavePlan.length; waveIdx++) {
|
|
1771
1820
|
// Check pause signal
|
|
1772
1821
|
if (batchState.pauseSignal.paused) {
|
|
1773
1822
|
batchState.phase = "paused";
|
|
1774
1823
|
persistRuntimeState("pause-before-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
|
|
1775
|
-
|
|
1824
|
+
const { displayWave: pauseWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount);
|
|
1825
|
+
onNotify(`⏸️ Batch paused before wave ${pauseWave}.`, "warning");
|
|
1776
1826
|
break;
|
|
1777
1827
|
}
|
|
1778
1828
|
|
|
@@ -1807,7 +1857,7 @@ export async function resumeOrchBatch(
|
|
|
1807
1857
|
// All tasks are terminal but the merge may have failed/been interrupted.
|
|
1808
1858
|
if (resumePoint.mergeRetryWaveIndexes.includes(waveIdx)) {
|
|
1809
1859
|
execLog("resume", batchState.batchId, `wave ${waveIdx + 1}: all tasks done but merge needs retry`);
|
|
1810
|
-
onNotify(`🔀 Wave ${waveIdx
|
|
1860
|
+
onNotify(`🔀 Wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave}: retrying merge (tasks already complete, merge was missing/failed)`, "info");
|
|
1811
1861
|
|
|
1812
1862
|
// Reconstruct lanes for this wave from persisted state
|
|
1813
1863
|
const waveTaskIds = new Set(wavePlan[waveIdx]);
|
|
@@ -1922,7 +1972,7 @@ export async function resumeOrchBatch(
|
|
|
1922
1972
|
batchState.mergeResults.push(mergeRetryResult);
|
|
1923
1973
|
|
|
1924
1974
|
if (mergeRetryResult.status === "succeeded") {
|
|
1925
|
-
onNotify(`✅ Wave ${waveIdx
|
|
1975
|
+
onNotify(`✅ Wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave} merge retry succeeded`, "info");
|
|
1926
1976
|
// Clean up merged branches
|
|
1927
1977
|
for (const lr of mergeRetryResult.laneResults) {
|
|
1928
1978
|
if (!lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED")) {
|
|
@@ -1932,7 +1982,7 @@ export async function resumeOrchBatch(
|
|
|
1932
1982
|
}
|
|
1933
1983
|
} else {
|
|
1934
1984
|
onNotify(
|
|
1935
|
-
`⚠️ Wave ${waveIdx
|
|
1985
|
+
`⚠️ Wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave} merge retry ${mergeRetryResult.status}: ${mergeRetryResult.failureReason || "unknown"}`,
|
|
1936
1986
|
"warning",
|
|
1937
1987
|
);
|
|
1938
1988
|
// Apply merge failure policy (same as normal wave merge failure)
|
|
@@ -1954,10 +2004,13 @@ export async function resumeOrchBatch(
|
|
|
1954
2004
|
continue;
|
|
1955
2005
|
}
|
|
1956
2006
|
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
2007
|
+
{
|
|
2008
|
+
const { displayWave, displayTotal } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount);
|
|
2009
|
+
onNotify(
|
|
2010
|
+
ORCH_MESSAGES.orchWaveStart(displayWave, displayTotal, waveTasks.length, Math.min(waveTasks.length, orchConfig.orchestrator.max_lanes)),
|
|
2011
|
+
"info",
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
1961
2014
|
|
|
1962
2015
|
const handleResumeMonitorUpdate: MonitorUpdateCallback = (monitorState) => {
|
|
1963
2016
|
const changed = syncTaskOutcomesFromMonitor(monitorState, allTaskOutcomes);
|
|
@@ -2064,7 +2117,7 @@ export async function resumeOrchBatch(
|
|
|
2064
2117
|
frontierSummary +
|
|
2065
2118
|
` Lane: ${laneForTask?.laneId ?? "unknown"} (lane ${laneForTask?.laneNumber ?? "?"})\n` +
|
|
2066
2119
|
` Partial progress preserved: ${hasPartialProgress ? "yes" : "no"}\n` +
|
|
2067
|
-
` Batch: wave ${waveIdx
|
|
2120
|
+
` Batch: wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave}/${taskLevelWaveCount ?? batchState.totalWaves}, ` +
|
|
2068
2121
|
`${batchState.succeededTasks} succeeded, ${batchState.failedTasks} failed\n\n` +
|
|
2069
2122
|
`Available actions:\n` +
|
|
2070
2123
|
` - orch_status() to inspect current state\n` +
|
|
@@ -2088,16 +2141,19 @@ export async function resumeOrchBatch(
|
|
|
2088
2141
|
persistRuntimeState("wave-execution-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
|
|
2089
2142
|
|
|
2090
2143
|
const elapsedSec = Math.round((waveResult.endedAt - waveResult.startedAt) / 1000);
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2144
|
+
{
|
|
2145
|
+
const { displayWave: completeDisplayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount);
|
|
2146
|
+
onNotify(
|
|
2147
|
+
ORCH_MESSAGES.orchWaveComplete(
|
|
2148
|
+
completeDisplayWave,
|
|
2149
|
+
waveResult.succeededTaskIds.length,
|
|
2150
|
+
waveResult.failedTaskIds.length,
|
|
2151
|
+
waveResult.skippedTaskIds.length,
|
|
2152
|
+
elapsedSec,
|
|
2153
|
+
),
|
|
2154
|
+
waveResult.failedTaskIds.length > 0 ? "warning" : "info",
|
|
2155
|
+
);
|
|
2156
|
+
}
|
|
2101
2157
|
|
|
2102
2158
|
// Check failure policy
|
|
2103
2159
|
if (waveResult.stoppedEarly) {
|
|
@@ -2144,7 +2200,7 @@ export async function resumeOrchBatch(
|
|
|
2144
2200
|
if (mergeableLaneCount > 0) {
|
|
2145
2201
|
batchState.phase = "merging";
|
|
2146
2202
|
persistRuntimeState("merge-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
|
|
2147
|
-
onNotify(ORCH_MESSAGES.orchMergeStart(waveIdx
|
|
2203
|
+
onNotify(ORCH_MESSAGES.orchMergeStart(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeableLaneCount), "info");
|
|
2148
2204
|
|
|
2149
2205
|
mergeResult = await mergeWaveByRepo(
|
|
2150
2206
|
waveResult.allocatedLanes,
|
|
@@ -2197,10 +2253,10 @@ export async function resumeOrchBatch(
|
|
|
2197
2253
|
const mergeTotalSec = Math.round(mergeResult.totalDurationMs / 1000);
|
|
2198
2254
|
|
|
2199
2255
|
if (mergeResult.status === "succeeded") {
|
|
2200
|
-
onNotify(ORCH_MESSAGES.orchMergeComplete(waveIdx
|
|
2256
|
+
onNotify(ORCH_MESSAGES.orchMergeComplete(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergedCount, mergeTotalSec), "info");
|
|
2201
2257
|
} else {
|
|
2202
2258
|
onNotify(
|
|
2203
|
-
ORCH_MESSAGES.orchMergeFailed(waveIdx
|
|
2259
|
+
ORCH_MESSAGES.orchMergeFailed(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"),
|
|
2204
2260
|
"error",
|
|
2205
2261
|
);
|
|
2206
2262
|
|
|
@@ -2231,14 +2287,14 @@ export async function resumeOrchBatch(
|
|
|
2231
2287
|
// Downstream retry/update paths assume the current wave has an entry.
|
|
2232
2288
|
batchState.mergeResults.push(mergeResult);
|
|
2233
2289
|
onNotify(
|
|
2234
|
-
ORCH_MESSAGES.orchMergeFailed(waveIdx
|
|
2290
|
+
ORCH_MESSAGES.orchMergeFailed(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeResult.failedLane, mergeResult.failureReason || "unknown"),
|
|
2235
2291
|
"error",
|
|
2236
2292
|
);
|
|
2237
2293
|
} else {
|
|
2238
|
-
onNotify(ORCH_MESSAGES.orchMergeSkipped(waveIdx
|
|
2294
|
+
onNotify(ORCH_MESSAGES.orchMergeSkipped(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave), "info");
|
|
2239
2295
|
}
|
|
2240
2296
|
} else {
|
|
2241
|
-
onNotify(ORCH_MESSAGES.orchMergeSkipped(waveIdx
|
|
2297
|
+
onNotify(ORCH_MESSAGES.orchMergeSkipped(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave), "info");
|
|
2242
2298
|
}
|
|
2243
2299
|
|
|
2244
2300
|
// ── TP-033: Safe-stop on rollback failure ─────────────────
|
|
@@ -2755,7 +2811,7 @@ export async function resumeOrchBatch(
|
|
|
2755
2811
|
summary:
|
|
2756
2812
|
`✅ Batch ${batchState.batchId} completed\n` +
|
|
2757
2813
|
` ${batchState.succeededTasks}/${batchState.totalTasks} tasks succeeded\n` +
|
|
2758
|
-
` ${batchState.totalWaves} wave(s), duration: ${durationStr}\n` +
|
|
2814
|
+
` ${batchState.taskLevelWaveCount ?? batchState.totalWaves} wave(s), duration: ${durationStr}\n` +
|
|
2759
2815
|
` Merged to orch branch: ${batchState.orchBranch}\n\n` +
|
|
2760
2816
|
`Ready for integration. Run orch_integrate() or review first.`,
|
|
2761
2817
|
context: {
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar Telemetry Utilities
|
|
3
|
+
*
|
|
4
|
+
* Canonical home for sidecar JSONL tailing and telemetry delta parsing.
|
|
5
|
+
*
|
|
6
|
+
* These utilities are used by the orchestrator to poll live worker telemetry
|
|
7
|
+
* (token usage, tool calls, retry state) from sidecar JSONL files written by
|
|
8
|
+
* the pi coding agent during task execution.
|
|
9
|
+
*
|
|
10
|
+
* @since TP-161
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, statSync, openSync, readSync, closeSync } from "fs";
|
|
14
|
+
import { join, dirname } from "path";
|
|
15
|
+
|
|
16
|
+
// ── Sidecar Directory Resolution ──────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns the .pi directory path for sidecar files (lane state, conversation logs).
|
|
20
|
+
* In orchestrated mode, the orchestrator passes ORCH_SIDECAR_DIR pointing to the
|
|
21
|
+
* MAIN repo's .pi/ directory (not the worktree's).
|
|
22
|
+
*/
|
|
23
|
+
export function getSidecarDir(): string {
|
|
24
|
+
// Orchestrator provides the main repo .pi path
|
|
25
|
+
const orchDir = process.env.ORCH_SIDECAR_DIR;
|
|
26
|
+
if (orchDir) {
|
|
27
|
+
if (!existsSync(orchDir)) mkdirSync(orchDir, { recursive: true });
|
|
28
|
+
return orchDir;
|
|
29
|
+
}
|
|
30
|
+
// Fallback: walk up from cwd
|
|
31
|
+
let dir = process.cwd();
|
|
32
|
+
for (let i = 0; i < 10; i++) {
|
|
33
|
+
const piDir = join(dir, ".pi");
|
|
34
|
+
if (existsSync(piDir)) return piDir;
|
|
35
|
+
const parent = dirname(dir);
|
|
36
|
+
if (parent === dir) break;
|
|
37
|
+
dir = parent;
|
|
38
|
+
}
|
|
39
|
+
const piDir = join(process.cwd(), ".pi");
|
|
40
|
+
if (!existsSync(piDir)) mkdirSync(piDir, { recursive: true });
|
|
41
|
+
return piDir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Sidecar Tail State ────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Mutable state for incremental byte-offset sidecar JSONL reading.
|
|
48
|
+
* One instance per sidecar file, persists across poll ticks within a session.
|
|
49
|
+
*/
|
|
50
|
+
export interface SidecarTailState {
|
|
51
|
+
/** Byte offset of the next unread position in the sidecar file */
|
|
52
|
+
offset: number;
|
|
53
|
+
/** Partial trailing line from the last read (incomplete JSONL line) */
|
|
54
|
+
partial: string;
|
|
55
|
+
/** Whether a retry is currently active (persisted across ticks) */
|
|
56
|
+
retryActive: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createSidecarTailState(): SidecarTailState {
|
|
60
|
+
return { offset: 0, partial: "", retryActive: false };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Sidecar Telemetry Delta ───────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parsed telemetry accumulated from sidecar JSONL events.
|
|
67
|
+
* Returned by tailSidecarJsonl() on each tick.
|
|
68
|
+
*/
|
|
69
|
+
export interface SidecarTelemetryDelta {
|
|
70
|
+
/** Per-turn input tokens (sum of new message_end events in this tick) */
|
|
71
|
+
inputTokens: number;
|
|
72
|
+
outputTokens: number;
|
|
73
|
+
cacheReadTokens: number;
|
|
74
|
+
cacheWriteTokens: number;
|
|
75
|
+
/** Incremental cost from new message_end events */
|
|
76
|
+
cost: number;
|
|
77
|
+
/** Most recent totalTokens from message_end usage (cumulative, for context %) */
|
|
78
|
+
latestTotalTokens: number;
|
|
79
|
+
/** Tool calls observed in this tick */
|
|
80
|
+
toolCalls: number;
|
|
81
|
+
/** Last tool description from tool_execution_start */
|
|
82
|
+
lastTool: string;
|
|
83
|
+
/** Whether a retry is currently active (persisted across ticks via SidecarTailState) */
|
|
84
|
+
retryActive: boolean;
|
|
85
|
+
/** Total retries started in this tick */
|
|
86
|
+
retriesStarted: number;
|
|
87
|
+
/** Error message from the most recent auto_retry_start */
|
|
88
|
+
lastRetryError: string;
|
|
89
|
+
/** Whether any sidecar events were parsed in this tick (used for callback gating) */
|
|
90
|
+
hadEvents: boolean;
|
|
91
|
+
/** Authoritative context usage from pi get_session_stats (pi ≥ 0.63.0, null if unavailable) */
|
|
92
|
+
contextUsage: { percent: number; totalTokens: number; maxTokens: number } | null;
|
|
93
|
+
/** True when a get_session_stats response was seen but lacked contextUsage (older pi) */
|
|
94
|
+
sawStatsResponseWithoutContextUsage: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Incremental JSONL Tailing ─────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Incrementally read new lines from a sidecar JSONL file and parse telemetry events.
|
|
101
|
+
*
|
|
102
|
+
* O(new) per call — only reads bytes after the previous offset. Handles:
|
|
103
|
+
* - File not yet created (returns zero delta)
|
|
104
|
+
* - Empty reads (no new data since last tick)
|
|
105
|
+
* - Partial trailing lines (buffered for next call)
|
|
106
|
+
* - Malformed JSON lines (skipped with stderr warning, does not break iteration)
|
|
107
|
+
*
|
|
108
|
+
* The caller (poll loop) accumulates the returned deltas into TaskState.
|
|
109
|
+
*/
|
|
110
|
+
export function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): SidecarTelemetryDelta {
|
|
111
|
+
const delta: SidecarTelemetryDelta = {
|
|
112
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
|
113
|
+
cost: 0, latestTotalTokens: 0, toolCalls: 0, lastTool: "",
|
|
114
|
+
retryActive: tailState.retryActive, retriesStarted: 0, lastRetryError: "",
|
|
115
|
+
hadEvents: false, contextUsage: null, sawStatsResponseWithoutContextUsage: false,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Gracefully handle missing file (wrapper hasn't written yet)
|
|
119
|
+
let fileSize: number;
|
|
120
|
+
try {
|
|
121
|
+
fileSize = statSync(filePath).size;
|
|
122
|
+
} catch {
|
|
123
|
+
return delta; // File doesn't exist yet — no-op
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (fileSize <= tailState.offset) {
|
|
127
|
+
return delta; // No new data
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Read new bytes from offset to end of file
|
|
131
|
+
const bytesToRead = fileSize - tailState.offset;
|
|
132
|
+
const buf = Buffer.alloc(bytesToRead);
|
|
133
|
+
let fd: number;
|
|
134
|
+
try {
|
|
135
|
+
fd = openSync(filePath, "r");
|
|
136
|
+
} catch {
|
|
137
|
+
return delta; // File became inaccessible between stat and open
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
readSync(fd, buf, 0, bytesToRead, tailState.offset);
|
|
141
|
+
} catch {
|
|
142
|
+
closeSync(fd);
|
|
143
|
+
return delta; // Read error — try again next tick
|
|
144
|
+
}
|
|
145
|
+
closeSync(fd);
|
|
146
|
+
tailState.offset = fileSize;
|
|
147
|
+
|
|
148
|
+
// Split into lines, preserving any partial trailing line
|
|
149
|
+
const chunk = tailState.partial + buf.toString("utf-8");
|
|
150
|
+
const lines = chunk.split("\n");
|
|
151
|
+
// Last element is either "" (if chunk ended with \n) or a partial line
|
|
152
|
+
tailState.partial = lines.pop() || "";
|
|
153
|
+
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
const trimmed = line.trim();
|
|
156
|
+
if (!trimmed) continue;
|
|
157
|
+
|
|
158
|
+
let event: any;
|
|
159
|
+
try {
|
|
160
|
+
event = JSON.parse(trimmed);
|
|
161
|
+
} catch {
|
|
162
|
+
// Malformed JSON — skip silently (concurrent write race, truncated line)
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!event || !event.type) continue;
|
|
167
|
+
|
|
168
|
+
delta.hadEvents = true;
|
|
169
|
+
|
|
170
|
+
switch (event.type) {
|
|
171
|
+
case "message_end": {
|
|
172
|
+
const usage = event.message?.usage;
|
|
173
|
+
if (usage) {
|
|
174
|
+
delta.inputTokens += usage.input || 0;
|
|
175
|
+
delta.outputTokens += usage.output || 0;
|
|
176
|
+
delta.cacheReadTokens += usage.cacheRead || 0;
|
|
177
|
+
delta.cacheWriteTokens += usage.cacheWrite || 0;
|
|
178
|
+
if (usage.cost) {
|
|
179
|
+
delta.cost += typeof usage.cost === "object"
|
|
180
|
+
? (usage.cost.total || 0)
|
|
181
|
+
: (typeof usage.cost === "number" ? usage.cost : 0);
|
|
182
|
+
}
|
|
183
|
+
// totalTokens is cumulative (grows each turn) — use latest value.
|
|
184
|
+
// Include cacheRead tokens: pi's totalTokens and the
|
|
185
|
+
// input+output fallback both exclude cache reads, but cached
|
|
186
|
+
// tokens still consume context window capacity.
|
|
187
|
+
const rawTotal = usage.totalTokens
|
|
188
|
+
|| ((usage.input || 0) + (usage.output || 0));
|
|
189
|
+
const totalTokens = rawTotal + (usage.cacheRead || 0);
|
|
190
|
+
if (totalTokens > delta.latestTotalTokens) {
|
|
191
|
+
delta.latestTotalTokens = totalTokens;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case "tool_execution_start": {
|
|
198
|
+
delta.toolCalls++;
|
|
199
|
+
const toolDesc = event.toolName || "unknown";
|
|
200
|
+
let argPreview = "";
|
|
201
|
+
if (event.args) {
|
|
202
|
+
if (typeof event.args === "string") {
|
|
203
|
+
argPreview = event.args.slice(0, 80);
|
|
204
|
+
} else if (typeof event.args === "object") {
|
|
205
|
+
const firstVal = Object.values(event.args)[0];
|
|
206
|
+
if (typeof firstVal === "string") {
|
|
207
|
+
argPreview = (firstVal as string).slice(0, 80);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
delta.lastTool = argPreview ? `${toolDesc} ${argPreview}` : toolDesc;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case "auto_retry_start": {
|
|
216
|
+
delta.retriesStarted++;
|
|
217
|
+
delta.lastRetryError = event.errorMessage || event.error || "unknown";
|
|
218
|
+
tailState.retryActive = true;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case "auto_retry_end": {
|
|
223
|
+
tailState.retryActive = false;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
case "response": {
|
|
228
|
+
// get_session_stats response from pi ≥ 0.63.0 — authoritative context usage
|
|
229
|
+
if (event.success === true && event.data?.contextUsage) {
|
|
230
|
+
const cu = event.data.contextUsage;
|
|
231
|
+
// pi sends `percent` (pi ≥ 0.63.0); accept `percentUsed` as legacy fallback
|
|
232
|
+
const pctValue = cu.percent ?? cu.percentUsed;
|
|
233
|
+
if (typeof pctValue === "number") {
|
|
234
|
+
delta.contextUsage = {
|
|
235
|
+
percent: pctValue,
|
|
236
|
+
totalTokens: cu.totalTokens || 0,
|
|
237
|
+
maxTokens: cu.maxTokens || 0,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
} else if (event.success === true && event.data && !event.data.contextUsage) {
|
|
241
|
+
// Successful get_session_stats response but no contextUsage — older pi
|
|
242
|
+
delta.sawStatsResponseWithoutContextUsage = true;
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Reflect persisted retry state into the delta for the caller
|
|
250
|
+
delta.retryActive = tailState.retryActive;
|
|
251
|
+
return delta;
|
|
252
|
+
}
|
|
@@ -335,6 +335,7 @@ git log --oneline orch/{branch}..task/{lane-branch} # empty = already merged
|
|
|
335
335
|
cd /tmp/verify && cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts
|
|
336
336
|
```
|
|
337
337
|
5. Update batch state and advance.
|
|
338
|
+
6. **IMPORTANT:** After any manual merge that completes the batch integration, always call `orch_integrate()` to record integration metadata (`integratedAt`, orch branch cleanup, batch history). Without this step, the dashboard will continue showing the batch in the active view rather than the history view, and `batch-state.json` will not reflect the completed integration.
|
|
338
339
|
|
|
339
340
|
### Pattern 1b: Merge Agent Stall (TP-056)
|
|
340
341
|
|
|
@@ -379,6 +380,7 @@ completion.
|
|
|
379
380
|
- Add `mergeResults[N] = { waveIndex: N, status: "succeeded", ... }`
|
|
380
381
|
- Advance `currentWaveIndex` past the merged wave
|
|
381
382
|
- Set `phase = "paused"` for clean resume
|
|
383
|
+
5. **IMPORTANT:** Once all waves are merged and the batch is complete, call `orch_integrate()` to record integration metadata. This ensures the dashboard moves the batch to history view and `integratedAt` is written to `batch-state.json`.
|
|
382
384
|
|
|
383
385
|
### Pattern 3: Resume Marks Pending Tasks as Failed
|
|
384
386
|
|
|
@@ -716,6 +718,7 @@ or check status manually. The engine wakes you up when you're needed.
|
|
|
716
718
|
| `task-failure` | ⚠️ | A task failed after deterministic recovery was exhausted |
|
|
717
719
|
| `merge-failure` | ⚠️ | Wave merge failed and batch paused |
|
|
718
720
|
| `batch-complete` | ✅/⚠️ | Batch finished (all waves done, with or without failures) |
|
|
721
|
+
| `worker-exit-intercept` | 🔄 | A worker exited without making progress — session still alive, awaiting instructions |
|
|
719
722
|
|
|
720
723
|
### Alert Format
|
|
721
724
|
|
|
@@ -1009,6 +1012,60 @@ BATCH COMPLETE: {batchId}
|
|
|
1009
1012
|
| merge-failure | Agent timeout, no result | `orch_resume(force=true)` to retry | Automatic |
|
|
1010
1013
|
| batch-complete | All succeeded | Report → suggest `orch_integrate` | Report only |
|
|
1011
1014
|
| batch-complete | Some failed | Report with failure details | Report only |
|
|
1015
|
+
| worker-exit-intercept | Worker analyzing, not editing | `send_agent_message` with targeted instructions | Automatic |
|
|
1016
|
+
| worker-exit-intercept | Worker genuinely stuck | "skip" or "let it fail" to close session | Supervised |
|
|
1017
|
+
| worker-exit-intercept | Unknown reason | Read STATUS.md, diagnose, then instruct or close | Automatic |
|
|
1018
|
+
|
|
1019
|
+
---
|
|
1020
|
+
|
|
1021
|
+
## 13c. Worker Exit Interception (TP-172)
|
|
1022
|
+
|
|
1023
|
+
When a worker agent produces a text-only response (no tool calls, no file
|
|
1024
|
+
edits) without having made visible progress (no checkbox updates), the
|
|
1025
|
+
lane-runner **intercepts the exit** instead of closing the session. The worker
|
|
1026
|
+
process remains alive with its full conversation context preserved.
|
|
1027
|
+
|
|
1028
|
+
**You receive a `worker-exit-intercept` alert** with:
|
|
1029
|
+
- Lane number and task ID
|
|
1030
|
+
- Current step and unchecked checkboxes
|
|
1031
|
+
- Worker's last assistant message (truncated to 500 chars)
|
|
1032
|
+
- Iteration count and no-progress count
|
|
1033
|
+
|
|
1034
|
+
### Response Protocol
|
|
1035
|
+
|
|
1036
|
+
1. **Read the worker's message** — understand why it wants to exit.
|
|
1037
|
+
Common patterns:
|
|
1038
|
+
- "I've analyzed the code and I'm not sure how to proceed"
|
|
1039
|
+
- "I need more information about X"
|
|
1040
|
+
- Generic summary without any file edits
|
|
1041
|
+
|
|
1042
|
+
2. **Decide** — based on diagnosis:
|
|
1043
|
+
- **If the worker needs direction:** Send targeted instructions via
|
|
1044
|
+
`send_agent_message(to, content)` with specific guidance on what to
|
|
1045
|
+
implement, which file to edit, or which approach to take.
|
|
1046
|
+
- **If the task is genuinely blocked:** Reply with `"skip"` or
|
|
1047
|
+
`"let it fail"` to close the session normally.
|
|
1048
|
+
|
|
1049
|
+
3. **Send your response** — The lane-runner polls for your reply for
|
|
1050
|
+
60 seconds. If you don't respond in time, the session closes and
|
|
1051
|
+
the normal corrective re-spawn mechanism takes over.
|
|
1052
|
+
|
|
1053
|
+
### Example Instructions
|
|
1054
|
+
|
|
1055
|
+
```
|
|
1056
|
+
send_agent_message(
|
|
1057
|
+
to: "orch-henrylach-lane-1-worker",
|
|
1058
|
+
content: "Stop analyzing and start implementing. Edit agent-host.ts line 605:
|
|
1059
|
+
replace the closeStdin() call with the interception logic described in
|
|
1060
|
+
PROMPT.md Step 1. Write the code now — don't read more files."
|
|
1061
|
+
)
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
### Interception Limits
|
|
1065
|
+
|
|
1066
|
+
Each worker session can be intercepted at most **2 times** (configurable via
|
|
1067
|
+
`maxExitInterceptions`). After the limit is reached, the session closes
|
|
1068
|
+
normally and the stall detector handles subsequent iterations.
|
|
1012
1069
|
|
|
1013
1070
|
---
|
|
1014
1071
|
|
|
@@ -1958,7 +1958,7 @@ function parseSupervisorTemplate(filePath: string): { fm: Record<string, string>
|
|
|
1958
1958
|
/**
|
|
1959
1959
|
* Load a supervisor template: base (from package) + local override (from project).
|
|
1960
1960
|
*
|
|
1961
|
-
* Follows the same composition pattern as `loadAgentDef()
|
|
1961
|
+
* Follows the same composition pattern as `loadAgentDef()`:
|
|
1962
1962
|
* - Base template: shipped in `templates/agents/{name}.md`
|
|
1963
1963
|
* - Local override: `.pi/agents/{name}.md` in the project
|
|
1964
1964
|
* - If local has `standalone: true`, use it exclusively
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Task Executor Core — Headless execution semantics for Runtime V2
|
|
3
3
|
*
|
|
4
|
-
* This module owns the deterministic task execution logic
|
|
5
|
-
* previously embedded inside the Pi extension host (task-runner.ts).
|
|
4
|
+
* This module owns the deterministic task execution logic for headless lane execution.
|
|
6
5
|
* It has NO dependency on Pi's ExtensionAPI, ExtensionContext, UI
|
|
7
6
|
* widgets, session lifecycle, TMUX, or TASK_AUTOSTART.
|
|
8
7
|
*
|
|
9
8
|
* Consumers:
|
|
10
|
-
* - task-runner.ts (deprecated /task compatibility wrapper)
|
|
11
9
|
* - lane-runner.ts (Runtime V2 headless lane execution, TP-105)
|
|
12
10
|
*
|
|
13
11
|
* Design rules:
|
|
@@ -29,8 +27,7 @@ import { spawnSync } from "child_process";
|
|
|
29
27
|
/**
|
|
30
28
|
* Parsed step information from PROMPT.md or STATUS.md.
|
|
31
29
|
*
|
|
32
|
-
* Re-exported from the core
|
|
33
|
-
* task-runner.ts for the type definition.
|
|
30
|
+
* Re-exported from the core for downstream consumers.
|
|
34
31
|
*/
|
|
35
32
|
export interface StepInfo {
|
|
36
33
|
number: number;
|