mocode-ai 0.7.1 → 0.7.2

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.
@@ -14,7 +14,7 @@ import { runAgentCore, isMutationTool, } from './core.js';
14
14
  import { createPetHooks } from '../pet/state.js';
15
15
  import { t } from '../i18n/index.js';
16
16
  import { isToolErrorOutput } from '../tools/result.js';
17
- import { appendCurrentSessionTrace } from '../session/index.js';
17
+ import { appendCurrentSessionTraceEvent } from '../session/index.js';
18
18
  /** 当前 turn 的 batch id(runAgent 内闭包变量;一条 turn 一轮 tool batch 结束即清空)。 */
19
19
  let currentBatchId = null;
20
20
  /** 取 userInput 的首行:字符串直接 split;多模态 parts 找首个 text part 再 split。 */
@@ -229,7 +229,7 @@ onContextUpdate) {
229
229
  onContextUpdate,
230
230
  hooks: combinedHooks,
231
231
  autoValidate: config.autoValidate,
232
- onTrace: appendCurrentSessionTrace,
232
+ onTraceEvent: appendCurrentSessionTraceEvent,
233
233
  });
234
234
  }
235
235
  finally {
package/dist/llm/index.js CHANGED
@@ -229,6 +229,14 @@ function firstNumber(arr) {
229
229
  }
230
230
  return undefined;
231
231
  }
