@nowcrew/daemon 0.5.41 → 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 +5 -3
- package/dist/daemon-update-eligibility.js +20 -10
- package/dist/legacy-service-installation.js +56 -0
- package/dist/local-executor.js +90 -12
- package/dist/memory-prune-diagnostics.js +140 -12
- package/package.json +1 -1
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
|
|
109
|
-
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/local-executor.js
CHANGED
|
@@ -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 {
|
|
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,8 @@ 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;
|
|
224
244
|
let executionWorkspace = workspace;
|
|
225
245
|
try {
|
|
226
246
|
if (isDeepSeekCodex && !providerConfig.providerApiKey) {
|
|
@@ -326,14 +346,36 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
326
346
|
const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
|
|
327
347
|
memoryPruneTraceId = parseMemoryPruneTraceId(wakePrompt);
|
|
328
348
|
if (memoryPruneTraceId !== null) {
|
|
329
|
-
|
|
330
|
-
|
|
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" } : {}),
|
|
331
358
|
execution_id: input.executionId,
|
|
332
359
|
agent_handle: input.handle,
|
|
333
360
|
task_key: input.taskKey,
|
|
334
361
|
prune_trace_id: memoryPruneTraceId,
|
|
335
|
-
|
|
362
|
+
active_prune_count: activePruneCount,
|
|
336
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
|
+
}
|
|
337
379
|
}
|
|
338
380
|
memoryPruneFailurePhase = "prompt_write";
|
|
339
381
|
await awaitWithCancellation(writeFile(workspace.systemPromptPath, systemPrompt, "utf8"), dependencies.cancellation);
|
|
@@ -583,24 +625,60 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
583
625
|
finally {
|
|
584
626
|
startupReservation?.release();
|
|
585
627
|
if (memoryPruneTraceId !== null) {
|
|
586
|
-
|
|
587
|
-
|
|
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", "长期记忆收尾执行结束", {
|
|
588
661
|
execution_id: input.executionId,
|
|
589
662
|
agent_handle: input.handle,
|
|
590
663
|
task_key: input.taskKey,
|
|
591
664
|
prune_trace_id: memoryPruneTraceId,
|
|
592
|
-
runtime_exit_code: memoryPruneRuntimeExitCode,
|
|
593
665
|
executor_outcome: memoryPruneExecutorCompleted ? "succeeded" : "failed",
|
|
594
|
-
|
|
666
|
+
runtime_exit_code: memoryPruneRuntimeExitCode,
|
|
667
|
+
...(memoryPruneExecutorCompleted ? {} : { failure_phase: memoryPruneFailurePhase }),
|
|
595
668
|
});
|
|
596
|
-
|
|
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 共享记忆收尾已结束", {
|
|
597
677
|
execution_id: input.executionId,
|
|
598
678
|
agent_handle: input.handle,
|
|
599
679
|
task_key: input.taskKey,
|
|
600
680
|
prune_trace_id: memoryPruneTraceId,
|
|
601
|
-
|
|
602
|
-
runtime_exit_code: memoryPruneRuntimeExitCode,
|
|
603
|
-
...(memoryPruneExecutorCompleted ? {} : { failure_phase: memoryPruneFailurePhase }),
|
|
681
|
+
active_prune_count: activePruneCount,
|
|
604
682
|
});
|
|
605
683
|
}
|
|
606
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 {
|
|
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 = {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
52
|
-
|
|
53
|
-
inspectFile(join(homeDir, "
|
|
54
|
-
inspectFile(
|
|
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 {
|
|
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
|
}
|