@rubytech/create-sitedesk-code 0.1.508 → 0.1.509

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/index.js +53 -0
  2. package/package.json +1 -1
  3. package/payload/platform/docs/superpowers/plans/2026-07-26-task-1926-portal-push-concurrency-guard.md +482 -0
  4. package/payload/platform/docs/superpowers/specs/2026-07-26-task-1926-portal-push-concurrency-guard-design.md +200 -0
  5. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +11 -6
  6. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
  7. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +68 -28
  8. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +271 -29
  9. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +5 -4
  10. package/payload/platform/plugins/docs/references/admin-ui.md +10 -5
  11. package/payload/platform/scripts/__tests__/check-brand-cache.test.sh +231 -0
  12. package/payload/platform/scripts/check-brand-cache.sh +138 -0
  13. package/payload/server/maxy-edge.js +15 -10
  14. package/payload/server/public/activity.html +5 -5
  15. package/payload/server/public/agents.html +4 -4
  16. package/payload/server/public/assets/{AdminLoginScreens-DKeCt1uf.js → AdminLoginScreens-8Jk7hnWH.js} +1 -1
  17. package/payload/server/public/assets/{AdminShell-HeELCCjJ.js → AdminShell-BMPEWNZk.js} +1 -1
  18. package/payload/server/public/assets/{activity-BEIDAxDA.js → activity-ByWYF5dC.js} +1 -1
  19. package/payload/server/public/assets/admin-CnUWbKcx.js +1 -0
  20. package/payload/server/public/assets/agents-C0rTfrYd.js +1 -0
  21. package/payload/server/public/assets/{browser-GkVTGqlb.js → browser-4tTaem8N.js} +1 -1
  22. package/payload/server/public/assets/{calendar-CSqJeFEg.js → calendar-BEjnkKG1.js} +1 -1
  23. package/payload/server/public/assets/chat-DBgzjDIE.js +1 -0
  24. package/payload/server/public/assets/chevron-left-DqYv3oFh.js +1 -0
  25. package/payload/server/public/assets/chevron-right-CQfPGsFb.js +1 -0
  26. package/payload/server/public/assets/clock-Dn6FHB51.js +1 -0
  27. package/payload/server/public/assets/data-D_e6Vdfd.js +1 -0
  28. package/payload/server/public/assets/{file-text-DiscM2pP.js → file-text-DMKw3nIk.js} +1 -1
  29. package/payload/server/public/assets/{graph-CRP5DM8C.js → graph-C3vW8lvw.js} +1 -1
  30. package/payload/server/public/assets/{graph-labels-Du-6KoBE.js → graph-labels-BZgd0L6n.js} +1 -1
  31. package/payload/server/public/assets/{maximize-2-D-H_uIV-.js → maximize-2-la3IBA3M.js} +1 -1
  32. package/payload/server/public/assets/{operator-D5rA76YY.js → operator-RRRdqoRQ.js} +1 -1
  33. package/payload/server/public/assets/{page-CHK1415C.js → page-BWFHRIAH.js} +1 -1
  34. package/payload/server/public/assets/{page-DZE3H_n4.js → page-DC11gesX.js} +1 -1
  35. package/payload/server/public/assets/{public-DFHXVkdD.js → public-DQbbLHQN.js} +1 -1
  36. package/payload/server/public/assets/{rotate-ccw-CesEJsuO.js → rotate-ccw-OQD5si8N.js} +1 -1
  37. package/payload/server/public/assets/{routines-DYMYY_r8.js → routines-asrBGZUR.js} +1 -1
  38. package/payload/server/public/assets/{skills-67jtJ16D.js → skills-D4ECwoxa.js} +1 -1
  39. package/payload/server/public/assets/{tasks-D2C2a-uk.js → tasks-U3QdbUsO.js} +1 -1
  40. package/payload/server/public/assets/{time-entry-format-BixTGQAd.js → time-entry-format-BgdTKgYr.js} +1 -1
  41. package/payload/server/public/assets/{triangle-alert-Co9RWb9q.js → triangle-alert-Ck_3VhT9.js} +1 -1
  42. package/payload/server/public/assets/{useCopyFeedback-7zwf9_w4.js → useCopyFeedback-BdUwyNpa.js} +1 -1
  43. package/payload/server/public/assets/{useSubAccountSwitcher-DE4v5_Fz.css → useSubAccountSwitcher-DOWQXDT8.css} +1 -1
  44. package/payload/server/public/assets/{useVoiceRecorder-CZDDiwMD.js → useVoiceRecorder-ChnBrzbZ.js} +1 -1
  45. package/payload/server/public/assets/{wrench-CjQqdj4P.js → wrench-Cpee3C3J.js} +1 -1
  46. package/payload/server/public/browser.html +4 -4
  47. package/payload/server/public/calendar.html +7 -7
  48. package/payload/server/public/chat.html +13 -13
  49. package/payload/server/public/data.html +11 -11
  50. package/payload/server/public/graph.html +9 -9
  51. package/payload/server/public/index.html +14 -14
  52. package/payload/server/public/operator.html +14 -14
  53. package/payload/server/public/public.html +13 -13
  54. package/payload/server/public/routines.html +6 -6
  55. package/payload/server/public/skills.html +5 -5
  56. package/payload/server/public/tasks.html +6 -6
  57. package/payload/server/server.js +364 -283
  58. package/payload/server/public/assets/admin-C0-WU1sf.js +0 -1
  59. package/payload/server/public/assets/agents-DjUvMFTj.js +0 -1
  60. package/payload/server/public/assets/chat-C0Ps84U5.js +0 -1
  61. package/payload/server/public/assets/chevron-left-DQ2uNlu9.js +0 -1
  62. package/payload/server/public/assets/chevron-right-C4QCiRGD.js +0 -1
  63. package/payload/server/public/assets/clock-BEeQ7JxX.js +0 -1
  64. package/payload/server/public/assets/data-CGuNfym7.js +0 -1
  65. /package/payload/server/public/assets/{useSubAccountSwitcher-Bg72hdQ8.js → useSubAccountSwitcher-CJKjEPNN.js} +0 -0
