taskplane 0.23.0 → 0.23.2

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.
@@ -77,7 +77,11 @@ function mergeV2LaneSnapshot(legacyLs, v2snap) {
77
77
  // RuntimeLaneSnapshot has worker: { status, elapsedMs, toolCalls, contextPct, ... }
78
78
  const w = v2snap.worker;
79
79
  if (w) {
80
- if (w.status) base.workerStatus = w.status;
80
+ // Map V2 agent status to legacy dashboard status strings
81
+ if (w.status) {
82
+ const statusMap = { running: 'running', spawning: 'running', exited: 'done', crashed: 'error', killed: 'error', timed_out: 'error', wrapping_up: 'running' };
83
+ base.workerStatus = statusMap[w.status] || w.status;
84
+ }
81
85
  if (w.elapsedMs != null) base.workerElapsed = w.elapsedMs;
82
86
  if (w.contextPct != null) base.workerContextPct = w.contextPct;
83
87
  if (w.toolCalls != null) base.workerToolCount = w.toolCalls;
@@ -89,6 +93,7 @@ function mergeV2LaneSnapshot(legacyLs, v2snap) {
89
93
  if (w.cacheWriteTokens != null) base.workerCacheWriteTokens = w.cacheWriteTokens;
90
94
  }
91
95
  if (v2snap.taskId) base.taskId = v2snap.taskId;
96
+ if (v2snap.batchId) base.batchId = v2snap.batchId;
92
97
  // Enrich progress display from V2 snapshot
93
98
  if (v2snap.progress) {
94
99
  base._v2Progress = v2snap.progress;
@@ -417,21 +422,49 @@ function renderSummary(batch) {
417
422
  let elapsedStr = `elapsed: ${formatDuration(elapsed)}`;
418
423
  if (batch.updatedAt) elapsedStr += ` · updated: ${relativeTime(batch.updatedAt)}`;
419
424
 
420
- // Aggregate tokens across all active lane states
425
+ // Aggregate tokens/cost for summary.
426
+ // Runtime V2 snapshots are authoritative when present; legacy lane-state sidecars are fallback.
421
427
  const laneStates = currentData?.laneStates || {};
422
- let batchInput = 0, batchOutput = 0, batchCacheRead = 0, batchCacheWrite = 0, batchCostFromLanes = 0;
423
- for (const ls of Object.values(laneStates)) {
424
- batchInput += ls.workerInputTokens || 0;
425
- batchOutput += ls.workerOutputTokens || 0;
426
- batchCacheRead += ls.workerCacheReadTokens || 0;
427
- batchCacheWrite += ls.workerCacheWriteTokens || 0;
428
- batchCostFromLanes += ls.workerCostUsd || 0;
429
- }
430
- // Use server-computed batchTotalCost (includes telemetry for uncovered lanes);
431
- // fallback to lane-state-only sum for backward compatibility (pre-telemetry server)
432
- const batchCost = (currentData?.batchTotalCost != null && currentData.batchTotalCost > 0)
433
- ? currentData.batchTotalCost
434
- : batchCostFromLanes;
428
+ const runtimeLaneSnapshots = currentData?.runtimeLaneSnapshots || {};
429
+ const v2Snaps = Object.values(runtimeLaneSnapshots);
430
+
431
+ let batchInput = 0, batchOutput = 0, batchCacheRead = 0, batchCacheWrite = 0, batchCostFromSnapshots = 0;
432
+
433
+ if (v2Snaps.length > 0) {
434
+ for (const snap of v2Snaps) {
435
+ const w = snap?.worker || {};
436
+ batchInput += w.inputTokens || 0;
437
+ batchOutput += w.outputTokens || 0;
438
+ batchCacheRead += w.cacheReadTokens || 0;
439
+ batchCacheWrite += w.cacheWriteTokens || 0;
440
+ batchCostFromSnapshots += w.costUsd || 0;
441
+
442
+ const r = snap?.reviewer || null;
443
+ if (r) {
444
+ batchInput += r.inputTokens || 0;
445
+ batchOutput += r.outputTokens || 0;
446
+ batchCacheRead += r.cacheReadTokens || 0;
447
+ batchCacheWrite += r.cacheWriteTokens || 0;
448
+ batchCostFromSnapshots += r.costUsd || 0;
449
+ }
450
+ }
451
+ } else {
452
+ // Legacy fallback
453
+ for (const ls of Object.values(laneStates)) {
454
+ batchInput += ls.workerInputTokens || 0;
455
+ batchOutput += ls.workerOutputTokens || 0;
456
+ batchCacheRead += ls.workerCacheReadTokens || 0;
457
+ batchCacheWrite += ls.workerCacheWriteTokens || 0;
458
+ batchCostFromSnapshots += ls.workerCostUsd || 0;
459
+ }
460
+ }
461
+
462
+ // Keep server-computed cost as fallback for uncovered early-start lanes.
463
+ const batchCost = batchCostFromSnapshots > 0
464
+ ? batchCostFromSnapshots
465
+ : ((currentData?.batchTotalCost != null && currentData.batchTotalCost > 0)
466
+ ? currentData.batchTotalCost
467
+ : 0);
435
468
  const batchTotalIn = batchInput + batchCacheRead;
436
469
  if (batchTotalIn > 0 || batchOutput > 0) {
437
470
  let tokenStr = ` · tokens: ↑${formatTokens(batchTotalIn)} ↓${formatTokens(batchOutput)}`;
@@ -1015,6 +1015,39 @@ function buildDashboardState() {
1015
1015
  const runtimeLaneSnapshots = loadRuntimeLaneSnapshots(state.batchId);
1016
1016
  const mailboxData = loadMailboxData(state.batchId);
1017
1017
 
1018
+ // TP-115: Synthesize laneStates from V2 snapshots so the dashboard
1019
+ // pipeline works without legacy lane-state-*.json sidecar files.
1020
+ // V2 snapshots are authoritative when present.
1021
+ if (Object.keys(runtimeLaneSnapshots).length > 0) {
1022
+ for (const [laneNum, snap] of Object.entries(runtimeLaneSnapshots)) {
1023
+ // Find the matching lane record to get the session name key
1024
+ const laneRec = (state.lanes || []).find(l => l.laneNumber === Number(laneNum));
1025
+ const key = laneRec ? laneRec.tmuxSessionName : `lane-${laneNum}`;
1026
+ if (!laneStates[key] || (snap.updatedAt && snap.updatedAt > (laneStates[key].timestamp || 0))) {
1027
+ const w = snap.worker || {};
1028
+ const statusMap = { running: "running", spawning: "running", exited: "done", crashed: "error", killed: "error", timed_out: "error", wrapping_up: "running" };
1029
+ laneStates[key] = {
1030
+ prefix: key,
1031
+ taskId: snap.taskId || null,
1032
+ phase: snap.status === "running" ? "worker-active" : snap.status === "complete" ? "complete" : "idle",
1033
+ workerStatus: statusMap[w.status] || w.status || "idle",
1034
+ workerElapsed: w.elapsedMs || 0,
1035
+ workerContextPct: w.contextPct || 0,
1036
+ workerLastTool: w.lastTool || "",
1037
+ workerToolCount: w.toolCalls || 0,
1038
+ workerInputTokens: w.inputTokens || 0,
1039
+ workerOutputTokens: w.outputTokens || 0,
1040
+ workerCacheReadTokens: w.cacheReadTokens || 0,
1041
+ workerCacheWriteTokens: w.cacheWriteTokens || 0,
1042
+ workerCostUsd: w.costUsd || 0,
1043
+ reviewerStatus: "idle",
1044
+ batchId: snap.batchId || state.batchId,
1045
+ timestamp: snap.updatedAt || Date.now(),
1046
+ };
1047
+ }
1048
+ }
1049
+ }
1050
+
1018
1051
  return {
1019
1052
  laneStates,
1020
1053
  telemetry,
@@ -1,567 +1,575 @@
1
- /**
2
- * Lane Runner — Headless per-lane execution for Runtime V2
3
- *
4
- * Replaces the legacy TMUX-backed lane execution path with a
5
- * deterministic Node process that owns:
6
- * - worker iteration loops
7
- * - STATUS.md progression
8
- * - .DONE creation detection
9
- * - reviewer orchestration (future)
10
- * - lane snapshot emission
11
- *
12
- * No Pi extension dependency. No TMUX. No TASK_AUTOSTART.
13
- *
14
- * @module taskplane/lane-runner
15
- * @since TP-105
16
- */
17
-
18
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
19
- import { join, dirname, resolve } from "path";
20
- import { fileURLToPath } from "url";
21
-
22
- import {
23
- parsePromptMd,
24
- parseStatusMd,
25
- generateStatusMd,
26
- updateStatusField,
27
- updateStepStatus,
28
- logExecution,
29
- isStepComplete,
30
- type StepInfo,
31
- type CoreParsedTask,
32
- } from "./task-executor-core.ts";
33
-
34
- import { spawnAgent, type AgentHostOptions, type AgentHostResult } from "./agent-host.ts";
35
-
36
- import {
37
- appendAgentEvent,
38
- writeLaneSnapshot,
39
- } from "./process-registry.ts";
40
-
41
- import {
42
- readOutbox,
43
- ackOutboxMessage,
44
- appendMailboxAuditEvent,
45
- } from "./mailbox.ts";
46
-
47
- import {
48
- resolvePacketPaths,
49
- buildRuntimeAgentId,
50
- runtimeAgentEventsPath,
51
- type ExecutionUnit,
52
- type RuntimeAgentId,
53
- type RuntimeLaneSnapshot,
54
- type RuntimeAgentTelemetrySnapshot,
55
- type RuntimeTaskProgress,
56
- type RuntimeAgentStatus,
57
- type PacketPaths,
58
- type LaneTaskOutcome,
59
- type LaneTaskStatus,
60
- type SupervisorAlertCallback,
61
- } from "./types.ts";
62
-
63
- const LANE_RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
64
-
65
- // ── Types ────────────────────────────────────────────────────────────
66
-
67
- /**
68
- * Configuration for a lane-runner execution.
69
- *
70
- * @since TP-105
71
- */
72
- export interface LaneRunnerConfig {
73
- /** Batch ID */
74
- batchId: string;
75
- /** Operator prefix for agent IDs (e.g., "orch-henrylach") */
76
- agentIdPrefix: string;
77
- /** Lane number (1-indexed) */
78
- laneNumber: number;
79
- /** Absolute path to the lane worktree */
80
- worktreePath: string;
81
- /** Git branch checked out in the worktree */
82
- branch: string;
83
- /** Repo ID */
84
- repoId: string;
85
- /** State root for runtime artifacts (workspace root or repo root) */
86
- stateRoot: string;
87
- /** Worker model (empty string = inherit from session) */
88
- workerModel: string;
89
- /** Worker tools */
90
- workerTools: string;
91
- /** Worker thinking mode */
92
- workerThinking: string;
93
- /** Worker system prompt */
94
- workerSystemPrompt: string;
95
- /** Max worker iterations before giving up */
96
- maxIterations: number;
97
- /** No-progress stall limit */
98
- noProgressLimit: number;
99
- /** Max worker time in minutes per iteration */
100
- maxWorkerMinutes: number;
101
- /** Context pressure warn threshold (0-100) */
102
- warnPercent: number;
103
- /** Context pressure kill threshold (0-100) */
104
- killPercent: number;
105
- /** Optional callback for surfacing runtime mailbox replies/escalations to supervisor */
106
- onSupervisorAlert?: SupervisorAlertCallback;
107
- }
108
-
109
- /**
110
- * Result of executing one task through the lane-runner.
111
- *
112
- * @since TP-105
113
- */
114
- export interface LaneRunnerTaskResult {
115
- /** Standard lane task outcome compatible with the engine */
116
- outcome: LaneTaskOutcome;
117
- /** Total worker iterations consumed */
118
- iterations: number;
119
- /** Cumulative worker cost in USD */
120
- costUsd: number;
121
- /** Total tokens used */
122
- totalTokens: number;
123
- }
124
-
125
- // ── Core Execution ───────────────────────────────────────────────────
126
-
127
- /**
128
- * Execute a single task in a lane using the Runtime V2 headless backend.
129
- *
130
- * This is the core function that replaces the legacy TMUX-backed
131
- * `executeLane()` → `spawnLaneSession()` → `task-runner TASK_AUTOSTART`
132
- * path with direct child-process hosting.
133
- *
134
- * Execution loop:
135
- * 1. Parse task and ensure STATUS.md exists
136
- * 2. For each iteration:
137
- * a. Determine remaining steps
138
- * b. Spawn worker agent via agent-host
139
- * c. Wait for worker to exit
140
- * d. Check progress (checkboxes)
141
- * e. If all steps complete → success
142
- * f. If no progress → increment stall counter
143
- * g. If stall limit or iteration limit hit → fail
144
- * 3. If all steps complete, check for .DONE
145
- * 4. Return LaneTaskOutcome
146
- *
147
- * @since TP-105
148
- */
149
- export async function executeTaskV2(
150
- unit: ExecutionUnit,
151
- config: LaneRunnerConfig,
152
- pauseSignal: { paused: boolean },
153
- ): Promise<LaneRunnerTaskResult> {
154
- const startTime = Date.now();
155
- const statusPath = unit.packet.statusPath;
156
- const donePath = unit.packet.donePath;
157
- const promptPath = unit.packet.promptPath;
158
- const taskFolder = unit.packet.taskFolder;
159
- const taskId = unit.taskId;
160
- const workerAgentId = buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker");
161
-
162
- // ── 1. Ensure STATUS.md exists ──────────────────────────────────
163
- if (!existsSync(statusPath)) {
164
- const content = readFileSync(promptPath, "utf-8");
165
- const parsed = parsePromptMd(content, promptPath);
166
- writeFileSync(statusPath, generateStatusMd(parsed));
167
- }
168
-
169
- updateStatusField(statusPath, "Status", "🟡 In Progress");
170
- updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
171
- logExecution(statusPath, "Task started", "Runtime V2 lane-runner execution");
172
-
173
- // ── 2. Iteration loop ───────────────────────────────────────────
174
- let noProgressCount = 0;
175
- let totalIterations = 0;
176
- let cumulativeCostUsd = 0;
177
- let cumulativeTokens = 0;
178
-
179
- for (let iter = 0; iter < config.maxIterations; iter++) {
180
- if (pauseSignal.paused) {
181
- logExecution(statusPath, "Paused", `User paused at iteration ${totalIterations}`);
182
- return makeResult(taskId, workerAgentId, "skipped", startTime,
183
- "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath);
184
- }
185
-
186
- // Determine remaining steps
187
- const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
188
- const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
189
- const remainingSteps = parsed.steps.filter(step => {
190
- const ss = currentStatus.steps.find(s => s.number === step.number);
191
- return !isStepComplete(ss);
192
- });
193
-
194
- if (remainingSteps.length === 0) break; // All done
195
-
196
- totalIterations++;
197
- updateStatusField(statusPath, "Current Step", `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`);
198
- updateStatusField(statusPath, "Iteration", `${totalIterations}`);
199
-
200
- // Mark first incomplete step as in-progress
201
- const firstStep = remainingSteps[0];
202
- const firstStepStatus = currentStatus.steps.find(s => s.number === firstStep.number);
203
- if (firstStepStatus?.status !== "in-progress") {
204
- updateStepStatus(statusPath, firstStep.number, "in-progress");
205
- logExecution(statusPath, `Step ${firstStep.number} started`, firstStep.name);
206
- }
207
-
208
- // Count checkboxes before worker runs
209
- const prevTotalChecked = currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
210
-
211
- // ── Build worker prompt ─────────────────────────────────────
212
- const wrapUpFile = join(taskFolder, ".task-wrap-up");
213
- if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
214
-
215
- const promptLines = [
216
- `Read your task instructions at: ${promptPath}`,
217
- `Read your execution state at: ${statusPath}`,
218
- ``,
219
- `Task: ${taskId}`,
220
- `Task folder: ${taskFolder}/`,
221
- `Iteration: ${totalIterations}`,
222
- `Wrap-up signal file: ${wrapUpFile}`,
223
- ``,
224
- `⚠️ ORCHESTRATED RUN: Do NOT archive or move the task folder. The orchestrator handles post-merge archival.`,
225
- ];
226
-
227
- if (totalIterations > 1 && remainingSteps.length > 0) {
228
- const remainingSet = new Set(remainingSteps.map(s => s.number));
229
- const completedSteps = parsed.steps.filter(s => !remainingSet.has(s.number));
230
- promptLines.push(
231
- ``,
232
- `IMPORTANT: You exited previously without completing all steps.`,
233
- `Completed (do not redo): ${completedSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`,
234
- `Remaining (focus here): ${remainingSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ")}`,
235
- );
236
- }
237
-
238
- // ── Spawn worker ────────────────────────────────────────────
239
- const eventsPath = runtimeAgentEventsPath(config.stateRoot, config.batchId, workerAgentId);
240
-
241
- const mailboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId);
242
- mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
243
-
244
- const steeringPendingPath = join(taskFolder, ".steering-pending");
245
-
246
- // TP-106: Bridge extension wiring for agent-side reply/escalate tools
247
- const outboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId, "outbox");
248
- const bridgeExtensionPath = join(LANE_RUNNER_DIR, "agent-bridge-extension.ts");
249
-
250
- const hostOpts: AgentHostOptions = {
251
- agentId: workerAgentId,
252
- role: "worker",
253
- batchId: config.batchId,
254
- laneNumber: config.laneNumber,
255
- taskId,
256
- repoId: config.repoId,
257
- cwd: config.worktreePath,
258
- prompt: promptLines.join("\n"),
259
- systemPrompt: config.workerSystemPrompt || undefined,
260
- model: config.workerModel || undefined,
261
- tools: config.workerTools || "read,write,edit,bash,grep,find,ls",
262
- thinking: config.workerThinking || undefined,
263
- mailboxDir,
264
- steeringPendingPath,
265
- eventsPath,
266
- exitSummaryPath: eventsPath.replace(/\.jsonl$/, "-exit.json"),
267
- timeoutMs: config.maxWorkerMinutes * 60_000,
268
- stateRoot: config.stateRoot,
269
- packet: unit.packet,
270
- extensions: [bridgeExtensionPath],
271
- env: {
272
- TASKPLANE_OUTBOX_DIR: outboxDir,
273
- TASKPLANE_AGENT_ID: workerAgentId,
274
- ORCH_BATCH_ID: config.batchId,
275
- },
276
- };
277
-
278
- // Context pressure: write wrap-up signal before kill
279
- let workerKillReason: "context" | "timer" | null = null;
280
-
281
- const spawned = spawnAgent(hostOpts, undefined, (telemetry) => {
282
- // Context pressure check
283
- if (telemetry.contextUsage) {
284
- const pct = telemetry.contextUsage.percent;
285
- if (pct >= config.warnPercent) {
286
- const msg = `Wrap up (context ${Math.round(pct)}%)`;
287
- if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
288
- }
289
- if (pct >= config.killPercent) {
290
- workerKillReason = "context";
291
- spawned.kill();
292
- }
293
- }
294
-
295
- // Emit lane snapshot
296
- emitSnapshot(config, taskId, "running", telemetry, statusPath);
297
- });
298
-
299
- const workerResult = await spawned.promise;
300
-
301
- // Clean up wrap-up signal
302
- if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
303
-
304
- // Accumulate costs
305
- cumulativeCostUsd += workerResult.costUsd;
306
- cumulativeTokens += workerResult.inputTokens + workerResult.outputTokens +
307
- workerResult.cacheReadTokens + workerResult.cacheWriteTokens;
308
-
309
- // ── TP-106: Poll worker outbox for replies/escalations ─────
310
- try {
311
- const outboxMessages = readOutbox(config.stateRoot, config.batchId, workerAgentId);
312
- for (const msg of outboxMessages) {
313
- const sanitized = msg.content.replace(/\r?\n/g, " / ").slice(0, 200);
314
- logExecution(statusPath, `Agent ${msg.type}`, sanitized);
315
-
316
- if (msg.type === "reply" || msg.type === "escalate") {
317
- appendAgentEvent(config.stateRoot, config.batchId, workerAgentId, {
318
- batchId: config.batchId,
319
- agentId: workerAgentId,
320
- role: "worker",
321
- laneNumber: config.laneNumber,
322
- taskId,
323
- repoId: config.repoId,
324
- ts: Date.now(),
325
- type: msg.type === "reply" ? "reply_sent" : "escalation_sent",
326
- payload: {
327
- messageId: msg.id,
328
- replyTo: msg.replyTo ?? null,
329
- content: sanitized,
330
- },
331
- });
332
-
333
- appendMailboxAuditEvent(config.stateRoot, config.batchId, {
334
- type: msg.type === "reply" ? "message_replied" : "message_escalated",
335
- from: workerAgentId,
336
- to: "supervisor",
337
- messageId: msg.id,
338
- messageType: msg.type,
339
- contentPreview: sanitized,
340
- });
341
-
342
- if (config.onSupervisorAlert) {
343
- const isEscalation = msg.type === "escalate";
344
- try {
345
- config.onSupervisorAlert({
346
- category: "agent-message",
347
- summary:
348
- `${isEscalation ? "🚨" : "📨"} Agent ${isEscalation ? "escalation" : "reply"} from ${workerAgentId}\n` +
349
- ` Task: ${taskId}\n` +
350
- ` Lane: lane-${config.laneNumber}\n` +
351
- ` Message: ${sanitized}`,
352
- context: {
353
- taskId,
354
- laneId: `lane-${config.laneNumber}`,
355
- laneNumber: config.laneNumber,
356
- agentId: workerAgentId,
357
- messageId: msg.id,
358
- exitReason: `${isEscalation ? "agent_escalation" : "agent_reply"}: ${sanitized}`,
359
- },
360
- });
361
- } catch { /* best effort */ }
362
- }
363
- }
364
-
365
- // Consume outbox message to prevent duplicate processing in later iterations.
366
- ackOutboxMessage(config.stateRoot, config.batchId, workerAgentId, msg.id);
367
- }
368
- } catch { /* best effort */ }
369
-
370
- // ── Steering annotation ─────────────────────────────────────
371
- try {
372
- if (existsSync(steeringPendingPath)) {
373
- const raw = readFileSync(steeringPendingPath, "utf-8");
374
- for (const line of raw.split("\n").filter(l => l.trim())) {
375
- try {
376
- const entry = JSON.parse(line) as { ts: number; content: string; id: string };
377
- const sanitized = entry.content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|").slice(0, 200);
378
- const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
379
- logExecution(statusPath, "⚠️ Steering", sanitized);
380
- } catch { /* skip malformed */ }
381
- }
382
- unlinkSync(steeringPendingPath);
383
- }
384
- } catch { /* non-fatal */ }
385
-
386
- // Log iteration result
387
- const statusMsg = workerResult.killed
388
- ? `killed (${workerKillReason === "context" ? "context limit" : "wall-clock timeout"})`
389
- : (workerResult.exitCode === 0 ? "done" : `error (code ${workerResult.exitCode})`);
390
- logExecution(statusPath, `Worker iter ${totalIterations}`,
391
- `${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`);
392
-
393
- // ── Check progress ──────────────────────────────────────────
394
- const afterStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
395
- const afterTotalChecked = afterStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
396
- const progressDelta = afterTotalChecked - prevTotalChecked;
397
-
398
- if (progressDelta <= 0) {
399
- noProgressCount++;
400
- logExecution(statusPath, "No progress",
401
- `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
402
- if (noProgressCount >= config.noProgressLimit) {
403
- logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
404
- return makeResult(taskId, workerAgentId, "failed", startTime,
405
- `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath);
406
- }
407
- } else {
408
- noProgressCount = 0;
409
- }
410
-
411
- // Mark completed steps
412
- for (const step of parsed.steps) {
413
- const ss = afterStatus.steps.find(s => s.number === step.number);
414
- if (isStepComplete(ss)) {
415
- updateStepStatus(statusPath, step.number, "complete");
416
- }
417
- }
418
-
419
- // Check if all steps are now complete
420
- const allComplete = parsed.steps.every(step => {
421
- const ss = afterStatus.steps.find(s => s.number === step.number);
422
- return isStepComplete(ss);
423
- });
424
- if (allComplete) break;
425
- }
426
-
427
- // ── 3. Post-loop completion check ───────────────────────────────
428
- const finalStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
429
- const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
430
- const allStepsComplete = parsed.steps.every(step => {
431
- const ss = finalStatus.steps.find(s => s.number === step.number);
432
- return isStepComplete(ss);
433
- });
434
-
435
- if (!allStepsComplete) {
436
- const incomplete = parsed.steps
437
- .filter(step => {
438
- const ss = finalStatus.steps.find(s => s.number === step.number);
439
- return !isStepComplete(ss);
440
- })
441
- .map(s => `Step ${s.number}`)
442
- .join(", ");
443
- logExecution(statusPath, "Task incomplete", `Max iterations reached. Incomplete: ${incomplete}`);
444
- return makeResult(taskId, workerAgentId, "failed", startTime,
445
- `Max iterations (${config.maxIterations}) reached with incomplete steps: ${incomplete}`,
446
- false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath);
447
- }
448
-
449
- // Create .DONE if not already present
450
- if (!existsSync(donePath)) {
451
- writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${taskId}\n`);
452
- }
453
- updateStatusField(statusPath, "Status", "✅ Complete");
454
- logExecution(statusPath, "Task complete", ".DONE created");
455
-
456
- return makeResult(taskId, workerAgentId, "succeeded", startTime,
457
- ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath);
458
- }
459
-
460
- // ── Helpers ──────────────────────────────────────────────────────────
461
-
462
- export function mapLaneTaskStatusToTerminalSnapshotStatus(
463
- status: LaneTaskStatus,
464
- ): "idle" | "complete" | "failed" {
465
- if (status === "succeeded") return "complete";
466
- if (status === "skipped") return "idle";
467
- return "failed";
468
- }
469
-
470
- export function mapLaneSnapshotStatusToWorkerStatus(
471
- status: "running" | "idle" | "complete" | "failed",
472
- ): RuntimeAgentStatus {
473
- if (status === "running") return "running";
474
- if (status === "complete") return "exited";
475
- if (status === "idle") return "wrapping_up";
476
- return "crashed";
477
- }
478
-
479
- function makeResult(
480
- taskId: string,
481
- sessionName: string,
482
- status: LaneTaskStatus,
483
- startTime: number,
484
- exitReason: string,
485
- doneFileFound: boolean,
486
- iterations: number,
487
- costUsd: number,
488
- totalTokens: number,
489
- config?: LaneRunnerConfig,
490
- statusPath?: string,
491
- ): LaneRunnerTaskResult {
492
- const result: LaneRunnerTaskResult = {
493
- outcome: {
494
- taskId,
495
- status,
496
- startTime,
497
- endTime: Date.now(),
498
- exitReason,
499
- sessionName,
500
- doneFileFound,
501
- },
502
- iterations,
503
- costUsd,
504
- totalTokens,
505
- };
506
-
507
- // Emit terminal snapshot so dashboard/registry reflect final state
508
- if (config && statusPath) {
509
- const terminalStatus = mapLaneTaskStatusToTerminalSnapshotStatus(status);
510
- emitSnapshot(config, taskId, terminalStatus, {}, statusPath);
511
- }
512
-
513
- return result;
514
- }
515
-
516
- function emitSnapshot(
517
- config: LaneRunnerConfig,
518
- taskId: string,
519
- status: "running" | "idle" | "complete" | "failed",
520
- telemetry: Partial<AgentHostResult>,
521
- statusPath: string,
522
- ): void {
523
- // Parse progress from STATUS.md
524
- let progress: RuntimeTaskProgress | null = null;
525
- try {
526
- const content = readFileSync(statusPath, "utf-8");
527
- const parsed = parseStatusMd(content);
528
- const currentStepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
529
- const checked = parsed.steps.reduce((sum, s) => sum + s.totalChecked, 0);
530
- const total = parsed.steps.reduce((sum, s) => sum + s.totalItems, 0);
531
- progress = {
532
- currentStep: currentStepMatch?.[1]?.trim() || "Unknown",
533
- checked,
534
- total,
535
- iteration: parsed.iteration,
536
- reviews: parsed.reviewCounter,
537
- };
538
- } catch { /* best effort */ }
539
-
540
- const snapshot: RuntimeLaneSnapshot = {
541
- batchId: config.batchId,
542
- laneNumber: config.laneNumber,
543
- laneId: `lane-${config.laneNumber}`,
544
- repoId: config.repoId,
545
- taskId,
546
- segmentId: null,
547
- status,
548
- worker: {
549
- agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker"),
550
- status: mapLaneSnapshotStatusToWorkerStatus(status),
551
- elapsedMs: telemetry.durationMs ?? 0,
552
- toolCalls: telemetry.toolCalls ?? 0,
553
- contextPct: telemetry.contextUsage?.percent ?? 0,
554
- costUsd: telemetry.costUsd ?? 0,
555
- lastTool: telemetry.lastTool ?? "",
556
- inputTokens: telemetry.inputTokens ?? 0,
557
- outputTokens: telemetry.outputTokens ?? 0,
558
- cacheReadTokens: telemetry.cacheReadTokens ?? 0,
559
- cacheWriteTokens: telemetry.cacheWriteTokens ?? 0,
560
- },
561
- reviewer: null,
562
- progress,
563
- updatedAt: Date.now(),
564
- };
565
-
566
- writeLaneSnapshot(config.stateRoot, config.batchId, config.laneNumber, snapshot as any);
567
- }
1
+ /**
2
+ * Lane Runner — Headless per-lane execution for Runtime V2
3
+ *
4
+ * Replaces the legacy TMUX-backed lane execution path with a
5
+ * deterministic Node process that owns:
6
+ * - worker iteration loops
7
+ * - STATUS.md progression
8
+ * - .DONE creation detection
9
+ * - reviewer orchestration (future)
10
+ * - lane snapshot emission
11
+ *
12
+ * No Pi extension dependency. No TMUX. No TASK_AUTOSTART.
13
+ *
14
+ * @module taskplane/lane-runner
15
+ * @since TP-105
16
+ */
17
+
18
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
19
+ import { join, dirname, resolve } from "path";
20
+ import { fileURLToPath } from "url";
21
+
22
+ import {
23
+ parsePromptMd,
24
+ parseStatusMd,
25
+ generateStatusMd,
26
+ updateStatusField,
27
+ updateStepStatus,
28
+ logExecution,
29
+ isStepComplete,
30
+ type StepInfo,
31
+ type CoreParsedTask,
32
+ } from "./task-executor-core.ts";
33
+
34
+ import { spawnAgent, type AgentHostOptions, type AgentHostResult } from "./agent-host.ts";
35
+
36
+ import {
37
+ appendAgentEvent,
38
+ writeLaneSnapshot,
39
+ } from "./process-registry.ts";
40
+
41
+ import {
42
+ readOutbox,
43
+ ackOutboxMessage,
44
+ appendMailboxAuditEvent,
45
+ } from "./mailbox.ts";
46
+
47
+ import {
48
+ resolvePacketPaths,
49
+ buildRuntimeAgentId,
50
+ runtimeAgentEventsPath,
51
+ type ExecutionUnit,
52
+ type RuntimeAgentId,
53
+ type RuntimeLaneSnapshot,
54
+ type RuntimeAgentTelemetrySnapshot,
55
+ type RuntimeTaskProgress,
56
+ type RuntimeAgentStatus,
57
+ type PacketPaths,
58
+ type LaneTaskOutcome,
59
+ type LaneTaskStatus,
60
+ type SupervisorAlertCallback,
61
+ } from "./types.ts";
62
+
63
+ const LANE_RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
64
+
65
+ // ── Types ────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Configuration for a lane-runner execution.
69
+ *
70
+ * @since TP-105
71
+ */
72
+ export interface LaneRunnerConfig {
73
+ /** Batch ID */
74
+ batchId: string;
75
+ /** Operator prefix for agent IDs (e.g., "orch-henrylach") */
76
+ agentIdPrefix: string;
77
+ /** Lane number (1-indexed) */
78
+ laneNumber: number;
79
+ /** Absolute path to the lane worktree */
80
+ worktreePath: string;
81
+ /** Git branch checked out in the worktree */
82
+ branch: string;
83
+ /** Repo ID */
84
+ repoId: string;
85
+ /** State root for runtime artifacts (workspace root or repo root) */
86
+ stateRoot: string;
87
+ /** Worker model (empty string = inherit from session) */
88
+ workerModel: string;
89
+ /** Worker tools */
90
+ workerTools: string;
91
+ /** Worker thinking mode */
92
+ workerThinking: string;
93
+ /** Worker system prompt */
94
+ workerSystemPrompt: string;
95
+ /** Max worker iterations before giving up */
96
+ maxIterations: number;
97
+ /** No-progress stall limit */
98
+ noProgressLimit: number;
99
+ /** Max worker time in minutes per iteration */
100
+ maxWorkerMinutes: number;
101
+ /** Context pressure warn threshold (0-100) */
102
+ warnPercent: number;
103
+ /** Context pressure kill threshold (0-100) */
104
+ killPercent: number;
105
+ /** Optional callback for surfacing runtime mailbox replies/escalations to supervisor */
106
+ onSupervisorAlert?: SupervisorAlertCallback;
107
+ }
108
+
109
+ /**
110
+ * Result of executing one task through the lane-runner.
111
+ *
112
+ * @since TP-105
113
+ */
114
+ export interface LaneRunnerTaskResult {
115
+ /** Standard lane task outcome compatible with the engine */
116
+ outcome: LaneTaskOutcome;
117
+ /** Total worker iterations consumed */
118
+ iterations: number;
119
+ /** Cumulative worker cost in USD */
120
+ costUsd: number;
121
+ /** Total tokens used */
122
+ totalTokens: number;
123
+ }
124
+
125
+ // ── Core Execution ───────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Execute a single task in a lane using the Runtime V2 headless backend.
129
+ *
130
+ * This is the core function that replaces the legacy TMUX-backed
131
+ * `executeLane()` → `spawnLaneSession()` → `task-runner TASK_AUTOSTART`
132
+ * path with direct child-process hosting.
133
+ *
134
+ * Execution loop:
135
+ * 1. Parse task and ensure STATUS.md exists
136
+ * 2. For each iteration:
137
+ * a. Determine remaining steps
138
+ * b. Spawn worker agent via agent-host
139
+ * c. Wait for worker to exit
140
+ * d. Check progress (checkboxes)
141
+ * e. If all steps complete → success
142
+ * f. If no progress → increment stall counter
143
+ * g. If stall limit or iteration limit hit → fail
144
+ * 3. If all steps complete, check for .DONE
145
+ * 4. Return LaneTaskOutcome
146
+ *
147
+ * @since TP-105
148
+ */
149
+ export async function executeTaskV2(
150
+ unit: ExecutionUnit,
151
+ config: LaneRunnerConfig,
152
+ pauseSignal: { paused: boolean },
153
+ ): Promise<LaneRunnerTaskResult> {
154
+ const startTime = Date.now();
155
+ const statusPath = unit.packet.statusPath;
156
+ const donePath = unit.packet.donePath;
157
+ const promptPath = unit.packet.promptPath;
158
+ const taskFolder = unit.packet.taskFolder;
159
+ const taskId = unit.taskId;
160
+ const workerAgentId = buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker");
161
+
162
+ // ── 1. Ensure STATUS.md exists ──────────────────────────────────
163
+ if (!existsSync(statusPath)) {
164
+ const content = readFileSync(promptPath, "utf-8");
165
+ const parsed = parsePromptMd(content, promptPath);
166
+ writeFileSync(statusPath, generateStatusMd(parsed));
167
+ }
168
+
169
+ updateStatusField(statusPath, "Status", "🟡 In Progress");
170
+ updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
171
+ logExecution(statusPath, "Task started", "Runtime V2 lane-runner execution");
172
+
173
+ // ── 2. Iteration loop ───────────────────────────────────────────
174
+ let noProgressCount = 0;
175
+ let totalIterations = 0;
176
+ let cumulativeCostUsd = 0;
177
+ let cumulativeTokens = 0;
178
+ // TP-115: carry latest worker telemetry across iterations and into post-loop terminal snapshots
179
+ let lastTelemetry: Partial<AgentHostResult> = {};
180
+
181
+ for (let iter = 0; iter < config.maxIterations; iter++) {
182
+ if (pauseSignal.paused) {
183
+ logExecution(statusPath, "Paused", `User paused at iteration ${totalIterations}`);
184
+ return makeResult(taskId, workerAgentId, "skipped", startTime,
185
+ "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath);
186
+ }
187
+
188
+ // Determine remaining steps
189
+ const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
190
+ const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
191
+ const remainingSteps = parsed.steps.filter(step => {
192
+ const ss = currentStatus.steps.find(s => s.number === step.number);
193
+ return !isStepComplete(ss);
194
+ });
195
+
196
+ if (remainingSteps.length === 0) break; // All done
197
+
198
+ totalIterations++;
199
+ updateStatusField(statusPath, "Current Step", `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`);
200
+ updateStatusField(statusPath, "Iteration", `${totalIterations}`);
201
+
202
+ // Mark first incomplete step as in-progress
203
+ const firstStep = remainingSteps[0];
204
+ const firstStepStatus = currentStatus.steps.find(s => s.number === firstStep.number);
205
+ if (firstStepStatus?.status !== "in-progress") {
206
+ updateStepStatus(statusPath, firstStep.number, "in-progress");
207
+ logExecution(statusPath, `Step ${firstStep.number} started`, firstStep.name);
208
+ }
209
+
210
+ // Count checkboxes before worker runs
211
+ const prevTotalChecked = currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
212
+
213
+ // ── Build worker prompt ─────────────────────────────────────
214
+ const wrapUpFile = join(taskFolder, ".task-wrap-up");
215
+ if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
216
+
217
+ const promptLines = [
218
+ `Read your task instructions at: ${promptPath}`,
219
+ `Read your execution state at: ${statusPath}`,
220
+ ``,
221
+ `Task: ${taskId}`,
222
+ `Task folder: ${taskFolder}/`,
223
+ `Iteration: ${totalIterations}`,
224
+ `Wrap-up signal file: ${wrapUpFile}`,
225
+ ``,
226
+ `⚠️ ORCHESTRATED RUN: Do NOT archive or move the task folder. The orchestrator handles post-merge archival.`,
227
+ ];
228
+
229
+ if (totalIterations > 1 && remainingSteps.length > 0) {
230
+ const remainingSet = new Set(remainingSteps.map(s => s.number));
231
+ const completedSteps = parsed.steps.filter(s => !remainingSet.has(s.number));
232
+ promptLines.push(
233
+ ``,
234
+ `IMPORTANT: You exited previously without completing all steps.`,
235
+ `Completed (do not redo): ${completedSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`,
236
+ `Remaining (focus here): ${remainingSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ")}`,
237
+ );
238
+ }
239
+
240
+ // ── Spawn worker ────────────────────────────────────────────
241
+ const eventsPath = runtimeAgentEventsPath(config.stateRoot, config.batchId, workerAgentId);
242
+
243
+ const mailboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId);
244
+ mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
245
+
246
+ const steeringPendingPath = join(taskFolder, ".steering-pending");
247
+
248
+ // TP-106: Bridge extension wiring for agent-side reply/escalate tools
249
+ const outboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId, "outbox");
250
+ const bridgeExtensionPath = join(LANE_RUNNER_DIR, "agent-bridge-extension.ts");
251
+
252
+ const hostOpts: AgentHostOptions = {
253
+ agentId: workerAgentId,
254
+ role: "worker",
255
+ batchId: config.batchId,
256
+ laneNumber: config.laneNumber,
257
+ taskId,
258
+ repoId: config.repoId,
259
+ cwd: config.worktreePath,
260
+ prompt: promptLines.join("\n"),
261
+ systemPrompt: config.workerSystemPrompt || undefined,
262
+ model: config.workerModel || undefined,
263
+ tools: config.workerTools || "read,write,edit,bash,grep,find,ls",
264
+ thinking: config.workerThinking || undefined,
265
+ mailboxDir,
266
+ steeringPendingPath,
267
+ eventsPath,
268
+ exitSummaryPath: eventsPath.replace(/\.jsonl$/, "-exit.json"),
269
+ timeoutMs: config.maxWorkerMinutes * 60_000,
270
+ stateRoot: config.stateRoot,
271
+ packet: unit.packet,
272
+ extensions: [bridgeExtensionPath],
273
+ env: {
274
+ TASKPLANE_OUTBOX_DIR: outboxDir,
275
+ TASKPLANE_AGENT_ID: workerAgentId,
276
+ ORCH_BATCH_ID: config.batchId,
277
+ },
278
+ };
279
+
280
+ // Context pressure: write wrap-up signal before kill
281
+ let workerKillReason: "context" | "timer" | null = null;
282
+
283
+ const spawned = spawnAgent(hostOpts, undefined, (telemetry) => {
284
+ // Context pressure check
285
+ if (telemetry.contextUsage) {
286
+ const pct = telemetry.contextUsage.percent;
287
+ if (pct >= config.warnPercent) {
288
+ const msg = `Wrap up (context ${Math.round(pct)}%)`;
289
+ if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
290
+ }
291
+ if (pct >= config.killPercent) {
292
+ workerKillReason = "context";
293
+ spawned.kill();
294
+ }
295
+ }
296
+
297
+ lastTelemetry = telemetry;
298
+ // Emit lane snapshot
299
+ emitSnapshot(config, taskId, "running", telemetry, statusPath);
300
+ });
301
+
302
+ const workerResult = await spawned.promise;
303
+
304
+ // TP-115: Update lastTelemetry with definitive final values from AgentHostResult
305
+ lastTelemetry = workerResult;
306
+
307
+ // Clean up wrap-up signal
308
+ if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
309
+
310
+ // Accumulate costs
311
+ cumulativeCostUsd += workerResult.costUsd;
312
+ cumulativeTokens += workerResult.inputTokens + workerResult.outputTokens +
313
+ workerResult.cacheReadTokens + workerResult.cacheWriteTokens;
314
+
315
+ // ── TP-106: Poll worker outbox for replies/escalations ─────
316
+ try {
317
+ const outboxMessages = readOutbox(config.stateRoot, config.batchId, workerAgentId);
318
+ for (const msg of outboxMessages) {
319
+ const sanitized = msg.content.replace(/\r?\n/g, " / ").slice(0, 200);
320
+ logExecution(statusPath, `Agent ${msg.type}`, sanitized);
321
+
322
+ if (msg.type === "reply" || msg.type === "escalate") {
323
+ appendAgentEvent(config.stateRoot, config.batchId, workerAgentId, {
324
+ batchId: config.batchId,
325
+ agentId: workerAgentId,
326
+ role: "worker",
327
+ laneNumber: config.laneNumber,
328
+ taskId,
329
+ repoId: config.repoId,
330
+ ts: Date.now(),
331
+ type: msg.type === "reply" ? "reply_sent" : "escalation_sent",
332
+ payload: {
333
+ messageId: msg.id,
334
+ replyTo: msg.replyTo ?? null,
335
+ content: sanitized,
336
+ },
337
+ });
338
+
339
+ appendMailboxAuditEvent(config.stateRoot, config.batchId, {
340
+ type: msg.type === "reply" ? "message_replied" : "message_escalated",
341
+ from: workerAgentId,
342
+ to: "supervisor",
343
+ messageId: msg.id,
344
+ messageType: msg.type,
345
+ contentPreview: sanitized,
346
+ });
347
+
348
+ if (config.onSupervisorAlert) {
349
+ const isEscalation = msg.type === "escalate";
350
+ try {
351
+ config.onSupervisorAlert({
352
+ category: "agent-message",
353
+ summary:
354
+ `${isEscalation ? "🚨" : "📨"} Agent ${isEscalation ? "escalation" : "reply"} from ${workerAgentId}\n` +
355
+ ` Task: ${taskId}\n` +
356
+ ` Lane: lane-${config.laneNumber}\n` +
357
+ ` Message: ${sanitized}`,
358
+ context: {
359
+ taskId,
360
+ laneId: `lane-${config.laneNumber}`,
361
+ laneNumber: config.laneNumber,
362
+ agentId: workerAgentId,
363
+ messageId: msg.id,
364
+ exitReason: `${isEscalation ? "agent_escalation" : "agent_reply"}: ${sanitized}`,
365
+ },
366
+ });
367
+ } catch { /* best effort */ }
368
+ }
369
+ }
370
+
371
+ // Consume outbox message to prevent duplicate processing in later iterations.
372
+ ackOutboxMessage(config.stateRoot, config.batchId, workerAgentId, msg.id);
373
+ }
374
+ } catch { /* best effort */ }
375
+
376
+ // ── Steering annotation ─────────────────────────────────────
377
+ try {
378
+ if (existsSync(steeringPendingPath)) {
379
+ const raw = readFileSync(steeringPendingPath, "utf-8");
380
+ for (const line of raw.split("\n").filter(l => l.trim())) {
381
+ try {
382
+ const entry = JSON.parse(line) as { ts: number; content: string; id: string };
383
+ const sanitized = entry.content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|").slice(0, 200);
384
+ const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
385
+ logExecution(statusPath, "⚠️ Steering", sanitized);
386
+ } catch { /* skip malformed */ }
387
+ }
388
+ unlinkSync(steeringPendingPath);
389
+ }
390
+ } catch { /* non-fatal */ }
391
+
392
+ // Log iteration result
393
+ const statusMsg = workerResult.killed
394
+ ? `killed (${workerKillReason === "context" ? "context limit" : "wall-clock timeout"})`
395
+ : (workerResult.exitCode === 0 ? "done" : `error (code ${workerResult.exitCode})`);
396
+ logExecution(statusPath, `Worker iter ${totalIterations}`,
397
+ `${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`);
398
+
399
+ // ── Check progress ──────────────────────────────────────────
400
+ const afterStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
401
+ const afterTotalChecked = afterStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
402
+ const progressDelta = afterTotalChecked - prevTotalChecked;
403
+
404
+ if (progressDelta <= 0) {
405
+ noProgressCount++;
406
+ logExecution(statusPath, "No progress",
407
+ `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
408
+ if (noProgressCount >= config.noProgressLimit) {
409
+ logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
410
+ return makeResult(taskId, workerAgentId, "failed", startTime,
411
+ `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, lastTelemetry);
412
+ }
413
+ } else {
414
+ noProgressCount = 0;
415
+ }
416
+
417
+ // Mark completed steps
418
+ for (const step of parsed.steps) {
419
+ const ss = afterStatus.steps.find(s => s.number === step.number);
420
+ if (isStepComplete(ss)) {
421
+ updateStepStatus(statusPath, step.number, "complete");
422
+ }
423
+ }
424
+
425
+ // Check if all steps are now complete
426
+ const allComplete = parsed.steps.every(step => {
427
+ const ss = afterStatus.steps.find(s => s.number === step.number);
428
+ return isStepComplete(ss);
429
+ });
430
+ if (allComplete) break;
431
+ }
432
+
433
+ // ── 3. Post-loop completion check ───────────────────────────────
434
+ const finalStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
435
+ const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
436
+ const allStepsComplete = parsed.steps.every(step => {
437
+ const ss = finalStatus.steps.find(s => s.number === step.number);
438
+ return isStepComplete(ss);
439
+ });
440
+
441
+ if (!allStepsComplete) {
442
+ const incomplete = parsed.steps
443
+ .filter(step => {
444
+ const ss = finalStatus.steps.find(s => s.number === step.number);
445
+ return !isStepComplete(ss);
446
+ })
447
+ .map(s => `Step ${s.number}`)
448
+ .join(", ");
449
+ logExecution(statusPath, "Task incomplete", `Max iterations reached. Incomplete: ${incomplete}`);
450
+ return makeResult(taskId, workerAgentId, "failed", startTime,
451
+ `Max iterations (${config.maxIterations}) reached with incomplete steps: ${incomplete}`,
452
+ false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, lastTelemetry);
453
+ }
454
+
455
+ // Create .DONE if not already present
456
+ if (!existsSync(donePath)) {
457
+ writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${taskId}\n`);
458
+ }
459
+ updateStatusField(statusPath, "Status", "✅ Complete");
460
+ logExecution(statusPath, "Task complete", ".DONE created");
461
+
462
+ return makeResult(taskId, workerAgentId, "succeeded", startTime,
463
+ ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, lastTelemetry);
464
+ }
465
+
466
+ // ── Helpers ──────────────────────────────────────────────────────────
467
+
468
+ export function mapLaneTaskStatusToTerminalSnapshotStatus(
469
+ status: LaneTaskStatus,
470
+ ): "idle" | "complete" | "failed" {
471
+ if (status === "succeeded") return "complete";
472
+ if (status === "skipped") return "idle";
473
+ return "failed";
474
+ }
475
+
476
+ export function mapLaneSnapshotStatusToWorkerStatus(
477
+ status: "running" | "idle" | "complete" | "failed",
478
+ ): RuntimeAgentStatus {
479
+ if (status === "running") return "running";
480
+ if (status === "complete") return "exited";
481
+ if (status === "idle") return "wrapping_up";
482
+ return "crashed";
483
+ }
484
+
485
+ function makeResult(
486
+ taskId: string,
487
+ sessionName: string,
488
+ status: LaneTaskStatus,
489
+ startTime: number,
490
+ exitReason: string,
491
+ doneFileFound: boolean,
492
+ iterations: number,
493
+ costUsd: number,
494
+ totalTokens: number,
495
+ config?: LaneRunnerConfig,
496
+ statusPath?: string,
497
+ finalTelemetry?: Partial<AgentHostResult>,
498
+ ): LaneRunnerTaskResult {
499
+ const result: LaneRunnerTaskResult = {
500
+ outcome: {
501
+ taskId,
502
+ status,
503
+ startTime,
504
+ endTime: Date.now(),
505
+ exitReason,
506
+ sessionName,
507
+ doneFileFound,
508
+ },
509
+ iterations,
510
+ costUsd,
511
+ totalTokens,
512
+ };
513
+
514
+ // TP-115: Emit terminal snapshot with real telemetry from agent-host result
515
+ if (config && statusPath) {
516
+ const terminalStatus = mapLaneTaskStatusToTerminalSnapshotStatus(status);
517
+ emitSnapshot(config, taskId, terminalStatus, finalTelemetry ?? {}, statusPath);
518
+ }
519
+
520
+ return result;
521
+ }
522
+
523
+ function emitSnapshot(
524
+ config: LaneRunnerConfig,
525
+ taskId: string,
526
+ status: "running" | "idle" | "complete" | "failed",
527
+ telemetry: Partial<AgentHostResult>,
528
+ statusPath: string,
529
+ ): void {
530
+ // Parse progress from STATUS.md
531
+ let progress: RuntimeTaskProgress | null = null;
532
+ try {
533
+ const content = readFileSync(statusPath, "utf-8");
534
+ const parsed = parseStatusMd(content);
535
+ const currentStepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
536
+ const checked = parsed.steps.reduce((sum, s) => sum + s.totalChecked, 0);
537
+ const total = parsed.steps.reduce((sum, s) => sum + s.totalItems, 0);
538
+ progress = {
539
+ currentStep: currentStepMatch?.[1]?.trim() || "Unknown",
540
+ checked,
541
+ total,
542
+ iteration: parsed.iteration,
543
+ reviews: parsed.reviewCounter,
544
+ };
545
+ } catch { /* best effort */ }
546
+
547
+ const snapshot: RuntimeLaneSnapshot = {
548
+ batchId: config.batchId,
549
+ laneNumber: config.laneNumber,
550
+ laneId: `lane-${config.laneNumber}`,
551
+ repoId: config.repoId,
552
+ taskId,
553
+ segmentId: null,
554
+ status,
555
+ worker: {
556
+ agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker"),
557
+ status: mapLaneSnapshotStatusToWorkerStatus(status),
558
+ elapsedMs: telemetry.durationMs ?? 0,
559
+ toolCalls: telemetry.toolCalls ?? 0,
560
+ contextPct: telemetry.contextUsage?.percent ?? 0,
561
+ costUsd: telemetry.costUsd ?? 0,
562
+ lastTool: telemetry.lastTool ?? "",
563
+ inputTokens: telemetry.inputTokens ?? 0,
564
+ outputTokens: telemetry.outputTokens ?? 0,
565
+ cacheReadTokens: telemetry.cacheReadTokens ?? 0,
566
+ cacheWriteTokens: telemetry.cacheWriteTokens ?? 0,
567
+ },
568
+ reviewer: null,
569
+ progress,
570
+ updatedAt: Date.now(),
571
+ };
572
+
573
+ writeLaneSnapshot(config.stateRoot, config.batchId, config.laneNumber, snapshot as any);
574
+ }
575
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.23.0",
3
+ "version": "0.23.2",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",