@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
|
@@ -23,7 +23,19 @@ function nativeSessionManager(): typeof import("@gajae-code/natives") {
|
|
|
23
23
|
return require("@gajae-code/natives") as typeof import("@gajae-code/natives");
|
|
24
24
|
}
|
|
25
25
|
const cwdTransitionAls = new AsyncLocalStorage<symbol>();
|
|
26
|
-
type CwdReadLeaseContext = {
|
|
26
|
+
type CwdReadLeaseContext = {
|
|
27
|
+
active: boolean;
|
|
28
|
+
released: boolean;
|
|
29
|
+
owner: symbol;
|
|
30
|
+
suspendedMoveCount: number;
|
|
31
|
+
committedMoveGeneration?: number;
|
|
32
|
+
/** Execution-local lineage; sibling tool executions never share this context. */
|
|
33
|
+
parent?: CwdReadLeaseContext;
|
|
34
|
+
root?: CwdReadLeaseContext;
|
|
35
|
+
executionId: symbol;
|
|
36
|
+
activeBranches: number;
|
|
37
|
+
suspendedMoveOwner?: symbol;
|
|
38
|
+
};
|
|
27
39
|
const cwdReadLeaseAls = new AsyncLocalStorage<CwdReadLeaseContext>();
|
|
28
40
|
const CWD_NOFOLLOW_OPEN_FLAGS =
|
|
29
41
|
fs.constants.O_RDONLY |
|
|
@@ -246,6 +258,8 @@ export interface SessionHeader {
|
|
|
246
258
|
id: string;
|
|
247
259
|
title?: string; // Auto-generated title from first message
|
|
248
260
|
titleSource?: "auto" | "user";
|
|
261
|
+
starredPatchVersion?: 1;
|
|
262
|
+
starred?: boolean;
|
|
249
263
|
timestamp: string;
|
|
250
264
|
cwd: string;
|
|
251
265
|
parentSession?: string;
|
|
@@ -931,7 +945,7 @@ export type SessionEntry =
|
|
|
931
945
|
/** Append-only replacement for mutable fields on the session header. */
|
|
932
946
|
export interface HeaderPatchRecord {
|
|
933
947
|
type: "header_patch";
|
|
934
|
-
patch: Partial<Pick<SessionHeader, "title" | "titleSource" | "cwd">>;
|
|
948
|
+
patch: Partial<Pick<SessionHeader, "title" | "titleSource" | "cwd" | "starred">>;
|
|
935
949
|
}
|
|
936
950
|
|
|
937
951
|
/** Append-only replacement for replay metadata on one existing session entry. */
|
|
@@ -2390,6 +2404,7 @@ export interface SessionInfo {
|
|
|
2390
2404
|
/** Working directory where the session was started. Empty string for old sessions. */
|
|
2391
2405
|
cwd: string;
|
|
2392
2406
|
title?: string;
|
|
2407
|
+
starred: boolean;
|
|
2393
2408
|
/** Path to the parent session (if this session was forked). */
|
|
2394
2409
|
parentSessionPath?: string;
|
|
2395
2410
|
created: Date;
|
|
@@ -2699,12 +2714,13 @@ function isHeaderPatchRecord(record: SessionPatchRecord): record is HeaderPatchR
|
|
|
2699
2714
|
)
|
|
2700
2715
|
return false;
|
|
2701
2716
|
const keys = Object.keys(record.patch);
|
|
2702
|
-
if (!keys.every(key => key === "cwd" || key === "title" || key === "titleSource")) return false;
|
|
2703
|
-
const { cwd, title, titleSource } = record.patch;
|
|
2717
|
+
if (!keys.every(key => key === "cwd" || key === "title" || key === "titleSource" || key === "starred")) return false;
|
|
2718
|
+
const { cwd, title, titleSource, starred } = record.patch;
|
|
2704
2719
|
return (
|
|
2705
2720
|
(cwd === undefined || typeof cwd === "string") &&
|
|
2706
2721
|
(title === undefined || typeof title === "string") &&
|
|
2707
|
-
(titleSource === undefined || titleSource === "auto" || titleSource === "user")
|
|
2722
|
+
(titleSource === undefined || titleSource === "auto" || titleSource === "user") &&
|
|
2723
|
+
(starred === undefined || typeof starred === "boolean")
|
|
2708
2724
|
);
|
|
2709
2725
|
}
|
|
2710
2726
|
|
|
@@ -2712,6 +2728,7 @@ function applyHeaderPatch(header: SessionHeader, patch: HeaderPatchRecord["patch
|
|
|
2712
2728
|
if (patch.cwd !== undefined) header.cwd = patch.cwd;
|
|
2713
2729
|
if (patch.title !== undefined) header.title = patch.title;
|
|
2714
2730
|
if (patch.titleSource !== undefined) header.titleSource = patch.titleSource;
|
|
2731
|
+
if (header.starredPatchVersion === 1 && patch.starred !== undefined) header.starred = patch.starred;
|
|
2715
2732
|
}
|
|
2716
2733
|
|
|
2717
2734
|
function isEntryPatchRecord(record: SessionPatchRecord): record is EntryPatchRecord {
|
|
@@ -4654,7 +4671,7 @@ async function getSortedSessions(
|
|
|
4654
4671
|
)
|
|
4655
4672
|
return undefined;
|
|
4656
4673
|
if (typeof header.version === "number" && header.version >= 4) {
|
|
4657
|
-
for (const patch of await readSessionListTrailingPatches(candidate.path, storage)) {
|
|
4674
|
+
for (const patch of await readSessionListTrailingPatches(candidate.path, storage, false)) {
|
|
4658
4675
|
applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
|
|
4659
4676
|
}
|
|
4660
4677
|
}
|
|
@@ -6413,6 +6430,10 @@ async function collectProjectSessions(cwd: string, storage: FileSessionStorage):
|
|
|
6413
6430
|
return await collectSessionsFromFiles(listProjectSessionTranscriptFiles(cwd), storage);
|
|
6414
6431
|
}
|
|
6415
6432
|
|
|
6433
|
+
export function prioritizeStarredSessions(sessions: readonly SessionInfo[]): SessionInfo[] {
|
|
6434
|
+
return [...sessions.filter(session => session.starred), ...sessions.filter(session => !session.starred)];
|
|
6435
|
+
}
|
|
6436
|
+
|
|
6416
6437
|
function mergeSessionInventories(...inventories: SessionInfo[][]): SessionInfo[] {
|
|
6417
6438
|
const sessions = new Map<string, SessionInfo>();
|
|
6418
6439
|
for (const inventory of inventories) {
|
|
@@ -6574,36 +6595,41 @@ async function readSessionListPrefix(file: string, storage: SessionStorage, buff
|
|
|
6574
6595
|
async function readSessionListTrailingPatches(
|
|
6575
6596
|
file: string,
|
|
6576
6597
|
storage: SessionStorage,
|
|
6598
|
+
includeStarred: boolean,
|
|
6577
6599
|
): Promise<HeaderPatchRecord["patch"][]> {
|
|
6578
6600
|
if (!(storage instanceof FileSessionStorage)) {
|
|
6579
6601
|
const content = await storage.readText(file);
|
|
6580
6602
|
const entries = parseSessionEntries(content);
|
|
6581
6603
|
const header = entries[0] as SessionHeader | undefined;
|
|
6582
|
-
return header?.type === "session"
|
|
6604
|
+
return header?.type === "session"
|
|
6605
|
+
? [{ cwd: header.cwd, title: header.title, ...(includeStarred ? { starred: header.starred } : {}) }]
|
|
6606
|
+
: [];
|
|
6583
6607
|
}
|
|
6584
6608
|
|
|
6585
6609
|
const size = storage.statSync(file).size;
|
|
6586
6610
|
if (size <= SESSION_LIST_PREFIX_BYTES) return [];
|
|
6587
6611
|
const latest: HeaderPatchRecord["patch"] = {};
|
|
6588
6612
|
let position = size;
|
|
6589
|
-
// Reverse-scan from EOF in fixed chunks. Stop once
|
|
6590
|
-
//
|
|
6613
|
+
// Reverse-scan from EOF in fixed chunks. Stop once every requested mutable list field
|
|
6614
|
+
// resolves so recent patches stay cheap. Continue past the historical 16 KiB
|
|
6591
6615
|
// window when a field is still missing so a buried but canonically valid
|
|
6592
6616
|
// header_patch remains listable without a full sequential JSONL parse (#3633).
|
|
6593
6617
|
// Chunks that cannot contain a header_patch marker skip JSON parsing.
|
|
6594
6618
|
//
|
|
6595
|
-
// The scan owns its buffer instead of borrowing the caller's 4 KiB prefix
|
|
6596
|
-
//
|
|
6597
|
-
//
|
|
6598
|
-
//
|
|
6599
|
-
// per transcript byte.
|
|
6619
|
+
// The scan owns its buffer instead of borrowing the caller's 4 KiB prefix.
|
|
6620
|
+
// A transcript with no header_patch at all — the common case — cannot be
|
|
6621
|
+
// recognized as such without reaching BOF, so the chunk size sets how many
|
|
6622
|
+
// read syscalls a resume costs per transcript byte.
|
|
6600
6623
|
let trailingFragment = Buffer.alloc(0);
|
|
6601
6624
|
const chunkSize = Math.min(size, SESSION_LIST_TRAILING_PATCH_BYTES);
|
|
6602
6625
|
const buffer = Buffer.allocUnsafe(chunkSize);
|
|
6603
6626
|
const headerPatchMarker = Buffer.from("header_patch");
|
|
6604
6627
|
const handle = await fs.promises.open(file, "r");
|
|
6605
6628
|
try {
|
|
6606
|
-
while (
|
|
6629
|
+
while (
|
|
6630
|
+
position > 0 &&
|
|
6631
|
+
(latest.cwd === undefined || latest.title === undefined || (includeStarred && latest.starred === undefined))
|
|
6632
|
+
) {
|
|
6607
6633
|
const start = Math.max(0, position - chunkSize);
|
|
6608
6634
|
const length = position - start;
|
|
6609
6635
|
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
@@ -6636,6 +6662,8 @@ async function readSessionListTrailingPatches(
|
|
|
6636
6662
|
if (latest.cwd === undefined && typeof record.patch.cwd === "string") latest.cwd = record.patch.cwd;
|
|
6637
6663
|
if (latest.title === undefined && typeof record.patch.title === "string")
|
|
6638
6664
|
latest.title = record.patch.title;
|
|
6665
|
+
if (includeStarred && latest.starred === undefined && typeof record.patch.starred === "boolean")
|
|
6666
|
+
latest.starred = record.patch.starred;
|
|
6639
6667
|
} catch {
|
|
6640
6668
|
// Ignore malformed or partial records exactly as the canonical loader does.
|
|
6641
6669
|
}
|
|
@@ -6643,7 +6671,7 @@ async function readSessionListTrailingPatches(
|
|
|
6643
6671
|
}
|
|
6644
6672
|
position = start;
|
|
6645
6673
|
}
|
|
6646
|
-
return latest.cwd === undefined && latest.title === undefined ? [] : [latest];
|
|
6674
|
+
return latest.cwd === undefined && latest.title === undefined && latest.starred === undefined ? [] : [latest];
|
|
6647
6675
|
} finally {
|
|
6648
6676
|
await handle.close();
|
|
6649
6677
|
}
|
|
@@ -6652,6 +6680,7 @@ async function readSessionListTrailingPatches(
|
|
|
6652
6680
|
function applySessionListHeaderPatch(header: SessionListHeader, patch: HeaderPatchRecord["patch"]): void {
|
|
6653
6681
|
if (typeof patch.cwd === "string") header.cwd = patch.cwd;
|
|
6654
6682
|
if (typeof patch.title === "string") header.title = patch.title;
|
|
6683
|
+
if (typeof patch.starred === "boolean") header.starred = patch.starred;
|
|
6655
6684
|
}
|
|
6656
6685
|
function decodeJsonStringFragment(value: string): string {
|
|
6657
6686
|
const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value;
|
|
@@ -6702,6 +6731,79 @@ function extractStringProperty(source: string, name: string, startIndex = 0): st
|
|
|
6702
6731
|
return decodeJsonStringFragment(source.slice(valueStart));
|
|
6703
6732
|
}
|
|
6704
6733
|
|
|
6734
|
+
function extractTopLevelPrimitiveProperty(source: string, name: string): string | undefined {
|
|
6735
|
+
let depth = 0;
|
|
6736
|
+
let latest: string | undefined;
|
|
6737
|
+
for (let index = 0; index < source.length; index++) {
|
|
6738
|
+
const char = source.charCodeAt(index);
|
|
6739
|
+
if (char === 0x7b || char === 0x5b) {
|
|
6740
|
+
depth++;
|
|
6741
|
+
continue;
|
|
6742
|
+
}
|
|
6743
|
+
if (char === 0x7d || char === 0x5d) {
|
|
6744
|
+
depth--;
|
|
6745
|
+
continue;
|
|
6746
|
+
}
|
|
6747
|
+
if (char !== 0x22) continue;
|
|
6748
|
+
|
|
6749
|
+
const keyStart = index;
|
|
6750
|
+
let escaped = false;
|
|
6751
|
+
for (index++; index < source.length; index++) {
|
|
6752
|
+
const stringChar = source.charCodeAt(index);
|
|
6753
|
+
if (escaped) {
|
|
6754
|
+
escaped = false;
|
|
6755
|
+
continue;
|
|
6756
|
+
}
|
|
6757
|
+
if (stringChar === 0x5c) {
|
|
6758
|
+
escaped = true;
|
|
6759
|
+
continue;
|
|
6760
|
+
}
|
|
6761
|
+
if (stringChar === 0x22) break;
|
|
6762
|
+
}
|
|
6763
|
+
if (index >= source.length || depth !== 1) continue;
|
|
6764
|
+
|
|
6765
|
+
let key: unknown;
|
|
6766
|
+
try {
|
|
6767
|
+
key = JSON.parse(source.slice(keyStart, index + 1));
|
|
6768
|
+
} catch {
|
|
6769
|
+
continue;
|
|
6770
|
+
}
|
|
6771
|
+
if (key !== name) continue;
|
|
6772
|
+
|
|
6773
|
+
let valueStart = index + 1;
|
|
6774
|
+
while (valueStart < source.length && /\s/.test(source[valueStart]!)) valueStart++;
|
|
6775
|
+
if (source.charCodeAt(valueStart) !== 0x3a) continue;
|
|
6776
|
+
valueStart++;
|
|
6777
|
+
while (valueStart < source.length && /\s/.test(source[valueStart]!)) valueStart++;
|
|
6778
|
+
|
|
6779
|
+
let valueEnd = valueStart;
|
|
6780
|
+
while (
|
|
6781
|
+
valueEnd < source.length &&
|
|
6782
|
+
source[valueEnd] !== "," &&
|
|
6783
|
+
source[valueEnd] !== "}" &&
|
|
6784
|
+
!/\s/.test(source[valueEnd]!)
|
|
6785
|
+
)
|
|
6786
|
+
valueEnd++;
|
|
6787
|
+
latest = source.slice(valueStart, valueEnd);
|
|
6788
|
+
index = valueEnd - 1;
|
|
6789
|
+
}
|
|
6790
|
+
return latest;
|
|
6791
|
+
}
|
|
6792
|
+
|
|
6793
|
+
function extractTopLevelIntegerProperty(source: string, name: string): number | undefined {
|
|
6794
|
+
const token = extractTopLevelPrimitiveProperty(source, name);
|
|
6795
|
+
if (token === undefined || !/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(token)) return undefined;
|
|
6796
|
+
const value = Number(token);
|
|
6797
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
6798
|
+
}
|
|
6799
|
+
|
|
6800
|
+
function extractTopLevelBooleanProperty(source: string, name: string): boolean | undefined {
|
|
6801
|
+
const token = extractTopLevelPrimitiveProperty(source, name);
|
|
6802
|
+
if (token === "true") return true;
|
|
6803
|
+
if (token === "false") return false;
|
|
6804
|
+
return undefined;
|
|
6805
|
+
}
|
|
6806
|
+
|
|
6705
6807
|
function countMessageMarkers(content: string): number {
|
|
6706
6808
|
let count = 0;
|
|
6707
6809
|
let index = 0;
|
|
@@ -6740,6 +6842,8 @@ interface SessionListHeader {
|
|
|
6740
6842
|
|
|
6741
6843
|
cwd?: string;
|
|
6742
6844
|
title?: string;
|
|
6845
|
+
starredPatchVersion?: 1;
|
|
6846
|
+
starred?: boolean;
|
|
6743
6847
|
parentSession?: string;
|
|
6744
6848
|
timestamp?: string;
|
|
6745
6849
|
}
|
|
@@ -6748,6 +6852,10 @@ function parseSessionListHeader(
|
|
|
6748
6852
|
content: string,
|
|
6749
6853
|
entries: Array<Record<string, unknown>>,
|
|
6750
6854
|
): SessionListHeader | undefined {
|
|
6855
|
+
const firstLineEnd = content.indexOf("\n");
|
|
6856
|
+
const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd);
|
|
6857
|
+
const inlineStarred = extractTopLevelBooleanProperty(firstLine, "starred");
|
|
6858
|
+
const starredPatchVersion = extractTopLevelIntegerProperty(firstLine, "starredPatchVersion") === 1 ? 1 : undefined;
|
|
6751
6859
|
const parsedHeader = entries[0];
|
|
6752
6860
|
if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") {
|
|
6753
6861
|
return {
|
|
@@ -6756,13 +6864,13 @@ function parseSessionListHeader(
|
|
|
6756
6864
|
version: typeof parsedHeader.version === "number" ? parsedHeader.version : undefined,
|
|
6757
6865
|
cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
|
|
6758
6866
|
title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
|
|
6867
|
+
starredPatchVersion: parsedHeader.starredPatchVersion === 1 ? 1 : starredPatchVersion,
|
|
6868
|
+
starred: typeof parsedHeader.starred === "boolean" ? parsedHeader.starred : inlineStarred,
|
|
6759
6869
|
parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
|
|
6760
6870
|
timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined,
|
|
6761
6871
|
};
|
|
6762
6872
|
}
|
|
6763
6873
|
|
|
6764
|
-
const firstLineEnd = content.indexOf("\n");
|
|
6765
|
-
const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd);
|
|
6766
6874
|
if (extractStringProperty(firstLine, "type") !== "session") return undefined;
|
|
6767
6875
|
|
|
6768
6876
|
const id = extractStringProperty(firstLine, "id");
|
|
@@ -6771,9 +6879,11 @@ function parseSessionListHeader(
|
|
|
6771
6879
|
return {
|
|
6772
6880
|
type: "session",
|
|
6773
6881
|
id,
|
|
6774
|
-
version:
|
|
6882
|
+
version: extractTopLevelIntegerProperty(firstLine, "version"),
|
|
6775
6883
|
cwd: extractStringProperty(firstLine, "cwd"),
|
|
6776
6884
|
title: extractStringProperty(firstLine, "title"),
|
|
6885
|
+
starred: inlineStarred,
|
|
6886
|
+
starredPatchVersion,
|
|
6777
6887
|
parentSession: extractStringProperty(firstLine, "parentSession"),
|
|
6778
6888
|
timestamp: extractStringProperty(firstLine, "timestamp"),
|
|
6779
6889
|
};
|
|
@@ -6798,8 +6908,9 @@ async function collectSessionFromFile(
|
|
|
6798
6908
|
const entries = parseSessionEntries(content).map(entry => entry as unknown as Record<string, unknown>);
|
|
6799
6909
|
const header = parseSessionListHeader(content, entries);
|
|
6800
6910
|
if (!header) return undefined;
|
|
6911
|
+
if (header.starredPatchVersion !== 1) header.starred = undefined;
|
|
6801
6912
|
if (typeof header.version === "number" && header.version >= 4 && header.version <= CURRENT_SESSION_VERSION) {
|
|
6802
|
-
for (const patch of await readSessionListTrailingPatches(file, storage)) {
|
|
6913
|
+
for (const patch of await readSessionListTrailingPatches(file, storage, header.starredPatchVersion === 1)) {
|
|
6803
6914
|
applySessionListHeaderPatch(header, patch);
|
|
6804
6915
|
}
|
|
6805
6916
|
}
|
|
@@ -6841,6 +6952,7 @@ async function collectSessionFromFile(
|
|
|
6841
6952
|
id: header.id,
|
|
6842
6953
|
cwd: header.cwd ?? "",
|
|
6843
6954
|
title: header.title ?? shortSummary,
|
|
6955
|
+
starred: header.starred === true,
|
|
6844
6956
|
parentSessionPath: header.parentSession,
|
|
6845
6957
|
created: new Date(header.timestamp ?? ""),
|
|
6846
6958
|
modified: stats.mtime,
|
|
@@ -7268,6 +7380,7 @@ export class SessionManager {
|
|
|
7268
7380
|
#flushed: boolean = false;
|
|
7269
7381
|
#needsFullRewriteOnNextPersist: boolean = false;
|
|
7270
7382
|
#readOnlyResume = false;
|
|
7383
|
+
#strictResumeMutationPending = false;
|
|
7271
7384
|
#resumedDraftConsumed = false;
|
|
7272
7385
|
#ensuredOnDisk: boolean = false;
|
|
7273
7386
|
#recoveryHydrationContext: RecoveryHydrationContext | undefined;
|
|
@@ -7296,11 +7409,58 @@ export class SessionManager {
|
|
|
7296
7409
|
/** Defense-in-depth (#4443): one-shot warn for adjacent private thinking blocks in persisted assistant transcripts. */
|
|
7297
7410
|
#warnedAdjacentThinkingPersist = false;
|
|
7298
7411
|
#closeRetryPending = false;
|
|
7412
|
+
#strictClosePending = false;
|
|
7299
7413
|
/** Serializes model, SDK, and ACP cwd transitions; dispose joins this tail. */
|
|
7300
7414
|
#cwdTransitionTail: Promise<void> = Promise.resolve();
|
|
7301
7415
|
#cwdTransitionOwner: symbol | undefined;
|
|
7416
|
+
#cwdMoveAdmissionClosing = false;
|
|
7417
|
+
#cwdMoveAdmissionClosed = false;
|
|
7418
|
+
#pendingCwdMoveAdmissions = new Set<AbortController>();
|
|
7419
|
+
#cwdMoveAdmittedAdmissions = new Set<AbortController>();
|
|
7420
|
+
#cwdMoveAdmittedOwners = new Set<symbol>();
|
|
7421
|
+
#cwdMoveAdmittedOwnerCounts = new Map<symbol, number>();
|
|
7302
7422
|
#cwdReadLeaseOwner = Symbol("cwd-read-lease-owner");
|
|
7303
7423
|
#cwdGeneration = 0;
|
|
7424
|
+
/**
|
|
7425
|
+
* Listeners invoked after every committed `moveTo`, for every move surface
|
|
7426
|
+
* (`move_session`, TUI `/move`, SDK/ACP `session.cwd.move`). They receive the previous
|
|
7427
|
+
* and new cwd and the previous session file, and run best-effort: a listener throw is
|
|
7428
|
+
* logged and never turns an already-durable move into a rejection.
|
|
7429
|
+
*/
|
|
7430
|
+
#afterMoveListeners = new Set<
|
|
7431
|
+
(move: { previousCwd: string; newCwd: string; previousSessionFile: string | undefined }) => void | Promise<void>
|
|
7432
|
+
>();
|
|
7433
|
+
#beforeMoveListeners = new Set<
|
|
7434
|
+
(move: {
|
|
7435
|
+
previousCwd: string;
|
|
7436
|
+
newCwd: string;
|
|
7437
|
+
previousSessionFile: string | undefined;
|
|
7438
|
+
newSessionFile: string | undefined;
|
|
7439
|
+
previousCwdIdentity: { dev: bigint; ino: bigint };
|
|
7440
|
+
newCwdIdentity: { dev: bigint; ino: bigint };
|
|
7441
|
+
}) => void | Promise<void>
|
|
7442
|
+
>();
|
|
7443
|
+
#moveAbortListeners = new Set<
|
|
7444
|
+
(move: {
|
|
7445
|
+
previousCwd: string;
|
|
7446
|
+
newCwd: string;
|
|
7447
|
+
previousSessionFile: string | undefined;
|
|
7448
|
+
newSessionFile: string | undefined;
|
|
7449
|
+
previousCwdIdentity: { dev: bigint; ino: bigint };
|
|
7450
|
+
newCwdIdentity: { dev: bigint; ino: bigint };
|
|
7451
|
+
preserveRecoveryJournal?: boolean;
|
|
7452
|
+
}) => void | Promise<void>
|
|
7453
|
+
>();
|
|
7454
|
+
#movePublicationListeners = new Set<
|
|
7455
|
+
(move: {
|
|
7456
|
+
previousCwd: string;
|
|
7457
|
+
newCwd: string;
|
|
7458
|
+
previousSessionFile: string | undefined;
|
|
7459
|
+
newSessionFile: string | undefined;
|
|
7460
|
+
previousCwdIdentity: { dev: bigint; ino: bigint };
|
|
7461
|
+
newCwdIdentity: { dev: bigint; ino: bigint };
|
|
7462
|
+
}) => void | Promise<void>
|
|
7463
|
+
>();
|
|
7304
7464
|
/** Number of tool executions currently holding a shared read lease on `cwd`. */
|
|
7305
7465
|
#cwdReaderCount = 0;
|
|
7306
7466
|
/** Resolved when the last outstanding read lease is released. */
|
|
@@ -8124,6 +8284,7 @@ export class SessionManager {
|
|
|
8124
8284
|
header: {
|
|
8125
8285
|
type: "session",
|
|
8126
8286
|
version: CURRENT_SESSION_VERSION,
|
|
8287
|
+
starredPatchVersion: 1,
|
|
8127
8288
|
id: sessionId,
|
|
8128
8289
|
timestamp,
|
|
8129
8290
|
cwd: this.cwd,
|
|
@@ -9680,6 +9841,7 @@ export class SessionManager {
|
|
|
9680
9841
|
const header: SessionHeader = {
|
|
9681
9842
|
type: "session",
|
|
9682
9843
|
version: CURRENT_SESSION_VERSION,
|
|
9844
|
+
starredPatchVersion: 1,
|
|
9683
9845
|
id: sessionId,
|
|
9684
9846
|
timestamp,
|
|
9685
9847
|
cwd: this.cwd,
|
|
@@ -9888,6 +10050,7 @@ export class SessionManager {
|
|
|
9888
10050
|
const header: SessionHeader = {
|
|
9889
10051
|
type: "session",
|
|
9890
10052
|
version: CURRENT_SESSION_VERSION,
|
|
10053
|
+
starredPatchVersion: 1,
|
|
9891
10054
|
id: sessionId,
|
|
9892
10055
|
title: this.#sessionName,
|
|
9893
10056
|
titleSource: this.#titleSource,
|
|
@@ -9956,6 +10119,7 @@ export class SessionManager {
|
|
|
9956
10119
|
const header: SessionHeader = {
|
|
9957
10120
|
type: "session",
|
|
9958
10121
|
version: CURRENT_SESSION_VERSION,
|
|
10122
|
+
starredPatchVersion: 1,
|
|
9959
10123
|
id: sessionId,
|
|
9960
10124
|
timestamp,
|
|
9961
10125
|
cwd: this.cwd,
|
|
@@ -10278,6 +10442,7 @@ export class SessionManager {
|
|
|
10278
10442
|
const newHeader: SessionHeader = {
|
|
10279
10443
|
type: "session",
|
|
10280
10444
|
version: CURRENT_SESSION_VERSION,
|
|
10445
|
+
starredPatchVersion: 1,
|
|
10281
10446
|
id: newSessionId,
|
|
10282
10447
|
title: oldHeader?.title ?? this.#sessionName,
|
|
10283
10448
|
titleSource: oldHeader?.titleSource ?? this.#titleSource,
|
|
@@ -10650,11 +10815,32 @@ export class SessionManager {
|
|
|
10650
10815
|
* only for the async context that already owns the lock — unrelated callers
|
|
10651
10816
|
* queue on the tail instead of skipping it.
|
|
10652
10817
|
*/
|
|
10653
|
-
async runExclusiveCwdTransition<T>(fn: () => Promise<T
|
|
10818
|
+
async runExclusiveCwdTransition<T>(fn: () => Promise<T>, options?: { signal?: AbortSignal }): Promise<T> {
|
|
10654
10819
|
const owner = this.#cwdTransitionOwner;
|
|
10655
10820
|
if (owner !== undefined && cwdTransitionAls.getStore() === owner) {
|
|
10656
10821
|
return fn();
|
|
10657
10822
|
}
|
|
10823
|
+
const signal = options?.signal;
|
|
10824
|
+
const throwIfAborted = (): void => {
|
|
10825
|
+
if (!signal?.aborted) return;
|
|
10826
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("Session cwd move admission is closed.");
|
|
10827
|
+
};
|
|
10828
|
+
const waitAbortably = async (promise: Promise<void>): Promise<void> => {
|
|
10829
|
+
throwIfAborted();
|
|
10830
|
+
if (!signal) {
|
|
10831
|
+
await promise;
|
|
10832
|
+
return;
|
|
10833
|
+
}
|
|
10834
|
+
const aborted = Promise.withResolvers<void>();
|
|
10835
|
+
const onAbort = () => aborted.resolve();
|
|
10836
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
10837
|
+
try {
|
|
10838
|
+
await Promise.race([promise, aborted.promise]);
|
|
10839
|
+
} finally {
|
|
10840
|
+
signal.removeEventListener("abort", onAbort);
|
|
10841
|
+
}
|
|
10842
|
+
throwIfAborted();
|
|
10843
|
+
};
|
|
10658
10844
|
const previous = this.#cwdTransitionTail;
|
|
10659
10845
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
10660
10846
|
this.#cwdTransitionTail = previous.then(
|
|
@@ -10666,11 +10852,11 @@ export class SessionManager {
|
|
|
10666
10852
|
this.#cwdWriterPending += 1;
|
|
10667
10853
|
const token = Symbol("cwd-transition");
|
|
10668
10854
|
try {
|
|
10669
|
-
await previous.catch(() => {});
|
|
10855
|
+
await waitAbortably(previous.catch(() => {}));
|
|
10670
10856
|
// A reader that entered before this writer was announced still holds the
|
|
10671
10857
|
// old cwd; the transition may not commit until every such lease is
|
|
10672
10858
|
// released, otherwise an in-flight tool resolves paths across the move.
|
|
10673
|
-
while (this.#cwdReaderCount > 0) await this.#cwdReadersIdle;
|
|
10859
|
+
while (this.#cwdReaderCount > 0) await waitAbortably(this.#cwdReadersIdle);
|
|
10674
10860
|
this.#cwdTransitionOwner = token;
|
|
10675
10861
|
return await cwdTransitionAls.run(token, fn);
|
|
10676
10862
|
} finally {
|
|
@@ -10680,6 +10866,90 @@ export class SessionManager {
|
|
|
10680
10866
|
}
|
|
10681
10867
|
}
|
|
10682
10868
|
|
|
10869
|
+
async runExclusiveCwdMoveTransition<T>(fn: () => Promise<T>): Promise<T> {
|
|
10870
|
+
if (this.#cwdMoveAdmissionClosing || this.#cwdMoveAdmissionClosed)
|
|
10871
|
+
throw new Error("Session cwd move admission is closed.");
|
|
10872
|
+
const transitionOwner = this.#cwdTransitionOwner;
|
|
10873
|
+
if (
|
|
10874
|
+
this.#strictClosePending &&
|
|
10875
|
+
(transitionOwner === undefined || cwdTransitionAls.getStore() !== transitionOwner)
|
|
10876
|
+
)
|
|
10877
|
+
throw new Error("Session manager is closing.");
|
|
10878
|
+
const readLease = cwdReadLeaseAls.getStore();
|
|
10879
|
+
const rootLease =
|
|
10880
|
+
readLease?.owner === this.#cwdReadLeaseOwner && !readLease.released
|
|
10881
|
+
? (readLease.root ?? readLease)
|
|
10882
|
+
: undefined;
|
|
10883
|
+
const suspendedOwnReadLease = rootLease !== undefined;
|
|
10884
|
+
if (rootLease) {
|
|
10885
|
+
// A move may suspend the shared root lease only when this execution is
|
|
10886
|
+
// the sole active descendant. If a sibling tool is still fenced by the
|
|
10887
|
+
// lease, returning a retry error is safer than restoring the lease while
|
|
10888
|
+
// that sibling continues against the old cwd.
|
|
10889
|
+
const activeBranchLimit = readLease === rootLease ? 1 : 2;
|
|
10890
|
+
if (
|
|
10891
|
+
rootLease.activeBranches > activeBranchLimit ||
|
|
10892
|
+
(rootLease.suspendedMoveOwner !== undefined && rootLease.suspendedMoveOwner !== readLease?.executionId)
|
|
10893
|
+
)
|
|
10894
|
+
throw new Error("Session working directory changed while this tool executed; retry against the new cwd.");
|
|
10895
|
+
rootLease.suspendedMoveCount += 1;
|
|
10896
|
+
rootLease.suspendedMoveOwner ??= readLease?.executionId;
|
|
10897
|
+
if (rootLease.active) {
|
|
10898
|
+
rootLease.active = false;
|
|
10899
|
+
this.#cwdReaderCount -= 1;
|
|
10900
|
+
if (this.#cwdReaderCount === 0) {
|
|
10901
|
+
const drained = this.#cwdReadersDrained;
|
|
10902
|
+
this.#cwdReadersDrained = undefined;
|
|
10903
|
+
drained?.();
|
|
10904
|
+
}
|
|
10905
|
+
}
|
|
10906
|
+
}
|
|
10907
|
+
const admittedBeforeClose = this.#cwdWriterPending === 0 && this.#cwdReaderCount === 0;
|
|
10908
|
+
const admission = new AbortController();
|
|
10909
|
+
this.#pendingCwdMoveAdmissions.add(admission);
|
|
10910
|
+
if (admittedBeforeClose) this.#cwdMoveAdmittedAdmissions.add(admission);
|
|
10911
|
+
try {
|
|
10912
|
+
return await this.runExclusiveCwdTransition(
|
|
10913
|
+
async () => {
|
|
10914
|
+
const owner = this.#cwdTransitionOwner;
|
|
10915
|
+
if (owner === undefined) throw new Error("Session cwd move transition owner is unavailable.");
|
|
10916
|
+
this.#cwdMoveAdmittedAdmissions.add(admission);
|
|
10917
|
+
this.#cwdMoveAdmittedOwners.add(owner);
|
|
10918
|
+
this.#cwdMoveAdmittedOwnerCounts.set(owner, (this.#cwdMoveAdmittedOwnerCounts.get(owner) ?? 0) + 1);
|
|
10919
|
+
try {
|
|
10920
|
+
return await fn();
|
|
10921
|
+
} finally {
|
|
10922
|
+
this.#cwdMoveAdmittedAdmissions.delete(admission);
|
|
10923
|
+
const count = this.#cwdMoveAdmittedOwnerCounts.get(owner) ?? 0;
|
|
10924
|
+
if (count <= 1) {
|
|
10925
|
+
this.#cwdMoveAdmittedOwnerCounts.delete(owner);
|
|
10926
|
+
this.#cwdMoveAdmittedOwners.delete(owner);
|
|
10927
|
+
} else {
|
|
10928
|
+
this.#cwdMoveAdmittedOwnerCounts.set(owner, count - 1);
|
|
10929
|
+
}
|
|
10930
|
+
}
|
|
10931
|
+
},
|
|
10932
|
+
{ signal: admission.signal },
|
|
10933
|
+
);
|
|
10934
|
+
} finally {
|
|
10935
|
+
this.#pendingCwdMoveAdmissions.delete(admission);
|
|
10936
|
+
this.#cwdMoveAdmittedAdmissions.delete(admission);
|
|
10937
|
+
if (suspendedOwnReadLease && rootLease) {
|
|
10938
|
+
rootLease.suspendedMoveCount -= 1;
|
|
10939
|
+
if (rootLease.suspendedMoveCount === 0 && !rootLease.released) {
|
|
10940
|
+
if (this.#cwdReaderCount === 0) {
|
|
10941
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
10942
|
+
this.#cwdReadersIdle = promise;
|
|
10943
|
+
this.#cwdReadersDrained = resolve;
|
|
10944
|
+
}
|
|
10945
|
+
this.#cwdReaderCount += 1;
|
|
10946
|
+
rootLease.active = true;
|
|
10947
|
+
rootLease.suspendedMoveOwner = undefined;
|
|
10948
|
+
}
|
|
10949
|
+
}
|
|
10950
|
+
}
|
|
10951
|
+
}
|
|
10952
|
+
|
|
10683
10953
|
/**
|
|
10684
10954
|
* Run `fn` under a shared read lease on `cwd`.
|
|
10685
10955
|
*
|
|
@@ -10693,14 +10963,39 @@ export class SessionManager {
|
|
|
10693
10963
|
async runWithCwdReadLease<T>(fn: () => Promise<T>): Promise<T> {
|
|
10694
10964
|
const activeReadLease = cwdReadLeaseAls.getStore();
|
|
10695
10965
|
// Nested tool dispatch (for example eval -> tool bridge) inherits the
|
|
10696
|
-
// outer lease's
|
|
10697
|
-
//
|
|
10698
|
-
|
|
10699
|
-
|
|
10966
|
+
// outer lease's cwd authority, but receives an execution-local context so
|
|
10967
|
+
// sibling descendants cannot attribute one another's nested move.
|
|
10968
|
+
if (activeReadLease?.owner === this.#cwdReadLeaseOwner && !activeReadLease.released) {
|
|
10969
|
+
const rootLease = activeReadLease.root ?? activeReadLease;
|
|
10970
|
+
if (rootLease.suspendedMoveOwner !== undefined)
|
|
10971
|
+
throw new Error("Session working directory changed while this tool executed; retry against the new cwd.");
|
|
10972
|
+
rootLease.activeBranches += 1;
|
|
10973
|
+
const nestedReadLease: CwdReadLeaseContext = {
|
|
10974
|
+
active: true,
|
|
10975
|
+
released: false,
|
|
10976
|
+
owner: this.#cwdReadLeaseOwner,
|
|
10977
|
+
parent: activeReadLease,
|
|
10978
|
+
root: rootLease,
|
|
10979
|
+
executionId: Symbol("cwd-read-execution"),
|
|
10980
|
+
activeBranches: 0,
|
|
10981
|
+
suspendedMoveCount: 0,
|
|
10982
|
+
};
|
|
10983
|
+
return cwdReadLeaseAls.run(nestedReadLease, async () => {
|
|
10984
|
+
try {
|
|
10985
|
+
return await fn();
|
|
10986
|
+
} finally {
|
|
10987
|
+
if (nestedReadLease.committedMoveGeneration !== undefined)
|
|
10988
|
+
activeReadLease.committedMoveGeneration = nestedReadLease.committedMoveGeneration;
|
|
10989
|
+
nestedReadLease.released = true;
|
|
10990
|
+
rootLease.activeBranches -= 1;
|
|
10991
|
+
}
|
|
10992
|
+
});
|
|
10993
|
+
}
|
|
10700
10994
|
const owner = this.#cwdTransitionOwner;
|
|
10701
10995
|
// The writer's own async context already holds exclusive access; taking a
|
|
10702
10996
|
// read lease there would wait on itself.
|
|
10703
10997
|
if (owner !== undefined && cwdTransitionAls.getStore() === owner) return fn();
|
|
10998
|
+
if (this.#strictClosePending || this.#cwdMoveAdmissionClosed) throw new Error("Session manager is closing.");
|
|
10704
10999
|
while (this.#cwdWriterPending > 0) await this.#cwdTransitionTail.catch(() => {});
|
|
10705
11000
|
if (this.#cwdReaderCount === 0) {
|
|
10706
11001
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
@@ -10708,18 +11003,29 @@ export class SessionManager {
|
|
|
10708
11003
|
this.#cwdReadersDrained = resolve;
|
|
10709
11004
|
}
|
|
10710
11005
|
this.#cwdReaderCount += 1;
|
|
10711
|
-
const readLease: CwdReadLeaseContext = {
|
|
11006
|
+
const readLease: CwdReadLeaseContext = {
|
|
11007
|
+
active: true,
|
|
11008
|
+
released: false,
|
|
11009
|
+
owner: this.#cwdReadLeaseOwner,
|
|
11010
|
+
suspendedMoveCount: 0,
|
|
11011
|
+
executionId: Symbol("cwd-read-execution"),
|
|
11012
|
+
activeBranches: 1,
|
|
11013
|
+
};
|
|
11014
|
+
readLease.root = readLease;
|
|
10712
11015
|
return cwdReadLeaseAls.run(readLease, async () => {
|
|
10713
11016
|
try {
|
|
10714
11017
|
return await fn();
|
|
10715
11018
|
} finally {
|
|
10716
|
-
readLease.active
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
|
|
10720
|
-
|
|
10721
|
-
|
|
11019
|
+
if (readLease.active) {
|
|
11020
|
+
readLease.active = false;
|
|
11021
|
+
this.#cwdReaderCount -= 1;
|
|
11022
|
+
if (this.#cwdReaderCount === 0) {
|
|
11023
|
+
const drained = this.#cwdReadersDrained;
|
|
11024
|
+
this.#cwdReadersDrained = undefined;
|
|
11025
|
+
drained?.();
|
|
11026
|
+
}
|
|
10722
11027
|
}
|
|
11028
|
+
readLease.released = true;
|
|
10723
11029
|
}
|
|
10724
11030
|
});
|
|
10725
11031
|
}
|
|
@@ -10728,10 +11034,160 @@ export class SessionManager {
|
|
|
10728
11034
|
async joinCwdTransition(): Promise<void> {
|
|
10729
11035
|
await this.#cwdTransitionTail;
|
|
10730
11036
|
}
|
|
11037
|
+
|
|
11038
|
+
async joinCwdReaders(): Promise<void> {
|
|
11039
|
+
while (this.#cwdReaderCount > 0) await this.#cwdReadersIdle;
|
|
11040
|
+
}
|
|
11041
|
+
|
|
11042
|
+
async closeCwdMoveAdmission(): Promise<void> {
|
|
11043
|
+
if (this.#cwdMoveAdmissionClosed) return;
|
|
11044
|
+
if (this.#cwdMoveAdmissionClosing) return;
|
|
11045
|
+
this.#cwdMoveAdmissionClosing = true;
|
|
11046
|
+
this.#cwdMoveAdmissionClosed = true;
|
|
11047
|
+
// Preserve moves that already acquired exclusive ownership, but cancel every
|
|
11048
|
+
// queued admission. A queued writer can otherwise pass the initial admission
|
|
11049
|
+
// check after an earlier writer finishes and mutate the closed session.
|
|
11050
|
+
for (const admission of this.#pendingCwdMoveAdmissions) {
|
|
11051
|
+
if (!this.#cwdMoveAdmittedAdmissions.has(admission)) {
|
|
11052
|
+
admission.abort(new Error("Session cwd move admission is closed."));
|
|
11053
|
+
}
|
|
11054
|
+
}
|
|
11055
|
+
}
|
|
10731
11056
|
getCwdGeneration(): number {
|
|
10732
11057
|
return this.#cwdGeneration;
|
|
10733
11058
|
}
|
|
10734
11059
|
|
|
11060
|
+
/** Consume a move committed by a nested move_session call in this read lease. */
|
|
11061
|
+
consumeNestedCwdMove(admittedGeneration: number): boolean {
|
|
11062
|
+
const readLease = cwdReadLeaseAls.getStore();
|
|
11063
|
+
const committed = readLease?.committedMoveGeneration;
|
|
11064
|
+
if (readLease) readLease.committedMoveGeneration = undefined;
|
|
11065
|
+
return committed !== undefined && committed !== admittedGeneration && committed === this.#cwdGeneration;
|
|
11066
|
+
}
|
|
11067
|
+
|
|
11068
|
+
/**
|
|
11069
|
+
* Register a listener invoked after every committed `moveTo`. Returns an idempotent
|
|
11070
|
+
* unregister handle. Every move surface funnels through `moveTo`, so a single
|
|
11071
|
+
* registration covers `move_session`, `/move`, and SDK/ACP `session.cwd.move`.
|
|
11072
|
+
*/
|
|
11073
|
+
registerAfterMoveListener(
|
|
11074
|
+
listener: (move: {
|
|
11075
|
+
previousCwd: string;
|
|
11076
|
+
newCwd: string;
|
|
11077
|
+
previousSessionFile: string | undefined;
|
|
11078
|
+
}) => void | Promise<void>,
|
|
11079
|
+
): () => void {
|
|
11080
|
+
this.#afterMoveListeners.add(listener);
|
|
11081
|
+
return () => {
|
|
11082
|
+
this.#afterMoveListeners.delete(listener);
|
|
11083
|
+
};
|
|
11084
|
+
}
|
|
11085
|
+
|
|
11086
|
+
registerBeforeMoveListener(
|
|
11087
|
+
listener: (move: {
|
|
11088
|
+
previousCwd: string;
|
|
11089
|
+
newCwd: string;
|
|
11090
|
+
previousSessionFile: string | undefined;
|
|
11091
|
+
newSessionFile: string | undefined;
|
|
11092
|
+
previousCwdIdentity: { dev: bigint; ino: bigint };
|
|
11093
|
+
newCwdIdentity: { dev: bigint; ino: bigint };
|
|
11094
|
+
preserveRecoveryJournal?: boolean;
|
|
11095
|
+
}) => void | Promise<void>,
|
|
11096
|
+
): () => void {
|
|
11097
|
+
this.#beforeMoveListeners.add(listener);
|
|
11098
|
+
return () => {
|
|
11099
|
+
this.#beforeMoveListeners.delete(listener);
|
|
11100
|
+
};
|
|
11101
|
+
}
|
|
11102
|
+
|
|
11103
|
+
async #runBeforeMoveListeners(move: {
|
|
11104
|
+
previousCwd: string;
|
|
11105
|
+
newCwd: string;
|
|
11106
|
+
previousSessionFile: string | undefined;
|
|
11107
|
+
newSessionFile: string | undefined;
|
|
11108
|
+
previousCwdIdentity: { dev: bigint; ino: bigint };
|
|
11109
|
+
newCwdIdentity: { dev: bigint; ino: bigint };
|
|
11110
|
+
}): Promise<void> {
|
|
11111
|
+
for (const listener of [...this.#beforeMoveListeners]) await listener(move);
|
|
11112
|
+
}
|
|
11113
|
+
|
|
11114
|
+
registerMovePublicationListener(
|
|
11115
|
+
listener: (move: {
|
|
11116
|
+
previousCwd: string;
|
|
11117
|
+
newCwd: string;
|
|
11118
|
+
previousSessionFile: string | undefined;
|
|
11119
|
+
newSessionFile: string | undefined;
|
|
11120
|
+
previousCwdIdentity: { dev: bigint; ino: bigint };
|
|
11121
|
+
newCwdIdentity: { dev: bigint; ino: bigint };
|
|
11122
|
+
preserveRecoveryJournal?: boolean;
|
|
11123
|
+
}) => void | Promise<void>,
|
|
11124
|
+
): () => void {
|
|
11125
|
+
this.#movePublicationListeners.add(listener);
|
|
11126
|
+
return () => {
|
|
11127
|
+
this.#movePublicationListeners.delete(listener);
|
|
11128
|
+
};
|
|
11129
|
+
}
|
|
11130
|
+
|
|
11131
|
+
async #runMovePublicationListeners(move: {
|
|
11132
|
+
previousCwd: string;
|
|
11133
|
+
newCwd: string;
|
|
11134
|
+
previousSessionFile: string | undefined;
|
|
11135
|
+
newSessionFile: string | undefined;
|
|
11136
|
+
previousCwdIdentity: { dev: bigint; ino: bigint };
|
|
11137
|
+
newCwdIdentity: { dev: bigint; ino: bigint };
|
|
11138
|
+
}): Promise<void> {
|
|
11139
|
+
for (const listener of [...this.#movePublicationListeners]) await listener(move);
|
|
11140
|
+
}
|
|
11141
|
+
|
|
11142
|
+
registerMoveAbortListener(
|
|
11143
|
+
listener: (move: {
|
|
11144
|
+
previousCwd: string;
|
|
11145
|
+
newCwd: string;
|
|
11146
|
+
previousSessionFile: string | undefined;
|
|
11147
|
+
newSessionFile: string | undefined;
|
|
11148
|
+
previousCwdIdentity: { dev: bigint; ino: bigint };
|
|
11149
|
+
newCwdIdentity: { dev: bigint; ino: bigint };
|
|
11150
|
+
preserveRecoveryJournal?: boolean;
|
|
11151
|
+
}) => void | Promise<void>,
|
|
11152
|
+
): () => void {
|
|
11153
|
+
this.#moveAbortListeners.add(listener);
|
|
11154
|
+
return () => {
|
|
11155
|
+
this.#moveAbortListeners.delete(listener);
|
|
11156
|
+
};
|
|
11157
|
+
}
|
|
11158
|
+
|
|
11159
|
+
async #runMoveAbortListeners(move: {
|
|
11160
|
+
previousCwd: string;
|
|
11161
|
+
newCwd: string;
|
|
11162
|
+
previousSessionFile: string | undefined;
|
|
11163
|
+
newSessionFile: string | undefined;
|
|
11164
|
+
previousCwdIdentity: { dev: bigint; ino: bigint };
|
|
11165
|
+
newCwdIdentity: { dev: bigint; ino: bigint };
|
|
11166
|
+
preserveRecoveryJournal?: boolean;
|
|
11167
|
+
}): Promise<void> {
|
|
11168
|
+
for (const listener of [...this.#moveAbortListeners]) {
|
|
11169
|
+
try {
|
|
11170
|
+
await listener(move);
|
|
11171
|
+
} catch (error) {
|
|
11172
|
+
logger.warn("move-abort listener failed", { error: String(error), cwd: move.newCwd });
|
|
11173
|
+
}
|
|
11174
|
+
}
|
|
11175
|
+
}
|
|
11176
|
+
|
|
11177
|
+
async #runAfterMoveListeners(move: {
|
|
11178
|
+
previousCwd: string;
|
|
11179
|
+
newCwd: string;
|
|
11180
|
+
previousSessionFile: string | undefined;
|
|
11181
|
+
}): Promise<void> {
|
|
11182
|
+
for (const listener of [...this.#afterMoveListeners]) {
|
|
11183
|
+
try {
|
|
11184
|
+
await listener(move);
|
|
11185
|
+
} catch (error) {
|
|
11186
|
+
logger.warn("after-move listener failed", { error: String(error), cwd: move.newCwd });
|
|
11187
|
+
}
|
|
11188
|
+
}
|
|
11189
|
+
}
|
|
11190
|
+
|
|
10735
11191
|
#ownsCwdTransition(): boolean {
|
|
10736
11192
|
const owner = this.#cwdTransitionOwner;
|
|
10737
11193
|
return owner !== undefined && cwdTransitionAls.getStore() === owner;
|
|
@@ -10802,15 +11258,76 @@ export class SessionManager {
|
|
|
10802
11258
|
options?: {
|
|
10803
11259
|
expectedIdentity?: { dev: bigint; ino: bigint };
|
|
10804
11260
|
targetHandle?: { stat: (opts: { bigint: true }) => Promise<fs.BigIntStats> };
|
|
11261
|
+
expectedSourceIdentity?: { dev: bigint; ino: bigint };
|
|
11262
|
+
sourceHandle?: { stat: (opts: { bigint: true }) => Promise<fs.BigIntStats> };
|
|
10805
11263
|
},
|
|
10806
11264
|
): Promise<void> {
|
|
11265
|
+
if (this.#strictClosePending && !this.#ownsCwdTransition()) throw new Error("Session manager is closing.");
|
|
10807
11266
|
if (!this.#ownsCwdTransition()) {
|
|
10808
|
-
return this.
|
|
11267
|
+
return this.runExclusiveCwdMoveTransition(() => this.moveTo(newCwd, options));
|
|
11268
|
+
}
|
|
11269
|
+
const owner = this.#cwdTransitionOwner;
|
|
11270
|
+
if (this.#cwdMoveAdmissionClosed && (owner === undefined || !this.#cwdMoveAdmittedOwners.has(owner)))
|
|
11271
|
+
throw new Error("Session cwd move admission is closed.");
|
|
11272
|
+
if (!options?.sourceHandle) {
|
|
11273
|
+
const sourceHandle = await SessionManager.openNoFollowDirectory(this.cwd);
|
|
11274
|
+
try {
|
|
11275
|
+
const sourceIdentity = await sourceHandle.stat({ bigint: true });
|
|
11276
|
+
if (!sourceIdentity.isDirectory()) throw new Error(`Current cwd is not a directory: ${this.cwd}`);
|
|
11277
|
+
return await this.moveTo(newCwd, {
|
|
11278
|
+
...options,
|
|
11279
|
+
expectedSourceIdentity: options?.expectedSourceIdentity ?? {
|
|
11280
|
+
dev: sourceIdentity.dev,
|
|
11281
|
+
ino: sourceIdentity.ino,
|
|
11282
|
+
},
|
|
11283
|
+
sourceHandle,
|
|
11284
|
+
});
|
|
11285
|
+
} finally {
|
|
11286
|
+
await sourceHandle.close();
|
|
11287
|
+
}
|
|
11288
|
+
}
|
|
11289
|
+
const resolvedCwd = await fs.promises.realpath(path.resolve(newCwd));
|
|
11290
|
+
const resolvedIdentity = await fs.promises.lstat(resolvedCwd, { bigint: true });
|
|
11291
|
+
if (!resolvedIdentity.isDirectory() || resolvedIdentity.isSymbolicLink()) {
|
|
11292
|
+
throw new Error(`Refusing to rescope: target is not a directory: ${resolvedCwd}`);
|
|
11293
|
+
}
|
|
11294
|
+
if (!options?.targetHandle) {
|
|
11295
|
+
const targetHandle = await SessionManager.openNoFollowDirectory(resolvedCwd);
|
|
11296
|
+
try {
|
|
11297
|
+
const targetIdentity = await targetHandle.stat({ bigint: true });
|
|
11298
|
+
const revalidatedCwd = await fs.promises.realpath(path.resolve(newCwd));
|
|
11299
|
+
const namedIdentity = await fs.promises.lstat(revalidatedCwd, { bigint: true });
|
|
11300
|
+
if (
|
|
11301
|
+
revalidatedCwd !== resolvedCwd ||
|
|
11302
|
+
!namedIdentity.isDirectory() ||
|
|
11303
|
+
namedIdentity.isSymbolicLink() ||
|
|
11304
|
+
namedIdentity.dev !== resolvedIdentity.dev ||
|
|
11305
|
+
namedIdentity.ino !== resolvedIdentity.ino ||
|
|
11306
|
+
targetIdentity.dev !== resolvedIdentity.dev ||
|
|
11307
|
+
targetIdentity.ino !== resolvedIdentity.ino
|
|
11308
|
+
)
|
|
11309
|
+
throw new Error(`Refusing to rescope: target identity changed: ${resolvedCwd}`);
|
|
11310
|
+
return await this.moveTo(resolvedCwd, {
|
|
11311
|
+
...options,
|
|
11312
|
+
expectedIdentity: options?.expectedIdentity ?? {
|
|
11313
|
+
dev: targetIdentity.dev,
|
|
11314
|
+
ino: targetIdentity.ino,
|
|
11315
|
+
},
|
|
11316
|
+
targetHandle,
|
|
11317
|
+
});
|
|
11318
|
+
} finally {
|
|
11319
|
+
await targetHandle.close();
|
|
11320
|
+
}
|
|
10809
11321
|
}
|
|
10810
|
-
const resolvedCwd = path.resolve(newCwd);
|
|
10811
11322
|
if (options?.expectedIdentity || options?.targetHandle) {
|
|
10812
11323
|
await this.#assertCwdTargetIdentity(resolvedCwd, options);
|
|
10813
11324
|
}
|
|
11325
|
+
if (options?.expectedSourceIdentity || options?.sourceHandle) {
|
|
11326
|
+
await this.#assertCwdTargetIdentity(this.cwd, {
|
|
11327
|
+
expectedIdentity: options.expectedSourceIdentity,
|
|
11328
|
+
targetHandle: options.sourceHandle,
|
|
11329
|
+
});
|
|
11330
|
+
}
|
|
10814
11331
|
if (resolvedCwd === this.cwd) return;
|
|
10815
11332
|
const previousCwd = this.cwd;
|
|
10816
11333
|
const previousSessionDir = this.sessionDir;
|
|
@@ -10826,6 +11343,19 @@ export class SessionManager {
|
|
|
10826
11343
|
)
|
|
10827
11344
|
: this.destination;
|
|
10828
11345
|
const newSessionDir = nextDestination.directory;
|
|
11346
|
+
const nextSessionFile = this.#sessionFile
|
|
11347
|
+
? path.join(newSessionDir, path.basename(this.#sessionFile))
|
|
11348
|
+
: undefined;
|
|
11349
|
+
const pinnedSourceIdentity = await options.sourceHandle.stat({ bigint: true });
|
|
11350
|
+
const pinnedTargetIdentity = await options.targetHandle.stat({ bigint: true });
|
|
11351
|
+
const moveDetails = {
|
|
11352
|
+
previousCwd,
|
|
11353
|
+
newCwd: resolvedCwd,
|
|
11354
|
+
previousSessionFile,
|
|
11355
|
+
newSessionFile: nextSessionFile,
|
|
11356
|
+
previousCwdIdentity: { dev: pinnedSourceIdentity.dev, ino: pinnedSourceIdentity.ino },
|
|
11357
|
+
newCwdIdentity: { dev: pinnedTargetIdentity.dev, ino: pinnedTargetIdentity.ino },
|
|
11358
|
+
};
|
|
10829
11359
|
let hadSessionFile = false;
|
|
10830
11360
|
const managedMove = this.destination.kind === "managed" && nextDestination.kind === "managed";
|
|
10831
11361
|
let managedSourceStore: ManagedSessionDescendantStore | undefined;
|
|
@@ -10833,16 +11363,29 @@ export class SessionManager {
|
|
|
10833
11363
|
let rollbackManagedMove: (() => Promise<void>) | undefined;
|
|
10834
11364
|
let residentTransition: PreparedResidentStoreTransition | undefined;
|
|
10835
11365
|
const hadPersistedSession = this.#ensuredOnDisk;
|
|
11366
|
+
try {
|
|
11367
|
+
await this.#runBeforeMoveListeners(moveDetails);
|
|
11368
|
+
} catch (error) {
|
|
11369
|
+
await this.#runMoveAbortListeners(moveDetails);
|
|
11370
|
+
throw error;
|
|
11371
|
+
}
|
|
10836
11372
|
|
|
11373
|
+
let targetIdentityRevalidated = false;
|
|
11374
|
+
let publicationStarted = false;
|
|
10837
11375
|
if (this.persist && this.#sessionFile) {
|
|
10838
11376
|
// Close the persist writer before moving files
|
|
10839
|
-
|
|
11377
|
+
try {
|
|
11378
|
+
await this.#closePersistWriter();
|
|
11379
|
+
} catch (error) {
|
|
11380
|
+
await this.#runMoveAbortListeners(moveDetails);
|
|
11381
|
+
throw error;
|
|
11382
|
+
}
|
|
10840
11383
|
this.#persistChain = Promise.resolve();
|
|
10841
11384
|
this.#persistError = undefined;
|
|
10842
11385
|
this.#persistErrorReported = false;
|
|
10843
11386
|
|
|
10844
11387
|
const oldSessionFile = this.#sessionFile;
|
|
10845
|
-
const newSessionFile =
|
|
11388
|
+
const newSessionFile = nextSessionFile!;
|
|
10846
11389
|
const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
|
|
10847
11390
|
const newArtifactDir = newSessionFile.slice(0, -6);
|
|
10848
11391
|
let managedTranscript!: ReturnType<ManagedSessionDescendantStore["readExpected"]>;
|
|
@@ -10852,24 +11395,30 @@ export class SessionManager {
|
|
|
10852
11395
|
hadSessionFile = managedMove ? false : this.#storage.existsSync(oldSessionFile);
|
|
10853
11396
|
let movedSessionFile = false;
|
|
10854
11397
|
let movedArtifactDir = false;
|
|
10855
|
-
|
|
10856
|
-
|
|
10857
|
-
|
|
10858
|
-
|
|
10859
|
-
|
|
10860
|
-
|
|
10861
|
-
|
|
10862
|
-
|
|
10863
|
-
|
|
10864
|
-
|
|
10865
|
-
|
|
10866
|
-
|
|
10867
|
-
|
|
10868
|
-
|
|
11398
|
+
try {
|
|
11399
|
+
const materializedEntries = materializeResidentEntriesForReadSync(
|
|
11400
|
+
this.#fileEntries,
|
|
11401
|
+
this.#residentBlobStores(),
|
|
11402
|
+
);
|
|
11403
|
+
const transitionEntries: FileEntry[] = materializedEntries.map(entry =>
|
|
11404
|
+
entry.type === "session" ? { ...entry, cwd: resolvedCwd } : entry,
|
|
11405
|
+
);
|
|
11406
|
+
residentTransition = this.#prepareResidentTextStoreTransition(
|
|
11407
|
+
{
|
|
11408
|
+
target: { sessionId: this.#sessionId, sessionFile: newSessionFile },
|
|
11409
|
+
primary: {
|
|
11410
|
+
mode: "materialize",
|
|
11411
|
+
sourceEntries: transitionEntries,
|
|
11412
|
+
sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
|
|
11413
|
+
},
|
|
10869
11414
|
},
|
|
10870
|
-
|
|
10871
|
-
|
|
10872
|
-
)
|
|
11415
|
+
"retain-and-throw",
|
|
11416
|
+
);
|
|
11417
|
+
} catch (error) {
|
|
11418
|
+
residentTransition?.dispose();
|
|
11419
|
+
await this.#runMoveAbortListeners(moveDetails);
|
|
11420
|
+
throw error;
|
|
11421
|
+
}
|
|
10873
11422
|
const discardResidentTransitionAndThrow = (error: unknown): never => {
|
|
10874
11423
|
residentTransition?.dispose();
|
|
10875
11424
|
throw error;
|
|
@@ -10912,6 +11461,14 @@ export class SessionManager {
|
|
|
10912
11461
|
throw new Error("artifact_destination_mismatch");
|
|
10913
11462
|
destination.fsyncTree();
|
|
10914
11463
|
};
|
|
11464
|
+
try {
|
|
11465
|
+
await this.#runMovePublicationListeners(moveDetails);
|
|
11466
|
+
publicationStarted = true;
|
|
11467
|
+
} catch (error) {
|
|
11468
|
+
residentTransition?.dispose();
|
|
11469
|
+
await this.#runMoveAbortListeners(moveDetails);
|
|
11470
|
+
throw error;
|
|
11471
|
+
}
|
|
10915
11472
|
try {
|
|
10916
11473
|
if (managedMove) {
|
|
10917
11474
|
if (previousDestination.kind !== "managed" || nextDestination.kind !== "managed")
|
|
@@ -10997,6 +11554,16 @@ export class SessionManager {
|
|
|
10997
11554
|
if (!isEnoent(err)) throw err;
|
|
10998
11555
|
}
|
|
10999
11556
|
}
|
|
11557
|
+
if (options?.expectedIdentity || options?.targetHandle) {
|
|
11558
|
+
if (options.expectedSourceIdentity || options.sourceHandle) {
|
|
11559
|
+
await this.#assertCwdTargetIdentity(previousCwd, {
|
|
11560
|
+
expectedIdentity: options.expectedSourceIdentity,
|
|
11561
|
+
targetHandle: options.sourceHandle,
|
|
11562
|
+
});
|
|
11563
|
+
}
|
|
11564
|
+
await this.#assertCwdTargetIdentity(resolvedCwd, options);
|
|
11565
|
+
targetIdentityRevalidated = true;
|
|
11566
|
+
}
|
|
11000
11567
|
} catch (err) {
|
|
11001
11568
|
if (managedMove && managedDestinationStore) {
|
|
11002
11569
|
try {
|
|
@@ -11038,6 +11605,7 @@ export class SessionManager {
|
|
|
11038
11605
|
)
|
|
11039
11606
|
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
11040
11607
|
} catch (rollbackErr) {
|
|
11608
|
+
await this.#runMoveAbortListeners({ ...moveDetails, preserveRecoveryJournal: true });
|
|
11041
11609
|
discardResidentTransitionAndThrow(
|
|
11042
11610
|
new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
|
|
11043
11611
|
cause: toError(err),
|
|
@@ -11045,20 +11613,54 @@ export class SessionManager {
|
|
|
11045
11613
|
);
|
|
11046
11614
|
}
|
|
11047
11615
|
} else {
|
|
11048
|
-
|
|
11049
|
-
|
|
11616
|
+
try {
|
|
11617
|
+
if (movedArtifactDir) await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
|
|
11618
|
+
if (movedSessionFile) await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
|
|
11619
|
+
} catch (rollbackError) {
|
|
11620
|
+
await this.#runMoveAbortListeners({ ...moveDetails, preserveRecoveryJournal: true });
|
|
11621
|
+
discardResidentTransitionAndThrow(
|
|
11622
|
+
new Error(`Failed to rollback move: ${toError(rollbackError).message}`, { cause: toError(err) }),
|
|
11623
|
+
);
|
|
11624
|
+
}
|
|
11050
11625
|
}
|
|
11626
|
+
await this.#runMoveAbortListeners(moveDetails);
|
|
11051
11627
|
discardResidentTransitionAndThrow(err);
|
|
11052
11628
|
}
|
|
11053
11629
|
this.#sessionFile = newSessionFile;
|
|
11054
11630
|
}
|
|
11631
|
+
if (!publicationStarted) {
|
|
11632
|
+
try {
|
|
11633
|
+
await this.#runMovePublicationListeners(moveDetails);
|
|
11634
|
+
publicationStarted = true;
|
|
11635
|
+
} catch (error) {
|
|
11636
|
+
await this.#runMoveAbortListeners(moveDetails);
|
|
11637
|
+
throw error;
|
|
11638
|
+
}
|
|
11639
|
+
}
|
|
11055
11640
|
|
|
11056
11641
|
// Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
|
|
11057
11642
|
// authority but deliberately retain any destination publication evidence rather than deleting it.
|
|
11058
|
-
if (options?.expectedIdentity || options?.targetHandle) {
|
|
11059
|
-
|
|
11643
|
+
if (!targetIdentityRevalidated && (options?.expectedIdentity || options?.targetHandle)) {
|
|
11644
|
+
try {
|
|
11645
|
+
if (options.expectedSourceIdentity || options.sourceHandle) {
|
|
11646
|
+
await this.#assertCwdTargetIdentity(previousCwd, {
|
|
11647
|
+
expectedIdentity: options.expectedSourceIdentity,
|
|
11648
|
+
targetHandle: options.sourceHandle,
|
|
11649
|
+
});
|
|
11650
|
+
}
|
|
11651
|
+
await this.#assertCwdTargetIdentity(resolvedCwd, options);
|
|
11652
|
+
} catch (error) {
|
|
11653
|
+
await this.#runMoveAbortListeners(moveDetails);
|
|
11654
|
+
throw error;
|
|
11655
|
+
}
|
|
11060
11656
|
}
|
|
11061
11657
|
this.#cwdGeneration += 1;
|
|
11658
|
+
const readLease = cwdReadLeaseAls.getStore();
|
|
11659
|
+
if (readLease?.owner === this.#cwdReadLeaseOwner) {
|
|
11660
|
+
readLease.committedMoveGeneration = this.#cwdGeneration;
|
|
11661
|
+
for (let parent = readLease.parent; parent; parent = parent.parent)
|
|
11662
|
+
parent.committedMoveGeneration = this.#cwdGeneration;
|
|
11663
|
+
}
|
|
11062
11664
|
this.cwd = resolvedCwd;
|
|
11063
11665
|
this.sessionDir = newSessionDir;
|
|
11064
11666
|
this.destination = nextDestination;
|
|
@@ -11077,13 +11679,13 @@ export class SessionManager {
|
|
|
11077
11679
|
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
11078
11680
|
try {
|
|
11079
11681
|
if (this.persist && this.#sessionFile && hadSessionFile) {
|
|
11080
|
-
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
11682
|
+
await this.#appendHeaderPatch({ cwd: resolvedCwd }, { allowStrictClose: true });
|
|
11081
11683
|
await this.#rewriteFile();
|
|
11082
11684
|
} else if (this.persist && this.#sessionFile && (hasAssistant || (!hadSessionFile && hadPersistedSession))) {
|
|
11083
|
-
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
11685
|
+
await this.#appendHeaderPatch({ cwd: resolvedCwd }, { allowStrictClose: true });
|
|
11084
11686
|
await this.#rewriteFile();
|
|
11085
11687
|
} else {
|
|
11086
|
-
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
11688
|
+
await this.#appendHeaderPatch({ cwd: resolvedCwd }, { allowStrictClose: true });
|
|
11087
11689
|
}
|
|
11088
11690
|
} catch (error) {
|
|
11089
11691
|
residentTransition?.dispose();
|
|
@@ -11096,6 +11698,7 @@ export class SessionManager {
|
|
|
11096
11698
|
await rollbackManagedMove();
|
|
11097
11699
|
} catch (rollbackError) {
|
|
11098
11700
|
if (toError(rollbackError).message !== "cleanup_pending") {
|
|
11701
|
+
await this.#runMoveAbortListeners({ ...moveDetails, preserveRecoveryJournal: true });
|
|
11099
11702
|
throw new Error(`Failed to rollback managed move: ${toError(rollbackError).message}`, {
|
|
11100
11703
|
cause: toError(error),
|
|
11101
11704
|
});
|
|
@@ -11116,12 +11719,14 @@ export class SessionManager {
|
|
|
11116
11719
|
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
11117
11720
|
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
11118
11721
|
this.#headerExportRevision++;
|
|
11722
|
+
await this.#runMoveAbortListeners(moveDetails);
|
|
11119
11723
|
throw error;
|
|
11120
11724
|
}
|
|
11121
11725
|
// The destination has already been published. Retain it rather than moving it
|
|
11122
11726
|
// back over the source after a metadata failure.
|
|
11123
11727
|
this.#managedTranscriptStoreCache = null;
|
|
11124
11728
|
this.#headerExportRevision++;
|
|
11729
|
+
await this.#runAfterMoveListeners(moveDetails);
|
|
11125
11730
|
throw error;
|
|
11126
11731
|
}
|
|
11127
11732
|
if (residentTransition) this.#commitResidentTextStoreTransition(residentTransition);
|
|
@@ -11130,6 +11735,9 @@ export class SessionManager {
|
|
|
11130
11735
|
if (this.#sessionFile) {
|
|
11131
11736
|
writeTerminalBreadcrumb(resolvedCwd, this.#sessionFile);
|
|
11132
11737
|
}
|
|
11738
|
+
// The move is fully committed here; notify every surface's after-move listeners
|
|
11739
|
+
// (coordinator runtime-state relocation, postmortem finalizer rebinding, ...).
|
|
11740
|
+
await this.#runAfterMoveListeners(moveDetails);
|
|
11133
11741
|
}
|
|
11134
11742
|
|
|
11135
11743
|
/** Sync version for initial creation (no existing writer to close). */
|
|
@@ -15484,6 +16092,7 @@ export class SessionManager {
|
|
|
15484
16092
|
* Used by ACP mode where session/new must create a discoverable session immediately.
|
|
15485
16093
|
*/
|
|
15486
16094
|
async ensureOnDisk(): Promise<void> {
|
|
16095
|
+
this.#assertRecoveryHydrationWritable();
|
|
15487
16096
|
if (!this.persist || !this.#sessionFile) return;
|
|
15488
16097
|
if (this.#readOnlyResume) return;
|
|
15489
16098
|
if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
|
|
@@ -15593,7 +16202,9 @@ export class SessionManager {
|
|
|
15593
16202
|
|
|
15594
16203
|
/** Close the persistent writer after flushing all pending data. */
|
|
15595
16204
|
async close(): Promise<void> {
|
|
16205
|
+
await this.closeCwdMoveAdmission();
|
|
15596
16206
|
await this.joinCwdTransition();
|
|
16207
|
+
await this.joinCwdReaders();
|
|
15597
16208
|
SessionManager.releaseProcessCwdOwnership(this);
|
|
15598
16209
|
// Drain any uncommitted prepared successors before releasing resources so
|
|
15599
16210
|
// dispose/shutdown retains exact cleanup authority (#3138).
|
|
@@ -15635,10 +16246,16 @@ export class SessionManager {
|
|
|
15635
16246
|
}
|
|
15636
16247
|
/** Flush while open, then strictly close; retryable close skips the invalid second flush. */
|
|
15637
16248
|
async flushAndCloseStrict(): Promise<SessionManagerCloseOutcome> {
|
|
16249
|
+
let priorFlushError: Error | undefined;
|
|
15638
16250
|
if (this.#persistWriter?.getCloseState() !== "close_failed_retryable") {
|
|
15639
|
-
|
|
16251
|
+
try {
|
|
16252
|
+
await this.flush();
|
|
16253
|
+
} catch (error) {
|
|
16254
|
+
priorFlushError = toError(error);
|
|
16255
|
+
}
|
|
15640
16256
|
}
|
|
15641
|
-
|
|
16257
|
+
const outcome = await this.closeStrict();
|
|
16258
|
+
return priorFlushError && outcome.kind === "closed" ? { kind: "close_unknown", error: priorFlushError } : outcome;
|
|
15642
16259
|
}
|
|
15643
16260
|
|
|
15644
16261
|
/**
|
|
@@ -15648,45 +16265,114 @@ export class SessionManager {
|
|
|
15648
16265
|
* writer closure before any destructive operation.
|
|
15649
16266
|
*/
|
|
15650
16267
|
async closeStrict(): Promise<SessionManagerCloseOutcome> {
|
|
16268
|
+
this.#strictClosePending = true;
|
|
16269
|
+
await this.closeCwdMoveAdmission();
|
|
16270
|
+
await this.joinCwdTransition();
|
|
16271
|
+
await this.joinCwdReaders();
|
|
15651
16272
|
// Drain staged successors on the strict ACP dispose path as well as best-effort close (#3138).
|
|
16273
|
+
let preparedCleanupError: Error | undefined;
|
|
15652
16274
|
try {
|
|
15653
16275
|
await this.#retryPreparedNewSessionCleanups();
|
|
15654
16276
|
} catch (error) {
|
|
16277
|
+
preparedCleanupError = toError(error);
|
|
15655
16278
|
logger.warn("Prepared session cleanup during closeStrict failed; retained for retry", {
|
|
15656
|
-
error:
|
|
16279
|
+
error: preparedCleanupError.message,
|
|
15657
16280
|
});
|
|
15658
16281
|
}
|
|
15659
|
-
|
|
15660
|
-
|
|
15661
|
-
|
|
15662
|
-
|
|
15663
|
-
|
|
15664
|
-
|
|
16282
|
+
if (preparedCleanupError) return { kind: "close_failed_retryable", error: preparedCleanupError };
|
|
16283
|
+
let priorPersistError = this.#persistError;
|
|
16284
|
+
if (this.#needsFullRewriteOnNextPersist && (!this.#readOnlyResume || this.#strictResumeMutationPending)) {
|
|
16285
|
+
await this.#persistChain.catch(() => {});
|
|
16286
|
+
try {
|
|
16287
|
+
await this.#rewriteFileContents();
|
|
16288
|
+
if (this.#strictResumeMutationPending && this.#readOnlyResume && this.#sessionFile) {
|
|
16289
|
+
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
16290
|
+
this.#readOnlyResume = false;
|
|
16291
|
+
}
|
|
16292
|
+
this.#strictResumeMutationPending = false;
|
|
16293
|
+
} catch (error) {
|
|
16294
|
+
priorPersistError ??= toError(error);
|
|
16295
|
+
return { kind: "close_failed_retryable", error: priorPersistError };
|
|
15665
16296
|
}
|
|
16297
|
+
}
|
|
16298
|
+
let outcome: SessionManagerCloseOutcome = { kind: "closed" };
|
|
16299
|
+
const closePublicationRevision = {
|
|
16300
|
+
entry: this.#entryRevision,
|
|
16301
|
+
header: this.#headerExportRevision,
|
|
16302
|
+
residentBlob: this.#residentBlobRevision,
|
|
16303
|
+
};
|
|
16304
|
+
await this.#queuePersistTask(
|
|
16305
|
+
async () => {
|
|
16306
|
+
const writer = this.#persistWriter;
|
|
16307
|
+
if (!writer) {
|
|
16308
|
+
this.#flushed = true;
|
|
16309
|
+
return;
|
|
16310
|
+
}
|
|
16311
|
+
try {
|
|
16312
|
+
await writer.close();
|
|
16313
|
+
} catch {
|
|
16314
|
+
// Outcome is captured from the underlying writer's close state below.
|
|
16315
|
+
}
|
|
16316
|
+
outcome = this.#closeOutcomeFromWriter(writer);
|
|
16317
|
+
if (outcome.kind === "closed") {
|
|
16318
|
+
this.#flushed = true;
|
|
16319
|
+
// Confirmed closed: release writer ownership.
|
|
16320
|
+
this.#persistWriter = undefined;
|
|
16321
|
+
this.#persistWriterPath = undefined;
|
|
16322
|
+
} else if (outcome.kind === "close_unknown") {
|
|
16323
|
+
// Quarantined (terminal): release ownership so no retry/finalizer
|
|
16324
|
+
// touches the uncertain fd again.
|
|
16325
|
+
this.#persistWriter = undefined;
|
|
16326
|
+
this.#persistWriterPath = undefined;
|
|
16327
|
+
}
|
|
16328
|
+
// close_failed_retryable: RETAIN the writer so a later closeStrict() call
|
|
16329
|
+
// can actually re-dispatch the OS close (ownership stays proven). The
|
|
16330
|
+
// wrapper must not manufacture success or surrender a retryable fd.
|
|
16331
|
+
},
|
|
16332
|
+
{ ignoreError: true },
|
|
16333
|
+
);
|
|
16334
|
+
// A mutation that entered before strict-close admission can finish its
|
|
16335
|
+
// asynchronous preparation after the initial rewrite check. Re-check the
|
|
16336
|
+
// publication debt after writer closure and repair it before releasing any
|
|
16337
|
+
// resident state; strict admission prevents new callers from creating a
|
|
16338
|
+
// second race while this repair is in flight.
|
|
16339
|
+
let observedPersistError = priorPersistError ?? this.#persistError;
|
|
16340
|
+
const mutationDuringWriterClose =
|
|
16341
|
+
this.#entryRevision !== closePublicationRevision.entry ||
|
|
16342
|
+
this.#headerExportRevision !== closePublicationRevision.header ||
|
|
16343
|
+
this.#residentBlobRevision !== closePublicationRevision.residentBlob;
|
|
16344
|
+
if (mutationDuringWriterClose) {
|
|
16345
|
+
this.#needsFullRewriteOnNextPersist = true;
|
|
16346
|
+
if (this.#readOnlyResume) this.#strictResumeMutationPending = true;
|
|
16347
|
+
}
|
|
16348
|
+
const lateRewriteDebt =
|
|
16349
|
+
(this.#needsFullRewriteOnNextPersist || this.#strictResumeMutationPending) &&
|
|
16350
|
+
(!this.#readOnlyResume || this.#strictResumeMutationPending);
|
|
16351
|
+
if (!observedPersistError && outcome.kind === "closed" && !this.#persistWriter && lateRewriteDebt) {
|
|
15666
16352
|
try {
|
|
15667
|
-
await
|
|
15668
|
-
|
|
15669
|
-
|
|
16353
|
+
await this.#rewriteFileContents();
|
|
16354
|
+
if (this.#strictResumeMutationPending && this.#readOnlyResume && this.#sessionFile) {
|
|
16355
|
+
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
16356
|
+
this.#readOnlyResume = false;
|
|
16357
|
+
}
|
|
16358
|
+
this.#strictResumeMutationPending = false;
|
|
16359
|
+
} catch (error) {
|
|
16360
|
+
observedPersistError = toError(error);
|
|
16361
|
+
outcome = { kind: "close_failed_retryable", error: observedPersistError };
|
|
15670
16362
|
}
|
|
15671
|
-
|
|
15672
|
-
|
|
15673
|
-
|
|
15674
|
-
|
|
15675
|
-
this.#persistWriter = undefined;
|
|
15676
|
-
this.#persistWriterPath = undefined;
|
|
15677
|
-
} else if (outcome.kind === "close_unknown") {
|
|
15678
|
-
// Quarantined (terminal): release ownership so no retry/finalizer
|
|
15679
|
-
// touches the uncertain fd again.
|
|
15680
|
-
this.#persistWriter = undefined;
|
|
15681
|
-
this.#persistWriterPath = undefined;
|
|
15682
|
-
}
|
|
15683
|
-
// close_failed_retryable: RETAIN the writer so a later closeStrict() call
|
|
15684
|
-
// can actually re-dispatch the OS close (ownership stays proven). The
|
|
15685
|
-
// wrapper must not manufacture success or surrender a retryable fd.
|
|
15686
|
-
});
|
|
16363
|
+
}
|
|
16364
|
+
const unpublishedMutation =
|
|
16365
|
+
this.persist &&
|
|
16366
|
+
(this.#strictResumeMutationPending || (this.#needsFullRewriteOnNextPersist && !this.#readOnlyResume));
|
|
15687
16367
|
// Only tear down the resident blob store on a terminal outcome; a retryable
|
|
15688
16368
|
// close leaves the session live for a genuine retry.
|
|
15689
|
-
if (
|
|
16369
|
+
if (
|
|
16370
|
+
!observedPersistError &&
|
|
16371
|
+
!this.#persistWriter &&
|
|
16372
|
+
(!this.persist ||
|
|
16373
|
+
(this.#readOnlyResume && !this.#strictResumeMutationPending) ||
|
|
16374
|
+
(!this.#needsFullRewriteOnNextPersist && !this.#strictResumeMutationPending))
|
|
16375
|
+
) {
|
|
15690
16376
|
this.#releaseResidentTextStore();
|
|
15691
16377
|
this.#retireEphemeralArtifacts();
|
|
15692
16378
|
try {
|
|
@@ -15697,6 +16383,15 @@ export class SessionManager {
|
|
|
15697
16383
|
if (this.#preparedNewSessions.size === 0) this.#releaseOwnedManagedAuthority();
|
|
15698
16384
|
this.#releaseClosedSessionState();
|
|
15699
16385
|
}
|
|
16386
|
+
if (!observedPersistError && !this.#persistWriter && unpublishedMutation && outcome.kind === "closed") {
|
|
16387
|
+
outcome = {
|
|
16388
|
+
kind: "close_failed_retryable",
|
|
16389
|
+
error: new Error("Session manager has unpublished state."),
|
|
16390
|
+
};
|
|
16391
|
+
}
|
|
16392
|
+
if (observedPersistError && outcome.kind === "closed") {
|
|
16393
|
+
return { kind: "close_unknown", error: observedPersistError };
|
|
16394
|
+
}
|
|
15700
16395
|
return outcome;
|
|
15701
16396
|
}
|
|
15702
16397
|
|
|
@@ -16575,6 +17270,49 @@ export class SessionManager {
|
|
|
16575
17270
|
return this.#sessionName;
|
|
16576
17271
|
}
|
|
16577
17272
|
|
|
17273
|
+
isSessionStarred(): boolean {
|
|
17274
|
+
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
17275
|
+
return header?.starredPatchVersion === 1 && header.starred === true;
|
|
17276
|
+
}
|
|
17277
|
+
|
|
17278
|
+
async setSessionStarred(starred: boolean): Promise<boolean> {
|
|
17279
|
+
this.#assertRecoveryHydrationWritable();
|
|
17280
|
+
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
17281
|
+
if (!header || this.isSessionStarred() === starred) return false;
|
|
17282
|
+
await this.#ensureStarPatchCapability(header);
|
|
17283
|
+
const previous = header.starred;
|
|
17284
|
+
try {
|
|
17285
|
+
await this.#appendHeaderPatch({ starred });
|
|
17286
|
+
return true;
|
|
17287
|
+
} catch (error) {
|
|
17288
|
+
if (previous === undefined) delete header.starred;
|
|
17289
|
+
else header.starred = previous;
|
|
17290
|
+
this.#headerExportRevision++;
|
|
17291
|
+
throw error;
|
|
17292
|
+
}
|
|
17293
|
+
}
|
|
17294
|
+
|
|
17295
|
+
async #ensureStarPatchCapability(header: SessionHeader): Promise<void> {
|
|
17296
|
+
if (header.starredPatchVersion === 1) return;
|
|
17297
|
+
const previousStarred = header.starred;
|
|
17298
|
+
const previousRewriteRequired = this.#needsFullRewriteOnNextPersist;
|
|
17299
|
+
header.starredPatchVersion = 1;
|
|
17300
|
+
delete header.starred;
|
|
17301
|
+
this.#headerExportRevision++;
|
|
17302
|
+
if (!this.persist || !this.#sessionFile || !this.#storage.existsSync(this.#sessionFile)) return;
|
|
17303
|
+
this.#needsFullRewriteOnNextPersist = true;
|
|
17304
|
+
try {
|
|
17305
|
+
await this.#persistPatches([{ type: "header_patch", patch: {} }]);
|
|
17306
|
+
} catch (error) {
|
|
17307
|
+
delete header.starredPatchVersion;
|
|
17308
|
+
if (previousStarred === undefined) delete header.starred;
|
|
17309
|
+
else header.starred = previousStarred;
|
|
17310
|
+
this.#needsFullRewriteOnNextPersist = previousRewriteRequired;
|
|
17311
|
+
this.#headerExportRevision++;
|
|
17312
|
+
throw error;
|
|
17313
|
+
}
|
|
17314
|
+
}
|
|
17315
|
+
|
|
16578
17316
|
/** Strip C0/C1 control characters (includes ESC, so removes ANSI sequences) and collapse whitespace. */
|
|
16579
17317
|
static #sanitizeName(name: string): string {
|
|
16580
17318
|
return name
|
|
@@ -16591,6 +17329,7 @@ export class SessionManager {
|
|
|
16591
17329
|
*/
|
|
16592
17330
|
#assertRecoveryHydrationWritable(): void {
|
|
16593
17331
|
if (this.#recoveryHydrationContext) throw new Error("recovery_hydration_not_promoted");
|
|
17332
|
+
if (this.#strictClosePending) throw new Error("Session manager is closing.");
|
|
16594
17333
|
}
|
|
16595
17334
|
async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
|
|
16596
17335
|
this.#assertRecoveryHydrationWritable();
|
|
@@ -16606,12 +17345,25 @@ export class SessionManager {
|
|
|
16606
17345
|
return true;
|
|
16607
17346
|
}
|
|
16608
17347
|
|
|
16609
|
-
async #appendHeaderPatch(
|
|
17348
|
+
async #appendHeaderPatch(
|
|
17349
|
+
patch: HeaderPatchRecord["patch"],
|
|
17350
|
+
options?: { allowStrictClose?: boolean },
|
|
17351
|
+
): Promise<void> {
|
|
17352
|
+
if (options?.allowStrictClose) {
|
|
17353
|
+
if (this.#recoveryHydrationContext) throw new Error("recovery_hydration_not_promoted");
|
|
17354
|
+
} else this.#assertRecoveryHydrationWritable();
|
|
16610
17355
|
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
16611
17356
|
if (!header) return;
|
|
16612
17357
|
applyHeaderPatch(header, patch);
|
|
16613
17358
|
this.#headerExportRevision++;
|
|
16614
|
-
|
|
17359
|
+
const records: HeaderPatchRecord[] =
|
|
17360
|
+
header.starredPatchVersion === 1 && patch.starred === undefined
|
|
17361
|
+
? [
|
|
17362
|
+
{ type: "header_patch", patch: { starred: header.starred === true } },
|
|
17363
|
+
{ type: "header_patch", patch },
|
|
17364
|
+
]
|
|
17365
|
+
: [{ type: "header_patch", patch }];
|
|
17366
|
+
await this.#persistPatches(records);
|
|
16615
17367
|
}
|
|
16616
17368
|
|
|
16617
17369
|
#appendManagedRecordsSync(records: readonly (FileEntry | SessionPatchRecord)[]): void {
|
|
@@ -16644,10 +17396,6 @@ export class SessionManager {
|
|
|
16644
17396
|
});
|
|
16645
17397
|
}
|
|
16646
17398
|
|
|
16647
|
-
async #persistPatch(record: SessionPatchRecord): Promise<void> {
|
|
16648
|
-
await this.#persistPatches([record]);
|
|
16649
|
-
}
|
|
16650
|
-
|
|
16651
17399
|
async #persistPatches(records: readonly SessionPatchRecord[]): Promise<void> {
|
|
16652
17400
|
if (records.length === 0) return;
|
|
16653
17401
|
if (this.#coldSidecarActive()) this.#deactivateColdForBranchMutation();
|
|
@@ -16714,6 +17462,7 @@ export class SessionManager {
|
|
|
16714
17462
|
}
|
|
16715
17463
|
_persist(entry: SessionEntry): void {
|
|
16716
17464
|
if (!this.persist || !this.#sessionFile) return;
|
|
17465
|
+
if (this.#strictClosePending) throw new Error("Session manager is closing.");
|
|
16717
17466
|
const publishResumeBreadcrumb = this.#readOnlyResume;
|
|
16718
17467
|
if (this.#persistError) throw this.#persistError;
|
|
16719
17468
|
|
|
@@ -17302,6 +18051,7 @@ export class SessionManager {
|
|
|
17302
18051
|
*/
|
|
17303
18052
|
applyEntryMessageUpdates(entries: readonly SessionMessageEntry[]): void {
|
|
17304
18053
|
this.#assertRecoveryHydrationWritable();
|
|
18054
|
+
if (this.#readOnlyResume) this.#strictResumeMutationPending = true;
|
|
17305
18055
|
this.#deactivateColdForBranchMutation();
|
|
17306
18056
|
for (const updated of entries) {
|
|
17307
18057
|
const canonical = this.#byId.get(updated.id);
|
|
@@ -17323,6 +18073,7 @@ export class SessionManager {
|
|
|
17323
18073
|
options: { preserveEvictedContent?: boolean } = {},
|
|
17324
18074
|
): void {
|
|
17325
18075
|
this.#assertRecoveryHydrationWritable();
|
|
18076
|
+
if (this.#readOnlyResume) this.#strictResumeMutationPending = true;
|
|
17326
18077
|
this.#deactivateColdForBranchMutation();
|
|
17327
18078
|
for (const updated of entries) {
|
|
17328
18079
|
const canonical = this.#byId.get(updated.id);
|
|
@@ -17350,6 +18101,7 @@ export class SessionManager {
|
|
|
17350
18101
|
* boundary for both managed and explicit persistent destinations.
|
|
17351
18102
|
*/
|
|
17352
18103
|
async recoverPersistenceFailure(): Promise<void> {
|
|
18104
|
+
this.#assertRecoveryHydrationWritable();
|
|
17353
18105
|
const persistenceError = this.#persistError;
|
|
17354
18106
|
if (!persistenceError) return;
|
|
17355
18107
|
if (!this.#sessionFile) throw persistenceError;
|
|
@@ -18501,6 +19253,7 @@ export class SessionManager {
|
|
|
18501
19253
|
const header: SessionHeader = {
|
|
18502
19254
|
type: "session",
|
|
18503
19255
|
version: CURRENT_SESSION_VERSION,
|
|
19256
|
+
starredPatchVersion: 1,
|
|
18504
19257
|
id: newSessionId,
|
|
18505
19258
|
timestamp,
|
|
18506
19259
|
cwd: this.cwd,
|