@osolmaz/pi-workflows 0.2.0 → 0.3.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 (133) hide show
  1. package/README.md +71 -5
  2. package/dist/builtins/monitor.workflow.d.ts +71 -0
  3. package/dist/builtins/monitor.workflow.js +234 -0
  4. package/dist/builtins/monitor.workflow.js.map +1 -0
  5. package/dist/controllers/conditions.d.ts +6 -0
  6. package/dist/controllers/conditions.js +68 -0
  7. package/dist/controllers/conditions.js.map +1 -0
  8. package/dist/controllers/definition.d.ts +6 -0
  9. package/dist/controllers/definition.js +45 -0
  10. package/dist/controllers/definition.js.map +1 -0
  11. package/dist/controllers/effects.d.ts +14 -0
  12. package/dist/controllers/effects.js +104 -0
  13. package/dist/controllers/effects.js.map +1 -0
  14. package/dist/controllers/errors.d.ts +12 -0
  15. package/dist/controllers/errors.js +25 -0
  16. package/dist/controllers/errors.js.map +1 -0
  17. package/dist/controllers/index.d.ts +13 -0
  18. package/dist/controllers/index.js +13 -0
  19. package/dist/controllers/index.js.map +1 -0
  20. package/dist/controllers/json.d.ts +5 -0
  21. package/dist/controllers/json.js +57 -0
  22. package/dist/controllers/json.js.map +1 -0
  23. package/dist/controllers/loader.d.ts +23 -0
  24. package/dist/controllers/loader.js +74 -0
  25. package/dist/controllers/loader.js.map +1 -0
  26. package/dist/controllers/manager.d.ts +58 -0
  27. package/dist/controllers/manager.js +399 -0
  28. package/dist/controllers/manager.js.map +1 -0
  29. package/dist/controllers/results.d.ts +5 -0
  30. package/dist/controllers/results.js +32 -0
  31. package/dist/controllers/results.js.map +1 -0
  32. package/dist/controllers/sqlite.d.ts +212 -0
  33. package/dist/controllers/sqlite.js +1009 -0
  34. package/dist/controllers/sqlite.js.map +1 -0
  35. package/dist/controllers/store.d.ts +112 -0
  36. package/dist/controllers/store.js +32 -0
  37. package/dist/controllers/store.js.map +1 -0
  38. package/dist/controllers/types.d.ts +159 -0
  39. package/dist/controllers/types.js +2 -0
  40. package/dist/controllers/types.js.map +1 -0
  41. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  42. package/dist/controllers/workflow-engine-scheduler.js +93 -0
  43. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  44. package/dist/controllers/workflows.d.ts +27 -0
  45. package/dist/controllers/workflows.js +109 -0
  46. package/dist/controllers/workflows.js.map +1 -0
  47. package/dist/extension/controller-host.d.ts +47 -0
  48. package/dist/extension/controller-host.js +110 -0
  49. package/dist/extension/controller-host.js.map +1 -0
  50. package/dist/extension/executor.js +1 -1
  51. package/dist/extension/executor.js.map +1 -1
  52. package/dist/extension/index.d.ts +7 -0
  53. package/dist/extension/index.js +1053 -96
  54. package/dist/extension/index.js.map +1 -1
  55. package/dist/extension/recorder.d.ts +6 -0
  56. package/dist/extension/recorder.js +20 -8
  57. package/dist/extension/recorder.js.map +1 -1
  58. package/dist/extension/workflow-tool.d.ts +28 -0
  59. package/dist/extension/workflow-tool.js +33 -0
  60. package/dist/extension/workflow-tool.js.map +1 -0
  61. package/dist/host/processes.d.ts +24 -0
  62. package/dist/host/processes.js +114 -0
  63. package/dist/host/processes.js.map +1 -0
  64. package/dist/host/rpc-bridge.d.ts +9 -0
  65. package/dist/host/rpc-bridge.js +39 -0
  66. package/dist/host/rpc-bridge.js.map +1 -0
  67. package/dist/host/rpc-executor.d.ts +38 -0
  68. package/dist/host/rpc-executor.js +254 -0
  69. package/dist/host/rpc-executor.js.map +1 -0
  70. package/dist/host/runner.d.ts +49 -0
  71. package/dist/host/runner.js +350 -0
  72. package/dist/host/runner.js.map +1 -0
  73. package/dist/viewer/cli.d.ts +7 -3
  74. package/dist/viewer/cli.js +150 -19
  75. package/dist/viewer/cli.js.map +1 -1
  76. package/dist/workflows/engine.d.ts +36 -0
  77. package/dist/workflows/engine.js +244 -14
  78. package/dist/workflows/engine.js.map +1 -1
  79. package/dist/workflows/errors.d.ts +23 -0
  80. package/dist/workflows/errors.js +38 -0
  81. package/dist/workflows/errors.js.map +1 -1
  82. package/dist/workflows/graph.js +0 -5
  83. package/dist/workflows/graph.js.map +1 -1
  84. package/dist/workflows/loader.d.ts +5 -3
  85. package/dist/workflows/loader.js +10 -1
  86. package/dist/workflows/loader.js.map +1 -1
  87. package/dist/workflows/schema.js +1 -1
  88. package/dist/workflows/schema.js.map +1 -1
  89. package/dist/workflows/store.d.ts +50 -6
  90. package/dist/workflows/store.js +446 -51
  91. package/dist/workflows/store.js.map +1 -1
  92. package/dist/workflows/types.d.ts +10 -0
  93. package/docs/CONTROLLERS.md +215 -0
  94. package/docs/development.md +12 -9
  95. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  96. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  97. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  98. package/docs/run-bundles.md +66 -27
  99. package/docs/workflows.md +131 -11
  100. package/examples/controllers/pull-request.controller.ts +215 -0
  101. package/package.json +10 -2
  102. package/src/builtins/monitor.workflow.ts +278 -0
  103. package/src/controllers/conditions.ts +110 -0
  104. package/src/controllers/definition.ts +65 -0
  105. package/src/controllers/effects.ts +123 -0
  106. package/src/controllers/errors.ts +27 -0
  107. package/src/controllers/index.ts +90 -0
  108. package/src/controllers/json.ts +62 -0
  109. package/src/controllers/loader.ts +104 -0
  110. package/src/controllers/manager.ts +533 -0
  111. package/src/controllers/results.ts +46 -0
  112. package/src/controllers/sqlite.ts +1427 -0
  113. package/src/controllers/store.ts +160 -0
  114. package/src/controllers/types.ts +183 -0
  115. package/src/controllers/workflow-engine-scheduler.ts +145 -0
  116. package/src/controllers/workflows.ts +152 -0
  117. package/src/extension/controller-host.ts +163 -0
  118. package/src/extension/executor.ts +1 -1
  119. package/src/extension/index.ts +1243 -117
  120. package/src/extension/recorder.ts +65 -36
  121. package/src/extension/workflow-tool.ts +59 -0
  122. package/src/host/processes.ts +119 -0
  123. package/src/host/rpc-bridge.ts +44 -0
  124. package/src/host/rpc-executor.ts +299 -0
  125. package/src/host/runner.ts +406 -0
  126. package/src/viewer/cli.ts +167 -21
  127. package/src/workflows/engine.ts +327 -13
  128. package/src/workflows/errors.ts +45 -0
  129. package/src/workflows/graph.ts +0 -5
  130. package/src/workflows/loader.ts +13 -3
  131. package/src/workflows/schema.ts +1 -1
  132. package/src/workflows/store.ts +555 -46
  133. package/src/workflows/types.ts +10 -0
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import {
3
4
  SESSION_BINDING_SCHEMA,
@@ -83,6 +84,12 @@ export class SessionRecorder {
83
84
  private readonly store: WorkflowRunStore;
84
85
  private readonly runDir: string;
85
86
  private readonly runId: string;
87
+ /**
88
+ * Capture segment for this recorder. Undefined writes the legacy flat
89
+ * stream; a second recorder attaching to the same bundle (resume or
90
+ * handoff) writes a segment instead so the first capture stays intact.
91
+ */
92
+ private segmentId: string | undefined;
86
93
  private cursor: string | null = null;
87
94
  private readonly recorded: string[] = [];
88
95
  private readonly unclaimedEntries: RecordedEntry[] = [];
@@ -128,22 +135,35 @@ export class SessionRecorder {
128
135
  this.bound = true;
129
136
  this.cursor = ctx.sessionManager.getLeafId();
130
137
  const sessionFile = ctx.sessionManager.getSessionFile();
131
- await this.store.writeSessionBinding(this.runDir, {
132
- schema: SESSION_BINDING_SCHEMA,
133
- runId: this.runId,
134
- piSessionId: ctx.sessionManager.getSessionId(),
135
- ...(sessionFile !== undefined ? { piSessionFile: sessionFile } : {}),
136
- cwd: ctx.cwd,
137
- boundAt: new Date().toISOString(),
138
- });
139
- await this.store.writeSessionCapture(this.runDir, {
140
- schema: SESSION_CAPTURE_SCHEMA,
141
- eventSchema: SESSION_EVENT_SCHEMA,
142
- status: "recording",
143
- eventCount: 0,
144
- entryCount: 0,
145
- lastEventSeq: 0,
146
- });
138
+ // A bundle that already has a binding belongs to an earlier capture
139
+ // attempt; this recorder writes its own segment under session/segments.
140
+ if (await this.store.hasSessionBinding(this.runDir)) {
141
+ this.segmentId = randomUUID();
142
+ }
143
+ await this.store.writeSessionBinding(
144
+ this.runDir,
145
+ {
146
+ schema: SESSION_BINDING_SCHEMA,
147
+ runId: this.runId,
148
+ piSessionId: ctx.sessionManager.getSessionId(),
149
+ ...(sessionFile !== undefined ? { piSessionFile: sessionFile } : {}),
150
+ cwd: ctx.cwd,
151
+ boundAt: new Date().toISOString(),
152
+ },
153
+ this.segmentId,
154
+ );
155
+ await this.store.writeSessionCapture(
156
+ this.runDir,
157
+ {
158
+ schema: SESSION_CAPTURE_SCHEMA,
159
+ eventSchema: SESSION_EVENT_SCHEMA,
160
+ status: "recording",
161
+ eventCount: 0,
162
+ entryCount: 0,
163
+ lastEventSeq: 0,
164
+ },
165
+ this.segmentId,
166
+ );
147
167
  }
148
168
 
149
169
  /** Fix the owner before the executor delivers an agent-step prompt. */
@@ -324,7 +344,7 @@ export class SessionRecorder {
324
344
  }
325
345
  const appended: RecordedEntry[] = [];
326
346
  for (const entry of branch.slice(startIndex)) {
327
- await this.store.appendSessionEntry(this.runDir, entry);
347
+ await this.store.appendSessionEntry(this.runDir, entry, this.segmentId);
328
348
  const recorded = { id: entry.id, entry, claimed: false };
329
349
  appended.push(recorded);
330
350
  this.unclaimedEntries.push(recorded);
@@ -406,31 +426,39 @@ export class SessionRecorder {
406
426
  try {
407
427
  await this.flushAllEvents();
408
428
  await this.entryChain;
409
- const counts = await this.store.sessionCounts(this.runDir);
410
- await this.store.writeSessionCapture(this.runDir, {
411
- schema: SESSION_CAPTURE_SCHEMA,
412
- eventSchema: SESSION_EVENT_SCHEMA,
413
- status: this.captureFailure ? "failed" : "complete",
414
- ...counts,
415
- ...(this.captureFailure ? { failure: this.captureFailure } : {}),
416
- });
429
+ const counts = await this.store.sessionCounts(this.runDir, this.segmentId);
430
+ await this.store.writeSessionCapture(
431
+ this.runDir,
432
+ {
433
+ schema: SESSION_CAPTURE_SCHEMA,
434
+ eventSchema: SESSION_EVENT_SCHEMA,
435
+ status: this.captureFailure ? "failed" : "complete",
436
+ ...counts,
437
+ ...(this.captureFailure ? { failure: this.captureFailure } : {}),
438
+ },
439
+ this.segmentId,
440
+ );
417
441
  } catch (error) {
418
442
  // Capture is observational. A finalization failure must never reject
419
443
  // the workflow's terminal persistence hook.
420
444
  this.failCapture("capture_finalize_failed", failureMessage(error));
421
445
  try {
422
- const counts = await this.store.sessionCounts(this.runDir);
423
- await this.store.writeSessionCapture(this.runDir, {
424
- schema: SESSION_CAPTURE_SCHEMA,
425
- eventSchema: SESSION_EVENT_SCHEMA,
426
- status: "failed",
427
- ...counts,
428
- failure: this.captureFailure ?? {
429
- failedAt: new Date().toISOString(),
430
- code: "capture_finalize_failed",
431
- message: failureMessage(error),
446
+ const counts = await this.store.sessionCounts(this.runDir, this.segmentId);
447
+ await this.store.writeSessionCapture(
448
+ this.runDir,
449
+ {
450
+ schema: SESSION_CAPTURE_SCHEMA,
451
+ eventSchema: SESSION_EVENT_SCHEMA,
452
+ status: "failed",
453
+ ...counts,
454
+ failure: this.captureFailure ?? {
455
+ failedAt: new Date().toISOString(),
456
+ code: "capture_finalize_failed",
457
+ message: failureMessage(error),
458
+ },
432
459
  },
433
- });
460
+ this.segmentId,
461
+ );
434
462
  } catch {
435
463
  // The viewer will report the missing/invalid capture file.
436
464
  }
@@ -576,6 +604,7 @@ export class SessionRecorder {
576
604
  .appendSessionEventBatch(
577
605
  this.runDir,
578
606
  batch.map((queued) => queued.record),
607
+ this.segmentId,
579
608
  )
580
609
  .catch((error: unknown) => {
581
610
  const message = failureMessage(error);
@@ -0,0 +1,59 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Type, type TSchema } from "typebox";
3
+
4
+ const noExtraProperties = { additionalProperties: false } as const;
5
+
6
+ export const WorkflowToolParameters: TSchema = Type.Union([
7
+ Type.Object(
8
+ {
9
+ action: StringEnum(["list"] as const),
10
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "Workflow list offset" })),
11
+ },
12
+ noExtraProperties,
13
+ ),
14
+ Type.Object(
15
+ {
16
+ action: StringEnum(["start"] as const),
17
+ workflow: Type.String({ description: "Discovered workflow name or workflow file path" }),
18
+ input: Type.Optional(Type.Unknown({ description: "Structured workflow input" })),
19
+ },
20
+ noExtraProperties,
21
+ ),
22
+ Type.Object(
23
+ {
24
+ action: StringEnum(["status"] as const),
25
+ runId: Type.Optional(Type.String({ description: "Run id; omit for the active run" })),
26
+ },
27
+ noExtraProperties,
28
+ ),
29
+ Type.Object({ action: StringEnum(["pause"] as const) }, noExtraProperties),
30
+ Type.Object({ action: StringEnum(["resume"] as const) }, noExtraProperties),
31
+ Type.Object({ action: StringEnum(["cancel"] as const) }, noExtraProperties),
32
+ Type.Object(
33
+ {
34
+ action: StringEnum(["answer"] as const),
35
+ input: Type.Unknown({ description: "Checkpoint answer" }),
36
+ runId: Type.Optional(Type.String({ description: "Waiting run id" })),
37
+ },
38
+ noExtraProperties,
39
+ ),
40
+ Type.Object(
41
+ {
42
+ action: StringEnum(["submit"] as const),
43
+ step: Type.String({ description: "Step id from the workflow step contract" }),
44
+ attempt: Type.String({ description: "Attempt id from the workflow step contract" }),
45
+ output: Type.Unknown({ description: "Step output matching the expected shape" }),
46
+ },
47
+ noExtraProperties,
48
+ ),
49
+ ]);
50
+
51
+ export type WorkflowToolInput =
52
+ | { action: "list"; offset?: number }
53
+ | { action: "start"; workflow: string; input?: unknown }
54
+ | { action: "status"; runId?: string }
55
+ | { action: "pause" }
56
+ | { action: "resume" }
57
+ | { action: "cancel" }
58
+ | { action: "answer"; input: unknown; runId?: string }
59
+ | { action: "submit"; step: string; attempt: string; output: unknown };
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Tracks headless child processes (spawned `pi --mode rpc` sessions) so a
6
+ * killed host never leaves orphans working. Children spawn in their own
7
+ * process group; the registry file lets a later host reap leftovers by
8
+ * killing the whole group. The file lives next to the project store and is
9
+ * only ever touched by one host at a time (the advisory lock ensures it).
10
+ */
11
+ export class HostProcessRegistry {
12
+ private readonly filePath: string;
13
+ private readonly pids = new Set<number>();
14
+
15
+ constructor(storeDir: string) {
16
+ this.filePath = path.join(storeDir, "host.children.json");
17
+ }
18
+
19
+ register(pid: number): void {
20
+ this.pids.add(pid);
21
+ this.persist();
22
+ }
23
+
24
+ unregister(pid: number): void {
25
+ this.pids.delete(pid);
26
+ this.persist();
27
+ }
28
+
29
+ get size(): number {
30
+ return this.pids.size;
31
+ }
32
+
33
+ /** Kill every registered child's process group, escalating to SIGKILL. */
34
+ killAll(): void {
35
+ for (const pid of this.pids) {
36
+ killProcessGroup(pid, "SIGTERM");
37
+ }
38
+ for (const pid of this.pids) {
39
+ killProcessGroup(pid, "SIGKILL");
40
+ }
41
+ this.pids.clear();
42
+ this.persist();
43
+ }
44
+
45
+ /**
46
+ * Reap children recorded by a previous host that died without cleanup.
47
+ * Called once at host start, before the lock is taken.
48
+ */
49
+ reapOrphans(): number[] {
50
+ const recorded = this.readFile();
51
+ const reaped: number[] = [];
52
+ const stillAlive: number[] = [];
53
+ for (const pid of recorded) {
54
+ if (isAlive(pid)) {
55
+ killProcessGroup(pid, "SIGKILL");
56
+ reaped.push(pid);
57
+ if (isAlive(pid)) {
58
+ // A group kill that left the leader alive stays registered so a
59
+ // later start tries again instead of forgetting it.
60
+ stillAlive.push(pid);
61
+ }
62
+ }
63
+ }
64
+ this.pids.clear();
65
+ for (const pid of stillAlive) {
66
+ this.pids.add(pid);
67
+ }
68
+ this.persist();
69
+ return reaped;
70
+ }
71
+
72
+ private readFile(): number[] {
73
+ try {
74
+ const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8")) as unknown;
75
+ if (!Array.isArray(parsed)) {
76
+ return [];
77
+ }
78
+ return parsed.filter((pid): pid is number => Number.isSafeInteger(pid) && pid > 0);
79
+ } catch {
80
+ return [];
81
+ }
82
+ }
83
+
84
+ private persist(): void {
85
+ try {
86
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
87
+ // Write exactly the live set. Unioning with the previous file would
88
+ // resurrect exited pids, and a later host reaping them could kill an
89
+ // unrelated process group that reused one.
90
+ fs.writeFileSync(this.filePath, `${JSON.stringify([...this.pids])}\n`, {
91
+ encoding: "utf8",
92
+ mode: 0o600,
93
+ });
94
+ } catch {
95
+ // Registry bookkeeping is best-effort; orphan reaping is the backstop.
96
+ }
97
+ }
98
+ }
99
+
100
+ function isAlive(pid: number): boolean {
101
+ try {
102
+ process.kill(pid, 0);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ function killProcessGroup(pid: number, signal: NodeJS.Signals): void {
110
+ try {
111
+ process.kill(-pid, signal);
112
+ } catch {
113
+ try {
114
+ process.kill(pid, signal);
115
+ } catch {
116
+ // Already gone.
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,44 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+
5
+ export const RPC_SUBMISSION_PREFIX = "PI_WORKFLOWS_STEP_SUBMISSION ";
6
+
7
+ /**
8
+ * Loaded into headless `pi --mode rpc` children spawned by the standalone
9
+ * host. The child has no workflow engine, so this bridge only registers the
10
+ * `workflow` tool and reports every submission to the host over stderr; the
11
+ * host validates against the engine and re-prompts on rejection.
12
+ */
13
+ export default function piWorkflowsRpcBridge(pi: ExtensionAPI) {
14
+ pi.registerTool({
15
+ name: "workflow",
16
+ label: "Workflow",
17
+ description: [
18
+ "Submit the output for the pending workflow step with action submit.",
19
+ "Only call this tool when a workflow step contract in the conversation asks you to.",
20
+ "Pass the exact step id from the contract and your result as the output.",
21
+ ].join(" "),
22
+ parameters: Type.Object(
23
+ {
24
+ action: StringEnum(["submit"] as const),
25
+ step: Type.String({ description: "Step id from the workflow step contract" }),
26
+ attempt: Type.String({ description: "Attempt id from the workflow step contract" }),
27
+ output: Type.Unknown({ description: "Step output matching the expected shape" }),
28
+ },
29
+ { additionalProperties: false },
30
+ ),
31
+ async execute(_toolCallId, params) {
32
+ process.stderr.write(`${RPC_SUBMISSION_PREFIX}${JSON.stringify(params)}\n`);
33
+ return {
34
+ content: [
35
+ {
36
+ type: "text",
37
+ text: "Submission recorded. Continue only when the workflow sends the next step.",
38
+ },
39
+ ],
40
+ details: {},
41
+ };
42
+ },
43
+ });
44
+ }
@@ -0,0 +1,299 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import type {
5
+ AgentStepExecutor,
6
+ AgentStepRequest,
7
+ AgentStepSubmission,
8
+ } from "../workflows/types.js";
9
+ import type { HostProcessRegistry } from "./processes.js";
10
+ import { RPC_SUBMISSION_PREFIX } from "./rpc-bridge.js";
11
+
12
+ // Production resolves the compiled .js; tests and dev checkouts load the .ts
13
+ // source, which pi compiles itself when loading extensions.
14
+ const BRIDGE_PATH = ["./rpc-bridge.js", "./rpc-bridge.ts"]
15
+ .map((candidate) => fileURLToPath(new URL(candidate, import.meta.url)))
16
+ .find((candidate) => fs.existsSync(candidate));
17
+ const ABORT_GRACE_MS = 3_000;
18
+
19
+ type StepSubmission = {
20
+ action: "submit";
21
+ step: string;
22
+ attempt: string;
23
+ output: unknown;
24
+ };
25
+
26
+ export type RpcStepExecutorOptions = {
27
+ cwd: string;
28
+ registry: HostProcessRegistry;
29
+ /** Absolute path to the pi binary; defaults to the installed `pi`. */
30
+ piBin?: string;
31
+ /** Extra environment for the child; the host's environment is inherited. */
32
+ env?: Record<string, string>;
33
+ /** Extra pi arguments, for example a model override. */
34
+ piArgs?: string[];
35
+ };
36
+
37
+ /**
38
+ * Runs agent steps in a headless `pi --mode rpc` child. One child serves one
39
+ * workflow run. The rpc-bridge extension inside the child registers the
40
+ * `workflow` tool and reports submissions over stderr; this executor
41
+ * validates them through `request.accept` and re-prompts on rejection, so
42
+ * the model sees the same tool contract as an in-session run.
43
+ */
44
+ export class RpcStepExecutor implements AgentStepExecutor {
45
+ private readonly options: RpcStepExecutorOptions;
46
+ private child: ChildProcess | null = null;
47
+ private childExited: { code: number | null; signal: string | null } | null = null;
48
+ private stderrBuffer = "";
49
+ private submissions: StepSubmission[] = [];
50
+ private submissionWaiters: Array<() => void> = [];
51
+ private stdoutBuffer = "";
52
+
53
+ constructor(options: RpcStepExecutorOptions) {
54
+ this.options = options;
55
+ }
56
+
57
+ async runAgentStep(request: AgentStepRequest, signal: AbortSignal): Promise<AgentStepSubmission> {
58
+ this.ensureStarted();
59
+ let prompt = request.prompt;
60
+ for (;;) {
61
+ throwIfAborted(signal);
62
+ const submission = await this.promptForSubmission(prompt, request, signal);
63
+ const accepted = await request.accept(submission.output);
64
+ if (accepted.ok) {
65
+ return { output: accepted.value };
66
+ }
67
+ prompt = [
68
+ `The workflow rejected your submission for step ${JSON.stringify(request.contract.nodeId)}:`,
69
+ accepted.error,
70
+ "",
71
+ "Call the `workflow` tool again with a corrected output.",
72
+ ].join("\n");
73
+ }
74
+ }
75
+
76
+ /** Stop the child's whole process group, then unregister it. */
77
+ async close(): Promise<void> {
78
+ const child = this.child;
79
+ this.child = null;
80
+ if (child === null) {
81
+ return;
82
+ }
83
+ const pid = child.pid;
84
+ if (this.childExited === null) {
85
+ try {
86
+ child.stdin?.end();
87
+ } catch {
88
+ // The pipe may already be closed.
89
+ }
90
+ // The child spawns in its own group; tool calls can leave
91
+ // grandchildren that leader-only signals would orphan forever.
92
+ if (pid !== undefined) {
93
+ killGroup(pid, "SIGTERM");
94
+ } else {
95
+ child.kill("SIGTERM");
96
+ }
97
+ const exited = await waitForExit(child, ABORT_GRACE_MS);
98
+ if (!exited) {
99
+ if (pid !== undefined) {
100
+ killGroup(pid, "SIGKILL");
101
+ } else {
102
+ child.kill("SIGKILL");
103
+ }
104
+ }
105
+ }
106
+ if (pid !== undefined) {
107
+ this.options.registry.unregister(pid);
108
+ }
109
+ }
110
+
111
+ private currentExit(): { code: number | null; signal: string | null } | null {
112
+ return this.childExited;
113
+ }
114
+
115
+ private ensureStarted(): void {
116
+ if (this.child !== null) {
117
+ const exited = this.currentExit();
118
+ if (exited !== null) {
119
+ throw new Error(
120
+ `Headless pi session exited (code ${exited.code}, signal ${exited.signal})`,
121
+ );
122
+ }
123
+ return;
124
+ }
125
+ if (BRIDGE_PATH === undefined) {
126
+ throw new Error("The pi-workflows rpc-bridge extension is missing from this installation");
127
+ }
128
+ const piBin = this.options.piBin ?? "pi";
129
+ const child = spawn(
130
+ piBin,
131
+ [
132
+ "--mode",
133
+ "rpc",
134
+ "--no-session",
135
+ // Isolation is required: an installed pi-workflows extension would
136
+ // otherwise load beside the bridge, register a competing workflow
137
+ // tool, and start its own resume and controller workers.
138
+ "--no-extensions",
139
+ "-e",
140
+ BRIDGE_PATH,
141
+ ...(this.options.piArgs ?? []),
142
+ ],
143
+ {
144
+ cwd: this.options.cwd,
145
+ env: { ...process.env, ...this.options.env },
146
+ stdio: ["pipe", "pipe", "pipe"],
147
+ // Own process group: the host kills groups, never individual PIDs,
148
+ // so a killed host cannot leave an orphaned agent working.
149
+ detached: true,
150
+ },
151
+ );
152
+ this.child = child;
153
+ if (child.pid !== undefined) {
154
+ this.options.registry.register(child.pid);
155
+ }
156
+ child.on("exit", (code, signal) => {
157
+ this.childExited = { code, signal };
158
+ if (child.pid !== undefined) {
159
+ this.options.registry.unregister(child.pid);
160
+ }
161
+ this.wakeSubmissionWaiters();
162
+ });
163
+ // A failed spawn (missing binary, EACCES) emits error instead of exit;
164
+ // without a listener it would crash the host as an uncaught exception.
165
+ child.on("error", (error) => {
166
+ this.childExited = { code: null, signal: error.message };
167
+ this.wakeSubmissionWaiters();
168
+ });
169
+ child.stdin?.on("error", () => {
170
+ // Writes racing child exit surface asynchronously as EPIPE; the step
171
+ // fails through the exit path instead of an uncaught stream error.
172
+ });
173
+ child.stderr?.on("data", (chunk: Buffer) => {
174
+ this.stderrBuffer += chunk.toString("utf8");
175
+ const lines = this.stderrBuffer.split("\n");
176
+ this.stderrBuffer = lines.pop() ?? "";
177
+ for (const line of lines) {
178
+ if (!line.startsWith(RPC_SUBMISSION_PREFIX)) {
179
+ continue;
180
+ }
181
+ try {
182
+ const parsed = JSON.parse(line.slice(RPC_SUBMISSION_PREFIX.length)) as StepSubmission;
183
+ if (
184
+ parsed.action === "submit" &&
185
+ typeof parsed.step === "string" &&
186
+ typeof parsed.attempt === "string"
187
+ ) {
188
+ this.submissions.push(parsed);
189
+ }
190
+ } catch {
191
+ // A malformed marker line is ignored; the step times out instead.
192
+ }
193
+ }
194
+ this.wakeSubmissionWaiters();
195
+ });
196
+ // stdout carries the RPC event stream. The submission channel is stderr,
197
+ // so stdout is only drained to keep the pipe from blocking.
198
+ child.stdout?.on("data", (chunk: Buffer) => {
199
+ this.stdoutBuffer = (this.stdoutBuffer + chunk.toString("utf8")).slice(-64 * 1024);
200
+ });
201
+ }
202
+
203
+ private async promptForSubmission(
204
+ prompt: string,
205
+ request: AgentStepRequest,
206
+ signal: AbortSignal,
207
+ ): Promise<StepSubmission> {
208
+ const child = this.child;
209
+ if (child === null || this.childExited !== null) {
210
+ throw new Error("Headless pi session is not running");
211
+ }
212
+ const matching = this.takeMatchingSubmission(request);
213
+ if (matching !== undefined) {
214
+ return matching;
215
+ }
216
+ child.stdin?.write(`${JSON.stringify({ type: "prompt", message: prompt })}\n`);
217
+ for (;;) {
218
+ throwIfAborted(signal);
219
+ const exited = this.currentExit();
220
+ if (exited !== null) {
221
+ throw new Error(`Headless pi session exited mid-step (code ${exited.code})`);
222
+ }
223
+ const found = this.takeMatchingSubmission(request);
224
+ if (found !== undefined) {
225
+ return found;
226
+ }
227
+ await new Promise<void>((resolve, reject) => {
228
+ const waiter = () => {
229
+ signal.removeEventListener("abort", onAbort);
230
+ resolve();
231
+ };
232
+ const onAbort = () => {
233
+ this.sendAbortQuietly();
234
+ reject(signal.reason ?? new Error("Workflow step aborted"));
235
+ };
236
+ this.submissionWaiters.push(waiter);
237
+ signal.addEventListener("abort", onAbort, { once: true });
238
+ });
239
+ }
240
+ }
241
+
242
+ private takeMatchingSubmission(request: AgentStepRequest): StepSubmission | undefined {
243
+ const index = this.submissions.findIndex(
244
+ (candidate) =>
245
+ candidate.step === request.contract.nodeId &&
246
+ candidate.attempt === request.contract.attemptId,
247
+ );
248
+ return index === -1 ? undefined : (this.submissions.splice(index, 1)[0] as StepSubmission);
249
+ }
250
+
251
+ private sendAbortQuietly(): void {
252
+ try {
253
+ this.child?.stdin?.write(`${JSON.stringify({ type: "abort" })}\n`);
254
+ } catch {
255
+ // Best-effort.
256
+ }
257
+ }
258
+
259
+ private wakeSubmissionWaiters(): void {
260
+ const waiters = this.submissionWaiters;
261
+ this.submissionWaiters = [];
262
+ for (const waiter of waiters) {
263
+ waiter();
264
+ }
265
+ }
266
+ }
267
+
268
+ function killGroup(pid: number, signal: NodeJS.Signals): void {
269
+ try {
270
+ process.kill(-pid, signal);
271
+ return;
272
+ } catch {
273
+ // Group signals are unsupported here; the leader still needs it.
274
+ }
275
+ try {
276
+ process.kill(pid, signal);
277
+ } catch {
278
+ // Already gone.
279
+ }
280
+ }
281
+
282
+ function throwIfAborted(signal: AbortSignal): void {
283
+ if (signal.aborted) {
284
+ throw (signal.reason as unknown) ?? new Error("Workflow step aborted");
285
+ }
286
+ }
287
+
288
+ async function waitForExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
289
+ if (child.exitCode !== null || child.signalCode !== null) {
290
+ return true;
291
+ }
292
+ return await new Promise<boolean>((resolve) => {
293
+ const timer = setTimeout(() => resolve(false), timeoutMs);
294
+ child.once("exit", () => {
295
+ clearTimeout(timer);
296
+ resolve(true);
297
+ });
298
+ });
299
+ }