@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,9 +1,9 @@
1
- export declare const AGENT_SERVICE_WS_SCHEMA: "botlearn-agent-sandbox-ws/0.2";
2
- export declare const AGENT_SERVICE_WS_SUBPROTOCOL: "botlearn-agent-sandbox.v2";
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" | "ping" | "sandbox.ready" | "sandbox.heartbeat" | "command.ack" | "session.opened" | "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.2";
3
- export const AGENT_SERVICE_WS_SUBPROTOCOL = "botlearn-agent-sandbox.v2";
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;
@@ -18,11 +18,14 @@ const FRAME_TYPES = new Set([
18
18
  "event.ack",
19
19
  "auth.rotate",
20
20
  "workspace.file.read",
21
+ "workspace.checkpoint",
22
+ "workspace.thaw",
21
23
  "ping",
22
24
  "sandbox.ready",
23
25
  "sandbox.heartbeat",
24
26
  "command.ack",
25
27
  "session.opened",
28
+ "session.open_failed",
26
29
  "session.closed",
27
30
  "turn.event",
28
31
  "turn.file.report",
@@ -38,8 +41,10 @@ const SESSION_TYPES = new Set([
38
41
  "session.activate",
39
42
  "session.close",
40
43
  "session.opened",
44
+ "session.open_failed",
41
45
  "session.closed",
42
46
  "workspace.file.read",
47
+ "workspace.checkpoint",
43
48
  "workspace.file.result",
44
49
  ]);
45
50
  /** TURN 帧:必须带 runtime_session_id + agent_run_id + worker_attempt + activation_id 四元组。 */
package/dist/cli.js CHANGED
@@ -372,7 +372,25 @@ async function cmdAgentServiceSession(args) {
372
372
  deepseekTuiVersion: process.env.BOTLEARN_MANAGED_DEEPSEEK_TUI_VERSION?.trim() || undefined,
373
373
  });
374
374
  clearAgentServiceControlEnv();
375
- await client.run();
375
+ let stopping = false;
376
+ const stop = () => {
377
+ if (stopping)
378
+ return;
379
+ stopping = true;
380
+ void client.stopGracefully().catch((error) => {
381
+ console.error(error instanceof Error ? error.message : "sandbox shutdown failed");
382
+ client.stop();
383
+ });
384
+ };
385
+ process.once("SIGTERM", stop);
386
+ process.once("SIGINT", stop);
387
+ try {
388
+ await client.run();
389
+ }
390
+ finally {
391
+ process.removeListener("SIGTERM", stop);
392
+ process.removeListener("SIGINT", stop);
393
+ }
376
394
  return 0;
377
395
  }
378
396
  // ---------------------------------------------------------------
@@ -12,11 +12,34 @@ export interface ScannedFile extends RunFileCandidate {
12
12
  }
13
13
  export interface FileReportingClient {
14
14
  postFile(agentRunId: string, file: RunFileCandidate): Promise<unknown>;
15
+ recordWorkspaceScanMeasurement?(agentRunId: string, measurement: WorkspaceScanMeasurement): Promise<void> | void;
16
+ }
17
+ export interface WorkspaceScanMeasurement {
18
+ fileCount: number;
19
+ totalBytes: number;
20
+ durationMs: number;
21
+ truncated: boolean;
15
22
  }
16
23
  export interface FileReportResult {
17
24
  reported: number;
18
25
  failed: number;
19
26
  truncated: boolean;
27
+ scan: WorkspaceScanMeasurement;
28
+ }
29
+ interface WorkspaceFileFingerprint {
30
+ sizeBytes: number;
31
+ sha256: string;
32
+ }
33
+ /**
34
+ * 普通 Agent turn 启动前捕获的 workspace 文件基线。
35
+ *
36
+ * 基线只用于判断本轮新增/修改,不会上报给 Course Service,也不把迁移复制进来的
37
+ * 既有文件变成本轮 TaskRun 证据。truncated=true 时,缺席于 files 的路径不能安全地
38
+ * 判定为“本轮新增”,所以上报阶段会对这类路径 fail closed。
39
+ */
40
+ export interface WorkspaceFileBaseline {
41
+ files: ReadonlyMap<string, WorkspaceFileFingerprint>;
42
+ truncated: boolean;
20
43
  }
