Skip to content

feat: W3C traceparent propagation for A2A delegations (#7)#31

Merged
arniesaha merged 5 commits intomainfrom
feat/issue-7-trace-propagation
Apr 13, 2026
Merged

feat: W3C traceparent propagation for A2A delegations (#7)#31
arniesaha merged 5 commits intomainfrom
feat/issue-7-trace-propagation

Conversation

@arniesaha
Copy link
Copy Markdown
Owner

Summary

Implements bidirectional W3C traceparent propagation for Max ↔ Nix A2A delegations. Traces now appear as linked parent-child spans in Grafana, providing end-to-end visibility across machine boundaries.

Changes

src/a2a-server.ts

  • Added OpenTelemetry imports: context, propagation from '@opentelemetry/api'
  • POST /tasks endpoint:
    • Extracts W3C traceparent from incoming request headers via propagation.extract()
    • Wraps async worker execution in extracted context: context.with(incomingContext, executeWorker)
    • Wraps sync agent task execution in the same context for consistent behavior
    • Both paths (worker thread + synchronous prompt) now run within inherited trace
  • POST /tasks/stream endpoint: Also extracts and propagates trace context
  • Result: when Nix calls Max with traceparent, the task runs as a child span under that parent

Tests

  • tests/a2a-trace-propagation.test.ts (new) — 8 tests covering:
    • Valid W3C traceparent format extraction (00-traceId-parentId-flags)
    • Graceful fallback when traceparent is absent or malformed
    • Propagation through sync task execution (agent.prompt)
    • Propagation through async task execution (worker thread)
    • Co-propagation of AgentWeave headers + traceparent (full context fidelity)
    • Trace inheritance in callback scenarios

How it works

  • Max → Nix: nix-relay.ts delegateToNix already injects traceparent via propagation.inject()
  • Nix → Max: New—A2A server now extracts traceparent from incoming requests
  • Result: Full bidirectional trace propagation. In Grafana, you see:
    • Max's delegation span
    • ↳ Nix execution spans (child)
    • ↳ Any Max callbacks from Nix (grandchild)
    • All linked in one unified trace

7/7 test suites, 65/65 tests passing.

Closes #7

arniesaha and others added 5 commits April 11, 2026 23:19
## What's in this PR

### New: src/telegram-notify.ts
- Extracts relayTaskUpdateToTelegram() from a2a-server.ts to avoid circular imports
- Adds relayJobCompletionToTelegram() with rich completion/failure/timeout messages
- Adds formatDuration(ms) helper: <60s → '42s', <1h → '1m 42s', ≥1h → '2h 5m'
- Adds summarizeResult(text, maxChars?) helper: truncates with '…' suffix

### Updated: src/tools/claude-subagent.ts (issue #26)
- Adds receiveCallback(jobId, status, result?, error?) → boolean
  - Updates in-memory job map + persists to disk
  - Fires Telegram notification unless job.silent=true
  - Returns false for unknown jobIds (used by HTTP endpoint for 404)
- Wires receiveCallback into close/error/timeout handlers (parent-process approach)
- Adds silent?: boolean field to DelegateJob
- Exports _buildAnthropicCustomHeadersForTest, _clearJobsForTest, _addJobForTest for tests
- Adds silent param to delegateToClaudeSubagent tool

### Updated: src/a2a-server.ts (issue #26 + #5)
- Adds POST /tasks/callback endpoint (auth-gated) for cross-machine callbacks (Nix → Max)
- Worker complete/error handlers now use relayJobCompletionToTelegram with duration tracking
- Adds isSilent + workerStartTime tracking for A2A worker tasks
- Agent card capabilities now includes callback_endpoint: true

### New tests
- tests/telegram-notify.test.ts — formatDuration edge cases, summarizeResult truncation
- tests/a2a-callback.test.ts — 401/400/404/200 HTTP coverage for /tasks/callback
- tests/worker.test.ts — extended with DelegateJob silent flag tests

Closes #5
Closes #26
## What's implemented

### Core feature: Trace context propagation (issue #7)
- A2A server now extracts W3C traceparent from incoming requests via OpenTelemetry propagation API
- Incoming trace context is propagated through both sync and async task execution
- Result: Max→Nix→Max delegations appear as linked parent-child spans in Grafana (unified trace)

### src/a2a-server.ts changes
- Added OpenTelemetry imports: `context`, `propagation`, `trace` from '@opentelemetry/api'
- POST /tasks endpoint:
  - Extracts traceparent from request headers: `propagation.extract(context.active(), req.headers)`
  - Wraps async worker execution in extracted context: `context.with(incomingContext, executeWorker)`
  - Wraps sync task execution in extracted context for consistent propagation
  - Both paths (async worker, sync agent.prompt) now execute within the inherited trace
- POST /tasks/stream endpoint now also extracts and propagates trace context

### Tests added
- tests/a2a-trace-propagation.test.ts — comprehensive trace propagation test suite:
  - Valid W3C traceparent format extraction
  - Graceful fallback when traceparent is absent or malformed
  - Propagation through both sync and async task execution
  - AgentWeave headers + traceparent co-propagation (full context fidelity)
  - Trace context inheritance across worker execution

### Architecture note
This is the Max-side of bidirectional trace propagation. When Nix delegates to Max (POST /tasks with traceparent header), the task runs within that trace context. Conversely, when Max delegates to Nix (via nix-relay.ts delegateToNix tool), it injects traceparent into the request headers.

Result: full end-to-end trace visibility across machine boundaries (Max ← → Nix).

**7/7 test suites, 65/65 tests passing.**

Closes #7
…orkerData, honest test descriptions

Three fixes:

1. Remove unused 'trace' import from @opentelemetry/api in a2a-server.ts
   Only 'context' and 'propagation' are used.

2. Serialize traceparent into workerData for cross-thread propagation
   Worker threads run in a separate thread — AsyncLocalStorage context does not
   cross thread boundaries. Previously context.with(incomingContext, ...) was a
   no-op for the worker. Fix:
   - a2a-server.ts: inject incomingContext into traceHeaders{} via propagation.inject(),
     pass traceHeaders in workerData alongside existing fields
   - worker.ts: re-extract trace context from traceHeaders via propagation.extract(),
     run entire worker execution inside context.with(incomingContext, ...) so any
     spans created by the worker are linked as children of the calling agent's trace

3. Rewrite test descriptions to be accurate
   Previous tests just asserted HTTP status codes but were labelled as if they
   verified actual span propagation. New tests:
   - Unit: propagation round-trip (extract → re-inject → re-extract) — tests the
     actual mechanism both a2a-server.ts and worker.ts rely on
   - HTTP smoke: /tasks endpoint accepts traceparent without erroring
- Add NODE_ENV=test guard to relayTaskUpdateToTelegram and relayJobCompletionToTelegram
  Prevents real Telegram messages firing when TELEGRAM_BOT_TOKEN is set in .env and
  tests inherit the env (which they do — caused the task failure spam to Telegram)
- Explicitly set NODE_ENV=test in jest.config.cjs testEnvironmentOptions for safety
jest.mock() silently fails for ESM modules in Jest 29 + ts-jest. Mocks
were never applied, masked locally by ~/max/data/ existing. In CI the
real task-journal.ts tried to open a nonexistent DB path and threw.

Switch all jest.mock() calls to jest.unstable_mockModule() in both
a2a-trace-propagation and a2a-callback test files. Simplify callback
tests to use mockReturnValueOnce instead of _addJobForTest helpers
(which were no-op mocks).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@arniesaha arniesaha merged commit 2ba391d into main Apr 13, 2026
1 check passed
@arniesaha arniesaha deleted the feat/issue-7-trace-propagation branch April 13, 2026 16:16
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.

feat: A2A trace propagation — link Nix delegations as child spans

1 participant