taskplane 0.28.8 → 0.29.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/bin/get-version.mjs +50 -0
- package/bin/taskplane.mjs +5 -8
- package/extensions/taskplane/agent-bridge-extension.ts +58 -3
- package/extensions/taskplane/agent-host.ts +11 -17
- package/extensions/taskplane/config-schema.ts +17 -12
- package/extensions/taskplane/engine-worker.ts +27 -0
- package/extensions/taskplane/engine.ts +78 -1
- package/extensions/taskplane/execution.ts +23 -1
- package/extensions/taskplane/extension.ts +303 -0
- package/extensions/taskplane/lane-runner.ts +74 -3
- package/extensions/taskplane/mailbox.ts +83 -0
- package/extensions/taskplane/messages.ts +19 -0
- package/extensions/taskplane/path-resolver.ts +63 -24
- package/extensions/taskplane/persistence.ts +378 -3
- package/extensions/taskplane/resume.ts +56 -6
- package/extensions/taskplane/tool-allowlist-constants.ts +37 -0
- package/extensions/taskplane/types.ts +34 -5
- package/extensions/taskplane/worktree.ts +5 -1
- package/package.json +1 -1
- package/skills/create-taskplane-task/SKILL.md +37 -0
- package/templates/agents/supervisor.md +62 -1
- package/templates/agents/task-worker.md +58 -6
|
@@ -86,15 +86,38 @@ export function getNpmGlobalRoot(): string {
|
|
|
86
86
|
return _npmGlobalRoot;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Pi CLI npm package scopes that taskplane resolves at runtime, ordered with
|
|
91
|
+
* the canonical (current) scope FIRST and legacy scopes after for backward
|
|
92
|
+
* compatibility. Issue #560: the Pi coding agent was renamed from
|
|
93
|
+
* `@mariozechner/pi-coding-agent` to `@earendil-works/pi-coding-agent` in
|
|
94
|
+
* Pi v0.74.0. Pi's own extension loader bundles BOTH scope aliases at runtime
|
|
95
|
+
* for in-process module imports, but spawn-side path resolution (this file)
|
|
96
|
+
* has to look on disk under whichever scope was actually installed.
|
|
97
|
+
*
|
|
98
|
+
* Order matters: the new scope is preferred so a system that has BOTH
|
|
99
|
+
* installed (e.g., during a transition window) picks up the current Pi.
|
|
100
|
+
*/
|
|
101
|
+
const PI_PACKAGE_SCOPES = ["@earendil-works", "@mariozechner"] as const;
|
|
102
|
+
|
|
89
103
|
/**
|
|
90
104
|
* Resolve the absolute path to the Pi coding agent CLI entrypoint (`cli.js`).
|
|
91
105
|
*
|
|
92
|
-
* The Pi CLI is installed
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* `node` directly, without a shell intermediary.
|
|
106
|
+
* The Pi CLI is installed under one of two npm scopes:
|
|
107
|
+
* - `@earendil-works/pi-coding-agent` (current, as of Pi v0.74.0)
|
|
108
|
+
* - `@mariozechner/pi-coding-agent` (legacy)
|
|
96
109
|
*
|
|
97
|
-
*
|
|
110
|
+
* On Windows, invoking `pi` directly executes a `.CMD` shim that cannot be
|
|
111
|
+
* spawned with `shell: false`. This function locates the underlying
|
|
112
|
+
* `dist/cli.js` so callers can spawn it with `node` directly, without a shell
|
|
113
|
+
* intermediary.
|
|
114
|
+
*
|
|
115
|
+
* Resolution order: the cross product of base directories × package scopes,
|
|
116
|
+
* with each base directory tried for the new scope before any base directory
|
|
117
|
+
* is tried for the legacy scope. (Equivalently: scope is the inner loop, base
|
|
118
|
+
* is the outer loop.)
|
|
119
|
+
*
|
|
120
|
+
* Base directories (outer loop):
|
|
98
121
|
* 1. `npm root -g` result (dynamic — covers all setups: nvm, Homebrew, volta, etc.)
|
|
99
122
|
* 2. `%APPDATA%\npm\node_modules\...` (Windows, APPDATA env var)
|
|
100
123
|
* 3. `%USERPROFILE%\AppData\Roaming\npm\node_modules\...` (Windows, HOME-relative)
|
|
@@ -102,40 +125,53 @@ export function getNpmGlobalRoot(): string {
|
|
|
102
125
|
* 5. `/usr/local/lib/node_modules/...` (macOS system Node, Linux)
|
|
103
126
|
* 6. `/opt/homebrew/lib/node_modules/...` (macOS Homebrew)
|
|
104
127
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
128
|
+
* Scopes per base (inner loop):
|
|
129
|
+
* a. `@earendil-works/pi-coding-agent/dist/cli.js`
|
|
130
|
+
* b. `@mariozechner/pi-coding-agent/dist/cli.js`
|
|
131
|
+
*
|
|
132
|
+
* @returns Absolute path to a Pi CLI `dist/cli.js` (under whichever scope was found).
|
|
133
|
+
* @throws {Error} If the CLI entrypoint cannot be found under any base × scope
|
|
134
|
+
* combination. The error message includes the `npm root -g` value
|
|
135
|
+
* AND lists both scopes searched, for operator diagnosis.
|
|
108
136
|
*/
|
|
109
137
|
export function resolvePiCliPath(): string {
|
|
110
|
-
const
|
|
111
|
-
const candidates: string[] = [];
|
|
138
|
+
const bases: string[] = [];
|
|
112
139
|
|
|
113
140
|
// 1. Dynamic: npm root -g (covers nvm, Homebrew, volta, custom npm prefix, etc.)
|
|
114
141
|
const npmRoot = getNpmGlobalRoot();
|
|
115
|
-
if (npmRoot)
|
|
142
|
+
if (npmRoot) bases.push(npmRoot);
|
|
116
143
|
|
|
117
144
|
// 2-3. Static Windows fallbacks
|
|
118
145
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
119
146
|
if (process.env.APPDATA) {
|
|
120
|
-
|
|
147
|
+
bases.push(join(process.env.APPDATA, "npm", "node_modules"));
|
|
121
148
|
}
|
|
122
149
|
if (home) {
|
|
123
|
-
|
|
150
|
+
bases.push(join(home, "AppData", "Roaming", "npm", "node_modules"));
|
|
124
151
|
// 4. macOS/Linux custom global prefix
|
|
125
|
-
|
|
152
|
+
bases.push(join(home, ".npm-global", "lib", "node_modules"));
|
|
126
153
|
}
|
|
127
154
|
// 5. macOS system Node / Linux
|
|
128
|
-
|
|
155
|
+
bases.push(join("/usr", "local", "lib", "node_modules"));
|
|
129
156
|
// 6. macOS Homebrew
|
|
130
|
-
|
|
157
|
+
bases.push(join("/opt", "homebrew", "lib", "node_modules"));
|
|
131
158
|
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
// Cross product: scope is the inner loop so a single base directory is
|
|
160
|
+
// fully exhausted (new scope, then legacy scope) before falling back to
|
|
161
|
+
// the next base. This matches operator intuition ("check the most likely
|
|
162
|
+
// install location for either scope first").
|
|
163
|
+
for (const base of bases) {
|
|
164
|
+
for (const scope of PI_PACKAGE_SCOPES) {
|
|
165
|
+
const candidate = join(base, scope, "pi-coding-agent", "dist", "cli.js");
|
|
166
|
+
if (existsSync(candidate)) return candidate;
|
|
167
|
+
}
|
|
134
168
|
}
|
|
135
169
|
|
|
136
170
|
throw new Error(
|
|
137
|
-
"Cannot find Pi CLI entrypoint (
|
|
138
|
-
"
|
|
171
|
+
"Cannot find Pi CLI entrypoint (pi-coding-agent/dist/cli.js) under any known npm scope " +
|
|
172
|
+
`(${PI_PACKAGE_SCOPES.join(" or ")}). ` +
|
|
173
|
+
"Install via 'npm install -g @earendil-works/pi-coding-agent' " +
|
|
174
|
+
"(or, for legacy installs, 'npm install -g @mariozechner/pi-coding-agent'). " +
|
|
139
175
|
`npm root -g returned: ${npmRoot || "(empty — npm may not be on PATH)"}`,
|
|
140
176
|
);
|
|
141
177
|
}
|
|
@@ -189,12 +225,15 @@ export function resolveTaskplanePackageFile(repoRoot: string, relPath: string):
|
|
|
189
225
|
candidates.push(join("/opt", "homebrew", "lib", "node_modules", "taskplane", relPath));
|
|
190
226
|
|
|
191
227
|
// 8. Peer of pi's package (look adjacent to pi's CLI entrypoint).
|
|
192
|
-
// pi is at: <npmRoot
|
|
193
|
-
//
|
|
194
|
-
//
|
|
228
|
+
// pi is at: <npmRoot>/<scope>/pi-coding-agent/dist/cli.js (where <scope> is
|
|
229
|
+
// @earendil-works (current) or @mariozechner (legacy)).
|
|
230
|
+
// so piPkgDir = <npmRoot>/<scope>/pi-coding-agent (resolve up 2 levels from cli.js).
|
|
231
|
+
// Then go up TWO more levels to reach <npmRoot>, then into taskplane/.
|
|
232
|
+
// This works regardless of which scope Pi is installed under because we
|
|
233
|
+
// only walk up the directory tree — we never name the scope explicitly.
|
|
195
234
|
try {
|
|
196
235
|
const piPath = process.argv[1] || "";
|
|
197
|
-
const piPkgDir = resolve(piPath, "..", ".."); // <npmRoot
|
|
236
|
+
const piPkgDir = resolve(piPath, "..", ".."); // <npmRoot>/<scope>/pi-coding-agent
|
|
198
237
|
const npmRootFromPi = resolve(piPkgDir, "..", ".."); // <npmRoot>
|
|
199
238
|
candidates.push(join(npmRootFromPi, "taskplane", relPath));
|
|
200
239
|
} catch { /* ignore — process.argv[1] may be undefined in test contexts */ }
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* State persistence, serialization, orphan detection
|
|
3
3
|
* @module orch/persistence
|
|
4
4
|
*/
|
|
5
|
-
import { readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, mkdirSync, appendFileSync } from "fs";
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, mkdirSync, appendFileSync, readdirSync, statSync } from "fs";
|
|
6
6
|
import { join, dirname, basename } from "path";
|
|
7
7
|
|
|
8
8
|
import { execLog } from "./execution.ts";
|
|
9
|
-
import { BATCH_STATE_SCHEMA_VERSION, StateFileError, batchStatePath, BATCH_HISTORY_MAX_ENTRIES, defaultResilienceState, defaultBatchDiagnostics } from "./types.ts";
|
|
10
|
-
import type { BatchHistorySummary } from "./types.ts";
|
|
9
|
+
import { BATCH_STATE_SCHEMA_VERSION, StateFileError, batchStatePath, BATCH_HISTORY_MAX_ENTRIES, defaultResilienceState, defaultBatchDiagnostics, runtimeRoot, runtimeManifestPath } from "./types.ts";
|
|
10
|
+
import type { BatchHistorySummary, RuntimeAgentManifest } from "./types.ts";
|
|
11
11
|
import type { AllocatedLane, DiscoveryResult, EngineEvent, EscalationContext, LaneTaskOutcome, LaneTaskStatus, MonitorState, OrchBatchPhase, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord, PersistedMergeResult, PersistedSegmentRecord, PersistedTaskRecord, TaskMonitorSnapshot, Tier0RecoveryPattern, WorkspaceMode } from "./types.ts";
|
|
12
12
|
import { sleepSync } from "./worktree.ts";
|
|
13
13
|
import type { PreserveFailedLaneProgressResult } from "./worktree.ts";
|
|
@@ -2085,3 +2085,378 @@ export function emitEngineEvent(
|
|
|
2085
2085
|
}
|
|
2086
2086
|
}
|
|
2087
2087
|
|
|
2088
|
+
|
|
2089
|
+
// ── TP-187 (#539): Batch-Meta Runtime Artifact ─────────────────────
|
|
2090
|
+
//
|
|
2091
|
+
// Small JSON file written at batch-start to `.pi/runtime/<batchId>/batch-meta.json`.
|
|
2092
|
+
// Captures the wave plan and the few non-recoverable scalars (baseBranch,
|
|
2093
|
+
// orchBranch, mode, startedAt, totalWaves) so that `orch_resume(force=true)`
|
|
2094
|
+
// can deterministically reconstruct a validator-compliant PersistedBatchState
|
|
2095
|
+
// after `orch_abort()` deletes `.pi/batch-state.json`.
|
|
2096
|
+
//
|
|
2097
|
+
// Without this artifact the wave topology is unrecoverable from the surviving
|
|
2098
|
+
// runtime registry alone (manifests don't carry wave info) and a flattened
|
|
2099
|
+
// "single wave with all surviving tasks" reconstruction can violate DAG
|
|
2100
|
+
// dependency ordering. See R003 plan review.
|
|
2101
|
+
|
|
2102
|
+
/**
|
|
2103
|
+
* Schema-tagged batch metadata persisted alongside per-batch runtime state.
|
|
2104
|
+
*
|
|
2105
|
+
* @since TP-187 (#539)
|
|
2106
|
+
*/
|
|
2107
|
+
export interface BatchMetaArtifact {
|
|
2108
|
+
schemaVersion: 1;
|
|
2109
|
+
batchId: string;
|
|
2110
|
+
wavePlan: string[][];
|
|
2111
|
+
baseBranch: string;
|
|
2112
|
+
orchBranch: string;
|
|
2113
|
+
mode: WorkspaceMode;
|
|
2114
|
+
startedAt: number;
|
|
2115
|
+
totalWaves: number;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
/** Path to the batch-meta artifact for a given batch. */
|
|
2119
|
+
function batchMetaPath(stateRoot: string, batchId: string): string {
|
|
2120
|
+
return join(runtimeRoot(stateRoot, batchId), "batch-meta.json");
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Persist the wave plan and core batch metadata to the runtime artifact
|
|
2125
|
+
* directory. Best-effort: failures are logged but do NOT crash the batch.
|
|
2126
|
+
*
|
|
2127
|
+
* Called once at batch-start (after wavePlan is finalized) and re-written
|
|
2128
|
+
* whenever the wave plan mutates (segment expansion).
|
|
2129
|
+
*
|
|
2130
|
+
* @since TP-187 (#539)
|
|
2131
|
+
*/
|
|
2132
|
+
export function saveBatchMetaRuntimeArtifact(
|
|
2133
|
+
stateRoot: string,
|
|
2134
|
+
artifact: BatchMetaArtifact,
|
|
2135
|
+
): void {
|
|
2136
|
+
try {
|
|
2137
|
+
const path = batchMetaPath(stateRoot, artifact.batchId);
|
|
2138
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2139
|
+
const tmp = path + ".tmp";
|
|
2140
|
+
writeFileSync(tmp, JSON.stringify(artifact, null, 2) + "\n", "utf-8");
|
|
2141
|
+
renameSync(tmp, path);
|
|
2142
|
+
execLog("state", artifact.batchId, "persisted batch-meta runtime artifact", {
|
|
2143
|
+
waves: artifact.wavePlan.length,
|
|
2144
|
+
tasks: artifact.wavePlan.reduce((sum, w) => sum + w.length, 0),
|
|
2145
|
+
});
|
|
2146
|
+
} catch (err) {
|
|
2147
|
+
execLog("state", artifact.batchId, `batch-meta write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
/**
|
|
2152
|
+
* Load the batch-meta artifact for a given batch, or null if missing/invalid.
|
|
2153
|
+
*
|
|
2154
|
+
* @since TP-187 (#539)
|
|
2155
|
+
*/
|
|
2156
|
+
export function loadBatchMetaRuntimeArtifact(
|
|
2157
|
+
stateRoot: string,
|
|
2158
|
+
batchId: string,
|
|
2159
|
+
): BatchMetaArtifact | null {
|
|
2160
|
+
const path = batchMetaPath(stateRoot, batchId);
|
|
2161
|
+
if (!existsSync(path)) return null;
|
|
2162
|
+
try {
|
|
2163
|
+
const raw = readFileSync(path, "utf-8");
|
|
2164
|
+
const parsed = JSON.parse(raw);
|
|
2165
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
2166
|
+
const obj = parsed as Record<string, unknown>;
|
|
2167
|
+
if (obj.schemaVersion !== 1) return null;
|
|
2168
|
+
if (typeof obj.batchId !== "string" || obj.batchId !== batchId) return null;
|
|
2169
|
+
if (!Array.isArray(obj.wavePlan)) return null;
|
|
2170
|
+
for (const wave of obj.wavePlan) {
|
|
2171
|
+
if (!Array.isArray(wave)) return null;
|
|
2172
|
+
for (const taskId of wave) {
|
|
2173
|
+
if (typeof taskId !== "string") return null;
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
if (typeof obj.baseBranch !== "string") return null;
|
|
2177
|
+
if (typeof obj.orchBranch !== "string") return null;
|
|
2178
|
+
if (obj.mode !== "repo" && obj.mode !== "workspace") return null;
|
|
2179
|
+
if (typeof obj.startedAt !== "number") return null;
|
|
2180
|
+
if (typeof obj.totalWaves !== "number") return null;
|
|
2181
|
+
return obj as unknown as BatchMetaArtifact;
|
|
2182
|
+
} catch {
|
|
2183
|
+
return null;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
|
|
2188
|
+
// ── TP-187 (#539): Reconstruct PersistedBatchState from runtime artifacts ──
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* Result of `reconstructBatchStateFromRuntime`. On success, contains the
|
|
2192
|
+
* validator-compliant state, the selected batchId, and a human-readable note
|
|
2193
|
+
* about how the selection was made (used by resume's onNotify output). On
|
|
2194
|
+
* failure, names the missing or corrupt artifact for fail-loud reporting.
|
|
2195
|
+
*
|
|
2196
|
+
* @since TP-187 (#539)
|
|
2197
|
+
*/
|
|
2198
|
+
export type ReconstructResult =
|
|
2199
|
+
| { ok: true; state: PersistedBatchState; batchId: string; selectionNote: string }
|
|
2200
|
+
| { ok: false; error: string };
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* List candidate `.pi/runtime/<batchId>/` directories newest-first by mtime,
|
|
2204
|
+
* with lex-largest tie-break for determinism.
|
|
2205
|
+
*/
|
|
2206
|
+
function listRuntimeBatchDirs(stateRoot: string): { batchId: string; mtimeMs: number }[] {
|
|
2207
|
+
const root = join(stateRoot, ".pi", "runtime");
|
|
2208
|
+
if (!existsSync(root)) return [];
|
|
2209
|
+
let entries: string[] = [];
|
|
2210
|
+
try {
|
|
2211
|
+
entries = readdirSync(root);
|
|
2212
|
+
} catch {
|
|
2213
|
+
return [];
|
|
2214
|
+
}
|
|
2215
|
+
const candidates: { batchId: string; mtimeMs: number }[] = [];
|
|
2216
|
+
for (const name of entries) {
|
|
2217
|
+
const dir = join(root, name);
|
|
2218
|
+
try {
|
|
2219
|
+
const st = statSync(dir);
|
|
2220
|
+
if (!st.isDirectory()) continue;
|
|
2221
|
+
candidates.push({ batchId: name, mtimeMs: st.mtimeMs });
|
|
2222
|
+
} catch {
|
|
2223
|
+
continue;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
candidates.sort((a, b) => {
|
|
2227
|
+
if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs;
|
|
2228
|
+
return b.batchId.localeCompare(a.batchId);
|
|
2229
|
+
});
|
|
2230
|
+
return candidates;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
/**
|
|
2234
|
+
* Read all worker manifests under `.pi/runtime/<batchId>/agents/`.
|
|
2235
|
+
*
|
|
2236
|
+
* Returns an empty array if the agents directory is missing.
|
|
2237
|
+
*/
|
|
2238
|
+
function readWorkerManifests(stateRoot: string, batchId: string): RuntimeAgentManifest[] {
|
|
2239
|
+
const agentsDir = join(runtimeRoot(stateRoot, batchId), "agents");
|
|
2240
|
+
if (!existsSync(agentsDir)) return [];
|
|
2241
|
+
let entries: string[] = [];
|
|
2242
|
+
try {
|
|
2243
|
+
entries = readdirSync(agentsDir);
|
|
2244
|
+
} catch {
|
|
2245
|
+
return [];
|
|
2246
|
+
}
|
|
2247
|
+
const manifests: RuntimeAgentManifest[] = [];
|
|
2248
|
+
for (const agentId of entries) {
|
|
2249
|
+
const manifestPath = runtimeManifestPath(stateRoot, batchId, agentId);
|
|
2250
|
+
if (!existsSync(manifestPath)) continue;
|
|
2251
|
+
try {
|
|
2252
|
+
const raw = readFileSync(manifestPath, "utf-8");
|
|
2253
|
+
const parsed = JSON.parse(raw) as RuntimeAgentManifest;
|
|
2254
|
+
if (parsed && typeof parsed === "object" && parsed.role === "worker") {
|
|
2255
|
+
manifests.push(parsed);
|
|
2256
|
+
}
|
|
2257
|
+
} catch {
|
|
2258
|
+
continue;
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
return manifests;
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/**
|
|
2265
|
+
* Deterministically reconstruct a validator-compliant `PersistedBatchState`
|
|
2266
|
+
* from the surviving runtime artifacts after `.pi/batch-state.json` has been
|
|
2267
|
+
* deleted (typically by `orch_abort()`).
|
|
2268
|
+
*
|
|
2269
|
+
* Required artifacts: at least one `.pi/runtime/<batchId>/` directory whose
|
|
2270
|
+
* `batch-meta.json` parses cleanly AND has at least one worker manifest with
|
|
2271
|
+
* an existing worktree on disk. Anything else returns a fail-loud error so
|
|
2272
|
+
* the caller can surface a clear "no resumable state" message instead of
|
|
2273
|
+
* silently producing an invalid state.
|
|
2274
|
+
*
|
|
2275
|
+
* @since TP-187 (#539)
|
|
2276
|
+
*/
|
|
2277
|
+
export function reconstructBatchStateFromRuntime(stateRoot: string): ReconstructResult {
|
|
2278
|
+
const candidates = listRuntimeBatchDirs(stateRoot);
|
|
2279
|
+
if (candidates.length === 0) {
|
|
2280
|
+
return { ok: false, error: "no .pi/runtime/ directory or no batch subdirectories" };
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// Try the newest batch first; if its required artifacts are missing, fall
|
|
2284
|
+
// through to the next candidate. We stop at the first batch with a parseable
|
|
2285
|
+
// batch-meta + at least one viable worker manifest.
|
|
2286
|
+
const failures: string[] = [];
|
|
2287
|
+
for (let idx = 0; idx < candidates.length; idx++) {
|
|
2288
|
+
const cand = candidates[idx];
|
|
2289
|
+
const meta = loadBatchMetaRuntimeArtifact(stateRoot, cand.batchId);
|
|
2290
|
+
if (!meta) {
|
|
2291
|
+
failures.push(`${cand.batchId}: batch-meta.json missing or invalid`);
|
|
2292
|
+
continue;
|
|
2293
|
+
}
|
|
2294
|
+
const manifests = readWorkerManifests(stateRoot, cand.batchId);
|
|
2295
|
+
if (manifests.length === 0) {
|
|
2296
|
+
failures.push(`${cand.batchId}: no worker manifests`);
|
|
2297
|
+
continue;
|
|
2298
|
+
}
|
|
2299
|
+
const workerManifestsWithWorktree = manifests.filter(m => typeof m.cwd === "string" && m.cwd.length > 0 && existsSync(m.cwd));
|
|
2300
|
+
if (workerManifestsWithWorktree.length === 0) {
|
|
2301
|
+
failures.push(`${cand.batchId}: worktree paths from manifests no longer exist on disk`);
|
|
2302
|
+
continue;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
// TP-187 (#539) — sage post-integration follow-up: refuse reconstruction
|
|
2306
|
+
// when the runtime artifacts indicate this batch was multi-repo (segment
|
|
2307
|
+
// expansion). Reconstruction hardcodes `segments: []` and cannot recover
|
|
2308
|
+
// the per-segment topology that lives only in the deleted batch-state.
|
|
2309
|
+
// Resuming with `segments: []` for a multi-repo batch would silently lose
|
|
2310
|
+
// the expansion state and could re-execute already-done segments OR fail
|
|
2311
|
+
// dependency checks for cross-repo waves. Detection heuristic: if worker
|
|
2312
|
+
// manifests carry more than one distinct repoId, segment expansion was
|
|
2313
|
+
// active. Single-repo batches (the common case, including Taskplane's
|
|
2314
|
+
// own self-orchestration) are unaffected.
|
|
2315
|
+
{
|
|
2316
|
+
const distinctRepoIds = new Set<string>();
|
|
2317
|
+
for (const m of workerManifestsWithWorktree) {
|
|
2318
|
+
if (typeof m.repoId === "string" && m.repoId.length > 0) {
|
|
2319
|
+
distinctRepoIds.add(m.repoId);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
if (distinctRepoIds.size > 1) {
|
|
2323
|
+
failures.push(
|
|
2324
|
+
`${cand.batchId}: multi-repo batch detected (${distinctRepoIds.size} distinct repoIds: ` +
|
|
2325
|
+
`${[...distinctRepoIds].slice(0, 4).join(", ")}` +
|
|
2326
|
+
`${distinctRepoIds.size > 4 ? ", ..." : ""}); reconstruction would lose segment ` +
|
|
2327
|
+
`expansion state and is refused. Restore .pi/batch-state.json from backup or start a new batch.`
|
|
2328
|
+
);
|
|
2329
|
+
continue;
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// Build per-lane aggregation from worker manifests.
|
|
2334
|
+
const laneMap = new Map<number, { laneNumber: number; agentId: string; worktreePath: string; repoId: string; taskIds: string[] }>();
|
|
2335
|
+
for (const m of workerManifestsWithWorktree) {
|
|
2336
|
+
if (typeof m.laneNumber !== "number") continue;
|
|
2337
|
+
const lane = laneMap.get(m.laneNumber) ?? {
|
|
2338
|
+
laneNumber: m.laneNumber,
|
|
2339
|
+
agentId: m.agentId,
|
|
2340
|
+
worktreePath: m.cwd,
|
|
2341
|
+
repoId: m.repoId ?? "default",
|
|
2342
|
+
taskIds: [] as string[],
|
|
2343
|
+
};
|
|
2344
|
+
if (typeof m.taskId === "string" && m.taskId && !lane.taskIds.includes(m.taskId)) {
|
|
2345
|
+
lane.taskIds.push(m.taskId);
|
|
2346
|
+
}
|
|
2347
|
+
laneMap.set(m.laneNumber, lane);
|
|
2348
|
+
}
|
|
2349
|
+
if (laneMap.size === 0) {
|
|
2350
|
+
failures.push(`${cand.batchId}: no lane numbers in manifests`);
|
|
2351
|
+
continue;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
// Tasks: union of taskIds across all lanes, plus any wavePlan tasks that
|
|
2355
|
+
// are not represented (they are pending, not yet executed).
|
|
2356
|
+
const knownTaskIds = new Set<string>();
|
|
2357
|
+
for (const lane of laneMap.values()) {
|
|
2358
|
+
for (const tid of lane.taskIds) knownTaskIds.add(tid);
|
|
2359
|
+
}
|
|
2360
|
+
for (const wave of meta.wavePlan) {
|
|
2361
|
+
for (const tid of wave) knownTaskIds.add(tid);
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// Build task records with conservative defaults; resume's reconciliation
|
|
2365
|
+
// pass will re-detect succeeded tasks via `.DONE` markers and STATUS.md.
|
|
2366
|
+
const tasks: PersistedTaskRecord[] = [];
|
|
2367
|
+
const manifestByTaskId = new Map<string, RuntimeAgentManifest>();
|
|
2368
|
+
for (const m of workerManifestsWithWorktree) {
|
|
2369
|
+
if (typeof m.taskId === "string" && m.taskId) {
|
|
2370
|
+
manifestByTaskId.set(m.taskId, m);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
for (const taskId of knownTaskIds) {
|
|
2374
|
+
const m = manifestByTaskId.get(taskId);
|
|
2375
|
+
const lane = m ? laneMap.get(m.laneNumber) : undefined;
|
|
2376
|
+
const taskRecord: PersistedTaskRecord = {
|
|
2377
|
+
taskId,
|
|
2378
|
+
taskName: taskId,
|
|
2379
|
+
taskFolder: m?.packet?.taskFolder ?? "",
|
|
2380
|
+
status: "pending",
|
|
2381
|
+
sessionName: m?.agentId ?? "",
|
|
2382
|
+
laneNumber: lane?.laneNumber ?? 0,
|
|
2383
|
+
startedAt: typeof m?.startedAt === "number" ? m.startedAt : null,
|
|
2384
|
+
endedAt: null,
|
|
2385
|
+
exitReason: "",
|
|
2386
|
+
doneFileFound: false,
|
|
2387
|
+
};
|
|
2388
|
+
if (m?.repoId) taskRecord.repoId = m.repoId;
|
|
2389
|
+
if (m?.packet?.packetRepoId) (taskRecord as Record<string, unknown>).packetRepoId = m.packet.packetRepoId;
|
|
2390
|
+
if (m?.packet?.packetTaskPath) (taskRecord as Record<string, unknown>).packetTaskPath = m.packet.packetTaskPath;
|
|
2391
|
+
tasks.push(taskRecord);
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// Build lane records.
|
|
2395
|
+
const lanes: PersistedLaneRecord[] = Array.from(laneMap.values())
|
|
2396
|
+
.sort((a, b) => a.laneNumber - b.laneNumber)
|
|
2397
|
+
.map(l => {
|
|
2398
|
+
const sessionId = l.agentId.replace(/-(worker|reviewer)$/, "");
|
|
2399
|
+
const rec: PersistedLaneRecord = {
|
|
2400
|
+
laneId: `lane-${l.laneNumber}`,
|
|
2401
|
+
laneNumber: l.laneNumber,
|
|
2402
|
+
laneSessionId: sessionId,
|
|
2403
|
+
worktreePath: l.worktreePath,
|
|
2404
|
+
branch: meta.orchBranch ? `${meta.orchBranch}-lane-${l.laneNumber}` : `lane-${l.laneNumber}`,
|
|
2405
|
+
taskIds: [...l.taskIds],
|
|
2406
|
+
};
|
|
2407
|
+
if (l.repoId && l.repoId !== "default") rec.repoId = l.repoId;
|
|
2408
|
+
return rec;
|
|
2409
|
+
});
|
|
2410
|
+
|
|
2411
|
+
const now = Date.now();
|
|
2412
|
+
const reconstructed: PersistedBatchState = {
|
|
2413
|
+
schemaVersion: BATCH_STATE_SCHEMA_VERSION,
|
|
2414
|
+
batchId: meta.batchId,
|
|
2415
|
+
phase: "stopped",
|
|
2416
|
+
baseBranch: meta.baseBranch,
|
|
2417
|
+
orchBranch: meta.orchBranch,
|
|
2418
|
+
mode: meta.mode,
|
|
2419
|
+
startedAt: meta.startedAt,
|
|
2420
|
+
endedAt: null,
|
|
2421
|
+
updatedAt: now,
|
|
2422
|
+
currentWaveIndex: 0,
|
|
2423
|
+
totalWaves: meta.totalWaves,
|
|
2424
|
+
totalTasks: tasks.length,
|
|
2425
|
+
succeededTasks: 0,
|
|
2426
|
+
failedTasks: 0,
|
|
2427
|
+
skippedTasks: 0,
|
|
2428
|
+
blockedTasks: 0,
|
|
2429
|
+
wavePlan: meta.wavePlan.map(wave => [...wave]),
|
|
2430
|
+
lanes,
|
|
2431
|
+
tasks,
|
|
2432
|
+
mergeResults: [],
|
|
2433
|
+
blockedTaskIds: [],
|
|
2434
|
+
errors: [],
|
|
2435
|
+
segments: [],
|
|
2436
|
+
lastError: null,
|
|
2437
|
+
resilience: { ...defaultResilienceState(), resumeForced: true },
|
|
2438
|
+
diagnostics: defaultBatchDiagnostics(),
|
|
2439
|
+
} as PersistedBatchState;
|
|
2440
|
+
|
|
2441
|
+
// Validate the reconstructed shape against the on-disk schema gate.
|
|
2442
|
+
try {
|
|
2443
|
+
const json = JSON.stringify(reconstructed);
|
|
2444
|
+
validatePersistedState(JSON.parse(json));
|
|
2445
|
+
} catch (err) {
|
|
2446
|
+
failures.push(`${cand.batchId}: reconstructed state failed validation: ${err instanceof Error ? err.message : String(err)}`);
|
|
2447
|
+
continue;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
const totalCandidates = candidates.length;
|
|
2451
|
+
const selectionNote = totalCandidates === 1
|
|
2452
|
+
? `single batch in .pi/runtime/`
|
|
2453
|
+
: `selected from ${totalCandidates} candidate(s) by mtime newest-first (skipped ${idx} earlier candidate(s))`;
|
|
2454
|
+
return { ok: true, state: reconstructed, batchId: meta.batchId, selectionNote };
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
return {
|
|
2458
|
+
ok: false,
|
|
2459
|
+
error: `no reconstructable batch found in .pi/runtime/ (${failures.length} candidate(s) inspected: ${failures.slice(0, 3).join("; ")}${failures.length > 3 ? "; ..." : ""})`,
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
|
|
@@ -37,7 +37,7 @@ import { mergeWaveByRepo } from "./merge.ts";
|
|
|
37
37
|
import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, extractFailedRepoId, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
|
|
38
38
|
import type { CleanupGateRepoFailure } from "./messages.ts";
|
|
39
39
|
import { resolveOperatorId } from "./naming.ts";
|
|
40
|
-
import { applyPartialProgressToOutcomes, deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
40
|
+
import { applyPartialProgressToOutcomes, deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, reconstructBatchStateFromRuntime, saveBatchState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
41
41
|
import { buildBatchProgressSnapshot, buildSupervisorSegmentFrontierSnapshot, defaultResilienceState, StateFileError } from "./types.ts";
|
|
42
42
|
import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, PersistedLaneRecord, PersistedSegmentRecord, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
43
43
|
import { buildDependencyGraph, resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
|
|
@@ -1070,6 +1070,18 @@ export async function resumeOrchBatch(
|
|
|
1070
1070
|
force: boolean = false,
|
|
1071
1071
|
onSupervisorAlert?: import("./types.ts").SupervisorAlertCallback | null,
|
|
1072
1072
|
supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
|
|
1073
|
+
/**
|
|
1074
|
+
* TP-187 (#538): Optional callback fired when a lane reaches a terminal
|
|
1075
|
+
* state during a resumed batch. Threaded through to executeWave so the
|
|
1076
|
+
* supervisor process keeps suppressing zombie alerts after resume too.
|
|
1077
|
+
*/
|
|
1078
|
+
onLaneTerminated?: import("./types.ts").LaneTerminatedCallback | null,
|
|
1079
|
+
/**
|
|
1080
|
+
* TP-187 (#538): Optional callback fired when a lane is freshly
|
|
1081
|
+
* (re-)allocated during resume. The supervisor uses it to lift any
|
|
1082
|
+
* carried-over zombie-alert suppression.
|
|
1083
|
+
*/
|
|
1084
|
+
onLaneRespawned?: ((laneNumber: number, agentId: string, batchId: string) => void) | null,
|
|
1073
1085
|
): Promise<void> {
|
|
1074
1086
|
const repoRoot = cwd;
|
|
1075
1087
|
// State files (.pi/batch-state.json, lane-state, etc.) belong in the workspace root,
|
|
@@ -1111,13 +1123,49 @@ export async function resumeOrchBatch(
|
|
|
1111
1123
|
}
|
|
1112
1124
|
|
|
1113
1125
|
if (!persistedState) {
|
|
1126
|
+
if (!force) {
|
|
1127
|
+
onNotify(
|
|
1128
|
+
ORCH_MESSAGES.resumeNoState(),
|
|
1129
|
+
"error",
|
|
1130
|
+
);
|
|
1131
|
+
// TP-040 R006: Reset phase on pre-execution early return
|
|
1132
|
+
batchState.phase = "idle";
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
// TP-187 (#539): On force-resume, attempt deterministic reconstruction
|
|
1136
|
+
// from .pi/runtime/<batchId>/ runtime artifacts (typically left intact
|
|
1137
|
+
// by `orch_abort()` even though `.pi/batch-state.json` is deleted).
|
|
1138
|
+
const reconstruction = reconstructBatchStateFromRuntime(stateRoot);
|
|
1139
|
+
if (!reconstruction.ok) {
|
|
1140
|
+
onNotify(
|
|
1141
|
+
ORCH_MESSAGES.resumeNoStateAfterAbort(reconstruction.error, null),
|
|
1142
|
+
"error",
|
|
1143
|
+
);
|
|
1144
|
+
// TP-040 R006: Reset phase on pre-execution early return
|
|
1145
|
+
batchState.phase = "idle";
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
// Successful reconstruction: persist so the rest of resumeOrchBatch
|
|
1149
|
+
// proceeds with a normal on-disk batch-state.json picture.
|
|
1114
1150
|
onNotify(
|
|
1115
|
-
ORCH_MESSAGES.
|
|
1116
|
-
"
|
|
1151
|
+
ORCH_MESSAGES.resumeReconstructed(reconstruction.batchId, reconstruction.selectionNote),
|
|
1152
|
+
"warning",
|
|
1117
1153
|
);
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1154
|
+
try {
|
|
1155
|
+
saveBatchState(JSON.stringify(reconstruction.state, null, 2), stateRoot);
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
onNotify(
|
|
1158
|
+
ORCH_MESSAGES.resumeNoStateAfterAbort(
|
|
1159
|
+
`reconstructed state could not be persisted: ${err instanceof Error ? err.message : String(err)}`,
|
|
1160
|
+
reconstruction.batchId,
|
|
1161
|
+
),
|
|
1162
|
+
"error",
|
|
1163
|
+
);
|
|
1164
|
+
// TP-040 R006: Reset phase on pre-execution early return
|
|
1165
|
+
batchState.phase = "idle";
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
persistedState = reconstruction.state;
|
|
1121
1169
|
}
|
|
1122
1170
|
|
|
1123
1171
|
// ── 2. Check eligibility ─────────────────────────────────────
|
|
@@ -2050,6 +2098,8 @@ export async function resumeOrchBatch(
|
|
|
2050
2098
|
runnerConfig.reviewer,
|
|
2051
2099
|
runnerConfig.worker,
|
|
2052
2100
|
runnerConfig.workerExcludeExtensions ?? [],
|
|
2101
|
+
onLaneTerminated ?? undefined,
|
|
2102
|
+
onLaneRespawned ?? undefined,
|
|
2053
2103
|
);
|
|
2054
2104
|
|
|
2055
2105
|
batchState.waveResults.push(waveResult);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight, import-free constants module for the worker tool allowlist.
|
|
3
|
+
*
|
|
4
|
+
* This module exists so that pure-data layers (`config-schema.ts`,
|
|
5
|
+
* `types.ts`) can reference the canonical `DEFAULT_WORKER_USER_TOOLS`
|
|
6
|
+
* literal without pulling `agent-host.ts`'s heavy `child_process` / `fs`
|
|
7
|
+
* imports into the schema/types graph (which would either be circular
|
|
8
|
+
* or pull subprocess plumbing into pure-data files).
|
|
9
|
+
*
|
|
10
|
+
* **Strict invariant:** this module MUST NOT have any imports beyond
|
|
11
|
+
* TypeScript built-ins. Anything more would re-introduce the very
|
|
12
|
+
* coupling this module exists to break.
|
|
13
|
+
*
|
|
14
|
+
* The companion `agent-host.ts` re-exports `DEFAULT_WORKER_USER_TOOLS`
|
|
15
|
+
* from this module for backward compatibility — existing internal
|
|
16
|
+
* imports (e.g., `execution.ts`, `worker-tools-allowlist.test.ts`)
|
|
17
|
+
* continue to work via the agent-host re-export. New code may import
|
|
18
|
+
* from either location; this module is the source of truth.
|
|
19
|
+
*
|
|
20
|
+
* `ENGINE_BRIDGE_TOOLS` and the `buildWorkerToolsAllowlist()` helper
|
|
21
|
+
* remain in `agent-host.ts` because that's where their consumers live
|
|
22
|
+
* and there is no duplication problem to solve for them.
|
|
23
|
+
*
|
|
24
|
+
* @module taskplane/tool-allowlist-constants
|
|
25
|
+
* @since TP-189 (Cluster B)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default user-tools portion of the worker `--tools` allowlist. This is the
|
|
30
|
+
* fallback used when neither `taskRunner.worker.tools` config nor the
|
|
31
|
+
* `TASKPLANE_WORKER_TOOLS` env var supplies a value. Engine bridge tools
|
|
32
|
+
* (review_step, notify_supervisor, escalate_to_supervisor,
|
|
33
|
+
* request_segment_expansion) are appended on top by
|
|
34
|
+
* `buildWorkerToolsAllowlist()` at the spawn site — they are NOT part of
|
|
35
|
+
* this default and should not be added by callers.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_WORKER_USER_TOOLS = "read,write,edit,bash,grep,find,ls";
|