Skip to content

Commit a044ede

Browse files
balegasclaude
andauthored
fix(agents-server,agents-runtime): unblock fresh-tenant first wake for shared-state writers (#4550)
## Summary Two first-spawn races prevented writer-side shared-state entities from ever reaching their first handler run on a fresh tenant. - **`agents-server` `durable-streams-router.ts:708-735`** — `PUT /_electric/shared-state/<id>` now inserts the `shared_state_links` row synchronously when a valid `electric-owner-entity` header is supplied. Was previously created only when the entity's manifest stream event was processed eventually-consistently, so the runtime's own preload GET (issued right after the PUT) always 401'd on the first wake. - **`agents-runtime` `process-wake.ts:1410-1424`** — `createChildDb` swallows `Stream not found` / 404 on the initial entity-observation preload. A handler may legitimately observe an entity that hasn't been spawned yet (parent observing its own future child); the previous unconditional throw aborted the wake and prevented the entity from ever recovering. 20 lines of source, plus a 1-line test mock update and a changeset. ## Why Reproduction with OpenFactory's `daily-digest` entity (uses `mkdb` + `observe(db(...))` + `observe(entity(<future writer>))`) against a freshly torn-down local `agents-server`: ```text [/daily-digest/main-1] handler failed: HTTP Error 401 at .../shared-state/daily-digest?offset=-1: Principal is not allowed to read shared state ``` with `shared_state_links` empty despite the runtime having just issued the bootstrap PUT seconds earlier. Once the server-side patch lands the link row, the next preload reads through `created_by` matching and the handler proceeds. The handler then tries to observe its own future writer (`/horton/daily-digest-writer-main-1`) which doesn't exist yet — the runtime patch turns that 404 into a deferred "no events yet" instead of aborting the wake. Combined, the two patches let `daily-digest` self-bootstrap end-to-end on a clean tenant without manual SQL or out-of-band link insertion. ## Test plan - [x] `pnpm --filter @electric-ax/agents-runtime typecheck` — clean - [x] `pnpm --filter @electric-ax/agents-server typecheck` — clean - [x] `pnpm --filter @electric-ax/agents-runtime test` — **827 pass, 2 skipped (66 files)** - [x] `pnpm --filter @electric-ax/agents-server test` — **402 pass, 41 skipped, 2 failing** (the two failing `oss-server-router` dashboard tests fail on `origin/main` without this PR — verified by stashing the patch and re-running, unrelated) - [x] End-to-end against a freshly torn-down local agents-server (this PR's build) + OpenFactory's daily-digest entity: first \`spawn-daily-digest.sh --run-now\` writes the digest, the discord-router subscriber posts to Discord. No manual DB inserts, no out-of-band link bootstrapping. ## Out of scope A few related concerns surfaced during diagnosis but aren't fixed here: - `setup-context.ts:749` uses string concat for `serverBaseUrl + streamPath` which produces `http://localhost:4437//horton/...` when the env URL has a trailing slash. Cosmetic — most servers normalise — but worth a follow-up using `appendPathToUrl`. - The runtime's `prepareAgentRun` / post-handler ordering (`process-wake.ts:2146-2152`) runs `waitForSharedStateWiring` *before* `commitManifestEntries`. Once this PR's server-side link bootstrap lands, the manifest event becomes a redundant secondary write rather than the only source of truth, so the ordering is benign — but conceptually the manifest should be committed before the wiring waits. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5238055 commit a044ede

4 files changed

Lines changed: 37 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@electric-ax/agents-server': patch
3+
'@electric-ax/agents-runtime': patch
4+
---
5+
6+
agents-server, agents-runtime: fix two first-spawn races that prevented writer-side shared-state entities from reaching their first handler run on a fresh tenant.
7+
8+
**agents-server**: `PUT /_electric/shared-state/<id>` now inserts the corresponding `shared_state_links` row synchronously whenever the request carries a valid `electric-owner-entity` header and the principal can access the entity. Previously this PUT only ran the authz check; the link row was created later — asynchronously — when the entity's manifest stream event was processed via `applyManifestEntitySource`. The runtime's `mkdb` wiring schedules the PUT and the preload GET back-to-back, so the GET always raced ahead of the eventually-consistent link insert and returned `401 UNAUTHORIZED: Principal is not allowed to read shared state` on every fresh-tenant first wake.
9+
10+
**agents-runtime**: `createChildDb` (used by entity observations) now swallows `Stream not found` / `404` on initial preload. A handler may legitimately observe an entity that hasn't been spawned yet — e.g. a parent observes its own future child to wake on the child's `runFinished`. Treating the 404 as "no events yet" matches the spirit of the observation (we'll be woken when the entity appears); the previous unconditional throw aborted the entire wake with `HTTP Error 404 ... Stream not found`, and the entity could never recover because the spawn that would create the child never ran.
11+
12+
Verified end-to-end with OpenFactory's `daily-digest` entity (uses `mkdb` + `observe(db(...))` + `observe(entity(<future child>))`) against a freshly torn-down local agents-server: the first `run-now` now writes the digest row, the discord-router subscriber picks it up, and the digest reaches Discord — without manual SQL or out-of-band link bootstrapping.

packages/agents-runtime/src/process-wake.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1408,7 +1408,17 @@ export async function processWake(
14081408
close: () => childDb.close(),
14091409
})
14101410
if (opts?.preload !== false) {
1411-
await childDb.preload()
1411+
try {
1412+
await childDb.preload()
1413+
} catch (err) {
1414+
// An observation may target an entity that hasn't been spawned
1415+
// yet (e.g. a daily-digest entity observes its child writer
1416+
// before the first spawn). 404 / Stream not found is fine — we
1417+
// treat the stream as empty for this wake; subsequent wakes
1418+
// pick up state once the entity is created.
1419+
const msg = err instanceof Error ? err.message : String(err)
1420+
if (!/Stream not found|404/i.test(msg)) throw err
1421+
}
14121422
}
14131423
return childDb
14141424
},

packages/agents-server/src/routing/durable-streams-router.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,19 @@ async function authorizeDurableStreamAccess(
717717
ownerEntityUrl
718718
)
719719
) {
720+
// Bootstrap the link synchronously so subsequent reads on this stream
721+
// (e.g. the runtime's preload right after `mkdb`) can resolve the owner
722+
// without waiting for the entity's manifest stream event to be
723+
// processed eventually-consistently. Without this, a brand-new
724+
// shared-state always 401s on the first preload because the link row
725+
// is only created later via `applyManifestEntitySource`.
726+
if (ownerEntityUrl) {
727+
await ctx.entityManager.registry.replaceSharedStateLink(
728+
ownerEntityUrl,
729+
`shared-state:${sharedStateId}`,
730+
sharedStateId
731+
)
732+
}
720733
return undefined
721734
}
722735
return apiError(

packages/agents-server/test/permissions-routes.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ function ctx(
8383
listSharedStateLinkedEntityUrls: vi.fn(
8484
async () => overrides.linkedSharedStateEntityUrls ?? [currentEntity.url]
8585
),
86+
replaceSharedStateLink: vi.fn(async () => undefined),
8687
hasEntityPermission: vi.fn(async (_url, permission) =>
8788
typeof overrides.hasEntityPermission === `function`
8889
? overrides.hasEntityPermission(permission)

0 commit comments

Comments
 (0)