@nowcrew/daemon 0.6.93-beta.1 → 0.6.93-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/agent-environment-execution.js +7 -2
  2. package/dist/cloud-report-upload.js +1 -10
  3. package/dist/execution-runner.js +1 -0
  4. package/dist/local-executor.js +25 -30
  5. package/dist/local-memory/commit-telemetry.js +30 -0
  6. package/dist/local-memory/execution-workspace.js +9 -2
  7. package/dist/local-memory/initialization-identity.js +18 -5
  8. package/dist/local-memory/inventory-cli.js +33 -17
  9. package/dist/local-memory/inventory-v2.js +282 -0
  10. package/dist/local-memory/migration-access.js +31 -8
  11. package/dist/local-memory/migration-pending.js +9 -0
  12. package/dist/local-memory/migration-plan.js +37 -3
  13. package/dist/local-memory/migration-state-invariants.js +65 -0
  14. package/dist/local-memory/migration-store.js +7 -35
  15. package/dist/local-memory/pending-query.js +5 -2
  16. package/dist/local-memory/pending-schema.js +1 -1
  17. package/dist/local-memory/pending.js +16 -2
  18. package/dist/local-memory/prompt.js +2 -2
  19. package/dist/local-memory/read-cli.js +4 -3
  20. package/dist/local-memory/rollout.js +32 -10
  21. package/dist/local-memory/runtime.js +46 -13
  22. package/dist/local-memory/write.js +63 -2
  23. package/dist/local-memory/writer-mode.js +13 -0
  24. package/dist/local-memory-diagnostics.js +55 -13
  25. package/dist/marketplace-protocol.js +1 -1
  26. package/dist/marketplace-remote.js +1 -1
  27. package/dist/marketplace-runner.js +1 -1
  28. package/dist/memory-migration-controller.js +2 -0
  29. package/dist/memory-migration-inspect.js +40 -7
  30. package/dist/prompt.js +1 -1
  31. package/dist/runtimes/claude-thread-runner.js +25 -4
  32. package/dist/serve.js +2 -0
  33. package/dist/workspace.js +3 -0
  34. package/package.json +1 -1
@@ -9,6 +9,7 @@ import { detectLocalMemoryStorageVersion, parseFullMemoryIndex } from "./storage
9
9
  import { readMemoryInventorySource, inventoryLimits } from "./inventory-source.js";
10
10
  import { MEMORY_STATE_GUARD_CAPABILITY, MEMORY_STORAGE_V2_CAPABILITY } from "../memory-migration-protocol.js";
11
11
  import { isLocalMemoryInitializationIdentity, localMemoryInitializationIdentity } from "./initialization-identity.js";
12
+ import { assertMigrationStateIntegrity } from "./migration-state-invariants.js";
12
13
  export const LOCAL_MEMORY_V2_WRITER_CAPABILITIES = [MEMORY_STORAGE_V2_CAPABILITY, MEMORY_STATE_GUARD_CAPABILITY];
