instar 0.28.95 → 0.28.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dashboard/index.html +249 -2
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +90 -1
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/core/MachineHeartbeat.d.ts +94 -0
  6. package/dist/core/MachineHeartbeat.d.ts.map +1 -0
  7. package/dist/core/MachineHeartbeat.js +155 -0
  8. package/dist/core/MachineHeartbeat.js.map +1 -0
  9. package/dist/core/ProjectAutoAdvancePoller.d.ts +107 -0
  10. package/dist/core/ProjectAutoAdvancePoller.d.ts.map +1 -0
  11. package/dist/core/ProjectAutoAdvancePoller.js +176 -0
  12. package/dist/core/ProjectAutoAdvancePoller.js.map +1 -0
  13. package/dist/core/ProjectRoundCompleteMessage.d.ts +158 -0
  14. package/dist/core/ProjectRoundCompleteMessage.d.ts.map +1 -0
  15. package/dist/core/ProjectRoundCompleteMessage.js +253 -0
  16. package/dist/core/ProjectRoundCompleteMessage.js.map +1 -0
  17. package/dist/core/ProjectRoundExecution.d.ts +111 -0
  18. package/dist/core/ProjectRoundExecution.d.ts.map +1 -0
  19. package/dist/core/ProjectRoundExecution.js +359 -0
  20. package/dist/core/ProjectRoundExecution.js.map +1 -0
  21. package/dist/core/ProjectRoundLock.d.ts +67 -0
  22. package/dist/core/ProjectRoundLock.d.ts.map +1 -0
  23. package/dist/core/ProjectRoundLock.js +161 -0
  24. package/dist/core/ProjectRoundLock.js.map +1 -0
  25. package/dist/core/ProjectRoundRunner.d.ts +146 -0
  26. package/dist/core/ProjectRoundRunner.d.ts.map +1 -0
  27. package/dist/core/ProjectRoundRunner.js +371 -0
  28. package/dist/core/ProjectRoundRunner.js.map +1 -0
  29. package/dist/core/ProjectRoundWorktrees.d.ts +57 -0
  30. package/dist/core/ProjectRoundWorktrees.d.ts.map +1 -0
  31. package/dist/core/ProjectRoundWorktrees.js +111 -0
  32. package/dist/core/ProjectRoundWorktrees.js.map +1 -0
  33. package/dist/server/AgentServer.d.ts +12 -0
  34. package/dist/server/AgentServer.d.ts.map +1 -1
  35. package/dist/server/AgentServer.js +3 -0
  36. package/dist/server/AgentServer.js.map +1 -1
  37. package/dist/server/routes.d.ts +19 -0
  38. package/dist/server/routes.d.ts.map +1 -1
  39. package/dist/server/routes.js +590 -7
  40. package/dist/server/routes.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/data/builtin-manifest.json +47 -47
  43. package/upgrades/0.28.96.md +347 -0
  44. package/upgrades/side-effects/project-scope-phase1b-connect-the-dots.md +184 -0
  45. package/upgrades/side-effects/project-scope-phase1b-pr3.md +228 -0
  46. package/upgrades/side-effects/project-scope-phase1b-pr4.md +198 -0
  47. package/upgrades/side-effects/project-scope-phase1b-pr5.md +150 -0
  48. package/upgrades/side-effects/project-scope-phase1b-pr6.md +136 -0
  49. package/upgrades/side-effects/project-scope-phase1b-pr7.md +175 -0
