@gajae-code/coding-agent 0.16.0 → 0.16.3
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/CHANGELOG.md +52 -1
- package/dist/types/async/job-manager.d.ts +32 -1
- package/dist/types/browser-backend.d.ts +12 -0
- package/dist/types/cli/update-cli.d.ts +1 -0
- package/dist/types/commands/sdk.d.ts +2 -0
- package/dist/types/config/billing-path.d.ts +25 -0
- package/dist/types/config/config-schema-version.d.ts +13 -1
- package/dist/types/config/model-registry.d.ts +16 -0
- package/dist/types/config/settings-schema.d.ts +17 -7
- package/dist/types/extensibility/extensions/types.d.ts +8 -3
- package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
- package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/acp/acp-agent.d.ts +21 -21
- package/dist/types/modes/components/model-selector.d.ts +4 -0
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +16 -2
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +2 -1
- package/dist/types/sdk/broker/ensure.d.ts +3 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
- package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
- package/dist/types/sdk/broker/state-version.d.ts +17 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/index.d.ts +3 -1
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
- package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
- package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/cli/rows.d.ts +15 -2
- package/dist/types/sdk/cli/session-cli.d.ts +2 -1
- package/dist/types/sdk/host/events.d.ts +2 -0
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/host/query/cursor.d.ts +4 -2
- package/dist/types/sdk/host/query/handlers.d.ts +1 -0
- package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +18 -17
- package/dist/types/sdk/lifecycle-session.d.ts +1 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
- package/dist/types/sdk/receipt-state.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +4 -0
- package/dist/types/sdk/turn-result.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +47 -7
- package/dist/types/session/fold-coordinator.d.ts +29 -2
- package/dist/types/session/session-manager.d.ts +14 -2
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/bash.d.ts +12 -0
- package/dist/types/tools/browser/launch.d.ts +10 -0
- package/dist/types/tools/browser/registry.d.ts +8 -0
- package/dist/types/tools/index.d.ts +4 -10
- package/dist/types/tools/job.d.ts +3 -1
- package/dist/types/utils/external-editor.d.ts +12 -0
- package/examples/extensions/README.md +1 -1
- package/examples/extensions/pirate.ts +7 -4
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +7 -1
- package/src/async/job-manager.ts +107 -9
- package/src/browser-backend.ts +29 -0
- package/src/cli/plugin-cli.ts +29 -0
- package/src/cli/update-cli.ts +63 -12
- package/src/commands/sdk.ts +22 -7
- package/src/config/billing-path.ts +42 -0
- package/src/config/config-schema-version.ts +18 -1
- package/src/config/model-preset-registry.ts +177 -18
- package/src/config/model-registry.ts +268 -42
- package/src/config/settings-schema.ts +19 -7
- package/src/config/settings.ts +46 -15
- package/src/coordinator-mcp/projection-scan.ts +6 -6
- package/src/coordinator-mcp/server.ts +21 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/extensibility/extensions/runner.ts +27 -0
- package/src/extensibility/extensions/types.ts +8 -3
- package/src/extensibility/gjc-plugins/installer.ts +51 -11
- package/src/extensibility/runtime-skill-discovery.ts +110 -3
- package/src/extensibility/skills.ts +22 -12
- package/src/gjc-runtime/launch-worktree.ts +285 -4
- package/src/gjc-runtime/session-state-lock.ts +156 -32
- package/src/gjc-runtime/session-state-sidecar.ts +10 -17
- package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
- package/src/internal-urls/docs-index.generated.ts +11 -11
- package/src/main.ts +4 -1
- package/src/modes/acp/acp-agent.ts +34 -19
- package/src/modes/components/model-selector.ts +76 -8
- package/src/modes/components/session-selector.ts +23 -15
- package/src/modes/components/sessions-dashboard.ts +8 -5
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/event-controller.ts +30 -4
- package/src/modes/controllers/extension-ui-controller.ts +13 -13
- package/src/modes/controllers/input-controller.ts +2 -2
- package/src/modes/controllers/selector-controller.ts +10 -3
- package/src/modes/interactive-mode.ts +36 -12
- package/src/modes/prompt-action-autocomplete.ts +30 -4
- package/src/modes/runtime-init.ts +12 -9
- package/src/modes/theme/theme.ts +5 -0
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +22 -3
- package/src/prompts/agent-fragments/browser-aside.md +22 -0
- package/src/prompts/tools/ask.md +1 -1
- package/src/prompts/tools/bash.md +1 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +6 -1
- package/src/sdk/broker/broker.ts +10 -1
- package/src/sdk/broker/ensure.ts +76 -3
- package/src/sdk/broker/lifecycle-ledger.ts +6 -0
- package/src/sdk/broker/lifecycle.ts +254 -34
- package/src/sdk/broker/spawn-substrate.ts +45 -2
- package/src/sdk/broker/startup-budget.ts +144 -2
- package/src/sdk/broker/state-version.ts +17 -0
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +118 -24
- package/src/sdk/bus/discord-daemon.ts +50 -10
- package/src/sdk/bus/index.ts +33 -3
- package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
- package/src/sdk/bus/lifecycle-commands.ts +4 -0
- package/src/sdk/bus/notification-service.ts +22 -5
- package/src/sdk/bus/prompt-reconciliation.ts +7 -2
- package/src/sdk/bus/reconciliation-store.ts +55 -31
- package/src/sdk/bus/slack-conversation.ts +1 -1
- package/src/sdk/bus/slack-daemon.ts +72 -38
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/cli/rows.ts +75 -7
- package/src/sdk/cli/session-cli.ts +68 -35
- package/src/sdk/host/control/dispatch.ts +2 -2
- package/src/sdk/host/events.ts +26 -2
- package/src/sdk/host/host.ts +6 -1
- package/src/sdk/host/query/cursor.ts +52 -7
- package/src/sdk/host/query/handlers.ts +33 -12
- package/src/sdk/host/query/revision-store.ts +12 -0
- package/src/sdk/host/session-runtime.test.ts +497 -31
- package/src/sdk/host/session-runtime.ts +387 -54
- package/src/sdk/host/surface-policy.ts +3 -3
- package/src/sdk/lifecycle-session.ts +7 -2
- package/src/sdk/prompt-deadline-manager.ts +2 -1
- package/src/sdk/protocol/operation-inventory.generated.json +54 -3
- package/src/sdk/protocol/operation-registry.ts +1 -1
- package/src/sdk/receipt-state.ts +10 -0
- package/src/sdk/router/session-router.ts +210 -94
- package/src/sdk/session.ts +59 -41
- package/src/sdk/turn-result.ts +22 -0
- package/src/session/agent-session.ts +1033 -260
- package/src/session/fold-coordinator.ts +40 -3
- package/src/session/session-manager.ts +517 -77
- package/src/slash-commands/builtin-registry.ts +41 -0
- package/src/task/executor.ts +9 -4
- package/src/tools/ask-contract.test.ts +64 -0
- package/src/tools/ask-contract.ts +36 -1
- package/src/tools/ask.ts +10 -8
- package/src/tools/bash-interactive.ts +11 -2
- package/src/tools/bash.ts +158 -33
- package/src/tools/browser/launch.ts +37 -0
- package/src/tools/browser/registry.ts +99 -9
- package/src/tools/cron.ts +6 -4
- package/src/tools/descriptors.test.ts +15 -0
- package/src/tools/descriptors.ts +5 -1
- package/src/tools/index.ts +4 -6
- package/src/tools/job.ts +4 -1
- package/src/tools/monitor.ts +12 -9
- package/src/tools/resolve.ts +30 -10
- package/src/tools/tool-catalog.generated.ts +2 -2
- package/src/utils/external-editor.ts +86 -1
|
@@ -29,6 +29,27 @@ export interface GjcLaunchWorktreeResult extends GjcLaunchWorktreePlan {
|
|
|
29
29
|
createdBranch: boolean;
|
|
30
30
|
dirty?: boolean;
|
|
31
31
|
}
|
|
32
|
+
export interface LaunchWorktreeAbortOptions {
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
deadlineAt?: number;
|
|
35
|
+
now?: () => number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class WorktreePreparationTimeoutError extends Error {
|
|
39
|
+
readonly code = "worktree_preparation_timeout";
|
|
40
|
+
constructor(message = "Worktree preparation exceeded its deadline before the session host was spawned.") {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "WorktreePreparationTimeoutError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class DependencyPreparationTimeoutError extends Error {
|
|
47
|
+
readonly code = "dependency_preparation_timeout";
|
|
48
|
+
constructor(message = "Dependency preparation exceeded its deadline before the session host was spawned.") {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "DependencyPreparationTimeoutError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
32
53
|
|
|
33
54
|
interface GitWorktreeEntry {
|
|
34
55
|
path: string;
|
|
@@ -39,10 +60,93 @@ interface GitWorktreeEntry {
|
|
|
39
60
|
|
|
40
61
|
const BRANCH_IN_USE_PATTERN = /already checked out|already used by worktree|is already checked out/i;
|
|
41
62
|
|
|
63
|
+
function throwIfAborted(options: LaunchWorktreeAbortOptions | undefined, timeout: Error): void {
|
|
64
|
+
if (options?.signal?.aborted) throw timeout;
|
|
65
|
+
if (options?.deadlineAt !== undefined && (options.now ?? Date.now)() >= options.deadlineAt) throw timeout;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sanitizeWorktreeDiagnostic(value: string): string {
|
|
69
|
+
return value
|
|
70
|
+
.replace(/([?&](?:token|secret|password|key|api[_-]?key)=[^\s&]*)/giu, "[redacted-query]")
|
|
71
|
+
.replace(
|
|
72
|
+
/(^|[^\p{L}\p{N}_-])[\p{L}\p{N}_-]*(?:token|secret|password|api[_-]?key|key|credential|auth)\s*[=:]\s*[^\s,;]+/giu,
|
|
73
|
+
"$1[redacted-secret]",
|
|
74
|
+
)
|
|
75
|
+
.replace(/\b(?:https?|wss?):\/\/[^\s]+/giu, "[redacted-url]")
|
|
76
|
+
.slice(0, 512);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function spawnProcessGroup(
|
|
80
|
+
command: string[],
|
|
81
|
+
cwd: string,
|
|
82
|
+
options: LaunchWorktreeAbortOptions | undefined,
|
|
83
|
+
timeout: Error,
|
|
84
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
85
|
+
throwIfAborted(options, timeout);
|
|
86
|
+
const [file, ...args] = command;
|
|
87
|
+
if (!file) throw timeout;
|
|
88
|
+
const proc = Bun.spawn([file, ...args], {
|
|
89
|
+
cwd,
|
|
90
|
+
stdout: "pipe",
|
|
91
|
+
stderr: "pipe",
|
|
92
|
+
stdin: "ignore",
|
|
93
|
+
// Windows has no POSIX process-group kill: parent-only proc.kill() is the
|
|
94
|
+
// documented seam. Leftover classify still runs after wait on both OS.
|
|
95
|
+
detached: process.platform !== "win32",
|
|
96
|
+
...(process.platform === "win32" ? { windowsHide: true } : {}),
|
|
97
|
+
});
|
|
98
|
+
const killGroup = (): void => {
|
|
99
|
+
try {
|
|
100
|
+
if (process.platform === "win32") proc.kill();
|
|
101
|
+
else if (proc.pid) process.kill(-proc.pid, "SIGKILL");
|
|
102
|
+
else proc.kill();
|
|
103
|
+
} catch {
|
|
104
|
+
try {
|
|
105
|
+
proc.kill();
|
|
106
|
+
} catch {
|
|
107
|
+
// already gone
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const onAbort = (): void => {
|
|
112
|
+
killGroup();
|
|
113
|
+
};
|
|
114
|
+
options?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
115
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
116
|
+
if (options?.deadlineAt !== undefined) {
|
|
117
|
+
const remaining = options.deadlineAt - (options.now ?? Date.now)();
|
|
118
|
+
if (remaining <= 0) {
|
|
119
|
+
killGroup();
|
|
120
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
121
|
+
await proc.exited.catch(() => undefined);
|
|
122
|
+
throw timeout;
|
|
123
|
+
}
|
|
124
|
+
deadlineTimer = setTimeout(() => {
|
|
125
|
+
killGroup();
|
|
126
|
+
}, remaining);
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const [stdoutBytes, stderrBytes, exitCode] = await Promise.all([
|
|
130
|
+
new Response(proc.stdout).arrayBuffer(),
|
|
131
|
+
new Response(proc.stderr).arrayBuffer(),
|
|
132
|
+
proc.exited,
|
|
133
|
+
]);
|
|
134
|
+
throwIfAborted(options, timeout);
|
|
135
|
+
return {
|
|
136
|
+
stdout: new TextDecoder().decode(stdoutBytes),
|
|
137
|
+
stderr: new TextDecoder().decode(stderrBytes),
|
|
138
|
+
exitCode: exitCode ?? 1,
|
|
139
|
+
};
|
|
140
|
+
} finally {
|
|
141
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
142
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
42
146
|
function runGit(cwd: string, args: string[]): string {
|
|
43
147
|
const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
44
148
|
if (result.exitCode === 0) return result.stdout.toString().trim();
|
|
45
|
-
const stderr = result.stderr.toString().trim();
|
|
149
|
+
const stderr = sanitizeWorktreeDiagnostic(result.stderr.toString().trim());
|
|
46
150
|
throw new Error(stderr || `git ${args.join(" ")} failed`);
|
|
47
151
|
}
|
|
48
152
|
|
|
@@ -408,6 +512,16 @@ export function planLaunchWorktree(
|
|
|
408
512
|
|
|
409
513
|
export function ensureLaunchWorktree(
|
|
410
514
|
plan: GjcLaunchWorktreePlan | { enabled: false },
|
|
515
|
+
options?: LaunchWorktreeAbortOptions,
|
|
516
|
+
): GjcLaunchWorktreeResult | { enabled: false } {
|
|
517
|
+
if (options?.signal || options?.deadlineAt !== undefined) {
|
|
518
|
+
throw new Error("ensureLaunchWorktree is synchronous; use ensureLaunchWorktreeCancellable for abortable prep.");
|
|
519
|
+
}
|
|
520
|
+
return ensureLaunchWorktreeSync(plan);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function ensureLaunchWorktreeSync(
|
|
524
|
+
plan: GjcLaunchWorktreePlan | { enabled: false },
|
|
411
525
|
): GjcLaunchWorktreeResult | { enabled: false } {
|
|
412
526
|
if (!plan.enabled) return { enabled: false };
|
|
413
527
|
const bucketPath = path.dirname(plan.worktreePath);
|
|
@@ -473,7 +587,93 @@ export function ensureLaunchWorktree(
|
|
|
473
587
|
|
|
474
588
|
const result = Bun.spawnSync(["git", ...args], { cwd: plan.repoRoot, stdout: "pipe", stderr: "pipe" });
|
|
475
589
|
if (result.exitCode !== 0) {
|
|
476
|
-
const stderr = result.stderr.toString().trim();
|
|
590
|
+
const stderr = sanitizeWorktreeDiagnostic(result.stderr.toString().trim());
|
|
591
|
+
if (plan.branchName && BRANCH_IN_USE_PATTERN.test(stderr)) throw new Error(`branch_in_use:${plan.branchName}`);
|
|
592
|
+
throw new Error(stderr || `worktree_add_failed:${args.join(" ")}`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
...plan,
|
|
597
|
+
worktreePath: path.resolve(plan.worktreePath),
|
|
598
|
+
created: true,
|
|
599
|
+
reused: false,
|
|
600
|
+
createdBranch: Boolean(plan.branchName && !branchAlreadyExisted),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export async function ensureLaunchWorktreeCancellable(
|
|
605
|
+
plan: GjcLaunchWorktreePlan | { enabled: false },
|
|
606
|
+
options: LaunchWorktreeAbortOptions = {},
|
|
607
|
+
): Promise<GjcLaunchWorktreeResult | { enabled: false }> {
|
|
608
|
+
if (!plan.enabled) return { enabled: false };
|
|
609
|
+
const timeout = new WorktreePreparationTimeoutError();
|
|
610
|
+
throwIfAborted(options, timeout);
|
|
611
|
+
const bucketPath = path.dirname(plan.worktreePath);
|
|
612
|
+
inspectBucketDir(bucketPath);
|
|
613
|
+
ensureRepositoryBucketIgnored(plan.repoRoot, bucketPath);
|
|
614
|
+
let allWorktrees = listWorktrees(plan.repoRoot);
|
|
615
|
+
const staleAtPath = findWorktreeByPath(allWorktrees, plan.worktreePath);
|
|
616
|
+
if (staleAtPath && !fs.existsSync(staleAtPath.path)) {
|
|
617
|
+
pruneStaleWorktreePath(plan.repoRoot);
|
|
618
|
+
allWorktrees = listWorktrees(plan.repoRoot);
|
|
619
|
+
}
|
|
620
|
+
throwIfAborted(options, timeout);
|
|
621
|
+
|
|
622
|
+
const listedAtPath = findWorktreeByPath(allWorktrees, plan.worktreePath);
|
|
623
|
+
const existingAtPath = readWorktreeEntryFromPath(plan.repoRoot, plan.worktreePath);
|
|
624
|
+
if (listedAtPath && !existingAtPath) {
|
|
625
|
+
if (fs.existsSync(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
|
|
626
|
+
throw new Error(
|
|
627
|
+
[
|
|
628
|
+
"worktree_path_unavailable",
|
|
629
|
+
"The requested launch worktree is still registered by Git but its directory is unavailable or locked.",
|
|
630
|
+
`Path: ${formatBucketPath(plan.worktreePath)}`,
|
|
631
|
+
"Safe remediation: inspect the worktree lock and remove or repair it with git worktree remove/prune when it is no longer needed, then relaunch. GJC did not delete or replace the entry.",
|
|
632
|
+
].join("\n"),
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
const expectedBranchRef = plan.branchName ? `refs/heads/${plan.branchName}` : null;
|
|
636
|
+
|
|
637
|
+
if (existingAtPath) {
|
|
638
|
+
let dirty = isWorktreeDirty(plan.worktreePath);
|
|
639
|
+
if (plan.detached) {
|
|
640
|
+
if (!existingAtPath.detached) {
|
|
641
|
+
throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
|
|
642
|
+
}
|
|
643
|
+
if (existingAtPath.head !== plan.baseRef) {
|
|
644
|
+
if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
|
|
645
|
+
runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
|
|
646
|
+
dirty = false;
|
|
647
|
+
}
|
|
648
|
+
} else if (existingAtPath.branchRef !== expectedBranchRef) {
|
|
649
|
+
throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
...plan,
|
|
653
|
+
worktreePath: path.resolve(plan.worktreePath),
|
|
654
|
+
created: false,
|
|
655
|
+
reused: true,
|
|
656
|
+
createdBranch: false,
|
|
657
|
+
...(dirty ? { dirty: true } : {}),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (fs.existsSync(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
|
|
662
|
+
if (plan.branchName && hasBranchInUse(allWorktrees, plan.branchName, plan.worktreePath)) {
|
|
663
|
+
throw new Error(`branch_in_use:${plan.branchName}`);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
ensureBucketDirUsable(path.dirname(plan.worktreePath));
|
|
667
|
+
throwIfAborted(options, timeout);
|
|
668
|
+
const branchAlreadyExisted = plan.branchName ? branchExists(plan.repoRoot, plan.branchName) : false;
|
|
669
|
+
const args = ["worktree", "add"];
|
|
670
|
+
if (plan.detached) args.push("--detach", plan.worktreePath, plan.baseRef);
|
|
671
|
+
else if (branchAlreadyExisted) args.push(plan.worktreePath, plan.branchName ?? "");
|
|
672
|
+
else args.push("-b", plan.branchName ?? "", plan.worktreePath, plan.baseRef);
|
|
673
|
+
|
|
674
|
+
const result = await spawnProcessGroup(["git", ...args], plan.repoRoot, options, timeout);
|
|
675
|
+
if (result.exitCode !== 0) {
|
|
676
|
+
const stderr = sanitizeWorktreeDiagnostic(result.stderr.trim());
|
|
477
677
|
if (plan.branchName && BRANCH_IN_USE_PATTERN.test(stderr)) throw new Error(`branch_in_use:${plan.branchName}`);
|
|
478
678
|
throw new Error(stderr || `worktree_add_failed:${args.join(" ")}`);
|
|
479
679
|
}
|
|
@@ -598,10 +798,49 @@ function installWorkspaceDependencies(
|
|
|
598
798
|
stderr: "pipe",
|
|
599
799
|
});
|
|
600
800
|
} catch (error) {
|
|
601
|
-
throw new Error(
|
|
801
|
+
throw new Error(
|
|
802
|
+
`worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(String(error))}`,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
if (result.exitCode !== 0) {
|
|
806
|
+
throw new Error(
|
|
807
|
+
`worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(result.stderr.toString().trim())}`,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
let installed: fs.Stats;
|
|
811
|
+
try {
|
|
812
|
+
installed = fs.lstatSync(path.join(worktreePath, "node_modules"));
|
|
813
|
+
} catch (error) {
|
|
814
|
+
if (fileSystemErrorCode(error) === "ENOENT") return;
|
|
815
|
+
throw error;
|
|
816
|
+
}
|
|
817
|
+
if (!installed.isDirectory() || installed.isSymbolicLink()) throw new Error("worktree_dependency_install_not_local");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function installWorkspaceDependenciesCancellable(
|
|
821
|
+
sourceRoot: string,
|
|
822
|
+
worktreePath: string,
|
|
823
|
+
manifest: WorkspacePackageManifest | null,
|
|
824
|
+
options: LaunchWorktreeAbortOptions = {},
|
|
825
|
+
): Promise<void> {
|
|
826
|
+
const timeout = new DependencyPreparationTimeoutError();
|
|
827
|
+
throwIfAborted(options, timeout);
|
|
828
|
+
removeLegacySourceNodeModulesLink(sourceRoot, worktreePath);
|
|
829
|
+
const manager = resolveWorkspacePackageManager(worktreePath, manifest);
|
|
830
|
+
const command = workspaceInstallCommand(manager);
|
|
831
|
+
let result: { stdout: string; stderr: string; exitCode: number };
|
|
832
|
+
try {
|
|
833
|
+
result = await spawnProcessGroup(command, worktreePath, options, timeout);
|
|
834
|
+
} catch (error) {
|
|
835
|
+
if (error instanceof DependencyPreparationTimeoutError) throw error;
|
|
836
|
+
throw new Error(
|
|
837
|
+
`worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(String(error))}`,
|
|
838
|
+
);
|
|
602
839
|
}
|
|
603
840
|
if (result.exitCode !== 0) {
|
|
604
|
-
throw new Error(
|
|
841
|
+
throw new Error(
|
|
842
|
+
`worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(result.stderr.trim())}`,
|
|
843
|
+
);
|
|
605
844
|
}
|
|
606
845
|
let installed: fs.Stats;
|
|
607
846
|
try {
|
|
@@ -628,6 +867,26 @@ export function ensureReusableNodeModules(sourceRoot: string, worktreePath: stri
|
|
|
628
867
|
return "symlink";
|
|
629
868
|
}
|
|
630
869
|
|
|
870
|
+
export async function ensureReusableNodeModulesCancellable(
|
|
871
|
+
sourceRoot: string,
|
|
872
|
+
worktreePath: string,
|
|
873
|
+
options: LaunchWorktreeAbortOptions = {},
|
|
874
|
+
): Promise<"symlink" | "present" | "missing"> {
|
|
875
|
+
const timeout = new DependencyPreparationTimeoutError();
|
|
876
|
+
throwIfAborted(options, timeout);
|
|
877
|
+
const target = path.join(worktreePath, "node_modules");
|
|
878
|
+
const manifest = readWorkspacePackageManifest(worktreePath);
|
|
879
|
+
if (isWorkspaceRoot(worktreePath, manifest)) {
|
|
880
|
+
await installWorkspaceDependenciesCancellable(sourceRoot, worktreePath, manifest, options);
|
|
881
|
+
return "present";
|
|
882
|
+
}
|
|
883
|
+
if (fs.existsSync(target)) return "present";
|
|
884
|
+
const source = path.join(sourceRoot, "node_modules");
|
|
885
|
+
if (!fs.existsSync(source)) return "missing";
|
|
886
|
+
fs.symlinkSync(source, target, "junction");
|
|
887
|
+
return "symlink";
|
|
888
|
+
}
|
|
889
|
+
|
|
631
890
|
/** Result of {@link prepareLaunchWorktree}: the effective working directory, remaining args, and resolved worktree plan. */
|
|
632
891
|
export interface PreparedLaunchWorktree {
|
|
633
892
|
cwd: string;
|
|
@@ -643,3 +902,25 @@ export function prepareLaunchWorktree(cwd: string, args: string[]): PreparedLaun
|
|
|
643
902
|
ensureReusableNodeModules(ensured.repoRoot, ensured.worktreePath);
|
|
644
903
|
return { cwd: ensured.worktreePath, args: parsed.remainingArgs, worktree: ensured };
|
|
645
904
|
}
|
|
905
|
+
export function removeOwnedLaunchWorktree(
|
|
906
|
+
plan: GjcLaunchWorktreePlan,
|
|
907
|
+
receipt: { created: boolean; reused: boolean; createdBranch: boolean },
|
|
908
|
+
): void {
|
|
909
|
+
if (!receipt.created || receipt.reused) return;
|
|
910
|
+
try {
|
|
911
|
+
runGit(plan.repoRoot, ["worktree", "remove", "--force", plan.worktreePath]);
|
|
912
|
+
} catch {
|
|
913
|
+
try {
|
|
914
|
+
runGit(plan.repoRoot, ["worktree", "prune"]);
|
|
915
|
+
} catch {
|
|
916
|
+
// Proof stays on the ledger; leftover classify is best-effort after abort.
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (receipt.createdBranch && plan.branchName) {
|
|
920
|
+
try {
|
|
921
|
+
runGit(plan.repoRoot, ["branch", "-D", plan.branchName]);
|
|
922
|
+
} catch {
|
|
923
|
+
// Owned-branch delete is best-effort; collisions keep the pre-existing branch.
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
@@ -55,6 +55,10 @@ function lockRetryElapsedMs(budget: LockRetryBudget): number {
|
|
|
55
55
|
return Math.max(0, performance.now() - budget.startedAt);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
function lockRetryExhausted(budget: LockRetryBudget): boolean {
|
|
59
|
+
return lockRetryElapsedMs(budget) >= LOCK_ACQUIRE_TIMEOUT_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
58
62
|
async function waitForLockRetry(budget: LockRetryBudget): Promise<boolean> {
|
|
59
63
|
budget.attempts++;
|
|
60
64
|
const remainingMs = LOCK_ACQUIRE_TIMEOUT_MS - lockRetryElapsedMs(budget);
|
|
@@ -78,8 +82,8 @@ async function waitForLockRetry(budget: LockRetryBudget): Promise<boolean> {
|
|
|
78
82
|
* owner record is the sibling `<file>.lock.transition.owner`. `mkdir` admits exactly one
|
|
79
83
|
* contender, while the held directory prevents a successor until release unlinks the
|
|
80
84
|
* validated sidecar and atomically `rmdir`s the claim. A crash before either step leaves a
|
|
81
|
-
*
|
|
82
|
-
*
|
|
85
|
+
* directory that a successor may reclaim only with proof: a host-qualified owner whose pid
|
|
86
|
+
* is proven dead, plus an exact directory-generation and tree match at removal time.
|
|
83
87
|
*
|
|
84
88
|
* The separate sidecar keeps the claim directory empty, which is what makes `rmdir` the
|
|
85
89
|
* identity-safe portable release primitive. The path stays distinct from `<file>.lock`
|
|
@@ -237,8 +241,7 @@ function removeOwnedTransitionClaim(nativePath: string, generation: TransitionDi
|
|
|
237
241
|
if (!captured.ok || !captured.snapshot) return captured.code === "not_found";
|
|
238
242
|
const root = captured.snapshot.entries.find(entry => entry.relativePath === "");
|
|
239
243
|
if (
|
|
240
|
-
|
|
241
|
-
root.kind !== "directory" ||
|
|
244
|
+
root?.kind !== "directory" ||
|
|
242
245
|
root.dev !== String(generation.dev) ||
|
|
243
246
|
root.ino !== String(generation.ino) ||
|
|
244
247
|
root.nlink !== String(generation.nlink) ||
|
|
@@ -410,6 +413,8 @@ export const SessionStateLockTestHooks: {
|
|
|
410
413
|
forcedQuarantineName?: string;
|
|
411
414
|
/** @internal Observes bounded acquisition retries without changing their timing. */
|
|
412
415
|
afterAcquireContention?: (lockFile: string, attempt: number, elapsedMs: number) => void;
|
|
416
|
+
/** @internal Runs after transition mkdir contention and before stale-claim inspection. */
|
|
417
|
+
afterTransitionClaimContention?: (transitionDir: string) => void | Promise<void>;
|
|
413
418
|
} = {};
|
|
414
419
|
|
|
415
420
|
/** Raised when the lock could not be acquired; callers map it to their own refusal. */
|
|
@@ -465,6 +470,112 @@ export class SessionStateLockUnavailableError extends Error {
|
|
|
465
470
|
}
|
|
466
471
|
}
|
|
467
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Log fields describing a persist failure: the error text plus, when a session state
|
|
475
|
+
* lock refusal sits anywhere in the cause chain, its typed `reason` and `lockPath`.
|
|
476
|
+
* Wrapping errors (`PreviousRuntimeStateReadError`, `AggregateError`) are searched
|
|
477
|
+
* so the actionable detail is never buried behind a generic message.
|
|
478
|
+
*/
|
|
479
|
+
export function sessionStateLockFailureFields(error: unknown): {
|
|
480
|
+
error: string;
|
|
481
|
+
reason?: SessionStateLockUnavailableReason;
|
|
482
|
+
lockPath?: string;
|
|
483
|
+
} {
|
|
484
|
+
const fields: { error: string; reason?: SessionStateLockUnavailableReason; lockPath?: string } = {
|
|
485
|
+
error: String(error),
|
|
486
|
+
};
|
|
487
|
+
const seen = new Set<unknown>();
|
|
488
|
+
const queue: unknown[] = [error];
|
|
489
|
+
while (queue.length > 0) {
|
|
490
|
+
const current = queue.shift();
|
|
491
|
+
if (current === null || typeof current !== "object" || seen.has(current)) continue;
|
|
492
|
+
seen.add(current);
|
|
493
|
+
if (current instanceof SessionStateLockUnavailableError) {
|
|
494
|
+
if (current.reason !== undefined) fields.reason = current.reason;
|
|
495
|
+
if (current.lockPath !== undefined) fields.lockPath = current.lockPath;
|
|
496
|
+
if (fields.reason !== undefined && fields.lockPath !== undefined) return fields;
|
|
497
|
+
}
|
|
498
|
+
if (current instanceof AggregateError) queue.push(...current.errors);
|
|
499
|
+
if ("cause" in current) queue.push((current as { cause?: unknown }).cause);
|
|
500
|
+
}
|
|
501
|
+
return fields;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function boundedPersistFailureClassPart(value: unknown, fallback: string): string {
|
|
505
|
+
if (typeof value !== "string" && typeof value !== "number") return fallback;
|
|
506
|
+
const normalized = String(value)
|
|
507
|
+
.replace(/[^A-Za-z0-9_.-]/gu, "_")
|
|
508
|
+
.slice(0, 64);
|
|
509
|
+
return normalized || fallback;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function persistFailureProperty(value: unknown, name: string): unknown {
|
|
513
|
+
if (value === null || (typeof value !== "object" && typeof value !== "function")) return undefined;
|
|
514
|
+
try {
|
|
515
|
+
return Reflect.get(value, name);
|
|
516
|
+
} catch {
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function persistFailureConstructorName(error: unknown): string {
|
|
522
|
+
if (error === null) return "null";
|
|
523
|
+
if (typeof error !== "object" && typeof error !== "function") return typeof error;
|
|
524
|
+
const ctor = persistFailureProperty(error, "constructor");
|
|
525
|
+
const name = persistFailureProperty(ctor, "name");
|
|
526
|
+
return typeof name === "string" && name.length > 0 ? name : "Object";
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Build a bounded warn-window partition without including arbitrary error text. Typed lock
|
|
531
|
+
* refusals use their stable reason; all other failures use the root constructor and the
|
|
532
|
+
* first structured `code`/`errno` found in the error/cause chain.
|
|
533
|
+
*/
|
|
534
|
+
function persistFailureClass(error: unknown): string {
|
|
535
|
+
const lockFields = sessionStateLockFailureFields(error);
|
|
536
|
+
if (lockFields.reason !== undefined) return `reason:${lockFields.reason}`;
|
|
537
|
+
const seen = new Set<unknown>();
|
|
538
|
+
const queue: unknown[] = [error];
|
|
539
|
+
let code: unknown;
|
|
540
|
+
let errno: unknown;
|
|
541
|
+
while (queue.length > 0) {
|
|
542
|
+
const current = queue.shift();
|
|
543
|
+
if (current === null || typeof current !== "object" || seen.has(current)) continue;
|
|
544
|
+
seen.add(current);
|
|
545
|
+
code ??= persistFailureProperty(current, "code");
|
|
546
|
+
errno ??= persistFailureProperty(current, "errno");
|
|
547
|
+
if (code !== undefined && errno !== undefined) break;
|
|
548
|
+
if (current instanceof AggregateError) queue.push(...current.errors);
|
|
549
|
+
const cause = persistFailureProperty(current, "cause");
|
|
550
|
+
if (cause !== undefined) queue.push(cause);
|
|
551
|
+
}
|
|
552
|
+
return `error:${boundedPersistFailureClassPart(persistFailureConstructorName(error), "Object")}:code=${boundedPersistFailureClassPart(code, "none")}:errno=${boundedPersistFailureClassPart(errno, "none")}`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export const PERSIST_FAILURE_WARN_WINDOW_MS = 30_000;
|
|
556
|
+
const persistFailureLastWarnedAt = new Map<string, number>();
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Whether a persist failure should be logged at warn level. The first failure for each
|
|
560
|
+
* document and stable failure class in a 30s window warns; repeats are the caller's cue
|
|
561
|
+
* to log at debug instead.
|
|
562
|
+
*/
|
|
563
|
+
export function shouldWarnPersistFailure(key: string, error: unknown, now = Date.now()): boolean {
|
|
564
|
+
for (const [otherKey, warnedAt] of persistFailureLastWarnedAt) {
|
|
565
|
+
if (now - warnedAt >= PERSIST_FAILURE_WARN_WINDOW_MS) persistFailureLastWarnedAt.delete(otherKey);
|
|
566
|
+
}
|
|
567
|
+
const scopedKey = `${key}\u0000${persistFailureClass(error)}`;
|
|
568
|
+
const last = persistFailureLastWarnedAt.get(scopedKey);
|
|
569
|
+
if (last !== undefined && now - last < PERSIST_FAILURE_WARN_WINDOW_MS) return false;
|
|
570
|
+
persistFailureLastWarnedAt.set(scopedKey, now);
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** @internal Test seam: forget every persist-failure warn window. */
|
|
575
|
+
export function resetPersistFailureWarnWindows(): void {
|
|
576
|
+
persistFailureLastWarnedAt.clear();
|
|
577
|
+
}
|
|
578
|
+
|
|
468
579
|
function lockUnavailable(
|
|
469
580
|
lockPath: string,
|
|
470
581
|
reason: SessionStateLockUnavailableReason,
|
|
@@ -1388,6 +1499,7 @@ async function releaseOwnerLock(file: string, held: LockOwnerSnapshot): Promise<
|
|
|
1388
1499
|
type SessionStateLockReclaimResult =
|
|
1389
1500
|
| "absent_or_changed"
|
|
1390
1501
|
| "legacy_directory_unprovenanced"
|
|
1502
|
+
| "owner_removed"
|
|
1391
1503
|
| "owner_live_or_unverifiable"
|
|
1392
1504
|
| "owner_record_fresh"
|
|
1393
1505
|
| "owner_unprovenanced"
|
|
@@ -1426,7 +1538,8 @@ async function reclaimStaleOwnerRecord(
|
|
|
1426
1538
|
// A successor that took the path in the final window keeps it; this call just loses.
|
|
1427
1539
|
if (outcome === "refused")
|
|
1428
1540
|
throw new SessionStateLockUnavailableError(new Error("Stale owner record could not be reclaimed."));
|
|
1429
|
-
|
|
1541
|
+
if (outcome === "removed") return "owner_removed";
|
|
1542
|
+
return "absent_or_changed";
|
|
1430
1543
|
}
|
|
1431
1544
|
|
|
1432
1545
|
/**
|
|
@@ -1558,14 +1671,18 @@ async function releaseTransitionClaim(
|
|
|
1558
1671
|
}
|
|
1559
1672
|
}
|
|
1560
1673
|
|
|
1561
|
-
async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName: string): Promise<
|
|
1562
|
-
|
|
1563
|
-
|
|
1674
|
+
async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName: string): Promise<boolean> {
|
|
1675
|
+
let stat: fsSync.BigIntStats;
|
|
1676
|
+
try {
|
|
1677
|
+
stat = await fs.lstat(transitionDir, { bigint: true });
|
|
1678
|
+
} catch {
|
|
1679
|
+
return false;
|
|
1680
|
+
}
|
|
1564
1681
|
// Regular-file claims belong to the superseded protocol. They retain the old
|
|
1565
1682
|
// exact-identity stale path; released PID-1 tombstones deliberately require
|
|
1566
1683
|
// explicit cleanup before this atomic-directory protocol can take over.
|
|
1567
1684
|
if (stat.isFile()) {
|
|
1568
|
-
await reclaimStaleOwnerRecord(
|
|
1685
|
+
const reclaimed = await reclaimStaleOwnerRecord(
|
|
1569
1686
|
transitionDir,
|
|
1570
1687
|
{
|
|
1571
1688
|
afterInspection: SessionStateLockTestHooks.afterTransitionStaleInspection,
|
|
@@ -1573,53 +1690,56 @@ async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName
|
|
|
1573
1690
|
},
|
|
1574
1691
|
quarantineName,
|
|
1575
1692
|
);
|
|
1576
|
-
return;
|
|
1693
|
+
return reclaimed === "owner_removed";
|
|
1577
1694
|
}
|
|
1578
1695
|
if (!stat.isDirectory()) throw new SessionStateLockUnavailableError();
|
|
1579
1696
|
const ownerSnapshot = await captureRegularLockOwner(`${transitionDir}.owner`);
|
|
1580
|
-
if (!ownerSnapshot) return;
|
|
1697
|
+
if (!ownerSnapshot) return false;
|
|
1581
1698
|
let owner: unknown;
|
|
1582
1699
|
try {
|
|
1583
1700
|
owner = JSON.parse(ownerSnapshot.bytes);
|
|
1584
1701
|
} catch {
|
|
1585
|
-
return;
|
|
1702
|
+
return false;
|
|
1586
1703
|
}
|
|
1587
|
-
if (!validLockOwner(owner)) return;
|
|
1704
|
+
if (!validLockOwner(owner)) return false;
|
|
1705
|
+
await SessionStateLockTestHooks.afterTransitionStaleInspection?.(transitionDir);
|
|
1588
1706
|
if (owner.released === true) {
|
|
1589
1707
|
// A released record is safe only when it is qualified by this installation.
|
|
1590
1708
|
// Never let a shared-volume tombstone, an absent identity, or a legacy foreign
|
|
1591
1709
|
// identity authorize removal of a claim this process cannot own.
|
|
1592
|
-
if (owner.owner_host_id === undefined) return;
|
|
1710
|
+
if (owner.owner_host_id === undefined) return false;
|
|
1593
1711
|
const currentHost = await currentOwnerHostId();
|
|
1594
1712
|
const legacyHost = await currentLegacyOwnerHostId();
|
|
1595
|
-
if (owner.owner_host_id !== currentHost && owner.owner_host_id !== legacyHost) return;
|
|
1596
|
-
if (Date.now() - Number(ownerSnapshot.mtimeNs / 1_000_000n) < RELEASED_TRANSITION_GRACE_MS) return;
|
|
1597
|
-
} else {
|
|
1598
|
-
|
|
1599
|
-
|
|
1713
|
+
if (owner.owner_host_id !== currentHost && owner.owner_host_id !== legacyHost) return false;
|
|
1714
|
+
if (Date.now() - Number(ownerSnapshot.mtimeNs / 1_000_000n) < RELEASED_TRANSITION_GRACE_MS) return false;
|
|
1715
|
+
} else if (await lockOwnerIsAlive(owner)) {
|
|
1716
|
+
// Only a host-qualified owner whose pid is PROVEN dead (ESRCH, or a live pid
|
|
1717
|
+
// with a provably different incarnation) authorizes reclaim. The generation
|
|
1718
|
+
// + exact-tree checks below then bind the removal to the very directory
|
|
1719
|
+
// inspected here, on every platform; a claim stranded by a force-quit
|
|
1720
|
+
// (`postmortem.quit`) would otherwise wall the session directory forever.
|
|
1721
|
+
return false;
|
|
1600
1722
|
}
|
|
1601
1723
|
const generation = transitionGenerationFromStat(stat);
|
|
1602
1724
|
const nativePath = await canonicalOwnedTransitionPath(transitionDir).catch(() => null);
|
|
1603
|
-
if (!nativePath) return;
|
|
1725
|
+
if (!nativePath) return false;
|
|
1604
1726
|
const captured = nativeSessionStateLock().snapshotDirectoryTree(nativePath);
|
|
1605
|
-
if (!captured.ok || !captured.snapshot) return;
|
|
1727
|
+
if (!captured.ok || !captured.snapshot) return false;
|
|
1606
1728
|
const root = captured.snapshot.entries.find(entry => entry.relativePath === "");
|
|
1607
1729
|
if (
|
|
1608
|
-
|
|
1609
|
-
root.kind !== "directory" ||
|
|
1730
|
+
root?.kind !== "directory" ||
|
|
1610
1731
|
root.dev !== String(generation.dev) ||
|
|
1611
1732
|
root.ino !== String(generation.ino) ||
|
|
1612
1733
|
root.nlink !== String(generation.nlink) ||
|
|
1613
1734
|
root.mtimeNs !== String(generation.mtimeNs) ||
|
|
1614
1735
|
root.ctimeNs !== String(generation.ctimeNs)
|
|
1615
1736
|
)
|
|
1616
|
-
return;
|
|
1617
|
-
if (captured.snapshot.entries.length !== 1) return;
|
|
1737
|
+
return false;
|
|
1738
|
+
if (captured.snapshot.entries.length !== 1) return false;
|
|
1618
1739
|
const removed = nativeSessionStateLock().exactRemoveDirectoryTree(nativePath, captured.snapshot);
|
|
1619
1740
|
if (removed.ok || removed.code === "not_found") {
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
return;
|
|
1741
|
+
exactUnlinkOwnerRecord(`${transitionDir}.owner`, ownerSnapshot, quarantineName);
|
|
1742
|
+
return removed.ok;
|
|
1623
1743
|
}
|
|
1624
1744
|
if (
|
|
1625
1745
|
removed.code === "cleanup_pending" &&
|
|
@@ -1635,9 +1755,8 @@ async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName
|
|
|
1635
1755
|
// collision on POSIX. rmdir never follows a directory's contents and refuses if a
|
|
1636
1756
|
// successor populated the detached path.
|
|
1637
1757
|
await removeTransitionDir(removed.detachedPath);
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
return;
|
|
1758
|
+
exactUnlinkOwnerRecord(`${transitionDir}.owner`, ownerSnapshot, quarantineName);
|
|
1759
|
+
return true;
|
|
1641
1760
|
}
|
|
1642
1761
|
throw new SessionStateLockUnavailableError(
|
|
1643
1762
|
new Error(`Stale transition claim could not be reclaimed (${removed.code ?? "unknown"}).`),
|
|
@@ -1901,7 +2020,12 @@ async function withLockPathTransition<T>(
|
|
|
1901
2020
|
} catch (error) {
|
|
1902
2021
|
const code = (error as NodeJS.ErrnoException).code;
|
|
1903
2022
|
if (code !== "EEXIST" && !isTransientLockError(error)) throw new SessionStateLockUnavailableError(error);
|
|
1904
|
-
await
|
|
2023
|
+
await SessionStateLockTestHooks.afterTransitionClaimContention?.(transitionDir);
|
|
2024
|
+
if (await reclaimStaleTransitionClaim(transitionDir, quarantineName)) {
|
|
2025
|
+
if (lockRetryExhausted(budget))
|
|
2026
|
+
throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
1905
2029
|
if (!(await waitForLockRetry(budget)))
|
|
1906
2030
|
throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
|
|
1907
2031
|
continue;
|
|
@@ -1632,12 +1632,9 @@ async function writeStateFileSyncConditional(
|
|
|
1632
1632
|
* than faulting forever.
|
|
1633
1633
|
*/
|
|
1634
1634
|
async function withStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
if (error instanceof SessionStateLockUnavailableError) throw new PreviousRuntimeStateReadError(error);
|
|
1639
|
-
throw error;
|
|
1640
|
-
}
|
|
1635
|
+
// Preserve the lock's typed path/reason diagnostics. A transition timeout means the
|
|
1636
|
+
// state file was never entered, not that its existing JSON marker was unreadable.
|
|
1637
|
+
return await withSessionStateFileLock(stateFile, operation);
|
|
1641
1638
|
}
|
|
1642
1639
|
|
|
1643
1640
|
function coordinatorTransactionLockFile(stateFile: string): string {
|
|
@@ -1650,17 +1647,13 @@ function coordinatorTransactionLockFile(stateFile: string): string {
|
|
|
1650
1647
|
* protocol.
|
|
1651
1648
|
*/
|
|
1652
1649
|
async function withCoordinatorTransactionLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
}
|
|
1660
|
-
if (error instanceof Error && error.message.startsWith("Failed to acquire lock"))
|
|
1661
|
-
throw new PreviousRuntimeStateReadError();
|
|
1662
|
-
throw error;
|
|
1663
|
-
}
|
|
1650
|
+
// Namespace-lock failures are contention or filesystem failures, not evidence that
|
|
1651
|
+
// runtime-state.json is malformed. Let the original diagnostic reach the caller.
|
|
1652
|
+
return await withFileLock(coordinatorTransactionLockFile(stateFile), operation, {
|
|
1653
|
+
staleMs: 30_000,
|
|
1654
|
+
retries: 12_000,
|
|
1655
|
+
retryDelayMs: 5,
|
|
1656
|
+
});
|
|
1664
1657
|
}
|
|
1665
1658
|
|
|
1666
1659
|
function orderedDistinctStateFiles(stateFiles: readonly string[], platform: NodeJS.Platform): string[] {
|