@gajae-code/coding-agent 0.15.6 → 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 +99 -0
- package/dist/types/async/job-manager.d.ts +32 -1
- package/dist/types/browser-backend.d.ts +12 -0
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/root-flags.d.ts +14 -0
- package/dist/types/cli/update-cli.d.ts +14 -0
- package/dist/types/cli.d.ts +7 -0
- package/dist/types/commands/launch.d.ts +10 -0
- package/dist/types/commands/sdk.d.ts +2 -0
- package/dist/types/config/autorouting-tier-map.d.ts +24 -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/file-lock.d.ts +5 -0
- package/dist/types/config/model-registry.d.ts +22 -0
- package/dist/types/config/settings-schema.d.ts +27 -8
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
- package/dist/types/crash/record-loader.d.ts +15 -0
- package/dist/types/extensibility/extensions/types.d.ts +8 -3
- package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
- package/dist/types/main.d.ts +1 -0
- package/dist/types/master-mode/context.d.ts +21 -0
- package/dist/types/master-mode/first-request.d.ts +34 -0
- package/dist/types/master-mode/snapshot.d.ts +32 -0
- package/dist/types/modes/acp/acp-agent.d.ts +21 -21
- package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
- package/dist/types/modes/components/model-selector.d.ts +4 -0
- package/dist/types/modes/components/oauth-selector.d.ts +1 -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/components/status-line/model-name.d.ts +18 -0
- package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
- package/dist/types/modes/components/status-line/segments.d.ts +10 -0
- package/dist/types/modes/interactive-mode.d.ts +16 -2
- package/dist/types/modes/print-mode.d.ts +17 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/ui-language.d.ts +7 -1
- package/dist/types/sdk/broker/broker.d.ts +79 -2
- package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
- package/dist/types/sdk/broker/ensure.d.ts +3 -0
- package/dist/types/sdk/broker/index.d.ts +3 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +40 -0
- package/dist/types/sdk/broker/master-capability.d.ts +10 -0
- package/dist/types/sdk/broker/session-index.d.ts +40 -11
- package/dist/types/sdk/broker/session-scope.d.ts +72 -0
- package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
- package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
- package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
- package/dist/types/sdk/broker/state-version.d.ts +21 -1
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/index.d.ts +9 -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/index.d.ts +1 -0
- package/dist/types/sdk/cli/master-cli.d.ts +63 -0
- package/dist/types/sdk/cli/rows.d.ts +12 -5
- package/dist/types/sdk/cli/session-cli.d.ts +20 -3
- package/dist/types/sdk/client/client.d.ts +3 -2
- package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +2 -0
- package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
- package/dist/types/sdk/host/events.d.ts +1 -0
- package/dist/types/sdk/host/host.d.ts +10 -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 +70 -17
- package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
- package/dist/types/sdk/lifecycle/service.d.ts +15 -3
- package/dist/types/sdk/lifecycle-session.d.ts +3 -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 +6 -0
- package/dist/types/sdk/session.d.ts +16 -0
- package/dist/types/sdk/startup-capability.d.ts +2 -1
- package/dist/types/sdk/turn-result.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +52 -7
- package/dist/types/session/credential-store-errors.d.ts +1 -0
- package/dist/types/session/fold-coordinator.d.ts +29 -2
- package/dist/types/session/session-manager.d.ts +79 -2
- package/dist/types/session/yield-queue.d.ts +5 -4
- package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/bash.d.ts +16 -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/gh.d.ts +8 -0
- package/dist/types/tools/index.d.ts +10 -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 +13 -1
- package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
- package/src/async/job-manager.ts +107 -9
- package/src/browser-backend.ts +29 -0
- package/src/cli/accounts-cli.ts +9 -5
- package/src/cli/args.ts +17 -0
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/plugin-cli.ts +29 -0
- package/src/cli/root-flags.ts +5 -0
- package/src/cli/update-cli.ts +140 -23
- package/src/commands/launch.ts +86 -18
- package/src/commands/sdk.ts +135 -28
- package/src/config/autorouting-tier-map.ts +12 -0
- package/src/config/billing-path.ts +42 -0
- package/src/config/config-schema-version.ts +18 -1
- package/src/config/file-lock.ts +158 -83
- package/src/config/model-preset-registry.ts +178 -23
- package/src/config/model-profiles.ts +5 -5
- package/src/config/model-registry.ts +291 -42
- package/src/config/model-resolver.ts +5 -0
- package/src/config/settings-schema.ts +27 -8
- package/src/config/settings.ts +51 -15
- package/src/coordinator-mcp/projection-scan.ts +27 -6
- package/src/coordinator-mcp/question-state.ts +9 -11
- package/src/coordinator-mcp/server.ts +115 -21
- package/src/crash/index-store.ts +2 -0
- package/src/crash/record-loader.ts +10 -0
- 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-reservation.ts +242 -0
- package/src/gjc-runtime/launch-worktree.ts +285 -4
- package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
- package/src/gjc-runtime/session-state-lock.ts +213 -14
- package/src/gjc-runtime/session-state-sidecar.ts +938 -39
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
- package/src/gjc-runtime/tmux-owner-isolation.ts +18 -3
- package/src/gjc-runtime/tmux-sessions.ts +252 -13
- package/src/hooks/skill-state.ts +6 -0
- package/src/internal-urls/docs-index.generated.ts +15 -15
- package/src/internal-urls/issue-pr-protocol.ts +4 -2
- package/src/main.ts +61 -3
- package/src/master-mode/context.ts +50 -0
- package/src/master-mode/first-request.ts +65 -0
- package/src/master-mode/snapshot.ts +205 -0
- package/src/modes/acp/acp-agent.ts +610 -161
- package/src/modes/components/footer.ts +4 -2
- package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
- package/src/modes/components/model-selector.ts +76 -8
- package/src/modes/components/oauth-selector.ts +6 -1
- package/src/modes/components/session-selector.ts +23 -15
- package/src/modes/components/sessions-dashboard.ts +8 -5
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/model-name.ts +53 -0
- package/src/modes/components/status-line/priority-row.ts +107 -0
- package/src/modes/components/status-line/segments.ts +23 -9
- package/src/modes/components/tool-status-header.ts +151 -15
- 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 +30 -2
- package/src/modes/controllers/selector-controller.ts +200 -98
- package/src/modes/interactive-mode.ts +36 -12
- package/src/modes/print-mode.ts +64 -0
- 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/ui-language.ts +49 -2
- package/src/modes/utils/ui-helpers.ts +22 -3
- package/src/prompts/agent-fragments/browser-aside.md +22 -0
- package/src/prompts/system/master-mode.md +28 -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 +1646 -27
- package/src/sdk/broker/endpoint-authority.ts +86 -0
- package/src/sdk/broker/ensure.ts +81 -3
- package/src/sdk/broker/index.ts +3 -0
- package/src/sdk/broker/lifecycle-ledger.ts +6 -0
- package/src/sdk/broker/lifecycle.ts +440 -75
- package/src/sdk/broker/master-capability.ts +232 -0
- package/src/sdk/broker/session-index.ts +121 -17
- package/src/sdk/broker/session-scope.ts +272 -0
- package/src/sdk/broker/spawn-authority.ts +1031 -0
- package/src/sdk/broker/spawn-substrate.ts +591 -0
- package/src/sdk/broker/startup-budget.ts +144 -2
- package/src/sdk/broker/state-version.ts +43 -14
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/broker/worktree-occupancy.ts +90 -0
- package/src/sdk/bus/chat-daemon-control.ts +5 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +136 -18
- package/src/sdk/bus/discord-daemon.ts +50 -10
- package/src/sdk/bus/index.ts +84 -7
- 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/index.ts +1 -0
- package/src/sdk/cli/master-cli.ts +259 -0
- package/src/sdk/cli/rows.ts +41 -15
- package/src/sdk/cli/session-cli.ts +297 -26
- package/src/sdk/client/client.ts +14 -8
- package/src/sdk/host/control/dispatch.ts +101 -40
- package/src/sdk/host/control/index.ts +2 -0
- package/src/sdk/host/control/operations.ts +2 -0
- package/src/sdk/host/control/runtime-gate.ts +203 -11
- package/src/sdk/host/events.ts +1 -0
- package/src/sdk/host/host.ts +29 -2
- 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 +673 -14
- package/src/sdk/host/session-runtime.ts +674 -103
- package/src/sdk/host/surface-policy.ts +3 -3
- package/src/sdk/lifecycle/broker-client.ts +28 -0
- package/src/sdk/lifecycle/service.ts +279 -20
- package/src/sdk/lifecycle-session.ts +17 -4
- package/src/sdk/prompt-deadline-manager.ts +2 -1
- package/src/sdk/protocol/operation-inventory.generated.json +105 -3
- package/src/sdk/protocol/operation-registry.ts +14 -4
- package/src/sdk/receipt-state.ts +10 -0
- package/src/sdk/router/session-router.ts +219 -118
- package/src/sdk/session.ts +416 -195
- package/src/sdk/startup-capability.ts +1 -0
- package/src/sdk/turn-result.ts +22 -0
- package/src/session/agent-session.ts +1628 -318
- package/src/session/blob-store.ts +15 -5
- package/src/session/credential-store-errors.ts +2 -0
- package/src/session/fold-coordinator.ts +40 -3
- package/src/session/internal/managed-session-storage.ts +153 -78
- package/src/session/session-manager.ts +850 -97
- package/src/session/yield-queue.ts +24 -10
- package/src/setup/frictionless-onboarding.ts +2 -0
- package/src/slash-commands/builtin-registry.ts +41 -0
- package/src/task/executor.ts +9 -4
- package/src/telemetry/events.ts +1138 -12
- 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 +412 -75
- 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/gh.ts +104 -48
- package/src/tools/github-cache.ts +2 -2
- package/src/tools/index.ts +10 -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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process reservation for a deterministic launch-worktree path.
|
|
3
|
+
*
|
|
4
|
+
* The durable session index is the long-lived ownership record, but a newly
|
|
5
|
+
* planned worktree has no index row yet. This reservation closes that interval:
|
|
6
|
+
* it is acquired before preparation and removed after `host_registered` is
|
|
7
|
+
* durably appended to the index.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import type { Dirent } from "node:fs";
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { resolveEquivalentPath } from "@gajae-code/utils";
|
|
15
|
+
import {
|
|
16
|
+
type FileLockGcObservation,
|
|
17
|
+
processStartTime,
|
|
18
|
+
readFileLockObservationForGc,
|
|
19
|
+
removeFileLockDirForGc,
|
|
20
|
+
} from "../config/file-lock";
|
|
21
|
+
import { processIncarnation } from "../sdk/broker/process-incarnation";
|
|
22
|
+
|
|
23
|
+
const RESERVATION_VERSION = 1;
|
|
24
|
+
const RESERVATION_DIRECTORY = "launch-worktree-reservations";
|
|
25
|
+
|
|
26
|
+
interface LaunchWorktreeReservationRecord {
|
|
27
|
+
version: typeof RESERVATION_VERSION;
|
|
28
|
+
worktreePath: string;
|
|
29
|
+
pid: number;
|
|
30
|
+
start_time?: string;
|
|
31
|
+
processIncarnation: string | null;
|
|
32
|
+
timestamp: number;
|
|
33
|
+
reservationId: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LaunchWorktreeReservation {
|
|
37
|
+
release(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function canonicalWorktreePath(worktreePath: string): string {
|
|
41
|
+
return resolveEquivalentPath(path.resolve(worktreePath));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function reservationRoot(agentDir: string): string {
|
|
45
|
+
return path.join(agentDir, "sdk", "sessions", RESERVATION_DIRECTORY);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function launchWorktreeReservationDirectory(agentDir: string, worktreePath: string): string {
|
|
49
|
+
const digest = createHash("sha256").update(canonicalWorktreePath(worktreePath)).digest("hex");
|
|
50
|
+
return path.join(reservationRoot(agentDir), `${digest}.lock`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Test seam for seeding a dead-owner reservation at the exact production path. */
|
|
54
|
+
export function launchWorktreeReservationDirectoryForTest(agentDir: string, worktreePath: string): string {
|
|
55
|
+
return launchWorktreeReservationDirectory(agentDir, worktreePath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isErrno(error: unknown, code: string): boolean {
|
|
59
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isReservationRecord(value: unknown): value is LaunchWorktreeReservationRecord {
|
|
63
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
64
|
+
const record = value as Partial<LaunchWorktreeReservationRecord>;
|
|
65
|
+
return (
|
|
66
|
+
record.version === RESERVATION_VERSION &&
|
|
67
|
+
typeof record.worktreePath === "string" &&
|
|
68
|
+
record.worktreePath.length > 0 &&
|
|
69
|
+
typeof record.pid === "number" &&
|
|
70
|
+
Number.isSafeInteger(record.pid) &&
|
|
71
|
+
record.pid > 0 &&
|
|
72
|
+
(record.start_time === undefined || (typeof record.start_time === "string" && record.start_time.length > 0)) &&
|
|
73
|
+
(record.processIncarnation === null ||
|
|
74
|
+
(typeof record.processIncarnation === "string" && record.processIncarnation.length > 0)) &&
|
|
75
|
+
typeof record.timestamp === "number" &&
|
|
76
|
+
Number.isFinite(record.timestamp) &&
|
|
77
|
+
typeof record.reservationId === "string" &&
|
|
78
|
+
record.reservationId.length > 0
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readReservation(lockDir: string): Promise<LaunchWorktreeReservationRecord | null> {
|
|
83
|
+
let value: unknown;
|
|
84
|
+
try {
|
|
85
|
+
value = await Bun.file(path.join(lockDir, "info")).json();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (isErrno(error, "ENOENT") || error instanceof SyntaxError) return null;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
return isReservationRecord(value) ? value : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ownerDefinitelyExited(record: LaunchWorktreeReservationRecord): boolean {
|
|
94
|
+
try {
|
|
95
|
+
process.kill(record.pid, 0);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return isErrno(error, "ESRCH");
|
|
98
|
+
}
|
|
99
|
+
if (record.processIncarnation !== null) {
|
|
100
|
+
const actualIncarnation = processIncarnation(record.pid);
|
|
101
|
+
if (actualIncarnation !== undefined) return actualIncarnation !== record.processIncarnation;
|
|
102
|
+
}
|
|
103
|
+
if (record.start_time) {
|
|
104
|
+
const actualStartTime = processStartTime(record.pid);
|
|
105
|
+
if (actualStartTime !== null) return actualStartTime !== record.start_time;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function removeReservation(
|
|
111
|
+
lockDir: string,
|
|
112
|
+
record: LaunchWorktreeReservationRecord,
|
|
113
|
+
): Promise<"removed" | "kept"> {
|
|
114
|
+
// Capture the exact on-disk identity BEFORE authorizing removal: the generic
|
|
115
|
+
// GC remover only deletes a tree whose pre-verdict identity it was handed,
|
|
116
|
+
// so a successor reservation published at the same path is never reaped.
|
|
117
|
+
let observation: FileLockGcObservation | null;
|
|
118
|
+
try {
|
|
119
|
+
observation = await readFileLockObservationForGc(lockDir);
|
|
120
|
+
} catch {
|
|
121
|
+
return "kept";
|
|
122
|
+
}
|
|
123
|
+
if (!observation) return "kept";
|
|
124
|
+
let onDisk: unknown;
|
|
125
|
+
try {
|
|
126
|
+
onDisk = JSON.parse(observation.bytes);
|
|
127
|
+
} catch {
|
|
128
|
+
return "kept";
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
!isReservationRecord(onDisk) ||
|
|
132
|
+
onDisk.reservationId !== record.reservationId ||
|
|
133
|
+
onDisk.pid !== record.pid ||
|
|
134
|
+
onDisk.timestamp !== record.timestamp ||
|
|
135
|
+
canonicalWorktreePath(onDisk.worktreePath) !== canonicalWorktreePath(record.worktreePath)
|
|
136
|
+
)
|
|
137
|
+
return "kept";
|
|
138
|
+
const result = await removeFileLockDirForGc(lockDir, observation.info, observation.identity);
|
|
139
|
+
return result === "removed" ? "removed" : "kept";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function writeReservation(lockDir: string, record: LaunchWorktreeReservationRecord): Promise<void> {
|
|
143
|
+
try {
|
|
144
|
+
await Bun.write(path.join(lockDir, "info"), JSON.stringify(record));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function tryPublishReservation(lockDir: string, record: LaunchWorktreeReservationRecord): Promise<boolean> {
|
|
152
|
+
const pendingDir = `${lockDir}.pending.${process.pid}.${randomUUID()}`;
|
|
153
|
+
await fs.mkdir(pendingDir, { mode: 0o700 });
|
|
154
|
+
try {
|
|
155
|
+
await writeReservation(pendingDir, record);
|
|
156
|
+
try {
|
|
157
|
+
await fs.rename(pendingDir, lockDir);
|
|
158
|
+
return true;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (isErrno(error, "EEXIST") || isErrno(error, "ENOTEMPTY")) return false;
|
|
161
|
+
if (isErrno(error, "EPERM")) {
|
|
162
|
+
try {
|
|
163
|
+
await fs.stat(lockDir);
|
|
164
|
+
return false;
|
|
165
|
+
} catch (statError) {
|
|
166
|
+
if (!isErrno(statError, "ENOENT")) throw statError;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
await fs.rm(pendingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Atomically claim `worktreePath`, returning `null` only when another live or
|
|
178
|
+
* unverified launch already owns the same deterministic target. A dead or
|
|
179
|
+
* PID-reused owner is reclaimed before retrying the exclusive directory create.
|
|
180
|
+
*/
|
|
181
|
+
export async function reserveLaunchWorktree(
|
|
182
|
+
agentDir: string,
|
|
183
|
+
worktreePath: string,
|
|
184
|
+
): Promise<LaunchWorktreeReservation | null> {
|
|
185
|
+
const canonicalPath = canonicalWorktreePath(worktreePath);
|
|
186
|
+
const lockDir = launchWorktreeReservationDirectory(agentDir, canonicalPath);
|
|
187
|
+
await fs.mkdir(path.dirname(lockDir), { recursive: true, mode: 0o700 });
|
|
188
|
+
const ownStartTime = processStartTime(process.pid) ?? undefined;
|
|
189
|
+
const ownIncarnation = processIncarnation(process.pid) ?? null;
|
|
190
|
+
|
|
191
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
192
|
+
const record: LaunchWorktreeReservationRecord = {
|
|
193
|
+
version: RESERVATION_VERSION,
|
|
194
|
+
worktreePath: canonicalPath,
|
|
195
|
+
pid: process.pid,
|
|
196
|
+
...(ownStartTime ? { start_time: ownStartTime } : {}),
|
|
197
|
+
processIncarnation: ownIncarnation,
|
|
198
|
+
timestamp: Date.now(),
|
|
199
|
+
reservationId: randomUUID(),
|
|
200
|
+
};
|
|
201
|
+
if (await tryPublishReservation(lockDir, record)) {
|
|
202
|
+
return {
|
|
203
|
+
async release(): Promise<void> {
|
|
204
|
+
await removeReservation(lockDir, record);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const existing = await readReservation(lockDir);
|
|
210
|
+
if (
|
|
211
|
+
!existing ||
|
|
212
|
+
canonicalWorktreePath(existing.worktreePath) !== canonicalPath ||
|
|
213
|
+
!ownerDefinitelyExited(existing)
|
|
214
|
+
)
|
|
215
|
+
return null;
|
|
216
|
+
if ((await removeReservation(lockDir, existing)) !== "removed") return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Remove the transient reservation only after the matching worktree is indexed. */
|
|
223
|
+
export async function releaseLaunchWorktreeReservationAfterRegistration(
|
|
224
|
+
agentDir: string,
|
|
225
|
+
worktreePath: string,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
const canonicalPath = canonicalWorktreePath(worktreePath);
|
|
228
|
+
let entries: Dirent[];
|
|
229
|
+
try {
|
|
230
|
+
entries = await fs.readdir(reservationRoot(agentDir), { withFileTypes: true });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (isErrno(error, "ENOENT")) return;
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
if (!entry.isDirectory() || !entry.name.endsWith(".lock")) continue;
|
|
237
|
+
const lockDir = path.join(reservationRoot(agentDir), entry.name);
|
|
238
|
+
const record = await readReservation(lockDir);
|
|
239
|
+
if (!record || canonicalWorktreePath(record.worktreePath) !== canonicalPath) continue;
|
|
240
|
+
await removeReservation(lockDir, record);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -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
|
+
}
|