Skip to content

fix(engine): restore agent presence lifecycle - #306

Merged
khaliqgant merged 8 commits into
mainfrom
fix/agent-presence-lifecycle
Aug 7, 2026
Merged

fix(engine): restore agent presence lifecycle#306
khaliqgant merged 8 commits into
mainfrom
fix/agent-presence-lifecycle

Conversation

@barryollama

@barryollama barryollama commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • restore the dormant five-minute agent presence sweep in the Node adapter and run a workspace-scoped sweep on roster reads, while deriving public active status from last_seen so correctness does not depend on cron timing
  • fail normal release closed with 503 agent_host_unavailable when no live hosting connection can own it, instead of returning an ownerless pending invocation
  • allow delete_agent to reap an undispatchable record locally, freeing the name by tombstone rename rather than by DELETE
  • gate cross-node name reclaim on observed silence rather than on the status column, so restoring the sweep does not loosen an identity boundary as a side effect
  • document the presence and reaping contract

Root cause

sweepStaleAgents was originally wired to a 60-second setInterval in commit 11ddbf5. The Fly.io -> Cloudflare Workers migration (b9eb241) removed the direct-run server loop, including that timer, but left the sweep function behind. No Worker scheduled replacement was added. The orphaned function was later copied into the portable engine (8aa0bf1) and has had no caller since.

The release path independently routed every request through the target location. If the provider send failed, dispatchRelease returned pending with dispatched_node_id: null, leaving no owner capable of completing it.

Presence contract

  • active means Relaycast observed authenticated activity within the last five minutes; last_seen is authoritative, not metadata.fleet.nodeId.
  • registration/authenticated activity renews the lease; an expired active/legacy-online row becomes offline.
  • a live-host release is dispatched and applied after host completion.
  • a normal release with no live host returns 503 agent_host_unavailable and persists a failed invocation, never pending.
  • delete_agent with no live host is completed locally: the name is released by tombstone rename, and the active bindings, capacity, and implicit direct node are cleaned up.

Identity contract, and why it is in this PR

Restoring the sweep changed who may take an agent's name, as a side effect, and the trigger was a read. That is resolved here rather than after.

registerAgentViaNode reclaims a name via onConflictDoUpdate and overwrites token_hash. A permitted reclaim is therefore a full credential handover, not a pointer move — demonstrated against the engine with a two-node probe:

  • incumbent registered on node_alpha; node_beta sends agent.register for the same name
  • with the incumbent's row stored active: refused, token_hash unchanged
  • with that same row stored offline: allowed, and the reply frame returns {"ok":true,"data":{"agent_id":"<same row id>","name":"contested","token":"at_live_<redacted>"}} — same row, working token, incumbent evicted

The guard was setWhere: or(ne(agents.status, 'active'), <owning node>). Because this branch calls sweepStaleAgents synchronously from listAgents and getAgentByName, a plain agent list rewrites status, and every record flipped to offline satisfied that first disjunct — moving from "reclaimable only by its own node" to "reclaimable by any node, on name alone".

Presence and identity are different questions and must not share a field. "Should the roster show this agent as here" is cheap to get wrong and self-corrects on the next heartbeat. "May another node take this name and be issued a working token for it" cannot be undone. The guard now reads last_seen, which a read cannot write, so a roster read can no longer widen who may claim an identity — structurally, not by convention.

An agent is absent from the roster after 5 minutes of silence and its name is reclaimable by a stranger after AGENT_RECLAIM_GRACE_MS (24h). Between those points it reads as away and its identity is still its own. The owning-node disjunct is unchanged, so a node restart or reconnect is never blocked.

Why gate on last_seen rather than simply tightening the status check

Because status is writable through the API, and that makes it unfit to govern identity regardless of what the sweep does.

PATCH /v1/agents/:name with {"status":"active"} writes the column while proving nothing about liveness. So while the guard branched on status, that endpoint was a write path into the identity guard: set active and a row re-tightens, let the sweep set offline and it re-opens. Two unrelated callers — a roster read and a metadata PATCH — could both move an identity boundary as a side effect of doing something else.

last_seen is only advanced by observed authenticated activity. updateAgent does not write it and neither does a roster read. So this change does not merely relocate the coupling — it removes the coupling for both of those paths at once, which tightening the status predicate could not have done.

It also closes a stranding, not only a loosening

"Tightening the reclaim guard" reads like it only takes something away. It does not — the two failure modes here are opposite, and this closes both.

Under the old ne(agents.status, 'active') guard, a row stored active but silent for weeks was not reclaimable by another node, because the disjunct was false and the node-identity condition could not be satisfied by a different node. So when a node died uncleanly and never deregistered, its agents' names were stranded — held by a record nothing could revoke, and unrecoverable except by a DELETE that the foreign keys refuse (see the reaping section, and #309). That is not hypothetical: it is why the fleet currently has names it cannot reuse.

Gating on last_seen makes the grace window expire into recovery. A genuinely abandoned name becomes reclaimable after 24h instead of never. The test an agent silent beyond the reclaim grace can be reclaimed by another node fails against the old guard with expected 'node_alpha' to be 'node_beta' — the old code refusing a reclaim that should always have been allowed.

Which class this closes, and which it does not. This fixes stranding on the engine's registerAgentViaNode path — agents registered through node-control agent.register. It does not help a name stranded through the SDK/registerOrRotate path or a fleet node whose own record is stuck, because those are different registration paths with different guards. Same class of harm, different code. Recovering those still depends on #309 landing.

Scope, measured

Against relaycast-cloud on 2026-08-07 (20,107 agent rows; 15,652 stored active with last_seen past the TTL):

this workspace fleet-wide
genuinely loosened by the sweep 8 1,578
already reclaimable today, no deploy needed 300 14,074

An earlier estimate of "305 loosened in this workspace" was wrong in both directions. It counted every stale-active row, but 300 of those 308 sit on their implicit direct node (location_node_id = 'node_direct_' || id), which already satisfies the guard from any node — they were open before this PR and remain open after it. The genuinely loosened set here is 8.

Fleet-wide, the bulk of what this PR would have opened is 1,563 rows with a NULL location_node_id, protected today by status='active' and nothing else.

The larger standing exposure — 14,074 rows open today via the node_direct_ disjunct — is not addressed here. That is #311.

Choosing 24h

Silence distribution across the 1,578 records this governs: <5m: 5, 5m-24h: 9, 1d-7d: 1, >7d: 1,568. 99.4% have been silent over a week, so a 24h grace costs essentially nothing steady-state (1,569 eligible vs 1,578) while protecting the ~14 identities a human would still call live. The reasoning and these numbers are recorded on the constant; lowering it converts live agents into reclaimable ones.

