@bermudi/pi-delegate 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -4
- package/concurrency.ts +55 -16
- package/config.ts +584 -50
- package/delegate.ts +8 -1
- package/dispatch.ts +410 -38
- package/extension.ts +14 -0
- package/format.ts +50 -4
- package/host.ts +6 -1
- package/isolated-workspace.ts +857 -0
- package/lifecycle.ts +50 -16
- package/manual.ts +13 -9
- package/package.json +1 -1
- package/pool.ts +23 -1
- package/provider-extensions.ts +11 -2
- package/render-branches.ts +30 -0
- package/render-result.ts +8 -5
- package/runner.ts +3 -1
- package/schema.ts +58 -51
- package/settings.ts +202 -84
- package/shared-write-safety.ts +273 -0
- package/task-resolution.ts +87 -16
- package/telemetry.ts +135 -68
- package/ticket-format.ts +13 -5
- package/tickets.ts +23 -11
- package/types.ts +79 -0
package/settings.ts
CHANGED
|
@@ -4,15 +4,15 @@ import * as path from "node:path";
|
|
|
4
4
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
5
5
|
import { VALID_THINKING } from "./constants.ts";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
/**
|
|
8
|
+
* One-release compatibility bridge for delegate overrides formerly stored in
|
|
9
|
+
* Pi's user/project settings.json. Only model and thinking are bridged; tools
|
|
10
|
+
* remain operator-controlled because project files must not restore shell
|
|
11
|
+
* capability. Modern delegate.json values win field-by-field.
|
|
12
|
+
*/
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
agentOverridesByParentModel?: Record<string, Record<string, AgentOverride>>;
|
|
14
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
15
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Read and validate a JSON settings object, returning null on I/O or parse errors. */
|
|
@@ -44,8 +44,104 @@ export function readDelegateSettingsFile(
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
interface LegacyDetectionCacheEntry {
|
|
48
|
+
signature: string;
|
|
49
|
+
carriesDelegateBlock: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const legacyDetectionCache = new Map<string, LegacyDetectionCacheEntry>();
|
|
53
|
+
const warnedLegacyDetectionFailures = new Set<string>();
|
|
54
|
+
|
|
55
|
+
/** A cheap file identity used to invalidate cached misses and parse failures
|
|
56
|
+
* when a settings file is created or edited. */
|
|
57
|
+
function settingsFileSignature(filePath: string): string {
|
|
58
|
+
try {
|
|
59
|
+
const stat = fs.statSync(filePath);
|
|
60
|
+
return [
|
|
61
|
+
"present",
|
|
62
|
+
stat.dev,
|
|
63
|
+
stat.ino,
|
|
64
|
+
stat.size,
|
|
65
|
+
stat.mtimeMs,
|
|
66
|
+
stat.ctimeMs,
|
|
67
|
+
stat.mode,
|
|
68
|
+
].join(":");
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (
|
|
71
|
+
error instanceof Error &&
|
|
72
|
+
"code" in error &&
|
|
73
|
+
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
74
|
+
) {
|
|
75
|
+
return "missing";
|
|
76
|
+
}
|
|
77
|
+
// An inaccessible or otherwise unstatable file is not a stable miss. Let
|
|
78
|
+
// the read path report it on each attempt rather than hiding a later fix.
|
|
79
|
+
return `unstatable:${Date.now()}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function settingsCarryDelegateBlock(filePath: string): boolean {
|
|
84
|
+
const key = path.resolve(filePath);
|
|
85
|
+
const signature = settingsFileSignature(key);
|
|
86
|
+
const cached = legacyDetectionCache.get(key);
|
|
87
|
+
if (cached?.signature === signature) return cached.carriesDelegateBlock;
|
|
88
|
+
|
|
89
|
+
const parsed = readDelegateSettingsFile(key);
|
|
90
|
+
const carriesDelegateBlock = parsed !== null && isRecord(parsed.delegate);
|
|
91
|
+
legacyDetectionCache.set(key, { signature, carriesDelegateBlock });
|
|
92
|
+
return carriesDelegateBlock;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Paths of pi settings files (user + nearest project `.pi/settings.json`)
|
|
96
|
+
* that still carry a `delegate` block. Fail-open: never throws. */
|
|
97
|
+
export function findLegacyDelegateSettings(cwd: string): string[] {
|
|
98
|
+
const paths: string[] = [];
|
|
99
|
+
try {
|
|
100
|
+
const userPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
101
|
+
if (settingsCarryDelegateBlock(userPath)) paths.push(userPath);
|
|
102
|
+
|
|
103
|
+
// Nearest `.pi` directory walking up from cwd — the same discovery the
|
|
104
|
+
// old loader used, so a project that relied on overrides is still caught.
|
|
105
|
+
let dir = path.resolve(cwd);
|
|
106
|
+
const root = path.resolve("/");
|
|
107
|
+
for (;;) {
|
|
108
|
+
if (fs.existsSync(path.join(dir, ".pi"))) {
|
|
109
|
+
const projectPath = path.join(dir, ".pi", "settings.json");
|
|
110
|
+
if (settingsCarryDelegateBlock(projectPath)) paths.push(projectPath);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
if (dir === root) break;
|
|
114
|
+
const parent = path.dirname(dir);
|
|
115
|
+
if (parent === dir) break;
|
|
116
|
+
dir = parent;
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
// Detection is best-effort and must never block a dispatch, but silently
|
|
120
|
+
// losing the migration signal would make ignored legacy overrides hard to
|
|
121
|
+
// diagnose. Report each affected cwd once to avoid warning floods when a
|
|
122
|
+
// dispatch resolves several tasks in the same project.
|
|
123
|
+
if (!warnedLegacyDetectionFailures.has(cwd)) {
|
|
124
|
+
warnedLegacyDetectionFailures.add(cwd);
|
|
125
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
126
|
+
console.warn(
|
|
127
|
+
`[delegate] could not check for legacy delegate settings from '${cwd}': ${detail}. Overrides in pi settings.json may be ignored; move them to ~/.pi/agent/delegate.json.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return paths;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const warnedLegacySources = new Set<string>();
|
|
135
|
+
|
|
136
|
+
/** Shape returned by the temporary compatibility loader. */
|
|
137
|
+
export interface DelegateSettings {
|
|
138
|
+
agentOverrides?: Record<string, AgentOverride>;
|
|
139
|
+
agentOverridesByParentModel?: Record<string, Record<string, AgentOverride>>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface AgentOverride {
|
|
143
|
+
model?: string;
|
|
144
|
+
thinking?: ThinkingLevel;
|
|
49
145
|
}
|
|
50
146
|
|
|
51
147
|
function normalizeOverride(
|
|
@@ -79,21 +175,9 @@ function normalizeOverride(
|
|
|
79
175
|
}
|
|
80
176
|
result.thinking = value as ThinkingLevel;
|
|
81
177
|
} else if (key === "tools") {
|
|
82
|
-
if (
|
|
83
|
-
!Array.isArray(value) ||
|
|
84
|
-
value.some((tool) => typeof tool !== "string")
|
|
85
|
-
) {
|
|
86
|
-
console.warn(
|
|
87
|
-
`[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: tools must be a string array.`,
|
|
88
|
-
);
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
result.tools = [...value];
|
|
92
|
-
} else if (key === "skills") {
|
|
93
178
|
console.warn(
|
|
94
|
-
`[delegate] ignoring
|
|
179
|
+
`[delegate] ignoring legacy tools override for agent '${agentName}' in ${source}: the temporary compatibility bridge honors only model and thinking.`,
|
|
95
180
|
);
|
|
96
|
-
return null;
|
|
97
181
|
} else {
|
|
98
182
|
console.warn(
|
|
99
183
|
`[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: unknown field '${key}'.`,
|
|
@@ -115,7 +199,7 @@ function normalizeOverrides(
|
|
|
115
199
|
return {};
|
|
116
200
|
}
|
|
117
201
|
|
|
118
|
-
const result
|
|
202
|
+
const result = Object.create(null) as Record<string, AgentOverride>;
|
|
119
203
|
const seenNames = new Map<string, string>();
|
|
120
204
|
for (const [agentName, value] of Object.entries(raw)) {
|
|
121
205
|
const normalizedAgentName = agentName.trim();
|
|
@@ -150,7 +234,10 @@ function normalizeOverridesByParentModel(
|
|
|
150
234
|
return {};
|
|
151
235
|
}
|
|
152
236
|
|
|
153
|
-
const result
|
|
237
|
+
const result = Object.create(null) as Record<
|
|
238
|
+
string,
|
|
239
|
+
Record<string, AgentOverride>
|
|
240
|
+
>;
|
|
154
241
|
const seenModels = new Map<string, string>();
|
|
155
242
|
for (const [parentModel, overrides] of Object.entries(raw)) {
|
|
156
243
|
const normalizedParentModel = parentModel.trim();
|
|
@@ -176,13 +263,9 @@ function normalizeOverridesByParentModel(
|
|
|
176
263
|
return result;
|
|
177
264
|
}
|
|
178
265
|
|
|
179
|
-
function
|
|
266
|
+
function readLegacyDelegateSettings(filePath: string): DelegateSettings | null {
|
|
180
267
|
const settings = readDelegateSettingsFile(filePath);
|
|
181
|
-
if (
|
|
182
|
-
!settings?.delegate ||
|
|
183
|
-
typeof settings.delegate !== "object" ||
|
|
184
|
-
Array.isArray(settings.delegate)
|
|
185
|
-
) {
|
|
268
|
+
if (!isRecord(settings?.delegate)) {
|
|
186
269
|
if (settings?.delegate !== undefined) {
|
|
187
270
|
console.warn(
|
|
188
271
|
`[delegate] ignoring malformed delegate settings in ${filePath}: expected an object.`,
|
|
@@ -190,35 +273,74 @@ function getDelegateSettings(filePath: string): DelegateSettings | null {
|
|
|
190
273
|
}
|
|
191
274
|
return null;
|
|
192
275
|
}
|
|
193
|
-
|
|
276
|
+
|
|
194
277
|
const result: DelegateSettings = {};
|
|
195
|
-
if (
|
|
196
|
-
result.agentOverrides = normalizeOverrides(
|
|
278
|
+
if (settings.delegate.agentOverrides !== undefined) {
|
|
279
|
+
result.agentOverrides = normalizeOverrides(
|
|
280
|
+
settings.delegate.agentOverrides,
|
|
281
|
+
filePath,
|
|
282
|
+
);
|
|
197
283
|
}
|
|
198
|
-
if (
|
|
284
|
+
if (settings.delegate.agentOverridesByParentModel !== undefined) {
|
|
199
285
|
result.agentOverridesByParentModel = normalizeOverridesByParentModel(
|
|
200
|
-
|
|
286
|
+
settings.delegate.agentOverridesByParentModel,
|
|
201
287
|
filePath,
|
|
202
288
|
);
|
|
203
289
|
}
|
|
204
290
|
return result;
|
|
205
291
|
}
|
|
206
292
|
|
|
207
|
-
|
|
293
|
+
function mergeOverride(
|
|
294
|
+
base: AgentOverride | undefined,
|
|
295
|
+
override: AgentOverride | undefined,
|
|
296
|
+
): AgentOverride {
|
|
297
|
+
return { ...(base ?? {}), ...(override ?? {}) };
|
|
298
|
+
}
|
|
208
299
|
|
|
209
|
-
|
|
210
|
-
|
|
300
|
+
function mergeOverrides(
|
|
301
|
+
user: Record<string, AgentOverride> | undefined,
|
|
302
|
+
project: Record<string, AgentOverride> | undefined,
|
|
303
|
+
): Record<string, AgentOverride> {
|
|
304
|
+
const result = Object.create(null) as Record<string, AgentOverride>;
|
|
305
|
+
for (const name of new Set([
|
|
306
|
+
...Object.keys(user ?? {}),
|
|
307
|
+
...Object.keys(project ?? {}),
|
|
308
|
+
])) {
|
|
309
|
+
result[name] = mergeOverride(user?.[name], project?.[name]);
|
|
310
|
+
}
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function mergeParentModelOverrides(
|
|
315
|
+
user: Record<string, Record<string, AgentOverride>> | undefined,
|
|
316
|
+
project: Record<string, Record<string, AgentOverride>> | undefined,
|
|
317
|
+
): Record<string, Record<string, AgentOverride>> {
|
|
318
|
+
const result = Object.create(null) as Record<
|
|
319
|
+
string,
|
|
320
|
+
Record<string, AgentOverride>
|
|
321
|
+
>;
|
|
322
|
+
for (const model of new Set([
|
|
323
|
+
...Object.keys(user ?? {}),
|
|
324
|
+
...Object.keys(project ?? {}),
|
|
325
|
+
])) {
|
|
326
|
+
result[model] = mergeOverrides(user?.[model], project?.[model]);
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Temporary compatibility reader. It accepts a working directory and returns
|
|
333
|
+
* the merged view (project fields override user fields). Deliberately rereads
|
|
334
|
+
* the two small JSON files: this bridge lasts one release, and avoiding a cache
|
|
335
|
+
* makes file create/edit/delete visible on the very next dispatch.
|
|
336
|
+
*/
|
|
211
337
|
export function loadDelegateSettings(cwd: string): DelegateSettings | null {
|
|
212
338
|
const key = path.resolve(cwd);
|
|
213
|
-
const cached = delegateSettingsCache.get(key);
|
|
214
|
-
if (cached !== undefined) return cached;
|
|
215
|
-
|
|
216
339
|
const userPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
217
|
-
|
|
218
|
-
let projectPath: string | null = null;
|
|
340
|
+
let projectPath: string | undefined;
|
|
219
341
|
let dir = key;
|
|
220
342
|
const root = path.resolve("/");
|
|
221
|
-
|
|
343
|
+
for (;;) {
|
|
222
344
|
if (fs.existsSync(path.join(dir, ".pi"))) {
|
|
223
345
|
projectPath = path.join(dir, ".pi", "settings.json");
|
|
224
346
|
break;
|
|
@@ -229,13 +351,12 @@ export function loadDelegateSettings(cwd: string): DelegateSettings | null {
|
|
|
229
351
|
dir = parent;
|
|
230
352
|
}
|
|
231
353
|
|
|
232
|
-
const user =
|
|
233
|
-
const project = projectPath ?
|
|
234
|
-
|
|
354
|
+
const user = readLegacyDelegateSettings(userPath);
|
|
355
|
+
const project = projectPath ? readLegacyDelegateSettings(projectPath) : null;
|
|
235
356
|
if (!user && !project) {
|
|
236
|
-
delegateSettingsCache.set(key, null);
|
|
237
357
|
return null;
|
|
238
358
|
}
|
|
359
|
+
|
|
239
360
|
const result: DelegateSettings = {
|
|
240
361
|
...(user?.agentOverrides || project?.agentOverrides
|
|
241
362
|
? {
|
|
@@ -255,46 +376,43 @@ export function loadDelegateSettings(cwd: string): DelegateSettings | null {
|
|
|
255
376
|
}
|
|
256
377
|
: {}),
|
|
257
378
|
};
|
|
258
|
-
delegateSettingsCache.set(key, result);
|
|
259
379
|
return result;
|
|
260
380
|
}
|
|
261
381
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
...Object.keys(project ?? {}),
|
|
291
|
-
])) {
|
|
292
|
-
result[model] = mergeOverrides(user?.[model], project?.[model]);
|
|
382
|
+
/** Warn (once per cwd) when pi settings files still carry a legacy `delegate`
|
|
383
|
+
* block. The same message goes to stderr and, when available, the TUI. Keys
|
|
384
|
+
* include both cwd and source so a newly-created project setting is not hidden
|
|
385
|
+
* by an earlier user-setting warning. */
|
|
386
|
+
export function warnLegacyDelegateSettingsMoved(
|
|
387
|
+
cwd: string,
|
|
388
|
+
notify?: (message: string) => void,
|
|
389
|
+
): void {
|
|
390
|
+
const cwdKey = path.resolve(cwd);
|
|
391
|
+
const paths = findLegacyDelegateSettings(cwd);
|
|
392
|
+
if (paths.length === 0) return;
|
|
393
|
+
for (const filePath of paths) {
|
|
394
|
+
const warningKey = `${cwdKey}\0${filePath}`;
|
|
395
|
+
if (warnedLegacySources.has(warningKey)) continue;
|
|
396
|
+
warnedLegacySources.add(warningKey);
|
|
397
|
+
const message =
|
|
398
|
+
`[delegate] TEMPORARY legacy compatibility: using model/thinking overrides from ${filePath}. ` +
|
|
399
|
+
"Move user-global overrides to ~/.pi/agent/delegate.json. Project-local overrides have no future config-file equivalent; use .pi/agents/*.md or explicit task model/thinking fields. " +
|
|
400
|
+
"Legacy settings compatibility is available for v0.1.12 only and will be removed in v0.1.13. Legacy tools overrides are not honored.";
|
|
401
|
+
console.warn(message);
|
|
402
|
+
try {
|
|
403
|
+
notify?.(message);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
console.error(
|
|
406
|
+
"[delegate] legacy settings TUI notification failed",
|
|
407
|
+
error,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
293
410
|
}
|
|
294
|
-
return result;
|
|
295
411
|
}
|
|
296
412
|
|
|
297
|
-
/** Clear
|
|
413
|
+
/** @deprecated Clear the set of cwd's already warned about legacy settings. */
|
|
298
414
|
export function clearDelegateSettingsCache(): void {
|
|
299
|
-
|
|
415
|
+
warnedLegacySources.clear();
|
|
416
|
+
warnedLegacyDetectionFailures.clear();
|
|
417
|
+
legacyDetectionCache.clear();
|
|
300
418
|
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { mapConcurrent } from "./concurrency.ts";
|
|
4
|
+
import { isPathWithinDirectoryLexical } from "./trusted-paths.ts";
|
|
5
|
+
import type { ResolvedTask } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
8
|
+
const PREFLIGHT_CONCURRENCY = 4;
|
|
9
|
+
const NON_MUTATING_TOOLS = new Set([
|
|
10
|
+
"read",
|
|
11
|
+
"grep",
|
|
12
|
+
"find",
|
|
13
|
+
"ls",
|
|
14
|
+
"web_search",
|
|
15
|
+
]);
|
|
16
|
+
const GIT_REDIRECTS = ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"] as const;
|
|
17
|
+
|
|
18
|
+
export interface SharedWriteScope {
|
|
19
|
+
kind: "git" | "directory";
|
|
20
|
+
root: string;
|
|
21
|
+
/** Additional physical roots reachable from the task cwd. Present only
|
|
22
|
+
* while resolving admissions; conflict results expose the witness root. */
|
|
23
|
+
roots?: ReachRoot[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ReachRoot {
|
|
27
|
+
kind: "git" | "directory";
|
|
28
|
+
root: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SharedWriteConflict {
|
|
32
|
+
scope: SharedWriteScope;
|
|
33
|
+
taskIndexes: number[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SharedWriteSafetyError extends Error {}
|
|
37
|
+
|
|
38
|
+
class GitScopeError extends Error {
|
|
39
|
+
constructor(
|
|
40
|
+
message: string,
|
|
41
|
+
readonly stderr: string,
|
|
42
|
+
options?: ErrorOptions,
|
|
43
|
+
) {
|
|
44
|
+
super(message, options);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function gitRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
49
|
+
const env = Object.fromEntries(
|
|
50
|
+
Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")),
|
|
51
|
+
);
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
execFile(
|
|
54
|
+
"git",
|
|
55
|
+
["rev-parse", "--show-toplevel"],
|
|
56
|
+
{
|
|
57
|
+
cwd,
|
|
58
|
+
signal,
|
|
59
|
+
timeout: GIT_TIMEOUT_MS,
|
|
60
|
+
maxBuffer: 1024 * 1024,
|
|
61
|
+
env: {
|
|
62
|
+
...env,
|
|
63
|
+
LC_ALL: "C",
|
|
64
|
+
LANG: "C",
|
|
65
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
66
|
+
GIT_DISCOVERY_ACROSS_FILESYSTEM: "1",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
(error, stdout, stderr) => {
|
|
70
|
+
if (error) {
|
|
71
|
+
reject(
|
|
72
|
+
new GitScopeError(error.message, stderr.trim(), { cause: error }),
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
resolve(stdout.trim());
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Resolve a primary write boundary and, for an external core.worktree, the
|
|
83
|
+
* physical cwd that remains reachable. Plain directories use the task cwd.
|
|
84
|
+
* Only Git's explicit "not a repository" permits directory fallback. */
|
|
85
|
+
export async function resolveSharedWriteScope(
|
|
86
|
+
cwd: string,
|
|
87
|
+
signal?: AbortSignal,
|
|
88
|
+
): Promise<SharedWriteScope> {
|
|
89
|
+
let physicalCwd: string;
|
|
90
|
+
try {
|
|
91
|
+
physicalCwd = await fs.promises.realpath(cwd);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new SharedWriteSafetyError(
|
|
94
|
+
`Could not resolve task directory '${cwd}'.`,
|
|
95
|
+
{ cause: error },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const root = await gitRepositoryRoot(physicalCwd, signal);
|
|
101
|
+
if (!root) {
|
|
102
|
+
throw new SharedWriteSafetyError(
|
|
103
|
+
`Git returned an empty repository root for '${physicalCwd}'.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const physicalRoot = await fs.promises.realpath(root);
|
|
107
|
+
const roots: ReachRoot[] = [{ kind: "git", root: physicalRoot }];
|
|
108
|
+
if (!isPathWithinDirectoryLexical(physicalRoot, physicalCwd)) {
|
|
109
|
+
roots.push({ kind: "directory", root: physicalCwd });
|
|
110
|
+
}
|
|
111
|
+
return { kind: "git", root: physicalRoot, roots };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (
|
|
114
|
+
error instanceof GitScopeError &&
|
|
115
|
+
/not a git repository/i.test(error.stderr)
|
|
116
|
+
) {
|
|
117
|
+
return { kind: "directory", root: physicalCwd };
|
|
118
|
+
}
|
|
119
|
+
if (error instanceof SharedWriteSafetyError) throw error;
|
|
120
|
+
const detail =
|
|
121
|
+
error instanceof GitScopeError
|
|
122
|
+
? error.stderr || error.message
|
|
123
|
+
: error instanceof Error
|
|
124
|
+
? error.message
|
|
125
|
+
: String(error);
|
|
126
|
+
throw new SharedWriteSafetyError(
|
|
127
|
+
`Could not safely determine the Git root for '${physicalCwd}': ${detail}`,
|
|
128
|
+
{ cause: error },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function isSharedWriter(task: ResolvedTask): boolean {
|
|
134
|
+
return (
|
|
135
|
+
task.workspace === "shared" &&
|
|
136
|
+
task.tools.some((tool) => !NON_MUTATING_TOOLS.has(tool))
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function reachRoots(scope: SharedWriteScope): ReachRoot[] {
|
|
141
|
+
return scope.roots ?? [{ kind: scope.kind, root: scope.root }];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function rootsOverlap(left: ReachRoot, right: ReachRoot): boolean {
|
|
145
|
+
return (
|
|
146
|
+
isPathWithinDirectoryLexical(left.root, right.root) ||
|
|
147
|
+
isPathWithinDirectoryLexical(right.root, left.root)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function shallower(left: ReachRoot, right: ReachRoot): ReachRoot {
|
|
152
|
+
return isPathWithinDirectoryLexical(left.root, right.root) ? left : right;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function overlapWitness(
|
|
156
|
+
left: SharedWriteScope,
|
|
157
|
+
right: SharedWriteScope,
|
|
158
|
+
): ReachRoot | undefined {
|
|
159
|
+
let witness: ReachRoot | undefined;
|
|
160
|
+
for (const leftRoot of reachRoots(left)) {
|
|
161
|
+
for (const rightRoot of reachRoots(right)) {
|
|
162
|
+
if (!rootsOverlap(leftRoot, rightRoot)) continue;
|
|
163
|
+
const candidate = shallower(leftRoot, rightRoot);
|
|
164
|
+
if (
|
|
165
|
+
witness === undefined ||
|
|
166
|
+
isPathWithinDirectoryLexical(candidate.root, witness.root)
|
|
167
|
+
) {
|
|
168
|
+
witness = candidate;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return witness;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Find connected overlapping writer scopes while preserving task order.
|
|
176
|
+
* Unknown tools fail closed as mutating. This is an in-process admission
|
|
177
|
+
* boundary, not filesystem confinement or a claim about external processes. */
|
|
178
|
+
export async function findSharedWriteConflicts(
|
|
179
|
+
tasks: readonly ResolvedTask[],
|
|
180
|
+
signal?: AbortSignal,
|
|
181
|
+
): Promise<SharedWriteConflict[]> {
|
|
182
|
+
const candidates = tasks
|
|
183
|
+
.map((task, taskIndex) => ({ task, taskIndex }))
|
|
184
|
+
.filter(({ task }) => isSharedWriter(task));
|
|
185
|
+
if (candidates.length < 2) return [];
|
|
186
|
+
|
|
187
|
+
const redirects = GIT_REDIRECTS.filter(
|
|
188
|
+
(name) => process.env[name] !== undefined,
|
|
189
|
+
);
|
|
190
|
+
if (
|
|
191
|
+
redirects.length &&
|
|
192
|
+
candidates.some(({ task }) => task.tools.includes("bash"))
|
|
193
|
+
) {
|
|
194
|
+
throw new SharedWriteSafetyError(
|
|
195
|
+
`Could not safely verify a bash-capable shared-write batch while ${redirects.join(", ")} redirects Git repository context.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const uniqueCwds = [...new Set(candidates.map(({ task }) => task.cwd))];
|
|
200
|
+
const scopes = await mapConcurrent(uniqueCwds, PREFLIGHT_CONCURRENCY, (cwd) =>
|
|
201
|
+
resolveSharedWriteScope(cwd, signal),
|
|
202
|
+
);
|
|
203
|
+
const byCwd = new Map(
|
|
204
|
+
uniqueCwds.map((cwd, index) => [cwd, scopes[index]!] as const),
|
|
205
|
+
);
|
|
206
|
+
const resolved = candidates.map(({ task, taskIndex }) => ({
|
|
207
|
+
taskIndex,
|
|
208
|
+
scope: byCwd.get(task.cwd)!,
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
const parents = resolved.map((_, index) => index);
|
|
212
|
+
const find = (index: number): number => {
|
|
213
|
+
while (parents[index] !== index) {
|
|
214
|
+
parents[index] = parents[parents[index]!]!;
|
|
215
|
+
index = parents[index]!;
|
|
216
|
+
}
|
|
217
|
+
return index;
|
|
218
|
+
};
|
|
219
|
+
const union = (left: number, right: number): void => {
|
|
220
|
+
const a = find(left);
|
|
221
|
+
const b = find(right);
|
|
222
|
+
if (a !== b) parents[b] = a;
|
|
223
|
+
};
|
|
224
|
+
for (let left = 0; left < resolved.length; left++) {
|
|
225
|
+
for (let right = left + 1; right < resolved.length; right++) {
|
|
226
|
+
if (
|
|
227
|
+
overlapWitness(resolved[left]!.scope, resolved[right]!.scope) !==
|
|
228
|
+
undefined
|
|
229
|
+
) {
|
|
230
|
+
union(left, right);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const components = new Map<number, number[]>();
|
|
236
|
+
for (let index = 0; index < resolved.length; index++) {
|
|
237
|
+
const root = find(index);
|
|
238
|
+
const members = components.get(root);
|
|
239
|
+
if (members) members.push(index);
|
|
240
|
+
else components.set(root, [index]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const conflicts: SharedWriteConflict[] = [];
|
|
244
|
+
for (const members of components.values()) {
|
|
245
|
+
if (members.length < 2) continue;
|
|
246
|
+
let witness: ReachRoot | undefined;
|
|
247
|
+
for (let left = 0; left < members.length; left++) {
|
|
248
|
+
for (let right = left + 1; right < members.length; right++) {
|
|
249
|
+
const candidate = overlapWitness(
|
|
250
|
+
resolved[members[left]!]!.scope,
|
|
251
|
+
resolved[members[right]!]!.scope,
|
|
252
|
+
);
|
|
253
|
+
if (
|
|
254
|
+
candidate &&
|
|
255
|
+
(witness === undefined ||
|
|
256
|
+
isPathWithinDirectoryLexical(candidate.root, witness.root))
|
|
257
|
+
) {
|
|
258
|
+
witness = candidate;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!witness) {
|
|
263
|
+
throw new SharedWriteSafetyError(
|
|
264
|
+
"Invariant violation: connected writer scopes have no overlap witness.",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
conflicts.push({
|
|
268
|
+
scope: { kind: witness.kind, root: witness.root },
|
|
269
|
+
taskIndexes: members.map((index) => resolved[index]!.taskIndex),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return conflicts;
|
|
273
|
+
}
|