@nowcrew/daemon 0.5.40 → 0.5.42

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
@@ -105,8 +105,10 @@ CREW_DAEMON_HOME="$HOME/.crew/daemon-dev" crew-daemon doctor --profile dev
105
105
  CREW_DAEMON_HOME="$HOME/.crew/daemon-dev" crew-daemon status --profile dev
106
106
  ```
107
107
 
108
- If the descriptor has drifted, the profile is stopped, or another host descriptor is not registered, the
109
- daemon fails closed and remains manually upgradeable.
108
+ If the target descriptor has drifted or the profile is stopped, the daemon fails closed and remains
109
+ manually upgradeable. On macOS, another unregistered legacy LaunchAgent may coexist only when its plist
110
+ exactly matches a generated NowCrew service and resolves to a different npm prefix. An unverifiable or
111
+ same-prefix descriptor still fails closed.
110
112
 
111
113
  The daemon advertises `daemon_update_v1` only when all of these conditions hold:
112
114
 
@@ -115,7 +117,7 @@ The daemon advertises `daemon_update_v1` only when all of these conditions hold:
115
117
  | Platform | macOS LaunchAgent or Linux systemd user service |
116
118
  | Entrypoint | global `@nowcrew/daemon/dist/main.js`, not npx or source/tsx |
117
119
  | Startup | `crew-daemon serve --profile <name>` through the installed service |
118
- | Registry | every other NowCrew descriptor on the OS user account is registered and conflict-free; the exact target legacy descriptor may be adopted once |
120
+ | Registry | registered descriptors remain conflict-free; the exact target legacy descriptor may be adopted once; an exact macOS legacy descriptor on another npm prefix may coexist |
119
121
  | Identity | service ID, descriptor, daemon home, Agent root, entrypoint, package root, and npm prefix match |
120
122
  | Service | the selected profile is running and holds the installation lease |
121
123
  | Install root | a standard writable Unix global npm prefix can be derived from the running entrypoint |
@@ -6,6 +6,7 @@ import { builtDaemonEntry } from "./computer-cli.js";
6
6
  import { buildServiceSpec, readServiceDescriptor, serviceStatus, } from "./computer-service.js";
7
7
  import { daemonGlobalInstallation } from "./daemon-installation.js";
8
8
  import { daemonInstallationLeaseHeld } from "./daemon-installation-lease.js";
9
+ import { inspectLegacyServiceInstallation } from "./legacy-service-installation.js";
9
10
  import { assertRegistryCoversDescriptors, listManagedServiceDescriptorPaths, managedServiceIdentityMatches, readManagedServiceRegistry, serviceDescriptorSha256, } from "./managed-service-registry.js";
10
11
  function defaults() {
11
12
  return {
@@ -22,8 +23,25 @@ function defaults() {
22
23
  serviceStatus,
23
24
  assertWritable: (path) => access(path, constants.W_OK),
24
25
  installationLeaseHeld: daemonInstallationLeaseHeld,
26
+ inspectLegacyDescriptorInstallation: (descriptorPath) => inspectLegacyServiceInstallation(descriptorPath, process.platform, homedir(), process.getuid?.()),
25
27
  };
26
28
  }
29
+ async function registryCoversUpdateScope(services, descriptorPaths, targetDescriptorPath, npmPrefix, inspectLegacyDescriptorInstallation) {
30
+ for (const descriptorPath of descriptorPaths) {
31
+ if (descriptorPath === targetDescriptorPath)
32
+ continue;
33
+ try {
34
+ assertRegistryCoversDescriptors(services, [descriptorPath]);
35
+ continue;
36
+ }
37
+ catch {
38
+ const legacy = await inspectLegacyDescriptorInstallation(descriptorPath).catch(() => null);
39
+ if (legacy === null || legacy.npmPrefix === npmPrefix)
40
+ return false;
41
+ }
42
+ }
43
+ return true;
44
+ }
27
45
  export async function detectDaemonUpdateEligibility(profileName, overrides = {}) {
28
46
  const deps = { ...defaults(), ...overrides };
29
47
  if (deps.platform !== "darwin" && deps.platform !== "linux") {
@@ -65,10 +83,7 @@ export async function detectDaemonUpdateEligibility(profileName, overrides = {})
65
83
  const registered = services.find((record) => record.serviceId === spec.id
66
84
  || (record.daemonHome === deps.profileHome && record.profile === profileName));
67
85
  if (registered !== undefined) {
68
- try {
69
- assertRegistryCoversDescriptors(services, await deps.listManagedDescriptorPaths());
70
- }
71
- catch {
86
+ if (!await registryCoversUpdateScope(services, await deps.listManagedDescriptorPaths(), spec.descriptorPath, installation.npmPrefix, deps.inspectLegacyDescriptorInstallation)) {
72
87
  return { eligible: false, reason: "managed_service_registry_incomplete" };
73
88
  }
74
89
  const descriptor = await deps.readServiceDescriptor(spec).catch(() => null);
@@ -109,12 +124,7 @@ export async function detectDaemonUpdateEligibility(profileName, overrides = {})
109
124
  if (legacyDescriptor !== legacySpec.descriptor) {
110
125
  return { eligible: false, reason: "managed_service_not_registered" };
111
126
  }
112
- try {
113
- const descriptorPaths = await deps.listManagedDescriptorPaths();
114
- const targetPath = legacySpec.descriptorPath;
115
- assertRegistryCoversDescriptors(services, descriptorPaths.filter((descriptorPath) => descriptorPath !== targetPath));
116
- }
117
- catch {
127
+ if (!await registryCoversUpdateScope(services, await deps.listManagedDescriptorPaths(), legacySpec.descriptorPath, installation.npmPrefix, deps.inspectLegacyDescriptorInstallation)) {
118
128
  return { eligible: false, reason: "managed_service_registry_incomplete" };
119
129
  }
120
130
  status = await deps.serviceStatus(legacySpec);
@@ -0,0 +1,56 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute } from "node:path";
4
+ import { z } from "zod";
5
+ import { buildServiceSpec, systemCommandRunner, } from "./computer-service.js";
6
+ import { daemonGlobalInstallation } from "./daemon-installation.js";
7
+ const LegacyLaunchAgentSchema = z.object({
8
+ Label: z.string().min(1),
9
+ ProgramArguments: z.tuple([
10
+ z.string().refine(isAbsolute),
11
+ z.string().refine(isAbsolute),
12
+ z.literal("serve"),
13
+ z.literal("--profile"),
14
+ z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/),
15
+ z.literal("--daemon-home"),
16
+ z.string().refine(isAbsolute),
17
+ ]),
18
+ KeepAlive: z.literal(true),
19
+ ProcessType: z.literal("Background"),
20
+ }).strict();
21
+ export async function inspectLegacyServiceInstallation(descriptorPath, platform, userHome = homedir(), uid = process.getuid?.(), runner = systemCommandRunner) {
22
+ if (platform !== "darwin" || uid === undefined)
23
+ return null;
24
+ const result = await runner("/usr/bin/plutil", ["-convert", "json", "-o", "-", descriptorPath]);
25
+ if (result.exitCode !== 0)
26
+ return null;
27
+ let decoded;
28
+ try {
29
+ decoded = JSON.parse(result.stdout);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ const parsed = LegacyLaunchAgentSchema.safeParse(decoded);
35
+ if (!parsed.success)
36
+ return null;
37
+ const [nodePath, entryPath, , , profile, , profileHome] = parsed.data.ProgramArguments;
38
+ const installation = daemonGlobalInstallation(entryPath, platform);
39
+ if (installation === null)
40
+ return null;
41
+ const spec = buildServiceSpec({
42
+ platform,
43
+ profile,
44
+ userHome,
45
+ uid,
46
+ nodePath,
47
+ entryPath,
48
+ profileHome,
49
+ legacyServiceId: true,
50
+ });
51
+ if (spec.id !== parsed.data.Label || spec.descriptorPath !== descriptorPath)
52
+ return null;
53
+ if (await readFile(descriptorPath, "utf8").catch(() => null) !== spec.descriptor)
54
+ return null;
55
+ return { npmPrefix: installation.npmPrefix };
56
+ }
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from "node:readline";
2
- import { rm, writeFile } from "node:fs/promises";
2
+ import { readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { delimiter, join } from "node:path";
4
- import { prepareWorkspace, rotateAgentSession, } from "./workspace.js";
4
+ import { prepareWorkspace, rotateAgentSession, safeKey, } from "./workspace.js";
5
5
  import { spawnClaude } from "./runtimes/claude.js";
6
6
  import { spawnCodex } from "./runtimes/codex.js";
7
7
  import { DEEPSEEK_CODEX_MODEL, DEEPSEEK_CODEX_REASONING_LEVELS, materializeDeepSeekCodexHome, } from "./runtimes/codex-deepseek-config.js";
@@ -18,7 +18,7 @@ import { routeRuntimeAttachments, runtimeCapability, } from "./runtime-capabilit
18
18
  import { awaitWithCancellation, RuntimeCancelledError, } from "./runtime-cancellation.js";
19
19
  import { isRuntimeReadyEvent, } from "./runtime-startup-gate.js";
20
20
  import { dslog } from "./slog.js";
21
- import { inspectMemoryPruneFiles, parseMemoryPruneTraceId, } from "./memory-prune-diagnostics.js";
21
+ import { evaluateMemoryPrunePostcondition, inspectMemoryPruneFilesWithinDeadline, parseMemoryPruneTraceId, } from "./memory-prune-diagnostics.js";
22
22
  function memoryPruneSnapshotFields(snapshot) {
23
23
  const fields = {};
24
24
  for (const [label, fact] of Object.entries(snapshot)) {
@@ -28,9 +28,25 @@ function memoryPruneSnapshotFields(snapshot) {
28
28
  fields[`${label}_sha256`] = fact.sha256;
29
29
  fields[`${label}_hash_skipped_reason`] = fact.hash_skipped_reason;
30
30
  fields[`${label}_error`] = fact.error;
31
+ fields[`${label}_markdown_heading_count`] = fact.markdown_heading_count;
32
+ fields[`${label}_lessons_reference_count`] = fact.lessons_reference_count;
33
+ fields[`${label}_same_as_shared_path`] = fact.same_as_shared_path;
31
34
  }
32
35
  return fields;
33
36
  }
37
+ function logMemoryPruneDiagnosticsFailure(input, traceId, phase, error) {
38
+ const errorCode = error?.code;
39
+ dslog("memory_prune.diagnostics_failed", "长期记忆收尾诊断失败", {
40
+ level: "WARN",
41
+ execution_id: input.executionId,
42
+ agent_handle: input.handle,
43
+ task_key: input.taskKey,
44
+ prune_trace_id: traceId,
45
+ diagnostics_phase: phase,
46
+ error_name: error instanceof Error ? error.name : "unknown",
47
+ error_code: errorCode,
48
+ });
49
+ }
34
50
  function truncateUtf8(value, maxBytes) {
35
51
  if (maxBytes <= 0)
36
52
  return "";
@@ -62,6 +78,7 @@ export function withLocalExecutionFacts(serverPrompt, maxBytes) {
62
78
  };
63
79
  }
64
80
  const STDERR_TAIL_CAP = 2_000;
81
+ const MEMORY_PRUNE_DIAGNOSTICS_TIMEOUT_MS = 2_000;
65
82
  const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
66
83
  const RESERVED_ENV = new Set([
67
84
  "PATH",
@@ -158,6 +175,7 @@ function resolvePrompt(prompt, context) {
158
175
  return typeof prompt === "string" ? prompt : prompt(context);
159
176
  }
160
177
  const nativeSessionLeaseTails = new Map();
178
+ const activeMemoryPrunes = new Map();
161
179
  async function withKeyedLease(key, operation, cancellation) {
162
180
  const predecessor = nativeSessionLeaseTails.get(key) ?? Promise.resolve();
163
181
  let release;
@@ -221,6 +239,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
221
239
  let memoryPruneRuntimeExitCode;
222
240
  let memoryPruneExecutorCompleted = false;
223
241
  let memoryPruneFailurePhase = "diagnostics_before";
242
+ let memoryPruneBeforeSnapshot = null;
243
+ let memoryPruneSharedWriteKey = null;
244
+ let executionWorkspace = workspace;
224
245
  try {
225
246
  if (isDeepSeekCodex && !providerConfig.providerApiKey) {
226
247
  throw new Error("DeepSeek API key is not configured for this Agent");
@@ -230,24 +251,62 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
230
251
  }
231
252
  const supportsNativeResume = runtime.name === "claude"
232
253
  || (dependencies.launchRuntime !== undefined && runtimeCapability(runtime.name).nativeResume);
233
- const prior = input.session.enabled && supportsNativeResume
254
+ const currentPrior = input.session.enabled && supportsNativeResume
234
255
  ? await readSession(workspace.sessionDir)
235
256
  : null;
236
- const resumeSessionId = supportsNativeResume && workspace.sessionResume
237
- ? pickResumeId(prior, Date.now(), input.session.warmMs, currentModel, input.session.budgetTokens, input.session.maxTurns, providerFp)
257
+ // Interactive V1 uses collision-resistant session directories, while the
258
+ // older legacy path stored metadata under tasks/<safe thread key>. Import
259
+ // that metadata once so an existing thread can cross the protocol boundary
260
+ // without losing its native runtime conversation.
261
+ const legacyRunDir = input.session.enabled
262
+ && supportsNativeResume
263
+ && input.keyMode === "opaque"
264
+ && input.resumeKey !== undefined
265
+ ? join(input.launch.agentsRoot, input.handle, "tasks", safeKey(input.resumeKey))
266
+ : null;
267
+ const legacyCwdMarker = legacyRunDir === null
268
+ ? null
269
+ : join(workspace.sessionDir, ".legacy-runtime-cwd");
270
+ const legacyCwdPinned = legacyCwdMarker !== null
271
+ && await readFile(legacyCwdMarker, "utf8").then((value) => value === "1").catch(() => false);
272
+ const legacyPrior = legacyRunDir === null || (currentPrior !== null && !legacyCwdPinned)
273
+ ? null
274
+ : await readSession(legacyRunDir);
275
+ const prior = currentPrior ?? legacyPrior;
276
+ const sessionExists = workspace.sessionResume || legacyPrior !== null;
277
+ const usesLegacyCwd = legacyRunDir !== null
278
+ && (legacyCwdPinned || (currentPrior === null && legacyPrior !== null));
279
+ executionWorkspace = !usesLegacyCwd || legacyRunDir === null
280
+ ? workspace
281
+ : {
282
+ ...workspace,
283
+ runDir: legacyRunDir,
284
+ workLogPath: join(legacyRunDir, "work-log.md"),
285
+ workLog: await readFile(join(legacyRunDir, "work-log.md"), "utf8").catch(() => ""),
286
+ };
287
+ // Claude Code accepts --resume and --model together. Compare against the
288
+ // prior model only when this turn has an explicit model, so the remaining
289
+ // safety checks can still decide whether the same session is reusable.
290
+ // An omitted model stays model-sensitive because it cannot reliably switch
291
+ // a resumed session back to the runtime default.
292
+ const resumeComparisonModel = runtime.name === "claude" && currentModel !== null
293
+ ? prior?.model ?? currentModel
294
+ : currentModel;
295
+ const resumeSessionId = supportsNativeResume && sessionExists
296
+ ? pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, input.session.budgetTokens, input.session.maxTurns, providerFp)
238
297
  : null;
239
298
  const resuming = resumeSessionId !== null;
240
- const rotatedForBudget = !resuming && supportsNativeResume && workspace.sessionResume
241
- && pickResumeId(prior, Date.now(), input.session.warmMs, currentModel, 0, 0, providerFp) !== null;
299
+ const rotatedForBudget = !resuming && supportsNativeResume && sessionExists
300
+ && pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, 0, 0, providerFp) !== null;
242
301
  const nearBudget = resuming && isNearBudget(prior, input.session.softTokens);
243
- const rotated = Boolean(workspace.agentSessionId && workspace.sessionResume && !resuming);
302
+ const rotated = Boolean(workspace.agentSessionId && sessionExists && !resuming);
244
303
  // Codex and Kimi choose their own id on a fresh start, so the native id persisted in
245
304
  // .crew-session.json can differ from the bootstrap id in .session. Resume the former.
246
305
  const launchSessionId = resumeSessionId ?? (rotated
247
306
  ? await rotateAgentSession(workspace.sessionDir)
248
307
  : workspace.agentSessionId);
249
308
  const promptContext = {
250
- workspace,
309
+ workspace: executionWorkspace,
251
310
  resuming,
252
311
  rotatedForBudget,
253
312
  nearBudget,
@@ -256,12 +315,12 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
256
315
  if (input.attachments && input.attachments.length > 0) {
257
316
  if (dependencies.cancellation?.isRequested())
258
317
  throw new RuntimeCancelledError();
259
- knownAttachmentDirectory = executionAttachmentDirectory(workspace.runDir, input.executionId);
318
+ knownAttachmentDirectory = executionAttachmentDirectory(executionWorkspace.runDir, input.executionId);
260
319
  const controller = new AbortController();
261
320
  const materialization = Promise.resolve().then(() => (dependencies.materializeAttachments ?? materializeAttachments)({
262
321
  serverUrl: input.launch.serverUrl,
263
322
  token: input.launch.token,
264
- runDir: workspace.runDir,
323
+ runDir: executionWorkspace.runDir,
265
324
  executionId: input.executionId,
266
325
  attachments: input.attachments,
267
326
  signal: controller.signal,
@@ -287,14 +346,36 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
287
346
  const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
288
347
  memoryPruneTraceId = parseMemoryPruneTraceId(wakePrompt);
289
348
  if (memoryPruneTraceId !== null) {
290
- const snapshot = await inspectMemoryPruneFiles(workspace.dir, workspace.workLogPath);
291
- dslog("memory_prune.files_before", "长期记忆收尾执行前文件指纹", {
349
+ memoryPruneSharedWriteKey = JSON.stringify([input.launch.agentsRoot, input.handle]);
350
+ const activePruneCount = (activeMemoryPrunes.get(memoryPruneSharedWriteKey) ?? 0) + 1;
351
+ activeMemoryPrunes.set(memoryPruneSharedWriteKey, activePruneCount);
352
+ dslog(activePruneCount > 1
353
+ ? "memory_prune.shared_write_overlap_detected"
354
+ : "memory_prune.shared_write_started", activePruneCount > 1
355
+ ? "同一 Agent 存在重叠的共享记忆收尾"
356
+ : "Agent 共享记忆收尾已开始", {
357
+ ...(activePruneCount > 1 ? { level: "WARN" } : {}),
292
358
  execution_id: input.executionId,
293
359
  agent_handle: input.handle,
294
360
  task_key: input.taskKey,
295
361
  prune_trace_id: memoryPruneTraceId,
296
- ...memoryPruneSnapshotFields(snapshot),
362
+ active_prune_count: activePruneCount,
297
363
  });
364
+ try {
365
+ const snapshot = await inspectMemoryPruneFilesWithinDeadline(executionWorkspace.dir, executionWorkspace.runDir, executionWorkspace.workLogPath, dependencies.memoryPruneDiagnosticsTimeoutMs
366
+ ?? MEMORY_PRUNE_DIAGNOSTICS_TIMEOUT_MS);
367
+ memoryPruneBeforeSnapshot = snapshot;
368
+ dslog("memory_prune.files_before", "长期记忆收尾执行前文件指纹", {
369
+ execution_id: input.executionId,
370
+ agent_handle: input.handle,
371
+ task_key: input.taskKey,
372
+ prune_trace_id: memoryPruneTraceId,
373
+ ...memoryPruneSnapshotFields(snapshot),
374
+ });
375
+ }
376
+ catch (error) {
377
+ logMemoryPruneDiagnosticsFailure(input, memoryPruneTraceId, "before", error);
378
+ }
298
379
  }
299
380
  memoryPruneFailurePhase = "prompt_write";
300
381
  await awaitWithCancellation(writeFile(workspace.systemPromptPath, systemPrompt, "utf8"), dependencies.cancellation);
@@ -313,7 +394,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
313
394
  CREW_TOKEN: input.launch.token,
314
395
  CREW_CHANNEL: input.channelId,
315
396
  CREW_HOME: workspace.dir,
316
- CREW_TASK_LOG: workspace.workLogPath,
397
+ CREW_TASK_LOG: executionWorkspace.workLogPath,
317
398
  ...(input.wakeMessageId ? { CREW_WAKE_MESSAGE_ID: input.wakeMessageId } : {}),
318
399
  XDG_CONFIG_HOME: join(workspace.homeDir, ".config"),
319
400
  XDG_DATA_HOME: join(workspace.homeDir, ".local", "share"),
@@ -355,7 +436,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
355
436
  const launchRequest = {
356
437
  runtime: runtime.name,
357
438
  bin: runtime.name,
358
- cwd: workspace.runDir,
439
+ cwd: executionWorkspace.runDir,
359
440
  ...(input.projectSkills === undefined ? {} : { agentRoot: workspace.dir }),
360
441
  systemPromptPath: workspace.systemPromptPath,
361
442
  systemPrompt,
@@ -516,10 +597,13 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
516
597
  lastExitOk: exitCode === 0,
517
598
  ...(contextTokens === undefined ? {} : { contextTokens }),
518
599
  });
600
+ if (exitCode === 0 && usesLegacyCwd && !legacyCwdPinned && legacyCwdMarker !== null) {
601
+ await writeFile(legacyCwdMarker, "1", "utf8");
602
+ }
519
603
  }
520
604
  memoryPruneExecutorCompleted = true;
521
605
  return {
522
- workspaceRunDir: workspace.runDir,
606
+ workspaceRunDir: executionWorkspace.runDir,
523
607
  exitCode,
524
608
  ...(terminationSignal === undefined ? {} : { terminationSignal }),
525
609
  activities,
@@ -541,24 +625,60 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
541
625
  finally {
542
626
  startupReservation?.release();
543
627
  if (memoryPruneTraceId !== null) {
544
- const snapshot = await inspectMemoryPruneFiles(workspace.dir, workspace.workLogPath);
545
- dslog("memory_prune.files_after", "长期记忆收尾执行后文件指纹", {
628
+ try {
629
+ const snapshot = await inspectMemoryPruneFilesWithinDeadline(executionWorkspace.dir, executionWorkspace.runDir, executionWorkspace.workLogPath, dependencies.memoryPruneDiagnosticsTimeoutMs
630
+ ?? MEMORY_PRUNE_DIAGNOSTICS_TIMEOUT_MS);
631
+ dslog("memory_prune.files_after", "长期记忆收尾执行后文件指纹", {
632
+ execution_id: input.executionId,
633
+ agent_handle: input.handle,
634
+ task_key: input.taskKey,
635
+ prune_trace_id: memoryPruneTraceId,
636
+ runtime_exit_code: memoryPruneRuntimeExitCode,
637
+ executor_outcome: memoryPruneExecutorCompleted ? "succeeded" : "failed",
638
+ ...memoryPruneSnapshotFields(snapshot),
639
+ });
640
+ if (memoryPruneBeforeSnapshot !== null) {
641
+ const postcondition = evaluateMemoryPrunePostcondition(memoryPruneBeforeSnapshot, snapshot, {
642
+ executorCompleted: memoryPruneExecutorCompleted,
643
+ ...(memoryPruneRuntimeExitCode === undefined
644
+ ? {}
645
+ : { runtimeExitCode: memoryPruneRuntimeExitCode }),
646
+ });
647
+ dslog("memory_prune.postcondition_evaluated", "长期记忆收尾后置条件已评估", {
648
+ execution_id: input.executionId,
649
+ agent_handle: input.handle,
650
+ task_key: input.taskKey,
651
+ prune_trace_id: memoryPruneTraceId,
652
+ postcondition_outcome: postcondition.outcome,
653
+ ...Object.fromEntries(Object.entries(postcondition).filter(([key]) => key !== "outcome")),
654
+ });
655
+ }
656
+ }
657
+ catch (error) {
658
+ logMemoryPruneDiagnosticsFailure(input, memoryPruneTraceId, "after", error);
659
+ }
660
+ dslog("memory_prune.execution_completed", "长期记忆收尾执行结束", {
546
661
  execution_id: input.executionId,
547
662
  agent_handle: input.handle,
548
663
  task_key: input.taskKey,
549
664
  prune_trace_id: memoryPruneTraceId,
550
- runtime_exit_code: memoryPruneRuntimeExitCode,
551
665
  executor_outcome: memoryPruneExecutorCompleted ? "succeeded" : "failed",
552
- ...memoryPruneSnapshotFields(snapshot),
666
+ runtime_exit_code: memoryPruneRuntimeExitCode,
667
+ ...(memoryPruneExecutorCompleted ? {} : { failure_phase: memoryPruneFailurePhase }),
553
668
  });
554
- dslog("memory_prune.execution_completed", "长期记忆收尾执行结束", {
669
+ }
670
+ if (memoryPruneSharedWriteKey !== null) {
671
+ const activePruneCount = Math.max(0, (activeMemoryPrunes.get(memoryPruneSharedWriteKey) ?? 1) - 1);
672
+ if (activePruneCount === 0)
673
+ activeMemoryPrunes.delete(memoryPruneSharedWriteKey);
674
+ else
675
+ activeMemoryPrunes.set(memoryPruneSharedWriteKey, activePruneCount);
676
+ dslog("memory_prune.shared_write_finished", "Agent 共享记忆收尾已结束", {
555
677
  execution_id: input.executionId,
556
678
  agent_handle: input.handle,
557
679
  task_key: input.taskKey,
558
680
  prune_trace_id: memoryPruneTraceId,
559
- executor_outcome: memoryPruneExecutorCompleted ? "succeeded" : "failed",
560
- runtime_exit_code: memoryPruneRuntimeExitCode,
561
- ...(memoryPruneExecutorCompleted ? {} : { failure_phase: memoryPruneFailurePhase }),
681
+ active_prune_count: activePruneCount,
562
682
  });
563
683
  }
564
684
  const attachmentDirectories = new Set([
@@ -2,23 +2,36 @@ import { createHash } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { stat } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
+ import { StringDecoder } from "node:string_decoder";
5
6
  const TRACE_PATTERN = /\[memory-prune trace_id=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/i;
6
7
  export const MEMORY_PRUNE_HASH_LIMIT_BYTES = 10 * 1024 * 1024;
7
8
  export function parseMemoryPruneTraceId(wakePrompt) {
8
9
  return TRACE_PATTERN.exec(wakePrompt)?.[1] ?? null;
9
10
  }
10
- async function inspectFile(path) {
11
+ async function inspectFile(path, markdownFacts = false, sameAsSharedPath, signal) {
11
12
  let metadata;
12
13
  try {
13
14
  metadata = await stat(path);
14
15
  }
15
16
  catch (error) {
16
17
  const code = error.code;
18
+ const pathFact = sameAsSharedPath === undefined
19
+ ? {}
20
+ : { same_as_shared_path: sameAsSharedPath };
17
21
  if (code === "ENOENT")
18
- return { exists: false };
19
- return { exists: false, error: code ?? (error instanceof Error ? error.name : "unknown") };
22
+ return { exists: false, ...pathFact };
23
+ return {
24
+ exists: false,
25
+ ...pathFact,
26
+ error: code ?? (error instanceof Error ? error.name : "unknown"),
27
+ };
20
28
  }
21
- const fact = { exists: true, size: metadata.size, mtime_ms: metadata.mtimeMs };
29
+ const fact = {
30
+ exists: true,
31
+ size: metadata.size,
32
+ mtime_ms: metadata.mtimeMs,
33
+ ...(sameAsSharedPath === undefined ? {} : { same_as_shared_path: sameAsSharedPath }),
34
+ };
22
35
  if (!metadata.isFile())
23
36
  return { ...fact, hash_skipped_reason: "not_regular_file" };
24
37
  if (metadata.size > MEMORY_PRUNE_HASH_LIMIT_BYTES) {
@@ -27,7 +40,29 @@ async function inspectFile(path) {
27
40
  try {
28
41
  const hash = createHash("sha256");
29
42
  let bytesRead = 0;
30
- const stream = createReadStream(path);
43
+ let markdownHeadingCount = 0;
44
+ let lessonsReferenceCount = 0;
45
+ let pendingText = "";
46
+ const decoder = new StringDecoder("utf8");
47
+ const inspectText = (text, final) => {
48
+ if (!markdownFacts)
49
+ return;
50
+ pendingText += text;
51
+ const lines = pendingText.split(/\r?\n/);
52
+ pendingText = final ? "" : (lines.pop() ?? "");
53
+ for (const line of lines) {
54
+ if (/^\s{0,3}#{1,6}(?:\s|$)/.test(line))
55
+ markdownHeadingCount += 1;
56
+ lessonsReferenceCount += line.match(/notes\/lessons\.md/gi)?.length ?? 0;
57
+ }
58
+ if (final && pendingText !== "") {
59
+ if (/^\s{0,3}#{1,6}(?:\s|$)/.test(pendingText))
60
+ markdownHeadingCount += 1;
61
+ lessonsReferenceCount += pendingText.match(/notes\/lessons\.md/gi)?.length ?? 0;
62
+ pendingText = "";
63
+ }
64
+ };
65
+ const stream = createReadStream(path, signal === undefined ? {} : { signal });
31
66
  try {
32
67
  for await (const chunk of stream) {
33
68
  bytesRead += chunk.length;
@@ -35,23 +70,116 @@ async function inspectFile(path) {
35
70
  return { ...fact, hash_skipped_reason: "grew_too_large" };
36
71
  }
37
72
  hash.update(chunk);
73
+ inspectText(decoder.write(chunk), false);
38
74
  }
75
+ inspectText(decoder.end(), true);
39
76
  }
40
77
  finally {
41
78
  stream.destroy();
42
79
  }
43
- return { ...fact, sha256: hash.digest("hex") };
80
+ return {
81
+ ...fact,
82
+ sha256: hash.digest("hex"),
83
+ ...(markdownFacts ? {
84
+ markdown_heading_count: markdownHeadingCount,
85
+ lessons_reference_count: lessonsReferenceCount,
86
+ } : {}),
87
+ };
44
88
  }
45
89
  catch (error) {
46
90
  const code = error.code;
47
91
  return { ...fact, error: code ?? (error instanceof Error ? error.name : "unknown") };
48
92
  }
49
93
  }
50
- export async function inspectMemoryPruneFiles(homeDir, workLogPath) {
51
- const [memory, lessons, workLog] = await Promise.all([
52
- inspectFile(join(homeDir, "MEMORY.md")),
53
- inspectFile(join(homeDir, "notes", "lessons.md")),
54
- inspectFile(workLogPath),
94
+ export async function inspectMemoryPruneFiles(homeDir, runDir, workLogPath, signal) {
95
+ const cwdIsHome = runDir === homeDir;
96
+ const [memory, lessons, cwdMemory, cwdLessons, workLog] = await Promise.all([
97
+ inspectFile(join(homeDir, "MEMORY.md"), true, undefined, signal),
98
+ inspectFile(join(homeDir, "notes", "lessons.md"), true, undefined, signal),
99
+ inspectFile(join(runDir, "MEMORY.md"), true, cwdIsHome, signal),
100
+ inspectFile(join(runDir, "notes", "lessons.md"), true, cwdIsHome, signal),
101
+ inspectFile(workLogPath, false, undefined, signal),
55
102
  ]);
56
- return { memory, lessons, work_log: workLog };
103
+ return {
104
+ memory,
105
+ lessons,
106
+ cwd_memory: cwdMemory,
107
+ cwd_lessons: cwdLessons,
108
+ work_log: workLog,
109
+ };
110
+ }
111
+ export async function inspectMemoryPruneFilesWithinDeadline(homeDir, runDir, workLogPath, timeoutMs) {
112
+ return withinMemoryPruneDiagnosticsDeadline((signal) => inspectMemoryPruneFiles(homeDir, runDir, workLogPath, signal), timeoutMs);
113
+ }
114
+ export async function withinMemoryPruneDiagnosticsDeadline(operation, timeoutMs) {
115
+ const controller = new AbortController();
116
+ let timer;
117
+ const deadline = new Promise((_resolve, reject) => {
118
+ timer = setTimeout(() => {
119
+ const error = Object.assign(new Error("Memory prune diagnostics timed out"), {
120
+ code: "diagnostics_timeout",
121
+ });
122
+ reject(error);
123
+ controller.abort(error);
124
+ }, timeoutMs);
125
+ timer.unref?.();
126
+ });
127
+ try {
128
+ return await Promise.race([
129
+ operation(controller.signal),
130
+ deadline,
131
+ ]);
132
+ }
133
+ finally {
134
+ if (timer !== undefined)
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+ function fileChanged(before, after) {
139
+ if (before.exists !== after.exists)
140
+ return true;
141
+ if (!before.exists && !after.exists)
142
+ return false;
143
+ if (before.sha256 !== undefined && after.sha256 !== undefined) {
144
+ return before.sha256 !== after.sha256;
145
+ }
146
+ return before.size !== after.size || before.mtime_ms !== after.mtime_ms;
147
+ }
148
+ export function evaluateMemoryPrunePostcondition(before, after, execution) {
149
+ const facts = {
150
+ work_log_before_nonempty: before.work_log.exists && (before.work_log.size ?? 0) > 0,
151
+ work_log_cleared: !after.work_log.exists || after.work_log.size === 0,
152
+ lessons_changed: fileChanged(before.lessons, after.lessons),
153
+ shared_memory_changed: fileChanged(before.memory, after.memory),
154
+ shared_memory_has_lessons_reference: (after.memory.lessons_reference_count ?? 0) > 0,
155
+ cwd_memory_changed: !after.cwd_memory.same_as_shared_path
156
+ && fileChanged(before.cwd_memory, after.cwd_memory),
157
+ cwd_memory_has_lessons_reference: !after.cwd_memory.same_as_shared_path
158
+ && (after.cwd_memory.lessons_reference_count ?? 0) > 0,
159
+ cwd_lessons_changed: !after.cwd_lessons.same_as_shared_path
160
+ && fileChanged(before.cwd_lessons, after.cwd_lessons),
161
+ };
162
+ let outcome;
163
+ if (!execution.executorCompleted)
164
+ outcome = "executor_failed";
165
+ else if (execution.runtimeExitCode !== 0)
166
+ outcome = "runtime_failed";
167
+ else if (!before.work_log.exists)
168
+ outcome = "source_work_log_missing";
169
+ else if (!facts.work_log_before_nonempty)
170
+ outcome = "source_work_log_empty";
171
+ else if (!facts.work_log_cleared)
172
+ outcome = "work_log_not_cleared";
173
+ else if (facts.lessons_changed && facts.shared_memory_has_lessons_reference) {
174
+ outcome = "promoted_and_indexed";
175
+ }
176
+ else if ((!facts.shared_memory_changed && facts.cwd_memory_changed)
177
+ || (!facts.lessons_changed && facts.cwd_lessons_changed)) {
178
+ outcome = "possible_wrong_memory_path";
179
+ }
180
+ else if (facts.lessons_changed)
181
+ outcome = "promoted_without_index_change";
182
+ else
183
+ outcome = "no_files_changed";
184
+ return { outcome, ...facts };
57
185
  }
package/dist/prompt.js CHANGED
@@ -18,7 +18,8 @@ const EXTERNAL_RESULT_READABILITY = `
18
18
  - 前三行依次说清结论、影响和需要谁做什么;不要用背景铺垫或复述任务开场。
