gsd-pi 2.37.0 → 2.37.1-dev.7352347
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.
- package/README.md +21 -20
- package/dist/onboarding.js +1 -0
- package/dist/resources/extensions/cmux/package.json +7 -0
- package/dist/resources/extensions/gsd/auto-loop.js +18 -4
- package/dist/resources/extensions/gsd/auto.js +42 -5
- package/dist/resources/extensions/gsd/commands.js +80 -33
- package/dist/resources/extensions/gsd/git-service.js +9 -1
- package/dist/resources/extensions/gsd/history.js +2 -1
- package/dist/resources/extensions/gsd/metrics.js +4 -2
- package/dist/resources/extensions/gsd/session-lock.js +26 -6
- package/dist/resources/extensions/shared/format-utils.js +5 -41
- package/dist/resources/extensions/shared/layout-utils.js +46 -0
- package/dist/resources/extensions/shared/mod.js +2 -1
- package/package.json +2 -1
- package/packages/pi-ai/dist/env-api-keys.js +13 -0
- package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +172 -0
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +172 -0
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic-shared.d.ts +64 -0
- package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic-shared.js +668 -0
- package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts +5 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.js +85 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.js.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic.d.ts +4 -30
- package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic.js +47 -764
- package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
- package/packages/pi-ai/dist/providers/register-builtins.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/register-builtins.js +6 -0
- package/packages/pi-ai/dist/providers/register-builtins.js.map +1 -1
- package/packages/pi-ai/dist/types.d.ts +2 -2
- package/packages/pi-ai/dist/types.d.ts.map +1 -1
- package/packages/pi-ai/dist/types.js.map +1 -1
- package/packages/pi-ai/package.json +1 -0
- package/packages/pi-ai/src/env-api-keys.ts +14 -0
- package/packages/pi-ai/src/models.generated.ts +172 -0
- package/packages/pi-ai/src/providers/anthropic-shared.ts +761 -0
- package/packages/pi-ai/src/providers/anthropic-vertex.ts +130 -0
- package/packages/pi-ai/src/providers/anthropic.ts +76 -868
- package/packages/pi-ai/src/providers/register-builtins.ts +7 -0
- package/packages/pi-ai/src/types.ts +2 -0
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.js +8 -4
- package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/extensions/loader.ts +8 -4
- package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
- package/pkg/package.json +1 -1
- package/src/resources/extensions/cmux/package.json +7 -0
- package/src/resources/extensions/gsd/auto-loop.ts +24 -6
- package/src/resources/extensions/gsd/auto.ts +56 -5
- package/src/resources/extensions/gsd/commands.ts +85 -31
- package/src/resources/extensions/gsd/git-service.ts +12 -1
- package/src/resources/extensions/gsd/history.ts +2 -1
- package/src/resources/extensions/gsd/metrics.ts +4 -2
- package/src/resources/extensions/gsd/session-lock.ts +41 -6
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +37 -1
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +19 -0
- package/src/resources/extensions/gsd/tests/cmux.test.ts +25 -1
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +45 -0
- package/src/resources/extensions/shared/format-utils.ts +5 -44
- package/src/resources/extensions/shared/layout-utils.ts +49 -0
- package/src/resources/extensions/shared/mod.ts +7 -4
- package/src/resources/extensions/shared/tests/format-utils.test.ts +5 -3
|
@@ -47,10 +47,11 @@ import {
|
|
|
47
47
|
} from "./crash-recovery.js";
|
|
48
48
|
import {
|
|
49
49
|
acquireSessionLock,
|
|
50
|
-
|
|
50
|
+
getSessionLockStatus,
|
|
51
51
|
releaseSessionLock,
|
|
52
52
|
updateSessionLock,
|
|
53
53
|
} from "./session-lock.js";
|
|
54
|
+
import type { SessionLockStatus } from "./session-lock.js";
|
|
54
55
|
import {
|
|
55
56
|
clearUnitRuntimeRecord,
|
|
56
57
|
inspectExecuteTaskDurability,
|
|
@@ -417,6 +418,38 @@ export function stopAutoRemote(projectRoot: string): {
|
|
|
417
418
|
}
|
|
418
419
|
}
|
|
419
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Check if a remote auto-mode session is running (from a different process).
|
|
423
|
+
* Reads the crash lock, checks PID liveness, and returns session details.
|
|
424
|
+
* Used by the guard in commands.ts to prevent bare /gsd, /gsd next, and
|
|
425
|
+
* /gsd auto from stealing the session lock.
|
|
426
|
+
*/
|
|
427
|
+
export function checkRemoteAutoSession(projectRoot: string): {
|
|
428
|
+
running: boolean;
|
|
429
|
+
pid?: number;
|
|
430
|
+
unitType?: string;
|
|
431
|
+
unitId?: string;
|
|
432
|
+
startedAt?: string;
|
|
433
|
+
completedUnits?: number;
|
|
434
|
+
} {
|
|
435
|
+
const lock = readCrashLock(projectRoot);
|
|
436
|
+
if (!lock) return { running: false };
|
|
437
|
+
|
|
438
|
+
if (!isLockProcessAlive(lock)) {
|
|
439
|
+
// Stale lock from a dead process — not a live remote session
|
|
440
|
+
return { running: false };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
running: true,
|
|
445
|
+
pid: lock.pid,
|
|
446
|
+
unitType: lock.unitType,
|
|
447
|
+
unitId: lock.unitId,
|
|
448
|
+
startedAt: lock.startedAt,
|
|
449
|
+
completedUnits: lock.completedUnits,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
420
453
|
export function isStepMode(): boolean {
|
|
421
454
|
return s.stepMode;
|
|
422
455
|
}
|
|
@@ -461,15 +494,33 @@ function buildSnapshotOpts(
|
|
|
461
494
|
};
|
|
462
495
|
}
|
|
463
496
|
|
|
464
|
-
function handleLostSessionLock(
|
|
465
|
-
|
|
497
|
+
function handleLostSessionLock(
|
|
498
|
+
ctx?: ExtensionContext,
|
|
499
|
+
lockStatus?: SessionLockStatus,
|
|
500
|
+
): void {
|
|
501
|
+
debugLog("session-lock-lost", {
|
|
502
|
+
lockBase: lockBase(),
|
|
503
|
+
reason: lockStatus?.failureReason,
|
|
504
|
+
existingPid: lockStatus?.existingPid,
|
|
505
|
+
expectedPid: lockStatus?.expectedPid,
|
|
506
|
+
});
|
|
466
507
|
s.active = false;
|
|
467
508
|
s.paused = false;
|
|
468
509
|
clearUnitTimeout();
|
|
469
510
|
deregisterSigtermHandler();
|
|
470
511
|
clearCmuxSidebar(loadEffectiveGSDPreferences()?.preferences);
|
|
512
|
+
const message =
|
|
513
|
+
lockStatus?.failureReason === "pid-mismatch"
|
|
514
|
+
? lockStatus.existingPid
|
|
515
|
+
? `Session lock moved to PID ${lockStatus.existingPid} — another GSD process appears to have taken over. Stopping gracefully.`
|
|
516
|
+
: "Session lock moved to a different process — another GSD process appears to have taken over. Stopping gracefully."
|
|
517
|
+
: lockStatus?.failureReason === "missing-metadata"
|
|
518
|
+
? "Session lock metadata disappeared, so ownership could not be confirmed. Stopping gracefully."
|
|
519
|
+
: lockStatus?.failureReason === "compromised"
|
|
520
|
+
? "Session lock was compromised or invalidated during heartbeat checks; takeover was not confirmed. Stopping gracefully."
|
|
521
|
+
: "Session lock lost. Stopping gracefully.";
|
|
471
522
|
ctx?.ui.notify(
|
|
472
|
-
|
|
523
|
+
message,
|
|
473
524
|
"error",
|
|
474
525
|
);
|
|
475
526
|
ctx?.ui.setStatus("gsd-auto", undefined);
|
|
@@ -736,7 +787,7 @@ function buildLoopDeps(): LoopDeps {
|
|
|
736
787
|
checkResourcesStale,
|
|
737
788
|
|
|
738
789
|
// Session lock
|
|
739
|
-
validateSessionLock,
|
|
790
|
+
validateSessionLock: getSessionLockStatus,
|
|
740
791
|
updateSessionLock,
|
|
741
792
|
handleLostSessionLock,
|
|
742
793
|
|
|
@@ -15,7 +15,7 @@ import { deriveState } from "./state.js";
|
|
|
15
15
|
import { GSDDashboardOverlay } from "./dashboard-overlay.js";
|
|
16
16
|
import { GSDVisualizerOverlay } from "./visualizer-overlay.js";
|
|
17
17
|
import { showQueue, showDiscuss, showHeadlessMilestoneCreation } from "./guided-flow.js";
|
|
18
|
-
import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js";
|
|
18
|
+
import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote, checkRemoteAutoSession } from "./auto.js";
|
|
19
19
|
import { dispatchDirectPhase } from "./auto-direct-dispatch.js";
|
|
20
20
|
import { resolveProjectRoot } from "./worktree.js";
|
|
21
21
|
import { assertSafeDirectory } from "./validate-directory.js";
|
|
@@ -50,6 +50,7 @@ import { handleLogs } from "./commands-logs.js";
|
|
|
50
50
|
import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js";
|
|
51
51
|
import { readSessionLockData, isSessionLockProcessAlive } from "./session-lock.js";
|
|
52
52
|
import { handleCmux } from "./commands-cmux.js";
|
|
53
|
+
import { showNextAction } from "../shared/mod.js";
|
|
53
54
|
|
|
54
55
|
|
|
55
56
|
/** Resolve the effective project root, accounting for worktree paths. */
|
|
@@ -72,36 +73,88 @@ export function projectRoot(): string {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/**
|
|
75
|
-
*
|
|
76
|
-
* Returns
|
|
76
|
+
* Guard against starting auto-mode when a remote session is already running.
|
|
77
|
+
* Returns true if the caller should proceed with startAuto, false if handled.
|
|
77
78
|
*/
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
async function guardRemoteSession(
|
|
80
|
+
ctx: ExtensionCommandContext,
|
|
81
|
+
pi: ExtensionAPI,
|
|
82
|
+
): Promise<boolean> {
|
|
83
|
+
// Local session already active — proceed (startAuto handles re-entrant calls)
|
|
84
|
+
if (isAutoActive() || isAutoPaused()) return true;
|
|
85
|
+
|
|
86
|
+
const remote = checkRemoteAutoSession(projectRoot());
|
|
87
|
+
if (!remote.running || !remote.pid) return true;
|
|
88
|
+
|
|
89
|
+
const unitLabel = remote.unitType && remote.unitId
|
|
90
|
+
? `${remote.unitType} (${remote.unitId})`
|
|
91
|
+
: "unknown unit";
|
|
92
|
+
const unitsMsg = remote.completedUnits != null
|
|
93
|
+
? `${remote.completedUnits} units completed`
|
|
94
|
+
: "";
|
|
95
|
+
|
|
96
|
+
const choice = await showNextAction(ctx, {
|
|
97
|
+
title: `Auto-mode is running in another terminal (PID ${remote.pid})`,
|
|
98
|
+
summary: [
|
|
99
|
+
`Currently executing: ${unitLabel}`,
|
|
100
|
+
...(unitsMsg ? [unitsMsg] : []),
|
|
101
|
+
...(remote.startedAt ? [`Started: ${remote.startedAt}`] : []),
|
|
102
|
+
],
|
|
103
|
+
actions: [
|
|
104
|
+
{
|
|
105
|
+
id: "status",
|
|
106
|
+
label: "View status",
|
|
107
|
+
description: "Show the current GSD progress dashboard.",
|
|
108
|
+
recommended: true,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "steer",
|
|
112
|
+
label: "Steer the session",
|
|
113
|
+
description: "Use /gsd steer <instruction> to redirect the running session.",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "stop",
|
|
117
|
+
label: "Stop remote session",
|
|
118
|
+
description: `Send SIGTERM to PID ${remote.pid} to stop it gracefully.`,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "force",
|
|
122
|
+
label: "Force start (steal lock)",
|
|
123
|
+
description: "Start a new session, terminating the existing one.",
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
notYetMessage: "Run /gsd when ready.",
|
|
127
|
+
});
|
|
85
128
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
129
|
+
if (choice === "status") {
|
|
130
|
+
await handleStatus(ctx);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
if (choice === "steer") {
|
|
134
|
+
ctx.ui.notify(
|
|
135
|
+
"Use /gsd steer <instruction> to redirect the running auto-mode session.\n" +
|
|
136
|
+
"Example: /gsd steer Use Postgres instead of SQLite",
|
|
137
|
+
"info",
|
|
138
|
+
);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (choice === "stop") {
|
|
142
|
+
const result = stopAutoRemote(projectRoot());
|
|
143
|
+
if (result.found) {
|
|
144
|
+
ctx.ui.notify(`Sent stop signal to auto-mode session (PID ${result.pid}). It will shut down gracefully.`, "info");
|
|
145
|
+
} else if (result.error) {
|
|
146
|
+
ctx.ui.notify(`Failed to stop remote auto-mode: ${result.error}`, "error");
|
|
147
|
+
} else {
|
|
148
|
+
ctx.ui.notify("Remote session is no longer running.", "info");
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
if (choice === "force") {
|
|
153
|
+
return true; // Proceed — startAuto will steal the lock
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// "not_yet" or escape
|
|
157
|
+
return false;
|
|
105
158
|
}
|
|
106
159
|
|
|
107
160
|
export function registerGSDCommand(pi: ExtensionAPI): void {
|
|
@@ -598,10 +651,10 @@ export async function handleGSDCommand(
|
|
|
598
651
|
await handleDryRun(ctx, projectRoot());
|
|
599
652
|
return;
|
|
600
653
|
}
|
|
601
|
-
if (notifyRemoteAutoActive(ctx, projectRoot())) return;
|
|
602
654
|
const verboseMode = trimmed.includes("--verbose");
|
|
603
655
|
const debugMode = trimmed.includes("--debug");
|
|
604
656
|
if (debugMode) enableDebug(projectRoot());
|
|
657
|
+
if (!(await guardRemoteSession(ctx, pi))) return;
|
|
605
658
|
await startAuto(ctx, pi, projectRoot(), verboseMode, { step: true });
|
|
606
659
|
return;
|
|
607
660
|
}
|
|
@@ -610,6 +663,7 @@ export async function handleGSDCommand(
|
|
|
610
663
|
const verboseMode = trimmed.includes("--verbose");
|
|
611
664
|
const debugMode = trimmed.includes("--debug");
|
|
612
665
|
if (debugMode) enableDebug(projectRoot());
|
|
666
|
+
if (!(await guardRemoteSession(ctx, pi))) return;
|
|
613
667
|
await startAuto(ctx, pi, projectRoot(), verboseMode);
|
|
614
668
|
return;
|
|
615
669
|
}
|
|
@@ -993,7 +1047,7 @@ Examples:
|
|
|
993
1047
|
}
|
|
994
1048
|
|
|
995
1049
|
if (trimmed === "") {
|
|
996
|
-
if (
|
|
1050
|
+
if (!(await guardRemoteSession(ctx, pi))) return;
|
|
997
1051
|
await startAuto(ctx, pi, projectRoot(), false, { step: true });
|
|
998
1052
|
return;
|
|
999
1053
|
}
|
|
@@ -479,9 +479,20 @@ export class GitServiceImpl {
|
|
|
479
479
|
|
|
480
480
|
const wtName = detectWorktreeName(this.basePath);
|
|
481
481
|
if (wtName) {
|
|
482
|
+
// Auto-mode worktrees use milestone/<MID> branches (wtName = milestone ID)
|
|
483
|
+
const milestoneBranch = `milestone/${wtName}`;
|
|
484
|
+
const currentBranch = nativeGetCurrentBranch(this.basePath);
|
|
485
|
+
|
|
486
|
+
// If we're on a milestone/<MID> branch, use it (auto-mode case)
|
|
487
|
+
if (currentBranch.startsWith("milestone/")) {
|
|
488
|
+
return currentBranch;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Otherwise check for manual worktree branch (worktree/<name>)
|
|
482
492
|
const wtBranch = `worktree/${wtName}`;
|
|
483
493
|
if (nativeBranchExists(this.basePath, wtBranch)) return wtBranch;
|
|
484
|
-
|
|
494
|
+
|
|
495
|
+
return currentBranch;
|
|
485
496
|
}
|
|
486
497
|
|
|
487
498
|
// Repo-level default detection: origin/HEAD → main → master → current branch.
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Human-readable display of past auto-mode unit executions.
|
|
3
3
|
|
|
4
4
|
import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
|
|
5
|
-
import { formatDuration,
|
|
5
|
+
import { formatDuration, truncateWithEllipsis } from "../shared/format-utils.js";
|
|
6
|
+
import { padRight } from "../shared/layout-utils.js";
|
|
6
7
|
import {
|
|
7
8
|
getLedger, getProjectTotals, formatCost, formatTokenCount,
|
|
8
9
|
aggregateBySlice, aggregateByPhase, aggregateByModel, loadLedgerFromDisk,
|
|
@@ -20,8 +20,10 @@ import { getAndClearSkills } from "./skill-telemetry.js";
|
|
|
20
20
|
import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
|
|
21
21
|
import { parseUnitId } from "./unit-id.js";
|
|
22
22
|
|
|
23
|
-
// Re-export from shared —
|
|
24
|
-
|
|
23
|
+
// Re-export from shared — import directly from format-utils to avoid pulling
|
|
24
|
+
// in the full barrel (mod.js → ui.js → @gsd/pi-tui) which breaks when loaded
|
|
25
|
+
// outside jiti's alias resolution (e.g. dynamic import in auto-loop reports).
|
|
26
|
+
export { formatTokenCount } from "../shared/format-utils.js";
|
|
25
27
|
|
|
26
28
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
27
29
|
|
|
@@ -40,6 +40,19 @@ export type SessionLockResult =
|
|
|
40
40
|
| { acquired: true }
|
|
41
41
|
| { acquired: false; reason: string; existingPid?: number };
|
|
42
42
|
|
|
43
|
+
export type SessionLockFailureReason =
|
|
44
|
+
| "compromised"
|
|
45
|
+
| "missing-metadata"
|
|
46
|
+
| "pid-mismatch";
|
|
47
|
+
|
|
48
|
+
export interface SessionLockStatus {
|
|
49
|
+
valid: boolean;
|
|
50
|
+
failureReason?: SessionLockFailureReason;
|
|
51
|
+
existingPid?: number;
|
|
52
|
+
expectedPid?: number;
|
|
53
|
+
recovered?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
// ─── Module State ───────────────────────────────────────────────────────────
|
|
44
57
|
|
|
45
58
|
/** Release function from proper-lockfile — calling it releases the OS lock. */
|
|
@@ -368,7 +381,7 @@ export function updateSessionLock(
|
|
|
368
381
|
*
|
|
369
382
|
* This is called periodically during the dispatch loop.
|
|
370
383
|
*/
|
|
371
|
-
export function
|
|
384
|
+
export function getSessionLockStatus(basePath: string): SessionLockStatus {
|
|
372
385
|
// Lock was compromised by proper-lockfile (mtime drift from sleep, stall, etc.)
|
|
373
386
|
if (_lockCompromised) {
|
|
374
387
|
// Recovery gate (#1512): Before declaring the lock lost, check if the lock
|
|
@@ -385,18 +398,23 @@ export function validateSessionLock(basePath: string): boolean {
|
|
|
385
398
|
process.stderr.write(
|
|
386
399
|
`[gsd] Lock recovered after onCompromised — lock file PID matched, re-acquired.\n`,
|
|
387
400
|
);
|
|
388
|
-
return true;
|
|
401
|
+
return { valid: true, recovered: true };
|
|
389
402
|
}
|
|
390
403
|
} catch {
|
|
391
404
|
// Re-acquisition failed — fall through to return false
|
|
392
405
|
}
|
|
393
406
|
}
|
|
394
|
-
return
|
|
407
|
+
return {
|
|
408
|
+
valid: false,
|
|
409
|
+
failureReason: "compromised",
|
|
410
|
+
existingPid: existing?.pid,
|
|
411
|
+
expectedPid: process.pid,
|
|
412
|
+
};
|
|
395
413
|
}
|
|
396
414
|
|
|
397
415
|
// If we have an OS-level lock, we're still the owner
|
|
398
416
|
if (_releaseFunction && _lockedPath === basePath) {
|
|
399
|
-
return true;
|
|
417
|
+
return { valid: true };
|
|
400
418
|
}
|
|
401
419
|
|
|
402
420
|
// Fallback: check the lock file PID
|
|
@@ -404,10 +422,27 @@ export function validateSessionLock(basePath: string): boolean {
|
|
|
404
422
|
const existing = readExistingLockData(lp);
|
|
405
423
|
if (!existing) {
|
|
406
424
|
// Lock file was deleted — we lost ownership
|
|
407
|
-
return
|
|
425
|
+
return {
|
|
426
|
+
valid: false,
|
|
427
|
+
failureReason: "missing-metadata",
|
|
428
|
+
expectedPid: process.pid,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (existing.pid !== process.pid) {
|
|
433
|
+
return {
|
|
434
|
+
valid: false,
|
|
435
|
+
failureReason: "pid-mismatch",
|
|
436
|
+
existingPid: existing.pid,
|
|
437
|
+
expectedPid: process.pid,
|
|
438
|
+
};
|
|
408
439
|
}
|
|
409
440
|
|
|
410
|
-
return
|
|
441
|
+
return { valid: true };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function validateSessionLock(basePath: string): boolean {
|
|
445
|
+
return getSessionLockStatus(basePath).valid;
|
|
411
446
|
}
|
|
412
447
|
|
|
413
448
|
/**
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type AgentEndEvent,
|
|
15
15
|
type LoopDeps,
|
|
16
16
|
} from "../auto-loop.js";
|
|
17
|
+
import type { SessionLockStatus } from "../session-lock.js";
|
|
17
18
|
|
|
18
19
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -341,7 +342,7 @@ function makeMockDeps(
|
|
|
341
342
|
preDispatchHealthGate: async () => ({ proceed: true, fixesApplied: [] }),
|
|
342
343
|
syncProjectRootToWorktree: () => {},
|
|
343
344
|
checkResourcesStale: () => null,
|
|
344
|
-
validateSessionLock: () => true,
|
|
345
|
+
validateSessionLock: () => ({ valid: true } as SessionLockStatus),
|
|
345
346
|
updateSessionLock: () => {
|
|
346
347
|
callLog.push("updateSessionLock");
|
|
347
348
|
},
|
|
@@ -532,6 +533,41 @@ test("autoLoop exits on terminal complete state", async (t) => {
|
|
|
532
533
|
);
|
|
533
534
|
});
|
|
534
535
|
|
|
536
|
+
test("autoLoop passes structured session-lock failure details to the handler", async () => {
|
|
537
|
+
_resetPendingResolve();
|
|
538
|
+
|
|
539
|
+
const ctx = makeMockCtx();
|
|
540
|
+
ctx.ui.setStatus = () => {};
|
|
541
|
+
const pi = makeMockPi();
|
|
542
|
+
const s = makeLoopSession();
|
|
543
|
+
let observedLockStatus: SessionLockStatus | undefined;
|
|
544
|
+
|
|
545
|
+
const deps = makeMockDeps({
|
|
546
|
+
validateSessionLock: () =>
|
|
547
|
+
({
|
|
548
|
+
valid: false,
|
|
549
|
+
failureReason: "compromised",
|
|
550
|
+
expectedPid: process.pid,
|
|
551
|
+
}) as SessionLockStatus,
|
|
552
|
+
handleLostSessionLock: (_ctx, lockStatus) => {
|
|
553
|
+
observedLockStatus = lockStatus;
|
|
554
|
+
deps.callLog.push("handleLostSessionLock");
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
await autoLoop(ctx, pi, s, deps);
|
|
559
|
+
|
|
560
|
+
assert.deepEqual(observedLockStatus, {
|
|
561
|
+
valid: false,
|
|
562
|
+
failureReason: "compromised",
|
|
563
|
+
expectedPid: process.pid,
|
|
564
|
+
});
|
|
565
|
+
assert.ok(
|
|
566
|
+
!deps.callLog.includes("resolveDispatch"),
|
|
567
|
+
"should stop before dispatch after lock validation fails",
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
|
|
535
571
|
test("autoLoop exits on terminal blocked state", async (t) => {
|
|
536
572
|
_resetPendingResolve();
|
|
537
573
|
|
|
@@ -153,6 +153,25 @@ async function main(): Promise<void> {
|
|
|
153
153
|
// After teardown, originalBase should be null
|
|
154
154
|
assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared");
|
|
155
155
|
|
|
156
|
+
// ─── #1526: getMainBranch returns milestone branch in auto-worktree ──
|
|
157
|
+
console.log("\n=== #1526: getMainBranch() returns milestone/<MID> in auto-worktree ===");
|
|
158
|
+
{
|
|
159
|
+
const { GitServiceImpl } = await import("../git-service.ts");
|
|
160
|
+
|
|
161
|
+
// Create worktree
|
|
162
|
+
const wtPath = createAutoWorktree(tempDir, "M005");
|
|
163
|
+
// Don't set main_branch pref so getMainBranch falls through to worktree detection
|
|
164
|
+
const gitService = new GitServiceImpl(wtPath);
|
|
165
|
+
gitService.setMilestoneId("M005");
|
|
166
|
+
|
|
167
|
+
// Verify getMainBranch returns the milestone branch
|
|
168
|
+
const mainBranch = gitService.getMainBranch();
|
|
169
|
+
assertEq(mainBranch, "milestone/M005", "getMainBranch returns milestone/<MID> in auto-worktree");
|
|
170
|
+
|
|
171
|
+
// Cleanup
|
|
172
|
+
teardownAutoWorktree(tempDir, "M005");
|
|
173
|
+
}
|
|
174
|
+
|
|
156
175
|
// ─── #778: reconcile plan checkboxes on re-attach ─────────────────
|
|
157
176
|
console.log("\n=== #778: reconcile plan checkboxes on re-attach ===");
|
|
158
177
|
{
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import test from "node:test";
|
|
1
|
+
import test, { describe } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
import {
|
|
4
7
|
buildCmuxProgress,
|
|
5
8
|
buildCmuxStatusLabel,
|
|
@@ -96,3 +99,24 @@ test("buildCmuxStatusLabel and progress prefer deepest active unit", () => {
|
|
|
96
99
|
assert.equal(buildCmuxStatusLabel(state), "M001 S02/T03 · executing");
|
|
97
100
|
assert.deepEqual(buildCmuxProgress(state), { value: 0.4, label: "2/5 tasks" });
|
|
98
101
|
});
|
|
102
|
+
|
|
103
|
+
describe("cmux extension discovery opt-out", () => {
|
|
104
|
+
test("cmux directory has package.json with pi manifest to prevent auto-discovery as extension", () => {
|
|
105
|
+
const cmuxDir = path.resolve(
|
|
106
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
107
|
+
"../../cmux",
|
|
108
|
+
);
|
|
109
|
+
const pkgPath = path.join(cmuxDir, "package.json");
|
|
110
|
+
assert.ok(fs.existsSync(pkgPath), `${pkgPath} must exist`);
|
|
111
|
+
|
|
112
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
113
|
+
assert.ok(
|
|
114
|
+
pkg.pi !== undefined && typeof pkg.pi === "object",
|
|
115
|
+
'package.json must have a "pi" field to opt out of extension auto-discovery',
|
|
116
|
+
);
|
|
117
|
+
assert.ok(
|
|
118
|
+
!pkg.pi.extensions?.length,
|
|
119
|
+
"pi.extensions must be empty or absent — cmux is a library, not an extension",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -17,6 +17,7 @@ import { tmpdir } from 'node:os';
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
acquireSessionLock,
|
|
20
|
+
getSessionLockStatus,
|
|
20
21
|
validateSessionLock,
|
|
21
22
|
releaseSessionLock,
|
|
22
23
|
readSessionLockData,
|
|
@@ -201,6 +202,50 @@ async function main(): Promise<void> {
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
// ─── 7b. getSessionLockStatus with missing metadata → reason surfaced ──
|
|
206
|
+
console.log('\n=== 7b. missing lock metadata → structured reason ===');
|
|
207
|
+
{
|
|
208
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
209
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const status = getSessionLockStatus(base);
|
|
213
|
+
assertEq(status.valid, false, 'missing lock metadata is invalid');
|
|
214
|
+
assertEq(status.failureReason, 'missing-metadata', 'missing metadata reason is surfaced');
|
|
215
|
+
assertEq(status.expectedPid, process.pid, 'expected PID is included');
|
|
216
|
+
} finally {
|
|
217
|
+
rmSync(base, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── 7c. getSessionLockStatus with foreign PID → reason surfaced ───────
|
|
222
|
+
console.log('\n=== 7c. foreign PID in lock file → structured reason ===');
|
|
223
|
+
{
|
|
224
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
225
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const foreignPid = process.pid + 1000;
|
|
229
|
+
const lockFile = join(gsdRoot(base), 'auto.lock');
|
|
230
|
+
writeFileSync(lockFile, JSON.stringify({
|
|
231
|
+
pid: foreignPid,
|
|
232
|
+
startedAt: new Date().toISOString(),
|
|
233
|
+
unitType: 'execute-task',
|
|
234
|
+
unitId: 'M001/S01/T01',
|
|
235
|
+
unitStartedAt: new Date().toISOString(),
|
|
236
|
+
completedUnits: 0,
|
|
237
|
+
}, null, 2));
|
|
238
|
+
|
|
239
|
+
const status = getSessionLockStatus(base);
|
|
240
|
+
assertEq(status.valid, false, 'foreign PID lock is invalid');
|
|
241
|
+
assertEq(status.failureReason, 'pid-mismatch', 'PID mismatch reason is surfaced');
|
|
242
|
+
assertEq(status.existingPid, foreignPid, 'existing PID is included');
|
|
243
|
+
assertEq(status.expectedPid, process.pid, 'expected PID is included');
|
|
244
|
+
} finally {
|
|
245
|
+
rmSync(base, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
204
249
|
// ─── 8. Acquire after release is possible ─────────────────────────────
|
|
205
250
|
console.log('\n=== 8. acquire after release → re-acquirable ===');
|
|
206
251
|
{
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared formatting
|
|
2
|
+
* Shared pure formatting utilities — no @gsd/pi-tui dependency.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* ANSI-aware layout helpers (padRight, joinColumns, centerLine, fitColumns)
|
|
5
|
+
* live in layout-utils.ts to avoid pulling @gsd/pi-tui into modules that
|
|
6
|
+
* run outside jiti's alias resolution (e.g. HTML report generation via
|
|
7
|
+
* dynamic import in auto-loop).
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
|
-
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
|
|
9
|
-
|
|
10
10
|
// ─── Duration Formatting ──────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
12
|
/** Format a millisecond duration as a compact human-readable string. */
|
|
@@ -31,45 +31,6 @@ export function formatTokenCount(count: number): string {
|
|
|
31
31
|
return `${(count / 1_000_000).toFixed(2)}M`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
// ─── Layout Helpers ───────────────────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
/** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
|
|
37
|
-
export function padRight(content: string, width: number): string {
|
|
38
|
-
const vis = visibleWidth(content);
|
|
39
|
-
return content + " ".repeat(Math.max(0, width - vis));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Build a line with left-aligned and right-aligned content. */
|
|
43
|
-
export function joinColumns(left: string, right: string, width: number): string {
|
|
44
|
-
const leftW = visibleWidth(left);
|
|
45
|
-
const rightW = visibleWidth(right);
|
|
46
|
-
if (leftW + rightW + 2 > width) {
|
|
47
|
-
return truncateToWidth(`${left} ${right}`, width);
|
|
48
|
-
}
|
|
49
|
-
return left + " ".repeat(width - leftW - rightW) + right;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Center content within `width` (ANSI-aware). */
|
|
53
|
-
export function centerLine(content: string, width: number): string {
|
|
54
|
-
const vis = visibleWidth(content);
|
|
55
|
-
if (vis >= width) return truncateToWidth(content, width);
|
|
56
|
-
const leftPad = Math.floor((width - vis) / 2);
|
|
57
|
-
return " ".repeat(leftPad) + content;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Join as many parts as fit within `width`, separated by `separator`. */
|
|
61
|
-
export function fitColumns(parts: string[], width: number, separator = " "): string {
|
|
62
|
-
const filtered = parts.filter(Boolean);
|
|
63
|
-
if (filtered.length === 0) return "";
|
|
64
|
-
let result = filtered[0];
|
|
65
|
-
for (let i = 1; i < filtered.length; i++) {
|
|
66
|
-
const candidate = `${result}${separator}${filtered[i]}`;
|
|
67
|
-
if (visibleWidth(candidate) > width) break;
|
|
68
|
-
result = candidate;
|
|
69
|
-
}
|
|
70
|
-
return truncateToWidth(result, width);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
34
|
// ─── Text Truncation ─────────────────────────────────────────────────────────
|
|
74
35
|
|
|
75
36
|
/** Truncate a string to `maxLength` characters, replacing the last character with an ellipsis if needed. */
|