21
44
  /**
22
45
  * 递归扫描 workspace 产出文件候选。
@@ -26,8 +49,12 @@ export interface FileReportResult {
26
49
  export declare function scanWorkspaceFiles(workspaceDir: string, limits?: ScanLimits): Promise<{
27
50
  files: ScannedFile[];
28
51
  truncated: boolean;
52
+ measurement: WorkspaceScanMeasurement;
29
53
  }>;
54
+ /** 捕获普通 Agent turn 开始前的 workspace 基线。 */
55
+ export declare function captureWorkspaceFileBaseline(workspaceDir: string, limits?: ScanLimits): Promise<WorkspaceFileBaseline>;
30
56
  /**
31
57
  * 扫描并逐个上报文件候选。单文件上报失败只 warn 不中断;返回成功上报数。
32
58
  */
33
- export declare function reportFileCandidates(client: FileReportingClient, agentRunId: string, workspaceDir: string, log: Logger, limits?: ScanLimits): Promise<FileReportResult>;
59
+ export declare function reportFileCandidates(client: FileReportingClient, agentRunId: string, workspaceDir: string, log: Logger, limits?: ScanLimits, baseline?: WorkspaceFileBaseline): Promise<FileReportResult>;
60
+ export {};
@@ -1,7 +1,8 @@
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
+ import { performance } from "node:perf_hooks";
5
6
  import { redactSecretString } from "./redaction.js";
6
7
  const DEFAULT_MAX_FILES = 50;
7
8
  // 与后端 settings.daemon_max_file_bytes 默认一致。
@@ -19,30 +20,50 @@ function isSafeRelativePath(rel) {
19
20
  const segments = rel.split("/");
20
21
  return segments.every((seg) => seg !== "" && seg !== "..");
21
22
  }
22
- async function sha256File(absPath) {
23
+ async function sha256File(handle) {
23
24
  const hash = createHash("sha256");
24
- const stream = createReadStream(absPath);
25
+ const stream = handle.createReadStream({ autoClose: false, start: 0 });
25
26
  for await (const chunk of stream)
26
27
  hash.update(chunk);
27
28
  return hash.digest("hex");
28
29
  }
29
- async function readPreview(absPath, maxPreviewChars) {
30
- 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);
31
52
  try {
32
- const sniff = Buffer.alloc(TEXT_SNIFF_BYTES);
33
- const { bytesRead } = await handle.read(sniff, 0, TEXT_SNIFF_BYTES, 0);
34
- if (sniff.subarray(0, bytesRead).includes(0))
35
- return null;
36
- // 预览最多 maxPreviewChars 字符;UTF-8 下 4 字节/字符封顶,读够即可。
37
- const want = maxPreviewChars * 4;
38
- let buf = sniff.subarray(0, bytesRead);
39
- if (bytesRead === TEXT_SNIFF_BYTES && want > TEXT_SNIFF_BYTES) {
40
- const more = Buffer.alloc(want - TEXT_SNIFF_BYTES);
41
- const extra = await handle.read(more, 0, more.length, TEXT_SNIFF_BYTES);
42
- 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");
43
56
  }
44
- const text = buf.toString("utf8").replace(/�+$/, "");
45
- 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 };
46
67
  }
