@botlearn-course/daemon 0.0.20-beta.2 → 0.0.20-beta.4

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.
@@ -33,8 +33,6 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
33
33
  private readonly dispatcher;
34
34
  /** agent_run_id → TURN scope(session/attempt/activation),事件与文件帧路由用。 */
35
35
  private readonly turnScopes;
36
- private readonly turnWorkspaceCheckpoints;
37
- private readonly completedWorkspaceCheckpoints;
38
36
  /** 每 run 内嵌 profile(turn payload 自带时优先)。 */
39
37
  private readonly runProfiles;
40
38
  /** activate 时装配的 per-session profile,turn.start 复用。 */
@@ -111,6 +109,7 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
111
109
  private isCurrentLifecycleFence;
112
110
  private runIdsForSession;
113
111
  private deactivateSession;
112
+ private terminateSessionWriters;
114
113
  private sameTurnScope;
115
114
  private matchesTurnScope;
116
115
  private assertTurnScope;
@@ -14,7 +14,7 @@ import { RunDispatcher, } from "./run-dispatcher.js";
14
14
  import { cleanupRuntimeSessionWorkspaceCopyStaging, copyRuntimeSessionWorkspace, ensureRuntimeSessionDirectories, ensureRuntimeSessionWorkspace, exposeRuntimeSessionWorkspace, finalizeRuntimeSessionWorkspaceCopy, removeRuntimeSessionWorkspace, revokeRuntimeSessionWorkspace, runtimeSessionWorkspaceDir, WORKSPACE_COPY_POLICY_V1, WorkspaceCopyError, workspaceCopyV1Supported, } from "./workspace.js";
15
15
  import { readWorkspaceFile, WorkspaceFileReadError } from "./workspace-file-read.js";
16
16
  import { checkpointWorkspace, WorkspaceSnapshotControlError, } from "./workspace-snapshot-control.js";
17
- import { issueWorkspaceQuiescenceProof, quiesceRuntimeWriters, } from "./runtime-quiescence.js";
17
+ import { assertWorkspaceWriterFreezeSupported, freezeRuntimeWriters, issueWorkspaceWriterFreezeProof, quiesceRuntimeWriters, thawRuntimeWriters, } from "./runtime-quiescence.js";
18
18
  import { WorkspaceSnapshotStagingError } from "./workspace-snapshot-staging.js";
19
19
  import { validateWorkspaceEntrySet, WorkspaceEntrySetError, } from "./workspace-entry-set.js";
20
20
  import { WORKSPACE_SNAPSHOT_POLICY_V1, workspaceSnapshotPolicyCapability, } from "./workspace-snapshot-policy.js";
