Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d14e848
docs: add pull-wake health check design spec
KyleAMathews May 16, 2026
afe87f2
docs: update health check spec with principal rename and shape sync
KyleAMathews May 16, 2026
6c9979e
docs: add pull-wake health check implementation plan
KyleAMathews May 16, 2026
e01b4d7
fix(plan): address code review findings — add canonicalizePrincipal, …
KyleAMathews May 16, 2026
83e2c41
fix(plan): strict no-compat — remove canonicalizePrincipal, validate …
KyleAMathews May 16, 2026
c7c3342
fix(plan): strict principal validation, clean up dependent tables in …
KyleAMathews May 16, 2026
454ea9b
fix(plan): scope migration to runner-owned claims, fix default princi…
KyleAMathews May 16, 2026
6530be3
fix(plan): store principal URLs directly in constants, not keys
KyleAMathews May 16, 2026
6c49982
feat(agents): add pull-wake runner health diagnostics
KyleAMathews May 16, 2026
15aef19
fix(agents): address pull-wake health review findings
KyleAMathews May 16, 2026
83fb039
chore: add changeset for pull-wake health diagnostics
KyleAMathews May 16, 2026
7c3a0fb
feat(agents): surface pull-wake runtime diagnostics
KyleAMathews May 16, 2026
944c272
fix(agents): harden pull-wake runner lifecycle and error handling
KyleAMathews May 17, 2026
d6c5d4d
chore: add changeset for pull-wake runner hardening
KyleAMathews May 17, 2026
e625468
fix(agents): avoid delayed pull-wake session startup
KyleAMathews May 18, 2026
897b7d4
chore: add changeset for pull-wake startup UI
KyleAMathews May 18, 2026
dc57435
fix(agents-desktop): align default pull-wake owner_principal with ser…
kevin-dp May 18, 2026
8c43f69
fix(agents-server): release pull-wake claim row even when in-memory t…
kevin-dp May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/desktop-default-pull-wake-principal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@electric-ax/agents-desktop': patch
---

Align the default pull-wake `owner_principal` in agents-desktop with the agents-server dev fallback (`system:dev-local`), so connecting to a local server without auth no longer fails with `owner_principal must match the authenticated principal`.
7 changes: 7 additions & 0 deletions .changeset/harden-pull-wake-runner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@electric-ax/agents-runtime': patch
'@electric-ax/agents-server': patch
'@electric-ax/agents': patch
---

Harden pull-wake runner lifecycle with a state machine, concurrent claim limits (`maxConcurrentClaims`), and exponential reconnect backoff (1s–30s). Add granular `status` field to `PullWakeRunnerHealth` (`stopped | starting | connecting | streaming | reconnecting | stopping`). The `onError` callback is now reporting-only (`(Error) => void`) — it can no longer control runner lifecycle. `stop()` rethrows `drainWakes` errors so callers observe wake handler failures. Event-driven heartbeat throttling avoids stale diagnostics between fixed-interval heartbeats.
9 changes: 9 additions & 0 deletions .changeset/pull-wake-health-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@electric-ax/agents-server': patch
'@electric-ax/agents-runtime': patch
'@electric-ax/agents-desktop': patch
'@electric-ax/agents': patch
'electric-ax': patch
---

Add pull-wake runner health check endpoint and rename `owner_user_id` to `owner_principal` across the runners system. The `GET /_electric/runners/:id/health` endpoint returns comprehensive diagnostics including runner state, client-reported stream/heartbeat/claim metrics, active claims, and dispatch stats with a derived health status (healthy/degraded/unhealthy). The `PullWakeRunner` now tracks internal diagnostics and reports them to the server via heartbeats, stored in a new `diagnostics` JSONB column on the runners table. The `owner_user_id` → `owner_principal` rename stores canonical principal URLs instead of keys, with strict validation via `principalKeyFromUrl()`. This is a breaking change with no backward compatibility — all callers must send principal URLs.
5 changes: 5 additions & 0 deletions .changeset/pull-wake-session-startup-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@electric-ax/agents-server-ui': patch
---

Send new-session initial messages through the spawn request so pull-wake sessions can start without waiting for the UI to preload the entity stream.
5 changes: 5 additions & 0 deletions .changeset/release-pull-wake-claim-after-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@electric-ax/agents-server': patch
---