232
+ function retryErrorCode(error) {
233
+ if (!error || typeof error !== 'object')
234
+ return 'RETRYABLE_ERROR';
235
+ const value = error;
236
+ if (typeof value.status === 'number')
237
+ return `HTTP_${value.status}`;
238
+ return value.code ?? value.name ?? 'RETRYABLE_ERROR';
239
+ }
232
240
  /**
233
241
  * 流式调一次 LLM:增量回调文本,内部累加 tool_calls 片段。
234
242
  * tool_calls 跨 chunk 按 index 累加(id / name / arguments 拼接)。
@@ -254,6 +262,12 @@ toolsOverride) {
254
262
  throw err;
255
263
  }
256
264
  const wait = computeBackoff(attempt, getRetryAfterMs(err));
265
+ handlers.onRetry?.({
266
+ attempt,
267
+ nextAttempt: attempt + 1,
268
+ waitMs: wait,
269
+ code: retryErrorCode(err),
270
+ });
257
271
  logRetry(attempt, err, wait);
258
272
  // sleep 自己会在 signal abort 时抛 AbortError——透传,让 runAgentCore 的 catch 按中断处理。
259
273
  await sleep(wait, signal);
@@ -7,7 +7,9 @@ import { config } from '../config/index.js';
7
7
  import { getSandboxRoot } from '../sandbox/index.js';
8
8
  import { t } from '../i18n/index.js';
9
9
  const PERMISSIONS_PATH = path.join(os.homedir(), '.mocode', 'permissions.json');
10
+ const PERMISSIONS_VERSION = 3;
10
11
  let permanentGrants = [];
12
+ let permanentToolAllows = new Set();
11
13
  let permanentLoaded = false;
12
14
  const sessionGrants = [];
13
15
  function stable(value) {
@@ -58,16 +60,27 @@ function loadPermanent() {
58
60
  permanentGrants = Array.isArray(parsed.grants)
59
61
  ? parsed.grants.filter(validGrant).filter((grant) => grant.scope === 'project')
60
62
  : [];
63
+ // Only the explicit v3 field enables broad grants. The retired legacy allowForever
64
+ // field remains ignored so upgrades cannot silently restore old authorization.
65
+ permanentToolAllows = parsed.version === PERMISSIONS_VERSION && Array.isArray(parsed.alwaysAllowTools)
66
+ ? new Set(parsed.alwaysAllowTools.filter((tool) => typeof tool === 'string' && tool.length > 0))
67
+ : new Set();
61
68
  }
62
69
  catch {
63
70
  permanentGrants = [];
71
+ permanentToolAllows = new Set();
64
72
  }
65
73
  return permanentGrants;
66
74
  }
67
75
  function savePermanent() {
68
76
  try {
69
77
  fs.mkdirSync(path.dirname(PERMISSIONS_PATH), { recursive: true });
70
- fs.writeFileSync(PERMISSIONS_PATH, `${JSON.stringify({ version: 2, grants: permanentGrants }, null, 2)}\n`, 'utf8');
78
+ const data = {
79
+ version: PERMISSIONS_VERSION,
80
+ grants: permanentGrants,
81
+ alwaysAllowTools: [...permanentToolAllows].sort(),
82
+ };
83
+ fs.writeFileSync(PERMISSIONS_PATH, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
71
84
  }
72
85
  catch {
73
86
  // A failed persistence write must never turn into broader authorization.
@@ -98,11 +111,14 @@ export async function checkPermission(tool, args, signal, options = {}) {
98
111
  return 'allow';
99
112
  if (signal?.aborted)
100
113
  return 'deny';
114
+ loadPermanent();
115
+ if (permanentToolAllows.has(tool.name))
116
+ return 'allow';
101
117
  const fingerprint = permissionFingerprint(tool, args);
102
118
  const projectRoot = canonicalProjectRoot(options.projectRoot ?? getSandboxRoot() ?? process.cwd());
103
119
  if (sessionGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
104
120
  return 'allow';
105
- if (loadPermanent().some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
121
+ if (permanentGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
106
122
  return 'allow';
107
123
  // CI/pipes must fail closed. Operators can deliberately restore unattended behavior.
108
124
  if (!process.stdin.isTTY && !config.permissionNonInteractiveAllow && !options.prompt)
@@ -112,9 +128,10 @@ export async function checkPermission(tool, args, signal, options = {}) {
112
128
  const onceOption = t('permission.allow');
113
129
  const sessionOption = t('permission.allowSessionResource');
114
130
  const projectOption = t('permission.allowProjectResource');
131
+ const alwaysOption = t('permission.allowForever');
115
132
  const denyOption = t('permission.deny');
116
133
  const dangerous = getToolRisk(tool) === 'dangerous';
117
- const choices = [onceOption, sessionOption, projectOption, denyOption];
134
+ const choices = [onceOption, sessionOption, projectOption, alwaysOption, denyOption];
118
135
  const result = await (options.prompt ?? promptIntervention)({
119
136
  type: 'choice',
120
137
  title: dangerous
@@ -131,23 +148,44 @@ export async function checkPermission(tool, args, signal, options = {}) {
131
148
  }
132
149
  else if (result.value === projectOption) {
133
150
  const grant = { tool: tool.name, fingerprint, scope: 'project', projectRoot };
134
- permanentGrants = loadPermanent().filter((item) => !matches(item, tool.name, fingerprint, projectRoot));
151
+ permanentGrants = permanentGrants.filter((item) => !matches(item, tool.name, fingerprint, projectRoot));
135
152
  permanentGrants.push(grant);
136
153
  if (options.persistProjectGrant !== false)
137
154
  savePermanent();
138
155
  }
156
+ else if (result.value === alwaysOption) {
157
+ permanentToolAllows.add(tool.name);
158
+ if (options.persistProjectGrant !== false)
159
+ savePermanent();
160
+ }
139
161
  return 'allow';
140
162
  }
141
163
  export function revokePermanentAllow(toolName, fingerprint) {
142
- permanentGrants = loadPermanent().filter((grant) => grant.tool !== toolName || (fingerprint !== undefined && grant.fingerprint !== fingerprint));
164
+ loadPermanent();
165
+ permanentGrants = permanentGrants.filter((grant) => grant.tool !== toolName || (fingerprint !== undefined && grant.fingerprint !== fingerprint));
166
+ if (fingerprint === undefined)
167
+ permanentToolAllows.delete(toolName);
168
+ savePermanent();
169
+ }
170
+ export function revokePermanentToolAllow(toolName) {
171
+ loadPermanent();
172
+ permanentToolAllows.delete(toolName);
143
173
  savePermanent();
144
174
  }
145
175
  export function listPermanentGrants() {
146
176
  return loadPermanent().map((grant) => ({ ...grant }));
147
177
  }
148
- /** Compatibility API: returns tools having at least one project-scoped grant. */
178
+ export function listPermanentToolAllows() {
179
+ loadPermanent();
180
+ return [...permanentToolAllows].sort();
181
+ }
182
+ /** Compatibility API: returns tools having any persistent resource or tool-wide grant. */
149
183
  export function listPermanentAllow() {
150
- return [...new Set(loadPermanent().map((grant) => grant.tool))];
184
+ loadPermanent();
185
+ return [...new Set([
186
+ ...permanentGrants.map((grant) => grant.tool),
187
+ ...permanentToolAllows,
188
+ ])].sort();
151
189
  }