@@ -0,0 +1,200 @@
1
+ # Task 1926 — same-account portal push concurrency guard (design)
2
+
3
+ **Date:** 2026-07-26
4
+ **Task:** [`.tasks/1926-concurrent-same-account-portal-push-lets-the-pre-stage-clear-delete-the-live-tree.md`](../../../../.tasks/1926-concurrent-same-account-portal-push-lets-the-pre-stage-clear-delete-the-live-tree.md)
5
+ **Touches:** `platform/plugins/cloudflare/bin/portal-index-push.mjs`, `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts`
6
+
7
+ ## Problem
8
+
9
+ `pushAccount` stages an account's file index at `next = currentGeneration + 1`, flips
10
+ the `directory_state` pointer to `next`, then sweeps every other generation. Task 1923
11
+ added a pre-stage `DELETE FROM directory WHERE accountId = ? AND generation = ?` bound
12
+ to `next`, so a stage that died mid-batch could self-heal instead of colliding forever
13
+ on `UNIQUE (accountId, relPath, generation)`.
14
+
15
+ Two pushes of one account break that. The second writer reads `current = 5` before the
16
+ first writer's flip, computes `next = 6`, and by the time its pre-stage DELETE runs the
17
+ pointer already names generation 6. The DELETE removes the live tree. A client listing
18
+ in the gap between that DELETE and the re-insert sees an empty portal.
19
+
20
+ The overlap is reachable: the admin server's 60s loop calls `pushAllAccounts`, and a
21
+ hand-run `node portal-index-push.mjs --account <id>` calls the same `pushAccount` from a
22
+ separate OS process.
23
+
24
+ ## What success looks like
25
+
26
+ Two concurrent pushes of one account never leave a reader seeing an empty or partial
27
+ tree, and never delete rows at the pointer's generation.
28
+
29
+ ## Why the three candidate approaches in the task file were rejected
30
+
31
+ **A per-account advisory lock.** The two racers are separate OS processes. A JavaScript
32
+ mutex inside the admin server does not exist in the CLI process, so it guards nothing.
33
+ A lock that does work across processes has to live in D1, which means new columns, a
34
+ lease, and a lease expiry. A lease expiry reintroduces the wedge class Task 1923 just
35
+ removed: a holder that dies wedges the account until the lease runs out, and a push
36
+ slower than its lease keeps writing after another writer has taken over.
37
+
38
+ **`next` from `MAX(generation)` alone.** This stops two writers sharing a staging
39
+ generation, but the slower writer still flips the pointer down onto its own generation
40
+ after the faster writer has already swept it. The reader still sees an empty tree, so
41
+ the success criterion is not met.
42
+
43
+ **Scoping the clear to `generation = next AND generation != current`.** `current` is a
44
+ value read at the top of the push. By the time the DELETE runs, the pointer has moved.
45
+ Re-reading the pointer immediately before the DELETE narrows the window without closing
46
+ it, because the peer can flip between the re-read and the DELETE.
47
+
48
+ ## Design
49
+
50
+ Three changes to `pushAccount`. No lock, no lease, no schema change, no new table.
51
+
52
+ ### 1. Claim a generation above everything that exists
53
+
54
+ Keep the existing pointer read, and add one statement beside it:
55
+
56
+ ```sql
57
+ SELECT MAX(generation) AS g FROM directory WHERE accountId = ?
58
+ ```
59
+
60
+ `next = Math.max(current, maxStagedGeneration) + 1`.
61
+
62
+ A peer that has already inserted at least one row is visible in that MAX, so the two
63
+ writers claim different generations and neither stages into the other's rows.
64
+
65
+ Two statements rather than one nested `UNION ALL`: each is trivially readable, each is
66
+ trivially fakeable in a test, and the pointer read stays the push's first statement,
67
+ which an existing test asserts.
68
+
69
+ ### 2. The flip only ever moves the pointer forward
70
+
71
+ ```sql
72
+ INSERT INTO directory_state (accountId, currentGeneration) VALUES (?, ?)
73
+ ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration
74
+ WHERE excluded.currentGeneration > directory_state.currentGeneration
75
+ ```
76
+
77
+ SQLite has supported a `WHERE` clause on upsert's `DO UPDATE` since 3.24, and D1 is
78
+ SQLite. This is what makes a losing writer harmless: it cannot drag readers back onto a
79
+ generation the winner has already swept.
80
+
81
+ The D1 client returns rows, not affected-row meta, so the flip is followed by a pointer
82
+ readback. That readback is the branch point:
83
+
84
+ - **Pointer equals `next`.** We won. Log `op=flip`, sweep, verify, return. Unchanged
85
+ behaviour.
86
+ - **Pointer is above `next`.** A peer won. Log
87
+ `op=stage-contended account=<id> generation=<next> pointer=<actual> action=superseded`
88
+ and return without sweeping and without verifying. Both of those would measure the
89
+ peer's tree against our expected row count, and `op=verify` would read as a failure
90
+ when nothing failed.
91
+
92
+ Our staged rows at the losing generation are left in place. They are invisible (no
93
+ reader sees a generation the pointer does not name) and the winner's next sweep collects
94
+ them.
95
+
96
+ ### 3. The sweep only ever removes older generations
97
+
98
+ ```sql
99
+ DELETE FROM directory WHERE accountId = ? AND generation < ?
100
+ ```
101
+
102
+ `< next` rather than `!= next`, so a writer never deletes a peer's higher in-flight
103
+ stage. Rows above `next` from an abandoned stage still converge: the following cycle
104
+ claims a generation above them and sweeps them once it flips.
105
+
106
+ ### 4. The Task 1923 pre-stage clear is removed
107
+
108
+ Because `next` is now always above every existing generation for the account, the orphan
109
+ `SELECT COUNT(*) ... generation = next` can only ever return 0 and the pre-stage DELETE
110
+ can never match a row. Leaving unreachable code whose comment claims it is the wedge
111
+ heal would be false narration, so the orphan count, the DELETE, the `op=stage-clear` log
112
+ line and its two tests go.
113
+
114
+ Task 1923's outcome is preserved by construction and is stronger than before. A stage
115
+ that died mid-batch leaves rows at generation 6; the next run claims 7, so there is no
116
+ UNIQUE collision to freeze on, and the gen-6 orphans are swept after the flip. The heal
117
+ now happens with no DELETE before the flip at all, which is exactly what removes this
118
+ task's hazard rather than narrowing it.
119
+
120
+ This was confirmed with the operator before the spec was written, because Task 1926
121
+ lists Task 1923's heal as out of scope.
122
+
123
+ ## Cases covered, and the one left fail-closed
124
+
125
+ **Peer reads the generation after we have staged a row.** It claims a higher generation.
126
+ Both pushes complete. The forward-only flip picks the winner. The reader sees the old
127
+ complete tree, then one complete new tree. Neither push fails.
128
+
129
+ **Both read the generation before either inserts.** Both claim the same generation. The
130
+ second writer's first INSERT hits `UNIQUE (accountId, relPath, generation)` and that push
131
+ throws. Both walks resolve the same exposed set and `walkExposed` emits the exposed dirs
132
+ in the same order, so the second writer collides on its first row and contributes
133
+ nothing. The reader is safe: the winner's tree is complete and the pointer never moved
134
+ backward. The loser logs `op=failed` and the next 60s cycle succeeds.
135
+
136
+ This second case is left fail-closed rather than retried. The task's success criterion is
137
+ about what a reader can see, and a loud failure that self-corrects one cycle later meets
138
+ it. A retry-on-collision path would be an invented requirement.
139
+
140
+ **The one residual, stated rather than fixed.** The claim above relies on both walks
141
+ starting with the same row. If the account's `SCHEMA.md` or `data-portal.json` changes in
142
+ the window between the two generation reads, the two walks can begin with different rows,
143
+ so the second writer contributes some non-overlapping rows at the shared generation before
144
+ colliding on a shared one. The winner then flips onto its own complete tree plus those few
145
+ extra rows. That is not an empty tree and not a partial one, so it does not breach the
146
+ success criterion, and the next cycle's sweep removes the whole generation. No task file is
147
+ filed for it because nothing is deferred: the case is inside what this design covers, and
148
+ its outcome is a stale row for one cycle rather than a reader-visible break.
149
+
150
+ ## Observability
151
+
152
+ `op=stage-contended account=<id> generation=<next> pointer=<actual> action=superseded`
153
+ is emitted when a push's flip was superseded by a peer. This deviates from the task
154
+ file's `action=<waited|skipped>` wording: the design never waits and never skips work, so
155
+ neither value describes what happened. `superseded` names the actual outcome, and
156
+ `pointer=` names the generation that won, which is what an operator needs to correlate
157
+ the two runs.
158
+
159
+ `op=stage-clear` is retired with the code that emitted it. The task file's line that a
160
+ `stage-clear` with `cleared=` equal to the live tree size must never appear is satisfied
161
+ absolutely: no `stage-clear` line can appear at all.
162
+
163
+ ## Testing
164
+
165
+ Both new tests drive two interleaved `pushAccount` calls against one shared fake store
166
+ and snapshot the reader's view after every statement. The assertion is the success
167
+ criterion stated directly: the visible tree is never empty and never a strict subset of
168
+ a complete tree.
169
+
170
+ 1. **The task file's interleave.** Writer B reads the generation, then pauses. Writer A
171
+ runs to completion. B resumes. Red on current code, because B's pre-stage DELETE binds
172
+ the generation the pointer now names and the snapshot goes empty. Green after, because
173
+ B collides on UNIQUE and A's tree is untouched. This test also asserts that no DELETE
174
+ is ever issued naming the pointer's current generation, which is the other half of the
175
+ success criterion.
176
+
177
+ 2. **The higher-claim interleave.** Writer A stages fully but pauses before its flip.
178
+ Writer B reads the generation, sees A's staged rows in the MAX, claims the next one up,
179
+ and completes. A resumes and its flip is a no-op. Asserts the pointer never moves
180
+ backward, that A emits `op=stage-contended` with `action=superseded`, and that A
181
+ issues no sweep.
182
+
183
+ The existing 27 tests stay green apart from the two Task 1923 stage-clear tests, which
184
+ are removed with the code they cover.
185
+
186
+ ## Scope boundaries
187
+
188
+ **In scope:** the generation claim, the forward-only flip, the backward-only sweep, the
189
+ removal of the pre-stage clear, and the two-writer regression tests.
190
+
191
+ **Out of scope:** INSERT batching (Task 1842). Cross-account contention (separate
192
+ generation counters, no contention). Any change to `runTargets`, `recordSuccess`, or the
193
+ portal reader in `files.ts`.
194
+
195
+ ## Deployment
196
+
197
+ `portal-index-push.mjs` ships inside the installer payload and is inlined into the admin
198
+ server bundle. No D1 migration is required: every statement runs against the existing
199
+ `directory` and `directory_state` tables as defined in
200
+ `platform/plugins/cloudflare/skills/data-portal/schema.sql`.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what SiteDesk ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:b15d0e491d957f75523ecf7056f8004bafcdf448767c0c3dd64b40a73926b58b
4
+ content-hash: sha256:aabbb6dbe5d9f4fe8ff6f7b3e2f06a46bd1c5281ea46b820829ab2b3bc93a0c0
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -3189,8 +3189,8 @@ explicit `?plugin=` parameter, not inferred from `--` in the slug.
3189
3189
 
