Skip to content

Stop silently discarding agent metadata on registration and spawn - #1436

Open
khaliqgant wants to merge 2 commits into
mainfrom
relay/spawn-identity-metadata
Open

Stop silently discarding agent metadata on registration and spawn#1436
khaliqgant wants to merge 2 commits into
mainfrom
relay/spawn-identity-metadata

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 6, 2026

Copy link
Copy Markdown
Member

Delegation identity — organization, project, workstream, role, reportsTo — belongs on the Relaycast agent record, where any consumer can read it. The record already has a first-class metadata bag, and the fleet spawn path already writes metadata.fleet into it (451 of 745 agents in one live workspace carry non-empty metadata). Identity cannot get there by either available route, so consumers fall back to inferring a hierarchy from the agent's name.

That fallback is a guess, and for a multi-token project slug it is not even a recoverable one — nothing in chief-delegation-governance-dispatch-contract-worker marks where the project ends and the workstream begins. A consumer splitting at the first hyphen reads cloud-chief-yc-demo-delivery-lead as project cloud.

Two independent gaps

1. register_agent accepted metadata and threw it away.

registerAgentWithRebind short-circuits when strict worker identity is on and the session already holds a token for that name, returning the cached token without ever calling registerOrRotate. The short-circuit is right about tokens and wrong about writes — a caller supplying metadata or persona is asking for the record to change, and got back success, no warnings, and an untouched record.

Verified against installed relay CLI 11.2.0 before writing any code. A call carrying a full identity bag returned {name, token, registered_name, warnings: []}; reading the record back afterwards showed only the platform's own metadata.fleet. A silent discard on a documented parameter is worse than a rejection, because the caller has no way to notice.

Now a supplied metadata or persona falls through to the write. The token-only path is unchanged and still short-circuits, so a bare re-registration does not rotate a token for nothing.

2. The fleet spawn action could not carry metadata at all.

Its input schema has no metadata parameter, so identity cannot be supplied at spawn even in principle. Added and forwarded — this is what lets an agent record carry its identity from birth rather than depending on a follow-up write that may never land.

Together these make the durable path reachable: a dispatcher can stamp a worker at spawn, and an agent can correct its own record afterwards.

Why this matters beyond one dashboard

Without it there is no route by which a dispatcher can attribute a worker it spawned. Chief's delegation gate (AgentWorkforce/chief#24) currently compensates with a local ledger that the Cloud dashboard cannot read — it closes the loop on one side only. This PR is what removes the need for that compensation.

Tests

packages/cli/src/cli/agent-relay-mcp.test.ts — write-through on metadata, write-through on persona, and that the token-only short-circuit survives unchanged.
packages/cli/src/cli/agent-relay-mcp.startup.test.ts — spawn forwards identity metadata into the action input.

Two pre-existing failures in agent-relay-mcp.startup.test.ts are unrelated and reproduce on an unmodified checkout (24 passed / 2 failed at baseline; 34 passed / same 2 failed with this change). They assert on telemetry context and pick up local machine configuration.

🤖 Generated with Claude Code

Review in cubic

@cursor

cursor Bot commented Aug 6, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c212926c-e7d8-4ceb-a737-9270b1261e09

📥 Commits

Reviewing files that changed from the base of the PR and between 18a0634 and 1ffbaca.

📒 Files selected for processing (2)
  • packages/cli/src/cli/agent-relay-mcp.test.ts
  • packages/cli/src/cli/agent-relay-mcp.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/cli/src/cli/agent-relay-mcp.test.ts
  • packages/cli/src/cli/agent-relay-mcp.ts

📝 Walkthrough

Walkthrough

Registration now accepts optional metadata verification and reports whether supplied metadata persisted. Persona or metadata updates bypass the cached-token path, while token-only requests retain it. The fleet spawn tool accepts and forwards optional metadata.

Changes

Agent identity updates

