@botlearn-course/daemon 0.0.19 → 0.0.20-beta.10

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.
Files changed (41) hide show
  1. package/README.md +7 -0
  2. package/dist/agent-service-sandbox.d.ts +9 -1
  3. package/dist/agent-service-sandbox.js +523 -39
  4. package/dist/agent-service-ws-protocol.d.ts +3 -3
  5. package/dist/agent-service-ws-protocol.js +7 -2
  6. package/dist/cli.js +19 -1
  7. package/dist/file-candidates.d.ts +28 -1
  8. package/dist/file-candidates.js +102 -49
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +6 -0
  11. package/dist/run-dispatcher.d.ts +3 -2
  12. package/dist/run-dispatcher.js +71 -11
  13. package/dist/runtime-env.js +4 -4
  14. package/dist/runtime-quiescence.d.ts +19 -0
  15. package/dist/runtime-quiescence.js +130 -0
  16. package/dist/runtimes/deepseek-tui.js +11 -0
  17. package/dist/runtimes/engine.js +1 -1
  18. package/dist/tool-observation.d.ts +9 -5
  19. package/dist/tool-observation.js +42 -19
  20. package/dist/trace-projection.d.ts +21 -0
  21. package/dist/trace-projection.js +56 -0
  22. package/dist/types.d.ts +3 -3
  23. package/dist/workspace-entry-set.d.ts +31 -0
  24. package/dist/workspace-entry-set.js +164 -0
  25. package/dist/workspace-filesystem.d.ts +20 -0
  26. package/dist/workspace-filesystem.js +120 -0
  27. package/dist/workspace-materialization.d.ts +16 -0
  28. package/dist/workspace-materialization.js +136 -0
  29. package/dist/workspace-quota.d.ts +4 -0
  30. package/dist/workspace-quota.js +42 -0
  31. package/dist/workspace-restore.d.ts +42 -0
  32. package/dist/workspace-restore.js +347 -0
  33. package/dist/workspace-snapshot-control.d.ts +29 -0
  34. package/dist/workspace-snapshot-control.js +169 -0
  35. package/dist/workspace-snapshot-policy.d.ts +24 -0
  36. package/dist/workspace-snapshot-policy.js +45 -0
  37. package/dist/workspace-snapshot-staging.d.ts +27 -0
  38. package/dist/workspace-snapshot-staging.js +275 -0
  39. package/dist/workspace.d.ts +60 -4
  40. package/dist/workspace.js +576 -9
  41. package/package.json +1 -1
@@ -1,4 +1,7 @@
1
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
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";
2
5
  import path from "node:path";
3
6
  import { ensureDaemonHome } from "./auth-store.js";
4
7
  import { AGENT_SERVICE_WS_SCHEMA, AGENT_SERVICE_WS_SUBPROTOCOL, createSandboxFrame, createWorkspaceFileChunk, parseSandboxFrame, UnsupportedSandboxProtocolError, WORKSPACE_FILE_READ_BINARY_CAPABILITY, } from "./agent-service-ws-protocol.js";
@@ -8,8 +11,17 @@ import { activationRuntimeEnv, runtimeChildEnv } from "./runtime-env.js";
8
11
  import { redactSecretString } from "./redaction.js";
9
12
  import { parseRuntimeSkillProviderGrantSet, prepareRuntimeSkillProvider, RuntimeSkillProviderError, } from "./runtime-skills.js";
10
13
  import { RunDispatcher, } from "./run-dispatcher.js";
