@osolmaz/pi-workflows 0.3.0 → 0.5.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 (74) hide show
  1. package/README.md +11 -7
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +32 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.d.ts +2 -2
  6. package/dist/builtins/monitor.workflow.js +25 -25
  7. package/dist/builtins/monitor.workflow.js.map +1 -1
  8. package/dist/controllers/index.d.ts +1 -1
  9. package/dist/controllers/index.js.map +1 -1
  10. package/dist/controllers/sqlite.d.ts +70 -9
  11. package/dist/controllers/sqlite.js +209 -35
  12. package/dist/controllers/sqlite.js.map +1 -1
  13. package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
  14. package/dist/controllers/workflow-engine-scheduler.js +3 -1
  15. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  16. package/dist/extension/executor.d.ts +3 -0
  17. package/dist/extension/executor.js +11 -1
  18. package/dist/extension/executor.js.map +1 -1
  19. package/dist/extension/index.js +157 -108
  20. package/dist/extension/index.js.map +1 -1
  21. package/dist/host/runner.d.ts +1 -0
  22. package/dist/host/runner.js +68 -20
  23. package/dist/host/runner.js.map +1 -1
  24. package/dist/render/graph-render.js +3 -0
  25. package/dist/render/graph-render.js.map +1 -1
  26. package/dist/workflows/catalog.d.ts +43 -0
  27. package/dist/workflows/catalog.js +79 -0
  28. package/dist/workflows/catalog.js.map +1 -0
  29. package/dist/workflows/definition.d.ts +2 -1
  30. package/dist/workflows/definition.js +9 -1
  31. package/dist/workflows/definition.js.map +1 -1
  32. package/dist/workflows/engine.d.ts +6 -6
  33. package/dist/workflows/engine.js +93 -33
  34. package/dist/workflows/engine.js.map +1 -1
  35. package/dist/workflows/index.d.ts +3 -3
  36. package/dist/workflows/index.js +2 -2
  37. package/dist/workflows/index.js.map +1 -1
  38. package/dist/workflows/loader.d.ts +18 -16
  39. package/dist/workflows/loader.js +58 -23
  40. package/dist/workflows/loader.js.map +1 -1
  41. package/dist/workflows/migrate-sources.d.ts +42 -0
  42. package/dist/workflows/migrate-sources.js +133 -0
  43. package/dist/workflows/migrate-sources.js.map +1 -0
  44. package/dist/workflows/schema.d.ts +2 -1
  45. package/dist/workflows/schema.js +14 -1
  46. package/dist/workflows/schema.js.map +1 -1
  47. package/dist/workflows/store.js +5 -2
  48. package/dist/workflows/store.js.map +1 -1
  49. package/dist/workflows/types.d.ts +44 -5
  50. package/docs/development.md +5 -3
  51. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  52. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  53. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
  54. package/docs/run-bundles.md +22 -4
  55. package/docs/workflows.md +57 -14
  56. package/package.json +1 -1
  57. package/src/builtins/catalog.ts +32 -0
  58. package/src/builtins/monitor.workflow.ts +33 -26
  59. package/src/controllers/index.ts +1 -0
  60. package/src/controllers/sqlite.ts +353 -43
  61. package/src/controllers/workflow-engine-scheduler.ts +5 -2
  62. package/src/extension/executor.ts +12 -1
  63. package/src/extension/index.ts +181 -140
  64. package/src/host/runner.ts +78 -20
  65. package/src/render/graph-render.ts +3 -0
  66. package/src/workflows/catalog.ts +135 -0
  67. package/src/workflows/definition.ts +11 -0
  68. package/src/workflows/engine.ts +128 -53
  69. package/src/workflows/index.ts +7 -0
  70. package/src/workflows/loader.ts +70 -26
  71. package/src/workflows/migrate-sources.ts +174 -0
  72. package/src/workflows/schema.ts +16 -1
  73. package/src/workflows/store.ts +5 -2
  74. package/src/workflows/types.ts +43 -4