47
68
  finally {
48
69
  await handle.close();
@@ -54,16 +75,16 @@ async function readPreview(absPath, maxPreviewChars) {
54
75
  * 达到 maxFiles 上限后停止并标记 truncated。
55
76
  */
56
77
  export async function scanWorkspaceFiles(workspaceDir, limits = {}) {
78
+ const scanStartedAt = performance.now();
57
79
  const maxFiles = limits.maxFiles ?? DEFAULT_MAX_FILES;
58
80
  const maxFileBytes = limits.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
59
81
  const maxPreviewChars = limits.maxPreviewChars ?? DEFAULT_MAX_PREVIEW_CHARS;
60
82
  const maxDepth = limits.maxDepth ?? DEFAULT_MAX_DEPTH;
61
83
  const files = [];
62
84
  let truncated = false;
63
- let stop = false;
85
+ let observedFileCount = 0;
86
+ let observedTotalBytes = 0;
64
87
  async function walk(dir, depth) {
65
- if (stop)
66
- return;
67
88
  if (depth > maxDepth) {
68
89
  truncated = true;
69
90
  return;
@@ -77,8 +98,6 @@ export async function scanWorkspaceFiles(workspaceDir, limits = {}) {
77
98
  }
78
99
  entries.sort((a, b) => a.name.localeCompare(b.name));
79
100
  for (const entry of entries) {
80
- if (stop)
81
- return;
82
101
  if (entry.name.startsWith(".") || entry.name === "node_modules")
83
102
  continue;
84
103
  if (entry.isSymbolicLink())
@@ -93,35 +112,22 @@ export async function scanWorkspaceFiles(workspaceDir, limits = {}) {
93
112
  const rel = path.relative(workspaceDir, absPath).split(path.sep).join("/");
94
113
  if (!isSafeRelativePath(rel))
95
114
  continue;
96
- let sizeBytes;
115
+ let scanned;
97
116
  try {
98
- const handle = await open(absPath, "r");
99
- try {
100
- sizeBytes = (await handle.stat()).size;
101
- }
102
- finally {
103
- await handle.close();
104
- }
117
+ scanned = await scanStableFile(absPath, maxPreviewChars, maxFileBytes);
105
118
  }
106
119
  catch {
120
+ truncated = true;
107
121
  continue;
108
122
  }
109
- if (sizeBytes > maxFileBytes) {
123
+ const { sizeBytes } = scanned;
124
+ observedFileCount += 1;
125
+ observedTotalBytes += sizeBytes;
126
+ if (scanned.tooLarge) {
110
127
  truncated = true;
111
128
  continue;
112
129
  }
113
130
  if (files.length >= maxFiles) {
114
- truncated = true;
115
- stop = true;
116
- return;
117
- }
118
- let sha256;
119
- let previewText;
120
- try {
121
- sha256 = await sha256File(absPath);
122
- previewText = await readPreview(absPath, maxPreviewChars);
123
- }
124
- catch {
125
131
  truncated = true;
126
132
  continue;
127
133
  }
@@ -131,19 +137,66 @@ export async function scanWorkspaceFiles(workspaceDir, limits = {}) {
131
137
  path: rel,
132
138
  name: entry.name,
133
139
  size_bytes: sizeBytes,
134
- sha256,
135
- ...(previewText !== null && previewText !== "" ? { preview_text: previewText } : {}),
140
+ sha256: scanned.sha256,
141
+ ...(scanned.previewText !== null && scanned.previewText !== ""
142
+ ? { preview_text: scanned.previewText }
143
+ : {}),
136
144
  });
137
145
  }
138
146
  }
139
147
  await walk(workspaceDir, 0);
140
- return { files, truncated };
148
+ return {
149
+ files,
150
+ truncated,
151
+ measurement: {
152
+ fileCount: observedFileCount,
153
+ totalBytes: observedTotalBytes,
154
+ durationMs: Math.max(0, performance.now() - scanStartedAt),
155
+ truncated,
156
+ },
157
+ };
158
+ }
159
+ /** 捕获普通 Agent turn 开始前的 workspace 基线。 */
160
+ export async function captureWorkspaceFileBaseline(workspaceDir, limits = {}) {
161
+ const { files, truncated } = await scanWorkspaceFiles(workspaceDir, limits);
162
+ return {
163
+ files: new Map(files.map((file) => [
164
+ file.path,
165
+ { sizeBytes: file.size_bytes ?? 0, sha256: file.sha256 ?? "" },
166
+ ])),
167
+ truncated,
168
+ };
141
169
  }
142
170
  /**
143
171
  * 扫描并逐个上报文件候选。单文件上报失败只 warn 不中断;返回成功上报数。
144
172
  */
145
- export async function reportFileCandidates(client, agentRunId, workspaceDir, log, limits) {
146
- const { files, truncated } = await scanWorkspaceFiles(workspaceDir, limits);
173
+ export async function reportFileCandidates(client, agentRunId, workspaceDir, log, limits, baseline) {
174
+ const scan = await scanWorkspaceFiles(workspaceDir, limits);
175
+ const measurement = scan.measurement;
176
+ try {
177
+ await client.recordWorkspaceScanMeasurement?.(agentRunId, measurement);
178
+ }
179
+ catch (err) {
180
+ log.warn("workspace scan measurement report failed", {
181
+ agentRunId,
182
+ error: err instanceof Error ? err.message : String(err),
183
+ });
184
+ }
185
+ const files = baseline
186
+ ? scan.files.flatMap((file) => {
187
+ const previous = baseline.files.get(file.path);
188
+ if (previous &&
189
+ previous.sizeBytes === file.size_bytes &&
190
+ previous.sha256 === file.sha256) {
191
+ return [];
192
+ }
193
+ if (!previous && baseline.truncated) {
194
+ return [];
195
+ }
196
+ return [{ ...file, event: previous ? "modified" : "created" }];
197
+ })
198
+ : scan.files;
199
+ const truncated = scan.truncated || Boolean(baseline?.truncated);
147
200
  if (truncated) {
148
201
  log.warn("workspace file scan truncated", { agentRunId, reported: files.length });
149
202
  }
@@ -180,5 +233,5 @@ export async function reportFileCandidates(client, agentRunId, workspaceDir, log
180
233
  clearTimeout(timeout);
181
234
  }
182
235
  }
183
- return { reported, failed, truncated };
236
+ return { reported, failed, truncated, scan: measurement };
184
237
  }
package/dist/index.d.ts CHANGED
@@ -11,6 +11,12 @@ export * from "./agent-service-ws-protocol.js";
11
11
  export * from "./run-dispatcher.js";
12
12
  export * from "./run-queue.js";
13
13
  export * from "./workspace.js";
14
+ export * from "./workspace-entry-set.js";
15
+ export * from "./workspace-materialization.js";
16
+ export * from "./workspace-snapshot-staging.js";
17
+ export * from "./workspace-snapshot-policy.js";
18
+ export * from "./workspace-snapshot-control.js";
19
+ export * from "./workspace-restore.js";
14
20
  export * from "./transcript.js";
15
21
  export * from "./file-candidates.js";
16
22
  export * from "./doctor.js";
package/dist/index.js CHANGED
@@ -11,6 +11,12 @@ export * from "./agent-service-ws-protocol.js";
11
11
  export * from "./run-dispatcher.js";
12
12
  export * from "./run-queue.js";
13
13
  export * from "./workspace.js";
14
+ export * from "./workspace-entry-set.js";
15
+ export * from "./workspace-materialization.js";
16
+ export * from "./workspace-snapshot-staging.js";
17
+ export * from "./workspace-snapshot-policy.js";
18
+ export * from "./workspace-snapshot-control.js";
19
+ export * from "./workspace-restore.js";
14
20
  export * from "./transcript.js";
15
21
  export * from "./file-candidates.js";
16
22
  export * from "./doctor.js";
@@ -1,4 +1,4 @@
1
- import { type ScanLimits } from "./file-candidates.js";
1
+ import { type ScanLimits, type WorkspaceScanMeasurement } from "./file-candidates.js";
2
2
  import { type InputAttachmentGrant } from "./input-attachments.js";
3
3
  import { type Logger } from "./log.js";
4
4
  import type { PreparedRuntimeSkillProvider } from "./runtime-skills.js";
@@ -23,11 +23,12 @@ export interface PreparedPersistentTurn {
23
23
  export interface PersistentSessionExecution {
24
24
  prepareTurn(payload: RunStartPayload): PreparedPersistentTurn;
25
25
  persistNativeSession(sessionId: string): void;
26
- finishTurn(payload: RunStartPayload): void;
26
+ finishTurn(payload: RunStartPayload): Promise<void>;
27
27
  }
28
28
  export interface RunReportingClient {
29
29
  postEvent(agentRunId: string, event: RunEvent): Promise<void | RunEventReceipt>;
30
30
  postFile(agentRunId: string, file: import("./types.js").RunFileCandidate): Promise<unknown>;
31
+ recordWorkspaceScanMeasurement?(agentRunId: string, measurement: WorkspaceScanMeasurement): Promise<void> | void;
31
32
  getRunRuntimeProfile?(agentRunId: string): Promise<CourseRuntimeProfile>;
32
33
  }
33
34
  /**
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { CourseClientError, isRunTerminal } from "./course-client.js";
3
- import { reportFileCandidates } from "./file-candidates.js";
3
+ import { captureWorkspaceFileBaseline, reportFileCandidates, } from "./file-candidates.js";
4
4
  import { InputAttachmentMaterializationError, materializeInputAttachments, } from "./input-attachments.js";
5
5
  import { log as defaultLog } from "./log.js";
6
6
  import { MAX_PROGRESS_EVENTS_PER_ATTEMPT, tryNormalizeProgressReport, } from "./mcp/report-progress.js";
@@ -10,14 +10,17 @@ import { RunQueue } from "./run-queue.js";
10
10
  import { applyRunRuntimeProfile, cleanupRunRuntimeProfile, RuntimeProfileApplyError, runtimeProfileInstructions, } from "./runtime-profile.js";
11
11
  import { TranscriptWriter } from "./transcript.js";
12
12
  import { buildToolObservation } from "./tool-observation.js";
13
+ import { buildReasoningTracePayload, extractReasoningText, REASONING_TRACE_MAX_CHARS, } from "./trace-projection.js";
13
14
  import { RuntimeExecutionError, } from "./types.js";
14
15
  import { ensureRunWorkspace, transcriptPath } from "./workspace.js";
16
+ import { isWorkspaceQuotaExceededError } from "./workspace-filesystem.js";
15
17
  // 与 runtimes/index.ts 的 DEFAULT_RUNTIME_ID 保持一致(此处不 import registry,避免拉入全部 adapter)。
16
18
  const DEFAULT_RUNTIME_ID = "codex";
17
19
  const DEFAULT_TIMEOUT_SECONDS = 900;
18
20
  const MIN_TIMEOUT_SECONDS = 30;
19
21
  const MAX_TIMEOUT_SECONDS = 7200;
20
22
  const DEFAULT_MAX_OUTPUT_CHARS = 100_000;
23
+ const DEFAULT_MAX_TOOL_CALLS = 100;
21
24
  // wire 上单块文本上限;完整文本在本地 transcript。
22
25
  const BLOCK_TEXT_MAX_CHARS = 4000;
23
26
  const CONTENT_FLUSH_MAX_CHARS = 512;
@@ -458,6 +461,9 @@ export class RunDispatcher {
458
461
  if (missingCapabilities.length > 0) {
459
462
  throw new RuntimeExecutionError(`runtime is missing required capabilities: ${missingCapabilities.join(", ")}`, "runtime_unavailable");
460
463
  }
464
+ // 迁移复制进来的文件属于 CourseRun workspace 基线,不属于本轮 TaskRun 产物。
465
+ // 模型执行后只上报相对这份快照新增或修改的文件。
466
+ const workspaceFileBaseline = await captureWorkspaceFileBaseline(workspaceDir, this.scanLimits);
461
467
  const activeTranscript = new TranscriptWriter(persistentTurn?.transcriptFile ?? transcriptPath(runId));
462
468
  transcript = activeTranscript;
463
469
  const timeoutSeconds = clampTimeoutSeconds(payload.limits.timeout_seconds);
@@ -548,6 +554,52 @@ export class RunDispatcher {
548
554
  },
549
555
  });
550
556
  };
557
+ // Reasoning text accumulates into one durable block-level run.trace per segment;
558
+ // realtime reasoning_delta blocks stream through the transient channel in parallel.
559
+ let reasoningBuffer = "";
560
+ let reasoningTruncated = false;
561
+ const collectReasoning = async (block) => {
562
+ if (block.kind === "progress")
563
+ return;
564
+ const thinkingText = block.kind === "thinking" && typeof block.text === "string" ? block.text : "";
565
+ const reasoningText = thinkingText + extractReasoningText(block.raw);
566
+ if (!reasoningText)
567
+ return;
568
+ if (reasoningBuffer.length >= REASONING_TRACE_MAX_CHARS) {
569
+ reasoningTruncated = true;
570
+ }
571
+ else {
572
+ const room = REASONING_TRACE_MAX_CHARS - reasoningBuffer.length;
573
+ if (reasoningText.length > room) {
574
+ reasoningBuffer += reasoningText.slice(0, room);
575
+ reasoningTruncated = true;
576
+ }
577
+ else {
578
+ reasoningBuffer += reasoningText;
579
+ }
580
+ }
581
+ for (const text of chunkUnicodeText(redactSecretString(reasoningText), BLOCK_TEXT_MAX_CHARS)) {
582
+ if (!text)
583
+ continue;
584
+ await queueStreamEvent({
585
+ type: "run.block",
586
+ text,
587
+ payload: {
588
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
589
+ kind: "reasoning_delta",
590
+ runtime: runtime.id,
591
+ },
592
+ });
593
+ }
594
+ };
595
+ const flushReasoningTrace = async () => {
596
+ if (!reasoningBuffer)
597
+ return;
598
+ const payload = buildReasoningTracePayload(reasoningBuffer, runtime.id, reasoningTruncated);
599
+ reasoningBuffer = "";
600
+ reasoningTruncated = false;
601
+ await send({ type: "run.trace", payload });
602
+ };
551
603
  const sink = {
552
604
  skillEvent: async (event) => {
553
605
  await flushContent();
@@ -653,7 +705,7 @@ export class RunDispatcher {
653
705
  const configured = payload.limits.max_tool_calls;
654
706
  const maxToolCalls = typeof configured === "number" && Number.isFinite(configured)
655
707
  ? Math.max(1, Math.min(1000, Math.floor(configured)))
656
- : 30;
708
+ : DEFAULT_MAX_TOOL_CALLS;
657
709
  if (toolCalls > maxToolCalls) {
658
710
  toolLimitExceeded = true;
659
711
  controller.abort();
@@ -662,6 +714,7 @@ export class RunDispatcher {
662
714
  }
663
715
  if (serverTerminal)
664
716
  return;
717
+ await collectReasoning(block);
665
718
  if (block.kind === "text_delta") {
666
719
  if (!block.text)
667
720
  return;
@@ -677,7 +730,10 @@ export class RunDispatcher {
677
730
  }
678
731
  await flushContent();
679
732
  if (block.kind === "thinking") {
680
- await sendReasoningPhase(block.phase ?? "in_progress");
733
+ const phase = block.phase ?? "in_progress";
734
+ await sendReasoningPhase(phase);
735
+ if (phase === "completed")
736
+ await flushReasoningTrace();
681
737
  return;
682
738
  }
683
739
  if (block.kind === "status") {
@@ -698,6 +754,7 @@ export class RunDispatcher {
698
754
  if (block.kind === "tool_call") {
699
755
  lastReportedKind = block.kind;
700
756
  lastReasoningPhase = null;
757
+ await flushReasoningTrace();
701
758
  const observation = buildToolObservation(block, runtime.id, workspaceDir);
702
759
  if (observation) {
703
760
  await send({
@@ -806,6 +863,7 @@ export class RunDispatcher {
806
863
  : {}),
807
864
  }, sink, controller.signal);
808
865
  await flushContent();
866
+ await flushReasoningTrace();
809
867
  if (serverTerminal)
810
868
  return;
811
869
  if (controller.signal.aborted)
@@ -863,7 +921,7 @@ export class RunDispatcher {
863
921
  activeTranscript.writeFinal(acceptedOutput);
864
922
  fileReportStartedAt = this.now();
865
923
  try {
866
- await reportFileCandidates(this.client, runId, workspaceDir, this.log, this.scanLimits);
924
+ await reportFileCandidates(this.client, runId, workspaceDir, this.log, this.scanLimits, workspaceFileBaseline);
867
925
  }
868
926
  finally {
869
927
  fileReportFinishedAt = this.now();
@@ -894,13 +952,15 @@ export class RunDispatcher {
894
952
  }
895
953
  else {
896
954
  const info = errorInfo(err);
897
- const errorType = err instanceof RuntimeProfileApplyError
898
- ? err.code
899
- : err instanceof InputAttachmentMaterializationError
955
+ const errorType = isWorkspaceQuotaExceededError(err)
956
+ ? "workspace_user_quota_exceeded"
957
+ : err instanceof RuntimeProfileApplyError
900
958
  ? err.code
901
- : err instanceof RuntimeExecutionError
902
- ? err.errorType
903
- : "runtime_error";
959
+ : err instanceof InputAttachmentMaterializationError
960
+ ? err.code
961
+ : err instanceof RuntimeExecutionError
962
+ ? err.errorType
963
+ : "runtime_error";
904
964
  await sendFailure(errorType, info.error_message, err, err instanceof RuntimeProfileApplyError
905
965
  ? { code: err.code, profile_apply_status: "failed" }
906
966
  : {});
@@ -922,7 +982,7 @@ export class RunDispatcher {
922
982
  });
923
983
  }
924
984
  try {
925
- this.persistentSession?.finishTurn(payload);
985
+ await this.persistentSession?.finishTurn(payload);
926
986
  }
927
987
  finally {
928
988
  this.inflight.delete(runId);
@@ -42,11 +42,15 @@ export function clearAgentServiceControlEnv(env = process.env) {
42
42
  export function runtimeChildEnv(env = process.env) {
43
43
  const childEnv = { ...env };
44
44
  const runtimeHome = childEnv.BOTLEARN_RUNTIME_HOME;
45
+ const activationId = childEnv.BOTLEARN_AGENT_SERVICE_ACTIVATION_ID;
45
46
  clearAgentServiceControlEnv(childEnv);
46
47
  for (const key of AGENT_SERVICE_SUPERVISOR_ENV_KEYS)
47
48
  delete childEnv[key];
48
49
  if (runtimeHome)
49
50
  childEnv.HOME = runtimeHome;
51
+ if (activationId && /^[A-Za-z0-9_-]{1,120}$/u.test(activationId)) {
52
+ childEnv.BOTLEARN_RUNTIME_ACTIVATION_SCOPE = activationId;
53
+ }
50
54
  return childEnv;
51
55
  }
52
56
  /**
@@ -146,10 +150,6 @@ export function runtimeChildLaunch(binary, args, env = process.env, options = {}
146
150
  "-n",
147
151
  "-H",
148
152
  "-E",
149
- "-u",
150
- runtimeUser,
151
- "-g",
152
- runtimeGroup,
153
153
  "--",
154
154
  MANAGED_RUNTIME_LAUNCHER,
155
155
  binary,
@@ -0,0 +1,19 @@
1
+ export interface WorkspaceWriterFreezeProof {
2
+ readonly proofVersion: "workspace-writer-freeze-proof/1";
3
+ readonly sandboxId: string;
4
+ readonly sandboxGeneration: number;
5
+ readonly runtimeSessionId: string;
6
+ readonly checkpointId: string;
7
+ readonly allWritersFrozen: true;
8
+ }
9
+ export declare function freezeRuntimeWriters(): Promise<void>;
10
+ export declare function thawRuntimeWriters(): Promise<void>;
11
+ export declare function assertWorkspaceWriterFreezeSupported(): void;
12
+ export declare function quiesceRuntimeWriters(activationId: string | null): Promise<void>;
13
+ export declare function issueWorkspaceWriterFreezeProof(scope: {
14
+ sandboxId: string;
15
+ sandboxGeneration: number;
16
+ runtimeSessionId: string;
17
+ checkpointId: string;
18
+ }): WorkspaceWriterFreezeProof;
19
+ export declare function isWorkspaceWriterFreezeProof(value: object): value is WorkspaceWriterFreezeProof;