152
190
  export function clearSessionPermissionGrants() {
153
191
  sessionGrants.length = 0;
@@ -155,5 +193,6 @@ export function clearSessionPermissionGrants() {
155
193
  export function resetPermissionGrantsForTests() {
156
194
  sessionGrants.length = 0;
157
195
  permanentGrants = [];
196
+ permanentToolAllows = new Set();
158
197
  permanentLoaded = true;
159
198
  }
@@ -22,8 +22,8 @@ import { estimateMessagesTokens, reconfigureClient, refreshChatTools, chatTools,
22
22
  import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
23
23
  import { modelSupportsVision } from '../llm/capabilities.js';
24
24
  import { computePruneStats } from '../context/relevance.js';
25
- import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
26
- import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
25
+ import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, appendCurrentSessionRuntimeEvent, hashTraceValue, } from '../session/index.js';
26
+ import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, getCurrentTurnId, } from '../rollback/index.js';
27
27
  import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
28
28
  import { buildMemorySection, buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, loadAll, } from '../memory/index.js';
29
29
  import { buildSnapshot, clearSnapshotCache } from '../project-snapshot/index.js';
@@ -396,8 +396,9 @@ function onRunningKey(_str, key) {
396
396
  runningInput = '';
397
397
  layout.paintRunningInputEcho(runningInput, runningPlaceholder);
398
398
  }
399
- else {
400
- currentAbort?.abort();
399
+ else if (currentAbort && !currentAbort.signal.aborted) {
400
+ appendCurrentSessionRuntimeEvent('abort', { phase: 'requested', source: 'keyboard' });
401
+ currentAbort.abort();
401
402
  }
402
403
  return;
403
404
  }
@@ -876,10 +877,22 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
876
877
  for (const c of revertable)
877
878
  revertPaths.add(c.path);
878
879
  }
879
- applyRollback(plan, history, revertPaths);
880
+ const rolledBackFromTurnId = getCurrentTurnId();
881
+ const turnCountBeforeRollback = listTurns().length;
882
+ const rollbackResult = applyRollback(plan, history, revertPaths);
880
883
  if (!currentSessionId)
881
884
  currentSessionId = newSessionId();
882
885
  setCurrentSessionId(currentSessionId, process.cwd()); // 同步到 session/state,确保 notes.md 存在
886
+ appendCurrentSessionRuntimeEvent('rollback', {
887
+ status: 'applied',
888
+ rolledBackFromTurnId,
889
+ cutoffTurnId: plan.cutoffTurnId,
890
+ retainedTurns: plan.n,
891
+ rolledBackTurns: Math.max(0, turnCountBeforeRollback - plan.n),
892
+ deletedMessages: rollbackResult.deletedMsgs,
893
+ revertedFiles: rollbackResult.revertedFiles,
894
+ requestedFileCount: revertPaths.size,
895
+ }, plan.cutoffTurnId);
883
896
  try {
884
897
  saveSession(history, currentSessionId, queryHistory);
885
898
  }
@@ -1321,6 +1334,15 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1321
1334
  // 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
1322
1335
  const log = await manualCompact(history, focus, { force });
1323
1336
  const d = log.compactDetail;