Reaping without destroying history

The local reap issued a bare DELETE on agents inside the same atomic unit as the binding update and the invocation completion. Four foreign keys reference agents.id without onDeletechannels.created_by (schema.ts:455), messages.agent_id (:503), files.uploaded_by (:666), webhooks.created_by (:759) — so the delete is refused for any agent that has ever spoken. Inside runAtomicWrites that refusal aborted the whole unit, so the invocation never completed either: a transaction abort rather than a legible error, on exactly the agents the reap exists to clean up. 444 agents in this workspace alone have sent a message.

Worth recording: the earlier review fix that moved these writes into runAtomicWrites (f852dec, correctly closing a real partial-write bug) is what turned this FK refusal from a survivable partial failure into a whole-unit abort. A correctness fix deepened a latent bug underneath it. Neither change is wrong; the interaction was invisible from either one alone.

Cascade would delete the agent's message history, and messages.agent_id is NOT NULL so set null cannot apply. This adopts the tombstone rename from #309: the unique key is (workspace_id, name), so freeing the name only requires the name to stop colliding.

Two choices beyond #309's sketch:

  • the tombstone is keyed on the agent id, not a timestamp. It runs inside an atomic batch where a unique-constraint violation would abort the whole unit — reintroducing the bug being fixed. The id is already unique per workspace, so it cannot collide and a repeat release is idempotent. releasedAt is preserved in metadata.release.
  • token_hash is rotated on release. The row survives, and token_hash is NOT NULL UNIQUE so it cannot be cleared; without the rotation a released agent's old token would keep authenticating.

listAgents excludes released rows so agent list does not fill with tombstones.

On #312

This does not close #312. effectiveAgentStatus can never return "unknown", and for a fresh stored-active agent it returns "active" — byte-identical to the previous status: a.status. So it cannot change the rows #312 is about (live agents serializing as "unknown"); it only changes stale rows from active to offline. The "unknown" mapping exists in no local tree and was not located in this checkout. #312 remains open and unaddressed by this PR.

Known-live, not fixed here

updateAgent (agent.ts) writes status without renewing last_seen, so PATCH /v1/agents/:name with {"status":"active"} returns offline in the response body while broadcasting agent.status.active to realtime subscribers. Reported by cubic on this PR and confirmed. It is presence reporting rather than identity — after this change that path no longer touches the reclaim guard — and it is left for a follow-up rather than growing this PR.

Verification

  • npm run typecheck --workspace=@relaycast/engine — clean
  • npm run lint --workspace=@relaycast/engine — clean
  • npm test --workspace=@relaycast/engine — 51 files, 548 tests (541 on the branch before these commits)

Both defects were reproduced red before being fixed:

  • reap: the existing delete_agent fixtures all registered a fresh agent and released it immediately, so the suite could not observe the FK refusal. A fixture that posts one message first fails with SQLITE_CONSTRAINT_FOREIGNKEY: FOREIGN KEY constraint failed (HTTP 500) before the change.
  • reclaim: reverting only the guard line to ne(agents.status,'active') turns a roster read does not make a recently-active agent reclaimable by another node red (expected 'node_beta' to be 'node_alpha').

New assertions and what each catches:

test catches
a roster read does not make a recently-active agent reclaimable by another node the read-triggered loosening; also asserts the sweep really flipped status, so it cannot pass by the sweep not running
an agent silent beyond the reclaim grace can be reclaimed by another node a fail-closed guard that would strand a name after its node dies
the owning node can re-register its own agent inside the grace window a future "simplification" of the owning-node disjunct breaking node restart
reaps a hostless agent that has already spoken the FK abort, and asserts both that the name is freed and that the row survives — so neither "deleted everything" nor "changed nothing" passes
refuses to register into the reserved released-agent namespace a caller pre-registering <victim>#released-<victimId> to make the victim's release abort
keeps released tombstones out of the roster and the presence view a tombstone leaking onto either roster surface, under the old name or the new one
records the caller-supplied release reason on the tombstone the two release paths writing different audit trails for the same operation

On the review round that followed

cubic raised four findings against the first two commits and all four were valid, including one that falsified a claim made in this PR: the id-keyed tombstone name was described as collision-free by construction, and it was not. Agent names are z.string().min(1), so a caller could occupy the tombstone namespace deliberately and turn any release into the whole-unit abort this PR exists to remove. That is now fixed at the root by reserving the marker at registration, rather than by adding entropy or a retry.

Recording it because the correction matters more than the fix: the original argument was sound given an assumption about the namespace that nothing in the code enforced.

Neither automated reviewer caught either defect

Worth stating plainly, because this PR was MERGEABLE/CLEAN with two green checks while carrying both of the above.

CodeRabbit and cubic filed ten inline comments between them — atomicity, capacity accounting, unreachable guards, exit-node derivation. Useful review; four of those are verified fixed on this branch and one (updateAgent) is confirmed still live and filed as a follow-up. But neither mentioned the FK RESTRICT refusal, and neither mentioned the identity loosening. Both checks reported pass throughout.

So on this PR, green CI and two passing bot reviewers were both compatible with two merge-blocking defects. Bot review passing is not the same as having been vetted, and it should not be read as sufficient on a change that touches identity or foreign keys.

PR only. Do not merge or deploy without Khaliq approval.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds five-minute agent presence leases, stale-status sweeping, atomic registration, and host-aware release handling. Releases dispatch to live hosts, complete locally for eligible deleted agents, or fail with agent_host_unavailable. API documentation and conformance tests cover these behaviors.

Changes

Agent lifecycle

Layer / File(s) Summary
Presence status and stale-agent sweeping
packages/engine/src/engine/agent.ts, packages/engine/src/adapters/node/index.ts, packages/engine/src/index.ts, openapi.yaml, README.md, packages/engine/src/__tests__/conformance/agentLifecycle.test.ts
Agent status uses a five-minute liveness lease. Reads derive effective status, stale records are persisted as offline, and registration is atomic. The Node adapter sweeps stale agents periodically.
Host-aware release completion
packages/engine/src/engine/action.ts, packages/engine/src/routes/agent.ts, openapi.yaml, packages/engine/src/__tests__/conformance/agentLifecycle.test.ts
Release actions dispatch to live hosts. delete_agent completes locally without a host. Other hostless releases fail with agent_host_unavailable. Completion effects update bindings, node counts, invocation state, and implicit-node cleanup.
Agent reclaim rules and validation
packages/engine/src/engine/node.ts, packages/engine/src/__tests__/conformance/agentNameReclaim.test.ts, packages/engine/src/__tests__/conformance/agentLifecycle.test.ts
Agent reclaim eligibility uses lastSeen and the reclaim grace period. Tests cover ownership preservation, credential replacement, rollback, deletion, and live dispatch.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AgentRoute
  participant ActionEngine
  participant LiveHost
  participant EngineDatabase

  AgentRoute->>ActionEngine: invoke release with completionDeps
  alt live host exists
    ActionEngine->>LiveHost: dispatch release
    LiveHost-->>ActionEngine: return release result
    ActionEngine->>EngineDatabase: apply completion effects
  else host is unavailable
    ActionEngine->>EngineDatabase: complete delete_agent locally
    ActionEngine->>EngineDatabase: record agent_host_unavailable for normal release
  end
