@bermudi/pi-delegate 0.1.12 → 0.1.14
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 +14 -17
- package/delegate.ts +0 -8
- package/dispatch.ts +24 -2
- package/extension.ts +14 -6
- package/file-tracking.ts +286 -15
- package/format.ts +79 -20
- package/isolated-workspace.ts +550 -62
- package/key-hints.ts +38 -0
- package/lifecycle.ts +555 -80
- package/manual.ts +10 -7
- package/model.ts +6 -1
- package/package.json +13 -9
- package/pool.ts +37 -6
- package/quiescence.ts +19 -6
- package/render-branches.ts +232 -85
- package/render-result.ts +116 -16
- package/runner.ts +361 -82
- package/schema.ts +2 -2
- package/session-quarantine.ts +266 -0
- package/spill.ts +93 -22
- package/task-resolution.ts +47 -48
- package/telemetry.ts +1 -1
- package/ticket-format.ts +8 -3
- package/tickets.ts +33 -13
- package/tools.ts +5 -4
- package/types.ts +77 -8
- package/utils.ts +123 -2
- package/workspace.ts +133 -0
- package/settings.ts +0 -418
package/README.md
CHANGED
|
@@ -67,21 +67,17 @@ across `.pi/agents/`, `~/.pi/agent/agents/`, `~/.agents/`, `.claude/agents/`,
|
|
|
67
67
|
workspace — `scout` stays read-only and `reviewer` stays scratch unless the
|
|
68
68
|
file explicitly sets `tools` or `workspace`. Fresh built-ins inherit the
|
|
69
69
|
parent's exact model object and thinking level; an explicit `model`/`thinking`
|
|
70
|
-
in the Markdown file replaces that inheritance. Task fields always win
|
|
71
|
-
`
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
Legacy `tools` is never honored because a project file must not restore shell
|
|
82
|
-
capability. This bridge is removed in v0.1.13. Project-local replacements are
|
|
83
|
-
`.pi/agents/*.md` profiles or explicit task `model`/`thinking` fields; there
|
|
84
|
-
will be no new project-level delegate config file.
|
|
70
|
+
in the Markdown file replaces that inheritance. Task fields always win. For
|
|
71
|
+
each unset `model`, `thinking`, or `tools` field on
|
|
72
|
+
`scout`/`coder`/`reviewer`, an exact-parent-model override in
|
|
73
|
+
`agentOverridesByParentModel` wins over the unconditional `agentOverrides`,
|
|
74
|
+
which wins over explicit Markdown frontmatter. `default` is the exception: it
|
|
75
|
+
ignores both `delegate.json` override maps, but task fields still win and
|
|
76
|
+
explicit `default.md` fields still override inherited parent values.
|
|
77
|
+
`~/.pi/agent/delegate.json` is the only delegate config file (user scope,
|
|
78
|
+
global), and edits apply from the next delegate call. Project-local
|
|
79
|
+
configuration belongs in `.pi/agents/*.md` profiles or explicit task fields;
|
|
80
|
+
Delegate ignores legacy `delegate` fields in project `.pi/settings.json`.
|
|
85
81
|
|
|
86
82
|
### Disposable scratch workspace
|
|
87
83
|
|
|
@@ -217,8 +213,9 @@ over an installed extension.
|
|
|
217
213
|
model, tools, and thinking level; the Markdown body is its system prompt. A
|
|
218
214
|
same-named file for a built-in (`default`/`scout`/`coder`/`reviewer`)
|
|
219
215
|
overrides that built-in; a prompt-only override keeps the built-in's tools
|
|
220
|
-
and workspace, and an explicit `model`/`thinking` replaces parent inheritance
|
|
221
|
-
|
|
216
|
+
and workspace, and an explicit `model`/`thinking` replaces parent inheritance.
|
|
217
|
+
For `default`, the `delegate.json` override maps are ignored (task fields still
|
|
218
|
+
win); for the other built-ins, those maps win field-by-field over frontmatter.
|
|
222
219
|
- **Ad-hoc subagent** — A subagent created from inline task fields instead of a
|
|
223
220
|
named Markdown agent profile. In current output this is labeled `ad-hoc`.
|
|
224
221
|
- **Inline task** — The task object itself when its configuration is supplied
|
package/delegate.ts
CHANGED
|
@@ -145,14 +145,6 @@ export {
|
|
|
145
145
|
resolveModelRequest,
|
|
146
146
|
findAvailableAlternative,
|
|
147
147
|
} from "./model.ts";
|
|
148
|
-
export {
|
|
149
|
-
readDelegateSettingsFile,
|
|
150
|
-
findLegacyDelegateSettings,
|
|
151
|
-
warnLegacyDelegateSettingsMoved,
|
|
152
|
-
loadDelegateSettings,
|
|
153
|
-
clearDelegateSettingsCache,
|
|
154
|
-
} from "./settings.ts";
|
|
155
|
-
export type { DelegateSettings } from "./settings.ts";
|
|
156
148
|
export { resolveCwd, extractOutput, extractUsage } from "./utils.ts";
|
|
157
149
|
export {
|
|
158
150
|
decideSpill,
|
package/dispatch.ts
CHANGED
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
} from "./shared-write-safety.ts";
|
|
38
38
|
import type { CallSpan } from "./telemetry.ts";
|
|
39
39
|
import { prepareIsolatedBatch } from "./isolated-workspace.ts";
|
|
40
|
+
import { sanitizeTerminalLine } from "./utils.ts";
|
|
41
|
+
import { quarantinedTasks } from "./session-quarantine.ts";
|
|
40
42
|
import type {
|
|
41
43
|
AgentConfig,
|
|
42
44
|
AsyncTicket,
|
|
@@ -110,7 +112,7 @@ export function initProgress(resolved: ResolvedTask[]): TaskProgress[] {
|
|
|
110
112
|
id: t.id,
|
|
111
113
|
index: i,
|
|
112
114
|
agent: t.agentName,
|
|
113
|
-
task: trunc(t.prompt || t.sessionAction || "", 50),
|
|
115
|
+
task: trunc(sanitizeTerminalLine(t.prompt || t.sessionAction || ""), 50),
|
|
114
116
|
status: "pending" as const,
|
|
115
117
|
durationMs: 0,
|
|
116
118
|
tokens: 0,
|
|
@@ -366,6 +368,12 @@ export async function dispatchDelegate(
|
|
|
366
368
|
);
|
|
367
369
|
}
|
|
368
370
|
}
|
|
371
|
+
for (const quarantined of quarantinedTasks()) {
|
|
372
|
+
activeResolved.push(asAdmissionWriter(quarantined));
|
|
373
|
+
references.push(
|
|
374
|
+
`quarantined ${quarantined.agentName}${quarantined.id ? ` task #${quarantined.id}` : " task"}`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
369
377
|
|
|
370
378
|
const incomingCount = resolved.length;
|
|
371
379
|
const conflicts = (
|
|
@@ -646,6 +654,11 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
646
654
|
ticketSignal,
|
|
647
655
|
)
|
|
648
656
|
.then(() => {
|
|
657
|
+
// mapConcurrentByModel is the worker-settled barrier. Publish that before
|
|
658
|
+
// terminal formatting/delivery so the final spill projection is safely
|
|
659
|
+
// memoized; shutdown may already have formatted an intentionally uncached
|
|
660
|
+
// partial snapshot while this flag was false.
|
|
661
|
+
ticket.workersSettled = true;
|
|
649
662
|
// Shutdown marks the ticket terminal before cooperative worker aborts
|
|
650
663
|
// have finished. Still write one final aggregate after every result has
|
|
651
664
|
// landed; the immediate shutdown snapshot may have missed late usage.
|
|
@@ -673,6 +686,8 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
673
686
|
finishLiveSettlement(ticket);
|
|
674
687
|
})
|
|
675
688
|
.catch((err) => {
|
|
689
|
+
// mapConcurrentByModel waits for all sibling workers before rejecting.
|
|
690
|
+
ticket.workersSettled = true;
|
|
676
691
|
// Defense-in-depth — should not happen if individual tasks catch properly.
|
|
677
692
|
// Even an unexpected worker rejection must leave the shutdown aggregate
|
|
678
693
|
// with every result that did settle, without touching the stale UI.
|
|
@@ -713,6 +728,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
713
728
|
parentModel: parentModelId,
|
|
714
729
|
ticketId,
|
|
715
730
|
status: ticket.status,
|
|
731
|
+
elapsedMs: Date.now() - ticket.created,
|
|
716
732
|
dispatchWarning,
|
|
717
733
|
},
|
|
718
734
|
};
|
|
@@ -859,12 +875,18 @@ export async function dispatchSync(
|
|
|
859
875
|
results: finalResults,
|
|
860
876
|
progress,
|
|
861
877
|
parentModel: parentModelId,
|
|
878
|
+
elapsedMs: elapsedTotal,
|
|
862
879
|
overlapWarning: overlapWarning || undefined,
|
|
863
880
|
dispatchWarning,
|
|
864
881
|
},
|
|
865
882
|
// Aggregate subagent spend so Pi folds it into the parent's
|
|
866
883
|
// session/footer totals. Sync dispatch only — async results arrive via a
|
|
867
884
|
// follow-up message that has no usage slot (see DelegateToolResult).
|
|
868
|
-
|
|
885
|
+
// A quarantined task can continue spending after this terminal snapshot.
|
|
886
|
+
// Omitting top-level usage avoids presenting a lower bound as final nested
|
|
887
|
+
// accounting to Pi; per-task results retain the observed lower bound.
|
|
888
|
+
...(finalResults.some((r) => r.incomplete)
|
|
889
|
+
? {}
|
|
890
|
+
: { usage: sumUsage(finalResults.map((r) => r.usage)) }),
|
|
869
891
|
};
|
|
870
892
|
}
|
package/extension.ts
CHANGED
|
@@ -16,7 +16,11 @@ import {
|
|
|
16
16
|
dispatchDelegate,
|
|
17
17
|
validateDelegateOperationResult,
|
|
18
18
|
} from "./dispatch.ts";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
renderAsyncDelegateMessage,
|
|
21
|
+
renderDelegateCall,
|
|
22
|
+
renderDelegateResult,
|
|
23
|
+
} from "./render-result.ts";
|
|
20
24
|
import { hostCompatError } from "./host-compat.ts";
|
|
21
25
|
import { invalidateHostDepsCache } from "./host.ts";
|
|
22
26
|
import { registerProviderExtensionNotifier } from "./provider-extensions.ts";
|
|
@@ -24,7 +28,6 @@ import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
|
|
|
24
28
|
import { closeAllPooledAgents } from "./pool.ts";
|
|
25
29
|
import { reconfigureGlobalConcurrency } from "./concurrency.ts";
|
|
26
30
|
import { reloadDelegateConfig, getMaxConcurrent } from "./config.ts";
|
|
27
|
-
import { warnLegacyDelegateSettingsMoved } from "./settings.ts";
|
|
28
31
|
import {
|
|
29
32
|
activeTicketSummary,
|
|
30
33
|
clearDelegateStatusContext,
|
|
@@ -41,7 +44,7 @@ import {
|
|
|
41
44
|
prepareTelemetryForSession,
|
|
42
45
|
sealTelemetryWrites,
|
|
43
46
|
} from "./telemetry.ts";
|
|
44
|
-
import type { DelegateArguments } from "./types.ts";
|
|
47
|
+
import type { DelegateArguments, DelegateDetails } from "./types.ts";
|
|
45
48
|
|
|
46
49
|
const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000;
|
|
47
50
|
let shutdownDrainTimeoutMs = DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
|
|
@@ -115,6 +118,14 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
115
118
|
// workers from the old runtime remain blocked from reopening it.
|
|
116
119
|
prepareTelemetryForSession();
|
|
117
120
|
|
|
121
|
+
// Async completion arrives as a custom message after the original tool call
|
|
122
|
+
// has returned. Give it the same compact/expanded UI as sync results while
|
|
123
|
+
// leaving the full message intact for model context.
|
|
124
|
+
pi.registerMessageRenderer<DelegateDetails>(
|
|
125
|
+
"async_delegate_result",
|
|
126
|
+
renderAsyncDelegateMessage,
|
|
127
|
+
);
|
|
128
|
+
|
|
118
129
|
pi.registerTool({
|
|
119
130
|
name: "delegate",
|
|
120
131
|
label: "Delegate to Subagents",
|
|
@@ -131,9 +142,6 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
131
142
|
// the global concurrency cap is reconfigured so hot-reloaded maxConcurrent
|
|
132
143
|
// takes effect for subsequent acquisitions. A parse/read error keeps the
|
|
133
144
|
// previous snapshot and warns instead of falling back to defaults.
|
|
134
|
-
warnLegacyDelegateSettingsMoved(ctx.cwd, (message) =>
|
|
135
|
-
ctx.ui.notify(message, "warning"),
|
|
136
|
-
);
|
|
137
145
|
reloadDelegateConfig();
|
|
138
146
|
reconfigureGlobalConcurrency(getMaxConcurrent());
|
|
139
147
|
|
package/file-tracking.ts
CHANGED
|
@@ -1,6 +1,253 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
FileAttribution,
|
|
6
|
+
FileAttributionPathSignature,
|
|
7
|
+
ToolActivity,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
|
|
10
|
+
function activityPath(
|
|
11
|
+
activity: Pick<ToolActivity, "args">,
|
|
12
|
+
cwd: string,
|
|
13
|
+
): string | undefined {
|
|
14
|
+
const raw =
|
|
15
|
+
activity.args?.path ?? activity.args?.file_path ?? activity.args?.filePath;
|
|
16
|
+
return typeof raw === "string" && raw ? path.resolve(cwd, raw) : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function errnoOf(error: unknown): string {
|
|
20
|
+
return error instanceof Error && "code" in error
|
|
21
|
+
? String((error as NodeJS.ErrnoException).code ?? "UNKNOWN")
|
|
22
|
+
: "UNKNOWN";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safePathForLog(candidate: string): string {
|
|
26
|
+
const limit = 1_024;
|
|
27
|
+
const sanitized =
|
|
28
|
+
candidate.length > limit
|
|
29
|
+
? `${candidate.slice(0, limit)}…[truncated ${candidate.length - limit} chars]`
|
|
30
|
+
: candidate;
|
|
31
|
+
// JSON escaping keeps model-supplied control characters out of the terminal.
|
|
32
|
+
return JSON.stringify(sanitized)
|
|
33
|
+
.replace(/\u2028/g, "\\u2028")
|
|
34
|
+
.replace(/\u2029/g, "\\u2029");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function logCanonicalizationFailure(candidate: string, error: unknown): void {
|
|
38
|
+
console.error(
|
|
39
|
+
`[delegate] could not canonicalize edit/write target ${safePathForLog(candidate)} (errno=${errnoOf(error)}); retaining uncertain lexical attribution`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function statKind(stat: fs.Stats): FileAttributionPathSignature["kind"] {
|
|
44
|
+
if (stat.isDirectory()) return "directory";
|
|
45
|
+
if (stat.isSymbolicLink()) return "symlink";
|
|
46
|
+
return "other";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function signatureOf(
|
|
50
|
+
candidate: string,
|
|
51
|
+
stat: fs.Stats,
|
|
52
|
+
symlinkTarget?: string,
|
|
53
|
+
): FileAttributionPathSignature {
|
|
54
|
+
return {
|
|
55
|
+
path: candidate,
|
|
56
|
+
dev: String(stat.dev),
|
|
57
|
+
ino: String(stat.ino),
|
|
58
|
+
birthtimeMs: stat.birthtimeMs,
|
|
59
|
+
kind: statKind(stat),
|
|
60
|
+
...(symlinkTarget === undefined ? {} : { symlinkTarget }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sameSignature(
|
|
65
|
+
left: FileAttributionPathSignature,
|
|
66
|
+
right: FileAttributionPathSignature,
|
|
67
|
+
): boolean {
|
|
68
|
+
return (
|
|
69
|
+
left.path === right.path &&
|
|
70
|
+
left.dev === right.dev &&
|
|
71
|
+
left.ino === right.ino &&
|
|
72
|
+
left.birthtimeMs === right.birthtimeMs &&
|
|
73
|
+
left.kind === right.kind &&
|
|
74
|
+
left.symlinkTarget === right.symlinkTarget
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PhysicalSnapshot {
|
|
79
|
+
path?: string;
|
|
80
|
+
uncertain: boolean;
|
|
81
|
+
signatures: FileAttributionPathSignature[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pathParts(value: string): { root: string; parts: string[] } {
|
|
85
|
+
const absolute = path.resolve(value);
|
|
86
|
+
const root = path.parse(absolute).root;
|
|
87
|
+
return {
|
|
88
|
+
root,
|
|
89
|
+
parts: absolute.slice(root.length).split(path.sep).filter(Boolean),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Resolve one component at a time so the physical snapshot carries the exact
|
|
94
|
+
* ancestor/symlink identities that justified it. */
|
|
95
|
+
function resolvePhysicalSnapshot(
|
|
96
|
+
value: string,
|
|
97
|
+
logCandidate: string,
|
|
98
|
+
): PhysicalSnapshot {
|
|
99
|
+
let { root, parts } = pathParts(value);
|
|
100
|
+
let cursor = root;
|
|
101
|
+
let uncertain = false;
|
|
102
|
+
const signatures: FileAttributionPathSignature[] = [];
|
|
103
|
+
const followed = new Set<string>();
|
|
104
|
+
let symlinkExpansions = 0;
|
|
105
|
+
|
|
106
|
+
while (parts.length) {
|
|
107
|
+
const component = parts.shift()!;
|
|
108
|
+
const candidate = path.join(cursor, component);
|
|
109
|
+
let stat: fs.Stats;
|
|
110
|
+
try {
|
|
111
|
+
stat = fs.lstatSync(candidate);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const code = errnoOf(error);
|
|
114
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
115
|
+
// A missing or unresolved component can be created or replaced before
|
|
116
|
+
// the tool opens it. Without an identity to guard that transition, the
|
|
117
|
+
// projected target must remain conservative even on a purely lexical
|
|
118
|
+
// path with no symlink seen yet.
|
|
119
|
+
return {
|
|
120
|
+
path: path.resolve(candidate, ...parts),
|
|
121
|
+
uncertain: true,
|
|
122
|
+
signatures,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
logCanonicalizationFailure(logCandidate, error);
|
|
126
|
+
return { uncertain: true, signatures };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (stat.isSymbolicLink()) {
|
|
130
|
+
symlinkExpansions++;
|
|
131
|
+
if (symlinkExpansions > 256) {
|
|
132
|
+
logCanonicalizationFailure(
|
|
133
|
+
logCandidate,
|
|
134
|
+
Object.assign(new Error("too many symbolic links"), {
|
|
135
|
+
code: "ELOOP",
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
return { uncertain: true, signatures };
|
|
139
|
+
}
|
|
140
|
+
let target: string;
|
|
141
|
+
try {
|
|
142
|
+
target = fs.readlinkSync(candidate);
|
|
143
|
+
const afterRead = fs.lstatSync(candidate);
|
|
144
|
+
const before = signatureOf(candidate, stat, target);
|
|
145
|
+
const after = signatureOf(candidate, afterRead, target);
|
|
146
|
+
signatures.push(before);
|
|
147
|
+
if (!sameSignature(before, after) || !afterRead.isSymbolicLink()) {
|
|
148
|
+
uncertain = true;
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const code = errnoOf(error);
|
|
152
|
+
if (code !== "ENOENT" && code !== "ENOTDIR" && code !== "EINVAL") {
|
|
153
|
+
logCanonicalizationFailure(logCandidate, error);
|
|
154
|
+
}
|
|
155
|
+
return { path: candidate, uncertain: true, signatures };
|
|
156
|
+
}
|
|
157
|
+
const cycleKey = `${candidate}\0${target}`;
|
|
158
|
+
if (followed.has(cycleKey)) {
|
|
159
|
+
logCanonicalizationFailure(
|
|
160
|
+
logCandidate,
|
|
161
|
+
Object.assign(new Error("symbolic link cycle"), { code: "ELOOP" }),
|
|
162
|
+
);
|
|
163
|
+
return { uncertain: true, signatures };
|
|
164
|
+
}
|
|
165
|
+
followed.add(cycleKey);
|
|
166
|
+
const targetPath = path.isAbsolute(target)
|
|
167
|
+
? target
|
|
168
|
+
: path.resolve(path.dirname(candidate), target);
|
|
169
|
+
const targetParts = pathParts(targetPath);
|
|
170
|
+
root = targetParts.root;
|
|
171
|
+
cursor = root;
|
|
172
|
+
parts = [...targetParts.parts, ...parts];
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Every existing component is an identity guard, including the leaf. An
|
|
177
|
+
// in-place edit preserves the leaf identity; replacement or creation does
|
|
178
|
+
// not, and may redirect the write through a newly installed symlink.
|
|
179
|
+
signatures.push(signatureOf(candidate, stat));
|
|
180
|
+
cursor = candidate;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { path: cursor, uncertain, signatures };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Capture edit/write attribution before tool work begins.
|
|
188
|
+
*
|
|
189
|
+
* The target is resolved component by component while recording the identity
|
|
190
|
+
* of every existing component, including the leaf, and the text of every
|
|
191
|
+
* followed symlink. Missing or dangling components retain their projected path
|
|
192
|
+
* but are uncertain because creation can redirect the eventual tool open.
|
|
193
|
+
* Non-ENOENT/ENOTDIR failures are actionable and logged with the lexical
|
|
194
|
+
* candidate and errno; callers retain that uncertain lexical evidence.
|
|
195
|
+
*/
|
|
196
|
+
export function snapshotPhysicalToolTarget(
|
|
197
|
+
activity: Pick<ToolActivity, "args" | "name">,
|
|
198
|
+
cwd: string,
|
|
199
|
+
): FileAttribution | undefined {
|
|
200
|
+
if (activity.name !== "edit" && activity.name !== "write") return undefined;
|
|
201
|
+
const candidate = activityPath(activity, cwd);
|
|
202
|
+
if (!candidate) return undefined;
|
|
203
|
+
|
|
204
|
+
const snapshot = resolvePhysicalSnapshot(candidate, candidate);
|
|
205
|
+
const attribution: FileAttribution = {
|
|
206
|
+
lexicalPath: candidate,
|
|
207
|
+
preExecutionPhysicalPath: snapshot.path,
|
|
208
|
+
preExecutionPathSignatures: snapshot.signatures,
|
|
209
|
+
provenance: activity.name,
|
|
210
|
+
uncertain: snapshot.uncertain,
|
|
211
|
+
};
|
|
212
|
+
// Close lstat/readlink and component-walk races before execution starts. A
|
|
213
|
+
// later terminal revalidation performs the same check after execution.
|
|
214
|
+
return revalidateFileAttribution(attribution);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Revalidate the identities used by a pre-execution physical snapshot without
|
|
218
|
+
* re-resolving that snapshot through the mutable final tree. */
|
|
219
|
+
export function revalidateFileAttribution(
|
|
220
|
+
attribution: FileAttribution,
|
|
221
|
+
): FileAttribution {
|
|
222
|
+
if (
|
|
223
|
+
attribution.uncertain ||
|
|
224
|
+
!attribution.preExecutionPathSignatures?.length
|
|
225
|
+
) {
|
|
226
|
+
return attribution;
|
|
227
|
+
}
|
|
228
|
+
for (const expected of attribution.preExecutionPathSignatures) {
|
|
229
|
+
try {
|
|
230
|
+
const stat = fs.lstatSync(expected.path);
|
|
231
|
+
let target: string | undefined;
|
|
232
|
+
if (stat.isSymbolicLink()) target = fs.readlinkSync(expected.path);
|
|
233
|
+
if (!sameSignature(expected, signatureOf(expected.path, stat, target))) {
|
|
234
|
+
return { ...attribution, uncertain: true };
|
|
235
|
+
}
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const code = errnoOf(error);
|
|
238
|
+
if (code !== "ENOENT" && code !== "ENOTDIR" && code !== "EINVAL") {
|
|
239
|
+
logCanonicalizationFailure(attribution.lexicalPath, error);
|
|
240
|
+
}
|
|
241
|
+
return { ...attribution, uncertain: true };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return attribution;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Stable string projection used for overlap reporting. */
|
|
248
|
+
export function projectedAttributionPath(attribution: FileAttribution): string {
|
|
249
|
+
return attribution.preExecutionPhysicalPath ?? attribution.lexicalPath;
|
|
250
|
+
}
|
|
4
251
|
|
|
5
252
|
/**
|
|
6
253
|
* Return absolute paths reported as changed by Git in the task cwd.
|
|
@@ -54,26 +301,50 @@ export async function getGitChangedFiles(
|
|
|
54
301
|
/**
|
|
55
302
|
* Extract file paths from explicit edit/write tool calls in the activity log.
|
|
56
303
|
*
|
|
57
|
-
* This is the
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
304
|
+
* This is the conservative, activity-based contribution to touched-file
|
|
305
|
+
* tracking. Every started edit/write call with a usable path is counted:
|
|
306
|
+
* write/edit tools can mutate before returning an error, and cancellation can
|
|
307
|
+
* interrupt them after mutation but before a terminal result is emitted.
|
|
308
|
+
* bash mutations are NOT captured here; they are only captured by git diff when
|
|
309
|
+
* the task cwd is inside a git repo with git available. The combined
|
|
310
|
+
* touchedFiles list is therefore still a lower bound: absence does not mean a
|
|
311
|
+
* file was unchanged.
|
|
65
312
|
*/
|
|
66
313
|
export function extractTouchedFromActivities(
|
|
67
314
|
activities: ToolActivity[],
|
|
68
315
|
cwd: string,
|
|
69
316
|
): string[] {
|
|
70
317
|
const files = new Set<string>();
|
|
71
|
-
for (const
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (typeof raw !== "string" || !raw) continue;
|
|
76
|
-
files.add(path.resolve(cwd, raw));
|
|
318
|
+
for (const activity of activities) {
|
|
319
|
+
if (activity.name !== "edit" && activity.name !== "write") continue;
|
|
320
|
+
const lexicalPath = activityPath(activity, cwd);
|
|
321
|
+
if (lexicalPath) files.add(lexicalPath);
|
|
77
322
|
}
|
|
78
323
|
return [...files];
|
|
79
324
|
}
|
|
325
|
+
|
|
326
|
+
/** Provenance-bearing edit/write evidence attributable to this run. */
|
|
327
|
+
export function extractAttributedFromActivities(
|
|
328
|
+
activities: ToolActivity[],
|
|
329
|
+
cwd: string,
|
|
330
|
+
): FileAttribution[] {
|
|
331
|
+
const files = new Map<string, FileAttribution>();
|
|
332
|
+
for (const activity of activities) {
|
|
333
|
+
if (activity.name !== "edit" && activity.name !== "write") continue;
|
|
334
|
+
const lexicalPath = activityPath(activity, cwd);
|
|
335
|
+
const attribution =
|
|
336
|
+
activity.fileAttribution ??
|
|
337
|
+
(lexicalPath
|
|
338
|
+
? {
|
|
339
|
+
lexicalPath,
|
|
340
|
+
provenance: activity.name,
|
|
341
|
+
uncertain: true,
|
|
342
|
+
}
|
|
343
|
+
: undefined);
|
|
344
|
+
if (!attribution) continue;
|
|
345
|
+
const revalidated = revalidateFileAttribution(attribution);
|
|
346
|
+
const key = `${revalidated.provenance}\0${revalidated.lexicalPath}\0${revalidated.preExecutionPhysicalPath ?? ""}\0${revalidated.uncertain}`;
|
|
347
|
+
files.set(key, revalidated);
|
|
348
|
+
}
|
|
349
|
+
return [...files.values()];
|
|
350
|
+
}
|