@botlearn-course/daemon 0.0.20-beta.9 → 0.0.21-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -29,6 +23,7 @@ const MAX_SPOOL_BYTES = 8 * 1024 * 1024;
29
23
  const MAX_EVENT_ACK_WINDOW = 64;
30
24
  const LEGACY_EVENT_ACK_WINDOW = 1;
31
25
  const INPUT_ATTACHMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
26
+ const ASSET_ACTIVATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
32
27
  /** 出站 seq 基址:seq = connection_epoch * SEQ_EPOCH_BASE + n,跨重连单调(合同 §1.1)。 */
33
28
  const SEQ_EPOCH_BASE = 1_000_000_000;
34
29
  function agentServiceHttpBase(wsUrl) {
@@ -41,6 +36,24 @@ function agentServiceHttpBase(wsUrl) {
41
36
  }
42
37
  const RUNTIME_LOG_WINDOW_MS = 60_000;
43
38
  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;
39
+ export function workspaceDurabilityCapabilities() {
40
+ return [
41
+ "workspace_writer_freeze_v1",
42
+ "workspace_snapshot_v2",
43
+ "workspace_nas_v1",
44
+ "workspace_identity_v1",
45
+ ];
46
+ }
47
+ /**
48
+ * NAS 是运行时 workspace 的唯一持久事实来源,committed snapshot 只供前端查看。
49
+ * 任何 restore payload 都意味着对端仍在下发已删除的 S3 回写指令,必须 fail closed,
50
+ * 否则可能用落后的派生快照覆盖 NAS 中较新的内容。
51
+ */
52
+ export function assertWorkspaceRestoreAllowed(requestedRestore) {
53
+ if (requestedRestore !== null && requestedRestore !== undefined) {
54
+ throw new Error("workspace_restore_forbidden_for_nas");
55
+ }
56
+ }
44
57
  const DISABLED_RUNTIME_LOG_POLICY = {
45
58
  enabled: false,
46
59
  maxEventBytes: 4096,
@@ -231,8 +244,8 @@ function sandboxStatePath(sandboxId) {
231
244
  function emptySessionState() {
232
245
  return {
233
246
  courseRunId: null,
247
+ workspaceId: null,
234
248
  workspaceRef: null,
235
- workspaceCopyReceipt: null,
236
249
  runtimeId: null,
237
250
  nativeSessionId: null,
238
251
  contextRevision: 0,
@@ -250,8 +263,8 @@ function normalizeSessionState(value) {
250
263
  return base;
251
264
  return {
252
265
  courseRunId: typeof value.courseRunId === "string" ? value.courseRunId : null,
266
+ workspaceId: typeof value.workspaceId === "string" ? value.workspaceId : null,
253
267
  workspaceRef: typeof value.workspaceRef === "string" ? value.workspaceRef : null,
254
- workspaceCopyReceipt: (value.workspaceCopyReceipt && typeof value.workspaceCopyReceipt === "object") ? value.workspaceCopyReceipt : null,
255
268
  runtimeId: typeof value.runtimeId === "string" ? value.runtimeId : null,
256
269
  nativeSessionId: typeof value.nativeSessionId === "string" ? value.nativeSessionId : null,
257
270
  contextRevision: Number(value.contextRevision ?? 0),
@@ -422,10 +435,10 @@ export class AgentServiceSandboxClient {
422
435
  if (!activation || activation.activationId !== scope.activationId) {
423
436
  throw new Error("managed turn activation context is unavailable");
424
437
  }
425
- const prepared = ensureRuntimeSessionWorkspace(scope.sessionId, this.sandboxGeneration, payload.agent_run_id);
438
+ const prepared = ensureRuntimeSessionWorkspace(scope.sessionId, this.sandboxGeneration, payload.agent_run_id, session.workspaceId ?? scope.sessionId);
426
439
  // ensureRuntimeSessionWorkspace creates missing paths with the closed-by-default
427
440
  // mode; re-expose only after the activation fence above has been checked.
428
- exposeRuntimeSessionWorkspace(scope.sessionId, this.sandboxGeneration);
441
+ exposeRuntimeSessionWorkspace(scope.sessionId, this.sandboxGeneration, session.workspaceId ?? scope.sessionId);
429
442
  this.currentTurnSessionId = scope.sessionId;
430
443
  const runtimeEnv = runtimeChildEnv(process.env);
431
444
  delete runtimeEnv.DEEPSEEK_API_KEY;
@@ -524,6 +537,29 @@ export class AgentServiceSandboxClient {
524
537
  // there is no safe monotonic ordering from which to infer that an old replay died.
525
538
  session.completedActivations.push(pending.activationId);
526
539
  }
540
+ let cleanupFrame = null;
541
+ if (ASSET_ACTIVATION_ID_PATTERN.test(pending.activationId)) {
542
+ cleanupFrame = createSandboxFrame({
543
+ type: "activation.cleaned",
544
+ sandboxId: this.options.sandboxId,
545
+ sandboxGeneration: this.sandboxGeneration,
546
+ connectionEpoch: this.connectionEpoch,
547
+ seq: this.nextOutboundSeq(),
548
+ runtimeSessionId: sessionId,
549
+ agentRunId: pending.agentRunId,
550
+ workerAttempt: pending.workerAttempt,
551
+ activationId: pending.activationId,
552
+ });
553
+ session.spool.push(cleanupFrame);
554
+ try {
555
+ this.enforceSpoolLimit();
556
+ }
557
+ catch (error) {
558
+ session.spool.pop();
559
+ this.persist();
560
+ throw error;
561
+ }
562
+ }
527
563
  const commandId = `run:${pending.agentRunId}:${pending.workerAttempt}`;
528
564
  if (!session.completedCommands.includes(commandId)) {
529
565
  session.completedCommands.push(commandId);
@@ -535,6 +571,8 @@ export class AgentServiceSandboxClient {
535
571
  if (this.currentTurnSessionId === sessionId)
536
572
  this.currentTurnSessionId = null;
537
573
  this.persist();
574
+ if (cleanupFrame !== null && this.runtimeLogHandshakeReady)
575
+ await this.sendFrame(cleanupFrame);
538
576
  }
539
577
  async recoverPendingActivationCleanups() {
540
578
  for (const sessionId of Object.keys(this.state.sessions)) {
@@ -588,7 +626,7 @@ export class AgentServiceSandboxClient {
588
626
  this.dispatcher.cancelAll();
589
627
  for (const [sessionId, session] of Object.entries(this.state.sessions)) {
590
628
  session.activationId = null;
591
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
629
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration, session.workspaceId ?? sessionId);
592
630
  }
593
631
  this.activationContexts.clear();
594
632
  this.sessionProfiles.clear();
@@ -819,9 +857,6 @@ export class AgentServiceSandboxClient {
819
857
  }
820
858
  }
821
859
  async handleHello(frame) {
822
- // A reconnect can race a copy that started on the previous socket. Wait for
823
- // the serialized lifecycle attempt to converge before deleting crash-left
824
- // staging and advertising capability on the new connection.
825
860
  await this.lifecycleChain;
826
861
  if (frame.sandbox_id !== this.options.sandboxId) {
827
862
  throw new SandboxClosedError(4403, "sandbox_mismatch");
@@ -834,9 +869,10 @@ export class AgentServiceSandboxClient {
834
869
  this.dispatcher.cancelAll();
835
870
  await quiesceRuntimeWriters(null);
836
871
  this.rejectPending(new Error("Agent Service sandbox generation changed"));
837
- for (const sessionId of Object.keys(this.state.sessions)) {
838
- revokeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration);
839
- removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, nasWorkspaceEnabled());
872
+ for (const [sessionId, session] of Object.entries(this.state.sessions)) {
873
+ revokeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, session.workspaceId ?? sessionId);
874
+ // NAS image 跨 generation 保留正文,只清掉本地 daemon 侧的 session 状态。
875
+ removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, true);
840
876
  }
841
877
  this.state.sessions = {};
842
878
  this.state.sandboxGeneration = frame.sandbox_generation;
@@ -864,7 +900,6 @@ export class AgentServiceSandboxClient {
864
900
  : LEGACY_EVENT_ACK_WINDOW;
865
901
  this.maxUnackedEvents = Math.min(MAX_EVENT_ACK_WINDOW, ackWindow);
866
902
  this.runtimeLogPolicy = runtimeLogPolicy(frame.payload.runtime_log_policy);
867
- await cleanupRuntimeSessionWorkspaceCopyStaging();
868
903
  this.persist();
869
904
  assertWorkspaceFilesystem(process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT ?? "");
870
905
  assertWorkspaceWriterFreezeSupported();
@@ -872,11 +907,7 @@ export class AgentServiceSandboxClient {
872
907
  protocol_versions: [AGENT_SERVICE_WS_SCHEMA],
873
908
  capabilities: [
874
909
  WORKSPACE_FILE_READ_BINARY_CAPABILITY,
875
- "workspace_writer_freeze_v1",
876
- "workspace_snapshot_v2",
877
- "workspace_restore_v1",
878
- ...(nasWorkspaceEnabled() ? ["workspace_nas_v1"] : []),
879
- ...(workspaceCopyV1Supported() ? ["workspace_copy_v1"] : []),
910
+ ...workspaceDurabilityCapabilities(),
880
911
  ],
881
912
  workspace_snapshot_policy: workspaceSnapshotPolicyCapability(),
882
913
  daemon_version: this.options.daemonVersion,
@@ -1056,310 +1087,54 @@ export class AgentServiceSandboxClient {
1056
1087
  const sessionId = frame.runtime_session_id;
1057
1088
  const session = this.state.sessions[sessionId] ?? emptySessionState();
1058
1089
  const courseRunId = frame.payload.course_run_id;
1090
+ // Mixed-version reconciliation may replay a pre-Workspace-identity session.open.
1091
+ // Its physical directory was keyed by RuntimeSession id, which is the exact backfill
1092
+ // identity used by the database migration.
1093
+ const requestedWorkspaceId = typeof frame.payload.workspace_id === "string"
1094
+ ? frame.payload.workspace_id
1095
+ : session.workspaceId ?? sessionId;
1059
1096
  const requestedWorkspaceRef = frame.payload.workspace_ref;
1060
- const requestedCopy = frame.payload.workspace_copy;
1061
- const requestedRestore = frame.payload.workspace_restore;
1062
- const requestedCopyRecord = requestedCopy && typeof requestedCopy === "object"
1063
- ? requestedCopy
1064
- : undefined;
1065
- const copyId = requestedCopyRecord
1066
- ? requestedCopyRecord.copy_id
1067
- : undefined;
1068
- const requestedCopySource = requestedCopyRecord
1069
- ? requestedCopyRecord.source_runtime_session_id
1070
- : undefined;
1097
+ assertWorkspaceRestoreAllowed(frame.payload.workspace_restore);
1071
1098
  if (typeof courseRunId !== "string" ||
1072
1099
  courseRunId.length < 1 ||
1100
+ requestedWorkspaceId.length < 1 ||
1073
1101
  (requestedWorkspaceRef !== null &&
1074
1102
  requestedWorkspaceRef !== undefined &&
1075
- (typeof requestedWorkspaceRef !== "string" || requestedWorkspaceRef.length < 1)) ||
1076
- (requestedCopy !== null &&
1077
- requestedCopy !== undefined &&
1078
- (typeof requestedCopy !== "object" ||
1079
- typeof copyId !== "string" ||
1080
- copyId.length < 1 ||
1081
- typeof requestedCopySource !== "string" ||
1082
- requestedCopySource.length < 1))
1083
- || (requestedRestore !== null && requestedRestore !== undefined &&
1084
- (typeof requestedRestore !== "object" || Array.isArray(requestedRestore)))
1085
- || (requestedCopy !== null && requestedCopy !== undefined &&
1086
- requestedRestore !== null && requestedRestore !== undefined)) {
1103
+ (typeof requestedWorkspaceRef !== "string" || requestedWorkspaceRef.length < 1))) {
1087
1104
  throw new Error("invalid session.open payload");
1088
1105
  }
1089
1106
  if (session.courseRunId !== null && session.courseRunId !== courseRunId) {
1090
1107
  throw new Error("runtime session course_run_id cannot be rebound");
1091
1108
  }
1109
+ if (session.workspaceId !== null && session.workspaceId !== requestedWorkspaceId) {
1110
+ throw new Error("runtime session workspace_id cannot be rebound");
1111
+ }
1092
1112
  if (session.workspaceRef !== null &&
1093
1113
  typeof requestedWorkspaceRef === "string" &&
1094
1114
  session.workspaceRef !== requestedWorkspaceRef) {
1095
1115
  throw new Error("runtime session workspace_ref cannot be rebound");
1096
1116
  }
1097
1117
  const isNewSession = session.courseRunId === null;
1098
- let markerPath = null;
1099
- try {
1100
- if (requestedRestore && typeof requestedRestore === "object") {
1101
- const raw = requestedRestore;
1102
- const descriptorRaw = raw.descriptor;
1103
- const controlFence = raw.control_fence;
1104
- let restoreControlToken = raw.control_token;
1105
- if (!controlFence || typeof controlFence !== "object" || Array.isArray(controlFence)) {
1106
- throw new Error("invalid workspace restore control fence");
1107
- }
1108
- if (typeof restoreControlToken !== "string" || restoreControlToken.length < 32) {
1109
- throw new Error("invalid workspace restore control token");
1110
- }
1111
- if (!descriptorRaw || !Array.isArray(raw.entries)) {
1112
- throw new Error("invalid workspace restore payload");
1113
- }
1114
- const descriptor = {
1115
- sandboxId: this.options.sandboxId,
1116
- sandboxGeneration: this.sandboxGeneration,
1117
- runtimeSessionId: sessionId,
1118
- continuityState: descriptorRaw.continuity_state,
1119
- continuityErrorCode: descriptorRaw.continuity_error_code,
1120
- snapshotId: descriptorRaw.snapshot_id,
1121
- revision: Number(descriptorRaw.revision),
1122
- contentSha256: descriptorRaw.content_sha256,
1123
- expectedFileCount: Number(descriptorRaw.expected_file_count),
1124
- expectedEntryCount: Number(descriptorRaw.expected_entry_count),
1125
- expectedTotalBytes: Number(descriptorRaw.expected_total_bytes),
1126
- };
1127
- const workspaceDirectory = runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration);
1128
- const requiredFreeBytes = Math.max(WORKSPACE_SNAPSHOT_POLICY_V1.requiredRestoreFreeBytes, descriptor.expectedTotalBytes + WORKSPACE_SNAPSHOT_POLICY_V1.requiredRestoreFreeBytes);
1129
- // Fail before issuing or consuming any object grant. The download staging is a
1130
- // sibling on this same bounded backing filesystem and is renamed, not copied,
1131
- // into the atomic restore staging tree.
1132
- assertWorkspaceRestoreCapacity({
1133
- workspaceDirectory,
1134
- descriptor,
1135
- requiredFreeBytes,
1136
- entryCopies: 2,
1137
- });
1138
- const entries = [];
1139
- const predownloadRoot = path.join(path.dirname(workspaceDirectory), `.botlearn-predownload-${sessionId}-${randomUUID()}`);
1140
- mkdirSync(predownloadRoot, { recursive: true, mode: 0o700 });
1141
- try {
1142
- let nextCursor = null;
1143
- do {
1144
- if (nextCursor !== null && nextCursor.length > 2048) {
1145
- throw new Error("invalid workspace restore cursor");
1146
- }
1147
- const pageSignal = AbortSignal.timeout(60_000);
1148
- const response = await fetch(`${agentServiceHttpBase(this.options.wsUrl)}/runtime-sessions/${sessionId}/restore-grants`, {
1149
- method: "POST",
1150
- headers: {
1151
- Authorization: `Bearer ${restoreControlToken}`,
1152
- "Content-Type": "application/json",
1153
- },
1154
- body: JSON.stringify({
1155
- policy_id: WORKSPACE_SNAPSHOT_POLICY_V1.policyId,
1156
- fence: controlFence,
1157
- cursor: nextCursor,
1158
- // Fetch one canonical entry at a time so a grant is consumed as soon as
1159
- // it is signed, including deployments using the minimum 60-second TTL.
1160
- max_entries: 1,
1161
- }),
1162
- signal: pageSignal,
1163
- });
1164
- if (!response.ok) {
1165
- let code = "workspace_restore_unavailable";
1166
- let retryable = response.status >= 500 || response.status === 408 ||
1167
- response.status === 429;
1168
- try {
1169
- const rejected = await response.json();
1170
- const detail = rejected.detail;
1171
- if (detail && typeof detail === "object" && !Array.isArray(detail)) {
1172
- const stableCode = detail.code;
1173
- if (typeof stableCode === "string" && stableCode.length <= 120) {
1174
- code = stableCode;
1175
- }
1176
- if (typeof detail.retryable === "boolean") {
1177
- retryable = detail.retryable;
1178
- }
1179
- }
1180
- }
1181
- catch {
1182
- // Content-free fallback retains the stable local failure code.
1183
- }
1184
- throw new WorkspaceRestoreError(code, retryable);
1185
- }
1186
- const page = await response.json();
1187
- if (!Array.isArray(page.entries))
1188
- throw new Error("invalid workspace restore page");
1189
- if (typeof page.control_token !== "string" || page.control_token.length < 32) {
1190
- throw new Error("invalid refreshed workspace restore control token");
1191
- }
1192
- restoreControlToken = page.control_token;
1193
- const pageFiles = [];
1194
- for (const value of page.entries) {
1195
- if (!value || typeof value !== "object" || Array.isArray(value)) {
1196
- throw new Error("invalid workspace restore entry");
1197
- }
1198
- const item = value;
1199
- if (item.entry_type === "directory") {
1200
- entries.push({ type: "directory", path: String(item.path), mode: "0700" });
1201
- continue;
1202
- }
1203
- if (item.entry_type !== "file" || !item.grant || typeof item.grant !== "object") {
1204
- throw new Error("invalid workspace restore file grant");
1205
- }
1206
- const entry = {
1207
- type: "file",
1208
- path: String(item.path),
1209
- mode: item.mode,
1210
- sizeBytes: Number(item.size_bytes),
1211
- sha256: String(item.sha256),
1212
- };
1213
- entries.push(entry);
1214
- pageFiles.push({ entry, grant: item.grant });
1215
- }
1216
- // Validate all paths and duplicates before using any page path locally.
1217
- validateWorkspaceEntrySet({ schemaVersion: "agent-workspace-entry-set/1", entries }, WORKSPACE_SNAPSHOT_POLICY_V1.limits);
1218
- for (const { entry, grant } of pageFiles) {
1219
- if (typeof grant.url !== "string")
1220
- throw new Error("workspace restore grant missing");
1221
- const destination = path.join(predownloadRoot, ...entry.path.split("/"));
1222
- mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
1223
- const part = `${destination}.part`;
1224
- const downloadSignal = AbortSignal.timeout(60_000);
1225
- const download = await fetch(grant.url, {
1226
- method: "GET",
1227
- headers: grant.headers,
1228
- signal: downloadSignal,
1229
- });
1230
- if (!download.ok)
1231
- throw new Error(`workspace_restore_download_${download.status}`);
1232
- if (!download.body)
1233
- throw new Error("workspace_restore_download_body_missing");
1234
- await pipeline(Readable.fromWeb(download.body), createWriteStream(part, { mode: 0o600 }), { signal: downloadSignal });
1235
- renameSync(part, destination);
1236
- }
1237
- const rawNext = page.next_cursor;
1238
- if (rawNext !== null && rawNext !== undefined && typeof rawNext !== "string") {
1239
- throw new Error("invalid workspace restore cursor");
1240
- }
1241
- nextCursor = rawNext ?? null;
1242
- } while (nextCursor !== null);
1243
- await restoreWorkspaceSnapshot({
1244
- workspaceDirectory,
1245
- controlDirectory: path.join(ensureDaemonHome(), "agent-service-sandboxes", this.options.sandboxId, "workspace-control"),
1246
- descriptor,
1247
- entrySet: { schemaVersion: "agent-workspace-entry-set/1", entries },
1248
- limits: WORKSPACE_SNAPSHOT_POLICY_V1.limits,
1249
- requiredFreeBytes,
1250
- downloadFile: async (entry, partPath) => {
1251
- renameSync(path.join(predownloadRoot, ...entry.path.split("/")), partPath);
1252
- },
1253
- });
1254
- }
1255
- finally {
1256
- rmSync(predownloadRoot, { recursive: true, force: true });
1257
- }
1258
- }
1259
- if (typeof requestedCopySource === "string" && typeof copyId === "string") {
1260
- if (session.workspaceCopyReceipt !== null) {
1261
- const receipt = session.workspaceCopyReceipt;
1262
- if (receipt.copy_id !== copyId ||
1263
- receipt.source_runtime_session_id !== requestedCopySource ||
1264
- receipt.target_runtime_session_id !== sessionId ||
1265
- receipt.source_sandbox_id !== this.options.sandboxId ||
1266
- receipt.source_generation !== this.sandboxGeneration) {
1267
- throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
1268
- }
1269
- markerPath = path.join(runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration), WORKSPACE_COPY_POLICY_V1.markerName);
1270
- }
1271
- else if (!isNewSession) {
1272
- throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
1273
- }
1274
- }
1275
- if (isNewSession && typeof requestedCopySource === "string" && typeof copyId === "string") {
1276
- const sourceSession = this.state.sessions[requestedCopySource];
1277
- if (!sourceSession ||
1278
- sourceSession.courseRunId === null ||
1279
- this.activeSessionId === requestedCopySource ||
1280
- this.currentTurnSessionId === requestedCopySource) {
1281
- throw new WorkspaceCopyError("workspace_migration_source_unavailable");
1282
- }
1283
- revokeRuntimeSessionWorkspace(requestedCopySource, this.sandboxGeneration);
1284
- const copied = await copyRuntimeSessionWorkspace({
1285
- copyId,
1286
- sourceRuntimeSessionId: requestedCopySource,
1287
- targetRuntimeSessionId: sessionId,
1288
- sandboxId: this.options.sandboxId,
1289
- sandboxGeneration: this.sandboxGeneration,
1290
- });
1291
- session.workspaceCopyReceipt = copied.receipt;
1292
- markerPath = copied.markerPath;
1293
- }
1294
- }
1295
- catch (error) {
1296
- if (error instanceof WorkspaceCopyError) {
1297
- await this.sendSessionFrame("session.open_failed", sessionId, {
1298
- error_code: error.code,
1299
- });
1300
- return;
1301
- }
1302
- if (requestedRestore) {
1303
- const restoreError = error instanceof WorkspaceRestoreError ? error : null;
1304
- await this.sendSessionFrame("session.open_failed", sessionId, {
1305
- error_code: restoreError?.code ?? "workspace_restore_unavailable",
1306
- retryable: restoreError?.retryable ?? true,
1307
- });
1308
- return;
1309
- }
1310
- throw error;
1311
- }
1312
1118
  session.courseRunId = courseRunId;
1119
+ session.workspaceId = requestedWorkspaceId;
1313
1120
  session.workspaceRef = session.workspaceRef ?? (typeof requestedWorkspaceRef === "string"
1314
1121
  ? requestedWorkspaceRef
1315
1122
  : `ws_${sessionId.replaceAll("-", "")}_g${this.sandboxGeneration}`);
1316
1123
  // Replaying session.open is part of reconnect reconciliation. An already-active
1317
1124
  // runtime may still be creating files in this workspace, so never transiently revoke
1318
1125
  // its group write/execute access before the following session.activate arrives.
1319
- if (session.workspaceCopyReceipt === null && (isNewSession || session.activationId === null)) {
1320
- ensureRuntimeSessionDirectories(sessionId, this.sandboxGeneration);
1321
- }
1322
- if (markerPath !== null) {
1323
- await finalizeRuntimeSessionWorkspaceCopy(markerPath);
1324
- }
1325
- try {
1326
- if (!nasWorkspaceEnabled()) {
1327
- await applyRuntimeWorkspaceQuota(sessionId, this.sandboxGeneration);
1328
- }
1329
- }
1330
- catch (error) {
1331
- if (requestedRestore) {
1332
- await this.sendSessionFrame("session.open_failed", sessionId, {
1333
- error_code: "workspace_restore_unavailable",
1334
- });
1335
- return;
1336
- }
1337
- if (requestedCopy) {
1338
- await this.sendSessionFrame("session.open_failed", sessionId, {
1339
- error_code: "workspace_migration_io_failed",
1340
- });
1341
- return;
1342
- }
1343
- throw error;
1126
+ if (isNewSession || session.activationId === null) {
1127
+ ensureRuntimeSessionDirectories(sessionId, this.sandboxGeneration, requestedWorkspaceId);
1344
1128
  }
1345
1129
  this.state.sessions[sessionId] = session;
1346
1130
  this.persist();
1347
- const materialization = readWorkspaceMaterializationMarker(this.options.sandboxId, sessionId);
1131
+ // NAS 是唯一的 workspace 权威来源,所以 session.opened 不携带 snapshot 物化标记:
1132
+ // 挂载证明只说明当前 generation 已挂上 NAS,不要求运行目录等于某个 committed revision。
1348
1133
  await this.sendSessionFrame("session.opened", sessionId, {
1134
+ workspace_id: session.workspaceId,
1349
1135
  workspace_ref: session.workspaceRef,
1350
1136
  native_session_id: session.nativeSessionId,
1351
- ...(materialization
1352
- ? {
1353
- workspace_marker_version: 1,
1354
- workspace_continuity_state: materialization.workspaceContinuityState,
1355
- workspace_snapshot_id: materialization.snapshotId,
1356
- workspace_revision: materialization.revision,
1357
- workspace_content_sha256: materialization.contentSha256,
1358
- }
1359
- : {}),
1360
- ...(session.workspaceCopyReceipt !== null
1361
- ? { workspace_copy_receipt: session.workspaceCopyReceipt }
1362
- : {}),
1137
+ workspace_authority: "nas",
1363
1138
  });
1364
1139
  }
1365
1140
  async handleWorkspaceCheckpoint(frame) {
@@ -1379,8 +1154,6 @@ export class AgentServiceSandboxClient {
1379
1154
  "sandbox_pause",
1380
1155
  "sandbox_cleanup",
1381
1156
  "generation_rotation",
1382
- "initial_migration",
1383
- "live_workspace_copy",
1384
1157
  "maintenance_repair",
1385
1158
  ].includes(String(sourceKind)) ||
1386
1159
  !Number.isSafeInteger(baseRevision) ||
@@ -1408,7 +1181,7 @@ export class AgentServiceSandboxClient {
1408
1181
  await checkpointWorkspace({
1409
1182
  wsUrl: this.options.wsUrl,
1410
1183
  reconnectToken: controlToken,
1411
- workspaceDirectory: runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration),
1184
+ workspaceDirectory: runtimeSessionWorkspaceDir(session.workspaceId ?? sessionId, this.sandboxGeneration),
1412
1185
  scope: {
1413
1186
  checkpointId,
1414
1187
  baseRevision,
@@ -1420,17 +1193,12 @@ export class AgentServiceSandboxClient {
1420
1193
  freezeProof,
1421
1194
  });
1422
1195
  checkpointCommitted = true;
1423
- const marker = readWorkspaceMaterializationMarker(this.options.sandboxId, sessionId);
1424
- if (!marker)
1425
- throw new Error("workspace checkpoint marker missing");
1196
+ // committed revision 只是派生投影,不改变运行目录,所以这里不上报物化标记。
1426
1197
  await this.sendSessionFrame("session.opened", sessionId, {
1198
+ workspace_id: session.workspaceId,
1427
1199
  workspace_ref: session.workspaceRef,
1428
1200
  native_session_id: session.nativeSessionId,
1429
- workspace_marker_version: 1,
1430
- workspace_continuity_state: marker.workspaceContinuityState,
1431
- workspace_snapshot_id: marker.snapshotId,
1432
- workspace_revision: marker.revision,
1433
- workspace_content_sha256: marker.contentSha256,
1201
+ workspace_authority: "nas",
1434
1202
  });
1435
1203
  await this.sendCommandAck(frame, "ok");
1436
1204
  if (!lifecycleCheckpoint)
@@ -1525,7 +1293,7 @@ export class AgentServiceSandboxClient {
1525
1293
  if (required.length === 0)
1526
1294
  return [];
1527
1295
  if (!availableCapabilities) {
1528
- const workspace = ensureRuntimeSessionDirectories(sessionId, this.sandboxGeneration);
1296
+ const workspace = ensureRuntimeSessionDirectories(sessionId, this.sandboxGeneration, session.workspaceId ?? sessionId);
1529
1297
  availableCapabilities = new Set(availableRunCapabilities({
1530
1298
  agent_run_id: "activation-probe",
1531
1299
  course_run_id: session.courseRunId ?? "",
@@ -1608,7 +1376,7 @@ export class AgentServiceSandboxClient {
1608
1376
  session.runtimeId = runtimeId;
1609
1377
  session.contextRevision = contextRevision;
1610
1378
  session.activationId = activationId;
1611
- exposeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1379
+ exposeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration, session.workspaceId ?? sessionId);
1612
1380
  this.activationContexts.set(sessionId, {
1613
1381
  activationId,
1614
1382
  runtimeEnv,
@@ -1779,8 +1547,8 @@ export class AgentServiceSandboxClient {
1779
1547
  await this.finishTurn({ agent_run_id: runId });
1780
1548
  }
1781
1549
  /**
1782
- * 幂等关闭一个 runtime session:终止其 runtime 子进程、删除 workspace/native state
1783
- * 清空本地状态分片,并回 session.closed。未知 session 直接回 closed。
1550
+ * 幂等关闭一个 runtime session:终止其 runtime 子进程并清空 session-local state
1551
+ * Durable Workspace 由独立身份拥有,关闭执行上下文不会删除学员文件。
1784
1552
  */
1785
1553
  async closeSession(sessionId, reason, sourceFrame, fence) {
1786
1554
  const session = this.state.sessions[sessionId];
@@ -1795,7 +1563,11 @@ export class AgentServiceSandboxClient {
1795
1563
  }
1796
1564
  await this.terminateSessionWriters(session);
1797
1565
  session.activationId = null;
1798
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1566
+ const workspaceId = session.workspaceId ?? sessionId;
1567
+ const sharedWorkspaceIsOpen = Object.entries(this.state.sessions).some(([otherSessionId, other]) => otherSessionId !== sessionId && (other.workspaceId ?? otherSessionId) === workspaceId);
1568
+ if (!sharedWorkspaceIsOpen) {
1569
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration, workspaceId);
1570
+ }
1799
1571
  if (fence && !this.isCurrentLifecycleFence(fence))
1800
1572
  return;
1801
1573
  if (this.activeSessionId === sessionId)
@@ -1804,7 +1576,7 @@ export class AgentServiceSandboxClient {
1804
1576
  this.currentTurnSessionId = null;
1805
1577
  for (const runId of runIds)
1806
1578
  this.turnScopes.delete(runId);
1807
- removeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1579
+ removeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration, true, workspaceId);
1808
1580
  delete this.state.sessions[sessionId];
1809
1581
  this.persist();
1810
1582
  }
@@ -1946,7 +1718,7 @@ export class AgentServiceSandboxClient {
1946
1718
  this.activationContexts.delete(sessionId);
1947
1719
  this.sessionProfiles.delete(sessionId);
1948
1720
  if (terminateWriters) {
1949
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1721
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration, session.workspaceId ?? sessionId);
1950
1722
  }
1951
1723
  if (this.activeSessionId === sessionId)
1952
1724
  this.activeSessionId = null;
@@ -2108,7 +1880,7 @@ export class AgentServiceSandboxClient {
2108
1880
  }
2109
1881
  this.workspaceFileReadInFlight = true;
2110
1882
  try {
2111
- const result = await readWorkspaceFile(runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration), relativePath, {
1883
+ const result = await readWorkspaceFile(runtimeSessionWorkspaceDir(this.state.sessions[sessionId]?.workspaceId ?? sessionId, this.sandboxGeneration), relativePath, {
2112
1884
  maxBytes: maxBytes,
2113
1885
  expectedSizeBytes: expectedSizeBytes,
2114
1886
  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);
@@ -27,6 +27,7 @@ const FRAME_TYPES = new Set([
27
27
  "session.opened",
28
28
  "session.open_failed",
29
29
  "session.closed",
30
+ "activation.cleaned",
30
31
  "turn.event",
31
32
  "turn.file.report",
32
33
  "workspace.file.result",
@@ -52,6 +53,7 @@ const TURN_TYPES = new Set([
52
53
  "turn.start",
53
54
  "turn.cancel",
54
55
  "event.ack",
56
+ "activation.cleaned",
55
57
  "turn.event",
56
58
  "turn.file.report",
57
59
  ]);