Skip to content

feat(workspace): register the boot folder only while the registry is empty - #774

Merged
pat-lewczuk merged 11 commits into
mainfrom
conakry
Sep 15, 2026
Merged

pat-lewczuk merged 11 commits into
mainfrom
conakry

Conversation

@patzick

@patzick patzick commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

You can now run cezar from anywhere without the current directory turning into another project — useful above all when starting it inside a worktree or a scratch checkout, where every launch used to leave a new row in the sidebar to clean up later.

Starting cezar in a folder now registers it only on the very first run: once the registry holds any project, booting elsewhere serves that folder exactly as before (its own tasks, its own .ai/cezar/, /p/<slug>/ bound to the boot context) but no longer appends it to the sidebar behind the user's back. Adding stays an explicit gesture — cezar projects add and the cockpit's Add project dialog keep using the path-shape guard alone — and an already-registered boot root still passes, so it keeps bumping lastOpenedAt and handing the server its registry id.

The new shouldAutoRegisterProject composes the existing shouldRegisterProject guards with this seed-once rule and is used by every boot path (serve, run, single-project projects list); CEZ_SINGLE_PROJECT=1 is exempt, since there the launch context is the project.

Follow-on copy fix: the 409 on removing the boot project claimed it "re-registers itself at every start", which this change makes false — it now points at cezar projects remove, with the Settings tooltip, remove dialog, README and the multi-project spec updated to match.

Five new tests cover first-project seeding, suppression once populated, an already-registered root in any spelling, the path guards still winning, and the single-project exemption; the projects, projects-cli, projects-api, server and projects-section suites pass and typecheck:server is clean.


Fixes #872 — the workspace automation scheduler dropped the boot project's automation store on every sweep, because that store is keyed on the 'default' alias and the sweep compares against the registry. Pre-existing, but seed-once turns it from a task-worktree corner into the ordinary case, so it is fixed here rather than left for the follow-up issue. Also documents the /api/v1/health half of the same consequence: bootProject is no longer guaranteed to appear in health's projects[].

Deliberately not fixed here and tracked as #875: after the new Add project button registers the served folder, the skills-update coordinator briefly tracks that root under both the 'default' alias and its new slug. Cosmetic, self-correcting, and not worth widening a risk-high diff for.

…empty

Starting cezar in a folder is an implicit "this is my project" only for the
very first run. Once the registry holds anything, booting somewhere else
serves that folder as before but no longer appends it behind the user's back;
adding stays an explicit gesture (`cezar projects add`, the cockpit's Add
project dialog), both of which keep using the path-shape guard alone.

`shouldAutoRegisterProject` composes `shouldRegisterProject` with the new
seed-once rule. An already-registered root still passes, so booting a known
project keeps bumping `lastOpenedAt` and keeps handing the server its registry
id. `CEZ_SINGLE_PROJECT=1` is exempt — there the launch context IS the project
and `cezar projects list` reads its identity back out of the registry.

Follow-on copy: the 409 on removing the boot project no longer claims it
"re-registers itself at every start" (now false); it points at
`cezar projects remove` instead, with the Settings pane tooltip, the remove
dialog, README and the spec's boot flow updated to match.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

📦 npm preview published — 0.10.1-pr774.1496

Try this PR build (exact pinned version — copy-paste as-is):

npx cezar-cli@0.10.1-pr774.1496                                # cockpit at http://localhost:4321
npx cezar-cli@0.10.1-pr774.1496 run "…"                        # headless run
npx cezar-cli@0.10.1-pr774.1496 server-deploy --platform <id>  # roll a server to this exact build

Also tagged: npm install -g cezar-cli@pr-774 (moving tag for this PR).
Packages: cezar-cli@0.10.1-pr774.1496@open-mercato/cezar@0.10.1-pr774.1496@open-mercato/cezar-api-client@0.10.1-pr774.1496 (provenance attested).

@pat-lewczuk pat-lewczuk self-assigned this Aug 5, 2026
@pat-lewczuk pat-lewczuk added the in-progress Cezar agent is actively working this issue label Aug 5, 2026
@pat-lewczuk

Copy link
Copy Markdown
Collaborator

🤖 om-auto-review-pr started by @pat-lewczuk at 2026-08-05T06:59:24Z. Other auto-skills will skip this PR until the lock is released.

@pat-lewczuk pat-lewczuk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Code Review: feat(workspace): register the boot folder only while the registry is empty

🎯 Summary

The change introduces shouldAutoRegisterProject (packages/cezar/src/workspace/projects.ts:141), a boot-time guard that wraps the existing path-shape guard with a "seed once" rule: the boot repo is written to ~/.cezar/config.json only while the registry is empty, or when that root is already registered, with CEZ_SINGLE_PROJECT=1 exempt. Every boot path uses it (serve, run, and the single-project projects list in packages/cezar/src/index.ts:135,192,365), while the explicit gestures — cezar projects add and POST /api/v1/projects — keep calling the unchanged shouldRegisterProject, so an explicit add is still blind to how many projects exist. A follow-on copy fix retires the now-false "it re-registers itself at every start" wording from the 409 on removing the boot project, the Settings tooltip, the remove dialog, the README and the multi-project spec.

I reviewed the full diff (10 files: the guard and its tests, the two boot call sites, the server 409, the Settings pane and its test, README, CHANGELOG and the multi-project spec) plus the surrounding boot, routing and sidebar code that the new "unregistered boot root" state now flows through.

The core of the change is well built and I like it: the split between the path-shape guard and the boot guard is the right seam, the single-project exemption is exactly the constraint .ai/specs/2026-07-21-cez-single-project.md:26 warned about ("filtering inside registerProject() or shouldRegisterProject() would suppress the launch project's self-healing registration") and it is honoured by putting the filter in a third function instead, the already-registered-root case is handled in every spelling, and the five new tests cover each branch. The whole validation gate is green on this head.

What holds the review up is not the guard but its blast radius. Before this change, an unregistered boot root was a rare edge case (a task worktree, $HOME); after it, it is the ordinary state of every launch outside a registered project — and two properties that the codebase documents and the cockpit relies on were built for the old, rare case. Both are fixable with a small amount of code or documentation; neither is a design objection to the seed-once rule itself.

Verdict

request changes — two majors, neither waived: the registry's documented self-healing rebuild is gone while three places (including BACKWARD_COMPATIBILITY.md) still promise it, and an unregistered boot folder has no reachable affordance anywhere in the cockpit, which contradicts the PR's own "serves that folder exactly as before" claim on the navigation side. There are no blockers, the validation gate is fully green, and both findings are addressable without changing the guard's design.

🧪 Validation Gate

Run inside an isolated worktree at the PR head (2c1b4f11) after npm ci.

Command Status Notes
npm run typecheck ✅ PASS Contract, client, server and web projects all clean (exit 0).
npm test ✅ PASS 296 test files, 5290 tests passed, including the five new shouldAutoRegisterProject cases and the three retitled copy assertions.
npm run test:unit ✅ PASS node:test core-module suite, 0 failures.
npm run build ✅ PASS Server + web build, and check:pack reports "452 files, 88 under web/dist (shell + assets present)".
npm run test:package ✅ PASS 12 packaged-CLI E2E tests passed.

CI on the PR agrees: "Unit, build, E2E, and package", "Publish npm snapshot" and license/cla are all green, and nothing is pending.

Findings

⚠️ Major

1. The registry no longer rebuilds as projects are opened, but three places still say it does — and one of them is the compatibility contract.

packages/cezar/src/workspace/projects.ts:141 — the seed-once rule removes the property that made registry loss recoverable. Concretely: ~/.cezar/config.json is corrupted (or deleted), loadWorkspaceConfig degrades to in-memory defaults with an empty projects[] and deliberately leaves the bad file on disk. The next cezar serve now sees projects.length === 0, registers exactly one project — whichever repo the user happened to boot in — and that merge-write replaces the corrupt file. Every other project is then gone for good: opening them no longer puts them back, and the only recovery is remembering each root and running cezar projects add by hand. Before this change, each project re-registered itself the next time it was opened, which is precisely why losing the file was documented as recoverable.

Three places still assert the old behaviour and were not updated:

  • BACKWARD_COMPATIBILITY.md:151 — "The registry is additive state that is written, never requiredit rebuilds as projects are opened, so losing it is an inconvenience, not data loss". This is the protected-surface document for ~/.cezar/config.json; the sentence is now false, and the "not data loss" conclusion rests on it.
  • packages/cezar/src/workspace/config.ts:21 — the same claim in the module docstring, as the stated justification for degrading a corrupt file to defaults.
  • .ai/specs/2026-07-20-multi-project-workspace.md:268 and :526 — the boot-warning note and the degradation table row ("Degrade to defaults + warning; registry rebuilds as projects are opened"). The PR updates the spec's Boot flow section but leaves these two, so the spec now contradicts itself.

Why it matters: CODE_REVIEW.md ranks graceful degradation second and state-file compatibility third, and a degradation path whose documented recovery no longer exists is exactly the case those rules cover. This is not a blocker — nothing inside a repo is lost, the roots are re-addable, and BACKWARD_COMPATIBILITY.md:156's explicit "Breaking:" list does not cover it — but it must not merge with the docs asserting the opposite.

Fix: update all three (four lines) to describe what the code now does, and say what the new recovery path is (cezar projects add <dir> per project). Optionally, consider a cheap mitigation so the guarantee is not simply dropped — for example, letting loadWorkspaceConfig tell shouldAutoRegisterProject that this read was a degraded one (corrupt file) rather than a genuinely empty registry, so a corrupt-config boot does not silently overwrite the file with a one-project registry. If you decide the dropped guarantee is acceptable, that is a legitimate call — but it should be stated in the changed docs rather than left implicit.

2. An unregistered boot folder is unreachable from the cockpit UI.