Loading

Possibly related PRs

Suggested labels: size:L

Suggested reviewers: willwashburn

Poem

A rabbit renews the lease at dawn,
Stale agents turn to offline lawn.
Live hosts carry releases through,
Lost hosts return a clear error too.
Deleted nodes hop away—
The engine keeps its state in play.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: restoring the engine's agent presence lifecycle.
Description check ✅ Passed The description directly explains the presence, release, reaping, reclaim, documentation, and validation changes in the pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/agent-presence-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/engine/src/engine/action.ts`:
- Around line 692-713: Update completeLocally and the corresponding flow around
the additional release-completion path to execute applyReleaseCompletionEffect
and the invocation status update within a single database transaction,
preserving the existing mutation order and conditions. Defer any external
completion effects until after the transaction successfully commits, so failures
roll back all lifecycle, binding, capacity, and invocation changes together.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e4d5010-819c-46aa-9385-36ae01f4faae

📥 Commits

Reviewing files that changed from the base of the PR and between 45beff3 and c280a96.

📒 Files selected for processing (8)
  • README.md
  • openapi.yaml
  • packages/engine/src/__tests__/conformance/agentLifecycle.test.ts
  • packages/engine/src/adapters/node/index.ts
  • packages/engine/src/engine/action.ts
  • packages/engine/src/engine/agent.ts
  • packages/engine/src/index.ts
  • packages/engine/src/routes/agent.ts

Comment thread packages/engine/src/engine/action.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

3 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/engine/src/engine/agent.ts">

<violation number="1" location="packages/engine/src/engine/agent.ts:129">
P3: `listAgents` now performs a full workspace-wide select and applies the status filter in memory, and it also triggers `sweepStaleAgents` (a DB write) on every roster GET. The old code pushed `status` into the SQL `where` clause, so a `?status=active` request loaded only the matching rows. For workspaces with a large roster this removes the SQL pushdown and adds a write to a hot read path. Consider keeping the dirty-status sweep, but only push the status predicate into SQL when a status filter was actually requested (the derived status only differs from the persisted column for stale `active`/`online` rows).</violation>

<violation number="2" location="packages/engine/src/engine/agent.ts:280">
P2: Updating a stale agent to `active` returns `offline` but broadcasts `agent.status.active`, leaving roster consumers and realtime subscribers with conflicting presence. Fanout should use the effective status (or otherwise renew `lastSeen` before publishing an active event).</violation>
</file>

<file name="packages/engine/src/engine/action.ts">

<violation number="1" location="packages/engine/src/engine/action.ts:758">
P3: The new guard `if (!registry || !nodeId) { throw ... }` placed after the `!hostLive` branch is unreachable. `hostLive` is already false whenever `registry` or `nodeId` is missing (the `!!registry && ... && !!nodeId` conjunction short-circuits), so we only reach this line when both are present and the guard can never fire. It reads like a safety net but adds confusion; consider removing it (or hoisting it above the liveness check if you intend it to run for the non-via_node case).</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/engine/src/engine/action.ts Outdated
Comment thread packages/engine/src/engine/agent.ts
handle: `@${updated.name}`,
type: updated.type,
status: updated.status,
status: effectiveAgentStatus(updated),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Updating a stale agent to active returns offline but broadcasts agent.status.active, leaving roster consumers and realtime subscribers with conflicting presence. Fanout should use the effective status (or otherwise renew lastSeen before publishing an active event).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/engine/agent.ts, line 280:

<comment>Updating a stale agent to `active` returns `offline` but broadcasts `agent.status.active`, leaving roster consumers and realtime subscribers with conflicting presence. Fanout should use the effective status (or otherwise renew `lastSeen` before publishing an active event).</comment>

<file context>
@@ -266,7 +277,7 @@ export async function updateAgent(
     handle: `@${updated.name}`,
     type: updated.type,
-    status: updated.status,
+    status: effectiveAgentStatus(updated),
     persona: updated.persona,
     capabilities: updated.capabilities ?? null,
</file context>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirmed still live, and filed as #313 rather than fixed here.

Reproduced the mechanism: updateAgent writes the status column but never renews last_seen, so effectiveAgentStatus(updated) returns offline for a stale row while fanoutAgentStatus(c, updated, body.status) (routes/agent.ts) broadcasts the requested value. Same request, same instant, two different answers.

Not fixed in this PR deliberately: it is presence reporting, not identity. Note the severity dropped during this PR — while the reclaim guard branched on status, this endpoint was a write path into the identity guard, letting a caller re-tighten a row while proving nothing about liveness. The guard now reads last_seen, which updateAgent does not write, so what remains is a reporting divergence rather than an identity one.

Comment thread packages/engine/src/engine/action.ts
}
// Keep the durable state aligned as a cleanup side effect, while still
// deriving below so correctness never depends on a cron/sweep having run.
await sweepStaleAgents(db, workspaceId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: listAgents now performs a full workspace-wide select and applies the status filter in memory, and it also triggers sweepStaleAgents (a DB write) on every roster GET. The old code pushed status into the SQL where clause, so a ?status=active request loaded only the matching rows. For workspaces with a large roster this removes the SQL pushdown and adds a write to a hot read path. Consider keeping the dirty-status sweep, but only push the status predicate into SQL when a status filter was actually requested (the derived status only differs from the persisted column for stale active/online rows).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/engine/agent.ts, line 129:

<comment>`listAgents` now performs a full workspace-wide select and applies the status filter in memory, and it also triggers `sweepStaleAgents` (a DB write) on every roster GET. The old code pushed `status` into the SQL `where` clause, so a `?status=active` request loaded only the matching rows. For workspaces with a large roster this removes the SQL pushdown and adds a write to a hot read path. Consider keeping the dirty-status sweep, but only push the status predicate into SQL when a status filter was actually requested (the derived status only differs from the persisted column for stale `active`/`online` rows).</comment>

<file context>
@@ -107,33 +124,27 @@ export async function registerAgent(
-  }
+  // Keep the durable state aligned as a cleanup side effect, while still
+  // deriving below so correctness never depends on a cron/sweep having run.
+  await sweepStaleAgents(db, workspaceId);
+  const rows = await db
+    .select()
</file context>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirmed valid on both halves, and filed as #315 rather than fixed here.

Splitting it, because the two halves are not the same severity:

  • the lost SQL pushdown is the P3 you rated it. Real, and measured: 877 agent rows in the workspace this was verified against, 20,107 fleet-wide.
  • the write on a read path is a design property rather than an optimisation gap, and I would rate it higher. GET /v1/agents now issues UPDATE agents, unbounded — the first roster read after a quiet period flips every stale row in one statement (308 of 877 here).

Not fixed in this PR because it is the PR's pre-existing design rather than something the last commits introduced, and reworking it means touching the derive-vs-filter logic the whole presence contract rests on. Holding a security fix on a performance refactor is the wrong trade.

One thing #315 records that is worth stating here: this PR removed the identity consequence of reads triggering the sweep — the reclaim guard no longer reads the column the sweep rewrites — but it did not stop reads from writing. Those shared a cause; only one was fixed.

Comment thread packages/engine/src/engine/action.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/engine/src/engine/action.ts">

<violation number="1" location="packages/engine/src/engine/action.ts:693">
P2: A concurrent bind/rebind can leave capacity permanently inflated: if the binding changes after this snapshot, the transaction deactivates the new binding but decrements only the old `activeNodeIds`, so the new node keeps an occupied `activeAgents` slot despite no active binding. Deriving the node decrement from the bindings inside the same atomic unit (or otherwise serializing the snapshot with binding changes) would keep placement capacity consistent.</violation>

<violation number="2" location="packages/engine/src/engine/action.ts:766">
P3: The local-offline completion branch inside `completeLocally` is unreachable. `completeLocally` is only called when `input.delete_agent === true` (both call sites in `dispatchRelease` use `delete_agent === true ? completeLocally() : failClosed()`), so the `else` branch that marks the agent `offline`/`self_connected`, clears `locationNodeId`, and stamps `metadata.release` can never run. It is dead code that now contradicts the PR contract (non-delete releases with no live host 503 via `failClosed`). Recommend removing the `else` branch (and simplifying the surrounding `if` to unconditional delete) so the local local-release path can't be mistakenly resurrected for non-delete releases.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/engine/src/engine/action.ts Outdated
Comment thread packages/engine/src/engine/action.ts Outdated
@miyaontherelay

Copy link
Copy Markdown
Contributor

Review follow-up complete in f852dec.

  • Fixed P1 direct-node liveness: cdd8968 removed the via_node-only gate and its regression test proves a directly registered, self_connected legacy row with a connected node_direct_ dispatches release. The test fails at c280a96 with 503 agent_host_unavailable and passes at HEAD.
  • Fixed P2 local-reap capacity race: f852dec derives the active binding in the atomic write batch, so the node decremented is the binding actually deactivated. Added a behavior test that reads back active_agents=0 and no active binding after local reap.
  • Fixed P3 dead local-release path: f852dec makes completeLocally delete-only (non-delete hostless releases still fail closed) and removes the unreachable post-liveness guard.

No findings were argued against. Validation: turbo build --filter=@relaycast/engine..., engine lint, and agentLifecycle conformance 9/9 passed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/engine/src/engine/action.ts (1)

771-784: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

A local delete of a legacy agent emits no agent.exited event.

The exit effect and handler_node_id both use agent.locationNodeId. The follow-up fix made dispatch resolve a host from the active bindings or from the implicit direct node id, precisely because a legacy directly registered row can have locationType: 'self_connected' and a null locationNodeId. The conformance tests at Lines 281-284 and 331-334 create exactly that state.

For such an agent, delete_agent completes locally, the agent row is deleted, and agent.locationNodeId is null. The condition at Line 771 is then false, so no agent.exited event reaches the spawn caller, the workspace event log, or the webhook. The response also reports handler_node_id: null.

Use the same resolved node id that liveness detection uses. nodeId is initialized at Line 817, before completeLocally runs at Line 832, so the closure can read it.

🐛 Proposed fix
-    if (completed.length > 0 && args.completionDeps && agent.locationNodeId) {
+    const exitNodeId = nodeId ?? agent.locationNodeId;
+    if (completed.length > 0 && args.completionDeps && exitNodeId) {
       await emitAgentExitedEffects(args.completionDeps, args.workspaceId, {
         agentId: agent.id,
         agentName: agent.name,
-        nodeId: agent.locationNodeId,
+        nodeId: exitNodeId,
         invocationId: fleetInvocationId(agent.metadata),
         reason: 'released',
       });
     }
     return {
       invocation_id: invocation.id,
       action_name: 'release',
       handler_agent_id: null,
-      handler_node_id: agent.locationNodeId,
+      handler_node_id: exitNodeId,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/action.ts` around lines 771 - 784, Update the
local release/delete flow around emitAgentExitedEffects and the returned
handler_node_id to use the resolved nodeId from the liveness-detection path
instead of agent.locationNodeId. Preserve the existing completionDeps and
completed checks, but ensure legacy self-connected agents with a null
locationNodeId emit agent.exited and report the resolved node identifier.
🧹 Nitpick comments (6)
packages/engine/src/engine/agent.ts (3)