@@ -0,0 +1,95 @@
1
+ ---
2
+ title: Route workflow reports to their starting session
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ date: 2026-08-13
5
+ ---
6
+
7
+ # Route workflow reports to their starting session
8
+
9
+ Pi Workflows must not send one workflow's report into an unrelated conversation. A workflow started in one Pi session must report only to that session, even when another session or the standalone host executes part of the run.
10
+
11
+ ## Requirements
12
+
13
+ - Record the starting Pi session as the origin of each interactive workflow run.
14
+ - Keep execution events separate from user-facing notifications.
15
+ - Store each notification durably and address it to one Pi session.
16
+ - Deliver a notification only when its target session is open.
17
+ - Keep undelivered notifications until the target session opens again.
18
+ - Prevent unrelated sessions from claiming session-bound runs.
19
+ - Permit a detached host to execute a run without changing its report target.
20
+ - Give workflow authors a runtime-owned notification node instead of using an agent step to relay text.
21
+ - Migrate active path-based runs and existing queue rows without guessing their origin.
22
+ - Remove project-wide hidden-message broadcasts and the shared project watermark.
23
+
24
+ ## Data model
25
+
26
+ Queue records gain an optional `origin_session_id`. Interactive runs set it from `ctx.sessionManager.getSessionId()`. Controller-created detached runs leave it empty. A session runner can claim a record only when the origin is empty or matches its current session. The standalone host can claim either form.
27
+
28
+ A `workflow_notifications` table is the durable outbox:
29
+
30
+ | Field | Meaning |
31
+ | --------------------------- | --------------------------------------------------- |
32
+ | `notification_id` | Stable unique delivery ID |
33
+ | `run_id` | Workflow run that created the notification |
34
+ | `node_id` | Node that created it |
35
+ | `attempt_id` | Latest node attempt that requested it |
36
+ | `notification_index` | Stable one-based occurrence of this node in the run |
37
+ | `target_session_id` | Exact Pi session that may receive it |
38
+ | `kind` | `progress` or `final` |
39
+ | `content` | Bounded plain-text report |
40
+ | `created_at` | Creation time |
41
+ | `delivery_claim_token` | Current delivery owner, or null |
42
+ | `delivery_claim_expires_at` | Delivery lease deadline, or null |
43
+ | `delivered_at` | Delivery time, or null while pending |
44
+
45
+ `(run_id, node_id, notification_index)` is unique. A crash retry reuses the same logical index, even though it gets a new attempt ID. A later loop iteration gets the next index. Delivery polling claims pending rows with a short lease before it writes to a session. This prevents two Pi processes that open the same session from sending the same notification concurrently. If a process stops, another process can reclaim the row after the lease expires. A stable notification ID is included in the custom session entry so the same session can detect a delivery completed before a crash.
46
+
47
+ The existing `run_events` table remains an execution audit feed. It does not inject conversation messages. The obsolete `session_watermarks` table is no longer used.
48
+
49
+ ## Workflow API
50
+
51
+ Add a `notify(...)` node. Its message callback returns plain text. The engine calls a host-provided notification sink and persists the resulting receipt as the node output. The node does not ask the model to relay a message and does not depend on the runner's active conversation.
52
+
53
+ The built-in monitor uses `notify` for progress and final reports. Its check agent still decides whether a report is needed, but delivery always targets the origin session.
54
+
55
+ ## Migration
56
+
57
+ For each active run with a queue row and no origin:
58
+
59
+ 1. Read `session/binding.json` from its run bundle.
60
+ 2. If the binding exists, set `origin_session_id` to its `piSessionId`.
61
+ 3. If no binding exists, keep the run detached.
62
+ 4. Never infer an origin from the working directory.
63
+
64
+ Existing lifecycle events are not converted into notifications. This prevents old completion events from appearing in unrelated sessions after the update.
65
+
66
+ ## Non-goals
67
+
68
+ - Do not modify Pi core or Pi session files directly.
69
+ - Do not broadcast workflow reports to all sessions in a project.
70
+ - Do not keep compatibility aliases for the shared project watermark behavior.
71
+ - Do not make closed Pi sessions execute workflow steps.
72
+
73
+ ## Acceptance criteria
74
+
75
+ - Two open Pi sessions in the same directory cannot receive each other's workflow reports.
76
+ - A report executed by the standalone host is delivered after the origin session opens.
77
+ - A report remains pending while its origin session is closed.
78
+ - A session-bound run is not claimed by a different interactive session.
79
+ - Duplicate polling and process restart do not duplicate a delivered notification.
80
+ - Monitor progress and final reports use the notification outbox.
81
+ - Existing active runs gain their recorded binding as origin when available.
82
+ - Pi starts with the released package in OnurPi.
83
+
84
+ ## Verification
85
+
86
+ - `npm run check`
87
+ - `npm run test:e2e`
88
+ - `npm run slophammer`
89
+ - `git diff --check`
90
+ - `npx -y @simpledoc/simpledoc check`
91
+ - `cargo fmt --check --manifest-path tui/Cargo.toml`
92
+ - `cargo clippy --manifest-path tui/Cargo.toml --all-targets --all-features -- -D warnings`
93
+ - `cargo test --manifest-path tui/Cargo.toml`
94
+ - `pi-reviewer --base main`
95
+ - Start two isolated RPC Pi sessions in one directory and prove that only the matching session receives a targeted notification.
@@ -123,7 +123,11 @@ Identity and pointers, kept in sync with the state on every snapshot:
123
123
  "runId": "20260729T023912Z-autoimplement-3f2a9c1b",
