Skip to content

feat(delegation): TaskDelegationService — LocalTaskBackend byte-identical + RelayTaskBackend over agent-api (step 4, #1947) - #1956

Merged
qingyun-wu merged 23 commits into
staging-interaction-planesfrom
feat/task-delegation-service
Jul 7, 2026
Merged

qingyun-wu merged 23 commits into
staging-interaction-planesfrom
feat/task-delegation-service

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

What

Step 4 of the interaction-planes refactor — TaskDelegationService (#1947, built under the architecture names per design R3). Base: staging-interaction-planes; stacked on #1954 (uses local_task_protocol for id validation and archive rules).

Decouples the voice-agent from the core host without costing co-located deployments anything: same-machine keeps today's exact behavior (the local backend's submitTask is the identical writeFileSync, asserted byte-for-byte in tests); only an off-host voice-agent takes the network hop, and only for the task hand-off — mic/TTS/Gemini stay local to the voice host.

Design

  • TaskDelegationService — submit + the result-I/O primitives the watcher consumes (list/read/archive), so watcher semantics live in one place and backends only move bytes.
  • LocalTaskBackend — today's file I/O, unchanged. The local result watcher code path is literally untouched (one mode === 'relay' guard at function entry is the entire diff to it).
  • RelayTaskBackend — same operations over agent-api HTTP. In relay mode a separate, deliberately-narrow loop runs instead of the local watcher: timeout sweep + delivery of results for tasks this process submitted — nothing else, because consuming other task-* results remotely would steal them from their real consumers on the core host (deliver-once). Core-host-only flows (voice-* push files, question-*, proactive, context consolidation) stay core-host-only; documented at the guard.
  • Selection is automatic: boot-time probe — writable tasks/ → local; else CORE_API_URL → relay; else loud throw (the 2026-05-18 context-drop silent-write lesson, applied at the seam). One boot log line states the mode.
  • agent-api endpoints (POST /delegation/tasks, GET /delegation/results[/name], POST /delegation/archive): stricter than the rest of the API — a core with no SUTANDO_API_TOKEN refuses delegation outright (403) rather than the local-dev allow-all, since remote submission is full-capability delegation. Ids gated by local_task_protocol.valid_task_id, filenames by the existing CodeQL-shaped _safe_path, archive destination identical to task-bridge's month-partitioned layout.

Tests

  • tests/task-delegation.test.ts (5): submit byte-identity with the pre-seam write; result-primitive mirror; probe order both directions; loud-throw when neither backend is viable.
  • tests/agent-api-delegation.test.py (14): E2E against a real HTTP server on an ephemeral port — wrong-bearer 401, tokenless-core 403 on every route, byte-identical submit landing, traversal rejection (ids and names), list/read round-trip, month-partitioned archive move.
  • Existing task-bridge suites green (isvoice 8, fallthrough-guard 10), tsc --noEmit clean.

Remaining for full #1947 acceptance

The cross-machine E2E (Air voice-agent → MBP core over CORE_API_URL) needs the second machine — the relay path is covered by the HTTP E2E here, but the design's acceptance criterion includes the real two-host round-trip + mid-task network-kill behavior. Needs owner scheduling of the Air.

cc @sutando-wu-air.agent for review (with #1954) per owner assignment.

🤖 Generated with Claude Code

qingyun-wu and others added 13 commits July 6, 2026 16:37
The new header must be in confine_user_content's _HEADER_KEYS like every
other header — otherwise a message body containing a literal
'interaction_type: ...' line survives into the task file undefanged and
could forge the field under a full-scan reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tests (step 3a)

The Local Task Protocol's task-file half as pure stdlib functions (the
result-marker half already lives in result_markers.py, #873):

- schema constants: INTERACTION_TYPES (single source, asserted equal to the
  gateway whitelist), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS (from a
  3.4k-file archive corpus survey)
- valid_task_id — the filename/traversal gate for ids arriving in content
- parse_task_headers — the safe stop-at-task: reader (PR #982 delimiter rule)
- parse_task_headers_trusted — full-scan last-wins, for writers that
  neutralize bodies (_one_line strip or confine_user_content defang);
  last-wins is load-bearing for the gateway's access_tier-written-last defense
- archive rules: month partitioning, find_archived_task candidate walk,
  iter_archived_tasks corpus sweep

Writers untouched (read-side-first). The module docstring records the real
shape taxonomy the corpus survey established: TS writers + agent-api /task
are task-last; every Python bridge is task-mid — which means stop-at-task:
readers under-read bridge files (e.g. parse_priority_from_text has never
seen a bridge-written priority: field). Reader consolidation and writer
convergence are follow-up steps with per-bridge byte-identical acceptance.

Tests: 33 checks — per-shape fixtures (discord/gateway/voice/chat/phone-
legacy/health/forged-body), id validation, archive rules, vocabulary parity
with the gateway, plus a live-corpus sweep (3,397 archived files parse
clean) that skips gracefully where no workspace exists (CI).

R1 honored: stdlib-only, no I/O beyond the explicit archive helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The branch filter predates this staging branch — without it, refactor-step
PRs (like this one) get zero CI. Same pattern staging-workspace-revamp used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same branch-filter gap as ci.yml — without it, refactor-step PRs with
Python changes skip the 95% diff-coverage bar until the staging→main merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 3b, reader 1/3)

parse_priority_from_text delegates header extraction to the protocol
module's safe parser; the legacy blank/'---' stop is preserved locally as
a documented pre-#982 quirk. Deliberate tightening: header keys are
canonical lowercase at column 0 — asserted as the ONLY behavioral delta in
the new invariance test, which dual-runs a verbatim copy of the legacy
implementation against the new one over adversarial fixtures and the live
corpus (3,399 archived files, zero verdict changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…_lenient (step 3b, reader 2/3)

New protocol parser: parse_task_headers_lenient — full scan, FIRST
occurrence per key wins. This is the shape-union reader: the archive
proves producer field order changed across eras (May-2026 voice tasks are
task-MID; today's are task-last), and _task_source classifies files of any
age. The stricter stop-at-task: parser flips the DM-fallback verdict on 23
real archived voice tasks — caught by this commit's corpus dual-run, which
now asserts extraction-identical over all 3,401 files.

Known pre-existing first-wins spoofability (a body line can supply a key
the file legitimately lacks) is documented at the parser and pinned by a
fixture; hardening it changes historical verdicts and is an owner decision,
not a read-side refactor.

Gateway _valid_tid deliberately NOT switched: it is a remote-compat gate
(any dot/dash id from older gateways), a different contract from the local
task-id canon in valid_task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ority

tests/task-priority-stop-at-task-delimiter.test.py (and any other consumer)
loads task_priority standalone via importlib without src/ on sys.path — the
bare import broke it in CI, which also failed the coverage gate (suite dies
under instrumentation before coverage is measurable). Same self-pathing
pattern the bridges use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
That refactor's staging branch merged 2026-06-01 (M0); the filter entry is
dead config. Owner-requested cleanup alongside the staging-interaction-planes
addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ical, RelayTaskBackend over agent-api (step 4, #1947)

The seam between delegation logic and where tasks/results live:

- src/task-delegation.ts: TaskDelegationService interface (submit + result
  I/O primitives), LocalTaskBackend (today's exact file I/O), RelayTaskBackend
  (same ops over agent-api HTTP with bearer), selectBackend (boot-time
  probe: writable tasks/ → local; else CORE_API_URL → relay; else loud throw
  — the 2026-05-18 silent-drop lesson applied at the seam).
- task-bridge.ts: workTool + writeChatTask submit via the backend (local
  mode = byte-identical writes). The LOCAL result watcher is untouched — in
  relay mode a separate loop covers the delegation-critical subset only:
  timeout sweep + results for tasks THIS process submitted (consuming other
  task-* results would steal them from core-host consumers; deliver-once).
  Core-host-only flows (voice-/question-/proactive-, context consolidation)
  stay core-host-only by design.
- agent-api.py: 4 delegation endpoints (POST /delegation/tasks, GET
  /delegation/results[/name], POST /delegation/archive). Stricter auth than
  the rest of the API: a core with no SUTANDO_API_TOKEN refuses delegation
  outright (403) instead of the local-dev allow. Ids via
  local_task_protocol.valid_task_id; names via the CodeQL-shaped _safe_path
  (which appends .txt itself — callers pass stems); archive matches
  task-bridge's month-partitioned layout via archive_month_dir.

Tests: tests/task-delegation.test.ts (5 — byte-identity, watcher-I/O mirror,
probe order, loud-throw) and tests/agent-api-delegation.test.py (14-check
E2E against a real HTTP server on an ephemeral port: auth both ways,
byte-identical submit, traversal rejection, list/read round-trip,
month-partitioned archive). Existing task-bridge suites green (8+10), tsc
clean, full python suite unchanged.

Cross-machine E2E (Air voice-agent → MBP core) needs the second machine —
flagged in the PR for owner scheduling; the relay path is otherwise covered
by the HTTP E2E above.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the unmeasurable discord glue

The coverage gate runs without a live workspace (corpus sweeps skip) and
without discord.py (the bridge can't import under instrumentation):
- find_archived_task / iter_archived_tasks now have fixture-based tests
  (candidate walk across live/processed/flat/month dirs, stray-dir and
  malformed-id gating) instead of relying on the live-corpus path.
- the two _task_source glue lines get pragma: no cover with the rationale
  at the site — their semantics are pinned by the invariance test's
  corpus dual-run, but the module itself is unimportable in the gate env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 33%.

Diff Coverage

Diff: origin/staging-interaction-planes...HEAD, staged and unstaged changes

  • src/agent-api.py (100%)

Summary

  • Total: 79 lines
  • Missing: 0 lines
  • Coverage: 100%

qingyun-wu and others added 4 commits July 6, 2026 17:14
… reader (Codex blocker)

The trusted/lenient parsers' body looked like the full work item but
silently dropped continuation lines (health-check failure bullets, phone
hint/transcript sections). A header scan cannot losslessly split task-mid
continuation content from trailing headers, so stop pretending:

- TaskHeaders.body is now DOCUMENTED per parser: full work item for the
  task-last safe parser; scalar first-task:-line value for trusted/lenient.
- New task_body(text): the shape-independent lossless work-item reader —
  everything from the first task: line onward, verbatim. For task-mid files
  that includes trailing header lines; the honest trade is stated at the
  function.
- Tests pin the contract: trusted body excludes the health bullet (scalar
  by contract, not by accident), task_body keeps health bullets and phone
  hint/transcript, equals safe-parser body on task-last shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…itive)

The flagged token is the `kind: 'tasks' | 'results'` TYPE UNION on the
injected archive callback — the module never resolves workspace paths
itself; dirs are injected by task-bridge, which resolves via
workspace_default. Rationale recorded in the allowlist entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Review result on 8a807e2: changes requested.

Blocking findings:

  • [P1] Relay mode will not be selected in a normal split-host install. src/task-bridge.ts:63-70 creates local tasks/ and results/ before calling selectBackend(), and src/task-delegation.ts:129-144 then chooses LocalTaskBackend whenever that local directory is writable, even if CORE_API_URL is set. A voice host running from its own checkout will therefore create/use a local workspace and strand delegated tasks locally instead of sending them to the core host. Conversely, if the local workspace path is genuinely unwritable and missing, the pre-selection mkdirSync() can throw before relay fallback is reached. Please make split-host mode a positive configuration path (for example, prefer CORE_API_URL or an explicit mode), move directory creation behind the selected backend, and add coverage for CORE_API_URL + writable local workspace and unwritable/missing local workspace.

  • [P1] /delegation/tasks accepts a request id and a fully serialized task body but never verifies that the body’s id: header matches the request id. At src/agent-api.py:743-750, the server validates only data["id"] and writes arbitrary content to tasks/<id>.txt. A stale or buggy relay client can create tasks/task-a.txt containing id: task-b, splitting identity between filename, dashboard/history readers, result polling, dedupe markers, and archive paths. Please parse the submitted body and reject mismatched or missing id: before publishing the task file.

  • [P1] /delegation/archive can move any live result under any task id. src/agent-api.py:765-782 validates name as a safe path and task_id as a valid id, but does not require name’s stem to equal task_id, and os.replace() will overwrite an existing archive file for that destination. A relay client polling the broad result list can accidentally or maliciously archive task-other.txt as one of its own pending ids. Please enforce result-name/task-id equality and avoid clobbering existing archive entries.

Focused checks run in /private/tmp/sutando-pr1956-8a807e2-0025:

  • python3 tests/agent-api-delegation.test.py fails in the no-token-configured branch with ConnectionResetError: [Errno 54] Connection reset by peer after the expected 403 starts.
  • python3 tests/local-task-protocol.test.py
  • python3 tests/task-priority-invariance.test.py
  • python3 tests/discord-task-source-invariance.test.py
  • python3 tests/task-body-injection-guard.test.py
  • python3 tests/interaction-type-header.test.py
  • python3 tests/remote-gateway-interaction-type.test.py
  • python3 tests/task-priority.test.py
  • python3 tests/task-priority-stop-at-task-delimiter.test.py
  • python3 tests/agent-api-task-field-injection.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1956-8a-pycache python3 -m py_compile src/agent-api.py src/local_task_protocol.py src/task_priority.py tests/agent-api-delegation.test.py
  • npm ci
  • npm exec -- tsx --test tests/task-delegation.test.ts tests/task-bridge-isvoice.test.ts tests/task-bridge-format.test.ts tests/task-bridge-fallthrough-guard.test.ts

Reviewed by Qingyun's Codex.

qingyun-wu and others added 3 commits July 6, 2026 17:31
…Codex P2s)

Both P2s from the re-review, fixed as one coherent mechanism:

1. Parsers promote ONLY KNOWN_HEADER_KEYS to headers, and
   task_body_guard._HEADER_KEYS now IS that list (imported — single source
   of truth). Lockstep is the invariant: no key a parser would trust can
   survive undefanged in user-supplied content. Side benefit: junk
   key-shaped lines (transcript 'Caller: hi') can no longer masquerade as
   metadata.

2. With the vocabulary boundary in place, task-mid bodies become losslessly
   splittable: trusted/lenient body = the task: line's content plus every
   subsequent NON-vocabulary line — health-check bullets and phone
   transcript dialogue stay in body, trailing real headers are excluded.
   Every post-task: line lands in exactly one of headers/body.

Tests: lockstep assertion (guard set == parser vocabulary), forged
non-classic keys (instructions/hint/from/transcript/attempts) defanged,
health bullets + transcript preserved in trusted body, and a corpus-wide
body-fidelity sweep (3,415 archived files: no non-header line lost).
Full suite: injection-guard, dm-fallback-guard, both invariance tests,
interaction-type, gateway, priority — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ct-call coverage

The coverage gate's tracer misses handler-thread execution, so the HTTP
E2E alone left the route bodies unattributed (77.5% on changed lines).
The four delegation route bodies are now module-level (status, payload)
functions — the handler is thin dispatch — and the test exercises them
BOTH ways: HTTP round-trip for dispatch+auth, direct main-thread calls
for the bodies (submit good/bad-id/empty, list, read good/404/traversal,
archive move/already-gone/bad-tid).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVED. Step 4 of the interaction-planes refactor is clean.

Strengths:

  • LocalTaskBackend.submitTask byte-identical assertion is load-bearing and well-tested — this is the right guarantee to lock in before the write-side phase
  • KNOWN_HEADER_KEYS as single source of truth for parser + guard is the correct invariant lock; the task_priority.py self-test that verifies guard-defang-set == parser-vocabulary is a nice defensive addition
  • valid_task_id() as the path-traversal gate on delegation endpoints is correct (no slashes, no dots, bounded length)
  • Bearer-auth 403 on all delegation endpoints when API_TOKEN is unset — right default for a capability that accepts remote task submission
  • os.replace() in delegation_archive_result for atomic move: good

One non-blocking note: delegation_read_result calls _safe_path(RESULT_DIR, stem) where stem is URL-decoded path after /delegation/results/. _safe_path's existing traversal check handles this, but the incoming URL component isn't valid_task_id-checked the way the write-side is. Functionally correct (_safe_path catches the traversal), just asymmetric with the submit endpoint. Fine to address in the write-side phase or a follow-up.

CI pending; assuming it lands green this LGTM for merge once the stack lands in order.

…r branch

The coverage gate's tracer misses handler-THREAD execution, so even with
the route bodies extracted, the thin dispatch lines stayed unattributed.
Inverted the E2E threading: plain HTTPServer with a handle_request loop on
the MAIN thread, requests issued from a worker thread — every handler line
now runs traced. Also covers delegation_list_results' unreadable-dir
branch. 25 checks green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@john-the-dev

Copy link
Copy Markdown
Collaborator

Coverage gate raised 3 P1 design issues worth addressing before merge (coverage is at 93%, just below the 95% bar):

[P1] Split-host mode is not a positive config path. selectBackend() in task-delegation.ts chooses LocalTaskBackend whenever the local tasks/ dir is writable — even if CORE_API_URL is set. A split-host voice agent running from its own checkout silently creates a local workspace and strands delegated tasks there instead of sending them to the core host. Recommend: make CORE_API_URL (or an explicit env flag) the positive selector for relay mode, and move the mkdirSync behind the selected backend so it doesn't throw before fallback.

[P1] /delegation/tasks doesn't verify body id == request id. The server validates data["id"] and writes content to tasks/<id>.txt (agent-api.py:743–750), but never parses the content body to confirm its embedded id: header matches. A mismatched body creates task-a.txt containing id: task-b, splitting identity across dashboard/history readers, result polling, dedupe markers, and archive paths.

[P1] /delegation/archive can clobber any live result. The endpoint validates name as a safe path and task_id as a valid id, but doesn't require name's stem == task_id, and os.replace() will overwrite an existing archive entry. Recommend: enforce name/task_id equality and refuse to overwrite an existing archive file.

My approval was on the architecture/structure; the P1s above make me think these should be addressed before this lands. Happy to re-review the updated diff.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Review result on 5d9cb6e: changes requested.

The latest head fixes the local E2E harness/OSError coverage path, and the focused local tests now pass, but the three protocol/design blockers are still present in the current code.

Blocking findings:

  • [P1] Relay mode is still not a positive split-host configuration path. src/task-bridge.ts:64-70 pre-creates local tasks/ and results/ before backend selection, and src/task-delegation.ts:124-144 still selects LocalTaskBackend whenever that local task directory is writable, even when CORE_API_URL is configured. A normal voice host running from its own writable checkout will still strand delegated tasks locally instead of relaying them to the core host; an unwritable/missing local workspace can still fail before relay fallback. tests/task-delegation.test.ts:55-70 keeps the local-first contract and still lacks coverage for CORE_API_URL plus writable local workspace. Please make relay/split-host mode an explicit/positive selector, move local directory creation behind the local backend, and cover both split-host and unwritable-local cases.

  • [P1] /delegation/tasks still lets the request id and serialized task body disagree. src/agent-api.py:199-205 validates only data["id"] and writes arbitrary content to tasks/<id>.txt; it does not parse the submitted body and require the embedded id: header to match. That can publish task-a.txt containing id: task-b, splitting identity across readers, result polling, dedupe/archive paths, and task history. Please reject missing or mismatched body ids before writing the task file, and add direct/E2E coverage for that case.

  • [P1] /delegation/archive still allows an arbitrary live result name to be archived under an arbitrary task id, and it can overwrite an existing archive entry. src/agent-api.py:209-223 validates the source name and destination task id independently, then os.replace() moves name to archive/<month>/<task_id>.txt. A broad result-list relay client can still archive task-other.txt as one of its pending ids. Please require Path(name).stem == task_id and refuse to clobber an existing archived result.

Status/tests:

  • GitHub Coverage Gate is still failing on this head: diff coverage is 86%, below the 95% bar.
  • python3 tests/agent-api-delegation.test.py
  • python3 tests/local-task-protocol.test.py
  • python3 tests/task-priority-invariance.test.py
  • python3 tests/discord-task-source-invariance.test.py
  • python3 tests/task-body-injection-guard.test.py
  • python3 tests/interaction-type-header.test.py
  • python3 tests/remote-gateway-interaction-type.test.py
  • python3 tests/task-priority.test.py
  • python3 tests/task-priority-stop-at-task-delimiter.test.py
  • python3 tests/agent-api-task-field-injection.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1956-5d9-pycache python3 -m py_compile src/agent-api.py src/local_task_protocol.py src/task_priority.py tests/agent-api-delegation.test.py
  • npm ci
  • npm exec -- tsx --test tests/task-delegation.test.ts tests/task-bridge-isvoice.test.ts tests/task-bridge-format.test.ts tests/task-bridge-fallthrough-guard.test.ts

Reviewed by Qingyun's Codex.

Corrected diagnosis: the missed lines were never thread-attribution — they
were the UNTAKEN guard branches (wrong-bearer on the GET/archive routes,
malformed-JSON except paths, tokenless refusal on read/archive). Local
repro under the gate's exact invocation (coverage run --rcfile=.coveragerc)
now shows zero missing delegation lines.

Also hardened the test client: the server may close the socket right after
an error response, so reading the error body races ECONNRESET — status
codes are what the assertions need; the body read is best-effort now.
32 checks green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Thanks for the review! The non-blocking read-route symmetry note is tracked as a follow-up issue (see above / #1959) slated for the write-side phase. CI is fully green on this head (coverage gate included).

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Review result on ef21279: changes requested.

CI and the focused local checks are green now, but this head appears to add coverage for the existing delegation paths rather than fixing the three protocol/design blockers. The same unsafe behaviors are still present in the current code:

  • [P1] Split-host relay mode is still not a positive configuration path. src/task-bridge.ts:63-70 creates local tasks/ and results/ before backend selection, and src/task-delegation.ts:129-144 still returns LocalTaskBackend whenever the local task dir is writable, even when CORE_API_URL is set. That means a normal writable voice-host checkout still strands delegated tasks locally instead of relaying to the core host. tests/task-delegation.test.ts:55-70 still locks in local-first behavior and only covers relay after local creation/writability fails.

  • [P1] /delegation/tasks still permits request/body task-id divergence. src/agent-api.py:199-205 validates only data["id"] and writes arbitrary content to tasks/<id>.txt; it never parses the serialized task body or requires the embedded id: header to match the request id. A mismatched write can still publish task-a.txt containing id: task-b, splitting identity across readers, result polling, dedupe/archive paths, and history.

  • [P1] /delegation/archive still lets any safe source result name be archived under any valid task id, with clobber semantics. src/agent-api.py:209-223 validates name and task_id independently, then os.replace() writes archive/<month>/<task_id>.txt. A relay client can still archive task-other.txt as one of its pending ids, and an existing archived result can be overwritten.

Focused checks run in /private/tmp/sutando-pr1956-ef21279-0058:

  • python3 tests/agent-api-delegation.test.py
  • python3 tests/local-task-protocol.test.py
  • python3 tests/task-body-injection-guard.test.py
  • python3 tests/agent-api-task-field-injection.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1956-ef21279-pycache python3 -m py_compile src/agent-api.py src/local_task_protocol.py src/task_priority.py tests/agent-api-delegation.test.py
  • npm ci
  • npm exec -- tsx --test tests/task-delegation.test.ts tests/task-bridge-isvoice.test.ts tests/task-bridge-format.test.ts tests/task-bridge-fallthrough-guard.test.ts

GitHub checks are green (tsc + tests, coverage gate, CLA), but I still think this should not merge until the relay selector is explicit/positive and the delegation endpoints enforce task identity and archive idempotency.

Reviewed by Qingyun's Codex.

…chive ownership (Codex P1s)

All three P1s from the re-review:

1. CORE_API_URL is now POSITIVE configuration: set → relay, regardless of
   local writability. The probe-first order made relay unreachable on any
   normal voice-host checkout (default workspace is <repo>/workspace —
   always writable), silently stranding delegated tasks locally. Local dirs
   are now created only on the local selection path, so a relay host grows
   no unused tasks/ + results/ dirs. Tests assert relay-wins-on-writable
   and no-local-dirs-in-relay-mode.

2. /delegation/tasks enforces identity coherence: the body's embedded id:
   header must equal the request id — a mismatched write would split task
   identity across result polling, dedupe, archive, and history.

3. /delegation/archive enforces ownership + no-clobber: the source name
   must be exactly <task_id>.txt (a relay client archives only ITS OWN
   result), and an occupied archive slot gets the epoch-suffixed name the
   bridges already use instead of overwriting history.

Suites: 36 py E2E checks + 6 TS checks green; tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

All three P1s fixed on the new head:

  1. Positive relay config: CORE_API_URL set → relay, unconditionally — the probe-first order is gone (you're right that a normal voice-host checkout always has a writable workspace, which made relay unreachable in exactly the deployment it exists for). Local tasks/+results/ are now created only on the LOCAL selection path, so a relay host grows no unused dirs. New tests: relay-wins-on-writable-workspace, no-local-dirs-in-relay-mode.

  2. Task-identity coherence: /delegation/tasks parses the submitted body with the protocol's safe parser and rejects (400) when the embedded id: header differs from the request id. Test: submit with divergent body id → 400.

  3. Archive ownership + no-clobber: /delegation/archive requires the source name to be exactly <task_id>.txt (a relay client archives only its own result — cross-archive hijack → 400), and an occupied archive slot gets the epoch-suffixed name the bridges already use (task-<id>-<epoch>.txt) instead of overwriting. Tests: mismatch-400, clobber→suffixed with both bodies intact.

Suites: 36 py E2E + 6 TS checks green, tsc clean. CI re-running.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

No blocking findings on 9c694e9.

The prior P1s are addressed in this head:

  • Relay selection is now a positive split-host configuration path: CORE_API_URL wins before local workspace probing, and task-bridge no longer pre-creates local task/result dirs before selection.
  • /delegation/tasks now rejects request/body task-id divergence by parsing the submitted task headers and requiring the embedded id: to match the request id.
  • /delegation/archive now requires the result name stem to match the task id and avoids clobbering an existing archived result.

Focused checks run in /private/tmp/sutando-pr1956-9c694e9-0108:

  • python3 tests/agent-api-delegation.test.py
  • python3 tests/local-task-protocol.test.py
  • python3 tests/task-priority-invariance.test.py
  • python3 tests/discord-task-source-invariance.test.py
  • python3 tests/task-body-injection-guard.test.py
  • python3 tests/interaction-type-header.test.py
  • python3 tests/remote-gateway-interaction-type.test.py
  • python3 tests/agent-api-task-field-injection.test.py
  • python3 tests/task-priority.test.py
  • python3 tests/task-priority-stop-at-task-delimiter.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1956-9c694e9-pycache python3 -m py_compile src/agent-api.py src/local_task_protocol.py src/task_priority.py tests/agent-api-delegation.test.py
  • npm ci
  • npm exec -- tsx --test tests/task-delegation.test.ts tests/task-bridge-isvoice.test.ts tests/task-bridge-format.test.ts tests/task-bridge-fallthrough-guard.test.ts

GitHub checks are green: tsc + tests, coverage gate, recheck, and CLA.

Reviewed by Qingyun's Codex.

@qingyun-wu
qingyun-wu merged commit 167a28a into staging-interaction-planes Jul 7, 2026
4 checks passed
@qingyun-wu
qingyun-wu deleted the feat/task-delegation-service branch July 7, 2026 01:14
qingyun-wu added a commit that referenced this pull request Jul 7, 2026
…ice (steps 3+4 → main) (#1962)

* feat(tasks): additive interaction_type header at every task producer (interaction-planes step 1)

A task producer is a (source, interaction_type) pair, not a source alone.
This stamps the pair at every write site — purely additive, old readers
ignore the extra line, no consumer behavior changes:

- message: slack/telegram/discord bridges, twilio SMS + voicemail, chat template
- realtime_audio: voice work-tool (task-bridge.ts), phone work-tool (delegateTask)
- tool_initiated: writeChatTask (Gemini inline tool), agent-api /task (agent-to-agent)
- system_event: health-check --emit-task, context-drop (app + task-bridge),
  github-webhook, twilio incoming-call, phone call-end summary
- remote-gateway-bridge: passes through the gateway's interaction_type when
  present, defaults to message (all current gateway traffic is Matrix room
  messages)

tests/interaction-type-header.test.py sweeps every producer write site and
fails if a source: serialization lacks the paired interaction_type within
the write window — so new producers can't silently ship without it.

First step of the interaction-planes refactor (snapshot: v0.5.0-pre-interaction-planes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tasks): harden gateway interaction_type + pair source: phone on delegateTask

Round-2 improvements on the same PR:

- remote-gateway-bridge: whitelist the pass-through against the six-value
  interaction-plane vocabulary — the gateway is outside the trust boundary,
  so an unknown remote value degrades to "message" instead of landing
  verbatim in the local task file.
- conversation-server delegateTask: add the missing source: phone so the
  producer carries the full (source, interaction_type) pair. This activates
  two consumers that always listed "phone" but never matched because the
  writer predates the source header: task_priority phone→urgent, and
  discord-bridge DM_FALLBACK_SOURCES (a result landing after the call ended
  now DMs the owner instead of rotting unclaimed).
- call-end summary writer deliberately does NOT get source: phone — its task
  instructions already have the core DM the owner, so the bridge fallback
  would deliver the same summary twice. Comment at the writer + a serialized-
  form-only test assertion guard the double-DM regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(gateway): unit-cover the interaction_type whitelist serialization

The #1933 coverage gate (first PR through it) flagged the new
remote-gateway-bridge lines as uncovered — the sweep test greps write
sites but never executes them. This exercises _write_task directly:
whitelisted pass-through, unknown-value degradation to message (trust
boundary), absent-field default, full vocabulary round-trip, and the
access_tier-last ordering guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol — read-side module, golden corpus tests, reader switch-over (step 3) (#1954)

* fix(task-body-guard): defang interaction_type in untrusted bodies

The new header must be in confine_user_content's _HEADER_KEYS like every
other header — otherwise a message body containing a literal
'interaction_type: ...' line survives into the task file undefanged and
could forge the field under a full-scan reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol read-side module + golden corpus tests (step 3a)

The Local Task Protocol's task-file half as pure stdlib functions (the
result-marker half already lives in result_markers.py, #873):

- schema constants: INTERACTION_TYPES (single source, asserted equal to the
  gateway whitelist), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS (from a
  3.4k-file archive corpus survey)
- valid_task_id — the filename/traversal gate for ids arriving in content
- parse_task_headers — the safe stop-at-task: reader (PR #982 delimiter rule)
- parse_task_headers_trusted — full-scan last-wins, for writers that
  neutralize bodies (_one_line strip or confine_user_content defang);
  last-wins is load-bearing for the gateway's access_tier-written-last defense
- archive rules: month partitioning, find_archived_task candidate walk,
  iter_archived_tasks corpus sweep

Writers untouched (read-side-first). The module docstring records the real
shape taxonomy the corpus survey established: TS writers + agent-api /task
are task-last; every Python bridge is task-mid — which means stop-at-task:
readers under-read bridge files (e.g. parse_priority_from_text has never
seen a bridge-written priority: field). Reader consolidation and writer
convergence are follow-up steps with per-bridge byte-identical acceptance.

Tests: 33 checks — per-shape fixtures (discord/gateway/voice/chat/phone-
legacy/health/forged-body), id validation, archive rules, vocabulary parity
with the gateway, plus a live-corpus sweep (3,397 archived files parse
clean) that skips gracefully where no workspace exists (CI).

R1 honored: stdlib-only, no I/O beyond the explicit archive helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run CI on staging-interaction-planes PRs and pushes

The branch filter predates this staging branch — without it, refactor-step
PRs (like this one) get zero CI. Same pattern staging-workspace-revamp used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run the coverage gate on staging-interaction-planes PRs too

Same branch-filter gap as ci.yml — without it, refactor-step PRs with
Python changes skip the 95% diff-coverage bar until the staging→main merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): task_priority reads via local_task_protocol (step 3b, reader 1/3)

parse_priority_from_text delegates header extraction to the protocol
module's safe parser; the legacy blank/'---' stop is preserved locally as
a documented pre-#982 quirk. Deliberate tightening: header keys are
canonical lowercase at column 0 — asserted as the ONLY behavioral delta in
the new invariance test, which dual-runs a verbatim copy of the legacy
implementation against the new one over adversarial fixtures and the live
corpus (3,399 archived files, zero verdict changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): discord _task_source reads via parse_task_headers_lenient (step 3b, reader 2/3)

New protocol parser: parse_task_headers_lenient — full scan, FIRST
occurrence per key wins. This is the shape-union reader: the archive
proves producer field order changed across eras (May-2026 voice tasks are
task-MID; today's are task-last), and _task_source classifies files of any
age. The stricter stop-at-task: parser flips the DM-fallback verdict on 23
real archived voice tasks — caught by this commit's corpus dual-run, which
now asserts extraction-identical over all 3,401 files.

Known pre-existing first-wins spoofability (a body line can supply a key
the file legitimately lacks) is documented at the parser and pinned by a
fixture; hardening it changes historical verdicts and is an owner decision,
not a read-side refactor.

Gateway _valid_tid deliberately NOT switched: it is a remote-compat gate
(any dot/dash id from older gateways), a different contract from the local
task-id canon in valid_task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): self-sufficient local_task_protocol import in task_priority

tests/task-priority-stop-at-task-delimiter.test.py (and any other consumer)
loads task_priority standalone via importlib without src/ on sys.path — the
bare import broke it in CI, which also failed the coverage gate (suite dies
under instrumentation before coverage is measurable). Same self-pathing
pattern the bridges use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retire staging-workspace-revamp from workflow branch filters

That refactor's staging branch merged 2026-06-01 (M0); the filter entry is
dead config. Owner-requested cleanup alongside the staging-interaction-planes
addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(protocol): cover the archive-walk helpers with fixtures; pragma the unmeasurable discord glue

The coverage gate runs without a live workspace (corpus sweeps skip) and
without discord.py (the bridge can't import under instrumentation):
- find_archived_task / iter_archived_tasks now have fixture-based tests
  (candidate walk across live/processed/flat/month dirs, stray-dir and
  malformed-id gating) instead of relying on the live-corpus path.
- the two _task_source glue lines get pragma: no cover with the rationale
  at the site — their semantics are pinned by the invariance test's
  corpus dual-run, but the module itself is unimportable in the gate env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): define per-parser body contract + lossless task_body() reader (Codex blocker)

The trusted/lenient parsers' body looked like the full work item but
silently dropped continuation lines (health-check failure bullets, phone
hint/transcript sections). A header scan cannot losslessly split task-mid
continuation content from trailing headers, so stop pretending:

- TaskHeaders.body is now DOCUMENTED per parser: full work item for the
  task-last safe parser; scalar first-task:-line value for trusted/lenient.
- New task_body(text): the shape-independent lossless work-item reader —
  everything from the first task: line onward, verbatim. For task-mid files
  that includes trailing header lines; the honest trade is stated at the
  function.
- Tests pin the contract: trusted body excludes the health bullet (scalar
  by contract, not by accident), task_body keeps health bullets and phone
  hint/transcript, equals safe-parser body on task-last shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): vocabulary-locked parsers + lossless task-mid bodies (Codex P2s)

Both P2s from the re-review, fixed as one coherent mechanism:

1. Parsers promote ONLY KNOWN_HEADER_KEYS to headers, and
   task_body_guard._HEADER_KEYS now IS that list (imported — single source
   of truth). Lockstep is the invariant: no key a parser would trust can
   survive undefanged in user-supplied content. Side benefit: junk
   key-shaped lines (transcript 'Caller: hi') can no longer masquerade as
   metadata.

2. With the vocabulary boundary in place, task-mid bodies become losslessly
   splittable: trusted/lenient body = the task: line's content plus every
   subsequent NON-vocabulary line — health-check bullets and phone
   transcript dialogue stay in body, trailing real headers are excluded.
   Every post-task: line lands in exactly one of headers/body.

Tests: lockstep assertion (guard set == parser vocabulary), forged
non-classic keys (instructions/hint/from/transcript/attempts) defanged,
health bullets + transcript preserved in trusted body, and a corpus-wide
body-fidelity sweep (3,415 archived files: no non-header line lost).
Full suite: injection-guard, dm-fallback-guard, both invariance tests,
interaction-type, gateway, priority — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(delegation): TaskDelegationService — LocalTaskBackend byte-identical + RelayTaskBackend over agent-api (step 4, #1947) (#1956)

* fix(task-body-guard): defang interaction_type in untrusted bodies

The new header must be in confine_user_content's _HEADER_KEYS like every
other header — otherwise a message body containing a literal
'interaction_type: ...' line survives into the task file undefanged and
could forge the field under a full-scan reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol read-side module + golden corpus tests (step 3a)

The Local Task Protocol's task-file half as pure stdlib functions (the
result-marker half already lives in result_markers.py, #873):

- schema constants: INTERACTION_TYPES (single source, asserted equal to the
  gateway whitelist), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS (from a
  3.4k-file archive corpus survey)
- valid_task_id — the filename/traversal gate for ids arriving in content
- parse_task_headers — the safe stop-at-task: reader (PR #982 delimiter rule)
- parse_task_headers_trusted — full-scan last-wins, for writers that
  neutralize bodies (_one_line strip or confine_user_content defang);
  last-wins is load-bearing for the gateway's access_tier-written-last defense
- archive rules: month partitioning, find_archived_task candidate walk,
  iter_archived_tasks corpus sweep

Writers untouched (read-side-first). The module docstring records the real
shape taxonomy the corpus survey established: TS writers + agent-api /task
are task-last; every Python bridge is task-mid — which means stop-at-task:
readers under-read bridge files (e.g. parse_priority_from_text has never
seen a bridge-written priority: field). Reader consolidation and writer
convergence are follow-up steps with per-bridge byte-identical acceptance.

Tests: 33 checks — per-shape fixtures (discord/gateway/voice/chat/phone-
legacy/health/forged-body), id validation, archive rules, vocabulary parity
with the gateway, plus a live-corpus sweep (3,397 archived files parse
clean) that skips gracefully where no workspace exists (CI).

R1 honored: stdlib-only, no I/O beyond the explicit archive helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run CI on staging-interaction-planes PRs and pushes

The branch filter predates this staging branch — without it, refactor-step
PRs (like this one) get zero CI. Same pattern staging-workspace-revamp used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run the coverage gate on staging-interaction-planes PRs too

Same branch-filter gap as ci.yml — without it, refactor-step PRs with
Python changes skip the 95% diff-coverage bar until the staging→main merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): task_priority reads via local_task_protocol (step 3b, reader 1/3)

parse_priority_from_text delegates header extraction to the protocol
module's safe parser; the legacy blank/'---' stop is preserved locally as
a documented pre-#982 quirk. Deliberate tightening: header keys are
canonical lowercase at column 0 — asserted as the ONLY behavioral delta in
the new invariance test, which dual-runs a verbatim copy of the legacy
implementation against the new one over adversarial fixtures and the live
corpus (3,399 archived files, zero verdict changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): discord _task_source reads via parse_task_headers_lenient (step 3b, reader 2/3)

New protocol parser: parse_task_headers_lenient — full scan, FIRST
occurrence per key wins. This is the shape-union reader: the archive
proves producer field order changed across eras (May-2026 voice tasks are
task-MID; today's are task-last), and _task_source classifies files of any
age. The stricter stop-at-task: parser flips the DM-fallback verdict on 23
real archived voice tasks — caught by this commit's corpus dual-run, which
now asserts extraction-identical over all 3,401 files.

Known pre-existing first-wins spoofability (a body line can supply a key
the file legitimately lacks) is documented at the parser and pinned by a
fixture; hardening it changes historical verdicts and is an owner decision,
not a read-side refactor.

Gateway _valid_tid deliberately NOT switched: it is a remote-compat gate
(any dot/dash id from older gateways), a different contract from the local
task-id canon in valid_task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* wip(delegation): step-4 seam — backends, relay watcher, agent-api endpoints

* fix(protocol): self-sufficient local_task_protocol import in task_priority

tests/task-priority-stop-at-task-delimiter.test.py (and any other consumer)
loads task_priority standalone via importlib without src/ on sys.path — the
bare import broke it in CI, which also failed the coverage gate (suite dies
under instrumentation before coverage is measurable). Same self-pathing
pattern the bridges use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retire staging-workspace-revamp from workflow branch filters

That refactor's staging branch merged 2026-06-01 (M0); the filter entry is
dead config. Owner-requested cleanup alongside the staging-interaction-planes
addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(delegation): TaskDelegationService — LocalTaskBackend byte-identical, RelayTaskBackend over agent-api (step 4, #1947)

The seam between delegation logic and where tasks/results live:

- src/task-delegation.ts: TaskDelegationService interface (submit + result
  I/O primitives), LocalTaskBackend (today's exact file I/O), RelayTaskBackend
  (same ops over agent-api HTTP with bearer), selectBackend (boot-time
  probe: writable tasks/ → local; else CORE_API_URL → relay; else loud throw
  — the 2026-05-18 silent-drop lesson applied at the seam).
- task-bridge.ts: workTool + writeChatTask submit via the backend (local
  mode = byte-identical writes). The LOCAL result watcher is untouched — in
  relay mode a separate loop covers the delegation-critical subset only:
  timeout sweep + results for tasks THIS process submitted (consuming other
  task-* results would steal them from core-host consumers; deliver-once).
  Core-host-only flows (voice-/question-/proactive-, context consolidation)
  stay core-host-only by design.
- agent-api.py: 4 delegation endpoints (POST /delegation/tasks, GET
  /delegation/results[/name], POST /delegation/archive). Stricter auth than
  the rest of the API: a core with no SUTANDO_API_TOKEN refuses delegation
  outright (403) instead of the local-dev allow. Ids via
  local_task_protocol.valid_task_id; names via the CodeQL-shaped _safe_path
  (which appends .txt itself — callers pass stems); archive matches
  task-bridge's month-partitioned layout via archive_month_dir.

Tests: tests/task-delegation.test.ts (5 — byte-identity, watcher-I/O mirror,
probe order, loud-throw) and tests/agent-api-delegation.test.py (14-check
E2E against a real HTTP server on an ephemeral port: auth both ways,
byte-identical submit, traversal rejection, list/read round-trip,
month-partitioned archive). Existing task-bridge suites green (8+10), tsc
clean, full python suite unchanged.

Cross-machine E2E (Air voice-agent → MBP core) needs the second machine —
flagged in the PR for owner scheduling; the relay path is otherwise covered
by the HTTP E2E above.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(protocol): cover the archive-walk helpers with fixtures; pragma the unmeasurable discord glue

The coverage gate runs without a live workspace (corpus sweeps skip) and
without discord.py (the bridge can't import under instrumentation):
- find_archived_task / iter_archived_tasks now have fixture-based tests
  (candidate walk across live/processed/flat/month dirs, stray-dir and
  malformed-id gating) instead of relying on the live-corpus path.
- the two _task_source glue lines get pragma: no cover with the rationale
  at the site — their semantics are pinned by the invariance test's
  corpus dual-run, but the module itself is unimportable in the gate env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): define per-parser body contract + lossless task_body() reader (Codex blocker)

The trusted/lenient parsers' body looked like the full work item but
silently dropped continuation lines (health-check failure bullets, phone
hint/transcript sections). A header scan cannot losslessly split task-mid
continuation content from trailing headers, so stop pretending:

- TaskHeaders.body is now DOCUMENTED per parser: full work item for the
  task-last safe parser; scalar first-task:-line value for trusted/lenient.
- New task_body(text): the shape-independent lossless work-item reader —
  everything from the first task: line onward, verbatim. For task-mid files
  that includes trailing header lines; the honest trade is stated at the
  function.
- Tests pin the contract: trusted body excludes the health bullet (scalar
  by contract, not by accident), task_body keeps health bullets and phone
  hint/transcript, equals safe-parser body on task-last shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(state-paths): allowlist task-delegation.ts (type-union false positive)

The flagged token is the `kind: 'tasks' | 'results'` TYPE UNION on the
injected archive callback — the module never resolves workspace paths
itself; dirs are injected by task-bridge, which resolves via
workspace_default. Rationale recorded in the allowlist entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): vocabulary-locked parsers + lossless task-mid bodies (Codex P2s)

Both P2s from the re-review, fixed as one coherent mechanism:

1. Parsers promote ONLY KNOWN_HEADER_KEYS to headers, and
   task_body_guard._HEADER_KEYS now IS that list (imported — single source
   of truth). Lockstep is the invariant: no key a parser would trust can
   survive undefanged in user-supplied content. Side benefit: junk
   key-shaped lines (transcript 'Caller: hi') can no longer masquerade as
   metadata.

2. With the vocabulary boundary in place, task-mid bodies become losslessly
   splittable: trusted/lenient body = the task: line's content plus every
   subsequent NON-vocabulary line — health-check bullets and phone
   transcript dialogue stay in body, trailing real headers are excluded.
   Every post-task: line lands in exactly one of headers/body.

Tests: lockstep assertion (guard set == parser vocabulary), forged
non-classic keys (instructions/hint/from/transcript/attempts) defanged,
health bullets + transcript preserved in trusted body, and a corpus-wide
body-fidelity sweep (3,415 archived files: no non-header line lost).
Full suite: injection-guard, dm-fallback-guard, both invariance tests,
interaction-type, gateway, priority — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(delegation): extract route bodies as module functions + direct-call coverage

The coverage gate's tracer misses handler-thread execution, so the HTTP
E2E alone left the route bodies unattributed (77.5% on changed lines).
The four delegation route bodies are now module-level (status, payload)
functions — the handler is thin dispatch — and the test exercises them
BOTH ways: HTTP round-trip for dispatch+auth, direct main-thread calls
for the bodies (submit good/bad-id/empty, list, read good/404/traversal,
archive move/already-gone/bad-tid).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(delegation): serve the E2E on the main thread + cover the OSError branch

The coverage gate's tracer misses handler-THREAD execution, so even with
the route bodies extracted, the thin dispatch lines stayed unattributed.
Inverted the E2E threading: plain HTTPServer with a handle_request loop on
the MAIN thread, requests issued from a worker thread — every handler line
now runs traced. Also covers delegation_list_results' unreadable-dir
branch. 25 checks green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(delegation): exercise every guard branch — the real coverage gap

Corrected diagnosis: the missed lines were never thread-attribution — they
were the UNTAKEN guard branches (wrong-bearer on the GET/archive routes,
malformed-JSON except paths, tokenless refusal on read/archive). Local
repro under the gate's exact invocation (coverage run --rcfile=.coveragerc)
now shows zero missing delegation lines.

Also hardened the test client: the server may close the socket right after
an error response, so reading the error body races ECONNRESET — status
codes are what the assertions need; the body read is best-effort now.
32 checks green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(delegation): positive relay config + task-identity coherence + archive ownership (Codex P1s)

All three P1s from the re-review:

1. CORE_API_URL is now POSITIVE configuration: set → relay, regardless of
   local writability. The probe-first order made relay unreachable on any
   normal voice-host checkout (default workspace is <repo>/workspace —
   always writable), silently stranding delegated tasks locally. Local dirs
   are now created only on the local selection path, so a relay host grows
   no unused tasks/ + results/ dirs. Tests assert relay-wins-on-writable
   and no-local-dirs-in-relay-mode.

2. /delegation/tasks enforces identity coherence: the body's embedded id:
   header must equal the request id — a mismatched write would split task
   identity across result polling, dedupe, archive, and history.

3. /delegation/archive enforces ownership + no-clobber: the source name
   must be exactly <task_id>.txt (a relay client archives only ITS OWN
   result), and an occupied archive slot gets the epoch-suffixed name the
   bridges already use instead of overwriting history.

Suites: 36 py E2E checks + 6 TS checks green; tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants