taskplane 0.1.7 → 0.1.8

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.
@@ -480,6 +480,12 @@ export async function pollUntilTaskComplete(
480
480
 
481
481
  let lastPaneTail = "";
482
482
 
483
+ // Abort signal file path — checked each poll cycle.
484
+ // Any process can create this file to trigger abort (belt-and-suspenders
485
+ // alongside the in-memory pauseSignal, since /orch-abort may not be able
486
+ // to run concurrently with the /orch command handler).
487
+ const abortSignalFile = join(repoRoot, ".pi", "orch-abort-signal");
488
+
483
489
  // Main polling loop
484
490
  while (true) {
485
491
  // Check pause signal
@@ -494,6 +500,20 @@ export async function pollUntilTaskComplete(
494
500
  };
495
501
  }
496
502
 
503
+ // Check file-based abort signal
504
+ if (existsSync(abortSignalFile)) {
505
+ execLog(laneId, task.taskId, "abort signal file detected — killing session and aborting");
506
+ tmuxKillSession(sessionName);
507
+ // Also kill child sessions (worker, reviewer)
508
+ tmuxKillSession(`${sessionName}-worker`);
509
+ tmuxKillSession(`${sessionName}-reviewer`);
510
+ return {
511
+ status: "failed",
512
+ exitReason: "Aborted by signal file (.pi/orch-abort-signal)",
513
+ doneFileFound: false,
514
+ };
515
+ }
516
+
497
517
  // Capture live pane output for diagnostics (best effort).
498
518
  const paneTail = captureTmuxPaneTail(sessionName);
499
519
  if (paneTail) {
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
 
3
3
  import { execSync } from "child_process";
4
+ import { writeFileSync, unlinkSync, mkdirSync } from "fs";
5
+ import { join } from "path";
4
6
 
5
7
  import {
6
8
  DEFAULT_ORCHESTRATOR_CONFIG,
@@ -10,7 +12,6 @@ import {
10
12
  createOrchWidget,
11
13
  deleteBatchState,
12
14
  detectOrphanSessions,
13
- executeAbort,
14
15
  executeLane,
15
16
  executeOrchBatch,
16
17
  formatDependencyGraph,
@@ -347,113 +348,144 @@ export default function (pi: ExtensionAPI) {
347
348
  pi.registerCommand("orch-abort", {
348
349
  description: "Abort batch: /orch-abort [--hard]",
349
350
  handler: async (args, ctx) => {
350
- const hard = args?.trim() === "--hard";
351
- const mode: AbortMode = hard ? "hard" : "graceful";
352
- const prefix = orchConfig.orchestrator.tmux_prefix;
353
- const gracePeriodMs = orchConfig.orchestrator.abort_grace_period * 1000;
354
-
355
- // Check for active in-memory batch
356
- const hasActiveBatch = orchBatchState.phase !== "idle" &&
357
- orchBatchState.phase !== "completed" &&
358
- orchBatchState.phase !== "failed" &&
359
- orchBatchState.phase !== "stopped";
360
-
361
- // Also check for persisted state (abort can work on orphaned batches too)
362
- let persistedState: PersistedBatchState | null = null;
363
351
  try {
364
- persistedState = loadBatchState(ctx.cwd);
365
- } catch {
366
- // Ignore we may still have in-memory state or orphan sessions
367
- }
352
+ const hard = args?.trim() === "--hard";
353
+ const mode: AbortMode = hard ? "hard" : "graceful";
354
+ const prefix = orchConfig.orchestrator.tmux_prefix;
355
+ const gracePeriodMs = orchConfig.orchestrator.abort_grace_period * 1000;
356
+
357
+ ctx.ui.notify(`🛑 Abort requested (${mode} mode, prefix: ${prefix})...`, "info");
358
+
359
+ // ── Step 1: Write abort signal file immediately ──────────
360
+ // This is the primary abort mechanism. The orchestrator's polling
361
+ // loop checks for this file on every cycle, so even if this command
362
+ // handler runs concurrently with /orch (or is queued behind it),
363
+ // the signal file will be detected.
364
+ const abortSignalFile = join(ctx.cwd, ".pi", "orch-abort-signal");
365
+ try {
366
+ mkdirSync(join(ctx.cwd, ".pi"), { recursive: true });
367
+ writeFileSync(abortSignalFile, `abort requested at ${new Date().toISOString()} (mode: ${mode})`, "utf-8");
368
+ ctx.ui.notify(" ✓ Abort signal file written (.pi/orch-abort-signal)", "info");
369
+ } catch (err) {
370
+ ctx.ui.notify(` ⚠ Failed to write abort signal file: ${err instanceof Error ? err.message : String(err)}`, "warning");
371
+ }
368
372
 
369
- // If no in-memory batch AND no persisted state, check for orphan sessions
370
- if (!hasActiveBatch && !persistedState) {
371
- // Last chance: check for orphan sessions
372
- const sessionNames = parseOrchSessionNames(
373
- (() => {
374
- try {
375
- return execSync('tmux list-sessions -F "#{session_name}"', {
376
- encoding: "utf-8",
377
- timeout: 5000,
378
- });
379
- } catch {
380
- return "";
381
- }
382
- })(),
383
- prefix,
384
- );
385
- if (sessionNames.length === 0) {
386
- ctx.ui.notify(ORCH_MESSAGES.abortNoBatch(), "warning");
387
- return;
373
+ // ── Step 2: Set pause signal immediately ─────────────────
374
+ // Belt-and-suspenders: if the /orch polling loop can see this
375
+ // shared object, it will stop on the next iteration.
376
+ if (orchBatchState.pauseSignal) {
377
+ orchBatchState.pauseSignal.paused = true;
378
+ ctx.ui.notify(" ✓ Pause signal set on in-memory batch state", "info");
388
379
  }
389
- // If orphan sessions exist, proceed with abort (will kill them)
390
- }
391
380
 
392
- const batchId = orchBatchState.batchId || persistedState?.batchId || "unknown";
381
+ // ── Step 3: Check what we're aborting ────────────────────
382
+ const hasActiveBatch = orchBatchState.phase !== "idle" &&
383
+ orchBatchState.phase !== "completed" &&
384
+ orchBatchState.phase !== "failed" &&
385
+ orchBatchState.phase !== "stopped";
386
+
387
+ let persistedState: PersistedBatchState | null = null;
388
+ try {
389
+ persistedState = loadBatchState(ctx.cwd);
390
+ } catch {
391
+ // Ignore — we may still have in-memory state or orphan sessions
392
+ }
393
393
 
394
- // Notify user of abort start
395
- if (mode === "graceful") {
396
- const sessionCount = orchBatchState.currentLanes.length || persistedState?.tasks.length || 0;
397
- ctx.ui.notify(ORCH_MESSAGES.abortGracefulStarting(batchId, sessionCount), "info");
398
394
  ctx.ui.notify(
399
- ORCH_MESSAGES.abortGracefulWaiting(batchId, orchConfig.orchestrator.abort_grace_period),
395
+ ` Batch state: in-memory=${hasActiveBatch ? orchBatchState.phase : "none"}, ` +
396
+ `persisted=${persistedState ? persistedState.batchId : "none"}`,
400
397
  "info",
401
398
  );
402
- } else {
403
- const sessionCount = orchBatchState.currentLanes.length || persistedState?.tasks.length || 0;
404
- ctx.ui.notify(ORCH_MESSAGES.abortHardStarting(batchId, sessionCount), "info");
405
- }
406
399
 
407
- // Execute abort
408
- const result = await executeAbort(
409
- mode,
410
- prefix,
411
- ctx.cwd,
412
- orchBatchState,
413
- persistedState,
414
- gracePeriodMs,
415
- );
400
+ // ── Step 4: Scan for tmux sessions ──────────────────────
401
+ let allSessionNames: string[] = [];
402
+ try {
403
+ const tmuxOutput = execSync('tmux list-sessions -F "#{session_name}"', {
404
+ encoding: "utf-8",
405
+ timeout: 5000,
406
+ }).trim();
407
+ const all = tmuxOutput ? tmuxOutput.split("\n").map(s => s.trim()).filter(Boolean) : [];
408
+ allSessionNames = all.filter(name => name.startsWith(`${prefix}-`));
409
+ ctx.ui.notify(` Found ${allSessionNames.length} session(s) matching prefix "${prefix}-": ${allSessionNames.join(", ") || "(none)"}`, "info");
410
+ } catch {
411
+ ctx.ui.notify(" ⚠ Could not list tmux sessions (tmux not available?)", "warning");
412
+ }
416
413
 
417
- // Update in-memory batch state
418
- orchBatchState.phase = "stopped";
419
- orchBatchState.endedAt = result.durationMs + Date.now() - result.durationMs; // Use actual time
420
- updateOrchWidget();
414
+ // If no batch AND no sessions, nothing to abort
415
+ if (!hasActiveBatch && !persistedState && allSessionNames.length === 0) {
416
+ ctx.ui.notify(ORCH_MESSAGES.abortNoBatch(), "warning");
417
+ // Clean up signal file
418
+ try { unlinkSync(abortSignalFile); } catch {}
419
+ return;
420
+ }
421
+
422
+ const batchId = orchBatchState.batchId || persistedState?.batchId || "unknown";
421
423
 
422
- // Notify results
423
- const durationSec = Math.round(result.durationMs / 1000);
424
- if (mode === "graceful") {
425
- const forceKilled = result.sessionsKilled - result.gracefulExits;
426
- if (forceKilled > 0) {
427
- ctx.ui.notify(
428
- ORCH_MESSAGES.abortGracefulForceKill(forceKilled),
429
- "warning",
430
- );
424
+ // ── Step 5: Kill sessions directly (fast path) ──────────
425
+ // For hard mode or when sessions are found, kill them immediately
426
+ // rather than waiting through the full executeAbort flow.
427
+ if (allSessionNames.length > 0) {
428
+ ctx.ui.notify(` Killing ${allSessionNames.length} tmux session(s)...`, "info");
429
+ let killed = 0;
430
+ for (const name of allSessionNames) {
431
+ try {
432
+ // Kill child sessions first (worker, reviewer)
433
+ execSync(`tmux kill-session -t "${name}-worker" 2>/dev/null`, { timeout: 3000 }).toString();
434
+ } catch {}
435
+ try {
436
+ execSync(`tmux kill-session -t "${name}-reviewer" 2>/dev/null`, { timeout: 3000 }).toString();
437
+ } catch {}
438
+ try {
439
+ execSync(`tmux kill-session -t "${name}" 2>/dev/null`, { timeout: 3000 }).toString();
440
+ killed++;
441
+ ctx.ui.notify(` ✓ Killed: ${name}`, "info");
442
+ } catch {
443
+ // Session may have already exited
444
+ ctx.ui.notify(` · ${name} (already exited)`, "info");
445
+ killed++;
446
+ }
447
+ }
448
+ ctx.ui.notify(` ✓ ${killed}/${allSessionNames.length} session(s) terminated`, "info");
449
+ } else {
450
+ ctx.ui.notify(" No tmux sessions to kill", "info");
431
451
  }
452
+
453
+ // ── Step 6: Clean up batch state ────────────────────────
454
+ try {
455
+ orchBatchState.phase = "stopped";
456
+ orchBatchState.endedAt = Date.now();
457
+ updateOrchWidget();
458
+ ctx.ui.notify(" ✓ In-memory batch state set to 'stopped'", "info");
459
+ } catch (err) {
460
+ ctx.ui.notify(` ⚠ Failed to update in-memory state: ${err instanceof Error ? err.message : String(err)}`, "warning");
461
+ }
462
+
463
+ try {
464
+ deleteBatchState(ctx.cwd);
465
+ ctx.ui.notify(" ✓ Batch state file deleted (.pi/batch-state.json)", "info");
466
+ } catch (err) {
467
+ ctx.ui.notify(` ⚠ Failed to delete batch state file: ${err instanceof Error ? err.message : String(err)}`, "warning");
468
+ }
469
+
470
+ // ── Step 7: Clean up abort signal file ───────────────────
471
+ try { unlinkSync(abortSignalFile); } catch {}
472
+
473
+ // ── Done ─────────────────────────────────────────────────
432
474
  ctx.ui.notify(
433
- ORCH_MESSAGES.abortGracefulComplete(batchId, result.gracefulExits, forceKilled, durationSec),
434
- "info",
435
- );
436
- } else {
437
- ctx.ui.notify(
438
- ORCH_MESSAGES.abortHardComplete(batchId, result.sessionsKilled, durationSec),
475
+ `✅ Abort complete for batch ${batchId}. Sessions killed, state cleaned up.\n` +
476
+ ` Worktrees and branches are preserved for inspection.`,
439
477
  "info",
440
478
  );
441
- }
442
-
443
- // Report errors if any
444
- if (result.errors.length > 0) {
445
- const errorDetails = result.errors.map(e => ` • [${e.code}] ${e.message}`).join("\n");
479
+ } catch (err) {
480
+ // Top-level catch: ensure the user ALWAYS sees something
446
481
  ctx.ui.notify(
447
- `${ORCH_MESSAGES.abortPartialFailure(result.errors.length)}\n${errorDetails}`,
448
- "warning",
482
+ `❌ Abort failed with error: ${err instanceof Error ? err.message : String(err)}\n` +
483
+ ` Stack: ${err instanceof Error ? err.stack : "N/A"}\n\n` +
484
+ ` Manual cleanup: tmux kill-server (kills ALL tmux sessions)\n` +
485
+ ` Or: tmux kill-session -t <session-name> for each session`,
486
+ "error",
449
487
  );
450
488
  }
451
-
452
- // Final message
453
- ctx.ui.notify(
454
- ORCH_MESSAGES.abortComplete(mode, result.sessionsKilled),
455
- "info",
456
- );
457
489
  },
458
490
  });
459
491
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",