@gajae-code/coding-agent 0.11.10 → 0.12.0
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 +86 -2
- package/dist/types/cli/read-cli.d.ts +1 -0
- package/dist/types/cli.d.ts +8 -1
- package/dist/types/commands/deep-interview.d.ts +9 -0
- package/dist/types/commands/harness.d.ts +22 -0
- package/dist/types/commands/read.d.ts +6 -0
- package/dist/types/config/model-profile-activation.d.ts +1 -0
- package/dist/types/config/model-profile-contract.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/provider-auth-health.d.ts +14 -0
- package/dist/types/config/provider-ranking.d.ts +59 -0
- package/dist/types/config/settings-schema.d.ts +86 -3
- package/dist/types/config/settings.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +43 -2
- package/dist/types/extensibility/shared-events.d.ts +6 -0
- package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
- package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
- package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
- package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +112 -1
- package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
- package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
- package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
- package/dist/types/gjc-runtime/team-store.d.ts +12 -0
- package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +7 -0
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
- package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
- package/dist/types/main.d.ts +1 -1
- package/dist/types/memories/index.d.ts +2 -0
- package/dist/types/modes/acp/acp-agent.d.ts +22 -9
- package/dist/types/modes/components/assistant-message.d.ts +3 -1
- package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
- package/dist/types/modes/components/queue-pane.d.ts +8 -0
- package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
- package/dist/types/modes/components/read-tool-group.d.ts +4 -4
- package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +8 -0
- package/dist/types/modes/components/welcome.d.ts +1 -0
- package/dist/types/modes/controllers/event-controller.d.ts +3 -0
- package/dist/types/modes/interactive-mode.d.ts +7 -1
- package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
- package/dist/types/runtime/memory-domain.d.ts +9 -0
- package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
- package/dist/types/runtime/memory-guard.d.ts +39 -0
- package/dist/types/runtime/memory-limit.d.ts +11 -0
- package/dist/types/runtime-mcp/manager.d.ts +8 -0
- package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
- package/dist/types/sdk/acp/final-text.d.ts +19 -0
- package/dist/types/sdk/acp/mcp.d.ts +23 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +7 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
- package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
- package/dist/types/sdk/bus/index.d.ts +18 -2
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
- package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -3
- package/dist/types/sdk/host/control/operations.d.ts +2 -1
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/host/query/handlers.d.ts +5 -0
- package/dist/types/sdk/lifecycle-session.d.ts +10 -1
- package/dist/types/sdk/startup-capability.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +51 -19
- package/dist/types/session/fallback-chain-controller.d.ts +18 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +27 -0
- package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
- package/dist/types/session/session-manager.d.ts +59 -13
- package/dist/types/session/streaming-output.d.ts +60 -5
- package/dist/types/skill-state/active-state.d.ts +0 -4
- package/dist/types/skill-state/workflow-hud.d.ts +3 -0
- package/dist/types/task/executor.d.ts +6 -0
- package/dist/types/task/types.d.ts +36 -0
- package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
- package/dist/types/tools/browser/launch.d.ts +7 -0
- package/dist/types/tools/fetch.d.ts +17 -1
- package/dist/types/tools/output-meta.d.ts +21 -1
- package/dist/types/tools/read-internals.d.ts +12 -0
- package/dist/types/tools/read.d.ts +45 -1
- package/dist/types/tools/resource-gc.d.ts +23 -0
- package/dist/types/tools/tool-result.d.ts +7 -1
- package/package.json +9 -13
- package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
- package/scripts/capture-sticky-viewport-showcase.ts +292 -0
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/scripts/run-test-manifest.ts +6 -4
- package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
- package/scripts/verify-sticky-viewport-showcase.ts +611 -0
- package/src/capability/index.ts +8 -0
- package/src/cli/args.ts +14 -8
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/read-cli.ts +2 -1
- package/src/cli.ts +49 -2
- package/src/commands/deep-interview.ts +29 -9
- package/src/commands/harness.ts +35 -12
- package/src/commands/read.ts +11 -2
- package/src/commands/sdk.ts +91 -7
- package/src/commands/team.ts +1 -1
- package/src/config/model-profile-activation.ts +1 -0
- package/src/config/model-profile-contract.ts +13 -0
- package/src/config/model-profiles.ts +24 -0
- package/src/config/model-registry.ts +21 -2
- package/src/config/provider-auth-health.ts +42 -0
- package/src/config/provider-ranking.ts +119 -0
- package/src/config/settings-schema.ts +84 -3
- package/src/config/settings.ts +254 -108
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +229 -82
- package/src/defaults/gjc/skills/ralplan/SKILL.md +78 -26
- package/src/defaults/gjc/skills/team/SKILL.md +1 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +26 -16
- package/src/eval/py/executor.ts +7 -2
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +42 -4
- package/src/extensibility/shared-events.ts +5 -0
- package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -68
- package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
- package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
- package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
- package/src/gjc-runtime/deep-interview-state.ts +12 -921
- package/src/gjc-runtime/gc-render.ts +1 -0
- package/src/gjc-runtime/gc-runtime.ts +11 -1
- package/src/gjc-runtime/linux-proc.ts +70 -36
- package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
- package/src/gjc-runtime/ralplan-runtime.ts +979 -71
- package/src/gjc-runtime/state-runtime.ts +50 -12
- package/src/gjc-runtime/state-writer.ts +70 -222
- package/src/gjc-runtime/team-launch.ts +34 -5
- package/src/gjc-runtime/team-runtime.ts +970 -33
- package/src/gjc-runtime/team-store.ts +32 -0
- package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
- package/src/gjc-runtime/team-workers.ts +1 -0
- package/src/gjc-runtime/tmux-owner-isolation.ts +18 -0
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +13 -7
- package/src/gjc-runtime/workflow-manifest.generated.json +97 -350
- package/src/gjc-runtime/workflow-manifest.ts +34 -179
- package/src/hooks/native-skill-hook.ts +17 -4
- package/src/hooks/skill-state.ts +1 -23
- package/src/internal-urls/docs-index.generated.ts +10 -11
- package/src/internal-urls/local-protocol.ts +23 -4
- package/src/internal-urls/local-root-gc.ts +127 -0
- package/src/main.ts +9 -5
- package/src/memories/index.ts +10 -0
- package/src/modes/DESIGN.md +47 -0
- package/src/modes/acp/acp-agent.ts +597 -67
- package/src/modes/acp/acp-event-mapper.ts +106 -12
- package/src/modes/acp/acp-mode.ts +39 -3
- package/src/modes/components/assistant-message.ts +123 -22
- package/src/modes/components/irc-sidebar.ts +176 -62
- package/src/modes/components/model-selector.ts +76 -5
- package/src/modes/components/notifications-settings-editor.ts +1 -1
- package/src/modes/components/oauth-selector.ts +42 -12
- package/src/modes/components/queue-pane.ts +55 -9
- package/src/modes/components/queued-message-selector.ts +53 -10
- package/src/modes/components/read-tool-group.ts +82 -14
- package/src/modes/components/session-observer-overlay.ts +187 -32
- package/src/modes/components/settings-selector.ts +60 -18
- package/src/modes/components/tool-execution.ts +138 -27
- package/src/modes/components/tool-status-header.ts +1 -4
- package/src/modes/components/welcome.ts +18 -3
- package/src/modes/controllers/command-controller.ts +4 -0
- package/src/modes/controllers/event-controller.ts +109 -27
- package/src/modes/controllers/extension-ui-controller.ts +23 -12
- package/src/modes/controllers/input-controller.ts +62 -6
- package/src/modes/controllers/selector-controller.ts +83 -37
- package/src/modes/interactive-mode.ts +127 -29
- package/src/modes/irc-observation-ledger.ts +71 -15
- package/src/modes/runtime-init.ts +20 -2
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +94 -1
- package/src/prompts/agents/architect.md +9 -1
- package/src/prompts/agents/critic.md +9 -0
- package/src/prompts/agents/executor.md +1 -1
- package/src/prompts/system/system-prompt.md +0 -1
- package/src/prompts/tools/read.md +4 -2
- package/src/runtime/memory-domain.ts +66 -0
- package/src/runtime/memory-guard-contract.ts +73 -0
- package/src/runtime/memory-guard.ts +292 -0
- package/src/runtime/memory-limit.ts +50 -0
- package/src/runtime-mcp/manager.ts +26 -6
- package/src/runtime-mcp/smithery-auth.ts +17 -2
- package/src/runtime-mcp/smithery-connect.ts +11 -1
- package/src/sdk/acp/adapter.ts +8 -1
- package/src/sdk/acp/final-text.ts +40 -0
- package/src/sdk/acp/mcp.ts +26 -0
- package/src/sdk/broker/lifecycle.ts +107 -7
- package/src/sdk/bus/chat-daemon-cli.ts +2 -2
- package/src/sdk/bus/chat-daemon-control.ts +4 -3
- package/src/sdk/bus/config.ts +1 -1
- package/src/sdk/bus/control-drain-lease.ts +104 -0
- package/src/sdk/bus/index.ts +503 -60
- package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
- package/src/sdk/bus/prompt-reconciliation.ts +2 -1
- package/src/sdk/bus/rate-limit-pool.ts +8 -0
- package/src/sdk/bus/reconciliation-store.ts +224 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -4
- package/src/sdk/bus/telegram-daemon.ts +531 -53
- package/src/sdk/host/control/dispatch.ts +7 -1
- package/src/sdk/host/control/operations.ts +2 -1
- package/src/sdk/host/host.ts +25 -3
- package/src/sdk/host/query/handlers.ts +55 -0
- package/src/sdk/lifecycle-session.ts +19 -3
- package/src/sdk/mcp/server.ts +46 -18
- package/src/sdk/protocol/operation-inventory.generated.json +76 -0
- package/src/sdk/protocol/operation-registry.ts +8 -2
- package/src/sdk/session.ts +25 -2
- package/src/sdk/startup-capability.ts +6 -0
- package/src/session/agent-session.ts +720 -190
- package/src/session/fallback-chain-controller.ts +47 -8
- package/src/session/internal/managed-session-scope.ts +40 -10
- package/src/session/internal/native-publish-outcome.ts +8 -3
- package/src/session/memory-guard-checkpoint-participant.ts +131 -0
- package/src/session/session-manager.ts +1372 -42
- package/src/session/streaming-output.ts +262 -43
- package/src/setup/provider-onboarding.ts +19 -4
- package/src/skill-state/active-state.ts +0 -10
- package/src/skill-state/workflow-hud.ts +25 -2
- package/src/skill-state/workflow-mutation-guard.ts +65 -7
- package/src/task/executor.ts +11 -3
- package/src/task/index.ts +2 -0
- package/src/task/types.ts +22 -11
- package/src/task/ultragoal-redteam-activation.ts +75 -0
- package/src/tools/ask.ts +89 -69
- package/src/tools/browser/launch.ts +41 -6
- package/src/tools/fetch.ts +114 -23
- package/src/tools/output-meta.ts +176 -9
- package/src/tools/path-utils.ts +5 -1
- package/src/tools/read-internals.ts +44 -0
- package/src/tools/read.ts +1542 -310
- package/src/tools/resource-gc.ts +665 -125
- package/src/tools/tool-result.ts +14 -1
- package/src/web/search/providers/codex.ts +1 -1
- package/src/workflow/workflow-intent-diff.ts +4 -1
- package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
- package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
- package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
- package/src/gjc-runtime/deep-interview-draft.ts +0 -843
- package/src/gjc-runtime/deep-interview-payload.ts +0 -262
- package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
|
@@ -31,9 +31,9 @@ import {
|
|
|
31
31
|
toError,
|
|
32
32
|
} from "@gajae-code/utils";
|
|
33
33
|
import type { TtsrInjectionRecord } from "../export/ttsr";
|
|
34
|
+
import { assertSafePathComponent } from "../gjc-runtime/session-layout";
|
|
34
35
|
import { writeTextAtomic } from "../gjc-runtime/state-writer";
|
|
35
36
|
import type { ManagedLegacyLocalMigrationSource } from "../internal-urls/local-protocol";
|
|
36
|
-
|
|
37
37
|
import * as git from "../utils/git";
|
|
38
38
|
import { ArtifactManager } from "./artifacts";
|
|
39
39
|
import {
|
|
@@ -79,6 +79,23 @@ import {
|
|
|
79
79
|
retainManagedDirectoryAuthority,
|
|
80
80
|
} from "./internal/managed-session-storage";
|
|
81
81
|
import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
|
|
82
|
+
import {
|
|
83
|
+
hasOnlyKeys as hasOnlyMemoryGuardKeys,
|
|
84
|
+
isMemoryGuardDecimalString,
|
|
85
|
+
isMemoryGuardRelativePath,
|
|
86
|
+
isMemoryGuardSha256Hex,
|
|
87
|
+
type MemoryGuardCheckpointBlobAuthorityV1,
|
|
88
|
+
type MemoryGuardCheckpointBlobManifestEntryV1,
|
|
89
|
+
type MemoryGuardCheckpointBlobManifestV1,
|
|
90
|
+
type MemoryGuardCreateCheckpointInput,
|
|
91
|
+
type MemoryGuardParticipantDescriptorV1,
|
|
92
|
+
type MemoryGuardParticipantIngressLease,
|
|
93
|
+
type MemoryGuardRestoreInput,
|
|
94
|
+
type MemoryGuardRestoreResult,
|
|
95
|
+
type MemoryGuardSessionManagerCheckpointV1,
|
|
96
|
+
memoryGuardCanonicalJson,
|
|
97
|
+
memoryGuardSha256Hex,
|
|
98
|
+
} from "./memory-guard-checkpoint-participant";
|
|
82
99
|
|
|
83
100
|
import {
|
|
84
101
|
type BashExecutionMessage,
|
|
@@ -134,6 +151,36 @@ export interface NewSessionOptions {
|
|
|
134
151
|
drop?: boolean;
|
|
135
152
|
}
|
|
136
153
|
|
|
154
|
+
/** Internal successor prepared without changing the manager's visible identity. */
|
|
155
|
+
export interface PreparedNewSession {
|
|
156
|
+
readonly sessionId: string;
|
|
157
|
+
readonly sessionFile: string | undefined;
|
|
158
|
+
readonly artifactsDir: string | null;
|
|
159
|
+
readonly managedLegacyLocalMigrationSource: ManagedLegacyLocalMigrationSource | null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface PreparedSessionIndex {
|
|
163
|
+
readonly byId: Map<string, SessionEntry>;
|
|
164
|
+
readonly labelsById: Map<string, string>;
|
|
165
|
+
readonly leafId: string | null;
|
|
166
|
+
readonly usageStatistics: UsageStatistics;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface PreparedNewSessionState extends PreparedNewSession {
|
|
170
|
+
readonly header: SessionHeader;
|
|
171
|
+
readonly fileEntries: FileEntry[];
|
|
172
|
+
readonly sessionName: string | undefined;
|
|
173
|
+
readonly titleSource: "auto" | "user" | undefined;
|
|
174
|
+
residentFileEntries?: FileEntry[];
|
|
175
|
+
residentTextBlobStore?: BlobStore;
|
|
176
|
+
index?: PreparedSessionIndex;
|
|
177
|
+
flushed: boolean;
|
|
178
|
+
committed: boolean;
|
|
179
|
+
discarded: boolean;
|
|
180
|
+
persistenceWriter?: NdjsonFileWriter;
|
|
181
|
+
persistenceTempPath?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
137
184
|
export interface SessionEntryBase {
|
|
138
185
|
type: string;
|
|
139
186
|
id: string;
|
|
@@ -450,6 +497,238 @@ export type DefaultModelSelectionStage = {
|
|
|
450
497
|
readonly tempPath: string | undefined;
|
|
451
498
|
readonly persistsToExistingFile: boolean;
|
|
452
499
|
};
|
|
500
|
+
export interface SessionManagerRevisionSnapshot {
|
|
501
|
+
entry: number;
|
|
502
|
+
leaf: number;
|
|
503
|
+
headerExport: number;
|
|
504
|
+
label: number;
|
|
505
|
+
replayMetadata: number;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export interface SessionManagerCheckpointRevisionStrings {
|
|
509
|
+
entry: string;
|
|
510
|
+
leaf: string;
|
|
511
|
+
headerExport: string;
|
|
512
|
+
label: string;
|
|
513
|
+
replayMetadata: string;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function assertMemoryGuardSessionId(sessionId: string): void {
|
|
517
|
+
assertSafePathComponent(sessionId, "memory guard checkpoint session id");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function memoryGuardParticipantRoot(checkpointRoot: string, sessionId: string): string {
|
|
521
|
+
assertMemoryGuardSessionId(sessionId);
|
|
522
|
+
return path.join(checkpointRoot, "participants", sessionId);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function memoryGuardParticipantRelativePath(sessionId: string, suffix: string): string {
|
|
526
|
+
assertMemoryGuardSessionId(sessionId);
|
|
527
|
+
return `participants/${sessionId}/${suffix}`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function fsyncDirectoryPath(directoryPath: string): Promise<void> {
|
|
531
|
+
const directory = await fs.promises.open(directoryPath, "r");
|
|
532
|
+
try {
|
|
533
|
+
await directory.sync();
|
|
534
|
+
} finally {
|
|
535
|
+
await directory.close();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function ensureOwnerOnlyDirectory(directoryPath: string): Promise<void> {
|
|
540
|
+
await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
541
|
+
await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
|
|
542
|
+
await fsyncDirectoryPath(directoryPath);
|
|
543
|
+
}
|
|
544
|
+
interface CreatedDirectoryIdentity {
|
|
545
|
+
dev: bigint;
|
|
546
|
+
ino: bigint;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function ensureOwnerOnlyDirectoryTracked(directoryPath: string): Promise<CreatedDirectoryIdentity | undefined> {
|
|
550
|
+
const createdPath = await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
551
|
+
await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
|
|
552
|
+
await fsyncDirectoryPath(directoryPath);
|
|
553
|
+
if (createdPath === undefined) return undefined;
|
|
554
|
+
const stat = await fs.promises.lstat(directoryPath, { bigint: true });
|
|
555
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function removeCreatedDirectoryIfEmpty(
|
|
559
|
+
directoryPath: string,
|
|
560
|
+
created: CreatedDirectoryIdentity | undefined,
|
|
561
|
+
): Promise<void> {
|
|
562
|
+
if (!created) return;
|
|
563
|
+
const stat = await fs.promises.lstat(directoryPath, { bigint: true }).catch(() => undefined);
|
|
564
|
+
if (!stat || stat.dev !== created.dev || stat.ino !== created.ino) return;
|
|
565
|
+
await fs.promises.rmdir(directoryPath).catch(() => undefined);
|
|
566
|
+
await fsyncDirectoryPath(path.dirname(directoryPath)).catch(() => undefined);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function writeOwnerOnlyFileNoReplace(filePath: string, content: Uint8Array | string): Promise<void> {
|
|
570
|
+
await ensureOwnerOnlyDirectory(path.dirname(filePath));
|
|
571
|
+
const handle = await fs.promises.open(filePath, "wx", 0o600);
|
|
572
|
+
try {
|
|
573
|
+
await handle.writeFile(content);
|
|
574
|
+
await handle.sync();
|
|
575
|
+
} finally {
|
|
576
|
+
await handle.close();
|
|
577
|
+
}
|
|
578
|
+
await fs.promises.chmod(filePath, 0o600).catch(() => undefined);
|
|
579
|
+
await fsyncDirectoryPath(path.dirname(filePath));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES = 64 * 1024 * 1024;
|
|
583
|
+
const MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES = 4096;
|
|
584
|
+
const MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES = 64 * 1024 * 1024;
|
|
585
|
+
|
|
586
|
+
function collectCheckpointBlobRefs(value: unknown, refs: Set<string> = new Set(), key?: string): Set<string> {
|
|
587
|
+
const addRef = (candidate: unknown): void => {
|
|
588
|
+
if (typeof candidate === "string" && parseBlobRef(candidate)) refs.add(candidate);
|
|
589
|
+
};
|
|
590
|
+
if (Array.isArray(value)) {
|
|
591
|
+
for (const item of value) collectCheckpointBlobRefs(item, refs, key);
|
|
592
|
+
return refs;
|
|
593
|
+
}
|
|
594
|
+
if (!value || typeof value !== "object") return refs;
|
|
595
|
+
if (isRecord(value) && value.kind === "cold_spill" && typeof value.ref === "string" && parseBlobRef(value.ref))
|
|
596
|
+
refs.add(value.ref);
|
|
597
|
+
if (isImageBlock(value) && key === TEXT_CONTENT_KEY) addRef(value.data);
|
|
598
|
+
if (hasImageUrl(value)) {
|
|
599
|
+
if (typeof value.image_url === "string") addRef(value.image_url);
|
|
600
|
+
else addRef(value.image_url.url);
|
|
601
|
+
}
|
|
602
|
+
for (const [childKey, item] of Object.entries(value as Record<string, unknown>)) {
|
|
603
|
+
if (childKey === "data" && key !== TEXT_CONTENT_KEY) addRef(item);
|
|
604
|
+
collectCheckpointBlobRefs(item, refs, childKey);
|
|
605
|
+
}
|
|
606
|
+
return refs;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function decodeCheckpointUtf8(data: Uint8Array): string | null {
|
|
610
|
+
try {
|
|
611
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
612
|
+
} catch {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function validateMemoryGuardBlobAuthority(value: unknown): value is MemoryGuardCheckpointBlobAuthorityV1 {
|
|
618
|
+
if (
|
|
619
|
+
!isRecord(value) ||
|
|
620
|
+
!hasOnlyMemoryGuardKeys(value, ["kind", "manifest_relative_path", "manifest_sha256", "root_relative_path"])
|
|
621
|
+
)
|
|
622
|
+
return false;
|
|
623
|
+
return (
|
|
624
|
+
value.kind === "checkpoint_blob_tree_v1" &&
|
|
625
|
+
isMemoryGuardRelativePath(value.manifest_relative_path) &&
|
|
626
|
+
isMemoryGuardSha256Hex(value.manifest_sha256) &&
|
|
627
|
+
isMemoryGuardRelativePath(value.root_relative_path)
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function validateMemoryGuardTranscriptDescriptor(
|
|
632
|
+
value: unknown,
|
|
633
|
+
): value is MemoryGuardSessionManagerCheckpointV1["transcript"] {
|
|
634
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
|
|
635
|
+
return (
|
|
636
|
+
isMemoryGuardDecimalString(value.bytes) &&
|
|
637
|
+
isMemoryGuardRelativePath(value.relative_path) &&
|
|
638
|
+
isMemoryGuardSha256Hex(value.sha256)
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function validateMemoryGuardRevisions(value: unknown): value is SessionManagerCheckpointRevisionStrings {
|
|
643
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entry", "leaf", "headerExport", "label", "replayMetadata"]))
|
|
644
|
+
return false;
|
|
645
|
+
return (
|
|
646
|
+
isMemoryGuardDecimalString(value.entry) &&
|
|
647
|
+
isMemoryGuardDecimalString(value.leaf) &&
|
|
648
|
+
isMemoryGuardDecimalString(value.headerExport) &&
|
|
649
|
+
isMemoryGuardDecimalString(value.label) &&
|
|
650
|
+
isMemoryGuardDecimalString(value.replayMetadata)
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function validateMemoryGuardCheckpoint(value: unknown): value is MemoryGuardSessionManagerCheckpointV1 {
|
|
655
|
+
if (
|
|
656
|
+
!isRecord(value) ||
|
|
657
|
+
!hasOnlyMemoryGuardKeys(value, [
|
|
658
|
+
"blob_authority",
|
|
659
|
+
"revisions",
|
|
660
|
+
"schema_version",
|
|
661
|
+
"session_id",
|
|
662
|
+
"session_name",
|
|
663
|
+
"transcript",
|
|
664
|
+
])
|
|
665
|
+
)
|
|
666
|
+
return false;
|
|
667
|
+
return (
|
|
668
|
+
value.schema_version === 1 &&
|
|
669
|
+
typeof value.session_id === "string" &&
|
|
670
|
+
value.session_id.length > 0 &&
|
|
671
|
+
(value.session_name === null || typeof value.session_name === "string") &&
|
|
672
|
+
validateMemoryGuardBlobAuthority(value.blob_authority) &&
|
|
673
|
+
validateMemoryGuardRevisions(value.revisions) &&
|
|
674
|
+
validateMemoryGuardTranscriptDescriptor(value.transcript)
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function validateMemoryGuardBlobManifestEntry(value: unknown): value is MemoryGuardCheckpointBlobManifestEntryV1 {
|
|
679
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
|
|
680
|
+
return (
|
|
681
|
+
isMemoryGuardDecimalString(value.bytes) &&
|
|
682
|
+
isMemoryGuardRelativePath(value.relative_path) &&
|
|
683
|
+
isMemoryGuardSha256Hex(value.sha256)
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function validateMemoryGuardBlobManifest(value: unknown): value is MemoryGuardCheckpointBlobManifestV1 {
|
|
688
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entries", "schema_version"])) return false;
|
|
689
|
+
if (value.schema_version !== 1 || !Array.isArray(value.entries)) return false;
|
|
690
|
+
return value.entries.every(validateMemoryGuardBlobManifestEntry);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function memoryGuardParticipantMatchesCheckpoint(
|
|
694
|
+
participant: MemoryGuardParticipantDescriptorV1,
|
|
695
|
+
checkpoint: MemoryGuardSessionManagerCheckpointV1,
|
|
696
|
+
): boolean {
|
|
697
|
+
return (
|
|
698
|
+
participant.session_id === checkpoint.session_id &&
|
|
699
|
+
participant.session_name === checkpoint.session_name &&
|
|
700
|
+
JSON.stringify(participant.checkpoint) === JSON.stringify(checkpoint.blob_authority) &&
|
|
701
|
+
JSON.stringify(participant.revisions) === JSON.stringify(checkpoint.revisions) &&
|
|
702
|
+
JSON.stringify(participant.transcript) === JSON.stringify(checkpoint.transcript)
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function readCheckpointAuthorityFile(
|
|
707
|
+
authority: native.RecoveryFsRoot,
|
|
708
|
+
relativePath: string,
|
|
709
|
+
maxBytes: number,
|
|
710
|
+
): Uint8Array | null {
|
|
711
|
+
const result = authority.readManaged(relativePath);
|
|
712
|
+
if (!result.ok || !result.data || result.data.byteLength > maxBytes) return null;
|
|
713
|
+
return result.data;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function toCanonicalRevisionString(name: keyof SessionManagerRevisionSnapshot, value: number): string {
|
|
717
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_session_manager_revision:${name}`);
|
|
718
|
+
return String(value);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function toSessionManagerCheckpointRevisionStrings(
|
|
722
|
+
snapshot: SessionManagerRevisionSnapshot,
|
|
723
|
+
): SessionManagerCheckpointRevisionStrings {
|
|
724
|
+
return {
|
|
725
|
+
entry: toCanonicalRevisionString("entry", snapshot.entry),
|
|
726
|
+
leaf: toCanonicalRevisionString("leaf", snapshot.leaf),
|
|
727
|
+
headerExport: toCanonicalRevisionString("headerExport", snapshot.headerExport),
|
|
728
|
+
label: toCanonicalRevisionString("label", snapshot.label),
|
|
729
|
+
replayMetadata: toCanonicalRevisionString("replayMetadata", snapshot.replayMetadata),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
453
732
|
|
|
454
733
|
export type DefaultModelSelectionPromotion =
|
|
455
734
|
| { readonly kind: "promoted" }
|
|
@@ -3476,6 +3755,81 @@ class NdjsonFileWriter {
|
|
|
3476
3755
|
}
|
|
3477
3756
|
}
|
|
3478
3757
|
|
|
3758
|
+
const PROJECT_SESSION_SCAN_MAX_DIRECTORIES = 4096;
|
|
3759
|
+
const PROJECT_SESSION_SCAN_MAX_FILES = 1000;
|
|
3760
|
+
|
|
3761
|
+
function isProjectSessionTranscriptPath(projectGjcDir: string, filePath: string): boolean {
|
|
3762
|
+
const relative = path.relative(projectGjcDir, filePath);
|
|
3763
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
|
|
3764
|
+
const segments = relative.split(path.sep);
|
|
3765
|
+
if (segments.length === 1) return true;
|
|
3766
|
+
const parent = segments.at(-2);
|
|
3767
|
+
return parent === "agent-session" || segments.includes("sessions");
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
/**
|
|
3771
|
+
* Discover resumable transcripts intentionally stored inside a project's `.gjc`.
|
|
3772
|
+
* Runtime token/audit JSONL files are excluded by requiring a known transcript
|
|
3773
|
+
* container (`agent-session` or `sessions`).
|
|
3774
|
+
*/
|
|
3775
|
+
function listProjectSessionTranscriptFiles(cwd: string): string[] {
|
|
3776
|
+
const projectGjcDir = path.join(path.resolve(cwd), ".gjc");
|
|
3777
|
+
let rootStat: fs.Stats;
|
|
3778
|
+
try {
|
|
3779
|
+
rootStat = fs.lstatSync(projectGjcDir);
|
|
3780
|
+
} catch {
|
|
3781
|
+
return [];
|
|
3782
|
+
}
|
|
3783
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return [];
|
|
3784
|
+
|
|
3785
|
+
const directories = [projectGjcDir];
|
|
3786
|
+
const files: string[] = [];
|
|
3787
|
+
let scannedDirectories = 0;
|
|
3788
|
+
while (directories.length > 0 && scannedDirectories < PROJECT_SESSION_SCAN_MAX_DIRECTORIES) {
|
|
3789
|
+
const directory = directories.pop()!;
|
|
3790
|
+
scannedDirectories++;
|
|
3791
|
+
let entries: fs.Dirent[];
|
|
3792
|
+
try {
|
|
3793
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
3794
|
+
} catch {
|
|
3795
|
+
continue;
|
|
3796
|
+
}
|
|
3797
|
+
for (const entry of entries) {
|
|
3798
|
+
if (entry.isSymbolicLink()) continue;
|
|
3799
|
+
const entryPath = path.join(directory, entry.name);
|
|
3800
|
+
if (entry.isDirectory()) {
|
|
3801
|
+
directories.push(entryPath);
|
|
3802
|
+
continue;
|
|
3803
|
+
}
|
|
3804
|
+
if (
|
|
3805
|
+
entry.isFile() &&
|
|
3806
|
+
!entry.name.startsWith(".") &&
|
|
3807
|
+
entry.name.endsWith(".jsonl") &&
|
|
3808
|
+
isProjectSessionTranscriptPath(projectGjcDir, entryPath)
|
|
3809
|
+
) {
|
|
3810
|
+
files.push(entryPath);
|
|
3811
|
+
if (files.length >= PROJECT_SESSION_SCAN_MAX_FILES) return files;
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
return files;
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
async function collectProjectSessions(cwd: string, storage: FileSessionStorage): Promise<SessionInfo[]> {
|
|
3819
|
+
return await collectSessionsFromFiles(listProjectSessionTranscriptFiles(cwd), storage);
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
function mergeSessionInventories(...inventories: SessionInfo[][]): SessionInfo[] {
|
|
3823
|
+
const sessions = new Map<string, SessionInfo>();
|
|
3824
|
+
for (const inventory of inventories) {
|
|
3825
|
+
for (const session of inventory) {
|
|
3826
|
+
const current = sessions.get(session.id);
|
|
3827
|
+
if (!current || session.modified.getTime() > current.modified.getTime()) sessions.set(session.id, session);
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
return [...sessions.values()].sort((left, right) => right.modified.getTime() - left.modified.getTime());
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3479
3833
|
const DEFAULT_WELCOME_RECENT_SESSION_LIMIT = 20;
|
|
3480
3834
|
|
|
3481
3835
|
/** Get recent sessions for display in welcome screen */
|
|
@@ -3487,6 +3841,21 @@ export async function getRecentSessions(
|
|
|
3487
3841
|
return getSortedSessions(sessionDir, storage, limit);
|
|
3488
3842
|
}
|
|
3489
3843
|
|
|
3844
|
+
export function getRecentSessionDisplay(
|
|
3845
|
+
sessions: readonly SessionInfo[],
|
|
3846
|
+
limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
|
|
3847
|
+
): Array<{ name: string; timeAgo: string }> {
|
|
3848
|
+
return sessions.slice(0, limit).map(session => {
|
|
3849
|
+
const recent = new RecentSessionInfo(
|
|
3850
|
+
session.path,
|
|
3851
|
+
session.modified.getTime(),
|
|
3852
|
+
{ title: session.title, timestamp: session.created.toISOString() },
|
|
3853
|
+
session.firstMessage,
|
|
3854
|
+
);
|
|
3855
|
+
return { name: recent.name, timeAgo: recent.timeAgo };
|
|
3856
|
+
});
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3490
3859
|
/**
|
|
3491
3860
|
* Manages conversation sessions as append-only trees stored in JSONL files.
|
|
3492
3861
|
*
|
|
@@ -3977,6 +4346,7 @@ export class SessionManager {
|
|
|
3977
4346
|
#sessionId: string = "";
|
|
3978
4347
|
/** True once a lifecycle pre-allocated id has been adopted (consume-once). */
|
|
3979
4348
|
#lifecycleIdAdopted: boolean = false;
|
|
4349
|
+
#preparedNewSessions = new Set<PreparedNewSessionState>();
|
|
3980
4350
|
#sessionName: string | undefined;
|
|
3981
4351
|
#titleSource: "auto" | "user" | undefined;
|
|
3982
4352
|
#sessionFile: string | undefined;
|
|
@@ -3984,6 +4354,8 @@ export class SessionManager {
|
|
|
3984
4354
|
#needsFullRewriteOnNextPersist: boolean = false;
|
|
3985
4355
|
#ensuredOnDisk: boolean = false;
|
|
3986
4356
|
#recoveryHydrationContext: RecoveryHydrationContext | undefined;
|
|
4357
|
+
#recoveryPromotionTranscriptPath: string | undefined;
|
|
4358
|
+
#memoryGuardParticipantIngressToken: symbol | undefined;
|
|
3987
4359
|
#fileEntries: FileEntry[] = [];
|
|
3988
4360
|
#pendingStrictAdoption: { canonicalPath: string; identity: ResumeSessionIdentity } | undefined;
|
|
3989
4361
|
#byId: Map<string, SessionEntry> = new Map();
|
|
@@ -4002,6 +4374,8 @@ export class SessionManager {
|
|
|
4002
4374
|
#persistChain: Promise<void> = Promise.resolve();
|
|
4003
4375
|
#persistError: Error | undefined;
|
|
4004
4376
|
#persistErrorReported = false;
|
|
4377
|
+
/** Failed staged persistence retains its exact writer and temporary pathname for retryable cleanup. */
|
|
4378
|
+
#preparedNewSessionCleanupInProgress = false;
|
|
4005
4379
|
|
|
4006
4380
|
#artifactManager: ArtifactManager | null = null;
|
|
4007
4381
|
#artifactManagerSessionFile: string | null = null;
|
|
@@ -4016,7 +4390,8 @@ export class SessionManager {
|
|
|
4016
4390
|
#inMemoryArtifactCounter = 0;
|
|
4017
4391
|
readonly #blobStore: BlobStore;
|
|
4018
4392
|
#residentTextBlobStore: BlobStore = new MemoryBlobStore();
|
|
4019
|
-
|
|
4393
|
+
#residentImageBlobStore: BlobStore;
|
|
4394
|
+
#memoryGuardCheckpointBlobs: Map<string, Buffer> | undefined;
|
|
4020
4395
|
#entryRevision = 0;
|
|
4021
4396
|
#leafRevision = 0;
|
|
4022
4397
|
/** Export/header cache invalidation contract; consumers may arrive after the revision field. */
|
|
@@ -4089,6 +4464,9 @@ export class SessionManager {
|
|
|
4089
4464
|
},
|
|
4090
4465
|
};
|
|
4091
4466
|
}
|
|
4467
|
+
#coldSpillReadStore(): BlobStore {
|
|
4468
|
+
return this.#memoryGuardCheckpointBlobs ? this.#residentImageBlobStore : this.#blobStore;
|
|
4469
|
+
}
|
|
4092
4470
|
|
|
4093
4471
|
#residentCacheDir(sessionFile: string): string {
|
|
4094
4472
|
const instance = ++residentCacheInstanceCounter;
|
|
@@ -4106,6 +4484,21 @@ export class SessionManager {
|
|
|
4106
4484
|
this.#buildIndex();
|
|
4107
4485
|
}
|
|
4108
4486
|
|
|
4487
|
+
#preparePreparedNewSessionForCommit(stage: PreparedNewSessionState): void {
|
|
4488
|
+
const residentTextBlobStore = new MemoryBlobStore();
|
|
4489
|
+
const residentFileEntries = stage.fileEntries.map(entry =>
|
|
4490
|
+
prepareEntryForResidentSync(entry, {
|
|
4491
|
+
textStore: residentTextBlobStore,
|
|
4492
|
+
imageStore: this.#residentImageBlobStore,
|
|
4493
|
+
sessionId: stage.sessionId,
|
|
4494
|
+
sessionFile: stage.sessionFile,
|
|
4495
|
+
}),
|
|
4496
|
+
);
|
|
4497
|
+
stage.residentTextBlobStore = residentTextBlobStore;
|
|
4498
|
+
stage.residentFileEntries = residentFileEntries;
|
|
4499
|
+
stage.index = this.#buildIndexForEntries(residentFileEntries, stage.sessionFile);
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4109
4502
|
#resetMaterializedCaches(): void {
|
|
4110
4503
|
this.#materializedEntriesRevision = -1;
|
|
4111
4504
|
this.#materializedEntriesCache = undefined;
|
|
@@ -4130,13 +4523,7 @@ export class SessionManager {
|
|
|
4130
4523
|
* revision contract). Tests assert the invalidation mapping through this;
|
|
4131
4524
|
* future export/label-view caches key off their respective domains.
|
|
4132
4525
|
*/
|
|
4133
|
-
revisionSnapshot(): {
|
|
4134
|
-
entry: number;
|
|
4135
|
-
leaf: number;
|
|
4136
|
-
headerExport: number;
|
|
4137
|
-
label: number;
|
|
4138
|
-
replayMetadata: number;
|
|
4139
|
-
} {
|
|
4526
|
+
revisionSnapshot(): SessionManagerRevisionSnapshot {
|
|
4140
4527
|
return {
|
|
4141
4528
|
entry: this.#entryRevision,
|
|
4142
4529
|
leaf: this.#leafRevision,
|
|
@@ -4260,6 +4647,7 @@ export class SessionManager {
|
|
|
4260
4647
|
|
|
4261
4648
|
/** Switch to a different session file (used for resume and branching) */
|
|
4262
4649
|
async setSessionFile(sessionFile: string): Promise<void> {
|
|
4650
|
+
this.#assertRecoveryHydrationWritable();
|
|
4263
4651
|
const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
|
|
4264
4652
|
const strictAdoption = this.#pendingStrictAdoption;
|
|
4265
4653
|
this.#pendingStrictAdoption = undefined;
|
|
@@ -4313,10 +4701,496 @@ export class SessionManager {
|
|
|
4313
4701
|
|
|
4314
4702
|
/** Start a new session. Closes any existing writer first. */
|
|
4315
4703
|
async newSession(options?: NewSessionOptions): Promise<string | undefined> {
|
|
4704
|
+
this.#assertRecoveryHydrationWritable();
|
|
4705
|
+
const prepared = await this.prepareNewSession(options);
|
|
4706
|
+
try {
|
|
4707
|
+
this.commitPreparedNewSession(prepared);
|
|
4708
|
+
} catch (error) {
|
|
4709
|
+
try {
|
|
4710
|
+
await this.discardPreparedNewSession(prepared);
|
|
4711
|
+
} catch (cleanupError) {
|
|
4712
|
+
throw new AggregateError(
|
|
4713
|
+
[error, cleanupError],
|
|
4714
|
+
"New session adoption and staged session cleanup both failed.",
|
|
4715
|
+
);
|
|
4716
|
+
}
|
|
4717
|
+
throw error;
|
|
4718
|
+
}
|
|
4719
|
+
return prepared.sessionFile;
|
|
4720
|
+
}
|
|
4721
|
+
|
|
4722
|
+
/**
|
|
4723
|
+
* Allocate a fresh successor without publishing it through the manager's public
|
|
4724
|
+
* getters. The returned authority is deliberately immutable so readiness work
|
|
4725
|
+
* can resolve local:// against the successor while the predecessor stays live.
|
|
4726
|
+
* @internal
|
|
4727
|
+
*/
|
|
4728
|
+
async prepareNewSession(options?: NewSessionOptions): Promise<PreparedNewSession> {
|
|
4729
|
+
this.#assertRecoveryHydrationWritable();
|
|
4730
|
+
await this.#retryPreparedNewSessionCleanups();
|
|
4316
4731
|
await this.#closePersistWriter();
|
|
4317
|
-
const
|
|
4732
|
+
const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
|
|
4733
|
+
const sessionId = preallocated ?? createSessionId();
|
|
4734
|
+
const timestamp = new Date().toISOString();
|
|
4735
|
+
const sessionFile = this.persist
|
|
4736
|
+
? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
|
|
4737
|
+
: undefined;
|
|
4738
|
+
const header: SessionHeader = {
|
|
4739
|
+
type: "session",
|
|
4740
|
+
version: CURRENT_SESSION_VERSION,
|
|
4741
|
+
id: sessionId,
|
|
4742
|
+
timestamp,
|
|
4743
|
+
cwd: this.cwd,
|
|
4744
|
+
parentSession: options?.parentSession,
|
|
4745
|
+
};
|
|
4746
|
+
const stage: PreparedNewSessionState = {
|
|
4747
|
+
sessionId,
|
|
4748
|
+
sessionFile,
|
|
4749
|
+
artifactsDir: sessionFile ? sessionFile.slice(0, -6) : null,
|
|
4750
|
+
managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
|
|
4751
|
+
header,
|
|
4752
|
+
fileEntries: [header],
|
|
4753
|
+
sessionName: undefined,
|
|
4754
|
+
titleSource: undefined,
|
|
4755
|
+
flushed: false,
|
|
4756
|
+
committed: false,
|
|
4757
|
+
discarded: false,
|
|
4758
|
+
};
|
|
4759
|
+
this.#preparedNewSessions.add(stage);
|
|
4760
|
+
this.#freezePreparedNewSessionIdentity(stage);
|
|
4761
|
+
return stage;
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
/** Append a model selection to an unpublished successor. @internal */
|
|
4765
|
+
appendPreparedModelChange(prepared: PreparedNewSession, model: string): string {
|
|
4766
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4767
|
+
const entry: ModelChangeEntry = {
|
|
4768
|
+
type: "model_change",
|
|
4769
|
+
id: this.#nextPreparedNewSessionEntryId(stage),
|
|
4770
|
+
parentId: this.#preparedNewSessionLeafId(stage),
|
|
4771
|
+
timestamp: new Date().toISOString(),
|
|
4772
|
+
model,
|
|
4773
|
+
};
|
|
4774
|
+
stage.fileEntries.push(entry);
|
|
4775
|
+
return entry.id;
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4778
|
+
/** Append a thinking-level selection to an unpublished successor. @internal */
|
|
4779
|
+
appendPreparedThinkingLevelChange(prepared: PreparedNewSession, thinkingLevel?: string): string {
|
|
4780
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4781
|
+
const entry: ThinkingLevelChangeEntry = {
|
|
4782
|
+
type: "thinking_level_change",
|
|
4783
|
+
id: this.#nextPreparedNewSessionEntryId(stage),
|
|
4784
|
+
parentId: this.#preparedNewSessionLeafId(stage),
|
|
4785
|
+
timestamp: new Date().toISOString(),
|
|
4786
|
+
thinkingLevel: thinkingLevel ?? null,
|
|
4787
|
+
};
|
|
4788
|
+
stage.fileEntries.push(entry);
|
|
4789
|
+
return entry.id;
|
|
4790
|
+
}
|
|
4791
|
+
|
|
4792
|
+
/** Append a service-tier selection to an unpublished successor. @internal */
|
|
4793
|
+
appendPreparedServiceTierChange(prepared: PreparedNewSession, serviceTier: ServiceTier | null): string {
|
|
4794
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4795
|
+
const entry: ServiceTierChangeEntry = {
|
|
4796
|
+
type: "service_tier_change",
|
|
4797
|
+
id: this.#nextPreparedNewSessionEntryId(stage),
|
|
4798
|
+
parentId: this.#preparedNewSessionLeafId(stage),
|
|
4799
|
+
timestamp: new Date().toISOString(),
|
|
4800
|
+
serviceTier,
|
|
4801
|
+
};
|
|
4802
|
+
stage.fileEntries.push(entry);
|
|
4803
|
+
return entry.id;
|
|
4804
|
+
}
|
|
4805
|
+
|
|
4806
|
+
/** Append a displayable custom message to an unpublished successor. @internal */
|
|
4807
|
+
appendPreparedCustomMessageEntry<T = unknown>(
|
|
4808
|
+
prepared: PreparedNewSession,
|
|
4809
|
+
customType: string,
|
|
4810
|
+
content: string | (TextContent | ImageContent)[],
|
|
4811
|
+
display: boolean,
|
|
4812
|
+
details?: T,
|
|
4813
|
+
attribution: MessageAttribution = "agent",
|
|
4814
|
+
): string {
|
|
4815
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4816
|
+
const entry: CustomMessageEntry<T> = {
|
|
4817
|
+
type: "custom_message",
|
|
4818
|
+
customType,
|
|
4819
|
+
content,
|
|
4820
|
+
display,
|
|
4821
|
+
details: stripInternalDetailsFields(details),
|
|
4822
|
+
attribution,
|
|
4823
|
+
id: this.#nextPreparedNewSessionEntryId(stage),
|
|
4824
|
+
parentId: this.#preparedNewSessionLeafId(stage),
|
|
4825
|
+
timestamp: new Date().toISOString(),
|
|
4826
|
+
};
|
|
4827
|
+
stage.fileEntries.push(entry);
|
|
4828
|
+
return entry.id;
|
|
4829
|
+
}
|
|
4830
|
+
|
|
4831
|
+
/** Persist an unpublished successor without adopting it. @internal */
|
|
4832
|
+
async ensurePreparedNewSessionOnDisk(prepared: PreparedNewSession): Promise<void> {
|
|
4833
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4834
|
+
if (!this.persist || !stage.sessionFile || stage.flushed) return;
|
|
4835
|
+
const entries = await Promise.all(
|
|
4836
|
+
stage.fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
|
|
4837
|
+
);
|
|
4838
|
+
if (this.destination.kind === "managed") {
|
|
4839
|
+
const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
|
4840
|
+
await this.#managedTranscriptStore(stage.sessionFile).replace(path.basename(stage.sessionFile), bytes);
|
|
4841
|
+
} else {
|
|
4842
|
+
const staleCleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
|
|
4843
|
+
if (staleCleanupError) throw staleCleanupError;
|
|
4844
|
+
const dir = path.resolve(stage.sessionFile, "..");
|
|
4845
|
+
const tempPath = path.join(dir, `.${path.basename(stage.sessionFile)}.${Snowflake.next()}.tmp`);
|
|
4846
|
+
const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
|
|
4847
|
+
stage.persistenceTempPath = tempPath;
|
|
4848
|
+
stage.persistenceWriter = writer;
|
|
4849
|
+
try {
|
|
4850
|
+
for (const entry of entries) await writer.write(entry);
|
|
4851
|
+
await writer.flush();
|
|
4852
|
+
await writer.fsync();
|
|
4853
|
+
await writer.close();
|
|
4854
|
+
stage.persistenceWriter = undefined;
|
|
4855
|
+
await this.#replaceSessionFile(tempPath, stage.sessionFile);
|
|
4856
|
+
stage.persistenceTempPath = undefined;
|
|
4857
|
+
} catch (error) {
|
|
4858
|
+
const cleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
|
|
4859
|
+
if (cleanupError)
|
|
4860
|
+
throw new AggregateError(
|
|
4861
|
+
[toError(error), cleanupError],
|
|
4862
|
+
"Prepared session persistence and temporary-file cleanup both failed.",
|
|
4863
|
+
);
|
|
4864
|
+
throw toError(error);
|
|
4865
|
+
}
|
|
4866
|
+
}
|
|
4867
|
+
stage.flushed = true;
|
|
4868
|
+
}
|
|
4869
|
+
|
|
4870
|
+
/** Build context from an unpublished successor without reading active manager state. @internal */
|
|
4871
|
+
buildPreparedNewSessionContext(prepared: PreparedNewSession): SessionContext {
|
|
4872
|
+
const stage = this.#getPreparedNewSessionStage(prepared);
|
|
4873
|
+
return buildSessionContext(
|
|
4874
|
+
stage.fileEntries.filter((entry): entry is SessionEntry => entry.type !== "session"),
|
|
4875
|
+
undefined,
|
|
4876
|
+
undefined,
|
|
4877
|
+
stage.sessionId,
|
|
4878
|
+
);
|
|
4879
|
+
}
|
|
4880
|
+
|
|
4881
|
+
#freezePreparedNewSessionIdentity(stage: PreparedNewSessionState): void {
|
|
4882
|
+
// Prevent identity ABA: callers must not rebind staged sessionId/file after prepare.
|
|
4883
|
+
Object.defineProperty(stage, "sessionId", {
|
|
4884
|
+
value: stage.sessionId,
|
|
4885
|
+
writable: false,
|
|
4886
|
+
enumerable: true,
|
|
4887
|
+
configurable: false,
|
|
4888
|
+
});
|
|
4889
|
+
Object.defineProperty(stage, "sessionFile", {
|
|
4890
|
+
value: stage.sessionFile,
|
|
4891
|
+
writable: false,
|
|
4892
|
+
enumerable: true,
|
|
4893
|
+
configurable: false,
|
|
4894
|
+
});
|
|
4895
|
+
Object.defineProperty(stage, "artifactsDir", {
|
|
4896
|
+
value: stage.artifactsDir,
|
|
4897
|
+
writable: false,
|
|
4898
|
+
enumerable: true,
|
|
4899
|
+
configurable: false,
|
|
4900
|
+
});
|
|
4901
|
+
Object.defineProperty(stage, "managedLegacyLocalMigrationSource", {
|
|
4902
|
+
value: stage.managedLegacyLocalMigrationSource,
|
|
4903
|
+
writable: false,
|
|
4904
|
+
enumerable: true,
|
|
4905
|
+
configurable: false,
|
|
4906
|
+
});
|
|
4907
|
+
}
|
|
4908
|
+
|
|
4909
|
+
#getPreparedNewSessionStage(prepared: PreparedNewSession): PreparedNewSessionState {
|
|
4910
|
+
const stage = prepared as PreparedNewSessionState;
|
|
4911
|
+
if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded)
|
|
4912
|
+
throw new Error("Prepared session is no longer available.");
|
|
4913
|
+
return stage;
|
|
4914
|
+
}
|
|
4915
|
+
|
|
4916
|
+
#preparedNewSessionLeafId(stage: PreparedNewSessionState): string | null {
|
|
4917
|
+
for (let index = stage.fileEntries.length - 1; index >= 0; index--) {
|
|
4918
|
+
const entry = stage.fileEntries[index];
|
|
4919
|
+
if (entry.type !== "session") return entry.id;
|
|
4920
|
+
}
|
|
4921
|
+
return null;
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4924
|
+
#nextPreparedNewSessionEntryId(stage: PreparedNewSessionState): string {
|
|
4925
|
+
const ids = new Map<string, SessionEntry>();
|
|
4926
|
+
for (const entry of stage.fileEntries) {
|
|
4927
|
+
if (entry.type !== "session") ids.set(entry.id, entry);
|
|
4928
|
+
}
|
|
4929
|
+
return generateId(ids);
|
|
4930
|
+
}
|
|
4931
|
+
|
|
4932
|
+
/** Prepare a forked successor without publishing it through public manager state. @internal */
|
|
4933
|
+
async prepareFork(): Promise<PreparedNewSession | undefined> {
|
|
4934
|
+
await this.#retryPreparedNewSessionCleanups();
|
|
4935
|
+
if (!this.persist || !this.#sessionFile) return undefined;
|
|
4936
|
+
await this.#closePersistWriter();
|
|
4937
|
+
const sourceFile = this.#sessionFile;
|
|
4938
|
+
const sourceId = this.#sessionId;
|
|
4939
|
+
const timestamp = new Date().toISOString();
|
|
4940
|
+
const sessionId = createSessionId();
|
|
4941
|
+
const sessionFile = path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
|
|
4942
|
+
const header: SessionHeader = {
|
|
4943
|
+
type: "session",
|
|
4944
|
+
version: CURRENT_SESSION_VERSION,
|
|
4945
|
+
id: sessionId,
|
|
4946
|
+
title: this.#sessionName,
|
|
4947
|
+
titleSource: this.#titleSource,
|
|
4948
|
+
timestamp,
|
|
4949
|
+
cwd: this.cwd,
|
|
4950
|
+
parentSession: sourceId,
|
|
4951
|
+
};
|
|
4952
|
+
const entries: FileEntry[] = [
|
|
4953
|
+
header,
|
|
4954
|
+
...materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores()).filter(
|
|
4955
|
+
(entry): entry is SessionEntry => entry.type !== "session",
|
|
4956
|
+
),
|
|
4957
|
+
];
|
|
4958
|
+
const stage: PreparedNewSessionState = {
|
|
4959
|
+
sessionId,
|
|
4960
|
+
sessionFile,
|
|
4961
|
+
artifactsDir: sessionFile.slice(0, -6),
|
|
4962
|
+
managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
|
|
4963
|
+
header,
|
|
4964
|
+
fileEntries: entries,
|
|
4965
|
+
sessionName: this.#sessionName,
|
|
4966
|
+
titleSource: this.#titleSource,
|
|
4967
|
+
flushed: true,
|
|
4968
|
+
committed: false,
|
|
4969
|
+
discarded: false,
|
|
4970
|
+
};
|
|
4971
|
+
this.#preparedNewSessions.add(stage);
|
|
4972
|
+
this.#freezePreparedNewSessionIdentity(stage);
|
|
4973
|
+
try {
|
|
4974
|
+
await this.copyArtifactsForFork(sourceFile, sessionFile);
|
|
4975
|
+
const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
|
|
4976
|
+
if (this.destination.kind === "managed") {
|
|
4977
|
+
this.#managedTranscriptStore(sessionFile).publishNoReplaceSync(
|
|
4978
|
+
path.basename(sessionFile),
|
|
4979
|
+
Buffer.from(content, "utf8"),
|
|
4980
|
+
);
|
|
4981
|
+
} else {
|
|
4982
|
+
this.storage.writeTextSync(sessionFile, content);
|
|
4983
|
+
}
|
|
4984
|
+
} catch (error) {
|
|
4985
|
+
try {
|
|
4986
|
+
await this.discardPreparedNewSession(stage);
|
|
4987
|
+
} catch (cleanupError) {
|
|
4988
|
+
throw new AggregateError(
|
|
4989
|
+
[toError(error), toError(cleanupError)],
|
|
4990
|
+
"Fork preparation and staged session cleanup both failed.",
|
|
4991
|
+
);
|
|
4992
|
+
}
|
|
4993
|
+
throw toError(error);
|
|
4994
|
+
}
|
|
4995
|
+
return stage;
|
|
4996
|
+
}
|
|
4997
|
+
|
|
4998
|
+
/** Prepare a path-only branch successor without publishing it through public manager state. @internal */
|
|
4999
|
+
async prepareBranchedSession(leafId: string): Promise<PreparedNewSession> {
|
|
5000
|
+
this.#assertRecoveryHydrationWritable();
|
|
5001
|
+
await this.#retryPreparedNewSessionCleanups();
|
|
5002
|
+
const branchPath = this.#getCanonicalBranchClones(leafId);
|
|
5003
|
+
if (branchPath.length === 0) throw new Error(`Entry ${leafId} not found`);
|
|
5004
|
+
await this.#closePersistWriter();
|
|
5005
|
+
const timestamp = new Date().toISOString();
|
|
5006
|
+
const sessionId = createSessionId();
|
|
5007
|
+
const sessionFile = this.persist
|
|
5008
|
+
? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
|
|
5009
|
+
: undefined;
|
|
5010
|
+
const header: SessionHeader = {
|
|
5011
|
+
type: "session",
|
|
5012
|
+
version: CURRENT_SESSION_VERSION,
|
|
5013
|
+
id: sessionId,
|
|
5014
|
+
timestamp,
|
|
5015
|
+
cwd: this.cwd,
|
|
5016
|
+
parentSession: this.persist ? this.#sessionFile : undefined,
|
|
5017
|
+
};
|
|
5018
|
+
const pathWithoutLabels = branchPath.filter(entry => entry.type !== "label");
|
|
5019
|
+
const pathEntryIds = new Set(pathWithoutLabels.map(entry => entry.id));
|
|
5020
|
+
const labelEntries: LabelEntry[] = [];
|
|
5021
|
+
let labelParentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id ?? null;
|
|
5022
|
+
for (const [targetId, label] of this.#labelsById) {
|
|
5023
|
+
if (!pathEntryIds.has(targetId)) continue;
|
|
5024
|
+
const labelEntry: LabelEntry = {
|
|
5025
|
+
type: "label",
|
|
5026
|
+
id: generateId({ has: id => pathEntryIds.has(id) }),
|
|
5027
|
+
parentId: labelParentId,
|
|
5028
|
+
timestamp: new Date().toISOString(),
|
|
5029
|
+
targetId,
|
|
5030
|
+
label,
|
|
5031
|
+
};
|
|
5032
|
+
pathEntryIds.add(labelEntry.id);
|
|
5033
|
+
labelEntries.push(labelEntry);
|
|
5034
|
+
labelParentId = labelEntry.id;
|
|
5035
|
+
}
|
|
5036
|
+
const entries: FileEntry[] = [
|
|
5037
|
+
header,
|
|
5038
|
+
...materializeResidentEntriesForReadSync(pathWithoutLabels, this.#residentBlobStores()),
|
|
5039
|
+
...labelEntries,
|
|
5040
|
+
];
|
|
5041
|
+
const stage: PreparedNewSessionState = {
|
|
5042
|
+
sessionId,
|
|
5043
|
+
sessionFile,
|
|
5044
|
+
artifactsDir: sessionFile ? sessionFile.slice(0, -6) : null,
|
|
5045
|
+
managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
|
|
5046
|
+
header,
|
|
5047
|
+
fileEntries: entries,
|
|
5048
|
+
sessionName: undefined,
|
|
5049
|
+
titleSource: undefined,
|
|
5050
|
+
flushed: this.persist,
|
|
5051
|
+
committed: false,
|
|
5052
|
+
discarded: false,
|
|
5053
|
+
};
|
|
5054
|
+
this.#preparedNewSessions.add(stage);
|
|
5055
|
+
this.#freezePreparedNewSessionIdentity(stage);
|
|
5056
|
+
try {
|
|
5057
|
+
if (sessionFile) {
|
|
5058
|
+
const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
|
|
5059
|
+
if (this.destination.kind === "managed") {
|
|
5060
|
+
this.#managedTranscriptStore(sessionFile).publishNoReplaceSync(
|
|
5061
|
+
path.basename(sessionFile),
|
|
5062
|
+
Buffer.from(content, "utf8"),
|
|
5063
|
+
);
|
|
5064
|
+
} else {
|
|
5065
|
+
this.storage.writeTextSync(sessionFile, content);
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
5068
|
+
return stage;
|
|
5069
|
+
} catch (error) {
|
|
5070
|
+
try {
|
|
5071
|
+
await this.discardPreparedNewSession(stage);
|
|
5072
|
+
} catch (cleanupError) {
|
|
5073
|
+
throw new AggregateError(
|
|
5074
|
+
[error, cleanupError],
|
|
5075
|
+
"Branch preparation and staged session cleanup both failed.",
|
|
5076
|
+
);
|
|
5077
|
+
}
|
|
5078
|
+
throw error;
|
|
5079
|
+
}
|
|
5080
|
+
}
|
|
5081
|
+
|
|
5082
|
+
/** Publish a prepared successor synchronously after all readiness awaits succeed. @internal */
|
|
5083
|
+
commitPreparedNewSession(prepared: PreparedNewSession): void {
|
|
5084
|
+
this.#assertRecoveryHydrationWritable();
|
|
5085
|
+
const stage = prepared as PreparedNewSessionState;
|
|
5086
|
+
if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) {
|
|
5087
|
+
throw new Error("Prepared session is no longer available.");
|
|
5088
|
+
}
|
|
5089
|
+
this.#preparePreparedNewSessionForCommit(stage);
|
|
5090
|
+
const residentFileEntries = stage.residentFileEntries;
|
|
5091
|
+
const residentTextBlobStore = stage.residentTextBlobStore;
|
|
5092
|
+
const index = stage.index;
|
|
5093
|
+
if (!residentFileEntries || !residentTextBlobStore || !index)
|
|
5094
|
+
throw new Error("Prepared session adoption is incomplete.");
|
|
5095
|
+
if (stage.sessionFile) writeTerminalBreadcrumb(this.cwd, stage.sessionFile);
|
|
5096
|
+
|
|
5097
|
+
stage.committed = true;
|
|
5098
|
+
this.#preparedNewSessions.delete(stage);
|
|
5099
|
+
if (!this.#lifecycleIdAdopted && stage.sessionId === lifecyclePreallocatedSessionId())
|
|
5100
|
+
this.#lifecycleIdAdopted = true;
|
|
5101
|
+
this.#persistChain = Promise.resolve();
|
|
5102
|
+
this.#persistError = undefined;
|
|
5103
|
+
this.#persistErrorReported = false;
|
|
5104
|
+
this.#sessionId = stage.sessionId;
|
|
5105
|
+
this.#sessionName = stage.sessionName;
|
|
5106
|
+
this.#titleSource = stage.titleSource;
|
|
5107
|
+
this.#sessionFile = stage.sessionFile;
|
|
5108
|
+
this.#fileEntries = residentFileEntries;
|
|
5109
|
+
this.#residentTextBlobStore = residentTextBlobStore;
|
|
5110
|
+
this.#byId = index.byId;
|
|
5111
|
+
this.#labelsById = index.labelsById;
|
|
5112
|
+
this.#leafId = index.leafId;
|
|
5113
|
+
this.#usageStatistics = index.usageStatistics;
|
|
5114
|
+
this.#flushed = stage.flushed;
|
|
5115
|
+
this.#needsFullRewriteOnNextPersist = false;
|
|
5116
|
+
this.#ensuredOnDisk = stage.flushed;
|
|
5117
|
+
this.#inMemoryArtifacts = null;
|
|
5118
|
+
this.#inMemoryArtifactCounter = 0;
|
|
5119
|
+
this.#artifactManager = null;
|
|
5120
|
+
this.#artifactManagerSessionFile = null;
|
|
5121
|
+
this.#adoptedArtifactManager = null;
|
|
4318
5122
|
this.#bumpAllRevisions();
|
|
4319
|
-
|
|
5123
|
+
}
|
|
5124
|
+
|
|
5125
|
+
/** Exact-discard only an uncommitted successor prepared by this manager. @internal */
|
|
5126
|
+
async discardPreparedNewSession(prepared: PreparedNewSession): Promise<void> {
|
|
5127
|
+
const stage = prepared as PreparedNewSessionState;
|
|
5128
|
+
if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) return;
|
|
5129
|
+
const persistenceCleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
|
|
5130
|
+
let sessionCleanupError: Error | undefined;
|
|
5131
|
+
if (stage.sessionFile) {
|
|
5132
|
+
try {
|
|
5133
|
+
await this.discardUncommittedSession(stage.sessionFile);
|
|
5134
|
+
} catch (error) {
|
|
5135
|
+
sessionCleanupError = toError(error);
|
|
5136
|
+
}
|
|
5137
|
+
}
|
|
5138
|
+
if (persistenceCleanupError || sessionCleanupError) {
|
|
5139
|
+
const errors = [persistenceCleanupError, sessionCleanupError].filter(
|
|
5140
|
+
(error): error is Error => error !== undefined,
|
|
5141
|
+
);
|
|
5142
|
+
throw errors.length === 1 ? errors[0] : new AggregateError(errors, "Prepared session cleanup failed.");
|
|
5143
|
+
}
|
|
5144
|
+
this.#preparedNewSessions.delete(stage);
|
|
5145
|
+
stage.discarded = true;
|
|
5146
|
+
}
|
|
5147
|
+
|
|
5148
|
+
async #retryPreparedNewSessionCleanups(): Promise<void> {
|
|
5149
|
+
if (this.#preparedNewSessionCleanupInProgress) return;
|
|
5150
|
+
this.#preparedNewSessionCleanupInProgress = true;
|
|
5151
|
+
try {
|
|
5152
|
+
const errors: Error[] = [];
|
|
5153
|
+
for (const stage of [...this.#preparedNewSessions]) {
|
|
5154
|
+
if (stage.committed || stage.discarded) continue;
|
|
5155
|
+
try {
|
|
5156
|
+
await this.discardPreparedNewSession(stage);
|
|
5157
|
+
} catch (error) {
|
|
5158
|
+
errors.push(toError(error));
|
|
5159
|
+
}
|
|
5160
|
+
}
|
|
5161
|
+
if (errors.length > 0)
|
|
5162
|
+
throw errors.length === 1
|
|
5163
|
+
? errors[0]
|
|
5164
|
+
: new AggregateError(errors, "Prepared session cleanup retry failed.");
|
|
5165
|
+
} finally {
|
|
5166
|
+
this.#preparedNewSessionCleanupInProgress = false;
|
|
5167
|
+
}
|
|
5168
|
+
}
|
|
5169
|
+
|
|
5170
|
+
async #cleanupPreparedNewSessionPersistence(stage: PreparedNewSessionState): Promise<Error | undefined> {
|
|
5171
|
+
const errors: Error[] = [];
|
|
5172
|
+
if (stage.persistenceWriter) {
|
|
5173
|
+
try {
|
|
5174
|
+
await stage.persistenceWriter.close();
|
|
5175
|
+
stage.persistenceWriter = undefined;
|
|
5176
|
+
} catch (error) {
|
|
5177
|
+
errors.push(toError(error));
|
|
5178
|
+
}
|
|
5179
|
+
}
|
|
5180
|
+
if (stage.persistenceTempPath && !stage.persistenceWriter) {
|
|
5181
|
+
try {
|
|
5182
|
+
await this.storage.unlink(stage.persistenceTempPath);
|
|
5183
|
+
stage.persistenceTempPath = undefined;
|
|
5184
|
+
} catch (error) {
|
|
5185
|
+
if (!isEnoent(error)) errors.push(toError(error));
|
|
5186
|
+
else stage.persistenceTempPath = undefined;
|
|
5187
|
+
}
|
|
5188
|
+
}
|
|
5189
|
+
return errors.length === 0
|
|
5190
|
+
? undefined
|
|
5191
|
+
: errors.length === 1
|
|
5192
|
+
? errors[0]
|
|
5193
|
+
: new AggregateError(errors, "Prepared session temporary-file cleanup failed.");
|
|
4320
5194
|
}
|
|
4321
5195
|
|
|
4322
5196
|
/** Tombstone and exact-delete managed transcripts, detaching the active transcript first. */
|
|
@@ -4962,41 +5836,73 @@ export class SessionManager {
|
|
|
4962
5836
|
}
|
|
4963
5837
|
|
|
4964
5838
|
#buildIndex(): void {
|
|
4965
|
-
this.#
|
|
4966
|
-
this.#
|
|
4967
|
-
this.#
|
|
4968
|
-
this.#
|
|
5839
|
+
const index = this.#buildIndexForEntries(this.#fileEntries, this.#sessionFile);
|
|
5840
|
+
this.#byId = index.byId;
|
|
5841
|
+
this.#labelsById = index.labelsById;
|
|
5842
|
+
this.#leafId = index.leafId;
|
|
5843
|
+
this.#usageStatistics = index.usageStatistics;
|
|
5844
|
+
}
|
|
5845
|
+
|
|
5846
|
+
#buildIndexForEntries(fileEntries: FileEntry[], sessionFile: string | undefined): PreparedSessionIndex {
|
|
5847
|
+
const byId = new Map<string, SessionEntry>();
|
|
5848
|
+
const labelsById = new Map<string, string>();
|
|
5849
|
+
let leafId: string | null = null;
|
|
5850
|
+
const usageStatistics: UsageStatistics = {
|
|
5851
|
+
input: 0,
|
|
5852
|
+
output: 0,
|
|
5853
|
+
cacheRead: 0,
|
|
5854
|
+
cacheWrite: 0,
|
|
5855
|
+
premiumRequests: 0,
|
|
5856
|
+
cost: 0,
|
|
5857
|
+
};
|
|
5858
|
+
const addUsage = (totals: ValidatedUsageTotals): boolean => {
|
|
5859
|
+
const next = {
|
|
5860
|
+
input: usageStatistics.input + totals.input,
|
|
5861
|
+
output: usageStatistics.output + totals.output,
|
|
5862
|
+
cacheRead: usageStatistics.cacheRead + totals.cacheRead,
|
|
5863
|
+
cacheWrite: usageStatistics.cacheWrite + totals.cacheWrite,
|
|
5864
|
+
premiumRequests: usageStatistics.premiumRequests + totals.premiumRequests,
|
|
5865
|
+
cost: usageStatistics.cost + totals.cost,
|
|
5866
|
+
};
|
|
5867
|
+
if (Object.values(next).some(value => !Number.isFinite(value))) return false;
|
|
5868
|
+
Object.assign(usageStatistics, next);
|
|
5869
|
+
return true;
|
|
5870
|
+
};
|
|
5871
|
+
const accumulateUsage = (entry: SessionEntry): boolean => {
|
|
5872
|
+
if (entry.type !== "message") return false;
|
|
5873
|
+
const usage =
|
|
5874
|
+
entry.message.role === "assistant"
|
|
5875
|
+
? entry.message.usage
|
|
5876
|
+
: entry.message.role === "toolResult" && entry.message.toolName === "task"
|
|
5877
|
+
? getTaskToolUsage(entry.message.details)
|
|
5878
|
+
: undefined;
|
|
5879
|
+
if (usage === undefined) return false;
|
|
5880
|
+
const totals = validatePersistedUsageTotals(usage);
|
|
5881
|
+
return !(totals && addUsage(totals));
|
|
5882
|
+
};
|
|
4969
5883
|
let malformedUsageRecords = 0;
|
|
4970
5884
|
const malformedUsageSample: string[] = [];
|
|
4971
|
-
for (const entry of
|
|
5885
|
+
for (const entry of fileEntries) {
|
|
4972
5886
|
if (entry.type === "session") continue;
|
|
4973
|
-
|
|
4974
|
-
|
|
5887
|
+
byId.set(entry.id, entry);
|
|
5888
|
+
leafId = entry.id;
|
|
4975
5889
|
if (entry.type === "label") {
|
|
4976
|
-
if (entry.label)
|
|
4977
|
-
|
|
4978
|
-
} else {
|
|
4979
|
-
this.#labelsById.delete(entry.targetId);
|
|
4980
|
-
}
|
|
5890
|
+
if (entry.label) labelsById.set(entry.targetId, entry.label);
|
|
5891
|
+
else labelsById.delete(entry.targetId);
|
|
4981
5892
|
}
|
|
4982
|
-
|
|
4983
|
-
// overflow-guarded path (see #accumulateEntryUsage). parseSessionEntries accepts
|
|
4984
|
-
// any parseable JSON, so a torn/corrupt record can carry a still-valid-JSON but
|
|
4985
|
-
// malformed `usage` (absent, {}, numeric strings, negatives, a non-record cost, or
|
|
4986
|
-
// a cumulative overflow); such a record is skipped and reported below instead of
|
|
4987
|
-
// poisoning every getUsageStatistics() consumer.
|
|
4988
|
-
if (this.#accumulateEntryUsage(entry)) {
|
|
5893
|
+
if (accumulateUsage(entry)) {
|
|
4989
5894
|
malformedUsageRecords++;
|
|
4990
5895
|
if (malformedUsageSample.length < MALFORMED_USAGE_SAMPLE_LIMIT) malformedUsageSample.push(entry.id);
|
|
4991
5896
|
}
|
|
4992
5897
|
}
|
|
4993
5898
|
if (malformedUsageRecords > 0) {
|
|
4994
5899
|
logger.warn("Skipped malformed or overflowing persisted usage records during resume aggregation", {
|
|
4995
|
-
sessionFile
|
|
5900
|
+
sessionFile,
|
|
4996
5901
|
count: malformedUsageRecords,
|
|
4997
5902
|
sampleEntryIds: malformedUsageSample,
|
|
4998
5903
|
});
|
|
4999
5904
|
}
|
|
5905
|
+
return { byId, labelsById, leafId, usageStatistics };
|
|
5000
5906
|
}
|
|
5001
5907
|
|
|
5002
5908
|
/**
|
|
@@ -5542,6 +6448,15 @@ export class SessionManager {
|
|
|
5542
6448
|
|
|
5543
6449
|
/** Close the persistent writer after flushing all pending data. */
|
|
5544
6450
|
async close(): Promise<void> {
|
|
6451
|
+
// Drain any uncommitted prepared successors before releasing resources so
|
|
6452
|
+
// dispose/shutdown retains exact cleanup authority (#3138).
|
|
6453
|
+
try {
|
|
6454
|
+
await this.#retryPreparedNewSessionCleanups();
|
|
6455
|
+
} catch (error) {
|
|
6456
|
+
logger.warn("Prepared session cleanup during close failed; retained for retry", {
|
|
6457
|
+
error: toError(error).message,
|
|
6458
|
+
});
|
|
6459
|
+
}
|
|
5545
6460
|
await this.#queuePersistTask(async () => {
|
|
5546
6461
|
if (this.#persistWriter) {
|
|
5547
6462
|
await this.#closePersistWriterInternal();
|
|
@@ -5566,6 +6481,14 @@ export class SessionManager {
|
|
|
5566
6481
|
* writer closure before any destructive operation.
|
|
5567
6482
|
*/
|
|
5568
6483
|
async closeStrict(): Promise<SessionManagerCloseOutcome> {
|
|
6484
|
+
// Drain staged successors on the strict ACP dispose path as well as best-effort close (#3138).
|
|
6485
|
+
try {
|
|
6486
|
+
await this.#retryPreparedNewSessionCleanups();
|
|
6487
|
+
} catch (error) {
|
|
6488
|
+
logger.warn("Prepared session cleanup during closeStrict failed; retained for retry", {
|
|
6489
|
+
error: toError(error).message,
|
|
6490
|
+
});
|
|
6491
|
+
}
|
|
5569
6492
|
let outcome: SessionManagerCloseOutcome = { kind: "closed" };
|
|
5570
6493
|
await this.#queuePersistTask(async () => {
|
|
5571
6494
|
const writer = this.#persistWriter;
|
|
@@ -5651,6 +6574,160 @@ export class SessionManager {
|
|
|
5651
6574
|
return this.#sessionFile;
|
|
5652
6575
|
}
|
|
5653
6576
|
|
|
6577
|
+
acquireMemoryGuardParticipantIngressLease(): MemoryGuardParticipantIngressLease {
|
|
6578
|
+
if (this.#memoryGuardParticipantIngressToken)
|
|
6579
|
+
throw new Error("memory_guard_participant_ingress_lease_already_held");
|
|
6580
|
+
const token = Symbol("memory_guard_participant_ingress_lease");
|
|
6581
|
+
this.#memoryGuardParticipantIngressToken = token;
|
|
6582
|
+
let released = false;
|
|
6583
|
+
return Object.freeze({
|
|
6584
|
+
token,
|
|
6585
|
+
release: () => {
|
|
6586
|
+
if (released) return;
|
|
6587
|
+
released = true;
|
|
6588
|
+
if (this.#memoryGuardParticipantIngressToken === token)
|
|
6589
|
+
this.#memoryGuardParticipantIngressToken = undefined;
|
|
6590
|
+
},
|
|
6591
|
+
});
|
|
6592
|
+
}
|
|
6593
|
+
|
|
6594
|
+
#assertMemoryGuardParticipantIngressLease(lease: MemoryGuardParticipantIngressLease): void {
|
|
6595
|
+
if (this.#memoryGuardParticipantIngressToken !== lease.token) {
|
|
6596
|
+
throw new Error("memory_guard_participant_ingress_lease_invalid");
|
|
6597
|
+
}
|
|
6598
|
+
}
|
|
6599
|
+
|
|
6600
|
+
#stageMemoryGuardCheckpointBlobs(blobs: Map<string, Buffer>): void {
|
|
6601
|
+
const stagedImageStore = new MemoryBlobStore();
|
|
6602
|
+
for (const blob of blobs.values()) {
|
|
6603
|
+
this.#residentTextBlobStore.putSync(blob);
|
|
6604
|
+
stagedImageStore.putSync(blob);
|
|
6605
|
+
}
|
|
6606
|
+
this.#residentImageBlobStore = stagedImageStore;
|
|
6607
|
+
this.#memoryGuardCheckpointBlobs = blobs;
|
|
6608
|
+
}
|
|
6609
|
+
|
|
6610
|
+
async createMemoryGuardCheckpoint(
|
|
6611
|
+
input: MemoryGuardCreateCheckpointInput,
|
|
6612
|
+
): Promise<MemoryGuardSessionManagerCheckpointV1> {
|
|
6613
|
+
this.#assertMemoryGuardParticipantIngressLease(input.ingressLease);
|
|
6614
|
+
if (!this.#sessionFile) throw new Error("memory_guard_checkpoint_session_file_unavailable");
|
|
6615
|
+
await this.flush();
|
|
6616
|
+
const captured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
|
|
6617
|
+
if (captured.kind !== "captured") {
|
|
6618
|
+
throw new Error(`memory_guard_checkpoint_capture_failed:${captured.reason}`);
|
|
6619
|
+
}
|
|
6620
|
+
if (captured.snapshot.content.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
|
|
6621
|
+
throw new Error("memory_guard_checkpoint_transcript_capacity_exceeded");
|
|
6622
|
+
const transcriptText = decodeCheckpointUtf8(captured.snapshot.content);
|
|
6623
|
+
if (transcriptText === null) throw new Error("memory_guard_checkpoint_transcript_unreadable");
|
|
6624
|
+
const entries = parseSessionEntries(transcriptText);
|
|
6625
|
+
const sessionId = this.getSessionId();
|
|
6626
|
+
assertMemoryGuardSessionId(sessionId);
|
|
6627
|
+
const sessionName = this.getSessionName() ?? null;
|
|
6628
|
+
const revisions = this.revisionSnapshot();
|
|
6629
|
+
const participantRoot = memoryGuardParticipantRoot(input.checkpointRoot, sessionId);
|
|
6630
|
+
const transcriptRelativePath = memoryGuardParticipantRelativePath(sessionId, "transcript.jsonl");
|
|
6631
|
+
const blobRootRelativePath = memoryGuardParticipantRelativePath(sessionId, "blobs");
|
|
6632
|
+
const blobManifestRelativePath = memoryGuardParticipantRelativePath(sessionId, "blob-manifest.json");
|
|
6633
|
+
const refs = [...collectCheckpointBlobRefs(entries)].sort();
|
|
6634
|
+
if (refs.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
|
|
6635
|
+
throw new Error("memory_guard_checkpoint_blob_count_exceeded");
|
|
6636
|
+
const blobManifestEntries: MemoryGuardCheckpointBlobManifestEntryV1[] = [];
|
|
6637
|
+
const blobWrites: Array<{ data: Buffer; relativePath: string }> = [];
|
|
6638
|
+
let aggregateBlobBytes = 0;
|
|
6639
|
+
for (const ref of refs) {
|
|
6640
|
+
const hash = parseBlobRef(ref);
|
|
6641
|
+
if (!hash) continue;
|
|
6642
|
+
const data =
|
|
6643
|
+
this.#residentTextBlobStore.getCheckedSync(hash) ??
|
|
6644
|
+
this.#residentImageBlobStore.getCheckedSync(hash) ??
|
|
6645
|
+
this.#blobStore.getCheckedSync(hash);
|
|
6646
|
+
if (!data) throw new Error(`memory_guard_checkpoint_blob_missing:${hash}`);
|
|
6647
|
+
if (data.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
|
|
6648
|
+
throw new Error(`memory_guard_checkpoint_blob_capacity_exceeded:${hash}`);
|
|
6649
|
+
aggregateBlobBytes += data.byteLength;
|
|
6650
|
+
if (aggregateBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
|
|
6651
|
+
throw new Error("memory_guard_checkpoint_blob_total_capacity_exceeded");
|
|
6652
|
+
const relativePath = hash;
|
|
6653
|
+
blobWrites.push({ data, relativePath });
|
|
6654
|
+
blobManifestEntries.push({
|
|
6655
|
+
bytes: String(data.byteLength),
|
|
6656
|
+
relative_path: relativePath,
|
|
6657
|
+
sha256: memoryGuardSha256Hex(data),
|
|
6658
|
+
});
|
|
6659
|
+
}
|
|
6660
|
+
const publishedCheckpointPaths: string[] = [];
|
|
6661
|
+
const cleanupPublishedCheckpointPaths = async (): Promise<void> => {
|
|
6662
|
+
for (const publishedPath of publishedCheckpointPaths.reverse())
|
|
6663
|
+
await fs.promises.rm(publishedPath, { force: true }).catch(() => undefined);
|
|
6664
|
+
await fs.promises.rmdir(path.join(input.checkpointRoot, blobRootRelativePath)).catch(() => undefined);
|
|
6665
|
+
await fs.promises.rmdir(participantRoot).catch(() => undefined);
|
|
6666
|
+
};
|
|
6667
|
+
const publishCheckpointFile = async (filePath: string, data: Uint8Array | string): Promise<void> => {
|
|
6668
|
+
try {
|
|
6669
|
+
await writeOwnerOnlyFileNoReplace(filePath, data);
|
|
6670
|
+
publishedCheckpointPaths.push(filePath);
|
|
6671
|
+
} catch (error) {
|
|
6672
|
+
await cleanupPublishedCheckpointPaths();
|
|
6673
|
+
throw error;
|
|
6674
|
+
}
|
|
6675
|
+
};
|
|
6676
|
+
await ensureOwnerOnlyDirectory(participantRoot);
|
|
6677
|
+
await publishCheckpointFile(path.join(input.checkpointRoot, transcriptRelativePath), captured.snapshot.content);
|
|
6678
|
+
for (const write of blobWrites)
|
|
6679
|
+
await publishCheckpointFile(
|
|
6680
|
+
path.join(input.checkpointRoot, blobRootRelativePath, write.relativePath),
|
|
6681
|
+
write.data,
|
|
6682
|
+
);
|
|
6683
|
+
blobManifestEntries.sort((left, right) => left.relative_path.localeCompare(right.relative_path));
|
|
6684
|
+
const blobManifest: MemoryGuardCheckpointBlobManifestV1 = {
|
|
6685
|
+
entries: blobManifestEntries,
|
|
6686
|
+
schema_version: 1,
|
|
6687
|
+
};
|
|
6688
|
+
const blobManifestText = memoryGuardCanonicalJson(blobManifest);
|
|
6689
|
+
await publishCheckpointFile(path.join(input.checkpointRoot, blobManifestRelativePath), blobManifestText);
|
|
6690
|
+
const checkpoint: MemoryGuardSessionManagerCheckpointV1 = {
|
|
6691
|
+
blob_authority: {
|
|
6692
|
+
kind: "checkpoint_blob_tree_v1",
|
|
6693
|
+
manifest_relative_path: blobManifestRelativePath,
|
|
6694
|
+
manifest_sha256: memoryGuardSha256Hex(blobManifestText),
|
|
6695
|
+
root_relative_path: blobRootRelativePath,
|
|
6696
|
+
},
|
|
6697
|
+
revisions: toSessionManagerCheckpointRevisionStrings(revisions),
|
|
6698
|
+
schema_version: 1,
|
|
6699
|
+
session_id: sessionId,
|
|
6700
|
+
session_name: sessionName,
|
|
6701
|
+
transcript: {
|
|
6702
|
+
bytes: String(captured.snapshot.content.byteLength),
|
|
6703
|
+
relative_path: transcriptRelativePath,
|
|
6704
|
+
sha256: captured.snapshot.identity.sha256,
|
|
6705
|
+
},
|
|
6706
|
+
};
|
|
6707
|
+
const checkpointPath = path.join(
|
|
6708
|
+
input.checkpointRoot,
|
|
6709
|
+
memoryGuardParticipantRelativePath(sessionId, "session-manager.json"),
|
|
6710
|
+
);
|
|
6711
|
+
await publishCheckpointFile(checkpointPath, memoryGuardCanonicalJson(checkpoint));
|
|
6712
|
+
const currentRevisions = this.revisionSnapshot();
|
|
6713
|
+
const recaptured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
|
|
6714
|
+
if (
|
|
6715
|
+
this.getSessionId() !== sessionId ||
|
|
6716
|
+
(this.getSessionName() ?? null) !== sessionName ||
|
|
6717
|
+
Object.keys(revisions).some(
|
|
6718
|
+
key =>
|
|
6719
|
+
currentRevisions[key as keyof SessionManagerRevisionSnapshot] !==
|
|
6720
|
+
revisions[key as keyof SessionManagerRevisionSnapshot],
|
|
6721
|
+
) ||
|
|
6722
|
+
recaptured.kind !== "captured" ||
|
|
6723
|
+
!sameResumeIdentity(captured.snapshot.identity, recaptured.snapshot.identity)
|
|
6724
|
+
) {
|
|
6725
|
+
await cleanupPublishedCheckpointPaths();
|
|
6726
|
+
throw new Error("memory_guard_checkpoint_state_changed_during_capture");
|
|
6727
|
+
}
|
|
6728
|
+
return checkpoint;
|
|
6729
|
+
}
|
|
6730
|
+
|
|
5654
6731
|
/**
|
|
5655
6732
|
* Returns the session artifacts directory path (session file path without .jsonl).
|
|
5656
6733
|
* Returns null when the session is not persisted to a file.
|
|
@@ -5670,9 +6747,13 @@ export class SessionManager {
|
|
|
5670
6747
|
|
|
5671
6748
|
/** Supplies opaque retained authority for mandatory managed legacy local migration. */
|
|
5672
6749
|
getManagedLegacyLocalMigrationSource(): ManagedLegacyLocalMigrationSource | null {
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
6750
|
+
return this.#managedLegacyLocalMigrationSourceFor(this.#sessionFile);
|
|
6751
|
+
}
|
|
6752
|
+
|
|
6753
|
+
#managedLegacyLocalMigrationSourceFor(sessionFile: string | undefined): ManagedLegacyLocalMigrationSource | null {
|
|
6754
|
+
if (this.destination.kind !== "managed" || !sessionFile || this.#adoptedArtifactManager) return null;
|
|
6755
|
+
const store = this.#managedTranscriptStore(sessionFile);
|
|
6756
|
+
const legacyArtifactsRoot = path.basename(sessionFile.slice(0, -6));
|
|
5676
6757
|
const legacyLocalRoot = path.posix.join(legacyArtifactsRoot, "local");
|
|
5677
6758
|
return {
|
|
5678
6759
|
capture: async () => {
|
|
@@ -5908,7 +6989,11 @@ export class SessionManager {
|
|
|
5908
6989
|
* @param source - "user" for explicit renames (/rename command, RPC); "auto" for generated titles.
|
|
5909
6990
|
* Auto-generated titles are silently ignored when the user has already set a name.
|
|
5910
6991
|
*/
|
|
6992
|
+
#assertRecoveryHydrationWritable(): void {
|
|
6993
|
+
if (this.#recoveryHydrationContext) throw new Error("recovery_hydration_not_promoted");
|
|
6994
|
+
}
|
|
5911
6995
|
async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
|
|
6996
|
+
this.#assertRecoveryHydrationWritable();
|
|
5912
6997
|
// User-set names take permanent precedence over auto-generated ones.
|
|
5913
6998
|
if (this.#titleSource === "user" && source === "auto") return false;
|
|
5914
6999
|
|
|
@@ -6034,6 +7119,7 @@ export class SessionManager {
|
|
|
6034
7119
|
}
|
|
6035
7120
|
|
|
6036
7121
|
#appendEntry(entry: SessionEntry): void {
|
|
7122
|
+
this.#assertRecoveryHydrationWritable();
|
|
6037
7123
|
const normalizedEntry = normalizeSessionEntryForStorage(entry);
|
|
6038
7124
|
const residentEntry = prepareEntryForResidentSync(normalizedEntry, this.#residentBlobStores()) as SessionEntry;
|
|
6039
7125
|
this.#fileEntries.push(residentEntry);
|
|
@@ -6252,6 +7338,7 @@ export class SessionManager {
|
|
|
6252
7338
|
* the canonical store. This applies such mutations for real.
|
|
6253
7339
|
*/
|
|
6254
7340
|
applyEntryMessageUpdates(entries: readonly SessionMessageEntry[]): void {
|
|
7341
|
+
this.#assertRecoveryHydrationWritable();
|
|
6255
7342
|
for (const updated of entries) {
|
|
6256
7343
|
const canonical = this.#byId.get(updated.id);
|
|
6257
7344
|
if (canonical?.type !== "message") continue;
|
|
@@ -6268,6 +7355,7 @@ export class SessionManager {
|
|
|
6268
7355
|
|
|
6269
7356
|
/** Write mutated custom-message entries back into the canonical entry store by id. */
|
|
6270
7357
|
applyCustomMessageEntryUpdates(entries: readonly CustomMessageEntry[]): void {
|
|
7358
|
+
this.#assertRecoveryHydrationWritable();
|
|
6271
7359
|
for (const updated of entries) {
|
|
6272
7360
|
const canonical = this.#byId.get(updated.id);
|
|
6273
7361
|
if (canonical?.type !== "custom_message") continue;
|
|
@@ -6287,6 +7375,7 @@ export class SessionManager {
|
|
|
6287
7375
|
* Use sparingly (e.g., pruning old tool outputs).
|
|
6288
7376
|
*/
|
|
6289
7377
|
async rewriteEntries(): Promise<void> {
|
|
7378
|
+
this.#assertRecoveryHydrationWritable();
|
|
6290
7379
|
if (!this.persist || !this.#sessionFile) return;
|
|
6291
7380
|
await this.#rewriteFile();
|
|
6292
7381
|
}
|
|
@@ -6452,6 +7541,7 @@ export class SessionManager {
|
|
|
6452
7541
|
}
|
|
6453
7542
|
|
|
6454
7543
|
evictCompactedContent(firstKeptEntryId: string, compactionEntryId: string): EvictCompactedContentResult {
|
|
7544
|
+
this.#assertRecoveryHydrationWritable();
|
|
6455
7545
|
const firstKept = this.#byId.get(firstKeptEntryId);
|
|
6456
7546
|
const compaction = this.#byId.get(compactionEntryId);
|
|
6457
7547
|
if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
|
|
@@ -6602,7 +7692,7 @@ export class SessionManager {
|
|
|
6602
7692
|
? cloneSessionEntry(
|
|
6603
7693
|
rehydrateColdSpillEntry(
|
|
6604
7694
|
materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()),
|
|
6605
|
-
this.#
|
|
7695
|
+
this.#coldSpillReadStore(),
|
|
6606
7696
|
this.#residentBlobStoresForColdRehydrate(),
|
|
6607
7697
|
),
|
|
6608
7698
|
)
|
|
@@ -6621,7 +7711,7 @@ export class SessionManager {
|
|
|
6621
7711
|
cloneSessionEntry(
|
|
6622
7712
|
rehydrateColdSpillEntry(
|
|
6623
7713
|
materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache),
|
|
6624
|
-
this.#
|
|
7714
|
+
this.#coldSpillReadStore(),
|
|
6625
7715
|
this.#residentBlobStoresForColdRehydrate(),
|
|
6626
7716
|
),
|
|
6627
7717
|
),
|
|
@@ -6663,7 +7753,7 @@ export class SessionManager {
|
|
|
6663
7753
|
cloneSessionEntry(
|
|
6664
7754
|
rehydrateColdSpillEntry(
|
|
6665
7755
|
materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache),
|
|
6666
|
-
this.#
|
|
7756
|
+
this.#coldSpillReadStore(),
|
|
6667
7757
|
this.#residentBlobStoresForColdRehydrate(),
|
|
6668
7758
|
),
|
|
6669
7759
|
),
|
|
@@ -6829,7 +7919,7 @@ export class SessionManager {
|
|
|
6829
7919
|
const materialized = materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map());
|
|
6830
7920
|
const rehydrated = rehydrateColdSpillEntry(
|
|
6831
7921
|
materialized,
|
|
6832
|
-
this.#
|
|
7922
|
+
this.#coldSpillReadStore(),
|
|
6833
7923
|
this.#residentBlobStoresForColdRehydrate(),
|
|
6834
7924
|
);
|
|
6835
7925
|
if (rehydrated !== materialized) this.#coldSpillReadCount += this.#countColdSpillPayloads(entry);
|
|
@@ -6845,6 +7935,7 @@ export class SessionManager {
|
|
|
6845
7935
|
}
|
|
6846
7936
|
/** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries without persisting it. */
|
|
6847
7937
|
sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
|
|
7938
|
+
this.#assertRecoveryHydrationWritable();
|
|
6848
7939
|
return this.#sanitizeLoadedOpenAIResponsesReplayMetadata().length > 0;
|
|
6849
7940
|
}
|
|
6850
7941
|
|
|
@@ -7000,6 +8091,7 @@ export class SessionManager {
|
|
|
7000
8091
|
* are not modified or deleted.
|
|
7001
8092
|
*/
|
|
7002
8093
|
branch(branchFromId: string): void {
|
|
8094
|
+
this.#assertRecoveryHydrationWritable();
|
|
7003
8095
|
if (!this.#byId.has(branchFromId)) {
|
|
7004
8096
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
7005
8097
|
}
|
|
@@ -7013,6 +8105,7 @@ export class SessionManager {
|
|
|
7013
8105
|
* Use this when navigating to re-edit the first user message.
|
|
7014
8106
|
*/
|
|
7015
8107
|
resetLeaf(): void {
|
|
8108
|
+
this.#assertRecoveryHydrationWritable();
|
|
7016
8109
|
this.#leafId = null;
|
|
7017
8110
|
this.#leafRevision++;
|
|
7018
8111
|
}
|
|
@@ -7023,6 +8116,7 @@ export class SessionManager {
|
|
|
7023
8116
|
* context from the abandoned conversation path.
|
|
7024
8117
|
*/
|
|
7025
8118
|
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
|
|
8119
|
+
this.#assertRecoveryHydrationWritable();
|
|
7026
8120
|
if (branchFromId !== null && !this.#byId.has(branchFromId)) {
|
|
7027
8121
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
7028
8122
|
}
|
|
@@ -7047,6 +8141,7 @@ export class SessionManager {
|
|
|
7047
8141
|
* Returns the new session file path, or undefined if not persisting.
|
|
7048
8142
|
*/
|
|
7049
8143
|
createBranchedSession(leafId: string): string | undefined {
|
|
8144
|
+
this.#assertRecoveryHydrationWritable();
|
|
7050
8145
|
const previousSessionFile = this.#sessionFile;
|
|
7051
8146
|
const branchPath = this.#getCanonicalBranchClones(leafId);
|
|
7052
8147
|
if (branchPath.length === 0) {
|
|
@@ -7529,10 +8624,11 @@ export class SessionManager {
|
|
|
7529
8624
|
if (resolved.kind === "error") return [];
|
|
7530
8625
|
const listing = listManagedCandidates(resolved.scope);
|
|
7531
8626
|
if (listing.kind === "error") return [];
|
|
7532
|
-
|
|
8627
|
+
const managed = await collectSessionsFromFiles(
|
|
7533
8628
|
listing.owned.map(candidate => candidate.path),
|
|
7534
8629
|
storage,
|
|
7535
8630
|
);
|
|
8631
|
+
return mergeSessionInventories(managed, await collectProjectSessions(cwd, storage));
|
|
7536
8632
|
}
|
|
7537
8633
|
|
|
7538
8634
|
/**
|
|
@@ -7553,12 +8649,35 @@ export class SessionManager {
|
|
|
7553
8649
|
}
|
|
7554
8650
|
}
|
|
7555
8651
|
|
|
7556
|
-
/**
|
|
8652
|
+
/** Delete an authorized managed or project-local picker candidate. */
|
|
7557
8653
|
static async deleteManagedCandidate(sessionPath: string): Promise<void> {
|
|
7558
8654
|
const storage = new FileSessionStorage();
|
|
7559
8655
|
const entries = await loadEntriesFromFile(sessionPath, storage);
|
|
7560
8656
|
const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
7561
|
-
if (!header?.cwd) throw new Error("Session has no valid
|
|
8657
|
+
if (!header?.cwd) throw new Error("Session has no valid workspace header.");
|
|
8658
|
+
const projectGjcDir = path.join(path.resolve(header.cwd), ".gjc");
|
|
8659
|
+
if (isProjectSessionTranscriptPath(projectGjcDir, sessionPath)) {
|
|
8660
|
+
const relativePath = path.relative(projectGjcDir, path.resolve(sessionPath)).split(path.sep).join("/");
|
|
8661
|
+
const authority = native.openRecoveryFsRoot(projectGjcDir);
|
|
8662
|
+
try {
|
|
8663
|
+
const captured = authority.readManaged(relativePath);
|
|
8664
|
+
if (!captured.ok || !captured.identity || !captured.data || !captured.identity.sha256)
|
|
8665
|
+
throw new Error("Project session is no longer an authorized candidate.");
|
|
8666
|
+
const removed = authority.removeManaged(
|
|
8667
|
+
relativePath,
|
|
8668
|
+
captured.identity.dev,
|
|
8669
|
+
captured.identity.ino,
|
|
8670
|
+
captured.identity.size,
|
|
8671
|
+
captured.identity.mtimeNs,
|
|
8672
|
+
captured.identity.ctimeNs,
|
|
8673
|
+
captured.identity.sha256,
|
|
8674
|
+
);
|
|
8675
|
+
if (!removed.ok) throw new Error(removed.code ?? "Could not delete project session.");
|
|
8676
|
+
return;
|
|
8677
|
+
} finally {
|
|
8678
|
+
authority.close();
|
|
8679
|
+
}
|
|
8680
|
+
}
|
|
7562
8681
|
const sessionsRoot = path.resolve(sessionPath, "../..");
|
|
7563
8682
|
const resolved = resolveManagedScope({
|
|
7564
8683
|
cwd: header.cwd,
|
|
@@ -7766,6 +8885,194 @@ export class SessionManager {
|
|
|
7766
8885
|
}
|
|
7767
8886
|
}
|
|
7768
8887
|
|
|
8888
|
+
static async restoreMemoryGuardCheckpoint(input: MemoryGuardRestoreInput): Promise<MemoryGuardRestoreResult> {
|
|
8889
|
+
try {
|
|
8890
|
+
assertMemoryGuardSessionId(input.checkpoint.session_id);
|
|
8891
|
+
} catch {
|
|
8892
|
+
return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
8893
|
+
}
|
|
8894
|
+
if (!validateMemoryGuardCheckpoint(input.checkpoint)) return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
8895
|
+
if (!memoryGuardParticipantMatchesCheckpoint(input.participant, input.checkpoint)) {
|
|
8896
|
+
return { kind: "blocked", reason: "participant-mismatch" };
|
|
8897
|
+
}
|
|
8898
|
+
const checkpointText = readCheckpointAuthorityFile(
|
|
8899
|
+
input.incidentAuthority,
|
|
8900
|
+
memoryGuardParticipantRelativePath(input.checkpoint.session_id, "session-manager.json"),
|
|
8901
|
+
256 * 1024,
|
|
8902
|
+
);
|
|
8903
|
+
if (!checkpointText) return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
8904
|
+
const checkpointCanonical = memoryGuardCanonicalJson(input.checkpoint);
|
|
8905
|
+
if (decodeCheckpointUtf8(checkpointText) !== checkpointCanonical) {
|
|
8906
|
+
return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
8907
|
+
}
|
|
8908
|
+
const transcriptBytes = Number(input.checkpoint.transcript.bytes);
|
|
8909
|
+
if (!Number.isSafeInteger(transcriptBytes) || transcriptBytes < 0) {
|
|
8910
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
8911
|
+
}
|
|
8912
|
+
const transcriptData = readCheckpointAuthorityFile(
|
|
8913
|
+
input.incidentAuthority,
|
|
8914
|
+
input.checkpoint.transcript.relative_path,
|
|
8915
|
+
transcriptBytes + 1,
|
|
8916
|
+
);
|
|
8917
|
+
if (!transcriptData || transcriptData.byteLength !== transcriptBytes) {
|
|
8918
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
8919
|
+
}
|
|
8920
|
+
if (memoryGuardSha256Hex(transcriptData) !== input.checkpoint.transcript.sha256) {
|
|
8921
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
8922
|
+
}
|
|
8923
|
+
const transcriptText = decodeCheckpointUtf8(transcriptData);
|
|
8924
|
+
if (transcriptText === null) return { kind: "blocked", reason: "transcript-mismatch" };
|
|
8925
|
+
let transcriptEntries: FileEntry[];
|
|
8926
|
+
try {
|
|
8927
|
+
transcriptEntries = parseSessionEntries(transcriptText);
|
|
8928
|
+
} catch {
|
|
8929
|
+
return { kind: "blocked", reason: "malformed" };
|
|
8930
|
+
}
|
|
8931
|
+
const transcriptHeader = transcriptEntries[0] as SessionHeader | undefined;
|
|
8932
|
+
if (
|
|
8933
|
+
transcriptHeader?.type !== "session" ||
|
|
8934
|
+
transcriptHeader.id !== input.checkpoint.session_id ||
|
|
8935
|
+
(transcriptHeader.title ?? null) !== input.checkpoint.session_name
|
|
8936
|
+
) {
|
|
8937
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
8938
|
+
}
|
|
8939
|
+
const manifestData = readCheckpointAuthorityFile(
|
|
8940
|
+
input.incidentAuthority,
|
|
8941
|
+
input.checkpoint.blob_authority.manifest_relative_path,
|
|
8942
|
+
8 * 1024 * 1024,
|
|
8943
|
+
);
|
|
8944
|
+
if (!manifestData) return { kind: "blocked", reason: "blob-authority-mismatch" };
|
|
8945
|
+
if (memoryGuardSha256Hex(manifestData) !== input.checkpoint.blob_authority.manifest_sha256) {
|
|
8946
|
+
return { kind: "blocked", reason: "blob-authority-mismatch" };
|
|
8947
|
+
}
|
|
8948
|
+
const manifestText = decodeCheckpointUtf8(manifestData);
|
|
8949
|
+
if (manifestText === null) return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8950
|
+
let blobManifest: unknown;
|
|
8951
|
+
try {
|
|
8952
|
+
blobManifest = JSON.parse(manifestText);
|
|
8953
|
+
} catch {
|
|
8954
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8955
|
+
}
|
|
8956
|
+
if (!validateMemoryGuardBlobManifest(blobManifest)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8957
|
+
if (memoryGuardCanonicalJson(blobManifest) !== manifestText)
|
|
8958
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8959
|
+
if (blobManifest.entries.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
|
|
8960
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8961
|
+
let checkpointBlobBytes = 0;
|
|
8962
|
+
const seenBlobPaths = new Set<string>();
|
|
8963
|
+
const checkpointBlobs = new Map<string, Buffer>();
|
|
8964
|
+
for (const entry of blobManifest.entries) {
|
|
8965
|
+
if (
|
|
8966
|
+
entry.relative_path !== entry.sha256 ||
|
|
8967
|
+
seenBlobPaths.has(entry.relative_path) ||
|
|
8968
|
+
checkpointBlobs.has(entry.sha256)
|
|
8969
|
+
)
|
|
8970
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8971
|
+
seenBlobPaths.add(entry.relative_path);
|
|
8972
|
+
const blobBytes = Number(entry.bytes);
|
|
8973
|
+
if (!Number.isSafeInteger(blobBytes) || blobBytes < 0)
|
|
8974
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8975
|
+
checkpointBlobBytes += blobBytes;
|
|
8976
|
+
if (checkpointBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
|
|
8977
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8978
|
+
const blobPath = `${input.checkpoint.blob_authority.root_relative_path}/${entry.relative_path}`;
|
|
8979
|
+
const blobData = readCheckpointAuthorityFile(input.incidentAuthority, blobPath, blobBytes + 1);
|
|
8980
|
+
if (!blobData || blobData.byteLength !== blobBytes) return { kind: "blocked", reason: "blob-missing" };
|
|
8981
|
+
if (memoryGuardSha256Hex(blobData) !== entry.sha256) return { kind: "blocked", reason: "blob-hash-mismatch" };
|
|
8982
|
+
checkpointBlobs.set(entry.sha256, Buffer.from(blobData));
|
|
8983
|
+
}
|
|
8984
|
+
for (const ref of collectCheckpointBlobRefs(transcriptEntries)) {
|
|
8985
|
+
const hash = parseBlobRef(ref);
|
|
8986
|
+
if (!hash || !checkpointBlobs.has(hash)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
8987
|
+
}
|
|
8988
|
+
const storage = new FileSessionStorage();
|
|
8989
|
+
let destination: SessionDestination;
|
|
8990
|
+
try {
|
|
8991
|
+
destination =
|
|
8992
|
+
input.destination === undefined
|
|
8993
|
+
? explicitDestination(path.join(os.tmpdir(), `gjc-memory-guard-${input.checkpoint.session_id}`))
|
|
8994
|
+
: destinationFor(getProjectDir(), input.destination, storage);
|
|
8995
|
+
} catch {
|
|
8996
|
+
return { kind: "blocked", reason: "destination-unavailable" };
|
|
8997
|
+
}
|
|
8998
|
+
const transcriptPath = path.join(
|
|
8999
|
+
destination.directory,
|
|
9000
|
+
`.${input.checkpoint.session_id}.memory-guard.${crypto.randomUUID()}.jsonl`,
|
|
9001
|
+
);
|
|
9002
|
+
const transcriptName = path.basename(transcriptPath);
|
|
9003
|
+
const managedStagingStore =
|
|
9004
|
+
destination.kind === "managed"
|
|
9005
|
+
? managedStoreFromContext(destination.securityContext, destination.directory)
|
|
9006
|
+
: undefined;
|
|
9007
|
+
const cleanupTranscript = async (): Promise<void> => {
|
|
9008
|
+
if (managedStagingStore) {
|
|
9009
|
+
await managedStagingStore.remove(transcriptName);
|
|
9010
|
+
return;
|
|
9011
|
+
}
|
|
9012
|
+
await fs.promises.rm(transcriptPath, { force: true });
|
|
9013
|
+
};
|
|
9014
|
+
let opened: RecoveryHydrationOpenResult;
|
|
9015
|
+
let transcriptIdentity: ResumeSessionIdentity;
|
|
9016
|
+
let createdDestination: CreatedDirectoryIdentity | undefined;
|
|
9017
|
+
try {
|
|
9018
|
+
if (managedStagingStore) await managedStagingStore.publishNoReplace(transcriptName, transcriptData);
|
|
9019
|
+
else {
|
|
9020
|
+
createdDestination = await ensureOwnerOnlyDirectoryTracked(destination.directory);
|
|
9021
|
+
await writeOwnerOnlyFileNoReplace(transcriptPath, transcriptData);
|
|
9022
|
+
}
|
|
9023
|
+
const captured = SessionManager.captureTranscriptStrict(transcriptPath, storage);
|
|
9024
|
+
if (captured.kind !== "captured") {
|
|
9025
|
+
await cleanupTranscript();
|
|
9026
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
9027
|
+
return { kind: "blocked", reason: captured.reason };
|
|
9028
|
+
}
|
|
9029
|
+
transcriptIdentity = captured.snapshot.identity;
|
|
9030
|
+
opened = await SessionManager.openExistingForRecoveryHydrationStrict(
|
|
9031
|
+
transcriptIdentity,
|
|
9032
|
+
input.destination,
|
|
9033
|
+
storage,
|
|
9034
|
+
);
|
|
9035
|
+
} catch {
|
|
9036
|
+
await cleanupTranscript();
|
|
9037
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
9038
|
+
return { kind: "blocked", reason: "destination-unavailable" };
|
|
9039
|
+
}
|
|
9040
|
+
if (opened.kind === "error") {
|
|
9041
|
+
await cleanupTranscript();
|
|
9042
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
9043
|
+
return { kind: "blocked", reason: opened.reason };
|
|
9044
|
+
}
|
|
9045
|
+
if (
|
|
9046
|
+
opened.manager.getSessionId() !== input.checkpoint.session_id ||
|
|
9047
|
+
(opened.manager.getSessionName() ?? null) !== input.checkpoint.session_name
|
|
9048
|
+
) {
|
|
9049
|
+
await opened.manager.close();
|
|
9050
|
+
await cleanupTranscript();
|
|
9051
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
9052
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
9053
|
+
}
|
|
9054
|
+
opened.manager.#stageMemoryGuardCheckpointBlobs(checkpointBlobs);
|
|
9055
|
+
opened.manager.#recoveryPromotionTranscriptPath = path.join(
|
|
9056
|
+
destination.directory,
|
|
9057
|
+
`${new Date().toISOString().replace(/[:.]/g, "-")}_${input.checkpoint.session_id}.jsonl`,
|
|
9058
|
+
);
|
|
9059
|
+
let cleanupConsumed = false;
|
|
9060
|
+
const cleanup = async (): Promise<void> => {
|
|
9061
|
+
if (cleanupConsumed) return;
|
|
9062
|
+
await opened.manager.close();
|
|
9063
|
+
await cleanupTranscript();
|
|
9064
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
9065
|
+
cleanupConsumed = true;
|
|
9066
|
+
};
|
|
9067
|
+
return {
|
|
9068
|
+
kind: "staged",
|
|
9069
|
+
manager: opened.manager,
|
|
9070
|
+
hydrationContext: opened.context,
|
|
9071
|
+
transcriptIdentity,
|
|
9072
|
+
cleanup,
|
|
9073
|
+
};
|
|
9074
|
+
}
|
|
9075
|
+
|
|
7769
9076
|
/** Inspect a selected session without acquiring write-capable ownership. */
|
|
7770
9077
|
static async inspectSessionTailReadOnly(
|
|
7771
9078
|
filePath: string,
|
|
@@ -7834,8 +9141,31 @@ export class SessionManager {
|
|
|
7834
9141
|
if ("kind" in inspected || !sameResumeIdentity(context.identity, inspected.identity)) {
|
|
7835
9142
|
throw new Error("Recovery transcript authority changed before promotion.");
|
|
7836
9143
|
}
|
|
9144
|
+
if (this.#memoryGuardCheckpointBlobs) {
|
|
9145
|
+
for (const blob of this.#memoryGuardCheckpointBlobs.values()) this.#blobStore.putImmutableSync(blob);
|
|
9146
|
+
this.#residentImageBlobStore = this.#blobStore;
|
|
9147
|
+
this.#memoryGuardCheckpointBlobs = undefined;
|
|
9148
|
+
}
|
|
7837
9149
|
await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
7838
|
-
|
|
9150
|
+
const promotionPath = this.#recoveryPromotionTranscriptPath;
|
|
9151
|
+
if (!promotionPath) throw new Error("Recovery transcript promotion path is unavailable.");
|
|
9152
|
+
const promotedSource = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
|
|
9153
|
+
if ("kind" in promotedSource) throw new Error("Recovery transcript became unavailable before publication.");
|
|
9154
|
+
if (this.destination.kind === "managed") {
|
|
9155
|
+
const store = this.#managedTranscriptStore();
|
|
9156
|
+
const sourceName = path.basename(context.identity.canonicalPath);
|
|
9157
|
+
const sourceSnapshot = store.readExpected(sourceName);
|
|
9158
|
+
if (!sourceSnapshot) throw new Error("Recovery transcript authority changed before publication.");
|
|
9159
|
+
await store.publishNoReplace(path.basename(promotionPath), promotedSource.content);
|
|
9160
|
+
store.removeExpected(sourceName, sourceSnapshot);
|
|
9161
|
+
} else {
|
|
9162
|
+
await writeOwnerOnlyFileNoReplace(promotionPath, promotedSource.content);
|
|
9163
|
+
await fs.promises.rm(context.identity.canonicalPath, { force: true });
|
|
9164
|
+
await fsyncDirectoryPath(path.dirname(context.identity.canonicalPath));
|
|
9165
|
+
}
|
|
9166
|
+
this.#sessionFile = promotionPath;
|
|
9167
|
+
this.#recoveryPromotionTranscriptPath = undefined;
|
|
9168
|
+
writeTerminalBreadcrumb(this.cwd, promotionPath);
|
|
7839
9169
|
this.#recoveryHydrationContext = undefined;
|
|
7840
9170
|
}
|
|
7841
9171
|
|