Fix pull-wake claims leaking in `consumer_claims` after dispatch. The release path in `callback-forward` was gated entirely on the in-memory write-token state, so any condition that lost or evicted the token (server restart, a newer wake on the same stream) would prevent `materializeReleasedClaim` from running and leave the DB row pinned at `status='active'`. The fix decouples the durable-row release (keyed by `consumerId + epoch`) from in-memory token cleanup, and uses `entityCleared || stillOwnsClaim` to gate the entity status transition back to `idle`. Includes regression tests in `test/webhook-forward-routing.test.ts`.
5 changes: 3 additions & 2 deletions docs/agents-principals-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ Helpers:
```ts
export function parsePrincipalKey(input: string): Principal
export function principalUrl(key: string): string
export function principalKeyFromUrl(url: string): string | null
export function parsePrincipalUrl(url: string): Principal | null
export function isPrincipalUrl(url: string): boolean
export function getPrincipalFromRequest(request: Request): Principal | null
export function getDevPrincipal(): Principal
```
Expand Down Expand Up @@ -575,7 +576,7 @@ entity: {
principal: entity.created_by
? {
url: entity.created_by,
key: principalKeyFromUrl(entity.created_by),
key: parsePrincipalUrl(entity.created_by)?.key ?? null,
}
: undefined,
```
Expand Down
12 changes: 6 additions & 6 deletions packages/agents-desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ This starts both the UI dev server (with HMR) and the Electron main process.

### Environment variables

| Variable | Default | Description |
| -------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ELECTRIC_DESKTOP_PRINCIPAL` | _(none)_ | Sets the `electric-principal` header on all requests to the agents-server. Use `system:dev-local` for local development without auth. |
| `ELECTRIC_DESKTOP_PULL_WAKE_OWNER_USER_ID` | `local-desktop` | Override the `owner_user_id` used when registering the pull-wake runner. When `ELECTRIC_DESKTOP_PRINCIPAL` is set, this is derived from it automatically. |
| `ELECTRIC_DESKTOP_PULL_WAKE_RUNNER_ID` | _(auto-generated)_ | Fixed runner ID for the pull-wake runner. |
| `ELECTRIC_DESKTOP_PULL_WAKE_REGISTER_RUNNER` | `true` | Set to `false` to skip runner registration (runner must already exist on the server). |
| Variable | Default | Description |
| -------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ELECTRIC_DESKTOP_PRINCIPAL` | _(none)_ | Sets the `electric-principal` header on all requests to the agents-server. Use `system:dev-local` for local development without auth. |
| `ELECTRIC_DESKTOP_PULL_WAKE_OWNER_PRINCIPAL` | `/principal/system%3Adev-local` | Override the `owner_principal` used when registering the pull-wake runner. When `ELECTRIC_DESKTOP_PRINCIPAL` is set, this is derived from it automatically. |
| `ELECTRIC_DESKTOP_PULL_WAKE_RUNNER_ID` | _(auto-generated)_ | Fixed runner ID for the pull-wake runner. |
| `ELECTRIC_DESKTOP_PULL_WAKE_REGISTER_RUNNER` | `true` | Set to `false` to skip runner registration (runner must already exist on the server). |

### Settings

Expand Down
31 changes: 18 additions & 13 deletions packages/agents-desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,9 @@ const PULL_WAKE_REGISTER_RUNNER =
: [`1`, `true`].includes(
process.env.ELECTRIC_DESKTOP_PULL_WAKE_REGISTER_RUNNER.trim().toLowerCase()
)
const PULL_WAKE_OWNER_USER_ID =
process.env.ELECTRIC_DESKTOP_PULL_WAKE_OWNER_USER_ID?.trim() ||
`local-desktop`
const PULL_WAKE_OWNER_PRINCIPAL =
process.env.ELECTRIC_DESKTOP_PULL_WAKE_OWNER_PRINCIPAL?.trim() ||
`/principal/system%3Adev-local`
const DEV_PRINCIPAL = ((): string | null => {
const raw = process.env.ELECTRIC_DESKTOP_PRINCIPAL?.trim() || null
if (!raw) return null
Expand Down Expand Up @@ -262,15 +262,18 @@ function hasHeader(
return headers ? new Headers(headers).has(name) : false
}

function runnerOwnerUserIdFromHeaders(
function runnerOwnerPrincipalFromHeaders(
headers: Record<string, string> | undefined
): string {
): string | undefined {
const normalized = new Headers(headers)
return (
normalized.get(`authorization`)?.trim() ||
normalized.get(ELECTRIC_PRINCIPAL_HEADER)?.trim() ||
PULL_WAKE_OWNER_USER_ID
)
const principalKey = normalized.get(ELECTRIC_PRINCIPAL_HEADER)?.trim()
if (principalKey) {
return principalKey.startsWith(`/principal/`)
? principalKey
: `/principal/${encodeURIComponent(principalKey)}`
}
if (normalized.has(`authorization`)) return undefined
return PULL_WAKE_OWNER_PRINCIPAL
}

/**
Expand Down Expand Up @@ -1541,14 +1544,14 @@ async function startRuntime(serverId: string): Promise<void> {

const serverWithPrincipal = injectDevPrincipalHeaders(activeServer)
const runtimeHeaders = mergeHeaders(serverWithPrincipal.headers)
const runnerOwnerUserId = runnerOwnerUserIdFromHeaders(runtimeHeaders)
const runnerOwnerPrincipal = runnerOwnerPrincipalFromHeaders(runtimeHeaders)
console.info(
`[agents-desktop] Starting built-in agents runtime for server ${activeServer.url}`
)
console.info(`[agents-desktop] Pull-wake runner id: ${runnerId}`)
if (PULL_WAKE_REGISTER_RUNNER) {
console.info(
`[agents-desktop] Pull-wake runner registration enabled; owner user id: ${runnerOwnerUserId}`
`[agents-desktop] Pull-wake runner registration enabled; owner principal: ${runnerOwnerPrincipal ?? `(derived from auth)`}`
)
} else {
console.info(
Expand All @@ -1572,7 +1575,9 @@ async function startRuntime(serverId: string): Promise<void> {
pullWake: {
runnerId,
registerRunner: PULL_WAKE_REGISTER_RUNNER,
ownerUserId: PULL_WAKE_REGISTER_RUNNER ? runnerOwnerUserId : undefined,
ownerPrincipal: PULL_WAKE_REGISTER_RUNNER
? runnerOwnerPrincipal
: undefined,
label: `Electric Agents Desktop`,
headers: runtimeHeaders,
claimHeaders: runtimeHeaders,
Expand Down
10 changes: 4 additions & 6 deletions packages/agents-runtime/src/create-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { zodToJsonSchema } from 'zod-to-json-schema'
import { processWebhookWake } from './process-wake'
import { processWake } from './process-wake'
import { getEntityType, listEntityTypes } from './define-entity'
import { DEFAULT_OUTPUT_SCHEMAS } from './default-output-schemas'
import { passthrough } from './entity-schema'
Expand Down Expand Up @@ -118,13 +118,11 @@ export interface RuntimeRouter {
options?: Pick<ProcessWakeConfig, `claimHeaders` | `claimTokenHeader`>
) => void

/**
* Dispatch an already-parsed webhook wake notification.
*/
/** Dispatch an already-parsed webhook wake notification. */
dispatchWebhookWake: (notification: WebhookNotification) => void

/**
* Wait for all in-flight webhook wake handlers to settle.
* Wait for all in-flight wake handlers to settle.
* Throws any wake errors instead of hiding them behind logs.
*/
drainWakes: () => Promise<void>
Expand Down Expand Up @@ -240,7 +238,7 @@ export function createRuntimeRouter(
const wakeLabel = notification.entity?.url ?? notification.streamPath
const controller = new AbortController()
const wake: Promise<void> = Promise.resolve(
processWebhookWake(notification, {
processWake(notification, {
...wakeConfig,
...options,
shutdownSignal: controller.signal,
Expand Down
4 changes: 3 additions & 1 deletion packages/agents-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export type {
TaggedQuery,
} from './observation-sources'

export { processWake, processWebhookWake } from './process-wake'
export { processWake } from './process-wake'
export type { ProcessWakeConfig } from './types'

export { DEFAULT_OUTPUT_SCHEMAS } from './default-output-schemas'
Expand Down Expand Up @@ -239,6 +239,8 @@ export type {
PullWakeEvent,
PullWakeRunner,
PullWakeRunnerConfig,
PullWakeRunnerHealth,
PullWakeRunnerStatus,
PullWakeStreamResponse,
} from './pull-wake-runner'

Expand Down
4 changes: 1 addition & 3 deletions packages/agents-runtime/src/process-wake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ function createInFlightTracker() {
}
}

export async function processWebhookWake(
export async function processWake(
notification: WebhookNotification,
config: ProcessWakeConfig
): Promise<WakeResult | null> {
Expand Down Expand Up @@ -1820,8 +1820,6 @@ export async function processWebhookWake(
return result
}

export const processWake: typeof processWebhookWake = processWebhookWake

async function sendDone(
callback: string,
token: string,
Expand Down
Loading
Loading