Skip to content

feat(officers): forward an officer instance's live session transcript to HQ - #392

Merged
ZacxDev merged 5 commits into
mainfrom
officer/transcript-forward
Nov 1, 2026
Merged

ZacxDev merged 5 commits into
mainfrom
officer/transcript-forward

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

What

Forward an officer instance's live session transcript to HQ while it holds a claimed item, so HQ's officer pages can show what the officer is doing. Opt-in per officer:

{"id": "release:example-web", "hq_url": "https://hq.example.com/api/v1", "transcript": {"enabled": true}}

Sink, to HQ's contract: POST {hq_url}/officers/{id}/items/{item_id}/transcript with the officer's token, body {"instance", "events": [{"seq", "at", "kind", "text"}]}. Events go in batches of 100 or fewer, each text is 8 KiB or less, and HQ answers {"stored", "last_seq"}.

How

  • Source: the structured transcript, not the screen. ccd gets GET /v1/transcript?after=<offset>&session=<id> (after=-1&backfill=<bytes> for the tail). It serves the primary CLI's own transcript JSONL. Decoding goes through internal/cctranscript (ReadLines / Backfill), the same code the rendered view uses: only complete lines, and a partial last line waits for the next read. The route is bearer-authed like /v1/responses, and the ccd route ledger is updated. muster-server reads it through agentgateway.Gateway.Transcript with the instance's turn bearer.
  • Forwarder (internal/officers/transcript):
    • Kind mapping: prompt → user, assistant text → assistant, a tool call → tool_use ("Tool: <target>"), a tool result → tool_result, an API error or harness notice → system.
    • Every text is redacted before it is sent. Three steps, in order:
      1. The officer token is replaced (officers.Client.Scrub, because a shape filter cannot recognise an opaque token).
      2. officership.RedactText runs: the CI-log filter's RedactLines, unchanged, behind a new exported entry point that keeps blank lines.
      3. The text is cut to 8 KiB with … [truncated N bytes].
    • Transcript text is never logged.
  • Cadence. Every 2 s, with at most 100 events per POST. HQ 5xx, 429 and network errors back off (2 s doubling to a 1 min cap). A batch is dropped after 5 failed re-sends, and a 400 drops it at once. The per-instance buffer is capped at 1000, and when full the oldest events are dropped. Every drop is logged and counted on muster_officer_transcript_events_total{result}. The officer tools and the wake loop only record state, so nothing blocks the officer.
  • Lifecycle:
    • officer_claim succeeding starts forwarding from the claim (5 s clock slack).
    • officer_complete or officer_release stops it.
    • Each wake pass resumes forwarding (from a 256 KiB backfill window) for an item an instance still holds under a live lease, which covers a muster restart. It also stops forwarding for an item no instance holds any more.
    • HQ 409 not_claimed, 409 officer_disabled, 404 and 401/403 stop forwarding for that item until the next claim. A lagging pass does not restart it.

seq design

