@osolmaz/pi-workflows 0.13.4 → 0.15.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 (237) hide show
  1. package/README.md +146 -160
  2. package/dist/builtins/autoimplement.workflow.js +3 -1
  3. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  4. package/dist/builtins/change-verification.workflow.js +7 -2
  5. package/dist/builtins/change-verification.workflow.js.map +1 -1
  6. package/dist/builtins/metadata.d.ts +22 -0
  7. package/dist/builtins/metadata.js +10 -0
  8. package/dist/builtins/metadata.js.map +1 -0
  9. package/dist/builtins/monitor.workflow.js +4 -1
  10. package/dist/builtins/monitor.workflow.js.map +1 -1
  11. package/dist/builtins/sanity-check.workflow.js +4 -1
  12. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  13. package/dist/builtins/workspace-preparation.workflow.js +3 -1
  14. package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
  15. package/dist/controllers/index.d.ts +1 -2
  16. package/dist/controllers/index.js +0 -1
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.d.ts +85 -31
  19. package/dist/controllers/sqlite.js +541 -126
  20. package/dist/controllers/sqlite.js.map +1 -1
  21. package/dist/controllers/store.d.ts +1 -0
  22. package/dist/extension/controller-command.d.ts +22 -0
  23. package/dist/extension/controller-command.js +32 -0
  24. package/dist/extension/controller-command.js.map +1 -0
  25. package/dist/extension/index.d.ts +2 -9
  26. package/dist/extension/index.js +876 -2928
  27. package/dist/extension/index.js.map +1 -1
  28. package/dist/extension/message-card.d.ts +13 -0
  29. package/dist/extension/message-card.js +46 -0
  30. package/dist/extension/message-card.js.map +1 -0
  31. package/dist/extension/step-message.d.ts +5 -6
  32. package/dist/extension/step-message.js +41 -52
  33. package/dist/extension/step-message.js.map +1 -1
  34. package/dist/host/child-worker-supervisor.d.ts +50 -0
  35. package/dist/host/child-worker-supervisor.js +159 -0
  36. package/dist/host/child-worker-supervisor.js.map +1 -0
  37. package/dist/host/client.d.ts +48 -0
  38. package/dist/host/client.js +216 -0
  39. package/dist/host/client.js.map +1 -0
  40. package/dist/host/controller-worker-entry.d.ts +2 -0
  41. package/dist/host/controller-worker-entry.js +244 -0
  42. package/dist/host/controller-worker-entry.js.map +1 -0
  43. package/dist/host/controller-worker-protocol.d.ts +36 -0
  44. package/dist/host/controller-worker-protocol.js +49 -0
  45. package/dist/host/controller-worker-protocol.js.map +1 -0
  46. package/dist/host/controller-worker-supervisor.d.ts +21 -0
  47. package/dist/host/controller-worker-supervisor.js +54 -0
  48. package/dist/host/controller-worker-supervisor.js.map +1 -0
  49. package/dist/host/host-entry.d.ts +2 -0
  50. package/dist/host/host-entry.js +23 -0
  51. package/dist/host/host-entry.js.map +1 -0
  52. package/dist/host/processes.d.ts +17 -12
  53. package/dist/host/processes.js +154 -50
  54. package/dist/host/processes.js.map +1 -1
  55. package/dist/host/protocol.d.ts +38 -0
  56. package/dist/host/protocol.js +156 -0
  57. package/dist/host/protocol.js.map +1 -0
  58. package/dist/host/resolver-entry.d.ts +49 -0
  59. package/dist/host/resolver-entry.js +149 -0
  60. package/dist/host/resolver-entry.js.map +1 -0
  61. package/dist/host/rpc-executor.d.ts +14 -3
  62. package/dist/host/rpc-executor.js +63 -30
  63. package/dist/host/rpc-executor.js.map +1 -1
  64. package/dist/host/runner.d.ts +95 -29
  65. package/dist/host/runner.js +2311 -348
  66. package/dist/host/runner.js.map +1 -1
  67. package/dist/host/state.d.ts +174 -0
  68. package/dist/host/state.js +689 -0
  69. package/dist/host/state.js.map +1 -0
  70. package/dist/host/worker-entry.d.ts +10 -0
  71. package/dist/host/worker-entry.js +446 -0
  72. package/dist/host/worker-entry.js.map +1 -0
  73. package/dist/host/worker-protocol.d.ts +31 -0
  74. package/dist/host/worker-protocol.js +122 -0
  75. package/dist/host/worker-protocol.js.map +1 -0
  76. package/dist/host/worker-store.d.ts +80 -0
  77. package/dist/host/worker-store.js +91 -0
  78. package/dist/host/worker-store.js.map +1 -0
  79. package/dist/host/worker-supervisor.d.ts +22 -0
  80. package/dist/host/worker-supervisor.js +54 -0
  81. package/dist/host/worker-supervisor.js.map +1 -0
  82. package/dist/render/canvas.d.ts +2 -1
  83. package/dist/render/canvas.js +40 -15
  84. package/dist/render/canvas.js.map +1 -1
  85. package/dist/render/graph-render.d.ts +7 -3
  86. package/dist/render/graph-render.js +125 -74
  87. package/dist/render/graph-render.js.map +1 -1
  88. package/dist/state/database.js +3 -1
  89. package/dist/state/database.js.map +1 -1
  90. package/dist/state/index.d.ts +1 -0
  91. package/dist/state/index.js +1 -0
  92. package/dist/state/index.js.map +1 -1
  93. package/dist/state/prune.js +36 -10
  94. package/dist/state/prune.js.map +1 -1
  95. package/dist/state/schema.d.ts +1 -1
  96. package/dist/state/schema.js +171 -3
  97. package/dist/state/schema.js.map +1 -1
  98. package/dist/state/viewer.d.ts +46 -0
  99. package/dist/state/viewer.js +249 -0
  100. package/dist/state/viewer.js.map +1 -0
  101. package/dist/viewer/cli.d.ts +1 -1
  102. package/dist/viewer/cli.js +47 -18
  103. package/dist/viewer/cli.js.map +1 -1
  104. package/dist/workflows/composition.js +25 -2
  105. package/dist/workflows/composition.js.map +1 -1
  106. package/dist/workflows/definition.d.ts +3 -1
  107. package/dist/workflows/definition.js +25 -0
  108. package/dist/workflows/definition.js.map +1 -1
  109. package/dist/workflows/engine.d.ts +1 -0
  110. package/dist/workflows/engine.js +157 -42
  111. package/dist/workflows/engine.js.map +1 -1
  112. package/dist/workflows/errors.d.ts +3 -1
  113. package/dist/workflows/errors.js +4 -7
  114. package/dist/workflows/errors.js.map +1 -1
  115. package/dist/workflows/human-decision.d.ts +3 -0
  116. package/dist/workflows/human-decision.js +31 -0
  117. package/dist/workflows/human-decision.js.map +1 -1
  118. package/dist/workflows/index.d.ts +1 -1
  119. package/dist/workflows/index.js +1 -1
  120. package/dist/workflows/index.js.map +1 -1
  121. package/dist/workflows/schema.js +17 -6
  122. package/dist/workflows/schema.js.map +1 -1
  123. package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
  124. package/dist/{viewer → workflows}/session-reducer.js +4 -0
  125. package/dist/workflows/session-reducer.js.map +1 -0
  126. package/dist/workflows/store.d.ts +87 -1
  127. package/dist/workflows/store.js +892 -93
  128. package/dist/workflows/store.js.map +1 -1
  129. package/dist/workflows/tool-input.d.ts +0 -22
  130. package/dist/workflows/tool-input.js +1 -44
  131. package/dist/workflows/tool-input.js.map +1 -1
  132. package/dist/workflows/types.d.ts +36 -4
  133. package/docs/2026-08-25-workflow-follow-ups.md +8 -6
  134. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
  135. package/docs/CONTROLLERS.md +11 -11
  136. package/docs/DEFERRED_TURNS.md +61 -27
  137. package/docs/HUMAN_DECISIONS.md +12 -4
  138. package/docs/SQLITE_STATE.md +42 -8
  139. package/docs/WORKFLOW_HOST.md +452 -0
  140. package/docs/development.md +46 -30
  141. package/docs/live-replay-protocol.md +129 -100
  142. package/docs/plans/2026-08-19-human-decision-gates-plan.md +34 -8
  143. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +399 -0
  144. package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
  145. package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
  146. package/docs/plans/tui-viewer-implementation-plan.md +5 -0
  147. package/docs/tui-viewer.md +18 -3
  148. package/docs/workflows.md +164 -156
  149. package/examples/workflows/command-batch.workflow.ts +2 -0
  150. package/examples/workflows/shell.workflow.ts +2 -1
  151. package/herdr-plugin.toml +1 -1
  152. package/package.json +1 -1
  153. package/skills/autodoc/SKILL.md +1 -1
  154. package/skills/autoimplement/SKILL.md +1 -1
  155. package/skills/autoplan/SKILL.md +1 -1
  156. package/skills/pi-workflows/SKILL.md +2 -0
  157. package/src/builtins/autoimplement.workflow.ts +3 -0
  158. package/src/builtins/change-verification.workflow.ts +7 -2
  159. package/src/builtins/metadata.ts +9 -0
  160. package/src/builtins/monitor.workflow.ts +4 -0
  161. package/src/builtins/sanity-check.workflow.ts +4 -0
  162. package/src/builtins/workspace-preparation.workflow.ts +3 -1
  163. package/src/controllers/index.ts +3 -5
  164. package/src/controllers/sqlite.ts +929 -225
  165. package/src/controllers/store.ts +1 -0
  166. package/src/extension/controller-command.ts +45 -0
  167. package/src/extension/index.ts +948 -3506
  168. package/src/extension/message-card.ts +61 -0
  169. package/src/extension/step-message.ts +58 -63
  170. package/src/host/child-worker-supervisor.ts +183 -0
  171. package/src/host/client.ts +293 -0
  172. package/src/host/controller-worker-entry.ts +311 -0
  173. package/src/host/controller-worker-protocol.ts +104 -0
  174. package/src/host/controller-worker-supervisor.ts +79 -0
  175. package/src/host/host-entry.ts +23 -0
  176. package/src/host/processes.ts +171 -54
  177. package/src/host/protocol.ts +196 -0
  178. package/src/host/resolver-entry.ts +241 -0
  179. package/src/host/rpc-executor.ts +76 -34
  180. package/src/host/runner.ts +2813 -422
  181. package/src/host/state.ts +1160 -0
  182. package/src/host/worker-entry.ts +533 -0
  183. package/src/host/worker-protocol.ts +165 -0
  184. package/src/host/worker-store.ts +229 -0
  185. package/src/host/worker-supervisor.ts +74 -0
  186. package/src/render/canvas.ts +44 -10
  187. package/src/render/graph-render.ts +145 -90
  188. package/src/state/database.ts +2 -1
  189. package/src/state/index.ts +14 -0
  190. package/src/state/prune.ts +35 -9
  191. package/src/state/schema.ts +171 -3
  192. package/src/state/viewer.ts +356 -0
  193. package/src/viewer/cli.ts +49 -17
  194. package/src/workflows/composition.ts +36 -2
  195. package/src/workflows/definition.ts +32 -0
  196. package/src/workflows/engine.ts +157 -54
  197. package/src/workflows/errors.ts +11 -2
  198. package/src/workflows/human-decision.ts +49 -0
  199. package/src/workflows/index.ts +2 -0
  200. package/src/workflows/schema.ts +19 -6
  201. package/src/{viewer → workflows}/session-reducer.ts +11 -1
  202. package/src/workflows/store.ts +1316 -108
  203. package/src/workflows/tool-input.ts +2 -60
  204. package/src/workflows/types.ts +32 -4
  205. package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
  206. package/dist/controllers/workflow-engine-scheduler.js +0 -175
  207. package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
  208. package/dist/extension/controller-host.d.ts +0 -48
  209. package/dist/extension/controller-host.js +0 -110
  210. package/dist/extension/controller-host.js.map +0 -1
  211. package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
  212. package/dist/extension/deferred-turn-coordinator.js +0 -143
  213. package/dist/extension/deferred-turn-coordinator.js.map +0 -1
  214. package/dist/extension/deferred-turn.d.ts +0 -44
  215. package/dist/extension/deferred-turn.js +0 -110
  216. package/dist/extension/deferred-turn.js.map +0 -1
  217. package/dist/extension/executor.d.ts +0 -86
  218. package/dist/extension/executor.js +0 -311
  219. package/dist/extension/executor.js.map +0 -1
  220. package/dist/extension/follow-up-coordinator.d.ts +0 -27
  221. package/dist/extension/follow-up-coordinator.js +0 -131
  222. package/dist/extension/follow-up-coordinator.js.map +0 -1
  223. package/dist/extension/recorder.d.ts +0 -84
  224. package/dist/extension/recorder.js +0 -528
  225. package/dist/extension/recorder.js.map +0 -1
  226. package/dist/extension/session-events.d.ts +0 -133
  227. package/dist/extension/session-events.js +0 -61
  228. package/dist/extension/session-events.js.map +0 -1
  229. package/dist/viewer/session-reducer.js.map +0 -1
  230. package/src/controllers/workflow-engine-scheduler.ts +0 -246
  231. package/src/extension/controller-host.ts +0 -167
  232. package/src/extension/deferred-turn-coordinator.ts +0 -171
  233. package/src/extension/deferred-turn.ts +0 -166
  234. package/src/extension/executor.ts +0 -411
  235. package/src/extension/follow-up-coordinator.ts +0 -151
  236. package/src/extension/recorder.ts +0 -655
  237. package/src/extension/session-events.ts +0 -121
@@ -1,452 +1,2415 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { once } from "node:events";
2
3
  import fs from "node:fs";
4
+ import net, {} from "node:net";
3
5
  import path from "node:path";
4
- import { builtinWorkflowCatalog } from "../builtins/catalog.js";
5
- import { ControllerManager, loadDiscoveredControllers, SqliteControllerStore, WorkflowEngineScheduler, } from "../controllers/index.js";
6
- import { workflowStatePath } from "../state/database.js";
7
- import { WorkflowEngine } from "../workflows/engine.js";
8
- import { ClaimLostError, errorMessage, isClaimLostError, WorkflowSourceChangedError, } from "../workflows/errors.js";
9
- import { resolveWorkflowRef, resolveWorkflowSource } from "../workflows/loader.js";
6
+ import { applyStatusPatch } from "../controllers/conditions.js";
7
+ import { ResourceConflictError } from "../controllers/errors.js";
8
+ import { controllerFileStem, controllerSearchDirs, discoverControllers, } from "../controllers/loader.js";
9
+ import { SqliteControllerStore, } from "../controllers/sqlite.js";
10
+ import { ControllerWorkflowCoordinator, } from "../controllers/workflows.js";
11
+ import { StateDatabase, workflowStatePath } from "../state/database.js";
12
+ import { canonicalJson } from "../state/json.js";
13
+ import { recordViewerDeltas } from "../state/viewer.js";
14
+ import { errorMessage } from "../workflows/errors.js";
15
+ import { HumanDecisionStore } from "../workflows/human-decision.js";
16
+ import {} from "../workflows/settings.js";
10
17
  import { WorkflowRunStore } from "../workflows/store.js";
11
- import { HostProcessRegistry } from "./processes.js";
12
- import { RpcStepExecutor } from "./rpc-executor.js";
18
+ import { validateWorkflowUpdate } from "../workflows/updates.js";
19
+ import { WorkflowHostClient } from "./client.js";
20
+ import { ControllerWorkerSupervisor } from "./controller-worker-supervisor.js";
21
+ import { HostProcessRegistry, matchesProcessIdentity, processParentPid, processStartIdentity, } from "./processes.js";
22
+ import { encodeProtocolLine, hostSocketPath, NdjsonFrameDecoder, parseHostRequest, } from "./protocol.js";
23
+ import { HostStateStore, } from "./state.js";
24
+ import { WorkflowWorkerSupervisor } from "./worker-supervisor.js";
25
+ const HOST_LEASE_MS = 30_000;
26
+ const HOST_RENEW_MS = 10_000;
13
27
  const CLAIM_POLL_MS = 2_000;
14
28
  const RUN_CLAIM_LEASE_MS = 30_000;
