instar 0.28.95 → 0.28.97

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 (51) 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 +782 -7
  40. package/dist/server/routes.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/data/builtin-manifest.json +48 -48
  43. package/upgrades/0.28.96.md +347 -0
  44. package/upgrades/0.28.97.md +146 -0
  45. package/upgrades/side-effects/project-scope-phase1-7-skill-surface.md +175 -0
  46. package/upgrades/side-effects/project-scope-phase1b-connect-the-dots.md +184 -0
  47. package/upgrades/side-effects/project-scope-phase1b-pr3.md +228 -0
  48. package/upgrades/side-effects/project-scope-phase1b-pr4.md +198 -0
  49. package/upgrades/side-effects/project-scope-phase1b-pr5.md +150 -0
  50. package/upgrades/side-effects/project-scope-phase1b-pr6.md +136 -0
  51. package/upgrades/side-effects/project-scope-phase1b-pr7.md +175 -0
@@ -0,0 +1,184 @@
1
+ # Side-Effects Review — project-scope Phase 1b connect-the-dots wiring
2
+
3
+ **Scope.** Wire the four primitives shipped during the Phase 1b
4
+ autonomous burst to their consumers, closing the deferrals named in
5
+ the side-effects reviews of PRs #164 (round runner), #166 (dashboard),
6
+ #167 (round-complete message), and #168 (run loop). Each touch is
7
+ small; they ride together because they're all "primitive-without-consumer
8
+ gets its consumer."
9
+
10
+ ## What's IN this PR
11
+
12
+ 1. **`ProjectAutoAdvancePoller.executor`** — optional async hook on the
13
+ poller. When configured, a successful preflight + bookkeeping move
14
+ triggers a fire-and-forget `runRound()` call. The `inFlight: Set`
15
+ guard prevents a slow run from being relaunched on the next 60s tick.
16
+ Errors land in `result.executorErrors[]`; they never throw out of
17
+ `tick()`. Server startup wires the executor against
18
+ `ProjectRoundExecution.runRound`.
19
+ 2. **`GET /projects/:id/next`** — per spec § Phase 1.5 line 268.
20
+ Returns `{ action, params, skillCommand }` for the first
21
+ non-complete round. Action verbs (a non-exhaustive contract):
22
+ `'await-user-approval'` (no `firstLaunchAckAt`), `'ack-required'`
23
+ (unacked counter at cap), `'resolve-conflict'` (awaiting
24
+ reconciliation), `'accept-partial'` (round partially-complete),
25
+ `'run-spec-converge'` (item at spec-drafted), `'run-drift-check'`
26
+ (item approved, no fresh verdict), `'start-round'` (default). Each
27
+ verb maps to a suggested `/project ...` skill invocation via
28
+ `skillCommandForAction()`. Returns 204 when all rounds complete,
29
+ 404 on non-project initiative. Read-only, no OCC required.
30
+ 3. **`GET /projects/:id` lazy merged-state reconciler** — per spec
31
+ § Phase 1.4 lines 256-258. For 'building' children with
32
+ `mergeCommitOid` set:
33
+ - **6h debounce** via `ciCheckedAt`: skip any child checked in the
34
+ last 6 hours.
35
+ - **Selection order**: oldest `ciCheckedAt` first (treat missing as
36
+ epoch 0), ties broken by `roundIndex` ASC, then `itemId` ASC. No
37
+ child starves.
38
+ - **Cap**: at most 3 child-revalidations per GET.
39
+ - **Both directions**: verified ancestor of origin/main → bump to
40
+ `'merged'`. NOT an ancestor → transition to `'regressed'` AND
41
+ clear future `autoAdvanceAt` on subsequent rounds so the chain
42
+ doesn't auto-fire while a child is broken.
43
+ - **ciCheckedAt is always written** on a revalidation attempt
44
+ (even on git-shell-out failure) so the debounce backs off
45
+ instead of hot-looping.
46
+ - Skipped when `?reconcile=false`, or when the project has no
47
+ `targetRepoPath`.
48
+ 4. **`POST /projects/:id/drift-check`** — wraps
49
+ `ProjectDriftChecker.run()`. Body: `{ roundIndex, specPath,
50
+ referencedFiles[], timeoutMs?, modelId? }`. **Mutex-guarded per
51
+ project (spec § Phase 1.5 line 279)**: a concurrent call against
52
+ the same `projectId` returns 409 `{error: 'drift-check already in
53
+ flight for this project'}` — protects the drift-spend ledger from
54
+ double-spend and the LLM from double-billing. Returns 503 when no
55
+ `IntelligenceProvider` is configured (no checker wired), 400 on
56
+ validation, 200 with `{ verdict, projectId, roundIndex }` on
57
+ success. `AgentServer.routeContext` and the server-startup wiring
58
+ both gained a `projectDriftChecker` field, populated against the
59
+ shared `IntelligenceProvider`.
60
+
61
+ ## What's explicitly DEFERRED
62
+
63
+ - **Drift cache + spend ledger wiring.** `ProjectDriftChecker` accepts
64
+ optional `cache` (`ProjectDriftCheckerCache`) and `ledger`
65
+ (`DriftSpendLedger`). Today the production instantiation passes
66
+ neither — every call recomputes from scratch, no spend cap.
67
+ Follow-up: wire both in `src/commands/server.ts` once the dashboard
68
+ shows live drift verdicts and the cost telemetry actually needs the
69
+ cap. Tracked here, no new ticket.
70
+ - **Reconciler scope.** Only children at `pipelineStage = 'building'`
71
+ with `mergeCommitOid` set are revalidated. Future PRs may expand the
72
+ predicate (e.g., `'spec-converged'` items that have moved to
73
+ `'approved'` upstream). The 3-child cap is intentionally tight to
74
+ keep the GET fast (<200ms p99 in local benchmarks against a 20-child
75
+ fixture).
76
+ - **`/projects/:id/next` preflight integration.** This endpoint
77
+ deliberately does NOT run `ProjectRoundRunner.preflight`. Preflight
78
+ is a heavy check (lock, drift, owner, ack-gap) that belongs at fire
79
+ time, not on a dashboard peek. Callers needing to know "would this
80
+ round actually fire?" can call `POST /projects/:id/preflight` (a
81
+ future endpoint).
82
+ - **Concurrent executor launches.** A single `inFlight` Set is keyed by
83
+ `projectId`, so two ticks of the same project never produce two
84
+ spawn calls. The `ProjectRoundLock` inside `runRound()` is the
85
+ authoritative defense — `inFlight` is a fast path that avoids
86
+ spawning a child that would immediately bail on `LOCK_HELD`.
87
+
88
+ ## Side-effects review
89
+
90
+ **Over-block.** The lazy reconciler runs on every `GET /projects/:id`
91
+ unless `?reconcile=false`. Worst case: 3 git `merge-base` calls per
92
+ GET, each <50ms on a hot repo. No long-running git operations. Skips
93
+ entirely when `targetRepoPath` is missing or all children are in
94
+ non-`building` stages. The dashboard's projects-tab poll fires every
95
+ 15s and calls GET only when the tab is active.
96
+
97
+ **Under-block.** The drift-check route does NOT require OCC. Drift is
98
+ read-only; staleness of the project record doesn't compromise the
99
+ verdict (the verdict is keyed on file content hashes, not project
100
+ version). Adding OCC would force the dashboard to read-then-mutate
101
+ under If-Match for what is conceptually a query.
102
+
103
+ **Level-of-abstraction fit.** Each new wiring sits at the same layer
104
+ as its primitive:
105
+ - The executor hook is on the poller, matching the poller's
106
+ responsibility ("decide whether to fire").
107
+ - The reconciler lives in the GET handler because it's a presentation
108
+ concern — making sure the response reflects on-disk truth, not a
109
+ cached pipelineStage.
110
+ - `/projects/:id/next` is a peek, not a mutation — read-only by design.
111
+ - `/projects/:id/drift-check` posts because the checker spends LLM
112
+ tokens; POSTing matches the semantics of "this isn't safely
113
+ cacheable, retry-able, or idempotent at the HTTP layer."
114
+
115
+ **Signal vs authority.** The reconciler is signal-only — it bumps
116
+ `pipelineStage` but doesn't block, retry, or revalidate other state.
117
+ The drift-check route returns the verdict; the CALLER decides what
118
+ to do with it (the spec keeps drift verdicts as advisory for the
119
+ agent's planning, not blocking gates).
120
+
121
+ **Interactions.**
122
+ - Poller × runRound: `inFlight` Set + `ProjectRoundLock` produces a
123
+ belt-and-suspenders against duplicate launches across ticks.
124
+ - Reconciler × auto-update: if the reconciler bumps a child to
125
+ `'merged'`, that doesn't trigger the auto-advance poller directly
126
+ (the poller is `setInterval`-driven, not event-driven). The next
127
+ tick sees the new state.
128
+ - Drift-check × runRound: orthogonal — `runRound` doesn't call this
129
+ HTTP endpoint; the checker is invoked directly inside `runRound`
130
+ via dependency injection. The HTTP route is for the dashboard +
131
+ operator-driven probes.
132
+
133
+ **Rollback cost.** Each wiring is a thin addition:
134
+ - Remove the `executor` config from server.ts → poller goes back to
135
+ bookkeeping-only. No state loss.
136
+ - `GET /projects/:id/next` revert restores the 501 stub. No state.
137
+ - The reconciler is gated by `?reconcile=false`; clients can disable
138
+ it without a code change.
139
+ - The drift-check route is feature-flagged by `projectDriftChecker`
140
+ presence; removing the wiring in server.ts returns 503, no harm.
141
+
142
+ ## Files touched
143
+
144
+ - `src/core/ProjectAutoAdvancePoller.ts` — executor hook, `inFlight`
145
+ guard, new `executed[]` + `executorErrors[]` result fields.
146
+ - `src/server/routes.ts` — new `verifyMergedItemsViaGit` +
147
+ `ProjectDriftChecker` imports; `GET /projects/:id` made async with
148
+ reconciler logic; `GET /projects/:id/next` implemented;
149
+ `POST /projects/:id/drift-check` added; `projectDriftChecker` in
150
+ `RouteContext`.
151
+ - `src/server/AgentServer.ts` — `projectDriftChecker` in options +
152
+ RouteContext init.
153
+ - `src/commands/server.ts` — wire executor (calls `runRound`) and
154
+ instantiate `ProjectDriftChecker` with the shared
155
+ `IntelligenceProvider`.
156
+ - `tests/unit/ProjectAutoAdvancePoller.test.ts` — 3 new cases
157
+ exercising executor invocation, error capture, and the in-flight
158
+ guard.
159
+ - `tests/integration/projects-api.test.ts` — 3 new cases for
160
+ `/projects/:id/next` (200/204/404), 1 new case for
161
+ `/projects/:id/drift-check` 503, removed the old 501-placeholder
162
+ assertion.
163
+ - `upgrades/NEXT.md` — release notes entry stacked above PR #168.
164
+ - `upgrades/side-effects/project-scope-phase1b-connect-the-dots.md` —
165
+ this file.
166
+
167
+ ## Spec references
168
+
169
+ - `docs/specs/PROJECT-SCOPE-SPEC.md` § Phase 1.5 ("Run loop") — the
170
+ spec section the poller's executor wiring completes.
171
+ - `docs/specs/PROJECT-SCOPE-SPEC.md` § Phase 1.4 — drift checker
172
+ surface; HTTP route exposes it.
173
+
174
+ ## Coverage
175
+
176
+ 137 project-related tests pass locally:
177
+ - `tests/unit/ProjectAutoAdvancePoller.test.ts` (10 cases, +3 new)
178
+ - `tests/unit/ProjectRoundExecution.test.ts` (8 cases, no change)
179
+ - `tests/unit/ProjectRoundRunner.test.ts` (27 cases, no change)
180
+ - `tests/unit/ProjectRoundLock.test.ts` (9 cases, no change)
181
+ - `tests/unit/ProjectDriftChecker.test.ts` (existing coverage)
182
+ - `tests/integration/projects-api.test.ts` (33 cases, +3 new for
183
+ `/projects/:id/next`, +1 new for drift-check 503, -1 obsolete 501
184
+ placeholder).
@@ -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).