@gajae-code/coding-agent 0.16.0 → 0.16.1
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 +41 -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 +8 -2
- package/dist/types/sdk/cli/session-cli.d.ts +2 -1
- package/dist/types/sdk/host/events.d.ts +1 -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 +6 -10
- package/dist/types/tools/job.d.ts +3 -1
- package/dist/types/utils/external-editor.d.ts +7 -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 +154 -28
- 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 +10 -10
- package/src/main.ts +4 -1
- package/src/modes/acp/acp-agent.ts +5 -5
- 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 +5 -0
- 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 +111 -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 +30 -7
- package/src/sdk/cli/session-cli.ts +33 -21
- package/src/sdk/host/control/dispatch.ts +2 -2
- package/src/sdk/host/events.ts +1 -0
- package/src/sdk/host/host.ts +2 -0
- 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 +463 -8
- package/src/sdk/host/session-runtime.ts +383 -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 +152 -59
- package/src/sdk/session.ts +60 -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 +156 -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 +6 -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 +72 -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`
|
|
@@ -410,6 +414,8 @@ export const SessionStateLockTestHooks: {
|
|
|
410
414
|
forcedQuarantineName?: string;
|
|
411
415
|
/** @internal Observes bounded acquisition retries without changing their timing. */
|
|
412
416
|
afterAcquireContention?: (lockFile: string, attempt: number, elapsedMs: number) => void;
|
|
417
|
+
/** @internal Runs after transition mkdir contention and before stale-claim inspection. */
|
|
418
|
+
afterTransitionClaimContention?: (transitionDir: string) => void | Promise<void>;
|
|
413
419
|
} = {};
|
|
414
420
|
|
|
415
421
|
/** Raised when the lock could not be acquired; callers map it to their own refusal. */
|
|
@@ -465,6 +471,112 @@ export class SessionStateLockUnavailableError extends Error {
|
|
|
465
471
|
}
|
|
466
472
|
}
|
|
467
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Log fields describing a persist failure: the error text plus, when a session state
|
|
476
|
+
* lock refusal sits anywhere in the cause chain, its typed `reason` and `lockPath`.
|
|
477
|
+
* Wrapping errors (`PreviousRuntimeStateReadError`, `AggregateError`) are searched
|
|
478
|
+
* so the actionable detail is never buried behind a generic message.
|
|
479
|
+
*/
|
|
480
|
+
export function sessionStateLockFailureFields(error: unknown): {
|
|
481
|
+
error: string;
|
|
482
|
+
reason?: SessionStateLockUnavailableReason;
|
|
483
|
+
lockPath?: string;
|
|
484
|
+
} {
|
|
485
|
+
const fields: { error: string; reason?: SessionStateLockUnavailableReason; lockPath?: string } = {
|
|
486
|
+
error: String(error),
|
|
487
|
+
};
|
|
488
|
+
const seen = new Set<unknown>();
|
|
489
|
+
const queue: unknown[] = [error];
|
|
490
|
+
while (queue.length > 0) {
|
|
491
|
+
const current = queue.shift();
|
|
492
|
+
if (current === null || typeof current !== "object" || seen.has(current)) continue;
|
|
493
|
+
seen.add(current);
|
|
494
|
+
if (current instanceof SessionStateLockUnavailableError) {
|
|
495
|
+
if (current.reason !== undefined) fields.reason = current.reason;
|
|
496
|
+
if (current.lockPath !== undefined) fields.lockPath = current.lockPath;
|
|
497
|
+
if (fields.reason !== undefined && fields.lockPath !== undefined) return fields;
|
|
498
|
+
}
|
|
499
|
+
if (current instanceof AggregateError) queue.push(...current.errors);
|
|
500
|
+
if ("cause" in current) queue.push((current as { cause?: unknown }).cause);
|
|
501
|
+
}
|
|
502
|
+
return fields;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function boundedPersistFailureClassPart(value: unknown, fallback: string): string {
|
|
506
|
+
if (typeof value !== "string" && typeof value !== "number") return fallback;
|
|
507
|
+
const normalized = String(value)
|
|
508
|
+
.replace(/[^A-Za-z0-9_.-]/gu, "_")
|
|
509
|
+
.slice(0, 64);
|
|
510
|
+
return normalized || fallback;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function persistFailureProperty(value: unknown, name: string): unknown {
|
|
514
|
+
if (value === null || (typeof value !== "object" && typeof value !== "function")) return undefined;
|
|
515
|
+
try {
|
|
516
|
+
return Reflect.get(value, name);
|
|
517
|
+
} catch {
|
|
518
|
+
return undefined;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function persistFailureConstructorName(error: unknown): string {
|
|
523
|
+
if (error === null) return "null";
|
|
524
|
+
if (typeof error !== "object" && typeof error !== "function") return typeof error;
|
|
525
|
+
const ctor = persistFailureProperty(error, "constructor");
|
|
526
|
+
const name = persistFailureProperty(ctor, "name");
|
|
527
|
+
return typeof name === "string" && name.length > 0 ? name : "Object";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Build a bounded warn-window partition without including arbitrary error text. Typed lock
|
|
532
|
+
* refusals use their stable reason; all other failures use the root constructor and the
|
|
533
|
+
* first structured `code`/`errno` found in the error/cause chain.
|
|
534
|
+
*/
|
|
535
|
+
function persistFailureClass(error: unknown): string {
|
|
536
|
+
const lockFields = sessionStateLockFailureFields(error);
|
|
537
|
+
if (lockFields.reason !== undefined) return `reason:${lockFields.reason}`;
|
|
538
|
+
const seen = new Set<unknown>();
|
|
539
|
+
const queue: unknown[] = [error];
|
|
540
|
+
let code: unknown;
|
|
541
|
+
let errno: unknown;
|
|
542
|
+
while (queue.length > 0) {
|
|
543
|
+
const current = queue.shift();
|
|
544
|
+
if (current === null || typeof current !== "object" || seen.has(current)) continue;
|
|
545
|
+
seen.add(current);
|
|
546
|
+
code ??= persistFailureProperty(current, "code");
|
|
547
|
+
errno ??= persistFailureProperty(current, "errno");
|
|
548
|
+
if (code !== undefined && errno !== undefined) break;
|
|
549
|
+
if (current instanceof AggregateError) queue.push(...current.errors);
|
|
550
|
+
const cause = persistFailureProperty(current, "cause");
|
|
551
|
+
if (cause !== undefined) queue.push(cause);
|
|
552
|
+
}
|
|
553
|
+
return `error:${boundedPersistFailureClassPart(persistFailureConstructorName(error), "Object")}:code=${boundedPersistFailureClassPart(code, "none")}:errno=${boundedPersistFailureClassPart(errno, "none")}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export const PERSIST_FAILURE_WARN_WINDOW_MS = 30_000;
|
|
557
|
+
const persistFailureLastWarnedAt = new Map<string, number>();
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Whether a persist failure should be logged at warn level. The first failure for each
|
|
561
|
+
* document and stable failure class in a 30s window warns; repeats are the caller's cue
|
|
562
|
+
* to log at debug instead.
|
|
563
|
+
*/
|
|
564
|
+
export function shouldWarnPersistFailure(key: string, error: unknown, now = Date.now()): boolean {
|
|
565
|
+
for (const [otherKey, warnedAt] of persistFailureLastWarnedAt) {
|
|
566
|
+
if (now - warnedAt >= PERSIST_FAILURE_WARN_WINDOW_MS) persistFailureLastWarnedAt.delete(otherKey);
|
|
567
|
+
}
|
|
568
|
+
const scopedKey = `${key}\u0000${persistFailureClass(error)}`;
|
|
569
|
+
const last = persistFailureLastWarnedAt.get(scopedKey);
|
|
570
|
+
if (last !== undefined && now - last < PERSIST_FAILURE_WARN_WINDOW_MS) return false;
|
|
571
|
+
persistFailureLastWarnedAt.set(scopedKey, now);
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** @internal Test seam: forget every persist-failure warn window. */
|
|
576
|
+
export function resetPersistFailureWarnWindows(): void {
|
|
577
|
+
persistFailureLastWarnedAt.clear();
|
|
578
|
+
}
|
|
579
|
+
|
|
468
580
|
function lockUnavailable(
|
|
469
581
|
lockPath: string,
|
|
470
582
|
reason: SessionStateLockUnavailableReason,
|
|
@@ -1388,6 +1500,7 @@ async function releaseOwnerLock(file: string, held: LockOwnerSnapshot): Promise<
|
|
|
1388
1500
|
type SessionStateLockReclaimResult =
|
|
1389
1501
|
| "absent_or_changed"
|
|
1390
1502
|
| "legacy_directory_unprovenanced"
|
|
1503
|
+
| "owner_removed"
|
|
1391
1504
|
| "owner_live_or_unverifiable"
|
|
1392
1505
|
| "owner_record_fresh"
|
|
1393
1506
|
| "owner_unprovenanced"
|
|
@@ -1426,7 +1539,8 @@ async function reclaimStaleOwnerRecord(
|
|
|
1426
1539
|
// A successor that took the path in the final window keeps it; this call just loses.
|
|
1427
1540
|
if (outcome === "refused")
|
|
1428
1541
|
throw new SessionStateLockUnavailableError(new Error("Stale owner record could not be reclaimed."));
|
|
1429
|
-
|
|
1542
|
+
if (outcome === "removed") return "owner_removed";
|
|
1543
|
+
return "absent_or_changed";
|
|
1430
1544
|
}
|
|
1431
1545
|
|
|
1432
1546
|
/**
|
|
@@ -1558,14 +1672,18 @@ async function releaseTransitionClaim(
|
|
|
1558
1672
|
}
|
|
1559
1673
|
}
|
|
1560
1674
|
|
|
1561
|
-
async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName: string): Promise<
|
|
1562
|
-
|
|
1563
|
-
|
|
1675
|
+
async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName: string): Promise<boolean> {
|
|
1676
|
+
let stat: fsSync.BigIntStats;
|
|
1677
|
+
try {
|
|
1678
|
+
stat = await fs.lstat(transitionDir, { bigint: true });
|
|
1679
|
+
} catch {
|
|
1680
|
+
return false;
|
|
1681
|
+
}
|
|
1564
1682
|
// Regular-file claims belong to the superseded protocol. They retain the old
|
|
1565
1683
|
// exact-identity stale path; released PID-1 tombstones deliberately require
|
|
1566
1684
|
// explicit cleanup before this atomic-directory protocol can take over.
|
|
1567
1685
|
if (stat.isFile()) {
|
|
1568
|
-
await reclaimStaleOwnerRecord(
|
|
1686
|
+
const reclaimed = await reclaimStaleOwnerRecord(
|
|
1569
1687
|
transitionDir,
|
|
1570
1688
|
{
|
|
1571
1689
|
afterInspection: SessionStateLockTestHooks.afterTransitionStaleInspection,
|
|
@@ -1573,36 +1691,41 @@ async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName
|
|
|
1573
1691
|
},
|
|
1574
1692
|
quarantineName,
|
|
1575
1693
|
);
|
|
1576
|
-
return;
|
|
1694
|
+
return reclaimed === "owner_removed";
|
|
1577
1695
|
}
|
|
1578
1696
|
if (!stat.isDirectory()) throw new SessionStateLockUnavailableError();
|
|
1579
1697
|
const ownerSnapshot = await captureRegularLockOwner(`${transitionDir}.owner`);
|
|
1580
|
-
if (!ownerSnapshot) return;
|
|
1698
|
+
if (!ownerSnapshot) return false;
|
|
1581
1699
|
let owner: unknown;
|
|
1582
1700
|
try {
|
|
1583
1701
|
owner = JSON.parse(ownerSnapshot.bytes);
|
|
1584
1702
|
} catch {
|
|
1585
|
-
return;
|
|
1703
|
+
return false;
|
|
1586
1704
|
}
|
|
1587
|
-
if (!validLockOwner(owner)) return;
|
|
1705
|
+
if (!validLockOwner(owner)) return false;
|
|
1706
|
+
await SessionStateLockTestHooks.afterTransitionStaleInspection?.(transitionDir);
|
|
1588
1707
|
if (owner.released === true) {
|
|
1589
1708
|
// A released record is safe only when it is qualified by this installation.
|
|
1590
1709
|
// Never let a shared-volume tombstone, an absent identity, or a legacy foreign
|
|
1591
1710
|
// identity authorize removal of a claim this process cannot own.
|
|
1592
|
-
if (owner.owner_host_id === undefined) return;
|
|
1711
|
+
if (owner.owner_host_id === undefined) return false;
|
|
1593
1712
|
const currentHost = await currentOwnerHostId();
|
|
1594
1713
|
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
|
-
|
|
1714
|
+
if (owner.owner_host_id !== currentHost && owner.owner_host_id !== legacyHost) return false;
|
|
1715
|
+
if (Date.now() - Number(ownerSnapshot.mtimeNs / 1_000_000n) < RELEASED_TRANSITION_GRACE_MS) return false;
|
|
1716
|
+
} else if (await lockOwnerIsAlive(owner)) {
|
|
1717
|
+
// Only a host-qualified owner whose pid is PROVEN dead (ESRCH, or a live pid
|
|
1718
|
+
// with a provably different incarnation) authorizes reclaim. The generation
|
|
1719
|
+
// + exact-tree checks below then bind the removal to the very directory
|
|
1720
|
+
// inspected here, on every platform; a claim stranded by a force-quit
|
|
1721
|
+
// (`postmortem.quit`) would otherwise wall the session directory forever.
|
|
1722
|
+
return false;
|
|
1600
1723
|
}
|
|
1601
1724
|
const generation = transitionGenerationFromStat(stat);
|
|
1602
1725
|
const nativePath = await canonicalOwnedTransitionPath(transitionDir).catch(() => null);
|
|
1603
|
-
if (!nativePath) return;
|
|
1726
|
+
if (!nativePath) return false;
|
|
1604
1727
|
const captured = nativeSessionStateLock().snapshotDirectoryTree(nativePath);
|
|
1605
|
-
if (!captured.ok || !captured.snapshot) return;
|
|
1728
|
+
if (!captured.ok || !captured.snapshot) return false;
|
|
1606
1729
|
const root = captured.snapshot.entries.find(entry => entry.relativePath === "");
|
|
1607
1730
|
if (
|
|
1608
1731
|
!root ||
|
|
@@ -1613,13 +1736,12 @@ async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName
|
|
|
1613
1736
|
root.mtimeNs !== String(generation.mtimeNs) ||
|
|
1614
1737
|
root.ctimeNs !== String(generation.ctimeNs)
|
|
1615
1738
|
)
|
|
1616
|
-
return;
|
|
1617
|
-
if (captured.snapshot.entries.length !== 1) return;
|
|
1739
|
+
return false;
|
|
1740
|
+
if (captured.snapshot.entries.length !== 1) return false;
|
|
1618
1741
|
const removed = nativeSessionStateLock().exactRemoveDirectoryTree(nativePath, captured.snapshot);
|
|
1619
1742
|
if (removed.ok || removed.code === "not_found") {
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
return;
|
|
1743
|
+
exactUnlinkOwnerRecord(`${transitionDir}.owner`, ownerSnapshot, quarantineName);
|
|
1744
|
+
return removed.ok;
|
|
1623
1745
|
}
|
|
1624
1746
|
if (
|
|
1625
1747
|
removed.code === "cleanup_pending" &&
|
|
@@ -1635,9 +1757,8 @@ async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName
|
|
|
1635
1757
|
// collision on POSIX. rmdir never follows a directory's contents and refuses if a
|
|
1636
1758
|
// successor populated the detached path.
|
|
1637
1759
|
await removeTransitionDir(removed.detachedPath);
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
return;
|
|
1760
|
+
exactUnlinkOwnerRecord(`${transitionDir}.owner`, ownerSnapshot, quarantineName);
|
|
1761
|
+
return true;
|
|
1641
1762
|
}
|
|
1642
1763
|
throw new SessionStateLockUnavailableError(
|
|
1643
1764
|
new Error(`Stale transition claim could not be reclaimed (${removed.code ?? "unknown"}).`),
|
|
@@ -1901,7 +2022,12 @@ async function withLockPathTransition<T>(
|
|
|
1901
2022
|
} catch (error) {
|
|
1902
2023
|
const code = (error as NodeJS.ErrnoException).code;
|
|
1903
2024
|
if (code !== "EEXIST" && !isTransientLockError(error)) throw new SessionStateLockUnavailableError(error);
|
|
1904
|
-
await
|
|
2025
|
+
await SessionStateLockTestHooks.afterTransitionClaimContention?.(transitionDir);
|
|
2026
|
+
if (await reclaimStaleTransitionClaim(transitionDir, quarantineName)) {
|
|
2027
|
+
if (lockRetryExhausted(budget))
|
|
2028
|
+
throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
1905
2031
|
if (!(await waitForLockRetry(budget)))
|
|
1906
2032
|
throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
|
|
1907
2033
|
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[] {
|