15
- const RUN_CLAIM_RENEW_MS = 10_000;
16
- /**
17
- * The always-on runner: claims parked workflow runs and reconciles durable
18
- * controllers without a Pi session. Conversation nodes execute in headless
19
- * `pi --mode rpc` children. Everything the host does is recoverable: claims
20
- * expire, SQLite leases fence stale writers, and child processes are reaped by
21
- * the next host.
22
- */
29
+ const PRESENTATION_CLAIM_LEASE_MS = 10_000;
30
+ const DELIVERY_CLAIM_LEASE_MS = 10_000;
31
+ const CONTROLLER_CLAIM_LEASE_MS = 120_000;
32
+ const CONTROLLER_RENEW_MS = 30_000;
33
+ const MAX_CONTROLLER_WORKERS = 4;
34
+ const DEFAULT_CONTROLLER_TIMEOUT_MS = 60_000;
35
+ /** Global package-owned host. It writes state and supervises code-only workers. */
23
36
  export class WorkflowHost {
24
37
  options;
25
- runnerId;
38
+ hostId;
39
+ databasePath;
40
+ stateDirectory;
41
+ socketPath;
42
+ lockPath;
43
+ state;
44
+ hostState;
45
+ queue;
46
+ decisions;
47
+ runStore;
26
48
  registry;
27
- store;
28
- childRunStore;
29
- manager = null;
30
- pollTimer = null;
31
49
  activeRuns = new Map();
32
- schedulerExecutors = new Map();
33
- /** Runs whose resume refused (edited source); skipped until a host restart. */
34
- skippedRuns = new Set();
50
+ workerDescendants = new Map();
51
+ activeControllers = new Map();
52
+ controlClaims = new Map();
53
+ activationTasks = new Map();
54
+ pendingStarts = new Set();
55
+ pendingRunClaims = new Map();
56
+ pendingResumes = new Set();
57
+ blockedRuns = new Set();
58
+ deliveryClaims = new Map();
59
+ sockets = new Set();
60
+ server = null;
61
+ claim = null;
62
+ heartbeatTimer = null;
63
+ pollTimer = null;
35
64
  stopping = false;
36
- stateDir;
37
- constructor(options) {
65
+ started = false;
66
+ controllerPollActive = false;
67
+ decisionTimeoutActive = false;
68
+ constructor(options = {}) {
38
69
  this.options = options;
39
- this.runnerId = options.runnerId ?? `host-${randomUUID().slice(0, 8)}`;
40
- const databasePath = options.databasePath ?? workflowStatePath();
41
- this.store = new SqliteControllerStore(databasePath, { projectPath: options.cwd });
42
- this.stateDir = path.join(path.dirname(this.store.filePath), "hosts", hostProjectScope(options.cwd));
43
- this.registry = options.registry ?? new HostProcessRegistry(this.stateDir);
44
- this.childRunStore = new WorkflowRunStore(databasePath);
70
+ this.hostId = options.runnerId ?? `host-${randomUUID()}`;
71
+ this.databasePath = path.resolve(options.databasePath ?? workflowStatePath());
72
+ this.stateDirectory = path.join(path.dirname(this.databasePath), "host");
73
+ this.socketPath = hostSocketPath(this.databasePath);
74
+ this.lockPath = path.join(this.stateDirectory, "host.lock.json");
75
+ this.state = new StateDatabase({ filePath: this.databasePath });
76
+ this.hostState = new HostStateStore(this.databasePath, { state: this.state });
77
+ this.queue = new SqliteControllerStore(this.databasePath, { state: this.state, global: true });
78
+ this.decisions = new HumanDecisionStore(this.databasePath, {
79
+ state: this.state,
80
+ authorityProvider: (runId) => {
81
+ const token = this.controlClaims.get(runId) ?? this.activeRuns.get(runId)?.claimToken;
82
+ return token === undefined ? undefined : this.queue.workflowRunAuthority(runId, token);
83
+ },
84
+ });
85
+ this.runStore = new WorkflowRunStore(this.databasePath, {
86
+ state: this.state,
87
+ authorityProvider: (runId) => {
88
+ const token = this.controlClaims.get(runId) ?? this.activeRuns.get(runId)?.claimToken;
89
+ return token === undefined ? undefined : this.queue.workflowRunAuthority(runId, token);
90
+ },
91
+ snapshotLifecycle: (context) => this.applyLifecycleProjection(context),
92
+ });
93
+ this.registry = options.registry ?? new HostProcessRegistry(this.stateDirectory);
45
94
  }
46
- log(message) {
47
- this.options.onLog?.(message);
95
+ get endpoint() {
96
+ return this.socketPath;
97
+ }
98
+ get runClaimLeaseMs() {
99
+ return this.options.runClaimLeaseMs ?? RUN_CLAIM_LEASE_MS;
48
100
  }
49
- /** Take the advisory lock, reap orphans, and start claiming. */
50
101
  async start() {
51
- // The lock comes first: a second host must refuse before touching the
52
- // children registry, or it would kill the live host's child processes
53
- // as supposed orphans.
54
- acquireHostLock(this.stateDir, this.runnerId, this.options.cwd);
55
- const reaped = this.registry.reapOrphans();
56
- if (reaped.length > 0) {
57
- this.log(`reaped ${reaped.length} orphaned headless session(s): ${reaped.join(", ")}`);
58
- }
59
- const definitions = await loadDiscoveredControllers({ cwd: this.options.cwd });
60
- if (definitions.length > 0) {
61
- const scheduler = new WorkflowEngineScheduler({
62
- store: this.childRunStore,
63
- resolveWorkflow: async (name) => {
64
- const resolved = await resolveWorkflowRef(name, { cwd: this.options.cwd }, builtinWorkflowCatalog);
65
- return { workflow: resolved.definition, workflowSource: resolved.source };
66
- },
67
- createEngine: () => {
68
- const executor = new RpcStepExecutor({
69
- cwd: this.options.cwd,
70
- registry: this.registry,
71
- ...(this.options.piArgs !== undefined ? { piArgs: this.options.piArgs } : {}),
72
- ...(this.options.env !== undefined ? { env: this.options.env } : {}),
73
- });
74
- const engine = new WorkflowEngine({ executor, store: this.childRunStore });
75
- this.schedulerExecutors.set(engine, executor);
76
- return engine;
77
- },
78
- disposeEngine: async (engine) => {
79
- const executor = this.schedulerExecutors.get(engine);
80
- if (executor !== undefined) {
81
- this.schedulerExecutors.delete(engine);
82
- await executor.close();
83
- }
84
- },
85
- });
86
- this.manager = new ControllerManager({
87
- store: this.store,
88
- controllers: definitions,
89
- workflowScheduler: scheduler,
102
+ if (this.started)
103
+ return;
104
+ fs.mkdirSync(this.stateDirectory, { recursive: true, mode: 0o700 });
105
+ fs.chmodSync(this.stateDirectory, 0o700);
106
+ const startIdentity = processStartIdentity(process.pid);
107
+ if (startIdentity === undefined) {
108
+ throw new Error("Cannot attest the workflow host process start identity");
109
+ }
110
+ acquireHostLock(this.lockPath, { pid: process.pid, startIdentity, hostId: this.hostId });
111
+ try {
112
+ const reaped = this.registry.reapOrphans();
113
+ if (reaped.length > 0)
114
+ this.log(`reaped ${reaped.length} exact orphan process(es)`);
115
+ const previousHost = this.hostState.hostStatus();
116
+ this.claim = this.hostState.acquireHost({
117
+ hostId: this.hostId,
118
+ pid: process.pid,
119
+ processStartIdentity: startIdentity,
120
+ leaseMs: this.options.hostLeaseMs ?? HOST_LEASE_MS,
90
121
  });
91
- this.manager.start();
92
- this.log(`controller workers started for ${definitions.length} controller(s)`);
122
+ this.recoverPreviousHost(previousHost.hostId, this.claim.epoch);
123
+ await this.listen();
124
+ this.startTimers();
125
+ this.started = true;
126
+ this.log(`ready on ${this.socketPath} at epoch ${this.claim.epoch}`);
127
+ this.expireTimedOutInteraction();
128
+ void this.expireTimedOutDecision();
129
+ void this.claimOne();
130
+ void this.claimControllerOne();
131
+ }
132
+ catch (error) {
133
+ const server = this.server;
134
+ this.server = null;
135
+ await this.closeServer(server);
136
+ if (this.claim !== null) {
137
+ try {
138
+ this.hostState.releaseHost(this.claim);
139
+ }
140
+ catch {
141
+ // Preserve the startup error when cleanup cannot release an already-lost claim.
142
+ }
143
+ }
144
+ this.claim = null;
145
+ if (process.platform !== "win32")
146
+ fs.rmSync(this.socketPath, { force: true });
147
+ this.releaseLock();
148
+ throw error;
93
149
  }
94
- this.pollTimer = setInterval(() => {
95
- this.claimOnce();
96
- }, this.options.claimPollMs ?? CLAIM_POLL_MS);
97
- this.pollTimer.unref?.();
98
- this.claimOnce();
99
- this.log(`host ${this.runnerId} watching ${this.options.cwd}`);
100
150
  }
101
- /** Drain: stop claiming, park in-flight runs, stop controllers, kill children. */
102
151
  async stop() {
103
- if (this.stopping) {
152
+ if (this.stopping)
104
153
  return;
105
- }
106
154
  this.stopping = true;
107
- if (this.pollTimer !== null) {
155
+ if (this.heartbeatTimer !== null)
156
+ clearInterval(this.heartbeatTimer);
157
+ if (this.pollTimer !== null)
108
158
  clearInterval(this.pollTimer);
109
- this.pollTimer = null;
110
- }
111
- const pending = [...this.activeRuns.values()];
112
- for (const run of this.parkedEngines.splice(0)) {
113
- run();
159
+ this.heartbeatTimer = null;
160
+ this.pollTimer = null;
161
+ const server = this.server;
162
+ this.server = null;
163
+ await this.closeServer(server);
164
+ for (const [runId, claimToken] of this.pendingRunClaims) {
165
+ this.queue.parkWorkflowRun({ runId, claimToken });
114
166
  }
115
- await Promise.allSettled(pending);
116
- await this.manager?.stop().catch(() => undefined);
117
- for (const executor of this.schedulerExecutors.values()) {
118
- await executor.close().catch(() => undefined);
119
- }
120
- this.schedulerExecutors.clear();
167
+ this.pendingRunClaims.clear();
168
+ this.pendingStarts.clear();
169
+ await Promise.allSettled([...this.activeRuns.values()].map(async (active) => {
170
+ active.control = "handoff";
171
+ await active.supervisor.stop("orphaned");
172
+ this.queue.parkWorkflowRun({ runId: active.record.runId, claimToken: active.claimToken });
173
+ }));
174
+ await Promise.allSettled([...this.activeControllers.values()].map(async (active) => {
175
+ clearInterval(active.renewTimer);
176
+ await active.supervisor.stop("orphaned");
177
+ if (!active.settled) {
178
+ active.store.requeueClaim(active.claim, { availableAt: new Date().toISOString(), error: "Workflow host stopped" }, new Date().toISOString());
179
+ }
180
+ }));
181
+ await Promise.allSettled(this.activationTasks.values());
121
182
  this.registry.killAll();
122
- releaseHostLock(this.stateDir, this.runnerId);
123
- this.store.close();
183
+ this.deliveryClaims.clear();
184
+ if (this.claim !== null)
185
+ this.hostState.releaseHost(this.claim);
186
+ this.claim = null;
187
+ if (process.platform !== "win32")
188
+ fs.rmSync(this.socketPath, { force: true });
189
+ this.releaseLock();
190
+ this.runStore.close();
191
+ this.queue.close();
192
+ this.hostState.close();
193
+ this.state.close();
194
+ this.started = false;
124
195
  }
125
- parkedEngines = [];
126
- claimOnce() {
127
- if (this.stopping || this.activeRuns.size > 0) {
196
+ async closeServer(server) {
197
+ const closed = new Promise((resolve) => {
198
+ if (server === null || !server.listening) {
199
+ resolve();
200
+ return;
201
+ }
202
+ try {
203
+ server.close(() => resolve());
204
+ }
205
+ catch {
206
+ resolve();
207
+ }
208
+ });
209
+ for (const socket of this.sockets)
210
+ socket.destroy();
211
+ await closed;
212
+ this.sockets.clear();
213
+ }
214
+ recoverPreviousHost(previousHostId, hostEpoch) {
215
+ if (previousHostId === null || previousHostId === this.hostId)
128
216
  return;
217
+ const now = Date.now();
218
+ this.state.transaction(() => {
219
+ this.state.connection
220
+ .prepare(`UPDATE leases SET expires_at = ?
221
+ WHERE owner_id = ? AND owner_type IN ('host', 'controller') AND expires_at > ?`)
222
+ .run(now, previousHostId, now);
223
+ this.state.connection
224
+ .prepare(`UPDATE run_workers SET status = 'orphaned', finished_at = ?
225
+ WHERE host_epoch < ? AND status IN ('starting', 'ready', 'running')`)
226
+ .run(now, hostEpoch);
227
+ });
228
+ }
229
+ startTimers() {
230
+ this.heartbeatTimer = setInterval(() => {
231
+ try {
232
+ if (this.claim === null)
233
+ return;
234
+ this.claim = this.hostState.renewHost(this.claim, this.options.hostLeaseMs ?? HOST_LEASE_MS);
235
+ for (const active of this.activeRuns.values()) {
236
+ if (active.control !== undefined || active.exiting)
237
+ continue;
238
+ if (!this.queue.renewWorkflowRunClaim({
239
+ runId: active.record.runId,
240
+ claimToken: active.claimToken,
241
+ leaseMs: this.runClaimLeaseMs,
242
+ })) {
243
+ active.claimLost = true;
244
+ void active.supervisor.stop("claimLost");
245
+ }
246
+ }
247
+ }
248
+ catch (error) {
249
+ this.log(`host claim lost: ${errorMessage(error)}`);
250
+ void this.stop();
251
+ }
252
+ }, this.options.hostRenewMs ?? HOST_RENEW_MS);
253
+ this.heartbeatTimer.unref?.();
254
+ this.pollTimer = setInterval(() => {
255
+ this.expireTimedOutInteraction();
256
+ void this.expireTimedOutDecision();
257
+ void this.claimOne();
258
+ void this.claimControllerOne();
259
+ }, this.options.claimPollMs ?? CLAIM_POLL_MS);
260
+ this.pollTimer.unref?.();
261
+ }
262
+ async listen() {
263
+ if (process.platform !== "win32")
264
+ fs.rmSync(this.socketPath, { force: true });
265
+ const server = net.createServer((socket) => this.handleConnection(socket));
266
+ this.server = server;
267
+ await new Promise((resolve, reject) => {
268
+ const cleanup = () => {
269
+ server.off("listening", onListening);
270
+ server.off("error", onError);
271
+ };
272
+ const onListening = () => {
273
+ cleanup();
274
+ resolve();
275
+ };
276
+ const onError = (error) => {
277
+ cleanup();
278
+ reject(error);
279
+ };
280
+ server.once("listening", onListening);
281
+ server.once("error", onError);
282
+ try {
283
+ server.listen(this.socketPath);
284
+ }
285
+ catch (error) {
286
+ cleanup();
287
+ reject(error);
288
+ }
289
+ });
290
+ server.on("error", (error) => this.log(`socket error: ${errorMessage(error)}`));
291
+ if (process.platform !== "win32")
292
+ fs.chmodSync(this.socketPath, 0o600);
293
+ }
294
+ handleConnection(socket) {
295
+ this.sockets.add(socket);
296
+ const decoder = new NdjsonFrameDecoder();
297
+ let processing = Promise.resolve();
298
+ socket.on("data", (chunk) => {
299
+ let frames;
300
+ try {
301
+ frames = decoder.push(chunk);
302
+ }
303
+ catch {
304
+ socket.destroy();
305
+ return;
306
+ }
307
+ for (const frame of frames) {
308
+ processing = processing
309
+ .then(async () => {
310
+ const request = parseHostRequest(frame);
311
+ const response = this.handleRequest(request);
312
+ if (!socket.write(encodeProtocolLine(response)))
313
+ await once(socket, "drain");
314
+ })
315
+ .catch(() => {
316
+ socket.destroy();
317
+ });
318
+ }
319
+ });
320
+ socket.on("error", (error) => {
321
+ this.log(`client socket error: ${errorMessage(error)}`);
322
+ socket.destroy();
323
+ });
324
+ socket.on("close", () => {
325
+ this.sockets.delete(socket);
326
+ });
327
+ }
328
+ handleRequest(request) {
329
+ if (this.claim === null || this.stopping) {
330
+ return {
331
+ schema: "pi-workflows.host-response.v1",
332
+ requestId: request.requestId,
333
+ outcome: "unavailable",
334
+ error: "Workflow host is stopping",
335
+ };
129
336
  }
130
- let claimed;
337
+ const afterCommit = [];
338
+ let response;
131
339
  try {
132
- claimed = this.store.claimNextWorkflowRun({
133
- runnerId: this.runnerId,
134
- claimToken: randomUUID(),
135
- leaseMs: RUN_CLAIM_LEASE_MS,
136
- excludeRunIds: [...this.skippedRuns],
137
- });
340
+ response = this.hostState.executeCommand(request, this.claim.epoch, () => this.executeOperation(request, afterCommit));
138
341
  }
139
342
  catch (error) {
140
- // Store contention or corruption must not kill the host's loop.
141
- this.log(`claim failed, retrying shortly: ${errorMessage(error)}`);
142
- return;
343
+ response = {
344
+ schema: "pi-workflows.host-response.v1",
345
+ requestId: request.requestId,
346
+ outcome: "rejected",
347
+ error: errorMessage(error),
348
+ };
143
349
  }
144
- if (claimed === undefined) {
145
- return;
350
+ for (const effect of afterCommit)
351
+ setImmediate(effect);
352
+ return response;
353
+ }
354
+ executeOperation(request, afterCommit) {
355
+ switch (request.operation) {
356
+ case "host.status":
357
+ return { outcome: "accepted", receipt: this.statusReceipt() };
358
+ case "host.stop":
359
+ afterCommit.push(() => void this.stop());
360
+ return { outcome: "accepted", receipt: { stopping: true } };
361
+ case "run.list":
362
+ return {
363
+ outcome: "accepted",
364
+ receipt: this.queue.listWorkflowRuns({
365
+ limit: payloadLimit(request.payload),
366
+ }),
367
+ };
368
+ case "run.status": {
369
+ const runId = requireRunId(request);
370
+ const run = this.queue.getWorkflowRun(runId);
371
+ return run === undefined
372
+ ? { outcome: "notFound", error: `Workflow run not found: ${runId}` }
373
+ : { outcome: "accepted", receipt: run };
374
+ }
375
+ case "run.cancel": {
376
+ const runId = requireRunId(request);
377
+ const active = this.activeRuns.get(runId);
378
+ if (active !== undefined) {
379
+ if (!this.queue.cancelWorkflowRun({
380
+ runId: active.record.runId,
381
+ claimToken: active.claimToken,
382
+ })) {
383
+ return { outcome: "claimLost", error: "Run claim was lost before cancellation" };
384
+ }
385
+ active.control = "cancel";
386
+ afterCommit.push(() => void active.supervisor.stop("cancelled"));
387
+ return { outcome: "accepted", receipt: { runId, status: "cancelled" } };
388
+ }
389
+ const pendingClaim = this.pendingRunClaims.get(runId);
390
+ if (pendingClaim !== undefined) {
391
+ if (!this.queue.cancelWorkflowRun({ runId, claimToken: pendingClaim })) {
392
+ return { outcome: "claimLost", error: "Run claim was lost before cancellation" };
393
+ }
394
+ this.clearPendingStart(runId, pendingClaim);
395
+ return { outcome: "accepted", receipt: { runId, status: "cancelled" } };
396
+ }
397
+ const cancelled = this.queue.cancelWorkflowRun({ runId });
398
+ return cancelled
399
+ ? { outcome: "accepted", receipt: { runId, status: "cancelled" } }
400
+ : { outcome: "rejected", error: "Run has a live owner or is already terminal" };
401
+ }
402
+ case "run.pause": {
403
+ const runId = requireRunId(request);
404
+ const active = this.activeRuns.get(runId);
405
+ if (active === undefined)
406
+ return { outcome: "rejected", error: "Run is not active" };
407
+ this.commitActivePause(active);
408
+ active.control = "pause";
409
+ afterCommit.push(() => void active.supervisor.stop("cancelled"));
410
+ return { outcome: "accepted", receipt: { runId, status: "parked", paused: true } };
411
+ }
412
+ case "run.resume": {
413
+ const runId = requireRunId(request);
414
+ if (this.activeRuns.has(runId) || this.pendingStarts.has(runId)) {
415
+ return { outcome: "adopted", receipt: { runId, active: true } };
416
+ }
417
+ const token = randomUUID();
418
+ const claimed = this.queue.claimWorkflowRun({
419
+ runId,
420
+ runnerId: this.hostId,
421
+ claimToken: token,
422
+ leaseMs: this.runClaimLeaseMs,
423
+ });
424
+ if (claimed === undefined)
425
+ return { outcome: "rejected", error: "Run is not resumable" };
426
+ this.markPendingStart(runId, token);
427
+ afterCommit.push(() => void this.activateRun(claimed, token));
428
+ return { outcome: "accepted", receipt: { runId, generation: claimed.claimGeneration } };
429
+ }
430
+ case "run.start":
431
+ return this.startRun(request, afterCommit);
432
+ case "checkpoint.answer":
433
+ return this.answerCheckpoint(request, afterCommit);
434
+ case "notification.claim":
435
+ return this.claimNotification(request);
436
+ case "notification.deliver":
437
+ return this.deliverNotification(request);
438
+ case "turn.claim":
439
+ return this.claimTurn(request);
440
+ case "turn.resolve":
441
+ return this.resolveTurn(request);
442
+ case "controller.list":
443
+ case "controller.get":
444
+ case "controller.apply":
445
+ case "controller.reconcile":
446
+ case "controller.delete":
447
+ return this.executeControllerOperation(request);
448
+ case "interaction.update": {
449
+ const payload = requireRecord(request.payload, "interaction update payload");
450
+ if (payload.claimPresentation === true) {
451
+ const interaction = this.hostState.claimInteractionPresentation({
452
+ requestId: requireString(payload.requestId, "requestId"),
453
+ expectedRevision: requireNonNegativeInteger(request.expectedRevision, "expectedRevision"),
454
+ presenterId: request.clientId,
455
+ leaseMs: PRESENTATION_CLAIM_LEASE_MS,
456
+ });
457
+ return {
458
+ outcome: "accepted",
459
+ revision: interaction.revision,
460
+ receipt: interaction,
461
+ };
462
+ }
463
+ if (typeof payload.sessionEntryId === "string") {
464
+ const interaction = this.hostState.markInteractionPresented({
465
+ requestId: requireString(payload.requestId, "requestId"),
466
+ expectedRevision: requireNonNegativeInteger(request.expectedRevision, "expectedRevision"),
467
+ sessionEntryId: payload.sessionEntryId,
468
+ });
469
+ return {
470
+ outcome: "accepted",
471
+ revision: interaction.revision,
472
+ receipt: interaction,
473
+ };
474
+ }
475
+ return this.publishInteractionUpdate(request);
476
+ }
477
+ case "interaction.submit":
478
+ return this.submitInteraction(request);
479
+ case "decision.answer":
480
+ return this.answerDecision(request, afterCommit);
481
+ }
482
+ }
483
+ executeControllerOperation(request) {
484
+ const payload = requireRecord(request.payload, "controller payload");
485
+ const projectPath = path.resolve(requireString(payload.projectPath, "projectPath"));
486
+ if (payload.projectPath !== projectPath) {
487
+ return {
488
+ outcome: "rejected",
489
+ error: "Controller projectPath must be absolute and normalized",
490
+ };
491
+ }
492
+ const store = new SqliteControllerStore(this.databasePath, {
493
+ state: this.state,
494
+ projectPath,
495
+ });
496
+ if (request.operation === "controller.list") {
497
+ return { outcome: "accepted", receipt: store.listResources() };
498
+ }
499
+ const controller = requireString(payload.controller, "controller");
500
+ const key = requireString(payload.key, "key");
501
+ const ref = { controller, key };
502
+ if (request.operation === "controller.get") {
503
+ const resource = store.getResource(ref);
504
+ return resource === undefined
505
+ ? { outcome: "notFound", error: `Controller resource not found: ${controller}/${key}` }
506
+ : { outcome: "accepted", receipt: resource };
507
+ }
508
+ if (request.operation === "controller.apply") {
509
+ if (!Object.hasOwn(payload, "spec") || !Object.hasOwn(payload, "initialStatus")) {
510
+ return { outcome: "rejected", error: "Controller apply requires spec and initialStatus" };
511
+ }
512
+ const spec = payload.spec;
513
+ const initialStatus = payload.initialStatus;
514
+ const controllerPath = path.resolve(requireString(payload.controllerPath, "controllerPath"));
515
+ const sourceHash = requireString(payload.sourceHash, "sourceHash");
516
+ if (!controllerPathAllowed(projectPath, controller, controllerPath)) {
517
+ return { outcome: "rejected", error: "Controller source does not match discovery rules" };
518
+ }
519
+ const observedHash = createHash("sha256")
520
+ .update(fs.readFileSync(controllerPath))
521
+ .digest("hex");
522
+ if (observedHash !== sourceHash) {
523
+ return { outcome: "conflict", error: "Controller source changed before apply committed" };
524
+ }
525
+ const resource = store.putResource({ controller, key, spec, initialStatus });
526
+ return { outcome: "accepted", receipt: resource };
146
527
  }
147
- const task = this.runClaimed(claimed).finally(() => {
148
- this.activeRuns.delete(claimed.runId);
528
+ const existing = store.getResource(ref);
529
+ if (existing === undefined) {
530
+ return { outcome: "notFound", error: `Controller resource not found: ${controller}/${key}` };
531
+ }
532
+ if (request.operation === "controller.reconcile") {
533
+ store.enqueue(ref);
534
+ return { outcome: "accepted", receipt: existing };
535
+ }
536
+ const deleting = store.requestDeletion(ref);
537
+ store.enqueue(ref);
538
+ return { outcome: "accepted", receipt: deleting };
539
+ }
540
+ statusReceipt() {
541
+ const host = this.hostState.hostStatus();
542
+ const count = (sql, ...params) => {
543
+ const row = this.state.connection.prepare(sql).get(...params);
544
+ return typeof row?.count === "number" ? row.count : 0;
545
+ };
546
+ const now = Date.now();
547
+ return {
548
+ state: host.live ? "running" : "stale",
549
+ epoch: host.epoch,
550
+ socketAvailable: this.server?.listening === true,
551
+ startedAt: host.startedAt,
552
+ heartbeatAt: host.heartbeatAt,
553
+ expiresAt: host.expiresAt,
554
+ activeWorkers: this.activeRuns.size,
555
+ connectedClients: this.sockets.size,
556
+ queuedRuns: count("SELECT COUNT(*) AS count FROM run_queue WHERE status = 'queued'"),
557
+ runningRuns: count("SELECT COUNT(*) AS count FROM run_queue WHERE status IN ('starting', 'running')"),
558
+ parkedRuns: count("SELECT COUNT(*) AS count FROM run_queue WHERE status = 'parked'"),
559
+ waitingRuns: count("SELECT COUNT(*) AS count FROM runs WHERE status = 'waiting'"),
560
+ expiredClaims: count(`SELECT COUNT(*) AS count FROM run_queue q
561
+ JOIN runs r ON r.run_id = q.run_id
562
+ JOIN leases l ON l.resource_id = r.resource_id
563
+ WHERE q.status NOT IN ('done', 'failed', 'cancelled')
564
+ AND l.owner_id IS NOT NULL AND l.expires_at <= ?`, now),
565
+ pendingInteractions: count("SELECT COUNT(*) AS count FROM interactive_requests WHERE status IN ('pending', 'presenting')"),
566
+ ambiguousEffects: count("SELECT COUNT(*) AS count FROM effects WHERE status = 'ambiguous'"),
567
+ pendingControllers: count("SELECT COUNT(*) AS count FROM controller_queue"),
568
+ activeControllerWorkers: this.activeControllers.size,
569
+ lifecycleContradictions: count(`SELECT COUNT(*) AS count
570
+ FROM runs r JOIN run_queue q ON q.run_id = r.run_id
571
+ WHERE NOT (
572
+ (r.status = 'queued' AND q.status = 'queued')
573
+ OR (r.status = 'running' AND q.status IN ('starting', 'running', 'parked'))
574
+ OR (r.status = 'waiting' AND q.status = 'parked')
575
+ OR (r.status = 'completed' AND q.status = 'done')
576
+ OR (r.status IN ('failed', 'timed_out') AND q.status = 'failed')
577
+ OR (r.status = 'cancelled' AND q.status = 'cancelled')
578
+ )`),
579
+ };
580
+ }
581
+ rememberDeliveryClaim(options) {
582
+ const now = Date.now();
583
+ for (const [claimId, claim] of this.deliveryClaims) {
584
+ if (claim.expiresAt <= now)
585
+ this.deliveryClaims.delete(claimId);
586
+ }
587
+ const claimId = randomUUID();
588
+ this.deliveryClaims.set(claimId, {
589
+ ...options,
590
+ expiresAt: now + DELIVERY_CLAIM_LEASE_MS,
149
591
  });
150
- this.activeRuns.set(claimed.runId, task);
592
+ return claimId;
151
593
  }
152
- async runClaimed(record) {
153
- const claimToken = record.claimToken;
154
- const runId = record.runId;
155
- this.log(`resuming ${record.workflowName} run ${runId}`);
156
- let workflow;
157
- let workflowSource;
594
+ deliveryClaim(claimId, clientId, kind, resourceId, targetSessionId) {
595
+ const claim = this.deliveryClaims.get(claimId);
596
+ if (claim === undefined ||
597
+ claim.expiresAt <= Date.now() ||
598
+ claim.clientId !== clientId ||
599
+ claim.kind !== kind ||
600
+ claim.resourceId !== resourceId ||
601
+ claim.targetSessionId !== targetSessionId) {
602
+ this.deliveryClaims.delete(claimId);
603
+ return undefined;
604
+ }
605
+ return claim;
606
+ }
607
+ claimNotification(command) {
608
+ const payload = requireRecord(command.payload, "notification claim payload");
609
+ const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
610
+ const token = randomUUID();
611
+ const notification = this.queue.claimPendingWorkflowNotifications({
612
+ targetSessionId,
613
+ claimToken: token,
614
+ leaseMs: DELIVERY_CLAIM_LEASE_MS,
615
+ limit: 1,
616
+ })[0];
617
+ if (notification === undefined) {
618
+ return { outcome: "accepted", receipt: { notification: null } };
619
+ }
620
+ const claimId = this.rememberDeliveryClaim({
621
+ clientId: command.clientId,
622
+ token,
623
+ targetSessionId,
624
+ kind: "notification",
625
+ resourceId: notification.notificationId,
626
+ });
627
+ return {
628
+ outcome: "accepted",
629
+ receipt: { claimId, notification },
630
+ };
631
+ }
632
+ deliverNotification(command) {
633
+ const payload = requireRecord(command.payload, "notification delivery payload");
634
+ const notificationId = requireString(payload.notificationId, "notificationId");
635
+ const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
636
+ const claimId = requireString(payload.claimId, "claimId");
637
+ const claim = this.deliveryClaim(claimId, command.clientId, "notification", notificationId, targetSessionId);
638
+ if (claim === undefined) {
639
+ return { outcome: "conflict", error: "Notification delivery claim is stale" };
640
+ }
641
+ const delivered = this.queue.markWorkflowNotificationDelivered({
642
+ notificationId,
643
+ targetSessionId,
644
+ claimToken: claim.token,
645
+ });
646
+ this.deliveryClaims.delete(claimId);
647
+ return delivered
648
+ ? { outcome: "accepted", receipt: { notificationId, delivered: true } }
649
+ : { outcome: "conflict", error: "Notification delivery claim is stale" };
650
+ }
651
+ claimTurn(command) {
652
+ const payload = requireRecord(command.payload, "turn claim payload");
653
+ const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
654
+ const token = randomUUID();
655
+ const intent = this.queue.claimEligibleWorkflowTurnIntents({
656
+ targetSessionId,
657
+ claimToken: token,
658
+ leaseMs: DELIVERY_CLAIM_LEASE_MS,
659
+ limit: 1,
660
+ })[0];
661
+ if (intent === undefined) {
662
+ return { outcome: "accepted", receipt: { turn: null } };
663
+ }
664
+ const claimId = this.rememberDeliveryClaim({
665
+ clientId: command.clientId,
666
+ token,
667
+ targetSessionId,
668
+ kind: "turn",
669
+ resourceId: intent.intentId,
670
+ });
671
+ return {
672
+ outcome: "accepted",
673
+ receipt: { claimId, turn: intent },
674
+ };
675
+ }
676
+ resolveTurn(command) {
677
+ const payload = requireRecord(command.payload, "turn resolution payload");
678
+ const intentId = requireString(payload.intentId, "intentId");
679
+ const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
680
+ const claimId = requireString(payload.claimId, "claimId");
681
+ const claim = this.deliveryClaim(claimId, command.clientId, "turn", intentId, targetSessionId);
682
+ if (claim === undefined) {
683
+ return { outcome: "conflict", error: "Workflow turn delivery claim is stale" };
684
+ }
685
+ const resolved = this.queue.resolveWorkflowTurnIntent({
686
+ intentId,
687
+ targetSessionId,
688
+ claimToken: claim.token,
689
+ resolution: "presentation",
690
+ ...(typeof payload.messageId === "string" ? { messageId: payload.messageId } : {}),
691
+ });
692
+ this.deliveryClaims.delete(claimId);
693
+ return resolved
694
+ ? { outcome: "accepted", receipt: { intentId, resolved: true } }
695
+ : { outcome: "conflict", error: "Workflow turn delivery claim is stale" };
696
+ }
697
+ answerCheckpoint(command, afterCommit) {
698
+ const parentRunId = requireRunId(command);
699
+ const payload = requireRecord(command.payload, "checkpoint answer payload");
700
+ const continuationRunId = requireString(payload.continuationRunId, "continuationRunId");
701
+ const parent = this.queue.getWorkflowRun(parentRunId);
702
+ const bundle = this.runStore.readRun(parentRunId);
703
+ if (parent === undefined || bundle === null || bundle instanceof Promise) {
704
+ return { outcome: "notFound", error: `Checkpoint run not found: ${parentRunId}` };
705
+ }
706
+ const waitingOn = bundle.state.waitingOn;
707
+ if (bundle.state.status !== "waiting" || waitingOn === undefined) {
708
+ return { outcome: "rejected", error: "Workflow run is not waiting at a checkpoint" };
709
+ }
710
+ if (bundle.snapshot.nodes[waitingOn]?.humanDecision !== undefined) {
711
+ return { outcome: "rejected", error: "Protected human decisions require a human channel" };
712
+ }
713
+ const projectPath = this.queue.workflowRunProjectPath(parentRunId);
714
+ if (projectPath === undefined || parent.originSessionId === null) {
715
+ throw new Error("Checkpoint parent provenance is missing");
716
+ }
717
+ const prepared = this.prepareContinuation({
718
+ parent,
719
+ parentRunId,
720
+ continuationRunId,
721
+ projectPath,
722
+ definitionSnapshot: bundle.snapshot,
723
+ input: payload.input,
724
+ launchOptions: {},
725
+ originSessionId: parent.originSessionId,
726
+ }, afterCommit);
727
+ return {
728
+ outcome: prepared.state === "adopted" ? "adopted" : "accepted",
729
+ receipt: {
730
+ parentRunId,
731
+ runId: continuationRunId,
732
+ status: prepared.run.status,
733
+ },
734
+ };
735
+ }
736
+ answerDecision(command, afterCommit) {
737
+ const payload = requireRecord(command.payload, "decision answer payload");
738
+ const requestId = requireString(payload.requestId, "requestId");
739
+ const interaction = this.hostState.getInteraction(requestId);
740
+ if (interaction === undefined || interaction.kind !== "decision") {
741
+ return { outcome: "notFound", error: `Decision request not found: ${requestId}` };
742
+ }
743
+ const request = interaction.contract;
744
+ const response = payload.response;
745
+ const accepted = this.decisions.acceptSync(request, {
746
+ ...response,
747
+ decisionId: request.decisionId,
748
+ requestDigest: request.requestDigest,
749
+ source: {
750
+ channel: "pi",
751
+ actorId: interaction.targetSessionId,
752
+ eventId: command.idempotencyKey,
753
+ },
754
+ idempotencyKey: command.idempotencyKey,
755
+ });
756
+ if (accepted.status === "conflict") {
757
+ return { outcome: "conflict", error: "Another human decision answer already won" };
758
+ }
759
+ this.hostState.submitInteraction({
760
+ requestId,
761
+ submissionId: requireString(payload.submissionId, "submissionId"),
762
+ idempotencyKey: command.idempotencyKey,
763
+ expectedRevision: requireNonNegativeInteger(command.expectedRevision, "expectedRevision"),
764
+ payload: response,
765
+ accepted: true,
766
+ receipt: accepted.decision,
767
+ });
768
+ const continuationRunId = this.prepareDecisionContinuation(interaction, request, accepted.decision, afterCommit);
769
+ return {
770
+ outcome: accepted.status === "adopted" ? "adopted" : "accepted",
771
+ receipt: {
772
+ requestId,
773
+ parentRunId: interaction.runId,
774
+ runId: continuationRunId,
775
+ decision: accepted.decision,
776
+ },
777
+ };
778
+ }
779
+ prepareDecisionContinuation(interaction, request, decision, afterCommit) {
780
+ const parent = this.queue.getWorkflowRun(interaction.runId);
781
+ const bundle = this.runStore.readRun(interaction.runId);
782
+ if (parent === undefined || bundle === null || bundle instanceof Promise) {
783
+ throw new Error(`Decision parent run is unreadable: ${interaction.runId}`);
784
+ }
785
+ const continuationRunId = `continuation-${request.decisionId.replace(/^decision-/, "")}`;
786
+ const projectPath = this.queue.workflowRunProjectPath(interaction.runId);
787
+ if (projectPath === undefined)
788
+ throw new Error("Decision parent project is missing");
789
+ this.prepareContinuation({
790
+ parent,
791
+ parentRunId: interaction.runId,
792
+ continuationRunId,
793
+ projectPath,
794
+ definitionSnapshot: bundle.snapshot,
795
+ input: {},
796
+ launchOptions: { humanDecision: decision },
797
+ originSessionId: interaction.targetSessionId,
798
+ }, afterCommit);
799
+ return continuationRunId;
800
+ }
801
+ prepareContinuation(options, afterCommit) {
802
+ const token = randomUUID();
803
+ const scoped = new SqliteControllerStore(this.databasePath, {
804
+ state: this.state,
805
+ projectPath: options.projectPath,
806
+ });
807
+ const prepared = scoped.prepareOrAdoptWorkflowRun({
808
+ runId: options.continuationRunId,
809
+ workflowName: options.parent.workflowName,
810
+ workflowSourceRef: options.parent.workflowSourceRef,
811
+ workflowSource: options.parent.workflowSource,
812
+ definitionDigest: options.parent.definitionDigest,
813
+ definitionSnapshot: options.definitionSnapshot,
814
+ input: options.input,
815
+ launchOptions: options.launchOptions,
816
+ runnerId: this.hostId,
817
+ claimToken: token,
818
+ leaseMs: this.runClaimLeaseMs,
819
+ originSessionId: options.originSessionId,
820
+ executionMode: options.parent.executionMode,
821
+ parentRunId: options.parentRunId,
822
+ });
823
+ if (prepared.state === "claimed") {
824
+ this.markPendingStart(options.continuationRunId, token);
825
+ afterCommit.push(() => void this.activateRun(prepared.run, token));
826
+ }
827
+ return prepared;
828
+ }
829
+ startRun(request, afterCommit) {
830
+ const runId = requireRunId(request);
831
+ const payload = requireRecord(request.payload, "run.start payload");
832
+ const projectPath = requireAbsolutePath(payload.projectPath, "projectPath");
833
+ const workflowName = requireString(payload.workflowName, "workflowName");
834
+ const workflowSourceRef = requireString(payload.workflowSourceRef, "workflowSourceRef");
835
+ const definitionDigest = requireString(payload.definitionDigest, "definitionDigest");
836
+ const originSessionId = requireString(payload.originSessionId, "originSessionId");
837
+ const executionMode = payload.executionMode === "headless" ? "headless" : "interactive";
838
+ const token = randomUUID();
839
+ const scoped = new SqliteControllerStore(this.databasePath, {
840
+ state: this.state,
841
+ projectPath,
842
+ });
843
+ const prepared = scoped.prepareOrAdoptWorkflowRun({
844
+ runId,
845
+ workflowName,
846
+ workflowSourceRef,
847
+ workflowSource: payload.workflowSource,
848
+ definitionDigest,
849
+ definitionSnapshot: payload.definitionSnapshot,
850
+ input: payload.input,
851
+ launchOptions: payload.launchOptions ?? {},
852
+ runnerId: this.hostId,
853
+ claimToken: token,
854
+ leaseMs: this.runClaimLeaseMs,
855
+ originSessionId,
856
+ executionMode,
857
+ ...(typeof payload.parentRunId === "string" ? { parentRunId: payload.parentRunId } : {}),
858
+ });
859
+ if (prepared.state === "adopted") {
860
+ return {
861
+ outcome: "adopted",
862
+ receipt: { runId, status: prepared.run.status },
863
+ };
864
+ }
865
+ this.markPendingStart(runId, token);
866
+ afterCommit.push(() => void this.activateRun(prepared.run, token));
867
+ return {
868
+ outcome: "accepted",
869
+ revision: runRevision(this.state, runId),
870
+ receipt: { runId, generation: prepared.run.claimGeneration },
871
+ };
872
+ }
873
+ publishInteractionUpdate(request) {
874
+ const runId = requireRunId(request);
875
+ const payload = requireRecord(request.payload, "interaction update payload");
876
+ const requestId = requireString(payload.requestId, "requestId");
877
+ const interaction = this.hostState.getInteraction(requestId);
878
+ if (interaction === undefined || interaction.runId !== runId) {
879
+ return { outcome: "notFound", error: `Interactive request not found: ${requestId}` };
880
+ }
881
+ const attemptId = requireString(payload.attempt, "attempt");
882
+ const nodeId = requireString(payload.step, "step");
883
+ const storedContract = requireRecord(interaction.contract, "interactive contract");
884
+ const stepContract = requireRecord(storedContract.contract, "workflow step contract");
885
+ if (interaction.attemptId !== attemptId ||
886
+ requireString(stepContract.nodeId, "contract.nodeId") !== nodeId ||
887
+ !["pending", "presenting"].includes(interaction.status)) {
888
+ return { outcome: "conflict", error: "Interactive request attempt is stale" };
889
+ }
890
+ if (interaction.revision !==
891
+ requireNonNegativeInteger(request.expectedRevision, "expectedRevision")) {
892
+ return { outcome: "conflict", error: "Interactive request revision is stale" };
893
+ }
894
+ const value = requireRecord(payload.value, "interaction update value");
895
+ const update = validateWorkflowUpdate(value.update);
896
+ const token = randomUUID();
897
+ const claimed = this.queue.claimWorkflowRunForControl({
898
+ runId,
899
+ runnerId: this.hostId,
900
+ claimToken: token,
901
+ leaseMs: this.runClaimLeaseMs,
902
+ });
903
+ if (claimed === undefined) {
904
+ return { outcome: "claimLost", error: "Interactive run is not available for update" };
905
+ }
906
+ this.controlClaims.set(runId, token);
158
907
  try {
159
- const bundle = this.childRunStore.readRun(runId);
160
- if (bundle?.state.status === "waiting") {
161
- if (this.store.parkWorkflowRun({ runId, claimToken })) {
162
- this.store.settleRunEffect(runId, "run.park_queue");
163
- this.skippedRuns.add(runId);
164
- this.log(`left ${record.workflowName} run ${runId} waiting for its decision owner`);
165
- }
166
- return;
908
+ this.runStore.synchronizeRevision(runId);
909
+ const loaded = this.runStore.readRun(runId);
910
+ if (loaded === null || loaded.state.status !== "waiting") {
911
+ throw new Error("Interactive update does not match a waiting workflow run");
167
912
  }
168
- if (bundle?.state.workflowSource === undefined) {
169
- throw new Error(`Workflow run ${runId} has no canonical workflow source`);
913
+ const result = this.runStore.publishUpdateSynchronous(runId, loaded.state, nodeId, attemptId, update);
914
+ if (!this.queue.parkWorkflowRun({ runId, claimToken: token })) {
915
+ throw new Error("Interactive update could not release its control claim");
170
916
  }
171
- workflow = await resolveWorkflowSource(bundle.state.workflowSource, builtinWorkflowCatalog, runId);
172
- workflowSource = bundle.state.workflowSource;
917
+ return {
918
+ outcome: "accepted",
919
+ revision: result.event.seq,
920
+ receipt: result.record,
921
+ };
173
922
  }
174
- catch (error) {
175
- if (error instanceof WorkflowSourceChangedError) {
176
- try {
177
- this.store.parkWorkflowRun({ runId, claimToken });
178
- }
179
- catch {
180
- // Best-effort.
181
- }
182
- this.skippedRuns.add(runId);
183
- this.recordEvent(runId, record.workflowName, "parked", {
184
- reason: "workflow source changed",
185
- });
186
- this.log(`run ${runId} skipped: workflow source changed; install the matching package revision, then restart the host`);
187
- return;
923
+ finally {
924
+ this.controlClaims.delete(runId);
925
+ }
926
+ }
927
+ submitInteraction(request) {
928
+ const payload = requireRecord(request.payload, "interaction payload");
929
+ const requestId = requireString(payload.requestId, "requestId");
930
+ const current = this.hostState.getInteraction(requestId);
931
+ if (current === undefined || current.runId !== requireRunId(request)) {
932
+ return { outcome: "notFound", error: `Interactive request not found: ${requestId}` };
933
+ }
934
+ const attemptId = requireString(payload.attempt, "attempt");
935
+ const nodeId = requireString(payload.step, "step");
936
+ const storedContract = requireRecord(current.contract, "interactive contract");
937
+ const stepContract = requireRecord(storedContract.contract, "workflow step contract");
938
+ if (current.attemptId !== attemptId ||
939
+ requireString(stepContract.nodeId, "contract.nodeId") !== nodeId) {
940
+ return { outcome: "conflict", error: "Interactive request attempt is stale" };
941
+ }
942
+ const submissionId = requireString(payload.submissionId, "submissionId");
943
+ const submission = this.hostState.beginInteractionValidation({
944
+ requestId,
945
+ submissionId,
946
+ idempotencyKey: request.idempotencyKey,
947
+ expectedRevision: requireNonNegativeInteger(request.expectedRevision, "expectedRevision"),
948
+ payload: (payload.value ?? null),
949
+ receipt: {
950
+ operation: request.operation,
951
+ requestId,
952
+ submissionId,
953
+ status: "validating",
954
+ },
955
+ });
956
+ const interaction = submission.interaction;
957
+ if (this.activeRuns.has(interaction.runId) || this.activationTasks.has(interaction.runId)) {
958
+ this.pendingResumes.add(interaction.runId);
959
+ }
960
+ else {
961
+ const token = randomUUID();
962
+ const claimed = this.queue.claimWorkflowRunForInteractionValidation({
963
+ runId: interaction.runId,
964
+ runnerId: this.hostId,
965
+ claimToken: token,
966
+ leaseMs: this.runClaimLeaseMs,
967
+ });
968
+ if (claimed !== undefined) {
969
+ this.markPendingStart(interaction.runId, token);
970
+ setImmediate(() => void this.activateRun(claimed, token));
188
971
  }
189
- await this.failUnresumable(record, claimToken, errorMessage(error));
972
+ }
973
+ return {
974
+ outcome: submission.outcome,
975
+ revision: interaction.revision,
976
+ receipt: submission.receipt,
977
+ };
978
+ }
979
+ async claimControllerOne() {
980
+ if (this.stopping ||
981
+ this.claim === null ||
982
+ this.controllerPollActive ||
983
+ this.activeControllers.size >= MAX_CONTROLLER_WORKERS) {
190
984
  return;
191
985
  }
192
- if (this.stopping) {
193
- // The drain started during setup: park before anything executes.
194
- try {
195
- this.store.parkWorkflowRun({ runId, claimToken });
196
- }
197
- catch {
198
- // Best-effort.
986
+ this.controllerPollActive = true;
987
+ try {
988
+ const rows = this.state.connection
989
+ .prepare(`SELECT DISTINCT p.canonical_path AS projectPath
990
+ FROM controller_queue q
991
+ JOIN controller_resources c ON c.controller_resource_id = q.controller_resource_id
992
+ JOIN projects p ON p.project_id = c.project_id
993
+ JOIN leases l ON l.resource_id = c.resource_id
994
+ WHERE q.available_at <= ? AND (l.owner_id IS NULL OR l.expires_at <= ?)
995
+ ORDER BY q.available_at, p.canonical_path`)
996
+ .all(Date.now(), Date.now());
997
+ for (const row of rows) {
998
+ if (typeof row.projectPath !== "string")
999
+ continue;
1000
+ const discovered = await discoverControllers({ cwd: row.projectPath });
1001
+ if (discovered.length === 0)
1002
+ continue;
1003
+ const byName = new Map(discovered.map((item) => [item.name, item.path]));
1004
+ const store = new SqliteControllerStore(this.databasePath, {
1005
+ state: this.state,
1006
+ projectPath: row.projectPath,
1007
+ });
1008
+ const claim = store.claimNext({
1009
+ controllers: [...byName.keys()],
1010
+ ownerId: this.hostId,
1011
+ leaseMs: CONTROLLER_CLAIM_LEASE_MS,
1012
+ exclude: [...this.activeControllers.values()]
1013
+ .filter((active) => active.projectPath === row.projectPath)
1014
+ .map((active) => ({
1015
+ controller: active.claim.controller,
1016
+ key: active.claim.key,
1017
+ })),
1018
+ });
1019
+ if (claim === undefined)
1020
+ continue;
1021
+ const definitionPath = byName.get(claim.controller);
1022
+ const resource = store.getResource({ controller: claim.controller, key: claim.key });
1023
+ if (definitionPath === undefined || resource === undefined) {
1024
+ store.requeueClaim(claim, {
1025
+ availableAt: new Date().toISOString(),
1026
+ error: "Controller source is unavailable",
1027
+ });
1028
+ continue;
1029
+ }
1030
+ void this.activateController(row.projectPath, definitionPath, store, claim, resource);
1031
+ break;
199
1032
  }
1033
+ }
1034
+ catch (error) {
1035
+ this.log(`controller scheduling failed: ${errorMessage(error)}`);
1036
+ }
1037
+ finally {
1038
+ this.controllerPollActive = false;
1039
+ }
1040
+ }
1041
+ async activateController(projectPath, definitionPath, store, claim, resource) {
1042
+ if (this.claim === null || this.stopping) {
1043
+ store.requeueClaim(claim, { availableAt: new Date().toISOString() });
200
1044
  return;
201
1045
  }
202
- const store = this.store;
203
- const fence = () => {
204
- if (!store.verifyWorkflowRunClaim({ runId, claimToken })) {
205
- throw new ClaimLostError(runId);
1046
+ const workerEpoch = randomUUID();
1047
+ const key = `${projectPath}\u0000${claim.controller}\u0000${claim.key}`;
1048
+ const reconcileId = randomUUID();
1049
+ const envelope = {
1050
+ schema: "pi-workflows.controller-worker-launch.v1",
1051
+ workerEpoch,
1052
+ hostEpoch: this.claim.epoch,
1053
+ generation: claim.generation,
1054
+ projectPath,
1055
+ definitionPath,
1056
+ controllerName: claim.controller,
1057
+ resource,
1058
+ timeoutMs: DEFAULT_CONTROLLER_TIMEOUT_MS,
1059
+ };
1060
+ let active;
1061
+ const supervisor = new ControllerWorkerSupervisor(envelope, {
1062
+ registry: this.registry,
1063
+ onMessage: async (message) => await this.handleControllerWorkerMessage(active, message),
1064
+ ...(this.options.env === undefined ? {} : { env: this.options.env }),
1065
+ ...(this.options.controllerWorkerEntryPath === undefined
1066
+ ? {}
1067
+ : { workerEntryPath: this.options.controllerWorkerEntryPath }),
1068
+ ...(this.options.workerStartupTimeoutMs === undefined
1069
+ ? {}
1070
+ : { startupTimeoutMs: this.options.workerStartupTimeoutMs }),
1071
+ onDiagnostic: (message) => this.log(`controller worker ${workerEpoch}: ${message}`),
1072
+ });
1073
+ const renewTimer = setInterval(() => {
1074
+ if (!store.renewClaim(claim, CONTROLLER_CLAIM_LEASE_MS)) {
1075
+ void supervisor.stop("claimLost");
206
1076
  }
1077
+ }, CONTROLLER_RENEW_MS);
1078
+ renewTimer.unref?.();
1079
+ active = {
1080
+ key,
1081
+ projectPath,
1082
+ store,
1083
+ claim,
1084
+ resource,
1085
+ reconcileId,
1086
+ startedAt: Date.now(),
1087
+ supervisor,
1088
+ renewTimer,
1089
+ settled: false,
207
1090
  };
208
- const fencedStore = new WorkflowRunStore(this.childRunStore.databasePath, {
209
- authorityProvider: () => store.workflowRunAuthority(runId, claimToken),
1091
+ this.activeControllers.set(key, active);
1092
+ store.recordEvent({
1093
+ controller: claim.controller,
1094
+ key: claim.key,
1095
+ claim,
1096
+ type: "reconcile_started",
1097
+ payload: { reconcileId, generation: resource.metadata.generation },
210
1098
  });
211
- const executor = new RpcStepExecutor({
212
- cwd: this.options.cwd,
213
- registry: this.registry,
214
- ...(record.originSessionId !== null ? { assistantMessageMode: "park" } : {}),
215
- ...(this.options.piArgs !== undefined ? { piArgs: this.options.piArgs } : {}),
216
- ...(this.options.env !== undefined ? { env: this.options.env } : {}),
217
- });
218
- const engine = new WorkflowEngine({
219
- executor,
220
- store: fencedStore,
221
- notificationSink: {
222
- notify: (request) => {
223
- fence();
224
- if (record.originSessionId === null) {
225
- throw new Error(`Workflow run ${request.runId} has no origin session`);
1099
+ try {
1100
+ await supervisor.start();
1101
+ const result = await supervisor.wait();
1102
+ if (result.outcome !== "exited" && !active.settled) {
1103
+ this.finishControllerFailure(active, result.diagnostic ?? `Controller worker ${result.outcome}`);
1104
+ }
1105
+ }
1106
+ catch (error) {
1107
+ await supervisor.stop("crashed");
1108
+ if (!active.settled)
1109
+ this.finishControllerFailure(active, errorMessage(error));
1110
+ }
1111
+ finally {
1112
+ clearInterval(renewTimer);
1113
+ this.activeControllers.delete(key);
1114
+ if (!this.stopping)
1115
+ setImmediate(() => void this.claimControllerOne());
1116
+ }
1117
+ }
1118
+ async handleControllerWorkerMessage(active, message) {
1119
+ const current = this.activeControllers.get(active.key);
1120
+ if (current !== active ||
1121
+ message.workerEpoch !== active.supervisor.envelope.workerEpoch ||
1122
+ message.generation !== active.claim.generation) {
1123
+ return controllerWorkerResponse(message, "claimLost", undefined, "Controller worker is stale");
1124
+ }
1125
+ if (!active.store.renewClaim(active.claim, CONTROLLER_CLAIM_LEASE_MS)) {
1126
+ return controllerWorkerResponse(message, "claimLost", undefined, "Controller claim expired");
1127
+ }
1128
+ try {
1129
+ const payload = requireRecord(message.payload, "controller worker payload");
1130
+ switch (message.operation) {
1131
+ case "worker.ready":
1132
+ return controllerWorkerResponse(message, "accepted", {});
1133
+ case "effect.reserve": {
1134
+ const reservation = active.store.reserveEffect({
1135
+ key: requireString(payload.key, "key"),
1136
+ resourceUid: active.resource.metadata.uid,
1137
+ claim: active.claim,
1138
+ generation: active.resource.metadata.generation,
1139
+ kind: requireString(payload.kind, "kind"),
1140
+ requestFingerprint: requireString(payload.requestFingerprint, "requestFingerprint"),
1141
+ });
1142
+ return controllerWorkerResponse(message, "accepted", reservation);
1143
+ }
1144
+ case "effect.settle": {
1145
+ const state = requireString(payload.state, "state");
1146
+ if (!["applied", "rejected", "indeterminate"].includes(state)) {
1147
+ throw new Error("Controller effect state is invalid");
226
1148
  }
227
- const notification = store.enqueueWorkflowNotification({
228
- ...request,
229
- targetSessionId: record.originSessionId,
1149
+ const record = active.store.updateEffect({
1150
+ resourceUid: active.resource.metadata.uid,
1151
+ key: requireString(payload.key, "key"),
1152
+ claim: active.claim,
1153
+ state: state,
1154
+ ...(typeof payload.externalRef === "string"
1155
+ ? { externalRef: payload.externalRef }
1156
+ : {}),
1157
+ ...(typeof payload.error === "string" ? { error: payload.error } : {}),
230
1158
  });
231
- return {
232
- notificationId: notification.notificationId,
233
- targetSessionId: notification.targetSessionId,
234
- };
235
- },
1159
+ active.store.recordEvent({
1160
+ controller: active.claim.controller,
1161
+ key: active.claim.key,
1162
+ claim: active.claim,
1163
+ type: `effect_${state}`,
1164
+ payload: { effectKey: record.key },
1165
+ });
1166
+ return controllerWorkerResponse(message, "accepted", record);
1167
+ }
1168
+ case "workflow.ensure":
1169
+ case "workflow.changeSettings":
1170
+ case "workflow.queueFollowUp":
1171
+ case "workflow.removeFollowUp": {
1172
+ const workflows = new ControllerWorkflowCoordinator(active.store, this.controllerWorkflowScheduler(active)).forResource(active.resource, active.claim, new AbortController().signal);
1173
+ const result = message.operation === "workflow.ensure"
1174
+ ? await workflows.ensure(payload)
1175
+ : message.operation === "workflow.changeSettings"
1176
+ ? await workflows.changeSettings(payload)
1177
+ : message.operation === "workflow.queueFollowUp"
1178
+ ? await workflows.queueFollowUp(payload)
1179
+ : await workflows.removeFollowUp(payload);
1180
+ return controllerWorkerResponse(message, "accepted", result);
1181
+ }
1182
+ case "worker.finished":
1183
+ this.finishControllerSuccess(active, message.payload);
1184
+ return controllerWorkerResponse(message, "accepted", {});
1185
+ case "worker.failed":
1186
+ this.finishControllerFailure(active, requireString(payload.error, "error"));
1187
+ return controllerWorkerResponse(message, "accepted", {});
1188
+ }
1189
+ }
1190
+ catch (error) {
1191
+ return controllerWorkerResponse(message, "rejected", undefined, errorMessage(error));
1192
+ }
1193
+ }
1194
+ controllerWorkflowScheduler(active) {
1195
+ return {
1196
+ ensure: async (request, _signal, _onComplete) => await this.ensureControllerWorkflow(active, request),
1197
+ changeSettings: async (request) => await this.changeControllerWorkflowSettings(active, request),
1198
+ queueFollowUp: async (request) => {
1199
+ const targetSessionId = this.runStore.originSessionId(request.runId);
1200
+ if (targetSessionId === undefined) {
1201
+ throw new Error("Controller follow-up requires an origin Pi session");
1202
+ }
1203
+ const result = this.runStore.queueFollowUp({
1204
+ runId: request.runId,
1205
+ requestId: request.actorRequestKey,
1206
+ targetSessionId,
1207
+ actor: { type: "controller", id: request.controllerResourceUid },
1208
+ source: "controller-request",
1209
+ prompt: request.prompt,
1210
+ });
1211
+ return {
1212
+ runId: request.runId,
1213
+ followUpId: result.followUp.followUpId,
1214
+ order: result.followUp.order,
1215
+ state: result.followUp.state,
1216
+ adopted: result.adopted,
1217
+ };
236
1218
  },
1219
+ removeFollowUp: async (request) => {
1220
+ const result = this.runStore.removeFollowUp({
1221
+ runId: request.runId,
1222
+ followUpId: request.followUpId,
1223
+ actor: { type: "controller", id: request.controllerResourceUid },
1224
+ source: "controller-request",
1225
+ });
1226
+ return {
1227
+ runId: request.runId,
1228
+ followUpId: result.followUpId,
1229
+ order: result.order,
1230
+ state: result.state,
1231
+ adopted: false,
1232
+ };
1233
+ },
1234
+ };
1235
+ }
1236
+ async changeControllerWorkflowSettings(_active, request) {
1237
+ const run = this.queue.getWorkflowRun(request.runId);
1238
+ if (run === undefined)
1239
+ throw new Error(`Workflow run not found: ${request.runId}`);
1240
+ const scopes = this.runStore.listSettingsScopes(request.runId);
1241
+ const scopeId = request.scopeId ?? (scopes.length === 1 ? scopes[0]?.scopeId : undefined);
1242
+ if (scopeId === undefined) {
1243
+ throw new Error("Controller settings changes require scopeId when several scopes exist");
1244
+ }
1245
+ const scope = this.runStore.getSettingsScope(scopeId);
1246
+ if (scope === undefined || scope.activeRunId !== request.runId) {
1247
+ throw new Error(`Workflow settings scope not found: ${scopeId}`);
1248
+ }
1249
+ const prior = this.state.connection
1250
+ .prepare(`SELECT change_number AS changeNumber
1251
+ FROM workflow_setting_changes WHERE scope_id = ? AND request_id = ?`)
1252
+ .get(scopeId, request.actorRequestKey);
1253
+ if (typeof prior?.changeNumber === "number") {
1254
+ return { runId: request.runId, scopeId, changeNumber: prior.changeNumber, adopted: true };
1255
+ }
1256
+ const projectPath = this.queue.workflowRunProjectPath(request.runId);
1257
+ if (projectPath === undefined)
1258
+ throw new Error("Workflow run project is missing");
1259
+ const resolver = new WorkflowHostClient({
1260
+ databasePath: this.databasePath,
1261
+ ...(this.options.env === undefined ? {} : { env: this.options.env }),
237
1262
  });
238
- if (!store.markWorkflowRunRunning({ runId, claimToken })) {
239
- throw new ClaimLostError(runId);
1263
+ const proposal = await resolver.resolveSettingsChange({
1264
+ cwd: projectPath,
1265
+ workflowRef: run.workflowSourceRef,
1266
+ definitionDigest: run.definitionDigest,
1267
+ mountPath: scope.mountPath,
1268
+ current: scope.settings,
1269
+ patch: request.patch,
1270
+ actorId: request.controllerResourceUid,
1271
+ });
1272
+ if (proposal.definitionDigest !== run.definitionDigest || !Array.isArray(proposal.paths)) {
1273
+ throw new Error("Workflow settings proposal does not match the durable source");
240
1274
  }
241
- const parkEngine = () => engine.park();
242
- this.parkedEngines.push(parkEngine);
243
- const renewTimer = setInterval(() => {
244
- try {
245
- if (!store.renewWorkflowRunClaim({ runId, claimToken, leaseMs: RUN_CLAIM_LEASE_MS })) {
246
- engine.cancel();
1275
+ const proposedSettings = canonicalJson(proposal.settings);
1276
+ const definition = {
1277
+ initial: proposal.settings,
1278
+ paths: proposal.paths,
1279
+ parse: (value) => {
1280
+ if (canonicalJson(value) !== proposedSettings) {
1281
+ throw new Error("Workflow settings proposal changed before commit");
247
1282
  }
1283
+ return proposal.settings;
1284
+ },
1285
+ };
1286
+ const result = await this.runStore.changeSettings(definition, {
1287
+ runId: request.runId,
1288
+ scopeId,
1289
+ requestId: request.actorRequestKey,
1290
+ expectedChangeNumber: request.expectedChangeNumber ?? scope.changeNumber,
1291
+ actor: { type: "controller", id: request.controllerResourceUid },
1292
+ source: "controller-request",
1293
+ patch: proposal.patch,
1294
+ });
1295
+ return {
1296
+ runId: request.runId,
1297
+ scopeId,
1298
+ changeNumber: result.scope.changeNumber,
1299
+ adopted: result.adopted,
1300
+ };
1301
+ }
1302
+ async ensureControllerWorkflow(active, request) {
1303
+ const existing = this.queue.getWorkflowRun(request.runId);
1304
+ if (existing !== undefined)
1305
+ return controllerWorkflowResult(existing);
1306
+ const resolver = new WorkflowHostClient({
1307
+ databasePath: this.databasePath,
1308
+ ...(this.options.env === undefined ? {} : { env: this.options.env }),
1309
+ });
1310
+ const resolved = await resolver.resolveWorkflow({
1311
+ cwd: active.projectPath,
1312
+ workflowRef: request.workflow,
1313
+ });
1314
+ const token = randomUUID();
1315
+ const scoped = new SqliteControllerStore(this.databasePath, {
1316
+ state: this.state,
1317
+ projectPath: active.projectPath,
1318
+ });
1319
+ const prepared = scoped.prepareOrAdoptWorkflowRun({
1320
+ runId: request.runId,
1321
+ workflowName: resolved.workflowName,
1322
+ workflowSourceRef: resolved.workflowSourceRef,
1323
+ workflowSource: resolved.workflowSource,
1324
+ definitionDigest: resolved.definitionDigest,
1325
+ definitionSnapshot: resolved.definitionSnapshot,
1326
+ input: request.input,
1327
+ launchOptions: {},
1328
+ runnerId: this.hostId,
1329
+ claimToken: token,
1330
+ leaseMs: this.runClaimLeaseMs,
1331
+ originSessionId: `controller-${active.resource.metadata.uid}`,
1332
+ executionMode: "headless",
1333
+ });
1334
+ if (prepared.state === "claimed") {
1335
+ this.markPendingStart(request.runId, token);
1336
+ setImmediate(() => void this.activateRun(prepared.run, token));
1337
+ }
1338
+ return controllerWorkflowResult(prepared.run);
1339
+ }
1340
+ finishControllerSuccess(active, result) {
1341
+ if (active.settled)
1342
+ return;
1343
+ const now = new Date();
1344
+ const ref = { controller: active.claim.controller, key: active.claim.key };
1345
+ const status = applyStatusPatch(active.resource.status, result.status, active.resource.metadata.generation, now.toISOString());
1346
+ try {
1347
+ const updated = active.store.updateStatus({
1348
+ ref,
1349
+ expectedResourceVersion: active.claim.resourceVersion,
1350
+ claim: active.claim,
1351
+ status,
1352
+ ...(result.status?.finalizers === undefined
1353
+ ? {}
1354
+ : { finalizers: result.status.finalizers }),
1355
+ now: now.toISOString(),
1356
+ });
1357
+ active.store.recordEvent({
1358
+ ...ref,
1359
+ claim: active.claim,
1360
+ type: "reconcile_finished",
1361
+ payload: {
1362
+ reconcileId: active.reconcileId,
1363
+ result: result.kind,
1364
+ generation: active.resource.metadata.generation,
1365
+ durationMs: Math.max(0, now.getTime() - active.startedAt),
1366
+ ...(result.kind === "requeue" && result.afterMs !== undefined
1367
+ ? { requeueAfterMs: result.afterMs }
1368
+ : {}),
1369
+ },
1370
+ });
1371
+ if (result.kind === "settled" &&
1372
+ updated.metadata.deletionTimestamp !== undefined &&
1373
+ updated.metadata.finalizers.length === 0) {
1374
+ active.store.recordEvent({
1375
+ ...ref,
1376
+ claim: active.claim,
1377
+ type: "resource_deleted",
1378
+ payload: { reconcileId: active.reconcileId },
1379
+ });
1380
+ active.store.deleteResource(ref, active.claim.resourceVersion, active.claim);
248
1381
  }
249
- catch {
250
- // Transient store errors leave fencing to decide ownership.
1382
+ else if (result.kind === "settled") {
1383
+ active.store.settleClaim(active.claim, now.toISOString());
251
1384
  }
252
- }, RUN_CLAIM_RENEW_MS);
253
- renewTimer.unref?.();
254
- this.recordEvent(runId, record.workflowName, "resumed", { runnerId: this.runnerId });
255
- try {
256
- const humanDecision = record.parentRunId === null ? undefined : hostHumanDecision(record.launchOptions);
257
- const result = record.initialized
258
- ? await engine.resumeRun(workflow, runId, { workflowSource })
259
- : record.parentRunId === null
260
- ? await engine.run(workflow, record.input, { workflowSource, runId })
261
- : await engine.continueRun(workflow, record.parentRunId, record.input, {
262
- workflowSource,
263
- runId,
264
- ...(humanDecision === undefined ? {} : { humanDecision }),
265
- });
266
- clearInterval(renewTimer);
267
- if (result.state.status === "running" || result.state.status === "waiting") {
268
- // A drained run, visible-assistant handoff, or new checkpoint stays
269
- // available to its next owner. Skip waits in this host process so it
270
- // cannot reclaim the same run in a tight loop.
271
- this.store.parkWorkflowRun({ runId, claimToken });
272
- this.store.settleRunEffect(runId, "run.park_queue");
273
- const waitingForOrigin = result.state.status === "running" &&
274
- result.state.statusDetail === "waiting for origin Pi session";
275
- if (result.state.status === "waiting" || waitingForOrigin)
276
- this.skippedRuns.add(runId);
277
- this.recordEvent(runId, record.workflowName, "parked", {
278
- status: result.state.status,
279
- ...(waitingForOrigin ? { reason: "waiting for origin Pi session" } : {}),
1385
+ else {
1386
+ active.store.requeueClaim(active.claim, { availableAt: new Date(now.getTime() + (result.afterMs ?? 0)).toISOString() }, now.toISOString());
1387
+ }
1388
+ active.settled = true;
1389
+ }
1390
+ catch (error) {
1391
+ if (error instanceof ResourceConflictError) {
1392
+ active.store.recordEvent({
1393
+ ...ref,
1394
+ claim: active.claim,
1395
+ type: "reconcile_conflict",
1396
+ payload: { reconcileId: active.reconcileId },
280
1397
  });
281
- this.log(`parked ${record.workflowName} run ${runId}${waitingForOrigin ? ": waiting for origin Pi session" : ""}`);
1398
+ active.store.requeueClaim(active.claim, { availableAt: now.toISOString() }, now.toISOString());
1399
+ active.settled = true;
282
1400
  return;
283
1401
  }
284
- this.store.completeWorkflowRun({ runId, claimToken });
285
- this.store.settleRunEffect(runId, "run.settle_queue");
286
- this.recordEvent(runId, record.workflowName, result.state.status, {
287
- ...(result.state.error !== undefined ? { error: result.state.error } : {}),
288
- ...(result.state.waitingOn !== undefined ? { waitingOn: result.state.waitingOn } : {}),
1402
+ throw error;
1403
+ }
1404
+ }
1405
+ finishControllerFailure(active, failure) {
1406
+ if (active.settled)
1407
+ return;
1408
+ const now = new Date();
1409
+ const delay = Math.min(60_000, 1_000 * 2 ** Math.min(active.claim.consecutiveErrors, 6));
1410
+ const error = failure.slice(0, 8_192);
1411
+ active.store.recordEvent({
1412
+ controller: active.claim.controller,
1413
+ key: active.claim.key,
1414
+ claim: active.claim,
1415
+ type: "reconcile_failed",
1416
+ payload: {
1417
+ reconcileId: active.reconcileId,
1418
+ error,
1419
+ durationMs: Math.max(0, now.getTime() - active.startedAt),
1420
+ requeueAfterMs: delay,
1421
+ },
1422
+ });
1423
+ active.store.requeueClaim(active.claim, { availableAt: new Date(now.getTime() + delay).toISOString(), error }, now.toISOString());
1424
+ active.settled = true;
1425
+ }
1426
+ async expireTimedOutDecision() {
1427
+ if (this.stopping || this.claim === null || this.decisionTimeoutActive)
1428
+ return;
1429
+ this.decisionTimeoutActive = true;
1430
+ try {
1431
+ const now = new Date();
1432
+ const requests = await this.decisions.listExpiredDefaultRequests(now);
1433
+ const candidate = requests
1434
+ .map((request) => ({
1435
+ request,
1436
+ interaction: this.hostState.getInteraction(request.decisionId),
1437
+ }))
1438
+ .find(({ request, interaction }) => interaction?.kind === "decision" &&
1439
+ interaction.runId === request.runId &&
1440
+ ["pending", "presenting"].includes(interaction.status) &&
1441
+ !this.activeRuns.has(request.runId) &&
1442
+ !this.pendingStarts.has(request.runId));
1443
+ if (candidate === undefined ||
1444
+ candidate.interaction === undefined ||
1445
+ candidate.request.defaultResponse === undefined) {
1446
+ return;
1447
+ }
1448
+ const { request, interaction } = candidate;
1449
+ const claimToken = randomUUID();
1450
+ const claimed = this.queue.claimWorkflowRunForControl({
1451
+ runId: request.runId,
1452
+ runnerId: this.hostId,
1453
+ claimToken,
1454
+ leaseMs: this.runClaimLeaseMs,
289
1455
  });
290
- this.log(`${record.workflowName} run ${runId} ${result.state.status}`);
1456
+ if (claimed === undefined)
1457
+ return;
1458
+ this.controlClaims.set(request.runId, claimToken);
1459
+ let committed = false;
1460
+ const afterCommit = [];
1461
+ try {
1462
+ this.state.transaction(() => {
1463
+ const accepted = this.decisions.resolveTimeoutSync(request, new Date());
1464
+ const submissionId = `timeout-${request.decisionId}`;
1465
+ this.hostState.submitInteraction({
1466
+ requestId: interaction.requestId,
1467
+ submissionId,
1468
+ idempotencyKey: submissionId,
1469
+ expectedRevision: interaction.revision,
1470
+ payload: request.defaultResponse,
1471
+ accepted: true,
1472
+ receipt: accepted.decision,
1473
+ });
1474
+ if (!this.queue.parkWorkflowRun({ runId: request.runId, claimToken })) {
1475
+ throw new Error("Decision timeout could not release the parent run claim");
1476
+ }
1477
+ this.prepareDecisionContinuation(interaction, request, accepted.decision, afterCommit);
1478
+ });
1479
+ committed = true;
1480
+ for (const effect of afterCommit)
1481
+ setImmediate(effect);
1482
+ this.log(`human decision ${request.decisionId} resolved by its timeout policy`);
1483
+ }
1484
+ catch (error) {
1485
+ this.log(`human decision timeout failed for ${request.decisionId}: ${errorMessage(error)}`);
1486
+ }
1487
+ finally {
1488
+ this.controlClaims.delete(request.runId);
1489
+ if (!committed)
1490
+ this.queue.parkWorkflowRun({ runId: request.runId, claimToken });
1491
+ }
291
1492
  }
292
1493
  catch (error) {
293
- clearInterval(renewTimer);
294
- if (isClaimLostError(error)) {
295
- this.log(`run ${runId} continues under another runner`);
1494
+ this.log(`human decision timeout scan failed: ${errorMessage(error)}`);
1495
+ }
1496
+ finally {
1497
+ this.decisionTimeoutActive = false;
1498
+ }
1499
+ }
1500
+ expireTimedOutInteraction() {
1501
+ if (this.stopping || this.claim === null)
1502
+ return;
1503
+ const runId = this.hostState
1504
+ .expiredInteractionRunIds()
1505
+ .find((candidate) => !this.activeRuns.has(candidate) && !this.pendingStarts.has(candidate));
1506
+ if (runId === undefined)
1507
+ return;
1508
+ const claimToken = randomUUID();
1509
+ const claimed = this.queue.claimWorkflowRunForControl({
1510
+ runId,
1511
+ runnerId: this.hostId,
1512
+ claimToken,
1513
+ leaseMs: this.runClaimLeaseMs,
1514
+ });
1515
+ if (claimed === undefined)
1516
+ return;
1517
+ let activated = false;
1518
+ try {
1519
+ activated = this.queue.beginWorkflowRunInteractionTimeout({ runId, claimToken });
1520
+ if (activated) {
1521
+ this.markPendingStart(runId, claimToken);
1522
+ setImmediate(() => void this.activateRun(claimed, claimToken));
1523
+ this.log(`run ${runId} is finishing its expired origin-session deadline`);
1524
+ }
1525
+ }
1526
+ catch (error) {
1527
+ this.log(`interaction timeout failed for run ${runId}: ${errorMessage(error)}`);
1528
+ }
1529
+ finally {
1530
+ if (!activated)
1531
+ this.queue.parkWorkflowRun({ runId, claimToken });
1532
+ }
1533
+ }
1534
+ markPendingStart(runId, claimToken) {
1535
+ this.pendingStarts.add(runId);
1536
+ this.pendingRunClaims.set(runId, claimToken);
1537
+ }
1538
+ clearPendingStart(runId, claimToken) {
1539
+ if (this.pendingRunClaims.get(runId) !== claimToken)
1540
+ return;
1541
+ this.pendingRunClaims.delete(runId);
1542
+ this.pendingStarts.delete(runId);
1543
+ }
1544
+ async claimOne() {
1545
+ if (this.stopping || this.claim === null)
1546
+ return;
1547
+ const excluded = new Set([
1548
+ ...this.activeRuns.keys(),
1549
+ ...this.pendingStarts,
1550
+ ...this.blockedRuns,
1551
+ ]);
1552
+ const validatingRunId = this.hostState
1553
+ .validatingInteractionRunIds()
1554
+ .find((runId) => !excluded.has(runId));
1555
+ if (validatingRunId !== undefined) {
1556
+ const validationToken = randomUUID();
1557
+ const validationRun = this.queue.claimWorkflowRunForInteractionValidation({
1558
+ runId: validatingRunId,
1559
+ runnerId: this.hostId,
1560
+ claimToken: validationToken,
1561
+ leaseMs: this.runClaimLeaseMs,
1562
+ });
1563
+ if (validationRun !== undefined) {
1564
+ this.markPendingStart(validatingRunId, validationToken);
1565
+ await this.activateRun(validationRun, validationToken);
296
1566
  return;
297
1567
  }
298
- if (error instanceof WorkflowSourceChangedError) {
299
- // Edited source is a refusal, not a failure: keep the run claimable
300
- // for a later fix, and stop spinning on it for this host's lifetime.
301
- try {
302
- this.store.parkWorkflowRun({ runId, claimToken });
303
- }
304
- catch {
305
- // Best-effort.
1568
+ }
1569
+ const token = randomUUID();
1570
+ const claimed = this.queue.claimNextWorkflowRun({
1571
+ runnerId: this.hostId,
1572
+ claimToken: token,
1573
+ leaseMs: this.runClaimLeaseMs,
1574
+ excludeRunIds: [...excluded],
1575
+ });
1576
+ if (claimed === undefined)
1577
+ return;
1578
+ this.markPendingStart(claimed.runId, token);
1579
+ await this.activateRun(claimed, token);
1580
+ }
1581
+ activateRun(record, claimToken) {
1582
+ const existing = this.activationTasks.get(record.runId);
1583
+ if (existing !== undefined)
1584
+ return existing;
1585
+ const task = this.activateRunNow(record, claimToken).finally(() => {
1586
+ if (this.activationTasks.get(record.runId) === task) {
1587
+ this.activationTasks.delete(record.runId);
1588
+ }
1589
+ if (this.stopping)
1590
+ return;
1591
+ if (this.pendingResumes.delete(record.runId)) {
1592
+ setImmediate(() => void this.resumePendingRun(record.runId));
1593
+ }
1594
+ else {
1595
+ setImmediate(() => void this.claimOne());
1596
+ }
1597
+ });
1598
+ this.activationTasks.set(record.runId, task);
1599
+ return task;
1600
+ }
1601
+ async activateRunNow(record, claimToken) {
1602
+ const runId = record.runId;
1603
+ if (this.pendingRunClaims.get(runId) !== claimToken)
1604
+ return;
1605
+ if (this.stopping) {
1606
+ this.clearPendingStart(runId, claimToken);
1607
+ this.queue.parkWorkflowRun({ runId, claimToken });
1608
+ return;
1609
+ }
1610
+ const existingActive = this.activeRuns.get(runId);
1611
+ if (existingActive !== undefined) {
1612
+ this.clearPendingStart(runId, claimToken);
1613
+ if (existingActive.claimToken !== claimToken) {
1614
+ this.queue.parkWorkflowRun({ runId, claimToken });
1615
+ }
1616
+ return;
1617
+ }
1618
+ const generation = record.claimGeneration;
1619
+ const projectPath = this.queue.workflowRunProjectPath(runId);
1620
+ if (generation === null || projectPath === undefined || this.claim === null) {
1621
+ this.clearPendingStart(runId, claimToken);
1622
+ this.queue.parkWorkflowRun({ runId, claimToken });
1623
+ return;
1624
+ }
1625
+ const envelope = {
1626
+ schema: "pi-workflows.worker-launch.v1",
1627
+ runId,
1628
+ generation,
1629
+ workerEpoch: randomUUID(),
1630
+ projectPath,
1631
+ workflowSource: record.workflowSource,
1632
+ definitionDigest: record.definitionDigest,
1633
+ inputHash: `sha256:${createHash("sha256").update(canonicalJson(record.input)).digest("hex")}`,
1634
+ protocolVersion: 1,
1635
+ };
1636
+ this.hostState.recordWorkerStart(envelope, this.claim.epoch);
1637
+ let active;
1638
+ const supervisor = new WorkflowWorkerSupervisor(envelope, {
1639
+ registry: this.registry,
1640
+ onMessage: async (message) => await this.handleWorkerMessage(message),
1641
+ ...(this.options.env === undefined ? {} : { env: this.options.env }),
1642
+ ...(this.options.workerEntryPath === undefined
1643
+ ? {}
1644
+ : { workerEntryPath: this.options.workerEntryPath }),
1645
+ ...(this.options.workerStartupTimeoutMs === undefined
1646
+ ? {}
1647
+ : { startupTimeoutMs: this.options.workerStartupTimeoutMs }),
1648
+ onSpawn: (identity) => {
1649
+ active.workerPid = identity.pid;
1650
+ this.hostState.attachWorkerProcess(envelope.workerEpoch, identity.pid, identity.startIdentity);
1651
+ },
1652
+ onDiagnostic: (message) => this.log(`worker ${envelope.workerEpoch}: ${message}`),
1653
+ });
1654
+ active = { record, claimToken, generation, supervisor, exiting: false };
1655
+ this.activeRuns.set(runId, active);
1656
+ this.clearPendingStart(runId, claimToken);
1657
+ try {
1658
+ this.runStore.synchronizeRevision(runId);
1659
+ const effectRecovery = await this.runStore.recoverApplyingEffects(runId);
1660
+ if (effectRecovery === "ambiguous" || hasUncertainEffect(this.state, runId)) {
1661
+ if (!this.queue.parkWorkflowRunForAmbiguousEffect({ runId, claimToken })) {
1662
+ throw new Error("Run claim was lost during ambiguous-effect recovery");
306
1663
  }
307
- this.skippedRuns.add(runId);
308
- this.recordEvent(runId, record.workflowName, "parked", {
309
- reason: "workflow source changed",
1664
+ this.hostState.finishWorker({
1665
+ workerEpoch: envelope.workerEpoch,
1666
+ outcome: "orphaned",
1667
+ diagnostic: "effect outcome is ambiguous",
310
1668
  });
311
- this.log(`run ${runId} skipped: workflow source changed; revert or force-resume it, then restart the host`);
312
1669
  return;
313
1670
  }
314
- await this.failUnresumable(record, claimToken, errorMessage(error));
1671
+ await supervisor.start();
1672
+ const result = await supervisor.wait();
1673
+ this.hostState.finishWorker({
1674
+ workerEpoch: envelope.workerEpoch,
1675
+ outcome: result.outcome,
1676
+ exitCode: result.exitCode,
1677
+ signal: result.signal,
1678
+ ...(result.diagnostic === undefined ? {} : { diagnostic: result.diagnostic }),
1679
+ });
1680
+ if (active.control === undefined &&
1681
+ (result.outcome !== "exited" ||
1682
+ this.hostState.validatingInteraction(active.record.runId) !== undefined)) {
1683
+ await this.recoverWorkerExit(active, result.outcome);
1684
+ }
315
1685
  }
316
- finally {
317
- const index = this.parkedEngines.indexOf(parkEngine);
318
- if (index !== -1) {
319
- this.parkedEngines.splice(index, 1);
1686
+ catch (error) {
1687
+ this.log(`worker ${envelope.workerEpoch} failed: ${errorMessage(error)}`);
1688
+ await supervisor.stop("crashed");
1689
+ const result = await supervisor.wait();
1690
+ try {
1691
+ this.hostState.finishWorker({
1692
+ workerEpoch: envelope.workerEpoch,
1693
+ outcome: "crashed",
1694
+ exitCode: result.exitCode,
1695
+ signal: result.signal,
1696
+ diagnostic: result.diagnostic ?? errorMessage(error),
1697
+ });
1698
+ }
1699
+ catch {
1700
+ // A completed worker record wins.
320
1701
  }
321
- fencedStore.close();
322
- await executor.close().catch(() => undefined);
1702
+ if (active.control === undefined) {
1703
+ await this.recoverWorkerExit(active, "crashed");
1704
+ }
1705
+ }
1706
+ finally {
1707
+ this.reapWorkerDescendants(envelope.workerEpoch);
1708
+ this.activeRuns.delete(runId);
323
1709
  }
324
1710
  }
325
- async failUnresumable(record, claimToken, message) {
326
- let actualStatus;
327
- let heldClaim = false;
1711
+ async resumePendingRun(runId) {
1712
+ if (this.stopping || this.claim === null)
1713
+ return;
1714
+ const token = randomUUID();
1715
+ const claimed = this.queue.claimWorkflowRunForInteractionValidation({
1716
+ runId,
1717
+ runnerId: this.hostId,
1718
+ claimToken: token,
1719
+ leaseMs: this.runClaimLeaseMs,
1720
+ });
1721
+ if (claimed === undefined) {
1722
+ await this.claimOne();
1723
+ return;
1724
+ }
1725
+ this.markPendingStart(runId, token);
1726
+ await this.activateRun(claimed, token);
1727
+ }
1728
+ async handleWorkerMessage(message) {
1729
+ const active = this.activeRuns.get(message.runId);
1730
+ if (active === undefined ||
1731
+ active.generation !== message.generation ||
1732
+ active.supervisor.envelope.workerEpoch !== message.workerEpoch) {
1733
+ return workerResponse(message, "claimLost", undefined, "Worker epoch is stale");
1734
+ }
1735
+ const stored = this.hostState.readWorkerMessage(message);
1736
+ if (stored !== undefined)
1737
+ return stored;
1738
+ const response = await this.handleFreshWorkerMessage(active, message);
1739
+ return this.hostState.recordWorkerMessage(message, response);
1740
+ }
1741
+ async handleFreshWorkerMessage(active, message) {
1742
+ if (message.operation === "worker.ready") {
1743
+ if (!this.queue.verifyWorkflowRunClaim({ runId: message.runId, claimToken: active.claimToken })) {
1744
+ active.claimLost = true;
1745
+ setImmediate(() => void active.supervisor.stop("claimLost"));
1746
+ return workerResponse(message, "claimLost", undefined, "Run claim expired");
1747
+ }
1748
+ this.hostState.markWorkerReady(message.workerEpoch);
1749
+ this.queue.markWorkflowRunRunning({ runId: message.runId, claimToken: active.claimToken });
1750
+ const revision = this.runStore.synchronizeRevision(message.runId);
1751
+ const current = this.queue.getWorkflowRun(message.runId);
1752
+ const candidateInteraction = this.hostState.acceptedInteraction(message.runId) ??
1753
+ this.hostState.validatingInteraction(message.runId);
1754
+ const timedOutInteraction = this.hostState.timedOutInteraction(message.runId);
1755
+ const resumeInteractionAttemptId = candidateInteraction?.attemptId ?? timedOutInteraction?.attemptId;
1756
+ return workerResponse(message, "accepted", {
1757
+ initialized: current?.initialized ?? active.record.initialized,
1758
+ input: active.record.input,
1759
+ launchOptions: active.record.launchOptions,
1760
+ parentRunId: active.record.parentRunId,
1761
+ originSessionId: active.record.originSessionId,
1762
+ stateDirectory: this.stateDirectory,
1763
+ ...(resumeInteractionAttemptId === undefined ? {} : { resumeInteractionAttemptId }),
1764
+ ...(candidateInteraction === undefined ? {} : { candidateInteraction }),
1765
+ ...(this.options.piArgs === undefined ? {} : { piArgs: this.options.piArgs }),
1766
+ }, undefined, revision);
1767
+ }
1768
+ if (message.operation === "worker.exiting") {
1769
+ const payload = requireRecord(message.payload, "worker exit payload");
1770
+ if (payload.status === "workflowLoadFailed") {
1771
+ active.workflowLoadFailure = requireString(payload.error, "workflow load error").slice(0, 8_192);
1772
+ }
1773
+ active.exiting = true;
1774
+ return workerResponse(message, "accepted", {});
1775
+ }
1776
+ if (message.operation === "process.register" || message.operation === "process.unregister") {
1777
+ return this.handleWorkerProcessOperation(active, message);
1778
+ }
1779
+ const currentRevision = runRevision(this.state, message.runId);
1780
+ if (message.expectedRevision !== currentRevision) {
1781
+ return workerResponse(message, "rejected", undefined, `Workflow run revision conflict: expected ${message.expectedRevision}, got ${currentRevision}`, currentRevision);
1782
+ }
1783
+ if (!this.queue.verifyWorkflowRunClaim({ runId: message.runId, claimToken: active.claimToken })) {
1784
+ active.claimLost = true;
1785
+ setImmediate(() => void active.supervisor.stop("claimLost"));
1786
+ return workerResponse(message, "claimLost", undefined, "Run claim expired");
1787
+ }
328
1788
  try {
329
- // The interruption write obeys the same fencing rule as every other
330
- // state write: without a live claim, the current owner decides.
331
- heldClaim = this.store.verifyWorkflowRunClaim({ runId: record.runId, claimToken });
332
- if (heldClaim) {
333
- const claimedStore = new WorkflowRunStore(this.store.filePath, {
334
- state: this.store.state,
335
- authorityProvider: () => this.store.workflowRunAuthority(record.runId, claimToken),
336
- });
337
- const bundle = await claimedStore.markRunInterrupted(record.runId, message);
338
- actualStatus = bundle?.state.status;
1789
+ const payload = requireRecord(message.payload, "worker payload");
1790
+ let result;
1791
+ switch (message.operation) {
1792
+ case "store.initializeRun":
1793
+ result = await this.runStore.initializeRunFromSnapshot(payload.snapshot, requireString(payload.workflowName, "workflowName"), payload.state, payload.options);
1794
+ break;
1795
+ case "store.prepareRunResume":
1796
+ await this.prepareInteractionResume(message.runId);
1797
+ result = await this.runStore.prepareRunResume(message.runId);
1798
+ break;
1799
+ case "store.readRun":
1800
+ result = this.runStore.readRun(requireString(payload.runId, "runId"), payload.options);
1801
+ break;
1802
+ case "store.writeSnapshot":
1803
+ result = await this.runStore.writeSnapshot(message.runId, payload.state, payload.event);
1804
+ break;
1805
+ case "store.publishUpdate":
1806
+ result = await this.runStore.publishUpdate(message.runId, payload.state, requireString(payload.nodeId, "nodeId"), requireString(payload.attemptId, "attemptId"), payload.update);
1807
+ break;
1808
+ case "store.findSettingsScope":
1809
+ result = this.runStore.findSettingsScope(message.runId, requireString(payload.mountPath, "mountPath", true), requireNonNegativeInteger(payload.invocation, "invocation"));
1810
+ break;
1811
+ case "store.ensureSettingsScope":
1812
+ result = this.runStore.ensureSettingsScope(payload.options);
1813
+ break;
1814
+ case "store.getSettingsScopeAtChange":
1815
+ result = this.runStore.getSettingsScopeAtChange(requireString(payload.scopeId, "scopeId"), requireNonNegativeInteger(payload.changeNumber, "changeNumber"));
1816
+ break;
1817
+ case "store.createHumanDecisionRequest": {
1818
+ const decision = payload.request;
1819
+ result = await this.runStore.createHumanDecisionRequest(decision);
1820
+ if (active.record.originSessionId !== null) {
1821
+ this.hostState.createInteractiveRequest({
1822
+ requestId: decision.decisionId,
1823
+ runId: decision.runId,
1824
+ attemptId: decision.attemptId,
1825
+ targetSessionId: active.record.originSessionId,
1826
+ kind: "decision",
1827
+ contract: decision,
1828
+ });
1829
+ }
1830
+ break;
1831
+ }
1832
+ case "store.readResolvedHumanDecision":
1833
+ result = await this.runStore.readResolvedHumanDecision(requireString(payload.decisionId, "decisionId"));
1834
+ break;
1835
+ case "store.reserveEffect":
1836
+ result = await this.runStore.reserveEffect(payload.options);
1837
+ if (typeof result === "object" &&
1838
+ result !== null &&
1839
+ result.disposition === "ambiguous" &&
1840
+ !this.queue.parkWorkflowRunForAmbiguousEffect({
1841
+ runId: message.runId,
1842
+ claimToken: active.claimToken,
1843
+ })) {
1844
+ throw new Error("Run claim was lost during ambiguous-effect recovery");
1845
+ }
1846
+ break;
1847
+ case "store.settleEffect": {
1848
+ const options = payload.options;
1849
+ await this.runStore.settleEffect(payload.options);
1850
+ if (options.outcome === "ambiguous" &&
1851
+ !this.queue.parkWorkflowRunForAmbiguousEffect({
1852
+ runId: message.runId,
1853
+ claimToken: active.claimToken,
1854
+ })) {
1855
+ throw new Error("Run claim was lost during ambiguous-effect recovery");
1856
+ }
1857
+ result = null;
1858
+ break;
1859
+ }
1860
+ case "interaction.accept":
1861
+ result = this.acceptWorkerInteraction(message, payload);
1862
+ break;
1863
+ case "interaction.reject":
1864
+ result = this.rejectWorkerInteraction(active, message, payload);
1865
+ break;
1866
+ case "notification.request":
1867
+ result = this.enqueueWorkerNotification(active, message, payload);
1868
+ break;
1869
+ case "presentation.request":
1870
+ result = this.requestWorkerPresentation(active, message, payload);
1871
+ break;
1872
+ case "interaction.request": {
1873
+ result = this.parkForInteraction(active, message, payload);
1874
+ const exitDeadline = setTimeout(() => {
1875
+ if (this.activeRuns.get(message.runId) === active && !active.exiting) {
1876
+ active.control = "handoff";
1877
+ void active.supervisor.stop("orphaned");
1878
+ }
1879
+ }, 1_000);
1880
+ exitDeadline.unref?.();
1881
+ break;
1882
+ }
1883
+ default:
1884
+ return workerResponse(message, "rejected", undefined, "Unknown worker operation");
339
1885
  }
1886
+ return workerResponse(message, "accepted", result === undefined ? null : result, undefined, runRevision(this.state, message.runId));
340
1887
  }
341
- catch {
342
- // The run state may be unreadable; the queue row still needs closure.
1888
+ catch (error) {
1889
+ return workerResponse(message, "rejected", undefined, errorMessage(error));
1890
+ }
1891
+ }
1892
+ handleWorkerProcessOperation(active, message) {
1893
+ if (message.operation === "process.register" &&
1894
+ !this.queue.verifyWorkflowRunClaim({ runId: message.runId, claimToken: active.claimToken })) {
1895
+ active.claimLost = true;
1896
+ setImmediate(() => void active.supervisor.stop("claimLost"));
1897
+ return workerResponse(message, "claimLost", undefined, "Run claim expired");
343
1898
  }
344
1899
  try {
345
- this.store.completeWorkflowRun({ runId: record.runId, claimToken });
346
- this.store.settleRunEffect(record.runId, "run.settle_queue");
1900
+ const payload = requireRecord(message.payload, "worker process payload");
1901
+ const pid = requireNonNegativeInteger(payload.pid, "pid");
1902
+ if (pid === 0)
1903
+ throw new Error("Worker process PID must be positive");
1904
+ const owned = this.workerDescendants.get(message.workerEpoch);
1905
+ if (message.operation === "process.unregister") {
1906
+ if (owned?.has(pid) !== true) {
1907
+ throw new Error("Worker process is not registered to this worker epoch");
1908
+ }
1909
+ this.registry.unregister(pid);
1910
+ owned.delete(pid);
1911
+ if (owned.size === 0)
1912
+ this.workerDescendants.delete(message.workerEpoch);
1913
+ return workerResponse(message, "accepted", { pid });
1914
+ }
1915
+ if (active.workerPid === undefined || processParentPid(pid) !== active.workerPid) {
1916
+ throw new Error("Worker process is not a direct child of the active worker");
1917
+ }
1918
+ for (const [workerEpoch, pids] of this.workerDescendants) {
1919
+ if (workerEpoch !== message.workerEpoch && pids.has(pid)) {
1920
+ throw new Error("Worker process is already registered to another worker epoch");
1921
+ }
1922
+ }
1923
+ this.registry.register(pid);
1924
+ const descendants = owned ?? new Set();
1925
+ descendants.add(pid);
1926
+ this.workerDescendants.set(message.workerEpoch, descendants);
1927
+ return workerResponse(message, "accepted", { pid });
347
1928
  }
348
- catch {
349
- // Best-effort.
1929
+ catch (error) {
1930
+ return workerResponse(message, "rejected", undefined, errorMessage(error));
350
1931
  }
351
- if (!heldClaim) {
352
- // Another runner owns the run; its owner reports from here.
353
- this.log(`run ${record.runId} continues under another runner`);
1932
+ }
1933
+ reapWorkerDescendants(workerEpoch) {
1934
+ const descendants = this.workerDescendants.get(workerEpoch);
1935
+ if (descendants === undefined)
354
1936
  return;
1937
+ this.workerDescendants.delete(workerEpoch);
1938
+ for (const pid of descendants)
1939
+ this.registry.kill(pid);
1940
+ }
1941
+ acceptWorkerInteraction(message, payload) {
1942
+ const candidate = this.requireWorkerInteraction(message, payload, true);
1943
+ return this.hostState.finishInteractionValidation({
1944
+ requestId: candidate.requestId,
1945
+ submissionId: candidate.submissionId,
1946
+ accepted: true,
1947
+ receipt: { status: "accepted" },
1948
+ });
1949
+ }
1950
+ rejectWorkerInteraction(active, message, payload) {
1951
+ const candidate = this.requireWorkerInteraction(message, payload, false);
1952
+ return this.settleRejectedInteraction(active, candidate, requireString(payload.error, "interaction validation error"));
1953
+ }
1954
+ requireWorkerInteraction(message, payload, acceptSettled) {
1955
+ const candidate = this.hostState.validatingInteraction(message.runId) ??
1956
+ (acceptSettled ? this.hostState.acceptedInteraction(message.runId) : undefined);
1957
+ if (candidate === undefined ||
1958
+ candidate.requestId !== requireString(payload.requestId, "interaction requestId") ||
1959
+ candidate.submissionId !== requireString(payload.submissionId, "interaction submissionId") ||
1960
+ candidate.attemptId !== message.attemptId ||
1961
+ candidate.attemptId !== requireString(payload.attemptId, "interaction attemptId")) {
1962
+ throw new Error("Interactive submission does not match the worker candidate");
1963
+ }
1964
+ return candidate;
1965
+ }
1966
+ settleRejectedInteraction(active, candidate, error) {
1967
+ const result = this.state.transaction(() => {
1968
+ const submission = this.hostState.finishInteractionValidation({
1969
+ requestId: candidate.requestId,
1970
+ submissionId: candidate.submissionId,
1971
+ accepted: false,
1972
+ receipt: { status: "rejected", error },
1973
+ });
1974
+ const now = Date.now();
1975
+ this.state.connection
1976
+ .prepare(`UPDATE node_attempts SET status = 'waiting', updated_at = ?
1977
+ WHERE attempt_id = ? AND run_id = ? AND status IN ('running', 'interrupted')`)
1978
+ .run(now, candidate.attemptId, active.record.runId);
1979
+ this.state.connection
1980
+ .prepare(`UPDATE runs SET status = 'waiting', status_detail = ?, updated_at = ?, finished_at = ?
1981
+ WHERE run_id = ? AND status = 'running'`)
1982
+ .run(error, now, now, active.record.runId);
1983
+ if (!this.queue.parkWorkflowRun({
1984
+ runId: active.record.runId,
1985
+ claimToken: active.claimToken,
1986
+ })) {
1987
+ throw new Error("Rejected interaction could not release the run claim");
1988
+ }
1989
+ recordViewerDeltas(this.state, active.record.runId, [{ targetType: "summary" }, { targetType: "replay" }, { targetType: "conversation" }], now);
1990
+ return submission;
1991
+ });
1992
+ active.control = "handoff";
1993
+ return result;
1994
+ }
1995
+ enqueueWorkerNotification(active, message, payload) {
1996
+ if (active.record.originSessionId === null) {
1997
+ throw new Error("Workflow notification has no origin Pi session");
355
1998
  }
356
- // Report the run's real terminal state when the interruption was a
357
- // no-op (the run was already waiting or completed), so the feed
358
- // stays truthful for sessions syncing from it.
359
- if (actualStatus !== undefined && actualStatus !== "failed") {
360
- this.recordEvent(record.runId, record.workflowName, actualStatus, {});
1999
+ const request = requireRecord(payload.request, "notification request");
2000
+ if (requireString(request.runId, "notification.runId") !== message.runId ||
2001
+ requireString(request.workflowName, "notification.workflowName") !==
2002
+ active.record.workflowName) {
2003
+ throw new Error("Workflow notification provenance does not match the active run");
361
2004
  }
362
- else {
363
- this.recordEvent(record.runId, record.workflowName, "failed", { error: message });
2005
+ const notificationIndex = requireNonNegativeInteger(request.notificationIndex, "notificationIndex");
2006
+ if (notificationIndex === 0)
2007
+ throw new Error("notificationIndex must be positive");
2008
+ const kind = request.kind;
2009
+ if (kind !== "progress" && kind !== "final") {
2010
+ throw new Error("Workflow notification kind is invalid");
2011
+ }
2012
+ const record = this.queue.enqueueWorkflowNotification({
2013
+ runId: message.runId,
2014
+ nodeId: requireString(request.nodeId, "notification.nodeId"),
2015
+ attemptId: requireString(request.attemptId, "notification.attemptId"),
2016
+ notificationIndex,
2017
+ targetSessionId: active.record.originSessionId,
2018
+ kind,
2019
+ content: requireString(request.content, "notification.content"),
2020
+ });
2021
+ return {
2022
+ notificationId: record.notificationId,
2023
+ targetSessionId: record.targetSessionId,
2024
+ };
2025
+ }
2026
+ requestWorkerPresentation(active, message, payload) {
2027
+ if (active.record.originSessionId === null) {
2028
+ throw new Error("Workflow presentation has no origin Pi session");
2029
+ }
2030
+ const instructions = requireString(payload.instructions, "presentation instructions").trim();
2031
+ if (instructions.length === 0)
2032
+ throw new Error("Presentation instructions must not be empty");
2033
+ const facts = {
2034
+ schema: "pi-workflows.deferred-turn-facts.v1",
2035
+ workflowName: active.record.workflowName,
2036
+ runId: message.runId,
2037
+ observedState: "completed",
2038
+ cause: "terminal",
2039
+ nodeId: null,
2040
+ attemptId: null,
2041
+ reason: null,
2042
+ handoff: false,
2043
+ presentationPrompt: instructions,
2044
+ };
2045
+ const intent = this.queue.ensureWorkflowTurnIntent({
2046
+ intentId: `presentation-${message.runId}`,
2047
+ sourceEventId: `presentation-request-${message.runId}`,
2048
+ runId: message.runId,
2049
+ workflowRef: active.record.workflowSourceRef,
2050
+ targetSessionId: active.record.originSessionId,
2051
+ cause: "terminal",
2052
+ fallbackFacts: facts,
2053
+ eligible: false,
2054
+ });
2055
+ return intent;
2056
+ }
2057
+ parkForInteraction(active, message, payload) {
2058
+ if (active.record.originSessionId === null) {
2059
+ throw new Error("Interactive request has no origin Pi session");
2060
+ }
2061
+ const attemptId = requireString(payload.attemptId, "attemptId");
2062
+ const requestId = `interaction-${message.runId}-${attemptId}`;
2063
+ return this.state.transaction(() => {
2064
+ const request = this.hostState.createInteractiveRequest({
2065
+ requestId,
2066
+ runId: message.runId,
2067
+ attemptId,
2068
+ targetSessionId: active.record.originSessionId,
2069
+ kind: payload.kind === "assistant" ? "assistant" : "agent",
2070
+ contract: payload.contract,
2071
+ });
2072
+ const now = Date.now();
2073
+ this.state.connection
2074
+ .prepare(`UPDATE node_attempts SET status = 'waiting', updated_at = ?
2075
+ WHERE attempt_id = ? AND run_id = ? AND status = 'running'`)
2076
+ .run(now, attemptId, message.runId);
2077
+ this.state.connection
2078
+ .prepare(`UPDATE runs SET status = 'waiting', status_detail = ?, updated_at = ?, finished_at = ?
2079
+ WHERE run_id = ? AND status = 'running'`)
2080
+ .run("waiting for origin Pi session", now, now, message.runId);
2081
+ if (!this.queue.parkWorkflowRun({ runId: message.runId, claimToken: active.claimToken })) {
2082
+ throw new Error("Interactive request could not release the run claim");
2083
+ }
2084
+ active.control = "handoff";
2085
+ recordViewerDeltas(this.state, message.runId, [{ targetType: "summary" }, { targetType: "replay" }, { targetType: "conversation" }], now);
2086
+ return request;
2087
+ });
2088
+ }
2089
+ async prepareInteractionResume(runId) {
2090
+ const accepted = this.hostState.acceptedInteraction(runId);
2091
+ const candidate = accepted ?? this.hostState.validatingInteraction(runId);
2092
+ const timedOut = this.hostState.timedOutInteraction(runId);
2093
+ if (candidate === undefined && timedOut === undefined)
2094
+ return;
2095
+ const loaded = this.runStore.readRun(runId);
2096
+ if (loaded === null)
2097
+ return;
2098
+ if (timedOut !== undefined) {
2099
+ if (loaded.state.status !== "running") {
2100
+ throw new Error(`Timed-out interaction run ${runId} is not running`);
2101
+ }
2102
+ return;
364
2103
  }
365
- this.log(`run ${record.runId} cannot resume: ${message}`);
2104
+ if (candidate === undefined || loaded.state.status !== "waiting")
2105
+ return;
2106
+ loaded.state.status = "running";
2107
+ delete loaded.state.statusDetail;
2108
+ delete loaded.state.finishedAt;
2109
+ await this.runStore.writeSnapshot(runId, loaded.state, {
2110
+ scope: "node",
2111
+ type: accepted === undefined ? "interaction_validation_started" : "interaction_accepted",
2112
+ nodeId: candidate.nodeId,
2113
+ attemptId: candidate.attemptId,
2114
+ payload: {
2115
+ requestId: candidate.requestId,
2116
+ submissionId: candidate.submissionId,
2117
+ },
2118
+ });
366
2119
  }
367
- recordEvent(runId, workflowRef, type, payload) {
2120
+ applyLifecycleProjection(context) {
2121
+ const active = this.activeRuns.get(context.runId);
2122
+ if (active === undefined)
2123
+ return;
2124
+ if (context.event.type === "node_finished" && context.event.attemptId !== undefined) {
2125
+ this.hostState.consumeAcceptedInteraction(context.runId, context.event.attemptId, context.now);
2126
+ }
2127
+ if (context.state.status === "running") {
2128
+ if (context.event.type === "run_started" || context.event.type === "run_resumed") {
2129
+ context.database.connection
2130
+ .prepare(`UPDATE run_queue SET status = 'running', started_at = COALESCE(started_at, ?), updated_at = ?
2131
+ WHERE run_id = ? AND status IN ('starting', 'parked')`)
2132
+ .run(context.now, context.now, context.runId);
2133
+ }
2134
+ return;
2135
+ }
2136
+ this.completeControllerWorkflow(context.runId, context.state);
2137
+ const queueStatus = context.state.status === "completed"
2138
+ ? "done"
2139
+ : context.state.status === "waiting"
2140
+ ? "parked"
2141
+ : context.state.status === "cancelled"
2142
+ ? "cancelled"
2143
+ : "failed";
2144
+ const run = context.database.connection
2145
+ .prepare("SELECT error_hash AS errorHash FROM runs WHERE run_id = ?")
2146
+ .get(context.runId);
2147
+ context.database.connection
2148
+ .prepare(`UPDATE run_queue
2149
+ SET status = ?, error_code = ?, error_hash = ?, updated_at = ?, finished_at = ?
2150
+ WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
2151
+ .run(queueStatus, queueStatus === "failed" ? context.state.status : null, run?.errorHash ?? null, context.now, ["done", "failed", "cancelled"].includes(queueStatus) ? context.now : null, context.runId);
2152
+ if (context.state.status === "completed") {
2153
+ if (context.event.payload?.presentationRequired === true &&
2154
+ active.record.originSessionId !== null &&
2155
+ this.queue.findPendingWorkflowTurnIntent({
2156
+ runId: context.runId,
2157
+ targetSessionId: active.record.originSessionId,
2158
+ }) === undefined) {
2159
+ const facts = {
2160
+ schema: "pi-workflows.deferred-turn-facts.v1",
2161
+ workflowName: active.record.workflowName,
2162
+ runId: context.runId,
2163
+ observedState: "completed",
2164
+ cause: "terminal",
2165
+ nodeId: null,
2166
+ attemptId: null,
2167
+ reason: "workflow presentation instructions were unavailable",
2168
+ handoff: false,
2169
+ presentationPrompt: "Summarize the completed workflow result for the user in a normal response.",
2170
+ };
2171
+ this.queue.ensureWorkflowTurnIntent({
2172
+ intentId: `presentation-${context.runId}`,
2173
+ sourceEventId: `presentation-request-${context.runId}`,
2174
+ runId: context.runId,
2175
+ workflowRef: active.record.workflowSourceRef,
2176
+ targetSessionId: active.record.originSessionId,
2177
+ cause: "terminal",
2178
+ fallbackFacts: facts,
2179
+ eligible: false,
2180
+ });
2181
+ }
2182
+ context.database.connection
2183
+ .prepare(`UPDATE turn_intents SET eligible_at = ?
2184
+ WHERE run_id = ? AND resolved_at IS NULL AND eligible_at IS NULL`)
2185
+ .run(context.now, context.runId);
2186
+ }
2187
+ context.database.connection
2188
+ .prepare(`UPDATE leases
2189
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
2190
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
2191
+ WHERE resource_id = (SELECT resource_id FROM runs WHERE run_id = ?)
2192
+ AND generation = ?`)
2193
+ .run(context.runId, active.generation);
2194
+ }
2195
+ completeControllerWorkflow(runId, state) {
2196
+ const row = this.state.connection
2197
+ .prepare(`SELECT w.request_id AS requestId, c.controller_name AS controller,
2198
+ c.resource_key AS resourceKey, p.canonical_path AS projectPath
2199
+ FROM controller_workflows w
2200
+ JOIN controller_resources c ON c.controller_resource_id = w.controller_resource_id
2201
+ JOIN projects p ON p.project_id = c.project_id
2202
+ WHERE w.run_id = ? LIMIT 1`)
2203
+ .get(runId);
2204
+ if (typeof row?.requestId !== "string" ||
2205
+ typeof row.controller !== "string" ||
2206
+ typeof row.resourceKey !== "string" ||
2207
+ typeof row.projectPath !== "string") {
2208
+ return;
2209
+ }
2210
+ const store = new SqliteControllerStore(this.databasePath, {
2211
+ state: this.state,
2212
+ projectPath: row.projectPath,
2213
+ });
2214
+ const childState = state.status === "completed"
2215
+ ? "succeeded"
2216
+ : state.status === "waiting"
2217
+ ? "waiting"
2218
+ : "failed";
2219
+ store.completeWorkflow(row.requestId, {
2220
+ state: childState,
2221
+ runId,
2222
+ ...(state.error === undefined ? {} : { error: state.error }),
2223
+ });
2224
+ store.enqueue({ controller: row.controller, key: row.resourceKey });
2225
+ }
2226
+ commitActivePause(active) {
2227
+ const now = Date.now();
2228
+ this.state.transaction(() => {
2229
+ this.state.connection
2230
+ .prepare("UPDATE runs SET paused = 1, status_detail = ?, updated_at = ? WHERE run_id = ?")
2231
+ .run("paused", now, active.record.runId);
2232
+ if (!this.queue.parkWorkflowRun({
2233
+ runId: active.record.runId,
2234
+ claimToken: active.claimToken,
2235
+ })) {
2236
+ throw new Error("Run claim was lost before pause committed");
2237
+ }
2238
+ });
2239
+ }
2240
+ async recoverWorkerExit(active, outcome) {
2241
+ if (!this.queue.verifyWorkflowRunClaim({
2242
+ runId: active.record.runId,
2243
+ claimToken: active.claimToken,
2244
+ })) {
2245
+ return;
2246
+ }
2247
+ const recovery = await this.runStore.recoverApplyingEffects(active.record.runId);
2248
+ if (recovery === "ambiguous" || hasUncertainEffect(this.state, active.record.runId)) {
2249
+ this.blockedRuns.add(active.record.runId);
2250
+ if (!this.queue.parkWorkflowRunForAmbiguousEffect({
2251
+ runId: active.record.runId,
2252
+ claimToken: active.claimToken,
2253
+ })) {
2254
+ return;
2255
+ }
2256
+ this.log(`run ${active.record.runId} parked for ambiguous effect recovery`);
2257
+ return;
2258
+ }
2259
+ const validating = this.hostState.validatingInteraction(active.record.runId);
2260
+ if (validating !== undefined) {
2261
+ this.settleRejectedInteraction(active, validating, `Workflow worker ${outcome} before it completed interaction validation`);
2262
+ this.log(`run ${active.record.runId} rejected an interrupted interaction validation`);
2263
+ return;
2264
+ }
2265
+ if (active.workflowLoadFailure !== undefined) {
2266
+ if (this.queue.parkWorkflowRunForSourceChange({
2267
+ runId: active.record.runId,
2268
+ claimToken: active.claimToken,
2269
+ detail: active.workflowLoadFailure,
2270
+ })) {
2271
+ this.log(`run ${active.record.runId} parked because its workflow source could not load`);
2272
+ }
2273
+ return;
2274
+ }
2275
+ this.queue.parkWorkflowRun({ runId: active.record.runId, claimToken: active.claimToken });
2276
+ this.log(`run ${active.record.runId} parked after worker ${outcome}`);
2277
+ }
2278
+ releaseLock() {
368
2279
  try {
369
- this.store.recordRunEvent({ runId, workflowRef, type, payload, runnerId: this.runnerId });
2280
+ const current = JSON.parse(fs.readFileSync(this.lockPath, "utf8"));
2281
+ if (isLockRecord(current) && current.hostId === this.hostId)
2282
+ fs.rmSync(this.lockPath);
370
2283
  }
371
2284
  catch {
372
- // The event feed is best-effort.
2285
+ // The lock is already gone.
373
2286
  }
374
2287
  }
2288
+ log(message) {
2289
+ this.options.onLog?.(message);
2290
+ }
375
2291
  }
376
- function hostHumanDecision(value) {
377
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
378
- throw new Error("Stored workflow launch options are invalid");
379
- }
380
- const decision = Reflect.get(value, "humanDecision");
381
- if (decision === undefined)
382
- return undefined;
383
- if (typeof decision !== "object" ||
384
- decision === null ||
385
- Array.isArray(decision) ||
386
- Reflect.get(decision, "schema") !== "pi-workflows.human-decision-accepted.v1") {
387
- throw new Error("Stored human decision launch option is invalid");
388
- }
389
- return decision;
390
- }
391
- function hostProjectScope(cwd) {
392
- let canonical;
2292
+ function acquireHostLock(lockPath, record) {
393
2293
  try {
394
- canonical = fs.realpathSync.native(cwd);
2294
+ const existing = JSON.parse(fs.readFileSync(lockPath, "utf8"));
2295
+ if (isLockRecord(existing) && matchesProcessIdentity(existing)) {
2296
+ throw new Error(`A workflow host is already running with PID ${existing.pid}`);
2297
+ }
2298
+ fs.rmSync(lockPath, { force: true });
395
2299
  }
396
- catch {
397
- canonical = path.resolve(cwd);
2300
+ catch (error) {
2301
+ if (error instanceof Error && error.message.startsWith("A workflow host is already"))
2302
+ throw error;
398
2303
  }
399
- return createHash("sha256").update(canonical).digest("hex").slice(0, 24);
2304
+ fs.writeFileSync(lockPath, `${JSON.stringify({ schema: "pi-workflows.host-lock.v1", ...record })}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
400
2305
  }
401
- function hostLockPath(stateDir) {
402
- return path.join(stateDir, "host.lock");
2306
+ function workerResponse(message, outcome, result, error, revision) {
2307
+ return {
2308
+ schema: "pi-workflows.worker-response.v1",
2309
+ messageId: message.messageId,
2310
+ outcome,
2311
+ ...(revision === undefined ? {} : { revision }),
2312
+ ...(result === undefined ? {} : { result }),
2313
+ ...(error === undefined ? {} : { error }),
2314
+ };
403
2315
  }
404
- /**
405
- * The advisory lock guards host-versus-host only: the embedded runner in a
406
- * Pi session does not take it. A second host refuses to start while the
407
- * recorded PID is alive.
408
- */
409
- function acquireHostLock(stateDir, runnerId, cwd) {
410
- const lockPath = hostLockPath(stateDir);
411
- const existing = readLock(lockPath);
412
- if (existing !== null && existing.runnerId !== runnerId && isAlive(existing.pid)) {
413
- throw new Error(`Another workflow host (pid ${existing.pid}, ${existing.runnerId}) is already running for ${cwd}`);
414
- }
415
- fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
416
- fs.writeFileSync(lockPath, `${JSON.stringify({ pid: process.pid, runnerId, startedAt: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600 });
2316
+ function controllerWorkerResponse(message, outcome, result, error) {
2317
+ return {
2318
+ schema: "pi-workflows.controller-worker-response.v1",
2319
+ messageId: message.messageId,
2320
+ outcome,
2321
+ ...(result === undefined ? {} : { result }),
2322
+ ...(error === undefined ? {} : { error }),
2323
+ };
417
2324
  }
418
- function releaseHostLock(stateDir, runnerId) {
419
- const lockPath = hostLockPath(stateDir);
420
- const existing = readLock(lockPath);
421
- if (existing?.runnerId !== runnerId) {
422
- return;
423
- }
424
- try {
425
- fs.rmSync(lockPath);
426
- }
427
- catch {
428
- // Already gone.
2325
+ function controllerWorkflowResult(record) {
2326
+ switch (record.status) {
2327
+ case "queued":
2328
+ case "starting":
2329
+ case "running":
2330
+ return { state: "running", runId: record.runId };
2331
+ case "parked":
2332
+ return { state: "waiting", runId: record.runId };
2333
+ case "done":
2334
+ return { state: "succeeded", runId: record.runId };
2335
+ case "failed":
2336
+ case "cancelled":
2337
+ return {
2338
+ state: "failed",
2339
+ runId: record.runId,
2340
+ ...(record.errorMessage === null ? {} : { error: record.errorMessage }),
2341
+ };
429
2342
  }
430
2343
  }
431
- function readLock(lockPath) {
432
- try {
433
- const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8"));
434
- if (typeof parsed.pid !== "number" || typeof parsed.runnerId !== "string") {
435
- return null;
436
- }
437
- return { pid: parsed.pid, runnerId: parsed.runnerId };
2344
+ function runRevision(state, runId) {
2345
+ const row = state.connection
2346
+ .prepare(`SELECT resources.revision FROM resources
2347
+ JOIN runs ON runs.resource_id = resources.resource_id WHERE runs.run_id = ?`)
2348
+ .get(runId);
2349
+ if (!isRevisionRow(row))
2350
+ throw new Error(`Workflow run not found: ${runId}`);
2351
+ return row.revision;
2352
+ }
2353
+ function hasUncertainEffect(state, runId) {
2354
+ const row = state.connection
2355
+ .prepare(`SELECT 1 AS present FROM effects e JOIN runs r ON r.resource_id = e.source_resource_id
2356
+ WHERE r.run_id = ? AND e.status IN ('applying', 'ambiguous') LIMIT 1`)
2357
+ .get(runId);
2358
+ return row !== undefined;
2359
+ }
2360
+ function controllerPathAllowed(projectPath, controllerName, controllerPath) {
2361
+ if (controllerFileStem(controllerPath) !== controllerName ||
2362
+ !/\.controller\.(?:ts|js|mts|mjs)$/u.test(controllerPath)) {
2363
+ return false;
438
2364
  }
439
- catch {
440
- return null;
2365
+ return controllerSearchDirs({ cwd: projectPath }).some(({ dir }) => path.dirname(controllerPath) === path.resolve(dir));
2366
+ }
2367
+ function payloadLimit(payload) {
2368
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload))
2369
+ return 100;
2370
+ const limit = payload.limit;
2371
+ return typeof limit === "number" && Number.isSafeInteger(limit) && limit > 0 ? limit : 100;
2372
+ }
2373
+ function requireRunId(request) {
2374
+ if (request.runId === undefined)
2375
+ throw new Error(`${request.operation} requires runId`);
2376
+ return request.runId;
2377
+ }
2378
+ function requireRecord(value, name) {
2379
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
2380
+ throw new Error(`${name} must be an object`);
441
2381
  }
2382
+ return value;
442
2383
  }
443
- function isAlive(pid) {
444
- try {
445
- process.kill(pid, 0);
446
- return true;
2384
+ function requireString(value, name, allowEmpty = false) {
2385
+ if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
2386
+ throw new Error(`${name} must be text`);
447
2387
  }
448
- catch {
449
- return false;
2388
+ return value;
2389
+ }
2390
+ function requireAbsolutePath(value, name) {
2391
+ const parsed = requireString(value, name);
2392
+ if (!path.isAbsolute(parsed))
2393
+ throw new Error(`${name} must be an absolute path`);
2394
+ return parsed;
2395
+ }
2396
+ function requireNonNegativeInteger(value, name) {
2397
+ if (!Number.isSafeInteger(value) || value < 0) {
2398
+ throw new Error(`${name} must be a non-negative integer`);
450
2399
  }
2400
+ return value;
2401
+ }
2402
+ function isRevisionRow(value) {
2403
+ return (typeof value === "object" &&
2404
+ value !== null &&
2405
+ typeof value.revision === "number");
2406
+ }
2407
+ function isLockRecord(value) {
2408
+ return (typeof value === "object" &&
2409
+ value !== null &&
2410
+ value.schema === "pi-workflows.host-lock.v1" &&
2411
+ typeof value.pid === "number" &&
2412
+ typeof value.startIdentity === "string" &&
2413
+ typeof value.hostId === "string");
451
2414
  }
452
2415
  //# sourceMappingURL=runner.js.map