@osolmaz/pi-workflows 0.1.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 (171) hide show
  1. package/README.md +107 -26
  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.d.ts +14 -1
  51. package/dist/extension/executor.js +12 -2
  52. package/dist/extension/executor.js.map +1 -1
  53. package/dist/extension/index.d.ts +7 -0
  54. package/dist/extension/index.js +1118 -89
  55. package/dist/extension/index.js.map +1 -1
  56. package/dist/extension/recorder.d.ts +91 -0
  57. package/dist/extension/recorder.js +537 -0
  58. package/dist/extension/recorder.js.map +1 -0
  59. package/dist/extension/session-events.d.ts +134 -0
  60. package/dist/extension/session-events.js +60 -0
  61. package/dist/extension/session-events.js.map +1 -0
  62. package/dist/extension/widget.js +25 -24
  63. package/dist/extension/widget.js.map +1 -1
  64. package/dist/extension/workflow-tool.d.ts +28 -0
  65. package/dist/extension/workflow-tool.js +33 -0
  66. package/dist/extension/workflow-tool.js.map +1 -0
  67. package/dist/host/processes.d.ts +24 -0
  68. package/dist/host/processes.js +114 -0
  69. package/dist/host/processes.js.map +1 -0
  70. package/dist/host/rpc-bridge.d.ts +9 -0
  71. package/dist/host/rpc-bridge.js +39 -0
  72. package/dist/host/rpc-bridge.js.map +1 -0
  73. package/dist/host/rpc-executor.d.ts +38 -0
  74. package/dist/host/rpc-executor.js +254 -0
  75. package/dist/host/rpc-executor.js.map +1 -0
  76. package/dist/host/runner.d.ts +49 -0
  77. package/dist/host/runner.js +350 -0
  78. package/dist/host/runner.js.map +1 -0
  79. package/dist/render/canvas.d.ts +1 -1
  80. package/dist/render/canvas.js +5 -0
  81. package/dist/render/canvas.js.map +1 -1
  82. package/dist/render/graph-render.d.ts +5 -0
  83. package/dist/render/graph-render.js +211 -48
  84. package/dist/render/graph-render.js.map +1 -1
  85. package/dist/viewer/cli.d.ts +7 -3
  86. package/dist/viewer/cli.js +150 -19
  87. package/dist/viewer/cli.js.map +1 -1
  88. package/dist/viewer/render.js +19 -3
  89. package/dist/viewer/render.js.map +1 -1
  90. package/dist/viewer/session-reducer.d.ts +45 -0
  91. package/dist/viewer/session-reducer.js +266 -0
  92. package/dist/viewer/session-reducer.js.map +1 -0
  93. package/dist/workflows/artifacts.d.ts +40 -0
  94. package/dist/workflows/artifacts.js +155 -0
  95. package/dist/workflows/artifacts.js.map +1 -0
  96. package/dist/workflows/engine.d.ts +38 -0
  97. package/dist/workflows/engine.js +281 -20
  98. package/dist/workflows/engine.js.map +1 -1
  99. package/dist/workflows/errors.d.ts +23 -0
  100. package/dist/workflows/errors.js +38 -0
  101. package/dist/workflows/errors.js.map +1 -1
  102. package/dist/workflows/graph.js +0 -5
  103. package/dist/workflows/graph.js.map +1 -1
  104. package/dist/workflows/index.d.ts +3 -2
  105. package/dist/workflows/index.js +2 -1
  106. package/dist/workflows/index.js.map +1 -1
  107. package/dist/workflows/loader.d.ts +5 -3
  108. package/dist/workflows/loader.js +10 -1
  109. package/dist/workflows/loader.js.map +1 -1
  110. package/dist/workflows/schema.js +1 -1
  111. package/dist/workflows/schema.js.map +1 -1
  112. package/dist/workflows/store.d.ts +98 -10
  113. package/dist/workflows/store.js +921 -46
  114. package/dist/workflows/store.js.map +1 -1
  115. package/dist/workflows/types.d.ts +136 -3
  116. package/docs/CONTROLLERS.md +215 -0
  117. package/docs/development.md +51 -24
  118. package/docs/live-replay-protocol.md +155 -0
  119. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  120. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  121. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  122. package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
  123. package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
  124. package/docs/plans/session-event-replay-implementation-plan.md +494 -0
  125. package/docs/plans/tui-viewer-implementation-plan.md +64 -0
  126. package/docs/run-bundles.md +359 -55
  127. package/docs/session-event-journal.md +470 -0
  128. package/docs/tui-viewer.md +218 -0
  129. package/docs/workflows.md +131 -11
  130. package/examples/controllers/pull-request.controller.ts +215 -0
  131. package/package.json +11 -2
  132. package/src/builtins/monitor.workflow.ts +278 -0
  133. package/src/controllers/conditions.ts +110 -0
  134. package/src/controllers/definition.ts +65 -0
  135. package/src/controllers/effects.ts +123 -0
  136. package/src/controllers/errors.ts +27 -0
  137. package/src/controllers/index.ts +90 -0
  138. package/src/controllers/json.ts +62 -0
  139. package/src/controllers/loader.ts +104 -0
  140. package/src/controllers/manager.ts +533 -0
  141. package/src/controllers/results.ts +46 -0
  142. package/src/controllers/sqlite.ts +1427 -0
  143. package/src/controllers/store.ts +160 -0
  144. package/src/controllers/types.ts +183 -0
  145. package/src/controllers/workflow-engine-scheduler.ts +145 -0
  146. package/src/controllers/workflows.ts +152 -0
  147. package/src/extension/controller-host.ts +163 -0
  148. package/src/extension/executor.ts +29 -2
  149. package/src/extension/index.ts +1315 -109
  150. package/src/extension/recorder.ts +662 -0
  151. package/src/extension/session-events.ts +119 -0
  152. package/src/extension/widget.ts +26 -24
  153. package/src/extension/workflow-tool.ts +59 -0
  154. package/src/host/processes.ts +119 -0
  155. package/src/host/rpc-bridge.ts +44 -0
  156. package/src/host/rpc-executor.ts +299 -0
  157. package/src/host/runner.ts +406 -0
  158. package/src/render/canvas.ts +19 -1
  159. package/src/render/graph-render.ts +277 -44
  160. package/src/viewer/cli.ts +167 -21
  161. package/src/viewer/render.ts +21 -3
  162. package/src/viewer/session-reducer.ts +347 -0
  163. package/src/workflows/artifacts.ts +188 -0
  164. package/src/workflows/engine.ts +365 -19
  165. package/src/workflows/errors.ts +45 -0
  166. package/src/workflows/graph.ts +0 -5
  167. package/src/workflows/index.ts +15 -0
  168. package/src/workflows/loader.ts +13 -3
  169. package/src/workflows/schema.ts +1 -1
  170. package/src/workflows/store.ts +1157 -48
  171. package/src/workflows/types.ts +151 -3
