@botlearn-course/daemon 0.0.20-beta.14 → 0.0.20-beta.16

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.
@@ -3,8 +3,13 @@ import type { WorkspaceScanMeasurement } from "./file-candidates.js";
3
3
  import { type RuntimeSkillProviderFactory } from "./runtime-skills.js";
4
4
  import { type PersistentSessionExecution, type PreparedPersistentTurn, type RunReportingClient } from "./run-dispatcher.js";
5
5
  import type { CourseRuntime, CourseRuntimeProfile, RunEvent, RunEventReceipt, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
6
- export declare function workspaceDurabilityCapabilities(usesNasWorkspace?: boolean): string[];
7
- export declare function assertWorkspaceRestoreAllowed(requestedRestore: unknown, usesNasWorkspace?: boolean): void;
6
+ export declare function workspaceDurabilityCapabilities(): string[];
7
+ /**
8
+ * NAS 是运行时 workspace 的唯一持久事实来源,committed snapshot 只供前端查看。
9
+ * 任何 restore payload 都意味着对端仍在下发已删除的 S3 回写指令,必须 fail closed,
10
+ * 否则可能用落后的派生快照覆盖 NAS 中较新的内容。
11
+ */
12
+ export declare function assertWorkspaceRestoreAllowed(requestedRestore: unknown): void;
8
13
  export interface AgentServiceSandboxOptions {
9
14
  wsUrl: string;
10
15
  sandboxId: string;
@@ -99,8 +104,8 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
99
104
  private handleTurnStart;
100
105
  private handleTurnCancel;
101
106
  /**
102
- * 幂等关闭一个 runtime session:终止其 runtime 子进程、删除 workspace/native state
103
- * 清空本地状态分片,并回 session.closed。未知 session 直接回 closed。
107
+ * 幂等关闭一个 runtime session:终止其 runtime 子进程并清空 session-local state
108
+ * Durable Workspace 由独立身份拥有,关闭执行上下文不会删除学员文件。
104
109
  */
105
110
  private closeSession;
106
111
  private reconcileInterruptedCommand;
@@ -1,7 +1,4 @@
1
- import { randomUUID } from "node:crypto";
2
- import { chmodSync, closeSync, createWriteStream, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
- import { Readable } from "node:stream";
4
- import { pipeline } from "node:stream/promises";
1
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
5
2
  import path from "node:path";
6
3
  import { ensureDaemonHome } from "./auth-store.js";
7
4
  import { AGENT_SERVICE_WS_SCHEMA, AGENT_SERVICE_WS_SUBPROTOCOL, createSandboxFrame, createWorkspaceFileChunk, parseSandboxFrame, UnsupportedSandboxProtocolError, WORKSPACE_FILE_READ_BINARY_CAPABILITY, } from "./agent-service-ws-protocol.js";
@@ -11,17 +8,14 @@ import { activationRuntimeEnv, runtimeChildEnv } from "./runtime-env.js";
11
8
  import { redactSecretString } from "./redaction.js";
12
9
  import { parseRuntimeSkillProviderGrantSet, prepareRuntimeSkillProvider, RuntimeSkillProviderError, } from "./runtime-skills.js";
13
10
  import { RunDispatcher, } from "./run-dispatcher.js";
14
- import { cleanupRuntimeSessionWorkspaceCopyStaging, copyRuntimeSessionWorkspace, ensureRuntimeSessionDirectories, ensureRuntimeSessionWorkspace, exposeRuntimeSessionWorkspace, finalizeRuntimeSessionWorkspaceCopy, removeRuntimeSessionWorkspace, revokeRuntimeSessionWorkspace, runtimeSessionWorkspaceDir, WORKSPACE_COPY_POLICY_V1, WorkspaceCopyError, workspaceCopyV1Supported, } from "./workspace.js";
11
+ import { ensureRuntimeSessionDirectories, ensureRuntimeSessionWorkspace, exposeRuntimeSessionWorkspace, removeRuntimeSessionWorkspace, revokeRuntimeSessionWorkspace, runtimeSessionWorkspaceDir, } from "./workspace.js";
15
12
  import { readWorkspaceFile, WorkspaceFileReadError } from "./workspace-file-read.js";
16
13
  import { checkpointWorkspace, WorkspaceSnapshotControlError, } from "./workspace-snapshot-control.js";
17
14
  import { assertWorkspaceWriterFreezeSupported, freezeRuntimeWriters, issueWorkspaceWriterFreezeProof, quiesceRuntimeWriters, thawRuntimeWriters, } from "./runtime-quiescence.js";
18
15
  import { WorkspaceSnapshotStagingError } from "./workspace-snapshot-staging.js";
19
- import { validateWorkspaceEntrySet, WorkspaceEntrySetError, } from "./workspace-entry-set.js";
20
- import { WORKSPACE_SNAPSHOT_POLICY_V1, workspaceSnapshotPolicyCapability, } from "./workspace-snapshot-policy.js";
21
- import { assertWorkspaceRestoreCapacity, restoreWorkspaceSnapshot, WorkspaceRestoreError, } from "./workspace-restore.js";
22
- import { readWorkspaceMaterializationMarker } from "./workspace-materialization.js";
23
- import { assertWorkspaceFilesystem, flushWorkspaceFilesystem, nasWorkspaceEnabled, } from "./workspace-filesystem.js";
24
- import { applyRuntimeWorkspaceQuota } from "./workspace-quota.js";
16
+ import { WorkspaceEntrySetError, } from "./workspace-entry-set.js";
17
+ import { workspaceSnapshotPolicyCapability, } from "./workspace-snapshot-policy.js";
18
+ import { assertWorkspaceFilesystem, flushWorkspaceFilesystem, } from "./workspace-filesystem.js";
25
19
  import { WebSocketClient, } from "./websocket-client.js";
26
20
  const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
27
21
  const MAX_SPOOL_FRAMES = 1024;
@@ -41,15 +35,21 @@ function agentServiceHttpBase(wsUrl) {
41
35
  }
42
36
  const RUNTIME_LOG_WINDOW_MS = 60_000;
43
37
  const WORKSPACE_FILE_TRANSFER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
44
- export function workspaceDurabilityCapabilities(usesNasWorkspace = nasWorkspaceEnabled()) {
38
+ export function workspaceDurabilityCapabilities() {
45
39
  return [
46
40
  "workspace_writer_freeze_v1",
47
41
  "workspace_snapshot_v2",
48
- ...(usesNasWorkspace ? ["workspace_nas_v1"] : ["workspace_restore_v1"]),
42
+ "workspace_nas_v1",
43
+ "workspace_identity_v1",
49
44
  ];
50
45
  }
51
- export function assertWorkspaceRestoreAllowed(requestedRestore, usesNasWorkspace = nasWorkspaceEnabled()) {
52
- if (usesNasWorkspace && requestedRestore !== null && requestedRestore !== undefined) {
46
+ /**
47
+ * NAS 是运行时 workspace 的唯一持久事实来源,committed snapshot 只供前端查看。
48
+ * 任何 restore payload 都意味着对端仍在下发已删除的 S3 回写指令,必须 fail closed,
49
+ * 否则可能用落后的派生快照覆盖 NAS 中较新的内容。
50
+ */
51
+ export function assertWorkspaceRestoreAllowed(requestedRestore) {
52
+ if (requestedRestore !== null && requestedRestore !== undefined) {
53
53
  throw new Error("workspace_restore_forbidden_for_nas");
54
54
  }
55
55
  }
@@ -243,8 +243,8 @@ function sandboxStatePath(sandboxId) {
243
243
  function emptySessionState() {
244
244
  return {
245
245
  courseRunId: null,
246
+ workspaceId: null,
246
247
  workspaceRef: null,
247
- workspaceCopyReceipt: null,
248
248
  runtimeId: null,
249
249
  nativeSessionId: null,
250
250
  contextRevision: 0,
@@ -262,8 +262,8 @@ function normalizeSessionState(value) {
262
262
  return base;
263
263
  return {
264
264
  courseRunId: typeof value.courseRunId === "string" ? value.courseRunId : null,
265
+ workspaceId: typeof value.workspaceId === "string" ? value.workspaceId : null,
265
266
  workspaceRef: typeof value.workspaceRef === "string" ? value.workspaceRef : null,
266
- workspaceCopyReceipt: (value.workspaceCopyReceipt && typeof value.workspaceCopyReceipt === "object") ? value.workspaceCopyReceipt : null,
267
267
  runtimeId: typeof value.runtimeId === "string" ? value.runtimeId : null,
268
268
  nativeSessionId: typeof value.nativeSessionId === "string" ? value.nativeSessionId : null,
269
269
  contextRevision: Number(value.contextRevision ?? 0),
@@ -434,10 +434,10 @@ export class AgentServiceSandboxClient {
434
434
  if (!activation || activation.activationId !== scope.activationId) {
435
435
  throw new Error("managed turn activation context is unavailable");
436
436
  }
437
- const prepared = ensureRuntimeSessionWorkspace(scope.sessionId, this.sandboxGeneration, payload.agent_run_id);
437
+ const prepared = ensureRuntimeSessionWorkspace(scope.sessionId, this.sandboxGeneration, payload.agent_run_id, session.workspaceId ?? scope.sessionId);
438
438
  // ensureRuntimeSessionWorkspace creates missing paths with the closed-by-default
439
439
  // mode; re-expose only after the activation fence above has been checked.
440
- exposeRuntimeSessionWorkspace(scope.sessionId, this.sandboxGeneration);
440
+ exposeRuntimeSessionWorkspace(scope.sessionId, this.sandboxGeneration, session.workspaceId ?? scope.sessionId);
441
441
  this.currentTurnSessionId = scope.sessionId;
442
442
  const runtimeEnv = runtimeChildEnv(process.env);
443
443
  delete runtimeEnv.DEEPSEEK_API_KEY;
@@ -536,6 +536,26 @@ export class AgentServiceSandboxClient {
536
536
  // there is no safe monotonic ordering from which to infer that an old replay died.
537
537
  session.completedActivations.push(pending.activationId);
538
538
  }
539
+ const cleanupFrame = createSandboxFrame({
540
+ type: "activation.cleaned",
541
+ sandboxId: this.options.sandboxId,
542
+ sandboxGeneration: this.sandboxGeneration,
543
+ connectionEpoch: this.connectionEpoch,
544
+ seq: this.nextOutboundSeq(),
545
+ runtimeSessionId: sessionId,
546
+ agentRunId: pending.agentRunId,
547
+ workerAttempt: pending.workerAttempt,
548
+ activationId: pending.activationId,
549
+ });
550
+ session.spool.push(cleanupFrame);
551
+ try {
552
+ this.enforceSpoolLimit();
553
+ }
554
+ catch (error) {
555
+ session.spool.pop();
556
+ this.persist();
557
+ throw error;
558
+ }
539
559
  const commandId = `run:${pending.agentRunId}:${pending.workerAttempt}`;
540
560
  if (!session.completedCommands.includes(commandId)) {
541
561
  session.completedCommands.push(commandId);
@@ -547,6 +567,8 @@ export class AgentServiceSandboxClient {
547
567
  if (this.currentTurnSessionId === sessionId)
548
568
  this.currentTurnSessionId = null;
549
569
  this.persist();
570
+ if (this.runtimeLogHandshakeReady)
571
+ await this.sendFrame(cleanupFrame);
550
572
  }
551
573
  async recoverPendingActivationCleanups() {
552
574
  for (const sessionId of Object.keys(this.state.sessions)) {
@@ -600,7 +622,7 @@ export class AgentServiceSandboxClient {
600
622
  this.dispatcher.cancelAll();
601
623
  for (const [sessionId, session] of Object.entries(this.state.sessions)) {
602
624
  session.activationId = null;
603
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
625
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration, session.workspaceId ?? sessionId);
604
626
  }
605
627
  this.activationContexts.clear();
606
628
  this.sessionProfiles.clear();
@@ -831,9 +853,6 @@ export class AgentServiceSandboxClient {
831
853
  }
832
854
  }
833
855
  async handleHello(frame) {
834
- // A reconnect can race a copy that started on the previous socket. Wait for
835
- // the serialized lifecycle attempt to converge before deleting crash-left
836
- // staging and advertising capability on the new connection.
837
856
  await this.lifecycleChain;
838
857
  if (frame.sandbox_id !== this.options.sandboxId) {
839
858
  throw new SandboxClosedError(4403, "sandbox_mismatch");
@@ -846,9 +865,10 @@ export class AgentServiceSandboxClient {
846
865
  this.dispatcher.cancelAll();
847
866
  await quiesceRuntimeWriters(null);
848
867
  this.rejectPending(new Error("Agent Service sandbox generation changed"));
849
- for (const sessionId of Object.keys(this.state.sessions)) {
850
- revokeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration);
851
- removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, nasWorkspaceEnabled());
868
+ for (const [sessionId, session] of Object.entries(this.state.sessions)) {
869
+ revokeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, session.workspaceId ?? sessionId);
870
+ // NAS image 跨 generation 保留正文,只清掉本地 daemon 侧的 session 状态。
871
+ removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, true);
852
872
  }
853
873
  this.state.sessions = {};
854
874
  this.state.sandboxGeneration = frame.sandbox_generation;
@@ -876,7 +896,6 @@ export class AgentServiceSandboxClient {
876
896
  : LEGACY_EVENT_ACK_WINDOW;
877
897
  this.maxUnackedEvents = Math.min(MAX_EVENT_ACK_WINDOW, ackWindow);
878
898
  this.runtimeLogPolicy = runtimeLogPolicy(frame.payload.runtime_log_policy);
879
- await cleanupRuntimeSessionWorkspaceCopyStaging();
880
899
  this.persist();
881
900
  assertWorkspaceFilesystem(process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT ?? "");
882
901
  assertWorkspaceWriterFreezeSupported();
@@ -885,7 +904,6 @@ export class AgentServiceSandboxClient {
885
904
  capabilities: [
886
905
  WORKSPACE_FILE_READ_BINARY_CAPABILITY,
887
906
  ...workspaceDurabilityCapabilities(),
888
- ...(workspaceCopyV1Supported() ? ["workspace_copy_v1"] : []),
889
907
  ],
890
908
  workspace_snapshot_policy: workspaceSnapshotPolicyCapability(),
891
909
  daemon_version: this.options.daemonVersion,
@@ -1065,315 +1083,54 @@ export class AgentServiceSandboxClient {
1065
1083
  const sessionId = frame.runtime_session_id;
1066
1084
  const session = this.state.sessions[sessionId] ?? emptySessionState();
1067
1085
  const courseRunId = frame.payload.course_run_id;
1086
+ // Mixed-version reconciliation may replay a pre-Workspace-identity session.open.
1087
+ // Its physical directory was keyed by RuntimeSession id, which is the exact backfill
1088
+ // identity used by the database migration.
1089
+ const requestedWorkspaceId = typeof frame.payload.workspace_id === "string"
1090
+ ? frame.payload.workspace_id
1091
+ : session.workspaceId ?? sessionId;
1068
1092
  const requestedWorkspaceRef = frame.payload.workspace_ref;
1069
- const requestedCopy = frame.payload.workspace_copy;
1070
- const requestedRestore = frame.payload.workspace_restore;
1071
- assertWorkspaceRestoreAllowed(requestedRestore);
1072
- const requestedCopyRecord = requestedCopy && typeof requestedCopy === "object"
1073
- ? requestedCopy
1074
- : undefined;
1075
- const copyId = requestedCopyRecord
1076
- ? requestedCopyRecord.copy_id
1077
- : undefined;
1078
- const requestedCopySource = requestedCopyRecord
1079
- ? requestedCopyRecord.source_runtime_session_id
1080
- : undefined;
1093
+ assertWorkspaceRestoreAllowed(frame.payload.workspace_restore);
1081
1094
  if (typeof courseRunId !== "string" ||
1082
1095
  courseRunId.length < 1 ||
1096
+ requestedWorkspaceId.length < 1 ||
1083
1097
  (requestedWorkspaceRef !== null &&
1084
1098
  requestedWorkspaceRef !== undefined &&
1085
- (typeof requestedWorkspaceRef !== "string" || requestedWorkspaceRef.length < 1)) ||
1086
- (requestedCopy !== null &&
1087
- requestedCopy !== undefined &&
1088
- (typeof requestedCopy !== "object" ||
1089
- typeof copyId !== "string" ||
1090
- copyId.length < 1 ||
1091
- typeof requestedCopySource !== "string" ||
1092
- requestedCopySource.length < 1))
1093
- || (requestedRestore !== null && requestedRestore !== undefined &&
1094
- (typeof requestedRestore !== "object" || Array.isArray(requestedRestore)))
1095
- || (requestedCopy !== null && requestedCopy !== undefined &&
1096
- requestedRestore !== null && requestedRestore !== undefined)) {
1099
+ (typeof requestedWorkspaceRef !== "string" || requestedWorkspaceRef.length < 1))) {
1097
1100
  throw new Error("invalid session.open payload");
1098
1101
  }
1099
1102
  if (session.courseRunId !== null && session.courseRunId !== courseRunId) {
1100
1103
  throw new Error("runtime session course_run_id cannot be rebound");
1101
1104
  }
1105
+ if (session.workspaceId !== null && session.workspaceId !== requestedWorkspaceId) {
1106
+ throw new Error("runtime session workspace_id cannot be rebound");
1107
+ }
1102
1108
  if (session.workspaceRef !== null &&
1103
1109
  typeof requestedWorkspaceRef === "string" &&
1104
1110
  session.workspaceRef !== requestedWorkspaceRef) {
1105
1111
  throw new Error("runtime session workspace_ref cannot be rebound");
1106
1112
  }
1107
1113
  const isNewSession = session.courseRunId === null;
1108
- let markerPath = null;
1109
- try {
1110
- if (requestedRestore && typeof requestedRestore === "object") {
1111
- const raw = requestedRestore;
1112
- const descriptorRaw = raw.descriptor;
1113
- const controlFence = raw.control_fence;
1114
- let restoreControlToken = raw.control_token;
1115
- if (!controlFence || typeof controlFence !== "object" || Array.isArray(controlFence)) {
1116
- throw new Error("invalid workspace restore control fence");
1117
- }
1118
- if (typeof restoreControlToken !== "string" || restoreControlToken.length < 32) {
1119
- throw new Error("invalid workspace restore control token");
1120
- }
1121
- if (!descriptorRaw || !Array.isArray(raw.entries)) {
1122
- throw new Error("invalid workspace restore payload");
1123
- }
1124
- const descriptor = {
1125
- sandboxId: this.options.sandboxId,
1126
- sandboxGeneration: this.sandboxGeneration,
1127
- runtimeSessionId: sessionId,
1128
- continuityState: descriptorRaw.continuity_state,
1129
- continuityErrorCode: descriptorRaw.continuity_error_code,
1130
- snapshotId: descriptorRaw.snapshot_id,
1131
- revision: Number(descriptorRaw.revision),
1132
- contentSha256: descriptorRaw.content_sha256,
1133
- expectedFileCount: Number(descriptorRaw.expected_file_count),
1134
- expectedEntryCount: Number(descriptorRaw.expected_entry_count),
1135
- expectedTotalBytes: Number(descriptorRaw.expected_total_bytes),
1136
- };
1137
- const workspaceDirectory = runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration);
1138
- const requiredFreeBytes = Math.max(WORKSPACE_SNAPSHOT_POLICY_V1.requiredRestoreFreeBytes, descriptor.expectedTotalBytes + WORKSPACE_SNAPSHOT_POLICY_V1.requiredRestoreFreeBytes);
1139
- // Fail before issuing or consuming any object grant. The download staging is a
1140
- // sibling on this same bounded backing filesystem and is renamed, not copied,
1141
- // into the atomic restore staging tree.
1142
- assertWorkspaceRestoreCapacity({
1143
- workspaceDirectory,
1144
- descriptor,
1145
- requiredFreeBytes,
1146
- entryCopies: 2,
1147
- });
1148
- const entries = [];
1149
- const predownloadRoot = path.join(path.dirname(workspaceDirectory), `.botlearn-predownload-${sessionId}-${randomUUID()}`);
1150
- mkdirSync(predownloadRoot, { recursive: true, mode: 0o700 });
1151
- try {
1152
- let nextCursor = null;
1153
- do {
1154
- if (nextCursor !== null && nextCursor.length > 2048) {
1155
- throw new Error("invalid workspace restore cursor");
1156
- }
1157
- const pageSignal = AbortSignal.timeout(60_000);
1158
- const response = await fetch(`${agentServiceHttpBase(this.options.wsUrl)}/runtime-sessions/${sessionId}/restore-grants`, {
1159
- method: "POST",
1160
- headers: {
1161
- Authorization: `Bearer ${restoreControlToken}`,
1162
- "Content-Type": "application/json",
1163
- },
1164
- body: JSON.stringify({
1165
- policy_id: WORKSPACE_SNAPSHOT_POLICY_V1.policyId,
1166
- fence: controlFence,
1167
- cursor: nextCursor,
1168
- // Fetch one canonical entry at a time so a grant is consumed as soon as
1169
- // it is signed, including deployments using the minimum 60-second TTL.
1170
- max_entries: 1,
1171
- }),
1172
- signal: pageSignal,
1173
- });
1174
- if (!response.ok) {
1175
- let code = "workspace_restore_unavailable";
1176
- let retryable = response.status >= 500 || response.status === 408 ||
1177
- response.status === 429;
1178
- try {
1179
- const rejected = await response.json();
1180
- const detail = rejected.detail;
1181
- if (detail && typeof detail === "object" && !Array.isArray(detail)) {
1182
- const stableCode = detail.code;
1183
- if (typeof stableCode === "string" && stableCode.length <= 120) {
1184
- code = stableCode;
1185
- }
1186
- if (typeof detail.retryable === "boolean") {
1187
- retryable = detail.retryable;
1188
- }
1189
- }
1190
- }
1191
- catch {
1192
- // Content-free fallback retains the stable local failure code.
1193
- }
1194
- throw new WorkspaceRestoreError(code, retryable);
1195
- }
1196
- const page = await response.json();
1197
- if (!Array.isArray(page.entries))
1198
- throw new Error("invalid workspace restore page");
1199
- if (typeof page.control_token !== "string" || page.control_token.length < 32) {
1200
- throw new Error("invalid refreshed workspace restore control token");
1201
- }
1202
- restoreControlToken = page.control_token;
1203
- const pageFiles = [];
1204
- for (const value of page.entries) {
1205
- if (!value || typeof value !== "object" || Array.isArray(value)) {
1206
- throw new Error("invalid workspace restore entry");
1207
- }
1208
- const item = value;
1209
- if (item.entry_type === "directory") {
1210
- entries.push({ type: "directory", path: String(item.path), mode: "0700" });
1211
- continue;
1212
- }
1213
- if (item.entry_type !== "file" || !item.grant || typeof item.grant !== "object") {
1214
- throw new Error("invalid workspace restore file grant");
1215
- }
1216
- const entry = {
1217
- type: "file",
1218
- path: String(item.path),
1219
- mode: item.mode,
1220
- sizeBytes: Number(item.size_bytes),
1221
- sha256: String(item.sha256),
1222
- };
1223
- entries.push(entry);
1224
- pageFiles.push({ entry, grant: item.grant });
1225
- }
1226
- // Validate all paths and duplicates before using any page path locally.
1227
- validateWorkspaceEntrySet({ schemaVersion: "agent-workspace-entry-set/1", entries }, WORKSPACE_SNAPSHOT_POLICY_V1.limits);
1228
- for (const { entry, grant } of pageFiles) {
1229
- if (typeof grant.url !== "string")
1230
- throw new Error("workspace restore grant missing");
1231
- const destination = path.join(predownloadRoot, ...entry.path.split("/"));
1232
- mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
1233
- const part = `${destination}.part`;
1234
- const downloadSignal = AbortSignal.timeout(60_000);
1235
- const download = await fetch(grant.url, {
1236
- method: "GET",
1237
- headers: grant.headers,
1238
- signal: downloadSignal,
1239
- });
1240
- if (!download.ok)
1241
- throw new Error(`workspace_restore_download_${download.status}`);
1242
- if (!download.body)
1243
- throw new Error("workspace_restore_download_body_missing");
1244
- await pipeline(Readable.fromWeb(download.body), createWriteStream(part, { mode: 0o600 }), { signal: downloadSignal });
1245
- renameSync(part, destination);
1246
- }
1247
- const rawNext = page.next_cursor;
1248
- if (rawNext !== null && rawNext !== undefined && typeof rawNext !== "string") {
1249
- throw new Error("invalid workspace restore cursor");
1250
- }
1251
- nextCursor = rawNext ?? null;
1252
- } while (nextCursor !== null);
1253
- await restoreWorkspaceSnapshot({
1254
- workspaceDirectory,
1255
- controlDirectory: path.join(ensureDaemonHome(), "agent-service-sandboxes", this.options.sandboxId, "workspace-control"),
1256
- descriptor,
1257
- entrySet: { schemaVersion: "agent-workspace-entry-set/1", entries },
1258
- limits: WORKSPACE_SNAPSHOT_POLICY_V1.limits,
1259
- requiredFreeBytes,
1260
- downloadFile: async (entry, partPath) => {
1261
- renameSync(path.join(predownloadRoot, ...entry.path.split("/")), partPath);
1262
- },
1263
- });
1264
- }
1265
- finally {
1266
- rmSync(predownloadRoot, { recursive: true, force: true });
1267
- }
1268
- }
1269
- if (typeof requestedCopySource === "string" && typeof copyId === "string") {
1270
- if (session.workspaceCopyReceipt !== null) {
1271
- const receipt = session.workspaceCopyReceipt;
1272
- if (receipt.copy_id !== copyId ||
1273
- receipt.source_runtime_session_id !== requestedCopySource ||
1274
- receipt.target_runtime_session_id !== sessionId ||
1275
- receipt.source_sandbox_id !== this.options.sandboxId ||
1276
- receipt.source_generation !== this.sandboxGeneration) {
1277
- throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
1278
- }
1279
- markerPath = path.join(runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration), WORKSPACE_COPY_POLICY_V1.markerName);
1280
- }
1281
- else if (!isNewSession) {
1282
- throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
1283
- }
1284
- }
1285
- if (isNewSession && typeof requestedCopySource === "string" && typeof copyId === "string") {
1286
- const sourceSession = this.state.sessions[requestedCopySource];
1287
- if (!sourceSession ||
1288
- sourceSession.courseRunId === null ||
1289
- this.activeSessionId === requestedCopySource ||
1290
- this.currentTurnSessionId === requestedCopySource) {
1291
- throw new WorkspaceCopyError("workspace_migration_source_unavailable");
1292
- }
1293
- revokeRuntimeSessionWorkspace(requestedCopySource, this.sandboxGeneration);
1294
- const copied = await copyRuntimeSessionWorkspace({
1295
- copyId,
1296
- sourceRuntimeSessionId: requestedCopySource,
1297
- targetRuntimeSessionId: sessionId,
1298
- sandboxId: this.options.sandboxId,
1299
- sandboxGeneration: this.sandboxGeneration,
1300
- });
1301
- session.workspaceCopyReceipt = copied.receipt;
1302
- markerPath = copied.markerPath;
1303
- }
1304
- }
1305
- catch (error) {
1306
- if (error instanceof WorkspaceCopyError) {
1307
- await this.sendSessionFrame("session.open_failed", sessionId, {
1308
- error_code: error.code,
1309
- });
1310
- return;
1311
- }
1312
- if (requestedRestore) {
1313
- const restoreError = error instanceof WorkspaceRestoreError ? error : null;
1314
- await this.sendSessionFrame("session.open_failed", sessionId, {
1315
- error_code: restoreError?.code ?? "workspace_restore_unavailable",
1316
- retryable: restoreError?.retryable ?? true,
1317
- });
1318
- return;
1319
- }
1320
- throw error;
1321
- }
1322
1114
  session.courseRunId = courseRunId;
1115
+ session.workspaceId = requestedWorkspaceId;
1323
1116
  session.workspaceRef = session.workspaceRef ?? (typeof requestedWorkspaceRef === "string"
1324
1117
  ? requestedWorkspaceRef
1325
1118
  : `ws_${sessionId.replaceAll("-", "")}_g${this.sandboxGeneration}`);
1326
1119
  // Replaying session.open is part of reconnect reconciliation. An already-active
1327
1120
  // runtime may still be creating files in this workspace, so never transiently revoke
1328
1121
  // its group write/execute access before the following session.activate arrives.
1329
- if (session.workspaceCopyReceipt === null && (isNewSession || session.activationId === null)) {
1330
- ensureRuntimeSessionDirectories(sessionId, this.sandboxGeneration);
1331
- }
1332
- if (markerPath !== null) {
1333
- await finalizeRuntimeSessionWorkspaceCopy(markerPath);
1334
- }
1335
- try {
1336
- if (!nasWorkspaceEnabled()) {
1337
- await applyRuntimeWorkspaceQuota(sessionId, this.sandboxGeneration);
1338
- }
1339
- }
1340
- catch (error) {
1341
- if (requestedRestore) {
1342
- await this.sendSessionFrame("session.open_failed", sessionId, {
1343
- error_code: "workspace_restore_unavailable",
1344
- });
1345
- return;
1346
- }
1347
- if (requestedCopy) {
1348
- await this.sendSessionFrame("session.open_failed", sessionId, {
1349
- error_code: "workspace_migration_io_failed",
1350
- });
1351
- return;
1352
- }
1353
- throw error;
1122
+ if (isNewSession || session.activationId === null) {
1123
+ ensureRuntimeSessionDirectories(sessionId, this.sandboxGeneration, requestedWorkspaceId);
1354
1124
  }
1355
1125
  this.state.sessions[sessionId] = session;
1356
1126
  this.persist();
1357
- const usesNasWorkspace = nasWorkspaceEnabled();
1358
- const materialization = usesNasWorkspace
1359
- ? null
1360
- : readWorkspaceMaterializationMarker(this.options.sandboxId, sessionId);
1127
+ // NAS 是唯一的 workspace 权威来源,所以 session.opened 不携带 snapshot 物化标记:
1128
+ // 挂载证明只说明当前 generation 已挂上 NAS,不要求运行目录等于某个 committed revision。
1361
1129
  await this.sendSessionFrame("session.opened", sessionId, {
1130
+ workspace_id: session.workspaceId,
1362
1131
  workspace_ref: session.workspaceRef,
1363
1132
  native_session_id: session.nativeSessionId,
1364
- workspace_authority: usesNasWorkspace ? "nas" : "snapshot",
1365
- ...(materialization
1366
- ? {
1367
- workspace_marker_version: 1,
1368
- workspace_continuity_state: materialization.workspaceContinuityState,
1369
- workspace_snapshot_id: materialization.snapshotId,
1370
- workspace_revision: materialization.revision,
1371
- workspace_content_sha256: materialization.contentSha256,
1372
- }
1373
- : {}),
1374
- ...(session.workspaceCopyReceipt !== null
1375
- ? { workspace_copy_receipt: session.workspaceCopyReceipt }
1376
- : {}),
1133
+ workspace_authority: "nas",
1377
1134
  });
1378
1135
  }
1379
1136
  async handleWorkspaceCheckpoint(frame) {
@@ -1393,8 +1150,6 @@ export class AgentServiceSandboxClient {
1393
1150
  "sandbox_pause",
1394
1151
  "sandbox_cleanup",
1395
1152
  "generation_rotation",
1396
- "initial_migration",
1397
- "live_workspace_copy",
1398
1153
  "maintenance_repair",
1399
1154
  ].includes(String(sourceKind)) ||
1400
1155
  !Number.isSafeInteger(baseRevision) ||
@@ -1422,7 +1177,7 @@ export class AgentServiceSandboxClient {
1422
1177
  await checkpointWorkspace({
1423
1178
  wsUrl: this.options.wsUrl,
1424
1179
  reconnectToken: controlToken,
1425
- workspaceDirectory: runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration),
1180
+ workspaceDirectory: runtimeSessionWorkspaceDir(session.workspaceId ?? sessionId, this.sandboxGeneration),
1426
1181
  scope: {
1427
1182
  checkpointId,
1428
1183
  baseRevision,
@@ -1434,17 +1189,12 @@ export class AgentServiceSandboxClient {
1434
1189
  freezeProof,
1435
1190
  });
1436
1191
  checkpointCommitted = true;
1437
- const marker = readWorkspaceMaterializationMarker(this.options.sandboxId, sessionId);
1438
- if (!marker)
1439
- throw new Error("workspace checkpoint marker missing");
1192
+ // committed revision 只是派生投影,不改变运行目录,所以这里不上报物化标记。
1440
1193
  await this.sendSessionFrame("session.opened", sessionId, {
1194
+ workspace_id: session.workspaceId,
1441
1195
  workspace_ref: session.workspaceRef,
1442
1196
  native_session_id: session.nativeSessionId,
1443
- workspace_marker_version: 1,
1444
- workspace_continuity_state: marker.workspaceContinuityState,
1445
- workspace_snapshot_id: marker.snapshotId,
1446
- workspace_revision: marker.revision,
1447
- workspace_content_sha256: marker.contentSha256,
1197
+ workspace_authority: "nas",
1448
1198
  });
1449
1199
  await this.sendCommandAck(frame, "ok");
1450
1200
  if (!lifecycleCheckpoint)
@@ -1539,7 +1289,7 @@ export class AgentServiceSandboxClient {
1539
1289
  if (required.length === 0)
1540
1290
  return [];
1541
1291
  if (!availableCapabilities) {
1542
- const workspace = ensureRuntimeSessionDirectories(sessionId, this.sandboxGeneration);
1292
+ const workspace = ensureRuntimeSessionDirectories(sessionId, this.sandboxGeneration, session.workspaceId ?? sessionId);
1543
1293
  availableCapabilities = new Set(availableRunCapabilities({
1544
1294
  agent_run_id: "activation-probe",
1545
1295
  course_run_id: session.courseRunId ?? "",
@@ -1622,7 +1372,7 @@ export class AgentServiceSandboxClient {
1622
1372
  session.runtimeId = runtimeId;
1623
1373
  session.contextRevision = contextRevision;
1624
1374
  session.activationId = activationId;
1625
- exposeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1375
+ exposeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration, session.workspaceId ?? sessionId);
1626
1376
  this.activationContexts.set(sessionId, {
1627
1377
  activationId,
1628
1378
  runtimeEnv,
@@ -1793,8 +1543,8 @@ export class AgentServiceSandboxClient {
1793
1543
  await this.finishTurn({ agent_run_id: runId });
1794
1544
  }
1795
1545
  /**
1796
- * 幂等关闭一个 runtime session:终止其 runtime 子进程、删除 workspace/native state
1797
- * 清空本地状态分片,并回 session.closed。未知 session 直接回 closed。
1546
+ * 幂等关闭一个 runtime session:终止其 runtime 子进程并清空 session-local state
1547
+ * Durable Workspace 由独立身份拥有,关闭执行上下文不会删除学员文件。
1798
1548
  */
1799
1549
  async closeSession(sessionId, reason, sourceFrame, fence) {
1800
1550
  const session = this.state.sessions[sessionId];
@@ -1809,7 +1559,11 @@ export class AgentServiceSandboxClient {
1809
1559
  }
1810
1560
  await this.terminateSessionWriters(session);
1811
1561
  session.activationId = null;
1812
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1562
+ const workspaceId = session.workspaceId ?? sessionId;
1563
+ const sharedWorkspaceIsOpen = Object.entries(this.state.sessions).some(([otherSessionId, other]) => otherSessionId !== sessionId && (other.workspaceId ?? otherSessionId) === workspaceId);
1564
+ if (!sharedWorkspaceIsOpen) {
1565
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration, workspaceId);
1566
+ }
1813
1567
  if (fence && !this.isCurrentLifecycleFence(fence))
1814
1568
  return;
1815
1569
  if (this.activeSessionId === sessionId)
@@ -1818,7 +1572,7 @@ export class AgentServiceSandboxClient {
1818
1572
  this.currentTurnSessionId = null;
1819
1573
  for (const runId of runIds)
1820
1574
  this.turnScopes.delete(runId);
1821
- removeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1575
+ removeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration, true, workspaceId);
1822
1576
  delete this.state.sessions[sessionId];
1823
1577
  this.persist();
1824
1578
  }
@@ -1960,7 +1714,7 @@ export class AgentServiceSandboxClient {
1960
1714
  this.activationContexts.delete(sessionId);
1961
1715
  this.sessionProfiles.delete(sessionId);
1962
1716
  if (terminateWriters) {
1963
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1717
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration, session.workspaceId ?? sessionId);
1964
1718
  }
1965
1719
  if (this.activeSessionId === sessionId)
1966
1720
  this.activeSessionId = null;
@@ -2122,7 +1876,7 @@ export class AgentServiceSandboxClient {
2122
1876
  }
2123
1877
  this.workspaceFileReadInFlight = true;
2124
1878
  try {
2125
- const result = await readWorkspaceFile(runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration), relativePath, {
1879
+ const result = await readWorkspaceFile(runtimeSessionWorkspaceDir(this.state.sessions[sessionId]?.workspaceId ?? sessionId, this.sandboxGeneration), relativePath, {
2126
1880
  maxBytes: maxBytes,
2127
1881
  expectedSizeBytes: expectedSizeBytes,
2128
1882
  expectedSha256,
@@ -3,7 +3,7 @@ export declare const AGENT_SERVICE_WS_SUBPROTOCOL: "botlearn-agent-sandbox.v4";
3
3
  export declare const WORKSPACE_FILE_READ_BINARY_CAPABILITY: "workspace_file_read_binary_v1";
4
4
  export declare const WORKSPACE_FILE_CHUNK_BYTES: number;
5
5
  export declare const WORKSPACE_FILE_CHUNK_HEADER_BYTES = 29;
6
- export type SandboxFrameType = "sandbox.hello" | "sandbox.sync" | "session.open" | "session.activate" | "turn.start" | "turn.cancel" | "session.close" | "sandbox.drain" | "sandbox.shutdown" | "event.ack" | "auth.rotate" | "workspace.file.read" | "workspace.checkpoint" | "workspace.thaw" | "ping" | "sandbox.ready" | "sandbox.heartbeat" | "command.ack" | "session.opened" | "session.open_failed" | "session.closed" | "turn.event" | "turn.file.report" | "workspace.file.result" | "sandbox.drained" | "sandbox.log" | "pong" | "protocol.error";
6
+ export type SandboxFrameType = "sandbox.hello" | "sandbox.sync" | "session.open" | "session.activate" | "turn.start" | "turn.cancel" | "session.close" | "sandbox.drain" | "sandbox.shutdown" | "event.ack" | "auth.rotate" | "workspace.file.read" | "workspace.checkpoint" | "workspace.thaw" | "ping" | "sandbox.ready" | "sandbox.heartbeat" | "command.ack" | "session.opened" | "session.open_failed" | "session.closed" | "activation.cleaned" | "turn.event" | "turn.file.report" | "workspace.file.result" | "sandbox.drained" | "sandbox.log" | "pong" | "protocol.error";
7
7
  export declare class UnsupportedSandboxProtocolError extends Error {
8
8
  readonly schemaVersion: unknown;
9
9
  constructor(schemaVersion: unknown);