13
14
  /**
14
15
  * Keep an execution that started before migration from writing a committed V2
@@ -77,14 +78,33 @@ export async function assertMemoryAccessAllowed(agentDir, options = {}) {
77
78
  try {
78
79
  const source = await readMemoryInventorySource(agentDir, ".crew/memory/migration.v1.json", inventoryLimits.fileBytes);
79
80
  state = StateGate.parse(JSON.parse(source.content));
80
- if (state.phase === "applying" || state.phase === "repair_required") {
81
- const agentId = options.initializedRecoveryAgentId;
82
- if (agentId === undefined)
81
+ if (state.phase === "prepared" || state.phase === "applying" || state.phase === "repair_required") {
82
+ if (options.pendingOnly && (state.phase === "prepared" || state.phase === "applying")) {
83
+ const fullState = MigrationStateSchema.parse(state);
84
+ assertMigrationStateIntegrity(fullState, fullState.identity);
85
+ // The migration inventory owns the pending directory while its journal
86
+ // is active. Allowing an execution to append a candidate here would
87
+ // make the next classify() see an unplanned file and force
88
+ // repair_required. Keep the journal fail-closed and let pending-only
89
+ // apply only to the marker-only legacy state (no active journal).
90
+ throw new Error("memory_migration_in_progress");
91
+ }
92
+ const recovery = options.initializedRecoveryIdentity;
93
+ if (recovery === undefined)
83
94
  throw new Error("memory_migration_in_progress");
84
95
  const fullState = MigrationStateSchema.parse(state);
85
- const identity = await localMemoryInitializationIdentity(agentDir, agentId);
96
+ assertMigrationStateIntegrity(fullState, fullState.identity);
97
+ const binding = recovery.workspaceId !== undefined && recovery.machineId !== undefined
98
+ ? { workspaceId: recovery.workspaceId, machineId: recovery.machineId }
99
+ : undefined;
100
+ const identity = await localMemoryInitializationIdentity(agentDir, recovery.agentId, binding);
101
+ const legacyIdentity = binding === undefined
102
+ ? identity
103
+ : await localMemoryInitializationIdentity(agentDir, recovery.agentId);
86
104
  if (fullState.origin !== "initialized"
87
- || !isLocalMemoryInitializationIdentity(fullState.identity, identity)) {
105
+ || (fullState.phase === "prepared" && fullState.intentReceipt !== undefined)
106
+ || (!isLocalMemoryInitializationIdentity(fullState.identity, identity)
107
+ && !isLocalMemoryInitializationIdentity(fullState.identity, legacyIdentity))) {
88
108
  throw new Error("memory_migration_in_progress");
89
109
  }
90
110
  return;
@@ -112,8 +132,10 @@ export async function assertMemoryAccessAllowed(agentDir, options = {}) {
112
132
  if (!state)
113
133
  throw new Error("memory_migration_state_missing");
114
134
  const verified = MigrationStateSchema.parse(state);
135
+ assertMigrationStateIntegrity(verified, verified.identity);
115
136
  if (verified.phase !== "committed" || verified.storageVersion !== 2 || version !== 2
116
- || verified.identity.rootFingerprint !== createHash("sha256").update(await realpath(agentDir)).digest("hex")) {
137
+ || verified.identity.rootFingerprint !== createHash("sha256").update(await realpath(agentDir)).digest("hex")
138
+ || verified.identity.ownerIdentity !== String((await lstat(agentDir)).uid)) {
117
139
  throw new Error("memory_migration_state_invalid");
118
140
  }
119
141
  parseFullMemoryIndex(source.content);
@@ -171,9 +193,10 @@ export async function withMemoryAccess(agentDir, operation, options = {}) {
171
193
  try {
172
194
  if (!options.maintenance)
173
195
  await assertMemoryAccessAllowed(root, {
174
- ...(options.initializedRecoveryAgentId === undefined ? {} : {
175
- initializedRecoveryAgentId: options.initializedRecoveryAgentId,
196
+ ...(options.initializedRecoveryIdentity === undefined ? {} : {
197
+ initializedRecoveryIdentity: options.initializedRecoveryIdentity,
176
198
  }),
199
+ ...(options.pendingOnly ? { pendingOnly: true } : {}),
177
200
  });
178
201
  lease.assertUsable();
179
202
  return await operation({ created });
@@ -0,0 +1,9 @@
1
+ import { detectLocalMemoryStorageVersion } from "./storage-format.js";
2
+ export function shouldUseMigrationDeferredPending(input) {
3
+ return input.pruneTraceId !== null
4
+ && input.writerMode === "blocked"
5
+ && input.rolloutEnabled
6
+ && input.legacyFallback !== true
7
+ && input.failure !== "inspection_failed"
8
+ && detectLocalMemoryStorageVersion(input.memory) === 1;
9
+ }
@@ -17,7 +17,10 @@ export function initializeExactWorkspaceSeed(bytes) {
17
17
  return null;
18
18
  const heading = '\n## Knowledge Index\n';
19
19
  const insertion = memory.length - tail.length + tail.indexOf(heading) + heading.length;
20
- return Buffer.from(`${memory.slice(0, insertion)}${renderFullMemoryIndex([])}\n${memory.slice(insertion)}`);
20
+ const initialized = `${memory.slice(0, insertion)}${renderFullMemoryIndex([])}\n${memory.slice(insertion)}`;
21
+ return Buffer.from(initialized
22
+ .replace(/<!-- 使用以下格式:[\s\S]*?-->\n<!-- 示例:[\s\S]*?-->\n/u, "<!-- V2 结构化索引由 daemon 管理。规范字段为 id、file、type、status、summary、triggers、priority、revision、anchor、updated,可选 review_after;不包含 scope。 -->\n")
23
+ .replace("- 每条索引必须保留 file、type、scope、summary、triggers、priority、updated 这些英文协议字段名。", "- V2 结构化索引由 daemon 管理;不要直接编辑索引字段或正文锚点。"));
21
24
  }
22
25
  const categories = /^(?:preferences|project-rules|testing|debugging|git|incidents|lessons)$/u;
23
26
  const blockPattern = /<!--\s*NOWWORK:MEMORY_ENTRY:BEGIN\s+id=([A-Za-z0-9_-]+)\s+revision=([1-9][0-9]*)\s*-->([\s\S]*?)<!--\s*NOWWORK:MEMORY_ENTRY:END\s+id=\1\s*-->/gu;
@@ -28,6 +31,29 @@ function migrationSummary(body, title) {
28
31
  .replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, 180);
29
32
  return clean(body) || clean(title) || 'Migrated memory';
30
33
  }
34
+ const MIGRATION_EPOCH_DATE = '1970-01-01';
35
+ function parseLegacyCardMetadata(card) {
36
+ const fieldMatches = [...card.matchAll(/^- ([a-z_]+):[^\S\r\n]*(.*)$/gmu)];
37
+ const fields = Object.fromEntries(fieldMatches.map(match => [match[1], match[2].trim()]));
38
+ const allowedFields = new Set(['file', 'scope', 'type', 'status', 'summary', 'priority', 'updated', 'triggers', 'review_after', 'id', 'anchor', 'revision']);
39
+ const category = /^notes\/(.+)\.md$/u.exec(fields.file ?? '')?.[1] ?? '';
40
+ const valid = !card.replace(/^- ([a-z_]+):[^\S\r\n]*(.*)$/gmu, '').replace(/^### .+\r?\n/u, '').trim()
41
+ && fieldMatches.length === Object.keys(fields).length
42
+ && Object.keys(fields).every(key => allowedFields.has(key))
43
+ && Boolean(categories.test(category) && fields.summary
44
+ && ['procedural', 'semantic', 'episodic', 'preference'].includes(fields.type ?? '')
45
+ && ['candidate', 'active', 'pinned', 'stale', 'superseded', 'archived'].includes(fields.status ?? '')
46
+ && ['high', 'medium', 'low'].includes(fields.priority ?? '') && fields.updated);
47
+ if (!valid)
48
+ return null;
49
+ return {
50
+ type: fields.type, summary: fields.summary,
51
+ priority: fields.priority, updated: fields.updated,
52
+ triggers: (fields.triggers ?? '').split(/[,,、]/u).map(item => item.trim()).filter(Boolean),
53
+ reviewAfter: fields.review_after ?? null,
54
+ };
55
+ }
56
+ const migrationDate = () => new Date().toISOString().slice(0, 10);
31
57
  export function validateMigrationTarget(files) {
32
58
  let totalBytes = 0;
33
59
  for (const [path, bytes] of files) {
@@ -61,6 +87,7 @@ export function planMemoryMigration(identity, files, supplied = []) {
61
87
  if (detectLocalMemoryStorageVersion(memory) !== identity.sourceStorageVersion)
62
88
  throw new MigrationError('migration_source_version_mismatch');
63
89
  const confirmedMappings = supplied.map(value => MigrationRecordMappingSchema.parse(value));
90
+ const fallbackUpdated = migrationDate();
64
91
  const mappingSourceDigests = new Map();
65
92
  for (const mapping of confirmedMappings) {
66
93
  if (mapping.sourceSha256 === undefined)
@@ -136,7 +163,7 @@ export function planMemoryMigration(identity, files, supplied = []) {
136
163
  const newEntries = additions.map(mapping => ({ id: mapping.targetId, title: mapping.title,
137
164
  file: `notes/${mapping.category}.md`, type: 'semantic', status: mapping.status,
138
165
  summary: migrationSummary(retainedHistory.get(mapping.targetId)?.body ?? files.get(mapping.sourceFile).toString('utf8').slice(mapping.start, mapping.end), mapping.title),
139
- triggers: [], priority: 'medium', updated: '1970-01-01', anchor: mapping.targetId, revision: retainedHistory.get(mapping.targetId)?.revision ?? 1, reviewAfter: null }));
166
+ triggers: [], priority: 'medium', updated: fallbackUpdated, anchor: mapping.targetId, revision: retainedHistory.get(mapping.targetId)?.revision ?? 1, reviewAfter: null }));
140
167
  for (const mapping of additions) {
141
168
  if (retainedHistory.has(mapping.targetId))
142
169
  continue;
@@ -229,8 +256,15 @@ export function planMemoryMigration(identity, files, supplied = []) {
229
256
  continue;
230
257
  }
231
258
  const id = mapping.targetId ?? `mem_${digest(`${identity.migrationId}:${mapping.sourceFile}:${mapping.start}:${mapping.end}`).slice(0, 24)}`;
259
+ const confirmedMetadata = mapping.sourceFile === 'MEMORY.md' ? parseLegacyCardMetadata(body.slice(mapping.start, mapping.end)) : null;
232
260
  entries.push({ id, title: mapping.title, file: `notes/${mapping.category}.md`, type: 'semantic', status: mapping.status, summary: migrationSummary(body.slice(mapping.start, mapping.end), mapping.title),
233
- triggers: [], priority: 'medium', updated: '1970-01-01', anchor: id, revision: 1, reviewAfter: null, ...knownMetadata.get(mapping.sourceFile) });
261
+ triggers: [], priority: 'medium', updated: fallbackUpdated, anchor: id, revision: 1, reviewAfter: null,
262
+ ...knownMetadata.get(mapping.sourceFile),
263
+ ...(confirmedMetadata ? (() => {
264
+ const source = confirmedMetadata;
265
+ return { type: source.type, summary: source.summary, triggers: source.triggers, priority: source.priority,
266
+ updated: source.updated === MIGRATION_EPOCH_DATE ? fallbackUpdated : source.updated, reviewAfter: source.reviewAfter };
267
+ })() : {}) });
234
268
  }
235
269
  for (const [path, bytes] of files) {
236
270
  if (!path.startsWith('notes/') && path !== 'MEMORY.md')
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { MigrationError, MigrationIntentReceiptSchema, } from './migration-schema.js';
3
+ export const migrationOperationRoot = (identity) => `.crew/memory/migrations/${identity.migrationId}`;
4
+ function canonical(data) {
5
+ if (Array.isArray(data))
6
+ return data.map(canonical);
7
+ if (data !== null && typeof data === 'object') {
8
+ return Object.fromEntries(Object.entries(data)
9
+ .sort(([left], [right]) => left.localeCompare(right))
10
+ .map(([key, value]) => [key, canonical(value)]));
11
+ }
12
+ return data;
13
+ }
14
+ export const encodeMigrationData = (data) => Buffer.from(JSON.stringify(canonical(data)));
15
+ export const sameMigrationData = (left, right) => encodeMigrationData(left).equals(encodeMigrationData(right));
16
+ export function migrationPlanDigest(identity, sourceDigest, manifest) {
17
+ return createHash('sha256')
18
+ .update(encodeMigrationData({ identity, sourceDigest, manifest }))
19
+ .digest('hex');
20
+ }
21
+ export function verifyMigrationIntent(receipt, state) {
22
+ const parsed = MigrationIntentReceiptSchema.parse(receipt);
23
+ for (const key of Object.keys(state.identity)) {
24
+ if (parsed[key] !== state.identity[key])
25
+ throw new MigrationError('migration_intent_identity_mismatch');
26
+ }
27
+ if (parsed.sourceDigest !== state.sourceDigest || parsed.planDigest !== state.planDigest) {
28
+ throw new MigrationError('migration_intent_digest_mismatch');
29
+ }
30
+ return parsed;
31
+ }
32
+ /** Pure validation shared by migration recovery and read-only inventory. */
33
+ export function assertMigrationStateIntegrity(state, identity) {
34
+ if (!sameMigrationData(state.identity, identity) || state.migrationId !== identity.migrationId
35
+ || state.planDigest !== migrationPlanDigest(identity, state.sourceDigest, state.manifest)) {
36
+ throw new MigrationError('migration_identity_or_manifest_invalid');
37
+ }
38
+ // A prepared/applying/rolled-back journal still describes the source store;
39
+ // only a committed journal describes the V2 target. repair_required may
40
+ // represent either side because it can be recorded after a committed target
41
+ // is found damaged. Reject contradictory states before any recovery or
42
+ // pending-only escape hatch can use them.
43
+ const sourceVersion = state.identity.sourceStorageVersion;
44
+ const sourcePhase = state.phase === 'prepared' || state.phase === 'applying' || state.phase === 'rolled_back';
45
+ if (state.origin === 'initialized' && sourceVersion !== 0
46
+ || sourcePhase && state.storageVersion !== sourceVersion
47
+ || state.phase === 'committed' && state.storageVersion !== state.targetStorageVersion
48
+ || state.targetStorageVersion !== identity.targetStorageVersion) {
49
+ throw new MigrationError('migration_identity_or_manifest_invalid');
50
+ }
51
+ const paths = state.manifest.files.map(file => file.path);
52
+ if (new Set(paths).size !== paths.length || !paths.includes('MEMORY.md')) {
53
+ throw new MigrationError('migration_manifest_invalid');
54
+ }
55
+ const operationRoot = `${migrationOperationRoot(identity)}/`;
56
+ for (const file of state.manifest.files) {
57
+ for (const image of [file.before, file.after]) {
58
+ if (image.exists && !image.ref.startsWith(operationRoot)) {
59
+ throw new MigrationError('migration_manifest_invalid');
60
+ }
61
+ }
62
+ }
63
+ if (state.intentReceipt !== undefined)
64
+ verifyMigrationIntent(state.intentReceipt, state);
65
+ }
@@ -3,33 +3,10 @@ import { dirname } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { atomicBytes, digest, inventory, readBytes, safePath, STATE_PATH, syncDirectory, validateMigrationRoot } from './migration-io.js';
5
5
  import { initializeExactWorkspaceSeed, planMemoryMigration, validateMigrationTarget } from './migration-plan.js';
