taskplane 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,10 +3,11 @@
3
3
  * @module orch/merge
4
4
  */
5
5
  import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdirSync, rmSync } from "fs";
6
+ import { readFile as fsReadFile } from "fs/promises";
6
7
  import { execSync, spawnSync } from "child_process";
7
8
  import { join, dirname, resolve, relative } from "path";
8
9
 
9
- import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, generateTelemetryPaths, resolveRpcWrapperPath, resolveTelemOpId, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
10
+ import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, generateTelemetryPaths, resolveRpcWrapperPath, resolveTelemOpId, tmuxHasSession, tmuxHasSessionAsync, tmuxKillSession, tmuxKillSessionAsync, tmuxAsync, toTmuxPath } from "./execution.ts";
10
11
  import { resolveOperatorId } from "./naming.ts";
11
12
  import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MAX_RETRIES, MERGE_TIMEOUT_MS, MERGE_HEALTH_POLL_INTERVAL_MS, MERGE_HEALTH_WARNING_THRESHOLD_MS, MERGE_HEALTH_STUCK_THRESHOLD_MS, MERGE_HEALTH_CAPTURE_LINES, MergeError, VALID_MERGE_STATUSES, buildEngineEventBase } from "./types.ts";
12
13
  import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig, MergeHealthStatus, MergeHealthEventType, MergeSessionSnapshot, MergeSessionHealthState, EngineEvent, OrchBatchPhase } from "./types.ts";
@@ -250,6 +251,186 @@ export function parseMergeResult(resultPath: string): MergeResult {
250
251
  );
251
252
  }
252
253
 
