@botlearn-course/daemon 0.0.20-beta.5 → 0.0.20-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,13 @@ daemon,由 root-owned 固定 launcher 清空附加组后直接降权为 `user`
47
47
  `agent-service session --bootstrap-stdin` 同样属于受 supervisor 保护的内部协议,不应直接暴露给
48
48
  课程任务或 workspace shell。
49
49
 
50
+ veFaaS Origin 的托管 workspace 使用用户级 NAS backing image。root bootstrap 完成 NFS、marker、
51
+ ext4、loop mount 和固定 3 GiB 校验后,daemon 才发送 `sandbox.ready`。RuntimeSession 使用稳定路径
52
+ `/workspace/sessions/{runtime_session_id}/workspace`,Sandbox generation 只参与写入隔离。磁盘写满
53
+ 统一上报 `workspace_user_quota_exceeded`。pause 和 cleanup 进入 drain 后,daemon 先停止 runtime
54
+ writer,再通过 root launcher 执行 ext4 sync、loop unmount、backing image 和 NFS parent fsync;完成后
55
+ 才发送 `sandbox.drained` 并退出。
56
+
50
57
  ## 支持的 runtime
51
58
 
52
59
  run 的 `runtime.id` 决定用哪个 adapter 执行;未指定时默认 `codex`。runtime 不可用时 run 会
@@ -78,6 +78,7 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
78
78
  run(): Promise<void>;
79
79
  stop(): void;
80
80
  stopGracefully(): Promise<void>;
81
+ private drainWorkspace;
81
82
  postEvent(agentRunId: string, event: RunEvent): Promise<RunEventReceipt>;
82
83
  postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord>;
83
84
  recordWorkspaceScanMeasurement(agentRunId: string, measurement: WorkspaceScanMeasurement): Promise<void>;
@@ -20,7 +20,8 @@ import { validateWorkspaceEntrySet, WorkspaceEntrySetError, } from "./workspace-
20
20
  import { WORKSPACE_SNAPSHOT_POLICY_V1, workspaceSnapshotPolicyCapability, } from "./workspace-snapshot-policy.js";
21
21
  import { assertWorkspaceRestoreCapacity, restoreWorkspaceSnapshot, WorkspaceRestoreError, } from "./workspace-restore.js";
22
22
  import { readWorkspaceMaterializationMarker } from "./workspace-materialization.js";
23
- import { applyRuntimeWorkspaceQuota, assertRuntimeWorkspaceQuota } from "./workspace-quota.js";
23
+ import { assertWorkspaceFilesystem, flushWorkspaceFilesystem, nasWorkspaceEnabled, } from "./workspace-filesystem.js";
24
+ import { applyRuntimeWorkspaceQuota } from "./workspace-quota.js";
24
25
  import { WebSocketClient, } from "./websocket-client.js";
25
26
  const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
26
27
  const MAX_SPOOL_FRAMES = 1024;
@@ -601,6 +602,18 @@ export class AgentServiceSandboxClient {
601
602
  async stopGracefully() {
602
603
  this.dispatcher.cancelAll();
603
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", {});
604
617
  this.stop();
605
618
  }
606
619
  async postEvent(agentRunId, event) {
@@ -823,7 +836,7 @@ export class AgentServiceSandboxClient {
823
836
  this.rejectPending(new Error("Agent Service sandbox generation changed"));
824
837
  for (const sessionId of Object.keys(this.state.sessions)) {
825
838
  revokeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration);
826
- removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration);
839
+ removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, nasWorkspaceEnabled());
827
840
  }
828
841
  this.state.sessions = {};
829
842
  this.state.sandboxGeneration = frame.sandbox_generation;
