@ferris1225/pi-subagents 4.1.13 → 4.1.16
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 +333 -291
- package/agents/cleaner.md +24 -30
- package/agents/documenter.md +23 -20
- package/agents/explorer.md +7 -2
- package/agents/reviewer.md +82 -77
- package/agents/worker.md +45 -37
- package/package.json +1 -1
- package/src/announcements.ts +59 -54
- package/src/background.ts +26 -10
- package/src/completion.ts +7 -1
- package/src/config.ts +1 -1
- package/src/dispatch.ts +133 -133
- package/src/durable.ts +85 -19
- package/src/format.ts +179 -167
- package/src/monitor.ts +4 -2
- package/src/prompt.ts +14 -27
- package/src/runtime.ts +18 -14
- package/src/setup.ts +3 -3
- package/src/spawn.ts +650 -642
- package/src/temp-hygiene.ts +0 -28
- package/src/thread-lifecycle.ts +85 -99
- package/src/tools.ts +712 -708
- package/src/widget.ts +9 -0
- package/src/workflow.ts +199 -248
- package/src/worktree.ts +64 -37
package/src/widget.ts
CHANGED
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
|
|
16
16
|
export const SUBAGENTS_WIDGET_ID = "pi-subagents";
|
|
17
17
|
|
|
18
|
+
/** The TUI caps string-array widgets at this many lines; a factory widget owns
|
|
19
|
+
* the same bound itself, or a wide parallel dispatch floods the editor area. */
|
|
20
|
+
const MAX_WIDGET_LINES = 10;
|
|
21
|
+
|
|
18
22
|
/** Keep the elapsed tail visible while clipping the descriptive left side. */
|
|
19
23
|
function compactLine(left: string, right: string, width: number): string {
|
|
20
24
|
if (width <= 0) return "";
|
|
@@ -256,6 +260,11 @@ export function formatActiveRunLines(
|
|
|
256
260
|
lines.push(...runActivityLine(child, theme, width, " "));
|
|
257
261
|
});
|
|
258
262
|
}
|
|
263
|
+
const hidden = lines.length - (MAX_WIDGET_LINES - 1);
|
|
264
|
+
if (hidden > 0) {
|
|
265
|
+
lines.length = MAX_WIDGET_LINES - 1;
|
|
266
|
+
lines.push(theme.fg("dim", `… +${hidden} more (subagent_status)`));
|
|
267
|
+
}
|
|
259
268
|
return lines;
|
|
260
269
|
}
|
|
261
270
|
|
package/src/workflow.ts
CHANGED
|
@@ -1,248 +1,199 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Managed workflow policy and handoff formatting.
|
|
3
|
-
*
|
|
4
|
-
* Successful top-level worker/cleaner runs continue through
|
|
5
|
-
* code review gate. A
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
result
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
lines
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
/** One clear final delivery for post-writer and direct reviewer → documenter workflows. */
|
|
201
|
-
export function formatManagedWorkflowSummary(
|
|
202
|
-
steps: readonly ChainStep[],
|
|
203
|
-
terminalResult: SingleResult = steps[steps.length - 1]!.result,
|
|
204
|
-
): string {
|
|
205
|
-
const route = steps.map((step) => step.result.agent).join(" → ");
|
|
206
|
-
const lines = [`## Managed workflow: ${route} — final ${workflowResultStatus(terminalResult)}`, "", ...steps.map(workflowStepLine)];
|
|
207
|
-
appendWorkflowFooter(lines, steps);
|
|
208
|
-
return lines.join("\n");
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export interface GateBriefOptions {
|
|
212
|
-
/** A conditional final documenter is available after the gate settles.
|
|
213
|
-
* Documentation drift is then routed to it as non-gating notes instead of
|
|
214
|
-
* failing the code gate. */
|
|
215
|
-
documenterPending: boolean;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Build the code gate that runs directly after a top-level writer, before any
|
|
219
|
-
* documentation. Reports carry intent; the actual pending diff remains
|
|
220
|
-
* authoritative. */
|
|
221
|
-
export function buildFinalReviewBrief(
|
|
222
|
-
initialResult: SingleResult,
|
|
223
|
-
options: GateBriefOptions,
|
|
224
|
-
): string {
|
|
225
|
-
return [
|
|
226
|
-
`Fresh code gate for a managed ${initialResult.agent} workflow.`,
|
|
227
|
-
``,
|
|
228
|
-
`The top-level ${initialResult.agent}'s full report:`,
|
|
229
|
-
`---`,
|
|
230
|
-
getResultOutput(initialResult),
|
|
231
|
-
`---`,
|
|
232
|
-
``,
|
|
233
|
-
`Run \`git status\` and \`git diff\` and inspect the actual pending code; the report is context, not proof.`,
|
|
234
|
-
`Remain read-only. Attach a concrete fix instruction to EVERY gate finding: what to change, where, and how to verify the fix`,
|
|
235
|
-
`— the report returns to the main agent, which uses your instructions to drive the fix.`,
|
|
236
|
-
...(options.documenterPending
|
|
237
|
-
? [
|
|
238
|
-
`A conditional documentation sync is available AFTER this gate, so documentation drift is not a code-gate finding:`,
|
|
239
|
-
`record it under "## Documentation notes" and emit the standalone line DOCUMENTATION: NEEDED,`,
|
|
240
|
-
`or DOCUMENTATION: CLEAN when no documentation update is needed.`,
|
|
241
|
-
]
|
|
242
|
-
: [
|
|
243
|
-
`No documenter is pending, so documentation drift is an ordinary gate finding.`,
|
|
244
|
-
]),
|
|
245
|
-
`This is an acceptance gate, not an advisory audit. End with exactly one standalone machine verdict line:`,
|
|
246
|
-
`VERDICT: REVIEW_PASS when no finding remains, otherwise VERDICT: REVIEW_FAIL.`,
|
|
247
|
-
].join("\n");
|
|
248
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Managed workflow policy and handoff formatting.
|
|
3
|
+
*
|
|
4
|
+
* Successful top-level worker/cleaner runs continue through one independent
|
|
5
|
+
* code review gate. A failing managed gate continues into the reviewer fix
|
|
6
|
+
* stage: the same retained reviewer session gets write access and applies its
|
|
7
|
+
* own fix instructions, so nobody outside the gate has to guess what satisfies
|
|
8
|
+
* it. Direct reviewer results never chain — a failing direct gate returns its
|
|
9
|
+
* findings to the main agent, which owns that fix decision. Documentation
|
|
10
|
+
* drift is an ordinary gate finding; dispatching a documenter stays the main
|
|
11
|
+
* agent's call. Internal steps are launched by dispatch directly, so they
|
|
12
|
+
* never re-enter this policy or wake the main agent mid-chain.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isWriteCapableAgent, type AgentConfig } from "./agents.ts";
|
|
16
|
+
import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
|
|
17
|
+
import { formatUsageCompact, sumUsage } from "./monitor.ts";
|
|
18
|
+
|
|
19
|
+
export interface WorkflowAgentAvailability {
|
|
20
|
+
reviewer: boolean;
|
|
21
|
+
writer: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function workflowAgentAvailability(
|
|
25
|
+
agents: readonly Pick<AgentConfig, "name" | "tools">[],
|
|
26
|
+
): WorkflowAgentAvailability {
|
|
27
|
+
const names = new Set(agents.map((agent) => agent.name));
|
|
28
|
+
return {
|
|
29
|
+
reviewer: names.has("reviewer"),
|
|
30
|
+
writer: agents.some(isWriteCapableAgent),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ManagedWorkflowPlan {
|
|
35
|
+
initialRelation: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Fixed cap on reviewer fix → re-review rounds inside one managed workflow.
|
|
39
|
+
* Re-reviews converge by construction (they verify fixes and fix regressions
|
|
40
|
+
* instead of re-scanning the whole surface); the cap only stops pathological
|
|
41
|
+
* burn and hands the still-failing gate back to the main agent. */
|
|
42
|
+
export const MAX_REVIEW_FIX_ROUNDS = 2;
|
|
43
|
+
|
|
44
|
+
/** Conservative pre-run check used to reserve one shared-repository lane
|
|
45
|
+
* around a complete writer workflow or a reviewer that needs a stable diff. */
|
|
46
|
+
export function canStartManagedWorkflow(
|
|
47
|
+
agent: Pick<AgentConfig, "name" | "tools">,
|
|
48
|
+
availability: WorkflowAgentAvailability,
|
|
49
|
+
): boolean {
|
|
50
|
+
// Every shared write-capable role—including custom agents—owns the repository
|
|
51
|
+
// lane even when no downstream role is enabled. Otherwise its edits can race
|
|
52
|
+
// a managed writer's pending diff.
|
|
53
|
+
if (isWriteCapableAgent(agent)) return true;
|
|
54
|
+
if (agent.name === "reviewer") {
|
|
55
|
+
// Hold a stable diff snapshot against every discoverable writer even when
|
|
56
|
+
// this review is advisory: a gate over a moving diff is unsound.
|
|
57
|
+
return availability.writer;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Classify only healthy top-level writer results; everything else delivers
|
|
63
|
+
* directly, including every reviewer result — a direct reviewer dispatch never
|
|
64
|
+
* starts another child. A failing managed gate is expanded by the workflow
|
|
65
|
+
* itself into the reviewer fix stage. */
|
|
66
|
+
export function getManagedWorkflowPlan(
|
|
67
|
+
result: SingleResult,
|
|
68
|
+
availability: WorkflowAgentAvailability,
|
|
69
|
+
): ManagedWorkflowPlan | undefined {
|
|
70
|
+
if (result.dispatchFailed || isFailedResult(result)) return undefined;
|
|
71
|
+
if (result.agent === "worker" || result.agent === "cleaner") {
|
|
72
|
+
if (!availability.reviewer) return undefined;
|
|
73
|
+
return {
|
|
74
|
+
initialRelation: result.agent === "cleaner" ? "initial cleanup" : "initial implementation",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build the code gate that runs directly after a top-level writer. Reports
|
|
81
|
+
* carry intent; the actual pending diff remains authoritative. */
|
|
82
|
+
export function buildFinalReviewBrief(initialResult: SingleResult): string {
|
|
83
|
+
return [
|
|
84
|
+
`Fresh code gate for a managed ${initialResult.agent} workflow.`,
|
|
85
|
+
``,
|
|
86
|
+
`The top-level ${initialResult.agent}'s full report:`,
|
|
87
|
+
`---`,
|
|
88
|
+
getResultOutput(initialResult),
|
|
89
|
+
`---`,
|
|
90
|
+
``,
|
|
91
|
+
`Run \`git status\` and \`git diff\` and judge the actual pending code; the report is context, not proof.`,
|
|
92
|
+
`Remain read-only. Attach a concrete fix instruction to EVERY gate finding — including documentation drift —:`,
|
|
93
|
+
`what to change, where, and how to verify it. A failing gate continues into your own write-enabled fix stage,`,
|
|
94
|
+
`so make every instruction executable exactly as written.`,
|
|
95
|
+
`End with exactly one standalone machine verdict line:`,
|
|
96
|
+
`VERDICT: REVIEW_PASS when no finding remains, otherwise VERDICT: REVIEW_FAIL.`,
|
|
97
|
+
].join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Build the follow-up brief for the reviewer fix stage: the same retained
|
|
101
|
+
* reviewer session continues with its read-only boundary lifted and applies
|
|
102
|
+
* its own fix instructions. The workflow continues with a converging re-review. */
|
|
103
|
+
export function buildReviewerFixBrief(gateOutput: string): string {
|
|
104
|
+
return [
|
|
105
|
+
`Fix stage: your gate review returned REVIEW_FAIL. You now have full write access in this same session.`,
|
|
106
|
+
`Apply every one of your own fix instructions now — exactly the changes you specified, nothing broader.`,
|
|
107
|
+
`Then re-check the code your fixes touch, so the next scan does not open with your own regression, and run`,
|
|
108
|
+
`the narrowest decisive checks (type check, focused tests) to verify.`,
|
|
109
|
+
``,
|
|
110
|
+
`Your gate review:`,
|
|
111
|
+
`---`,
|
|
112
|
+
gateOutput,
|
|
113
|
+
`---`,
|
|
114
|
+
``,
|
|
115
|
+
`Report:`,
|
|
116
|
+
`## Fixed`,
|
|
117
|
+
`- each finding → the exact fix applied (path + what changed)`,
|
|
118
|
+
`## Verification`,
|
|
119
|
+
`- checks actually run and their results`,
|
|
120
|
+
`Do not emit another VERDICT; a converging gate re-reviews the diff after you.`,
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Fresh gate over the updated diff after a fix round. The re-review runs in a
|
|
125
|
+
* brand-new context but with a converging contract: verify the recorded fixes
|
|
126
|
+
* landed and hunt regressions the fixes introduced. It must not reopen new
|
|
127
|
+
* structural or style findings — the initial gate owned those — or every fresh
|
|
128
|
+
* scan would surface fresh nits forever and the loop would never end. */
|
|
129
|
+
export function buildReReviewBrief(fixResult: SingleResult, round: number): string {
|
|
130
|
+
return [
|
|
131
|
+
`Re-review after fix round ${round}. This gate CONVERGES: it verifies fixes, it does not re-scan the whole surface.`,
|
|
132
|
+
``,
|
|
133
|
+
`The fix stage reported:`,
|
|
134
|
+
`---`,
|
|
135
|
+
getResultOutput(fixResult),
|
|
136
|
+
`---`,
|
|
137
|
+
``,
|
|
138
|
+
`Verify every recorded fix actually landed in the code, and hunt regressions the fixes introduced in the touched`,
|
|
139
|
+
`code and its direct blast radius (\`git status\` + \`git diff\`). Do NOT open new structural, style, or pre-existing`,
|
|
140
|
+
`findings — the initial gate owned those; a remaining earlier finding counts only if its fix failed to land.`,
|
|
141
|
+
`Remain read-only. Attach a concrete fix instruction to every finding you do report.`,
|
|
142
|
+
`End with exactly one standalone machine verdict line:`,
|
|
143
|
+
`VERDICT: REVIEW_PASS when nothing remains, otherwise VERDICT: REVIEW_FAIL.`,
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One step of a managed workflow as delivered: the run id (so the condensed
|
|
149
|
+
* summary can point at per-run detail via subagent_status), the result, and
|
|
150
|
+
* the human-readable role within the workflow ("initial implementation",
|
|
151
|
+
* "final review"). runId is optional only for synthetic steps that never
|
|
152
|
+
* spawned a child.
|
|
153
|
+
*/
|
|
154
|
+
export interface ChainStep {
|
|
155
|
+
runId?: number;
|
|
156
|
+
result: SingleResult;
|
|
157
|
+
relation: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface ManagedWorkflowOutcome {
|
|
161
|
+
steps: ChainStep[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function workflowResultStatus(result: SingleResult, relation?: string): string {
|
|
165
|
+
if (isFailedResult(result)) return "failed";
|
|
166
|
+
// The fix stage is reviewer-named writer work: judge it by outcome, not by
|
|
167
|
+
// the verdict contract its review stage was held to.
|
|
168
|
+
if (relation === "review fix") return "completed";
|
|
169
|
+
if (result.agent === "reviewer") {
|
|
170
|
+
const verdict = reviewVerdict(getResultOutput(result));
|
|
171
|
+
return verdict ? verdict.toUpperCase() : "NO_VERDICT";
|
|
172
|
+
}
|
|
173
|
+
return "completed";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function workflowStepLine(step: ChainStep): string {
|
|
177
|
+
const id = step.runId !== undefined ? `#${step.runId} ` : "";
|
|
178
|
+
return `- ${id}${step.result.agent} · ${step.relation} · ${workflowResultStatus(step.result, step.relation)}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function appendWorkflowFooter(lines: string[], steps: readonly ChainStep[]): void {
|
|
182
|
+
const total = sumUsage(steps.map((step) => step.result.usage));
|
|
183
|
+
const usage = formatUsageCompact(total);
|
|
184
|
+
lines.push("", `Totals: ${steps.length} run${steps.length === 1 ? "" : "s"}${usage ? ` · ${usage}` : ""}`);
|
|
185
|
+
const ids = steps.filter((step) => step.runId !== undefined).map((step) => `#${step.runId}`);
|
|
186
|
+
lines.push(`Per-run details: subagent_status ${ids.join(" ")}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One clear final delivery for managed writer → gate workflows. */
|
|
190
|
+
export function formatManagedWorkflowSummary(
|
|
191
|
+
steps: readonly ChainStep[],
|
|
192
|
+
terminalResult: SingleResult = steps[steps.length - 1]!.result,
|
|
193
|
+
terminalRelation: string = steps[steps.length - 1]!.relation,
|
|
194
|
+
): string {
|
|
195
|
+
const route = steps.map((step) => step.result.agent).join(" → ");
|
|
196
|
+
const lines = [`## Managed workflow: ${route} — final ${workflowResultStatus(terminalResult, terminalRelation)}`, "", ...steps.map(workflowStepLine)];
|
|
197
|
+
appendWorkflowFooter(lines, steps);
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
package/src/worktree.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
13
|
-
import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
13
|
+
import { copyFile, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
16
16
|
|
|
@@ -34,6 +34,8 @@ export interface CommandRunOptions {
|
|
|
34
34
|
signal?: AbortSignal;
|
|
35
35
|
timeoutMs?: number;
|
|
36
36
|
maxOutputBytes?: number;
|
|
37
|
+
/** Extra environment entries merged over the inherited environment. */
|
|
38
|
+
env?: Record<string, string>;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export interface CommandResult {
|
|
@@ -54,33 +56,9 @@ export const GIT_COMMAND_KILL_GRACE_MS = 2_000;
|
|
|
54
56
|
export const GIT_OUTPUT_MAX_BYTES = 64 * 1024 * 1024;
|
|
55
57
|
export const WORKTREE_PATCH_MAX_BYTES = GIT_OUTPUT_MAX_BYTES;
|
|
56
58
|
|
|
57
|
-
/** Git apply validates and writes in one process
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* of silently winning a last-writer race. */
|
|
61
|
-
const originalRootApplyTails = new Map<string, Promise<void>>();
|
|
62
|
-
|
|
63
|
-
async function withSerializedOriginalRootApply<T>(
|
|
64
|
-
originalRoot: string,
|
|
65
|
-
operation: () => Promise<T>,
|
|
66
|
-
): Promise<T> {
|
|
67
|
-
const key = process.platform === "win32" ? originalRoot.toLowerCase() : originalRoot;
|
|
68
|
-
const previous = originalRootApplyTails.get(key) ?? Promise.resolve();
|
|
69
|
-
let release!: () => void;
|
|
70
|
-
const gate = new Promise<void>((resolveGate) => {
|
|
71
|
-
release = resolveGate;
|
|
72
|
-
});
|
|
73
|
-
const tail = previous.catch(() => undefined).then(() => gate);
|
|
74
|
-
originalRootApplyTails.set(key, tail);
|
|
75
|
-
await previous.catch(() => undefined);
|
|
76
|
-
try {
|
|
77
|
-
return await operation();
|
|
78
|
-
} finally {
|
|
79
|
-
release();
|
|
80
|
-
if (originalRootApplyTails.get(key) === tail) originalRootApplyTails.delete(key);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
59
|
+
/** Git apply validates and writes in one process. The repository lane
|
|
60
|
+
* (thread-lifecycle) already serializes every finalize against all writers of
|
|
61
|
+
* the same canonical checkout, so applies never race each other here. */
|
|
84
62
|
function terminateCommandTree(child: ChildProcess, force: boolean, processGroup: boolean): void {
|
|
85
63
|
if (process.platform === "win32" && child.pid !== undefined) {
|
|
86
64
|
const fallback = (): void => {
|
|
@@ -126,6 +104,7 @@ export const runCommand: CommandRunner = (command, args, options) =>
|
|
|
126
104
|
windowsHide: true,
|
|
127
105
|
stdio: ["pipe", "pipe", "pipe"],
|
|
128
106
|
detached: usePosixProcessGroup,
|
|
107
|
+
...(options.env ? { env: { ...process.env, ...options.env } } : {}),
|
|
129
108
|
});
|
|
130
109
|
const stdout: Buffer[] = [];
|
|
131
110
|
const stderr: Buffer[] = [];
|
|
@@ -315,12 +294,14 @@ async function runGit(
|
|
|
315
294
|
args: readonly string[],
|
|
316
295
|
action: string,
|
|
317
296
|
input?: Buffer,
|
|
297
|
+
env?: Record<string, string>,
|
|
318
298
|
): Promise<CommandResult> {
|
|
319
299
|
let result: CommandResult;
|
|
320
300
|
try {
|
|
321
301
|
result = await runner("git", args, {
|
|
322
302
|
cwd,
|
|
323
303
|
input,
|
|
304
|
+
env,
|
|
324
305
|
timeoutMs: GIT_COMMAND_TIMEOUT_MS,
|
|
325
306
|
maxOutputBytes: GIT_OUTPUT_MAX_BYTES,
|
|
326
307
|
});
|
|
@@ -560,15 +541,7 @@ class GitWorktreeIsolation implements WorktreeIsolation {
|
|
|
560
541
|
if (hadChanges) {
|
|
561
542
|
await writeFile(this.patchPath, diff.stdout, { flag: "wx" });
|
|
562
543
|
patchWritten = true;
|
|
563
|
-
await
|
|
564
|
-
runGit(
|
|
565
|
-
this.runner,
|
|
566
|
-
this.originalRoot,
|
|
567
|
-
["apply", "--binary", "--whitespace=nowarn", this.patchPath],
|
|
568
|
-
`Applying isolated patch to ${this.originalRoot}`,
|
|
569
|
-
),
|
|
570
|
-
);
|
|
571
|
-
integrated = true;
|
|
544
|
+
integrated = await this.applyPatchThreeWay();
|
|
572
545
|
}
|
|
573
546
|
|
|
574
547
|
const cleanupError = await this.removeAndPrune();
|
|
@@ -606,6 +579,60 @@ class GitWorktreeIsolation implements WorktreeIsolation {
|
|
|
606
579
|
};
|
|
607
580
|
}
|
|
608
581
|
|
|
582
|
+
/** Apply the patch as a three-way merge against its recorded preimage
|
|
583
|
+
* blobs, so parallel workers that touched disjoint regions (or disjoint
|
|
584
|
+
* files) of the same checkout integrate cleanly instead of the whole patch
|
|
585
|
+
* failing on context drift. A genuine overlap still fails and retains the
|
|
586
|
+
* artifacts, with conflict markers left in place for the main model to
|
|
587
|
+
* resolve. `--3way` implies `--index` and demands a working tree matching
|
|
588
|
+
* that index, so everything runs against a private copy of the checkout's
|
|
589
|
+
* index: the copy first absorbs the current unstaged state (`add -A`),
|
|
590
|
+
* making the working tree "ours" of the merge, and the user's real staged
|
|
591
|
+
* state is never touched. */
|
|
592
|
+
private async applyPatchThreeWay(): Promise<boolean> {
|
|
593
|
+
const indexCopy = join(this.tempDir, "apply-index");
|
|
594
|
+
try {
|
|
595
|
+
const indexPath = resolve(
|
|
596
|
+
this.originalRoot,
|
|
597
|
+
(await runGit(
|
|
598
|
+
this.runner,
|
|
599
|
+
this.originalRoot,
|
|
600
|
+
["rev-parse", "--git-path", "index"],
|
|
601
|
+
`Resolving index path for ${this.originalRoot}`,
|
|
602
|
+
)).stdout.toString("utf8").trim(),
|
|
603
|
+
);
|
|
604
|
+
if (!existsSync(indexPath)) {
|
|
605
|
+
await runGit(
|
|
606
|
+
this.runner,
|
|
607
|
+
this.originalRoot,
|
|
608
|
+
["apply", "--binary", "--whitespace=nowarn", this.patchPath],
|
|
609
|
+
`Applying isolated patch to ${this.originalRoot}`,
|
|
610
|
+
);
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
await copyFile(indexPath, indexCopy);
|
|
614
|
+
await runGit(
|
|
615
|
+
this.runner,
|
|
616
|
+
this.originalRoot,
|
|
617
|
+
["add", "-A", "--", "."],
|
|
618
|
+
`Staging checkout state for isolated merge in ${this.originalRoot}`,
|
|
619
|
+
undefined,
|
|
620
|
+
{ GIT_INDEX_FILE: indexCopy },
|
|
621
|
+
);
|
|
622
|
+
await runGit(
|
|
623
|
+
this.runner,
|
|
624
|
+
this.originalRoot,
|
|
625
|
+
["apply", "--binary", "--3way", "--whitespace=nowarn", this.patchPath],
|
|
626
|
+
`Three-way applying isolated patch to ${this.originalRoot}`,
|
|
627
|
+
undefined,
|
|
628
|
+
{ GIT_INDEX_FILE: indexCopy },
|
|
629
|
+
);
|
|
630
|
+
return true;
|
|
631
|
+
} finally {
|
|
632
|
+
await rm(indexCopy, { force: true }).catch(() => undefined);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
609
636
|
/** Return an error string instead of throwing so applied work is never retried. */
|
|
610
637
|
private async removeAndPrune(): Promise<string | undefined> {
|
|
611
638
|
try {
|