@@ -0,0 +1,254 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { RPC_SUBMISSION_PREFIX } from "./rpc-bridge.js";
5
+ // Production resolves the compiled .js; tests and dev checkouts load the .ts
6
+ // source, which pi compiles itself when loading extensions.
7
+ const BRIDGE_PATH = ["./rpc-bridge.js", "./rpc-bridge.ts"]
8
+ .map((candidate) => fileURLToPath(new URL(candidate, import.meta.url)))
9
+ .find((candidate) => fs.existsSync(candidate));
10
+ const ABORT_GRACE_MS = 3_000;
11
+ /**
12
+ * Runs agent steps in a headless `pi --mode rpc` child. One child serves one
13
+ * workflow run. The rpc-bridge extension inside the child registers the
14
+ * `workflow` tool and reports submissions over stderr; this executor
15
+ * validates them through `request.accept` and re-prompts on rejection, so
16
+ * the model sees the same tool contract as an in-session run.
17
+ */
18
+ export class RpcStepExecutor {
19
+ options;
20
+ child = null;
21
+ childExited = null;
22
+ stderrBuffer = "";
23
+ submissions = [];
24
+ submissionWaiters = [];
25
+ stdoutBuffer = "";
26
+ constructor(options) {
27
+ this.options = options;
28
+ }
29
+ async runAgentStep(request, signal) {
30
+ this.ensureStarted();
31
+ let prompt = request.prompt;
32
+ for (;;) {
33
+ throwIfAborted(signal);
34
+ const submission = await this.promptForSubmission(prompt, request, signal);
35
+ const accepted = await request.accept(submission.output);
36
+ if (accepted.ok) {
37
+ return { output: accepted.value };
38
+ }
39
+ prompt = [
40
+ `The workflow rejected your submission for step ${JSON.stringify(request.contract.nodeId)}:`,
41
+ accepted.error,
42
+ "",
43
+ "Call the `workflow` tool again with a corrected output.",
44
+ ].join("\n");
45
+ }
46
+ }
47
+ /** Stop the child's whole process group, then unregister it. */
48
+ async close() {
49
+ const child = this.child;
50
+ this.child = null;
51
+ if (child === null) {
52
+ return;
53
+ }
54
+ const pid = child.pid;
55
+ if (this.childExited === null) {
56
+ try {
57
+ child.stdin?.end();
58
+ }
59
+ catch {
60
+ // The pipe may already be closed.
61
+ }
62
+ // The child spawns in its own group; tool calls can leave
63
+ // grandchildren that leader-only signals would orphan forever.
64
+ if (pid !== undefined) {
65
+ killGroup(pid, "SIGTERM");
66
+ }
67
+ else {
68
+ child.kill("SIGTERM");
69
+ }
70
+ const exited = await waitForExit(child, ABORT_GRACE_MS);
71
+ if (!exited) {
72
+ if (pid !== undefined) {
73
+ killGroup(pid, "SIGKILL");
74
+ }
75
+ else {
76
+ child.kill("SIGKILL");
77
+ }
78
+ }
79
+ }
80
+ if (pid !== undefined) {
81
+ this.options.registry.unregister(pid);
82
+ }
83
+ }
84
+ currentExit() {
85
+ return this.childExited;
86
+ }
87
+ ensureStarted() {
88
+ if (this.child !== null) {
89
+ const exited = this.currentExit();
90
+ if (exited !== null) {
91
+ throw new Error(`Headless pi session exited (code ${exited.code}, signal ${exited.signal})`);
92
+ }
93
+ return;
94
+ }
95
+ if (BRIDGE_PATH === undefined) {
96
+ throw new Error("The pi-workflows rpc-bridge extension is missing from this installation");
97
+ }
98
+ const piBin = this.options.piBin ?? "pi";
99
+ const child = spawn(piBin, [
100
+ "--mode",
101
+ "rpc",
102
+ "--no-session",
103
+ // Isolation is required: an installed pi-workflows extension would
104
+ // otherwise load beside the bridge, register a competing workflow
105
+ // tool, and start its own resume and controller workers.
106
+ "--no-extensions",
107
+ "-e",
108
+ BRIDGE_PATH,
109
+ ...(this.options.piArgs ?? []),
110
+ ], {
111
+ cwd: this.options.cwd,
112
+ env: { ...process.env, ...this.options.env },
113
+ stdio: ["pipe", "pipe", "pipe"],
114
+ // Own process group: the host kills groups, never individual PIDs,
115
+ // so a killed host cannot leave an orphaned agent working.
116
+ detached: true,
117
+ });
118
+ this.child = child;
119
+ if (child.pid !== undefined) {
120
+ this.options.registry.register(child.pid);
121
+ }
122
+ child.on("exit", (code, signal) => {
123
+ this.childExited = { code, signal };
124
+ if (child.pid !== undefined) {
125
+ this.options.registry.unregister(child.pid);
126
+ }
127
+ this.wakeSubmissionWaiters();
128
+ });
129
+ // A failed spawn (missing binary, EACCES) emits error instead of exit;
130
+ // without a listener it would crash the host as an uncaught exception.
131
+ child.on("error", (error) => {
132
+ this.childExited = { code: null, signal: error.message };
133
+ this.wakeSubmissionWaiters();
134
+ });
135
+ child.stdin?.on("error", () => {
136
+ // Writes racing child exit surface asynchronously as EPIPE; the step
137
+ // fails through the exit path instead of an uncaught stream error.
138
+ });
139
+ child.stderr?.on("data", (chunk) => {
140
+ this.stderrBuffer += chunk.toString("utf8");
141
+ const lines = this.stderrBuffer.split("\n");
142
+ this.stderrBuffer = lines.pop() ?? "";
143
+ for (const line of lines) {
144
+ if (!line.startsWith(RPC_SUBMISSION_PREFIX)) {
145
+ continue;
146
+ }
147
+ try {
148
+ const parsed = JSON.parse(line.slice(RPC_SUBMISSION_PREFIX.length));
149
+ if (parsed.action === "submit" &&
150
+ typeof parsed.step === "string" &&
151
+ typeof parsed.attempt === "string") {
152
+ this.submissions.push(parsed);
153
+ }
154
+ }
155
+ catch {
156
+ // A malformed marker line is ignored; the step times out instead.
157
+ }
158
+ }
159
+ this.wakeSubmissionWaiters();
160
+ });
161
+ // stdout carries the RPC event stream. The submission channel is stderr,
162
+ // so stdout is only drained to keep the pipe from blocking.
163
+ child.stdout?.on("data", (chunk) => {
164
+ this.stdoutBuffer = (this.stdoutBuffer + chunk.toString("utf8")).slice(-64 * 1024);
165
+ });
166
+ }
167
+ async promptForSubmission(prompt, request, signal) {
168
+ const child = this.child;
169
+ if (child === null || this.childExited !== null) {
170
+ throw new Error("Headless pi session is not running");
171
+ }
172
+ const matching = this.takeMatchingSubmission(request);
173
+ if (matching !== undefined) {
174
+ return matching;
175
+ }
176
+ child.stdin?.write(`${JSON.stringify({ type: "prompt", message: prompt })}\n`);
177
+ for (;;) {
178
+ throwIfAborted(signal);
179
+ const exited = this.currentExit();
180
+ if (exited !== null) {
181
+ throw new Error(`Headless pi session exited mid-step (code ${exited.code})`);
182
+ }
183
+ const found = this.takeMatchingSubmission(request);
184
+ if (found !== undefined) {
185
+ return found;
186
+ }
187
+ await new Promise((resolve, reject) => {
188
+ const waiter = () => {
189
+ signal.removeEventListener("abort", onAbort);
190
+ resolve();
191
+ };
192
+ const onAbort = () => {
193
+ this.sendAbortQuietly();
194
+ reject(signal.reason ?? new Error("Workflow step aborted"));
195
+ };
196
+ this.submissionWaiters.push(waiter);
197
+ signal.addEventListener("abort", onAbort, { once: true });
198
+ });
199
+ }
200
+ }
201
+ takeMatchingSubmission(request) {
202
+ const index = this.submissions.findIndex((candidate) => candidate.step === request.contract.nodeId &&
203
+ candidate.attempt === request.contract.attemptId);
204
+ return index === -1 ? undefined : this.submissions.splice(index, 1)[0];
205
+ }
206
+ sendAbortQuietly() {
207
+ try {
208
+ this.child?.stdin?.write(`${JSON.stringify({ type: "abort" })}\n`);
209
+ }
210
+ catch {
211
+ // Best-effort.
212
+ }
213
+ }
214
+ wakeSubmissionWaiters() {
215
+ const waiters = this.submissionWaiters;
216
+ this.submissionWaiters = [];
217
+ for (const waiter of waiters) {
218
+ waiter();
219
+ }
220
+ }
221
+ }
222
+ function killGroup(pid, signal) {
223
+ try {
224
+ process.kill(-pid, signal);
225
+ return;
226
+ }
227
+ catch {
228
+ // Group signals are unsupported here; the leader still needs it.
229
+ }
230
+ try {
231
+ process.kill(pid, signal);
232
+ }
233
+ catch {
234
+ // Already gone.
235
+ }
236
+ }
237
+ function throwIfAborted(signal) {
238
+ if (signal.aborted) {
239
+ throw signal.reason ?? new Error("Workflow step aborted");
240
+ }
241
+ }
242
+ async function waitForExit(child, timeoutMs) {
243
+ if (child.exitCode !== null || child.signalCode !== null) {
244
+ return true;
245
+ }
246
+ return await new Promise((resolve) => {
247
+ const timer = setTimeout(() => resolve(false), timeoutMs);
248
+ child.once("exit", () => {
249
+ clearTimeout(timer);
250
+ resolve(true);
251
+ });
252
+ });
253
+ }
254
+ //# sourceMappingURL=rpc-executor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rpc-executor.js","sourceRoot":"","sources":["../../src/host/rpc-executor.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,49 @@
1
+ import { HostProcessRegistry } from "./processes.js";
2
+ export type WorkflowHostOptions = {
3
+ cwd: string;
4
+ runnerId?: string;
5
+ /** Explicit store path; defaults to the project-scoped controller store. */
6
+ storeFile?: string;
7
+ /** Explicit run-bundle root; defaults to the shared runs directory. */
8
+ runsDir?: string;
9
+ registry?: HostProcessRegistry;
10
+ piArgs?: string[];
11
+ /** Extra environment for headless children (for example a test provider). */
12
+ env?: Record<string, string>;
13
+ /** Poll interval for the claim loop; tests use a faster cadence. */
14
+ claimPollMs?: number;
15
+ onLog?: (message: string) => void;
16
+ };
17
+ /**
18
+ * The always-on runner: claims parked workflow runs and reconciles durable
19
+ * controllers without a Pi session. Conversation nodes execute in headless
20
+ * `pi --mode rpc` children. Everything the host does is recoverable: claims
21
+ * expire, bundles fence stale writers, and child processes are reaped by
22
+ * the next host.
23
+ */
24
+ export declare class WorkflowHost {
25
+ private readonly options;
26
+ private readonly runnerId;
27
+ private readonly registry;
28
+ private readonly store;
29
+ private readonly childRunStore;
30
+ private manager;
31
+ private pollTimer;
32
+ private readonly activeRuns;
33
+ private readonly schedulerExecutors;
34
+ /** Runs whose resume refused (edited source); skipped until a host restart. */
35
+ private readonly skippedRuns;
36
+ private stopping;
37
+ private readonly stateDir;
38
+ constructor(options: WorkflowHostOptions);
39
+ private log;
40
+ /** Take the advisory lock, reap orphans, and start claiming. */
41
+ start(): Promise<void>;
42
+ /** Drain: stop claiming, park in-flight runs, stop controllers, kill children. */
43
+ stop(): Promise<void>;
44
+ private readonly parkedEngines;
45
+ private claimOnce;
46
+ private runClaimed;
47
+ private failUnresumable;
48
+ private recordEvent;
49
+ }
@@ -0,0 +1,350 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { ControllerManager, loadDiscoveredControllers, projectControllerStorePath, SqliteControllerStore, WorkflowEngineScheduler, } from "../controllers/index.js";
5
+ import { WorkflowEngine } from "../workflows/engine.js";
6
+ import { ClaimLostError, errorMessage, isClaimLostError, WorkflowSourceChangedError, } from "../workflows/errors.js";
7
+ import { hashWorkflowSource, loadWorkflowFile, resolveWorkflowRef } from "../workflows/loader.js";
8
+ import { WorkflowRunStore } from "../workflows/store.js";
9
+ import { HostProcessRegistry } from "./processes.js";
10
+ import { RpcStepExecutor } from "./rpc-executor.js";
11
+ const CLAIM_POLL_MS = 2_000;
12
+ const RUN_CLAIM_LEASE_MS = 30_000;
13
+ const RUN_CLAIM_RENEW_MS = 10_000;
14
+ /**
15
+ * The always-on runner: claims parked workflow runs and reconciles durable
16
+ * controllers without a Pi session. Conversation nodes execute in headless
17
+ * `pi --mode rpc` children. Everything the host does is recoverable: claims
18
+ * expire, bundles fence stale writers, and child processes are reaped by
19
+ * the next host.
20
+ */
21
+ export class WorkflowHost {
22
+ options;
23
+ runnerId;
24
+ registry;
25
+ store;
26
+ childRunStore;
27
+ manager = null;
28
+ pollTimer = null;
29
+ activeRuns = new Map();
30
+ schedulerExecutors = new Map();
31
+ /** Runs whose resume refused (edited source); skipped until a host restart. */
32
+ skippedRuns = new Set();
33
+ stopping = false;
34
+ stateDir;
35
+ constructor(options) {
36
+ this.options = options;
37
+ this.runnerId = options.runnerId ?? `host-${randomUUID().slice(0, 8)}`;
38
+ this.store = new SqliteControllerStore(options.storeFile ?? projectControllerStorePath(options.cwd));
39
+ this.stateDir = path.dirname(this.store.filePath);
40
+ this.registry = options.registry ?? new HostProcessRegistry(this.stateDir);
41
+ this.childRunStore = new WorkflowRunStore(options.runsDir ?? process.env.PI_WORKFLOWS_RUNS_DIR ?? undefined);
42
+ }
43
+ log(message) {
44
+ this.options.onLog?.(message);
45
+ }
46
+ /** Take the advisory lock, reap orphans, and start claiming. */
47
+ async start() {
48
+ // The lock comes first: a second host must refuse before touching the
49
+ // children registry, or it would kill the live host's child processes
50
+ // as supposed orphans.
51
+ acquireHostLock(this.stateDir, this.runnerId, this.options.cwd);
52
+ const reaped = this.registry.reapOrphans();
53
+ if (reaped.length > 0) {
54
+ this.log(`reaped ${reaped.length} orphaned headless session(s): ${reaped.join(", ")}`);
55
+ }
56
+ const definitions = await loadDiscoveredControllers({ cwd: this.options.cwd });
57
+ if (definitions.length > 0) {
58
+ const scheduler = new WorkflowEngineScheduler({
59
+ store: this.childRunStore,
60
+ resolveWorkflow: async (name) => {
61
+ const resolved = await resolveWorkflowRef(name, { cwd: this.options.cwd });
62
+ const workflow = await loadWorkflowFile(resolved.path);
63
+ return { workflow };
64
+ },
65
+ createEngine: () => {
66
+ const executor = new RpcStepExecutor({
67
+ cwd: this.options.cwd,
68
+ registry: this.registry,
69
+ ...(this.options.piArgs !== undefined ? { piArgs: this.options.piArgs } : {}),
70
+ ...(this.options.env !== undefined ? { env: this.options.env } : {}),
71
+ });
72
+ const engine = new WorkflowEngine({ executor, store: this.childRunStore });
73
+ this.schedulerExecutors.set(engine, executor);
74
+ return engine;
75
+ },
76
+ disposeEngine: async (engine) => {
77
+ const executor = this.schedulerExecutors.get(engine);
78
+ if (executor !== undefined) {
79
+ this.schedulerExecutors.delete(engine);
80
+ await executor.close();
81
+ }
82
+ },
83
+ });
84
+ this.manager = new ControllerManager({
85
+ store: this.store,
86
+ controllers: definitions,
87
+ workflowScheduler: scheduler,
88
+ });
89
+ this.manager.start();
90
+ this.log(`controller workers started for ${definitions.length} controller(s)`);
91
+ }
92
+ this.pollTimer = setInterval(() => {
93
+ this.claimOnce();
94
+ }, this.options.claimPollMs ?? CLAIM_POLL_MS);
95
+ this.pollTimer.unref?.();
96
+ this.claimOnce();
97
+ this.log(`host ${this.runnerId} watching ${this.options.cwd}`);
98
+ }
99
+ /** Drain: stop claiming, park in-flight runs, stop controllers, kill children. */
100
+ async stop() {
101
+ if (this.stopping) {
102
+ return;
103
+ }
104
+ this.stopping = true;
105
+ if (this.pollTimer !== null) {
106
+ clearInterval(this.pollTimer);
107
+ this.pollTimer = null;
108
+ }
109
+ const pending = [...this.activeRuns.values()];
110
+ for (const run of this.parkedEngines.splice(0)) {
111
+ run();
112
+ }
113
+ await Promise.allSettled(pending);
114
+ await this.manager?.stop().catch(() => undefined);
115
+ for (const executor of this.schedulerExecutors.values()) {
116
+ await executor.close().catch(() => undefined);
117
+ }
118
+ this.schedulerExecutors.clear();
119
+ this.registry.killAll();
120
+ releaseHostLock(this.stateDir, this.runnerId);
121
+ this.store.close();
122
+ }
123
+ parkedEngines = [];
124
+ claimOnce() {
125
+ if (this.stopping || this.activeRuns.size > 0) {
126
+ return;
127
+ }
128
+ let claimed;
129
+ try {
130
+ claimed = this.store.claimNextWorkflowRun({
131
+ runnerId: this.runnerId,
132
+ claimToken: randomUUID(),
133
+ leaseMs: RUN_CLAIM_LEASE_MS,
134
+ excludeRunIds: [...this.skippedRuns],
135
+ });
136
+ }
137
+ catch (error) {
138
+ // Store contention or corruption must not kill the host's loop.
139
+ this.log(`claim failed, retrying shortly: ${errorMessage(error)}`);
140
+ return;
141
+ }
142
+ if (claimed === undefined) {
143
+ return;
144
+ }
145
+ const task = this.runClaimed(claimed).finally(() => {
146
+ this.activeRuns.delete(claimed.runId);
147
+ });
148
+ this.activeRuns.set(claimed.runId, task);
149
+ }
150
+ async runClaimed(record) {
151
+ const claimToken = record.claimToken;
152
+ const runId = record.runId;
153
+ this.log(`resuming ${record.workflowRef} run ${runId}`);
154
+ let workflow;
155
+ let workflowHash;
156
+ try {
157
+ workflow = await loadWorkflowFile(record.workflowPath);
158
+ workflowHash = await hashWorkflowSource(record.workflowPath);
159
+ }
160
+ catch (error) {
161
+ await this.failUnresumable(record, claimToken, errorMessage(error));
162
+ return;
163
+ }
164
+ if (this.stopping) {
165
+ // The drain started during setup: park before anything executes.
166
+ try {
167
+ this.store.parkWorkflowRun({ runId, claimToken });
168
+ }
169
+ catch {
170
+ // Best-effort.
171
+ }
172
+ return;
173
+ }
174
+ const store = this.store;
175
+ const fence = () => {
176
+ if (!store.verifyWorkflowRunClaim({ runId, claimToken })) {
177
+ throw new ClaimLostError(runId);
178
+ }
179
+ };
180
+ const fencedStore = new WorkflowRunStore(this.childRunStore.outputRoot, {
181
+ fenceProvider: () => fence,
182
+ });
183
+ const executor = new RpcStepExecutor({
184
+ cwd: this.options.cwd,
185
+ registry: this.registry,
186
+ ...(this.options.piArgs !== undefined ? { piArgs: this.options.piArgs } : {}),
187
+ ...(this.options.env !== undefined ? { env: this.options.env } : {}),
188
+ });
189
+ const engine = new WorkflowEngine({ executor, store: fencedStore });
190
+ const parkEngine = () => engine.park();
191
+ this.parkedEngines.push(parkEngine);
192
+ const renewTimer = setInterval(() => {
193
+ try {
194
+ if (!store.renewWorkflowRunClaim({ runId, claimToken, leaseMs: RUN_CLAIM_LEASE_MS })) {
195
+ engine.cancel();
196
+ }
197
+ }
198
+ catch {
199
+ // Transient store errors leave fencing to decide ownership.
200
+ }
201
+ }, RUN_CLAIM_RENEW_MS);
202
+ renewTimer.unref?.();
203
+ this.recordEvent(runId, record.workflowRef, "resumed", { runnerId: this.runnerId });
204
+ try {
205
+ const result = await engine.resumeRun(workflow, runId, { workflowHash });
206
+ clearInterval(renewTimer);
207
+ if (result.state.status === "running") {
208
+ // Parked again mid-drain: leave it claimable for the next runner.
209
+ this.store.parkWorkflowRun({ runId, claimToken });
210
+ this.recordEvent(runId, record.workflowRef, "parked", {});
211
+ this.log(`parked ${record.workflowRef} run ${runId}`);
212
+ return;
213
+ }
214
+ this.store.completeWorkflowRun({ runId, claimToken });
215
+ this.recordEvent(runId, record.workflowRef, result.state.status, {
216
+ ...(result.state.error !== undefined ? { error: result.state.error } : {}),
217
+ ...(result.state.waitingOn !== undefined ? { waitingOn: result.state.waitingOn } : {}),
218
+ });
219
+ this.log(`${record.workflowRef} run ${runId} ${result.state.status}`);
220
+ }
221
+ catch (error) {
222
+ clearInterval(renewTimer);
223
+ if (isClaimLostError(error)) {
224
+ this.log(`run ${runId} continues under another runner`);
225
+ return;
226
+ }
227
+ if (error instanceof WorkflowSourceChangedError) {
228
+ // Edited source is a refusal, not a failure: keep the run claimable
229
+ // for a later fix, and stop spinning on it for this host's lifetime.
230
+ try {
231
+ this.store.parkWorkflowRun({ runId, claimToken });
232
+ }
233
+ catch {
234
+ // Best-effort.
235
+ }
236
+ this.skippedRuns.add(runId);
237
+ this.recordEvent(runId, record.workflowRef, "parked", {
238
+ reason: "workflow source changed",
239
+ });
240
+ this.log(`run ${runId} skipped: workflow source changed; revert or force-resume it, then restart the host`);
241
+ return;
242
+ }
243
+ await this.failUnresumable(record, claimToken, errorMessage(error));
244
+ }
245
+ finally {
246
+ const index = this.parkedEngines.indexOf(parkEngine);
247
+ if (index !== -1) {
248
+ this.parkedEngines.splice(index, 1);
249
+ }
250
+ await executor.close().catch(() => undefined);
251
+ }
252
+ }
253
+ async failUnresumable(record, claimToken, message) {
254
+ let actualStatus;
255
+ let heldClaim = false;
256
+ try {
257
+ // The interruption write obeys the same fencing rule as every other
258
+ // bundle write: without a live claim, the current owner decides.
259
+ heldClaim = this.store.verifyWorkflowRunClaim({ runId: record.runId, claimToken });
260
+ if (heldClaim) {
261
+ const bundle = await this.childRunStore.markRunInterrupted(record.runId, message);
262
+ actualStatus = bundle?.state.status;
263
+ }
264
+ }
265
+ catch {
266
+ // The bundle may be unreadable; the queue row still needs closure.
267
+ }
268
+ try {
269
+ this.store.completeWorkflowRun({ runId: record.runId, claimToken });
270
+ }
271
+ catch {
272
+ // Best-effort.
273
+ }
274
+ if (!heldClaim) {
275
+ // Another runner owns the run; its owner reports from here.
276
+ this.log(`run ${record.runId} continues under another runner`);
277
+ return;
278
+ }
279
+ // Report the bundle's real terminal state when the interruption was a
280
+ // no-op (the bundle was already waiting or completed), so the feed
281
+ // stays truthful for sessions syncing from it.
282
+ if (actualStatus !== undefined && actualStatus !== "failed") {
283
+ this.recordEvent(record.runId, record.workflowRef, actualStatus, {});
284
+ }
285
+ else {
286
+ this.recordEvent(record.runId, record.workflowRef, "failed", { error: message });
287
+ }
288
+ this.log(`run ${record.runId} cannot resume: ${message}`);
289
+ }
290
+ recordEvent(runId, workflowRef, type, payload) {
291
+ try {
292
+ this.store.recordRunEvent({ runId, workflowRef, type, payload, runnerId: this.runnerId });
293
+ }
294
+ catch {
295
+ // The event feed is best-effort.
296
+ }
297
+ }
298
+ }
299
+ function hostLockPath(stateDir) {
300
+ return path.join(stateDir, "host.lock");
301
+ }
302
+ /**
303
+ * The advisory lock guards host-versus-host only: the embedded runner in a
304
+ * Pi session does not take it. A second host refuses to start while the
305
+ * recorded PID is alive.
306
+ */
307
+ function acquireHostLock(stateDir, runnerId, cwd) {
308
+ const lockPath = hostLockPath(stateDir);
309
+ const existing = readLock(lockPath);
310
+ if (existing !== null && existing.runnerId !== runnerId && isAlive(existing.pid)) {
311
+ throw new Error(`Another workflow host (pid ${existing.pid}, ${existing.runnerId}) is already running for ${cwd}`);
312
+ }
313
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
314
+ fs.writeFileSync(lockPath, `${JSON.stringify({ pid: process.pid, runnerId, startedAt: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600 });
315
+ }
316
+ function releaseHostLock(stateDir, runnerId) {
317
+ const lockPath = hostLockPath(stateDir);
318
+ const existing = readLock(lockPath);
319
+ if (existing?.runnerId !== runnerId) {
320
+ return;
321
+ }
322
+ try {
323
+ fs.rmSync(lockPath);
324
+ }
325
+ catch {
326
+ // Already gone.
327
+ }
328
+ }
329
+ function readLock(lockPath) {
330
+ try {
331
+ const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8"));
332
+ if (typeof parsed.pid !== "number" || typeof parsed.runnerId !== "string") {
333
+ return null;
334
+ }
335
+ return { pid: parsed.pid, runnerId: parsed.runnerId };
336
+ }
337
+ catch {
338
+ return null;
339
+ }
340
+ }
341
+ function isAlive(pid) {
342
+ try {
343
+ process.kill(pid, 0);
344
+ return true;
345
+ }
346
+ catch {
347
+ return false;
348
+ }
349
+ }
350
+ //# sourceMappingURL=runner.js.map