@@ -359,8 +359,6 @@ export class AgentServiceSandboxClient {
359
359
  dispatcher;
360
360
  /** agent_run_id → TURN scope(session/attempt/activation),事件与文件帧路由用。 */
361
361
  turnScopes = new Map();
362
- turnWorkspaceCheckpoints = new Map();
363
- completedWorkspaceCheckpoints = new Set();
364
362
  /** 每 run 内嵌 profile(turn payload 自带时优先)。 */
365
363
  runProfiles = new Map();
366
364
  /** activate 时装配的 per-session profile,turn.start 复用。 */
@@ -514,15 +512,11 @@ export class AgentServiceSandboxClient {
514
512
  const pending = session?.pendingActivationCleanup;
515
513
  if (!session || !pending)
516
514
  return;
517
- await quiesceRuntimeWriters(pending.activationId);
518
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
519
515
  const activation = this.activationContexts.get(sessionId);
520
516
  if (activation?.activationId === pending.activationId) {
521
517
  this.activationContexts.delete(sessionId);
522
518
  this.sessionProfiles.delete(sessionId);
523
519
  }
524
- if (session.activationId === pending.activationId)
525
- session.activationId = null;
526
520
  if (!session.completedActivations.includes(pending.activationId)) {
527
521
  // Session-close/generation-rotate are the retention bounds. Never evict an
528
522
  // activation tombstone while the session is alive: activation IDs are opaque, so
@@ -537,8 +531,6 @@ export class AgentServiceSandboxClient {
537
531
  delete session.acceptedCommands[commandId];
538
532
  session.pendingActivationCleanup = null;
539
533
  this.turnScopes.delete(pending.agentRunId);
540
- if (this.activeSessionId === sessionId)
541
- this.activeSessionId = null;
542
534
  if (this.currentTurnSessionId === sessionId)
543
535
  this.currentTurnSessionId = null;
544
536
  this.persist();
@@ -621,9 +613,8 @@ export class AgentServiceSandboxClient {
621
613
  if (!this.sandboxGeneration || !this.connectionEpoch) {
622
614
  throw new Error("Agent Service sandbox is not authenticated");
623
615
  }
624
- // Terminal truth is never delayed by object storage. Course Service keeps the
625
- // checkpoint obligation pending and, after this turn is ACKed and fully quiesced,
626
- // re-delivers it as a session-scoped workspace.checkpoint command.
616
+ // Terminal truth is never delayed by object storage. Durable workspace checkpointing
617
+ // is a separate lifecycle command issued only for pause/cleanup/rotation.
627
618
  if (event.type === "run.block") {
628
619
  await this.waitForEventCapacity();
629
620
  }
@@ -863,11 +854,13 @@ export class AgentServiceSandboxClient {
863
854
  await cleanupRuntimeSessionWorkspaceCopyStaging();
864
855
  this.persist();
865
856
  assertRuntimeWorkspaceQuota(process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT ?? "");
857
+ assertWorkspaceWriterFreezeSupported();
866
858
  await this.sendControlFrame("sandbox.ready", {
867
859
  protocol_versions: [AGENT_SERVICE_WS_SCHEMA],
868
860
  capabilities: [
869
861
  WORKSPACE_FILE_READ_BINARY_CAPABILITY,
870
- "workspace_snapshot_v1",
862
+ "workspace_writer_freeze_v1",
863
+ "workspace_snapshot_v2",
871
864
  "workspace_restore_v1",
872
865
  ...(workspaceCopyV1Supported() ? ["workspace_copy_v1"] : []),
873
866
  ],
@@ -1006,6 +999,10 @@ export class AgentServiceSandboxClient {
1006
999
  case "workspace.checkpoint":
1007
1000
  this.scheduleLifecycle("workspace.checkpoint", () => this.handleWorkspaceCheckpoint(frame));
1008
1001
  return;
1002
+ case "workspace.thaw":
1003
+ await thawRuntimeWriters();
1004
+ await this.sendCommandAck(frame, "ok");
1005
+ return;
1009
1006
  case "sandbox.shutdown":
1010
1007
  await this.stopGracefully();
1011
1008
  return;
@@ -1033,6 +1030,9 @@ export class AgentServiceSandboxClient {
1033
1030
  this.scheduleLifecycle("sandbox.sync.close", (fence) => this.closeSession(sessionId, "sync_close", frame, fence));
1034
1031
  }
1035
1032
  const state = frame.payload.state;
1033
+ if (frame.payload.thaw_runtime_writers === true) {
1034
+ await thawRuntimeWriters();
1035
+ }
1036
1036
  if (state === "shutdown") {
1037
1037
  await this.stopGracefully();
1038
1038
  return;
@@ -1360,38 +1360,38 @@ export class AgentServiceSandboxClient {
1360
1360
  const checkpointId = frame.payload.checkpoint_id;
1361
1361
  const controlToken = frame.payload.control_token;
1362
1362
  const baseRevision = Number(frame.payload.base_revision);
1363
- const agentRunId = frame.payload.agent_run_id;
1364
- const workerAttempt = frame.payload.worker_attempt;
1365
- const activationId = frame.payload.activation_id;
1366
- const pendingCleanup = session?.pendingActivationCleanup;
1367
- if (pendingCleanup &&
1368
- pendingCleanup.agentRunId === agentRunId &&
1369
- pendingCleanup.workerAttempt === workerAttempt &&
1370
- pendingCleanup.activationId === activationId) {
1371
- await this.completePendingActivationCleanup(sessionId);
1372
- }
1363
+ const sourceKind = frame.payload.source_kind;
1373
1364
  if (!session ||
1374
- this.activeSessionId === sessionId ||
1375
1365
  this.currentTurnSessionId === sessionId ||
1376
1366
  typeof checkpointId !== "string" ||
1377
1367
  typeof controlToken !== "string" ||
1378
1368
  controlToken.length < 32 ||
1379
1369
  frame.payload.policy_id !== "WorkspaceSnapshotPolicyV1" ||
1370
+ ![
1371
+ "sandbox_pause",
1372
+ "sandbox_cleanup",
1373
+ "generation_rotation",
1374
+ "initial_migration",
1375
+ "live_workspace_copy",
1376
+ "maintenance_repair",
1377
+ ].includes(String(sourceKind)) ||
1380
1378
  !Number.isSafeInteger(baseRevision) ||
1381
1379
  baseRevision < 0 ||
1382
- !((agentRunId == null && workerAttempt == null && activationId == null) ||
1383
- (typeof agentRunId === "string" &&
1384
- Number.isSafeInteger(workerAttempt) &&
1385
- Number(workerAttempt) >= 1 &&
1386
- typeof activationId === "string" &&
1387
- activationId.length > 0))) {
1380
+ frame.payload.agent_run_id != null ||
1381
+ frame.payload.worker_attempt != null ||
1382
+ frame.payload.activation_id != null) {
1388
1383
  await this.sendCommandAck(frame, "rejected", "invalid_workspace_checkpoint");
1389
1384
  return;
1390
1385
  }
1386
+ const lifecycleCheckpoint = [
1387
+ "sandbox_pause",
1388
+ "sandbox_cleanup",
1389
+ "generation_rotation",
1390
+ ].includes(String(sourceKind));
1391
+ let checkpointCommitted = false;
1391
1392
  try {
1392
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1393
- await quiesceRuntimeWriters(typeof activationId === "string" ? activationId : null);
1394
- const quiescenceProof = issueWorkspaceQuiescenceProof({
1393
+ await freezeRuntimeWriters();
1394
+ const freezeProof = issueWorkspaceWriterFreezeProof({
1395
1395
  sandboxId: this.options.sandboxId,
1396
1396
  sandboxGeneration: this.sandboxGeneration,
1397
1397
  runtimeSessionId: sessionId,
@@ -1408,16 +1408,10 @@ export class AgentServiceSandboxClient {
1408
1408
  sandboxGeneration: this.sandboxGeneration,
1409
1409
  connectionEpoch: this.connectionEpoch,
1410
1410
  runtimeSessionId: sessionId,
1411
- ...(typeof agentRunId === "string"
1412
- ? {
1413
- agentRunId,
1414
- workerAttempt: Number(workerAttempt),
1415
- activationId: String(activationId),
1416
- }
1417
- : {}),
1418
1411
  },
1419
- quiescenceProof,
1412
+ freezeProof,
1420
1413
  });
1414
+ checkpointCommitted = true;
1421
1415
  const marker = readWorkspaceMaterializationMarker(this.options.sandboxId, sessionId);
1422
1416
  if (!marker)
1423
1417
  throw new Error("workspace checkpoint marker missing");
@@ -1431,8 +1425,13 @@ export class AgentServiceSandboxClient {
1431
1425
  workspace_content_sha256: marker.contentSha256,
1432
1426
  });
1433
1427
  await this.sendCommandAck(frame, "ok");
1428
+ if (!lifecycleCheckpoint)
1429
+ await thawRuntimeWriters();
1434
1430
  }
1435
1431
  catch (error) {
1432
+ if (!lifecycleCheckpoint || !checkpointCommitted) {
1433
+ await thawRuntimeWriters().catch(() => undefined);
1434
+ }
1436
1435
  this.log.error("workspace baseline checkpoint failed", {
1437
1436
  sandboxId: this.options.sandboxId,
1438
1437
  runtimeSessionId: sessionId,
@@ -1583,7 +1582,7 @@ export class AgentServiceSandboxClient {
1583
1582
  const previousSessionId = this.activeSessionId;
1584
1583
  const changesActivation = previousSessionId !== null && (previousSessionId !== sessionId || session.activationId !== activationId);
1585
1584
  if (changesActivation) {
1586
- const stopped = await this.deactivateSession(previousSessionId);
1585
+ const stopped = await this.deactivateSession(previousSessionId, previousSessionId !== sessionId);
1587
1586
  if (fence && !this.isCurrentLifecycleFence(fence))
1588
1587
  return;
1589
1588
  if (!stopped) {
@@ -1633,21 +1632,6 @@ export class AgentServiceSandboxClient {
1633
1632
  await this.sendCommandAck(frame, "rejected", "activation_mismatch");
1634
1633
  return;
1635
1634
  }
1636
- const checkpoint = frame.payload.workspace_checkpoint;
1637
- if (checkpoint !== undefined) {
1638
- if (!checkpoint || typeof checkpoint !== "object" || Array.isArray(checkpoint) ||
1639
- typeof checkpoint.checkpoint_id !== "string" ||
1640
- checkpoint.policy_id !== "WorkspaceSnapshotPolicyV1" ||
1641
- !Number.isSafeInteger(checkpoint.base_revision) ||
1642
- Number(checkpoint.base_revision) < 0) {
1643
- await this.sendCommandAck(frame, "rejected", "invalid_workspace_checkpoint");
1644
- return;
1645
- }
1646
- this.turnWorkspaceCheckpoints.set(runId, {
1647
- checkpointId: String(checkpoint.checkpoint_id),
1648
- baseRevision: Number(checkpoint.base_revision),
1649
- });
1650
- }
1651
1635
  const payload = frame.payload;
1652
1636
  if (!this.validTurnPayload(payload)) {
1653
1637
  await this.sendCommandAck(frame, "rejected", "invalid_turn_payload");
@@ -1734,8 +1718,6 @@ export class AgentServiceSandboxClient {
1734
1718
  .finally(() => {
1735
1719
  this.inflightCommands.delete(commandId);
1736
1720
  this.runProfiles.delete(runId);
1737
- this.turnWorkspaceCheckpoints.delete(runId);
1738
- this.completedWorkspaceCheckpoints.delete(runId);
1739
1721
  });
1740
1722
  }
1741
1723
  async handleTurnCancel(frame) {
@@ -1796,15 +1778,16 @@ export class AgentServiceSandboxClient {
1796
1778
  const session = this.state.sessions[sessionId];
1797
1779
  if (session) {
1798
1780
  const runIds = this.runIdsForSession(sessionId);
1799
- session.activationId = null;
1800
1781
  this.activationContexts.delete(sessionId);
1801
1782
  this.sessionProfiles.delete(sessionId);
1802
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1803
1783
  for (const runId of runIds)
1804
1784
  this.dispatcher.cancel(runId);
1805
1785
  if (!await this.dispatcher.waitForRuns(runIds, 10_000)) {
1806
1786
  throw new Error(`runtime session ${sessionId} did not stop before close`);
1807
1787
  }
1788
+ await this.terminateSessionWriters(session);
1789
+ session.activationId = null;
1790
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1808
1791
  if (fence && !this.isCurrentLifecycleFence(fence))
1809
1792
  return;
1810
1793
  if (this.activeSessionId === sessionId)
@@ -1947,21 +1930,40 @@ export class AgentServiceSandboxClient {
1947
1930
  .filter(([, scope]) => scope.sessionId === sessionId)
1948
1931
  .map(([runId]) => runId);
1949
1932
  }
1950
- async deactivateSession(sessionId) {
1933
+ async deactivateSession(sessionId, terminateWriters = false) {
1951
1934
  const session = this.state.sessions[sessionId];
1952
1935
  if (!session)
1953
1936
  return true;
1954
1937
  const runIds = this.runIdsForSession(sessionId);
1955
- session.activationId = null;
1956
1938
  this.activationContexts.delete(sessionId);
1957
1939
  this.sessionProfiles.delete(sessionId);
1958
- revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1940
+ if (terminateWriters) {
1941
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1942
+ }
1959
1943
  if (this.activeSessionId === sessionId)
1960
1944
  this.activeSessionId = null;
1961
1945
  for (const runId of runIds)
1962
1946
  this.dispatcher.cancel(runId);
1963
1947
  this.persist();
1964
- return this.dispatcher.waitForRuns(runIds, 10_000);
1948
+ const stopped = await this.dispatcher.waitForRuns(runIds, 10_000);
1949
+ if (stopped && terminateWriters) {
1950
+ await this.terminateSessionWriters(session);
1951
+ }
1952
+ session.activationId = null;
1953
+ this.persist();
1954
+ return stopped;
1955
+ }
1956
+ async terminateSessionWriters(session) {
1957
+ const activationIds = new Set([
1958
+ ...session.completedActivations,
1959
+ ...(session.activationId ? [session.activationId] : []),
1960
+ ...(session.pendingActivationCleanup?.activationId
1961
+ ? [session.pendingActivationCleanup.activationId]
1962
+ : []),
1963
+ ]);
1964
+ for (const activationId of activationIds) {
1965
+ await quiesceRuntimeWriters(activationId);
1966
+ }
1965
1967
  }
1966
1968
  sameTurnScope(left, right) {
1967
1969
  return left.sessionId === right.sessionId &&
@@ -1,9 +1,9 @@
1
- export declare const AGENT_SERVICE_WS_SCHEMA: "botlearn-agent-sandbox-ws/0.3";
2
- export declare const AGENT_SERVICE_WS_SUBPROTOCOL: "botlearn-agent-sandbox.v3";
1
+ export declare const AGENT_SERVICE_WS_SCHEMA: "botlearn-agent-sandbox-ws/0.4";
2
+ 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" | "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" | "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);
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- export const AGENT_SERVICE_WS_SCHEMA = "botlearn-agent-sandbox-ws/0.3";
3
- export const AGENT_SERVICE_WS_SUBPROTOCOL = "botlearn-agent-sandbox.v3";
2
+ export const AGENT_SERVICE_WS_SCHEMA = "botlearn-agent-sandbox-ws/0.4";
3
+ export const AGENT_SERVICE_WS_SUBPROTOCOL = "botlearn-agent-sandbox.v4";
4
4
  export const WORKSPACE_FILE_READ_BINARY_CAPABILITY = "workspace_file_read_binary_v1";
5
5
  export const WORKSPACE_FILE_CHUNK_BYTES = 64 * 1024;
6
6
  export const WORKSPACE_FILE_CHUNK_HEADER_BYTES = 29;
@@ -19,6 +19,7 @@ const FRAME_TYPES = new Set([
19
19
  "auth.rotate",
20
20
  "workspace.file.read",
21
21
  "workspace.checkpoint",
22
+ "workspace.thaw",
22
23
  "ping",
23
24
  "sandbox.ready",
24
25
  "sandbox.heartbeat",
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { createReadStream } from "node:fs";
2
+ import { constants } from "node:fs";
3
3
  import { open, readdir } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { performance } from "node:perf_hooks";
@@ -20,30 +20,50 @@ function isSafeRelativePath(rel) {
20
20
  const segments = rel.split("/");
21
21
  return segments.every((seg) => seg !== "" && seg !== "..");
22
22
  }
23
- async function sha256File(absPath) {
23
+ async function sha256File(handle) {
24
24
  const hash = createHash("sha256");
25
- const stream = createReadStream(absPath);
25
+ const stream = handle.createReadStream({ autoClose: false, start: 0 });
26
26
  for await (const chunk of stream)
27
27
  hash.update(chunk);
28
28
  return hash.digest("hex");
29
29
  }
30
- async function readPreview(absPath, maxPreviewChars) {
31
- const handle = await open(absPath, "r");
30
+ async function readPreview(handle, maxPreviewChars) {
31
+ const sniff = Buffer.alloc(TEXT_SNIFF_BYTES);
32
+ const { bytesRead } = await handle.read(sniff, 0, TEXT_SNIFF_BYTES, 0);
33
+ if (sniff.subarray(0, bytesRead).includes(0))
34
+ return null;
35
+ // 预览最多 maxPreviewChars 字符;UTF-8 下 4 字节/字符封顶,读够即可。
36
+ const want = maxPreviewChars * 4;
37
+ let buf = sniff.subarray(0, bytesRead);
38
+ if (bytesRead === TEXT_SNIFF_BYTES && want > TEXT_SNIFF_BYTES) {
39
+ const more = Buffer.alloc(want - TEXT_SNIFF_BYTES);
40
+ const extra = await handle.read(more, 0, more.length, TEXT_SNIFF_BYTES);
41
+ buf = Buffer.concat([buf, more.subarray(0, extra.bytesRead)]);
42
+ }
43
+ const text = buf.toString("utf8").replace(/�+$/, "");
44
+ return redactSecretString(text.slice(0, maxPreviewChars));
45
+ }
46
+ function sameFileVersion(left, right) {
47
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
48
+ left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
49
+ }
50
+ async function scanStableFile(absPath, maxPreviewChars, maxFileBytes) {
51
+ const handle = await open(absPath, constants.O_RDONLY | constants.O_NOFOLLOW);
32
52
  try {
33
- const sniff = Buffer.alloc(TEXT_SNIFF_BYTES);
34
- const { bytesRead } = await handle.read(sniff, 0, TEXT_SNIFF_BYTES, 0);
35
- if (sniff.subarray(0, bytesRead).includes(0))
36
- return null;
37
- // 预览最多 maxPreviewChars 字符;UTF-8 下 4 字节/字符封顶,读够即可。
38
- const want = maxPreviewChars * 4;
39
- let buf = sniff.subarray(0, bytesRead);
40
- if (bytesRead === TEXT_SNIFF_BYTES && want > TEXT_SNIFF_BYTES) {
41
- const more = Buffer.alloc(want - TEXT_SNIFF_BYTES);
42
- const extra = await handle.read(more, 0, more.length, TEXT_SNIFF_BYTES);
43
- buf = Buffer.concat([buf, more.subarray(0, extra.bytesRead)]);
53
+ const before = await handle.stat({ bigint: true });
54
+ if (!before.isFile() || before.size > BigInt(Number.MAX_SAFE_INTEGER)) {
55
+ throw new Error("workspace_candidate_invalid_file");
44
56
  }
45
- const text = buf.toString("utf8").replace(/�+$/, "");
46
- return redactSecretString(text.slice(0, maxPreviewChars));
57
+ const sizeBytes = Number(before.size);
58
+ if (sizeBytes > maxFileBytes)
59
+ return { sizeBytes, tooLarge: true };
60
+ const sha256 = await sha256File(handle);
61
+ const previewText = await readPreview(handle, maxPreviewChars);
62
+ const after = await handle.stat({ bigint: true });
63
+ if (!sameFileVersion(before, after)) {
64
+ throw new Error("workspace_candidate_changed_during_scan");
65
+ }
66
+ return { sizeBytes, tooLarge: false, sha256, previewText };
47
67
  }
48
68
  finally {
49
69
  await handle.close();
@@ -92,23 +112,18 @@ export async function scanWorkspaceFiles(workspaceDir, limits = {}) {
92
112
  const rel = path.relative(workspaceDir, absPath).split(path.sep).join("/");
93
113
  if (!isSafeRelativePath(rel))
94
114
  continue;
95
- let sizeBytes;
115
+ let scanned;
96
116
  try {
97
- const handle = await open(absPath, "r");
98
- try {
99
- sizeBytes = (await handle.stat()).size;
100
- }
101
- finally {
102
- await handle.close();
103
- }
117
+ scanned = await scanStableFile(absPath, maxPreviewChars, maxFileBytes);
104
118
  }
105
119
  catch {
106
120
  truncated = true;
107
121
  continue;
108
122
  }
123
+ const { sizeBytes } = scanned;
109
124
  observedFileCount += 1;
110
125
  observedTotalBytes += sizeBytes;
111
- if (sizeBytes > maxFileBytes) {
126
+ if (scanned.tooLarge) {
112
127
  truncated = true;
113
128
  continue;
114
129
  }
@@ -116,24 +131,16 @@ export async function scanWorkspaceFiles(workspaceDir, limits = {}) {
116
131
  truncated = true;
117
132
  continue;
118
133
  }
119
- let sha256;
120
- let previewText;
121
- try {
122
- sha256 = await sha256File(absPath);
123
- previewText = await readPreview(absPath, maxPreviewChars);
124
- }
125
- catch {
126
- truncated = true;
127
- continue;
128
- }
129
134
  files.push({
130
135
  absPath,
131
136
  event: "created",
132
137
  path: rel,
133
138
  name: entry.name,
134
139
  size_bytes: sizeBytes,
135
- sha256,
136
- ...(previewText !== null && previewText !== "" ? { preview_text: previewText } : {}),
140
+ sha256: scanned.sha256,
141
+ ...(scanned.previewText !== null && scanned.previewText !== ""
142
+ ? { preview_text: scanned.previewText }
143
+ : {}),
137
144
  });
138
145
  }
139
146
  }
@@ -1,16 +1,19 @@
1
- export interface WorkspaceQuiescenceProof {
2
- readonly proofVersion: "workspace-quiescence-proof/1";
1
+ export interface WorkspaceWriterFreezeProof {
2
+ readonly proofVersion: "workspace-writer-freeze-proof/1";
3
3
  readonly sandboxId: string;
4
4
  readonly sandboxGeneration: number;
5
5
  readonly runtimeSessionId: string;
6
6
  readonly checkpointId: string;
7
- readonly allWritersStopped: true;
7
+ readonly allWritersFrozen: true;
8
8
  }
9
+ export declare function freezeRuntimeWriters(): Promise<void>;
10
+ export declare function thawRuntimeWriters(): Promise<void>;
11
+ export declare function assertWorkspaceWriterFreezeSupported(): void;
9
12
  export declare function quiesceRuntimeWriters(activationId: string | null): Promise<void>;
10
- export declare function issueWorkspaceQuiescenceProof(scope: {
13
+ export declare function issueWorkspaceWriterFreezeProof(scope: {
11
14
  sandboxId: string;
12
15
  sandboxGeneration: number;
13
16
  runtimeSessionId: string;
14
17
  checkpointId: string;
15
- }): WorkspaceQuiescenceProof;
16
- export declare function isWorkspaceQuiescenceProof(value: object): value is WorkspaceQuiescenceProof;
18
+ }): WorkspaceWriterFreezeProof;
19
+ export declare function isWorkspaceWriterFreezeProof(value: object): value is WorkspaceWriterFreezeProof;
@@ -1,8 +1,76 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { accessSync, constants as fsConstants } from "node:fs";
3
+ import { readFile, readdir, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
2
5
  import { promisify } from "node:util";
3
6
  const execFileAsync = promisify(execFile);
4
7
  const RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
8
+ const RUNTIME_CGROUP_ROOT = "/sys/fs/cgroup/botlearn-runtime";
5
9
  const proofs = new WeakSet();
10
+ const sleep = (milliseconds) => new Promise((resolve) => {
11
+ setTimeout(resolve, milliseconds);
12
+ });
13
+ async function setDirectCgroupsFrozen(frozen) {
14
+ let entries;
15
+ try {
16
+ entries = await readdir(RUNTIME_CGROUP_ROOT, { withFileTypes: true });
17
+ }
18
+ catch (error) {
19
+ if (error.code === "ENOENT")
20
+ return;
21
+ throw error;
22
+ }
23
+ for (const entry of entries) {
24
+ if (!entry.isDirectory())
25
+ continue;
26
+ const directory = path.join(RUNTIME_CGROUP_ROOT, entry.name);
27
+ await writeFile(path.join(directory, "cgroup.freeze"), frozen ? "1" : "0");
28
+ for (let attempt = 0; attempt < 100; attempt += 1) {
29
+ const events = await readFile(path.join(directory, "cgroup.events"), "utf8");
30
+ if (events.includes(`frozen ${frozen ? 1 : 0}`))
31
+ break;
32
+ if (attempt === 99)
33
+ throw new Error("workspace_runtime_freeze_timeout");
34
+ await sleep(50);
35
+ }
36
+ }
37
+ }
38
+ async function setRuntimeWritersFrozen(frozen) {
39
+ const mode = process.env.BOTLEARN_RUNTIME_LAUNCH_MODE;
40
+ if (mode === "direct-uid") {
41
+ await setDirectCgroupsFrozen(frozen);
42
+ return;
43
+ }
44
+ if (mode === "sudo") {
45
+ await execFileAsync("/usr/bin/sudo", ["-n", "--", RUNTIME_LAUNCHER, frozen ? "--freeze-all" : "--thaw-all"], { timeout: 10_000 });
46
+ return;
47
+ }
48
+ if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
49
+ return;
50
+ }
51
+ throw new Error("workspace_runtime_cgroup_unavailable");
52
+ }
53
+ export async function freezeRuntimeWriters() {
54
+ await setRuntimeWritersFrozen(true);
55
+ }
56
+ export async function thawRuntimeWriters() {
57
+ await setRuntimeWritersFrozen(false);
58
+ }
59
+ export function assertWorkspaceWriterFreezeSupported() {
60
+ const mode = process.env.BOTLEARN_RUNTIME_LAUNCH_MODE;
61
+ if (mode === "direct-uid") {
62
+ accessSync(RUNTIME_CGROUP_ROOT, fsConstants.R_OK | fsConstants.W_OK);
63
+ return;
64
+ }
65
+ if (mode === "sudo") {
66
+ accessSync(RUNTIME_LAUNCHER, fsConstants.X_OK);
67
+ return;
68
+ }
69
+ if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
70
+ return;
71
+ }
72
+ throw new Error("workspace_runtime_cgroup_unavailable");
73
+ }
6
74
  function managedQuiescenceCommand(activationId) {
7
75
  const mode = process.env.BOTLEARN_RUNTIME_LAUNCH_MODE;
8
76
  if (mode === "direct-uid") {
@@ -28,15 +96,15 @@ export async function quiesceRuntimeWriters(activationId) {
28
96
  }
29
97
  await execFileAsync(command[0], command[1], { timeout: 10_000 });
30
98
  }
31
- export function issueWorkspaceQuiescenceProof(scope) {
99
+ export function issueWorkspaceWriterFreezeProof(scope) {
32
100
  const proof = Object.freeze({
33
- proofVersion: "workspace-quiescence-proof/1",
101
+ proofVersion: "workspace-writer-freeze-proof/1",
34
102
  ...scope,
35
- allWritersStopped: true,
103
+ allWritersFrozen: true,
36
104
  });
37
105
  proofs.add(proof);
38
106
  return proof;
39
107
  }
40
- export function isWorkspaceQuiescenceProof(value) {
108
+ export function isWorkspaceWriterFreezeProof(value) {
41
109
  return proofs.has(value);
42
110
  }
@@ -13,12 +13,13 @@ export interface ToolObservationPayload extends Record<string, unknown> {
13
13
  redacted: boolean;
14
14
  }
15
15
  /**
16
- * Project a provider tool envelope into durable teacher evidence.
16
+ * Project a provider tool envelope into durable learner-preview and teaching-audit evidence.
17
17
  *
18
18
  * Only allowlisted operational argument/result fields survive, credentials are removed,
19
- * and host paths become workspace-relative. `detail_preview` stays a bounded feed-sized
20
- * excerpt; `detail_full` carries the same allowlisted projection at audit fidelity (deeper
21
- * walk, larger arrays, 64k chars). Message text, request IDs, and arbitrary envelope
22
- * metadata never cross this boundary.
19
+ * and paths inside the current workspace become workspace-relative. Other semantic content,
20
+ * including execution-host paths outside that workspace, stays intact. `detail_preview` is
21
+ * a bounded feed-sized excerpt; `detail_full` carries the same allowlisted projection at
22
+ * audit fidelity (deeper walk, larger arrays, 64k chars). Message text, request IDs, and
23
+ * arbitrary envelope metadata never cross this boundary.
23
24
  */
24
25
  export declare function buildToolObservation(block: RuntimeBlock, runtime: string, workspaceDir: string): ToolObservationPayload | null;
@@ -39,13 +39,14 @@ const RESULT_FIELDS = new Set([
39
39
  "bytes",
40
40
  ]);
41
41
  /**
42
- * Project a provider tool envelope into durable teacher evidence.
42
+ * Project a provider tool envelope into durable learner-preview and teaching-audit evidence.
43
43
  *
44
44
  * Only allowlisted operational argument/result fields survive, credentials are removed,
45
- * and host paths become workspace-relative. `detail_preview` stays a bounded feed-sized
46
- * excerpt; `detail_full` carries the same allowlisted projection at audit fidelity (deeper
47
- * walk, larger arrays, 64k chars). Message text, request IDs, and arbitrary envelope
48
- * metadata never cross this boundary.
45
+ * and paths inside the current workspace become workspace-relative. Other semantic content,
46
+ * including execution-host paths outside that workspace, stays intact. `detail_preview` is
47
+ * a bounded feed-sized excerpt; `detail_full` carries the same allowlisted projection at
48
+ * audit fidelity (deeper walk, larger arrays, 64k chars). Message text, request IDs, and
49
+ * arbitrary envelope metadata never cross this boundary.
49
50
  */
50
51
  export function buildToolObservation(block, runtime, workspaceDir) {
51
52
  if (block.kind !== "tool_call" && block.kind !== "tool_result")
@@ -17,5 +17,5 @@ export interface ReasoningTracePayload extends Record<string, unknown> {
17
17
  * envelope stays local to the sandbox transcript.
18
18
  */
19
19
  export declare function extractReasoningText(raw: unknown): string;
20
- /** Project accumulated reasoning text into one durable, redacted, bounded trace payload. */
20
+ /** Project reasoning into one bounded payload that filters credentials and preserves semantics. */
21
21
  export declare function buildReasoningTracePayload(text: string, runtime: string, alreadyTruncated?: boolean): ReasoningTracePayload;
@@ -40,7 +40,7 @@ function collectReasoningStrings(value, depth, out) {
40
40
  collectReasoningStrings(item, depth + 1, out);
41
41
  }
42
42
  }
43
- /** Project accumulated reasoning text into one durable, redacted, bounded trace payload. */
43
+ /** Project reasoning into one bounded payload that filters credentials and preserves semantics. */
44
44
  export function buildReasoningTracePayload(text, runtime, alreadyTruncated = false) {
45
45
  const redactedText = redactSecretString(text);
46
46
  const truncated = alreadyTruncated || redactedText.length > REASONING_TRACE_MAX_CHARS;
@@ -1,4 +1,4 @@
1
- import type { WorkspaceQuiescenceProof } from "./runtime-quiescence.js";
1
+ import type { WorkspaceWriterFreezeProof } from "./runtime-quiescence.js";
2
2
  interface WorkspaceCheckpointScope {
3
3
  checkpointId: string;
4
4
  baseRevision: number;
@@ -20,7 +20,7 @@ export declare function checkpointWorkspace(options: {
20
20
  reconnectToken: string;
21
21
  workspaceDirectory: string;
22
22
  scope: WorkspaceCheckpointScope;
23
- quiescenceProof: WorkspaceQuiescenceProof;
23
+ freezeProof: WorkspaceWriterFreezeProof;
24
24
  }): Promise<{
25
25
  revision: number;
26
26
  snapshotId: string | null;
@@ -77,7 +77,7 @@ export async function checkpointWorkspace(options) {
77
77
  checkpointId: scope.checkpointId,
78
78
  limits: WORKSPACE_SNAPSHOT_POLICY_V1.limits,
79
79
  maxStabilityRetries: WORKSPACE_SNAPSHOT_POLICY_V1.maxStabilityRetries,
80
- quiescenceProof: options.quiescenceProof,
80
+ freezeProof: options.freezeProof,
81
81
  });
82
82
  try {
83
83
  const entrySet = {
@@ -1,4 +1,4 @@
1
- import { type WorkspaceQuiescenceProof } from "./runtime-quiescence.js";
1
+ import { type WorkspaceWriterFreezeProof } from "./runtime-quiescence.js";
2
2
  import { type CanonicalWorkspaceEntrySet, type WorkspaceEntrySetLimits } from "./workspace-entry-set.js";
3
3
  export declare class WorkspaceSnapshotStagingError extends Error {
4
4
  readonly code: string;
@@ -22,6 +22,6 @@ export interface WorkspaceSnapshotStagingOptions {
22
22
  checkpointId: string;
23
23
  limits: WorkspaceEntrySetLimits;
24
24
  maxStabilityRetries: number;
25
- quiescenceProof: WorkspaceQuiescenceProof;
25
+ freezeProof: WorkspaceWriterFreezeProof;
26
26
  }
27
27
  export declare function stageWorkspaceSnapshot(options: WorkspaceSnapshotStagingOptions): StagedWorkspaceSnapshot;
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeSync, } from "node:fs";
3
3
  import path from "node:path";
4
- import { isWorkspaceQuiescenceProof, } from "./runtime-quiescence.js";
4
+ import { isWorkspaceWriterFreezeProof, } from "./runtime-quiescence.js";
5
5
  import { defaultCaseFold, validateWorkspaceEntrySet, WORKSPACE_ENTRY_SET_SCHEMA, } from "./workspace-entry-set.js";
6
6
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
7
  const EXCLUDED_NAMES = new Set(["node_modules"]);
@@ -233,14 +233,14 @@ function stageOnce(options, paths) {
233
233
  export function stageWorkspaceSnapshot(options) {
234
234
  if (!UUID.test(options.checkpointId))
235
235
  fail("workspace_snapshot_checkpoint_invalid");
236
- if (!isWorkspaceQuiescenceProof(options.quiescenceProof) ||
237
- options.quiescenceProof.proofVersion !== "workspace-quiescence-proof/1" ||
238
- !UUID.test(options.quiescenceProof.sandboxId) ||
239
- !UUID.test(options.quiescenceProof.runtimeSessionId) ||
240
- options.quiescenceProof.checkpointId !== options.checkpointId ||
241
- !Number.isSafeInteger(options.quiescenceProof.sandboxGeneration) ||
242
- options.quiescenceProof.sandboxGeneration < 1 ||
243
- options.quiescenceProof.allWritersStopped !== true)
236
+ if (!isWorkspaceWriterFreezeProof(options.freezeProof) ||
237
+ options.freezeProof.proofVersion !== "workspace-writer-freeze-proof/1" ||
238
+ !UUID.test(options.freezeProof.sandboxId) ||
239
+ !UUID.test(options.freezeProof.runtimeSessionId) ||
240
+ options.freezeProof.checkpointId !== options.checkpointId ||
241
+ !Number.isSafeInteger(options.freezeProof.sandboxGeneration) ||
242
+ options.freezeProof.sandboxGeneration < 1 ||
243
+ options.freezeProof.allWritersFrozen !== true)
244
244
  fail("workspace_snapshot_writer_isolation_unproven");
245
245
  if (!Number.isSafeInteger(options.maxStabilityRetries) || options.maxStabilityRetries < 0) {
246
246
  fail("workspace_snapshot_retry_limit_invalid");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.20-beta.2",
3
+ "version": "0.0.20-beta.4",
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": {