pi-workflow-engine 0.10.3 → 0.11.0
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/.pi/extensions/pi-workflow-engine/index.ts +168 -121
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
- package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
- package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
- package/README.md +170 -226
- package/USAGE.md +92 -12
- package/package.json +9 -9
|
@@ -2,21 +2,53 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { mkdir, readdir, readFile, rm, stat, appendFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import type { AgentOptions } from "./types.ts";
|
|
5
|
+
import type { WorktreeBaseline } from "./worktree.ts";
|
|
6
|
+
import { canonicalizeIdentity } from "./identity-canonicalization.ts";
|
|
7
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
8
|
+
import {
|
|
9
|
+
isAgentResumeContext,
|
|
10
|
+
resumeContextMismatchReason,
|
|
11
|
+
type AgentResumeContext,
|
|
12
|
+
} from "./resume-context.ts";
|
|
5
13
|
|
|
6
14
|
export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
|
|
7
15
|
export const WORKFLOW_JOURNAL_KEEP = 50;
|
|
8
16
|
|
|
9
|
-
export interface
|
|
17
|
+
export interface JournalEntryV2 {
|
|
18
|
+
readonly version: 2;
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly result: unknown;
|
|
21
|
+
readonly identity: AgentResumeContext;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Parsed so journals written before effective-session identity can fail closed without aborting resume. */
|
|
25
|
+
export interface LegacyJournalEntryV2 {
|
|
26
|
+
readonly version: 2;
|
|
27
|
+
readonly key: string;
|
|
28
|
+
readonly result: unknown;
|
|
29
|
+
readonly identity: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Parsed only so resume can explain why an older entry is not replayed. */
|
|
33
|
+
export interface LegacyJournalEntryV1 {
|
|
34
|
+
readonly version?: 1;
|
|
10
35
|
readonly key: string;
|
|
11
36
|
readonly value: unknown;
|
|
37
|
+
readonly context?: unknown;
|
|
12
38
|
}
|
|
13
39
|
|
|
14
|
-
export type
|
|
40
|
+
export type JournalEntry = LegacyJournalEntryV2 | LegacyJournalEntryV1;
|
|
41
|
+
|
|
42
|
+
export type JournalLookup = { readonly hit: true; readonly value: unknown } | { readonly hit: false; readonly reason?: string };
|
|
15
43
|
export type JournalRecordResult = { readonly ok: true } | { readonly ok: false; readonly error: string };
|
|
16
44
|
|
|
45
|
+
export type AgentJournalKeyCapture =
|
|
46
|
+
| { readonly kind: "verified"; readonly key: string }
|
|
47
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
48
|
+
|
|
17
49
|
export interface WorkflowJournal {
|
|
18
|
-
lookup(key: string): JournalLookup;
|
|
19
|
-
record(key: string,
|
|
50
|
+
lookup(key: string, identity: AgentResumeContext): JournalLookup;
|
|
51
|
+
record(key: string, result: unknown, identity: AgentResumeContext): Promise<JournalRecordResult>;
|
|
20
52
|
}
|
|
21
53
|
|
|
22
54
|
export class WorkflowJournalLoadError extends Error {
|
|
@@ -31,28 +63,71 @@ export function workflowJournalPath(cwd: string, runId: string): string {
|
|
|
31
63
|
return join(cwd, WORKFLOW_RUNS_DIR, `${validateRunId(runId)}.jsonl`);
|
|
32
64
|
}
|
|
33
65
|
|
|
34
|
-
export function agentJournalKey(prompt: string, opts: AgentOptions = {}): string {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
66
|
+
export function agentJournalKey(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
|
|
67
|
+
const capture = captureAgentJournalKey(prompt, opts, worktreeBaseline);
|
|
68
|
+
return capture.kind === "verified" ? capture.key : `agent:unverifiable:${randomUUID()}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function hashAgentCall(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
|
|
72
|
+
const capture = captureAgentCallHash(prompt, opts, worktreeBaseline);
|
|
73
|
+
return capture.kind === "verified"
|
|
74
|
+
? capture.hash
|
|
75
|
+
: createHash("sha256").update(`unverifiable:${randomUUID()}`).digest("hex");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Capture a replay key without allowing hostile or oversized schemas to escape as exceptions. */
|
|
79
|
+
export function captureAgentJournalKey(
|
|
80
|
+
prompt: string,
|
|
81
|
+
opts: AgentOptions = {},
|
|
82
|
+
worktreeBaseline?: WorktreeBaseline,
|
|
83
|
+
): AgentJournalKeyCapture {
|
|
84
|
+
const behavior = captureAgentCallHash(prompt, opts, worktreeBaseline);
|
|
85
|
+
if (behavior.kind === "unverifiable") return behavior;
|
|
86
|
+
let cacheKey: string | undefined;
|
|
87
|
+
try {
|
|
88
|
+
cacheKey = opts.cacheKey?.trim();
|
|
89
|
+
} catch {
|
|
90
|
+
return { kind: "unverifiable", reason: "agent cache key could not be inspected" };
|
|
91
|
+
}
|
|
92
|
+
if (!cacheKey) return { kind: "verified", key: `agent:${behavior.hash}` };
|
|
38
93
|
const cacheKeyHash = createHash("sha256").update(cacheKey).digest("hex");
|
|
39
|
-
return `agent:${cacheKeyHash}:${
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
94
|
+
return { kind: "verified", key: `agent:${cacheKeyHash}:${behavior.hash}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type AgentCallHashCapture =
|
|
98
|
+
| { readonly kind: "verified"; readonly hash: string }
|
|
99
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
100
|
+
|
|
101
|
+
function captureAgentCallHash(
|
|
102
|
+
prompt: string,
|
|
103
|
+
opts: AgentOptions,
|
|
104
|
+
worktreeBaseline: WorktreeBaseline | undefined,
|
|
105
|
+
): AgentCallHashCapture {
|
|
106
|
+
try {
|
|
107
|
+
const behavior = {
|
|
108
|
+
prompt,
|
|
109
|
+
opts: {
|
|
110
|
+
thinkingLevel: opts.thinkingLevel,
|
|
111
|
+
tools: opts.tools,
|
|
112
|
+
toolHints: opts.toolHints,
|
|
113
|
+
skills: opts.skills,
|
|
114
|
+
schema: opts.schema,
|
|
115
|
+
isolation: opts.isolation,
|
|
116
|
+
worktreeBaseline: worktreeBaseline
|
|
117
|
+
? {
|
|
118
|
+
ref: worktreeBaseline.ref,
|
|
119
|
+
patchFingerprint: createHash("sha256").update(worktreeBaseline.patch ?? "").digest("hex"),
|
|
120
|
+
}
|
|
121
|
+
: undefined,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const canonical = canonicalizeIdentity(behavior);
|
|
125
|
+
return canonical.kind === "verified"
|
|
126
|
+
? { kind: "verified", hash: createHash("sha256").update(canonical.value).digest("hex") }
|
|
127
|
+
: canonical;
|
|
128
|
+
} catch {
|
|
129
|
+
return { kind: "unverifiable", reason: "agent replay inputs could not be inspected" };
|
|
130
|
+
}
|
|
56
131
|
}
|
|
57
132
|
|
|
58
133
|
export async function loadJournalEntries(path: string, options: { readonly required?: boolean } = {}): Promise<JournalEntry[]> {
|
|
@@ -61,7 +136,7 @@ export async function loadJournalEntries(path: string, options: { readonly requi
|
|
|
61
136
|
content = await readFile(path, "utf8");
|
|
62
137
|
} catch (error) {
|
|
63
138
|
if (options.required) {
|
|
64
|
-
throw new WorkflowJournalLoadError(`Could not load workflow resume journal at ${path}: ${
|
|
139
|
+
throw new WorkflowJournalLoadError(`Could not load workflow resume journal at ${path}: ${unknownErrorMessage(error)}`);
|
|
65
140
|
}
|
|
66
141
|
return [];
|
|
67
142
|
}
|
|
@@ -99,25 +174,42 @@ export async function createWorkflowJournal(options: {
|
|
|
99
174
|
}
|
|
100
175
|
|
|
101
176
|
export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[] = [], writePath?: string): WorkflowJournal {
|
|
102
|
-
const priorByKey = new Map<string, JournalEntry
|
|
177
|
+
const priorByKey = new Map<string, JournalEntry[]>();
|
|
103
178
|
for (const entry of priorEntries) {
|
|
104
|
-
|
|
179
|
+
const entries = priorByKey.get(entry.key) ?? [];
|
|
180
|
+
entries.push(entry);
|
|
181
|
+
priorByKey.set(entry.key, entries);
|
|
105
182
|
}
|
|
106
183
|
|
|
107
184
|
return {
|
|
108
|
-
lookup(key) {
|
|
109
|
-
const
|
|
110
|
-
if (!
|
|
111
|
-
|
|
185
|
+
lookup(key, identity) {
|
|
186
|
+
const entries = priorByKey.get(key);
|
|
187
|
+
if (!entries || entries.length === 0) return { hit: false };
|
|
188
|
+
|
|
189
|
+
const current = entries.filter(
|
|
190
|
+
(entry): entry is JournalEntryV2 => entry.version === 2 && isAgentResumeContext(entry.identity),
|
|
191
|
+
);
|
|
192
|
+
if (current.length === 0) {
|
|
193
|
+
return {
|
|
194
|
+
hit: false,
|
|
195
|
+
reason: entries.some((entry) => entry.version === 2)
|
|
196
|
+
? "journal entry predates effective replay identity"
|
|
197
|
+
: "journal entry predates replay contract v2",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const matches = current.filter((entry) => resumeContextMismatchReason(entry.identity, identity) === undefined);
|
|
201
|
+
if (matches.length === 1) return { hit: true, value: matches[0]!.result };
|
|
202
|
+
if (matches.length > 1) return { hit: false, reason: "multiple cached entries match this agent call" };
|
|
203
|
+
return { hit: false, reason: resumeContextMismatchReason(current[0]!.identity, identity) };
|
|
112
204
|
},
|
|
113
|
-
async record(key,
|
|
114
|
-
const entry = { key,
|
|
205
|
+
async record(key, result, identity) {
|
|
206
|
+
const entry: JournalEntryV2 = { version: 2, key, result, identity };
|
|
115
207
|
if (writePath) {
|
|
116
208
|
try {
|
|
117
209
|
await mkdir(dirname(writePath), { recursive: true });
|
|
118
210
|
await appendFile(writePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
119
211
|
} catch (error) {
|
|
120
|
-
|
|
212
|
+
return { ok: false, error: unknownErrorMessage(error) };
|
|
121
213
|
}
|
|
122
214
|
}
|
|
123
215
|
return { ok: true };
|
|
@@ -164,37 +256,29 @@ function validateRunId(runId: string): string {
|
|
|
164
256
|
return trimmed;
|
|
165
257
|
}
|
|
166
258
|
|
|
167
|
-
function sortedArray(values: readonly string[] | undefined): readonly string[] | undefined {
|
|
168
|
-
return values ? [...values].sort() : undefined;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function stableStringify(value: unknown): string {
|
|
172
|
-
return JSON.stringify(normalizeStable(value));
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function normalizeStable(value: unknown): unknown {
|
|
176
|
-
if (value === undefined) return undefined;
|
|
177
|
-
if (value === null) return null;
|
|
178
|
-
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
179
|
-
if (Array.isArray(value)) return value.map(normalizeStable);
|
|
180
|
-
if (typeof value === "object") {
|
|
181
|
-
const record = value as Record<string, unknown>;
|
|
182
|
-
const out: Record<string, unknown> = {};
|
|
183
|
-
for (const key of Object.keys(record).sort()) {
|
|
184
|
-
const normalized = normalizeStable(record[key]);
|
|
185
|
-
if (normalized !== undefined) out[key] = normalized;
|
|
186
|
-
}
|
|
187
|
-
return out;
|
|
188
|
-
}
|
|
189
|
-
return String(value);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
259
|
function isJournalEntry(value: unknown): value is JournalEntry {
|
|
193
260
|
if (typeof value !== "object" || value === null) return false;
|
|
194
|
-
const entry = value as {
|
|
195
|
-
|
|
261
|
+
const entry = value as {
|
|
262
|
+
readonly version?: unknown;
|
|
263
|
+
readonly key?: unknown;
|
|
264
|
+
readonly result?: unknown;
|
|
265
|
+
readonly identity?: unknown;
|
|
266
|
+
readonly value?: unknown;
|
|
267
|
+
readonly context?: unknown;
|
|
268
|
+
};
|
|
269
|
+
if (entry.version === 2) {
|
|
270
|
+
return typeof entry.key === "string" && "result" in entry && "identity" in entry;
|
|
271
|
+
}
|
|
272
|
+
return (
|
|
273
|
+
(entry.version === undefined || entry.version === 1) &&
|
|
274
|
+
typeof entry.key === "string" &&
|
|
275
|
+
"value" in entry &&
|
|
276
|
+
(entry.context === undefined || isLegacyAgentResumeContext(entry.context))
|
|
277
|
+
);
|
|
196
278
|
}
|
|
197
279
|
|
|
198
|
-
function
|
|
199
|
-
|
|
280
|
+
function isLegacyAgentResumeContext(value: unknown): boolean {
|
|
281
|
+
if (typeof value !== "object" || value === null) return false;
|
|
282
|
+
const context = value as { readonly repository?: unknown; readonly workflow?: unknown; readonly model?: unknown };
|
|
283
|
+
return "repository" in context && "workflow" in context && "model" in context;
|
|
200
284
|
}
|
|
@@ -4,12 +4,15 @@ import type { WorkflowRunOptions } from "./types.ts";
|
|
|
4
4
|
export const WORKFLOW_BUDGET_MIN = 1;
|
|
5
5
|
export const WORKFLOW_BUDGET_MAX = 1_000_000_000;
|
|
6
6
|
|
|
7
|
-
export
|
|
7
|
+
export type ResolvedWorkflowRunOptions = Omit<
|
|
8
|
+
WorkflowRunOptions,
|
|
9
|
+
"perf" | "concurrency" | "parallelSubmissionLimit" | "budget"
|
|
10
|
+
> & {
|
|
8
11
|
readonly perf: boolean;
|
|
9
12
|
readonly concurrency: number;
|
|
10
|
-
readonly parallelSubmissionLimit
|
|
11
|
-
readonly budget
|
|
12
|
-
}
|
|
13
|
+
readonly parallelSubmissionLimit: number | null;
|
|
14
|
+
readonly budget: number | null;
|
|
15
|
+
};
|
|
13
16
|
|
|
14
17
|
export function defaultConcurrency(cpuCount = cpus().length): number {
|
|
15
18
|
return Math.min(8, Math.max(2, cpuCount));
|
|
@@ -30,8 +33,8 @@ export function resolveWorkflowRunOptions(
|
|
|
30
33
|
...input,
|
|
31
34
|
perf: input.perf ?? env.PI_WORKFLOW_PERF === "1",
|
|
32
35
|
concurrency,
|
|
33
|
-
parallelSubmissionLimit,
|
|
34
|
-
budget,
|
|
36
|
+
parallelSubmissionLimit: parallelSubmissionLimit ?? null,
|
|
37
|
+
budget: budget ?? null,
|
|
35
38
|
};
|
|
36
39
|
}
|
|
37
40
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { performance } from "node:perf_hooks";
|
|
3
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
3
4
|
|
|
4
5
|
export interface BoundedProcessOptions {
|
|
5
6
|
readonly file: string;
|
|
6
7
|
readonly args: readonly string[];
|
|
7
8
|
readonly cwd: string;
|
|
8
9
|
readonly env?: NodeJS.ProcessEnv;
|
|
10
|
+
readonly stdin?: string | Uint8Array;
|
|
9
11
|
readonly signal?: AbortSignal;
|
|
10
12
|
readonly timeoutMs: number;
|
|
11
13
|
readonly maxBufferBytes?: number;
|
|
@@ -16,81 +18,243 @@ export interface BoundedProcessOptions {
|
|
|
16
18
|
readonly exitError: (stderr: string, code: number | null, signal: NodeJS.Signals | null) => string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
readonly ok: boolean;
|
|
21
|
+
interface BoundedProcessResultBase {
|
|
21
22
|
readonly stdout: string;
|
|
22
23
|
readonly stderr: string;
|
|
23
24
|
readonly durationMs: number;
|
|
24
25
|
readonly bytes: number;
|
|
25
|
-
readonly error?: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export
|
|
28
|
+
export interface UnconfirmedProcessTermination {
|
|
29
|
+
readonly status: "unconfirmed";
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type BoundedProcessFailure =
|
|
34
|
+
| { readonly kind: "spawn" | "abort" | "timeout" | "max-buffer"; readonly message: string }
|
|
35
|
+
| {
|
|
36
|
+
readonly kind: "exit";
|
|
37
|
+
readonly message: string;
|
|
38
|
+
readonly code: number | null;
|
|
39
|
+
readonly signal: NodeJS.Signals | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type BoundedProcessResult =
|
|
43
|
+
| (BoundedProcessResultBase & { readonly ok: true; readonly error?: undefined; readonly failure?: undefined })
|
|
44
|
+
| (BoundedProcessResultBase & {
|
|
45
|
+
readonly ok: false;
|
|
46
|
+
/** Backward-compatible display message. Branch on `failure.kind` for control flow. */
|
|
47
|
+
readonly error: string;
|
|
48
|
+
readonly failure: BoundedProcessFailure;
|
|
49
|
+
/** Present only when the runner's final bound elapsed without observing child closure. */
|
|
50
|
+
readonly termination?: UnconfirmedProcessTermination;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export type WindowsTaskkillResult =
|
|
54
|
+
| { readonly ok: true }
|
|
55
|
+
| { readonly ok: false; readonly reason: string };
|
|
56
|
+
|
|
57
|
+
export interface ProcessRunnerDependencies {
|
|
58
|
+
readonly platform: NodeJS.Platform;
|
|
59
|
+
readonly runWindowsTaskkill: (pid: number, complete: (result: WindowsTaskkillResult) => void) => void;
|
|
60
|
+
readonly killChild: (child: ChildProcess, signal: NodeJS.Signals) => boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const NODE_PROCESS_RUNNER_DEPENDENCIES: ProcessRunnerDependencies = {
|
|
64
|
+
platform: process.platform,
|
|
65
|
+
runWindowsTaskkill(pid, complete) {
|
|
66
|
+
let completed = false;
|
|
67
|
+
const finish = (result: WindowsTaskkillResult) => {
|
|
68
|
+
if (completed) return;
|
|
69
|
+
completed = true;
|
|
70
|
+
complete(result);
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
const killer = spawn("taskkill.exe", ["/pid", String(pid), "/t", "/f"], {
|
|
74
|
+
detached: false,
|
|
75
|
+
stdio: "ignore",
|
|
76
|
+
windowsHide: true,
|
|
77
|
+
});
|
|
78
|
+
killer.once("error", (error) => finish({ ok: false, reason: `taskkill failed to start: ${unknownErrorMessage(error)}` }));
|
|
79
|
+
killer.once("close", (code, signal) => {
|
|
80
|
+
if (code === 0) {
|
|
81
|
+
finish({ ok: true });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
finish({
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: `taskkill exited with ${code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`}`,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
killer.unref();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
finish({ ok: false, reason: `taskkill could not be started: ${unknownErrorMessage(error)}` });
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
killChild: (child, signal) => child.kill(signal),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export async function runBoundedProcess(
|
|
98
|
+
options: BoundedProcessOptions,
|
|
99
|
+
dependencies: ProcessRunnerDependencies = NODE_PROCESS_RUNNER_DEPENDENCIES,
|
|
100
|
+
): Promise<BoundedProcessResult> {
|
|
29
101
|
const start = performance.now();
|
|
30
102
|
let stdout = "";
|
|
31
103
|
let stderr = "";
|
|
32
104
|
let bytes = 0;
|
|
33
|
-
let
|
|
105
|
+
let pendingFailure: BoundedProcessFailure | undefined;
|
|
34
106
|
const child = spawn(options.file, [...options.args], {
|
|
35
107
|
cwd: options.cwd,
|
|
36
108
|
env: options.env ?? process.env,
|
|
37
|
-
|
|
109
|
+
detached: dependencies.platform !== "win32",
|
|
110
|
+
stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
38
111
|
});
|
|
39
112
|
|
|
40
113
|
return await new Promise<BoundedProcessResult>((resolve) => {
|
|
41
114
|
let settled = false;
|
|
42
115
|
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
116
|
+
let hardSettleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
117
|
+
let directChildKillAttempted = false;
|
|
118
|
+
let windowsTerminationReason = "process close was not observed after taskkill and direct-child fallback";
|
|
43
119
|
const killGraceMs = Math.max(1, options.killGraceMs ?? 100);
|
|
44
|
-
const
|
|
120
|
+
const cleanup = () => {
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
123
|
+
if (hardSettleTimer) clearTimeout(hardSettleTimer);
|
|
124
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
125
|
+
};
|
|
126
|
+
const finish = (failure?: BoundedProcessFailure, termination?: UnconfirmedProcessTermination) => {
|
|
45
127
|
if (settled) return;
|
|
46
128
|
settled = true;
|
|
47
129
|
cleanup();
|
|
48
|
-
|
|
130
|
+
const base = { stdout, stderr, durationMs: performance.now() - start, bytes };
|
|
131
|
+
if (!failure) {
|
|
132
|
+
resolve({ ...base, ok: true });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
resolve({ ...base, ok: false, error: failure.message, failure, ...(termination ? { termination } : {}) });
|
|
49
136
|
};
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
137
|
+
const killDirectChild = (signal: NodeJS.Signals, reason: string) => {
|
|
138
|
+
if (directChildKillAttempted) return;
|
|
139
|
+
directChildKillAttempted = true;
|
|
140
|
+
windowsTerminationReason = reason;
|
|
141
|
+
try {
|
|
142
|
+
if (!dependencies.killChild(child, signal)) {
|
|
143
|
+
windowsTerminationReason = `${reason}; direct-child termination was not accepted`;
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
windowsTerminationReason = `${reason}; direct-child termination failed: ${unknownErrorMessage(error)}`;
|
|
147
|
+
}
|
|
57
148
|
};
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
149
|
+
const signalPosixProcess = (signal: NodeJS.Signals) => {
|
|
150
|
+
let groupSignalled = false;
|
|
151
|
+
if (child.pid !== undefined) {
|
|
152
|
+
try {
|
|
153
|
+
process.kill(-child.pid, signal);
|
|
154
|
+
groupSignalled = true;
|
|
155
|
+
} catch {
|
|
156
|
+
// The process may have exited before its descendants released stdio.
|
|
157
|
+
// Fall back to the direct child below.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!groupSignalled) {
|
|
161
|
+
try {
|
|
162
|
+
dependencies.killChild(child, signal);
|
|
163
|
+
} catch {
|
|
164
|
+
// The hard-settlement timer remains authoritative when signalling fails.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
64
167
|
};
|
|
168
|
+
const destroyStdio = () => {
|
|
169
|
+
child.stdin?.destroy();
|
|
170
|
+
child.stdout?.destroy();
|
|
171
|
+
child.stderr?.destroy();
|
|
172
|
+
};
|
|
173
|
+
const terminate = (failure: BoundedProcessFailure) => {
|
|
174
|
+
pendingFailure ??= failure;
|
|
175
|
+
if (forceKillTimer) return;
|
|
176
|
+
if (dependencies.platform === "win32") {
|
|
177
|
+
if (child.pid === undefined) {
|
|
178
|
+
killDirectChild("SIGKILL", "taskkill was unavailable because the child process had no pid");
|
|
179
|
+
} else {
|
|
180
|
+
try {
|
|
181
|
+
dependencies.runWindowsTaskkill(child.pid, (result) => {
|
|
182
|
+
if (settled || result.ok) return;
|
|
183
|
+
killDirectChild("SIGKILL", result.reason);
|
|
184
|
+
});
|
|
185
|
+
} catch (error) {
|
|
186
|
+
killDirectChild("SIGKILL", `taskkill invocation failed: ${unknownErrorMessage(error)}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
signalPosixProcess("SIGTERM");
|
|
191
|
+
}
|
|
192
|
+
forceKillTimer = setTimeout(() => {
|
|
193
|
+
if (dependencies.platform === "win32") {
|
|
194
|
+
if (directChildKillAttempted) {
|
|
195
|
+
windowsTerminationReason = `${windowsTerminationReason}; child did not close within the termination grace period`;
|
|
196
|
+
} else {
|
|
197
|
+
killDirectChild("SIGKILL", "taskkill did not close the child within the termination grace period");
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
signalPosixProcess("SIGKILL");
|
|
201
|
+
}
|
|
202
|
+
destroyStdio();
|
|
203
|
+
if (!settled) {
|
|
204
|
+
hardSettleTimer = setTimeout(() => {
|
|
205
|
+
if (!pendingFailure) return;
|
|
206
|
+
finish(
|
|
207
|
+
pendingFailure,
|
|
208
|
+
{ status: "unconfirmed", reason: dependencies.platform === "win32"
|
|
209
|
+
? windowsTerminationReason
|
|
210
|
+
: "process close was not observed after forced process-group termination" },
|
|
211
|
+
);
|
|
212
|
+
}, killGraceMs);
|
|
213
|
+
}
|
|
214
|
+
}, killGraceMs);
|
|
215
|
+
};
|
|
216
|
+
const onAbort = () => terminate({ kind: "abort", message: options.abortError });
|
|
217
|
+
const timeout = setTimeout(() => terminate({ kind: "timeout", message: options.timeoutError }), options.timeoutMs);
|
|
65
218
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} else {
|
|
69
|
-
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
child.stdout?.on("data", (chunk: Buffer) => {
|
|
219
|
+
const captureChunk = (chunk: Buffer, append: (text: string) => void) => {
|
|
220
|
+
if (pendingFailure) return;
|
|
73
221
|
bytes += chunk.length;
|
|
74
222
|
if (options.maxBufferBytes !== undefined && bytes > options.maxBufferBytes) {
|
|
75
|
-
|
|
223
|
+
terminate({
|
|
224
|
+
kind: "max-buffer",
|
|
225
|
+
message: options.maxBufferError ?? `process output exceeded ${options.maxBufferBytes} bytes`,
|
|
226
|
+
});
|
|
76
227
|
return;
|
|
77
228
|
}
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
child.
|
|
81
|
-
|
|
229
|
+
append(chunk.toString("utf8"));
|
|
230
|
+
};
|
|
231
|
+
child.stdout?.on("data", (chunk: Buffer) => captureChunk(chunk, (text) => (stdout += text)));
|
|
232
|
+
child.stderr?.on("data", (chunk: Buffer) => captureChunk(chunk, (text) => (stderr += text)));
|
|
233
|
+
child.on("error", (spawnError) => {
|
|
234
|
+
if (!pendingFailure) finish({ kind: "spawn", message: spawnError.message });
|
|
82
235
|
});
|
|
83
|
-
child.on("error", (spawnError) => finish(false, spawnError.message));
|
|
84
236
|
child.on("close", (code, signal) => {
|
|
85
|
-
if (
|
|
86
|
-
finish(
|
|
237
|
+
if (pendingFailure) {
|
|
238
|
+
finish(pendingFailure);
|
|
87
239
|
return;
|
|
88
240
|
}
|
|
89
241
|
if (code === 0) {
|
|
90
|
-
finish(
|
|
242
|
+
finish();
|
|
91
243
|
return;
|
|
92
244
|
}
|
|
93
|
-
|
|
245
|
+
const message = options.exitError(stderr, code, signal);
|
|
246
|
+
finish({ kind: "exit", message, code, signal });
|
|
94
247
|
});
|
|
248
|
+
if (options.signal?.aborted) {
|
|
249
|
+
onAbort();
|
|
250
|
+
} else {
|
|
251
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
252
|
+
}
|
|
253
|
+
if (options.stdin !== undefined) {
|
|
254
|
+
// The child may reject input and exit before consuming it. Its process exit
|
|
255
|
+
// remains the authoritative result; avoid surfacing a secondary EPIPE.
|
|
256
|
+
child.stdin?.on("error", () => undefined);
|
|
257
|
+
child.stdin?.end(options.stdin);
|
|
258
|
+
}
|
|
95
259
|
});
|
|
96
260
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { WorkflowLaneItemStatus, WorkflowProgressEvent } from "./types.ts";
|
|
4
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
4
5
|
import { statusTextFromCounts, type WorkflowStatusCounts } from "./ui/workflow-format.ts";
|
|
5
6
|
import { createWorkflowWidget, type WorkflowWidget } from "./ui/workflow-widget.ts";
|
|
6
7
|
|
|
@@ -228,7 +229,7 @@ export class ProgressTracker {
|
|
|
228
229
|
if (row) {
|
|
229
230
|
this.transitionAgentStatus(row, "failed");
|
|
230
231
|
row.doneAt = Date.now();
|
|
231
|
-
row.error =
|
|
232
|
+
row.error = unknownErrorMessage(error);
|
|
232
233
|
}
|
|
233
234
|
this.render();
|
|
234
235
|
}
|
|
@@ -384,7 +385,3 @@ function laneItemLimitFromEnv(): number {
|
|
|
384
385
|
if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_LANE_ITEM_LIMIT;
|
|
385
386
|
return Math.trunc(parsed);
|
|
386
387
|
}
|
|
387
|
-
|
|
388
|
-
function errorMessage(error: unknown): string {
|
|
389
|
-
return error instanceof Error ? error.message : String(error);
|
|
390
|
-
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { relative, resolve, sep } from "node:path";
|
|
2
|
+
import { isPathWithin } from "./tree-fingerprint.ts";
|
|
3
|
+
|
|
4
|
+
export interface ReplayWorkspaceRoots {
|
|
5
|
+
readonly sessionCwd: string;
|
|
6
|
+
readonly workspaceRoot: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Map a path in either the disposable session or main workspace to one stable namespace. */
|
|
10
|
+
export function logicalWorkspacePath(path: string, roots: ReplayWorkspaceRoots): string | undefined {
|
|
11
|
+
for (const root of resolvedWorkspaceRoots(roots)) {
|
|
12
|
+
if (isPathWithin(root, path)) return `workspace:${portableRelativePath(root, path)}`;
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Normalize absolute workspace paths embedded in generated system prompts. */
|
|
18
|
+
export function normalizeWorkspaceReferences(value: string, roots: ReplayWorkspaceRoots): string {
|
|
19
|
+
const paths = [...resolvedWorkspaceRoots(roots)].sort((left, right) => right.length - left.length);
|
|
20
|
+
return paths.reduce((normalized, path) => replacePathPrefix(normalized, path, "<workspace>"), value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function portableRelativePath(root: string, path: string): string {
|
|
24
|
+
const value = relative(root, path).split(sep).join("/");
|
|
25
|
+
return value.length === 0 ? "." : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolvedWorkspaceRoots(roots: ReplayWorkspaceRoots): readonly string[] {
|
|
29
|
+
return [...new Set([resolve(roots.sessionCwd), resolve(roots.workspaceRoot)])];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function replacePathPrefix(value: string, prefix: string, replacement: string): string {
|
|
33
|
+
let result = "";
|
|
34
|
+
let cursor = 0;
|
|
35
|
+
while (cursor < value.length) {
|
|
36
|
+
const index = value.indexOf(prefix, cursor);
|
|
37
|
+
if (index === -1) return result + value.slice(cursor);
|
|
38
|
+
const next = value[index + prefix.length];
|
|
39
|
+
if (next !== undefined && next !== "/" && next !== "\\" && /[\p{L}\p{N}._-]/u.test(next)) {
|
|
40
|
+
result += value.slice(cursor, index + prefix.length);
|
|
41
|
+
cursor = index + prefix.length;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
result += value.slice(cursor, index) + replacement;
|
|
45
|
+
cursor = index + prefix.length;
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|