254
+ /**
255
+ * Async version of parseMergeResult — reads and validates a merge result
256
+ * JSON file without blocking the event loop.
257
+ *
258
+ * Uses `fs/promises.readFile` instead of `readFileSync` and `sleepAsync`
259
+ * instead of `sleepSync` for retry delays. Validation semantics and error
260
+ * codes are identical to the sync version.
261
+ *
262
+ * @param resultPath - Path to the merge result JSON file
263
+ * @returns Promise resolving to a validated MergeResult
264
+ * @throws MergeError on missing/invalid/unparseable result
265
+ *
266
+ * @since TP-070
267
+ */
268
+ export async function parseMergeResultAsync(resultPath: string): Promise<MergeResult> {
269
+ if (!existsSync(resultPath)) {
270
+ throw new MergeError(
271
+ "MERGE_RESULT_INVALID",
272
+ `Merge result file not found: ${resultPath}`,
273
+ );
274
+ }
275
+
276
+ const pickString = (obj: Record<string, unknown>, ...keys: string[]): string | null => {
277
+ for (const key of keys) {
278
+ const value = obj[key];
279
+ if (typeof value === "string" && value.trim().length > 0) {
280
+ return value;
281
+ }
282
+ }
283
+ return null;
284
+ };
285
+
286
+ const hasFlatVerification = (obj: Record<string, unknown>): boolean =>
287
+ typeof obj.verification_passed === "boolean"
288
+ || Array.isArray(obj.verification_commands)
289
+ || typeof obj.verification_output === "string"
290
+ || typeof obj.verification_exit_code === "number";
291
+
292
+ const normalizeVerification = (obj: Record<string, unknown>): MergeResult["verification"] | null => {
293
+ const nested = (obj.verification && typeof obj.verification === "object")
294
+ ? obj.verification as Record<string, unknown>
295
+ : null;
296
+
297
+ if (!nested && !hasFlatVerification(obj)) {
298
+ return null;
299
+ }
300
+
301
+ const passedFromBool =
302
+ (nested && typeof nested.passed === "boolean" ? nested.passed : undefined)
303
+ ?? (nested && typeof nested.all_passed === "boolean" ? nested.all_passed : undefined)
304
+ ?? (typeof obj.verification_passed === "boolean" ? obj.verification_passed : undefined);
305
+
306
+ const exitCode =
307
+ (nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined)
308
+ ?? (nested && typeof nested.exit_code === "number" ? nested.exit_code : undefined)
309
+ ?? (typeof obj.verification_exit_code === "number" ? obj.verification_exit_code : undefined);
310
+
311
+ const passed = typeof passedFromBool === "boolean"
312
+ ? passedFromBool
313
+ : (typeof exitCode === "number" ? exitCode === 0 : false);
314
+
315
+ const ran = (nested && typeof nested.ran === "boolean")
316
+ ? nested.ran
317
+ : (
318
+ typeof passedFromBool === "boolean"
319
+ || typeof exitCode === "number"
320
+ || (nested && typeof nested.command === "string")
321
+ || (nested && typeof nested.summary === "string")
322
+ || typeof obj.verification_output === "string"
323
+ || Array.isArray(obj.verification_commands)
324
+ );
325
+
326
+ const output = (
327
+ (nested && typeof nested.output === "string" ? nested.output : undefined)
328
+ ?? (nested && typeof nested.summary === "string" ? nested.summary : undefined)
329
+ ?? (nested && typeof nested.notes === "string" ? nested.notes : undefined)
330
+ ?? (typeof obj.verification_output === "string" ? obj.verification_output : "")
331
+ ).slice(0, 2000);
332
+
333
+ return { ran, passed, output };
334
+ };
335
+
336
+ // Retry-read loop for partially-written files — async version
337
+ let lastParseError = "";
338
+ for (let attempt = 1; attempt <= MERGE_RESULT_READ_RETRIES; attempt++) {
339
+ try {
340
+ const raw = (await fsReadFile(resultPath, "utf-8")).trim();
341
+ if (!raw) {
342
+ lastParseError = "File is empty";
343
+ if (attempt < MERGE_RESULT_READ_RETRIES) {
344
+ await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
345
+ continue;
346
+ }
347
+ throw new MergeError(
348
+ "MERGE_RESULT_INVALID",
349
+ `Merge result file is empty after ${MERGE_RESULT_READ_RETRIES} attempts: ${resultPath}`,
350
+ );
351
+ }
352
+
353
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
354
+
355
+ // Validate required fields
356
+ if (typeof parsed.status !== "string") {
357
+ throw new MergeError(
358
+ "MERGE_RESULT_MISSING_FIELDS",
359
+ `Merge result missing required field "status": ${resultPath}`,
360
+ );
361
+ }
362
+
363
+ const sourceBranch = pickString(parsed, "source_branch", "sourceBranch", "source");
364
+ if (!sourceBranch) {
365
+ throw new MergeError(
366
+ "MERGE_RESULT_MISSING_FIELDS",
367
+ `Merge result missing required field "source_branch" (accepted aliases: sourceBranch, source): ${resultPath}`,
368
+ );
369
+ }
370
+
371
+ const verification = normalizeVerification(parsed);
372
+ if (!verification) {
373
+ throw new MergeError(
374
+ "MERGE_RESULT_MISSING_FIELDS",
375
+ `Merge result missing required field "verification": ${resultPath}`,
376
+ );
377
+ }
378
+
379
+ // Normalize status to uppercase
380
+ parsed.status = String(parsed.status).toUpperCase();
381
+
382
+ if (!VALID_MERGE_STATUSES.has(parsed.status)) {
383
+ execLog("merge", "parse", `unknown merge status "${parsed.status}" — treating as BUILD_FAILURE`, {
384
+ resultPath,
385
+ });
386
+ parsed.status = "BUILD_FAILURE";
387
+ }
388
+
389
+ const targetBranch = pickString(parsed, "target_branch", "targetBranch", "target") ?? "";
390
+ const mergeCommit = pickString(parsed, "merge_commit", "mergeCommit") ?? "";
391
+ const conflicts = Array.isArray(parsed.conflicts)
392
+ ? parsed.conflicts
393
+ .filter((c): c is { file: string; type: string; resolved: boolean; resolution?: string } => (
394
+ typeof c === "object"
395
+ && c !== null
396
+ && typeof (c as { file?: unknown }).file === "string"
397
+ && typeof (c as { type?: unknown }).type === "string"
398
+ && typeof (c as { resolved?: unknown }).resolved === "boolean"
399
+ ))
400
+ .map(c => ({
401
+ file: c.file,
402
+ type: c.type,
403
+ resolved: c.resolved,
404
+ ...(typeof c.resolution === "string" ? { resolution: c.resolution } : {}),
405
+ }))
406
+ : [];
407
+
408
+ return {
409
+ status: parsed.status as MergeResultStatus,
410
+ source_branch: sourceBranch,
411
+ target_branch: targetBranch,
412
+ merge_commit: mergeCommit,
413
+ conflicts,
414
+ verification,
415
+ };
416
+ } catch (err: unknown) {
417
+ if (err instanceof MergeError) throw err;
418
+
419
+ lastParseError = err instanceof Error ? err.message : String(err);
420
+ if (attempt < MERGE_RESULT_READ_RETRIES) {
421
+ await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
422
+ continue;
423
+ }
424
+ }
425
+ }
426
+
427
+ throw new MergeError(
428
+ "MERGE_RESULT_INVALID",
429
+ `Failed to parse merge result JSON after ${MERGE_RESULT_READ_RETRIES} attempts. ` +
430
+ `Last error: ${lastParseError}. File: ${resultPath}`,
431
+ );
432
+ }
433
+
253
434
  /**
254
435
  * Determine merge order for completed lanes.
255
436
  *
@@ -580,7 +761,7 @@ export async function waitForMergeResult(
580
761
  // just pushed past the timeout. Accept successful results without killing.
581
762
  if (existsSync(resultPath)) {
582
763
  try {
583
- const lateResult = parseMergeResult(resultPath);
764
+ const lateResult = await parseMergeResultAsync(resultPath);
584
765
  if (SUCCESSFUL_MERGE_STATUSES.has(lateResult.status)) {
585
766
  execLog("merge", sessionName, "merge agent slow but succeeded — accepting result at timeout", {
586
767
  status: lateResult.status,
@@ -588,8 +769,8 @@ export async function waitForMergeResult(
588
769
  timeoutMs,
589
770
  });
590
771
  // Clean up session (agent may still be running post-write)
591
- if (tmuxHasSession(sessionName)) {
592
- tmuxKillSession(sessionName);
772
+ if (await tmuxHasSessionAsync(sessionName)) {
773
+ await tmuxKillSessionAsync(sessionName);
593
774
  }
594
775
  return lateResult;
595
776
  }
@@ -608,7 +789,7 @@ export async function waitForMergeResult(
608
789
  elapsed,
609
790
  timeoutMs,
610
791
  });
611
- tmuxKillSession(sessionName);
792
+ await tmuxKillSessionAsync(sessionName);
612
793
 
613
794
  throw new MergeError(
614
795
  "MERGE_TIMEOUT",
@@ -621,25 +802,25 @@ export async function waitForMergeResult(
621
802
  // Check if result file exists
622
803
  if (existsSync(resultPath)) {
623
804
  try {
624
- const result = parseMergeResult(resultPath);
805
+ const result = await parseMergeResultAsync(resultPath);
625
806
  execLog("merge", sessionName, "merge result received", {
626
807
  status: result.status,
627
808
  elapsed,
628
809
  });
629
810
  // Kill session if still alive (agent should exit, but ensure cleanup)
630
- if (tmuxHasSession(sessionName)) {
631
- tmuxKillSession(sessionName);
811
+ if (await tmuxHasSessionAsync(sessionName)) {
812
+ await tmuxKillSessionAsync(sessionName);
632
813
  }
633
814
  return result;
634
815
  } catch (err: unknown) {
635
816
  // File exists but invalid — might be partially written.
636
- // parseMergeResult already retries, so if it throws, it's final.
817
+ // parseMergeResultAsync already retries, so if it throws, it's final.
637
818
  if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") {
638
819
  // Wait a bit and try once more (file might still be in flight)
639
820
  await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
640
821
  if (existsSync(resultPath)) {
641
822
  try {
642
- return parseMergeResult(resultPath);
823
+ return await parseMergeResultAsync(resultPath);
643
824
  } catch {
644
825
  // Give up on this file
645
826
  }
@@ -649,8 +830,8 @@ export async function waitForMergeResult(
649
830
  }
650
831
  }
651
832
 
652
- // Check session liveness
653
- const sessionAlive = tmuxHasSession(sessionName);
833
+ // Check session liveness — async to avoid blocking
834
+ const sessionAlive = await tmuxHasSessionAsync(sessionName);
654
835
 
655
836
  if (!sessionAlive) {
656
837
  if (sessionDiedAt === null) {
@@ -664,7 +845,7 @@ export async function waitForMergeResult(
664
845
  // One final check
665
846
  if (existsSync(resultPath)) {
666
847
  try {
667
- return parseMergeResult(resultPath);
848
+ return await parseMergeResultAsync(resultPath);
668
849
  } catch {
669
850
  // Fall through to session died error
670
851
  }
@@ -2310,6 +2491,38 @@ export function captureMergePaneOutput(
2310
2491
  }
2311
2492
  }
2312
2493
 
2494
+ /**
2495
+ * Async version of captureMergePaneOutput — captures pane output
2496
+ * without blocking the event loop.
2497
+ *
2498
+ * @param sessionName - TMUX session name
2499
+ * @param lines - Number of lines to capture from the bottom
2500
+ * @returns Promise resolving to captured text, or null on failure
2501
+ *
2502
+ * @since TP-070
2503
+ */
2504
+ export async function captureMergePaneOutputAsync(
2505
+ sessionName: string,
2506
+ lines: number = MERGE_HEALTH_CAPTURE_LINES,
2507
+ ): Promise<string | null> {
2508
+ try {
2509
+ const result = await tmuxAsync([
2510
+ "capture-pane",
2511
+ "-t", sessionName,
2512
+ "-p",
2513
+ "-S", `-${lines}`,
2514
+ ], 5_000);
2515
+
2516
+ if (result.status !== 0) {
2517
+ return null;
2518
+ }
2519
+
2520
+ return result.stdout || null;
2521
+ } catch {
2522
+ return null;
2523
+ }
2524
+ }
2525
+
2313
2526
  /**
2314
2527
  * Classify the health of a merge session based on session liveness
2315
2528
  * and pane output activity.
@@ -2458,6 +2671,9 @@ export class MergeHealthMonitor {
2458
2671
  this._resultPaths.delete(sessionName);
2459
2672
  }
2460
2673
 
2674
+ /** Overlap guard for async poll (TP-070) */
2675
+ private _polling = false;
2676
+
2461
2677
  /**
2462
2678
  * Start the health monitoring polling loop.
2463
2679
  */
