@osolmaz/pi-workflows 0.16.3 → 0.16.5
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.
- package/dist/server/server.d.ts +13 -0
- package/dist/server/server.js +138 -6
- package/dist/server/server.js.map +1 -1
- package/dist/state/prune.d.ts +28 -0
- package/dist/state/prune.js +278 -76
- package/dist/state/prune.js.map +1 -1
- package/docs/SQLITE_STATE.md +21 -3
- package/docs/WORKFLOW_SERVER.md +29 -1
- package/docs/plans/2026-09-04-automatic-state-retention-plan.md +283 -0
- package/docs/workflows.md +16 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +8 -7
- package/src/server/server.ts +156 -7
- package/src/state/prune.ts +409 -83
package/docs/WORKFLOW_SERVER.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Workflow server
|
|
2
2
|
|
|
3
|
-
Status: the out-of-process server, unified live client, workflow-message contract, and restored session behavior are implemented. [Unify workflow run state](2026-09-04-workflow-run-state-plan.md) records the approved refactor for turn ownership, managed effects, restarts, terminal data, cancellation, and runner recovery. [Unify workflow messages and restore hosted behavior](2026-09-02-unify-workflow-messages-plan.md), [run workflows outside Pi](2026-08-30-out-of-process-workflow-host-plan.md), [restore workflow session delivery and controls](2026-09-01-restore-session-delivery-controls-plan.md), and [unify live workflow clients](2026-09-01-unified-workflow-client-plan.md) record the earlier design and implementation plans.
|
|
3
|
+
Status: the out-of-process server, unified live client, workflow-message contract, and restored session behavior are implemented. [Unify workflow run state](2026-09-04-workflow-run-state-plan.md) records the approved refactor for turn ownership, managed effects, restarts, terminal data, cancellation, and runner recovery. [Add automatic workflow state retention](plans/2026-09-04-automatic-state-retention-plan.md) records the approved 30-day cleanup contract. [Unify workflow messages and restore hosted behavior](2026-09-02-unify-workflow-messages-plan.md), [run workflows outside Pi](2026-08-30-out-of-process-workflow-host-plan.md), [restore workflow session delivery and controls](2026-09-01-restore-session-delivery-controls-plan.md), and [unify live workflow clients](2026-09-01-unified-workflow-client-plan.md) record the earlier design and implementation plans.
|
|
4
4
|
|
|
5
5
|
## Purpose
|
|
6
6
|
|
|
@@ -426,11 +426,32 @@ At startup the server:
|
|
|
426
426
|
9. Resumes any remaining provisional `validating` submission in a new supervised child.
|
|
427
427
|
10. Waits for the extension's active-branch report before it confirms pending entries as sent, closes a turn as `lost`, or creates a branch-specific replacement. The extension sends this report after every server connection.
|
|
428
428
|
11. Starts no model turn until a matching Pi session connects or headless mode is declared.
|
|
429
|
+
12. Requests an automatic state-retention sweep after recovery. The sweep waits until normal server work is idle.
|
|
429
430
|
|
|
430
431
|
Recovery resumes from the last committed boundary. An uncommitted compute node may run again because compute is pure. An action with a stored effect receipt adopts that receipt. An effect in `ambiguous` state requires explicit recovery.
|
|
431
432
|
|
|
432
433
|
Claim loss is a handoff, not a run failure. The old owner writes no terminal event after claim loss.
|
|
433
434
|
|
|
435
|
+
## State retention
|
|
436
|
+
|
|
437
|
+
The server keeps terminal root-run trees for 30 days from `finished_at`. A tree can expire only when all restart and continuation descendants are terminal, older than the cutoff, and free of protected state or references from outside the tree.
|
|
438
|
+
|
|
439
|
+
The server protects waiting and parked runs, live queue rows, pending workflow messages, open workflow turns, pending interactions and human decisions, recording session segments, queued follow-ups, active leases, unsettled effects, controller ownership, active runner content, resumable checkpoints, and undelivered terminal results. A cross-tree continuation or step reference also blocks deletion. Unknown ownership blocks deletion.
|
|
440
|
+
|
|
441
|
+
The server requests a sweep after startup recovery and after a workflow runner exits. It also schedules the next daily check after a completed sweep. Overlapping requests join one in-process task. A sweep starts only when there is no active or pending workflow runner, resource-manager runner, state-maintenance command, or shutdown. One server process completes at most one sweep in 24 hours. If new work appears, cleanup stops between complete root trees and remains due. The next idle lifecycle trigger or a five-minute idle retry continues it.
|
|
442
|
+
|
|
443
|
+
Automatic cleanup and `state prune` use the same selector, exact-tree deletion, and unreferenced-blob collection. Automatic cleanup does not make a backup. The explicit manual apply command still requires a new absolute verified backup.
|
|
444
|
+
|
|
445
|
+
Automatic cleanup deletes one root tree per transaction and rechecks that tree before deletion. It then yields so server work can start. Matching repeated cleanup has no additional effect.
|
|
446
|
+
|
|
447
|
+
After deletion, SQLite can reuse free pages. The server truncates the WAL while idle. It runs automatic `VACUUM` only when it is still idle, at least 64 MiB is reclaimable, and at least 20 percent of database pages are free. A skipped or failed compaction leaves committed deletion and reusable pages intact.
|
|
448
|
+
|
|
449
|
+
A cleanup failure records one bounded diagnostic. It does not fail a workflow or stop the server. The next attempt waits five minutes, so it cannot start a tight retry loop.
|
|
450
|
+
|
|
451
|
+
Retained runs keep their normal resume, bounded viewer, content-reference, and terminal-result behavior. Deleted expired runs disappear from run lists and direct views. Pi session history is unchanged.
|
|
452
|
+
|
|
453
|
+
This policy limits old completed history. It does not set a hard database-size cap because recent or protected work can be arbitrarily large. Physical file shrink also depends on a safe, successful SQLite compaction.
|
|
454
|
+
|
|
434
455
|
## Effects and retry safety
|
|
435
456
|
|
|
436
457
|
Compute nodes must not perform external side effects. They can repeat after a runner crash.
|
|
@@ -549,4 +570,11 @@ The implementation conforms when:
|
|
|
549
570
|
- a terminal run remains in the origin-session view while its terminal message is pending or its first turn is open, and then for 60 seconds after that turn ends, without retaining execution authority;
|
|
550
571
|
- a TypeScript-created live database is viewable by the matching Rust `piw` through the client protocol without a duplicated SQLite digest;
|
|
551
572
|
- no removed server, replay, or direct SQLite client path remains selectable;
|
|
573
|
+
- only complete terminal run trees older than 30 days and free of protected work can expire;
|
|
574
|
+
- startup and runner-exit cleanup share the manual prune selector and deletion rules;
|
|
575
|
+
- automatic cleanup creates no backup, while manual apply remains backup-first;
|
|
576
|
+
- repeated cleanup cannot partly delete a lineage tree or remove a referenced blob;
|
|
577
|
+
- retained runs keep their resume, viewer, content-reference, and terminal-result behavior;
|
|
578
|
+
- large session history does not get copied into repeated runner result blobs;
|
|
579
|
+
- free pages remain reusable when automatic compaction is skipped or fails;
|
|
552
580
|
- real Pi end-to-end tests, repository checks, reviewer checks, and CI pass.
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Add automatic workflow state retention
|
|
3
|
+
author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
|
|
4
|
+
date: 2026-09-04
|
|
5
|
+
status: approved
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Add automatic workflow state retention
|
|
9
|
+
|
|
10
|
+
## Goal
|
|
11
|
+
|
|
12
|
+
Pi Workflows will remove old completed workflow state automatically. It will keep active, resumable, pending, unsettled, and undelivered work.
|
|
13
|
+
|
|
14
|
+
The server will retain terminal run trees for 30 days. It will then remove safe expired trees and blobs that no retained record or active runner uses. The manual prune command will use the same safety rules.
|
|
15
|
+
|
|
16
|
+
A regression test will also protect the earlier runner fix. Repeated runner replies must not copy growing Pi session history and make the database grow by several gigabytes.
|
|
17
|
+
|
|
18
|
+
## Observed problem
|
|
19
|
+
|
|
20
|
+
One old state database reached about 6.1 GB. The `blobs` table held about 6.06 GB across 101,355 blobs, which were almost all JSON. `worker_messages.result_hash` was the main reference path.
|
|
21
|
+
|
|
22
|
+
Older runner replies copied growing session and workflow history into new immutable result blobs. Small changes produced another large blob, so content hashing could not deduplicate the replies. [Keep large workflow history out of runner resume replies](2026-09-04-workflow-runner-resume-state-plan.md) fixed that interface by returning only the execution state a runner needs.
|
|
23
|
+
|
|
24
|
+
That fix stops the main amplification path. Completed workflow history still remains until a person runs `pi-workflows state prune`, so the database can still grow without a retention rule.
|
|
25
|
+
|
|
26
|
+
## Selected design
|
|
27
|
+
|
|
28
|
+
Use the existing run-tree prune logic as the one cleanup engine. Automatic cleanup and manual prune will share selection, deletion, blob collection, and compaction rules.
|
|
29
|
+
|
|
30
|
+
A terminal root run and all its restart or continuation descendants remain for 30 days after `finished_at`. Every descendant must be terminal and older than the cutoff before the server can remove the tree. Protected state or a reference from outside the tree blocks removal.
|
|
31
|
+
|
|
32
|
+
The Workflow Server checks for cleanup after startup recovery and after workflow runners exit. It also schedules a daily check after each completed sweep. It starts cleanup only while normal server work is idle. One server process completes no more than one sweep in 24 hours. An interrupted sweep remains due. The next idle lifecycle trigger or a five-minute idle retry continues it.
|
|
33
|
+
|
|
34
|
+
Automatic cleanup creates no backup. Creating a new backup for every sweep would cause another unbounded store. The explicit manual prune command keeps its current dry-run and backup-first apply forms.
|
|
35
|
+
|
|
36
|
+
This design does not add size-based deletion. A clear time limit gives users predictable history. Recent or protected data can still be large, so this design does not promise a hard disk-size cap.
|
|
37
|
+
|
|
38
|
+
## Retention contract
|
|
39
|
+
|
|
40
|
+
### Eligible run trees
|
|
41
|
+
|
|
42
|
+
A run tree is eligible only when all of these facts are true:
|
|
43
|
+
|
|
44
|
+
- The root run and every descendant have status `completed`, `failed`, `timed_out`, or `cancelled`.
|
|
45
|
+
- Every run in the tree has `finished_at` earlier than the 30-day cutoff.
|
|
46
|
+
- No row outside the tree depends on a row inside it.
|
|
47
|
+
- No protected work belongs to the tree.
|
|
48
|
+
|
|
49
|
+
The cleanup transaction rechecks the exact root tree before deletion. A changed tree is skipped.
|
|
50
|
+
|
|
51
|
+
### Protected work
|
|
52
|
+
|
|
53
|
+
Automatic cleanup must keep a tree when it contains or owns any of this work:
|
|
54
|
+
|
|
55
|
+
- a waiting or parked run
|
|
56
|
+
- a queued, starting, running, or parked queue row
|
|
57
|
+
- a pending workflow message
|
|
58
|
+
- an open workflow turn
|
|
59
|
+
- a pending interaction or human decision
|
|
60
|
+
- a recording session segment
|
|
61
|
+
- a queued follow-up
|
|
62
|
+
- an active lease
|
|
63
|
+
- a pending, applying, or ambiguous effect
|
|
64
|
+
- controller ownership or a managed resource reference
|
|
65
|
+
- a continuation or step reference from outside the tree
|
|
66
|
+
- an active runner content hash
|
|
67
|
+
- a resumable checkpoint
|
|
68
|
+
- an undelivered terminal result
|
|
69
|
+
|
|
70
|
+
Unknown or conflicting ownership blocks deletion. Cleanup must fail closed.
|
|
71
|
+
|
|
72
|
+
### Automatic scheduling
|
|
73
|
+
|
|
74
|
+
The server requests one automatic sweep after recovery and after a workflow runner exits. Overlapping requests join the same in-process task.
|
|
75
|
+
|
|
76
|
+
A sweep starts only when there is no active or pending workflow runner, resource-manager runner, state-maintenance command, or server shutdown. It deletes one complete root tree in one transaction, yields, and checks for new work before it selects another tree.
|
|
77
|
+
|
|
78
|
+
A completed sweep starts a 24-hour in-process interval and schedules the next daily check. A sweep that stops because new work appeared remains due. The next idle lifecycle trigger or a five-minute idle retry continues it. A new request during the completed-sweep interval waits until that interval ends.
|
|
79
|
+
|
|
80
|
+
The cleanup scheduler is part of the existing server process. It does not add a service, scheduler process, or second writer.
|
|
81
|
+
|
|
82
|
+
### Blob cleanup
|
|
83
|
+
|
|
84
|
+
After run deletion, the state layer removes only blobs with no database foreign-key reference and no active runner reference. The existing schema scan remains the source for blob references, so a future blob foreign key is protected automatically.
|
|
85
|
+
|
|
86
|
+
A repeated sweep is safe. A second sweep finds no extra rows from work that was already removed.
|
|
87
|
+
|
|
88
|
+
### Space reuse and file compaction
|
|
89
|
+
|
|
90
|
+
After logical deletion, the server truncates the WAL while idle and reads SQLite `page_count`, `freelist_count`, and `page_size`. SQLite can reuse free pages even when the main file does not shrink.
|
|
91
|
+
|
|
92
|
+
Automatic cleanup runs `VACUUM` only when all of these facts are true:
|
|
93
|
+
|
|
94
|
+
- normal server work is still idle
|
|
95
|
+
- at least 64 MiB is reclaimable
|
|
96
|
+
- at least 20 percent of database pages are free
|
|
97
|
+
|
|
98
|
+
A skipped or failed `VACUUM` does not undo committed logical deletion. The server reports the outcome and leaves the free pages available for reuse. It does not claim that the file shrank unless measurement proves it.
|
|
99
|
+
|
|
100
|
+
Manual prune keeps its current backup-first full compaction and integrity checks.
|
|
101
|
+
|
|
102
|
+
### Failure handling
|
|
103
|
+
|
|
104
|
+
A cleanup error does not fail a workflow or stop the server. The server records one clear diagnostic and waits five minutes before another safe attempt. It does not retry in a tight loop.
|
|
105
|
+
|
|
106
|
+
Cleanup is atomic for one complete root tree. An interruption can leave later trees for another sweep, but it cannot leave half of one lineage tree deleted.
|
|
107
|
+
|
|
108
|
+
## Public behavior
|
|
109
|
+
|
|
110
|
+
Retained runs keep their current resume, viewer, content-reference, and terminal-result behavior. The viewer continues to read bounded pages. Pi Workflows does not edit Pi session history.
|
|
111
|
+
|
|
112
|
+
After an expired tree is removed, it no longer appears in run lists. A direct run view returns not found.
|
|
113
|
+
|
|
114
|
+
The manual commands remain:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pi-workflows state prune --before <timestamp> --dry-run
|
|
118
|
+
pi-workflows state prune --before <timestamp> --backup <absolute-path> --apply
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Automatic cleanup adds no client protocol operation. The SQLite schema name and version remain `pi-workflows-state` version 1. This change adds no migration, compatibility reader, second state path, fallback, feature flag, archive, or external resource.
|
|
122
|
+
|
|
123
|
+
## Implementation
|
|
124
|
+
|
|
125
|
+
### Share the cleanup engine
|
|
126
|
+
|
|
127
|
+
**Where**
|
|
128
|
+
|
|
129
|
+
- `src/state/prune.ts`
|
|
130
|
+
- version-1 invariants in `src/state/schema.ts`
|
|
131
|
+
|
|
132
|
+
**Change**
|
|
133
|
+
|
|
134
|
+
Split the current prune work into shared run-tree selection, exact-tree deletion, unreferenced-blob collection, page measurement, and compaction operations. Add every protected-state check from this plan.
|
|
135
|
+
|
|
136
|
+
Automatic deletion must recheck and delete one root tree in the same transaction. Manual prune keeps its backup and complete-selection recheck.
|
|
137
|
+
|
|
138
|
+
**Check**
|
|
139
|
+
|
|
140
|
+
State tests prove that eligible trees are removed as one unit. Each protected state keeps its whole tree. Foreign-key and integrity checks pass after deletion.
|
|
141
|
+
|
|
142
|
+
### Add automatic cleanup
|
|
143
|
+
|
|
144
|
+
**Where**
|
|
145
|
+
|
|
146
|
+
- `src/state/prune.ts`
|
|
147
|
+
- `src/server/server.ts`
|
|
148
|
+
|
|
149
|
+
**Change**
|
|
150
|
+
|
|
151
|
+
Add an automatic entry point with a cutoff of the current time minus 30 days. It uses the shared cleanup engine without creating a backup.
|
|
152
|
+
|
|
153
|
+
Process one complete root tree per transaction. Yield between trees. Stop when normal server work appears and leave the sweep due.
|
|
154
|
+
|
|
155
|
+
**Check**
|
|
156
|
+
|
|
157
|
+
Automatic and manual cleanup select the same eligible trees. Automatic cleanup creates no backup. A stopped sweep continues later without duplicate deletion.
|
|
158
|
+
|
|
159
|
+
### Schedule cleanup from the server lifecycle
|
|
160
|
+
|
|
161
|
+
**Where**
|
|
162
|
+
|
|
163
|
+
- lifecycle fields in `src/server/server.ts`
|
|
164
|
+
- `WorkflowServer.start()`
|
|
165
|
+
- server shutdown
|
|
166
|
+
- the active-run exit path after the server removes the run from `activeRuns`
|
|
167
|
+
|
|
168
|
+
**Change**
|
|
169
|
+
|
|
170
|
+
Request cleanup after startup recovery and after runner exit. Schedule a daily check after each completed sweep. Coalesce overlapping requests. Enforce the idle check, five-minute idle retry, and 24-hour completed-sweep interval.
|
|
171
|
+
|
|
172
|
+
A failed sweep logs one bounded error and waits for a later trigger. Shutdown starts no new sweep and settles any current scheduling task safely.
|
|
173
|
+
|
|
174
|
+
**Check**
|
|
175
|
+
|
|
176
|
+
Server tests use controlled scheduler timestamps and temporary databases to cover startup, runner exit, coalescing, the 24-hour interval, interruption, clean shutdown, and one injected failure.
|
|
177
|
+
|
|
178
|
+
### Reuse pages and compact when worthwhile
|
|
179
|
+
|
|
180
|
+
**Where**
|
|
181
|
+
|
|
182
|
+
- database-size helpers in `src/state/prune.ts`
|
|
183
|
+
- `test/prune.test.ts`
|
|
184
|
+
|
|
185
|
+
**Change**
|
|
186
|
+
|
|
187
|
+
Measure free SQLite pages after deletion. Truncate the WAL while idle. Run automatic `VACUUM` only above the 64 MiB and 20 percent thresholds.
|
|
188
|
+
|
|
189
|
+
Keep committed deletion when compaction is skipped or fails. Report logical deletion and physical file size separately.
|
|
190
|
+
|
|
191
|
+
**Check**
|
|
192
|
+
|
|
193
|
+
Tests prove page reuse below the thresholds, no early automatic `VACUUM`, physical reduction after successful compaction, and intact retained data after a simulated compaction failure.
|
|
194
|
+
|
|
195
|
+
### Add the large-state regression
|
|
196
|
+
|
|
197
|
+
**Where**
|
|
198
|
+
|
|
199
|
+
- `test/server.test.ts`
|
|
200
|
+
- `test/workflow-runner-content.test.ts`
|
|
201
|
+
- the existing large-resume test fixture
|
|
202
|
+
|
|
203
|
+
**Change**
|
|
204
|
+
|
|
205
|
+
Create more than 2 MiB of distinct recorded session history. Run several resume, wait, continue, and terminal runner exchanges. Add a small unique item between exchanges.
|
|
206
|
+
|
|
207
|
+
Measure distinct blobs referenced by `worker_messages.result_hash`, every runner frame, total blob bytes, and SQLite pages. The test must prove that runner control replies contain only required execution state or content references. They must not copy complete session history into every reply.
|
|
208
|
+
|
|
209
|
+
**Check**
|
|
210
|
+
|
|
211
|
+
The regression fails when each reply includes the growing history. It passes when result growth follows new workflow state, and every runner frame stays inside its protocol limit.
|
|
212
|
+
|
|
213
|
+
### Cover retention behavior
|
|
214
|
+
|
|
215
|
+
**Where**
|
|
216
|
+
|
|
217
|
+
- `test/prune.test.ts`
|
|
218
|
+
- `test/server.test.ts`
|
|
219
|
+
- `test/server-view.test.ts`
|
|
220
|
+
- the existing state-prune client tests
|
|
221
|
+
|
|
222
|
+
**Change**
|
|
223
|
+
|
|
224
|
+
Test recent and expired trees, restart and continuation families, pending and sent terminal messages, open turns, waiting and parked work, every blocker, concurrent manual prune, automatic blob cleanup, viewer access, and resume.
|
|
225
|
+
|
|
226
|
+
Use temporary databases and deterministic executors. Automated tests must not call a model or touch the user's live state.
|
|
227
|
+
|
|
228
|
+
**Check**
|
|
229
|
+
|
|
230
|
+
Retained runs still resume and render. Expired delivered terminal trees disappear as one unit. Protected trees remain. Repeated cleanup has no additional effect.
|
|
231
|
+
|
|
232
|
+
### Update documentation
|
|
233
|
+
|
|
234
|
+
**Where**
|
|
235
|
+
|
|
236
|
+
- `docs/SQLITE_STATE.md`
|
|
237
|
+
- `docs/WORKFLOW_SERVER.md`
|
|
238
|
+
- `docs/workflows.md`
|
|
239
|
+
- CLI help where it describes state retention
|
|
240
|
+
|
|
241
|
+
**Change**
|
|
242
|
+
|
|
243
|
+
Document the contract in this plan. Remove the old statement that Pi Workflows never prunes at startup.
|
|
244
|
+
|
|
245
|
+
**Check**
|
|
246
|
+
|
|
247
|
+
Documentation matches the code constants and tests. It makes no hard size or file-shrink promise.
|
|
248
|
+
|
|
249
|
+
## Tests and checks
|
|
250
|
+
|
|
251
|
+
Run:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
npm run check
|
|
255
|
+
npm run test:e2e
|
|
256
|
+
npx slophammer-ts@latest dry .
|
|
257
|
+
npx slophammer-ts@latest check . --only ts.dependency-boundaries-required
|
|
258
|
+
npx -y @simpledoc/simpledoc check
|
|
259
|
+
git diff --check
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Automated tests use temporary directories and deterministic executors. They do not call a real model.
|
|
263
|
+
|
|
264
|
+
After these checks pass, run the separate installed-package E2E with the exact authenticated low-cost model `openai/gpt-5.6-luna`. Use temporary workflow state. Do not prune or seed the user's live database.
|
|
265
|
+
|
|
266
|
+
## Acceptance criteria
|
|
267
|
+
|
|
268
|
+
The work is complete when all of these statements are true:
|
|
269
|
+
|
|
270
|
+
- Terminal root-run trees remain for 30 days.
|
|
271
|
+
- Cleanup removes only complete eligible trees.
|
|
272
|
+
- Every active, pending, open, resumable, recording, queued, leased, unsettled, controller-owned, cross-linked, undelivered, or active-content case remains protected.
|
|
273
|
+
- Automatic cleanup runs without a second service or manual command.
|
|
274
|
+
- Manual prune remains backup-first.
|
|
275
|
+
- Repeated cleanup is safe and does not partly delete a lineage tree.
|
|
276
|
+
- Freed pages can be reused.
|
|
277
|
+
- Automatic `VACUUM` follows the 64 MiB, 20 percent, and idle rules.
|
|
278
|
+
- Retained runs still resume and render through bounded reads.
|
|
279
|
+
- Deleted runs return not found.
|
|
280
|
+
- The large-state regression proves that repeated runner replies do not copy growing session history.
|
|
281
|
+
- The SQLite schema stays at version 1 with no migration or compatibility path.
|
|
282
|
+
- Pi core, Pi session history, other repositories, external services, and the user's live workflow state remain unchanged.
|
|
283
|
+
- All repository checks and the separate real-model E2E pass.
|
package/docs/workflows.md
CHANGED
|
@@ -771,6 +771,22 @@ resume` takes a new generation and reruns only work after the last durable
|
|
|
771
771
|
- Server status reports safe counts and timestamps. It does not report session
|
|
772
772
|
IDs, project paths, prompts, payloads, tokens, process IDs, or credentials.
|
|
773
773
|
|
|
774
|
+
## Run history retention
|
|
775
|
+
|
|
776
|
+
Pi Workflows keeps a terminal root run and all its restart or continuation descendants for 30 days from `finished_at`. The server can remove the tree after that point only when every descendant is terminal and no protected work or outside reference remains.
|
|
777
|
+
|
|
778
|
+
Protected work includes waiting or parked runs, live queue rows, pending workflow messages, open workflow turns, pending interactions or decisions, recording session segments, queued follow-ups, active leases, unsettled effects, controller ownership, active runner content, resumable checkpoints, and undelivered terminal results.
|
|
779
|
+
|
|
780
|
+
The server checks for cleanup after startup recovery and after workflow runners exit. It also schedules a daily check after each completed sweep. Cleanup runs only while normal server work is idle. One process completes at most one sweep in 24 hours. If work appears, cleanup stops between complete root trees. The next idle lifecycle trigger or a five-minute idle retry continues it.
|
|
781
|
+
|
|
782
|
+
Automatic cleanup does not create a backup. The explicit `pi-workflows state prune` apply command still requires a new absolute verified backup. Both paths use the same eligibility, deletion, and blob-reference rules.
|
|
783
|
+
|
|
784
|
+
Deleting a run tree removes its unreferenced blobs. SQLite can reuse those pages immediately. Automatic `VACUUM` runs only while the server remains idle, at least 64 MiB is reclaimable, and at least 20 percent of pages are free. A failed or skipped compaction does not restore deleted history or make free pages unusable.
|
|
785
|
+
|
|
786
|
+
Retained runs keep complete resume and viewer behavior through the existing bounded reads. An expired deleted run no longer appears in lists and a direct view returns not found. Pi session history is not changed.
|
|
787
|
+
|
|
788
|
+
This is a history policy, not a hard disk limit. Recent or protected runs can be large. See [SQLite state](SQLITE_STATE.md) and [the automatic state-retention plan](plans/2026-09-04-automatic-state-retention-plan.md) for the full safety and test contract.
|
|
789
|
+
|
|
774
790
|
## Workflows started by resource managers
|
|
775
791
|
|
|
776
792
|
A resource manager can start a workflow as a finite child job with `ctx.workflows.ensure()`. The request key is stable across reconciliation passes, and the input fingerprint prevents one key from being reused for different work.
|
package/herdr-plugin.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osolmaz/pi-workflows",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.5",
|
|
4
4
|
"description": "Workflow and resource manager runtime with a live terminal viewer for the pi coding agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -71,14 +71,15 @@
|
|
|
71
71
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
|
+
"@earendil-works/pi-server": ">=0.85.0",
|
|
74
75
|
"better-sqlite3": "^13.0.2",
|
|
75
76
|
"jiti": "^2.7.0",
|
|
76
77
|
"tsx": "^4.23.13"
|
|
77
78
|
},
|
|
78
79
|
"devDependencies": {
|
|
79
|
-
"@earendil-works/pi-ai": "0.
|
|
80
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
81
|
-
"@earendil-works/pi-tui": "0.
|
|
80
|
+
"@earendil-works/pi-ai": "0.85.0",
|
|
81
|
+
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
82
|
+
"@earendil-works/pi-tui": "0.85.0",
|
|
82
83
|
"@types/better-sqlite3": "^7.6.13",
|
|
83
84
|
"@types/node": "^26.1.1",
|
|
84
85
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
@@ -89,9 +90,9 @@
|
|
|
89
90
|
"vitest": "^4.1.10"
|
|
90
91
|
},
|
|
91
92
|
"peerDependencies": {
|
|
92
|
-
"@earendil-works/pi-ai": ">=0.84.2
|
|
93
|
-
"@earendil-works/pi-coding-agent": ">=0.84.2
|
|
94
|
-
"@earendil-works/pi-tui": ">=0.84.2
|
|
93
|
+
"@earendil-works/pi-ai": ">=0.84.2",
|
|
94
|
+
"@earendil-works/pi-coding-agent": ">=0.84.2",
|
|
95
|
+
"@earendil-works/pi-tui": ">=0.84.2",
|
|
95
96
|
"typebox": "*"
|
|
96
97
|
},
|
|
97
98
|
"engines": {
|
package/src/server/server.ts
CHANGED
|
@@ -62,7 +62,11 @@ import {
|
|
|
62
62
|
import { StateDatabase, workflowStatePath } from "../state/database.js";
|
|
63
63
|
import { canonicalJson, type JsonValue } from "../state/json.js";
|
|
64
64
|
import { resourceIdFor } from "../state/mutation.js";
|
|
65
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
AUTOMATIC_STATE_PRUNE_INTERVAL_MS,
|
|
67
|
+
pruneState,
|
|
68
|
+
pruneStateAutomatically,
|
|
69
|
+
} from "../state/prune.js";
|
|
66
70
|
import { recordViewerDeltas } from "../state/viewer.js";
|
|
67
71
|
import { workflowMessageIdFor } from "../state/workflow-messages.js";
|
|
68
72
|
import { humanDecisionChannelRequest } from "../workflows/decision-presentation.js";
|
|
@@ -135,6 +139,7 @@ const SERVER_RENEW_MS = 10_000;
|
|
|
135
139
|
const PACKAGE_VERSION = runtimePackageVersion();
|
|
136
140
|
const CLAIM_POLL_MS = 2_000;
|
|
137
141
|
const TERMINAL_MESSAGE_RECONCILE_MS = 1_000;
|
|
142
|
+
const AUTOMATIC_STATE_PRUNE_IDLE_RETRY_MS = 5 * 60 * 1_000;
|
|
138
143
|
const RUN_CLAIM_LEASE_MS = 30_000;
|
|
139
144
|
const RESOURCE_MANAGER_CLAIM_LEASE_MS = 120_000;
|
|
140
145
|
const RESOURCE_MANAGER_RENEW_MS = 30_000;
|
|
@@ -264,6 +269,12 @@ export class WorkflowServer {
|
|
|
264
269
|
private decisionChannelConfig: DecisionChannelConfig | null = null;
|
|
265
270
|
private decisionChannelError: string | null = null;
|
|
266
271
|
private channelReloading = false;
|
|
272
|
+
private automaticStatePruneTask: Promise<void> | null = null;
|
|
273
|
+
private automaticStatePruneTimer: ReturnType<typeof setTimeout> | null = null;
|
|
274
|
+
private automaticStatePruneScheduled = false;
|
|
275
|
+
private automaticStatePruneDue = true;
|
|
276
|
+
private lastAutomaticStatePruneAt: number | null = null;
|
|
277
|
+
private nextAutomaticStatePruneAttemptAt = 0;
|
|
267
278
|
private nextTerminalMessageReconciliationAt = 0;
|
|
268
279
|
|
|
269
280
|
constructor(options: WorkflowServerOptions = {}) {
|
|
@@ -347,13 +358,16 @@ export class WorkflowServer {
|
|
|
347
358
|
this.startTimers();
|
|
348
359
|
this.started = true;
|
|
349
360
|
this.log(`ready on ${this.socketPath} at epoch ${this.claim.epoch}`);
|
|
361
|
+
this.requestAutomaticStatePrune();
|
|
350
362
|
this.expireTimedOutInteraction();
|
|
351
|
-
void this.expireTimedOutDecision();
|
|
352
|
-
void this.claimOne();
|
|
353
|
-
void this.claimResourceManagerOne();
|
|
354
|
-
void this.reloadDecisionChannels()
|
|
355
|
-
|
|
356
|
-
|
|
363
|
+
void this.expireTimedOutDecision().finally(() => this.resumeAutomaticStatePruneIfDue());
|
|
364
|
+
void this.claimOne().finally(() => this.resumeAutomaticStatePruneIfDue());
|
|
365
|
+
void this.claimResourceManagerOne().finally(() => this.resumeAutomaticStatePruneIfDue());
|
|
366
|
+
void this.reloadDecisionChannels()
|
|
367
|
+
.catch((error) => {
|
|
368
|
+
this.log(`decision channel startup failed: ${errorMessage(error)}`);
|
|
369
|
+
})
|
|
370
|
+
.finally(() => this.resumeAutomaticStatePruneIfDue());
|
|
357
371
|
} catch (error) {
|
|
358
372
|
const server = this.server;
|
|
359
373
|
this.server = null;
|
|
@@ -378,9 +392,12 @@ export class WorkflowServer {
|
|
|
378
392
|
if (this.heartbeatTimer !== null) clearInterval(this.heartbeatTimer);
|
|
379
393
|
if (this.pollTimer !== null) clearInterval(this.pollTimer);
|
|
380
394
|
if (this.viewTimer !== null) clearInterval(this.viewTimer);
|
|
395
|
+
if (this.automaticStatePruneTimer !== null) clearTimeout(this.automaticStatePruneTimer);
|
|
381
396
|
this.heartbeatTimer = null;
|
|
382
397
|
this.pollTimer = null;
|
|
383
398
|
this.viewTimer = null;
|
|
399
|
+
this.automaticStatePruneTimer = null;
|
|
400
|
+
this.automaticStatePruneScheduled = false;
|
|
384
401
|
const server = this.server;
|
|
385
402
|
this.server = null;
|
|
386
403
|
this.detachSessionCoordinators();
|
|
@@ -419,6 +436,9 @@ export class WorkflowServer {
|
|
|
419
436
|
this.activeChannels.clear();
|
|
420
437
|
await Promise.allSettled(this.activationTasks.values());
|
|
421
438
|
await Promise.allSettled(this.maintenanceCommands.values());
|
|
439
|
+
if (this.automaticStatePruneTask !== null) {
|
|
440
|
+
await Promise.allSettled([this.automaticStatePruneTask]);
|
|
441
|
+
}
|
|
422
442
|
this.registry.killAll();
|
|
423
443
|
if (this.claim !== null) this.serverState.releaseServer(this.claim);
|
|
424
444
|
this.claim = null;
|
|
@@ -763,6 +783,7 @@ export class WorkflowServer {
|
|
|
763
783
|
});
|
|
764
784
|
case "state.prune":
|
|
765
785
|
return await this.executeMaintenanceCommand(request, async () => {
|
|
786
|
+
await this.waitForAutomaticStatePrune();
|
|
766
787
|
const payload = requireRecord(request.payload, "state.prune payload");
|
|
767
788
|
const before = requireString(payload.before, "before");
|
|
768
789
|
const apply = requireBoolean(payload.apply, "apply");
|
|
@@ -836,6 +857,7 @@ export class WorkflowServer {
|
|
|
836
857
|
if (this.maintenanceCommands.get(key) === execution) {
|
|
837
858
|
this.maintenanceCommands.delete(key);
|
|
838
859
|
}
|
|
860
|
+
this.requestAutomaticStatePrune();
|
|
839
861
|
}
|
|
840
862
|
}
|
|
841
863
|
|
|
@@ -1927,6 +1949,127 @@ export class WorkflowServer {
|
|
|
1927
1949
|
return [...digests].map((digest) => Buffer.from(digest, "hex"));
|
|
1928
1950
|
}
|
|
1929
1951
|
|
|
1952
|
+
private requestAutomaticStatePrune(): void {
|
|
1953
|
+
this.automaticStatePruneDue = true;
|
|
1954
|
+
if (
|
|
1955
|
+
!this.started ||
|
|
1956
|
+
this.stopping ||
|
|
1957
|
+
this.automaticStatePruneScheduled ||
|
|
1958
|
+
this.automaticStatePruneTask !== null
|
|
1959
|
+
) {
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
this.automaticStatePruneScheduled = true;
|
|
1963
|
+
setImmediate(() => {
|
|
1964
|
+
if (!this.automaticStatePruneScheduled) return;
|
|
1965
|
+
this.automaticStatePruneScheduled = false;
|
|
1966
|
+
this.startAutomaticStatePrune();
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
private resumeAutomaticStatePruneIfDue(): void {
|
|
1971
|
+
if (this.automaticStatePruneDue) this.requestAutomaticStatePrune();
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
private startAutomaticStatePrune(): void {
|
|
1975
|
+
if (this.automaticStatePruneTask !== null || this.stopping || !this.started) return;
|
|
1976
|
+
const task = this.runAutomaticStatePrune();
|
|
1977
|
+
this.automaticStatePruneTask = task;
|
|
1978
|
+
void task.finally(() => {
|
|
1979
|
+
if (this.automaticStatePruneTask === task) this.automaticStatePruneTask = null;
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
private async runAutomaticStatePrune(): Promise<void> {
|
|
1984
|
+
if (!this.automaticStatePruneDue) return;
|
|
1985
|
+
if (!this.automaticStatePruneCanContinue()) {
|
|
1986
|
+
this.scheduleAutomaticStatePruneTimer(AUTOMATIC_STATE_PRUNE_IDLE_RETRY_MS);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
const now = Date.now();
|
|
1990
|
+
if (now < this.nextAutomaticStatePruneAttemptAt) {
|
|
1991
|
+
this.scheduleAutomaticStatePruneTimer(this.nextAutomaticStatePruneAttemptAt - now);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
if (
|
|
1995
|
+
this.lastAutomaticStatePruneAt !== null &&
|
|
1996
|
+
now - this.lastAutomaticStatePruneAt < AUTOMATIC_STATE_PRUNE_INTERVAL_MS
|
|
1997
|
+
) {
|
|
1998
|
+
this.scheduleAutomaticStatePruneTimer(
|
|
1999
|
+
this.lastAutomaticStatePruneAt + AUTOMATIC_STATE_PRUNE_INTERVAL_MS - now,
|
|
2000
|
+
);
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
try {
|
|
2004
|
+
const report = await pruneStateAutomatically(this.state, this.databasePath, {
|
|
2005
|
+
now,
|
|
2006
|
+
activeBlobHashes: () => this.activeRunnerContentHashes(),
|
|
2007
|
+
shouldContinue: () => this.automaticStatePruneCanContinue(),
|
|
2008
|
+
});
|
|
2009
|
+
if (!report.completed) {
|
|
2010
|
+
this.automaticStatePruneDue = true;
|
|
2011
|
+
this.scheduleAutomaticStatePruneTimer(AUTOMATIC_STATE_PRUNE_IDLE_RETRY_MS);
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
this.lastAutomaticStatePruneAt = now;
|
|
2015
|
+
this.nextAutomaticStatePruneAttemptAt = 0;
|
|
2016
|
+
this.automaticStatePruneDue = false;
|
|
2017
|
+
this.scheduleAutomaticStatePruneTimer(AUTOMATIC_STATE_PRUNE_INTERVAL_MS);
|
|
2018
|
+
this.log(
|
|
2019
|
+
`automatic state prune completed: ${report.selectedRuns} run(s) selected, ` +
|
|
2020
|
+
`${report.blockedTrees} tree(s) blocked, ${report.deletedBlobs} blob(s) and ` +
|
|
2021
|
+
`${report.deletedBlobBytes} byte(s) removed`,
|
|
2022
|
+
);
|
|
2023
|
+
if (report.compactionError !== undefined) {
|
|
2024
|
+
this.log(`automatic state compaction failed: ${report.compactionError}`);
|
|
2025
|
+
}
|
|
2026
|
+
} catch (error) {
|
|
2027
|
+
this.automaticStatePruneDue = true;
|
|
2028
|
+
this.nextAutomaticStatePruneAttemptAt = Date.now() + AUTOMATIC_STATE_PRUNE_IDLE_RETRY_MS;
|
|
2029
|
+
this.scheduleAutomaticStatePruneTimer(AUTOMATIC_STATE_PRUNE_IDLE_RETRY_MS);
|
|
2030
|
+
this.log(`automatic state prune failed: ${errorMessage(error)}`);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
private scheduleAutomaticStatePruneTimer(delayMs: number): void {
|
|
2035
|
+
if (this.stopping || !this.started) return;
|
|
2036
|
+
if (this.automaticStatePruneTimer !== null) clearTimeout(this.automaticStatePruneTimer);
|
|
2037
|
+
this.automaticStatePruneTimer = setTimeout(
|
|
2038
|
+
() => {
|
|
2039
|
+
this.automaticStatePruneTimer = null;
|
|
2040
|
+
this.requestAutomaticStatePrune();
|
|
2041
|
+
},
|
|
2042
|
+
Math.max(1, delayMs),
|
|
2043
|
+
);
|
|
2044
|
+
this.automaticStatePruneTimer.unref?.();
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
private automaticStatePruneCanContinue(): boolean {
|
|
2048
|
+
return (
|
|
2049
|
+
this.started &&
|
|
2050
|
+
!this.stopping &&
|
|
2051
|
+
this.activeRuns.size === 0 &&
|
|
2052
|
+
this.activeResourceManagers.size === 0 &&
|
|
2053
|
+
this.activationTasks.size === 0 &&
|
|
2054
|
+
this.maintenanceCommands.size === 0 &&
|
|
2055
|
+
this.pendingStarts.size === 0 &&
|
|
2056
|
+
this.pendingRunClaims.size === 0 &&
|
|
2057
|
+
this.pendingResumes.size === 0 &&
|
|
2058
|
+
this.controlClaims.size === 0 &&
|
|
2059
|
+
this.pendingTerminalMessageReconciliations.size === 0 &&
|
|
2060
|
+
!this.resourceManagerPollActive &&
|
|
2061
|
+
!this.decisionTimeoutActive &&
|
|
2062
|
+
!this.channelReloading &&
|
|
2063
|
+
[...this.activeChannels.values()].every((channel) => channel.inFlight.size === 0)
|
|
2064
|
+
);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
private async waitForAutomaticStatePrune(): Promise<void> {
|
|
2068
|
+
this.automaticStatePruneScheduled = false;
|
|
2069
|
+
const task = this.automaticStatePruneTask;
|
|
2070
|
+
if (task !== null) await task;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1930
2073
|
private stateStatusReceipt(): JsonValue {
|
|
1931
2074
|
const count = (sql: string, ...params: unknown[]): number => {
|
|
1932
2075
|
const row = this.state.connection.prepare(sql).get(...params) as
|
|
@@ -3139,6 +3282,7 @@ export class WorkflowServer {
|
|
|
3139
3282
|
} finally {
|
|
3140
3283
|
clearInterval(renewTimer);
|
|
3141
3284
|
this.activeResourceManagers.delete(key);
|
|
3285
|
+
this.requestAutomaticStatePrune();
|
|
3142
3286
|
if (!this.stopping) setImmediate(() => void this.claimResourceManagerOne());
|
|
3143
3287
|
}
|
|
3144
3288
|
}
|
|
@@ -3798,6 +3942,7 @@ export class WorkflowServer {
|
|
|
3798
3942
|
} finally {
|
|
3799
3943
|
this.reapRunnerDescendants(envelope.runnerEpoch);
|
|
3800
3944
|
this.activeRuns.delete(runId);
|
|
3945
|
+
this.requestAutomaticStatePrune();
|
|
3801
3946
|
}
|
|
3802
3947
|
}
|
|
3803
3948
|
|
|
@@ -4434,6 +4579,10 @@ export class WorkflowServer {
|
|
|
4434
4579
|
AND generation = ?`,
|
|
4435
4580
|
)
|
|
4436
4581
|
.run(context.runId, active.generation);
|
|
4582
|
+
this.queue.settleRunEffect(
|
|
4583
|
+
context.runId,
|
|
4584
|
+
context.state.status === "waiting" ? "run.park_queue" : "run.settle_queue",
|
|
4585
|
+
);
|
|
4437
4586
|
}
|
|
4438
4587
|
|
|
4439
4588
|
private ensureTerminalWorkflowMessage(
|