3190
3190
  | Route | Behaviour |
3191
3191
  |-------|-----------|
3192
- | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `worstTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
3193
- | `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, worstTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
3192
+ | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `riskiestTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
3193
+ | `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, riskiestTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
3194
3194
  | `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. Same `accountId` contract as delete. |
3195
3195
  | `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. |
3196
3196
  | `DELETE /api/admin/agents/:slug?accountId=` | Public agents only, on the named validated account. Removes the dir after `deleteAgentProjection`; refuses the `admin` slug (403) and a missing/unknown `accountId` (400) with no write. Loud-fail: a graph-cleanup throw aborts the file removal. |
@@ -3220,10 +3220,15 @@ Agents page survived undiagnosed on a multi-account install.
3220
3220
  `reason=<graph-cleanup-failed|rm-error>` on failure (files preserved on a
3221
3221
  graph-cleanup throw).
3222
3222
  - `op=classify account=<id8> agent=<name> origin=<shipped|public|specialist>
3223
- risk=<green|amber|red> worstTool=<tool> tools=<n> unresolved=<comma-list|none>
3223
+ risk=<green|amber|red> riskiestTool=<tool> tools=<n> unresolved=<comma-list|none>
3224
3224
  disabled=<bool>` — one line per classified agent per listing, so it fires on