Layer / File(s) Summary
Registration metadata handling
packages/cli/src/cli/agent-relay-mcp.ts, packages/cli/src/cli/agent-relay-mcp.test.ts
registerAgentWithRebind accepts verifyMetadata. Registration verifies persisted metadata, reports verification states, and returns warnings when verification cannot complete. Tests cover persistence, platform-field preservation, read-back failures, persona-triggered registration, unchecked metadata, and token-only caching.
Spawn metadata forwarding
packages/cli/src/cli/agent-relay-mcp.ts, packages/cli/src/cli/agent-relay-mcp.startup.test.ts
The spawn schema accepts metadata and forwards it to the generated invocation. The startup test verifies the forwarded delegation identity metadata.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested labels: size:L

Suggested reviewers: willwashburn

Poem

A rabbit records each role and name,
Then checks the relay record came.
Cached tokens wait when fields stay still,
Spawn carries metadata at will.
Hop, hop—the agent trail is clear!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: preserving agent metadata during registration and spawn.
Description check ✅ Passed The description explains the changes and tests in detail, although it does not use the template headings or test checklist.
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.
✨ 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 relay/spawn-identity-metadata

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

const result = await relay.agents.registerOrRotate({
name: effectiveName,
type: effectiveType,
persona,
metadata,
});

P1 Badge Persist metadata after reclaiming the registered identity

When a strict session already owns this agent, falling through to registerOrRotate still does not update the existing record: that operation reclaims the identity and rotates its token, as demonstrated by the existing create-conflict → get-agent → rotate-token sequence in crates/broker/src/relaycast/auth.rs, without an agent update request. Consequently, the exact metadata/persona correction scenario targeted here still returns success while leaving those fields unchanged; explicitly update the record after reclaiming it rather than treating token rotation as the write.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