seq = (end offset of the event's line in the transcript file) × 32768 + (index of the event within that line, 15 bits; ccd serves no line over 1 MiB, so no line's events are dropped).

  • It strictly increases along the file and is a pure function of the transcript position. A restarted muster derives the same seq for the same event, so when its resume re-reads the backfill window, HQ's "skip seq ≤ highest stored" rule removes the duplicates. Nothing has to be kept in memory.
  • It stays below 2^53 for any file under 256 GiB, so it is JSON-safe even for a reader that parses numbers as doubles.
  • A new conversation mid-item (a new file, offsets restarting from zero, e.g. an operator's /clear; ccd resumes the primary's own conversation by id on restart) is shifted above all earlier seqs.
  • If muster restarts into such a conversation, the shift is lost. HQ then answers stored: 0 with a last_seq above a batch read live (one past the resume's window, so not a duplicate). The forwarder re-sequences that batch and every later one above last_seq.
  • What this double edge can lose: the part of the resume window that HQ skipped as old.

Alternatives considered: a seq derived from timestamps (records can share a millisecond), and resuming only from HQ's last_seq (the contract has no read for it, and it cannot be mapped back to a transcript position).

Deploy-order hazard

MUSTER_OFFICERS rejects unknown fields. The transcript key must reach a deployment only together with, or after, an image that knows it. If the order is reversed, muster-server refuses to boot. Rollback is the reverse: remove the transcript key before rolling back to an image without it. Instance pods need the new ccd for GET /v1/transcript. An older pod answers 404, and the forwarder logs it and retries every tick, sending nothing until the pod runs the new image. An officer that enables transcript on a server whose gateway cannot read transcripts is a boot refusal.

Tests

  • The first commit is tests only, red because none of the code existed yet (build failure). Covered:
    • Fake HQ route (hqtest) implementing the contract: limits, 409s, idempotency.
    • Strictly increasing seq and the kind mapping.
    • Batches of 100 or fewer.
    • 8 KiB truncation.
    • Redaction of a secret in a tool_result, with clean lines as a positive control.
    • The officer token in a line that no secret pattern matches.
    • Stop on not_claimed, and no restart from a lagging pass.
    • Resume after a restart with no duplicates, with the test confirming the resume did re-send and HQ deduplicated it.
    • A new session keeps seq increasing.
    • A disabled officer sends nothing (with an enabled control).
    • Buffer cap drops with a counted metric.
    • Capped retries.
    • Wake-pass start and stop.
    • The ccd route.
    • The config switch.
    • The metric ledger.
  • Second commit, added alongside the code: the server seam (claim / complete / wake pass driving the forwarder, transcript read through the gateway by the instance's own row), the gateway read, and a restart-into-a-new-conversation heal test.
  • Red/green from clean git archive copies: the test commit is red (build failure in officers, officers/transcript, metrics, cmd/ccd). HEAD is green: 12 forwarder tests, 2 ccd route tests and 3 api tests pass.
  • Mutation-checked: each mutant was killed by the specific assertion it should hit.
    • Removing the RedactText call → the redaction test fails.
    • Removing the token scrub → fails.
    • Treating not_claimed as an outage, or removing the stop → the stop test fails.
    • Also killed: removing truncation, batching 200, removing the buffer cap, removing the session shift, removing the heal, removing the disabled check, removing the claim-time filter, uncapping retries, and dropping the server's Claimed / Observed / Ended wiring.
  • Not changed: go.mod and the nix goVendorHash, since no new module imports were added.

🤖 Generated with Claude Code

ZacxDev and others added 2 commits November 1, 2026 10:26
The fake HQ (hqtest) gains POST /officers/{id}/items/{item}/transcript to
the contract: <=100 events, text <=8 KiB, seq strictly increasing,
idempotent on seq, 409 officer_disabled / not_claimed, {stored, last_seq}.

Tests for the forwarder (internal/officers/transcript): kinds and strict
seq order, batches of <=100, 8 KiB truncation with a marker, redaction of
a secret in a tool_result (with a positive control line), stop on
not_claimed (and no restart from a lagging pass), resume after a restart
without duplicates, a new session keeps seq increasing, disabled config
sends nothing, the buffer cap drops with a counted metric, capped
retries, and the wake-pass start/stop. Plus ccd's GET /v1/transcript
route, the `transcript` config switch, and the metric ledger entry.

Red: none of the code under test exists yet (compile failure).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… to HQ

Opt-in per officer (`"transcript": {"enabled": true}` in MUSTER_OFFICERS).
While an instance holds a claimed item, muster-server reads the instance's
own Claude Code transcript incrementally and POSTs new events to
POST {hq_url}/officers/{id}/items/{item}/transcript with the officer's token.

- ccd: GET /v1/transcript serves the primary CLI's transcript from a
  line-boundary offset (cctranscript.ReadLines / Backfill), bearer-authed
  like /v1/responses. The route ledger is updated.
- agentgateway.Gateway.Transcript reads it with the instance's turn bearer.
- internal/officers/transcript: the forwarder. Kinds user / assistant /
  tool_use ("Tool: target") / tool_result / system; every text scrubbed of
  the officer token, then redacted by officership.RedactText (the CI-log
  filter's RedactLines, unchanged; a new exported entry point that keeps
  blank lines), then cut to 8 KiB with "… [truncated N bytes]". Batches of
  <=100 every 2 s; HQ outages back off (2 s doubling to 1 min) with capped
  retries; the per-instance buffer is capped and drops the oldest, counted
  on muster_officer_transcript_events_total{result}.
- seq = line end offset * 16 + index in line: a function of the transcript
  position, so a restarted muster derives the same seq and HQ's
  idempotency dedupes its resume re-read. A new conversation mid-item is
  shifted above; a restart into one heals from HQ's last_seq.
- officer_claim starts forwarding (from the claim on), officer_complete /
  officer_release stop it, each wake pass resumes a held item muster was
  not forwarding and stops one no longer held; 409 not_claimed (and every
  other HQ decision) stops it until the next claim.
- docs/officers.md: "Transcript to HQ".

Tests: one ccd assertion on a transcript base field was dropped with that
field (seq no longer uses it); the forwarder's test rig gained a 24 h lease
and claim-relative times after the forwarder started at the claim; a
heal test was added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ZacxDev

ZacxDev commented Nov 1, 2026

Copy link
Copy Markdown
Owner Author

Round 0 (requirements & deletion) at b31eb9a — verdict: requirement questioned (the pull-from-ccd source design was the agent's; checked, it holds) + one deletion candidate; no open premises. Requirements 11 (unattributed 5).

Also found: the leak gate is red on a token-shaped literal fixture in internal/officers/transcript/forward_test.go (build it at runtime like cilogs_test.go); the rollback order (remove the config key before rolling back) is undocumented; a transcript line with more than 16 events is dropped by the ×16 seq scheme.

D1 — drop the new-conversation seq shift + heal path (forward.go ~436-440, ~507-519): reachable only via a human /clear inside an officer pod mid-item; ccd resumes the primary conversation by id on restart

Disposition: D1 kept — whether auto-compaction rotates the session id is unestablished, and the cost of being wrong is lost live events.

… 15-bit line index

- forward_test: build the redaction fixture's GitHub-token-shaped secret at
  runtime ("ghp_" + strings.Repeat("A1b2", 9)), as cilogs_test does, so the
  source carries no token-shaped literal.
- docs/officers.md: the rollback order — remove `transcript` from
  MUSTER_OFFICERS before rolling muster-server back to an image without it.
- seq: the per-line event index is 15 bits (was 4), so a user record with
  many parallel tool results is never dropped as unsequenced. ccd serves no
  line over 1 MiB, so a line carries < 32768 events; seq stays below 2^53
  for any transcript under 256 GiB. Test: a line with 40 tool results posts
  all 40 in strictly increasing seq (red at 4 bits: 17 of 41 stored).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ZacxDev

ZacxDev commented Nov 1, 2026

Copy link
Copy Markdown
Owner Author

Round 1 (full checklist) at 2543157: 🔴 0 · 🟡 4 · 🟢 3 — ladder ends; all being fixed un-re-audited.

  • Y1 forward.go:508-519: a lost HQ reply triggers the heal and duplicates events (reproduced).
  • Y2 forward.go:336,452-458: a resume with no HQ events forwards up to 256 KiB of earlier work into the item (reproduced).
  • Y3 forward.go:520-522: HQ-skipped events counted as posted; a re-claim by another instance loses events uncounted.
  • Redaction internal/officership/cilogs.go: misses sk_live_, AIza, glpat-, hf_, kubeconfig client-key-data — treated as must-fix, since transcripts are member-visible.
  • 🟢 token prefix can survive truncation before scrub; a 413 stops forwarding instead of dropping; deploy order with HQ undocumented.

Contract vs civitai/civ-hq#306 checked: field names, kinds, limits, error codes all match.

ZacxDev and others added 2 commits November 1, 2026 11:39
- a lost acknowledgement (HQ commits, then 5xx) is stored once, counted
  skipped_by_hq, not re-sequenced (Y1);
- a resume with nothing at HQ forwards only new lines; resuming A after
  B completes starts live; an untimed line before a fresh claim is not
  sent (Y2);
- a re-claim by another instance lands above HQ's last_seq (Y3);
- a 413 drops the batch and halves the next (G2);
- a token cut by a cap leaves no prefix (G1);
- more credential shapes withheld by the CI-log filter, with ordinary
  lines as positive controls; every fixture built at run time.

The fake HQ gains FailTranscript (incl. commit-then-fail). The resume
test now expects HQ's last_seq to be learned (an empty post) instead of
a blind re-send; the retries test lets that probe succeed first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s, 413, more secret shapes

- Probe: before an item's first events, one empty post learns HQ's
  last_seq (the floor). A resume sends only what is above it; with
  nothing at HQ it starts live (the backfill window is earlier work).
  A resume of an instance this muster already forwarded for starts live.
  An untimed line in a fresh claim's first read is not sent. (Y2)
- A batch not above the floor (a re-claim by another instance: its own
  file, its own offsets; HQ's last_seq is per item) is shifted above it,
  including a batch that straddles it. (Y3)
- The heal re-sequences only a batch's FIRST send: a re-send answered
  stored 0 is a lost acknowledgement and is stored once. (Y1)
- posted counts what HQ stored; the rest is skipped_by_hq (logged);
  skipped_resume, dropped_too_large added. (Y3)
- A 413 drops that batch and halves the batch size for the item. (G2)
- The CI-log filter knows Stripe sk_/rk_ live/test, Google AIza, GitLab
  glpat-, Hugging Face hf_, AWS ASIA and aws_secret_access_key, npm_,
  pypi-, kubeconfig client-key-data / client-certificate-data; and
  RedactCutTail withholds a line whose cap-cut word starts like a token.
  Client.Scrub replaces a cut prefix of the officer token. No muster-side
  cut happens before the filters (the tool_use 200-byte cut is gone). (G1)
- docs: the probe, resume scope, the new results, and HQ's route must be
  live before `transcript` is enabled (else 404 stops each item). (G3)

Tests (the round-1 test commit, plus here: the re-claim test now
straddles HQ's last_seq, the 413 test sends 250 so halving is visible —
both after mutants survived the first versions).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ZacxDev
ZacxDev marked this pull request as ready for review November 1, 2026 20:23
@ZacxDev
ZacxDev merged commit 02f9e14 into main Nov 1, 2026
19 checks passed
@ZacxDev
ZacxDev deleted the officer/transcript-forward branch November 1, 2026 20:23
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