@@ -853,7 +866,7 @@ export class AgentServiceSandboxClient {
853
866
  this.runtimeLogPolicy = runtimeLogPolicy(frame.payload.runtime_log_policy);
854
867
  await cleanupRuntimeSessionWorkspaceCopyStaging();
855
868
  this.persist();
856
- assertRuntimeWorkspaceQuota(process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT ?? "");
869
+ assertWorkspaceFilesystem(process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT ?? "");
857
870
  assertWorkspaceWriterFreezeSupported();
858
871
  await this.sendControlFrame("sandbox.ready", {
859
872
  protocol_versions: [AGENT_SERVICE_WS_SCHEMA],
@@ -862,6 +875,7 @@ export class AgentServiceSandboxClient {
862
875
  "workspace_writer_freeze_v1",
863
876
  "workspace_snapshot_v2",
864
877
  "workspace_restore_v1",
878
+ ...(nasWorkspaceEnabled() ? ["workspace_nas_v1"] : []),
865
879
  ...(workspaceCopyV1Supported() ? ["workspace_copy_v1"] : []),
866
880
  ],
867
881
  workspace_snapshot_policy: workspaceSnapshotPolicyCapability(),
@@ -1008,11 +1022,7 @@ export class AgentServiceSandboxClient {
1008
1022
  return;
1009
1023
  case "sandbox.drain":
1010
1024
  await this.sendCommandAck(frame, "ok");
1011
- this.scheduleLifecycle("sandbox.drain", async (fence) => {
1012
- if (await this.dispatcher.drain(10_000) && this.isCurrentLifecycleFence(fence)) {
1013
- await this.sendControlFrame("sandbox.drained", {});
1014
- }
1015
- });
1025
+ this.scheduleLifecycle("sandbox.drain", (fence) => this.drainWorkspace(fence));
1016
1026
  return;
1017
1027
  case "protocol.error":
1018
1028
  throw new Error(`server protocol error: ${String(frame.payload.code ?? "unknown")}`);
@@ -1038,11 +1048,7 @@ export class AgentServiceSandboxClient {
1038
1048
  return;
1039
1049
  }
1040
1050
  if (state === "drain") {
1041
- this.scheduleLifecycle("sandbox.sync.drain", async (fence) => {
1042
- if (await this.dispatcher.drain(10_000) && this.isCurrentLifecycleFence(fence)) {
1043
- await this.sendControlFrame("sandbox.drained", {});
1044
- }
1045
- });
1051
+ this.scheduleLifecycle("sandbox.sync.drain", (fence) => this.drainWorkspace(fence));
1046
1052
  }
1047
1053
  // idle/run/cancel:实际工作全部由显式命令帧下发(合同 §1.3)。
1048
1054
  }
@@ -1317,7 +1323,9 @@ export class AgentServiceSandboxClient {
1317
1323
  await finalizeRuntimeSessionWorkspaceCopy(markerPath);
1318
1324
  }
1319
1325
  try {
1320
- await applyRuntimeWorkspaceQuota(sessionId, this.sandboxGeneration);
1326
+ if (!nasWorkspaceEnabled()) {
1327
+ await applyRuntimeWorkspaceQuota(sessionId, this.sandboxGeneration);
1328
+ }
1321
1329
  }
1322
1330
  catch (error) {
1323
1331
  if (requestedRestore) {
@@ -13,6 +13,7 @@ import { buildToolObservation } from "./tool-observation.js";
13
13
  import { buildReasoningTracePayload, extractReasoningText, REASONING_TRACE_MAX_CHARS, } from "./trace-projection.js";
14
14
  import { RuntimeExecutionError, } from "./types.js";
15
15
  import { ensureRunWorkspace, transcriptPath } from "./workspace.js";
16
+ import { isWorkspaceQuotaExceededError } from "./workspace-filesystem.js";
16
17
  // 与 runtimes/index.ts 的 DEFAULT_RUNTIME_ID 保持一致(此处不 import registry,避免拉入全部 adapter)。
17
18
  const DEFAULT_RUNTIME_ID = "codex";
18
19
  const DEFAULT_TIMEOUT_SECONDS = 900;
@@ -951,13 +952,15 @@ export class RunDispatcher {
951
952
  }
952
953
  else {
953
954
  const info = errorInfo(err);
954
- const errorType = err instanceof RuntimeProfileApplyError
955
- ? err.code
956
- : err instanceof InputAttachmentMaterializationError
955
+ const errorType = isWorkspaceQuotaExceededError(err)
956
+ ? "workspace_user_quota_exceeded"
957
+ : err instanceof RuntimeProfileApplyError
957
958
  ? err.code
958
- : err instanceof RuntimeExecutionError
959
- ? err.errorType
960
- : "runtime_error";
959
+ : err instanceof InputAttachmentMaterializationError
960
+ ? err.code
961
+ : err instanceof RuntimeExecutionError
962
+ ? err.errorType
963
+ : "runtime_error";
961
964
  await sendFailure(errorType, info.error_message, err, err instanceof RuntimeProfileApplyError
962
965
  ? { code: err.code, profile_apply_status: "failed" }
963
966
  : {});
@@ -6,11 +6,23 @@ import { promisify } from "node:util";
6
6
  const execFileAsync = promisify(execFile);
7
7
  const RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
8
8
  const RUNTIME_CGROUP_ROOT = "/sys/fs/cgroup/botlearn-runtime";
9
+ const RUNTIME_CGROUP_UNAVAILABLE = "/run/botlearn-runtime-cgroup-unavailable";
9
10
  const proofs = new WeakSet();
10
11
  const sleep = (milliseconds) => new Promise((resolve) => {
11
12
  setTimeout(resolve, milliseconds);
12
13
  });
13
14
  async function setDirectCgroupsFrozen(frozen) {
15
+ try {
16
+ accessSync(RUNTIME_CGROUP_UNAVAILABLE, fsConstants.F_OK);
17
+ await execFileAsync(RUNTIME_LAUNCHER, [frozen ? "--freeze-all" : "--thaw-all"], {
18
+ timeout: 10_000,
19
+ });
20
+ return;
21
+ }
22
+ catch (error) {
23
+ if (error.code !== "ENOENT")
24
+ throw error;
25
+ }
14
26
  let entries;
15
27
  try {
16
28
  entries = await readdir(RUNTIME_CGROUP_ROOT, { withFileTypes: true });
@@ -59,7 +71,15 @@ export async function thawRuntimeWriters() {
59
71
  export function assertWorkspaceWriterFreezeSupported() {
60
72
  const mode = process.env.BOTLEARN_RUNTIME_LAUNCH_MODE;
61
73
  if (mode === "direct-uid") {
62
- accessSync(RUNTIME_CGROUP_ROOT, fsConstants.R_OK | fsConstants.W_OK);
74
+ try {
75
+ accessSync(RUNTIME_CGROUP_UNAVAILABLE, fsConstants.F_OK);
76
+ accessSync(RUNTIME_LAUNCHER, fsConstants.X_OK);
77
+ }
78
+ catch (error) {
79
+ if (error.code !== "ENOENT")
80
+ throw error;
81
+ accessSync(RUNTIME_CGROUP_ROOT, fsConstants.R_OK | fsConstants.W_OK);
82
+ }
63
83
  return;
64
84
  }
65
85
  if (mode === "sudo") {
package/dist/types.d.ts CHANGED
@@ -233,9 +233,9 @@ export interface CourseRuntime {
233
233
  }
234
234
  /** runtime 执行失败(dispatcher 折叠为 run.failed)。 */
235
235
  export declare class RuntimeExecutionError extends Error {
236
- readonly errorType: "runtime_error" | "runtime_unavailable" | "timeout";
236
+ readonly errorType: "runtime_error" | "runtime_unavailable" | "timeout" | "workspace_user_quota_exceeded";
237
237
  readonly failure?: Partial<RuntimeFailureSummary> | undefined;
238
- constructor(message: string, errorType?: "runtime_error" | "runtime_unavailable" | "timeout", failure?: Partial<RuntimeFailureSummary> | undefined);
238
+ constructor(message: string, errorType?: "runtime_error" | "runtime_unavailable" | "timeout" | "workspace_user_quota_exceeded", failure?: Partial<RuntimeFailureSummary> | undefined);
239
239
  }
240
240
  /**
241
241
  * 本地诊断用的失败摘要。完整结构只进脱敏日志/transcript;wire 仅允许
@@ -0,0 +1,14 @@
1
+ export declare const WORKSPACE_IMAGE_FORMAT: "botlearn-workspace-image/1";
2
+ export declare const WORKSPACE_IMAGE_LOGICAL_BYTES: number;
3
+ interface MountInfoEntry {
4
+ target: string;
5
+ filesystemType: string;
6
+ mountOptions: Set<string>;
7
+ }
8
+ export declare function parseWorkspaceMountInfo(text: string): MountInfoEntry[];
9
+ export declare function nasWorkspaceEnabled(env?: NodeJS.ProcessEnv): boolean;
10
+ export declare function workspaceFilesystemFlushCommand(env?: NodeJS.ProcessEnv): [string, string[]] | null;
11
+ export declare function flushWorkspaceFilesystem(env?: NodeJS.ProcessEnv): Promise<void>;
12
+ export declare function isWorkspaceQuotaExceededError(error: unknown): boolean;
13
+ export declare function assertWorkspaceFilesystem(workspaceRoot: string, env?: NodeJS.ProcessEnv, mountInfo?: string): void;
14
+ export {};
@@ -0,0 +1,100 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFileSync, statfsSync } from "node:fs";
3
+ import { promisify } from "node:util";
4
+ import { assertRuntimeWorkspaceQuota } from "./workspace-quota.js";
5
+ export const WORKSPACE_IMAGE_FORMAT = "botlearn-workspace-image/1";
6
+ export const WORKSPACE_IMAGE_LOGICAL_BYTES = 3 * 1024 * 1024 * 1024;
7
+ const RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
8
+ const execFileAsync = promisify(execFile);
9
+ function decodeMountInfoPath(value) {
10
+ return value.replace(/\\([0-7]{3})/gu, (_match, octal) => String.fromCharCode(Number.parseInt(octal, 8)));
11
+ }
12
+ export function parseWorkspaceMountInfo(text) {
13
+ return text.split("\n").filter(Boolean).map((line) => {
14
+ const [left, right] = line.split(" - ", 2);
15
+ const fields = left?.split(" ") ?? [];
16
+ const filesystem = right?.split(" ") ?? [];
17
+ if (fields.length < 6 || filesystem.length < 3) {
18
+ throw new Error("workspace_mount_proof_invalid");
19
+ }
20
+ return {
21
+ target: decodeMountInfoPath(fields[4]),
22
+ filesystemType: filesystem[0],
23
+ mountOptions: new Set(fields[5].split(",")),
24
+ };
25
+ });
26
+ }
27
+ export function nasWorkspaceEnabled(env = process.env) {
28
+ return env.BOTLEARN_WORKSPACE_IMAGE_FORMAT !== undefined;
29
+ }
30
+ export function workspaceFilesystemFlushCommand(env = process.env) {
31
+ if (!nasWorkspaceEnabled(env))
32
+ return null;
33
+ if (env.BOTLEARN_RUNTIME_LAUNCH_MODE === "direct-uid") {
34
+ return [RUNTIME_LAUNCHER, ["--workspace-filesystem-flush"]];
35
+ }
36
+ if (env.BOTLEARN_RUNTIME_LAUNCH_MODE === "sudo") {
37
+ return [
38
+ "/usr/bin/sudo",
39
+ ["-n", "--", RUNTIME_LAUNCHER, "--workspace-filesystem-flush"],
40
+ ];
41
+ }
42
+ if (env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || env.NODE_ENV === "test") {
43
+ return null;
44
+ }
45
+ throw new Error("workspace_nas_flush_failed");
46
+ }
47
+ export async function flushWorkspaceFilesystem(env = process.env) {
48
+ const command = workspaceFilesystemFlushCommand(env);
49
+ if (command === null)
50
+ return;
51
+ try {
52
+ await execFileAsync(command[0], command[1], { timeout: 30_000 });
53
+ }
54
+ catch {
55
+ throw new Error("workspace_nas_flush_failed");
56
+ }
57
+ }
58
+ export function isWorkspaceQuotaExceededError(error) {
59
+ if (!(error instanceof Error))
60
+ return false;
61
+ const candidate = error;
62
+ const code = String(candidate.code ?? candidate.failure?.error_code ?? "");
63
+ const detail = [
64
+ candidate.message,
65
+ candidate.failure?.error_message,
66
+ candidate.failure?.stderr_tail,
67
+ ].map((value) => String(value ?? "")).join(" ");
68
+ return code === "ENOSPC"
69
+ || code === "EDQUOT"
70
+ || /\b(?:ENOSPC|EDQUOT|no space left on device|disk quota exceeded)\b/iu.test(detail)
71
+ || (candidate.cause !== undefined && isWorkspaceQuotaExceededError(candidate.cause));
72
+ }
73
+ export function assertWorkspaceFilesystem(workspaceRoot, env = process.env, mountInfo) {
74
+ if (!nasWorkspaceEnabled(env)) {
75
+ assertRuntimeWorkspaceQuota(workspaceRoot);
76
+ return;
77
+ }
78
+ if (env.BOTLEARN_WORKSPACE_IMAGE_FORMAT !== WORKSPACE_IMAGE_FORMAT
79
+ || env.BOTLEARN_WORKSPACE_LOGICAL_BYTES !== String(WORKSPACE_IMAGE_LOGICAL_BYTES)
80
+ || !/^[0-9a-f]{64}$/u.test(env.BOTLEARN_WORKSPACE_SCOPE_DIGEST ?? "")) {
81
+ throw new Error("workspace_quota_mismatch");
82
+ }
83
+ if (env.NODE_ENV === "test" && mountInfo === undefined)
84
+ return;
85
+ const mount = parseWorkspaceMountInfo(mountInfo ?? readFileSync("/proc/self/mountinfo", "utf8"))
86
+ .find((candidate) => candidate.target === workspaceRoot);
87
+ if (!mount
88
+ || mount.filesystemType !== "ext4"
89
+ || !mount.mountOptions.has("rw")
90
+ || !mount.mountOptions.has("nodev")
91
+ || !mount.mountOptions.has("nosuid")) {
92
+ throw new Error("workspace_loop_mount_failed");
93
+ }
94
+ if (env.NODE_ENV === "test")
95
+ return;
96
+ const filesystem = statfsSync(workspaceRoot, { bigint: true });
97
+ if (filesystem.blocks * filesystem.bsize !== BigInt(WORKSPACE_IMAGE_LOGICAL_BYTES)) {
98
+ throw new Error("workspace_quota_mismatch");
99
+ }
100
+ }
@@ -20,9 +20,9 @@ export declare function runtimeProfileDir(agentRunId: string): string;
20
20
  export declare function runtimeProfileRunRootDir(agentRunId: string): string;
21
21
  export declare function runtimeSessionRootDir(runtimeSessionId: string, sandboxGeneration: number): string;
22
22
  /**
23
- * per-session workspace(ADR-015 §7):managed sandbox 下为
24
- * `<BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT>/<runtime_session_id>/generation-<sandbox_generation>`,
25
- * daemon 在 session.open 时创建。
23
+ * per-session workspace(ADR-015 §7):NAS managed sandbox 使用稳定路径
24
+ * `<root>/sessions/<runtime_session_id>/workspace`;旧 provider 保留 generation 路径。
25
+ * 两种路径都由 daemon 在 session.open 时创建。
26
26
  */
27
27
  export declare function runtimeSessionWorkspaceDir(runtimeSessionId: string, sandboxGeneration: number): string;
28
28
  export declare function runtimeSessionTranscriptPath(runtimeSessionId: string, sandboxGeneration: number, agentRunId: string): string;
@@ -102,7 +102,7 @@ export declare function exposeRuntimeSessionWorkspace(runtimeSessionId: string,
102
102
  /** Revoke a managed workspace without deleting the session's durable local materialization. */
103
103
  export declare function revokeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number): void;
104
104
  /** session.close 时删除 workspace 与本地 session 物化状态(transcripts 等)。幂等。 */
105
- export declare function removeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number): void;
105
+ export declare function removeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number, preserveManagedWorkspace?: boolean): void;
106
106
  export declare function ensureRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number, agentRunId: string): {
107
107
  rootDir: string;
108
108
  workspaceDir: string;
package/dist/workspace.js CHANGED
@@ -4,6 +4,7 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import path from "node:path";
5
5
  import { TextDecoder } from "node:util";
6
6
  import { daemonHome } from "./auth-store.js";
7
+ import { nasWorkspaceEnabled } from "./workspace-filesystem.js";
7
8
  /**
8
9
  * 每 run 隔离工作区(spec §4):
9
10
  *
@@ -53,9 +54,9 @@ export function runtimeSessionRootDir(runtimeSessionId, sandboxGeneration) {
53
54
  return path.join(daemonHome(), "agent-service-sessions", runtimeSessionId, `generation-${sandboxGeneration}`);
54
55
  }
55
56
  /**
56
- * per-session workspace(ADR-015 §7):managed sandbox 下为
57
- * `<BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT>/<runtime_session_id>/generation-<sandbox_generation>`,
58
- * daemon 在 session.open 时创建。
57
+ * per-session workspace(ADR-015 §7):NAS managed sandbox 使用稳定路径
58
+ * `<root>/sessions/<runtime_session_id>/workspace`;旧 provider 保留 generation 路径。
59
+ * 两种路径都由 daemon 在 session.open 时创建。
59
60
  */
60
61
  export function runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration) {
61
62
  assertSafeId(runtimeSessionId, "runtime_session_id");
@@ -64,6 +65,9 @@ export function runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration)
64
65
  }
65
66
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
66
67
  if (managedRoot) {
68
+ if (nasWorkspaceEnabled()) {
69
+ return path.join(managedRoot, "sessions", runtimeSessionId, "workspace");
70
+ }
67
71
  return path.join(managedRoot, runtimeSessionId, `generation-${sandboxGeneration}`);
68
72
  }
69
73
  return path.join(runtimeSessionRootDir(runtimeSessionId, sandboxGeneration), "workspace");
@@ -71,7 +75,11 @@ export function runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration)
71
75
  function managedRuntimeSessionParentDir(runtimeSessionId) {
72
76
  assertSafeId(runtimeSessionId, "runtime_session_id");
73
77
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
74
- return managedRoot ? path.join(managedRoot, runtimeSessionId) : null;
78
+ if (!managedRoot)
79
+ return null;
80
+ return nasWorkspaceEnabled()
81
+ ? path.join(managedRoot, "sessions", runtimeSessionId)
82
+ : path.join(managedRoot, runtimeSessionId);
75
83
  }
76
84
  export function runtimeSessionTranscriptPath(runtimeSessionId, sandboxGeneration, agentRunId) {
77
85
  assertSafeId(agentRunId, "agent_run_id");
@@ -228,12 +236,16 @@ async function childDirectories(root) {
228
236
  export async function cleanupRuntimeSessionWorkspaceCopyStaging() {
229
237
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
230
238
  if (managedRoot) {
231
- for (const sessionName of await childDirectories(managedRoot)) {
239
+ const sessionRoot = nasWorkspaceEnabled()
240
+ ? path.join(managedRoot, "sessions")
241
+ : managedRoot;
242
+ for (const sessionName of await childDirectories(sessionRoot)) {
232
243
  if (!SAFE_ID_PATTERN.test(sessionName))
233
244
  continue;
234
- const sessionParent = path.join(managedRoot, sessionName);
245
+ const sessionParent = path.join(sessionRoot, sessionName);
235
246
  for (const name of await childDirectories(sessionParent)) {
236
- if (MANAGED_COPY_STAGING_PATTERN.test(name)) {
247
+ if (MANAGED_COPY_STAGING_PATTERN.test(name)
248
+ || LOCAL_COPY_STAGING_PATTERN.test(name)) {
237
249
  await fs.rm(path.join(sessionParent, name), { recursive: true, force: true });
238
250
  }
239
251
  }
@@ -700,16 +712,19 @@ export function revokeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneratio
700
712
  }
701
713
  }
702
714
  /** session.close 时删除 workspace 与本地 session 物化状态(transcripts 等)。幂等。 */
703
- export function removeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration) {
704
- // 校验路径段安全后按 session 整体删除(含全部 generation 与 transcripts)。
715
+ export function removeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, preserveManagedWorkspace = false) {
716
+ // 校验路径段安全后删除本地物化状态;generation 换代可保留 NAS 正文。
705
717
  runtimeSessionRootDir(runtimeSessionId, sandboxGeneration);
706
718
  rmSync(path.join(daemonHome(), "agent-service-sessions", runtimeSessionId), {
707
719
  recursive: true,
708
720
  force: true,
709
721
  });
710
722
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
711
- if (managedRoot) {
712
- rmSync(path.join(managedRoot, runtimeSessionId), { recursive: true, force: true });
723
+ if (managedRoot && !preserveManagedWorkspace) {
724
+ const sessionRoot = nasWorkspaceEnabled()
725
+ ? path.join(managedRoot, "sessions", runtimeSessionId)
726
+ : path.join(managedRoot, runtimeSessionId);
727
+ rmSync(sessionRoot, { recursive: true, force: true });
713
728
  }
714
729
  }
715
730
  export function ensureRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, agentRunId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.20-beta.5",
3
+ "version": "0.0.20-beta.7",
4
4
  "description": "Lightweight BotLearn Course daemon: run course tasks on your own machine with your own agent runtime (BYOA).",
5
5
  "type": "module",
6
6
  "bin": {