1337
+ appendCurrentSessionRuntimeEvent('compact', {
1338
+ source: 'manual',
1339
+ force,
1340
+ called: log.compactHistoryCalled,
1341
+ reason: d?.reason ?? 'unknown',
1342
+ estimateBefore: d?.estimateBefore,
1343
+ estimateAfter: d?.estimateAfter,
1344
+ focusHash: focus ? hashTraceValue(focus) : undefined,
1345
+ });
1324
1346
  if (!d) {
1325
1347
  // 兜底(旧调用):只显示 old 文案
1326
1348
  if (!log.compactHistoryCalled) {
@@ -114,6 +114,11 @@ export function beginTurn(firstLine) {
114
114
  turnIdCounter += 1;
115
115
  currentTurnId = turnIdCounter;
116
116
  turns.push({ turnId: currentTurnId, firstLine });
117
+ return currentTurnId;
118
+ }
119
+ /** Stable identity shared by tracing, validation, and rollback for the active main turn. */
120
+ export function getCurrentTurnId() {
121
+ return currentTurnId;
117
122
  }
118
123
  /** 单路径工具执行前捕获,不立即记账;失败/no-op 不应出现在 rollback 中。 */
119
124
  export function beginPathMutation(p) {
@@ -12,4 +12,6 @@ export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, con
12
12
  export { runScheduler, manualCompact, createBudgetScheduler, } from './scheduler.js';
13
13
  export { dropContextFromHistory, formatDropResult, } from './drop.js';
14
14
  export { newSessionId, saveSession, loadSession, listSessions, sessionDir, } from './persist.js';
15
- export { appendCurrentSessionTrace } from './trace.js';
15
+ export { appendCurrentSessionTrace, appendCurrentSessionTraceEvent, appendCurrentSessionRuntimeEvent, createTraceEvent, } from './trace.js';
16
+ export { reduceTraceMetrics, readTraceEvents, readTraceMetrics } from './trace-metrics.js';
17
+ export { summarizeToolArguments, hashTraceValue, safeProviderId } from './trace-sanitize.js';
@@ -0,0 +1,70 @@
1
+ import { readFileSync } from 'node:fs';
2
+ export function reduceTraceMetrics(events) {
3
+ const ends = events.filter((event) => event.type === 'tool_call_end');
4
+ let recovered = false;
5
+ let hadFailure = false;
6
+ let successes = 0;
7
+ let toolRetries = 0;
8
+ let tokens = 0;
9
+ let hasTokens = false;
10
+ for (const event of ends) {
11
+ const status = String(event.data.status ?? 'error');
12
+ const retry = Number(event.data.retry ?? 0);
13
+ toolRetries += Number.isFinite(retry) ? retry : 0;
14
+ if (status === 'success') {
15
+ successes++;
16
+ if (hadFailure)
17
+ recovered = true;
18
+ }
19
+ else {
20
+ hadFailure = true;
21
+ }
22
+ }
23
+ for (const event of events) {
24
+ if (event.type !== 'model_end')
25
+ continue;
26
+ const value = event.data.totalTokens;
27
+ if (typeof value === 'number' && Number.isFinite(value)) {
28
+ tokens += value;
29
+ hasTokens = true;
30
+ }
31
+ }
32
+ const modelRetries = events.filter((event) => event.type === 'model_retry').length;
33
+ const firstValidation = events.find((event) => event.type === 'validation_end');
34
+ const turnEnd = [...events].reverse().find((event) => event.type === 'turn_end');
35
+ return {
36
+ toolCalls: events.filter((event) => event.type === 'tool_call_start').length,
37
+ toolFailures: ends.length - successes,
38
+ toolRecovery: recovered,
39
+ firstSuccessRate: ends.length ? successes / ends.length : 1,
40
+ modelRetries,
41
+ toolRetries,
42
+ retries: modelRetries + toolRetries,
43
+ tokens: hasTokens ? tokens : null,
44
+ durationMs: Number(turnEnd?.data.durationMs ?? 0),
45
+ firstValidationPassed: firstValidation?.data.status === 'passed',
46
+ };
47
+ }
48
+ /** Reads event JSONL; malformed/legacy summary lines are ignored. */
49
+ export function readTraceEvents(file) {
50
+ const events = [];
51
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
52
+ if (!line.trim())
53
+ continue;
54
+ try {
55
+ const value = JSON.parse(line);
56
+ if (value.schemaVersion === 1 && typeof value.type === 'string' &&
57
+ typeof value.sessionId === 'string' && typeof value.turnId === 'number' &&
58
+ value.data && typeof value.data === 'object') {
59
+ events.push(value);
60
+ }
61
+ }
62
+ catch {
63
+ // One corrupt best-effort trace line must not hide the remaining run.
64
+ }
65
+ }
66
+ return events;
67
+ }
68
+ export function readTraceMetrics(file) {
69
+ return reduceTraceMetrics(readTraceEvents(file));
70
+ }
@@ -0,0 +1,34 @@
1
+ import { createHash } from 'node:crypto';
2
+ export function hashTraceValue(value) {
3
+ return createHash('sha256').update(value).digest('hex');
4
+ }
5
+ /** Never persists argument values: only shape, size, and a one-way fingerprint. */
6
+ export function summarizeToolArguments(raw) {
7
+ let keys = [];
8
+ let parseable = false;
9
+ try {
10
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
11
+ parseable = true;
12
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
13
+ keys = Object.keys(parsed).sort();
14
+ }
15
+ }
16
+ catch {
17
+ // Invalid arguments are still fingerprinted without retaining their contents.
18
+ }
19
+ return {
20
+ sha256: hashTraceValue(raw),
21
+ byteLength: Buffer.byteLength(raw, 'utf8'),
22
+ keys,
23
+ parseable,
24
+ };
25
+ }
26
+ /** Provider identity is deliberately reduced to a hostname; credentials/path/query are discarded. */
27
+ export function safeProviderId(baseURL) {
28
+ try {
29
+ return new URL(baseURL).hostname.toLowerCase() || 'custom';
30
+ }
31
+ catch {
32
+ return 'custom';
33
+ }
34
+ }
@@ -1,26 +1,54 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { appendFileSync, mkdirSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { config } from '../config/index.js';
5
+ import { getCurrentTurnId } from '../rollback/index.js';
4
6
  import { getCurrentSessionId } from './state.js';
5
- function boundedValidation(result) {
6
- if (!result)
7
- return undefined;
8
- const max = 4000;
9
- return result.output.length <= max
10
- ? result
11
- : { ...result, output: `${result.output.slice(0, 2000)}\n…[trace output truncated]…\n${result.output.slice(-1900)}` };
7
+ export function createTraceEvent(input) {
8
+ return {
9
+ schemaVersion: 1,
10
+ eventId: randomUUID(),
11
+ ts: new Date().toISOString(),
12
+ ...input,
13
+ };
12
14
  }
13
- /** Best-effort JSONL trace. Failure must never affect the agent turn. */
14
- export function appendCurrentSessionTrace(trace) {
15
- const sessionId = getCurrentSessionId();
16
- if (!sessionId)
17
- return;
15
+ function appendTraceLine(sessionId, value) {
18
16
  try {
19
17
  const dir = path.join(config.sessionDir, sessionId);
20
18
  mkdirSync(dir, { recursive: true });
21
- appendFileSync(path.join(dir, 'trace.jsonl'), `${JSON.stringify({ ...trace, validation: boundedValidation(trace.validation) })}\n`, 'utf8');
19
+ appendFileSync(path.join(dir, 'trace.jsonl'), `${JSON.stringify(value)}\n`, 'utf8');
22
20
  }
23
21
  catch {
24
22
  // Observability is best-effort and cannot block coding work.
25
23
  }
26
24
  }
25
+ /** Persists a typed event in the current session's append-only black-box log. */
26
+ export function appendCurrentSessionTraceEvent(event) {
27
+ const sessionId = getCurrentSessionId();
28
+ if (!sessionId)
29
+ return;
30
+ appendTraceLine(sessionId, { ...event, sessionId });
31
+ }
32
+ /** Records events initiated outside runAgentCore, such as Ctrl+C, /compact, and /rollback. */
33
+ export function appendCurrentSessionRuntimeEvent(type, data, turnId = getCurrentTurnId()) {
34
+ const sessionId = getCurrentSessionId();
35
+ if (!sessionId)
36
+ return;
37
+ appendTraceLine(sessionId, createTraceEvent({ sessionId, turnId, type, data }));
38
+ }
39
+ /** Legacy turn-summary sink retained for API compatibility. New production code writes events. */
40
+ export function appendCurrentSessionTrace(trace) {
41
+ const sessionId = getCurrentSessionId();
42
+ if (!sessionId)
43
+ return;
44
+ const validation = trace.validation
45
+ ? {
46
+ status: trace.validation.status,
47
+ level: trace.validation.level,
48
+ durationMs: trace.validation.durationMs,
49
+ verificationComplete: trace.validation.verificationComplete,
50
+ fingerprint: trace.validation.fingerprint,
51
+ }
52
+ : undefined;
53
+ appendTraceLine(sessionId, { ...trace, sessionId, validation });
54
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
+ import { verifyWrittenFile } from '../../verification/postconditions.js';
3
4
  // ---------- edit_file ----------
4
5
  export const editFileTool = {
5
6
  name: 'edit_file',
@@ -39,6 +40,17 @@ export const editFileTool = {
39
40
  // 检测原始行尾风格,写回时还原(存在 \r\n 即视为 CRLF 文件;纯 LF 文件保持 LF)
40
41
  const out = data.includes('\r\n') ? updated.replace(/\n/g, '\r\n') : updated;
41
42
  await writeFile(full, out, 'utf8');
42
- return `已在 ${path} 中完成 1 处替换。`;
43
+ const postcondition = await verifyWrittenFile(full, out);
44
+ if (postcondition.status === 'failed') {
45
+ return {
46
+ status: 'error',
47
+ code: 'POSTCONDITION_FAILED',
48
+ retryable: true,
49
+ output: postcondition.diagnostics
50
+ .map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
51
+ .join('\n'),
52
+ };
53
+ }
54
+ return `已在 ${path} 中完成 1 处替换 (sha256=${postcondition.actualHash})。`;
43
55
  },
44
56
  };
@@ -69,8 +69,15 @@ const CAPABILITIES = {
69
69
  memory_update: { effect: 'write', concurrency: 'serial', retry: 'never', resources: memoryResource },
70
70
  memory_forget: { effect: 'write', concurrency: 'serial', retry: 'never', resources: memoryResource },
71
71
  project_skill_update: { effect: 'write', concurrency: 'serial', retry: 'never', resources: workspaceResource },
72
- // Agent 共享主工作区;在隔离 workspace / write-set 锁实现前必须串行。
73
- task: { effect: 'write', concurrency: 'serial', retry: 'never', resources: workspaceResource, supportsAbort: true },
72
+ // task 只编排子 Agent;真实读写由子调用自行持锁,父调用不得包 workspace 锁。
73
+ task: {
74
+ effect: 'write',
75
+ concurrency: 'serial',
76
+ retry: 'never',
77
+ resources: workspaceResource,
78
+ delegatesResourceLocks: true,
79
+ supportsAbort: true,
80
+ },
74
81
  };
75
82
  const rawBuiltinTools = [
76
83
  readFileTool,
@@ -1,5 +1,6 @@
1
1
  import { writeFile, mkdir } from 'node:fs/promises';
2
2
  import { resolve, dirname } from 'node:path';
3
+ import { verifyWrittenFile } from '../../verification/postconditions.js';
3
4
  // ---------- write_file ----------
4
5
  export const writeFileTool = {
5
6
  name: 'write_file',
@@ -19,6 +20,17 @@ export const writeFileTool = {
19
20
  const full = resolve(path);
20
21
  await mkdir(dirname(full), { recursive: true });
21
22
  await writeFile(full, content, 'utf8');
22
- return `已写入 ${path} (${content.length} 字符)`;
23
+ const postcondition = await verifyWrittenFile(full, content);
24
+ if (postcondition.status === 'failed') {
25
+ return {
26
+ status: 'error',
27
+ code: 'POSTCONDITION_FAILED',
28
+ retryable: true,
29
+ output: postcondition.diagnostics
30
+ .map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
31
+ .join('\n'),
32
+ };
33
+ }
34
+ return `已写入 ${path} (${content.length} 字符, sha256=${postcondition.actualHash})`;
23
35
  },
24
36
  };
@@ -1,6 +1,7 @@
1
1
  import { builtinTools } from './builtins/index.js';
2
2
  import { beginPathMutation, beginWorkspaceMutation, endPathMutation, endWorkspaceMutation, getCurrentTurnMutationState, } from '../rollback/index.js';
3
3
  import { enforceSandbox } from '../sandbox/index.js';
4
+ import { resolveResourceLockRequests, toolResourceLockManager } from './resource-lock.js';
4
5
  import { t } from '../i18n/index.js';
5
6
  import { isToolErrorOutput } from './result.js';
6
7
  /**
@@ -115,45 +116,63 @@ export async function executeToolOutcome(name, argsRaw, signal, opts) {
115
116
  return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
116
117
  }
117
118
  const capabilities = getToolCapabilities(tool);
118
- const mutationBefore = getCurrentTurnMutationState();
119
+ let mutationVersionBefore;
120
+ let capturedPath;
119
121
  try {
120
122
  const sandboxError = enforceSandbox(name, args);
121
123
  if (sandboxError) {
122
124
  return terminalOutcome('denied', 'SANDBOX_DENIED', sandboxError, startedAt);
123
125
  }
124
- const pathCapture = isFileMutationTool(name) && typeof args.path === 'string' && args.path
125
- ? beginPathMutation(args.path)
126
- : null;
127
- // 进程和未知扩展可能间接改动任意文件;已声明 write 的非文件工具自行管理其状态。
128
- const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
129
- ? beginWorkspaceMutation()
130
- : null;
131
- let raw;
132
- try {
133
- raw = await tool.execute(args, {
134
- signal,
135
- dropContext: opts?.dropContext,
136
- });
137
- }
138
- finally {
139
- if (pathCapture)
140
- endPathMutation(pathCapture, name);
141
- if (workspaceCapture)
142
- endWorkspaceMutation(workspaceCapture, name);
143
- }
144
- const mutationAfter = getCurrentTurnMutationState();
145
- const changedFiles = mutationAfter.version !== mutationBefore.version
146
- ? mutationAfter.changedFiles.map((item) => item.path)
147
- : [];
148
- if (signal?.aborted) {
149
- return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
150
- }
151
- return normalizeOutcome(raw, capabilities, Date.now() - startedAt, changedFiles);
126
+ const requests = resolveResourceLockRequests(capabilities, args);
127
+ return await toolResourceLockManager.withLocks(requests, signal, async () => {
128
+ // Diff 等执行前观察必须发生在真正持锁之后;同路径排队调用才能看到前序写入结果。
129
+ opts?.onLockAcquired?.(args);
130
+ const mutationBefore = getCurrentTurnMutationState();
131
+ mutationVersionBefore = mutationBefore.version;
132
+ const pathCapture = isFileMutationTool(name) && typeof args.path === 'string' && args.path
133
+ ? beginPathMutation(args.path)
134
+ : null;
135
+ capturedPath = pathCapture?.path;
136
+ // 进程和未知扩展可能间接改动任意文件;其 workspace lock 同时隔离全盘捕获。
137
+ const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
138
+ ? beginWorkspaceMutation()
139
+ : null;
140
+ let raw;
141
+ try {
142
+ raw = await tool.execute(args, {
143
+ signal,
144
+ dropContext: opts?.dropContext,
145
+ });
146
+ }
147
+ finally {
148
+ if (pathCapture)
149
+ endPathMutation(pathCapture, name);
150
+ if (workspaceCapture)
151
+ endWorkspaceMutation(workspaceCapture, name);
152
+ }
153
+ const mutationAfter = getCurrentTurnMutationState();
154
+ const changedFiles = mutationAfter.version !== mutationBefore.version
155
+ ? pathCapture
156
+ ? mutationAfter.changedFiles
157
+ .filter((item) => item.path === pathCapture.path)
158
+ .map((item) => item.path)
159
+ : mutationAfter.changedFiles.map((item) => item.path)
160
+ : [];
161
+ if (signal?.aborted) {
162
+ return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
163
+ }
164
+ return normalizeOutcome(raw, capabilities, Date.now() - startedAt, changedFiles);
165
+ });
152
166
  }
153
167
  catch (error) {
154
168
  const mutationAfter = getCurrentTurnMutationState();
155
- const changedFiles = mutationAfter.version !== mutationBefore.version
156
- ? mutationAfter.changedFiles.map((item) => item.path)
169
+ const changedFiles = mutationVersionBefore !== undefined &&
170
+ mutationAfter.version !== mutationVersionBefore
171
+ ? capturedPath
172
+ ? mutationAfter.changedFiles
173
+ .filter((item) => item.path === capturedPath)
174
+ .map((item) => item.path)
175
+ : mutationAfter.changedFiles.map((item) => item.path)
157
176
  : [];
158
177
  if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
159
178
  return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt, changedFiles);