mixdog 0.9.132 → 0.9.133
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/package.json +1 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +16 -5
- package/src/runtime/agent/orchestrator/session/eager-dispatch.test.mjs +55 -0
- package/src/runtime/agent/orchestrator/session/evidence-union.mjs +201 -64
- package/src/runtime/agent/orchestrator/session/evidence-union.test.mjs +54 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +8 -2
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +107 -0
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.test.mjs +27 -0
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +70 -65
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +9 -1
- package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.mjs +31 -9
- package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.test.mjs +19 -0
package/package.json
CHANGED
|
@@ -22,7 +22,6 @@ import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../provide
|
|
|
22
22
|
import {
|
|
23
23
|
_stripMcpPrefix,
|
|
24
24
|
_isReadTool,
|
|
25
|
-
_isMutationTool,
|
|
26
25
|
_isScopedCacheableTool,
|
|
27
26
|
_isShellTool,
|
|
28
27
|
_intraTurnSig,
|
|
@@ -659,7 +658,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
659
658
|
});
|
|
660
659
|
const _providerMessages = _evidenceProjection.messages;
|
|
661
660
|
if (_evidenceProjection.stats.reusedRows > 0
|
|
662
|
-
|| _evidenceProjection.stats.exactResultRefs > 0
|
|
661
|
+
|| _evidenceProjection.stats.exactResultRefs > 0
|
|
662
|
+
|| _evidenceProjection.stats.pathAliases > 0) {
|
|
663
663
|
try {
|
|
664
664
|
const _evidencePayload = {
|
|
665
665
|
shadow: _evidenceUnionShadow,
|
|
@@ -671,6 +671,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
671
671
|
changed_tool_results: _evidenceProjection.stats.changedToolResults,
|
|
672
672
|
exact_result_refs: _evidenceProjection.stats.exactResultRefs,
|
|
673
673
|
exact_result_bytes_saved: _evidenceProjection.stats.exactResultBytesSaved,
|
|
674
|
+
path_facts: _evidenceProjection.stats.pathFacts,
|
|
675
|
+
path_aliases: _evidenceProjection.stats.pathAliases,
|
|
676
|
+
reused_path_facts: _evidenceProjection.stats.reusedPathFacts,
|
|
677
|
+
path_alias_bytes_saved: _evidenceProjection.stats.pathAliasBytesSaved,
|
|
674
678
|
};
|
|
675
679
|
appendAgentTrace({
|
|
676
680
|
sessionId,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Eager tool-dispatch controller, extracted from agent-loop.mjs. Owns the
|
|
2
2
|
// per-turn pending promise map, the intra-turn in-flight signature set, and
|
|
3
3
|
// the mutation epoch. Every valid call dispatches while the provider is still
|
|
4
|
-
// streaming. Calls execute in parallel except that
|
|
5
|
-
//
|
|
4
|
+
// streaming. Calls execute in parallel except that repository-wide Git writes
|
|
5
|
+
// serialize against file edits, while shell waits for every earlier mutation;
|
|
6
6
|
// results are collected later in call order.
|
|
7
7
|
import { normalizeToolEnvelope } from './tool-envelope.mjs';
|
|
8
8
|
import { classifyResultKind } from './result-classification.mjs';
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
_intraTurnSig,
|
|
12
12
|
_argShapeSig,
|
|
13
13
|
_isEditTool,
|
|
14
|
+
_isGitMutationTool,
|
|
14
15
|
_isMutationTool,
|
|
15
16
|
_isReadTool,
|
|
16
17
|
_isScopedCacheableTool,
|
|
@@ -46,6 +47,9 @@ export function createEagerDispatcher({
|
|
|
46
47
|
// Exact-string edits are single-replacement calls. Serialize them so
|
|
47
48
|
// multiple edits to one file in the same model turn observe prior writes.
|
|
48
49
|
let editBarrier = Promise.resolve();
|
|
50
|
+
// Git mutations have repository-wide effects. They wait for earlier
|
|
51
|
+
// file edits, and later edits/shell verification wait for them.
|
|
52
|
+
let gitMutationBarrier = Promise.resolve();
|
|
49
53
|
// Streaming-time intra-turn dedup. When the LLM emits two
|
|
50
54
|
// tool_use blocks with identical (name, args) signatures in
|
|
51
55
|
// sequence, the provider's onToolCall fires for both BEFORE
|
|
@@ -131,19 +135,23 @@ export function createEagerDispatcher({
|
|
|
131
135
|
localSearchTelemetry: {},
|
|
132
136
|
resultTelemetry: {},
|
|
133
137
|
};
|
|
134
|
-
const
|
|
138
|
+
const gitMutation = _isGitMutationTool(call.name, call.arguments);
|
|
139
|
+
const mutation = _isMutationTool(call.name, call.arguments);
|
|
140
|
+
const precedingPatches = (_isShellTool(call.name) || gitMutation) ? patchBarrier : null;
|
|
141
|
+
const precedingGitMutation = (_isShellTool(call.name) || mutation) ? gitMutationBarrier : null;
|
|
135
142
|
const precedingEdits = _isEditTool(call.name) ? editBarrier : null;
|
|
136
143
|
if (_dedupEligible) _eagerInFlightSigs.set(_sig, call.id);
|
|
137
144
|
entry.promise = (async () => {
|
|
138
145
|
try {
|
|
139
146
|
if (precedingEdits) await precedingEdits;
|
|
147
|
+
if (precedingGitMutation) await precedingGitMutation;
|
|
140
148
|
if (precedingPatches) {
|
|
141
149
|
const patchState = await precedingPatches;
|
|
142
150
|
if (patchState.failedPatchIds.length > 0) {
|
|
143
151
|
return {
|
|
144
152
|
ok: true,
|
|
145
153
|
skipped: true,
|
|
146
|
-
value: `[
|
|
154
|
+
value: `[mutation-dependency-guard] \`${call.name}\` skipped because earlier mutation call(s) failed: ${patchState.failedPatchIds.join(', ')}; no verification ran.`,
|
|
147
155
|
};
|
|
148
156
|
}
|
|
149
157
|
}
|
|
@@ -215,7 +223,7 @@ export function createEagerDispatcher({
|
|
|
215
223
|
if (_isEditTool(call.name)) {
|
|
216
224
|
editBarrier = entry.promise.then(() => undefined, () => undefined);
|
|
217
225
|
}
|
|
218
|
-
if (_isMutationTool(call.name)) {
|
|
226
|
+
if (_isMutationTool(call.name, call.arguments)) {
|
|
219
227
|
const precedingPatchState = patchBarrier;
|
|
220
228
|
const currentPatch = entry.promise;
|
|
221
229
|
patchBarrier = Promise.all([precedingPatchState, currentPatch]).then(([state, settled]) => ({
|
|
@@ -224,6 +232,9 @@ export function createEagerDispatcher({
|
|
|
224
232
|
: state.failedPatchIds,
|
|
225
233
|
}));
|
|
226
234
|
}
|
|
235
|
+
if (gitMutation) {
|
|
236
|
+
gitMutationBarrier = entry.promise.then(() => undefined, () => undefined);
|
|
237
|
+
}
|
|
227
238
|
return entry;
|
|
228
239
|
};
|
|
229
240
|
const startEagerRun = (calls, startIndex, dupSet) => {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createEagerDispatcher } from './eager-dispatch.mjs';
|
|
4
|
+
|
|
5
|
+
function gate() {
|
|
6
|
+
let release;
|
|
7
|
+
return { promise: new Promise((resolve) => { release = resolve; }), release };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test('eager dispatch serializes Git mutations, file edits, and shell verification', async () => {
|
|
11
|
+
const gitGate = gate();
|
|
12
|
+
const patchGate = gate();
|
|
13
|
+
const events = [];
|
|
14
|
+
const executeToolFn = async (name) => {
|
|
15
|
+
events.push(`${name}:start`);
|
|
16
|
+
if (name === 'git') await gitGate.promise;
|
|
17
|
+
if (name === 'apply_patch') await patchGate.promise;
|
|
18
|
+
events.push(`${name}:end`);
|
|
19
|
+
return { result: 'ok', explicitSuccess: true };
|
|
20
|
+
};
|
|
21
|
+
const dispatcher = createEagerDispatcher({
|
|
22
|
+
tools: [],
|
|
23
|
+
cwd: process.cwd(),
|
|
24
|
+
sessionId: null,
|
|
25
|
+
sessionRef: {},
|
|
26
|
+
signal: null,
|
|
27
|
+
opts: {},
|
|
28
|
+
crossTurnCalls: new Map(),
|
|
29
|
+
getIterations: () => 1,
|
|
30
|
+
getNextIteration: () => 1,
|
|
31
|
+
repeatFailLimit: 3,
|
|
32
|
+
executeToolFn,
|
|
33
|
+
});
|
|
34
|
+
const calls = [
|
|
35
|
+
{ id: 'git', name: 'git', arguments: { command: 'git add --all' } },
|
|
36
|
+
{ id: 'patch', name: 'apply_patch', arguments: { patch: 'test' } },
|
|
37
|
+
{ id: 'shell', name: 'shell', arguments: { command: 'git status' } },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
dispatcher.startEagerRun(calls, 0, new Set());
|
|
41
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
42
|
+
assert.deepEqual(events, ['git:start']);
|
|
43
|
+
|
|
44
|
+
gitGate.release();
|
|
45
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
46
|
+
assert.deepEqual(events, ['git:start', 'git:end', 'apply_patch:start']);
|
|
47
|
+
|
|
48
|
+
patchGate.release();
|
|
49
|
+
await Promise.all([...dispatcher.pending.values()].map((entry) => entry.promise));
|
|
50
|
+
assert.deepEqual(events, [
|
|
51
|
+
'git:start', 'git:end',
|
|
52
|
+
'apply_patch:start', 'apply_patch:end',
|
|
53
|
+
'shell:start', 'shell:end',
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* their toolCallId but omit exact file lines already visible in an earlier
|
|
6
6
|
* result, replacing them with references to the earlier tool call and source
|
|
7
7
|
* location. Exact repeated list/glob/find results use a whole-result reference.
|
|
8
|
-
* Any
|
|
8
|
+
* Any file, repository, or shell mutation starts a fresh evidence epoch.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { gitCommandMutates } from '../tools/builtin/git-command-policy.mjs';
|
|
12
12
|
|
|
13
13
|
const EXACT_RESULT_TOOLS = new Set(['find', 'find_files', 'glob', 'list']);
|
|
14
14
|
|
|
@@ -156,70 +156,9 @@ function evidenceKey(row) {
|
|
|
156
156
|
return `${row.path}\0${row.line}\0${row.content}`;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
const READ_ONLY_GIT_COMMANDS = new Set([
|
|
160
|
-
'blame', 'cat-file', 'check-attr', 'check-ignore', 'check-ref-format',
|
|
161
|
-
'cherry', 'count-objects', 'describe', 'diff', 'diff-files', 'diff-index',
|
|
162
|
-
'diff-tree', 'for-each-ref', 'fsck', 'grep', 'help', 'log', 'ls-files',
|
|
163
|
-
'ls-remote', 'ls-tree', 'merge-base', 'merge-tree', 'name-rev',
|
|
164
|
-
'range-diff', 'rev-list', 'rev-parse', 'shortlog', 'show', 'show-branch',
|
|
165
|
-
'show-ref', 'status', 'verify-commit', 'verify-pack', 'verify-tag',
|
|
166
|
-
'whatchanged',
|
|
167
|
-
]);
|
|
168
|
-
|
|
169
|
-
function parsedGitCommand(command) {
|
|
170
|
-
const tokens = tokenizeDirectArgv(command);
|
|
171
|
-
if (!tokens?.length || !/(^|[\\/])git(?:\.exe)?$/i.test(tokens[0])) return null;
|
|
172
|
-
let index = 1;
|
|
173
|
-
while (index < tokens.length) {
|
|
174
|
-
const token = tokens[index];
|
|
175
|
-
if (token === '-C' || token === '-c' || token === '--git-dir' || token === '--work-tree' || token === '--namespace') {
|
|
176
|
-
index += 2;
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
if (/^-C.+/.test(token) || /^--(?:git-dir|work-tree|namespace)=/.test(token)
|
|
180
|
-
|| ['--no-pager', '--paginate', '--bare', '--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs'].includes(token)) {
|
|
181
|
-
index++;
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
break;
|
|
185
|
-
}
|
|
186
|
-
return index < tokens.length ? { command: tokens[index], args: tokens.slice(index + 1) } : null;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function firstGitArg(args) {
|
|
190
|
-
return args.find((value) => value !== '--' && !value.startsWith('-')) || '';
|
|
191
|
-
}
|
|
192
|
-
|
|
193
159
|
function gitCallMutates(call) {
|
|
194
160
|
let args = call?.arguments ?? call?.function?.arguments ?? {};
|
|
195
|
-
|
|
196
|
-
try { args = JSON.parse(args); } catch { return true; }
|
|
197
|
-
}
|
|
198
|
-
const parsed = parsedGitCommand(args?.command);
|
|
199
|
-
if (!parsed) return true;
|
|
200
|
-
const operation = parsed.command;
|
|
201
|
-
const action = firstGitArg(parsed.args);
|
|
202
|
-
if (READ_ONLY_GIT_COMMANDS.has(operation)) return false;
|
|
203
|
-
if (operation === 'reflog') return ['delete', 'expire'].includes(action);
|
|
204
|
-
if (operation === 'branch' || operation === 'tag') {
|
|
205
|
-
return parsed.args.length > 0 && !parsed.args.some((value) => ['--list', '-l', '-a', '--all', '-r', '--remotes', '--show-current'].includes(value));
|
|
206
|
-
}
|
|
207
|
-
if (operation === 'worktree') return !['', 'list'].includes(action);
|
|
208
|
-
if (operation === 'stash') return !['list', 'show'].includes(action);
|
|
209
|
-
if (operation === 'remote') return !['', 'show', 'get-url'].includes(action);
|
|
210
|
-
if (operation === 'config') {
|
|
211
|
-
return !parsed.args.some((value) => ['--list', '-l', '--get', '--get-all', '--get-regexp', '--show-origin', '--show-scope'].includes(value))
|
|
212
|
-
&& parsed.args.filter((value) => !value.startsWith('-')).length > 1;
|
|
213
|
-
}
|
|
214
|
-
if (operation === 'clean') return !parsed.args.some((value) => value === '-n' || value === '--dry-run');
|
|
215
|
-
if (operation === 'bundle') return !['list-heads', 'verify'].includes(action);
|
|
216
|
-
if (operation === 'notes') return !['', 'list', 'show'].includes(action);
|
|
217
|
-
if (operation === 'replace') return parsed.args.length > 0 && !parsed.args.includes('--list');
|
|
218
|
-
if (operation === 'sparse-checkout') return action !== 'list';
|
|
219
|
-
if (operation === 'submodule') return !['', 'status', 'summary'].includes(action);
|
|
220
|
-
if (operation === 'symbolic-ref') return parsed.args.filter((value) => !value.startsWith('-')).length > 1;
|
|
221
|
-
if (operation === 'hash-object') return parsed.args.includes('-w') || parsed.args.includes('--stdin-paths');
|
|
222
|
-
return true;
|
|
161
|
+
return gitCommandMutates(args);
|
|
223
162
|
}
|
|
224
163
|
|
|
225
164
|
function mutationBatch(toolCalls) {
|
|
@@ -271,6 +210,181 @@ function byteLength(value) {
|
|
|
271
210
|
return Buffer.byteLength(String(value || ''), 'utf8');
|
|
272
211
|
}
|
|
273
212
|
|
|
213
|
+
const PATH_ONLY_TOOLS = new Set(['find', 'find_files', 'glob']);
|
|
214
|
+
|
|
215
|
+
function pathOccurrenceForLine(toolName, line) {
|
|
216
|
+
const name = normalizeToolName(toolName);
|
|
217
|
+
if (PATH_ONLY_TOOLS.has(name)) {
|
|
218
|
+
if (!line || /^(?:\.\.\.|[[(#]|Error:)/.test(line)) return null;
|
|
219
|
+
return { path: line, start: 0, end: line.length };
|
|
220
|
+
}
|
|
221
|
+
if (name === 'list') {
|
|
222
|
+
const match = /^(.*)\t(?:file|dir|symlink|other)$/.exec(line);
|
|
223
|
+
return match?.[1]
|
|
224
|
+
? { path: match[1], start: 0, end: match[1].length }
|
|
225
|
+
: null;
|
|
226
|
+
}
|
|
227
|
+
if (name === 'read') {
|
|
228
|
+
const match = /^(.+?)(?: \[[^\]]+\])? \[(?:ok|error)\](?: .*)?$/.exec(line);
|
|
229
|
+
return match?.[1]
|
|
230
|
+
? { path: match[1], start: 0, end: match[1].length }
|
|
231
|
+
: null;
|
|
232
|
+
}
|
|
233
|
+
if (name === 'grep') {
|
|
234
|
+
const match = /^# (.+):\d+ \[lines \d+-\d+\]$/.exec(line);
|
|
235
|
+
return match?.[1]
|
|
236
|
+
? { path: match[1], start: 2, end: 2 + match[1].length }
|
|
237
|
+
: null;
|
|
238
|
+
}
|
|
239
|
+
if (name === 'code_graph') {
|
|
240
|
+
const match = /^(.+):\d+-\d+:\d+ \([^)]+\)$/.exec(line);
|
|
241
|
+
return match?.[1]
|
|
242
|
+
? { path: match[1], start: 0, end: match[1].length }
|
|
243
|
+
: null;
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function projectProviderPathAliases(messages, { apply = true } = {}) {
|
|
249
|
+
const callById = new Map();
|
|
250
|
+
const occurrences = [];
|
|
251
|
+
let epoch = 0;
|
|
252
|
+
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
|
|
253
|
+
const message = messages[messageIndex];
|
|
254
|
+
if (!message || typeof message !== 'object') continue;
|
|
255
|
+
if (message.role === 'assistant' && Array.isArray(message.toolCalls)) {
|
|
256
|
+
if (mutationBatch(message.toolCalls)) epoch += 1;
|
|
257
|
+
for (const call of message.toolCalls) {
|
|
258
|
+
const id = call?.id || call?.toolCallId;
|
|
259
|
+
if (!id) continue;
|
|
260
|
+
callById.set(id, {
|
|
261
|
+
epoch,
|
|
262
|
+
name: call?.name || call?.function?.name,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (message.role !== 'tool' || typeof message.content !== 'string'
|
|
268
|
+
|| message.toolKind === 'error' || message.isError === true) continue;
|
|
269
|
+
const call = callById.get(message.toolCallId);
|
|
270
|
+
if (!call) continue;
|
|
271
|
+
const lines = message.content.split('\n');
|
|
272
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
273
|
+
const occurrence = pathOccurrenceForLine(call.name, lines[lineIndex]);
|
|
274
|
+
if (!occurrence?.path) continue;
|
|
275
|
+
occurrences.push({
|
|
276
|
+
...occurrence,
|
|
277
|
+
epoch: call.epoch,
|
|
278
|
+
messageIndex,
|
|
279
|
+
lineIndex,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const groups = new Map();
|
|
285
|
+
for (const occurrence of occurrences) {
|
|
286
|
+
const key = `${occurrence.epoch}\0${occurrence.path}`;
|
|
287
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
288
|
+
groups.get(key).push(occurrence);
|
|
289
|
+
}
|
|
290
|
+
const repeated = [...groups.values()]
|
|
291
|
+
.filter((group) => group.length > 1)
|
|
292
|
+
.sort((left, right) => (
|
|
293
|
+
left[0].messageIndex - right[0].messageIndex
|
|
294
|
+
|| left[0].lineIndex - right[0].lineIndex
|
|
295
|
+
|| left[0].start - right[0].start
|
|
296
|
+
));
|
|
297
|
+
const aliasesByEpoch = new Map();
|
|
298
|
+
const dictionaries = new Map();
|
|
299
|
+
const edits = new Map();
|
|
300
|
+
let pathAliases = 0;
|
|
301
|
+
let reusedPathFacts = 0;
|
|
302
|
+
for (const group of repeated) {
|
|
303
|
+
const first = group[0];
|
|
304
|
+
const nextAlias = (aliasesByEpoch.get(first.epoch) || 0) + 1;
|
|
305
|
+
const alias = `p${nextAlias}`;
|
|
306
|
+
const dictionary = `[path-alias ${alias}=${JSON.stringify(first.path)}]`;
|
|
307
|
+
const originalBytes = group.reduce((sum, item) => sum + byteLength(item.path), 0);
|
|
308
|
+
const projectedBytes = byteLength(dictionary) + 1
|
|
309
|
+
+ group.reduce((sum) => sum + byteLength(alias), 0);
|
|
310
|
+
if (projectedBytes >= originalBytes) continue;
|
|
311
|
+
aliasesByEpoch.set(first.epoch, nextAlias);
|
|
312
|
+
pathAliases += 1;
|
|
313
|
+
reusedPathFacts += group.length - 1;
|
|
314
|
+
if (!dictionaries.has(first.messageIndex)) dictionaries.set(first.messageIndex, []);
|
|
315
|
+
dictionaries.get(first.messageIndex).push(dictionary);
|
|
316
|
+
for (const occurrence of group) {
|
|
317
|
+
if (!edits.has(occurrence.messageIndex)) edits.set(occurrence.messageIndex, new Map());
|
|
318
|
+
const byLine = edits.get(occurrence.messageIndex);
|
|
319
|
+
if (!byLine.has(occurrence.lineIndex)) byLine.set(occurrence.lineIndex, []);
|
|
320
|
+
byLine.get(occurrence.lineIndex).push({
|
|
321
|
+
start: occurrence.start,
|
|
322
|
+
end: occurrence.end,
|
|
323
|
+
replacement: alias,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (pathAliases === 0) {
|
|
328
|
+
return {
|
|
329
|
+
messages,
|
|
330
|
+
stats: {
|
|
331
|
+
pathFacts: occurrences.length,
|
|
332
|
+
pathAliases: 0,
|
|
333
|
+
reusedPathFacts: 0,
|
|
334
|
+
pathAliasBytesSaved: 0,
|
|
335
|
+
changedIndexes: [],
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const changedIndexes = new Set([...dictionaries.keys(), ...edits.keys()]);
|
|
341
|
+
const projected = messages.slice();
|
|
342
|
+
let beforeBytes = 0;
|
|
343
|
+
let afterBytes = 0;
|
|
344
|
+
for (const messageIndex of changedIndexes) {
|
|
345
|
+
const message = messages[messageIndex];
|
|
346
|
+
beforeBytes += byteLength(message.content);
|
|
347
|
+
const lines = message.content.split('\n');
|
|
348
|
+
const byLine = edits.get(messageIndex);
|
|
349
|
+
if (byLine) {
|
|
350
|
+
for (const [lineIndex, lineEdits] of byLine) {
|
|
351
|
+
let line = lines[lineIndex];
|
|
352
|
+
for (const edit of [...lineEdits].sort((a, b) => b.start - a.start)) {
|
|
353
|
+
line = line.slice(0, edit.start) + edit.replacement + line.slice(edit.end);
|
|
354
|
+
}
|
|
355
|
+
lines[lineIndex] = line;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const dictionaryLines = dictionaries.get(messageIndex) || [];
|
|
359
|
+
const content = [...dictionaryLines, ...lines].join('\n');
|
|
360
|
+
afterBytes += byteLength(content);
|
|
361
|
+
if (apply) projected[messageIndex] = { ...message, content };
|
|
362
|
+
}
|
|
363
|
+
const saved = beforeBytes - afterBytes;
|
|
364
|
+
if (saved <= 0) {
|
|
365
|
+
return {
|
|
366
|
+
messages,
|
|
367
|
+
stats: {
|
|
368
|
+
pathFacts: occurrences.length,
|
|
369
|
+
pathAliases: 0,
|
|
370
|
+
reusedPathFacts: 0,
|
|
371
|
+
pathAliasBytesSaved: 0,
|
|
372
|
+
changedIndexes: [],
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
messages: apply ? projected : messages,
|
|
378
|
+
stats: {
|
|
379
|
+
pathFacts: occurrences.length,
|
|
380
|
+
pathAliases,
|
|
381
|
+
reusedPathFacts,
|
|
382
|
+
pathAliasBytesSaved: saved,
|
|
383
|
+
changedIndexes: [...changedIndexes],
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
274
388
|
export function projectProviderEvidence(messages, options = {}) {
|
|
275
389
|
if (!Array.isArray(messages) || options.enabled === false) {
|
|
276
390
|
return {
|
|
@@ -284,6 +398,10 @@ export function projectProviderEvidence(messages, options = {}) {
|
|
|
284
398
|
changedToolResults: 0,
|
|
285
399
|
exactResultRefs: 0,
|
|
286
400
|
exactResultBytesSaved: 0,
|
|
401
|
+
pathFacts: 0,
|
|
402
|
+
pathAliases: 0,
|
|
403
|
+
reusedPathFacts: 0,
|
|
404
|
+
pathAliasBytesSaved: 0,
|
|
287
405
|
},
|
|
288
406
|
};
|
|
289
407
|
}
|
|
@@ -292,6 +410,7 @@ export function projectProviderEvidence(messages, options = {}) {
|
|
|
292
410
|
const callById = new Map();
|
|
293
411
|
const seen = new Map();
|
|
294
412
|
const seenExactResults = new Map();
|
|
413
|
+
const changedToolResultIndexes = new Set();
|
|
295
414
|
let projected = null;
|
|
296
415
|
const stats = {
|
|
297
416
|
beforeBytes: 0,
|
|
@@ -302,6 +421,10 @@ export function projectProviderEvidence(messages, options = {}) {
|
|
|
302
421
|
changedToolResults: 0,
|
|
303
422
|
exactResultRefs: 0,
|
|
304
423
|
exactResultBytesSaved: 0,
|
|
424
|
+
pathFacts: 0,
|
|
425
|
+
pathAliases: 0,
|
|
426
|
+
reusedPathFacts: 0,
|
|
427
|
+
pathAliasBytesSaved: 0,
|
|
305
428
|
};
|
|
306
429
|
|
|
307
430
|
for (let index = 0; index < messages.length; index += 1) {
|
|
@@ -341,6 +464,7 @@ export function projectProviderEvidence(messages, options = {}) {
|
|
|
341
464
|
stats.exactResultRefs += 1;
|
|
342
465
|
stats.exactResultBytesSaved += originalBytes - nextBytes;
|
|
343
466
|
stats.changedToolResults += 1;
|
|
467
|
+
changedToolResultIndexes.add(index);
|
|
344
468
|
stats.afterBytes += nextBytes;
|
|
345
469
|
if (apply) {
|
|
346
470
|
if (!projected) projected = messages.slice();
|
|
@@ -394,6 +518,7 @@ export function projectProviderEvidence(messages, options = {}) {
|
|
|
394
518
|
stats.reusedRows += duplicates.length;
|
|
395
519
|
stats.referenceGroups += references.length;
|
|
396
520
|
stats.changedToolResults += 1;
|
|
521
|
+
changedToolResultIndexes.add(index);
|
|
397
522
|
stats.afterBytes += nextBytes;
|
|
398
523
|
if (apply) {
|
|
399
524
|
if (!projected) projected = messages.slice();
|
|
@@ -401,6 +526,18 @@ export function projectProviderEvidence(messages, options = {}) {
|
|
|
401
526
|
}
|
|
402
527
|
}
|
|
403
528
|
|
|
529
|
+
const pathProjection = projectProviderPathAliases(projected || messages, { apply });
|
|
530
|
+
stats.pathFacts = pathProjection.stats.pathFacts;
|
|
531
|
+
stats.pathAliases = pathProjection.stats.pathAliases;
|
|
532
|
+
stats.reusedPathFacts = pathProjection.stats.reusedPathFacts;
|
|
533
|
+
stats.pathAliasBytesSaved = pathProjection.stats.pathAliasBytesSaved;
|
|
534
|
+
stats.afterBytes -= pathProjection.stats.pathAliasBytesSaved;
|
|
535
|
+
for (const index of pathProjection.stats.changedIndexes) changedToolResultIndexes.add(index);
|
|
536
|
+
stats.changedToolResults = changedToolResultIndexes.size;
|
|
537
|
+
if (apply && pathProjection.messages !== (projected || messages)) {
|
|
538
|
+
projected = pathProjection.messages;
|
|
539
|
+
}
|
|
540
|
+
|
|
404
541
|
return { messages: projected || messages, stats };
|
|
405
542
|
}
|
|
406
543
|
|
|
@@ -81,6 +81,60 @@ test('keeps tiny exact results when a reference would be larger', () => {
|
|
|
81
81
|
assert.equal(projected.stats.exactResultRefs, 0);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
test('aliases repeated typed paths across same-turn glob and batch read results', () => {
|
|
85
|
+
const repeated = `src/${'deep/'.repeat(10)}feature.mjs`;
|
|
86
|
+
const first = result('glob_1', `${repeated}\nsrc/unique.mjs`);
|
|
87
|
+
const second = result('read_1', `read 1\n\n${repeated} [ok]\n1→export const value = 1;`);
|
|
88
|
+
const messages = [
|
|
89
|
+
{
|
|
90
|
+
role: 'assistant',
|
|
91
|
+
content: '',
|
|
92
|
+
toolCalls: [
|
|
93
|
+
{ id: 'glob_1', name: 'glob', arguments: { pattern: '**/*.mjs' } },
|
|
94
|
+
{ id: 'read_1', name: 'read', arguments: { file_path: '**/*.mjs' } },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
first,
|
|
98
|
+
second,
|
|
99
|
+
];
|
|
100
|
+
const projected = projectProviderEvidence(messages);
|
|
101
|
+
const combined = `${projected.messages[1].content}\n${projected.messages[2].content}`;
|
|
102
|
+
assert.equal(projected.messages[1].toolCallId, 'glob_1');
|
|
103
|
+
assert.equal(projected.messages[2].toolCallId, 'read_1');
|
|
104
|
+
assert.equal(combined.split(repeated).length - 1, 1);
|
|
105
|
+
assert.match(projected.messages[1].content, /\[path-alias p1="/);
|
|
106
|
+
assert.match(projected.messages[1].content, /(?:^|\n)p1(?:\n|$)/);
|
|
107
|
+
assert.match(projected.messages[2].content, /(?:^|\n)p1 \[ok\](?:\n|$)/);
|
|
108
|
+
assert.equal(projected.stats.pathAliases, 1);
|
|
109
|
+
assert.equal(projected.stats.reusedPathFacts, 1);
|
|
110
|
+
assert.ok(projected.stats.pathAliasBytesSaved > 0);
|
|
111
|
+
assert.equal(messages[1], first);
|
|
112
|
+
assert.equal(messages[2], second);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('keeps short repeated paths and resets path aliases across mutations', () => {
|
|
116
|
+
const shortMessages = [
|
|
117
|
+
call('glob_1', 'glob'),
|
|
118
|
+
result('glob_1', 'src/a.mjs'),
|
|
119
|
+
call('read_1', 'read'),
|
|
120
|
+
result('read_1', 'src/a.mjs [ok]\n1→a'),
|
|
121
|
+
];
|
|
122
|
+
assert.equal(projectProviderEvidence(shortMessages).stats.pathAliases, 0);
|
|
123
|
+
|
|
124
|
+
const repeated = `src/${'nested/'.repeat(10)}a.mjs`;
|
|
125
|
+
const mutationMessages = [
|
|
126
|
+
call('glob_1', 'glob'),
|
|
127
|
+
result('glob_1', repeated),
|
|
128
|
+
call('patch_1', 'apply_patch'),
|
|
129
|
+
result('patch_1', 'ok'),
|
|
130
|
+
call('read_1', 'read'),
|
|
131
|
+
result('read_1', `${repeated} [ok]\n1→a`),
|
|
132
|
+
];
|
|
133
|
+
const projected = projectProviderEvidence(mutationMessages);
|
|
134
|
+
assert.equal(projected.messages, mutationMessages);
|
|
135
|
+
assert.equal(projected.stats.pathAliases, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
84
138
|
test('apply_patch, shell, and mutating git batches invalidate all earlier evidence', () => {
|
|
85
139
|
const same = `same ${'s'.repeat(160)}`;
|
|
86
140
|
const listing = Array.from({ length: 24 }, (_, index) => `src/item-${index}.mjs`).join('\n');
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { gitCommandMutates } from '../../tools/builtin/git-command-policy.mjs';
|
|
2
|
+
|
|
1
3
|
// Tool-name classification + intra-turn signature helpers, extracted from
|
|
2
4
|
// loop.mjs. These drive cross-turn read dedup, scoped caching, shell routing,
|
|
3
5
|
// and duplicate-call detection. Strips the MCP prefix so direct calls and
|
|
@@ -11,9 +13,13 @@ export function _stripMcpPrefix(name) {
|
|
|
11
13
|
export function _isReadTool(name) {
|
|
12
14
|
return _stripMcpPrefix(name) === 'read';
|
|
13
15
|
}
|
|
14
|
-
export function _isMutationTool(name) {
|
|
16
|
+
export function _isMutationTool(name, args = null) {
|
|
17
|
+
const n = String(_stripMcpPrefix(name) || '').toLowerCase();
|
|
18
|
+
return n === 'apply_patch' || n === 'edit' || (n === 'git' && gitCommandMutates(args));
|
|
19
|
+
}
|
|
20
|
+
export function _isGitMutationTool(name, args = null) {
|
|
15
21
|
const n = String(_stripMcpPrefix(name) || '').toLowerCase();
|
|
16
|
-
return n === '
|
|
22
|
+
return n === 'git' && gitCommandMutates(args);
|
|
17
23
|
}
|
|
18
24
|
export function _isEditTool(name) {
|
|
19
25
|
return String(_stripMcpPrefix(name) || '').toLowerCase() === 'edit';
|
|
@@ -587,7 +587,7 @@ export async function processToolBatch(ctx) {
|
|
|
587
587
|
clearScopedToolsForSession(sessionId);
|
|
588
588
|
}
|
|
589
589
|
}
|
|
590
|
-
if (_isMutationTool(call.name)) {
|
|
590
|
+
if (_isMutationTool(call.name, call.arguments)) {
|
|
591
591
|
epoch.mutation += 1;
|
|
592
592
|
}
|
|
593
593
|
// Bash always clears scoped cache UNCONDITIONALLY — a mutating bash
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { tokenizeDirectArgv } from './shell-direct-exe.mjs';
|
|
2
|
+
|
|
3
|
+
const ALWAYS_READ = new Set([
|
|
4
|
+
'blame', 'cat-file', 'check-attr', 'check-ignore', 'check-ref-format',
|
|
5
|
+
'cherry', 'count-objects', 'describe', 'diff', 'diff-files', 'diff-index',
|
|
6
|
+
'diff-tree', 'for-each-ref', 'fsck', 'grep', 'help', 'log', 'ls-files',
|
|
7
|
+
'ls-remote', 'ls-tree', 'merge-base', 'merge-tree', 'name-rev',
|
|
8
|
+
'range-diff', 'rev-list', 'rev-parse', 'shortlog', 'show', 'show-branch',
|
|
9
|
+
'show-ref', 'status', 'verify-commit', 'verify-pack', 'verify-tag',
|
|
10
|
+
'whatchanged',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function commandHasShellSyntax(command) {
|
|
14
|
+
const text = String(command || '');
|
|
15
|
+
let quote = null;
|
|
16
|
+
for (let index = 0; index < text.length; index++) {
|
|
17
|
+
const char = text[index];
|
|
18
|
+
if (quote === "'") {
|
|
19
|
+
if (char === "'") quote = null;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (quote === '"') {
|
|
23
|
+
if (char === '\\' && text[index + 1] === '"') { index++; continue; }
|
|
24
|
+
if (char === '"') { quote = null; continue; }
|
|
25
|
+
if (char === '$' || char === '`') return true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (char === "'" || char === '"') { quote = char; continue; }
|
|
29
|
+
if ('|&;<>()\n\r'.includes(char) || char === '$' || char === '`') return true;
|
|
30
|
+
}
|
|
31
|
+
return quote !== null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function gitActionOf(operation, args) {
|
|
35
|
+
const first = args.find((value) => value && !value.startsWith('-'));
|
|
36
|
+
if (operation === 'stash') return first || 'push';
|
|
37
|
+
if (operation === 'worktree') return first || 'list';
|
|
38
|
+
if (operation === 'remote') return first || 'list';
|
|
39
|
+
if (operation === 'reflog') return first || 'show';
|
|
40
|
+
return first || 'list';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function gitPlanIsReadOnly(plan) {
|
|
44
|
+
const { operation, args } = plan;
|
|
45
|
+
if (operation === 'fsck' && args.includes('--lost-found')) return false;
|
|
46
|
+
if (ALWAYS_READ.has(operation)) return true;
|
|
47
|
+
if (operation === 'reflog') return !['delete', 'expire'].includes(gitActionOf(operation, args));
|
|
48
|
+
if (operation === 'stash') return ['list', 'show'].includes(gitActionOf(operation, args));
|
|
49
|
+
if (operation === 'worktree') return gitActionOf(operation, args) === 'list';
|
|
50
|
+
if (operation === 'remote') return args.length === 0 || args.includes('-v') || ['get-url', 'show'].includes(gitActionOf(operation, args));
|
|
51
|
+
if (operation === 'clean') return args.some((value) => value === '-n' || value === '--dry-run' || /^-[^-]*n/.test(value));
|
|
52
|
+
if (operation === 'bundle') return ['list-heads', 'verify'].includes(gitActionOf(operation, args));
|
|
53
|
+
if (operation === 'notes') return ['', 'list', 'show'].includes(gitActionOf(operation, args));
|
|
54
|
+
if (operation === 'replace') return args.length === 0 || args.includes('--list');
|
|
55
|
+
if (operation === 'sparse-checkout') return gitActionOf(operation, args) === 'list';
|
|
56
|
+
if (operation === 'submodule') return ['', 'status', 'summary'].includes(gitActionOf(operation, args));
|
|
57
|
+
if (operation === 'symbolic-ref') return args.filter((value) => !value.startsWith('-')).length <= 1;
|
|
58
|
+
if (operation === 'hash-object') return !args.includes('-w') && !args.includes('--stdin-paths');
|
|
59
|
+
if (operation === 'branch' || operation === 'tag') {
|
|
60
|
+
return args.length === 0 || args.some((value) => ['--list', '-l', '-a', '--all', '-r', '--remotes'].includes(value));
|
|
61
|
+
}
|
|
62
|
+
if (operation === 'config') {
|
|
63
|
+
const mutationFlags = new Set([
|
|
64
|
+
'--add', '--edit', '--rename-section', '--remove-section',
|
|
65
|
+
'--replace-all', '--unset', '--unset-all',
|
|
66
|
+
]);
|
|
67
|
+
if (args.some((value) => mutationFlags.has(value))) return false;
|
|
68
|
+
const readFlag = args.some((value) => /^--(?:get|get-all|get-regexp|get-urlmatch|list|show-origin|show-scope)$/.test(value));
|
|
69
|
+
const positional = args.filter((value) => !value.startsWith('-'));
|
|
70
|
+
if (['set', 'unset', 'rename-section', 'remove-section'].includes(positional[0])) return false;
|
|
71
|
+
if (['get', 'get-all', 'get-regexp', 'get-urlmatch', 'list'].includes(positional[0])) return true;
|
|
72
|
+
return readFlag || positional.length <= 1;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parsedGitOperation(command) {
|
|
78
|
+
if (commandHasShellSyntax(command)) return null;
|
|
79
|
+
const tokens = tokenizeDirectArgv(command);
|
|
80
|
+
if (!tokens?.length || !/(^|[\\/])git(?:\.exe)?$/i.test(tokens[0])) return null;
|
|
81
|
+
let index = 1;
|
|
82
|
+
while (index < tokens.length) {
|
|
83
|
+
const token = tokens[index];
|
|
84
|
+
if (token === '-C' || token === '-c' || token === '--config-env' || token === '--git-dir'
|
|
85
|
+
|| token === '--work-tree' || token === '--namespace') {
|
|
86
|
+
index += 2;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (/^-C.+/.test(token) || /^--(?:git-dir|work-tree|namespace|config-env)=/.test(token)
|
|
90
|
+
|| ['--no-pager', '--paginate', '--bare', '--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs'].includes(token)) {
|
|
91
|
+
index++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
const operation = String(tokens[index] || '').toLowerCase();
|
|
97
|
+
return operation ? { operation, args: tokens.slice(index + 1) } : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function gitCommandMutates(value) {
|
|
101
|
+
let args = value;
|
|
102
|
+
if (typeof args === 'string') {
|
|
103
|
+
try { args = JSON.parse(args); } catch { args = { command: args }; }
|
|
104
|
+
}
|
|
105
|
+
const parsed = parsedGitOperation(args?.command);
|
|
106
|
+
return !parsed || !gitPlanIsReadOnly(parsed);
|
|
107
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { _isGitMutationTool, _isMutationTool } from '../../session/loop/tool-classify.mjs';
|
|
4
|
+
import { gitCommandMutates } from './git-command-policy.mjs';
|
|
5
|
+
|
|
6
|
+
test('git mutation policy is shared by orchestration and evidence projection', () => {
|
|
7
|
+
for (const command of ['git status', 'git log -5', 'git fsck --full', 'git clean -n', 'git config get user.name']) {
|
|
8
|
+
assert.equal(gitCommandMutates({ command }), false, command);
|
|
9
|
+
assert.equal(_isMutationTool('git', { command }), false, command);
|
|
10
|
+
assert.equal(_isGitMutationTool('git', { command }), false, command);
|
|
11
|
+
}
|
|
12
|
+
for (const command of [
|
|
13
|
+
'git add --all',
|
|
14
|
+
'git commit -m test',
|
|
15
|
+
'git prune --expire=now',
|
|
16
|
+
'git reflog delete HEAD@{1}',
|
|
17
|
+
'git fsck --lost-found',
|
|
18
|
+
'git config --unset user.name',
|
|
19
|
+
'git config unset user.name',
|
|
20
|
+
]) {
|
|
21
|
+
assert.equal(gitCommandMutates({ command }), true, command);
|
|
22
|
+
assert.equal(_isMutationTool('git', { command }), true, command);
|
|
23
|
+
assert.equal(_isGitMutationTool('git', { command }), true, command);
|
|
24
|
+
}
|
|
25
|
+
assert.equal(gitCommandMutates({ command: 'git status && git clean -fd' }), true);
|
|
26
|
+
assert.equal(_isMutationTool('apply_patch', {}), true);
|
|
27
|
+
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { basename, resolve } from 'node:path';
|
|
3
2
|
import { tokenizeDirectArgv } from './shell-direct-exe.mjs';
|
|
4
3
|
import { withBuiltinPathLocks } from './path-locks.mjs';
|
|
@@ -6,6 +5,8 @@ import { withAdvisoryLocks } from './advisory-lock.mjs';
|
|
|
6
5
|
import { withGitRepoReadLock, withGitRepoWriteLock } from './git-repo-rw-lock.mjs';
|
|
7
6
|
import { invalidateBuiltinResultCache } from './cache-layers.mjs';
|
|
8
7
|
import { drainCodeGraphCache } from '../code-graph-state.mjs';
|
|
8
|
+
import { ensureNativeSpawnServer, tryNativeSpawn } from '../lib/native-spawn-client.mjs';
|
|
9
|
+
import { gitActionOf as actionOf, gitPlanIsReadOnly as isReadOnly } from './git-command-policy.mjs';
|
|
9
10
|
|
|
10
11
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
11
12
|
const MAX_CAPTURE_BYTES = 128 * 1024 * 1024;
|
|
@@ -24,15 +25,6 @@ const SUPPORTED = new Set([
|
|
|
24
25
|
'symbolic-ref', 'tag', 'update-ref', 'verify-commit', 'verify-pack',
|
|
25
26
|
'verify-tag', 'whatchanged', 'worktree', 'write-tree',
|
|
26
27
|
]);
|
|
27
|
-
const ALWAYS_READ = new Set([
|
|
28
|
-
'blame', 'cat-file', 'check-attr', 'check-ignore', 'check-ref-format',
|
|
29
|
-
'cherry', 'count-objects', 'describe', 'diff', 'diff-files', 'diff-index',
|
|
30
|
-
'diff-tree', 'for-each-ref', 'fsck', 'grep', 'help', 'log', 'ls-files',
|
|
31
|
-
'ls-remote', 'ls-tree', 'merge-base', 'merge-tree', 'name-rev',
|
|
32
|
-
'range-diff', 'rev-list', 'rev-parse', 'shortlog', 'show', 'show-branch',
|
|
33
|
-
'show-ref', 'status', 'verify-commit', 'verify-pack', 'verify-tag',
|
|
34
|
-
'whatchanged',
|
|
35
|
-
]);
|
|
36
28
|
const PUSH_NOISE = [
|
|
37
29
|
'Enumerating objects:', 'Counting objects:', 'Compressing objects:',
|
|
38
30
|
'Writing objects:', 'Delta compression using', 'Total ',
|
|
@@ -155,11 +147,14 @@ function parseCommand(command, workDir) {
|
|
|
155
147
|
return { command: String(command), cwd, globalArgs, operation, args: tokens.slice(index + 1) };
|
|
156
148
|
}
|
|
157
149
|
|
|
158
|
-
function runProcess(program, argv, { cwd, signal, maxBytes = MAX_CAPTURE_BYTES } = {}) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
150
|
+
async function runProcess(program, argv, { cwd, signal, maxBytes = MAX_CAPTURE_BYTES } = {}) {
|
|
151
|
+
let child;
|
|
152
|
+
try {
|
|
153
|
+
await ensureNativeSpawnServer();
|
|
154
|
+
const native = tryNativeSpawn({
|
|
155
|
+
shell: program,
|
|
156
|
+
argv,
|
|
157
|
+
spawnOptions: {
|
|
163
158
|
cwd,
|
|
164
159
|
env: {
|
|
165
160
|
...process.env,
|
|
@@ -169,14 +164,15 @@ function runProcess(program, argv, { cwd, signal, maxBytes = MAX_CAPTURE_BYTES }
|
|
|
169
164
|
GIT_SEQUENCE_EDITOR: 'true',
|
|
170
165
|
LC_ALL: 'C',
|
|
171
166
|
},
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
167
|
+
outputLimit: maxBytes,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
if (!native?.child) throw Object.assign(new Error('verified native spawn server unavailable'), { code: 'NATIVE_SPAWN_UNAVAILABLE' });
|
|
171
|
+
child = native.child;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return { exitCode: null, signal: null, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), error, timedOut: false, aborted: false, overflow: false };
|
|
174
|
+
}
|
|
175
|
+
return new Promise((done) => {
|
|
180
176
|
const stdout = [], stderr = [];
|
|
181
177
|
let bytes = 0, timedOut = false, aborted = false, overflow = false, settled = false;
|
|
182
178
|
const stop = () => { try { child.kill('SIGKILL'); } catch {} };
|
|
@@ -228,42 +224,6 @@ function runGit(plan, argv, options = {}) {
|
|
|
228
224
|
return runProcess('git', [...plan.globalArgs, ...argv], { cwd: plan.cwd, signal: options.signal });
|
|
229
225
|
}
|
|
230
226
|
|
|
231
|
-
function actionOf(operation, args) {
|
|
232
|
-
const first = args.find((value) => value && !value.startsWith('-'));
|
|
233
|
-
if (operation === 'stash') return first || 'push';
|
|
234
|
-
if (operation === 'worktree') return first || 'list';
|
|
235
|
-
if (operation === 'remote') return first || 'list';
|
|
236
|
-
if (operation === 'reflog') return first || 'show';
|
|
237
|
-
return first || 'list';
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function isReadOnly(plan) {
|
|
241
|
-
const { operation, args } = plan;
|
|
242
|
-
if (ALWAYS_READ.has(operation)) return true;
|
|
243
|
-
if (operation === 'reflog') return !['delete', 'expire'].includes(actionOf(operation, args));
|
|
244
|
-
if (operation === 'stash') return ['list', 'show'].includes(actionOf(operation, args));
|
|
245
|
-
if (operation === 'worktree') return actionOf(operation, args) === 'list';
|
|
246
|
-
if (operation === 'remote') return args.length === 0 || args.includes('-v') || ['get-url', 'show'].includes(actionOf(operation, args));
|
|
247
|
-
if (operation === 'clean') return args.some((value) => value === '-n' || value === '--dry-run' || /^-[^-]*n/.test(value));
|
|
248
|
-
if (operation === 'bundle') return ['list-heads', 'verify'].includes(actionOf(operation, args));
|
|
249
|
-
if (operation === 'notes') return ['', 'list', 'show'].includes(actionOf(operation, args));
|
|
250
|
-
if (operation === 'replace') return args.length === 0 || args.includes('--list');
|
|
251
|
-
if (operation === 'sparse-checkout') return actionOf(operation, args) === 'list';
|
|
252
|
-
if (operation === 'submodule') return ['', 'status', 'summary'].includes(actionOf(operation, args));
|
|
253
|
-
if (operation === 'symbolic-ref') return args.filter((value) => !value.startsWith('-')).length <= 1;
|
|
254
|
-
if (operation === 'hash-object') return !args.includes('-w') && !args.includes('--stdin-paths');
|
|
255
|
-
if (operation === 'branch' || operation === 'tag') {
|
|
256
|
-
if (args.length === 0 || args.some((value) => ['--list', '-l', '-a', '--all', '-r', '--remotes'].includes(value))) return true;
|
|
257
|
-
return false;
|
|
258
|
-
}
|
|
259
|
-
if (operation === 'config') {
|
|
260
|
-
const readFlag = args.some((value) => /^--(?:get|get-all|get-regexp|get-urlmatch|list|show-origin|show-scope)$/.test(value));
|
|
261
|
-
const positional = args.filter((value) => !value.startsWith('-'));
|
|
262
|
-
return readFlag || positional.length <= 1;
|
|
263
|
-
}
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
227
|
function destructiveReason(plan) {
|
|
268
228
|
const { operation, args } = plan;
|
|
269
229
|
if (operation === 'reset' && args.includes('--hard')) return 'git reset --hard';
|
|
@@ -544,8 +504,50 @@ async function resolveRepo(plan, signal) {
|
|
|
544
504
|
return succeeded(result) ? cleanText(result.stdout) : null;
|
|
545
505
|
}
|
|
546
506
|
|
|
547
|
-
|
|
548
|
-
|
|
507
|
+
function localizeConfigPlan(plan) {
|
|
508
|
+
if (plan.operation !== 'config') return plan;
|
|
509
|
+
const external = plan.args.find((value) => ['--global', '--system', '--file', '-f'].includes(value)
|
|
510
|
+
|| value.startsWith('--file=')
|
|
511
|
+
|| (/^-f./.test(value) && !value.startsWith('--')));
|
|
512
|
+
if (external) throw new Error(`git config ${external} is outside the local repository scope`);
|
|
513
|
+
if (plan.args.some((value) => value === '--local' || value === '--worktree')) return plan;
|
|
514
|
+
return { ...plan, args: ['--local', ...plan.args] };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function optionFreePositionals(args) {
|
|
518
|
+
const takesValue = new Set([
|
|
519
|
+
'-b', '--branch', '-c', '--config', '--depth', '-j', '--jobs', '-o',
|
|
520
|
+
'--origin', '--reference', '--reference-if-able', '--separate-git-dir',
|
|
521
|
+
'--template', '-u', '--upload-pack', '--filter', '--server-option',
|
|
522
|
+
'--shallow-since', '--shallow-exclude', '--bundle-uri', '--revision',
|
|
523
|
+
'--ref-format', '--object-format', '--initial-branch',
|
|
524
|
+
]);
|
|
525
|
+
const out = [];
|
|
526
|
+
for (let index = 0; index < args.length; index++) {
|
|
527
|
+
const value = args[index];
|
|
528
|
+
if (value === '--') {
|
|
529
|
+
out.push(...args.slice(index + 1));
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
if (takesValue.has(value)) { index++; continue; }
|
|
533
|
+
if (value.startsWith('-')) continue;
|
|
534
|
+
out.push(value);
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function creationTarget(plan) {
|
|
540
|
+
const positional = optionFreePositionals(plan.args);
|
|
541
|
+
if (plan.operation === 'init') return resolve(plan.cwd, positional.at(-1) || '.');
|
|
542
|
+
if (plan.operation !== 'clone') return plan.cwd;
|
|
543
|
+
if (positional.length >= 2) return resolve(plan.cwd, positional.at(-1));
|
|
544
|
+
const source = String(positional[0] || '').replace(/[\\/]+$/, '');
|
|
545
|
+
const leaf = basename(source.includes(':') ? source.slice(source.lastIndexOf(':') + 1) : source).replace(/\.git$/i, '') || 'repo';
|
|
546
|
+
return resolve(plan.cwd, leaf);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function executeCreation(plan, target, limit, signal) {
|
|
550
|
+
return withBuiltinPathLocks([target], () => withAdvisoryLocks([target], async () => {
|
|
549
551
|
const result = await runGit(plan, [plan.operation, ...plan.args], { signal });
|
|
550
552
|
if (!succeeded(result)) return commandFailure(plan, result);
|
|
551
553
|
invalidateBuiltinResultCache();
|
|
@@ -557,7 +559,7 @@ async function executeCreation(plan, limit, signal) {
|
|
|
557
559
|
export async function executeGitTool(input, workDir, options = {}) {
|
|
558
560
|
if (!input || typeof input !== 'object' || Array.isArray(input)) return fail('git requires an arguments object');
|
|
559
561
|
let plan;
|
|
560
|
-
try { plan = parseCommand(input.command, workDir); }
|
|
562
|
+
try { plan = localizeConfigPlan(parseCommand(input.command, workDir)); }
|
|
561
563
|
catch (error) { return fail(error.message); }
|
|
562
564
|
if (plan.operation === 'archive' && !plan.args.some((value) => value === '-o' || value === '--output' || value.startsWith('--output='))) {
|
|
563
565
|
return fail('git archive requires -o/--output; binary stdout is not returned');
|
|
@@ -568,7 +570,8 @@ export async function executeGitTool(input, workDir, options = {}) {
|
|
|
568
570
|
if (reason && input.confirm !== true) return fail(`${reason} requires confirm:true`);
|
|
569
571
|
const signal = options?.signal || options?.abortSignal || null;
|
|
570
572
|
if (plan.operation === 'init' || plan.operation === 'clone') {
|
|
571
|
-
|
|
573
|
+
const target = creationTarget(plan);
|
|
574
|
+
return withGitRepoWriteLock(target, () => executeCreation(plan, target, limit, signal), { signal });
|
|
572
575
|
}
|
|
573
576
|
const repo = await resolveRepo(plan, signal);
|
|
574
577
|
if (!repo) {
|
|
@@ -581,7 +584,7 @@ export async function executeGitTool(input, workDir, options = {}) {
|
|
|
581
584
|
const result = await runGit(plan, prepared.argv, { signal });
|
|
582
585
|
if (!succeeded(result)) return commandFailure(plan, result);
|
|
583
586
|
return ok(formatRead(prepared, cleanText(result.stdout), cleanText(result.stderr), limit));
|
|
584
|
-
});
|
|
587
|
+
}, { signal });
|
|
585
588
|
}
|
|
586
589
|
return withGitRepoWriteLock(repo, () => withBuiltinPathLocks([repo], () => withAdvisoryLocks([repo], async () => {
|
|
587
590
|
const before = await statusSnapshot(repo, signal);
|
|
@@ -591,5 +594,7 @@ export async function executeGitTool(input, workDir, options = {}) {
|
|
|
591
594
|
drainCodeGraphCache();
|
|
592
595
|
if (!succeeded(result)) return `${commandFailure(plan, result)}\n${JSON.stringify({ status: statusDelta(before, after, limit) })}`;
|
|
593
596
|
return ok({ ...mutationData(plan, cleanText(result.stdout), cleanText(result.stderr), limit), status: statusDelta(before, after, limit) });
|
|
594
|
-
})));
|
|
597
|
+
})), { signal });
|
|
595
598
|
}
|
|
599
|
+
|
|
600
|
+
export const _gitCommandInternals = { creationTarget, localizeConfigPlan, parseCommand };
|
|
@@ -4,7 +4,7 @@ import { mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import test from 'node:test';
|
|
7
|
-
import { executeGitTool, GIT_TOOL_DEF } from './git-command-tool.mjs';
|
|
7
|
+
import { executeGitTool, GIT_TOOL_DEF, _gitCommandInternals } from './git-command-tool.mjs';
|
|
8
8
|
|
|
9
9
|
function parseOk(result) {
|
|
10
10
|
assert.doesNotMatch(String(result), /^Error:/, String(result));
|
|
@@ -29,6 +29,14 @@ test('git command tool preserves shell syntax, compacts output, and gates destru
|
|
|
29
29
|
assert.equal(parseOk(await executeGitTool({ command: `git init ${quote(repo)}` }, root)).summary, 'initialized');
|
|
30
30
|
parseOk(await git(repo, 'config user.name "Mixdog Test"'));
|
|
31
31
|
parseOk(await git(repo, 'config user.email mixdog@example.invalid'));
|
|
32
|
+
assert.match(String(await git(repo, 'config --global user.name')), /outside the local repository scope/);
|
|
33
|
+
assert.match(String(await git(repo, 'config --system user.name')), /outside the local repository scope/);
|
|
34
|
+
assert.match(String(await git(repo, 'config --file ..\/outside user.name')), /outside the local repository scope/);
|
|
35
|
+
assert.match(String(await git(repo, 'config -f..\/outside user.name')), /outside the local repository scope/);
|
|
36
|
+
assert.equal(
|
|
37
|
+
_gitCommandInternals.creationTarget(_gitCommandInternals.parseCommand(`git clone origin ${quote(join(root, 'target'))}`, root)),
|
|
38
|
+
join(root, 'target'),
|
|
39
|
+
);
|
|
32
40
|
|
|
33
41
|
writeFileSync(join(repo, 'base.txt'), 'base\n');
|
|
34
42
|
const staged = parseOk(await git(repo, 'add --all'));
|
|
@@ -26,12 +26,16 @@ function drain(key, state) {
|
|
|
26
26
|
if (state.readers === 0 && state.queue[0].mode === 'write') {
|
|
27
27
|
const entry = state.queue.shift();
|
|
28
28
|
state.writer = true;
|
|
29
|
+
entry.granted = true;
|
|
30
|
+
entry.signal?.removeEventListener?.('abort', entry.onAbort);
|
|
29
31
|
entry.resolve(releaseFor(key, state, 'write'));
|
|
30
32
|
return;
|
|
31
33
|
}
|
|
32
34
|
while (state.queue[0]?.mode === 'read' && !state.writer) {
|
|
33
35
|
const entry = state.queue.shift();
|
|
34
36
|
state.readers++;
|
|
37
|
+
entry.granted = true;
|
|
38
|
+
entry.signal?.removeEventListener?.('abort', entry.onAbort);
|
|
35
39
|
entry.resolve(releaseFor(key, state, 'read'));
|
|
36
40
|
}
|
|
37
41
|
}
|
|
@@ -47,28 +51,46 @@ function releaseFor(key, state, mode) {
|
|
|
47
51
|
};
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
function
|
|
54
|
+
function abortError(signal) {
|
|
55
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
56
|
+
return Object.assign(new Error('git repository lock aborted'), { name: 'AbortError', code: 'ABORT_ERR' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function acquire(repo, mode, signal) {
|
|
60
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
51
61
|
const key = keyFor(repo);
|
|
52
62
|
const state = stateFor(key);
|
|
53
|
-
return new Promise((resolveLock) => {
|
|
54
|
-
|
|
63
|
+
return new Promise((resolveLock, rejectLock) => {
|
|
64
|
+
const entry = { mode, resolve: resolveLock, reject: rejectLock, signal, onAbort: null, granted: false };
|
|
65
|
+
entry.onAbort = () => {
|
|
66
|
+
if (entry.granted) return;
|
|
67
|
+
const index = state.queue.indexOf(entry);
|
|
68
|
+
if (index >= 0) state.queue.splice(index, 1);
|
|
69
|
+
signal?.removeEventListener?.('abort', entry.onAbort);
|
|
70
|
+
rejectLock(abortError(signal));
|
|
71
|
+
drain(key, state);
|
|
72
|
+
};
|
|
73
|
+
state.queue.push(entry);
|
|
74
|
+
signal?.addEventListener?.('abort', entry.onAbort, { once: true });
|
|
55
75
|
drain(key, state);
|
|
56
76
|
});
|
|
57
77
|
}
|
|
58
78
|
|
|
59
|
-
async function withLock(repo, mode, fn) {
|
|
60
|
-
const
|
|
79
|
+
async function withLock(repo, mode, fn, options = {}) {
|
|
80
|
+
const signal = options?.signal || null;
|
|
81
|
+
const release = await acquire(repo, mode, signal);
|
|
61
82
|
try {
|
|
83
|
+
if (signal?.aborted) throw abortError(signal);
|
|
62
84
|
return await fn();
|
|
63
85
|
} finally {
|
|
64
86
|
release();
|
|
65
87
|
}
|
|
66
88
|
}
|
|
67
89
|
|
|
68
|
-
export function withGitRepoReadLock(repo, fn) {
|
|
69
|
-
return withLock(repo, 'read', fn);
|
|
90
|
+
export function withGitRepoReadLock(repo, fn, options = {}) {
|
|
91
|
+
return withLock(repo, 'read', fn, options);
|
|
70
92
|
}
|
|
71
93
|
|
|
72
|
-
export function withGitRepoWriteLock(repo, fn) {
|
|
73
|
-
return withLock(repo, 'write', fn);
|
|
94
|
+
export function withGitRepoWriteLock(repo, fn, options = {}) {
|
|
95
|
+
return withLock(repo, 'write', fn, options);
|
|
74
96
|
}
|
|
@@ -62,3 +62,22 @@ test('git repo writers on different repositories run in parallel', async () => {
|
|
|
62
62
|
hold.release();
|
|
63
63
|
await Promise.all([first, second]);
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
test('a queued git repo lock aborts without running or blocking later work', async () => {
|
|
67
|
+
const repo = `repo-abort-${Date.now()}-${Math.random()}`;
|
|
68
|
+
const hold = gate();
|
|
69
|
+
const writer = withGitRepoWriteLock(repo, () => hold.promise);
|
|
70
|
+
await Promise.resolve();
|
|
71
|
+
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
let ran = false;
|
|
74
|
+
const queued = withGitRepoReadLock(repo, async () => { ran = true; }, { signal: controller.signal });
|
|
75
|
+
controller.abort();
|
|
76
|
+
await assert.rejects(queued, /abort/i);
|
|
77
|
+
assert.equal(ran, false);
|
|
78
|
+
|
|
79
|
+
hold.release();
|
|
80
|
+
await writer;
|
|
81
|
+
await withGitRepoReadLock(repo, async () => { ran = true; });
|
|
82
|
+
assert.equal(ran, true);
|
|
83
|
+
});
|