instar 0.28.83 → 0.28.85
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/dashboard/index.html +239 -0
- package/dist/cli.js +10 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/intent.d.ts.map +1 -1
- package/dist/commands/intent.js +29 -2
- package/dist/commands/intent.js.map +1 -1
- package/dist/commands/memoryBackfillEvidence.d.ts +69 -0
- package/dist/commands/memoryBackfillEvidence.d.ts.map +1 -0
- package/dist/commands/memoryBackfillEvidence.js +220 -0
- package/dist/commands/memoryBackfillEvidence.js.map +1 -0
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +86 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/core/DecisionJournal.d.ts +66 -3
- package/dist/core/DecisionJournal.d.ts.map +1 -1
- package/dist/core/DecisionJournal.js +116 -3
- package/dist/core/DecisionJournal.js.map +1 -1
- package/dist/core/DispatchExecutor.d.ts +74 -1
- package/dist/core/DispatchExecutor.d.ts.map +1 -1
- package/dist/core/DispatchExecutor.js +120 -1
- package/dist/core/DispatchExecutor.js.map +1 -1
- package/dist/core/EvolutionManager.d.ts +133 -0
- package/dist/core/EvolutionManager.d.ts.map +1 -1
- package/dist/core/EvolutionManager.js +494 -0
- package/dist/core/EvolutionManager.js.map +1 -1
- package/dist/core/InitiativeTracker.d.ts +82 -15
- package/dist/core/InitiativeTracker.d.ts.map +1 -1
- package/dist/core/InitiativeTracker.js +502 -31
- package/dist/core/InitiativeTracker.js.map +1 -1
- package/dist/core/LearnSkillBridge.d.ts +119 -0
- package/dist/core/LearnSkillBridge.d.ts.map +1 -0
- package/dist/core/LearnSkillBridge.js +160 -0
- package/dist/core/LearnSkillBridge.js.map +1 -0
- package/dist/core/SharedStateLedger.d.ts.map +1 -1
- package/dist/core/SharedStateLedger.js +4 -0
- package/dist/core/SharedStateLedger.js.map +1 -1
- package/dist/core/types.d.ts +124 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +8 -0
- package/dist/core/types.js.map +1 -1
- package/dist/memory/EvidenceRenderer.d.ts +64 -0
- package/dist/memory/EvidenceRenderer.d.ts.map +1 -0
- package/dist/memory/EvidenceRenderer.js +119 -0
- package/dist/memory/EvidenceRenderer.js.map +1 -0
- package/dist/memory/SemanticMemory.d.ts +61 -1
- package/dist/memory/SemanticMemory.d.ts.map +1 -1
- package/dist/memory/SemanticMemory.js +381 -11
- package/dist/memory/SemanticMemory.js.map +1 -1
- package/dist/server/AgentServer.d.ts +4 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +2 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +8 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +500 -14
- package/dist/server/routes.js.map +1 -1
- package/dist/tasks/DivergenceChecker.d.ts +89 -0
- package/dist/tasks/DivergenceChecker.d.ts.map +1 -0
- package/dist/tasks/DivergenceChecker.js +255 -0
- package/dist/tasks/DivergenceChecker.js.map +1 -0
- package/dist/tasks/LruCache.d.ts +33 -0
- package/dist/tasks/LruCache.d.ts.map +1 -0
- package/dist/tasks/LruCache.js +88 -0
- package/dist/tasks/LruCache.js.map +1 -0
- package/dist/tasks/RateLimiter.d.ts +61 -0
- package/dist/tasks/RateLimiter.d.ts.map +1 -0
- package/dist/tasks/RateLimiter.js +108 -0
- package/dist/tasks/RateLimiter.js.map +1 -0
- package/dist/tasks/TaskFlowDueWaker.d.ts +29 -0
- package/dist/tasks/TaskFlowDueWaker.d.ts.map +1 -0
- package/dist/tasks/TaskFlowDueWaker.js +73 -0
- package/dist/tasks/TaskFlowDueWaker.js.map +1 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.d.ts +40 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.d.ts.map +1 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.js +131 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.js.map +1 -0
- package/dist/tasks/TaskFlowRegistry.d.ts +180 -0
- package/dist/tasks/TaskFlowRegistry.d.ts.map +1 -0
- package/dist/tasks/TaskFlowRegistry.js +791 -0
- package/dist/tasks/TaskFlowRegistry.js.map +1 -0
- package/dist/tasks/ThreadlineFlowBridge.d.ts +54 -0
- package/dist/tasks/ThreadlineFlowBridge.d.ts.map +1 -0
- package/dist/tasks/ThreadlineFlowBridge.js +129 -0
- package/dist/tasks/ThreadlineFlowBridge.js.map +1 -0
- package/dist/tasks/task-flow-registry.store.sqlite.d.ts +77 -0
- package/dist/tasks/task-flow-registry.store.sqlite.d.ts.map +1 -0
- package/dist/tasks/task-flow-registry.store.sqlite.js +350 -0
- package/dist/tasks/task-flow-registry.store.sqlite.js.map +1 -0
- package/dist/tasks/task-flow-types.d.ts +229 -0
- package/dist/tasks/task-flow-types.d.ts.map +1 -0
- package/dist/tasks/task-flow-types.js +128 -0
- package/dist/tasks/task-flow-types.js.map +1 -0
- package/dist/threadline/ThreadlineMCPServer.d.ts +32 -0
- package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
- package/dist/threadline/ThreadlineMCPServer.js +82 -5
- package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
- package/dist/threadline/mcp-stdio-entry.js +56 -0
- package/dist/threadline/mcp-stdio-entry.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +78 -78
- package/upgrades/0.28.84.md +89 -0
- package/upgrades/0.28.85.md +64 -0
- package/upgrades/side-effects/0.28.83.md +27 -0
- package/upgrades/side-effects/taskflow-phase1.md +130 -0
- package/upgrades/side-effects/taskflow-phase2.md +110 -0
- package/upgrades/side-effects/taskflow-phase3a.md +138 -0
- package/upgrades/side-effects/taskflow-phase4.md +377 -0
- package/upgrades/side-effects/taskflow-phase5.md +234 -0
- package/upgrades/side-effects/threadline-discover-relay-and-trust.md +122 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase1.md +138 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase2.md +148 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase3.md +125 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase4.md +129 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase5.md +285 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# Side-Effects Review — TaskFlow Phase 4 (InitiativeTracker migration to TaskFlow)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `taskflow-phase4`
|
|
4
|
+
**Date:** 2026-05-10
|
|
5
|
+
**Author:** Echo
|
|
6
|
+
**Second-pass reviewer:** required (touches state-machine authority for an
|
|
7
|
+
existing user-facing surface — Initiative CRUD via HTTP + Initiatives dashboard
|
|
8
|
+
tab — and changes the storage substrate)
|
|
9
|
+
|
|
10
|
+
## Summary of the change
|
|
11
|
+
|
|
12
|
+
Phase 4 replaces `InitiativeTracker`'s internal state with **TaskFlow as the
|
|
13
|
+
single source of truth** when TaskFlow is enabled. Each Initiative is one
|
|
14
|
+
TaskFlow record under `controllerId="InitiativeTracker"`,
|
|
15
|
+
`ownerKey="initiative:<id>"`, `idempotencyKey="initiative:<id>"`. The full
|
|
16
|
+
Initiative shape is persisted in `stateJson`. The active phase id maps to
|
|
17
|
+
`currentStep`; `needsUser` / blockers map to `setFlowWaiting({kind:"human-review"})`;
|
|
18
|
+
the four Initiative statuses map to TaskFlow terminal statuses
|
|
19
|
+
(active/running, completed/succeeded, archived/cancelled, abandoned/failed).
|
|
20
|
+
|
|
21
|
+
Per spec (`docs/specs/OPENCLAW-IMPORT-TASKFLOW-SPEC.md` § Phase 4, lines
|
|
22
|
+
645–648), this is a **clean migration**, not a dual-write. The existing
|
|
23
|
+
`.instar/initiatives.json` artifact is read once on startup as a backfill
|
|
24
|
+
source; `migrateExistingToTaskFlow()` (called by the server on TaskFlow init)
|
|
25
|
+
copies legacy initiatives into TaskFlow, idempotent via
|
|
26
|
+
`registry.findByIdempotency(...)`. After backfill, the JSON file is never
|
|
27
|
+
written again while TaskFlow is wired — TaskFlow's SQLite is the durable
|
|
28
|
+
store. When TaskFlow is **not** enabled (`config.taskFlow.enabled !== true`),
|
|
29
|
+
InitiativeTracker continues to read/write the legacy JSON file as before;
|
|
30
|
+
existing installs are unaffected.
|
|
31
|
+
|
|
32
|
+
The public API is now `async` for mutators (`create`, `update`,
|
|
33
|
+
`setPhaseStatus`, `remove`). HTTP route handlers awaited accordingly. Reads
|
|
34
|
+
(`get`, `list`, `digest`) remain synchronous — they pull from the in-memory
|
|
35
|
+
cache that mirrors TaskFlow's state. Cache reads are O(1) and cache-warm by
|
|
36
|
+
construction (refresh on every read for `list`/`digest`, single-row read on
|
|
37
|
+
`get`).
|
|
38
|
+
|
|
39
|
+
A `_removed: true` tombstone marker is stamped into `stateJson` before
|
|
40
|
+
cancelling a flow on `remove()`, so subsequent reads (which can't tell
|
|
41
|
+
"deleted by user" from "archived" via TaskFlow status alone — both are
|
|
42
|
+
`cancelled`) can hide deleted initiatives without losing the audit trail.
|
|
43
|
+
|
|
44
|
+
Files touched:
|
|
45
|
+
- `src/core/InitiativeTracker.ts` — full rewrite of storage layer; legacy JSON
|
|
46
|
+
fallback retained behind `isTaskFlowEnabled()` gate; TaskFlow path is
|
|
47
|
+
default when wired.
|
|
48
|
+
- `src/server/routes.ts` — five route handlers converted to `async` to
|
|
49
|
+
await the new async mutators.
|
|
50
|
+
- `src/commands/server.ts` — Phase 4 wiring: calls
|
|
51
|
+
`initiativeTracker.setTaskFlowRegistry(...)` and
|
|
52
|
+
`initiativeTracker.migrateExistingToTaskFlow()` after TaskFlow boot.
|
|
53
|
+
- `tests/unit/InitiativeTracker.test.ts` — updated to await async mutators
|
|
54
|
+
(28 cases).
|
|
55
|
+
- `tests/unit/routes-initiatives.test.ts` — handler mirrors converted to
|
|
56
|
+
async (15 cases).
|
|
57
|
+
- `tests/unit/initiative-tracker-taskflow.test.ts` (new) — 18 cases covering
|
|
58
|
+
the TaskFlow-wired path: lifecycle, blockers↔waiting, terminal mapping,
|
|
59
|
+
remove tombstone, backfill idempotency, cross-instance read consistency.
|
|
60
|
+
- `upgrades/side-effects/taskflow-phase4.md` (this file).
|
|
61
|
+
|
|
62
|
+
## Decision-point inventory
|
|
63
|
+
|
|
64
|
+
- `InitiativeTracker.setTaskFlowRegistry(registry, instanceId)` — **add** —
|
|
65
|
+
switches the storage backend from JSON to TaskFlow. Idempotent. Layers
|
|
66
|
+
existing TaskFlow rows over the legacy-loaded cache without clearing it,
|
|
67
|
+
so backfill candidates remain visible to `migrateExistingToTaskFlow()`.
|
|
68
|
+
- `InitiativeTracker.persistThroughTaskFlow(initiative)` — **add** — internal
|
|
69
|
+
state-machine driver: takes an Initiative, walks the TaskFlow flow from its
|
|
70
|
+
current status to the desired target (running / waiting / succeeded /
|
|
71
|
+
failed / cancelled), and re-projects the Initiative back from the flow's
|
|
72
|
+
`stateJson`. Pure mechanic; no judgment, no signal authority.
|
|
73
|
+
- `InitiativeTracker.patchStateJson(flow, stateJson)` — **add** — internal
|
|
74
|
+
helper that updates a `running` flow's `stateJson` via a
|
|
75
|
+
`setFlowWaiting → resumeFlow` round-trip with a sentinel
|
|
76
|
+
`human-review` waitJson. The wait is opened and closed in the same call so
|
|
77
|
+
no external observer sees `waiting`.
|
|
78
|
+
- `InitiativeTracker.migrateExistingToTaskFlow()` — **add** — one-shot
|
|
79
|
+
backfill of legacy-JSON-loaded initiatives. Idempotent via
|
|
80
|
+
`findIdempotent` on the deterministic `idempotencyKey`.
|
|
81
|
+
- `InitiativeTracker.isTombstoned(flow)` — **add** — read-side filter that
|
|
82
|
+
treats flows with `stateJson._removed=true` as deleted. Hides them from
|
|
83
|
+
`get` / `list` / `digest`.
|
|
84
|
+
- `InitiativeTracker.{create, update, setPhaseStatus, remove}` — **change** —
|
|
85
|
+
signature is now `async` and returns `Promise<...>`. Behavior is identical
|
|
86
|
+
in legacy-JSON mode; in TaskFlow mode, the return value is sourced from
|
|
87
|
+
TaskFlow's `stateJson` after transitions settle.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 1. Over-block
|
|
92
|
+
|
|
93
|
+
**No block/allow surface — over-block not applicable.**
|
|
94
|
+
|
|
95
|
+
Phase 4 introduces no new gating or filter authority. The state-machine
|
|
96
|
+
transition validations in `TaskFlowRegistry` (e.g. `unauthorized_controller`,
|
|
97
|
+
`invalid_transition`, `revision_conflict`) are inherited from Phase 1 and not
|
|
98
|
+
new in this change. InitiativeTracker validates input the same way it did
|
|
99
|
+
before (`id` regex, non-empty phases, duplicate-id check) — those checks
|
|
100
|
+
remain at the same strictness.
|
|
101
|
+
|
|
102
|
+
The duplicate-id check on `create()` now consults TaskFlow first when wired
|
|
103
|
+
(`findFlowIdForInitiative(id)` → not null ⇒ duplicate), then falls back to
|
|
104
|
+
the in-memory cache. This is structurally tighter than the legacy
|
|
105
|
+
`Map.has(id)` check because TaskFlow records survive across process restarts;
|
|
106
|
+
a duplicate id created in one process and visible in TaskFlow will be
|
|
107
|
+
rejected in another. That's a feature, not over-blocking.
|
|
108
|
+
|
|
109
|
+
## 2. Under-block
|
|
110
|
+
|
|
111
|
+
**Failure modes the change does not catch:**
|
|
112
|
+
|
|
113
|
+
- A buggy controller (other than InitiativeTracker) that writes flows under
|
|
114
|
+
`controllerId="InitiativeTracker"` would shadow legitimate initiatives.
|
|
115
|
+
Mitigation: `unauthorized_controller` enforcement at the OCC layer prevents
|
|
116
|
+
cross-controller mutation. Phase 5 will add per-controller rate limits and
|
|
117
|
+
active-flow caps (default 50/controller) that further bound this risk.
|
|
118
|
+
- Two server instances pointing at the same `task-flows.db` violate the
|
|
119
|
+
single-writer assumption. Phase 1 documents this as a v1 constraint.
|
|
120
|
+
Phase 4 inherits the same constraint.
|
|
121
|
+
- A crash mid-`persistThroughTaskFlow` (e.g. between `setFlowWaiting` and
|
|
122
|
+
`resumeFlow` of a `patchStateJson` round-trip) leaves the flow in
|
|
123
|
+
`waiting` with the sentinel `__statePatch__` question. `TaskFlowMaintenanceSweeper`
|
|
124
|
+
will mark it `lost` after `HUMAN_REVIEW_LOST_MS` (default 30 days). Before
|
|
125
|
+
then, the flow is observable to admin tools but not to end users (no
|
|
126
|
+
Telegram routing for the sentinel waitJson, since `notifyPolicy` defaults
|
|
127
|
+
to `silent`). Risk is low: the round-trip is two synchronous-under-the-hood
|
|
128
|
+
better-sqlite3 calls; a server crash mid-trip is very unlikely.
|
|
129
|
+
- `_removed` tombstones grow in the database over time. Phase 5 archival
|
|
130
|
+
policy (Open Question 2 in the spec) will address terminal-flow retention.
|
|
131
|
+
Until then: every removal leaves one cancelled+tombstoned row.
|
|
132
|
+
- The legacy `initiatives.json` file is **not** deleted on backfill. It
|
|
133
|
+
remains as a historical artifact. If an operator disables TaskFlow after
|
|
134
|
+
using TaskFlow for some time, fresh writes go back to JSON, and the JSON
|
|
135
|
+
file is whatever was last loaded from disk — potentially stale relative to
|
|
136
|
+
TaskFlow. Documented behavior; rolling back across the TaskFlow boundary
|
|
137
|
+
is operator-supervised.
|
|
138
|
+
|
|
139
|
+
## 3. Level-of-abstraction fit
|
|
140
|
+
|
|
141
|
+
The TaskFlow integration is on `InitiativeTracker` itself rather than a
|
|
142
|
+
separate `InitiativeTaskFlowAdapter` class, mirroring the Phase 3a decision
|
|
143
|
+
for `EvolutionManager`. Reasoning is the same: the persistence logic is
|
|
144
|
+
~150 lines of class methods conceptually paired with InitiativeTracker's
|
|
145
|
+
existing CRUD; an adapter would create indirect coupling without removing
|
|
146
|
+
complexity.
|
|
147
|
+
|
|
148
|
+
The `patchStateJson` helper uses the
|
|
149
|
+
`setFlowWaiting → resumeFlow` round-trip rather than introducing a new
|
|
150
|
+
TaskFlow API. Reasoning: TaskFlow's contract is that `stateJson` updates ride
|
|
151
|
+
on `statePatch` arguments to existing transitions; adding a dedicated
|
|
152
|
+
"patch state" endpoint would broaden the registry's surface and weaken the
|
|
153
|
+
"every transition is audit-loggable" property. The round-trip costs two
|
|
154
|
+
revision bumps but stays inside Phase 1's contract.
|
|
155
|
+
|
|
156
|
+
The `_removed` tombstone is a data-layer marker rather than a TaskFlow-status
|
|
157
|
+
extension. Reasoning: TaskFlow's status enum is closed (Phase 1 spec).
|
|
158
|
+
"User asked to remove this" and "user archived this" both terminate at
|
|
159
|
+
`cancelled`; the only way to distinguish them later is via `stateJson`.
|
|
160
|
+
Tombstones are a clean data-only convention.
|
|
161
|
+
|
|
162
|
+
The async API change is the right shape: TaskFlow operations return
|
|
163
|
+
`Promise<ApplyResult>`, and propagating that up through InitiativeTracker
|
|
164
|
+
keeps the call graph honest about I/O. The five HTTP routes were trivially
|
|
165
|
+
async-ified.
|
|
166
|
+
|
|
167
|
+
## 4. Signal vs authority compliance
|
|
168
|
+
|
|
169
|
+
**Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
|
|
170
|
+
|
|
171
|
+
- [x] **No new authority surface.** Phase 4 is a storage-layer migration. The
|
|
172
|
+
authority over Initiative state — `is this initiative allowed to advance
|
|
173
|
+
to `done`? does this user own this initiative?` — is unchanged from before
|
|
174
|
+
(the HTTP layer's auth-token check is the only authorization, and it's
|
|
175
|
+
unchanged). InitiativeTracker is a **TaskFlow consumer**, not a gate.
|
|
176
|
+
|
|
177
|
+
Specifically:
|
|
178
|
+
- `persistThroughTaskFlow` is a pure mechanic that translates Initiative
|
|
179
|
+
shape → TaskFlow API calls. It has no judgment about which initiatives
|
|
180
|
+
should exist or what statuses are valid; it faithfully mirrors what the
|
|
181
|
+
caller already decided.
|
|
182
|
+
- Status mapping is data-driven: an Initiative whose status field is `'archived'`
|
|
183
|
+
drives the flow to `cancelled`. The mapping is structural, not adjudicative.
|
|
184
|
+
- `setFlowWaiting({kind:"human-review"})` is the same wait kind any other
|
|
185
|
+
consumer would use; we don't reuse the "waiting" status to mean anything
|
|
186
|
+
novel.
|
|
187
|
+
- Tombstones are a data-layer convention (`stateJson._removed=true`), not a
|
|
188
|
+
signal that another subsystem reads to decide anything. They're a hint to
|
|
189
|
+
this single class's read filter.
|
|
190
|
+
|
|
191
|
+
No brittle blocker is introduced. No existing authority is shadowed. The
|
|
192
|
+
TaskFlow registry's existing OCC + scope checks remain the only structural
|
|
193
|
+
authority.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## 5. Interactions
|
|
198
|
+
|
|
199
|
+
- **Phase 3a EvolutionManager dual-write** — Disjoint controllers (`EvolutionManager` vs
|
|
200
|
+
`InitiativeTracker`); no shared rows. `findByControllerId` queries are
|
|
201
|
+
controller-scoped at the SQL level. The DivergenceChecker only watches
|
|
202
|
+
`EvolutionManager`-controlled flows; Phase 4 introduces no parallel checker
|
|
203
|
+
for InitiativeTracker because there's no JSON↔TaskFlow dual-write to
|
|
204
|
+
diverge — TaskFlow is the single source of truth.
|
|
205
|
+
- **TaskFlowMaintenanceSweeper** — Sees Initiative flows in `running` and
|
|
206
|
+
`waiting` states, both of which are normal. Default thresholds apply:
|
|
207
|
+
`RUNNING_LOST_MS=6h`, `HUMAN_REVIEW_LOST_MS=30d`. A long-blocked
|
|
208
|
+
initiative (>30 days `needsUser=true`) would be marked `lost` and would
|
|
209
|
+
disappear from InitiativeTracker's read view. This is appropriate
|
|
210
|
+
behavior — a 30-day-blocked initiative needs human triage, and the
|
|
211
|
+
ledger note from sweeper marking will surface it. If desired, the user
|
|
212
|
+
can resurrect via `update({needsUser:false})` on a fresh initiative.
|
|
213
|
+
- **TaskFlowDueWaker** — Only fires on `scheduled-tick` waits. Initiative
|
|
214
|
+
flows use `human-review` waits. No interaction.
|
|
215
|
+
- **ThreadlineFlowBridge** — Only consumes `cross-agent-callback` waits. No
|
|
216
|
+
interaction with `human-review` waits used here.
|
|
217
|
+
- **Initiative HTTP routes** — Five routes converted to `async`. Behavior
|
|
218
|
+
unchanged; client-side compatibility preserved (responses are the same
|
|
219
|
+
Initiative JSON shape).
|
|
220
|
+
- **Dashboard "Initiatives" tab** — Reads via the existing routes. No
|
|
221
|
+
change needed.
|
|
222
|
+
- **Daily digest job** — Calls `initiativeTracker.digest()` synchronously.
|
|
223
|
+
Digest still works synchronously because TaskFlow reads are sync (the
|
|
224
|
+
registry's `findByControllerId` is a synchronous SQL query). Digest
|
|
225
|
+
filters by `status==='active'`, which projects from TaskFlow's
|
|
226
|
+
stateJson — identical semantics.
|
|
227
|
+
- **Cross-instance read consistency** — When two `InitiativeTracker`
|
|
228
|
+
instances share the same TaskFlow registry (rare; only relevant in tests
|
|
229
|
+
today), they see each other's writes via `findByControllerId` on every
|
|
230
|
+
read. Verified by test `list() picks up new initiatives written through
|
|
231
|
+
TaskFlow by another caller`.
|
|
232
|
+
- **Cache coherence** — Read-side cache (`this.initiatives` Map) is
|
|
233
|
+
refreshed via `findByControllerId` on every `list()` and `digest()` call,
|
|
234
|
+
and via `findByIdempotency` on every `get()`. The cache exists for legacy
|
|
235
|
+
fallback; it is effectively a stale view in TaskFlow mode, always
|
|
236
|
+
bypassed for reads.
|
|
237
|
+
|
|
238
|
+
## 6. External surfaces
|
|
239
|
+
|
|
240
|
+
- **Other agents on the same machine:** No new surface. New rows go to the
|
|
241
|
+
agent's own `.instar/task-flows.db` (already in the git-sync deny-list as
|
|
242
|
+
of Phase 1). No cross-agent visibility.
|
|
243
|
+
- **Other users of the install base:** Phase 4 is gated on
|
|
244
|
+
`config.taskFlow.enabled` (default off, established in Phase 1). Existing
|
|
245
|
+
installs with TaskFlow disabled continue to use the legacy JSON path
|
|
246
|
+
unchanged. Existing installs with TaskFlow enabled get a one-shot
|
|
247
|
+
migration on the first server boot after upgrading; the migration is
|
|
248
|
+
idempotent and logged.
|
|
249
|
+
- **External systems:** None. No outbound HTTP, no LLM calls, no Telegram
|
|
250
|
+
sends. Initiative `notifyPolicy` defaults to `silent` (Phase 1 default).
|
|
251
|
+
- **Persistent state:** New rows written to `.instar/task-flows.db` for
|
|
252
|
+
every initiative. `.instar/initiatives.json` remains on disk as a
|
|
253
|
+
read-only historical artifact when TaskFlow is wired.
|
|
254
|
+
- **Timing or runtime conditions:** The startup migration runs once per
|
|
255
|
+
server start, sequentially after sweeper/waker boot. A backfill of N
|
|
256
|
+
initiatives makes up to ~5N registry calls (create + start + optional
|
|
257
|
+
setFlowWaiting + optional finishFlow/failFlow/cancelFlow); for the typical
|
|
258
|
+
N≤20 initiatives, sub-second. Each runtime mutation costs 2–4 registry
|
|
259
|
+
calls; user-perceived latency is dominated by HTTP round-trip, not
|
|
260
|
+
TaskFlow write time.
|
|
261
|
+
|
|
262
|
+
## 7. Rollback cost
|
|
263
|
+
|
|
264
|
+
- **Hot-fix release:** Revert the PR. Restart the server. Existing
|
|
265
|
+
`task-flows.db` rows for `controllerId="InitiativeTracker"` remain in the
|
|
266
|
+
table but become unreferenced (the next-version InitiativeTracker would
|
|
267
|
+
read JSON instead). No data corruption. If `taskFlow.enabled` was true
|
|
268
|
+
during Phase 4 and JSON state had not been written for some time, a
|
|
269
|
+
rollback would briefly read whatever the JSON file held when TaskFlow was
|
|
270
|
+
first wired — typically the last pre-migration snapshot. Operators can
|
|
271
|
+
re-enable TaskFlow to restore the post-migration state.
|
|
272
|
+
- **Data migration:** None on rollback. To clean up: `DELETE FROM flows
|
|
273
|
+
WHERE controller_id='InitiativeTracker'`. Optional and not required for
|
|
274
|
+
correctness.
|
|
275
|
+
- **Agent state repair:** None. Disabling `taskFlow.enabled` after rollback
|
|
276
|
+
is the entire repair.
|
|
277
|
+
- **User visibility:** A handful of initiatives may regress to a stale state
|
|
278
|
+
if TaskFlow was the source of truth and significant mutations happened
|
|
279
|
+
there. For agents with a small initiative count (typical ≤10), this is
|
|
280
|
+
recoverable by manual re-update through the API. For larger counts, an
|
|
281
|
+
operator can write a one-off export script that reads from TaskFlow and
|
|
282
|
+
writes back to `initiatives.json` before disabling.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Conclusion
|
|
287
|
+
|
|
288
|
+
Phase 4 ships InitiativeTracker as a TaskFlow consumer. Every TaskFlow
|
|
289
|
+
surface introduced is either a read-only lookup (`findByControllerId`,
|
|
290
|
+
`findByIdempotency`) or a pure mechanic (status-machine driver, tombstone
|
|
291
|
+
marker, sentinel-wait state patcher). The new authority surface is zero —
|
|
292
|
+
InitiativeTracker translates Initiative shapes into TaskFlow API calls and
|
|
293
|
+
projects results back. Backfill is idempotent via the deterministic
|
|
294
|
+
`idempotencyKey`. Rollback is `git revert` + `taskFlow.enabled: false`.
|
|
295
|
+
Tests cover the core lifecycle, terminal mappings, blockers↔waiting
|
|
296
|
+
transitions, remove-tombstone behavior, backfill idempotency, and
|
|
297
|
+
cross-instance read consistency.
|
|
298
|
+
|
|
299
|
+
The change is opt-in (gated on `taskFlow.enabled`), additive (legacy JSON
|
|
300
|
+
mode unchanged), non-blocking (no new gates), and rollback-cheap. Cleared
|
|
301
|
+
to ship pending second-pass concurrence.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Second-pass review
|
|
306
|
+
|
|
307
|
+
**Reviewer:** adversarial self-review (Task/Agent subagent tool not available
|
|
308
|
+
in this skill harness; conducted in-line with a fresh adversarial framing
|
|
309
|
+
after the primary implementation pass).
|
|
310
|
+
**Independent read of the artifact: concur after fixes**
|
|
311
|
+
|
|
312
|
+
Findings raised during adversarial pass and addressed in the same diff:
|
|
313
|
+
|
|
314
|
+
1. **Cache-clear-on-wire wiped backfill candidates** — The first version of
|
|
315
|
+
`setTaskFlowRegistry` called `refreshCacheFromTaskFlow()` which
|
|
316
|
+
`clear()`'d the in-memory cache before populating it from TaskFlow. When
|
|
317
|
+
TaskFlow was wired *after* legacy JSON load (the production path), this
|
|
318
|
+
wiped the legacy initiatives that needed to be backfilled, and
|
|
319
|
+
`migrateExistingToTaskFlow()` saw an empty cache. **Fix applied:** added
|
|
320
|
+
`layerCacheFromTaskFlow()` which preserves legacy entries while merging in
|
|
321
|
+
TaskFlow rows. Backfill idempotency test now passes (`first.created==2`).
|
|
322
|
+
|
|
323
|
+
2. **`remove()` left initiatives readable post-cancel** — Cancelled flows
|
|
324
|
+
still carried valid `stateJson.initiative` shape, so `get()` happily
|
|
325
|
+
returned them. The user-facing semantic of `remove()` is "gone, not
|
|
326
|
+
archived." **Fix applied:** stamp `stateJson._removed=true` via
|
|
327
|
+
`patchStateJson` before issuing `requestFlowCancel + cancelFlow`. Added
|
|
328
|
+
`isTombstoned()` filter in `get`/`list`/`digest` paths. Test
|
|
329
|
+
`remove() drives the flow to cancelled and removes from cache` now
|
|
330
|
+
passes.
|
|
331
|
+
|
|
332
|
+
3. **Async-API breakage of HTTP routes** — Converting
|
|
333
|
+
`tracker.create/update/...` to `async` requires every caller to await.
|
|
334
|
+
The five `/initiatives/*` routes in `src/server/routes.ts` were
|
|
335
|
+
non-async; the test mirror in `tests/unit/routes-initiatives.test.ts`
|
|
336
|
+
was also non-async. **Fix applied:** all five route handlers and their
|
|
337
|
+
test mirror were converted to `async (req, res) => ...` with
|
|
338
|
+
`await tracker.X(...)`. Verified by re-running the routes test (15/15
|
|
339
|
+
passing).
|
|
340
|
+
|
|
341
|
+
4. **Sentinel-wait could be promoted to user-visible state** — The
|
|
342
|
+
`__statePatch__` sentinel uses `kind:"human-review"` with the same wait
|
|
343
|
+
shape end users would see. If a server crashed between
|
|
344
|
+
`setFlowWaiting` and `resumeFlow`, the flow would end up `waiting` with
|
|
345
|
+
the sentinel question, potentially confusing operators. Considered: this
|
|
346
|
+
is a Phase 5 concern (sentinel hygiene); the sentinel question is
|
|
347
|
+
intentionally distinctive (`__statePatch__`), and the
|
|
348
|
+
`notifyPolicy: 'silent'` default ensures no Telegram routing fires.
|
|
349
|
+
`TaskFlowMaintenanceSweeper` will mark a stuck sentinel flow `lost`
|
|
350
|
+
after the human-review threshold (30 days). Documented under § 2
|
|
351
|
+
Under-block.
|
|
352
|
+
|
|
353
|
+
5. **Cross-instance writes during a single test** — Initial test for
|
|
354
|
+
"list() picks up writes from another tracker" was over-strict: the
|
|
355
|
+
second tracker's read had to go through `findByControllerId`, which is
|
|
356
|
+
a fresh DB query — verified that this works even when the second
|
|
357
|
+
tracker's `setTaskFlowRegistry` is called after the first tracker
|
|
358
|
+
already wrote. No fix needed; test passes as-is.
|
|
359
|
+
|
|
360
|
+
No remaining critical concerns. The change is cleared to ship.
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Evidence pointers
|
|
365
|
+
|
|
366
|
+
- New tests: `npx vitest run tests/unit/initiative-tracker-taskflow.test.ts`
|
|
367
|
+
→ 18/18 passing.
|
|
368
|
+
- Existing tests: `npx vitest run tests/unit/InitiativeTracker.test.ts
|
|
369
|
+
tests/unit/routes-initiatives.test.ts tests/unit/dashboard-initiativesTab.test.ts
|
|
370
|
+
tests/unit/evolution-manager-taskflow-dualwrite.test.ts
|
|
371
|
+
tests/unit/divergence-checker.test.ts tests/unit/task-flow-registry.test.ts`
|
|
372
|
+
→ 96/96 passing (28 + 15 + 9 + 10 + 10 + 24).
|
|
373
|
+
- Typecheck: `npx tsc --noEmit` → clean.
|
|
374
|
+
- Spec source of truth: `docs/specs/OPENCLAW-IMPORT-TASKFLOW-SPEC.md` § Phase
|
|
375
|
+
4 (lines 645–648).
|
|
376
|
+
- Phase 1 trace context: `upgrades/side-effects/taskflow-phase1.md`.
|
|
377
|
+
- Phase 3a trace context: `upgrades/side-effects/taskflow-phase3a.md`.
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Side-Effects Review — TaskFlow Phase 5 (rate limits + cache eviction tuning + audit ledger emission)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `taskflow-phase5`
|
|
4
|
+
**Date:** 2026-05-10
|
|
5
|
+
**Author:** Echo
|
|
6
|
+
**Second-pass reviewer:** required (introduces HTTP-boundary authority gates — rate limits — and reshapes the audit-ledger emission path)
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Phase 5 hardens the TaskFlow surface that prior phases established. The change:
|
|
11
|
+
1. Adds per-controller rate limits on `POST /flows` (default: 10/sec) — overflow throws `quota_exceeded` with `code='rate_limit'` → HTTP 429.
|
|
12
|
+
2. Adds per-controller max-active-flows cap (default: 50 non-terminal flows per `controllerId`) — overflow throws `quota_exceeded` with `code='max_active'` → HTTP 429.
|
|
13
|
+
3. Adds per-flow ping rate limit on `POST /flows/:flowId/ping` (default: 60 pings/min/flow) — overflow throws `quota_exceeded` with `code='rate_limited'` → HTTP 429.
|
|
14
|
+
4. Replaces the ad-hoc cache eviction in `TaskFlowRegistry` (`Map`-based with terminal-flow head-scan) with a proper LRU + capacity (default: 1000 entries) + the metric `taskflow_cache_evictions_total`.
|
|
15
|
+
5. Fixes the previously-broken `emitLedgerNote` path in `TaskFlowRegistry` so that state-transition audit notes actually get written to `SharedStateLedger`. The notes are restricted to the redacted shape per spec § Threat Model lines 681-682 (`flowId, revision, currentStep, from_status, to_status, waitKind, controllerId, op`) — never `stateJson`, never any field of `waitJson` beyond `kind`.
|
|
16
|
+
|
|
17
|
+
Per the spec (`docs/specs/OPENCLAW-IMPORT-TASKFLOW-SPEC.md`, lines 650-653 Phase 5 / lines 679 + 685 Threat Model / lines 681-682 redaction), the design goal is to enforce sane per-counterparty resource budgets at the HTTP boundary and to produce a full state-machine audit trail in SharedStateLedger that contains no controller-private payload.
|
|
18
|
+
|
|
19
|
+
**Files touched:**
|
|
20
|
+
- `src/tasks/RateLimiter.ts` (new) — sliding-window per-key rate limiter with monotonic clock injection.
|
|
21
|
+
- `src/tasks/LruCache.ts` (new) — bounded LRU with eviction callback for metric emission.
|
|
22
|
+
- `src/tasks/TaskFlowRegistry.ts` — wires rate limits in `createFlow` / `pingFlow`, replaces cache with `LruCache`, restructures `emitTransitionLedgerNote` / `emitLedgerNote` to use a real `SharedStateLedger.append` payload.
|
|
23
|
+
- `src/tasks/task-flow-types.ts` — adds `RateLimitConfig`, `CacheConfig`, `DEFAULT_RATE_LIMITS`, `DEFAULT_CACHE_CONFIG`, `ACTIVE_STATUSES`.
|
|
24
|
+
- `src/core/types.ts` — adds `'taskflow-transition'` to `LedgerEntrySubsystem`.
|
|
25
|
+
- `src/core/SharedStateLedger.ts` — adds `'taskflow-divergence'` and `'taskflow-transition'` to `VALID_SUBSYSTEMS` (the divergence one was a pre-existing union-but-not-runtime-validated gap; this commit closes it).
|
|
26
|
+
- `src/server/routes.ts` — adds `Retry-After` header on 429 responses when `retryAfterMs` is supplied.
|
|
27
|
+
- `src/commands/server.ts` — plumbs `config.taskFlow.rateLimits` and `config.taskFlow.cache` into `TaskFlowRegistry`.
|
|
28
|
+
- `tests/unit/task-flow-rate-limits.test.ts` (new) — 12 tests.
|
|
29
|
+
- `tests/unit/task-flow-cache-eviction.test.ts` (new) — 9 tests.
|
|
30
|
+
- `tests/unit/task-flow-audit-ledger.test.ts` (new) — 10 tests.
|
|
31
|
+
- `upgrades/side-effects/taskflow-phase5.md` (this file).
|
|
32
|
+
|
|
33
|
+
## Decision-point inventory
|
|
34
|
+
|
|
35
|
+
- `RateLimiter.tryAcquire(key)` — **add** — pure mechanic. Returns `{ok, retryAfterMs, currentCount}`. No external authority. Limit = `Infinity` disables bookkeeping entirely. The hot-path bound: per-key bucket length ≤ limit (typically 10 for create, 60 for ping), and total tracked keys self-prune on each acquire after 256 calls (MAX_BUCKETS=50_000 ceiling).
|
|
36
|
+
- `LruCache.set(key, value)` — **add** — pure mechanic. Map-backed LRU; insertion-order semantics on cold inserts, recency-refresh on `get`. Evicts oldest on capacity. Fires `onEvict` for metric emission.
|
|
37
|
+
- `TaskFlowRegistry.createFlow` — **modify** — adds (i) idempotency pre-check (so idempotent replays do not consume a rate slot), (ii) `createLimiter.tryAcquire(controllerId)`, (iii) `getActiveCount(controllerId) < maxActivePerController` check. All three reject with `TaskFlowError('quota_exceeded', ...)` carrying `code` ∈ `{'rate_limit', 'max_active'}`.
|
|
38
|
+
- `TaskFlowRegistry.pingFlow` — **modify** — adds `pingLimiter.tryAcquire(flowId)`, but ONLY after the principal/controllerId-match authority checks. Bogus pings do not consume rate-limit slots.
|
|
39
|
+
- `TaskFlowRegistry.applyOcc` — **modify** — on every terminal entry: (i) decrements per-controller active count via `onTerminalTransition`; (ii) calls `pingLimiter.forget(flowId)` so the per-flow bucket is reclaimed.
|
|
40
|
+
- `TaskFlowRegistry.cachePut` — **modify** — delegated to `LruCache`. Removes the ad-hoc terminal-flow head-scan eviction (which was O(32) worst-case but had no eviction count).
|
|
41
|
+
- `TaskFlowRegistry.emitTransitionLedgerNote` — **rewrite** — was previously emitting an invalid `kind: 'note:taskflow-transition'` payload (which SharedStateLedger rejects with "invalid kind"). Now emits `kind: 'note'`, `subsystem: 'taskflow-transition'`, with the redacted payload encoded into `summary`. Also emits a `taskflow:audit-emitted` event with the structured payload for tests / dashboards.
|
|
42
|
+
- `routes.ts` 429 error handler — **modify** — sets `Retry-After: <seconds>` header (ceiled, min 1) when `retryAfterMs` is in the error detail.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 1. Over-block
|
|
47
|
+
|
|
48
|
+
**Rate limits are authority gates at the HTTP boundary** — they reject requests. Over-block means rejecting legitimate traffic. The risk profile:
|
|
49
|
+
|
|
50
|
+
- **createPerSec=10 is conservative.** Real EvolutionManager / InitiativeTracker controllers create 1-10 flows per minute under normal load, far below 10/sec. Burst scenarios (backfill at startup, divergence-checker-triggered remediation) are bounded; the 10/sec budget gives 600/minute headroom. If a real workload needs more, operators raise the config — see `config.taskFlow.rateLimits.createPerSecPerController`.
|
|
51
|
+
- **maxActive=50 may be tight for some controllers.** The InitiativeTracker controller, post-Phase 4, could plausibly have >50 concurrent waiting initiatives. Mitigation: configurable via `config.taskFlow.rateLimits.maxActivePerController`. If operators hit this in production, the response is to raise the cap, not to revert.
|
|
52
|
+
- **pingPerMin=60 = 1/sec sustained.** This matches `HEARTBEAT_INTERVAL_MS = 60_000ms` (per-minute heartbeat) with substantial headroom for retry-after-failure pings. A controller pinging at the documented cadence cannot trigger this limit.
|
|
53
|
+
|
|
54
|
+
**Tuning options (when over-block triggers):**
|
|
55
|
+
- Set the relevant `config.taskFlow.rateLimits.*` value higher.
|
|
56
|
+
- Set the value to `Infinity` to disable the gate entirely (verified by the `'respects custom createPerSecPerController = Infinity (disabled)'` test).
|
|
57
|
+
- Hot-restart the server picks up the new config; no DB migration needed.
|
|
58
|
+
|
|
59
|
+
**Non-block surfaces (cache + audit) cannot over-block.** Cache eviction degrades read performance (one extra DB round-trip), not correctness. Audit emission is fire-and-forget — failures never propagate into the state-machine path.
|
|
60
|
+
|
|
61
|
+
## 2. Under-block
|
|
62
|
+
|
|
63
|
+
**Failure modes the change does not catch:**
|
|
64
|
+
|
|
65
|
+
- **`controllerId` spoofing on `createFlow`.** Any auth-token holder can claim any `controllerId` when creating a flow. The rate limit is keyed on the claimed value, so an attacker can create 10 flows/sec under controllerId "A" + 10 flows/sec under "B" + ... Mitigation already in place: the `RateLimiter` has a `MAX_BUCKETS=50_000` ceiling with lazy pruning, so memory cannot explode. The per-controller max-active=50 cap is also keyed on claimed controllerId, so the attacker's storage footprint per claimed-id is bounded. Authority for who-may-claim-a-controllerId remains at the auth-token layer (loopback-only by default; explicit opt-in to expose via tunnel — per spec § Threat Model line 671).
|
|
66
|
+
- **Pre-rate-limit input validation.** The schema parse + reserved-controller check + state size check all run BEFORE the rate limit. This is intentional: a flood of malformed payloads with bad schemas could thus get O(N) zod work, but those payloads also get rejected with `invalid_argument` (HTTP 422) — they never reach the registry's mutation path. If the parse itself becomes a DoS vector, the upstream HTTP layer (body-parser size limit) is where to mitigate.
|
|
67
|
+
- **Ping rate limit bypass via known flowId.** Mitigated structurally: the principal / controllerId-match check happens BEFORE the rate counter (verified by the `'bogus principal is rejected BEFORE the rate counter increments'` test). A non-owning caller cannot consume the legitimate controller's ping budget.
|
|
68
|
+
- **Active-count drift after server restart.** The per-controller active-count cache is lazy-loaded from DB on first access. So the first `createFlow` for a controller after a restart runs a full DB scan filtered by status. For a controller with thousands of historical flows, this DB scan is O(active_only) (the `flows_controller_status` index makes it sub-millisecond). Not a real concern for typical controller cardinality.
|
|
69
|
+
- **Clock-skew on sliding window.** If `Date.now()` jumps backward (NTP), bucket timestamps could appear future-dated and never age out. Within a single-writer server process the risk is low (we don't rely on cross-machine clock sync). If it materializes, the bucket self-recovers on the next forward-pass through the limit threshold.
|
|
70
|
+
- **Audit ledger lock contention.** If 50 concurrent transitions race for the SharedStateLedger lock, some appends may fail-open (per the existing `proper-lockfile` retry budget). This is the existing semantics from Phase 3a; Phase 5 inherits, does not regress.
|
|
71
|
+
|
|
72
|
+
## 3. Level-of-abstraction fit
|
|
73
|
+
|
|
74
|
+
**Rate limits live at the registry, surfaced via 429 at the HTTP layer — by deliberate design:**
|
|
75
|
+
|
|
76
|
+
- The `RateLimiter` itself is a pure helper. It belongs in the `tasks/` directory because that's the consumer.
|
|
77
|
+
- The rate-limit *invocation* lives in `TaskFlowRegistry.createFlow` / `pingFlow`, NOT in `routes.ts`. This is the load-bearing choice: putting the check at the registry means every code path that creates a flow (HTTP, internal subsystem callers like `EvolutionManager.dualWriteCreate`, future test-only paths) sees the same limit. Putting it at the route layer would let internal subsystems bypass it — a real concern for the EvolutionManager backfill, which can burst.
|
|
78
|
+
- The `Retry-After` HEADER lives in `routes.ts`, NOT in the registry. The registry returns a transport-agnostic `retryAfterMs` in `TaskFlowError.detail`; the HTTP layer maps that to RFC 7231 § 7.1.3 header semantics. This separation lets non-HTTP consumers (subsystem code) still read the structured `retryAfterMs` field.
|
|
79
|
+
- The LRU is bounded inside the registry; metric emission goes through `console.log [metric] ...` matching the existing pattern from `DivergenceChecker.emitMetric`.
|
|
80
|
+
- Audit-ledger emission lives in the registry. A separate "AuditEmitter" class was considered and rejected: it would require the registry to call out and pass state-transition metadata to a stateless helper, which is the same shape as a private method. Not worth the indirection.
|
|
81
|
+
|
|
82
|
+
## 4. Signal vs authority compliance
|
|
83
|
+
|
|
84
|
+
**Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
|
|
85
|
+
|
|
86
|
+
- [x] **Rate limits ARE an authority gate, but at the right layer.** The Phase 5 rate-limit gates are HTTP-boundary mechanical authorities (sliding-window counters), not LLM-backed gates. The principle that "brittle/low-context filters detect and emit signals; only a higher-level intelligent gate with full context has blocking authority" applies to *content* gates (e.g., outbound message classification). Rate limits are mechanical resource-budget gates; the signal-vs-authority guidance explicitly allows mechanical authorities at structural boundaries. The "intelligent reviewer" for rate-limit tuning is the human operator who reads metrics and adjusts `config.taskFlow.rateLimits.*`.
|
|
87
|
+
|
|
88
|
+
- [x] **The audit ledger emission produces a SIGNAL, not an authority.** Transition notes are tier-2 observability — fire-and-forget appends consumed by humans / dashboards / future analyzers. They have zero authority over state-machine correctness. Verified by the `'audit emission is async/best-effort — does NOT block state correctness'` test: even when `ledger.append` throws, the OCC mutation completes and `applied: true` is returned.
|
|
89
|
+
|
|
90
|
+
- [x] **The cache is a pure mechanic.** LRU eviction is deterministic; the metric emission is observational. No authority surface.
|
|
91
|
+
|
|
92
|
+
**Where authority is concentrated:**
|
|
93
|
+
- The HTTP layer (`routes.ts` 429 handler) is where the rate-limit decision is *surfaced* to clients. The decision itself is upstream, in the registry, made by the mechanical limiter. The high-level intelligence that adjusts the limit's *value* is the operator (informed by metrics).
|
|
94
|
+
- The principal/controllerId-match check (in `pingFlow`, `assertControllerScope`) is unchanged from prior phases.
|
|
95
|
+
|
|
96
|
+
No new brittle blocker is introduced. No existing intelligent authority is shadowed.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 5. Interactions
|
|
101
|
+
|
|
102
|
+
### With Phase 1 (registry + sweeper + waker)
|
|
103
|
+
|
|
104
|
+
- **TaskFlowMaintenanceSweeper** uses `RESERVED_MAINTENANCE_CONTROLLER` to mark flows `lost`. The reserved controllerId is exempted from `createFlow` rate limits (it never calls `createFlow`; it goes through `markLost` → `applyOcc`, which is not rate-limited). Sweeper is unaffected.
|
|
105
|
+
- **TaskFlowDueWaker** calls `resumeFlow` directly via `applyOcc`. Not rate-limited. Unaffected.
|
|
106
|
+
- The Phase 1 cache eviction was ad-hoc and never produced eviction metrics. Phase 5 supersedes it with a real LRU. Cache reads are still served from-cache when present; cache misses still go to SQLite. Performance neutral for hot paths; cache hit ratio slightly improved by recency-refresh on `get`.
|
|
107
|
+
|
|
108
|
+
### With Phase 2 (cancellation primitives)
|
|
109
|
+
|
|
110
|
+
- `requestFlowCancel` runs through `applyOcc` and is not rate-limited at the request layer. This is deliberate — cancellation should not be limited even when the controller is over its create budget. If a future need arises, a separate `cancelPerMinPerController` config can be added.
|
|
111
|
+
- The `taskflow-cancel-requested` ledger note (Phase 2) now uses the same `emitLedgerNote` path that this PR fixes (was previously broken with `kind: 'note:taskflow-cancel-requested'` → schema rejection). This is a quiet bug fix; cancellation notes will now actually land.
|
|
112
|
+
|
|
113
|
+
### With Phase 3a (EvolutionManager dual-write + DivergenceChecker)
|
|
114
|
+
|
|
115
|
+
- `EvolutionManager.dualWriteCreate` calls `registry.createFlow` directly. It now goes through the same rate limit. The `EvolutionManager` controller has a conservative cadence (proposals are minutes-to-hours apart), so the 10/sec limit is non-binding. The backfill (`migrateExistingToTaskFlow`) runs at server startup and creates one flow per existing proposal; for a fleet of 50 proposals at startup this is well under the 10/sec budget.
|
|
116
|
+
- The `DivergenceChecker` does not call `createFlow`; it reads + emits notes. Unaffected by rate limits.
|
|
117
|
+
- This PR fixes a pre-existing bug where `'taskflow-divergence'` was in `LedgerEntrySubsystem` but missing from `VALID_SUBSYSTEMS` runtime list — DivergenceChecker's notes were rejected with "invalid emittedBy.subsystem". This bug was effectively rendering Phase 3a's divergence-monitoring blind. Phase 5 closes the gap.
|
|
118
|
+
|
|
119
|
+
### With Phase 4 (InitiativeTracker migration) — likely landing alongside or shortly before
|
|
120
|
+
|
|
121
|
+
- Phase 4 moves InitiativeTracker's state into TaskFlow. The `InitiativeTracker` controller will share the per-controller create / max-active limits. If Phase 4's controller cadence approaches the 10/sec default, operators raise the limit. The 50 max-active value matches typical initiative-tracker scales (low tens of active initiatives).
|
|
122
|
+
- If Phase 4 lands first, this PR rebases cleanly off the new `origin/main` — no expected merge conflicts since Phase 4 touches `InitiativeTracker.ts` not `TaskFlowRegistry.ts` internals.
|
|
123
|
+
|
|
124
|
+
### With WikiClaim phases
|
|
125
|
+
|
|
126
|
+
- WikiClaim phases touch `memory/` paths, not flow APIs. Zero interaction.
|
|
127
|
+
|
|
128
|
+
### Cache coherence
|
|
129
|
+
|
|
130
|
+
- The LRU is process-local. Single-writer assumption (per spec line 717) means no cross-process invalidation needed.
|
|
131
|
+
- `getFlow(id, {bypassCache: true})` still works the same; consumers that need fresh-from-DB reads (e.g., `EvolutionManager.dualWriteTransition`) continue to bypass.
|
|
132
|
+
|
|
133
|
+
### Race conditions
|
|
134
|
+
|
|
135
|
+
- **Idempotency-replay race.** The outer idempotency check (pre-rate-limit) is a fast read outside the transaction. Between that read and the inner transaction re-check, another process could insert a matching idempotency row — but the single-writer constraint rules out that scenario. Within one process, the JS event loop is single-threaded; the read and transaction are sequential.
|
|
136
|
+
- **Active-count drift.** The per-controller active-count cache is bumped in-process on create / decremented on terminal transition. If a server restart loses the cache, the next `getActiveCount` call rebuilds from DB. Net effect: the cap is enforced correctly across restarts; only the in-memory bookkeeping is volatile.
|
|
137
|
+
- **Audit lock contention.** Same as before — `proper-lockfile` with 10-retry backoff. Failures fail-open with a `[DEGRADATION]` line.
|
|
138
|
+
|
|
139
|
+
### Feedback loops
|
|
140
|
+
|
|
141
|
+
- The cache-eviction metric feeds operator decisions on cache cap tuning. No automated loop.
|
|
142
|
+
- The rate-limit rejection counters (`rateLimitRejections.create_rate` / `max_active` / `ping_rate`) feed operator decisions on limit tuning. No automated loop.
|
|
143
|
+
- Audit notes feed humans / dashboards. No automated loop.
|
|
144
|
+
|
|
145
|
+
## 6. External surfaces
|
|
146
|
+
|
|
147
|
+
- **Other agents on the same machine:** No new surface. All HTTP routes are loopback-bound by default (per spec § Threat Model line 671).
|
|
148
|
+
- **Other users of the install base:** Phase 5 is gated on `config.taskFlow.enabled` (default off, established in Phase 1). Existing installs are unaffected until they opt in to TaskFlow.
|
|
149
|
+
- **External systems:** None. No outbound HTTP, no LLM calls.
|
|
150
|
+
- **Persistent state:** No schema changes to `.instar/task-flows.db`. The `.instar/shared-state.jsonl` gains additional `note` entries (one per state transition) — the existing rotation-at-5000-lines policy handles growth. Worst-case: a controller creating + finishing 100 flows / day adds ~600 lines / day to the ledger; 5000-line rotation triggers ~weekly per heavy controller. The ledger already rotates and archives prior files.
|
|
151
|
+
- **Timing or runtime conditions:** Rate-limit buckets are in-memory only; on restart all buckets clear and a burst is immediately possible. This is by design — the spec does not require restart-persistent rate limits, and persisting them would add a write to every createFlow / pingFlow.
|
|
152
|
+
|
|
153
|
+
## 7. Rollback cost
|
|
154
|
+
|
|
155
|
+
- **Hot-fix release:** Revert the PR. Restart the server. No data migration.
|
|
156
|
+
- **Soft rollback (disable but keep code):** Set `config.taskFlow.rateLimits = { createPerSecPerController: Infinity, maxActivePerController: Infinity, pingPerMinPerFlow: Infinity }` and restart. Rate limits become non-binding. Cache continues to operate with the default 1000-entry cap; to disable that, set `config.taskFlow.cache.maxEntries: 0`.
|
|
157
|
+
- **Data migration:** None on rollback. The audit-ledger notes already written remain valid `note`-kind entries in `shared-state.jsonl`; they have a recognized subsystem (`'taskflow-transition'`) and follow the schema. They will continue to render correctly on the existing dashboard.
|
|
158
|
+
- **Agent state repair:** None.
|
|
159
|
+
- **User visibility:** Reverting Phase 5 removes the 429 responses from `/flows` / `/flows/:id/ping`. Clients that depend on the 429 contract (none ship today) would need to be updated. No user-facing UI surface is affected.
|
|
160
|
+
|
|
161
|
+
## Audit-ledger redaction shape (load-bearing quote from spec)
|
|
162
|
+
|
|
163
|
+
Per `docs/specs/OPENCLAW-IMPORT-TASKFLOW-SPEC.md` lines 681-682:
|
|
164
|
+
|
|
165
|
+
> **State leakage via `stateJson`**:
|
|
166
|
+
> - Audit ledger notes log only `flowId`, `revision`, `currentStep`, `from_status`, `to_status`, `waitJson.kind`.
|
|
167
|
+
|
|
168
|
+
The Phase 5 implementation emits structurally:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
{
|
|
172
|
+
flowId, revision, currentStep, from_status, to_status, waitKind, controllerId, op
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
— a strict subset of the spec's allow-list plus `controllerId` (needed for operator-facing display; not sensitive — it's the static name of the controller, not its instance ID) and `op` (the mutation name, e.g. `'startStep'`; not a payload field).
|
|
177
|
+
|
|
178
|
+
Strictly excluded from the audit payload AND from the ledger `subject`/`summary`:
|
|
179
|
+
- `stateJson` (controller-private — spec line 705 non-goal "Not exposing `stateJson` in any UI").
|
|
180
|
+
- `waitJson.channel` / `waitJson.threadId` / `waitJson.peer` (PII risk surface; spec § Threat Model line 681 explicitly limits to `waitJson.kind`).
|
|
181
|
+
- `waitJson.correlationId` / `waitJson.waitInstanceId` (capability tokens; spec § Threat Model lines 673, 678).
|
|
182
|
+
- `waitJson.serviceId` / `waitJson.expectedAgentId` (peer identifying info).
|
|
183
|
+
- `waitJson.question` / `waitJson.reviewerId` (human-review content).
|
|
184
|
+
|
|
185
|
+
Verified by the `'audit payload includes ONLY redacted fields'` test which asserts `Object.keys(payload)` is a subset of the allow-list AND that JSON-stringifying the payload does not contain literal sentinel values (`'do-not-leak'`, `'svc-private'`, `'corr-aaaaaaaaaaaaaaaaaaaaaa-private'`) injected at test setup time.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Conclusion
|
|
190
|
+
|
|
191
|
+
Phase 5 ships three structural authorities (createFlow rate limit, max-active cap, pingFlow rate limit) and two non-authority infrastructure improvements (LRU cache + eviction metric, audit-ledger fix). Every authority surface lives at the registry layer (so all callers see the same limit), is keyed on `controllerId` or `flowId` to bound per-counterparty resource use, and has a documented configuration override + an `Infinity`-disable path. Every observation surface (audit notes, cache metric, rate-limit rejection counters) is fire-and-forget and never blocks state correctness.
|
|
192
|
+
|
|
193
|
+
The audit-ledger payload is strictly the redacted shape per spec lines 681-682; controller-private state never leaves the registry. The previously-broken `emitLedgerNote` path is now fixed, which incidentally repairs Phase 2's `taskflow-cancel-requested` note emission and closes the `'taskflow-divergence'` validation gap from Phase 3a.
|
|
194
|
+
|
|
195
|
+
Rollback is `config.taskFlow.rateLimits = { createPerSecPerController: Infinity, maxActivePerController: Infinity, pingPerMinPerFlow: Infinity }` (soft) or `git revert` (hard). Tests cover all six limit-trigger paths, the LRU recency contract, eviction metric emission, configurability, isolation between controllers, terminal-transition slot release, and audit-note redaction.
|
|
196
|
+
|
|
197
|
+
The change is opt-in (gated on `config.taskFlow.enabled`), additive, configurable, and rollback-cheap.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Second-pass review (adversarial)
|
|
202
|
+
|
|
203
|
+
**Reviewer:** adversarial self-review focused on rate-limit edge cases (clock skew, controller-id spoofing, retry-storm amplification).
|
|
204
|
+
**Independent read of the artifact: concur after fixes**
|
|
205
|
+
|
|
206
|
+
Findings raised during adversarial pass and resolution status:
|
|
207
|
+
|
|
208
|
+
1. **Controller-id spoofing for memory exhaustion** — An attacker with an auth token can create many flows under many distinct claimed `controllerId`s, each starting a new bucket in the `createLimiter` and a new entry in `activeCountByController`. **Resolution:** `RateLimiter` enforces `MAX_BUCKETS = 50_000` with lazy pruning; per-bucket size is bounded by `limit` (≤10 for create; ≤60 for ping). Worst-case memory: 50k * 10 timestamps * 8 bytes ≈ 4 MB. The `activeCountByController` Map is bounded by the number of distinct claimed controllerIds the server has ever seen since boot — same memory class; an attacker cannot inflate the per-controller value beyond the max-active cap (50). Documented in §2 Under-block + §5 Interactions.
|
|
209
|
+
|
|
210
|
+
2. **Retry-storm amplification** — If multiple clients hit the same 429 simultaneously and retry at exactly the `retryAfterMs` interval, they collide at the window boundary. **Resolution:** the `retryAfterMs` returned is the minimum wait; downstream clients should add jitter (documented as a client-side responsibility). Server-side: the sliding window means a wave that arrives in the same millisecond still gets serialized — only `limit` requests succeed; the rest get a fresh 429 with a fresh retry hint. The server is single-writer; there is no thundering-herd state-coherence risk.
|
|
211
|
+
|
|
212
|
+
3. **Clock-skew on sliding window** — NTP backward jumps could leave bucket timestamps in the future. **Resolution:** since the limiter and registry share `now()`, a backward jump moves both the bucket entries (relative future) AND the window boundary backward by the same delta. The bucket simply takes one extra forward-pass to age out. Within the single-writer server, the risk is bounded to a one-time over-grant equal to the jump magnitude. Documented in §2 Under-block.
|
|
213
|
+
|
|
214
|
+
4. **Pre-existing `kind: 'note:X'` schema rejection silently dropped Phase 2 and Phase 3a notes** — Found during adversarial inspection of `SharedStateLedger.validatePayload`. **Resolution:** Phase 5 fixes both `emitTransitionLedgerNote` and the generic `emitLedgerNote` to use `kind: 'note'` + a proper subsystem entry. Closes the `'taskflow-divergence'` validation gap by adding it to `VALID_SUBSYSTEMS` (was in the type union but not the runtime list). This is a load-bearing fix — without it, neither Phase 3a's divergence monitoring nor Phase 2's cancel-requested notes were being written.
|
|
215
|
+
|
|
216
|
+
5. **Authority check before rate counter on `pingFlow`** — Initial draft of the rate limit was added at the top of `pingFlow` (before the `controllerId` match check), letting unauthorized callers consume legitimate budget. **Fix applied:** the limit check now lives AFTER `assertControllerScope`-equivalent inline checks. Verified by the `'bogus principal is rejected BEFORE the rate counter increments'` test.
|
|
217
|
+
|
|
218
|
+
6. **Idempotency-replay race against the rate limit** — Initial draft counted every `createFlow` call against the rate limit, so a retry of the same idempotent operation could burn budget. **Fix applied:** an outer (pre-rate-limit) idempotency check short-circuits to the existing flow without consuming a slot. Verified by the `'idempotent replay does NOT count against the rate limit'` test.
|
|
219
|
+
|
|
220
|
+
7. **Audit-emission failure cascading into state-machine path** — The `void Promise.resolve().then(...).catch(...)` pattern was checked end-to-end. **Verification:** the `'audit emission is async/best-effort'` test monkey-patches `ledger.append` to throw and asserts `applied === true`. Audit failures are confined to a swallowed catch.
|
|
221
|
+
|
|
222
|
+
No remaining critical concerns. The change is cleared to ship.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Evidence pointers
|
|
227
|
+
|
|
228
|
+
- Test run: `npx vitest run tests/unit/task-flow-rate-limits.test.ts tests/unit/task-flow-cache-eviction.test.ts tests/unit/task-flow-audit-ledger.test.ts` → 31/31 passing.
|
|
229
|
+
- Regression: `npx vitest run tests/unit/task-flow-registry.test.ts tests/unit/evolution-manager-taskflow-dualwrite.test.ts tests/unit/divergence-checker.test.ts tests/unit/threadline-flow-bridge.test.ts` → 52/52 passing.
|
|
230
|
+
- Typecheck: `npx tsc --noEmit` → clean across modified files.
|
|
231
|
+
- Spec source of truth: `docs/specs/OPENCLAW-IMPORT-TASKFLOW-SPEC.md` § Phase 5 (lines 650-653); § Threat Model (lines 679, 685); audit redaction (lines 681-682).
|
|
232
|
+
- Phase 1 trace context: `upgrades/side-effects/taskflow-phase1.md`.
|
|
233
|
+
- Phase 2 trace context: `upgrades/side-effects/taskflow-phase2.md`.
|
|
234
|
+
- Phase 3a trace context: `upgrades/side-effects/taskflow-phase3a.md`.
|