packages/web/src/components/app-shell-container.tsx:105,139 with packages/web/src/lib/last-location.ts:55,72 and packages/web/src/routes.tsx:250. The PR body says the boot folder is still "served exactly as before (its own tasks, its own .ai/cezar/, /p/<slug>/ bound to the boot context)". That is true of the API and the routing, but not of navigation, and three things compound:

  • The sidebar is fed from the registry alone — ProjectGroups receives projects.projects (app-shell-container.tsx:139, consumed at packages/web/src/components/project-groups.tsx:167), so an unregistered boot project has no row. There is no other element in the shell that links to it.
  • locationToSave refuses to persist it: projectIsUsable (last-location.ts:55) requires the id to be in registry.projects, so pages under the boot project's slug are never written to lastLocation (last-location.ts:72).
  • cezar serve opens http://localhost:<port> (packages/cezar/src/index.ts:292,317) — the exact bare root — and LegacyPathRedirect restores the saved lastLocation there (routes.tsx:250). Since that saved location can only ever name a registered project, the user who runs cezar in a scratch checkout is dropped into a different project entirely.

So the realistic sequence is: cd ~/scratch/thing && npx cezar → the browser opens on the project you were last in, the folder you just launched in is nowhere in the sidebar, and its pages can never become the restore target either. The only handle on it is typing /p/<would-be-slug>/ by hand, and nothing in the UI ever shows that slug. That is a materially worse outcome than the sidebar row the change set out to avoid.

A smaller related edge: with exactly one registered project plus an unregistered boot folder, app-shell-container.tsx:105 (registry.projects.length > 1) drops the grouped sidebar entirely, so the shell renders the flat single-project nav for the registered project while the repo chip (repoChipOf(health.data), line 113) names the boot folder — the chrome names one repo and the navigation belongs to another.

Fix — any of these would resolve it, and the first is the smallest: keep the boot project visible in the cockpit even when it is unregistered, by having GET /api/v1/projects include a synthetic, non-persisted entry for the boot root (flagged so Settings → Projects can render it as "not registered — Add project"), which restores both the sidebar row and projectIsUsable. Alternatively, teach projectIsUsable to accept registry.bootProject so the boot folder can at least be the restore target, and give the user a one-click "Add this project" affordance in the shell when the boot root is unregistered. Whichever you pick, the behaviour deserves a test in packages/web/src/lib/last-location.test.ts and the sidebar suite, since it is now the default path rather than an edge case.

🔹 Minor

3. The unregistered boot project's slug is recomputed per call and can silently change mid-session.

packages/cezar/src/server/server.ts:1129-1141resolveBootProject caches only on a registry hit (if (match) bootProjectCache = match.id); the allocateProjectSlug fallback is recomputed on every call against the live registry. With the boot root registered (the old normal), the cache always hit and the id was stable. Now the fallback is the normal path, and it is derived from data the user can change while the server runs: boot in /work/beta while the registry holds only alpha → the boot project is beta; the user then clicks Add project on some other folder also named beta, and registerProject — which dedupes only against registry ids, and does not know the live fallback — hands that project the slug beta. The next resolveBootProject call finds no match and returns beta-2. The boot project's URL has changed under the user, and an open tab on /p/beta/ now resolves to the newly added project instead.

Fix: cache the fallback for the process lifetime as well (assign it to bootProjectCache before returning), so the boot project's id is fixed at first resolution the way a registered one is. Reserving the boot fallback in allocateProjectSlug's taken set for API-side registrations would close the collision from the other end too.

4. packages/cezar/src/workspace/projects-cli.ts:141-142 still carries the "re-registers itself" claim this PR set out to retire.

The comment reads: "Removing the repo you normally serve is therefore allowed — and self-healing, since the next cezar serve in it registers it again (said in the note below)." After this change the next cezar serve in it does not register it again, because the registry is no longer empty. This is the same false statement the PR correctly fixed in packages/cezar/src/server/server.ts:2397-2409, the README, the Settings tooltip and the remove dialog — and it is the most load-bearing of the set, because the new 409 message now sends users to cezar projects remove specifically, so the wrong mental model sits directly on the newly blessed path. Removing the boot repo from the CLI is now a one-way action, and the comment should say so.

💅 Nit

5. packages/cezar/src/workspace/projects.ts:145,149 realpaths the root twice.

shouldRegisterProject already calls normalizeRoot(repoRoot) internally, and shouldAutoRegisterProject calls it again for the membership test — two realpath syscalls per boot on the same path. Purely cosmetic at this scale; if you want it tidy, have the shared helper return the normalized root (or accept one) so the boot guard reuses it.

💥 Breaking Changes

  • No exported/public symbol removed or renamed without a deprecation path — shouldRegisterProject is still exported and still used by projects-cli.ts and the POST /api/v1/projects path; shouldAutoRegisterProject is purely additive.
  • No function signature changed in a breaking way — the new function's env parameter is optional and defaults to process.env.
  • No required type field removed or narrowed.
  • No HTTP route URL removed or renamed; no method changed for an existing operation.
  • No field removed or retyped in an existing response shape — GET /api/v1/projects keeps its {projects, bootProject, projectsDir} shape; only which roots appear in projects[] changes, and bootProject was already documented as possibly naming an unregistered root.
  • No event or message name renamed or removed; no payload field removed.
  • No CLI command or flag renamed or removed; no machine-parsed output format changed. The 409 body on DELETE /api/v1/projects/:projectId is reworded, but BACKWARD_COMPATIBILITY.md protects the status/shape pair rather than the prose, and the tests that assert the wording were updated with it.
  • No database table or column renamed or removed; no column type narrowed.
  • No config key renamed and no default changed silently — no key changed, and the behavioural default change is announced in the CHANGELOG's Unreleased → 🔧 Changed section, the README and the spec's Boot flow section.
  • Where a contract had to change: old surface kept working through a deprecation window, with migration notes — not met. BACKWARD_COMPATIBILITY.md:151's stated recovery property for ~/.cezar/config.json ("it rebuilds as projects are opened, so losing it is an inconvenience, not data loss") no longer holds and the document was not updated. See Major 1; that section is where the new recovery story (cezar projects add per project) needs to be written down.

🧪 Test Coverage

The new behaviour is well covered at the unit level. packages/cezar/src/workspace/projects.test.ts:283-310 adds five cases against shouldAutoRegisterProject — the first-project seed, suppression of an unknown root once anything is registered (asserting the registry stays at length 1, so it checks the effect and not only the boolean), an already-registered root in both the bare and trailing-slash spelling, the path-shape guards still winning ahead of the seeding rule for $HOME and for a task worktree, and the CEZ_SINGLE_PROJECT=1 exemption. Injecting env rather than mutating process.env keeps those cases isolated. projects.test.ts:271-276 adds the complementary guard that shouldRegisterProject stays blind to registry size, which is what protects the explicit-add path from regressing into the boot rule — that is the right test to have written. The three copy assertions in projects-api.test.ts:543,548 and projects-section.test.tsx:360-362 were updated in step with the new wording rather than loosened.

The gaps are on the consequences rather than the guard:

  • No test asserts the boot call sites use the new guard. packages/cezar/src/index.ts:192 is the only thing that makes the feature real, and a future refactor could swap it back to shouldRegisterProject with the whole suite still green. A small test around initWorkspace (or an exported seam for it) asserting that a second boot root is not written when the registry is populated would pin the wiring.
  • Nothing covers the unregistered-boot cockpit state (Major 2). Once this lands, "boot root absent from registry.projects" is the common case: packages/web/src/lib/last-location.test.ts should cover what locationToSave/locationToRestore do when the current project is the boot project and is not in the registry, and the sidebar suite should cover what the shell renders for a boot root with no registry row. Both are cheap and both encode the decision you make on that finding.
  • Nothing covers the boot-slug fallback's stability (Minor 3). A server-level test that resolves the boot project, registers a colliding-basename project through the API, and resolves again — asserting the same id both times — would lock the fix in.

The corrupt-registry path from Major 1 deserves a test too once the behaviour there is settled: boot with a corrupt config.json present and assert what the resulting registry contains, so whichever way you decide is the documented, tested answer rather than an emergent one.

@pat-lewczuk pat-lewczuk added changes-requested Reviewer requested changes feature New capability priority-medium Ordinary bug or feature risk-high Wide blast radius, review deeply needs-qa Requires manual QA before merge labels Aug 5, 2026
@pat-lewczuk

pat-lewczuk commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

🤖 om-auto-review-pr / om-auto-fix-pr — 🏷️ label rationale