6
- import { MigrationError, MigrationHistorySchema, MigrationIntentReceiptSchema, MigrationProcessStoppedReceiptSchema, MigrationStateSchema } from './migration-schema.js';
6
+ import { MigrationError, MigrationHistorySchema, MigrationProcessStoppedReceiptSchema, MigrationStateSchema } from './migration-schema.js';
7
+ import { assertMigrationStateIntegrity as assertIdentity, encodeMigrationData as encode, migrationOperationRoot as opRoot, migrationPlanDigest as planDigest, sameMigrationData as same, verifyMigrationIntent as verifyIntent, } from './migration-state-invariants.js';
7
8
  import { detectLocalMemoryStorageVersion } from './storage-format.js';
8
9
  import { isLocalMemoryInitializationIdentity, localMemoryInitializationIdentity } from './initialization-identity.js';
9
- const opRoot = (identity) => `.crew/memory/migrations/${identity.migrationId}`;
10
- function canonical(data) {
11
- if (Array.isArray(data))
12
- return data.map(canonical);
13
- if (data !== null && typeof data === 'object')
14
- return Object.fromEntries(Object.entries(data).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => [key, canonical(value)]));
15
- return data;
16
- }
17
- const encode = (data) => Buffer.from(JSON.stringify(canonical(data)));
18
- const same = (a, b) => encode(a).equals(encode(b));
19
- const planDigest = (identity, sourceDigest, manifest) => digest(encode({ identity, sourceDigest, manifest }));
20
- function assertIdentity(state, identity) {
21
- if (!same(state.identity, identity) || state.migrationId !== identity.migrationId
22
- || state.planDigest !== planDigest(identity, state.sourceDigest, state.manifest))
23
- throw new MigrationError('migration_identity_or_manifest_invalid');
24
- const paths = state.manifest.files.map(file => file.path);
25
- if (new Set(paths).size !== paths.length || !paths.includes('MEMORY.md'))
26
- throw new MigrationError('migration_manifest_invalid');
27
- for (const file of state.manifest.files)
28
- for (const image of [file.before, file.after]) {
29
- if (image.exists && !image.ref.startsWith(`${opRoot(identity)}/`))
30
- throw new MigrationError('migration_manifest_invalid');
31
- }
32
- }
33
10
  async function immutable(root, path, data) {
34
11
  const previous = await readBytes(root, path, 64 * 1024 * 1024);
35
12
  if (previous) {
@@ -41,6 +18,7 @@ async function immutable(root, path, data) {
41
18
  }
42
19
  async function save(root, state) {
43
20
  const parsed = MigrationStateSchema.parse(state);
21
+ assertIdentity(parsed, parsed.identity);
44
22
  if (parsed.phase === 'committed' || parsed.phase === 'rolled_back') {
45
23
  const previous = await readBytes(root, `${opRoot(parsed.identity)}/terminal.json`, 32 * 1024 * 1024);
46
24
  if (previous && !same(JSON.parse(previous.toString('utf8')), parsed)) {
@@ -76,15 +54,6 @@ export async function statusMemoryMigration(input) {
76
54
  const identity = await validateMigrationRoot(input.agentDir, input.identity);
77
55
  return load({ ...input, identity });
78
56
  }
79
- function verifyIntent(receipt, state) {
80
- const parsed = MigrationIntentReceiptSchema.parse(receipt);
81
- for (const key of Object.keys(state.identity))
82
- if (parsed[key] !== state.identity[key])
83
- throw new MigrationError('migration_intent_identity_mismatch');
84
- if (parsed.sourceDigest !== state.sourceDigest || parsed.planDigest !== state.planDigest)
85
- throw new MigrationError('migration_intent_digest_mismatch');
86
- return parsed;
87
- }
88
57
  export async function prepareMemoryMigration(input, hooks = {}) {
89
58
  const identity = await validateMigrationRoot(input.agentDir, input.identity);
90
59
  const history = MigrationHistorySchema.parse(input.serverHistory);
@@ -120,7 +89,10 @@ export async function prepareMemoryMigration(input, hooks = {}) {
120
89
  const previousSeed = existing?.origin === 'initialized' && existing.phase === 'rolled_back'
121
90
  ? existing.manifest.files.find(file => file.path === 'MEMORY.md')?.before : undefined;
122
91
  const retryingOriginalSeed = previousSeed?.exists === true && sourceMemory !== undefined && digest(sourceMemory) === previousSeed.sha256;
123
- const expectedInitializationIdentity = await localMemoryInitializationIdentity(input.agentDir, identity.agentId);
92
+ const expectedInitializationIdentity = await localMemoryInitializationIdentity(input.agentDir, identity.agentId, {
93
+ workspaceId: identity.workspaceId,
94
+ machineId: identity.machineId,
95
+ });
124
96
  const onlyCurrentOperationResidue = isLocalMemoryInitializationIdentity(identity, expectedInitializationIdentity)
125
97
  && existing === null && operations.length === 1
126
98
  && operations[0] === identity.migrationId;
@@ -15,6 +15,7 @@ const SearchSchema = z.object({
15
15
  cursor: z.string().max(2048).optional(), limit: z.number().int().min(1).max(40).default(10),
16
16
  }).strict();
17
17
  const excerpt = (text, cap) => Array.from(text).slice(0, cap).join("");
18
+ const pendingState = (reason) => reason === "legacy_requires_migration" ? "migration_deferred" : "capacity_deferred";
18
19
  export async function searchPendingMemoryPage(input) {
19
20
  const parsed = SearchSchema.safeParse(input);
20
21
  if (!parsed.success)
@@ -61,7 +62,8 @@ export async function searchPendingMemoryPage(input) {
61
62
  const match = source && isRecallablePending(source.candidate) && pendingRecallScore(source.candidate, args.query) > 0
62
63
  ? { pendingId: source.candidate.pendingId, sha256: source.sha256, title: source.candidate.candidate.title,
63
64
  summary: excerpt(source.candidate.candidate.summary, 180), triggers: source.candidate.candidate.triggers.slice(0, 4),
64
- operation: "create", state: "capacity_deferred" }
65
+ operation: "create", reason: source.candidate.reason,
66
+ state: pendingState(source.candidate.reason) }
65
67
  : null;
66
68
  const addition = match ?? warning;
67
69
  const size = addition ? Buffer.byteLength(JSON.stringify(addition), "utf8") + 2 : 0;
@@ -96,5 +98,6 @@ export async function readPendingMemoryBody(input) {
96
98
  throw new Error("memory_pending_not_recallable");
97
99
  const candidate = source.candidate.candidate;
98
100
  return { pendingId: source.candidate.pendingId, sha256: source.sha256, title: candidate.title,
99
- operation: "create", state: "capacity_deferred", content: candidate.detailBody };
101
+ operation: "create", reason: source.candidate.reason,
102
+ state: pendingState(source.candidate.reason), content: candidate.detailBody };
100
103
  }
@@ -8,7 +8,7 @@ const ArchivedMemoryChangeSchema = LocalMemoryChangeSchema.extend({
8
8
  export const PendingIdSchema = z.string().max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u);
9
9
  export const PendingMemorySchema = z.object({
10
10
  schemaVersion: z.literal(1), pendingId: PendingIdSchema,
11
- createdAt: z.string().min(1).max(100), reason: z.literal("index_over_budget"),
11
+ createdAt: z.string().min(1).max(100), reason: z.enum(["index_over_budget", "legacy_requires_migration"]),
12
12
  source: z.object({ executionId: z.string().min(1).max(500), pruneTraceId: z.string().min(1).max(500).nullable() }).strict(),
13
13
  candidate: ArchivedMemoryChangeSchema,
14
14
  }).strict();
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, realpath, rename, rm, readFile, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { isRecallablePending, parsePendingMemoryCandidate } from "./pending-schema.js";
5
5
  import { pendingFileNames, pendingReadDirectory, PENDING_FILE_BYTES, readPendingSource } from "./pending-source.js";
@@ -103,6 +103,17 @@ export async function persistPendingMemoryCandidate(input) {
103
103
  const directory = await pendingDirectory(input.agentDir);
104
104
  const target = join(directory, `${parsed.pendingId}.json`);
105
105
  await assertRegularFile(target);
106
+ try {
107
+ const existing = parsePendingMemoryCandidate(JSON.parse(await readFile(target, "utf8")));
108
+ const dedupKey = (value) => value.reason === "legacy_requires_migration"
109
+ ? JSON.stringify({ schemaVersion: value.schemaVersion, reason: value.reason, candidate: value.candidate })
110
+ : JSON.stringify({ ...value, createdAt: "" });
111
+ if (dedupKey(existing) === dedupKey(parsed))
112
+ return target;
113
+ }
114
+ catch {
115
+ // A malformed existing candidate is replaced by the validated input below.
116
+ }
106
117
  await atomicWrite(target, `${JSON.stringify(parsed, null, 2)}\n`);
107
118
  return target;
108
119
  }
@@ -164,7 +175,10 @@ function truncateCodePoints(value, maxCodePoints) {
164
175
  return Array.from(value).slice(0, Math.max(0, maxCodePoints)).join("");
165
176
  }
166
177
  export function renderPendingMemoryContext(candidates, maxCodePoints = 2_000) {
167
- const recallable = candidates.filter(isRecallablePending);
178
+ // Migration-deferred candidates remain explicitly searchable, but must not
179
+ // be injected into the ordinary prompt before a reviewed migration promotes
180
+ // them to the active catalog.
181
+ const recallable = candidates.filter((candidate) => isRecallablePending(candidate) && candidate.reason === "index_over_budget");
168
182
  if (recallable.length === 0 || maxCodePoints <= 0)
169
183
  return "";
170
184
  const header = `\n\n## Archived memory candidates (daemon-managed)\n容量暂存的新增资料,仅为部分预览,不代表完整检索;可参考但不能覆盖现行规则,未包含内容请按需查询。\n`;
@@ -2,7 +2,7 @@ import { dslog } from "../slog.js";
2
2
  import { renderPendingMemoryContext, searchPendingMemoryCandidates } from "./pending.js";
3
3
  import { renderLocalMemoryResultInstructions, renderLocalMemoryV2WriteGuard } from "./runtime.js";
4
4
  import { LOCAL_MEMORY_V2_DEFERRED_GUIDANCE, LOCAL_MEMORY_LEGACY_GUIDANCE } from "./rollout.js";
5
- const PENDING_READ_GUIDANCE = "\n\nV2新增资料预览未命中不代表没有资料;先查有效索引,必要时用 crew-memory search-pending --query '关键词',按 nextCursor 续查;用 read-pending --id ID 分页读正文。容量暂存资料不能覆盖现行规则,未应用修改不作为记忆。用 crew-memory --help 查看参数。\n";
5
+ const PENDING_READ_GUIDANCE = "\n\nV2新增资料预览未命中不代表没有资料;先查有效索引,必要时用 crew-memory search-pending --query '关键词',按 nextCursor 续查;用 read-pending --id ID 分页读正文。容量暂存或迁移延期资料不能覆盖现行规则,未应用修改不作为记忆;迁移延期候选必须等 reviewed migration 后再按当前 ID/revision/hash 处理。用 crew-memory --help 查看参数。\n";
6
6
  export async function prepareLocalMemoryPrompt(input) {
7
7
  let pending = "";
8
8
  if (input.enabled) {
@@ -24,7 +24,7 @@ export async function prepareLocalMemoryPrompt(input) {
24
24
  const original = input.renderBase(0);
25
25
  const guard = input.legacyFallback ? LOCAL_MEMORY_LEGACY_GUIDANCE : input.deferred ? LOCAL_MEMORY_V2_DEFERRED_GUIDANCE
26
26
  : input.strictRollout && input.enabled && input.resultPath === null ? renderLocalMemoryV2WriteGuard() : "";
27
- const structured = input.resultPath === null ? "" : renderLocalMemoryResultInstructions(input.resultPath, input.storageVersion);
27
+ const structured = input.resultPath === null ? "" : renderLocalMemoryResultInstructions(input.resultPath, input.storageVersion, input.migrationDeferred === true);
28
28
  const guidance = input.enabled ? PENDING_READ_GUIDANCE : "";
29
29
  let requiredMemoryBudgetError;
30
30
  // 短命令指引放在当前轮,独立于可舍弃预览;收尾契约仍保持系统与当前轮两份。
@@ -81,10 +81,10 @@ search 关键词按空白分隔,同一行须全部命中;返回完整文件
81
81
  后续页可传 --if-hash <前一页的 sha256>,文件变化时停止,避免混读不同版本。
82
82
  schema=2 的 search 查询完整有效目录(active/pinned),按标题、摘要、触发词联合匹配并返回 ID、revision 和正文引用;search-history 显式查询历史/失效记录。预览未展示不代表记录不存在。
83
83
  旧格式 search/search-history 及 read 读取原文;search-history 是显式 legacy fallback,不把旧内容当作已结构化 V2 记录;read 按指定文件和哈希分页,不扫描 pending。
84
- search-pending 只查因容量延期的 create 候选;有 nextCursor 就继续,即使本页 matches 为空。
84
+ search-pending 只查容量延期或迁移延期的 create 候选;有 nextCursor 就继续,即使本页 matches 为空。
85
85
  complete=true 才表示全部候选已检索完成且无遗漏;warnings 或非零退出码表示读取未完成,不能当作零命中。
86
86
  read-pending 只返回候选正文;offset 非零时必须传 --if-hash,update/supersede 候选不可读取。
87
- 候选只供相关问题参考,未生效,不能覆盖有效规则;先读取当前有效索引与明细。`;
87
+ 候选只供相关问题参考,未生效,不能覆盖有效规则;先读取当前有效索引与明细。迁移延期候选会标记为 migration_deferred,必须等 reviewed migration 后再按当前 ID/revision/hash 处理。`;
88
88
  function inside(root, path) {
89
89
  const value = relative(root, path);
90
90
  return value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value);
@@ -180,8 +180,9 @@ function writeResult(result, io) {
180
180
  export async function runMemoryReadCommand(argv, io) {
181
181
  if (!io.env.CREW_HOME || argv.includes("--help") || argv.includes("-h"))
182
182
  return runMemoryReadUnlocked(argv, io);
183
+ const pendingOnly = argv[0] === "search-pending" || argv[0] === "read-pending";
183
184
  try {
184
- return await withMemoryAccess(io.env.CREW_HOME, () => runMemoryReadUnlocked(argv, io));
185
+ return await withMemoryAccess(io.env.CREW_HOME, () => runMemoryReadUnlocked(argv, io), pendingOnly ? { pendingOnly: true } : {});
185
186
  }
186
187
  catch (error) {
187
188
  const reason = error instanceof Error && /^(?:local_)?memory_[a-z_]+$/u.test(error.message) ? error.message : "memory_migration_access_failed";
@@ -10,6 +10,19 @@ import { dslog } from "../slog.js";
10
10
  import { createHash } from "node:crypto";
11
11
  import { detectLocalMemoryStorageVersion, parseFullMemoryIndex } from "./storage-format.js";
12
12
  import { MigrationStateSchema } from "./migration-schema.js";
13
+ import { assertMigrationStateIntegrity } from "./migration-state-invariants.js";
14
+ const TRANSIENT_INSPECTION_ERRORS = new Set([
15
+ "EACCES", "EPERM", "EIO", "EBUSY", "EAGAIN", "EINTR", "ESTALE", "EMFILE", "ENFILE", "ENOMEM", "ETIMEDOUT",
16
+ "source_changed", "memory_migration_access_busy",
17
+ ]);
18
+ export function localMemoryRolloutFailure(error) {
19
+ const code = error?.code;
20
+ const reason = error instanceof Error ? error.message : "";
21
+ return (typeof code === "string" && TRANSIENT_INSPECTION_ERRORS.has(code))
22
+ || TRANSIENT_INSPECTION_ERRORS.has(reason)
23
+ ? "inspection_failed"
24
+ : "requires_migration";
25
+ }
13
26
  export const LOCAL_MEMORY_V2_DEFERRED_GUIDANCE = `
14
27
 
15
28
  ## 本轮本地记忆兼容状态
@@ -22,12 +35,19 @@ export const LOCAL_MEMORY_LEGACY_GUIDANCE = `
22
35
  本 Agent 尚未完成 V2 格式转换,本轮继续原记忆流程。用户明确要求新增或修改长期规则时,可按原规则最小范围更新共享记忆;记忆收尾继续依据任务日志更新原 MEMORY.md 和 notes,确认成功后再清理日志。
23
36
  本节覆盖旧会话中因等待 V2 转换而冻结共享记忆写入的要求,但不是新的用户写入授权;本任务如明确要求不更新记忆,继续遵守。
24
37
  保留原身份、目标及无关资料,不自行迁移或重置文件,不宣称已启用 V2。`;
25
- export function reportLocalMemoryV2Deferred(executionId, handle, memoryPrune, legacyFallback = false) {
26
- dslog("local_memory.v2_deferred", legacyFallback ? "旧格式继续原记忆流程,转换成功后再启用V2" : "记忆格式异常,保留原资料和待处理日志", {
27
- execution_id: executionId, agent_handle: handle, reason: legacyFallback ? "legacy_compatible" : "requires_migration"
38
+ export function reportLocalMemoryV2Deferred(executionId, handle, memoryPrune, legacyFallback = false, failure = "requires_migration", allowPendingOnly = false) {
39
+ dslog("local_memory.v2_deferred", allowPendingOnly
40
+ ? "迁移尚未完成,新增候选隔离保存到 pending"
41
+ : legacyFallback ? "旧格式继续原记忆流程,转换成功后再启用V2" : "记忆格式异常,保留原资料和待处理日志", {
42
+ execution_id: executionId,
43
+ agent_handle: handle,
44
+ reason: legacyFallback ? "legacy_compatible" : failure,
45
+ writer_mode: allowPendingOnly ? "pending_only" : legacyFallback ? "legacy" : "blocked",
46
+ ...(allowPendingOnly ? { pending_reason: "legacy_requires_migration" } : {}),
28
47
  });
29
- if (memoryPrune && !legacyFallback)
30
- throw new Error("local_memory_v2_requires_migration");
48
+ if (memoryPrune && !legacyFallback && !allowPendingOnly) {
49
+ throw new Error(failure === "inspection_failed" ? "local_memory_inspection_failed" : "local_memory_v2_requires_migration");
50
+ }
31
51
  }
32
52
  /** 只核对小型索引和其引用;不扫描全部历史任务、不迁移或生成新存储。 */
33
53
  export async function resolveLocalMemoryV2Rollout(input) {
@@ -43,14 +63,16 @@ export async function resolveLocalMemoryV2Rollout(input) {
43
63
  const root = await realpath(input.workspace.dir);
44
64
  const record = await readMemoryInventorySource(root, ".crew/memory/migration.v1.json", inventoryLimits.fileBytes);
45
65
  const state = MigrationStateSchema.parse(JSON.parse(record.content));
66
+ assertMigrationStateIntegrity(state, state.identity);
46
67
  if (state.phase !== "committed" || state.storageVersion !== 2
47
68
  || state.identity.agentId !== (input.agentId ?? input.handle)
48
- || state.identity.rootFingerprint !== createHash("sha256").update(root).digest("hex"))
69
+ || state.identity.rootFingerprint !== createHash("sha256").update(root).digest("hex")
70
+ || state.identity.ownerIdentity !== String((await lstat(root)).uid))
49
71
  throw new Error("invalid_state");
50
72
  return { enabled: true, deferred: false };
51
73
  }
52
- catch {
53
- return { enabled: false, deferred: true };
74
+ catch (error) {
75
+ return { enabled: false, deferred: true, failure: localMemoryRolloutFailure(error) };
54
76
  }
55
77
  }
56
78
  if (environment === "production")
@@ -122,7 +144,7 @@ export async function resolveLocalMemoryV2Rollout(input) {
122
144
  }
123
145
  return { enabled: true, deferred: false };
124
146
  }
125
- catch {
126
- return deferred;
147
+ catch (error) {
148
+ return { ...deferred, failure: localMemoryRolloutFailure(error) };
127
149
  }
128
150
  }
@@ -55,8 +55,8 @@ function initializedIntent(identity, state) {
55
55
  * publication path used by migration. The marker is written only after the
56
56
  * committed catalog and state have both been verified.
57
57
  */
58
- export async function initializeLocalMemoryV2(agentDir, agentId) {
59
- const identity = await localMemoryInitializationIdentity(agentDir, agentId);
58
+ async function initializeLocalMemoryV2WithBinding(agentDir, agentId, binding) {
59
+ const identity = await localMemoryInitializationIdentity(agentDir, agentId, binding);
60
60
  let state = await prepareMemoryMigration({
61
61
  agentDir,
62
62
  identity,
@@ -96,7 +96,11 @@ export async function initializeLocalMemoryV2(agentDir, agentId) {
96
96
  }
97
97
  await writeLocalMemoryV2Marker(agentDir);
98
98
  }
99
- export async function initializeLocalMemoryV2ForWorkspace(workspace, enabledForHandle, agentId) {
99
+ /** Fresh initialization requires the identity from the authenticated ready frame. */
100
+ export function initializeLocalMemoryV2(agentDir, agentId, binding) {
101
+ return initializeLocalMemoryV2WithBinding(agentDir, agentId, binding);
102
+ }
103
+ export async function initializeLocalMemoryV2ForWorkspace(workspace, enabledForHandle, agentId, binding) {
100
104
  const initialized = workspace.localMemoryV2Initialized === true;
101
105
  if (initialized)
102
106
  return true;
@@ -105,12 +109,19 @@ export async function initializeLocalMemoryV2ForWorkspace(workspace, enabledForH
105
109
  if (workspace.localMemoryV2Initialized !== false)
106
110
  return false;
107
111
  let resumable = false;
112
+ let effectiveBinding = binding;
108
113
  try {
109
114
  const state = MigrationStateSchema.parse(JSON.parse(await readFile(join(workspace.dir, ".crew", "memory", "migration.v1.json"), "utf8")));
110
- const identity = await localMemoryInitializationIdentity(workspace.dir, agentId);
115
+ const identity = await localMemoryInitializationIdentity(workspace.dir, agentId, binding);
116
+ const legacyIdentity = binding === undefined
117
+ ? identity
118
+ : await localMemoryInitializationIdentity(workspace.dir, agentId);
111
119
  resumable = state.origin === "initialized"
112
120
  && state.phase !== "rolled_back"
113
- && isLocalMemoryInitializationIdentity(state.identity, identity);
121
+ && (isLocalMemoryInitializationIdentity(state.identity, identity)
122
+ || isLocalMemoryInitializationIdentity(state.identity, legacyIdentity));
123
+ if (resumable && isLocalMemoryInitializationIdentity(state.identity, legacyIdentity))
124
+ effectiveBinding = undefined;
114
125
  }
115
126
  catch (error) {
116
127
  if (error.code !== "ENOENT")
@@ -120,8 +131,18 @@ export async function initializeLocalMemoryV2ForWorkspace(workspace, enabledForH
120
131
  if (!migrations.isDirectory() || migrations.isSymbolicLink()) {
121
132
  throw new Error("local_memory_v2_initialization_residue_invalid");
122
133
  }
123
- const identity = await localMemoryInitializationIdentity(workspace.dir, agentId);
124
- const operation = await lstat(join(workspace.dir, ".crew", "memory", "migrations", identity.migrationId));
134
+ const identity = await localMemoryInitializationIdentity(workspace.dir, agentId, binding);
135
+ const legacyIdentity = binding === undefined
136
+ ? identity
137
+ : await localMemoryInitializationIdentity(workspace.dir, agentId);
138
+ let operationIdentity = identity;
139
+ const operation = await lstat(join(workspace.dir, ".crew", "memory", "migrations", identity.migrationId))
140
+ .catch(async (error) => {
141
+ if (error.code !== "ENOENT" || identity.migrationId === legacyIdentity.migrationId)
142
+ throw error;
143
+ operationIdentity = legacyIdentity;
144
+ return lstat(join(workspace.dir, ".crew", "memory", "migrations", legacyIdentity.migrationId));
145
+ });
125
146
  if (!operation.isDirectory() || operation.isSymbolicLink()) {
126
147
  throw new Error("local_memory_v2_initialization_residue_invalid");
127
148
  }
@@ -130,6 +151,8 @@ export async function initializeLocalMemoryV2ForWorkspace(workspace, enabledForH
130
151
  resumable = current.files.size === 1 && memory !== undefined
131
152
  && current.directories.every(path => path === "notes" || path === ".crew/memory/pending")
132
153
  && initializeExactWorkspaceSeed(memory) !== null;
154
+ if (resumable && operationIdentity.migrationId === legacyIdentity.migrationId)
155
+ effectiveBinding = undefined;
133
156
  }
134
157
  catch (residueError) {
135
158
  if (residueError.code !== "ENOENT")
@@ -142,17 +165,23 @@ export async function initializeLocalMemoryV2ForWorkspace(workspace, enabledForH
142
165
  && workspace.memorySeedCreated === true;
143
166
  if (!resumable && !pristineFirstInitialization)
144
167
  return false;
145
- await initializeLocalMemoryV2(workspace.dir, agentId);
168
+ // Never create another unbound sentinel. Undefined is retained solely to
169
+ // finish a pre-upgrade journal already proven above.
170
+ if (!resumable && effectiveBinding === undefined)
171
+ return false;
172
+ await initializeLocalMemoryV2WithBinding(workspace.dir, agentId, effectiveBinding);
146
173
  return true;
147
174
  }
148
175
  export function localMemoryResultPath(runDir, executionId) {
149
176
  const suffix = createHash("sha256").update(executionId, "utf8").digest("hex").slice(0, 32);
150
177
  return join(runDir, `.memory-result.v1-${suffix}.json`);
151
178
  }
152
- export function renderLocalMemoryResultInstructions(resultPath, storageVersion = 1) {
153
- const storagePolicy = storageVersion === 2
154
- ? "完整目录保存在 MEMORY.md,模型只看到有界预览;未展示记录仍然有效。涉及旧规则时先用 crew-memory search 查询完整目录,按当前 ID/revision 更新,不因预览未命中就重复新建。20条预览不是有效记录上限;完整存储超出资源上限会拒绝提交,不得自行删除或降级旧记录。已有 pending 仍是未应用候选,须重新核对授权、重复与版本后通过 changes 申请保存,不因迁移自动生效。"
155
- : "daemon 会校验、去重、限制 MEMORY.md 大小,超出索引容量的候选会保存在 pending 归档中。容量暂存的 create 可按需检索参考但不能覆盖现行规则;有空间的后续 proposed 提交可提升。未应用的 update/supersede 不进入普通召回,必须重新核对目标版本后处理。";
179
+ export function renderLocalMemoryResultInstructions(resultPath, storageVersion = 1, migrationDeferred = false) {
180
+ const storagePolicy = migrationDeferred
181
+ ? "该 Agent 的旧记忆仍在迁移审阅中。本轮只允许提交新的 create 候选;daemon 会把候选原子保存到 .crew/memory/pending/,reason=legacy_requires_migration,并提供 search-pending/read-pending 查询。候选不会进入 active,也不会自动注入普通上下文;更新、替代和 statusChanges 必须留待迁移完成后按当前 V2 ID/revision/hash 重新核对。旧 MEMORY.md、notes 和已有 active/pending 保持不变。"
182
+ : storageVersion === 2
183
+ ? "完整目录保存在 MEMORY.md,模型只看到有界预览;未展示记录仍然有效。涉及旧规则时先用 crew-memory search 查询完整目录,按当前 ID/revision 更新,不因预览未命中就重复新建。20条预览不是有效记录上限;完整存储超出资源上限会拒绝提交,不得自行删除或降级旧记录。已有 pending 仍是未应用候选,须重新核对授权、重复与版本后通过 changes 申请保存,不因迁移自动生效。"
184
+ : "daemon 会校验、去重、限制 MEMORY.md 大小,超出索引容量的候选会保存在 pending 归档中。容量暂存的 create 可按需检索参考但不能覆盖现行规则;有空间的后续 proposed 提交可提升。未应用的 update/supersede 不进入普通召回,必须重新核对目标版本后处理。";
156
185
  return `
157
186
 
158
187
  ## Structured memory prune (daemon-managed)
@@ -243,6 +272,8 @@ export async function commitLocalMemoryResultAfterExit(input) {
243
272
  executionId: input.executionId,
244
273
  pruneTraceId: input.pruneTraceId,
245
274
  ...(input.writerCapabilities === undefined ? {} : { writerCapabilities: input.writerCapabilities }),
275
+ ...(input.requiredStorageVersion === undefined ? {} : { requiredStorageVersion: input.requiredStorageVersion }),
276
+ ...(input.migrationDeferredPending ? { migrationDeferredPending: true } : {}),
246
277
  });
247
278
  storageAfter = await localMemoryStorageFacts(input.agentDir);
248
279
  if (input.requiredStorageVersion === 2
@@ -282,5 +313,7 @@ export async function commitLocalMemoryResultAfterExit(input) {
282
313
  return { status: "applied", storageVersionBefore: storageBefore.storageVersion,
283
314
  storageVersionAfter: storageAfter.storageVersion, committedState: storageAfter.committedState,
284
315
  createdIds: applied.createdIds, updatedIds: applied.updatedIds,
285
- statusChangedIds: applied.statusChangedIds, pendingIds: applied.pendingIds };
316
+ statusChangedIds: applied.statusChangedIds, pendingIds: applied.pendingIds,
317
+ ...(input.migrationDeferredPending
318
+ ? { pendingReason: "legacy_requires_migration" } : {}) };
286
319
  }