353-364: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider the write cost of sweeping on every read path.

getAgentByName (Line 193) and listAgents both call sweepStaleAgents before reading. Each call issues two UPDATE statements even when no row matches. On SQLite and D1 each statement takes the write lock, so agent detail and roster reads now serialize behind the writer.

Two options reduce this:

  • Run a cheap SELECT first and issue the UPDATE statements only when a candidate row exists.
  • Rely on the periodic adapter sweep for durability and keep the request path read-only, since effectiveAgentStatus already derives the correct public status.

Also consider an index on (workspace_id, status, last_seen) to keep both predicates from scanning all workspace rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/agent.ts` around lines 353 - 364, Avoid issuing
unconditional UPDATE statements from sweepStaleAgents on read paths used by
getAgentByName and listAgents; either perform a lightweight candidate SELECT
before updating or remove request-path sweeping and rely on effectiveAgentStatus
plus the periodic adapter sweep. If retaining the sweep, add or reuse an index
covering workspaceId, status, and lastSeen to avoid full scans.

353-379: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The returned count mixes clamped rows with expired rows.

sweepStaleAgents returns normalized.length + result.length. A clamped future timestamp is not a stale agent. A caller that reports this number as "stale agents marked offline" reports an incorrect value. Return the two counts separately, or return only result.length.

♻️ Proposed refactor
-export async function sweepStaleAgents(db: Db, workspaceId?: string): Promise<number> {
+export async function sweepStaleAgents(
+  db: Db,
+  workspaceId?: string,
+): Promise<{ clamped: number; expired: number }> {
@@
-  return normalized.length + result.length;
+  return { clamped: normalized.length, expired: result.length };
 }

Update the Node adapter sweep call site and any other caller that consumes the number.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/agent.ts` around lines 353 - 379, Update
sweepStaleAgents to return the count of agents actually marked offline, using
result.length rather than combining it with normalized.length; adjust the Node
adapter sweep call site and any other consumers to use the corrected stale-agent
count.