124
124
  "workflowName": "autoimplement",
125
125
  "runTitle": "autoimplement: fix the flaky test",
126
- "workflowPath": "/repo/.pi/workflows/autoimplement.workflow.ts",
126
+ "workflowSource": {
127
+ "kind": "file",
128
+ "path": "/repo/.pi/workflows/autoimplement.workflow.ts",
129
+ "hash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
130
+ },
127
131
  "startedAt": "2026-07-29T02:39:12.412Z",
128
132
  "finishedAt": "2026-07-29T02:41:03.977Z",
129
133
  "status": "completed",
@@ -138,6 +142,11 @@ Identity and pointers, kept in sync with the state on every snapshot:
138
142
  }
139
143
  ```
140
144
 
145
+ `workflowSource` identifies the definition used by the run. User workflow
146
+ files use an absolute path and SHA-256 hash. Package-provided workflows use a
147
+ stable identity such as `{ "kind": "builtin", "id": "monitor", "revision": "1" }`.
148
+ A built-in identity does not contain an installation path.
149
+
141
150
  `paths.artifacts` is declared from bundle creation so a live session-event
142
151
  patch can safely reference a newly written artifact before the next workflow
143
152
  state projection. The directory itself is created only when needed.
@@ -167,9 +176,10 @@ instead of failing. Resume is a named operation with strict rules:
167
176
  `state.traceSeq` and the trace agree again before any new event.
168
177
  3. Completed nodes replay from the projection. The in-flight node reruns with
169
178
  a fresh attempt; a `run_resumed` trace event marks the boundary.
170
- 4. `state.workflowHash` pins the workflow source from run start. Resume
171
- refuses a hash mismatch unless forced, and a forced resume records the
172
- mismatch in the `run_resumed` payload.
179
+ 4. `state.workflowSource` pins the workflow source from run start. File
180
+ sources require the same hash. Built-in sources require the same catalog
181
+ id and revision. Resume refuses a mismatch unless forced, and a forced
182
+ resume records the mismatch in the `run_resumed` payload.
173
183
 
174
184
  Continuation runs (answering a checkpoint) are new bundles, not resumed ones.
175
185
  They link back through `state.parentRunId`, carry the parent's outputs,
@@ -189,6 +199,11 @@ The full run projection (`WorkflowRunState` in
189
199
  "traceSeq": 17,
190
200
  "runId": "20260729T023912Z-autoimplement-3f2a9c1b",
191
201
  "workflowName": "autoimplement",
202
+ "workflowSource": {
203
+ "kind": "file",
204
+ "path": "/repo/.pi/workflows/autoimplement.workflow.ts",
205
+ "hash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
206
+ },
192
207
  "startedAt": "…",
193
208
  "updatedAt": "…",
194
209
  "status": "running",
@@ -199,6 +214,9 @@ The full run projection (`WorkflowRunState` in
199
214
  }
200
215
  ```
201
216
 
