Skip to content

feat(harness): coordinator phase-boundary instrumentation for RFC #2251 - #2255

Merged
HongmingWang-Rabbit merged 1 commit into
stagingfrom
feat/rfc2251-coordinator-phase-instrumentation
Apr 29, 2026
Merged

feat(harness): coordinator phase-boundary instrumentation for RFC #2251#2255
HongmingWang-Rabbit merged 1 commit into
stagingfrom
feat/rfc2251-coordinator-phase-instrumentation

Conversation

@HongmingWang-Rabbit

Copy link
Copy Markdown
Contributor

Summary

Pre-V1.0 gate #2 of the RFC at #2251 — the in-process companion to the existing `scripts/measure-coordinator-task-bounds.sh` external harness. Adds structured `rfc2251_phase=...` log lines at deterministic phase boundaries inside `route_task_to_team` and `check_task_status` so the harness operator can answer "the coordinator response took 7 minutes — was it stuck delegating, polling children, or synthesizing?" by greping the workspace container log.

What's instrumented

Every phase boundary emits one log line with the phase name + elapsed_ms-from-route_start. Phases:

Phase Where What it tells you
`route_start` enter route_task_to_team task arrived
`children_fetched` after get_children() returns platform discovery latency
`routing_decided` after build_team_routing_payload routing cost (CPU only, no I/O)
`delegate_invoked` just before delegate_task_async.ainvoke A2A send latency starts
`delegate_returned` after delegate_task_async returns A2A send latency ends
`check_status` every check_task_status poll one line per poll — polling cadence + last status seen
`route_returning_decision_only` fall-through non-delegation path

The synthesis phase (between last `check_status: completed` and the final A2A response) is NOT instrumented here because it's agent-driven (no deterministic Python boundary). Operators infer:
```
synthesis_secs = total_a2a_response_secs − max(check_status timestamp)
```

Why no enforcement

This is reproduction-harness scaffolding. Adds zero behavior. The `logger.info` calls flow into the existing workspace log stream — no new infrastructure, no per-task DB writes, no metric emission yet. When V1.0 ships and per-task phase data lands in the structured heartbeat payload, these lines get stripped (commit message documents this for the future cleanup).

Tests

  • `pytest workspace/tests/test_coordinator_routing.py` — 27 pass
  • `pytest workspace/tests/test_delegation.py` — pass
  • Full workspace suite: 1232 pass, 2 xfailed (pre-existing)

How operators use it

After this PR merges + a redeploy of any coordinator workspace:

  1. Run `bash scripts/measure-coordinator-task-bounds.sh` (already in staging) — kicks off a synthesis-heavy task against a real coordinator.
  2. While it runs, tail the coordinator workspace's container log:
    ```
    railway logs --service controlplane | grep "rfc2251_phase="
    ```
  3. Compute per-phase deltas from the elapsed_ms field. The phase the coordinator was last seen in before the harness's external timeout fires = the answer to "is Issue 4 a synthesis bug or a delegation bug?"

Refs

🤖 Generated with Claude Code

Adds structured `rfc2251_phase=...` log lines at the deterministic phase
boundaries inside route_task_to_team and check_task_status, so an
operator running scripts/measure-coordinator-task-bounds.sh against
staging can correlate the harness's external timing trace with what
phase the coordinator was in at any given second.

The harness already exists in staging and measures end-to-end response
time + heartbeat trace. What it CAN'T do without this PR is answer
"the coordinator response took 7 minutes — was it stuck delegating, or
stuck polling children, or stuck synthesizing after all children
returned?" The phase logs answer that question.

Phases instrumented (deterministic Python boundaries, no agent prompt
involvement):

  route_start             → enter route_task_to_team
  children_fetched        → after get_children() returns
  routing_decided         → after build_team_routing_payload
  delegate_invoked        → just before delegate_task_async.ainvoke
  delegate_returned       → after delegate_task_async returns
  check_status            → every check_task_status poll (per-poll)
  route_returning_decision_only → fall-through path

Each line includes elapsed_ms from route_start so per-phase durations
are extractable via:

  grep rfc2251_phase= <container.log> \
    | awk '{...}' to compute deltas between consecutive phases

The synthesis phase (after all children return, before agent emits
final A2A response) is NOT instrumented here because it's
agent-driven (no deterministic Python boundary). The harness operator
infers synthesis_secs = total_response_secs − max(check_status_ts).

This is reproduction-harness scaffolding; it adds zero behavior. Strip
the rfc2251_phase log lines when V1.0 ships and the phase data lands
in the structured heartbeat payload instead.