77-140: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the returning-row lookup independent of write order.

results[1] depends on the agents insert staying the second element of writes. A future write added before it silently shifts the index, and agent becomes the wrong row type. Capture the index when you push the statement.

♻️ Proposed refactor
   let agent;
   try {
+    let agentWriteIndex = -1;
     const results = await runAtomicWrites(db, (writeDb) => {
       const writes: AtomicWrite[] = [writeDb.insert(nodes).values({
@@
         .returning()];
+      agentWriteIndex = writes.length - 1;
 
       if (generalChannel) {
@@
       return writes;
     });
-    [agent] = results[1] as (typeof agents.$inferSelect)[];
+    [agent] = results[agentWriteIndex] as (typeof agents.$inferSelect)[];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/agent.ts` around lines 77 - 140, In the
transaction around runAtomicWrites, capture the array index returned when
pushing the agents insert into writes, then use that saved index instead of
hard-coded results[1] when assigning agent. Keep the existing write ordering and
returned-row type handling unchanged.
packages/engine/src/__tests__/conformance/agentLifecycle.test.ts (1)

285-292: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Drop the trigger after the test.

The test creates fail_local_release_completion and never removes it. If the harness reuses one SQLite handle across tests in this file, the trigger aborts any later local release completion, and failures then depend on declaration order. Add an explicit cleanup so the test does not rely on per-test database disposal.

♻️ Proposed change
     expect(invocation.status).toBe('pending');
+    stack.runtime.handle.sqlite.exec('DROP TRIGGER IF EXISTS fail_local_release_completion');
   });

Prefer try/finally if an assertion can throw before the cleanup runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/__tests__/conformance/agentLifecycle.test.ts` around
lines 285 - 292, Update the test creating the fail_local_release_completion
trigger to wrap its assertions and execution in a try/finally block, and drop
the trigger in the finally cleanup using the same SQLite handle. Ensure cleanup
runs even when an assertion throws and preserve the trigger’s existing failure
behavior during the test.
packages/engine/src/engine/action.ts (2)

767-767: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Both runAtomicWrites callers recover typed rows by position. runAtomicWrites returns unknown[], and each caller picks a result by index and casts it without a check. If a write is later added or reordered, the cast still compiles and the wrong element is read.

  • packages/engine/src/engine/action.ts#L767-L767: capture the index of the completion write when you push it, and verify the value is an array before you use completed.length to gate the agent.exited emission.
  • packages/engine/src/engine/agent.ts#L138-L140: capture the index of the agents insert when you push it, instead of hardcoding results[1].
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/action.ts` at line 767, Update
packages/engine/src/engine/action.ts lines 767-767 in the runAtomicWrites caller
to capture the completion write’s index when enqueueing it, then retrieve that
result by index and verify it is an array before using completed.length to gate
agent.exited emission. Update packages/engine/src/engine/agent.ts lines 138-140
in its runAtomicWrites caller to capture the agents insert index when enqueueing
it and use that index instead of hardcoded results[1].

808-820: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Order the active bindings, and reuse directNodeIdForAgent.

Two points on the resolution chain:

  1. activeBindings[0]?.nodeId selects an arbitrary row. The query has no orderBy, and agent_node_bindings carries a priority column. If an agent holds several active bindings and none matches agent.locationNodeId or the implicit direct node, the selected host can differ between attempts. Order the select by priority so the choice is deterministic.
  2. The literal node_direct_${agent.id} appears here and at Line 747. directNodeIdForAgent in packages/engine/src/engine/node.ts already produces this id. Import it so the format has one definition.
♻️ Proposed refactor
   const activeBindings = await args.db
     .select({ nodeId: agentNodeBindings.nodeId })
     .from(agentNodeBindings)
     .where(and(
       eq(agentNodeBindings.workspaceId, args.workspaceId),
       eq(agentNodeBindings.agentId, agent.id),
       eq(agentNodeBindings.status, 'active'),
-    ));
-  const implicitDirectNodeId = `node_direct_${agent.id}`;
+    ))
+    .orderBy(desc(agentNodeBindings.priority), agentNodeBindings.createdAt);
+  const implicitDirectNodeId = directNodeIdForAgent(agent.id);

Add the imports:

import { desc } from 'drizzle-orm';
import { directNodeIdForAgent } from './node.js';

Apply the same helper at Line 747.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/action.ts` around lines 808 - 820, Update the
active-bindings query around agent node resolution to order results by the
binding priority column, preserving deterministic fallback selection via
activeBindings[0]. Replace the local node_direct_${agent.id} construction and
the equivalent literal near the other occurrence with the imported
directNodeIdForAgent helper, adding the required drizzle-orm and node.js
imports.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/engine/src/engine/action.ts`:
- Around line 771-784: Update the local release/delete flow around
emitAgentExitedEffects and the returned handler_node_id to use the resolved
nodeId from the liveness-detection path instead of agent.locationNodeId.
Preserve the existing completionDeps and completed checks, but ensure legacy
self-connected agents with a null locationNodeId emit agent.exited and report
the resolved node identifier.

---

Nitpick comments:
In `@packages/engine/src/__tests__/conformance/agentLifecycle.test.ts`:
- Around line 285-292: Update the test creating the
fail_local_release_completion trigger to wrap its assertions and execution in a
try/finally block, and drop the trigger in the finally cleanup using the same
SQLite handle. Ensure cleanup runs even when an assertion throws and preserve
the trigger’s existing failure behavior during the test.

In `@packages/engine/src/engine/action.ts`:
- Line 767: Update packages/engine/src/engine/action.ts lines 767-767 in the
runAtomicWrites caller to capture the completion write’s index when enqueueing
it, then retrieve that result by index and verify it is an array before using
completed.length to gate agent.exited emission. Update
packages/engine/src/engine/agent.ts lines 138-140 in its runAtomicWrites caller
to capture the agents insert index when enqueueing it and use that index instead
of hardcoded results[1].
- Around line 808-820: Update the active-bindings query around agent node
resolution to order results by the binding priority column, preserving
deterministic fallback selection via activeBindings[0]. Replace the local
node_direct_${agent.id} construction and the equivalent literal near the other
occurrence with the imported directNodeIdForAgent helper, adding the required
drizzle-orm and node.js imports.

In `@packages/engine/src/engine/agent.ts`:
- Around line 353-364: Avoid issuing unconditional UPDATE statements from
sweepStaleAgents on read paths used by getAgentByName and listAgents; either
perform a lightweight candidate SELECT before updating or remove request-path
sweeping and rely on effectiveAgentStatus plus the periodic adapter sweep. If
retaining the sweep, add or reuse an index covering workspaceId, status, and
lastSeen to avoid full scans.
- Around line 353-379: Update sweepStaleAgents to return the count of agents
actually marked offline, using result.length rather than combining it with
normalized.length; adjust the Node adapter sweep call site and any other
consumers to use the corrected stale-agent count.
- Around line 77-140: In the transaction around runAtomicWrites, capture the
array index returned when pushing the agents insert into writes, then use that
saved index instead of hard-coded results[1] when assigning agent. Keep the
existing write ordering and returned-row type handling unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fc2e5da4-232e-4cea-8351-b97620c6e9fd

📥 Commits

Reviewing files that changed from the base of the PR and between c280a96 and f852dec.

📒 Files selected for processing (3)
  • packages/engine/src/__tests__/conformance/agentLifecycle.test.ts
  • packages/engine/src/engine/action.ts
  • packages/engine/src/engine/agent.ts

@miyaontherelay

Copy link
Copy Markdown
Contributor

Additional CodeRabbit major fixed in 7801180: local delete/reap now uses the resolved host node for both handler_node_id and agent.exited. The legacy self_connected/null-location regression now reads back the completed response and durable workspace event, each with node_direct_<agent_id>. Validation remains green: engine build, lint, and agentLifecycle 9/9.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/engine/src/engine/action.ts
@khaliqgant

Copy link
Copy Markdown
Member

Before this merges: restoring the sweep loosens an identity boundary on the resident-roster registration path

This is a presence fix with a security side effect that is invisible from inside this issue. Flagging it here rather than in the issues where it was found, because the decision happens on merge of this one.

The coupling

registerAgentViaNode (packages/engine/src/engine/node.ts:1118) is how every broker-spawned agent registers. It reclaims a name via onConflictDoUpdate that overwrites tokenHash, guarded by:

setWhere: or(
  ne(agents.status, 'active'),
  and(
    eq(agents.locationType, 'via_node'),
    or(
      eq(agents.locationNodeId, nodeId),
      sql`${agents.locationNodeId} = 'node_direct_' || ${agents.id}`,
    ),
  ),
),

Read the first disjunct against today's reality. Because the sweep has had no caller, 'active' is never revoked — measured on a live workspace on 2026-08-07, 305 of 329 'active' records have lastSeen older than five minutes, and the oldest has been 'active' for 23.9 days. So ne(status,'active') is false for essentially every registered agent, the node-identity condition always does the work, and reclaim is effectively restricted to the same node.

Restoring the sweep inverts that. Every record it flips to 'offline' makes the first disjunct true, the OR short-circuits, and the row becomes claimable by any node on name alone — no node match, no identity proof. The tokenHash overwrite evicts whoever held it.

That is the AR-448 duplicate-agent/name-takeover class that AgentWorkforce/relay#1438 was written to close on the broker's own registration path. This path is not covered by that gate.

I am not arguing the sweep shouldn't be restored — presence being permanently stale is its own serious problem, and the current protection is an accident rather than a design. The ask is that the setWhere be revisited in the same change, so the identity property is chosen deliberately instead of being altered as a side effect of fixing presence.

Two more interactions worth checking before merge

1. The delete_agent bullet will not work as written for any agent that has ever spoken. This issue proposes letting delete_agent reap an undispatchable record locally. deleteAgent (packages/engine/src/engine/agent.ts:277) issues a bare db.delete, and four FKs to agents.id are declared without onDelete, i.e. RESTRICT: channels.created_by (schema.ts:455), messages sender (:503), files (:666), webhooks.created_by (:759). The delete is refused server-side for any agent with history — which is every agent worth reaping. Binding and direct-node cleanup won't change that; the refusal is on the agents row itself. #309 covers this and proposes a tombstone rename instead of a delete (cascade would destroy the message history). Worth reconciling the two before implementing this bullet, or it will pass tests against fresh fixtures and fail against real agents.

2. "Deriving public active from last_seen" overlaps #312. That issue reports that stored 'active' currently serializes as "unknown" — the response body and the stored column disagree, which is what made this whole area so hard to reason about. If this change alters the derived value, please confirm what happens to the stored column, since setWhere above branches on the stored value, not the serialized one. Fixing the derivation while leaving the column unmaintained (or vice versa) would replace one misleading answer with a differently misleading one.

Suggested sequencing

Decide these together rather than in isolation:

Provenance

Line references read from relaycast main at 08ddec7. The deployed engine is not this checkout — it carries a serialization mapping present in no local tree (see #312), so treat file:line as provisional against the deployed build. The measurements above were taken against the live workspace and do not depend on which build is deployed.

@khaliqgant

Copy link
Copy Markdown
Member

Follow-up after reading the diff — one correction to my own comment, and the timing is sharper than I said

I wrote the above from the description. Having now read the diff, two things change. The core concern gets more urgent, and one of my pointers was wrong.

1. The sweep is not a background timer — it fires on every roster read

listAgents and getAgentByName both gain a synchronous await sweepStaleAgents(db, workspaceId) before selecting. So this does not flip records gradually in the background; the first agent list after deploy flips every stale record at once.

Measured on a live workspace on 2026-08-07: 305 records currently stored 'active' have lastSeen older than the TTL. On the first roster read after this merges, all 305 become 'offline' — and at that moment every one of them satisfies ne(agents.status, 'active'), the first disjunct of registerAgentViaNode's setWhere, making each claimable by any node on name alone with a tokenHash overwrite.

That is a one-shot transition of 305 agent identities from "reclaimable only by their own node" to "reclaimable by anyone", triggered by a read. Worth deciding deliberately.

2. Correction: the local reap does not go through deleteAgent

My comment above pointed at deleteAgent (packages/engine/src/engine/agent.ts:277). That was wrong — this PR does not touch that function. The local reap inlines its own delete inside the atomic write batch:

// This helper is only used for delete_agent releases. Non-delete
// releases fail closed when no live host can receive the invocation.
writes.push(writeDb
  .delete(agents)
  .where(and(
    eq(agents.workspaceId, args.workspaceId),
    eq(agents.id, agent.id),
    invocationIsOpen,
  )));

The conclusion is unchanged and the consequence is worse. It is still a bare DELETE on agents, so the four FKs declared without onDeletechannels.created_by (schema.ts:455), messages sender (:503), files (:666), webhooks.created_by (:759) — still RESTRICT it for any agent that has ever created a channel, sent a message, uploaded a file, or created a webhook. That is every agent worth reaping.

Because this delete sits inside runAtomic alongside the binding update and the actionInvocations completion, an FK refusal does not fail just the delete — it aborts the whole atomic unit, so the invocation does not complete either. The reap path would fail exactly on the agents it exists to clean up, and fail as a transaction abort rather than a clear error.

This is very likely to pass tests against freshly-created fixtures and fail against any real agent. Recommend a fixture with at least one sent message before merge. #309 proposes the tombstone-rename alternative that frees the name without touching history.

3. effectiveAgentStatus looks like it resolves #312 — please confirm the stored/derived split is intended

The switch from status: a.status to status: effectiveAgentStatus(a) addresses #312 (live agents currently serializing as "unknown"). Two things to confirm rather than assume:

  • After this, the serialized value is derived from lastSeen while setWhere still branches on the stored column. That split is defensible, but it means the API view and the identity guard are deliberately reading different things — worth a comment in the code so the next investigator does not spend an afternoon on it, as happened here.
  • The current "unknown" serialization exists in the deployed build but in no local checkout, so I cannot tell whether this replaces that mapping or coexists with it. Verifying against the deployed build before merge would settle it.

Timing

This PR is MERGEABLE / mergeStateStatus: CLEAN with no blocking review as of 2026-08-07T11:47Z. Nothing above is an objection to the goal — restoring presence is clearly right and the current state is an accident. The ask is that items 1 and 2 be resolved in this change rather than after it.

The local reap inlined a bare `DELETE` on `agents` inside the same atomic
unit as the binding update and the invocation completion. Four foreign keys
reference `agents.id` without `onDelete` — channels.created_by
(schema.ts:455), messages.agent_id (:503), files.uploaded_by (:666),
webhooks.created_by (:759) — so SQLite refuses the delete for any agent that
has ever created a channel, sent a message, uploaded a file, or created a
webhook. Because the statement sits inside `runAtomicWrites`, that refusal
aborted the whole unit, so the invocation never completed either: a
transaction abort rather than a legible error, on exactly the agents the
reap exists to clean up.

Every existing `delete_agent` fixture registered a fresh agent and released
it immediately, so the suite could not observe this. The added fixture posts
one message first and reproduced it as
`SQLITE_CONSTRAINT_FOREIGNKEY: FOREIGN KEY constraint failed` (HTTP 500)
before this change.

Cascade is not an alternative — it would delete the agent's message history,
which is the thing worth keeping — and `messages.agent_id` is NOT NULL, so
`set null` cannot apply. That leaves the tombstone rename proposed in #309:
the unique key is `(workspace_id, name)`, so freeing the name only requires
the name to stop colliding, not the row to disappear.

The released row keeps its id, so every FK target stays valid and every
message keeps its sender. It is renamed to `<name>#released-<agentId>`,
marked `released`, and stamped with `metadata.release`. Two deliberate
choices beyond #309's sketch:

- the tombstone is keyed on the agent id rather than a timestamp. It runs
  inside an atomic batch, where a unique-constraint violation would abort
  the whole unit — reintroducing the failure being fixed. The id is already
  unique per workspace, so the name cannot collide and a repeat release is
  idempotent. The release time is preserved in `metadata.release.releasedAt`.
- `token_hash` is rotated to an unheld value. The row survives the release,
  and `token_hash` is NOT NULL UNIQUE so it cannot be cleared; without the
  rotation a released agent's old token would keep authenticating.

`listAgents` now excludes released rows so `agent list` does not fill with
tombstones.

Refs #309
Restoring the presence sweep loosened an identity boundary as a side
effect, and the trigger was a read.

`registerAgentViaNode` reclaims a name via `onConflictDoUpdate` and
overwrites `token_hash`, so a permitted reclaim is a full credential
handover: the incumbent's token stops working and the claiming node is
handed a live `at_live_` token for the same row. That decision was guarded
by `setWhere: or(ne(agents.status, 'active'), <owning node>)`.

`status` is maintained by `sweepStaleAgents`, which this branch calls
synchronously from `listAgents` and `getAgentByName`. So a plain `agent
list` rewrote the column, and every record it flipped to 'offline'
satisfied the first disjunct and moved from "reclaimable only by its own
node" to "reclaimable by any node, on name alone". A read widened who may
claim an identity.

Presence and identity are different questions and must not share a field.
The first disjunct now gates on observed silence — `last_seen` older than
`AGENT_RECLAIM_GRACE_MS` — which reads cannot write. The owning-node
disjunct is unchanged, so a node restart still re-registers freely.

An agent is absent from the roster after 5 minutes of silence and its name
is reclaimable by a stranger after 24 hours. Between those points it reads
as away and its identity is still its own.

The grace value is measured, not guessed (relaycast-cloud, 2026-08-07): of
the 1,578 records this governs, silence was <5m: 5, 5m-24h: 9, 1d-7d: 1,
>7d: 1,568. At 24h the eligible set is 1,569 against 1,578, so it costs
essentially nothing steady-state while protecting the ~14 identities a
human would still call live. The reasoning is recorded on the constant.

Scope, measured on the same data: this workspace has 8 genuinely loosened
records, not the 305 first estimated — 300 of its 308 stale-active rows sit
on their implicit direct node and were already reclaimable by any node with
no deploy at all. Fleet-wide the split is 1,578 loosened against 14,074
already open. The larger standing hole is the `location_node_id =
'node_direct_' || id` disjunct, which this change does not touch; that is
#311's subject.

Also fixes the reverse defect: a row stored 'active' but silent for weeks
was previously NOT reclaimable, so a name stranded by a dead node stayed
stranded. The grace window now expires into recovery.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/engine/src/engine/action.ts (1)

694-807: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Guard local completion against stale agent state.

completeLocally uses an agent snapshot taken before the invocation and later updates by agent.id only. If another node re-registers the agent, or another release completes first, this invocation can tombstone the newer agent state and still mark itself completed.

Make the atomic unit compare the current agent ownership state, such as the captured tokenHash and active binding identity, before it deactivates bindings or completes the invocation. If the comparison fails, do not return completed. Reload and route the current state, or fail the stale invocation safely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/action.ts` around lines 694 - 807, The
completeLocally atomic workflow must reject stale agent snapshots before
mutating state. In completeLocally, require the current agents row to match the
captured agent ownership (including tokenHash) and require the expected active
binding identity before deactivating bindings, renaming the agent, deleting the
direct node, or completing the invocation; if validation fails, avoid returning
completed and instead reload and route the current state or safely fail the
invocation.
🧹 Nitpick comments (1)
packages/engine/src/engine/agent.ts (1)

210-212: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Move stale-status persistence off the roster request path.

Line 212 waits for a workspace-wide write before every roster response. effectiveAgentStatus already derives the response status. A workspace with many stale agents can block roster reads and concurrent writes during the first sweep.

Run durable sweeping in the periodic presence worker, or batch it outside this request path. Keep effective-status derivation in the read path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/agent.ts` around lines 210 - 212, Remove the
awaited sweepStaleAgents call from the roster request path in the surrounding
agent handler, while preserving effectiveAgentStatus derivation for response
correctness. Move durable stale-agent persistence to the periodic presence
worker or another batched background flow so roster reads do not perform
workspace-wide writes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/engine/src/engine/action.ts`:
- Around line 694-807: The completeLocally atomic workflow must reject stale
agent snapshots before mutating state. In completeLocally, require the current
agents row to match the captured agent ownership (including tokenHash) and
require the expected active binding identity before deactivating bindings,
renaming the agent, deleting the direct node, or completing the invocation; if
validation fails, avoid returning completed and instead reload and route the
current state or safely fail the invocation.

---

Nitpick comments:
In `@packages/engine/src/engine/agent.ts`:
- Around line 210-212: Remove the awaited sweepStaleAgents call from the roster
request path in the surrounding agent handler, while preserving
effectiveAgentStatus derivation for response correctness. Move durable
stale-agent persistence to the periodic presence worker or another batched
background flow so roster reads do not perform workspace-wide writes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 65fbfce7-69bb-4c05-bb6e-766dfba52320

📥 Commits

Reviewing files that changed from the base of the PR and between f852dec and 9f68124.

📒 Files selected for processing (5)
  • packages/engine/src/__tests__/conformance/agentLifecycle.test.ts
  • packages/engine/src/__tests__/conformance/agentNameReclaim.test.ts
  • packages/engine/src/engine/action.ts
  • packages/engine/src/engine/agent.ts
  • packages/engine/src/engine/node.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/engine/src/engine/agent.ts">

<violation number="1" location="packages/engine/src/engine/agent.ts:218">
P2: The `/v1/agents/presence` response includes released tombstones even though they are no longer roster members. Applying the released-row exclusion in the presence query would keep presence consumers from seeing historical rows as live agents.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

.from(agents)
// Released rows are tombstones retained only to keep history attributable;
// they are not roster members, so `agent list` must not fill with them.
.where(and(eq(agents.workspaceId, workspaceId), ne(agents.status, RELEASED_AGENT_STATUS)));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The /v1/agents/presence response includes released tombstones even though they are no longer roster members. Applying the released-row exclusion in the presence query would keep presence consumers from seeing historical rows as live agents.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/engine/agent.ts, line 218:

<comment>The `/v1/agents/presence` response includes released tombstones even though they are no longer roster members. Applying the released-row exclusion in the presence query would keep presence consumers from seeing historical rows as live agents.</comment>

<file context>
@@ -170,7 +213,9 @@ export async function listAgents(db: Db, workspaceId: string, status?: string) {
-    .where(eq(agents.workspaceId, workspaceId));
+    // Released rows are tombstones retained only to keep history attributable;
+    // they are not roster members, so `agent list` must not fill with them.
+    .where(and(eq(agents.workspaceId, workspaceId), ne(agents.status, RELEASED_AGENT_STATUS)));
   const requestedStatus = status === 'online' ? 'active' : status;
 
</file context>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirmed and fixed. listAgents was filtered but getPresence runs its own query against agents with no status predicate, so releasing a name made it reappear on /v1/agents/presence as a permanently offline agent rather than disappearing.

Filtering one roster surface and not the other is worse than filtering neither — it makes the tombstone look like a real second agent to exactly the consumers watching for liveness.

Fixed in getPresence. Test keeps released tombstones out of the roster and the presence view asserts both surfaces in one go, and asserts both that the original name is gone and that no #released- name is present, so a future surface that leaks the tombstone under its new name is also caught.

Comment thread packages/engine/src/engine/agent.ts Outdated
Comment thread packages/engine/src/engine/action.ts Outdated
Comment thread packages/engine/src/engine/node.ts Outdated
That test asserts no new behaviour — it passes before and after the guard
change. It pins a decision: gating reclaim on node identity alone was
rejected because an agent whose node dies and respawns elsewhere could never
reclaim its own name, and a stranded name has no recovery path short of
relaycast#309.

A test that guards a decision rather than a behaviour reads like a redundant
one, and is the first to be deleted by someone simplifying the disjunct it
protects. Say so in the test.
…indings

Four findings from cubic on the previous two commits. All verified against
the code before being actioned; one falsifies a claim I made in a comment.

1. The id-keyed tombstone was NOT collision-free, as claimed.

   The argument was "the agent id is unique per workspace, so the name cannot
   collide". That holds only if nothing else can occupy the namespace, and
   agent names are validated as `z.string().min(1)` — arbitrary strings. A
   caller could pre-register `<victim>#released-<victimId>`, and the victim's
   release would then hit `UNIQUE(workspace_id, name)` inside the atomic batch
   and abort the whole unit: exactly the failure the tombstone exists to
   avoid, reachable on demand.

   Fixed at the root rather than by adding entropy: `#released-` is now a
   reserved marker rejected on both registration paths (`registerAgent` and
   `registerAgentViaNode`), which is what makes the id-keyed name actually
   collision-free. Production has zero existing names containing the marker,
   so nothing is grandfathered out.

2. `/v1/agents/presence` still listed released tombstones. `listAgents` was
   filtered but `getPresence` runs its own query, so releasing a name made it
   reappear as a permanently offline agent instead of disappearing. Filtering
   one roster surface and not the other is worse than filtering neither.

3. The local reap discarded the caller's release reason, hardcoding
   `reason: 'released'`, while the dispatched path records the supplied one.
   Now uses the same `release: { reason, released_at, previous_name }` shape,
   so an audit does not have to know which path released the agent.

4. The reclaim guard's comment overclaimed. It said reads do not move
   `last_seen`; the sweep does write it, clamping a FUTURE timestamp back to
   the server clock. The security conclusion is unchanged — the clamp writes
   `now`, and the gate needs `now - AGENT_RECLAIM_GRACE_MS`, so a read still
   cannot make a row claimable — but "reads never touch this column" was
   false, and a security-sensitive comment that overstates its guarantee is
   how the next person justifies a change it does not actually cover.

Each fix has a test that fails without it.
@khaliqgant
khaliqgant merged commit f1ded59 into main Aug 7, 2026
5 checks passed
@khaliqgant
khaliqgant deleted the fix/agent-presence-lifecycle branch August 7, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GET /v1/agents reports every live agent as status 'unknown' while the column holds 'active' — the filter and the body disagree

4 participants