@nowcrew/daemon 0.6.87-beta.2 → 0.6.87
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/dist/execution-runner.js +4 -2
- package/dist/external-output.js +154 -68
- package/dist/local-executor.js +31 -3
- package/dist/local-memory/pending.js +136 -2
- package/dist/local-memory/runtime.js +8 -1
- package/dist/local-memory/write.js +126 -4
- package/dist/main.js +6 -2
- package/dist/prompt.js +4 -1
- package/dist/runner.js +3 -1
- package/dist/serve.js +17 -11
- package/dist/storage-identity.js +4 -1
- package/dist/storage-ownership.js +93 -5
- package/dist/storage-runtime-controller.js +40 -20
- package/dist/storage-runtime.js +38 -2
- package/dist/storage-serve-adapter.js +22 -5
- package/package.json +1 -1
package/dist/execution-runner.js
CHANGED
|
@@ -25,6 +25,7 @@ import { PROJECT_WORKSPACES_CAPABILITY } from "./machine-info.js";
|
|
|
25
25
|
import { PROJECT_SKILL_PROJECTION_V2_CAPABILITY } from "./project-skills/types.js";
|
|
26
26
|
import { dslog } from "./slog.js";
|
|
27
27
|
import { identityRosterPromptFacts } from "./identity-roster-telemetry.js";
|
|
28
|
+
import { renderLocalMemoryV2WriteGuard } from "./local-memory/runtime.js";
|
|
28
29
|
export { supervisorLaunch } from "./supervised-runtime.js";
|
|
29
30
|
const MAX_LOCAL_EXECUTION_ATTEMPTS = 3;
|
|
30
31
|
const ACTIVITY_KIND = {
|
|
@@ -828,7 +829,8 @@ export async function runExecution(config, input, dependencies) {
|
|
|
828
829
|
const baseSystemPromptBudget = spec.agent.abilityRelease === undefined
|
|
829
830
|
? systemPromptBudget
|
|
830
831
|
: Math.max(0, Math.min(systemPromptBudget, Math.max(32 * 1024, systemPromptBudget - 168 * 1024)));
|
|
831
|
-
const
|
|
832
|
+
const localMemoryV2Enabled = config.localMemoryV2Handles?.has(spec.agent.handle) === true;
|
|
833
|
+
const systemPromptWithLocalFacts = withLocalExecutionFacts(`${spec.instructions.systemPrompt}${localMemoryV2Enabled ? renderLocalMemoryV2WriteGuard() : ""}`, baseSystemPromptBudget, {
|
|
832
834
|
...(selfTraining ? { isolateLocalMemory: true } : {}),
|
|
833
835
|
collaborationTools: config.codexCollaborationTools && spec.runtime.name === "codex",
|
|
834
836
|
});
|
|
@@ -887,7 +889,7 @@ export async function runExecution(config, input, dependencies) {
|
|
|
887
889
|
maxTurns: config.sessionMaxTurns,
|
|
888
890
|
},
|
|
889
891
|
...(selfTraining ? { maintenancePurpose: "self_training" } : {}),
|
|
890
|
-
localMemoryV2Enabled
|
|
892
|
+
localMemoryV2Enabled,
|
|
891
893
|
};
|
|
892
894
|
const result = await Promise.race([
|
|
893
895
|
execute(localInput, callbacks, localDependencies),
|
package/dist/external-output.js
CHANGED
|
@@ -1,15 +1,104 @@
|
|
|
1
1
|
export const EXTERNAL_ANSWER_OPEN = "<nowwork_external_answer>";
|
|
2
2
|
export const EXTERNAL_ANSWER_CLOSE = "</nowwork_external_answer>";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
const MAX_MARKER_TAG_LENGTH = 96;
|
|
4
|
+
const KNOWN_EXTERNAL_ANSWER_TAG = /^now+orkexternalanswer$/u;
|
|
5
|
+
const CANONICAL_EXTERNAL_ANSWER_TAG = "noworkexternalanswer";
|
|
6
|
+
function editDistanceWithin(value, target, maximum) {
|
|
7
|
+
if (Math.abs(value.length - target.length) > maximum)
|
|
8
|
+
return false;
|
|
9
|
+
let previous = Array.from({ length: target.length + 1 }, (_, index) => index);
|
|
10
|
+
for (let row = 1; row <= value.length; row += 1) {
|
|
11
|
+
const current = [row];
|
|
12
|
+
let rowMinimum = row;
|
|
13
|
+
for (let column = 1; column <= target.length; column += 1) {
|
|
14
|
+
const cost = value[row - 1] === target[column - 1] ? 0 : 1;
|
|
15
|
+
const distance = Math.min(previous[column] + 1, current[column - 1] + 1, previous[column - 1] + cost);
|
|
16
|
+
current.push(distance);
|
|
17
|
+
rowMinimum = Math.min(rowMinimum, distance);
|
|
18
|
+
}
|
|
19
|
+
if (rowMinimum > maximum)
|
|
20
|
+
return false;
|
|
21
|
+
previous = current;
|
|
22
|
+
}
|
|
23
|
+
return previous[target.length] <= maximum;
|
|
24
|
+
}
|
|
25
|
+
function resemblesExternalAnswerTag(name) {
|
|
26
|
+
if (name.includes("externalanswer"))
|
|
27
|
+
return true;
|
|
28
|
+
const canonicalized = name.replace(/^now+ork/u, "nowork");
|
|
29
|
+
return editDistanceWithin(canonicalized, CANONICAL_EXTERNAL_ANSWER_TAG, 2);
|
|
30
|
+
}
|
|
31
|
+
function classifyMarkerTag(body) {
|
|
32
|
+
const trimmed = body.trim();
|
|
33
|
+
const closing = trimmed.startsWith("/");
|
|
34
|
+
const name = (closing ? trimmed.slice(1) : trimmed)
|
|
35
|
+
.trim()
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[_\-\s]/gu, "");
|
|
38
|
+
if (KNOWN_EXTERNAL_ANSWER_TAG.test(name))
|
|
39
|
+
return { closing, kind: "known" };
|
|
40
|
+
return {
|
|
41
|
+
closing,
|
|
42
|
+
kind: resemblesExternalAnswerTag(name) ? "suspicious" : "other",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function nextMarkerTag(value, from) {
|
|
46
|
+
let cursor = from;
|
|
47
|
+
while (cursor < value.length) {
|
|
48
|
+
const start = value.indexOf("<", cursor);
|
|
49
|
+
if (start < 0)
|
|
50
|
+
return null;
|
|
51
|
+
const end = value.indexOf(">", start + 1);
|
|
52
|
+
if (end < 0)
|
|
53
|
+
return null;
|
|
54
|
+
const body = value.slice(start + 1, end);
|
|
55
|
+
const classified = classifyMarkerTag(body);
|
|
56
|
+
if (classified.kind === "suspicious"
|
|
57
|
+
|| (classified.kind === "known" && body.length > MAX_MARKER_TAG_LENGTH)) {
|
|
58
|
+
return { start, end: end + 1, closing: classified.closing, kind: "suspicious" };
|
|
59
|
+
}
|
|
60
|
+
if (classified.kind === "known") {
|
|
61
|
+
return { start, end: end + 1, closing: classified.closing, kind: "known" };
|
|
62
|
+
}
|
|
63
|
+
cursor = end + 1;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function parseExternalAnswerText(value) {
|
|
68
|
+
const sections = [];
|
|
69
|
+
let cursor = 0;
|
|
70
|
+
let contentStart = 0;
|
|
71
|
+
let inside = false;
|
|
72
|
+
let found = false;
|
|
73
|
+
for (;;) {
|
|
74
|
+
const tag = nextMarkerTag(value, cursor);
|
|
75
|
+
if (!tag)
|
|
76
|
+
break;
|
|
77
|
+
if (tag.kind === "suspicious")
|
|
78
|
+
return { kind: "rejected", text: "" };
|
|
79
|
+
if (tag.closing) {
|
|
80
|
+
if (!inside)
|
|
81
|
+
return { kind: "rejected", text: "" };
|
|
82
|
+
sections.push(value.slice(contentStart, tag.start));
|
|
83
|
+
inside = false;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
if (inside)
|
|
87
|
+
return { kind: "rejected", text: "" };
|
|
88
|
+
found = true;
|
|
89
|
+
inside = true;
|
|
90
|
+
contentStart = tag.end;
|
|
91
|
+
}
|
|
92
|
+
cursor = tag.end;
|
|
8
93
|
}
|
|
9
|
-
|
|
94
|
+
if (inside)
|
|
95
|
+
sections.push(value.slice(contentStart));
|
|
96
|
+
if (!found)
|
|
97
|
+
return { kind: "plain", text: value.trim() };
|
|
98
|
+
return { kind: "marked", text: sections.join("").trim() };
|
|
10
99
|
}
|
|
11
100
|
/**
|
|
12
|
-
* 跨 chunk
|
|
101
|
+
* 跨 chunk 的契约标签解码器:push() 在合法闭合后返回完整标签块(供 answerStream 转发);
|
|
13
102
|
* 同时记住整轮里**最后一个完整闭合**的标签块(`lastClosedBlock`),供完结时作为契约答案——
|
|
14
103
|
* 只有闭合了的块才算数:半截块(被工具调用拆开的前半段)与叙述里提到的开标签
|
|
15
104
|
* ("我会把结论包在 <marker> 里,先去跑测试")都不会被当成答案(review of a027bf15, C1)。
|
|
@@ -40,34 +129,63 @@ export class ExternalAnswerDecoder {
|
|
|
40
129
|
}
|
|
41
130
|
consume(value, output) {
|
|
42
131
|
this.pending = "";
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
this.
|
|
53
|
-
|
|
54
|
-
|
|
132
|
+
const appendInside = (text) => {
|
|
133
|
+
if (!text)
|
|
134
|
+
return;
|
|
135
|
+
this.block += text;
|
|
136
|
+
};
|
|
137
|
+
for (const character of value) {
|
|
138
|
+
if (this.pending) {
|
|
139
|
+
this.pending += character;
|
|
140
|
+
if (character === ">") {
|
|
141
|
+
const body = this.pending.slice(1, -1);
|
|
142
|
+
const tag = classifyMarkerTag(body);
|
|
143
|
+
if (tag.kind === "known" && body.length <= MAX_MARKER_TAG_LENGTH) {
|
|
144
|
+
if (tag.closing && this.state === "inside") {
|
|
145
|
+
const completed = this.block.trim();
|
|
146
|
+
if (completed) {
|
|
147
|
+
this.closed = completed;
|
|
148
|
+
output.push(completed);
|
|
149
|
+
}
|
|
150
|
+
this.block = "";
|
|
151
|
+
this.state = "outside";
|
|
152
|
+
}
|
|
153
|
+
else if (!tag.closing && this.state === "outside") {
|
|
154
|
+
this.block = "";
|
|
155
|
+
this.state = "inside";
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
this.block = "";
|
|
159
|
+
this.state = tag.closing ? "outside" : "inside";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else if (tag.kind === "suspicious" || tag.kind === "known") {
|
|
163
|
+
this.block = "";
|
|
164
|
+
this.state = "outside";
|
|
165
|
+
}
|
|
166
|
+
else if (this.state === "inside") {
|
|
167
|
+
appendInside(this.pending);
|
|
168
|
+
}
|
|
169
|
+
this.pending = "";
|
|
170
|
+
}
|
|
171
|
+
else if (this.pending.length > MAX_MARKER_TAG_LENGTH + 2) {
|
|
172
|
+
const tag = classifyMarkerTag(this.pending.slice(1));
|
|
173
|
+
if (tag.kind === "suspicious" || tag.kind === "known") {
|
|
174
|
+
this.block = "";
|
|
175
|
+
this.state = "outside";
|
|
176
|
+
}
|
|
177
|
+
else if (this.state === "inside") {
|
|
178
|
+
appendInside(this.pending);
|
|
179
|
+
}
|
|
180
|
+
this.pending = "";
|
|
181
|
+
}
|
|
55
182
|
}
|
|
56
|
-
else {
|
|
57
|
-
this.
|
|
58
|
-
|
|
183
|
+
else if (character === "<") {
|
|
184
|
+
this.pending = character;
|
|
185
|
+
}
|
|
186
|
+
else if (this.state === "inside") {
|
|
187
|
+
appendInside(character);
|
|
59
188
|
}
|
|
60
|
-
const remaining = value.slice(markerAt + marker.length);
|
|
61
|
-
if (remaining)
|
|
62
|
-
this.consume(remaining, output);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
const retained = retainedMarkerPrefix(value, marker);
|
|
66
|
-
const safe = retained > 0 ? value.slice(0, -retained) : value;
|
|
67
|
-
this.pending = retained > 0 ? value.slice(-retained) : "";
|
|
68
|
-
if (this.state === "inside" && safe) {
|
|
69
|
-
output.push(safe);
|
|
70
|
-
this.block += safe;
|
|
71
189
|
}
|
|
72
190
|
}
|
|
73
191
|
}
|
|
@@ -104,20 +222,7 @@ export function decodeExternalOutputEvent(runtime, event, decoder) {
|
|
|
104
222
|
: [];
|
|
105
223
|
}
|
|
106
224
|
export function stripExternalAnswerMarkers(value) {
|
|
107
|
-
|
|
108
|
-
let cursor = 0;
|
|
109
|
-
while (cursor < value.length) {
|
|
110
|
-
const openAt = value.indexOf(EXTERNAL_ANSWER_OPEN, cursor);
|
|
111
|
-
if (openAt < 0)
|
|
112
|
-
break;
|
|
113
|
-
const contentAt = openAt + EXTERNAL_ANSWER_OPEN.length;
|
|
114
|
-
const closeAt = value.indexOf(EXTERNAL_ANSWER_CLOSE, contentAt);
|
|
115
|
-
if (closeAt < 0)
|
|
116
|
-
return value.slice(contentAt).trim();
|
|
117
|
-
sections.push(value.slice(contentAt, closeAt));
|
|
118
|
-
cursor = closeAt + EXTERNAL_ANSWER_CLOSE.length;
|
|
119
|
-
}
|
|
120
|
-
return (sections.length > 0 ? sections.join("") : value).trim();
|
|
225
|
+
return parseExternalAnswerText(value).text;
|
|
121
226
|
}
|
|
122
227
|
/**
|
|
123
228
|
* 提取 marker 内容:有 marker 返回拼接内容(trim,空→null),无 marker 返回 null。
|
|
@@ -125,25 +230,6 @@ export function stripExternalAnswerMarkers(value) {
|
|
|
125
230
|
* 本函数用于判定"agent 是否给出了频道直接回复"——必须能区分有无 marker。
|
|
126
231
|
*/
|
|
127
232
|
export function extractExternalAnswer(value) {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
let rest = value;
|
|
131
|
-
for (;;) {
|
|
132
|
-
const open = rest.indexOf(EXTERNAL_ANSWER_OPEN);
|
|
133
|
-
if (open < 0)
|
|
134
|
-
break;
|
|
135
|
-
found = true;
|
|
136
|
-
const afterOpen = rest.slice(open + EXTERNAL_ANSWER_OPEN.length);
|
|
137
|
-
const close = afterOpen.indexOf(EXTERNAL_ANSWER_CLOSE);
|
|
138
|
-
if (close < 0) {
|
|
139
|
-
parts.push(afterOpen);
|
|
140
|
-
break;
|
|
141
|
-
}
|
|
142
|
-
parts.push(afterOpen.slice(0, close));
|
|
143
|
-
rest = afterOpen.slice(close + EXTERNAL_ANSWER_CLOSE.length);
|
|
144
|
-
}
|
|
145
|
-
if (!found)
|
|
146
|
-
return null;
|
|
147
|
-
const answer = parts.join("").trim();
|
|
148
|
-
return answer.length > 0 ? answer : null;
|
|
233
|
+
const parsed = parseExternalAnswerText(value);
|
|
234
|
+
return parsed.kind === "marked" && parsed.text ? parsed.text : null;
|
|
149
235
|
}
|
package/dist/local-executor.js
CHANGED
|
@@ -37,6 +37,7 @@ import { formatRuntimeStartupError } from "./runtime-startup-error.js";
|
|
|
37
37
|
import { machinePackageManagerEnv, packageManagerEnvFacts } from "./package-manager-env.js";
|
|
38
38
|
import { sanitizeEnvVars } from "./runtime-env.js";
|
|
39
39
|
import { commitLocalMemoryResultAfterExit, initializeLocalMemoryV2ForWorkspace, isLocalMemoryV2Eligible, localMemoryResultPath as makeLocalMemoryResultPath, renderLocalMemoryResultInstructions, } from "./local-memory/runtime.js";
|
|
40
|
+
import { renderPendingMemoryContext, searchPendingMemoryCandidates, } from "./local-memory/pending.js";
|
|
40
41
|
export class RuntimeRunningCallbackTimeoutError extends Error {
|
|
41
42
|
constructor(runtime, timeoutMs) {
|
|
42
43
|
super(`${runtime} runtime-running callback timed out after ${timeoutMs}ms`);
|
|
@@ -381,6 +382,26 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
381
382
|
}
|
|
382
383
|
const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
|
|
383
384
|
const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
|
|
385
|
+
let pendingMemoryContext = "";
|
|
386
|
+
if (input.localMemoryV2Enabled === true) {
|
|
387
|
+
try {
|
|
388
|
+
const pendingCandidates = await searchPendingMemoryCandidates({
|
|
389
|
+
agentDir: executionWorkspace.dir,
|
|
390
|
+
query: wakePrompt,
|
|
391
|
+
limit: 5,
|
|
392
|
+
});
|
|
393
|
+
pendingMemoryContext = renderPendingMemoryContext(pendingCandidates);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
dslog("local_memory.pending_recall_failed", "本地归档记忆召回失败", {
|
|
397
|
+
level: "WARN",
|
|
398
|
+
execution_id: input.executionId,
|
|
399
|
+
agent_handle: input.handle,
|
|
400
|
+
error_name: error instanceof Error ? error.name : "unknown",
|
|
401
|
+
error_code: error?.code,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
384
405
|
memoryPruneTraceId = parseMemoryPruneTraceId(wakePrompt);
|
|
385
406
|
if (input.localMemoryV2Enabled === true && memoryPruneTraceId !== null
|
|
386
407
|
&& isLocalMemoryV2Eligible({
|
|
@@ -392,14 +413,21 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
392
413
|
})) {
|
|
393
414
|
localMemoryResultPath = makeLocalMemoryResultPath(executionWorkspace.runDir, input.executionId);
|
|
394
415
|
}
|
|
395
|
-
const
|
|
416
|
+
const promptWithoutPending = resolvePrompt(input.systemPrompt, promptContext);
|
|
417
|
+
let baseSystemPrompt = `${promptWithoutPending}${pendingMemoryContext}`;
|
|
396
418
|
const structuredMemoryInstructions = localMemoryResultPath === null
|
|
397
419
|
? null
|
|
398
420
|
: renderLocalMemoryResultInstructions(localMemoryResultPath);
|
|
399
|
-
const
|
|
421
|
+
const fitsPromptBudget = (prompt) => structuredMemoryInstructions === null
|
|
400
422
|
|| input.maxSystemPromptBytes === undefined
|
|
401
|
-
|| Buffer.byteLength(
|
|
423
|
+
|| Buffer.byteLength(prompt, "utf8") + Buffer.byteLength(structuredMemoryInstructions, "utf8")
|
|
402
424
|
<= input.maxSystemPromptBytes;
|
|
425
|
+
// The structured prune contract is mandatory for V2. Drop optional archived
|
|
426
|
+
// recall before dropping the daemon result file when the prompt budget is tight.
|
|
427
|
+
if (!fitsPromptBudget(baseSystemPrompt) && pendingMemoryContext !== "") {
|
|
428
|
+
baseSystemPrompt = promptWithoutPending;
|
|
429
|
+
}
|
|
430
|
+
const structuredPromptFits = fitsPromptBudget(baseSystemPrompt);
|
|
403
431
|
if (!structuredPromptFits)
|
|
404
432
|
localMemoryResultPath = null;
|
|
405
433
|
const systemPrompt = localMemoryResultPath === null
|
|
@@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { lstat, mkdir, readdir, realpath, rename, rm, writeFile, readFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
export const LOCAL_MEMORY_PENDING_SCAN_FILE_CAP = 128;
|
|
6
|
+
export const LOCAL_MEMORY_PENDING_SCAN_BYTE_CAP = 1_000_000;
|
|
5
7
|
const CandidateSchema = z.object({
|
|
6
8
|
operation: z.enum(["create", "update", "supersede"]),
|
|
7
9
|
memoryId: z.string().nullable(),
|
|
@@ -59,6 +61,44 @@ async function pendingDirectory(agentDir) {
|
|
|
59
61
|
}
|
|
60
62
|
return directory;
|
|
61
63
|
}
|
|
64
|
+
async function existingPendingDirectory(agentDir) {
|
|
65
|
+
const agentRoot = await realpath(agentDir);
|
|
66
|
+
const memoryRoot = resolve(agentRoot, ".crew", "memory");
|
|
67
|
+
let memoryStat;
|
|
68
|
+
try {
|
|
69
|
+
memoryStat = await lstat(memoryRoot);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error.code === "ENOENT")
|
|
73
|
+
return null;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
if (!memoryStat.isDirectory() || memoryStat.isSymbolicLink()) {
|
|
77
|
+
throw new Error("local_memory_pending_path_invalid");
|
|
78
|
+
}
|
|
79
|
+
const resolvedMemoryRoot = await realpath(memoryRoot);
|
|
80
|
+
if (resolvedMemoryRoot !== memoryRoot || !within(agentRoot, resolvedMemoryRoot)) {
|
|
81
|
+
throw new Error("local_memory_pending_path_invalid");
|
|
82
|
+
}
|
|
83
|
+
const directory = join(memoryRoot, "pending");
|
|
84
|
+
let stat;
|
|
85
|
+
try {
|
|
86
|
+
stat = await lstat(directory);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code === "ENOENT")
|
|
90
|
+
return null;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
94
|
+
throw new Error("local_memory_pending_path_invalid");
|
|
95
|
+
}
|
|
96
|
+
const resolvedDirectory = await realpath(directory);
|
|
97
|
+
if (resolvedDirectory !== directory || !within(agentRoot, resolvedDirectory)) {
|
|
98
|
+
throw new Error("local_memory_pending_path_invalid");
|
|
99
|
+
}
|
|
100
|
+
return directory;
|
|
101
|
+
}
|
|
62
102
|
async function assertRegularFile(path) {
|
|
63
103
|
try {
|
|
64
104
|
const stat = await lstat(path);
|
|
@@ -91,15 +131,40 @@ export async function persistPendingMemoryCandidate(input) {
|
|
|
91
131
|
await atomicWrite(target, `${JSON.stringify(parsed.data, null, 2)}\n`);
|
|
92
132
|
return target;
|
|
93
133
|
}
|
|
134
|
+
export async function removePendingMemoryCandidate(input) {
|
|
135
|
+
const directory = await existingPendingDirectory(input.agentDir);
|
|
136
|
+
if (directory === null)
|
|
137
|
+
return;
|
|
138
|
+
const target = join(directory, `${input.pendingId}.json`);
|
|
139
|
+
await assertRegularFile(target);
|
|
140
|
+
await rm(target, { force: true });
|
|
141
|
+
}
|
|
94
142
|
export async function readPendingMemoryCandidates(input) {
|
|
95
|
-
const directory = await
|
|
143
|
+
const directory = await existingPendingDirectory(input.agentDir);
|
|
144
|
+
if (directory === null)
|
|
145
|
+
return [];
|
|
96
146
|
const names = (await readdir(directory)).filter((name) => name.endsWith(".json")).sort();
|
|
147
|
+
const maxFiles = Math.max(0, input.maxFiles ?? LOCAL_MEMORY_PENDING_SCAN_FILE_CAP);
|
|
148
|
+
const maxBytes = Math.max(0, input.maxBytes ?? LOCAL_MEMORY_PENDING_SCAN_BYTE_CAP);
|
|
97
149
|
const result = [];
|
|
150
|
+
let scannedBytes = 0;
|
|
98
151
|
for (const name of names) {
|
|
152
|
+
if (result.length >= maxFiles || scannedBytes >= maxBytes)
|
|
153
|
+
break;
|
|
99
154
|
const path = join(directory, name);
|
|
100
155
|
try {
|
|
101
156
|
await assertRegularFile(path);
|
|
102
|
-
const
|
|
157
|
+
const stat = await lstat(path);
|
|
158
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
159
|
+
throw new Error("local_memory_pending_path_invalid");
|
|
160
|
+
if (scannedBytes + stat.size > maxBytes)
|
|
161
|
+
break;
|
|
162
|
+
const raw = await readFile(path, "utf8");
|
|
163
|
+
const size = Buffer.byteLength(raw, "utf8");
|
|
164
|
+
if (scannedBytes + size > maxBytes)
|
|
165
|
+
break;
|
|
166
|
+
scannedBytes += size;
|
|
167
|
+
const parsed = PendingSchema.safeParse(JSON.parse(raw));
|
|
103
168
|
if (parsed.success && `${parsed.data.pendingId}.json` === name)
|
|
104
169
|
result.push(parsed.data);
|
|
105
170
|
}
|
|
@@ -109,3 +174,72 @@ export async function readPendingMemoryCandidates(input) {
|
|
|
109
174
|
}
|
|
110
175
|
return result;
|
|
111
176
|
}
|
|
177
|
+
function normalize(value) {
|
|
178
|
+
return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/gu, " ").trim();
|
|
179
|
+
}
|
|
180
|
+
function priorityRank(priority) {
|
|
181
|
+
return priority === "high" ? 0 : priority === "medium" ? 1 : 2;
|
|
182
|
+
}
|
|
183
|
+
function candidateText(candidate) {
|
|
184
|
+
return [
|
|
185
|
+
candidate.candidate.title,
|
|
186
|
+
candidate.candidate.summary,
|
|
187
|
+
...candidate.candidate.triggers,
|
|
188
|
+
candidate.candidate.detailCategory,
|
|
189
|
+
candidate.candidate.detailBody,
|
|
190
|
+
].join("\n");
|
|
191
|
+
}
|
|
192
|
+
function recallScore(candidate, query) {
|
|
193
|
+
const normalizedQuery = normalize(query);
|
|
194
|
+
if (normalizedQuery === "")
|
|
195
|
+
return 1;
|
|
196
|
+
const text = normalize(candidateText(candidate));
|
|
197
|
+
if (text.includes(normalizedQuery))
|
|
198
|
+
return 100;
|
|
199
|
+
const terms = normalizedQuery.split(/[\s,,、。!?;::()[\]{}"'`]+/u).filter((term) => term.length > 0);
|
|
200
|
+
return terms.reduce((score, term) => score + (text.includes(term) ? 1 : 0), 0);
|
|
201
|
+
}
|
|
202
|
+
export async function searchPendingMemoryCandidates(input) {
|
|
203
|
+
const candidates = await readPendingMemoryCandidates({ agentDir: input.agentDir });
|
|
204
|
+
const limit = Math.max(0, input.limit ?? 5);
|
|
205
|
+
if (limit === 0)
|
|
206
|
+
return [];
|
|
207
|
+
return candidates
|
|
208
|
+
.map((candidate) => ({ candidate, score: recallScore(candidate, input.query) }))
|
|
209
|
+
.filter((item) => normalize(input.query) === "" || item.score > 0)
|
|
210
|
+
.sort((left, right) => right.score - left.score
|
|
211
|
+
|| priorityRank(left.candidate.candidate.priority) - priorityRank(right.candidate.candidate.priority)
|
|
212
|
+
|| right.candidate.createdAt.localeCompare(left.candidate.createdAt)
|
|
213
|
+
|| left.candidate.pendingId.localeCompare(right.candidate.pendingId))
|
|
214
|
+
.slice(0, limit)
|
|
215
|
+
.map((item) => item.candidate);
|
|
216
|
+
}
|
|
217
|
+
function truncateCodePoints(value, maxCodePoints) {
|
|
218
|
+
return Array.from(value).slice(0, Math.max(0, maxCodePoints)).join("");
|
|
219
|
+
}
|
|
220
|
+
export function renderPendingMemoryContext(candidates, maxCodePoints = 2_000) {
|
|
221
|
+
if (candidates.length === 0 || maxCodePoints <= 0)
|
|
222
|
+
return "";
|
|
223
|
+
const header = `\n\n## Archived memory candidates (daemon-managed)\n这些记忆已持久化,但尚未进入常驻热索引;只在当前请求相关时参考,必要时再由 memory-prune 提升。\n`;
|
|
224
|
+
let remaining = maxCodePoints - Array.from(header).length;
|
|
225
|
+
if (remaining <= 0)
|
|
226
|
+
return truncateCodePoints(header, maxCodePoints);
|
|
227
|
+
const sections = [];
|
|
228
|
+
for (const item of candidates) {
|
|
229
|
+
const change = item.candidate;
|
|
230
|
+
const prefix = `\n### ${change.title}\n`
|
|
231
|
+
+ `- pending_file: .crew/memory/pending/${item.pendingId}.json\n`
|
|
232
|
+
+ `- summary: ${change.summary}\n`
|
|
233
|
+
+ `- triggers: ${change.triggers.join(", ")}\n`
|
|
234
|
+
+ "- detail: ";
|
|
235
|
+
const prefixLength = Array.from(prefix).length;
|
|
236
|
+
if (prefixLength + 1 > remaining)
|
|
237
|
+
break;
|
|
238
|
+
const detail = truncateCodePoints(change.detailBody, remaining - prefixLength - 1);
|
|
239
|
+
const section = `${prefix}${detail}\n`;
|
|
240
|
+
const length = Array.from(section).length;
|
|
241
|
+
sections.push(section);
|
|
242
|
+
remaining -= length;
|
|
243
|
+
}
|
|
244
|
+
return `${header}${sections.join("")}`;
|
|
245
|
+
}
|
|
@@ -57,9 +57,16 @@ ${resultPath}
|
|
|
57
57
|
JSON 顶层必须包含:
|
|
58
58
|
{"schemaVersion":1,"purpose":"memory_prune","baseCatalogRevision":0,"outcome":"no_change","changes":[],"statusChanges":[]}
|
|
59
59
|
每个 changes 项使用 operation=create|update|supersede、title、kind、summary、triggers、priority、detailCategory、detailBody、reviewAfter;create 的 memoryId/baseRevision/supersedesMemoryId 必须为 null,update/supersede 按现有索引填写版本和 id。
|
|
60
|
-
只提交可复用、与本次任务明确相关的少量候选;不要写入凭证、token、隐私或内部控制标记。daemon 会校验、去重、限制 MEMORY.md 大小,超出索引容量的候选会保存在 pending
|
|
60
|
+
只提交可复用、与本次任务明确相关的少量候选;不要写入凭证、token、隐私或内部控制标记。daemon 会校验、去重、限制 MEMORY.md 大小,超出索引容量的候选会保存在 pending 归档中;后续相关任务会按需召回,索引腾出空间后可自动提升。
|
|
61
61
|
`;
|
|
62
62
|
}
|
|
63
|
+
/** Prevent ordinary V2 turns from racing the daemon-managed memory writer. */
|
|
64
|
+
export function renderLocalMemoryV2WriteGuard() {
|
|
65
|
+
return `
|
|
66
|
+
|
|
67
|
+
## V2 shared memory write safety
|
|
68
|
+
本节覆盖前文关于普通任务直接更新共享记忆的例外规则。本 Agent 已启用结构化本地记忆 V2。普通任务不得直接编辑共享的 $CREW_HOME/MEMORY.md 或 $CREW_HOME/notes/ 文件;即使用户明确要求记住内容,也只把候选、用户授权和分类理由记录到本任务的 $CREW_TASK_LOG,留给任务结束后的 memory-prune 结构化收尾应用。只有 memory-prune 收尾提示明确要求写入 daemon 提供的 result 文件时,才按该协议提交候选。`;
|
|
69
|
+
}
|
|
63
70
|
export async function commitLocalMemoryResultAfterExit(input) {
|
|
64
71
|
if (input.exitCode !== 0)
|
|
65
72
|
return { status: "skipped" };
|
|
@@ -4,7 +4,7 @@ import { dirname, join, relative, resolve, sep } from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { containsLikelySecret } from "../agent-memory/policy.js";
|
|
6
6
|
import { LOCAL_MEMORY_INDEX_CODE_POINT_CAP, LOCAL_MEMORY_INDEX_ENTRY_CAP, LOCAL_MEMORY_TOTAL_CODE_POINT_CAP, renderLocalMemoryIndexCard, selectLocalMemoryIndexEntries, } from "./index-budget.js";
|
|
7
|
-
import { persistPendingMemoryCandidate } from "./pending.js";
|
|
7
|
+
import { persistPendingMemoryCandidate, readPendingMemoryCandidates, removePendingMemoryCandidate, } from "./pending.js";
|
|
8
8
|
const CategorySchema = z.enum([
|
|
9
9
|
"preferences", "project-rules", "testing", "debugging", "git", "incidents", "lessons",
|
|
10
10
|
]);
|
|
@@ -228,6 +228,47 @@ function normalize(value) {
|
|
|
228
228
|
function activeOrPinnedIds(entries) {
|
|
229
229
|
return new Set(entries.filter((entry) => entry.status === "active" || entry.status === "pinned").map((entry) => entry.id));
|
|
230
230
|
}
|
|
231
|
+
function pinnedIds(entries) {
|
|
232
|
+
return new Set(entries.filter((entry) => entry.status === "pinned").map((entry) => entry.id));
|
|
233
|
+
}
|
|
234
|
+
const DETAIL_CATEGORIES = new Set([
|
|
235
|
+
"preferences", "project-rules", "testing", "debugging", "git", "incidents", "lessons",
|
|
236
|
+
]);
|
|
237
|
+
function detailCategoryFromFile(file) {
|
|
238
|
+
if (!file.startsWith("notes/") || !file.endsWith(".md"))
|
|
239
|
+
return null;
|
|
240
|
+
const category = file.slice("notes/".length, -".md".length);
|
|
241
|
+
return DETAIL_CATEGORIES.has(category) ? category : null;
|
|
242
|
+
}
|
|
243
|
+
function readNoteBody(content, id) {
|
|
244
|
+
const begin = content.indexOf(`<!-- NOWWORK:MEMORY_ENTRY:BEGIN id=${id}`);
|
|
245
|
+
if (begin < 0)
|
|
246
|
+
return null;
|
|
247
|
+
const bodyStart = content.indexOf("\n\n", begin);
|
|
248
|
+
const bodyEnd = content.indexOf(`\n<!-- NOWWORK:MEMORY_ENTRY:END id=${id}`, bodyStart + 2);
|
|
249
|
+
if (bodyStart < 0 || bodyEnd < 0)
|
|
250
|
+
return null;
|
|
251
|
+
return content.slice(bodyStart + 2, bodyEnd).trim();
|
|
252
|
+
}
|
|
253
|
+
function archivedChange(entry, detailBody) {
|
|
254
|
+
const detailCategory = detailCategoryFromFile(entry.file);
|
|
255
|
+
if (detailCategory === null)
|
|
256
|
+
return null;
|
|
257
|
+
return {
|
|
258
|
+
operation: "create",
|
|
259
|
+
memoryId: null,
|
|
260
|
+
baseRevision: null,
|
|
261
|
+
supersedesMemoryId: null,
|
|
262
|
+
title: entry.title,
|
|
263
|
+
kind: entry.type,
|
|
264
|
+
summary: entry.summary,
|
|
265
|
+
triggers: entry.triggers,
|
|
266
|
+
priority: entry.priority,
|
|
267
|
+
detailCategory,
|
|
268
|
+
detailBody,
|
|
269
|
+
reviewAfter: entry.reviewAfter,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
231
272
|
function emptyResult() {
|
|
232
273
|
return { createdIds: [], updatedIds: [], statusChangedIds: [], deferredIds: [], pendingIds: [] };
|
|
233
274
|
}
|
|
@@ -341,7 +382,9 @@ export async function applyLocalMemoryResult(input) {
|
|
|
341
382
|
const statusChangedIds = input.result.statusChanges.map((change) => change.memoryId);
|
|
342
383
|
const deferredIds = [];
|
|
343
384
|
const pendingCandidates = [];
|
|
385
|
+
const resolvedPendingIds = new Set();
|
|
344
386
|
const noteUpdates = new Map();
|
|
387
|
+
let allowActiveEviction = false;
|
|
345
388
|
const randomId = input.randomId ?? randomUUID;
|
|
346
389
|
const executionId = input.executionId ?? "unknown";
|
|
347
390
|
const defer = (change, index) => {
|
|
@@ -399,7 +442,10 @@ export async function applyLocalMemoryResult(input) {
|
|
|
399
442
|
: item),
|
|
400
443
|
entry,
|
|
401
444
|
];
|
|
402
|
-
const
|
|
445
|
+
const allowEvictionForChange = change.operation === "create" && change.priority === "high";
|
|
446
|
+
const protectedIds = new Set(allowEvictionForChange ? pinnedIds(entries) : activeOrPinnedIds(entries));
|
|
447
|
+
if (allowEvictionForChange)
|
|
448
|
+
allowActiveEviction = true;
|
|
403
449
|
if (change.supersedesMemoryId !== null)
|
|
404
450
|
protectedIds.delete(change.supersedesMemoryId);
|
|
405
451
|
const projection = projectIndex(memory, candidateEntries, protectedIds);
|
|
@@ -428,15 +474,91 @@ export async function applyLocalMemoryResult(input) {
|
|
|
428
474
|
else if (existingCreate === undefined)
|
|
429
475
|
createdIds.push(id);
|
|
430
476
|
}
|
|
431
|
-
|
|
432
|
-
|
|
477
|
+
// Pending create candidates are durable overflow, not dead ends. Promote only
|
|
478
|
+
// when the current catalog has room; supersede/update candidates stay pending
|
|
479
|
+
// until a fresh revision-aware decision can safely replay them.
|
|
480
|
+
const pendingCandidatesOnDisk = await readPendingMemoryCandidates({ agentDir: input.agentDir });
|
|
481
|
+
for (const pending of pendingCandidatesOnDisk) {
|
|
482
|
+
const change = pending.candidate;
|
|
483
|
+
if (change.operation !== "create")
|
|
484
|
+
continue;
|
|
485
|
+
const id = stableId(`pending:${pending.pendingId}`, 0, change);
|
|
486
|
+
const existing = entries.find((entry) => entry.id === id);
|
|
487
|
+
if (existing !== undefined) {
|
|
488
|
+
resolvedPendingIds.add(pending.pendingId);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const duplicate = entries.find((entry) => (entry.status === "active" || entry.status === "pinned")
|
|
492
|
+
&& (normalize(entry.title) === normalize(change.title)
|
|
493
|
+
|| normalize(entry.summary) === normalize(change.summary)));
|
|
494
|
+
if (duplicate !== undefined) {
|
|
495
|
+
const duplicatePath = resolve(input.agentDir, duplicate.file);
|
|
496
|
+
const duplicateBody = readNoteBody(noteUpdates.get(duplicatePath) ?? await readNote(duplicatePath), duplicate.id);
|
|
497
|
+
if (duplicateBody === change.detailBody)
|
|
498
|
+
resolvedPendingIds.add(pending.pendingId);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const entry = entryFromChange(change, id, 1, "active");
|
|
502
|
+
const candidateEntries = [...entries, entry];
|
|
503
|
+
const allowEvictionForPending = change.priority === "high";
|
|
504
|
+
const projection = projectIndex(memory, candidateEntries, allowEvictionForPending ? pinnedIds(entries) : activeOrPinnedIds(entries));
|
|
505
|
+
if (allowEvictionForPending)
|
|
506
|
+
allowActiveEviction = true;
|
|
507
|
+
if (projection.omittedIds.includes(id))
|
|
508
|
+
continue;
|
|
509
|
+
const notePath = resolve(input.agentDir, entry.file);
|
|
510
|
+
if (!isWithin(resolve(input.agentDir, "notes"), notePath))
|
|
511
|
+
throw new Error("local_memory_note_path_invalid");
|
|
512
|
+
const previous = noteUpdates.get(notePath) ?? await readNote(notePath);
|
|
513
|
+
const replacement = noteSection(id, 1, change.title, change.detailBody);
|
|
514
|
+
const existingSection = replaceNote(previous, id, replacement);
|
|
515
|
+
const next = existingSection ?? `${previous.trimEnd()}${previous.trimEnd() ? "\n\n" : ""}${replacement}`;
|
|
516
|
+
noteUpdates.set(notePath, next);
|
|
517
|
+
entries = candidateEntries;
|
|
518
|
+
createdIds.push(id);
|
|
519
|
+
resolvedPendingIds.add(pending.pendingId);
|
|
520
|
+
}
|
|
521
|
+
let finalProjection = projectIndex(memory, entries, allowActiveEviction ? pinnedIds(entries) : activeOrPinnedIds(entries));
|
|
522
|
+
if (finalProjection.reason !== null
|
|
523
|
+
&& (!allowActiveEviction || finalProjection.reason === "index_over_budget_pinned")) {
|
|
433
524
|
throw new Error(finalProjection.reason);
|
|
525
|
+
}
|
|
526
|
+
if (allowActiveEviction) {
|
|
527
|
+
const omittedActiveEntries = finalProjection.omittedIds
|
|
528
|
+
.map((id) => entries.find((entry) => entry.id === id))
|
|
529
|
+
.filter((entry) => entry?.status === "active");
|
|
530
|
+
for (const entry of omittedActiveEntries) {
|
|
531
|
+
const notePath = resolve(input.agentDir, entry.file);
|
|
532
|
+
const content = noteUpdates.get(notePath) ?? await readNote(notePath);
|
|
533
|
+
const detailBody = readNoteBody(content, entry.id);
|
|
534
|
+
const change = detailBody === null ? null : archivedChange(entry, detailBody);
|
|
535
|
+
if (change === null)
|
|
536
|
+
throw new Error("local_memory_note_anchor_missing");
|
|
537
|
+
const pendingId = `pending-${stableId(`evicted:${executionId}`, 0, change).slice("mem_".length)}`;
|
|
538
|
+
pendingCandidates.push({
|
|
539
|
+
schemaVersion: 1,
|
|
540
|
+
pendingId,
|
|
541
|
+
createdAt: new Date().toISOString(),
|
|
542
|
+
reason: "index_over_budget",
|
|
543
|
+
source: { executionId, pruneTraceId: input.pruneTraceId ?? null },
|
|
544
|
+
candidate: change,
|
|
545
|
+
});
|
|
546
|
+
deferredIds.push(pendingId);
|
|
547
|
+
entries = entries.map((item) => item.id === entry.id ? { ...item, status: "archived" } : item);
|
|
548
|
+
}
|
|
549
|
+
finalProjection = projectIndex(memory, entries, pinnedIds(entries));
|
|
550
|
+
if (finalProjection.reason !== null)
|
|
551
|
+
throw new Error(finalProjection.reason);
|
|
552
|
+
}
|
|
434
553
|
for (const candidate of pendingCandidates)
|
|
435
554
|
await persistPendingMemoryCandidate({ agentDir: input.agentDir, candidate });
|
|
436
555
|
for (const [path, content] of noteUpdates)
|
|
437
556
|
await atomicWrite(path, content);
|
|
438
557
|
if (noteUpdates.size > 0 || statusChangedIds.length > 0)
|
|
439
558
|
await atomicWrite(memoryPath, finalProjection.memory);
|
|
559
|
+
for (const pendingId of resolvedPendingIds) {
|
|
560
|
+
await removePendingMemoryCandidate({ agentDir: input.agentDir, pendingId });
|
|
561
|
+
}
|
|
440
562
|
return {
|
|
441
563
|
createdIds,
|
|
442
564
|
updatedIds,
|
package/dist/main.js
CHANGED
|
@@ -24,7 +24,7 @@ import { formatDaemonStartupError } from "./daemon-startup-error.js";
|
|
|
24
24
|
import { assertManagedServiceStartup } from "./managed-service-startup.js";
|
|
25
25
|
import { daemonGlobalInstallation } from "./daemon-installation.js";
|
|
26
26
|
import { acquireDaemonInstallationLease, runWithDaemonInstallationLease, } from "./daemon-installation-lease.js";
|
|
27
|
-
import { loadStorageRuntime } from "./storage-runtime.js";
|
|
27
|
+
import { createStorageRuntimeOwnershipBinding, loadStorageRuntime } from "./storage-runtime.js";
|
|
28
28
|
import { readStorageMigrationReceipt } from "./storage-migration-controller.js";
|
|
29
29
|
import { storageMigrationStatusReplay } from "./storage-status-replay.js";
|
|
30
30
|
import { runStorageWorkerCommand } from "./storage-worker-command.js";
|
|
@@ -160,6 +160,9 @@ async function main() {
|
|
|
160
160
|
...(process.getuid === undefined ? {} : { uid: process.getuid() }),
|
|
161
161
|
environment: process.env,
|
|
162
162
|
});
|
|
163
|
+
const targetStorageBinding = storageRuntime === null
|
|
164
|
+
? null
|
|
165
|
+
: createStorageRuntimeOwnershipBinding(storageRuntime);
|
|
163
166
|
const targetConfirmation = storageRuntime?.identity.storageMigrationId === undefined
|
|
164
167
|
? null
|
|
165
168
|
: createStorageTargetConfirmationHandler({
|
|
@@ -172,7 +175,8 @@ async function main() {
|
|
|
172
175
|
? {
|
|
173
176
|
ready: false,
|
|
174
177
|
statusOutboxPath: join(storageRuntime.local.stateRoot, "storage-status-outbox.json"),
|
|
175
|
-
identity:
|
|
178
|
+
identity: targetStorageBinding.identity,
|
|
179
|
+
bindServerIdentity: targetStorageBinding.bind,
|
|
176
180
|
...(targetConfirmation === null ? {} : { confirm: targetConfirmation.handle }),
|
|
177
181
|
...(storageRuntime.identity.storageMigrationId === undefined ? {} : {
|
|
178
182
|
replayStatus: async (send) => {
|
package/dist/prompt.js
CHANGED
|
@@ -279,6 +279,9 @@ ${newThreadRule}
|
|
|
279
279
|
|
|
280
280
|
## Skill 指定
|
|
281
281
|
- 如果用户输入 \`/xxxx\`、或消息开头/正文中有形如 \`/skill-name\` 的片段,优先判断用户是否在精准指定某个 skill。若该 skill 可用且适合当前任务,按该 skill 的工作流处理;若名称不明确或不可用,先说明不确定并按最接近的可用能力处理。`;
|
|
282
|
+
const memoryWritePolicy = ctx.localMemoryV2Enabled
|
|
283
|
+
? "- **V2 结构化本地记忆**:普通任务不得直接编辑共享 `$CREW_HOME/MEMORY.md` 或 notes/;即使用户明确要求长期记忆,也只记录到 `$CREW_TASK_LOG`,由任务结束时的 memory-prune 结构化提交。只有 memory-prune 收尾提示明确要求写入 daemon result 文件时才提交候选。"
|
|
284
|
+
: "- **当前请求明确要求更新或记住长期记忆,明确表达需要跨后续任务持续生效的具体规则、偏好或约束,或当前消息是对本线程紧邻记忆方案的无歧义确认时**,可以按最小范围直接写共享记忆。例如“以后所有测试报告统一按五部分写”属于明确长期意图,即使没有使用“记忆”一词。先读取相关索引和 note,语义已存在时不重复追加,不要把 Agent 自己判断值得记住视为用户授权。";
|
|
282
285
|
return `你是 "${ctx.handle}",${product}(一个让人类与 AI agent 协作的共享工作区)中的 AI 成员。${product} 为可能运行在不同机器上的人与 agent 提供共享的消息服务。
|
|
283
286
|
|
|
284
287
|
## 你是谁
|
|
@@ -293,7 +296,7 @@ ${newThreadRule}
|
|
|
293
296
|
## 并行与记忆(CRITICAL — 防冲突 + 防上下文膨胀)
|
|
294
297
|
你**可能同时在处理多个任务**(你自己的多个并行进程)。为避免互相覆盖、并保持上下文精简,严格遵守:
|
|
295
298
|
- **普通任务默认只写两处**:① 本任务的隔离 cwd(代码/草稿);② 本任务的工作日志 **\`$CREW_TASK_LOG\`**(每任务一份,别人不会碰)。默认不要修改共享 \`$CREW_HOME/MEMORY.md\` 或 notes/;只有当前用户明确要求更新或记住长期记忆,或明确表达具体且跨后续任务持续生效的规则、偏好或约束时,才按下方条件写入相关共享文件。在工作日志里记:当前阶段 / 下一步 / 卡在谁 / 关键结论(每条尽量 ≤3 行)。
|
|
296
|
-
|
|
299
|
+
${memoryWritePolicy}
|
|
297
300
|
- 本节的直接更新共享记忆和相关 task 流转仅适用于用户消息触发的普通交互执行。memory_prune 和 scheduled 执行只遵守各自唤醒指令,不得依据本节认领、创建、重开或推进 task。
|
|
298
301
|
${memoryTaskReadRule}
|
|
299
302
|
- 普通交互执行将写共享记忆时,使用当前线程唯一的普通 task:已有非终态 task 时先按归属规则确保当前 Agent 成功认领;当前线程没有 task 时先创建并认领一个普通 task;已有 task 为 done/closed 时不得由 Agent 重开,done 报告当前轻量方案无法安全复用终态 task,closed 保持人工终态。遇到终态或归属问题时报告冲突且不得创建第二个 task 或新的任务类型。完成写入后,只有任务本身的全部工作已完成时才推进终态以触发 prune。
|
package/dist/runner.js
CHANGED
|
@@ -30,6 +30,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
|
|
|
30
30
|
}), dependencies.cancellation);
|
|
31
31
|
const providerConfig = credential.config ?? {};
|
|
32
32
|
const runtime = runtimeName(providerConfig.runtime ?? config.runtimeBin);
|
|
33
|
+
const localMemoryV2Enabled = config.localMemoryV2Handles?.has(input.handle) === true;
|
|
33
34
|
const reasoning = ReasoningSchema.safeParse(providerConfig.reasoning);
|
|
34
35
|
const baseWake = input.wake ?? buildWakePrompt(input.channelId);
|
|
35
36
|
const executionId = input.runId ?? `legacy-${randomUUID()}`;
|
|
@@ -61,6 +62,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
|
|
|
61
62
|
memory: capMemoryForInject(workspace.memory),
|
|
62
63
|
...(resuming ? {} : { workLog: capWorkLogForInject(workspace.workLog) }),
|
|
63
64
|
collaborationToolsEnabled: config.codexCollaborationTools && runtime === "codex",
|
|
65
|
+
localMemoryV2Enabled,
|
|
64
66
|
}),
|
|
65
67
|
wakePrompt: ({ resuming, rotatedForBudget, nearBudget }) => {
|
|
66
68
|
const distillHint = nearBudget
|
|
@@ -111,7 +113,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
|
|
|
111
113
|
softTokens: config.sessionSoftTokens,
|
|
112
114
|
maxTurns: config.sessionMaxTurns,
|
|
113
115
|
},
|
|
114
|
-
localMemoryV2Enabled
|
|
116
|
+
localMemoryV2Enabled,
|
|
115
117
|
}, {
|
|
116
118
|
onActivity,
|
|
117
119
|
onConsole,
|
package/dist/serve.js
CHANGED
|
@@ -54,7 +54,7 @@ import { buildControlPlaneUrl } from "./control-plane-url.js";
|
|
|
54
54
|
import { createServeMigrationController, handleMigrationControlMessage } from "./daemon-migration-wiring.js";
|
|
55
55
|
import { createCodexHomeModeController } from "./codex-home-mode-controller.js";
|
|
56
56
|
import { resolveDaemonLocalStateRootFromEnv } from "./daemon-local-home.js";
|
|
57
|
-
import { attachStorageMigrationRuntime, createServeStorageStatusDelivery, handleStorageMigrationControl, replayStorageMigrationStatus, storageMigrationCapabilities, } from "./storage-serve-adapter.js";
|
|
57
|
+
import { attachStorageMigrationRuntime, bindStorageMigrationServerIdentity, createServeStorageStatusDelivery, handleStorageMigrationControl, replayStorageMigrationStatus, storageMigrationCapabilities, } from "./storage-serve-adapter.js";
|
|
58
58
|
export { buildControlPlaneUrl } from "./control-plane-url.js";
|
|
59
59
|
// normalize.ts 的活动种类 → activity 枚举
|
|
60
60
|
const ACTIVITY_MAP = {
|
|
@@ -316,6 +316,7 @@ export function serve(config, opts = {}) {
|
|
|
316
316
|
let connectionEpochToken;
|
|
317
317
|
ws.on("open", () => {
|
|
318
318
|
connectionEpochToken = projectSkillsController.beginConnectionEpoch();
|
|
319
|
+
storageMigrationRuntime.beginIdentityBinding();
|
|
319
320
|
const openedSocket = ws;
|
|
320
321
|
backoff = 1000;
|
|
321
322
|
connectedAt = Date.now();
|
|
@@ -811,16 +812,21 @@ export function serve(config, opts = {}) {
|
|
|
811
812
|
// ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
|
|
812
813
|
const r = msg;
|
|
813
814
|
setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
|
|
814
|
-
if (opts.storageMigration
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
}
|
|
823
|
-
|
|
815
|
+
if (opts.storageMigration !== undefined && typeof r.workspaceId === "string" && typeof r.machineId === "string") {
|
|
816
|
+
const bindingSocket = ws;
|
|
817
|
+
bindStorageMigrationServerIdentity(opts.storageMigration, { workspaceId: r.workspaceId, machineId: r.machineId }, {
|
|
818
|
+
onBound: () => {
|
|
819
|
+
if (bindingSocket !== ws || bindingSocket?.readyState !== WebSocket.OPEN)
|
|
820
|
+
return;
|
|
821
|
+
storageMigrationRuntime.completeIdentityBinding();
|
|
822
|
+
machineHelloPublisher.republish();
|
|
823
|
+
},
|
|
824
|
+
onError: (error) => {
|
|
825
|
+
dslog("daemon.storage_identity_bind_failed", "存储迁移 Workspace 身份绑定失败", { level: "ERROR", error_message: error.message });
|
|
826
|
+
if (bindingSocket === ws && bindingSocket?.readyState === WebSocket.OPEN)
|
|
827
|
+
bindingSocket.close(1011, "storage identity binding failed");
|
|
828
|
+
},
|
|
829
|
+
});
|
|
824
830
|
}
|
|
825
831
|
// 旧 server 无此字段 → 空集合(daemon 保持全部旧行为)。
|
|
826
832
|
serverCapabilities = new Set(Array.isArray(r.serverCapabilities)
|
package/dist/storage-identity.js
CHANGED
|
@@ -4,6 +4,9 @@ import { createStorageOwnershipClaim } from "./storage-ownership.js";
|
|
|
4
4
|
function fingerprint(namespace, value) {
|
|
5
5
|
return `sha256:${createHash("sha256").update(`${namespace}\0${value}`).digest("hex")}`;
|
|
6
6
|
}
|
|
7
|
+
export function storageWorkspaceRootFingerprint(workspaceRoot) {
|
|
8
|
+
return fingerprint("workspace-root", workspaceRoot);
|
|
9
|
+
}
|
|
7
10
|
export function storageIdFingerprint(storageId) {
|
|
8
11
|
return fingerprint("storage", storageId);
|
|
9
12
|
}
|
|
@@ -15,7 +18,7 @@ export function storageHelloIdentity(input) {
|
|
|
15
18
|
|| installation.runtimeRoot !== layout.runtimeRoot) {
|
|
16
19
|
throw new Error("installation storage identity does not match resolved layout");
|
|
17
20
|
}
|
|
18
|
-
const workspaceRootFingerprint =
|
|
21
|
+
const workspaceRootFingerprint = storageWorkspaceRootFingerprint(layout.workspaceRoot);
|
|
19
22
|
let ownership;
|
|
20
23
|
if (input.ownership !== undefined) {
|
|
21
24
|
const { claimFingerprint: reportedFingerprint, ...claimIdentity } = input.ownership;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { atomicRenameNoReplace } from "./atomic-no-replace-rename.js";
|
|
6
|
+
import { durableDirectorySync } from "./atomic-private-write.js";
|
|
5
7
|
const FingerprintSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/u);
|
|
8
|
+
const UuidSchema = z.string().uuid();
|
|
6
9
|
const StorageOwnershipClaimSchema = z.object({
|
|
7
10
|
version: z.literal(1),
|
|
8
11
|
machineId: z.string().uuid(),
|
|
@@ -38,8 +41,10 @@ export function createStorageOwnershipClaim(input) {
|
|
|
38
41
|
export function storageOwnershipClaimPath(nowworkHome) {
|
|
39
42
|
return join(nowworkHome, "storage-layout.json");
|
|
40
43
|
}
|
|
41
|
-
export
|
|
42
|
-
|
|
44
|
+
export function workspaceStorageOwnershipClaimPath(nowworkHome, workspaceId) {
|
|
45
|
+
return join(nowworkHome, ".storage-ownership", `${UuidSchema.parse(workspaceId).toLowerCase()}.json`);
|
|
46
|
+
}
|
|
47
|
+
async function readClaim(path) {
|
|
43
48
|
const metadata = await lstat(path);
|
|
44
49
|
if (!metadata.isFile())
|
|
45
50
|
throw new Error("storage ownership claim must be a file");
|
|
@@ -53,3 +58,86 @@ export async function readStorageOwnershipClaim(nowworkHome) {
|
|
|
53
58
|
}
|
|
54
59
|
return expected;
|
|
55
60
|
}
|
|
61
|
+
function missingClaim(message) {
|
|
62
|
+
return Object.assign(new Error(message), { code: "ENOENT" });
|
|
63
|
+
}
|
|
64
|
+
export async function readStorageOwnershipClaim(nowworkHome, workspaceId) {
|
|
65
|
+
if (workspaceId !== undefined) {
|
|
66
|
+
const normalizedWorkspaceId = UuidSchema.parse(workspaceId).toLowerCase();
|
|
67
|
+
try {
|
|
68
|
+
return await readClaim(workspaceStorageOwnershipClaimPath(nowworkHome, normalizedWorkspaceId));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code !== "ENOENT")
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
const legacy = await readClaim(storageOwnershipClaimPath(nowworkHome));
|
|
75
|
+
if (legacy.workspaceId !== normalizedWorkspaceId) {
|
|
76
|
+
throw missingClaim(`storage ownership claim is not provisioned for workspace '${normalizedWorkspaceId}'`);
|
|
77
|
+
}
|
|
78
|
+
return legacy;
|
|
79
|
+
}
|
|
80
|
+
return readClaim(storageOwnershipClaimPath(nowworkHome));
|
|
81
|
+
}
|
|
82
|
+
export async function resolveWorkspaceStorageOwnershipClaim(input) {
|
|
83
|
+
const machineId = UuidSchema.parse(input.machineId).toLowerCase();
|
|
84
|
+
const workspaceId = UuidSchema.parse(input.workspaceId).toLowerCase();
|
|
85
|
+
try {
|
|
86
|
+
const existing = await readStorageOwnershipClaim(input.nowworkHome, workspaceId);
|
|
87
|
+
const expected = createStorageOwnershipClaim({
|
|
88
|
+
machineId,
|
|
89
|
+
workspaceId,
|
|
90
|
+
environment: input.environment,
|
|
91
|
+
volumeFingerprint: existing.volumeFingerprint,
|
|
92
|
+
workspaceRootFingerprint: input.workspaceRootFingerprint,
|
|
93
|
+
ownerEpoch: existing.ownerEpoch,
|
|
94
|
+
});
|
|
95
|
+
if (existing.claimFingerprint !== expected.claimFingerprint) {
|
|
96
|
+
throw new Error("storage ownership claim conflicts with the authenticated workspace identity");
|
|
97
|
+
}
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code !== "ENOENT")
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
const anchor = await readClaim(storageOwnershipClaimPath(input.nowworkHome));
|
|
105
|
+
if (anchor.environment !== input.environment.toLowerCase()) {
|
|
106
|
+
throw new Error("storage ownership volume anchor environment does not match");
|
|
107
|
+
}
|
|
108
|
+
const claim = createStorageOwnershipClaim({
|
|
109
|
+
machineId,
|
|
110
|
+
workspaceId,
|
|
111
|
+
environment: input.environment,
|
|
112
|
+
volumeFingerprint: anchor.volumeFingerprint,
|
|
113
|
+
workspaceRootFingerprint: input.workspaceRootFingerprint,
|
|
114
|
+
ownerEpoch: 1,
|
|
115
|
+
});
|
|
116
|
+
const path = workspaceStorageOwnershipClaimPath(input.nowworkHome, workspaceId);
|
|
117
|
+
const directory = dirname(path);
|
|
118
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
119
|
+
await chmod(directory, 0o700);
|
|
120
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
121
|
+
let handle = null;
|
|
122
|
+
try {
|
|
123
|
+
handle = await open(temporary, "wx", 0o600);
|
|
124
|
+
await handle.writeFile(`${JSON.stringify({ version: 1, ...claim })}\n`, "utf8");
|
|
125
|
+
await handle.sync();
|
|
126
|
+
await handle.close();
|
|
127
|
+
handle = null;
|
|
128
|
+
const published = await atomicRenameNoReplace(temporary, path);
|
|
129
|
+
if (published === "published") {
|
|
130
|
+
await durableDirectorySync(directory);
|
|
131
|
+
return claim;
|
|
132
|
+
}
|
|
133
|
+
const raced = await readClaim(path);
|
|
134
|
+
if (raced.claimFingerprint !== claim.claimFingerprint) {
|
|
135
|
+
throw new Error("storage ownership claim was concurrently provisioned for another identity");
|
|
136
|
+
}
|
|
137
|
+
return raced;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await handle?.close().catch(() => undefined);
|
|
141
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -5,12 +5,13 @@ import { buildServiceSpec } from "./computer-service.js";
|
|
|
5
5
|
import { buildManagedServiceRecord } from "./managed-service-lifecycle.js";
|
|
6
6
|
import { createStorageControlController } from "./storage-control.js";
|
|
7
7
|
import { runStorageDoctor } from "./storage-doctor.js";
|
|
8
|
-
import { storageHelloIdentity } from "./storage-identity.js";
|
|
8
|
+
import { storageHelloIdentity, storageWorkspaceRootFingerprint, } from "./storage-identity.js";
|
|
9
9
|
import { storageLayout, validateStorageEnvironment } from "./storage-layout.js";
|
|
10
10
|
import { StorageMigrationFailure } from "./storage-migration-controller.js";
|
|
11
11
|
import { launchStorageMigrationWorker, writeStorageWorkerHandoff } from "./storage-worker-handoff.js";
|
|
12
12
|
import { writeStorageWorkerOperation } from "./storage-worker-operation.js";
|
|
13
13
|
import { latestSourceStorageMigrationReceipt, monitorStorageMigrationStatus, storageMigrationStatusReplay, } from "./storage-status-replay.js";
|
|
14
|
+
import { resolveWorkspaceStorageOwnershipClaim, } from "./storage-ownership.js";
|
|
14
15
|
const UUID = z.string().uuid();
|
|
15
16
|
function fingerprint(value) {
|
|
16
17
|
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
@@ -22,13 +23,13 @@ function deterministicUuid(value) {
|
|
|
22
23
|
const hex = chars.join("");
|
|
23
24
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
24
25
|
}
|
|
25
|
-
function storageId(input, workspaceId) {
|
|
26
|
-
if (
|
|
26
|
+
function storageId(input, workspaceId, ownership) {
|
|
27
|
+
if (ownership.workspaceId !== workspaceId || ownership.environment !== input.environment) {
|
|
27
28
|
throw new Error("Storage ownership claim does not match the migration identity");
|
|
28
29
|
}
|
|
29
|
-
return `storage_${
|
|
30
|
+
return `storage_${ownership.claimFingerprint.slice("sha256:".length)}`;
|
|
30
31
|
}
|
|
31
|
-
function installationBase(input, workspaceId, migrationId, createdAt) {
|
|
32
|
+
function installationBase(input, workspaceId, migrationId, createdAt, ownership) {
|
|
32
33
|
const layout = storageLayout({
|
|
33
34
|
nowworkHome: input.nowworkHome,
|
|
34
35
|
environment: input.environment,
|
|
@@ -43,7 +44,7 @@ function installationBase(input, workspaceId, migrationId, createdAt) {
|
|
|
43
44
|
serverUrl: input.serverUrl,
|
|
44
45
|
environment: input.environment,
|
|
45
46
|
workspaceId,
|
|
46
|
-
storageId: storageId(input, workspaceId),
|
|
47
|
+
storageId: storageId(input, workspaceId, ownership),
|
|
47
48
|
nowworkHome: input.nowworkHome,
|
|
48
49
|
runtimeRoot: layout.runtimeRoot,
|
|
49
50
|
credentialRef: `profile:${input.profile}`,
|
|
@@ -99,6 +100,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
99
100
|
writeHandoff: writeStorageWorkerHandoff,
|
|
100
101
|
launchWorker: launchStorageMigrationWorker,
|
|
101
102
|
monitorWorker: monitorStorageMigrationStatus,
|
|
103
|
+
resolveOwnership: resolveWorkspaceStorageOwnershipClaim,
|
|
102
104
|
now: () => new Date(),
|
|
103
105
|
...injected,
|
|
104
106
|
};
|
|
@@ -108,6 +110,8 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
108
110
|
let gate = null;
|
|
109
111
|
let sendStatus = null;
|
|
110
112
|
let control = null;
|
|
113
|
+
let ownership = input.ownership;
|
|
114
|
+
let identityBindTail = Promise.resolve();
|
|
111
115
|
const pendingControlMessages = [];
|
|
112
116
|
let drainingPendingControlMessages = false;
|
|
113
117
|
const drainPendingControlMessages = () => {
|
|
@@ -197,8 +201,8 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
197
201
|
backupsRoot: input.local.backupsRoot,
|
|
198
202
|
backupArtifacts: input.backupArtifacts.map((artifact) => ({ ...artifact })),
|
|
199
203
|
ownership: {
|
|
200
|
-
ownerEpoch:
|
|
201
|
-
ownershipClaimFingerprint:
|
|
204
|
+
ownerEpoch: ownership.ownerEpoch,
|
|
205
|
+
ownershipClaimFingerprint: ownership.claimFingerprint,
|
|
202
206
|
},
|
|
203
207
|
acceptedWarnings: [...input.policy.acceptedWarnings],
|
|
204
208
|
},
|
|
@@ -214,7 +218,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
214
218
|
installation: {
|
|
215
219
|
configRoot: input.local.configRoot,
|
|
216
220
|
target: {
|
|
217
|
-
...installationBase(input, workspaceId, doctor.manifest.migrationId, createdAt),
|
|
221
|
+
...installationBase(input, workspaceId, doctor.manifest.migrationId, createdAt, ownership),
|
|
218
222
|
storageMigrationId: doctor.manifest.migrationId,
|
|
219
223
|
},
|
|
220
224
|
},
|
|
@@ -236,8 +240,8 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
236
240
|
sourceStorageFingerprint,
|
|
237
241
|
targetStorageFingerprint: identity.storageIdFingerprint,
|
|
238
242
|
ownership: {
|
|
239
|
-
ownerEpoch:
|
|
240
|
-
ownershipClaimFingerprint:
|
|
243
|
+
ownerEpoch: ownership.ownerEpoch,
|
|
244
|
+
ownershipClaimFingerprint: ownership.claimFingerprint,
|
|
241
245
|
},
|
|
242
246
|
runDoctor: async (migrationId) => dependencies.runDoctor(await doctorInput(migrationId)),
|
|
243
247
|
runMigration: async (doctor) => {
|
|
@@ -294,7 +298,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
294
298
|
sendStatus,
|
|
295
299
|
});
|
|
296
300
|
};
|
|
297
|
-
const
|
|
301
|
+
const bindServerIdentityOnce = async (server) => {
|
|
298
302
|
const nextWorkspaceId = UUID.parse(server.workspaceId).toLowerCase();
|
|
299
303
|
const nextMachineId = UUID.parse(server.machineId).toLowerCase();
|
|
300
304
|
if (workspaceId !== null && workspaceId !== nextWorkspaceId) {
|
|
@@ -303,13 +307,6 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
303
307
|
if (machineId !== null && machineId !== nextMachineId) {
|
|
304
308
|
throw new Error("Storage migration server machine identity changed");
|
|
305
309
|
}
|
|
306
|
-
if (input.ownership.workspaceId !== nextWorkspaceId
|
|
307
|
-
|| input.ownership.machineId !== nextMachineId
|
|
308
|
-
|| input.ownership.environment !== input.environment) {
|
|
309
|
-
throw new Error("Storage ownership claim does not match authenticated server identity");
|
|
310
|
-
}
|
|
311
|
-
const createdAt = dependencies.now().toISOString();
|
|
312
|
-
const installation = installationBase(input, nextWorkspaceId, undefined, createdAt);
|
|
313
310
|
const layout = storageLayout({
|
|
314
311
|
nowworkHome: input.nowworkHome,
|
|
315
312
|
environment: input.environment,
|
|
@@ -317,13 +314,36 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
317
314
|
runtimeBase: input.local.runtimeBase,
|
|
318
315
|
platform: input.platform,
|
|
319
316
|
});
|
|
320
|
-
|
|
317
|
+
if (ownership.workspaceId !== nextWorkspaceId
|
|
318
|
+
|| ownership.machineId !== nextMachineId
|
|
319
|
+
|| ownership.environment !== input.environment) {
|
|
320
|
+
ownership = await dependencies.resolveOwnership({
|
|
321
|
+
nowworkHome: input.nowworkHome,
|
|
322
|
+
machineId: nextMachineId,
|
|
323
|
+
workspaceId: nextWorkspaceId,
|
|
324
|
+
environment: input.environment,
|
|
325
|
+
workspaceRootFingerprint: storageWorkspaceRootFingerprint(layout.workspaceRoot),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (ownership.workspaceId !== nextWorkspaceId
|
|
329
|
+
|| ownership.machineId !== nextMachineId
|
|
330
|
+
|| ownership.environment !== input.environment) {
|
|
331
|
+
throw new Error("Storage ownership claim does not match authenticated server identity");
|
|
332
|
+
}
|
|
333
|
+
const createdAt = dependencies.now().toISOString();
|
|
334
|
+
const installation = installationBase(input, nextWorkspaceId, undefined, createdAt, ownership);
|
|
335
|
+
const nextIdentity = storageHelloIdentity({ installation, layout, ownership });
|
|
321
336
|
workspaceId = nextWorkspaceId;
|
|
322
337
|
machineId = nextMachineId;
|
|
323
338
|
identity = nextIdentity;
|
|
324
339
|
ensureControl();
|
|
325
340
|
drainPendingControlMessages();
|
|
326
341
|
};
|
|
342
|
+
const bindServerIdentity = (server) => {
|
|
343
|
+
const current = identityBindTail.then(() => bindServerIdentityOnce(server));
|
|
344
|
+
identityBindTail = current.catch(() => undefined);
|
|
345
|
+
return current;
|
|
346
|
+
};
|
|
327
347
|
const attach = (runtime) => {
|
|
328
348
|
gate = runtime.gate;
|
|
329
349
|
sendStatus = runtime.sendStatus;
|
package/dist/storage-runtime.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { findInstallationForProfile, readInstallations, resolveLocalStorageRoots, } from "./storage-config.js";
|
|
2
2
|
import { storageHelloIdentity } from "./storage-identity.js";
|
|
3
3
|
import { storageLayout } from "./storage-layout.js";
|
|
4
|
-
import { readStorageOwnershipClaim } from "./storage-ownership.js";
|
|
4
|
+
import { readStorageOwnershipClaim, resolveWorkspaceStorageOwnershipClaim, } from "./storage-ownership.js";
|
|
5
5
|
const canonicalServerUrl = (value) => value.replace(/\/+$/u, "");
|
|
6
6
|
export function resolveStorageRuntime(input) {
|
|
7
7
|
const installation = findInstallationForProfile(input.records, input.profile);
|
|
@@ -45,7 +45,7 @@ export async function loadStorageRuntime(input) {
|
|
|
45
45
|
return null;
|
|
46
46
|
let ownership;
|
|
47
47
|
try {
|
|
48
|
-
ownership = await readStorageOwnershipClaim(baseline.layout.persistentRoot);
|
|
48
|
+
ownership = await readStorageOwnershipClaim(baseline.layout.persistentRoot, baseline.layout.workspaceId);
|
|
49
49
|
}
|
|
50
50
|
catch (error) {
|
|
51
51
|
if (error.code !== "ENOENT")
|
|
@@ -53,3 +53,39 @@ export async function loadStorageRuntime(input) {
|
|
|
53
53
|
}
|
|
54
54
|
return ownership === undefined ? baseline : resolveStorageRuntime({ ...input, records, ownership });
|
|
55
55
|
}
|
|
56
|
+
export async function bindStorageRuntimeOwnership(runtime, server) {
|
|
57
|
+
const workspaceId = server.workspaceId.toLowerCase();
|
|
58
|
+
const machineId = server.machineId.toLowerCase();
|
|
59
|
+
if (runtime.installation.workspaceId !== workspaceId) {
|
|
60
|
+
throw new Error("Storage runtime server workspace identity changed");
|
|
61
|
+
}
|
|
62
|
+
const existing = runtime.identity.storageOwnership;
|
|
63
|
+
if (existing !== undefined) {
|
|
64
|
+
if (existing.machineId !== machineId)
|
|
65
|
+
throw new Error("Storage runtime server machine identity changed");
|
|
66
|
+
return runtime;
|
|
67
|
+
}
|
|
68
|
+
const ownership = await resolveWorkspaceStorageOwnershipClaim({
|
|
69
|
+
nowworkHome: runtime.layout.persistentRoot,
|
|
70
|
+
machineId,
|
|
71
|
+
workspaceId,
|
|
72
|
+
environment: runtime.installation.environment,
|
|
73
|
+
workspaceRootFingerprint: runtime.identity.workspaceRootFingerprint,
|
|
74
|
+
});
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
...runtime,
|
|
77
|
+
identity: storageHelloIdentity({ installation: runtime.installation, layout: runtime.layout, ownership }),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export function createStorageRuntimeOwnershipBinding(initial) {
|
|
81
|
+
let runtime = initial;
|
|
82
|
+
let bindTail = Promise.resolve();
|
|
83
|
+
const bind = (server) => {
|
|
84
|
+
const current = bindTail.then(async () => {
|
|
85
|
+
runtime = await bindStorageRuntimeOwnership(runtime, server);
|
|
86
|
+
});
|
|
87
|
+
bindTail = current.catch(() => undefined);
|
|
88
|
+
return current;
|
|
89
|
+
};
|
|
90
|
+
return Object.freeze({ identity: () => runtime.identity, bind });
|
|
91
|
+
}
|
|
@@ -9,6 +9,15 @@ export function storageMigrationCapabilities(configured, options) {
|
|
|
9
9
|
...(options.ready ? [STORAGE_MIGRATION_CAPABILITY] : []),
|
|
10
10
|
])]);
|
|
11
11
|
}
|
|
12
|
+
export function bindStorageMigrationServerIdentity(options, identity, callbacks) {
|
|
13
|
+
const bind = options?.bindServerIdentity;
|
|
14
|
+
if (bind === undefined) {
|
|
15
|
+
if (options?.ready === true)
|
|
16
|
+
callbacks.onBound();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
void Promise.resolve().then(() => bind(identity)).then(callbacks.onBound).catch(callbacks.onError);
|
|
20
|
+
}
|
|
12
21
|
export function createServeStorageStatusDelivery(options, dependencies) {
|
|
13
22
|
return createStorageStatusDelivery({
|
|
14
23
|
...(options?.statusOutboxPath === undefined ? {} : { outboxPath: options.statusOutboxPath }),
|
|
@@ -17,28 +26,36 @@ export function createServeStorageStatusDelivery(options, dependencies) {
|
|
|
17
26
|
});
|
|
18
27
|
}
|
|
19
28
|
export function attachStorageMigrationRuntime(options, dependencies) {
|
|
20
|
-
let
|
|
29
|
+
let migrationAdmissionClosed = false;
|
|
30
|
+
let identityAdmissionClosed = options?.bindServerIdentity !== undefined;
|
|
21
31
|
if (options?.ready === true) {
|
|
22
32
|
options.attach?.({
|
|
23
33
|
gate: {
|
|
24
34
|
isBusy: dependencies.isBusy,
|
|
25
35
|
closeAndFlush: async () => {
|
|
26
|
-
|
|
36
|
+
migrationAdmissionClosed = true;
|
|
27
37
|
await dependencies.waitForExecutionFrames();
|
|
28
38
|
if (dependencies.isBusy()) {
|
|
29
|
-
|
|
39
|
+
migrationAdmissionClosed = false;
|
|
30
40
|
throw Object.assign(new Error("active execution entered before storage migration quiesce"), {
|
|
31
41
|
code: "runtime_busy",
|
|
32
42
|
});
|
|
33
43
|
}
|
|
34
44
|
await dependencies.flushLocalState();
|
|
35
45
|
},
|
|
36
|
-
reopen: () => {
|
|
46
|
+
reopen: () => { migrationAdmissionClosed = false; },
|
|
37
47
|
},
|
|
38
48
|
sendStatus: dependencies.sendStatus,
|
|
39
49
|
});
|
|
40
50
|
}
|
|
41
|
-
return Object.freeze({
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
isAdmissionClosed: () => migrationAdmissionClosed || identityAdmissionClosed,
|
|
53
|
+
beginIdentityBinding: () => {
|
|
54
|
+
if (options?.bindServerIdentity !== undefined)
|
|
55
|
+
identityAdmissionClosed = true;
|
|
56
|
+
},
|
|
57
|
+
completeIdentityBinding: () => { identityAdmissionClosed = false; },
|
|
58
|
+
});
|
|
42
59
|
}
|
|
43
60
|
export function replayStorageMigrationStatus(options, socket, isCurrent, onError, deliver) {
|
|
44
61
|
return options?.replayStatus?.((frame) => {
|