@@ -0,0 +1,228 @@
1
+ # Side-Effects Review — project-scope Phase 1b PR 3 (Round runner + halt/advance/ack endpoints)
2
+
3
+ **Version / slug:** `project-scope-phase1b-pr3`
4
+ **Date:** `2026-05-11`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `required (new HTTP mutating surface + lock primitive + cross-record mutation orchestration)`
7
+
8
+ ## Summary of the change
9
+
10
+ Third PR of project-scope Phase 1b. Ships the **single-chokepoint runner**
11
+ that the spec names in § Phase 1.5, plus the four mutating HTTP endpoints
12
+ that route through it (`/advance`, `/halt`, `/ack`, `/accept-partial`).
13
+ The actual autonomous-delegating run loop and dynamic stop-condition
14
+ revalidation are intentionally deferred to a follow-up PR — this PR
15
+ ships the **preflight gate** + **lock primitive** + **state-management
16
+ verbs** without the orchestration loop, so the routes can be exercised
17
+ end-to-end with real validators today.
18
+
19
+ Spec source: `docs/specs/PROJECT-SCOPE-SPEC.md` § Phase 1.5 (preflight,
20
+ lock, halt switch, runner authority model), § Phase 1.3 (HTTP endpoints).
21
+
22
+ New files:
23
+ - `src/core/ProjectRoundLock.ts` (~150 lines) — machine-local lock
24
+ primitive at `.instar/local/round-runner.lock`. Acquire encodes
25
+ `{pid, projectId, roundIndex, acquiredAt}` as JSON, atomic via
26
+ `O_CREAT|O_EXCL` write + rename. Stale-PID sweep on every acquire
27
+ uses `process.kill(pid, 0)`. Release / read / isAlive helpers.
28
+ - `src/core/ProjectRoundRunner.ts` (~470 lines) — the single
29
+ chokepoint. `preflight(projectId, roundIndex)` runs the 9 deterministic
30
+ checks from § Phase 1.5 (drift check, step 10, deferred to a follow-up
31
+ PR alongside the cache+ledger HTTP wiring). `halt`, `recordAck`, and
32
+ `acceptPartial` are idempotent state-management verbs that mutate
33
+ through `InitiativeTracker.update()` with OCC. Static
34
+ `validateChildFrontmatter` exported for future `run()` and dashboard
35
+ consumers — verifies `review-convergence: true` AND `approved: true`.
36
+ - `tests/unit/ProjectRoundLock.test.ts` (9 cases) — acquire on free,
37
+ reject on held + alive PID, stale-PID sweep, release returns true /
38
+ false correctly, malformed-file tolerance, `isAlive` edges.
39
+ - `tests/unit/ProjectRoundRunner.test.ts` (27 cases) — all preflight
40
+ reject codes (`PROJECT_NOT_FOUND`, `PROJECT_NOT_PROJECT_KIND`,
41
+ `PROJECT_INACTIVE`, `ROUND_INDEX_OUT_OF_RANGE`, `PROJECT_HALTED`,
42
+ `FIRST_LAUNCH_ACK_REQUIRED`, `UNACKED_ADVANCES_OVER_CAP`,
43
+ `ROUND_ACK_GAP_TOO_LARGE`, `NOT_OWNER_MACHINE`,
44
+ `TARGET_REPO_PATH_INVALID`, `LOCK_HELD`, `ITEMS_NOT_ALL_APPROVED`) +
45
+ happy path, halt idempotent + lock-release behavior, recordAck
46
+ semantics (populate + reset + never-regress + idempotent on index),
47
+ acceptPartial (skip + complete-with-skips + ack-advance),
48
+ `validateChildFrontmatter` happy + reject cases.
49
+
50
+ Modified files:
51
+ - `src/server/routes.ts` (+~190 lines) — adds `projectRoundRunner`
52
+ field to `RouteContext`. Adds four mutating routes:
53
+ - `POST /projects/:id/advance` — single-item stage transition through
54
+ `StageTransitionValidator`. Requires `If-Match` (OCC). Body:
55
+ `{itemId, targetStage, artifact: {...}, fromStage?}`. Returns
56
+ 409 on validator reject (with code + reason), 409 on version
57
+ mismatch, 404 on unknown child / non-project record, 428 on
58
+ missing `If-Match`. Bumps project version on success so concurrent
59
+ /advance calls don't operate on a stale view.
60
+ - `POST /projects/:id/halt` — calls `ProjectRoundRunner.halt`. Body:
61
+ `{reason}`. Idempotent.
62
+ - `POST /projects/:id/ack` — calls `ProjectRoundRunner.recordAck`.
63
+ Body: `{forRoundIndex}` (or `{roundIndex}` for symmetry).
64
+ - `POST /projects/:id/accept-partial` — calls
65
+ `ProjectRoundRunner.acceptPartial`. Body:
66
+ `{roundIndex, reason, skippedBy}`. Both `reason` and `skippedBy`
67
+ required (downstream StageTransitionValidator demands them on
68
+ every `* → skipped` transition).
69
+ - `src/server/AgentServer.ts` (+5 lines) — `projectRoundRunner`
70
+ field added to `AgentServerOptions` + passed through to the
71
+ `RouteContext`.
72
+ - `src/commands/server.ts` (+10 lines) — instantiates
73
+ `ProjectRoundRunner` at startup, passes it into `new AgentServer({})`.
74
+ `machineId` falls back to `os.hostname()` when the multi-machine
75
+ coordinator isn't configured, matching the existing fallback in
76
+ `src/commands/server.ts:5428`.
77
+ - `tests/integration/projects-api.test.ts` (+~150 lines) — 11 new
78
+ integration cases covering the four new routes. `targetRepo` is
79
+ now `git init`-ialized in `beforeAll` so preflight step 8 passes;
80
+ the `ProjectRoundRunner` is instantiated and passed into
81
+ `AgentServer`.
82
+
83
+ ## Decision-point inventory
84
+
85
+ - **Preflight gate** (`ProjectRoundRunner.preflight`) — **add** — the
86
+ single chokepoint for round-start authority. Routes don't re-implement
87
+ these checks; the runner is authoritative. Drift check (step 10) is
88
+ intentionally deferred until the drift-check HTTP endpoint and its
89
+ cache+ledger wiring are in place.
90
+ - **Lock acquisition** (`ProjectRoundLock.acquire`) — **add** —
91
+ `O_CREAT|O_EXCL` + rename. Stale-PID sweep ensures a dead runner
92
+ doesn't permanently block subsequent acquires. The lock file is
93
+ MACHINE-LOCAL (under `.instar/local/`, not git-synced), matching the
94
+ spec's separation between machine-level mutual exclusion and
95
+ cross-machine ownership via `ownerMachineId`.
96
+ - **Stage transition rejection** (`/advance` calling
97
+ `validateStageTransition`) — **reuse** — the validator from PR 2 is
98
+ the authority for artifact-bound transitions. Routes return its
99
+ rejection code verbatim.
100
+ - **OCC enforcement on every mutating route** — **add** — `If-Match`
101
+ required on `/advance` (428 if missing, 409 if stale). Halt, ack,
102
+ accept-partial use the tracker's own `If-Match`-aware `update()`,
103
+ re-asserting the spec's OCC contract across the new routes.
104
+ - **Halt as idempotent kill switch** (`ProjectRoundRunner.halt`) —
105
+ **add** — finds the active round (preference: in-progress → pending /
106
+ ready → already-halted) and writes `haltedAt` + `haltReason` if not
107
+ already set. Releases the lock if the calling machine holds it. The
108
+ SIGTERM-of-autonomous-child path lands when `run()` ships in a
109
+ follow-up.
110
+
111
+ ## Over-block vs under-block analysis
112
+
113
+ ### Preflight
114
+ Over-block: every reject code corresponds to a documented spec invariant
115
+ (see § Phase 1.5 steps 1–9). Returning a structured `code` (rather than
116
+ just `reason`) lets the dashboard render targeted UI hints later. The
117
+ default-undefined round status is treated as `'pending'`, so freshly-
118
+ parsed plan-doc rounds don't false-negative on a status check.
119
+
120
+ Under-block: step 10 (drift check) is missing here. The follow-up PR
121
+ that wires the drift-check HTTP endpoint will add it to preflight in
122
+ the same commit. Until then, callers SHOULD treat preflight as
123
+ "deterministic gate passed; drift signal still pending."
124
+
125
+ ### `/advance` route
126
+ Over-block: rejects with 409 on every validator failure (file missing,
127
+ slug regex fail, frontmatter mismatch, mergeCommit not on origin/main,
128
+ etc.). The 409 body carries `{error, code, reason}` so callers can
129
+ distinguish failure modes. **Refuses to advance without
130
+ `If-Match`** even though the canonical project record may not have
131
+ changed — protects against TOCTOU when two clients race
132
+ on the same record.
133
+
134
+ Under-block: `/advance` operates on child records, not on rounds. It
135
+ does NOT enforce round-level invariants (first-launch ack, owner
136
+ machine, etc.) — those belong to `run()` (the autonomous loop) and to
137
+ auto-advance. This is intentional and matches the spec's separation
138
+ between "single-item user-driven advance" and "round-start automated
139
+ flow." The follow-up PR will close any gap if a `run()` consumer
140
+ discovers one.
141
+
142
+ ### Lock primitive
143
+ Over-block: the rename can technically race with another acquirer if
144
+ two processes pass the read-then-write window simultaneously. The spec
145
+ declares at-most-one-runner-per-machine as a single-process guarantee
146
+ (routes hold the call in one promise; runners only spawn from the
147
+ supervised AgentServer), so cross-process concurrent acquires are
148
+ out-of-band. Documented in the source comment.
149
+
150
+ Under-block: a runner that crashes between rename and a subsequent
151
+ operation leaves a lock file holding its (now-dead) PID. The stale-PID
152
+ sweep on the next acquire reclaims it. No alternate path (e.g., file
153
+ mtime-based timeout) is needed at this layer.
154
+
155
+ ## Signal vs authority audit
156
+
157
+ The runner is authority for round-start preflight. It produces a
158
+ deterministic `ok|reject` based on verifiable artifacts (tracker
159
+ record fields, lock file state, filesystem checks, `gh pr view` via
160
+ the existing StageTransitionValidator). No LLM-mediated judgment
161
+ lives here — that's the drift checker (signal), which is invoked
162
+ separately at step 10 (deferred).
163
+
164
+ The HTTP routes don't add authority; they thread requests through the
165
+ runner / validator. Auth is enforced globally by `authMiddleware`.
166
+ OCC is enforced via `If-Match`. The validator's reject codes flow
167
+ verbatim to the response — clients see the exact failure mode.
168
+
169
+ ## Interactions with existing systems
170
+
171
+ - **`InitiativeTracker.update()`.** Halt / ack / accept-partial all
172
+ mutate through update with `ifMatch`, so concurrent writers race
173
+ through the tracker's existing OCC. Bumping the project version
174
+ inside `/advance` (via a no-op `nextCheckAt` self-write) makes the
175
+ next caller see the new version on `GET /projects/:id`.
176
+ - **`StageTransitionValidator`.** The drift checker (PR 1) and the
177
+ /advance route (this PR) are the only callers. Validator's slug
178
+ regex + `realpath` jailing protect against path traversal in the
179
+ `specPath` artifact field.
180
+ - **`ProjectDigestCache`.** Every mutation (halt, ack, accept-partial,
181
+ advance) goes through the tracker, which calls the digest cache
182
+ invalidator on the write path. Session-start hook reads the updated
183
+ cache on next session bootstrap.
184
+ - **Multi-machine.** `machineId` is read from
185
+ `coordinator.identity?.machineId ?? os.hostname()` — same fallback
186
+ the existing PR-coordination code uses. `NOT_OWNER_MACHINE` rejection
187
+ fires if a project record has been claimed by another machine; the
188
+ claim-ownership flow ships in PR 4.
189
+ - **No new dependencies.** `proper-lockfile` (already a dep) is NOT
190
+ used here — the round-runner lock is sub-process scoped, where the
191
+ atomic-rename pattern is cheaper and matches the spec's
192
+ machine-local intent.
193
+
194
+ ## Rollback cost
195
+
196
+ Revert deletes `src/core/ProjectRound{Lock,Runner}.ts`, removes the
197
+ ctx field, drops the four routes, drops the test files. Existing
198
+ project records carry the optional fields `firstLaunchAckAt`,
199
+ `lastAckedRoundIndex`, etc. through unchanged — older code that
200
+ doesn't know about them ignores them. No schema migration, no
201
+ state-file migrations. The lock file at `.instar/local/round-runner.lock`
202
+ becomes orphaned but harmless; subsequent acquires would error
203
+ gracefully because no code reads it.
204
+
205
+ ## What this PR explicitly defers (and where it lands)
206
+
207
+ - **Step 10 of preflight (drift check).** Lands when `POST
208
+ /projects/:id/drift-check` ships with its per-project mutex; the
209
+ cache + ledger from PR 2 are already on disk waiting for a consumer.
210
+ - **Autonomous-delegating run loop (`run()`)** with lazy worktrees,
211
+ dynamic stop-condition revalidation, SIGTERM/SIGKILL of process
212
+ groups, partial-complete detection. Round-runner verb scope is
213
+ state-management today; the orchestration loop is the next
214
+ follow-up PR.
215
+ - **Auto-advance poller** and **multi-machine claim-ownership**.
216
+ Phase 1b PR 4.
217
+ - **`GET /projects/:id/next` real implementation.** Still 501 in PR 3;
218
+ ships alongside `run()`.
219
+
220
+ ## Verification
221
+
222
+ - `npm run lint` — passes (tsc + lint-no-direct-destructive).
223
+ - `npx vitest run tests/unit/ProjectRoundLock.test.ts
224
+ tests/unit/ProjectRoundRunner.test.ts
225
+ tests/integration/projects-api.test.ts` — 61/61 pass (9 lock + 27
226
+ runner + 25 routes).
227
+ - Existing PR 1 + PR 2 tests still green (DriftChecker / Cache / Ledger
228
+ tests unaffected — the runner doesn't touch them yet).
@@ -0,0 +1,198 @@
1
+ # Side-Effects Review — project-scope Phase 1b PR 4 (Auto-advance poller + multi-machine claim-ownership)
2
+
3
+ **Version / slug:** `project-scope-phase1b-pr4`
4
+ **Date:** `2026-05-11`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `required (new periodic-job + new multi-machine ownership transfer surface)`
7
+
8
+ ## Summary of the change
9
+
10
+ Fourth PR of project-scope Phase 1b. Ships the three pieces the spec
11
+ names for multi-machine and time-based round automation:
12
+
13
+ - `MachineHeartbeat` — per-machine liveness signal at
14
+ `.instar/machine-health/<machineId>.json` (git-synced), updated every
15
+ 30 minutes. Consulted by the claim-ownership flow for the 48h
16
+ staleness check.
17
+ - `ProjectAutoAdvancePoller` — periodic scan that finds projects
18
+ with `autoAdvanceAt <= now` and bookkeeps the next round
19
+ (clears the timestamp, increments `unacknowledgedAdvanceCount`)
20
+ when the round runner's preflight passes.
21
+ - `POST /projects/:id/claim-ownership` — OCC-protected ownership
22
+ transfer with the heartbeat-staleness gate.
23
+ - Post-restore reconciler — server startup downgrades any round
24
+ flagged `in-progress` to `pending` (the previous owner may have
25
+ crashed; no TaskFlow exists yet to detect an actually-live run).
26
+
27
+ Spec source: `docs/specs/PROJECT-SCOPE-SPEC.md` § P5 ("Machine ownership"),
28
+ § Phase 1.3 (HTTP), § Phase 1.5 ("Auto-advance polling"), § Phase 1.12
29
+ ("Post-restore reconciler").
30
+
31
+ New files:
32
+ - `src/core/MachineHeartbeat.ts` (~160 lines) — write + read +
33
+ isStale + listAll. Machine id is URL-encoded into the file name so a
34
+ stray slash can't escape `.instar/machine-health/`. Periodic timer
35
+ is `.unref()`'d so heartbeats never keep the process alive.
36
+ - `src/core/ProjectAutoAdvancePoller.ts` (~140 lines) — `tick()` does
37
+ one pass over `tracker.list({kind:'project', status:'active'})`,
38
+ applies the owner-machine / cap / autoAdvanceAt filters, calls
39
+ `ProjectRoundRunner.preflight`, and either bookkeeps the fire or
40
+ clears the timestamp on structural rejects.
41
+ - `tests/unit/MachineHeartbeat.test.ts` (10 cases)
42
+ - `tests/unit/ProjectAutoAdvancePoller.test.ts` (7 cases)
43
+
44
+ Modified files:
45
+ - `src/server/routes.ts` (+~65 lines) — `machineHeartbeat` field on
46
+ `RouteContext`. New route `POST /projects/:id/claim-ownership` with
47
+ OCC + heartbeat-staleness gate. Idempotent on already-owns,
48
+ 409 on fresh-peer-heartbeat (unless `{force:true}`), 200 with
49
+ `previousOwner` and `ownerMachineId` on success.
50
+ - `src/server/AgentServer.ts` (+8 lines) — `machineHeartbeat` field
51
+ on `AgentServerOptions`, threaded into `RouteContext`.
52
+ - `src/commands/server.ts` (+~50 lines) — instantiates
53
+ `MachineHeartbeat`, calls `start()`, instantiates
54
+ `ProjectAutoAdvancePoller` and wires a 60-second interval (also
55
+ `.unref()`'d). Runs the post-restore reconciler once at startup,
56
+ best-effort, ignoring OCC races.
57
+ - `tests/integration/projects-api.test.ts` (+~110 lines) — 5 new
58
+ integration cases for `/claim-ownership`: idempotent on
59
+ already-owned, refused on fresh peer heartbeat, succeeds on stale
60
+ peer heartbeat, `{force:true}` override, auth + If-Match gates.
61
+
62
+ ## Decision-point inventory
63
+
64
+ - **Heartbeat staleness gate** (`MachineHeartbeat.isStale`) — **add** —
65
+ returns true when the file is missing, malformed, or older than the
66
+ threshold (default 48h, configurable for tests). Used by
67
+ `/claim-ownership` to decide whether a peer's record is recoverable.
68
+ Conservative: a missing record is treated as stale (consistent with
69
+ the spec's "leader-election fires when no heartbeat").
70
+ - **Claim-ownership refusal** (`/claim-ownership` route) — **add** —
71
+ refuses with 409 when the current owner has a fresh heartbeat AND
72
+ `force: true` was not set. Idempotent on already-owns.
73
+ - **Auto-advance structural-reject clearing**
74
+ (`ProjectAutoAdvancePoller.tick`) — **add** — rejects from preflight
75
+ that mean "the project structurally cannot advance" (FIRST_LAUNCH_ACK_REQUIRED,
76
+ UNACKED_ADVANCES_OVER_CAP, PROJECT_INACTIVE, PROJECT_HALTED,
77
+ PROJECT_NOT_PROJECT_KIND, PROJECT_NOT_FOUND, TARGET_REPO_PATH_INVALID,
78
+ ROUND_ACK_GAP_TOO_LARGE) cause us to clear `autoAdvanceAt` so we don't
79
+ re-fire every tick. Transient rejects (lock held, items missing
80
+ temporarily) leave the timestamp so a future tick can succeed.
81
+ - **Post-restore reconciler downgrade** — **add** — startup-only,
82
+ one-shot. Any project round flagged `in-progress` is reset to
83
+ `pending`. The previous owner may have crashed; no TaskFlow yet
84
+ exists to verify whether a child is actually live. Best-effort — OCC
85
+ races are silently ignored (next reconcile pass will retry).
86
+
87
+ ## Over-block vs under-block analysis
88
+
89
+ ### Auto-advance poller
90
+ Over-block: the structural-reject list errs on the side of clearing
91
+ `autoAdvanceAt`. The cap-brake (≥2 unacked) is in that list, so if a
92
+ user acks the project the timestamp is no longer set — they'd have to
93
+ explicitly re-set it (or wait for a completed round to auto-set the
94
+ next). Acceptable: requiring an explicit step to re-engage auto-advance
95
+ after the brake fires matches the spec's user-attention semantics.
96
+
97
+ Under-block: the poller skips projects with a different owner machine
98
+ silently — no attention queue entry, no telemetry. A user who's
99
+ working with a forgotten ownership claim would not see this from the
100
+ poller's perspective; they'd need to use `/claim-ownership` or
101
+ `/projects/:id` to discover the state. Acceptable: this is the
102
+ documented multi-machine semantics, not a failure.
103
+
104
+ ### Claim-ownership
105
+ Over-block: refuses 409 when the peer heartbeat is fresh AND no
106
+ `{force:true}`. The `force` flag is the documented escape hatch when
107
+ two machines genuinely conflict — fairness preserved by recording the
108
+ `previousOwner` in the response and by the audit-log entry the route
109
+ emits.
110
+
111
+ Under-block: the route only RECORDS the claim. The spec requires the
112
+ caller to commit-and-push the change AND wait 60s for git-sync to
113
+ converge BEFORE acting on it. That's a property of the consumer (the
114
+ round runner, the auto-advance poller, the dashboard) — not this
115
+ endpoint. The endpoint's behavior is correct as a primitive; the
116
+ caller-level safety is documented in the spec and enforced when the
117
+ autonomous loop ships.
118
+
119
+ ### Heartbeat
120
+ Over-block: a malformed heartbeat is treated as stale. A peer with a
121
+ corrupted record could lose ownership unexpectedly. The corruption
122
+ case is rare (atomic write + rename) and the recovery is one write of
123
+ a fresh record.
124
+
125
+ Under-block: heartbeats are written every 30 minutes. A machine that
126
+ sleeps for an hour then wakes up will have a 30-90 minute gap before
127
+ the next heartbeat lands. Acceptable: the staleness threshold is 48h
128
+ by spec, leaving generous slack for sleep/wake cycles.
129
+
130
+ ## Signal vs authority audit
131
+
132
+ The poller is **authority** for time-based round advancement bookkeeping
133
+ but the *decision to advance* still routes through the round runner's
134
+ preflight (the chokepoint). The poller never bypasses preflight.
135
+
136
+ The heartbeat is **signal** for staleness — the claim-ownership endpoint
137
+ uses it as one input to its decision, alongside OCC and `{force}`.
138
+ The signal is read-only deterministic, no LLM-mediated judgment.
139
+
140
+ The post-restore reconciler is **authority** for the narrow case of
141
+ "no TaskFlow yet exists" → downgrade-to-pending. It does NOT touch
142
+ rounds in any other state. Future TaskFlow integration will provide
143
+ the authoritative liveness check.
144
+
145
+ ## Interactions with existing systems
146
+
147
+ - **`InitiativeTracker.update()`.** Every mutation in this PR
148
+ (auto-advance bookkeeping, claim-ownership, reconciler) goes through
149
+ `update` with OCC. Concurrent writes race through the tracker's
150
+ existing version-mismatch mechanism.
151
+ - **`ProjectRoundRunner.preflight()`.** The poller's only entry into
152
+ the runner. Preflight rejects are surfaced verbatim (code + reason)
153
+ to telemetry — no re-interpretation here.
154
+ - **Git-sync.** The heartbeat file lives at
155
+ `.instar/machine-health/<machineId>.json` — the GitSync layer treats
156
+ it like any other state file. Per-machine paths mean two machines
157
+ never collide on the same file.
158
+ - **Multi-machine coordinator.** `machineId` source: the existing
159
+ `coordinator.identity?.machineId ?? os.hostname()` fallback,
160
+ consistent with PR 3.
161
+ - **Scheduler.** Auto-advance poller runs on its own 1-minute
162
+ `setInterval`, not through the existing `JobScheduler` cron system.
163
+ Rationale: the poller is a small, fast scan (millisecond-class on
164
+ typical project sets) and doesn't benefit from the job scheduler's
165
+ rate-limit / quota plumbing. Both timers are `.unref()`'d.
166
+
167
+ ## Rollback cost
168
+
169
+ Revert deletes `src/core/MachineHeartbeat.ts`,
170
+ `src/core/ProjectAutoAdvancePoller.ts`, removes the new ctx field,
171
+ drops the `/claim-ownership` route, drops the heartbeat + poller
172
+ instantiation from `src/commands/server.ts`. Heartbeat files on disk
173
+ become orphaned but harmless. Project records carry the optional
174
+ `ownerMachineId` field through unchanged; older code that doesn't
175
+ know about it ignores it.
176
+
177
+ ## What this PR explicitly defers
178
+
179
+ - **Autonomous-delegating `run()` loop.** The poller bookkeeps the
180
+ auto-advance move (increments `unacknowledgedAdvanceCount`, clears
181
+ `autoAdvanceAt`); the actual round-running work waits on `run()`.
182
+ Phase 1b PR 5.
183
+ - **`GET /projects/:id/next` real implementation.** Still 501 in PR 4.
184
+ Ships alongside `run()` in PR 5.
185
+ - **`POST /projects/:id/resolve-conflict`.** The git-sync conflict
186
+ resolution endpoint. Phase 1.12 work; ships when the git-sync
187
+ conflict path is rebuilt.
188
+
189
+ ## Verification
190
+
191
+ - `npm run lint` — passes (tsc + lint-no-direct-destructive).
192
+ - `npx vitest run tests/integration/projects-api.test.ts
193
+ tests/unit/MachineHeartbeat.test.ts
194
+ tests/unit/ProjectAutoAdvancePoller.test.ts
195
+ tests/unit/ProjectRoundRunner.test.ts
196
+ tests/unit/ProjectRoundLock.test.ts
197
+ tests/unit/route-completeness.test.ts` — 92/92 pass (30 integration
198
+ + 27 runner + 10 heartbeat + 9 lock + 7 poller + 9 route-completeness).
@@ -0,0 +1,150 @@
1
+ # Side-Effects Review — project-scope Phase 1b PR 5 (Dashboard Projects tab + Initiatives filter)
2
+
3
+ **Version / slug:** `project-scope-phase1b-pr5`
4
+ **Date:** `2026-05-11`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `required (new dashboard surface + new server-side filter)`
7
+
8
+ ## Summary of the change
9
+
10
+ Fifth PR of project-scope Phase 1b. Ships the dashboard surface plus
11
+ the small server-side filter the dashboard depends on. The compaction-
12
+ recovery hook's active-projects digest was already in the template (it
13
+ shipped alongside session-start.sh in Phase 1.9 / PR 1's session-start
14
+ work and the equivalent block is present at lines 467-509 of
15
+ `src/templates/hooks/compaction-recovery.sh`), so no template change
16
+ is needed for this PR.
17
+
18
+ Spec source: `docs/specs/PROJECT-SCOPE-SPEC.md` § Phase 1.10
19
+ ("Dashboard Projects tab"), § Phase 1.10 ("Initiatives tab filter").
20
+
21
+ Modified files:
22
+ - `dashboard/index.html` (+~210 lines) — adds the Projects tab button
23
+ alongside the Initiatives tab, adds the Projects panel HTML (header,
24
+ empty state, list container), registers the tab in the panel router,
25
+ and ships ~180 lines of JS:
26
+ - `loadProjects()` — fetches `/projects`, filters to `status:'active'`
27
+ client-side, fetches each project's joined view via
28
+ `/projects/:id?reconcile=false` in parallel (capped at 25), renders
29
+ cards.
30
+ - `renderProjectCard(project, children)` — XSS-safe construction
31
+ (`textContent` everywhere, `appendChild` only, no `innerHTML`).
32
+ Per spec: title row, drift status badge (colored per verdict),
33
+ round-by-round progress bar + pipelineStage histogram + Halt
34
+ + Ack buttons.
35
+ - `startProjectsPoll` / `stopProjectsPoll` — 15-second `setInterval`
36
+ when the tab is active, cleared on deactivation.
37
+ - `projectHalt(id)` + `projectAck(id, idx)` — POST to the new
38
+ runner-backed routes. 409 ("version mismatch") triggers a silent
39
+ `loadProjects()` re-render rather than an error toast.
40
+ - `loadInitiatives()` is updated to pass
41
+ `?excludeKind=project&excludeParented=true` by default, with a
42
+ "Show projects + children" checkbox to bypass. The filter is
43
+ server-side; the records never make the wire-trip.
44
+ - `src/server/routes.ts` (+~10 lines) — `GET /initiatives` now accepts
45
+ `excludeKind=<string>` (drops records where `kind ?? 'task'` matches)
46
+ and `excludeParented=true` (drops records with a `parentProjectId`).
47
+ - `tests/unit/routes-initiatives.test.ts` (+3 cases + mirror update) —
48
+ exercises both new params individually and combined with the existing
49
+ `status=` filter. The test mirror at the top of the file (which
50
+ duplicates the production handler shape) was updated in lockstep.
51
+
52
+ ## Decision-point inventory
53
+
54
+ - **Initiatives filter** — **add** — `excludeKind` and
55
+ `excludeParented` are additive query parameters that DEFAULT TO NOT
56
+ EXCLUDING. Existing callers see no behavior change. The dashboard
57
+ opts in via a UI checkbox that defaults to "exclude" (matches the
58
+ spec's "filter project-kind by default").
59
+ - **Projects tab read-only verbs** — **add** — the Halt and Ack
60
+ buttons route through the existing `POST /projects/:id/halt` and
61
+ `POST /projects/:id/ack` (shipped in PR 3). No new HTTP surface in
62
+ this PR. The buttons act on the project record's current version
63
+ (from the latest `loadProjects()` fetch); the routes enforce OCC
64
+ via `If-Match` — actually, `/halt` and `/ack` do NOT require
65
+ `If-Match` in PR 3's design (they use the tracker's internal
66
+ version-aware update via `ifMatch` from the latest `get()`). The
67
+ dashboard relies on that internal handling rather than passing
68
+ `If-Match` itself.
69
+ - **15-second poll cadence** — **add** — matches the spec exactly.
70
+ The interval is cleared when the tab deactivates so a hidden tab
71
+ doesn't burn cycles.
72
+
73
+ ## Over-block vs under-block analysis
74
+
75
+ ### Server-side filter
76
+ Over-block: the dashboard's default-on filter means a user who clicks
77
+ "Initiatives" no longer sees project-kind records or child items
78
+ unless they tick the "Show projects + children" checkbox. This is the
79
+ spec's documented behavior (Projects get their own tab; children live
80
+ inside their parent's card). No information is lost, just relocated.
81
+
82
+ Under-block: the filter is purely additive. Existing scripts or
83
+ clients that hit `/initiatives` without the new params see the same
84
+ behavior they always have.
85
+
86
+ ### Dashboard rendering
87
+ Over-block: 409 from `/halt` or `/ack` is treated as "stale view,
88
+ just refresh" — no error toast, no user-visible noise. The spec
89
+ explicitly calls this out: "409 response handled silently: refresh
90
+ + re-render, no error toast."
91
+
92
+ Under-block: the project-list fetch caps detailed lookups at 25. A
93
+ deployment with >25 active projects would have the rest rendered as
94
+ empty cards (since `detailed` is `null` for un-fetched IDs and we
95
+ skip nulls). Acceptable for Phase 1; a follow-up PR can add pagination
96
+ when project count actually exceeds the cap.
97
+
98
+ ### XSS safety
99
+ Every rendered string goes through `.textContent` (never `.innerHTML`).
100
+ Project ids, titles, round names, child stages, drift verdicts — all
101
+ constructed via `createElement` + `textContent` + `appendChild`. The
102
+ existing `dashboard/index.html` conventions already enforce this; the
103
+ new code follows the same pattern.
104
+
105
+ ## Signal vs authority audit
106
+
107
+ Dashboard is **read-only display** for the project surface (with two
108
+ button-triggered mutating calls to existing PR 3 routes). The
109
+ authority for halt and ack lives in `ProjectRoundRunner` — the
110
+ dashboard just calls the documented endpoints. No new authority
111
+ introduced.
112
+
113
+ The initiatives filter is a **read-side** filter, not a
114
+ record-modifying gate. The records themselves are unchanged; the
115
+ filter only affects the response set.
116
+
117
+ ## Interactions with existing systems
118
+
119
+ - **PR Pipeline tab.** No interaction — the Projects tab uses its
120
+ own panel + JS namespace.
121
+ - **Initiatives tab.** Shares the existing `loadInitiatives()`
122
+ function; the new filter is a query-string addition with a UI
123
+ checkbox to bypass. Existing tab behavior preserved when the
124
+ checkbox is checked.
125
+ - **`/projects/:id?reconcile=false`.** This query parameter is
126
+ already documented in the spec (Phase 1.3 — "Clients that need
127
+ pure-read semantics use `?reconcile=false`"). The dashboard uses
128
+ it to avoid triggering the lazy reconciler on every 15-second
129
+ poll. The `?reconcile=false` parameter is honored as a passthrough
130
+ in the existing route (the lazy reconciler isn't wired yet, so
131
+ it's a no-op today — when the reconciler ships, the dashboard
132
+ poll won't trigger it).
133
+ - **Compaction-recovery hook.** Already includes the active-projects
134
+ digest section (PR 1's template work). No change needed in this PR.
135
+
136
+ ## Rollback cost
137
+
138
+ Revert removes the new tab button, the new panel HTML, the JS block,
139
+ the Initiatives filter checkbox, the server-side filter, and the test
140
+ cases. No schema changes. Existing project records and initiatives
141
+ are unaffected.
142
+
143
+ ## Verification
144
+
145
+ - `npm run lint` — passes (tsc + lint-no-direct-destructive).
146
+ - `npx vitest run tests/unit/routes-initiatives.test.ts
147
+ tests/unit/route-completeness.test.ts
148
+ tests/integration/projects-api.test.ts
149
+ tests/unit/dashboard-initiativesTab.test.ts` — 66/66 pass.
150
+ - Existing PR 1-4 invariants still green.