217
+ - `workflowSource` is the canonical source identity. Resuming a file requires
218
+ the same hash. Resuming a built-in requires the same catalog revision. A
219
+ mismatch refuses the resume instead of loading another definition.
202
220
  - `status` is one of `running`, `waiting`, `completed`, `failed`, `timed_out`,
203
221
  or `cancelled`. A controller host records an abandoned bundle as `failed`
204
222
  with a final `run_interrupted` trace event. Before doing that, recovery checks
package/docs/workflows.md CHANGED
@@ -16,7 +16,14 @@ Files are discovered by suffix (`.workflow.ts`, `.workflow.js`, `.workflow.mts`,
16
16
  3. Workflows built into Pi Workflows
17
17
 
18
18
  Pi Workflows includes a built-in `monitor` workflow. A project or global file
19
- named `monitor.workflow.ts` replaces it.
19
+ named `monitor.workflow.ts` replaces it. The package registers each built-in in
20
+ a process-local catalog with a stable reference such as `builtin:monitor` and
21
+ an explicit revision. Built-ins are imported with the engine when a Pi process
22
+ starts. They are not read from the package directory when a run starts or
23
+ resumes. Updating the package on disk cannot mix a new built-in with that
24
+ process's old engine; reload or restart Pi to use the new built-in. A revision
25
+ mismatch refuses resume. Project and global workflow files still reload on
26
+ each run and use their path and SHA-256 hash as their source identity.
20
27
 
21
28
  The workflow's command name is the file stem, so `.pi/workflows/triage.workflow.ts`
22
29
  runs as `/workflow triage`. A direct path also works: `/workflow ./somewhere/x.workflow.ts`.
@@ -110,10 +117,9 @@ Conversation nodes execute in headless `pi --mode rpc` children that load a
110
117
  small bridge extension; the model sees the same `workflow` tool contract as an
111
118
  in-session run. The host is a foreground process: start it in a terminal and
112
119
  stop it with Ctrl-C. A second host for the same project refuses to start, and
113
- a host that dies has its orphaned children reaped by the next one. While the
114
- host works, any open Pi session stays current: a per-session watermark over
115
- the shared run event feed produces catch-up summaries and quiet context
116
- updates.
120
+ a host that dies has its orphaned children reaped by the next one. While the host works, reports enter a durable outbox addressed to the Pi
121
+ session that started the run. They remain pending while that session is closed
122
+ and never enter another conversation in the same project.
117
123
 
118
124
  ## Node types
119
125
 
@@ -127,7 +133,10 @@ agent({
127
133
  prompt: ({ outputs }) => `Review this: ${JSON.stringify(outputs.implement)}`,
128
134
  expectedOutput: `{ "verdict": "clean" | "issues_found" }`,
129
135
  validate: (output) => output, // optional; throw to reject the submission
130
- timeoutMs: 30 * 60_000, // optional; default 15 minutes
136
+ timeoutMs: ({ input }) =>
137
+ (input as { timeoutMinutes?: number }).timeoutMinutes
138
+ ? (input as { timeoutMinutes: number }).timeoutMinutes * 60_000
139
+ : 30 * 60_000, // optional number or context callback; default 15 minutes
131
140
  statusDetail: "reviewing", // optional; shown in widget and viewer
132
141
  });
133
142
  ```
@@ -137,7 +146,15 @@ calls the tool, the output passes through normalization (a JSON string is
137
146
  parsed tolerantly) and then `validate`. If `validate` throws, the tool call
138
147
  returns an error and the model can retry within the same step. If the agent
139
148
  ends its turn without submitting, the extension nudges it, twice by default,
140
- then fails the step.
149
+ then fails the step. If an agent node times out or the workflow is cancelled,
150
+ the extension also aborts its active Pi turn. The model cannot continue to use
151
+ tools after the engine has closed that attempt.
152
+
153
+ `timeoutMs` can be a finite positive number or a function of the normal node
154
+ context. A timeout function can use prepared outputs to select a deadline for
155
+ this run. It has 30 seconds to return a value. Computed timeout functions are
156
+ runtime code, so definition snapshots omit them; fixed numeric timeouts remain
157
+ in the snapshot.
141
158
 
142
159
  ### compute
143
160
 
@@ -147,6 +164,26 @@ Runs a TypeScript function inline. Use it for pure data shaping.
147
164
  compute({ run: ({ outputs }) => ({ merged: { ...outputs } }) });
148
165
  ```
149
166
 
167
+ ### notify
168
+
169
+ Queues a durable plain-text message for the Pi session that started the run.
170
+ The runner does not write the message into its own conversation. A standalone
171
+ host can execute this node, and the message still waits for the origin session.
172
+
173
+ ```typescript
174
+ notify({
175
+ kind: "progress", // or "final"
176
+ message: ({ outputs }) => String(outputs.check),
177
+ });
178
+ ```
179
+
180
+ The runtime gives each logical execution of a notification node a stable index.
181
+ A retry after a crash reuses that index, so it cannot queue the same message
182
+ twice. Re-entering the node later in a loop gets the next index. A workflow
183
+ with a `notify` node must be an interactive queued run with an origin session.
184
+ Controller child workflows are detached and must report through their
185
+ controller resource instead.
186
+
150
187
  ### action
151
188
 
152
189
  Performs a side effect. Two forms exist. The function form runs arbitrary
@@ -276,7 +313,8 @@ one looping workflow run. Its input is:
276
313
  "everyMinutes": 30,
277
314
  "reportWhen": "Checks fail or the state changes materially",
278
315
  "stopWhen": "The pull request is merged or closed",
279
- "maxChecks": 1000
316
+ "maxChecks": 1000,
317
+ "checkTimeoutMinutes": 60
280
318
  }