11
- import { ensureRuntimeSessionDirectories, ensureRuntimeSessionWorkspace, exposeRuntimeSessionWorkspace, removeRuntimeSessionWorkspace, revokeRuntimeSessionWorkspace, runtimeSessionWorkspaceDir, } from "./workspace.js";
14
+ import { cleanupRuntimeSessionWorkspaceCopyStaging, copyRuntimeSessionWorkspace, ensureRuntimeSessionDirectories, ensureRuntimeSessionWorkspace, exposeRuntimeSessionWorkspace, finalizeRuntimeSessionWorkspaceCopy, removeRuntimeSessionWorkspace, revokeRuntimeSessionWorkspace, runtimeSessionWorkspaceDir, WORKSPACE_COPY_POLICY_V1, WorkspaceCopyError, workspaceCopyV1Supported, } from "./workspace.js";
12
15
  import { readWorkspaceFile, WorkspaceFileReadError } from "./workspace-file-read.js";
16
+ import { checkpointWorkspace, WorkspaceSnapshotControlError, } from "./workspace-snapshot-control.js";
17
+ import { assertWorkspaceWriterFreezeSupported, freezeRuntimeWriters, issueWorkspaceWriterFreezeProof, quiesceRuntimeWriters, thawRuntimeWriters, } from "./runtime-quiescence.js";
18
+ 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";
13
25
  import { WebSocketClient, } from "./websocket-client.js";
14
26
  const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
15
27
  const MAX_SPOOL_FRAMES = 1024;
@@ -19,6 +31,14 @@ const LEGACY_EVENT_ACK_WINDOW = 1;
19
31
  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;
20
32
  /** 出站 seq 基址:seq = connection_epoch * SEQ_EPOCH_BASE + n,跨重连单调(合同 §1.1)。 */
21
33
  const SEQ_EPOCH_BASE = 1_000_000_000;
34
+ function agentServiceHttpBase(wsUrl) {
35
+ const url = new URL(wsUrl);
36
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
37
+ url.pathname = "/internal/v1";
38
+ url.search = "";
39
+ url.hash = "";
40
+ return url.toString().replace(/\/$/, "");
41
+ }
22
42
  const RUNTIME_LOG_WINDOW_MS = 60_000;
23
43
  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;
