@osolmaz/pi-workflows 0.16.4 → 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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  id = "osolmaz.pi-workflows"
2
2
  name = "pi-workflows"
3
- version = "0.16.4"
3
+ version = "0.16.5"
4
4
  min_herdr_version = "0.7.0"
5
5
  description = "Open the active pi-workflows run in piw from a managed Herdr pane."
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osolmaz/pi-workflows",
3
- "version": "0.16.4",
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"
@@ -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 { pruneState } from "../state/prune.js";
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().catch((error) => {
355
- this.log(`decision channel startup failed: ${errorMessage(error)}`);
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(