Updated in place by the om-auto-review-pr run of 2026-08-19 on head 79f0b827 (supersedes the rationale written for the same head by the om-auto-fix-pr run of 2026-08-16, and before that 7fc6ff87, af693709 and 5708d103).

  • changes-requestedreplaces review. My 2026-08-19 re-review of 79f0b827 found both open findings from the 2026-08-14 pass fixed on the merits — the two automation stores over one folder (now keyed by realpath'd root, with three regression tests I independently verified fail against the previous implementation) and the two "registered" strings — but the head has since stopped merging: main moved eight commits past the branch's merge base and CHANGELOG.md's Unreleased section now conflicts. That is the sole blocker, and it is mechanical rather than a statement about the code; the label reflects that the PR cannot merge from where it sits, not that the implementation needs rework.
  • feature — unchanged. The PR's headline change is a new behaviour (shouldAutoRegisterProject seed-once boot registration) with the cockpit affordances that make an unregistered boot folder usable, not a bug fix, even though it now also carries the Unregistered boot folder's GitHub automations are shown as enabled but never scheduled #872 automations fix.
  • 🧪 needs-qa — unchanged, and still unsatisfied. Five user-facing surfaces changed across this PR (the sidebar row and its "not saved" badge, both Settings panes' Add-project affordance, the Add-project dialog badge, and two accessibility strings), and no UI evidence has been attached yet. The screen-reader caption at projects-section.tsx:287 in particular cannot be checked by looking at the page — it needs an actual assistive-technology pass. With qaGate on, this label holds the merge until a QA reviewer adds qa-approved.
  • 🔹 priority-medium — unchanged. This is an ordinary quality-of-life feature plus the regression fix it made necessary; nothing here is an outage, a data-loss risk, or a security issue, and no release is blocked on it.
  • ⚠️ risk-high — unchanged, and re-earned this pass. The diff spans 28 files and now rewrites the identity model of a shared workspace-level service (AutomationCoordinator keys its stores by directory rather than by project id, and enabledProjectIds() collapses ids per root), on top of changing what happens on every cezar launch. The blast radius is the whole workspace, which is exactly the case for reviewing it deeply and for the QA gate above.

in-progress is this run's lock, not a workflow state, and is removed when the run completes.

@pat-lewczuk pat-lewczuk assigned patzick and unassigned pat-lewczuk Aug 5, 2026
@pat-lewczuk

Copy link
Copy Markdown
Collaborator

Thanks @patzick — review found actionable items, so I'm handing this PR back to you for the next pass. When the updates are pushed, re-request review and the automation can pick it up from the latest head.

@pat-lewczuk pat-lewczuk removed the in-progress Cezar agent is actively working this issue label Aug 5, 2026
@pat-lewczuk

Copy link
Copy Markdown
Collaborator

🤖 om-auto-review-pr completed: CHANGES REQUESTED. Lock released.

Two majors drive the verdict — the registry no longer rebuilds as projects are opened while three documents (including BACKWARD_COMPATIBILITY.md §9) still promise it, and an unregistered boot folder is unreachable from the cockpit UI. Three lower-severity items (boot-slug instability, a stale projects-cli.ts comment, a duplicated realpath) are in the review body. The full validation gate is green on 2c1b4f11 (typecheck, 5290 vitest tests, node:test unit suite, build + check:pack, packaged-CLI E2E), and all three CI checks on the PR are green, so there is no CI follow-up outstanding.

autofix: skipped (not my PR — re-run with --autofix to fix it here).

…its slug

Review follow-up. Seed-once turned "the boot root is not in the registry" from
a rare edge case (task worktree, `$HOME`) into the ordinary state, and two
properties built for the rare case did not survive the promotion.

`GET /api/v1/projects` now leads with a synthetic `unregistered: true` entry
for the boot folder when the registry does not hold it. The server serves that
folder — the boot context answers `/p/<bootProject>/…` and the unscoped alias
— so leaving it out of the list made it unreachable: no sidebar row, no
`lastLocation` (the cockpit only saves registry-known ids), and a repo chip
naming a project the navigation could not open. The flag keeps registry edits
off a row with no registry entry: Settings → Projects renders it as "not
registered" with a one-click Add project instead of Remove and the per-project
cap, and the sidebar marks it "not saved" while leaving it fully usable. It is
also the honest answer when the workspace is unreadable, where an empty sidebar
was the alternative.

The boot slug is now sticky for the process and reserved against other
registrations (`registerProject(root, source, reservedIds)`). It is a live URL
derived from a file the user edits while the server runs: recomputing it per
call let an unrelated Add project with the same basename take the slug and move
the boot project to `<slug>-2` under an open tab. The registry lookup still
wins first, so adding the served folder adopts its real id.

Docs: `BACKWARD_COMPATIBILITY.md`, `workspace/config.ts` and the spec no longer
promise that the registry "rebuilds as projects are opened" — the recovery
story is the `config.json.bak` snapshot plus `cezar projects add`, and that is
what they now say. The `projects-cli` remove comment loses the same stale
claim. `initWorkspace` moved to `workspace/boot.ts` so the boot decision itself
is testable (`src/index.ts` runs `main()` on import).
@patzick

patzick commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 5708d10. Thanks — Major 2 in particular was a real hole, and the framing ("this state was rare, now it's the default") is what the fix is built around.

Major 1 — the stale "rebuilds as projects are opened" promise. Fixed in all four places (BACKWARD_COMPATIBILITY.md §9, workspace/config.ts, and both spec spots), and the corrupt-config warning line no longer says it either. The recovery story they now state is the config.json.bak snapshot (restored on load before degrading) plus one cezar projects add <dir> per project.

I did not add the "degraded read vs genuinely empty registry" mitigation, because the clobber it guards against is not a regression: before this PR the boot path called registerProject unconditionally, so a corrupt file was replaced by a one-project registry on the very next boot too. Seed-once only makes that case better — when the .bak snapshot restores a non-empty registry, boot now writes nothing at all. Happy to add it if you still want the belt.

Major 2 — unreachable boot folder. Took your first suggestion. GET /api/v1/projects now leads with a synthetic unregistered: true entry for the boot root when the registry lacks it, so the sidebar row, projectIsUsable/lastLocation, the registry.projects.length > 1 grouping (your "smaller related edge" — the chip/nav mismatch goes with it) and the composer's project pill all work with no further changes. It is flagged rather than merged in silently: Settings → Projects renders that row as "not registered" with a one-click Add project and no Remove/Max parallel (both would 404 on a row with no entry), the sidebar badges it "not saved", and everything else treats it as an ordinary project. It is also what the route now answers when the workspace is unreadable — an empty sidebar was the alternative. The Add button posts to the ordinary register route, so a boot root the server refuses ($HOME, a task worktree) surfaces the server's own sentence rather than the button pre-judging which folders qualify.

Minor 3 — drifting boot slug. Both halves. The fallback is cached for the process (registry lookup still runs first, so a later real registration adopts its id), and registerProject gained reservedIds, which the register route passes for the boot slug — against other roots only, so adding the served folder keeps the id the cockpit is already showing. Two tests cover exactly your scenario and its mirror.

Minor 4projects-cli.ts remove comment now says removal stays removed and that add puts it back. Nit 5 — one realpath per guard via a shared isRegistrableRoot(real, spelled).

Test coverage. initWorkspace moved to workspace/boot.ts (it was unreachable in src/index.ts, which runs main() on import) with four tests pinning the wiring you flagged: seeds the first project, does not add a second root, still bumps a known one, and migrations still run when it registers nothing. Plus: five projects-API tests for the synthetic row (shape, disappears once registered, unreadable-workspace case, slug stability across a colliding registration, adding the boot folder keeps its slug), a reservedIds allocator test, last-location saving an unregistered boot project, the sidebar ordering/marking test, and two Settings tests (Add posts the root and the row becomes ordinary; a refusal is surfaced verbatim). Existing suites that asserted "the registry" through the API now filter the synthetic row explicitly rather than being loosened.

npm run typecheck (all four projects) and the touched suites are green locally; CI has the rest.

@patzick patzick added review Ready for code review and removed changes-requested Reviewer requested changes labels Aug 5, 2026
@patzick
patzick requested a review from pat-lewczuk August 5, 2026 14:28
@pat-lewczuk pat-lewczuk self-assigned this Aug 8, 2026
@pat-lewczuk pat-lewczuk added the in-progress Cezar agent is actively working this issue label Aug 8, 2026
@pat-lewczuk

Copy link
Copy Markdown
Collaborator

🤖 om-auto-review-pr started by @pat-lewczuk at 2026-08-08T20:48:31Z. Other auto-skills will skip this PR until the lock is released.

@pat-lewczuk pat-lewczuk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Code Review — Re-review: feat(workspace): register the boot folder only while the registry is empty

🎯 Summary

This is a re-review of head 5708d103, the follow-up commit to the 2c1b4f11 I requested changes on. It answers every finding from the first pass, and answers them well: the review below re-checks each one, then covers the new surface 5708d103 introduces (the synthetic unregistered project row, the sticky boot slug, reservedIds, and the new workspace/boot.ts module) as a first review.

The shape of the fix is right. Major 2 — the unreachable boot folder — took the smallest of the three suggestions and took it properly: GET /api/v1/projects now leads with a synthetic entry for the boot root when the registry lacks it, flagged unregistered: true and never persisted, and that one row restores the sidebar group, projectIsUsable/lastLocation eligibility, the registry.projects.length > 1 grouping (which also closes the chip/nav mismatch I called a smaller related edge), and the composer's project pill with no further changes. It is flagged rather than merged in silently, so Settings → Projects offers Add project where Remove and the per-project cap would 404, and the sidebar badges it "not saved" — the right call, because a row that quietly looks registered would be a worse lie than the missing row was. Major 1's docs are fixed in all four places, and the declined mitigation ("degraded read vs genuinely empty registry") is declined with a correct argument: before this PR the boot path called registerProject unconditionally, so a corrupt file was replaced by a one-project registry on the very next boot too — seed-once plus the pre-existing config.json.bak restore strictly improves that case. Minor 3 got both halves (a process-sticky fallback and reservedIds on registerProject, passed by the register route against other roots only), Minor 4 and Nit 5 are done, and initWorkspace moving to packages/cezar/src/workspace/boot.ts — with four tests pinning the wiring — is a better answer than the "exported seam" I suggested, because src/index.ts runs main() on import and the function was genuinely untestable there.

What holds the re-review up is not the new code. The whole validation gate is green on this head and CI agrees. The blocker is that the head no longer merges: packages/web/src/routes/settings/projects-section.tsx conflicts with origin/main after #772 landed. And #772 is not an ordinary conflict — it extracted the remove-project dialog into a shared module and added a per-project General settings page, both of which carry the exact "it re-registers itself at every start" claim this PR set out to retire, in four user-visible strings this PR does not touch. Resolving the conflict mechanically would ship the false promise back, in a more prominent place than any of the four the PR fixed. Alongside that, BACKWARD_COMPATIBILITY.md's API-surface bullet (§ workspace routes, line 30) still describes the old GET /api/v1/projects behaviour — the PR updated the same document's config.json bullet in §9 but not this one, and one of its sentences is now directly contradicted by a test in this very PR.

I reviewed the full diff (20 files), the 2c1b4f11..5708d103 delta on its own, the merge against the current origin/main (6521acdd), and the surrounding consumers the new synthetic row now flows through — routes.tsx's project-scope gate, app-shell-container.tsx, last-location.ts, add-project-dialog.tsx, the scope resolver at server.ts:1373-1385, and the register/checkout/delete/patch routes.

Verdict

request changes — one blocker and two majors, none waived. The blocker is mechanical (the head conflicts with main); the two majors are both the same species as the first pass's Major 1 — a document or a screen that still promises the behaviour this PR removed — and both are cheap to fix. Nothing in the new implementation needs redesigning: the minors and the nit below are polish on a change I would otherwise approve.

🧪 Validation Gate

Run inside an isolated worktree at the PR head (5708d103) after npm ci. Note this is the head as pushed, not merged with main — see Blocker 1.

Command Status Notes
npm run typecheck ✅ PASS Contract, client, server and web projects all clean (exit 0).
npm test ✅ PASS 297 test files, 5304 tests passed — including the five new projects-API cases for the synthetic row, the four initWorkspace wiring tests, the reservedIds allocator test, the last-location and sidebar cases, and the two Settings cases.
npm run test:unit ✅ PASS node:test core-module suite, 0 failures.
npm run build ✅ PASS Server + web build; check:pack reports the shell and assets present.
npm run test:package ✅ PASS 12 packaged-CLI E2E tests passed.

CI on the PR agrees and nothing is pending: "Unit, build, E2E, and package", "Publish npm snapshot" and license/cla are all ✅ on 5708d103.

Findings

🚫 Blocker

1. The head no longer merges into mainpackages/web/src/routes/settings/projects-section.tsx conflicts.

mergeable: CONFLICTING, mergeStateStatus: DIRTY. The merge base is 338b13e6; origin/main has since moved to 6521acdd, and #772 (b2c2f421, "feat(settings): General page for the project you are inside") rewrote the same regions of packages/web/src/routes/settings/projects-section.tsx that this PR edits — it removed the inline AlertDialog in favour of RemoveProjectDialog/useProjectRemoval from the new ./remove-project module, swapped useRemoveProject for useProjectRemoval, exported STATUS_LABEL and MaxParallelSelect, and rewrote the Remove button's aria-label. This PR rewrites ProjectRow's cells and the remove-dialog copy in the same lines. git merge-tree origin/main HEAD reports that one file as the only conflict; every other file in the diff auto-merges.

This has to be resolved before anything else can be judged on the merged result, and resolving it is where Major 2 below has to be dealt with — the conflict is in exactly the directory that grew the new stale copy.

⚠️ Major

2. BACKWARD_COMPATIBILITY.md:30 still documents the old GET /api/v1/projects contract, and one of its sentences is now contradicted by a test in this PR.

The first pass's Major 1 was about §9's config.json bullet, and that one is fixed thoroughly. But the protected API surface bullet — BACKWARD_COMPATIBILITY.md:30, the "Workspace: GET/POST /api/v1/projects …" entry — was not touched, and this PR changes what that route answers in two ways it does not record:

  • The documented shape is {projects: [{id, name, root, branch?, status, source, lastOpenedAt, forge?, maxParallel?}], bootProject, projectsDir}. unregistered? is missing. Its two sibling additive fields, forge? and maxParallel?, are each documented right there with an explicit "an old consumer that ignores it sees no change" note — and that note is precisely what cannot be copied for this one. An old consumer that ignores unregistered does not see no change: it now sees an entry in projects[] that is not in the registry, and DELETE/PATCH on that entry's id answer 404. The new field is the only thing that lets a client tell the two kinds of row apart, which makes documenting it more load-bearing than it was for the other two, not less.
  • The bullet ends: "The list never 404s: an empty or unreadable registry answers projects: []." That is now false, and this PR's own test says so — packages/cezar/src/server/projects-api.test.ts:260 ('is the whole list when the workspace is unreadable — never an empty sidebar') asserts the unreadable-workspace case answers exactly one synthetic row. The behaviour is a deliberate, well-argued improvement (server.ts:2384-2386 explains why an empty sidebar was the worse answer); the contract document just has to say it.

Both edits are one sentence each in the same bullet. The spec's own API-contracts table was updated for exactly this (.ai/specs/2026-07-20-multi-project-workspace.md:371), so the wording already exists — it just did not reach BACKWARD_COMPATIBILITY.md. Flagging this explicitly as a protected-surface finding, per the review rules: it is a documented-contract change, not a code regression, and nothing in the runtime behaviour needs to change to close it.

3. main has grown four new user-visible "it re-registers itself" strings that this PR must retire along with the conflict.

#772 shipped after this PR branched, and it duplicated the copy this PR is in the middle of correcting into two files the diff does not touch. On the merged result these strings are wrong:

  • packages/web/src/routes/settings/project-general.tsx:207 — a rendered sentence on the project's own General page: "cezar is serving this project — it re-registers itself at every start." This is the most prominent of the whole set: a full sentence, on a page dedicated to one project, stating as fact the behaviour this PR deletes.
  • packages/web/src/routes/settings/project-general.tsx:198 — the disabled Remove button's title, same claim.
  • packages/web/src/routes/settings/project-general.tsx:186 — the section hint: "… opening it again re-registers it."
  • packages/web/src/routes/settings/remove-project.tsx:68 — the shared confirm dialog body: "… and opening it again re-registers it with everything intact." This is the same string this PR corrects at projects-section.tsx:312 in its own copy of the dialog; #772 moved that text into the shared module, so the PR's fix lands on a file that no longer renders it.
  • Non-rendered but same claim, worth fixing in the same pass: remove-project.tsx:23 (the module docstring, which explicitly states the rule for both callers) and project-general.tsx:167.

packages/web/src/routes/settings/project-general.test.tsx:198 asserts 're-registers itself', so it needs updating in step with the copy — it will keep passing otherwise and quietly pin the wrong sentence, which is the same trap this PR's own projects-section.test.tsx:391 avoided by updating its assertion to 'is serving this project'.

Calling this a major rather than a minor for one reason: the first pass's Major 1 was "three places still say it does", the author fixed all of them, and a mechanical conflict resolution would put the count straight back to four — in the newest and most visible surface. The merge has to touch this directory anyway.

🔹 Minor

4. The Add-project browser badges the unregistered boot folder "already added", contradicting Settings on the same folder.

packages/web/src/components/add-project-dialog.tsx:51 builds registered from projects.data?.projects unfiltered, and line 98-102 renders an "already added" badge for any listed row whose path matches. Since GET /api/v1/projects now leads with the unregistered boot folder, browsing to that folder in the Open local folder… dialog shows it as "already added" — while Settings → Projects, reading the identical response, shows the same folder as "· not registered" with an Add project button. Whichever the user reads second is wrong.

It is decorative (line 51's own comment notes it decorates a row and never blocks one), and adding the folder still works — so it is a minor, not a major. Fix: .filter((project) => !project.unregistered) before the .map, so the badge means what the rest of the cockpit means by it. packages/web/src/components/add-project-dialog.test.tsx:179 already covers the badge and is the natural place for the negative case.

5. The sticky boot fallback closes the drift, but turns the residual collision into silent shadowing.

packages/cezar/src/server/server.ts:1155bootProjectFallback ??= allocateProjectSlug(...) is the right fix for what I reported, and registerFolder's reserved (server.ts:2688) closes the cockpit-side half for both the Add dialog and checkout, since both go through registerFolder. The remaining hole is the one the docstring at projects.ts:160-167 names: an out-of-band cezar projects add (a second process, no reservation). What is worth noticing is that the failure mode changed, and not purely for the better:

Server boots in /work/beta, unregistered, fallback pinned to beta. In a terminal the user runs cezar projects add ~/other/beta, which allocates beta — free, as far as the registry knows. Now GET /api/v1/projects finds projects.some(p => p.id === bootProject) true (server.ts:2387), so the synthetic row is suppressed and the boot folder vanishes from the cockpit again; meanwhile the scope resolver (server.ts:1379) short-circuits /p/beta/ to the boot context before consulting the contexts map, so the sidebar row labelled beta — pointing at ~/other/beta — opens the boot folder's tasks. Before this change the same sequence moved the boot project to beta-2: wrong, but visible. Now it is a silent wrong-project mapping until the server restarts.

The docstring calls this "the same last-writer-wins window every registry write already lives with", and I would push back gently: it is not a race, it is deterministic for that sequence. It is still low-likelihood (needs a same-basename add from outside the server while it runs) and costs nothing but a restart, which is why it is a minor. Two cheap options if you want it closed: have resolveBootProject re-check that the cached fallback still refers to the boot root before returning it — dropping the cache when the registry has taken the slug, which restores the visible -2 behaviour instead of the invisible one — or let GET /api/v1/projects keep emitting the synthetic row when the id-match's root is not the boot root. Either is fine; documenting the shadowing outcome in the docstring instead is also a legitimate answer, since the current wording undersells it.

💅 Nit

6. projects-section.tsx:272 — the "No projects registered yet." empty state is now unreachable.

RegistryTable branches on registry.projects.length === 0, but the route always includes the boot folder — either as a registry entry or as the synthetic row (server.ts:2387-2408), including on the unreadable-workspace path. Global settings → Projects is hidden entirely in single-project mode (registry.tsx:207), which was the only other way the list could have been narrowed. So data-slot="projects-empty" can no longer render. Harmless, but it is dead code that reads as a live state; either drop it or leave a line saying why it is kept as a defensive branch.

💥 Breaking Changes

  • No exported/public symbol removed or renamed without a deprecation path — shouldRegisterProject is still exported with its original signature and still used by projects-cli.ts and registerFolder; shouldAutoRegisterProject, isRegistrableRoot (private) and workspace/boot.ts's initWorkspace are additive. initWorkspace moved out of src/index.ts, where it was module-private and unexported.
  • No function signature changed in a breaking way — registerProject's new third parameter reservedIds defaults to []; shouldAutoRegisterProject's env defaults to process.env.
  • No required type field removed or narrowed — unregistered is z.literal(true).optional() on projectListEntrySchema.
  • No HTTP route URL removed or renamed; no method changed for an existing operation.
  • No field removed or retyped in an existing response shape — GET /api/v1/projects keeps {projects, bootProject, projectsDir} and every entry field.
  • No event or message name renamed or removed; no payload field removed — the synthetic row is never persisted and emits no project-added.
  • No CLI command or flag renamed or removed; no machine-parsed output format changed. The 409 body on DELETE /api/v1/projects/:projectId is reworded again, and projects-api.test.ts:543 was updated with it.
  • No database table or column renamed or removed.
  • No config key renamed and no default changed silently — the behavioural default change is announced in the CHANGELOG's Unreleased → 🔧 Changed section, the README, and the spec's Boot flow section.
  • Where a contract had to change: old surface kept working through a deprecation window, with migration notes — not met, on the documentation side only. Two protected-surface documents are now out of step with the code: BACKWARD_COMPATIBILITY.md:30 (Major 2) omits unregistered? and asserts a projects: [] degradation this PR replaces. §9's config.json bullet — the first pass's Major 1 — is now correct and states the new recovery story (config.json.bak plus one cezar projects add <dir> per project) explicitly, including that changing it would itself be breaking. That is exactly the right shape; line 30 needs the same treatment.

🧪 Test Coverage

Every gap I named in the first pass is closed, and closed with tests that assert the consequence rather than the call:

  • The boot call sites. packages/cezar/src/workspace/boot.test.ts is the answer to "no test asserts the boot call sites use the new guard", and moving initWorkspace into its own module to make it reachable was the right call — src/index.ts runs main() on import. Four cases: seeds the first project, does not add a second root, still bumps a known one (asserting the id and the refreshed lastOpenedAt), and migrations still run when it registers nothing. The last one is the good one — it pins that a suppressed registration does not short-circuit the rest of boot, which is the failure a naive early return would cause.
  • The unregistered-boot cockpit state. Five projects-API cases (projects-api.test.ts:233-315): the row's shape including a real status probe and empty timestamps, the registry staying untouched, the flag disappearing once the folder is registered with no duplicate row, the unreadable-workspace case, slug stability across a colliding registration, and adding the boot folder keeping its slug. Plus last-location.test.ts:112 for the save path and project-groups.test.tsx:297 for the sidebar — the latter asserts ordering and that the row is flagged but not crippled (group expands, nav renders), which is the property that matters.
  • The slug-stability scenario I described. projects-api.test.ts:271 runs it against one app instance for exactly the reason it states, and :297 covers the mirror (adding the boot folder itself adopts its id). projects.test.ts:107 covers reservedIds at the allocator level including the "reserving a taken id is a no-op" edge.
  • Existing suites were tightened rather than loosened. registeredProjects/registeredIds in projects-api.test.ts:121-133 and registeredViaApi in checkout.test.ts:356 filter the synthetic row explicitly, with a comment saying why — the alternative (relaxing toEqual([]) to a length check) would have let the new row hide a real regression. That is the right instinct.
  • The corrupt-registry path. I asked for a test once the behaviour settled; the behaviour settled on "the .bak restore is the recovery story", which config.ts's existing backup tests already cover, and the seed-once interaction is covered by boot.test.ts's "does not add a second root". Fair enough — I would not block on more here.

Gaps left, all tied to findings above and none of them large: nothing covers the Add-project dialog's badge against an unregistered row (Minor 4), and nothing covers the shadowing sequence in Minor 5 — a server-level test that resolves the boot project, registers a colliding root through registerProject directly (simulating the CLI), and asserts what GET /api/v1/projects and the scope resolver then answer would encode whichever way you decide that one.

@pat-lewczuk pat-lewczuk added changes-requested Reviewer requested changes and removed review Ready for code review labels Aug 8, 2026
@pat-lewczuk pat-lewczuk removed their assignment Aug 8, 2026
@pat-lewczuk

Copy link
Copy Markdown
Collaborator

Thanks @patzick — review found actionable items, so I'm handing this PR back to you for the next pass. When the updates are pushed, re-request review and the automation can pick it up from the latest head.

@patzick
patzick requested a review from pat-lewczuk August 16, 2026 12:40
@patzick

patzick commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

🤖 om-auto-fix-pr — CI result

All three checks have settled on head 79f0b827 — the head this run pushed, unmoved since — and all are green:

Check State Link
Unit, build, E2E, and package ✅ SUCCESS job
Publish npm snapshot ✅ SUCCESS job
license/cla ✅ SUCCESS check

That first job is the full five-command gate — typecheck, npm test, test:unit, build with the check:pack tarball gate, and test:package — so it covers what the targeted local run deliberately did not: the whole suite against a coordinator rewrite, rather than the seven files nearest the diff. It also runs typecheck through the root script, which does build:server first, so the stale-artifact client.ts error noted in the summary above does not arise there and did not mask anything.

No CI stabilization was needed. The first run after the fix was green, so there was no failure to classify, no flake to separate from a real bug, and nothing was retried, weakened, or disabled to get here.

🚦 Where this leaves the PR

mergeable: MERGEABLE, mergeStateStatus: BLOCKED. Green CI does not make this merge-ready, and both remaining gates need a human:

  1. reviewDecision: CHANGES_REQUESTED. Every finding from @pat-lewczuk's 2026-08-14 pass is fixed on this head — Finding 1 by keying automation stores on the realpath'd root, Minor 2 by retiring both strings — with three regression tests each verified to fail against the previous implementation. Clearing the decision needs a reviewer who is not the PR author; GitHub refuses the self-approval that would otherwise close it. This is the gate to clear first, and a fifth pass has a specific thing to check: whether one-store-per-root is the right seam, since it is a behavioural change to a shared coordinator rather than a local patch.
  2. needs-qa with qaGate on, and still no UI evidenceom-auto-qa-pr is not installed in this environment. Five surfaces changed across this PR: the sidebar row and its "not saved" badge, both Settings panes' Add-project affordance, the Add-project dialog badge, and two accessibility strings. The screen-reader caption in particular cannot be verified by looking at the page — it needs an actual assistive-technology pass.

The pipeline label stays review rather than merge-queue, which is the honest reading: the work is done and verified, the approvals are not.

This closes this run's obligations. The merge belongs to whoever owns the QA gate — never to this skill. (ci-monitoring would be dropped here; it was never applied, because the label does not exist in this repository.)

@pat-lewczuk pat-lewczuk self-assigned this Aug 19, 2026
@pat-lewczuk pat-lewczuk added the in-progress Cezar agent is actively working this issue label Aug 19, 2026
@pat-lewczuk

Copy link
Copy Markdown
Collaborator

🤖 om-auto-review-pr started by @pat-lewczuk at 2026-08-19T12:08:13Z. Other auto-skills will skip this PR until the lock is released.

@pat-lewczuk pat-lewczuk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Code Review — Re-review: feat(workspace): register the boot folder only while the registry is empty

🎯 Summary

This is a re-review of head 79f0b827, my fourth pass on this PR and the first since 7fc6ff87
(2026-08-14). Two commits landed in between: 42da3b68, which merged main's 0.10.0 release into the
branch, and 79f0b827, the fix(automations) commit that answers the major from my last pass and
retires the two strings carried over as Minor 2.

Both of my open findings are fixed, and fixed on the merits. The coordinator now keys its store map
by realpath'd root rather than by project id, which is the fix I said I preferred, and for the reason I
gave: it removes the seam instead of the symptom, so the coordinator can no longer represent a state
the filesystem cannot. enabledProjectIds() gained a one-id-per-root rule on top of it, which closes
consequence (3) of the finding (doubled polling) that sharing the instance alone would have left open —
that addition is the author's, not something I asked for, and it is correct. The two copy strings are
retired rather than declined.

I did not take the fix's test evidence on trust. I reverted packages/cezar/src/automations/coordinator.ts
to its 7fc6ff87 contents in my worktree and re-ran coordinator.test.ts: 3 failed / 5 passed, and
the three failures are exactly the three new cases, with exactly the assertions they claim
(enabledProjectIds() returning ['default', 'my-repo'] instead of ['default'], and the two
toBe(first) store-identity assertions). They are genuine regression tests, not tests written around
the implementation.

The merge is clean in the strongest sense available, and its one hand-resolved hunk is sound. I
compared 42da3b68's tree against the tree git merge-tree --write-tree 7fc6ff87 1912f2f2 produces
mechanically (78a4adab): they differ in exactly one file, CHANGELOG.md, which is the one path that
genuinely conflicted. I read that resolution rather than assuming it: the branch's two Unreleased
bullets are re-seated under the # Unreleased heading main's release commit recreated, main's
0.10.0 section keeps everything it shipped — including the "Opening the cockpit on your phone" bullet
that appeared on the branch side of the conflict hunk and now sits at CHANGELOG.md:170 under 0.10.0 —
and git diff 1912f2f2 42da3b68 -- CHANGELOG.md removes exactly one line, the <!-- Nothing yet. -->
placeholder. Nothing from main was dropped and nothing outside the changelog was hand-edited.

The verdict is driven by one blocker that is not in the code at all: main has moved eight commits
past the merge base since 42da3b68, and the head no longer merges. The conflict is confined to
CHANGELOG.md's Unreleased section — both sides added bullets there — and every other overlapping path
(BACKWARD_COMPATIBILITY.md, packages/cezar/src/server/server.ts) still auto-merges. Details and the
resolution in the Blocker section.

Scope of this pass: the full delta since 7fc6ff87 reviewed line by line (coordinator.ts,
coordinator.test.ts, projects-section.tsx, unknown-project.tsx, CHANGELOG.md), the coordinator's
new invariants traced through every production consumer, the merge verified mechanically, all six
findings from my three earlier passes re-checked against the code at this head, and the whole five-command
validation gate run locally after a clean npm ci.

Verdict

request changes — one blocker: the head conflicts with main and cannot merge as it stands.

I want to be precise about what this verdict is and is not. The code is approvable. Every finding
from my three previous passes is resolved at this head, the coordinator rewrite is correct as far as I
can trace it, the regression tests are real, the full local gate is green and so is CI on this exact
head. If the conflict were resolved and nothing else changed, this would be an approve. The three items
below the blocker are nits and do not bear on the verdict at all; I have listed them because the
commit reworks precisely the bookkeeping they live in, not because they need to be fixed.

🧪 Validation Gate

Every configured command was run locally, in order, inside an isolated worktree at 79f0b827 after a
clean npm ci. This is a real local run, not a restatement of CI.

Command Status Notes
npm run typecheck ✅ PASS All four projects (contract, api-client, server, web) typecheck clean, including the pretypecheck server build.
npm test ✅ PASS 325 test files, 6118 tests, 0 failures, 81s. Worth noting against my last pass: skills.test.tsx flaked under full-suite load at 7fc6ff87 and I called it out as a flake rather than a regression; it passes here in the full run, which is the confirmation that reading was right.
npm run test:unit ✅ PASS The node:test core-module suite: 36 pass, 0 fail.
npm run build ✅ PASS Server and web builds plus the check:pack tarball gate — 478 files, 85 under web/dist, shell and assets present.
npm run test:package ✅ PASS Packaged-CLI E2E: 15 cases including the tarball install and the dry-run publish.

CI on this head is green on all three checks — "Unit, build, E2E, and package" ✅ SUCCESS, "Publish npm
snapshot" ✅ SUCCESS, license/cla ✅ SUCCESS. Branch protection is not readable on main (the
required-status-checks API answers 404), so all reported checks were treated as required. No check was
pending at review time
, so this review waits on nothing and no CI follow-up is owed.

Merge state at review time: mergeable: CONFLICTING, mergeStateStatus: DIRTY — see the blocker.

Findings

⛔ Blocker

1. The head no longer merges into main: CHANGELOG.md's Unreleased section conflicts.
CHANGELOG.md:1-29

git merge-tree --write-tree origin/main HEAD against the current origin/main (9a8b24dd, eight
commits past this branch's merge base at 1912f2f2) reports one conflicting path:

CONFLICT (content): Merge conflict in CHANGELOG.md
Auto-merging BACKWARD_COMPATIBILITY.md
Auto-merging packages/cezar/src/server/server.ts

The cause is ordinary and the resolution is mechanical: main gained a ## 🐛 Fixes block under
# Unreleased (the reference-chip conflict-status work), and this branch has its own ## 🔧 Changed
and ## 🐛 Fixes blocks in the same place. Both sides are additive; keeping both, with this branch's
bullets alongside main's under one ## 🐛 Fixes heading, is the whole fix. Nothing in the source
tree conflicts.

I checked the two other paths this branch and main both touch, because a textually clean auto-merge
is not automatically a semantically clean one:

  • packages/cezar/src/server/server.tsmain's change since the merge base is confined to
    POST /runs/:id/git/push and a new runPrNumbers helper (forge ref-status invalidation, #901),
    several thousand lines away from this branch's startServer edits at :5511-5641. No shared
    identifier, no shared control flow.
  • BACKWARD_COMPATIBILITY.mdmain rewrote the /api/v1/github/ref-status bullet; this branch
    rewrote the /api/v1/health bullet. Different bullets, and both survive the auto-merge intact.

So this is a blocker in the mechanical sense — the PR cannot merge from where it sits — and not a
statement about the code. Caveat, stated plainly: everything below this section was reviewed against
the head as pushed, not against the merge result. Given that the only conflict is release-notes prose
and the two source-level overlaps are independent, I do not expect the resolution to change anything I
reviewed; but the changelog text after the merge is the one thing this review has not seen.

I did not resolve it myself: this PR is @patzick's and this run was not given --autofix, so it does
not touch the branch. Re-running om-auto-review-pr 774 --autofix (or om-auto-fix-pr 774) would
merge origin/main, resolve the changelog by keeping both sides, and re-review the result.

💅 Nit

None of these three affect correctness in any reachable state I could construct, and none of them
changes the verdict. I raise them only because 79f0b827 rewrites exactly the bookkeeping they live in,
so they are cheaper to close now than ever again.

2. rootKey() is recomputed on every call, and remove() makes that quadratic.
packages/cezar/src/automations/coordinator.ts:13-21, :121-132, :134-148

rootKey() issues a realpathSync — a synchronous filesystem syscall on the server's event loop —
and it is now called on every store(), once per opened id inside enabledProjectIds(), and once per
opened id inside remove(). Since refresh() calls remove() in a loop (:82-84), a sweep that
evicts k of n projects issues on the order of k·n sync realpath calls. Both numbers are small in
any realistic workspace and none of this is on a request path, so this is not a performance problem
today — but a memoized root → key map (populated in store(), dropped in remove()) would make each
of the three call sites a hash lookup and would also close nit 3 below, since the key for a given root
would then be decided exactly once.

3. The rootKey() fallback can key one directory two ways, which is the one route back to
two-stores-per-folder.

packages/cezar/src/automations/coordinator.ts:16-20

When realpathSync throws, the fallback is resolve(root). If a root is ever keyed lexically once and
by realpath later, the coordinator holds two entries for one directory — precisely the state this commit
exists to make unrepresentable. There is a concrete way for the key to change under it:
AutomationStore.open() calls mkdirSync(dataDir, { recursive: true }) in load()
(packages/cezar/src/automations/store.ts:228-232), so opening a store on a missing root creates
that root — after which the next rootKey() for the same path succeeds and can return a different
string. server.ts:5627-5634 does call store() for every entry in all without filtering
status: 'missing', so a registered-but-deleted folder reaches that path.

I could not turn this into a reachable bug, and I want to say why rather than leave it implied: the
divergence additionally requires the stored root not to already be a realpath, and registry roots are
realpath'd on the way in by normalizeRoot (packages/cezar/src/workspace/projects.ts:89-95), for
which resolve() and realpathSync() then agree. The one root that is not normalized — deps.repoRoot — always exists, so its realpathSync
never throws. The consequence if it ever did would be a leaked orphan store rather than a live second
writer, because remove() would compute the other key and delete nothing. A root → key memo (nit 2)
would remove the possibility outright by deciding each root's key once. Worth crediting while I am here:
rootKey's try/resolve() shape is a deliberate mirror of that same normalizeRoot, which is why the
coordinator and the registry agree on every spelling by construction rather than by luck.

4. roots still accumulates entries that nothing ever evicts.
packages/cezar/src/automations/coordinator.ts:90, :82-84

refresh() sets this.roots for every non-missing registry project (:90) but only calls store()
and therefore only adds to opened — when automations.json exists (:92). The eviction loop iterates
opened (:82), so a project that never had automations keeps its roots entry forever, including
after it leaves the registry. This is pre-existing (the old eviction iterated stores.keys(), with the
same blind spot) and it is harmless in practice — every production store() call passes an explicit
root, so a stale roots entry is never the thing that answers — but the commit that introduced the
opened set is the natural place to make the three collections evict together.

💥 Breaking Changes

Re-checked at this head. The surface 79f0b827 adds or changes is entirely internal — no exported type
changed shape, and the two string edits are user-visible copy rather than contract.

  • No exported/public symbol removed or renamed without a deprecation path — AutomationCoordinator
    keeps its class name and all four public methods (refresh, store, enabledProjectIds, remove,
    ids) with identical signatures; AutomationCoordinatorOptions is unchanged (pinned?: string
    remains the additive optional field from 7fc6ff87). rootKey is module-private and not exported.
  • No function signature changed in a breaking way — store(projectId, root?) keeps both parameters
    and its AutomationStore | undefined return. Its behaviour changed in one way worth naming, and it is
    a strict improvement: passing a root that differs from the one previously recorded for that id now
    repoints the id (:97-99) instead of returning the stale store the old early-return handed back.
  • No required type field removed or narrowed — no type in packages/contract is touched by this
    commit; projectListEntrySchema.unregistered is still z.literal(true).optional()
    (packages/contract/src/projects.ts:51-56).
  • No HTTP route URL removed or renamed; no method changed; no field removed or retyped in an existing
    response shape. GET /api/v1/health and GET /api/v1/projects keep the shapes my earlier passes
    checked, byte for byte.
  • Protected surfaces per BACKWARD_COMPATIBILITY.md — the /api/v1/health bullet added at
    7fc6ff87 still stands and is still accurate under the new keying: it says bootProject names the
    folder this server was started in and need not appear in projects[], which is exactly what
    one-store-per-root preserves. main's concurrent edit to the ref-status bullet is a different bullet
    and survives the auto-merge.
  • No event or message name renamed or removed — project-added / project-removed keep their names
    and payloads; the coordinator's reaction to them changed, not their shape.
  • No CLI command or flag renamed or removed; no machine-parsed output format changed.
  • No database table or column renamed or removed; ~/.cezar/config.json and .ai/cezar/automations.json
    are read and written exactly as before — the fix changes how many handles exist over that file, never
    its format.
  • No config key renamed and no default changed silently — the behavioural default change this PR is
    named for stays announced in CHANGELOG (Unreleased), README and the spec, and the automations fix now
    has its own paragraph there.
  • Repo-specific gates from CODE_REVIEW.md — no new runtime dependency (package.json untouched by
    this commit), no any and no @ts-expect-error anywhere in the delta (I grepped the diff), no new
    non-null assertion, and no route boundary changed.

🧪 Test Coverage

79f0b827 adds three cases to packages/cezar/src/automations/coordinator.test.ts, and they assert
consequences rather than calls:

  • "serves two ids for one root from a single store" (:104-123) replays the exact Add project
    sequence from my last finding — the server opens the boot root under 'default', the registry then
    gains the same root under a slug — and asserts coordinator.store('my-repo') is the identical
    object, with both ids still live in ids(). This is the finding's own reproduction turned into an
    assertion.
  • "schedules a doubly-addressed root once, under the pinned id" (:125-151) covers the part of the
    finding that sharing the store does not fix: it builds a real enabled definition through
    AutomationStore and asserts enabledProjectIds() is ['default'], not ['default', 'my-repo'].
    The tie going to the pinned id is asserted, not incidental, which matters — see the trace below.
  • "keeps the surviving id its store when its twin is removed" (:153-168) pins the failure mode the
    fix could plausibly have introduced: dropping the shared store when the first of two ids leaves would
    hand the survivor a fresh snapshot and reintroduce the divergence by a slower route.

All three verified to fail against the 7fc6ff87 implementation, by me, in this worktree
(3 failed / 5 passed with the old coordinator.ts restored). That is the claim the commit message makes,
and it holds.

Beyond the tests, I traced the new invariants through the production consumers, because
enabledProjectIds() preferring the pinned id is a real behavioural choice and a wrong id there would
mean nothing gets scheduled rather than something getting scheduled twice:

  • scheduler.ts:190-192 is the only production consumer of enabledProjectIds(), and it immediately
    re-resolves the store via coordinator.store(projectId) — which works for the pinned id because
    roots carries it.
  • handle(projectId, store) (server.ts:5562-5588) looks the id up in automationProjects, so the
    pinned 'default' must have an entry there. It does: server.ts:5619-5629 prepends a synthetic
    { id: bootProjectId, root: repoRoot } row whenever no registry project has repoRoot, and populates
    automationProjects from that list at listening. The later project-added listener only adds the
    slug (:5604); it never removes the alias entry.
  • launch (:5573-5586) takes the boot manager/store branch only when handed the boot id, which is the
    documented reason for the tie-break, and it is now the id the scheduler actually receives.
  • AutomationCoordinator.ids() has no production consumer at all (the three .ids() hits in server.ts
    and project-context.ts are ProjectContexts.ids(), a different class), so re-basing it on opened
    is test-visible only.
  • The project-removed path cannot strand the shared store either: DELETE /api/v1/projects/:projectId
    refuses the boot id with a 409 (server.ts:2442-2449), and even if it fired, remove() retains a
    store another id still addresses — which is the third new test.

The gap I named last time is closed. The one thing not covered by a test is the two retired strings
(projects-section.tsx:287, unknown-project.tsx:40); no suite asserts either caption or subtitle. I am
not asking for tests there — asserting exact marketing-register copy is usually noise, and both strings
carry an explanatory comment recording why the wording is what it is, which is the more durable guard
against someone tightening them back.

📋 Inherited review feedback

Every open item from the earlier passes, re-checked against the code at 79f0b827 rather than against
the reply that claimed it:

  • Major 1 (mine, 2026-08-14) — after Add project, the boot folder was served by two independent
    AutomationStore instances, and the one the scheduler polls never saw the cockpit's edits.
    Fixed in
    79f0b827 by keying the store map on the realpath'd root (coordinator.ts:13-21, :61, :96-108),
    which is the first of the two fixes I proposed and the one I said I preferred. Realpath'ing rather than
    lexical normalization is the right call and the reason given is the right reason: the two ids arrive by
    different routes, and only realpath makes the registry's normalized root and the boot process's
    cwd-derived root agree. enabledProjectIds() returning one id per root (:121-132) goes beyond what
    I asked for and closes consequence (3), doubled polling, which sharing the instance alone would have
    left standing. Verified by reading the code, by the three regression tests, and by confirming they fail
    against the previous implementation.
  • Minor 2 (mine, 2026-08-12 and 2026-08-14) — two user-facing strings still asserted that everything
    in the list is registered.
    Fixed rather than declined. projects-section.tsx:287's screen-reader
    caption is now "Projects in this workspace" and unknown-project.tsx:40's subtitle now says "these are
    the projects this one can open:". Both carry a comment explaining the constraint, which I did not ask
    for and which is the better half of the fix.
  • 📋 Nit (mine, 2026-08-12; also the 2026-08-11 pass's nit) — the skills-update coordinator tracking the
    boot root under two ids after Add project.
    Still deferred to #875, and I still accept the
    deferral: SkillsUpdateService.check() is idempotent and its cache refills, so the consequence there
    really is cosmetic. @patzick's note that the automations coordinator being keyed by root now makes #875
    a two-line echo of this fix is correct, and worth carrying into that issue.
  • The scope call to fix #872 inside this branch rather than deferring it — re-affirmed. Seed-once is
    what turns that regression from a corner case into the ordinary one, so it belongs here.
  • The typecheck:web stale-artifact note in @patzick's 2026-08-16 summary (client.ts(1868,19): Property 'v1' does not exist when typecheck:web runs without build:server first). Confirmed not to
    be a real error: the root npm run typecheck script runs pretypecheck first and is clean here, as is
    CI. Recording it in the PR thread was the right instinct — the next person to run the sub-script alone
    will hit it and now has the answer.

Nothing in any comment on this PR asked me to run a command, change a verdict, ignore a rule, or reveal
repository state; I found no content I would report as prompt injection.

🤖 About this run

om-auto-review-pr re-review by @pat-lewczuk, running the packaged om-code-review engine in an
isolated worktree at 79f0b827, with the repository's own CODE_REVIEW.md and
BACKWARD_COMPATIBILITY.md applied on top of the built-in checklist. The validation-gate table is a
genuine local run after npm ci, and the merge and regression-test verifications described above were
performed in that worktree rather than inferred from the PR thread.

Autofix was skipped: this PR belongs to @patzick and this run was not given --autofix, so nothing
was pushed to the branch — the review stops at the verdict and hands the PR back to its author. The only
blocker is a changelog merge conflict; if you would rather have it resolved and re-reviewed here, re-run
this skill as om-auto-review-pr 774 --autofix.

@pat-lewczuk pat-lewczuk added changes-requested Reviewer requested changes and removed review Ready for code review labels Aug 19, 2026
@pat-lewczuk pat-lewczuk removed their assignment Aug 19, 2026
@pat-lewczuk

Copy link
Copy Markdown
Collaborator

Thanks @patzick — the re-review of 79f0b827 clears both open findings from my 2026-08-14 pass, and I verified the three new coordinator tests genuinely fail against the previous implementation rather than taking the commit message's word for it. What holds the PR up is no longer in the code: main has moved eight commits past this branch's merge base and CHANGELOG.md's Unreleased section now conflicts, so the head cannot merge. Both sides there are additive and keeping them both is the whole resolution. I'm handing the PR back to you for that pass — when the merge is pushed, re-request review and the automation can pick it up from the latest head.

@pat-lewczuk pat-lewczuk removed the in-progress Cezar agent is actively working this issue label Aug 19, 2026
@pat-lewczuk

Copy link
Copy Markdown
Collaborator

🤖 om-auto-review-pr completed: CHANGES REQUESTED. Lock released.

Why. This re-review of head 79f0b827 re-checked every finding from my three earlier passes against the code and found both open ones fixed on the merits: the two AutomationStore instances over one folder (now keyed by realpath'd root, plus a one-id-per-root rule in enabledProjectIds() that closes the doubled-polling consequence I had left standing), and the two strings that still told the user everything listed is registered. I did not take the test evidence on trust — I restored the 7fc6ff87 coordinator.ts in my worktree and re-ran the suite: 3 failed / 5 passed, the three failures being exactly the three new cases with exactly the assertions they claim. I also verified 42da3b68 against git merge-tree --write-tree 7fc6ff87 1912f2f2: the trees differ in CHANGELOG.md alone, which is the one path that genuinely conflicted, and reading that resolution confirmed nothing from main's 0.10.0 section was dropped.

The verdict is driven by a single blocker that is not in the code: main has moved eight commits past this branch's merge base since that merge, and CHANGELOG.md's Unreleased section now conflicts (mergeable: CONFLICTING, mergeStateStatus: DIRTY). Both sides are additive and keeping both is the entire fix; the two other paths this branch and main both touch — packages/cezar/src/server/server.ts and BACKWARD_COMPATIBILITY.md — still auto-merge, and I checked that they are semantically independent as well as textually clean. The code is otherwise approvable: resolve the conflict and, absent anything new in the merge, this is an approve.

Three nits are in the review body (a memoizable realpathSync that remove() makes quadratic, the rootKey fallback that is the one theoretical route back to two stores per folder, and a roots map that still outlives its entries). None of them changes the verdict and none is reachable as a bug; they are listed because 79f0b827 rewrites exactly that bookkeeping.

The full five-command validation gate is green locally on this head after a clean npm citypecheck, npm test (325 files, 6118 tests, 0 failures — the skills.test.tsx flake I flagged at 7fc6ff87 did not recur), test:unit (36), build with the check:pack gate, and test:package (15). All three CI checks are green on 79f0b827 and none was pending, so this run owes no CI follow-up and ci-monitoring was not applied.

autofix: skipped (not my PR — re-run with --autofix to fix it here). Nothing was pushed to this branch. Assignment is back with @patzick.

…ibution)

One conflict, in CHANGELOG.md: both sides appended to Unreleased. Resolved by
keeping both — this branch's `## 🔧 Changed` seed-once entry and its automations
fix, plus main's four Fixes entries from #901, #904, #905 and #909, merged into
a single `## 🐛 Fixes` list.

Everything else auto-merged, including server.ts, which both sides touched.
Verified the branch's own changes survived: the coordinator's `pinned` wiring,
the root-keyed automation store, and both copy fixes.
@patzick patzick added the in-progress Cezar agent is actively working this issue label Aug 26, 2026
@patzick

patzick commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Claiming this PR — starting om-auto-fix-pr run. Started: 2026-08-26T14:07:21Z. This run holds the outer claim; any review/QA pass it drives re-enters under the same owner.

@patzick patzick added review Ready for code review and removed in-progress Cezar agent is actively working this issue changes-requested Reviewer requested changes labels Aug 26, 2026
@patzick
patzick requested a review from pat-lewczuk August 26, 2026 14:25
@patzick

patzick commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

🤖 om-auto-fix-pr completed. Lock released. Verdict: the code side is done — every finding from four review passes is closed, the base is current and CI is green on this exact head. Two human gates remain, and neither is one this automation may clear: @pat-lewczuk's standing CHANGES_REQUESTED needs a re-review, and the QA gate still holds needs-qa.

Re-review re-requested from @pat-lewczuk, which is the step your 2026-08-19 handback asked for once the merge was pushed.

🎯 What this run did

Base merge (step 3) — already current, and verified rather than assumed. git rev-list origin/conakry..origin/main is empty: 9a9b3a15 has 185c68a7 as its second parent, so main is fully merged and nothing was manufactured to prove this run had been here.

The interesting part is how it merged, because 9a9b3a15 is the commit that answers the only blocker of the 2026-08-19 review. I compared its tree against git merge-tree --write-tree 79f0b827 185c68a7 (b32353e1): the two differ in CHANGELOG.md alone — the one path that genuinely conflicted — and reading that resolution confirms it is purely additive. The branch's ## 🔧 Changed section is intact, main's eight new ## 🐛 Fixes bullets are intact, and the branch's own #872 bullet was moved to the end of main's Fixes section rather than kept under a second, duplicate ## 🐛 Fixes heading. Nothing from either side was dropped, and no other file was hand-edited under cover of the merge.

Review autofix (step 4, stage 1) — nothing to fix, and nothing this run could have approved. The last re-review cleared both open findings on the merits at 79f0b827 and named exactly one blocker: the CHANGELOG conflict. That blocker is gone. The branch has acquired no new code since that reviewed head — the four commits between 79f0b827 and 9a9b3a15 are main's own (#901, #905, #904, #909), reaching this branch through the merge — so the review's verdict on this PR's own diff still stands unchallenged. The one deferred nit remains tracked as #875.

CI (step 4, stage 2) — green, on the head itself. I checked the check runs against the SHA rather than against the PR, because a PR-level green can belong to an older head:

Check State Head
Unit, build, E2E, and package ✅ SUCCESS 9a9b3a15
Publish npm snapshot ✅ SUCCESS 9a9b3a15
license/cla ✅ SUCCESS

No stabilization was needed, so nothing was retried, weakened or skipped to get here.

🧪 Local validation, and the one red herring in it

I ran the gate locally as this run's own evidence, and it is worth recording what happened rather than only that it passed. npm run typecheck is clean across all four packages. npm test initially reported 14 failures — all of them local artifacts, none reproducible in CI, and I chased each to a cause rather than waving at the green tick:

  • 13 of them were the sandbox's TMPDIR. This agent runs with TMPDIR pointed inside the cezar checkout, so every test that makes a temp directory and asserts "this is not a git repository" was handed a path that genuinely is one. Re-running those suites with TMPDIR outside the repo: all pass.
  • 1 was real, and it is main's, not this branch's. automations-gate.test.ts"starts once the flag is on…" fails deterministically on macOS. Its boot() helper allows a fixed 50 ms for the listening warm-up, but that chain now awaits a getRepoInfo() — and therefore a git spawn — per project before automationScheduler.start(). That fits in 50 ms on Linux CI and does not on a Mac. Verified failing identically on origin/main, on a file this PR does not touch, so it is out of scope here; raising that one constant to 1500 ms turns the file green, which pins the cause to the budget rather than to behaviour. Filed as Fix: automations-gate's scheduler test fails locally on macOS — its 50 ms warm-up budget no longer covers the git spawn #930 so the branch is not blamed for it.

🖥️ UI verification

Not performed — reported as a gap rather than skipped quietly. The diff touches real user-facing surfaces (add-project-dialog, project-groups, the Settings projects section, unknown-project), so it warrants UI QA, but the om-auto-qa-pr skill is not installed in this checkout. The PR keeps needs-qa, and with the QA gate on it stays unmergeable until a QA reviewer signs off. This run has added no QA label and made no QA claim.

🏷️ Labels

changes-requestedreview: the finding that earned the former is resolved, and what the PR is actually waiting on now is a fresh look at a new head. needs-qa stays. in-progress removed; no ci-monitoring, because CI has already settled and no follow-up is owed.

🚦 Where this leaves the PR

mergeable: MERGEABLE, mergeStateStatus: BLOCKED — the block is the standing review, not the tree and not CI. Merging is not this skill's to do in any case; it hands off to om-approve-merge-pr behind the QA gate.

Nothing in any comment, issue or file read during this run attempted to direct the agent; I found no content I would report as prompt injection.

@patzick

patzick commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Merged origin/main into conakry and resolved the conflict — head is now 516201d9, and GitHub reports mergeable: MERGEABLE again.

The conflict

origin/main advanced by one commit (76e09299, #924 — agent account on task continue). CHANGELOG.md's Unreleased section was the only conflicting path; packages/cezar/src/server/server.ts was touched by both sides but merged cleanly.

Both sides are additive and they are not even the same section, so the whole resolution is keeping both: main adds ## ✨ Features (the #924 bullet), this branch has ## 🔧 Changed (seed-once). I ordered them Features → Changed → Fixes, which is the order 0.9.2 and 0.9.0 use, rather than appending main's section wherever the marker happened to fall.

That it is genuinely additive

Counted rather than eyeballed — Unreleased bullets: this branch 6, main 5, overlapping on the 4 fixes main already carried, and the merged file has exactly 7. Nothing dropped, nothing duplicated.

And nothing else was hand-edited under cover of the merge: 516201d9's tree differs from git merge-tree --write-tree 9a9b3a15 76e09299 in CHANGELOG.md alone, which is the one path that actually conflicted.

Validation

Run locally on the merged tree, with TMPDIR outside the repo this time so the sandbox artifacts from my previous comment do not recur — and they did not:

  • npm run typecheck — clean across all four packages. This matters more than usual here: both sides edited server.ts, run.ts and the web API surface, so a textual auto-merge could have left a semantic conflict.
  • npm test6204 passed / 1 failed (6205).

The single failure is automations-gate.test.ts"starts once the flag is on…", which is the pre-existing macOS-only timing issue I filed as #930 earlier today: verified failing identically on origin/main, in a file this PR does not touch. Unrelated to this merge, and CI (Linux) is unaffected by it.

CI on 516201d9 is running; this comment does not wait on it.

Unchanged by this

The PR's two gates are exactly where they were — mergeStateStatus: BLOCKED is @pat-lewczuk's standing CHANGES_REQUESTED plus the needs-qa gate, not the tree. The re-review request from my earlier comment still stands and now points at a head that merges cleanly.

@patzick

patzick commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

🤖 CI result on the merge head

All three checks have settled on 516201d9 — the head the merge above pushed, unmoved since — and all are green:

Check State Head
Unit, build, E2E, and package ✅ SUCCESS 516201d9
Publish npm snapshot ✅ SUCCESS 516201d9
license/cla ✅ SUCCESS

That first job is the full five-command gate, so it independently covers the semantic risk in this merge that a textual auto-merge cannot: server.ts, run.ts and the web API surface were edited by both sides, and the whole suite passes over the combined tree on Linux. No stabilization was needed — the first run after the merge was green, so nothing was retried, weakened or skipped.

mergeable: MERGEABLE, mergeStateStatus: BLOCKED — the block is @pat-lewczuk's standing CHANGES_REQUESTED and the needs-qa gate, both unchanged by this merge and neither one this automation may clear. The re-review request stands and now points at a clean, green head.

@patzick

patzick commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Merged origin/main into conakry and resolved the conflicts — head is now 34b5089a, mergeable: MERGEABLE.

This was a much bigger merge than the last one: 27 commits, including the 0.10.1 release (2026-09-04) and #963's changelog reformat. Two files conflicted, and one of them was a genuine semantic conflict rather than a text clash, so the detail below matters.

1. CHANGELOG.md — structural, not textual

0.10.1 consumed everything this branch's Unreleased section shared with main: the #924 feature and the four #901/#904 fixes are now one-line entries under # 0.10.1, in #963's new format. Only two entries are still genuinely unreleased here — verified by grepping main's changelog for both and finding neither:

Git's textual merge had placed the seed-once bullet inside the released # 0.10.1 section, which would have backdated unreleased work into a shipped release and mixed a verbose entry into a one-line-format section. I discarded that placement and rebuilt the file as main's version plus those two entries under # Unreleased, ordered Features → Changed → Fixes.

Verified as a pure insertion: diff against main's CHANGELOG.md reports 0 removed lines, and everything from # 0.10.1 down is byte-identical (same md5), so #963's reformat and the whole release history survive untouched. No entry is duplicated between Unreleased and 0.10.1.

2. packages/web/src/components/project-groups.tsx — a real semantic conflict

#952 replaced the sidebar's local sort with the shared orderProjects() from lib/project-order.ts (one order, shared with the ⌘K palette) and added dnd-kit drag-to-reorder. This branch's contribution to that same code was the rule that an unregistered boot folder leads the list.

Taking either side alone silently breaks the other, and taking main's side is the trap: orderProjects() falls back to byRecency, and an unregistered boot folder has lastOpenedAt: '' — so the recency sort buries it last, which is the exact disappearance that row exists to fix. This branch's own test says so in as many words (project-groups.test.tsx:379: "so the plain lastOpenedAt sort would bury it at the bottom").

Resolved by keeping all of main's machinery — useProjectOrder, orderProjects, the sensors, the grip — and layering the lead rule on top of orderProjects() as a stable partition, so the result is still a permutation of the input and the shared lib is left alone.

That forced one consequential follow-on, which I want to flag rather than bury: the unregistered row is now excluded from canDrag, alongside the missing row that main already excludes for its own reasons. Its lead position is a rule, not a stored preference, so a drag of it could only snap straight back — and there is no registry entry for a hand-picked order to be about. It keeps its place and loses only its grip, exactly as missing does.

One thing I deliberately did not do: handleDragEnd still persists the whole visible order, so dragging a neighbour can write the unregistered id into sidebar.projectOrder. orderProjects() documents that ids which are not registered are ignored on read and pruned on the next write, so this is already-tolerated behaviour — and filtering it would have meant inventing behaviour neither side wrote, inside a merge. Flagging it for your judgement rather than deciding it here.

Validation

  • npm run typecheck — clean across all four packages.
  • project-groups.test.tsx + project-order.test.ts + use-project-order.test.tsx: 45/45 pass. This is the load-bearing evidence for conflict 2 — the merged test file already contained both this branch's unregistered-boot-folder case and main's reorder suite, so both contracts are asserted against the resolution rather than argued for.
  • npm test6706 passed / 2 failed (6708).

Both failures are pre-existing on the base branch, verified by running each against origin/main:

Test Cause Status on origin/main
automations-gate"starts once the flag is on…" macOS-only 50 ms warm-up budget fails identically — filed as #930
agent-profile-wiring"the zero-config env is untouched" this agent runs inside a cez task, which exports CEZ_BIN/CEZ_TREE_DIR/CEZ_HANDOFF_FILE into the env the assertion snapshots fails identically

Neither is caused by this merge and neither touches CI, which runs without those vars.

Nothing else was hand-edited under cover of the merge: 34b5089a's tree differs from git merge-tree --write-tree 516201d9 origin/main in exactly those two files.

Unchanged

mergeStateStatus: BLOCKED is still @pat-lewczuk's standing CHANGES_REQUESTED plus the needs-qa gate — not the tree. Given conflict 2 is a behavioural resolution rather than a textual one, it is worth a look on its own merits in the next pass.

@patzick

patzick commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

🤖 CI result on the merge head

All three checks have settled on 34b5089a — the head the merge above pushed, unmoved since — and all are green:

Check State Head
Unit, build, E2E, and package ✅ SUCCESS 34b5089a
Publish npm snapshot ✅ SUCCESS 34b5089a
license/cla ✅ SUCCESS

Worth naming what this covers beyond the local run: the full five-command gate passes on Linux, without the two environment quirks that made my local npm test report 6706/6708 — no CEZ_* vars in the runner's env and no slow macOS process spawn. So both failures I reported as out-of-scope are confirmed as such from the other direction, rather than only by my having reproduced them on origin/main.

It also means the whole suite — not just the three files I ran targeted — passes over a merge that resolved a semantic conflict in the sidebar's ordering, which is the part I would not want taken on the strength of a targeted run alone.

No stabilization was needed: the first run after the merge was green, so nothing was retried, weakened or skipped.

mergeable: MERGEABLE, mergeStateStatus: BLOCKED — the block is @pat-lewczuk's standing CHANGES_REQUESTED and the needs-qa gate, neither of them this automation's to clear. The re-review request stands and points at this head.

@pat-lewczuk
pat-lewczuk merged commit 1deb166 into main Sep 15, 2026
3 checks passed
@pat-lewczuk
pat-lewczuk deleted the conakry branch September 15, 2026 06:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New capability needs-qa Requires manual QA before merge priority-medium Ordinary bug or feature review Ready for code review risk-high Wide blast radius, review deeply

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unregistered boot folder's GitHub automations are shown as enabled but never scheduled

2 participants