Refs:
  - RFC: molecule-core#2251
  - Harness: scripts/measure-coordinator-task-bounds.sh (shipped earlier)
  - V1.0 gate: this is deliverable #2 of the four pre-V1.0 gates
@HongmingWang-Rabbit
HongmingWang-Rabbit added this pull request to the merge queue Apr 29, 2026
Merged via the queue into staging with commit 54ea64b Apr 29, 2026
21 checks passed
@HongmingWang-Rabbit
HongmingWang-Rabbit deleted the feat/rfc2251-coordinator-phase-instrumentation branch April 29, 2026 03:22
HongmingWang-Rabbit pushed a commit that referenced this pull request Jun 12, 2026
…SSOT; fix type→kind on send path

Two incident-derived regression gates plus the real source bug the first
one surfaced.

1) Outbound A2A `message/send` envelope (#2251) — REAL, currently-shipping bug.
   buildA2AMessageParts (mcp_tools.go, feeds delegate_task +
   delegate_task_async) and the inline sync-delegation envelope
   (delegation.go) emitted the text Part as {"type":"text"} instead of
   the A2A v0.3-canonical {"kind":"text"}. A v0.3 peer's Pydantic
   validator discriminates Parts on `kind` and silently drops a
   `type`-keyed Part — the sender sees a happy 200/202 while the brief
   is lost. #2255 fixed the INBOUND normalizeA2APayload (type→kind on
   receive); this OUTBOUND send path was separate and still buggy on
   main. The file-attachment Part already used `kind` (untouched);
   MCP tools/call content schema legitimately keeps `type` (different
   protocol, untouched).
   Fix: text Part type→kind in both send paths.
   Gate: a2a_outbound_envelope_test.go — pins text-part `kind`,
   file-part `kind` (non-regression), and the full envelope role+kind.
   RED before the fix (the two kind-asserting tests failed against the
   shipping `type` shape), GREEN after.

2) Platform provider auth_env SSOT (#2250) — exact-equality gate.
   The `platform` (closed proxy) provider must advertise ONLY
   MOLECULE_LLM_USAGE_TOKEN in auth_env; a vendor key there makes the
   canvas demand a credential the platform path ignores (wrong-bill /
   silent no-op). The pre-existing tests only do a membership /
   non-empty check, which passes against a drifted two-element list.
   This pins the WHOLE set. Core's providers.yaml is already clean
   (the vendor key lives in the separate auth_token_env field), so the
   gate currently PASSES and locks that invariant against future drift
   onto this SSOT. The drift itself lives in the codex template repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HongmingWang-Rabbit pushed a commit that referenced this pull request Jun 12, 2026
…stic

PR #2255's normalizeA2APayload (#2251) renames the legacy Part
discriminator "type" -> "kind" (A2A v0.3) on ingest. ProxyA2A logs the
NORMALIZED body to activity_logs (a2a_proxy.go:432 body=normalizedBody;
logA2AReceiveQueued RequestBody=json.RawMessage(body)). So a poll-mode
caller that posts {"type":"text",...} has its row stored with
{"kind":"text",...}.

test_poll_mode_e2e.sh Phase 5's ASC parser hard-coded
`if p.get('type')=='text'` to extract part text from the stored
request_body. Post-rename every part is keyed on "kind", so the filter
matched nothing, text_of() returned '' for every row, and the assert saw
`got: |` (empty|empty) -> REQUIRED E2E API Smoke gate FAILED on #2255.

Root cause: the test asserted on an INTERNAL wire detail (which
discriminator field the server stored) instead of on the text payload.
The product change is correct and is covered by Go unit tests in
a2a_proxy_test.go; only the E2E parser was coupled to the legacy format.

Fix:
- text_of() now accepts kind=='text' OR type=='text' (works on main's
  legacy feed AND on #2255's normalized feed) — so it gates the text
  payload, not the field name.
- Add a positive wire-contract assertion: the stored Part must carry the
  v0.3 "kind" discriminator and NOT the legacy "type". This is the
  end-to-end half of the unit tests — it proves the rename survives the
  durable activity_logs path, and makes a dropped/reverted rename (or a
  feed that stops storing the normalized body) fail LOUDLY here instead
  of silently feeding a polling agent an untagged Part.

Verified: on main (no rename) poll-mode = 22 passed/0 failed; on #2255
(f0b6079) it was 21 passed/1 failed at this exact assert. Both parsers
simulated against kind- and type-shaped feeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HongmingWang-Rabbit pushed a commit that referenced this pull request Jun 12, 2026
…load single-choke (#2251)' (#2255) from fix/a2a-2251-go-role-default into main
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