taskplane 0.23.6 → 0.23.7

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