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.
- package/dashboard/index.html +249 -2
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +90 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/core/MachineHeartbeat.d.ts +94 -0
- package/dist/core/MachineHeartbeat.d.ts.map +1 -0
- package/dist/core/MachineHeartbeat.js +155 -0
- package/dist/core/MachineHeartbeat.js.map +1 -0
- package/dist/core/ProjectAutoAdvancePoller.d.ts +107 -0
- package/dist/core/ProjectAutoAdvancePoller.d.ts.map +1 -0
- package/dist/core/ProjectAutoAdvancePoller.js +176 -0
- package/dist/core/ProjectAutoAdvancePoller.js.map +1 -0
- package/dist/core/ProjectRoundCompleteMessage.d.ts +158 -0
- package/dist/core/ProjectRoundCompleteMessage.d.ts.map +1 -0
- package/dist/core/ProjectRoundCompleteMessage.js +253 -0
- package/dist/core/ProjectRoundCompleteMessage.js.map +1 -0
- package/dist/core/ProjectRoundExecution.d.ts +111 -0
- package/dist/core/ProjectRoundExecution.d.ts.map +1 -0
- package/dist/core/ProjectRoundExecution.js +359 -0
- package/dist/core/ProjectRoundExecution.js.map +1 -0
- package/dist/core/ProjectRoundLock.d.ts +67 -0
- package/dist/core/ProjectRoundLock.d.ts.map +1 -0
- package/dist/core/ProjectRoundLock.js +161 -0
- package/dist/core/ProjectRoundLock.js.map +1 -0
- package/dist/core/ProjectRoundRunner.d.ts +146 -0
- package/dist/core/ProjectRoundRunner.d.ts.map +1 -0
- package/dist/core/ProjectRoundRunner.js +371 -0
- package/dist/core/ProjectRoundRunner.js.map +1 -0
- package/dist/core/ProjectRoundWorktrees.d.ts +57 -0
- package/dist/core/ProjectRoundWorktrees.d.ts.map +1 -0
- package/dist/core/ProjectRoundWorktrees.js +111 -0
- package/dist/core/ProjectRoundWorktrees.js.map +1 -0
- package/dist/server/AgentServer.d.ts +12 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +3 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +19 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +590 -7
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +47 -47
- package/upgrades/0.28.96.md +347 -0
- package/upgrades/side-effects/project-scope-phase1b-connect-the-dots.md +184 -0
- package/upgrades/side-effects/project-scope-phase1b-pr3.md +228 -0
- package/upgrades/side-effects/project-scope-phase1b-pr4.md +198 -0
- package/upgrades/side-effects/project-scope-phase1b-pr5.md +150 -0
- package/upgrades/side-effects/project-scope-phase1b-pr6.md +136 -0
- 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.
|