@@ -2470,8 +2686,14 @@ export class MergeHealthMonitor {
2470
2686
  pollIntervalMs: this.pollIntervalMs,
2471
2687
  });
2472
2688
 
2473
- this.pollTimer = setInterval(() => {
2474
- this.poll();
2689
+ this.pollTimer = setInterval(async () => {
2690
+ if (this._polling) return; // Overlap guard (TP-070)
2691
+ this._polling = true;
2692
+ try {
2693
+ await this.poll();
2694
+ } finally {
2695
+ this._polling = false;
2696
+ }
2475
2697
  }, this.pollIntervalMs);
2476
2698
  }
2477
2699
 
@@ -2499,18 +2721,19 @@ export class MergeHealthMonitor {
2499
2721
  * Run a single poll cycle across all monitored sessions.
2500
2722
  *
2501
2723
  * Exposed as public for testing — normally called by the interval timer.
2724
+ * Async (TP-070) — uses non-blocking tmux calls to avoid event loop stalls.
2502
2725
  */
2503
- poll(): void {
2726
+ async poll(): Promise<void> {
2504
2727
  const now = Date.now();
2505
2728
 
2506
2729
  for (const [sessionName, state] of this.sessions) {
2507
- const sessionAlive = tmuxHasSession(sessionName);
2730
+ const sessionAlive = await tmuxHasSessionAsync(sessionName);
2508
2731
  const resultPath = this._resultPaths.get(sessionName) ?? "";
2509
2732
  const hasResultFile = resultPath ? existsSync(resultPath) : false;
2510
2733
 
2511
- // Capture pane output for activity detection
2734
+ // Capture pane output for activity detection — async to avoid blocking
2512
2735
  const currentOutput = sessionAlive
2513
- ? captureMergePaneOutput(sessionName)
2736
+ ? await captureMergePaneOutputAsync(sessionName)
2514
2737
  : null;
2515
2738
 
2516
2739
  // Classify health
@@ -38,7 +38,7 @@ guardrails) is injected at runtime.
38
38
  ```
39
39
  You (supervisor) ← operator talks to you
40
40
 
41
- ├── Engine (deterministic TypeScript code)
41
+ ├── Engine (deterministic TypeScript code, runs in worker_thread)
42
42
  │ ├── Discovers tasks, builds dependency DAG
43
43
  │ ├── Computes waves (topological sort)
44
44
  │ ├── Assigns tasks to lanes (parallel execution slots)
@@ -29,6 +29,7 @@
29
29
  import { join, dirname } from "path";
30
30
  import { fileURLToPath } from "url";
31
31
  import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync, statSync, openSync, readSync, closeSync, appendFileSync } from "fs";
32
+ import { stat as fsStat, open as fsOpen, readFile as fsReadFile, writeFile as fsWriteFile, rename as fsRename } from "fs/promises";
32
33
  import { execFileSync } from "child_process";
33
34
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
34
35
  import type { Model, Api } from "@mariozechner/pi-ai";
@@ -2587,7 +2588,7 @@ export interface SupervisorRoutingContext {
2587
2588
  /**
2588
2589
  * Activate the supervisor agent in the current pi session.
2589
2590
  *
2590
- * This is called after `startBatchAsync()` in the `/orch` command handler,
2591
+ * This is called after `startBatchInWorker()` in the `/orch` command handler,
2591
2592
  * or directly by the `/orch` no-args routing logic (TP-042).
2592
2593
  *
2593
2594
  * It:
@@ -2705,7 +2706,9 @@ export async function activateSupervisor(
2705
2706
  // Initializes byte offset to current file size so we skip stale events.
2706
2707
  // Idempotent — safe even if called from takeover paths that may have
2707
2708
  // started a tailer previously (stopEventTailer is called in deactivate).
2708
- startEventTailer(pi, state.eventTailer, state);
2709
+ startEventTailer(pi, state.eventTailer, state, (key, text) => {
2710
+ try { ctx.ui.setStatus(key, text); } catch { /* non-fatal */ }
2711
+ });
2709
2712
 
2710
2713
  // Send activation message to trigger the supervisor's first turn.
2711
2714
  // The content is generic — specific counts may not be available yet
@@ -3071,6 +3074,62 @@ export function writeLockfile(stateRoot: string, lock: SupervisorLockfile): void
3071
3074
  renameSync(tmpPath, finalPath);
3072
3075
  }
3073
3076
 
3077
+ /**
3078
+ * Async version of readLockfile — reads lockfile without blocking the event loop.
3079
+ *
3080
+ * @param stateRoot - Root path for .pi/ state directory
3081
+ * @returns Parsed lockfile or null
3082
+ *
3083
+ * @since TP-070
3084
+ */
3085
+ export async function readLockfileAsync(stateRoot: string): Promise<SupervisorLockfile | null> {
3086
+ const path = lockfilePath(stateRoot);
3087
+
3088
+ try {
3089
+ const raw = await fsReadFile(path, "utf-8");
3090
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
3091
+
3092
+ if (
3093
+ typeof parsed.pid !== "number" ||
3094
+ typeof parsed.sessionId !== "string" ||
3095
+ typeof parsed.batchId !== "string" ||
3096
+ typeof parsed.startedAt !== "string" ||
3097
+ typeof parsed.heartbeat !== "string"
3098
+ ) {
3099
+ return null;
3100
+ }
3101
+
3102
+ return parsed as unknown as SupervisorLockfile;
3103
+ } catch {
3104
+ return null;
3105
+ }
3106
+ }
3107
+
3108
+ /**
3109
+ * Async version of writeLockfile — writes lockfile without blocking the event loop.
3110
+ *
3111
+ * Creates the `.pi/supervisor/` directory if it doesn't exist.
3112
+ * Uses temp+rename for atomicity.
3113
+ *
3114
+ * @param stateRoot - Root path for .pi/ state directory
3115
+ * @param lock - Lockfile data to write
3116
+ *
3117
+ * @since TP-070
3118
+ */
3119
+ export async function writeLockfileAsync(stateRoot: string, lock: SupervisorLockfile): Promise<void> {
3120
+ const dir = join(stateRoot, ".pi", "supervisor");
3121
+ if (!existsSync(dir)) {
3122
+ mkdirSync(dir, { recursive: true });
3123
+ }
3124
+
3125
+ const finalPath = lockfilePath(stateRoot);
3126
+ const tmpPath = finalPath + ".tmp";
3127
+ const json = JSON.stringify(lock, null, 2) + "\n";
3128
+
3129
+ await fsWriteFile(tmpPath, json, "utf-8");
3130
+ await fsRename(tmpPath, finalPath);
3131
+ }
3132
+
3074
3133
  /**
3075
3134
  * Remove the supervisor lockfile.
3076
3135
  *
@@ -3301,47 +3360,55 @@ export function startHeartbeat(
3301
3360
  pi: ExtensionAPI,
3302
3361
  ): ReturnType<typeof setInterval> {
3303
3362
  const sessionId = state.lockSessionId;
3363
+ let heartbeatInProgress = false; // Overlap guard (TP-070)
3304
3364
 
3305
- const timer = setInterval(() => {
3365
+ const timer = setInterval(async () => {
3306
3366
  if (!state.active) {
3307
3367
  clearInterval(timer);
3308
3368
  return;
3309
3369
  }
3310
3370
 
3311
- // Read current lockfile to detect force takeover
3312
- const currentLock = readLockfile(stateRoot);
3313
- if (currentLock && currentLock.sessionId !== sessionId) {
3314
- // Another session has taken over — yield gracefully
3315
- clearInterval(timer);
3316
- pi.sendMessage(
3317
- {
3318
- customType: "supervisor-yield",
3319
- content: [{
3320
- type: "text",
3321
- text: "⚡ Another session has taken over supervisor duties. Yielding.",
3322
- }],
3323
- display: "Supervisor yielded to another session",
3324
- },
3325
- { triggerTurn: false },
3326
- );
3327
- deactivateSupervisor(pi, state);
3328
- return;
3329
- }
3371
+ if (heartbeatInProgress) return; // Overlap guard (TP-070)
3372
+ heartbeatInProgress = true;
3330
3373
 
3331
- // Update heartbeat (and refresh batchId if it was initially unknown)
3332
3374
  try {
3333
- const lock = readLockfile(stateRoot);
3334
- if (lock && lock.sessionId === sessionId) {
3335
- lock.heartbeat = new Date().toISOString();
3336
- // TP-130: batchId may have been "(initializing)" at lock creation
3337
- // because the batch hadn't started yet. Refresh from live state ref.
3338
- if (state.batchStateRef?.batchId && lock.batchId !== state.batchStateRef.batchId) {
3339
- lock.batchId = state.batchStateRef.batchId;
3375
+ // Read current lockfile to detect force takeover — async (TP-070)
3376
+ const currentLock = await readLockfileAsync(stateRoot);
3377
+ if (currentLock && currentLock.sessionId !== sessionId) {
3378
+ // Another session has taken over yield gracefully
3379
+ clearInterval(timer);
3380
+ pi.sendMessage(
3381
+ {
3382
+ customType: "supervisor-yield",
3383
+ content: [{
3384
+ type: "text",
3385
+ text: "⚡ Another session has taken over supervisor duties. Yielding.",
3386
+ }],
3387
+ display: "Supervisor yielded to another session",
3388
+ },
3389
+ { triggerTurn: false },
3390
+ );
3391
+ deactivateSupervisor(pi, state);
3392
+ return;
3393
+ }
3394
+
3395
+ // Update heartbeat (and refresh batchId if it was initially unknown)
3396
+ try {
3397
+ const lock = await readLockfileAsync(stateRoot);
3398
+ if (lock && lock.sessionId === sessionId) {
3399
+ lock.heartbeat = new Date().toISOString();
3400
+ // TP-130: batchId may have been "(initializing)" at lock creation
3401
+ // because the batch hadn't started yet. Refresh from live state ref.
3402
+ if (state.batchStateRef?.batchId && lock.batchId !== state.batchStateRef.batchId) {
3403
+ lock.batchId = state.batchStateRef.batchId;
3404
+ }
3405
+ await writeLockfileAsync(stateRoot, lock);
3340
3406
  }
3341
- writeLockfile(stateRoot, lock);
3407
+ } catch {
3408
+ // Best-effort heartbeat — don't crash the supervisor
3342
3409
  }
3343
- } catch {
3344
- // Best-effort heartbeat — don't crash the supervisor
3410
+ } finally {
3411
+ heartbeatInProgress = false;
3345
3412
  }
3346
3413
  }, HEARTBEAT_INTERVAL_MS);
3347
3414
 
@@ -3602,6 +3669,39 @@ export function readNewBytes(eventsPath: string, byteOffset: number): [string, n
3602
3669
  return [buffer.toString("utf-8"), fileSize];
3603
3670
  }
3604
3671
 
3672
+ /**
3673
+ * Async version of readNewBytes — reads new bytes without blocking the event loop.
3674
+ *
3675
+ * Uses `fs/promises` for non-blocking stat and read operations.
3676
+ *
3677
+ * @param eventsPath - Full path to events.jsonl
3678
+ * @param byteOffset - Start reading from this byte offset
3679
+ * @returns [newData, newByteOffset]
3680
+ *
3681
+ * @since TP-070
3682
+ */
3683
+ export async function readNewBytesAsync(eventsPath: string, byteOffset: number): Promise<[string, number]> {
3684
+ try {
3685
+ const stats = await fsStat(eventsPath);
3686
+ const fileSize = stats.size;
3687
+ if (fileSize <= byteOffset) return ["", byteOffset];
3688
+
3689
+ const bytesToRead = fileSize - byteOffset;
3690
+ const buffer = Buffer.alloc(bytesToRead);
3691
+
3692
+ const fh = await fsOpen(eventsPath, "r");
3693
+ try {
3694
+ await fh.read(buffer, 0, bytesToRead, byteOffset);
3695
+ } finally {
3696
+ await fh.close();
3697
+ }
3698
+
3699
+ return [buffer.toString("utf-8"), fileSize];
3700
+ } catch {
3701
+ return ["", byteOffset];
3702
+ }
3703
+ }
3704
+
3605
3705
  /**
3606
3706
  * Parse JSONL lines from raw data, handling partial lines.
3607
3707
  *
@@ -3940,6 +4040,8 @@ export function startEventTailer(
3940
4040
  pi: ExtensionAPI,
3941
4041
  tailer: EventTailerState,
3942
4042
  supervisorState: SupervisorState,
4043
+ /** Optional callback to update footer status immediately (bypasses sendMessage queue). @since TP-068/214 */
4044
+ setStatus?: (key: string, text: string) => void,
3943
4045
  ): void {
3944
4046
  if (tailer.running) return; // Idempotent guard (R005-2)
3945
4047
 
@@ -3967,6 +4069,15 @@ export function startEventTailer(
3967
4069
  // Notification callback — sends as a supervisor event message
3968
4070
  const notify = (text: string) => {
3969
4071
  if (!supervisorState.active) return; // Guard: don't notify after deactivation
4072
+
4073
+ // TP-068/214: Update footer status immediately for visibility.
4074
+ // setStatus renders in the TUI footer without waiting for user input,
4075
+ // unlike sendMessage which queues until next turn.
4076
+ if (setStatus) {
4077
+ const statusText = text.replace(/\*\*/g, "").replace(/\n.*/s, "").substring(0, 120);
4078
+ setStatus("supervisor", `🔀 ${statusText}`);
4079
+ }
4080
+
3970
4081
  pi.sendMessage(
3971
4082
  {
3972
4083
  customType: "supervisor-event",
@@ -3978,27 +4089,35 @@ export function startEventTailer(
3978
4089
  };
3979
4090
 
3980
4091
  // ── TP-043: Integration is triggered by triggerSupervisorIntegration() ──
3981
- // called from the onTerminal callback in startBatchAsync (extension.ts),
4092
+ // called from the onTerminal callback in startBatchInWorker (extension.ts),
3982
4093
  // gated on phase === "completed" (R002-1). For auto mode, integration is
3983
4094
  // executed programmatically via the executor callback (R002-2). The event
3984
4095
  // tailer does NOT duplicate the integration trigger — batch_complete events
3985
4096
  // are handled via the normal notification path (formatEventNotification).
3986
4097
 
3987
- // ── Poll timer ───────────────────────────────────────────────
3988
- tailer.pollTimer = setInterval(() => {
4098
+ // ── Poll timer (async, TP-070) ───────────────────────────────
4099
+ let tailerPollInProgress = false; // Overlap guard (TP-070)
4100
+ tailer.pollTimer = setInterval(async () => {
3989
4101
  if (!supervisorState.active || !tailer.running) {
3990
4102
  stopEventTailer(tailer);
3991
4103
  return;
3992
4104
  }
3993
4105
 
3994
- const [newData, newOffset] = readNewBytes(eventsPath, tailer.byteOffset);
3995
- if (!newData) return; // No new data
4106
+ if (tailerPollInProgress) return; // Overlap guard (TP-070)
4107
+ tailerPollInProgress = true;
3996
4108
 
3997
- tailer.byteOffset = newOffset;
3998
- const [events, remaining] = parseJsonlLines(newData, tailer.partialLine);
3999
- tailer.partialLine = remaining;
4109
+ try {
4110
+ const [newData, newOffset] = await readNewBytesAsync(eventsPath, tailer.byteOffset);
4111
+ if (!newData) return; // No new data
4000
4112
 
4001
- processEvents(events, tailer, autonomy, notify);
4113
+ tailer.byteOffset = newOffset;
4114
+ const [events, remaining] = parseJsonlLines(newData, tailer.partialLine);
4115
+ tailer.partialLine = remaining;
4116
+
4117
+ processEvents(events, tailer, autonomy, notify);
4118
+ } finally {
4119
+ tailerPollInProgress = false;
4120
+ }
4002
4121
  }, EVENT_POLL_INTERVAL_MS);
4003
4122
 
4004
4123
  // ── Digest flush timer ───────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",