281
319
  ```
282
320
 
@@ -286,8 +324,10 @@ separate agent node so its structured check result is validated before the user
286
324
  sees the message. The next check can read the previous accepted observation.
287
325
 
288
326
  Intervals must be whole minutes from 1 through 1,440. `maxChecks` defaults to
289
- 1,000 and cannot exceed 1,000. The workflow also has a finite step limit and
290
- bounded observation and report sizes.
327
+ 1,000 and cannot exceed 1,000. `checkTimeoutMinutes` is optional and applies to
328
+ check and report agent nodes. It must be from 5 through 1,440 minutes. Its
329
+ default is the larger of 60 minutes and `everyMinutes`. The workflow also has
330
+ a finite step limit and bounded observation and report sizes.
291
331
 
292
332
  The interval uses the existing shell action to launch the current Node
293
333
  executable with a timer. This works on every platform supported by Pi. The node
@@ -344,8 +384,9 @@ After the final run state has been persisted, the Pi extension sends the
344
384
  presentation instructions and bounded final result to the model as a hidden
345
385
  follow-up message. The next visible message is a normal assistant response.
346
386
  Returning `undefined`, returning an empty string, or omitting
347
- `presentationPrompt` produces no follow-up. Cancelled runs are never
348
- presented. Async prompt builders have 30 seconds to finish and receive an
387
+ `presentationPrompt` produces no follow-up. Failed, timed-out, and cancelled
388
+ runs are never presented; the extension reports their persisted status and
389
+ error directly. Async prompt builders have 30 seconds to finish and receive an
349
390
  `AbortSignal` that fires on timeout, session shutdown, or when a new workflow
350
391
  or normal user turn starts; stale presentations are discarded. Once a presentation message has
351
392
  been queued, another workflow cannot start until that assistant response
@@ -364,8 +405,10 @@ Runs execute one node at a time. Every transition is persisted to the run
364
405
  bundle before the engine moves on, which is what makes the live viewer
365
406
  possible. Defaults worth knowing:
366
407
 
367
- - Node timeout is 15 minutes unless the node sets `timeoutMs`. A timed-out
368
- node has outcome `timed_out` and can be routed with `$result.outcome`.
408
+ - Node timeout is 15 minutes unless the node sets `timeoutMs` to a positive
409
+ number or context callback. A timed-out node has outcome `timed_out` and can
410
+ be routed with `$result.outcome`. A timed-out agent node also aborts its Pi
411
+ turn, and late output for that attempt is rejected.
369
412
  - `maxSteps` (workflow-level, default 100) bounds loops built from cycles in
370
413
  the graph.
371
414
  - `/workflow pause` requests a pause: the current step finishes normally,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osolmaz/pi-workflows",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Workflow and controller runtime with a live terminal viewer for the pi coding agent",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,32 @@
1
+ import { BuiltinWorkflowCatalog } from "../workflows/catalog.js";
2
+ import monitorWorkflow from "./monitor.workflow.js";
3
+
4
+ export const builtinWorkflowCatalog = new BuiltinWorkflowCatalog([
5
+ {
6
+ id: "monitor",
7
+ revision: "2",
8
+ definition: monitorWorkflow,
9
+ legacySources: [
10
+ {
11
+ workflowHash: "7a22158da94d18ec1c9fe42e70d72017a4e0620d5e5142ae839d0cd6eea55c06",
12
+ revision: "2",
13
+ pathSuffixes: [
14
+ "/src/builtins/monitor.workflow.ts",
15
+ "/dist/builtins/monitor.workflow.js",
16
+ "/src/workflows/monitor.workflow.ts",
17
+ "/dist/workflows/monitor.workflow.js",
18
+ ],
19
+ },
20
+ {
21
+ workflowHash: "352fc09c88922c7375281b52f049c1039d05441ce37f2082f5ff07fea66d5318",
22
+ revision: "2",
23
+ pathSuffixes: ["/dist/builtins/monitor.workflow.js"],
24
+ },
25
+ {
26
+ workflowHash: "dc601e2323a8213f5d52fa555e804ae8ed0846f809b3a1e9e5073a3c9c3a114e",
27
+ revision: "2",
28
+ pathSuffixes: ["/dist/builtins/monitor.workflow.js"],
29
+ },
30
+ ],
31
+ },
32
+ ]);
@@ -1,8 +1,11 @@
1
- import { agent, compute, defineWorkflow, shell } from "../workflows/index.js";
1
+ import { agent, compute, defineWorkflow, notify, shell } from "../workflows/definition.js";
2
2
  import type { WorkflowNodeContext } from "../workflows/types.js";
3
3
 
4
4
  const MIN_INTERVAL_MINUTES = 1;
5
5
  const MAX_INTERVAL_MINUTES = 24 * 60;
6
+ const MIN_CHECK_TIMEOUT_MINUTES = 5;
7
+ const MAX_CHECK_TIMEOUT_MINUTES = 24 * 60;
8
+ const DEFAULT_MIN_CHECK_TIMEOUT_MINUTES = 60;
6
9
  const DEFAULT_MAX_CHECKS = 1_000;
7
10
  const MAX_CHECKS = 1_000;
8
11
  const MAX_OBSERVATION_CHARS = 8_000;
@@ -17,6 +20,7 @@ type MonitorInput = {
17
20
  reportWhen?: string;
18
21
  stopWhen?: string;
19
22
  maxChecks?: number;
23
+ checkTimeoutMinutes?: number;
20
24
  };
21
25
 
22
26
  type MonitorConfig = {
@@ -25,6 +29,7 @@ type MonitorConfig = {
25
29
  reportWhen: string;
26
30
  stopWhen: string;
27
31
  maxChecks: number;
32
+ checkTimeoutMinutes: number;
28
33
  };
29
34
 
30
35
  type MonitorRoute = "continue_quiet" | "continue_report" | "stop_quiet" | "stop_report";
@@ -81,6 +86,17 @@ function prepareInput(input: unknown): MonitorConfig {
81
86
  if (!Number.isInteger(maxChecks) || maxChecks <= 0 || maxChecks > MAX_CHECKS) {
82
87
  throw new Error(`maxChecks must be an integer from 1 through ${MAX_CHECKS}`);
83
88
  }
89
+ const checkTimeoutMinutes =
90
+ value.checkTimeoutMinutes ?? Math.max(DEFAULT_MIN_CHECK_TIMEOUT_MINUTES, value.everyMinutes);
91
+ if (
92
+ !Number.isInteger(checkTimeoutMinutes) ||
93
+ checkTimeoutMinutes < MIN_CHECK_TIMEOUT_MINUTES ||
94
+ checkTimeoutMinutes > MAX_CHECK_TIMEOUT_MINUTES
95
+ ) {
96
+ throw new Error(
97
+ `checkTimeoutMinutes must be an integer from ${MIN_CHECK_TIMEOUT_MINUTES} through ${MAX_CHECK_TIMEOUT_MINUTES}`,
98
+ );
99
+ }
84
100
  return {
85
101
  task,
86
102
  everyMinutes: value.everyMinutes,
@@ -93,6 +109,7 @@ function prepareInput(input: unknown): MonitorConfig {
93
109
  ? "The user cancels the monitor or it reaches its maximum check count."
94
110
  : requireBoundedString(value.stopWhen, "stopWhen", 4_000),
95
111
  maxChecks,
112
+ checkTimeoutMinutes,
96
113
  };
97
114
  }
98
115
 
@@ -100,6 +117,10 @@ function configFrom(outputs: Record<string, unknown>): MonitorConfig {
100
117
  return outputs.prepare as MonitorConfig;
101
118
  }
102
119
 
120
+ function agentTimeoutMs({ outputs }: WorkflowNodeContext): number {
121
+ return configFrom(outputs).checkTimeoutMinutes * 60_000;
122
+ }
123
+
103
124
  function completedChecks(context: WorkflowNodeContext): number {
104
125
  return context.state.steps.filter((step) => step.nodeId === "check" && step.outcome === "ok")
105
126
  .length;
@@ -129,22 +150,9 @@ function validateCheck(output: unknown): MonitorCheck {
129
150
  };
130
151
  }
131
152
 
132
- function validateReportAck(output: unknown): { reported: true } {
133
- const value = requireRecord(output, "report acknowledgement");
134
- if (value.reported !== true) {
135
- throw new Error("report acknowledgement must set reported to true");
136
- }
137
- return { reported: true };
138
- }
139
-
140
- function reportPrompt(outputs: Record<string, unknown>): string {
153
+ function reportMessage(outputs: Record<string, unknown>): string {
141
154
  const check = outputs.check as MonitorCheck;
142
- return [
143
- "Write one concise normal assistant message to the user with this monitoring update:",
144
- check.report ?? check.observation,
145
- "Do not add unrelated detail.",
146
- "After writing the update, submit the acknowledgement required by the workflow step contract.",
147
- ].join("\n\n");
155
+ return check.report ?? check.observation;
148
156
  }
149
157
 
150
158
  export default defineWorkflow({
@@ -190,6 +198,7 @@ export default defineWorkflow({
190
198
  }),
191
199
  check: agent({
192
200
  statusDetail: "checking monitored target",
201
+ timeoutMs: agentTimeoutMs,
193
202
  prompt: (context) => {
194
203
  const config = configFrom(context.outputs);
195
204
  const previous = context.outputs.check as MonitorCheck | undefined;
@@ -210,17 +219,15 @@ export default defineWorkflow({
210
219
  '{ "route": "continue_quiet" | "continue_report" | "stop_quiet" | "stop_report", "observation": "current factual state", "report": "required for report routes", "reason": "short reason" }',
211
220
  validate: (output) => validateCheck(output),
212
221
  }),
213
- report_continue: agent({
214
- statusDetail: "reporting monitor update",
215
- prompt: ({ outputs }) => reportPrompt(outputs),
216
- expectedOutput: '{ "reported": true }',
217
- validate: (output) => validateReportAck(output),
222
+ report_continue: notify({
223
+ statusDetail: "queueing monitor update",
224
+ message: ({ outputs }) => reportMessage(outputs),
225
+ kind: "progress",
218
226
  }),
219
- report_stop: agent({
220
- statusDetail: "reporting final monitor update",
221
- prompt: ({ outputs }) => reportPrompt(outputs),
222
- expectedOutput: '{ "reported": true }',
223
- validate: (output) => validateReportAck(output),
227
+ report_stop: notify({
228
+ statusDetail: "queueing final monitor update",
229
+ message: ({ outputs }) => reportMessage(outputs),
230
+ kind: "final",
224
231
  }),
225
232
  sleep: shell({
226
233
  statusDetail: "waiting for next monitor check",
@@ -23,6 +23,7 @@ export { createResultHelpers, requeue, requeueAfter, settled } from "./results.j
23
23
  export {
24
24
  SqliteControllerStore,
25
25
  type RunEventRecord,
26
+ type WorkflowNotificationRecord,
26
27
  type WorkflowRunQueueRecord,
27
28
  } from "./sqlite.js";
28
29
  export {