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.
Files changed (55) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +168 -121
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
  7. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
  10. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
  11. package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
  12. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
  13. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
  14. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
  15. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
  16. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  17. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
  18. package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
  19. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  20. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  21. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  22. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
  23. package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
  24. package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
  25. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  26. package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
  27. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  28. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
  29. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  30. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  31. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  32. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  33. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  34. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  35. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  36. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
  40. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
  41. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
  42. package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
  43. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
  44. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  47. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
  48. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
  49. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
  50. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
  51. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
  52. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
  53. package/README.md +170 -226
  54. package/USAGE.md +92 -12
  55. 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 JournalEntry {
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 JournalLookup = { readonly hit: true; readonly value: unknown } | { readonly hit: false };
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, value: unknown): Promise<JournalRecordResult>;
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 behaviorHash = hashAgentCall(prompt, opts);
36
- const cacheKey = opts.cacheKey?.trim();
37
- if (!cacheKey) return `agent:${behaviorHash}`;
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}:${behaviorHash}`;
40
- }
41
-
42
- export function hashAgentCall(prompt: string, opts: AgentOptions = {}): string {
43
- const behavior = {
44
- prompt,
45
- opts: {
46
- model: opts.model,
47
- thinkingLevel: opts.thinkingLevel,
48
- tools: sortedArray(opts.tools),
49
- toolHints: sortedArray(opts.toolHints),
50
- skills: sortedArray(opts.skills),
51
- schema: opts.schema,
52
- isolation: opts.isolation,
53
- },
54
- };
55
- return createHash("sha256").update(stableStringify(behavior)).digest("hex");
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}: ${formatError(error)}`);
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 | "ambiguous">();
177
+ const priorByKey = new Map<string, JournalEntry[]>();
103
178
  for (const entry of priorEntries) {
104
- priorByKey.set(entry.key, priorByKey.has(entry.key) ? "ambiguous" : entry);
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 entry = priorByKey.get(key);
110
- if (!entry || entry === "ambiguous") return { hit: false };
111
- return { hit: true, value: entry.value };
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, value) {
114
- const entry = { key, value };
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
- return { ok: false, error: formatError(error) };
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 { readonly key?: unknown; readonly value?: unknown };
195
- return typeof entry.key === "string" && "value" in entry;
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 formatError(error: unknown): string {
199
- return error instanceof Error ? error.message : String(error);
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 interface ResolvedWorkflowRunOptions extends WorkflowRunOptions {
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?: number;
11
- readonly budget?: number;
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
- export interface BoundedProcessResult {
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 async function runBoundedProcess(options: BoundedProcessOptions): Promise<BoundedProcessResult> {
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 error: string | undefined;
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
- stdio: ["ignore", "pipe", "pipe"],
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 finish = (ok: boolean, finishError?: string) => {
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
- resolve({ ok, stdout, stderr, durationMs: performance.now() - start, bytes, error: finishError });
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 kill = (message: string) => {
51
- error = error ?? message;
52
- child.kill("SIGTERM");
53
- forceKillTimer ??= setTimeout(() => {
54
- child.kill("SIGKILL");
55
- finish(false, error);
56
- }, killGraceMs);
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 onAbort = () => kill(options.abortError);
59
- const timeout = setTimeout(() => kill(options.timeoutError), options.timeoutMs);
60
- const cleanup = () => {
61
- clearTimeout(timeout);
62
- if (forceKillTimer) clearTimeout(forceKillTimer);
63
- options.signal?.removeEventListener("abort", onAbort);
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
- if (options.signal?.aborted) {
67
- kill(options.abortError);
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
- kill(options.maxBufferError ?? `process output exceeded ${options.maxBufferBytes} bytes`);
223
+ terminate({
224
+ kind: "max-buffer",
225
+ message: options.maxBufferError ?? `process output exceeded ${options.maxBufferBytes} bytes`,
226
+ });
76
227
  return;
77
228
  }
78
- stdout += chunk.toString("utf8");
79
- });
80
- child.stderr?.on("data", (chunk: Buffer) => {
81
- stderr += chunk.toString("utf8");
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 (error) {
86
- finish(false, error);
237
+ if (pendingFailure) {
238
+ finish(pendingFailure);
87
239
  return;
88
240
  }
89
241
  if (code === 0) {
90
- finish(true);
242
+ finish();
91
243
  return;
92
244
  }
93
- finish(false, options.exitError(stderr, code, signal));
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 = errorMessage(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
+ }