3225
- every page load. `worstTool` makes a wrong class diagnosable without
3226
- re-deriving it. **A non-empty `unresolved=` is the one to act on**: the agent
3225
+ every page load. `riskiestTool` makes a wrong class diagnosable without
3226
+ re-deriving it. Its value is the FULL canonical tool name, while the card
3227
+ renders only the segment after the plugin prefix. One load
3228
+ showing `riskiest tool: memory-update` on the card and
3229
+ `riskiestTool=mcp__plugin_memory_memory__memory-update` on this line is the
3230
+ confirmation that both derive from one value.
3231
+ **A non-empty `unresolved=` is the one to act on**: the agent
3227
3232
  is forced red, and the cause is that the built-in table or the registry
3228
3233
  mapping has drifted, not that the agent changed.
3229
3234
  - `op=risk-surface status=load-failed reason=<msg>` — the plugin registry did
@@ -9,6 +9,12 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-07-26 (0.1.509)
13
+
14
+ - The Agents page now names the tool that set an agent's risk level, and shows it by its short name instead of the full internal path.
15
+ - Publishing a site twice at the same time can no longer delete the live copy; the later publish now stands down instead of clearing the tree out from under the first.
16
+ - A slow dashboard now records where the time actually went, so a sluggish load can be traced rather than guessed at.
17
+
12
18
  ## 2026-07-25 (0.1.508)
