diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 616e64229..1fcd98a4d 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -41,6 +41,8 @@ export interface AutoDashboardData { profileDowngraded?: boolean; /** Number of pending captures awaiting triage (0 if none or file missing) */ pendingCaptureCount: number; + /** Cross-process: another auto-mode session detected via auto.lock (PID, startedAt) */ + remoteSession?: { pid: number; startedAt: string; unitType: string; unitId: string }; } // ─── Unit Description Helpers ───────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 87ef155f4..41d585f32 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -786,6 +786,9 @@ export async function startAuto( pausedSessionFile = null; } + // Write lock on resume so cross-process status detection works (#723). + writeLock(lockBase(), "resuming", currentMilestoneId ?? "unknown", completedUnits.length); + await dispatchNextUnit(ctx, pi); return; } @@ -1121,6 +1124,11 @@ export async function startAuto( : "Will loop until milestone complete."; ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info"); + // Write initial lock file immediately so cross-process status detection + // works even before the first unit is dispatched (#723). + // The lock is updated with unit-specific info on each dispatch and cleared on stop. + writeLock(lockBase(), "starting", currentMilestoneId ?? "unknown", 0); + // Secrets collection gate — collect pending secrets before first dispatch const mid = state.activeMilestone!.id; try { @@ -1573,7 +1581,7 @@ export async function handleAgentEnd( return; } const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(basePath, triageUnitType, triageUnitId, completedUnits.length, sessionFile); + writeLock(lockBase(), triageUnitType, triageUnitId, completedUnits.length, sessionFile); // Start unit timeout for triage (use same supervisor config as hooks) clearUnitTimeout(); diff --git a/src/resources/extensions/gsd/dashboard-overlay.ts b/src/resources/extensions/gsd/dashboard-overlay.ts index 72214a66b..953c97130 100644 --- a/src/resources/extensions/gsd/dashboard-overlay.ts +++ b/src/resources/extensions/gsd/dashboard-overlay.ts @@ -319,16 +319,23 @@ export class GSDDashboardOverlay { const centered = (content: string) => row(centerLine(content, contentWidth)); const title = th.fg("accent", th.bold("GSD Dashboard")); + const isRemote = !!this.dashData.remoteSession; const status = this.dashData.active ? `${Date.now() % 2000 < 1000 ? th.fg("success", "●") : th.fg("dim", "○")} ${th.fg("success", "AUTO")}` : this.dashData.paused ? th.fg("warning", "⏸ PAUSED") - : th.fg("dim", "idle"); + : isRemote + ? `${Date.now() % 2000 < 1000 ? th.fg("success", "●") : th.fg("dim", "○")} ${th.fg("success", "AUTO")} ${th.fg("dim", `(PID ${this.dashData.remoteSession!.pid})`)}` + : th.fg("dim", "idle"); const worktreeName = getActiveWorktreeName(); const worktreeTag = worktreeName ? ` ${th.fg("warning", `⎇ ${worktreeName}`)}` : ""; - const elapsed = th.fg("dim", formatDuration(this.dashData.elapsed)); + const elapsed = this.dashData.active || this.dashData.paused + ? th.fg("dim", formatDuration(this.dashData.elapsed)) + : isRemote + ? th.fg("dim", `since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`) + : ""; lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsed, contentWidth))); lines.push(blank()); @@ -344,6 +351,13 @@ export class GSDDashboardOverlay { } else if (this.dashData.paused) { lines.push(row(th.fg("dim", "/gsd auto to resume"))); lines.push(blank()); + } else if (isRemote) { + const rs = this.dashData.remoteSession!; + const unitDisplay = rs.unitType === "starting" || rs.unitType === "resuming" + ? rs.unitType + : `${unitLabel(rs.unitType)} ${rs.unitId}`; + lines.push(row(th.fg("text", `Remote session: ${unitDisplay}`))); + lines.push(blank()); } else { lines.push(row(th.fg("dim", "No unit running · /gsd auto to start"))); lines.push(blank()); diff --git a/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts b/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts new file mode 100644 index 000000000..2694e8820 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts @@ -0,0 +1,186 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { writeLock, readCrashLock, clearLock, isLockProcessAlive } from "../crash-recovery.ts"; + +// ─── writeLock creates auto.lock in .gsd/ ──────────────────────────────── + +test("writeLock creates auto.lock with correct structure", () => { + const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-")); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + writeLock(dir, "starting", "M001", 0); + + const lockPath = join(dir, ".gsd", "auto.lock"); + assert.ok(existsSync(lockPath), "auto.lock should exist after writeLock"); + + const data = JSON.parse(readFileSync(lockPath, "utf-8")); + assert.equal(data.pid, process.pid, "lock should contain current PID"); + assert.equal(data.unitType, "starting", "lock should contain unit type"); + assert.equal(data.unitId, "M001", "lock should contain unit ID"); + assert.equal(data.completedUnits, 0, "lock should show 0 completed units"); + assert.ok(data.startedAt, "lock should have startedAt timestamp"); + + rmSync(dir, { recursive: true, force: true }); +}); + +test("writeLock updates existing lock with new unit info", () => { + const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-")); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + writeLock(dir, "starting", "M001", 0); + writeLock(dir, "execute-task", "M001/S01/T01", 2, "/tmp/session.jsonl"); + + const data = JSON.parse(readFileSync(join(dir, ".gsd", "auto.lock"), "utf-8")); + assert.equal(data.unitType, "execute-task", "lock should be updated to new unit type"); + assert.equal(data.unitId, "M001/S01/T01", "lock should be updated to new unit ID"); + assert.equal(data.completedUnits, 2, "completed count should be updated"); + assert.equal(data.sessionFile, "/tmp/session.jsonl", "session file should be recorded"); + + rmSync(dir, { recursive: true, force: true }); +}); + +// ─── readCrashLock reads auto.lock data ────────────────────────────────── + +test("readCrashLock returns null when no lock file exists", () => { + const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-")); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + const lock = readCrashLock(dir); + assert.equal(lock, null, "should return null when no lock file"); + + rmSync(dir, { recursive: true, force: true }); +}); + +test("readCrashLock returns lock data when file exists", () => { + const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-")); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + writeLock(dir, "plan-milestone", "M002", 5); + const lock = readCrashLock(dir); + + assert.ok(lock, "should return lock data"); + assert.equal(lock!.unitType, "plan-milestone"); + assert.equal(lock!.unitId, "M002"); + assert.equal(lock!.completedUnits, 5); + + rmSync(dir, { recursive: true, force: true }); +}); + +// ─── clearLock removes auto.lock ───────────────────────────────────────── + +test("clearLock removes the lock file", () => { + const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-")); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + writeLock(dir, "starting", "M001", 0); + assert.ok(existsSync(join(dir, ".gsd", "auto.lock")), "lock should exist before clear"); + + clearLock(dir); + assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "lock should be removed after clear"); + + rmSync(dir, { recursive: true, force: true }); +}); + +test("clearLock is safe when no lock file exists", () => { + const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-")); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + // Should not throw + clearLock(dir); + + rmSync(dir, { recursive: true, force: true }); +}); + +// ─── isLockProcessAlive detects live vs dead PIDs ──────────────────────── + +test("isLockProcessAlive returns false for dead PID", () => { + const lock = { + pid: 9999999, + startedAt: new Date().toISOString(), + unitType: "execute-task", + unitId: "M001/S01/T01", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }; + assert.equal(isLockProcessAlive(lock), false, "dead PID should return false"); +}); + +test("isLockProcessAlive returns false for own PID (recycled)", () => { + const lock = { + pid: process.pid, + startedAt: new Date().toISOString(), + unitType: "execute-task", + unitId: "M001/S01/T01", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }; + assert.equal(isLockProcessAlive(lock), false, "own PID should return false (recycled)"); +}); + +test("isLockProcessAlive returns false for invalid PID", () => { + const lock = { + pid: -1, + startedAt: new Date().toISOString(), + unitType: "execute-task", + unitId: "M001/S01/T01", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }; + assert.equal(isLockProcessAlive(lock), false, "negative PID should return false"); +}); + +// ─── Cross-process detection via lock file ─────────────────────────────── + +test("lock file enables cross-process auto-mode detection", () => { + const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-")); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + // Use the parent process PID — guaranteed alive on all platforms (Unix and Windows). + // PID 1 (init) only works on Unix; on Windows it doesn't exist. + const alivePid = process.ppid; + const lockData = { + pid: alivePid, + startedAt: new Date().toISOString(), + unitType: "execute-task", + unitId: "M001/S01/T02", + unitStartedAt: new Date().toISOString(), + completedUnits: 3, + }; + writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2)); + + const lock = readCrashLock(dir); + assert.ok(lock, "should read the lock"); + assert.equal(lock!.pid, alivePid); + + // Parent PID is always alive — isLockProcessAlive should detect it + const alive = isLockProcessAlive(lock!); + assert.equal(alive, true, "parent PID should be detected as alive"); + + rmSync(dir, { recursive: true, force: true }); +}); + +test("stale lock from dead process is detected as not alive", () => { + const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-")); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + // Simulate a stale lock from a process that no longer exists + const lockData = { + pid: 9999999, + startedAt: "2026-03-01T00:00:00Z", + unitType: "plan-slice", + unitId: "M001/S02", + unitStartedAt: "2026-03-01T00:05:00Z", + completedUnits: 1, + }; + writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2)); + + const lock = readCrashLock(dir); + assert.ok(lock, "should read the stale lock"); + assert.equal(isLockProcessAlive(lock!), false, "dead process should not be alive"); + + rmSync(dir, { recursive: true, force: true }); +});