...(model ? { model } : {}),
...(session_ref ? { session_ref } : {}),
...(target_node ? { target_node } : {}),
...(metadata ? { metadata } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Carry spawn metadata through the node registration path

For the default fleet spawn path, adding metadata to the action input does not attach it to the spawned agent record. packages/fleet/src/index.ts parses the loose input but constructs an AgentSpec without metadata, while the built-in broker action handler extracts only fields such as name, CLI, task, channel, and model and its AgentRegister wire type has no metadata field. Thus callers receive a queued invocation containing their metadata, but the spawned record still lacks the promised delegation identity; the metadata must be threaded through the fleet/broker registration path as well.

Useful? React with 👍 / 👎.

Comment on lines +709 to +711
metadata: z
.record(z.string(), z.unknown())
.optional()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add the user-visible MCP fix to Unreleased

This commit changes the public register_agent and spawn MCP behavior but leaves CHANGELOG.md unchanged, so the pending release narrative will omit the new metadata support. Add a concise impact-first entry under the existing [Unreleased - Patch] section as required by the repository changelog policy.

AGENTS.md reference: AGENTS.md:L31-L33

Useful? React with 👍 / 👎.

Delegation identity — organization, project, workstream, role, reportsTo —
belongs on the Relaycast agent record, where any consumer can read it.
The record already has a first-class `metadata` bag and the fleet spawn
path already writes `metadata.fleet` into it. Identity cannot get there
by either available route, so consumers fall back to inferring a
hierarchy from the agent's name. That is a guess, and for a multi-token
project slug it is not even a recoverable one: nothing in
`chief-delegation-governance-dispatch-contract-worker` marks where the
project ends and the workstream begins.

Three gaps, all fixed here.

1. register_agent accepted `metadata` and threw it away.

   `registerAgentWithRebind` short-circuits when strict worker identity
   is on and the session already holds a token for the name, returning
   the cached token without calling `registerOrRotate`. The short-circuit
   is right about tokens and wrong about writes: a caller supplying
   `metadata` or `persona` is asking for the record to change, and got
   back success, no warnings, and an untouched record.

   Verified against installed relay CLI 11.2.0 before writing any code —
   a call carrying a full identity bag returned `{name, token,
   registered_name, warnings: []}`, and reading the record back showed
   only the platform's own `metadata.fleet`.

   Now a supplied metadata or persona falls through to the write. The
   token-only path is unchanged and still short-circuits, so a bare
   re-registration does not rotate a token for nothing.

2. The caller could not tell whether the write landed.

   This is why the discard went unnoticed for so long. A registration
   response carries {id, name, token, status, createdAt} and nothing
   else — `normalizeAgentRegistration` drops the rest — so success and
   silent failure are the same bytes. Fixing the passthrough without
   fixing this would leave a worse bug than no passthrough at all,
   because it would look like it worked.

   `verify_metadata` reads the record back and reports `metadata_verified`
   as true or false, with a warning naming the missing keys. It is opt-in
   because verification costs a workspace listing and `add_agent` sends
   `metadata: {model}` on every spawn as a broker hint; making each of
   those refetch every agent would be a bad trade. When it is not
   requested the result is the literal 'unchecked', never a defaulted
   false or an omitted field — "nobody looked" is a different claim from
   "it is not there", and collapsing them is the same class of error as
   the silent discard itself.

3. The fleet spawn action could not carry metadata at all.

   Its input schema has no metadata parameter, so identity cannot be
   supplied at spawn even in principle. Added and forwarded, which is
   what lets an agent record carry its identity from birth rather than
   depending on a follow-up write that may never land.

Tests prove the round trip rather than the call: a stateful fake stores
what registration is given, and the test reads the record back and
asserts the fields are present and that the platform's own `fleet` block
was not clobbered. Asserting only that `registerOrRotate` was called with
metadata would repeat the exact mistake this change is about. The
not-persisted path, the read-back-failed path, and the unchecked path are
each covered.

Two pre-existing failures in agent-relay-mcp.startup.test.ts are
unrelated and reproduce on an unmodified checkout — they assert on
telemetry context and pick up local machine configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@khaliqgant
khaliqgant force-pushed the relay/spawn-identity-metadata branch from c956bdb to 18a0634 Compare August 6, 2026 02:44
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 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/cli/src/cli/agent-relay-mcp.test.ts`:
- Around line 101-106: Update the fleet metadata assertion in the relay agent
test so the custom message is passed to expect for record.metadata.fleet, while
keeping toEqual focused only on the expected value and preserving the existing
assertion behavior.

In `@packages/cli/src/cli/agent-relay-mcp.ts`:
- Around line 479-491: Update the metadata comparison in verifyMetadataLanded to
avoid relying on JSON.stringify’s insertion-order serialization for nested
objects. Use a stable, key-order-independent comparison or stable serialization
for each metadata value while preserving the existing missing-key detection and
fail-closed verification behavior.
🪄 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: 5505717e-1a10-4d03-ab22-335432f12e12

📥 Commits

Reviewing files that changed from the base of the PR and between b0151ff and 18a0634.

📒 Files selected for processing (3)
  • packages/cli/src/cli/agent-relay-mcp.startup.test.ts
  • packages/cli/src/cli/agent-relay-mcp.test.ts
  • packages/cli/src/cli/agent-relay-mcp.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/cli/src/cli/agent-relay-mcp.startup.test.ts

Comment on lines +101 to +106
const [record] = (await relay.agents.list()).filter((a) => a.name === 'WorkerA');
expect(record.metadata).toMatchObject(IDENTITY);
expect(record.metadata.fleet).toEqual({ nodeId: 'node_x' }, 'must not clobber platform keys');

expect(payload.metadata_verified).toBe(true);
expect(payload.warnings).toEqual([]);

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check how custom assertion messages are supplied in existing Vitest tests.
set -euo pipefail

rg -nP --type=ts -C 1 '\.toEqual\([^)]*,\s*['"'"'"]' -g '**/*.test.ts' | head -50

rg -nP --type=ts -C 1 'expect\([^,()]+,\s*['"'"'"]' -g '**/*.test.ts' | head -50

fd -t f -g 'vitest.config.*' --exec cat -n {}
fd -t f -g 'package.json' -d 3 --exec rg -n '"vitest"' {}

Repository: AgentWorkforce/relay

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "tracked candidate files:"
git ls-files | rg '(^|/)agent-relay-mcp\.test\.ts$|vitest|package\.json$' || true

echo
echo "target snippet:"
fd -a 'agent-relay-mcp\.test\.ts' . -x sh -c 'echo "--- $1"; sed -n "70,115p" "$1" | cat -n' sh {}

echo
echo "vitest deps from package manifests:"
fd -t f 'package\.json' -d 5 -x sh -c 'echo "--- $1"; rg -n "(\"`@vitest`|\"vitest\")" "$1" || true' sh {}

echo
echo "matcher second-arg and expect message patterns:"
rg -n --glob '*.test.ts' '\.toEqual\([^,)]+,\s*['"'"'"][^)]*\)' || true
rg -n --glob '*.test.ts' 'expect\s*\([^,()]+,\s*['"'"'"][^,)]+\)' || true

Repository: AgentWorkforce/relay

Length of output: 4891


🌐 Web query:

Vitest toEqual signature expects single argument expect value message custom message

💡 Result:

In Vitest, the standard toEqual matcher takes a single argument, which is the expected value to compare against the received value [1][2][3]. Although it is not explicitly highlighted in the primary API documentation, you can provide an optional custom failure message as a second argument to the expect function itself [4][5][6]. When used this way, the message acts as a prefix to the default error message if the assertion fails [5][6]. The signature for an assertion with a custom message is: expect(received, 'your custom message').toEqual(expected); Key points regarding this behavior: - Compatibility: This functionality allows the second argument of expect to serve as a custom message across various matchers [5][6]. - Behavior: If the assertion fails, the test will throw an error that includes your custom message [5][6]. If the message is omitted, empty, or undefined, it defaults to the standard matcher error message [6]. - Caveats: Because this is an expect function feature rather than a specific matcher parameter, it may behave unexpectedly if a specific matcher (like those provided by external libraries) also attempts to interpret a second argument [7]. Always verify that the matcher you are using is designed to receive only one argument before relying on this pattern [7].

Citations:


🌐 Web query:

Jest toEqual optional second argument message expect.toEqual actually accepts message

💡 Result:

No, Jest's standard expect.toEqual matcher does not accept an optional second argument for a custom error message [1][2][3]. The function signature is strictly expect.toEqual(expected) [1][2]. If you provide a second argument to toEqual, Jest will not treat it as a custom error message, and it may lead to unexpected behavior or assertion errors depending on your test implementation. To provide custom error messages in Jest, common approaches include: 1. Use Third-Party Packages: The library jest-expect-message is a widely used community solution that enables support for custom messages in assertions [4][5]. 2. Custom Matchers: You can use expect.extend to create your own matcher that wraps existing Jest matchers and includes custom logic to return a custom failure message [6][7]. 3. Manual Error Handling: For complex logic, developers often wrap the assertion in a try-catch block and manually throw a new Error with a custom message if the assertion fails [4][5].

Citations:


Move the custom message onto expect, not into toEqual.

expect(record.metadata.fleet, 'must not clobber platform keys').toEqual({ nodeId: 'node_x' })); has the expected type and keeps the message with this assertion.

🤖 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/cli/src/cli/agent-relay-mcp.test.ts` around lines 101 - 106, Update
the fleet metadata assertion in the relay agent test so the custom message is
passed to expect for record.metadata.fleet, while keeping toEqual focused only
on the expected value and preserving the existing assertion behavior.

Comment on lines +479 to +491
const stored = record.metadata ?? {};
const missing = keys.filter(
(key) => JSON.stringify(stored[key]) !== JSON.stringify(metadata[key])
);
if (missing.length === 0) return { verified: true, warning: '' };

return {
verified: false,
warning:
`Registered "${name}", but the metadata was not persisted: ${missing.join(', ')} ` +
`${missing.length === 1 ? 'is' : 'are'} missing or different on the record. ` +
`Treat this registration as unattributed.`,
};

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

JSON.stringify comparison is sensitive to object key order.

The comparison treats two semantically equal object values as different when their keys are serialized in a different order. The metadata schema is z.record(z.string(), z.unknown()), so a caller can send nested objects. If the platform re-serializes metadata with a different key order, verifyMetadataLanded reports metadata_verified: false and emits the "Treat this registration as unattributed" warning for a registration that actually persisted correctly.

The stated contract asks dispatchers to fail closed on this field. A false negative therefore rejects a good registration.

Compare with a stable serialization, or restrict the comparison to primitives.

🛠️ Proposed fix using stable key ordering
   const stored = record.metadata ?? {};
+  const stable = (value: unknown): string =>
+    JSON.stringify(value, (_key, inner) =>
+      inner && typeof inner === 'object' && !Array.isArray(inner)
+        ? Object.fromEntries(Object.entries(inner as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : 1)))
+        : inner
+    );
   const missing = keys.filter(
-    (key) => JSON.stringify(stored[key]) !== JSON.stringify(metadata[key])
+    (key) => stable(stored[key]) !== stable(metadata[key])
   );