13
19
 
14
20
  - SiteDesk's screens now use a single, cleaner typeface throughout, and the old olive-green colour is gone.
@@ -122,7 +122,8 @@ export async function walkExposed(accountDir, exposed) {
122
122
  * client: { query: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]> },
123
123
  * log: (line: string) => void, nowIso: string,
124
124
  * exposeFolders?: string[], previousExposed?: string[] }} opts
125
- * @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean }>}
125
+ * @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean,
126
+ * generation?: number, superseded?: boolean }>}
126
127
  */
127
128
  export async function pushAccount(opts) {
128
129
  const { accountDir, accountId, client, log, nowIso } = opts
@@ -196,29 +197,26 @@ export async function pushAccount(opts) {
196
197
  [accountId],
197
198
  )
198
199
  const current = Number(state[0]?.currentGeneration ?? 0)
199
- const next = current + 1
200
-
201
- // Crash safety (Task 1923). A stage that died mid-batch — a D1 500 on one
202
- // INSERT leaves rows at `next` while the pointer stays at `current`. Every
203
- // later run recomputes the same `next`, and its first colliding INSERT then
204
- // hits UNIQUE(accountId, relPath, generation), so the index freezes at the
205
- // stale generation until an operator hand-clears the orphans. Clearing `next`
206
- // before staging lets a partial stage self-heal on the next cycle. Guarded on
207
- // a non-zero count so the DELETE never precedes the flip on a clean cycle and
208
- // the log line always means a recovered wedge. The count comes from a SELECT
209
- // because the D1 client returns rows, not the affected-row meta.
210
- const orphan = await client.query(
211
- 'SELECT COUNT(*) AS n FROM directory WHERE accountId = ? AND generation = ?',
212
- [accountId, next],
200
+
201
+ // Claim a generation above EVERY generation this account already holds, not
202
+ // merely above the pointer (Task 1926). Two pushes of one account are
203
+ // reachable: the admin server's 60s loop and a hand-run `--account` are
204
+ // separate processes, so no in-process lock can see both. `current + 1` let
205
+ // the second writer claim the generation the first had just flipped onto and
206
+ // then delete it. A peer that has inserted even one row is visible in this
207
+ // MAX, so the two runs claim different generations.
208
+ //
209
+ // This also retires the Task 1923 pre-stage clear. That clear existed because
210
+ // a later run recomputed the SAME generation as a dead partial stage and
211
+ // collided on UNIQUE(accountId, relPath, generation); claiming above the
212
+ // orphans leaves nothing to collide with, and the post-flip sweep collects
213
+ // them. The clear was itself the DELETE a concurrent peer could aim at the
214
+ // live tree, so removing it removes the hazard rather than narrowing it.
215
+ const staged = await client.query(
216
+ 'SELECT MAX(generation) AS g FROM directory WHERE accountId = ?',
217
+ [accountId],
213
218
  )
214
- const orphanCount = Number(orphan[0]?.n ?? 0)
215
- if (orphanCount > 0) {
216
- await client.query('DELETE FROM directory WHERE accountId = ? AND generation = ?', [
217
- accountId,
218
- next,
219
- ])
220
- log(`${TAG} op=stage-clear account=${accountId} generation=${next} cleared=${orphanCount}`)
221
- }
219
+ const next = Math.max(current, Number(staged[0]?.g ?? 0)) + 1
222
220
 
223
221
  for (const row of rows) {
224
222
  await client.query(
@@ -231,18 +229,56 @@ export async function pushAccount(opts) {
231
229
 
232
230
  // THE atomic point. Upsert rather than insert-or-update, so an account
233
231
  // publishing for the first time and one republishing take the same path.
232
+ //
233
+ // The WHERE moves the pointer FORWARD ONLY (Task 1926). Without it a writer
234
+ // that was overtaken while staging drags every reader back onto a generation
235
+ // the winner has already swept, which is an empty tree. SQLite has carried a
236
+ // WHERE on upsert's DO UPDATE since 3.24, and D1 is SQLite.
234
237
  await client.query(
235
238
  'INSERT INTO directory_state (accountId, currentGeneration) VALUES (?, ?) ' +
236
- 'ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration',
239
+ 'ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration ' +
240
+ 'WHERE excluded.currentGeneration > directory_state.currentGeneration',
237
241
  [accountId, next],
238
242
  )
243
+
244
+ // Read back rather than assume: the D1 client returns rows, not affected-row
245
+ // meta, so a suppressed flip is otherwise indistinguishable from an applied
246
+ // one.
247
+ const flipped = await client.query(
248
+ 'SELECT currentGeneration FROM directory_state WHERE accountId = ?',
249
+ [accountId],
250
+ )
251
+ const pointer = Number(flipped[0]?.currentGeneration ?? 0)
252
+ if (pointer !== next) {
253
+ // A peer published while this run was staging. Its tree is live and
254
+ // complete, ours is invisible, and its rows are collected by the peer's
255
+ // next sweep. Sweeping or verifying from here would act on, and measure,
256
+ // the PEER's tree — reporting a failure where nothing failed. Logged rather
257
+ // than silent: a suppressed run is otherwise indistinguishable from one
258
+ // that never started.
259
+ log(
260
+ `${TAG} op=stage-contended account=${accountId} generation=${next} ` +
261
+ `pointer=${pointer} action=superseded`,
262
+ )
263
+ return {
264
+ exposed: r.exposed,
265
+ rows: rows.length,
266
+ schemaPresent: true,
267
+ generation: next,
268
+ superseded: true,
269
+ }
270
+ }
239
271
  log(`${TAG} op=flip account=${accountId} generation=${next}`)
240
272
 
241
273
  // Housekeeping only. A failure here leaves stale rows nothing reads, which is
242
274
  // waste rather than incorrectness — so it must not fail the publish that has
243
275
  // already succeeded.
244
276
  try {
245
- await client.query('DELETE FROM directory WHERE accountId = ? AND generation != ?', [
277
+ // Strictly older only (Task 1926): `!= next` would delete a peer's HIGHER
278
+ // in-flight stage. Rows above `next` left by an abandoned stage still
279
+ // converge, because the next cycle claims a generation above them and
280
+ // sweeps them once it flips.
281
+ await client.query('DELETE FROM directory WHERE accountId = ? AND generation < ?', [
246
282
  accountId,
247
283
  next,
248
284
  ])
@@ -291,9 +327,13 @@ export function readState(platformRoot) {
291
327
  }
292
328
 
293
329
  /**
294
- * Written ONLY after op=verify, so a stale entry means the push stopped
295
- * succeeding rather than merely stopped logging. Read-modify-write per account
296
- * so one account's failure does not erase another's record.
330
+ * Written after a publish that reached the flip, so a stale entry means the
331
+ * push stopped succeeding rather than merely stopped logging. That includes a
332
+ * run superseded by a concurrent peer (Task 1926), which emits
333
+ * `op=stage-contended` instead of `op=verify`: the index WAS refreshed inside
334
+ * this cycle, by the peer, and recording otherwise would report a healthy
335
+ * account as never-ran. Read-modify-write per account so one account's failure
336
+ * does not erase another's record.
297
337
  * @param {string} platformRoot @param {string} accountId
298
338
  * @param {{lastPushMs: number, rows: number, exposed: string[]}} entry
299
339
  */