@nowcrew/daemon 0.6.88-beta.1 → 0.6.88-beta.3

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.
@@ -0,0 +1,83 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, realpath } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ const MAX_REPORT_BYTES = 32 * 1024 * 1024;
5
+ export function reportDeliveryContext(executionId, runDir, facts) {
6
+ return {
7
+ version: 1,
8
+ provider: facts.provider,
9
+ executionId,
10
+ receiptFile: join(runDir, `.qa-report-delivery-${createHash("sha256").update(executionId).digest("hex")}.json`),
11
+ ...(facts.ossUploadScript === undefined ? {} : { script: facts.ossUploadScript }),
12
+ };
13
+ }
14
+ function inside(root, file) {
15
+ const part = relative(root, file);
16
+ return part !== ".." && !part.startsWith("../") && !part.startsWith("..\\") && !isAbsolute(part);
17
+ }
18
+ export async function verifiedReportReceipt(input) {
19
+ if (!input.receiptFile || !input.answer)
20
+ return false;
21
+ try {
22
+ const stat = await lstat(input.receiptFile);
23
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)
24
+ return false;
25
+ const receipt = JSON.parse(await readFile(input.receiptFile, "utf8"));
26
+ if (!receipt || typeof receipt !== "object")
27
+ return false;
28
+ const r = receipt;
29
+ if (r.version !== 1 || r.executionId !== input.executionId || r.evidenceComplete !== true
30
+ || !Array.isArray(r.runIds) || r.runIds.length === 0 || r.runIds.length > 100
31
+ || r.runIds.some(id => typeof id !== "string" || !id || id.length > 256)
32
+ || typeof r.file !== "string" || !isAbsolute(r.file) || !r.file.endsWith(".html")
33
+ || typeof r.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(r.sha256)
34
+ || typeof r.url !== "string")
35
+ return false;
36
+ const url = new URL(r.url);
37
+ if (url.protocol !== "https:" || url.hostname !== "ai-claw-in.nowcoder.com"
38
+ || !url.pathname.endsWith(".html") || url.username || url.password || url.search || url.hash)
39
+ return false;
40
+ const answerUrls = input.answer.match(/https:\/\/ai-claw-in\.nowcoder\.com\/[^\s<>"')\]]+/g) ?? [];
41
+ if (!answerUrls.includes(url.href))
42
+ return false;
43
+ const file = await realpath(r.file);
44
+ const roots = await Promise.all([input.root, input.qaReportRoot ?? join(input.root, "qa-report-server-v2")]
45
+ .map(root => realpath(resolve(root)).catch(() => null)));
46
+ if (!roots.some(root => root !== null && inside(root, file)))
47
+ return false;
48
+ const fileStat = await lstat(file);
49
+ if (!fileStat.isFile() || fileStat.size > MAX_REPORT_BYTES)
50
+ return false;
51
+ const expectedHash = createHash("sha256").update(await readFile(file)).digest("hex");
52
+ if (expectedHash !== r.sha256)
53
+ return false;
54
+ const response = await fetch(url.href, { redirect: "error", signal: AbortSignal.timeout(15_000) });
55
+ if (!response.ok || !/^text\/html(?:;|$)/i.test(response.headers.get("content-type") ?? "")
56
+ || /attachment/i.test(response.headers.get("content-disposition") ?? "") || !response.body) {
57
+ await response.body?.cancel();
58
+ return false;
59
+ }
60
+ const reader = response.body.getReader();
61
+ const hash = createHash("sha256");
62
+ let bytes = 0;
63
+ try {
64
+ while (true) {
65
+ const chunk = await reader.read();
66
+ if (chunk.done)
67
+ break;
68
+ bytes += chunk.value.byteLength;
69
+ if (bytes > MAX_REPORT_BYTES)
70
+ return false;
71
+ hash.update(chunk.value);
72
+ }
73
+ return bytes === fileStat.size && hash.digest("hex") === expectedHash;
74
+ }
75
+ finally {
76
+ await reader.cancel().catch(() => undefined);
77
+ reader.releaseLock();
78
+ }
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
@@ -4,6 +4,7 @@ import { extname, join, relative, resolve, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { cloudReportUploadFacts } from "./cloud-report-upload.js";
6
6
  import { dslog } from "./slog.js";
7
+ import { verifiedReportReceipt } from "./cloud-report-receipt.js";
7
8
  const execFileP = promisify(execFile);
8
9
  const REPORT_FILENAMES = new Set(["report.md", "result.json"]);
9
10
  const MAX_DEPTH = 8;
@@ -166,6 +167,14 @@ export async function deliverCloudReports(input) {
166
167
  if (!input.facts.enabled || input.facts.ossUploadScript === undefined || input.before === null) {
167
168
  return { finalText: input.finalText, externalAnswer: input.externalAnswer };
168
169
  }
170
+ if (input.captureFinal && await verifiedReportReceipt({
171
+ ...input, answer: input.externalAnswer ?? input.finalText,
172
+ })) {
173
+ dslog("cloud_report_upload.already_delivered", "本次执行的 HTML 交付收据及内容已验证,跳过重复上传", {
174
+ execution_id: input.executionId, agent_handle: input.agentHandle,
175
+ });
176
+ return { finalText: input.finalText, externalAnswer: input.externalAnswer };
177
+ }
169
178
  try {
170
179
  const result = await (input.upload ?? uploadCloudReports)({
171
180
  script: input.facts.ossUploadScript,
@@ -12,6 +12,7 @@ import { spawnKimi, KIMI_EFFORT_LEVELS } from "./runtimes/kimi.js";
12
12
  import { applyDeepSeekHarnessMachineEnv, applyProviderEnv, providerFingerprint, } from "./provider-env.js";
13
13
  import { augmentedPath } from "./runtime-path.js";
14
14
  import { deliverCloudReports, prepareCloudReportExecution } from "./cloud-report-uploader.js";
15
+ import { reportDeliveryContext } from "./cloud-report-receipt.js";
15
16
  import { extractFinalText, extractRunMeta, normalizeEvent, parseLine, } from "./normalize.js";
16
17
  import { readSession, writeSession, pickResumeId, isNearBudget } from "./session.js";
17
18
  import { providerErrorFromExit } from "./provider-error.js";
@@ -34,7 +35,8 @@ import { diffMemoryPruneNotes, evaluateMemoryPrunePostcondition, inspectMemoryPr
34
35
  import { createLocalMemoryTelemetry, logLocalMemoryContextPrepareFailure, logLocalMemoryDiagnosticsFailure, } from "./local-memory-telemetry.js";
35
36
  import { CodexStartupStageParser } from "./codex-startup-stage.js";
36
37
  import { formatRuntimeStartupError } from "./runtime-startup-error.js";
37
- import { machinePackageManagerEnv, packageManagerEnvFacts } from "./package-manager-env.js";
38
+ import { machinePackageManagerEnv } from "./package-manager-env.js";
39
+ import { logRuntimeEnvironmentContract } from "./runtime-environment-telemetry.js";
38
40
  import { sanitizeEnvVars } from "./runtime-env.js";
39
41
  import { commitLocalMemoryResultAfterExit, initializeLocalMemoryV2ForWorkspace, isLocalMemoryV2Eligible, localMemoryResultPath as makeLocalMemoryResultPath, } from "./local-memory/runtime.js";
40
42
  import { prepareLocalMemoryPrompt } from "./local-memory/prompt.js";
@@ -260,6 +262,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
260
262
  };
261
263
  const cloudReportPreparation = await prepareCloudReportExecution({ env: inheritedEnv, ...reportRoots, executionId: input.executionId, agentHandle: input.handle });
262
264
  const { facts: cloudReportUpload, snapshot: reportSnapshot } = cloudReportPreparation;
265
+ const reportContext = reportDeliveryContext(input.executionId, workspace.runDir, cloudReportUpload);
263
266
  for (const key of Object.keys(inheritedEnv)) {
264
267
  if (key.startsWith("CREW_AGENT_MEMORY_") || ["CREW_CLOUD_PROVIDER", "CREW_OSS_UPLOAD_SCRIPT", "CREW_OSS_REPORT_UPLOAD"].includes(key))
265
268
  delete inheritedEnv[key];
@@ -485,6 +488,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
485
488
  // Expose only the training root; ability-specific workspace layout stays
486
489
  // owned by the training package rather than the NowWork daemon.
487
490
  QA_RUNTIME_ROOT: join(crewHome, "training"),
491
+ QA_REPORT_DELIVERY_CONTEXT: JSON.stringify(reportContext),
488
492
  CREW_TASK_DIR: executionWorkspace.runDir,
489
493
  CREW_TASK_LOG: executionWorkspace.workLogPath,
490
494
  ...(input.wakeMessageId ? { CREW_WAKE_MESSAGE_ID: input.wakeMessageId } : {}),
@@ -504,30 +508,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
504
508
  const childEnv = runtime.name === "deepseek-harness"
505
509
  ? applyDeepSeekHarnessMachineEnv(providerEnv, inheritedEnv)
506
510
  : providerEnv;
507
- dslog("runtime.environment_contract", "Runtime 子进程环境契约", {
508
- execution_id: input.executionId,
509
- agent_handle: input.handle,
510
- runtime: runtime.name,
511
- qa_runtime_root: childEnv.QA_RUNTIME_ROOT ?? null,
512
- qa_runtime_root_present: childEnv.QA_RUNTIME_ROOT !== undefined,
513
- inherited_qa_runtime_root_present: inheritedEnv.QA_RUNTIME_ROOT !== undefined,
514
- ...packageManagerEnvFacts(childEnv),
515
- training_root: join(crewHome, "training"),
516
- cloud_provider: cloudReportUpload.provider,
517
- oss_report_upload_enabled: cloudReportUpload.enabled,
518
- project_context_present: input.projectContext !== undefined,
519
- project_ids: JSON.stringify(input.projectContext === undefined
520
- ? []
521
- : [
522
- ...(input.projectContext.primary === undefined
523
- ? []
524
- : [input.projectContext.primary.projectId]),
525
- ...input.projectContext.secondary.map((project) => project.projectId),
526
- ]),
527
- ability_release_present: input.abilityRelease !== undefined,
528
- ability_release_id: input.abilityRelease?.releaseId,
529
- ability_root_commit: input.abilityRelease?.rootCommit,
530
- });
511
+ logRuntimeEnvironmentContract(input, childEnv, inheritedEnv, crewHome, cloudReportUpload);
531
512
  const warn = dependencies.warnRuntime ?? warnRuntimeLocally;
532
513
  const launchRuntime = dependencies.launchRuntime ?? (async (launchReq) => {
533
514
  if (launchReq.runtime === "codex" && launchReq.codexCollaborationTools === true) {
@@ -936,22 +917,27 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
936
917
  localMemoryTelemetry?.markRuntimeExited(exitCode);
937
918
  memoryPruneRuntimeExitCode = exitCode;
938
919
  if (localMemoryResultPath !== null) {
920
+ memoryPruneFailurePhase = "memory_commit";
939
921
  const committed = await commitLocalMemoryResultAfterExit({
940
922
  resultPath: localMemoryResultPath,
941
923
  agentDir: executionWorkspace.dir,
942
924
  executionId: input.executionId,
943
925
  pruneTraceId: memoryPruneTraceId,
944
926
  exitCode,
927
+ workLogPath: executionWorkspace.workLogPath,
945
928
  });
946
- if (committed.status === "failed") {
947
- dslog("local_memory.commit_failed", "结构化本地记忆候选未落地,保留结果文件", {
948
- level: "WARN",
929
+ if (committed.status === "failed" || committed.status === "missing") {
930
+ const reason = committed.error ?? "local_memory_result_missing";
931
+ dslog("local_memory.commit_failed", "结构化本地记忆提交或清理未完成,保留可用恢复材料", {
932
+ level: "ERROR",
949
933
  execution_id: input.executionId,
950
934
  agent_handle: input.handle,
951
- error_code: committed.error,
935
+ error_code: reason,
952
936
  result_path: localMemoryResultPath,
953
937
  });
938
+ throw new Error(`local_memory_commit_failed: ${reason}`);
954
939
  }
940
+ memoryPruneFailurePhase = "runtime_finalize";
955
941
  }
956
942
  const errorTail = [
957
943
  stderrTail.trim(),
@@ -991,6 +977,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
991
977
  const cloudReportResult = exitCode === 0
992
978
  ? await deliverCloudReports({
993
979
  facts: cloudReportUpload, ...reportRoots, before: reportSnapshot, finalText,
980
+ receiptFile: reportContext.receiptFile,
994
981
  externalAnswer: externalOutput.lastClosedBlock
995
982
  ?? (finalText === null ? null : extractExternalAnswer(finalText)),
996
983
  captureFinal: input.captureFinal, executionId: input.executionId, agentHandle: input.handle,
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { lstat, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, readFile, realpath, rm, writeFile } from "node:fs/promises";
4
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  import { applyLocalMemoryResult, parseLocalMemoryResult } from "./write.js";
5
6
  export function isLocalMemoryV2Eligible(input) {
6
7
  // The allowlist is the rollout boundary. The marker and seed/pre-existing
@@ -54,10 +55,21 @@ export function renderLocalMemoryResultInstructions(resultPath) {
54
55
  将一个合法 JSON 对象写入以下 daemon 提供的文件,写入完成后再结束本轮:
55
56
  ${resultPath}
56
57
 
57
- JSON 顶层必须包含:
58
+ outcome 只允许 no_change、proposed、conflict、failed。新增、更新、替代记忆或任何 statusChanges 都必须使用 proposed;no_change 的 changes/statusChanges 必须都为空。conflict/failed 表示未完成,不能当作提交成功。自然语言“已修正”不是 JSON 枚举值,不能写成 corrected。
59
+
60
+ 新增记忆的完整合法示例(内容须替换为本任务实际候选):
61
+ \`\`\`json
62
+ {"schemaVersion":1,"purpose":"memory_prune","baseCatalogRevision":0,"outcome":"proposed","changes":[{"operation":"create","memoryId":null,"baseRevision":null,"supersedesMemoryId":null,"title":"发布验收约定","kind":"procedural","summary":"发布前必须验证回滚步骤。","triggers":["发布","回滚"],"priority":"medium","detailCategory":"project-rules","detailBody":"发布前必须验证回滚步骤,并将验证结果写入发布报告。","reviewAfter":null}],"statusChanges":[]}
63
+ \`\`\`
64
+
65
+ 没有需要变更的记忆时:
66
+ \`\`\`json
58
67
  {"schemaVersion":1,"purpose":"memory_prune","baseCatalogRevision":0,"outcome":"no_change","changes":[],"statusChanges":[]}
59
- 每个 changes 项使用 operation=create|update|supersede、title、kind、summary、triggers、priority、detailCategory、detailBody、reviewAfter;create 的 memoryId/baseRevision/supersedesMemoryId 必须为 null,update/supersede 按现有索引填写版本和 id。
68
+ \`\`\`
69
+
70
+ 每个 changes 项须保留示例中的全部字段。operation=create|update|supersede;kind=procedural|semantic|episodic|preference;priority=high|medium|low;detailCategory=preferences|project-rules|testing|debugging|git|incidents|lessons。create 的 memoryId/baseRevision/supersedesMemoryId 必须为 null;update 按现有索引填写 memoryId/baseRevision,supersedesMemoryId 为 null;supersede 填 supersedesMemoryId/baseRevision,memoryId 为 null。
60
71
  只提交可复用、与本次任务明确相关的少量候选;不要写入凭证、token、隐私或内部控制标记。daemon 会校验、去重、限制 MEMORY.md 大小,超出索引容量的候选会保存在 pending 归档中;后续相关任务会按需召回,索引腾出空间后可自动提升。
72
+ 写入候选文件不等于已经记住,只有 daemon 校验并提交成功后记忆才生效。本结构化轮次覆盖前文提前清理工作日志的要求:保留 $CREW_TASK_LOG,不要清空或删除;daemon 将在提交成功后清理,失败时保留日志和候选用于排查或重试。
61
73
  `;
62
74
  }
63
75
  /** Prevent ordinary V2 turns from racing the daemon-managed memory writer. */
@@ -81,16 +93,52 @@ export async function commitLocalMemoryResultAfterExit(input) {
81
93
  }
82
94
  try {
83
95
  const result = parseLocalMemoryResult(raw);
96
+ if (result.outcome === "conflict" || result.outcome === "failed") {
97
+ return { status: "failed", error: `local_memory_result_${result.outcome}` };
98
+ }
84
99
  await applyLocalMemoryResult({
85
100
  agentDir: input.agentDir,
86
101
  result,
87
102
  executionId: input.executionId,
88
103
  pruneTraceId: input.pruneTraceId,
89
104
  });
90
- await rm(input.resultPath, { force: true });
91
- return { status: "applied" };
92
105
  }
93
106
  catch (error) {
94
107
  return { status: "failed", error: error instanceof Error ? error.message : "apply_failed" };
95
108
  }
109
+ if (input.workLogPath !== undefined) {
110
+ try {
111
+ const relativeLogPath = relative(resolve(input.agentDir), resolve(input.workLogPath));
112
+ if (relativeLogPath === "" || relativeLogPath === ".." || relativeLogPath.startsWith(`..${sep}`)
113
+ || isAbsolute(relativeLogPath))
114
+ throw new Error("local_memory_work_log_path_invalid");
115
+ const expectedLogPath = join(await realpath(input.agentDir), relativeLogPath);
116
+ const before = await lstat(input.workLogPath);
117
+ if (!before.isFile() || before.isSymbolicLink())
118
+ throw new Error("local_memory_work_log_path_invalid");
119
+ if (await realpath(input.workLogPath) !== expectedLogPath)
120
+ throw new Error("local_memory_work_log_path_invalid");
121
+ const handle = await open(input.workLogPath, constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
122
+ try {
123
+ const opened = await handle.stat();
124
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) {
125
+ throw new Error("local_memory_work_log_path_changed");
126
+ }
127
+ await handle.truncate(0);
128
+ }
129
+ finally {
130
+ await handle.close();
131
+ }
132
+ }
133
+ catch {
134
+ return { status: "failed", error: "local_memory_work_log_cleanup_failed" };
135
+ }
136
+ }
137
+ try {
138
+ await rm(input.resultPath, { force: true });
139
+ }
140
+ catch {
141
+ return { status: "failed", error: "local_memory_result_cleanup_failed" };
142
+ }
143
+ return { status: "applied" };
96
144
  }
@@ -0,0 +1,27 @@
1
+ import { join } from "node:path";
2
+ import { packageManagerEnvFacts } from "./package-manager-env.js";
3
+ import { dslog } from "./slog.js";
4
+ export function logRuntimeEnvironmentContract(input, childEnv, inheritedEnv, crewHome, cloudReportUpload) {
5
+ dslog("runtime.environment_contract", "Runtime 子进程环境契约", {
6
+ execution_id: input.executionId,
7
+ agent_handle: input.handle,
8
+ runtime: input.runtime.name,
9
+ qa_runtime_root: childEnv.QA_RUNTIME_ROOT ?? null,
10
+ qa_runtime_root_present: childEnv.QA_RUNTIME_ROOT !== undefined,
11
+ inherited_qa_runtime_root_present: inheritedEnv.QA_RUNTIME_ROOT !== undefined,
12
+ ...packageManagerEnvFacts(childEnv),
13
+ training_root: join(crewHome, "training"),
14
+ cloud_provider: cloudReportUpload.provider,
15
+ oss_report_upload_enabled: cloudReportUpload.enabled,
16
+ project_context_present: input.projectContext !== undefined,
17
+ project_ids: JSON.stringify(input.projectContext === undefined
18
+ ? []
19
+ : [
20
+ ...(input.projectContext.primary === undefined ? [] : [input.projectContext.primary.projectId]),
21
+ ...input.projectContext.secondary.map((project) => project.projectId),
22
+ ]),
23
+ ability_release_present: input.abilityRelease !== undefined,
24
+ ability_release_id: input.abilityRelease?.releaseId,
25
+ ability_root_commit: input.abilityRelease?.rootCommit,
26
+ });
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.88-beta.1",
3
+ "version": "0.6.88-beta.3",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",