24
44
  const DISABLED_RUNTIME_LOG_POLICY = {
@@ -212,6 +232,7 @@ function emptySessionState() {
212
232
  return {
213
233
  courseRunId: null,
214
234
  workspaceRef: null,
235
+ workspaceCopyReceipt: null,
215
236
  runtimeId: null,
216
237
  nativeSessionId: null,
217
238
  contextRevision: 0,
@@ -230,6 +251,7 @@ function normalizeSessionState(value) {
230
251
  return {
231
252
  courseRunId: typeof value.courseRunId === "string" ? value.courseRunId : null,
232
253
  workspaceRef: typeof value.workspaceRef === "string" ? value.workspaceRef : null,
254
+ workspaceCopyReceipt: (value.workspaceCopyReceipt && typeof value.workspaceCopyReceipt === "object") ? value.workspaceCopyReceipt : null,
233
255
  runtimeId: typeof value.runtimeId === "string" ? value.runtimeId : null,
234
256
  nativeSessionId: typeof value.nativeSessionId === "string" ? value.nativeSessionId : null,
235
257
  contextRevision: Number(value.contextRevision ?? 0),
@@ -344,6 +366,7 @@ export class AgentServiceSandboxClient {
344
366
  sessionProfiles = new Map();
345
367
  /** activation-scoped 模型短凭据;只保存在内存中,绝不进入 state.json。 */
346
368
  activationContexts = new Map();
369
+ activationCleanupTasks = new Map();
347
370
  pendingAcks = new Map();
348
371
  pendingFileReports = new Map();
349
372
  inflightCommands = new Set();
@@ -436,7 +459,7 @@ export class AgentServiceSandboxClient {
436
459
  session.nativeSessionId = nativeSessionId.trim() || null;
437
460
  this.persist();
438
461
  }
439
- finishTurn(payload) {
462
+ async finishTurn(payload) {
440
463
  const scope = this.turnScopes.get(payload.agent_run_id);
441
464
  if (!scope)
442
465
  return;
@@ -452,7 +475,7 @@ export class AgentServiceSandboxClient {
452
475
  };
453
476
  this.persist();
454
477
  try {
455
- this.completePendingActivationCleanup(scope.sessionId);
478
+ await this.completePendingActivationCleanup(scope.sessionId);
456
479
  }
457
480
  catch (error) {
458
481
  this.log.error("failed to revoke a terminal runtime session workspace", {
@@ -470,19 +493,31 @@ export class AgentServiceSandboxClient {
470
493
  this.currentTurnSessionId = null;
471
494
  this.persist();
472
495
  }
473
- completePendingActivationCleanup(sessionId) {
496
+ async completePendingActivationCleanup(sessionId) {
497
+ const existing = this.activationCleanupTasks.get(sessionId);
498
+ if (existing)
499
+ return await existing;
500
+ const task = this.runPendingActivationCleanup(sessionId);
501
+ this.activationCleanupTasks.set(sessionId, task);
502
+ try {
503
+ await task;
504
+ }
505
+ finally {
506
+ if (this.activationCleanupTasks.get(sessionId) === task) {
507
+ this.activationCleanupTasks.delete(sessionId);
508
+ }
509
+ }
510
+ }
511
+ async runPendingActivationCleanup(sessionId) {
474
512
  const session = this.state.sessions[sessionId];
475
513
  const pending = session?.pendingActivationCleanup;
476
514
  if (!session || !pending)
477
515
  return;
478
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
479
516
  const activation = this.activationContexts.get(sessionId);
480
517
  if (activation?.activationId === pending.activationId) {
481
518
  this.activationContexts.delete(sessionId);
482
519
  this.sessionProfiles.delete(sessionId);
483
520
  }
484
- if (session.activationId === pending.activationId)
485
- session.activationId = null;
486
521
  if (!session.completedActivations.includes(pending.activationId)) {
487
522
  // Session-close/generation-rotate are the retention bounds. Never evict an
488
523
  // activation tombstone while the session is alive: activation IDs are opaque, so
@@ -497,15 +532,13 @@ export class AgentServiceSandboxClient {
497
532
  delete session.acceptedCommands[commandId];
498
533
  session.pendingActivationCleanup = null;
499
534
  this.turnScopes.delete(pending.agentRunId);
500
- if (this.activeSessionId === sessionId)
501
- this.activeSessionId = null;
502
535
  if (this.currentTurnSessionId === sessionId)
503
536
  this.currentTurnSessionId = null;
504
537
  this.persist();
505
538
  }
506
- recoverPendingActivationCleanups() {
539
+ async recoverPendingActivationCleanups() {
507
540
  for (const sessionId of Object.keys(this.state.sessions)) {
508
- this.completePendingActivationCleanup(sessionId);
541
+ await this.completePendingActivationCleanup(sessionId);
509
542
  }
510
543
  }
511
544
  async run() {
@@ -566,6 +599,23 @@ export class AgentServiceSandboxClient {
566
599
  this.removeOperationalLogSink();
567
600
  this.socket?.close(1000, "daemon_stopping");
568
601
  }
602
+ async stopGracefully() {
603
+ this.dispatcher.cancelAll();
604
+ await quiesceRuntimeWriters(null);
605
+ await flushWorkspaceFilesystem();
606
+ this.stop();
607
+ }
608
+ async drainWorkspace(fence) {
609
+ if (!(await this.dispatcher.drain(10_000)) || !this.isCurrentLifecycleFence(fence)) {
610
+ return;
611
+ }
612
+ await quiesceRuntimeWriters(null);
613
+ await flushWorkspaceFilesystem();
614
+ if (!this.isCurrentLifecycleFence(fence))
615
+ return;
616
+ await this.sendControlFrame("sandbox.drained", {});
617
+ this.stop();
618
+ }
569
619
  async postEvent(agentRunId, event) {
570
620
  const scope = this.turnScopes.get(agentRunId);
571
621
  if (!scope)
@@ -576,6 +626,8 @@ export class AgentServiceSandboxClient {
576
626
  if (!this.sandboxGeneration || !this.connectionEpoch) {
577
627
  throw new Error("Agent Service sandbox is not authenticated");
578
628
  }
629
+ // Terminal truth is never delayed by object storage. Durable workspace checkpointing
630
+ // is a separate lifecycle command issued only for pause/cleanup/rotation.
579
631
  if (event.type === "run.block") {
580
632
  await this.waitForEventCapacity();
581
633
  }
@@ -655,6 +707,29 @@ export class AgentServiceSandboxClient {
655
707
  await this.sendFrame(frame);
656
708
  return await reported;
657
709
  }
710
+ async recordWorkspaceScanMeasurement(agentRunId, measurement) {
711
+ if (!this.turnScopes.has(agentRunId)) {
712
+ throw new Error("Agent Service sandbox scan measurement has no active turn scope");
713
+ }
714
+ await this.sendFrame(createSandboxFrame({
715
+ type: "sandbox.log",
716
+ sandboxId: this.options.sandboxId,
717
+ sandboxGeneration: this.sandboxGeneration,
718
+ connectionEpoch: this.connectionEpoch,
719
+ seq: this.nextOutboundSeq(),
720
+ payload: {
721
+ level: "info",
722
+ stream: "daemon",
723
+ message: "workspace.scan.measurement",
724
+ fields: {
725
+ file_count: measurement.fileCount,
726
+ total_bytes: measurement.totalBytes,
727
+ duration_ms: measurement.durationMs,
728
+ result: measurement.truncated ? "truncated" : "complete",
729
+ },
730
+ },
731
+ }));
732
+ }
658
733
  async getRunRuntimeProfile(agentRunId) {
659
734
  const embedded = this.runProfiles.get(agentRunId);
660
735
  if (embedded)
@@ -744,6 +819,10 @@ export class AgentServiceSandboxClient {
744
819
  }
745
820
  }
746
821
  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
+ await this.lifecycleChain;
747
826
  if (frame.sandbox_id !== this.options.sandboxId) {
748
827
  throw new SandboxClosedError(4403, "sandbox_mismatch");
749
828
  }
@@ -753,10 +832,11 @@ export class AgentServiceSandboxClient {
753
832
  if (frame.sandbox_generation !== this.state.sandboxGeneration) {
754
833
  // Generation change wipes every session shard (contract §1.4).
755
834
  this.dispatcher.cancelAll();
835
+ await quiesceRuntimeWriters(null);
756
836
  this.rejectPending(new Error("Agent Service sandbox generation changed"));
757
837
  for (const sessionId of Object.keys(this.state.sessions)) {
758
838
  revokeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration);
759
- removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration);
839
+ removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, nasWorkspaceEnabled());
760
840
  }
761
841
  this.state.sessions = {};
762
842
  this.state.sandboxGeneration = frame.sandbox_generation;
@@ -771,7 +851,7 @@ export class AgentServiceSandboxClient {
771
851
  this.connectionEpoch = frame.connection_epoch;
772
852
  this.inboundSeq = frame.seq;
773
853
  this.outboundSeq = frame.connection_epoch * SEQ_EPOCH_BASE;
774
- this.recoverPendingActivationCleanups();
854
+ await this.recoverPendingActivationCleanups();
775
855
  const heartbeatSeconds = Number(frame.payload.heartbeat_seconds ?? 15);
776
856
  this.heartbeatMs = Math.max(1_000, Math.min(60_000, heartbeatSeconds * 1000));
777
857
  const staleSeconds = Number(frame.payload.stale_after_seconds ?? 45);
@@ -784,10 +864,21 @@ export class AgentServiceSandboxClient {
784
864
  : LEGACY_EVENT_ACK_WINDOW;
785
865
  this.maxUnackedEvents = Math.min(MAX_EVENT_ACK_WINDOW, ackWindow);
786
866
  this.runtimeLogPolicy = runtimeLogPolicy(frame.payload.runtime_log_policy);
867
+ await cleanupRuntimeSessionWorkspaceCopyStaging();
787
868
  this.persist();
869
+ assertWorkspaceFilesystem(process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT ?? "");
870
+ assertWorkspaceWriterFreezeSupported();
788
871
  await this.sendControlFrame("sandbox.ready", {
789
872
  protocol_versions: [AGENT_SERVICE_WS_SCHEMA],
790
- capabilities: [WORKSPACE_FILE_READ_BINARY_CAPABILITY],
873
+ capabilities: [
874
+ 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"] : []),
880
+ ],
881
+ workspace_snapshot_policy: workspaceSnapshotPolicyCapability(),
791
882
  daemon_version: this.options.daemonVersion,
792
883
  runtime_versions: {
793
884
  course_daemon: this.options.daemonVersion,
@@ -919,16 +1010,19 @@ export class AgentServiceSandboxClient {
919
1010
  });
920
1011
  });
921
1012
  return;
1013
+ case "workspace.checkpoint":
1014
+ this.scheduleLifecycle("workspace.checkpoint", () => this.handleWorkspaceCheckpoint(frame));
1015
+ return;
1016
+ case "workspace.thaw":
1017
+ await thawRuntimeWriters();
1018
+ await this.sendCommandAck(frame, "ok");
1019
+ return;
922
1020
  case "sandbox.shutdown":
923
- this.stop();
1021
+ await this.stopGracefully();
924
1022
  return;
925
1023
  case "sandbox.drain":
926
1024
  await this.sendCommandAck(frame, "ok");
927
- this.scheduleLifecycle("sandbox.drain", async (fence) => {
928
- if (await this.dispatcher.drain(10_000) && this.isCurrentLifecycleFence(fence)) {
929
- await this.sendControlFrame("sandbox.drained", {});
930
- }
931
- });
1025
+ this.scheduleLifecycle("sandbox.drain", (fence) => this.drainWorkspace(fence));
932
1026
  return;
933
1027
  case "protocol.error":
934
1028
  throw new Error(`server protocol error: ${String(frame.payload.code ?? "unknown")}`);
@@ -946,16 +1040,15 @@ export class AgentServiceSandboxClient {
946
1040
  this.scheduleLifecycle("sandbox.sync.close", (fence) => this.closeSession(sessionId, "sync_close", frame, fence));
947
1041
  }
948
1042
  const state = frame.payload.state;
1043
+ if (frame.payload.thaw_runtime_writers === true) {
1044
+ await thawRuntimeWriters();
1045
+ }
949
1046
  if (state === "shutdown") {
950
- this.stop();
1047
+ await this.stopGracefully();
951
1048
  return;
952
1049
  }
953
1050
  if (state === "drain") {
954
- this.scheduleLifecycle("sandbox.sync.drain", async (fence) => {
955
- if (await this.dispatcher.drain(10_000) && this.isCurrentLifecycleFence(fence)) {
956
- await this.sendControlFrame("sandbox.drained", {});
957
- }
958
- });
1051
+ this.scheduleLifecycle("sandbox.sync.drain", (fence) => this.drainWorkspace(fence));
959
1052
  }
960
1053
  // idle/run/cancel:实际工作全部由显式命令帧下发(合同 §1.3)。
961
1054
  }
@@ -964,11 +1057,33 @@ export class AgentServiceSandboxClient {
964
1057
  const session = this.state.sessions[sessionId] ?? emptySessionState();
965
1058
  const courseRunId = frame.payload.course_run_id;
966
1059
  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;
967
1071
  if (typeof courseRunId !== "string" ||
968
1072
  courseRunId.length < 1 ||
969
1073
  (requestedWorkspaceRef !== null &&
970
1074
  requestedWorkspaceRef !== undefined &&
971
- (typeof requestedWorkspaceRef !== "string" || requestedWorkspaceRef.length < 1))) {
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)) {
972
1087
  throw new Error("invalid session.open payload");
973
1088
  }
974
1089
  if (session.courseRunId !== null && session.courseRunId !== courseRunId) {
@@ -979,18 +1094,366 @@ export class AgentServiceSandboxClient {
979
1094
  session.workspaceRef !== requestedWorkspaceRef) {
980
1095
  throw new Error("runtime session workspace_ref cannot be rebound");
981
1096
  }
1097
+ 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
+ }
982
1312
  session.courseRunId = courseRunId;
983
1313
  session.workspaceRef = session.workspaceRef ?? (typeof requestedWorkspaceRef === "string"
984
1314
  ? requestedWorkspaceRef
985
1315
  : `ws_${sessionId.replaceAll("-", "")}_g${this.sandboxGeneration}`);
986
- ensureRuntimeSessionDirectories(sessionId, this.sandboxGeneration);
1316
+ // Replaying session.open is part of reconnect reconciliation. An already-active
1317
+ // runtime may still be creating files in this workspace, so never transiently revoke
1318
+ // 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;
1344
+ }
987
1345
  this.state.sessions[sessionId] = session;
988
1346
  this.persist();
1347
+ const materialization = readWorkspaceMaterializationMarker(this.options.sandboxId, sessionId);
989
1348
  await this.sendSessionFrame("session.opened", sessionId, {
990
1349
  workspace_ref: session.workspaceRef,
991
1350
  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
+ : {}),
992
1363
  });
993
1364
  }
1365
+ async handleWorkspaceCheckpoint(frame) {
1366
+ const sessionId = frame.runtime_session_id;
1367
+ const session = this.state.sessions[sessionId];
1368
+ const checkpointId = frame.payload.checkpoint_id;
1369
+ const controlToken = frame.payload.control_token;
1370
+ const baseRevision = Number(frame.payload.base_revision);
1371
+ const sourceKind = frame.payload.source_kind;
1372
+ if (!session ||
1373
+ this.currentTurnSessionId === sessionId ||
1374
+ typeof checkpointId !== "string" ||
1375
+ typeof controlToken !== "string" ||
1376
+ controlToken.length < 32 ||
1377
+ frame.payload.policy_id !== "WorkspaceSnapshotPolicyV1" ||
1378
+ ![
1379
+ "sandbox_pause",
1380
+ "sandbox_cleanup",
1381
+ "generation_rotation",
1382
+ "initial_migration",
1383
+ "live_workspace_copy",
1384
+ "maintenance_repair",
1385
+ ].includes(String(sourceKind)) ||
1386
+ !Number.isSafeInteger(baseRevision) ||
1387
+ baseRevision < 0 ||
1388
+ frame.payload.agent_run_id != null ||
1389
+ frame.payload.worker_attempt != null ||
1390
+ frame.payload.activation_id != null) {
1391
+ await this.sendCommandAck(frame, "rejected", "invalid_workspace_checkpoint");
1392
+ return;
1393
+ }
1394
+ const lifecycleCheckpoint = [
1395
+ "sandbox_pause",
1396
+ "sandbox_cleanup",
1397
+ "generation_rotation",
1398
+ ].includes(String(sourceKind));
1399
+ let checkpointCommitted = false;
1400
+ try {
1401
+ await freezeRuntimeWriters();
1402
+ const freezeProof = issueWorkspaceWriterFreezeProof({
1403
+ sandboxId: this.options.sandboxId,
1404
+ sandboxGeneration: this.sandboxGeneration,
1405
+ runtimeSessionId: sessionId,
1406
+ checkpointId,
1407
+ });
1408
+ await checkpointWorkspace({
1409
+ wsUrl: this.options.wsUrl,
1410
+ reconnectToken: controlToken,
1411
+ workspaceDirectory: runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration),
1412
+ scope: {
1413
+ checkpointId,
1414
+ baseRevision,
1415
+ sandboxId: this.options.sandboxId,
1416
+ sandboxGeneration: this.sandboxGeneration,
1417
+ connectionEpoch: this.connectionEpoch,
1418
+ runtimeSessionId: sessionId,
1419
+ },
1420
+ freezeProof,
1421
+ });
1422
+ checkpointCommitted = true;
1423
+ const marker = readWorkspaceMaterializationMarker(this.options.sandboxId, sessionId);
1424
+ if (!marker)
1425
+ throw new Error("workspace checkpoint marker missing");
1426
+ await this.sendSessionFrame("session.opened", sessionId, {
1427
+ workspace_ref: session.workspaceRef,
1428
+ 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,
1434
+ });
1435
+ await this.sendCommandAck(frame, "ok");
1436
+ if (!lifecycleCheckpoint)
1437
+ await thawRuntimeWriters();
1438
+ }
1439
+ catch (error) {
1440
+ if (!lifecycleCheckpoint || !checkpointCommitted) {
1441
+ await thawRuntimeWriters().catch(() => undefined);
1442
+ }
1443
+ this.log.error("workspace baseline checkpoint failed", {
1444
+ sandboxId: this.options.sandboxId,
1445
+ runtimeSessionId: sessionId,
1446
+ error: error instanceof Error ? redactSecretString(error.message) : "unexpected",
1447
+ });
1448
+ const deterministic = (error instanceof WorkspaceSnapshotStagingError && !error.retryable) ||
1449
+ error instanceof WorkspaceEntrySetError ||
1450
+ (error instanceof WorkspaceSnapshotControlError && !error.retryable);
1451
+ const errorCode = deterministic && "code" in error
1452
+ ? String(error.code)
1453
+ : "workspace_checkpoint_retryable";
1454
+ await this.sendCommandAck(frame, "rejected", errorCode, !deterministic);
1455
+ }
1456
+ }
994
1457
  async handleSessionActivate(frame, fence) {
995
1458
  const sessionId = frame.runtime_session_id;
996
1459
  const activationId = frame.activation_id;
@@ -1127,7 +1590,7 @@ export class AgentServiceSandboxClient {
1127
1590
  const previousSessionId = this.activeSessionId;
1128
1591
  const changesActivation = previousSessionId !== null && (previousSessionId !== sessionId || session.activationId !== activationId);
1129
1592
  if (changesActivation) {
1130
- const stopped = await this.deactivateSession(previousSessionId);
1593
+ const stopped = await this.deactivateSession(previousSessionId, previousSessionId !== sessionId);
1131
1594
  if (fence && !this.isCurrentLifecycleFence(fence))
1132
1595
  return;
1133
1596
  if (!stopped) {
@@ -1313,7 +1776,7 @@ export class AgentServiceSandboxClient {
1313
1776
  }
1314
1777
  delete session.acceptedCommands[commandId];
1315
1778
  this.persist();
1316
- this.finishTurn({ agent_run_id: runId });
1779
+ await this.finishTurn({ agent_run_id: runId });
1317
1780
  }
1318
1781
  /**
1319
1782
  * 幂等关闭一个 runtime session:终止其 runtime 子进程、删除 workspace/native state、
@@ -1323,15 +1786,16 @@ export class AgentServiceSandboxClient {
1323
1786
  const session = this.state.sessions[sessionId];
1324
1787
  if (session) {
1325
1788
  const runIds = this.runIdsForSession(sessionId);
1326
- session.activationId = null;
1327
1789
  this.activationContexts.delete(sessionId);
1328
1790
  this.sessionProfiles.delete(sessionId);
1329
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1330
1791
  for (const runId of runIds)
1331
1792
  this.dispatcher.cancel(runId);
1332
1793
  if (!await this.dispatcher.waitForRuns(runIds, 10_000)) {
1333
1794
  throw new Error(`runtime session ${sessionId} did not stop before close`);
1334
1795
  }
1796
+ await this.terminateSessionWriters(session);
1797
+ session.activationId = null;
1798
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1335
1799
  if (fence && !this.isCurrentLifecycleFence(fence))
1336
1800
  return;
1337
1801
  if (this.activeSessionId === sessionId)
@@ -1372,7 +1836,7 @@ export class AgentServiceSandboxClient {
1372
1836
  delete session.acceptedCommands[commandId];
1373
1837
  this.persist();
1374
1838
  }
1375
- this.finishTurn({ agent_run_id: accepted.agentRunId });
1839
+ await this.finishTurn({ agent_run_id: accepted.agentRunId });
1376
1840
  }
1377
1841
  catch (error) {
1378
1842
  this.log.error("failed to reconcile an interrupted accepted turn", {
@@ -1474,21 +1938,40 @@ export class AgentServiceSandboxClient {
1474
1938
  .filter(([, scope]) => scope.sessionId === sessionId)
1475
1939
  .map(([runId]) => runId);
1476
1940
  }
1477
- async deactivateSession(sessionId) {
1941
+ async deactivateSession(sessionId, terminateWriters = false) {
1478
1942
  const session = this.state.sessions[sessionId];
1479
1943
  if (!session)
1480
1944
  return true;
1481
1945
  const runIds = this.runIdsForSession(sessionId);
1482
- session.activationId = null;
1483
1946
  this.activationContexts.delete(sessionId);
1484
1947
  this.sessionProfiles.delete(sessionId);
1485
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1948
+ if (terminateWriters) {
1949
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1950
+ }
1486
1951
  if (this.activeSessionId === sessionId)
1487
1952
  this.activeSessionId = null;
1488
1953
  for (const runId of runIds)
1489
1954
  this.dispatcher.cancel(runId);
1490
1955
  this.persist();
1491
- return this.dispatcher.waitForRuns(runIds, 10_000);
1956
+ const stopped = await this.dispatcher.waitForRuns(runIds, 10_000);
1957
+ if (stopped && terminateWriters) {
1958
+ await this.terminateSessionWriters(session);
1959
+ }
1960
+ session.activationId = null;
1961
+ this.persist();
1962
+ return stopped;
1963
+ }
1964
+ async terminateSessionWriters(session) {
1965
+ const activationIds = new Set([
1966
+ ...session.completedActivations,
1967
+ ...(session.activationId ? [session.activationId] : []),
1968
+ ...(session.pendingActivationCleanup?.activationId
1969
+ ? [session.pendingActivationCleanup.activationId]
1970
+ : []),
1971
+ ]);
1972
+ for (const activationId of activationIds) {
1973
+ await quiesceRuntimeWriters(activationId);
1974
+ }
1492
1975
  }
1493
1976
  sameTurnScope(left, right) {
1494
1977
  return left.sessionId === right.sessionId &&
@@ -1581,7 +2064,7 @@ export class AgentServiceSandboxClient {
1581
2064
  spool_frames: this.spoolFrameCount(),
1582
2065
  });
1583
2066
  }
1584
- async sendCommandAck(frame, status, error) {
2067
+ async sendCommandAck(frame, status, error, retryable) {
1585
2068
  if (frame.sandbox_generation !== this.sandboxGeneration ||
1586
2069
  frame.connection_epoch !== this.connectionEpoch)
1587
2070
  return;
@@ -1589,6 +2072,7 @@ export class AgentServiceSandboxClient {
1589
2072
  command_frame_id: frame.frame_id,
1590
2073
  status,
1591
2074
  ...(error !== undefined ? { error } : {}),
2075
+ ...(retryable !== undefined ? { retryable } : {}),
1592
2076
  });
1593
2077
  }
1594
2078
  async handleWorkspaceFileRead(frame) {