19
19
  - 除非用户明确要求完整明细,正文最多三个短小节、最多六个要点;总结日志、命令输出和长表格,不要整段粘贴。
20
20
  - 有足够重点时,只加粗三到五处真正决定性的数字、最终状态、风险、截止时间、负责人或行动项;不足三处时宁缺毋滥。加粗范围要短,不要整段加粗,不要把每个数字都加粗,不得强化未经验证的判断。
21
- - 使用标题、列表、引用和加粗形成无颜色也清楚的层级;不得输出 \`<font>\` 或其它未确认可用于企微流式消息的 HTML 标色标签。`;
21
+ - 需要颜色层级时,只在确有对应语义的短加粗重点前使用一个彩色圆点:🟢 绿色圆点表示已验证成功或健康,🟠 橙色圆点表示风险、临期或需要关注,🔴 红色圆点表示失败、阻塞或严重异常,🔵 蓝色圆点表示负责人、行动项或关键中性数据。每个短加粗重点前最多放一个;不要给普通段落、标题或每个要点都加标记。
22
+ - 使用标题、列表、引用、彩色圆点和加粗形成在企微原生流式消息中稳定可见的层级;不得输出 \`<font>\` 或其它 HTML 标色标签。`;
22
23
  export function capWorkLogForInject(workLog, cap = WORKLOG_INJECT_CAP) {
23
24
  if (workLog.length <= cap)
24
25
  return workLog;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.5.40",
3
+ "version": "0.5.42",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",