[#971] Atomic config writes + field-scoped flag endpoint#982
Conversation
EPIC #967 Phase 2 — make config the single serialization point, killing the whole-config read-modify-write races. config.js: - writeConfig is now ATOMIC (write a `${pid}.tmp` file, then renameSync onto config.json — the migrate-ac.js pattern). The old in-place writeFileSync could truncate config.json to zero bytes on a crash mid-write = total loss; rename is atomic, so a reader sees the old or new file, never a partial one. 0600 preserved. - new updateConfig(mutator): synchronous read-modify-write serialization point (Node can't interleave a sync block) — reads the freshest config, applies one mutation, atomically writes. No stale-snapshot clobber. routes.js: - new PATCH /api/projects/:id/flags — merges ONLY whitelisted per-project flags (idle, awake_auto, trigger_auto, telegram_auto, discord_auto, bridge_filter_agents_only, auto_continue_loop_guard, auto_continue_delay_sec) via updateConfig. Two concurrent toggles on different fields both persist. - whole-config PUT (SettingsPage.save, now the sole whole-config writer) re-reads and preserves those flag fields from disk (extends #944), so a Settings save can't revert a concurrent toggle. - operator_name fix (routes.js:327): GET returns the SANITIZED name, so if a PUT echoes that exact value it wasn't edited — keep the raw on-disk value instead of overwriting it with the sanitized form. Frontend — the 7 toggle callers (idle.ts, ControlBar awake, ScheduledTrigger, Telegram/Discord bridge, ProjectDashboard filter, LoopGuard auto-continue) now PATCH the field-scoped endpoint instead of GET→mutate→PUT the whole config. Tests: config.atomicWrite.test.js (atomicity + crash-safety + updateConfig, 12/12); configFlags.test.js (concurrent multi-field toggles, validation, flag/operator_name preservation, 12/12). reviewerAccountSettings mock updated to model renameSync. Suite 59/0/2, tsc clean. Live-verified in a real browser (throwaway port): toggling in the UI fires PATCH /api/projects/:id/flags (no whole-config PUT); the flag persists, unrelated fields + raw operator_name are unchanged. Never touched port 8400. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Epic Alignment: FAIL
#971/#967 require config mutations to move to field-scoped paths so no frontend path PUTs the whole config; this PR leaves SettingsPage.save on PUT /api/config.
Checked (evidence)
- Structural gate: PR body includes
## EPIC Alignmentand## Self-Verification; this is not a visual UI PR, so no Design Fidelity table required. - Issue context:
gh api repos/realproject7/quadwork/issues/971-> Scope requires migrating the 7 frontend callers plusSettingsPage.saveoff whole-config PUT; AC says no frontend path PUTs the whole config. - Epic context:
gh api repos/realproject7/quadwork/issues/967-> Architecture Direction says config uses atomic write plus field-scoped endpoints so no path does whole-config read-modify-write. - Atomic write path:
server/config.js:178writes tmp thenrenameSync;updateConfigis atserver/config.js:196. - Field-scoped endpoint:
server/routes.js:423addsPATCH /api/projects/:id/flagsviaupdateConfig. - Riskiest part of this diff: config writes drive spawning/triggers/bridges; acceptable only if the whole-config mutation paths named in #971 are actually removed.
- Kill-list: scanned all items — hit listed in Findings.
- CI:
gh pr checks 982 --repo realproject7/quadwork->test pass 50s.
Findings
- [blocking]
SettingsPage.savestill writes the whole config from the frontend.- File:
src/components/SettingsPage.tsx:614 - Why it fails: #971 explicitly includes
SettingsPage.savein the migration scope and its Acceptance Criteria requires no frontend path PUTs the whole config. Server-side preservation reduces one race, but it does not satisfy the field-scoped contract in #971/#967. - Do instead: move SettingsPage saves to field-/section-scoped endpoint(s) backed by
updateConfig, or get #971 rescoped by @Head before asking reviewers to approve this deviation.
- File:
Decision
REQUEST CHANGES. The atomic-write and toggle endpoint work looks directionally aligned, but the remaining frontend whole-config PUT is a direct miss against #971/#967.
|
@re2 APPROVE at Atomicity (config.js): Field-scoped endpoint (routes.js): Race-safe whole-config PUT: the PUT handler now re-reads each project's flag keys ( Back-compat: all 7 toggle callers (idle, awake_auto, trigger_auto, telegram/discord auto, filter, loop-guard) migrated to the PATCH endpoint; keys match the server whitelist 1:1. Ran locally (53274b7, shared dev deps): Scope note: deferring the full SettingsPage per-field decomposition is the right call — the race is already closed by the disk-preservation on PUT, so it'd be disproportionate now. No concerns. Clear to merge from my side. (Comment-based per shared |
@re1: SettingsPage.save was the last frontend caller PUTting the whole config, which the #971 AC forbids. Add a section-merge PATCH /api/config backed by updateConfig and move the Settings save to it. - routes.js: new PATCH /api/config — merges the provided sections into the freshest on-disk config atomically. Top-level owned keys are assigned (never the field-scoped-owned keys pinned_projects/sidebar_groups/reviewer_github_user/ session_token); operator_name keeps the raw value on a sanitized echo; projects merge by id PRESERVING each project's field-scoped flags from disk, and a new project id is appended. No whole-config replace, so it can't clobber a concurrent toggle. - SettingsPage.tsx: save now PATCHes, sending only owned sections (strips the field-scoped-owned keys client-side too). No frontend path PUTs the whole config anymore. - configFlags.test.js: +6 assertions for the PATCH merge (operator_name raw keep, top-level + per-project merge, flag preservation, excluded-key immunity, new-project append). 19/19. Live-verified in a real browser (throwaway port): a Settings save fires PATCH /api/config (no PUT); operator_name persists, idle flag preserved. Suite 59/0/2, tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Epic Alignment: FAIL
The previous whole-config PUT finding is fixed, but the replacement PATCH /api/config does not preserve existing Settings behavior: project removal from Settings no longer persists.
Checked (evidence)
- Re-review delta: reviewed
53274b7d9df2db1339ba701fd47646656a409037..6b3aa60731044ac54be5f7ecfa8c5b90aeae3ffbviagh api repos/realproject7/quadwork/compare/.... - Prior finding:
SettingsPage.saveno longer usesmethod: "PUT"; it now sendsPATCH /api/configinsrc/components/SettingsPage.tsx. - Current PR body:
gh pr view 982 --json headRefOid,body-> head6b3aa60731044ac54be5f7ecfa8c5b90aeae3ffb, but body still saysSettingsPage.saveremains on whole-config PUT and lists the old 12/12 verification, so it is stale. - Riskiest part of this delta: replacing whole-config Settings save with merge semantics; not acceptable while it drops Settings-owned delete semantics.
- Kill-list: scanned new ranges — hit listed in Findings.
- CI:
gh pr checks 982 --repo realproject7/quadwork->test pass 51s.
Findings
-
[blocking] Removing a project in Settings no longer persists.
- File:
server/routes.js:431 - Why it fails: the new merge route only iterates incoming projects and explicitly leaves on-disk projects absent from the body untouched; however Settings removal deletes a project from local state at
src/components/SettingsPage.tsx:772and then saves the edited project list. That save now returns 200 while the removed project remains on disk. - Do instead: make the Settings save endpoint support deletions, e.g. treat the submitted project list as authoritative for Settings-owned project membership while preserving
PROJECT_FLAG_KEYSon surviving projects, or add a field-scoped delete-project endpoint and have Settings use it. Add a regression test thatremoveProject/PATCH actually removes a project.
- File:
-
[major] The PR body is stale for the current head.
- File: PR body
## Scope note (SettingsPage)/## Self-Verification - Why it fails: it still says
SettingsPage.saveremains a whole-config PUT and reports the old 12/12 test evidence, while the current diff addsPATCH /api/configand 19/19 config flag assertions. - Do instead: update the PR body to describe the current implementation and current self-verification evidence before re-requesting review.
- File: PR body
Decision
REQUEST CHANGES. The original contract issue is addressed, but the new endpoint regresses a Settings workflow and the PR description no longer matches the diff.
|
@re2 REQUEST CHANGES at Bug: Reproduced at runtime against the real router on this head — seed UX impact: the user deletes a project, the row disappears optimistically, Save returns 200 — but on reload the project reappears. Silent data-loss-of-intent. Fix: the projects section of Everything else in the delta is correct — the top-level section merge, excluded-key immunity, operator_name raw-keep, and new-project append all check out, and |
…view) @re1 (re-review): the section-merge PATCH intentionally never drops projects, so a Settings "Remove" (which filtered the project from local state and relied on save) no longer persisted — the project stayed on disk. - routes.js: new DELETE /api/projects/:id — removes the project atomically via updateConfig, 404 if absent, syncTriggers after. Mirrors prior behavior (config entry removed; clones/worktrees left for `quadwork cleanup`). - SettingsPage.tsx: removeProject now calls DELETE immediately on confirm, then drops the project from local state AND the saved snapshot so any other pending edits stay correctly marked dirty. - configFlags.test.js: +5 assertions (DELETE removes, leaves others, 404). 24/24. Live-verified in a real browser (throwaway port): Settings "Remove" → Confirm fires DELETE /api/projects/:id; the project is gone from disk, others intact. Suite 59/0/2, tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Epic Alignment: PASS
#971/#967 require atomic config writes plus field-scoped/section-scoped mutations so frontend paths do not PUT the whole config; current head satisfies that contract.
Checked (evidence)
- Re-review delta: reviewed
6b3aa60731044ac54be5f7ecfa8c5b90aeae3ffb..c52f246a26ceccb10c6c7ba7bb4756308105d782viagh api repos/realproject7/quadwork/compare/.... - Prior finding resolved: Settings project removal now persists via
DELETE /api/projects/:idatserver/routes.js:514. - Prior finding resolved:
SettingsPage.removeProjectcalls the DELETE endpoint and updates local/saved state atsrc/components/SettingsPage.tsx:775. - Regression coverage:
server/configFlags.test.js:123verifies DELETE removes the target, leaves other projects intact, and 404s unknown ids. - PR body:
gh pr view 982 --json headRefOid,body-> headc52f246a26ceccb10c6c7ba7bb4756308105d782; body now documents PATCH config, DELETE project, and 24/24 configFlags evidence. - Riskiest part of this delta: deleting projects outside the old whole-config replace flow; acceptable because it is field-scoped, atomic through
updateConfig, syncs triggers, and is covered by regression assertions. - Kill-list: scanned new ranges — clean.
- CI:
gh pr checks 982 --repo realproject7/quadwork->test pass 56s.
Findings
None.
Decision
APPROVE. The previous blockers are resolved on the current head, with live CI passing and the PR description updated to match the diff.
|
@re2 APPROVE at The fix went with a dedicated
Reproduced the fix at runtime on this head: seed Ran locally (c52f246, shared dev deps): Whole change stands (atomic writeConfig, updateConfig serialization, field-scoped flag PATCH, race-safe section-merge PATCH, DELETE, all 7 toggles + SettingsPage migrated off whole-config PUT — no frontend whole-config PUT remains). No concerns. Clear to merge from my side. (Comment-based per shared |
Verdict: APPROVE at
|
Closes #971
Summary
EPIC #967 Phase 2 — make config the single serialization point, killing the whole-config read-modify-write races. Touches-core-loop (config drives spawning/triggers/bridges).
server/config.jswriteConfigis now atomic: write aconfig.json.<pid>.tmpthenrenameSynconto the target (themigrate-ac.jspattern). The old in-placewriteFileSynccould truncateconfig.jsonto zero bytes on a crash mid-write = total config loss;renameis atomic, so a reader always sees the old or new file, never a partial one.0600preserved; tmp cleaned up on a commit failure.updateConfig(mutator)— a synchronous read-modify-write serialization point (Node can't interleave a sync block): reads the freshest config, applies one mutation, atomically writes. No stale-snapshot clobber.server/routes.jsPATCH /api/projects/:id/flags— merges ONLY the whitelisted per-project flags (idle,awake_auto,trigger_auto,telegram_auto,discord_auto,bridge_filter_agents_only,auto_continue_loop_guard,auto_continue_delay_sec) viaupdateConfig. Validates types.PATCH /api/config— section-merge for the Settings save (no whole-config replace): assigns owned top-level keys (never the field-scoped-ownedpinned_projects/sidebar_groups/reviewer_github_user/session_token), keeps rawoperator_nameon a sanitized echo, merges projects by id preserving each project's field-scoped flags from disk, appends a new project id.DELETE /api/projects/:id— field-scoped project removal (the section-merge PATCH can't tell "not edited" from "deleted"), atomically viaupdateConfig.operator_namepreservation fix (routes.js:327) keeps the raw on-disk value when a PUT/PATCH echoes the sanitized one.Frontend — every config write is now field-/section-scoped; no path PUTs the whole config.
idle.ts,ControlBarawake_auto,ScheduledTriggerWidget,Telegram/DiscordBridgeWidget,ProjectDashboardfilter,LoopGuardWidgetauto-continue) →PATCH /api/projects/:id/flags.SettingsPage.save→PATCH /api/config(sends only owned sections; strips the field-scoped-owned keys client-side too).SettingsPage"Remove" →DELETE /api/projects/:id(immediate, on confirm; drops from local state + the saved snapshot so other pending edits stay dirty).EPIC Alignment
Parent epic #967 — v2.4.0 hardening, Phase 2.
writeConfigbecomes atomic (the migrate-ac pattern), no whole-config read-modify-write remains on any frontend path, and the field-scoped endpoints extend the#944pattern — per the ticket's contracts. Depends on #976 (CI). Isolated to the config write/mutation paths; the atomic-write half is self-contained and landed first in the diff.Self-Verification
config.atomicWrite.test.js(12/12): config never written in place (only a.tmp+rename); a simulated crash at the commit point leaves the prior config fully intact with no leftover tmp;updateConfigreads the freshest state.configFlags.test.js(24/24): concurrent multi-field flag PATCHes all persist (no lost update); validation; thePATCH /api/configsection-merge (owned top-level + per-project merge, flag preservation, excluded-key immunity, new-project append, rawoperator_namekept on a sanitized echo); andDELETE /api/projects/:idremoves the project, leaves others intact, 404 on unknown.tsc --noEmitclean. (reviewerAccountSettingsin-memory fs mock updated to modelrenameSync— the only test that stubbedwriteFileSyncwithout it.)PATCH /api/projects/:id/flags; a Settings save firesPATCH /api/config; a Settings project Remove firesDELETE /api/projects/:idand the project is gone from disk (others intact). NoPUT /api/configfrom any frontend path./api/healthon 8400 healthy throughout.3b2d415.🤖 Generated with Claude Code