🤖 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/cli/src/cli/agent-relay-mcp.ts` around lines 479 - 491, Update the
metadata comparison in verifyMetadataLanded to avoid relying on JSON.stringify’s
insertion-order serialization for nested objects. Use a stable,
key-order-independent comparison or stable serialization for each metadata value
while preserving the existing missing-key detection and fail-closed verification
behavior.

@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 2 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/cli/src/cli/agent-relay-mcp.ts">

<violation number="1" location="packages/cli/src/cli/agent-relay-mcp.ts:480">
P2: The metadata verification comparison uses JSON.stringify(stored[key]) !== JSON.stringify(metadata[key]) to detect mismatches. Since the metadata schema allows nested objects (z.record(z.string(), z.unknown())), this comparison is sensitive to key ordering — if the platform re-serializes an object with a different key order, this will report metadata_verified: false and trigger the 'unattributed' warning even though the registration actually persisted correctly. Consider using a stable/order-independent comparison (e.g., sorting object keys before stringifying) instead of raw JSON.stringify.</violation>
</file>

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

Re-trigger cubic

}

const stored = record.metadata ?? {};
const missing = keys.filter((key) => JSON.stringify(stored[key]) !== JSON.stringify(metadata[key]));

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 metadata verification comparison uses JSON.stringify(stored[key]) !== JSON.stringify(metadata[key]) to detect mismatches. Since the metadata schema allows nested objects (z.record(z.string(), z.unknown())), this comparison is sensitive to key ordering — if the platform re-serializes an object with a different key order, this will report metadata_verified: false and trigger the 'unattributed' warning even though the registration actually persisted correctly. Consider using a stable/order-independent comparison (e.g., sorting object keys before stringifying) instead of raw JSON.stringify.

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

<comment>The metadata verification comparison uses JSON.stringify(stored[key]) !== JSON.stringify(metadata[key]) to detect mismatches. Since the metadata schema allows nested objects (z.record(z.string(), z.unknown())), this comparison is sensitive to key ordering — if the platform re-serializes an object with a different key order, this will report metadata_verified: false and trigger the 'unattributed' warning even though the registration actually persisted correctly. Consider using a stable/order-independent comparison (e.g., sorting object keys before stringifying) instead of raw JSON.stringify.</comment>

<file context>
@@ -477,9 +477,7 @@ async function verifyMetadataLanded(
-  const missing = keys.filter(
-    (key) => JSON.stringify(stored[key]) !== JSON.stringify(metadata[key])
-  );
+  const missing = keys.filter((key) => JSON.stringify(stored[key]) !== JSON.stringify(metadata[key]));
   if (missing.length === 0) return { verified: true, warning: '' };
 
</file context>

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.

1 participant