Tags: sonichi/sutando
Tags
fix(team-guard): a marker is a control only where the router executes… … it (#3109) * fix(team-guard): a marker is a control only where the router executes it The guard treated any bracketed marker anywhere in a non-owner body as a delivery-control leak and replaced the whole reply with the sensitive-content notice. Measured cost in one day: 34 bookkeeping no-send stubs delivered as canned notices across four rooms, plus real replies eaten for QUOTING a marker in prose (issues #3107/#3022). Scope each class to the position result_markers actually honours: - [channel:] is a control at line start only; an inline mention is prose. - attach aliases stay controlled anywhere - their router regex is unanchored, and the guard must never cover less than the router executes. - suppressive markers (no-send/REPLIED/deduped) are controls at BODY START only, and stay withheld there: a guarded sender must not close its own delivery lease silently (the gateway's own redelivery no-send passes via its bytes+process-record check, never via this scan). The substituted notice now tells the truth - marked not-for-delivery, nothing withheld for content reasons - instead of alleging sensitive information. - [dm-only] is no longer controlled: it only suppresses a redirect, and redirects are themselves controlled. Both copies (src + vendored sparrow) move together. The test table grows mention-vs-use cases per marker class and pins the honest suppress notice; the old table's "team redirect withheld" fixture was an inline mention the router never executes - the mention conflation was encoded in the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(session-worker): injection cases carry per-class reasons; mention passes The consumer suite encoded the old single-class contract. Widening markers keep the leak reason; suppressive markers at body start carry their own reason (the notice tells the truth); dm-only and a prose mention of a marker pass — the router executes neither. Found by CI: this consumer was missing from my adjacent-suite list — the guard has five consumers and I ran four. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(team-guard): derive control classification from parse_markers The guard kept a parallel grammar and disagreed with the parser in both directions: a D7-headed [no-send] escaped it (the parser peels the header and executes the skip), and (?m)-anchored [channel:] withheld bodies whose redirect the router never executes (non-leading, uppercase tag, or an id outside the parser's shape). Classification now asks parse_markers what it would execute: redirect/attach actions withhold as leaks, skip actions take the honest suppression notice, dm-only and inert mentions pass. Vendored copy stays byte-identical; session-worker drops a dead constant import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(team-guard): expose the verdict as a first-class three-way outcome Cross-PR reconciliation surface for #3108: classify_result_for_tier returns deliver/leak/suppress with the notice body, and guard_result_for_tier derives from it, so the policy decision has exactly one owner. An adapter with a durable transport journal may realise SUPPRESS as a journaled silent close; the auditable record, not the visibility, is the invariant. Vendored copy stays byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(team-guard): incorporate the journaled-close stub verdict (#3108 agreement) suppression_stub_for_tier lands here verbatim from #3108's head so the shared module evolves in one branch: it answers whether a journaled transport may close a guarded lease silently and with which inert bytes (fixed literals + grammar-checked dedup id -- influenced text never rides). classify_result_for_tier answers what a delivering transport renders. A consistency pin asserts every stub verdict is also a suppress verdict. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(test): trim the consistency comment to the prose cap Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(sparrow): bump version to 0.3.1 (release cut, folded per owner) Owner-directed fold of the v0.3.1 release cut into this PR instead of a standalone bump PR. After merge, the merge commit is tagged sparrow-v0.3.1 and publish-sparrow.yml releases via Trusted Publishing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
docs(sparrow): update ag2-sparrow readme, docs and logo (#3074) * docs(sparrow): reposition as the local reliable I/O runtime; add brand assets The README, pyproject description, and package docstring still described ag2-sparrow as "the AG2 Space task relay client" — a definition the package outgrew when the event channel (0.3.0) and the delivery outbox / Delivery Core (#2975, #3013) landed. Reposition it as the channel-neutral local delivery runtime for persistent AI agents: document the three transport paths (task relay / event channel / delivery outbox), the outbox flow and its boundaries (single-drainer claims, three-state outcomes, park on OUTCOME_UNKNOWN, adapter seam), and the explicit non-goals. AG2 Space is named as the first transport profile, not the full definition. Adds the AG2 Sparrow logo under packages/ag2-sparrow/assets/ (icon PNG + SVGs, unignored from the blanket workspace assets/ rule); the README references the raw.githubusercontent URL so the image also renders on PyPI. No code changes; version stays 0.3.0 (unreleased). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sparrow): adopt the charcoal-lime brand kit (owner pick) Replaces the purple icon set with the full variant-11 brand kit (svg/png marks, lockup, favicon, og-image, usage README). README header now uses png/icon-charcoal-512.png. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sparrow): trim brand assets to the two files in use (owner ask) Just the README header icon (png + source svg); the full kit stays out of the repo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sparrow): distinguish the outbox contract from the wired relay path Codex review (blocking): the README/description presented transactional- outbox delivery as the package's active outbound runtime, but at this head DeliveryCore/DesignAClaimBackend/acquire_delivery_claim have no callers outside their owning modules — the installed entry point posts results directly to the gateway and retries a network-ambiguous POST. Reframe the outbox as a library contract the outbound path is migrating onto: opening copy, path table, outbox section preface, single-drainer bullet, pyproject description, and package docstring all now say primitives/contract, not active guarantee. Wiring the migration is a separate code PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sparrow): header uses the transparent mark, not the app tile (owner) A runtime's docs mark shouldn't read as an app icon. Theme-aware <picture>: mark-on-dark for dark scheme, mark-on-light as the fallback img (also what PyPI's sanitizer keeps). Assets stay at four small files (2 png + 2 svg sources), from the updated kit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sparrow): add the mark assets the header references (missed in 67b0baa) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: qingyun-wu (sutando-001) <qingyun@ag2.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
v0.11.0 — collaborator tier with AG2 Space 54 commits since v0.10.0 (10 features, 39 fixes). Headline: a Team room reaches the owner-capability runtime only on an explicit, broker-attested collaborator opt-in (#2824). Missing or invalid controls, older gateways, and local owner-to-Team downgrades all keep the restricted path.
feat(observe): default policy pack + seed machinery for #2319 (connec… …t-wiring is a follow-up) (#2320) * feat(observe): default policy pack — factory-default subscriptions, owner-visible + disable-able Auto-registers a set of pre-blessed standing-approval policies on agent first-connect (no manual config). First entry: 👀 react baseline (observe m.reaction across all member rooms), owner-visible and individually disable-able. Reuses observe_policy.validate_draft + evaluate_standing_approval rather than bypassing the boundary — a pack entry that fails the locked scope is refused, never silently activated (fail-closed; tested). Fans cross-room entries out to concrete per-room records so observe_policy's room_id invariant holds and air's sparrow consumer (#2319) stays zero-change. Disable cancels live per-room subs + bumps a per-entry generation; deterministic per-room ids keep connect-time re-seed idempotent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(observe): cover default-pack CLI + branch paths (diff coverage 71%→99%) Add tests for list_pack, unknown-entry fail-closed branches, on_room_join default-scope + disabled-skip, wrong-scope entry skip, the owner-ops CLI (main() via argv with an explicit --store, incl. error exits), and _default_store_dir resolution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(observe): react baseline subscribes to message.created, not m.reaction Air's #2319 default_observer keys on event type 'message.created' — the 👀 observed-receipt reacts to each new MESSAGE, not to reactions (which would be circular). Align the react-baseline pack entry's event_types so the registration side (this pack) and the consumer (#2319) meet on the same event type. Confirmed against #2319's default_observer._maybe_react. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(observe): design doc says message.created (was stale m.reaction) Follow-up to the code fix (e22c175) — the design doc's react-baseline line still described the old m.reaction event type. Align it. (air review nit) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(observe): seed_room is idempotent on ANY existing generation record (never resurrect owner-cancelled) John #2320: seed_room only skipped a record whose status was `active`, so a current-generation record the owner had directly cancelled was re-seeded and transitioned back to `active` on the next connect-time reseed — silently undoing the owner's cancellation. Fix: skip if a current-generation record EXISTS in any state. `cancelled` is terminal (the owner's decision); a genuine re-enable bumps the entry's generation, yielding a fresh pid with no existing record, so re-enabling still seeds — only same-generation reseed is suppressed. Regression: seed → owner transitions one record to `cancelled` (same generation) → reseed → assert it stays `cancelled` and is reported `skipped`. Full suite 50/50 (the disable→re-enable path is unaffected). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(observe): scope the default-pack prose to what's wired — seed machinery, not connect-time registration Review blocker (#2320): the title/What/docstring/design-goal claimed the pack "auto-registers on agent first-connect / is the registration side," but the only caller of seed_defaults() is the CLI __main__ block — no production connect-time caller exists (a full-tree search finds none). The PR's own "Follow-up (not in this PR)" section already lists the connect/join hooks as follow-up, which contradicts the assertive top-line prose. Align the claims with the delivered scope: this PR ships the pack DEFINITION + seed_defaults()/on_room_join() (CLI-invokable, tested), designed to run at connect/join via a one-line hook that is an explicit follow-up in the events/room-ops layer — not wired here. Doc/docstring only; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(observe): resume a crash-interrupted draft on reseed, don't skip it forever Review blocker (#2320, my inline finding at default_policy_pack.py:151): seed_room does store.save() then a SEPARATE store.transition(pid, "active") — two atomic writes. A crash between them leaves a non-terminal DRAFT for this (entry, generation, room). The existing-record guard skipped ANY existing record, so reconnect kept finding the deterministic draft, returned "skipped", and left that room UNSUBSCRIBED FOREVER. Distinguish states in the guard: an existing `draft` is a crash-interrupted seed — re-run the standing boundary (the owner's scope may have changed since the crash; refuse if it no longer passes) and transition it to active, so the seed self-heals on the next connect. `active` (already seeded) and `cancelled` (the owner's direct cancellation — never resurrect) still skip. Test: test_reseed_resumes_crash_interrupted_draft saves a validated draft (simulating the crash), reseeds, asserts status "resumed" + stored record active, plus idempotence on the next reseed. Verified it fails without the draft-resume branch (disable-the-repair). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(observe): bound the pack's aggregate spend, don't just cap each draft evaluate_standing_approval() checks ONE draft's cost cap, which says nothing about how many drafts there may be. Fanning an entry across every member room multiplies it, and every later join multiplies it again. Reproduced on 144ea82: advertised default = 2 3 rooms -> caps [2,2,2] aggregate = 6 ...then on_room_join('!d') aggregate = 8 Each draft passes the boundary individually while the total the owner actually authorized grows with no policy edit and no renewed approval. A per-draft ceiling structurally cannot express "how much in total" — it sees one draft and cannot know it is the twelfth. The pack now carries its own aggregate budget, enforced at BOTH activation sites (fresh seed and the crash-resume path — a resumed draft is not yet active, so leaving it unchecked would make crashing a way to exceed the budget). Beyond the budget a room is not silently dropped and not silently activated: it is refused with a reason naming the budget, so it surfaces as an explicit card the owner can approve. The boundary degrades to CONSENT, which is the only direction that is safe to get wrong — a wrong "needs a click" costs a click, a wrong "auto-activated" spends the owner's budget without asking. The budget counts only pack-provenance records. Counting owner-approved policies too would let an explicit approval shrink the automatic allowance, so approving something would make the next automatic grant harder — backwards. Regressions pin the contract, per the review ask: 15-room fan-out stays bounded, a later join cannot widen it, an under-budget join STILL seeds (calibration — the other assertions are all satisfied by a blanket refusal that would break the feature outright), repeated reseeds do not double-count, and a non-pack policy contributes zero. The suite computes the aggregate from the store on disk rather than calling the module's own accounting, so a miscount cannot confirm itself. Branch refreshed against main (79 commits) per the review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): back the "explicit approval" promise with an actual record The refusal copy said explicit approval was required for an over-budget room, but seed_room() returned before store.save(), so the deterministic policy_id did not exist. transition(pid,"active") had nothing to act on and the result carried none of the fields a confirmation card needs. Verified on 08cedd9 with an 11-room seed: 10 seeded, 1 refused, store.get(refused) is None, result keys were only {entry, policy_id, reason, room_id, status}. So rooms past the budget were neither auto-subscribed NOR owner-actionable — which is precisely the silent drop the budget exists to prevent. The guard was correct about what NOT to do and wrong about what happens instead. The over-budget policy is now persisted as a DRAFT. A draft is the right state: inspectable, consumes no budget (committed_evals_per_day counts ACTIVE only), and the resume path re-runs the same check so it cannot self-activate while over budget — but it DOES activate on the next seed once a room is cancelled and the allowance frees. Self-healing instead of requiring an owner re-seed dance. Guards: the refused room now leaves an approvable record and transition() works; a persisted draft does NOT resume while saturated (control — seed_room treats an existing draft as crash-interrupted, so persisting one could have opened a back door that activates on reconnect); and it DOES resume once budget frees (calibration — the control alone is satisfied by a draft that can never activate, which would make "awaiting approval" a dead end). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): disabling the pack must revoke its pending drafts too Follow-on blocker created by the previous fix. set_enabled(False) cancelled only ACTIVE records; the over-budget draft that fix started persisting is not active, so it survived the disable — and `draft -> active` is a legal transition, so an approval card minted before the disable still activated a room afterwards. Reproduced on adbd1b5: before_disable draft / disable {'cancelled_rooms': 10} / after_disable draft activate_stale True / entry_enabled False / final_status active The owner's disable was advisory, which is the one thing a disable must not be. Cancelling closes it completely rather than partially: `cancelled` is TERMINAL in the store (transition permits draft->{active,cancelled} and active->{cancelled,expired}, nothing out of cancelled), so a late click on a stale card now returns False instead of resurrecting the room. That is why no separate check is added to the activation path — the guard belongs in the state machine, not in a caller that could forget to ask. Guards: disable revokes the draft AND a stale approval is refused afterwards (the status is not the point; the refused activation is). Plus a calibration that disable-then-re-enable still seeds a fresh generation, since the first guard is equally satisfied by a disable that destroys the entry permanently. The general shape, worth stating because it is what I missed: a fix that introduces a NEW RECORD TYPE has to be walked through every lifecycle that enumerates records, not only the one it was written for. This draft was reachable by seeding, budget accounting, resume, and disable; I had considered the first three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): make disable authoritative against a seed already in flight TOCTOU in the sweep added by the previous fix. set_enabled(False) read the draft list, cancelled, and only THEN committed disabled=True — so a seed already in flight could persist a record into that gap, absent from the sweep and not yet gated by the flag. Reproduced on 4d50448, two variants of one root cause: over budget -> persisted as `draft`, survived the disable, and a late approval click activated it: activate_late True, final active under budget -> the sweep had already freed allowance, so the racing seed went straight to `active` and needed no click at all — a live subscription on an entry reporting disabled The second is worse and ordering alone cannot catch it, so the fix is both: 1. Commit disabled/generation BEFORE sweeping. The window inverts: anything persisted before the commit is caught by the sweep that now runs after it. 2. Revalidate at every persist/activate point in seed_room (_entry_still_live re-READS pack state rather than trusting what was loaded at entry). Anything still in flight after the commit sees the disable and refuses. Together they cover both halves without a lock the store does not have. The generation is checked too, so a stale in-flight seed cannot land on a re-enabled entry's fresh generation. Regressions pin both variants plus the ordering itself — the latter asserted directly (a reader observing mid-sweep sees the entry already disabled) rather than inferred from the race passing. Note for the reviewer: the first version of this guard broke three existing resume tests because I inserted it at the wrong indentation, making the resume path's activation dead code after a return. The existing suite caught it. Kept as a reminder that a guard added to a nested branch is a structural edit, not an insertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): an owner approval must not consume the pack's automatic allowance Found by enumerating the pack's lifecycles rather than waiting for the next review — the previous four rounds each surfaced one lifecycle at a time, so this pass walked them deliberately: seed, resume, budget, disable, re-enable, approve. The approve cell was wrong. committed_evals_per_day()'s own docstring states the rule: "approving something should never make the next automatic grant harder." But an over-budget draft was persisted with plain pack provenance, so approving it counted against the very budget that had refused it. Measured on df4b7b3 with 13 rooms: seeded 10, aggregate 20/20, 3 rooms queued as approvable drafts owner approves all 3 -> aggregate 26/20 brand-new room joins -> refused (26 + 2 > 20) cancel a room and retry -> still refused The pack could never auto-seed again. Note the shape: the code implemented NEITHER policy cleanly — approval could exceed the cap (so the cap was not a hard total) while also consuming the allowance (so approvals were not outside it). Over-budget drafts now carry `pack.over_budget = True`, and the accounting skips those records permanently, including after approval. The budget bounds what the pack grants ITSELF; an explicit owner decision is deliberately outside it, which is what the module already claimed to do. If the intended contract is instead a hard total across both, that is a different change — the approval path would need its own budget check — and it is an owner policy call, not a refactor. Flagged rather than assumed. Calibrated: the cap still bounds automatic grants (13 rooms -> 10 auto-seeds, aggregate 20), since the new guard is equally satisfied by deleting the budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(observe): the owner view must show rooms awaiting the owner's approval Continued lifecycle enumeration — the `list_pack` cell. The over-budget refusal tells the owner the room "surfaces as an explicit card the owner can approve", but `list_pack` reported only `active_rooms`, so the rooms actually awaiting her decision were invisible in the one view built for her. This is the same defect already fixed once on this PR, one layer up. At the RECORD layer the refusal promised an approval path with nothing approvable behind it; here the record exists and the VIEW omits it. A decision she cannot see is not a decision she has. list_pack now reports `awaiting_approval` alongside `active_rooms`. Guards: every queued room appears, an approved room MOVES between the two lists rather than appearing in both, and — calibration — the field can be empty, since "always lists 3" would satisfy the first assertion just as well. The empty case is a disabled entry, which also re-checks that disable revokes pending drafts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): compare-and-commit so a disable cannot be lost by an in-flight seed `_entry_still_live()` was a READ followed by SEPARATE save/activate writes. A disable landing in that window was missed by both sides: its sweep found no record to cancel (the seed had not saved yet) and the seed had already passed its only check, so an entry the owner had just revoked came back ACTIVE. Reachable across processes, which is what makes it a defect rather than a synthetic interleaving: this module ships a CLI (`disable`) that runs against the same store dir while the core handles a room join, so observe_policy's "single-writer (the core), so no lock protocol needed" does not hold here. Fix: publish the record BEFORE the deciding check, then verify. * if the disable's sweep runs after the save, it SEES the record and cancels it -- `cancelled` is terminal, so the activation fails safely; * if the sweep already passed, the re-read observes `disabled` and the seed cancels its own record. Either interleaving ends non-active. transition()'s return value is now honoured; ignoring it is what let a cancelled record report as seeded. The cheap pre-write check is kept: it is what stops a seed racing the sweep from persisting anything at all (the existing racing-seed tests assert exactly that). Evidence, same harness at parent and at HEAD: parent: {"seed_status": "seeded", "stored_status": "active", "entry_enabled": false} HEAD: {"seed_status": "refused", "stored_status": "cancelled", "entry_enabled": false} Regression added with its injection asserted as a control, so it cannot pass vacuously. It FAILS at the parent commit ("got 'active'") and passes here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): close the same seed/disable race on the over-budget draft path The previous commit fixed one instance of the pattern, not the class. The over-budget branch does the same thing the activate path did: `_entry_still_live()` is a READ, `store.save()` is a WRITE, and nothing between them re-verifies. A disable landing there is missed by the sweep (no record yet) and strands a draft on a disabled entry. A stranded DRAFT is not benign, which is the easy mistake: it observes nothing by itself, but `draft -> active` is a legal transition, so an approval card minted before the disable can still activate the room afterwards. That is reproduced on adbd1b5 and is exactly why set_enabled() sweeps pending drafts as well as active records -- so leaving this path unfixed would have re-opened the hole that sweep exists to close. Same publish-then-verify as the activate path: save first, re-read, cancel the record if the entry went away. Regression: test_over_budget_draft_racing_a_disable_is_not_left_live. It FAILS at the parent ("got 'draft'") and passes here. Note on its control: asserting the refusal RESULT would have been worthless, since the reason string flips from the budget message to the disabled message once the fix lands -- any "status == refused" check then passes without proving the over-budget branch was reached at all. The control asserts the PRECONDITION instead (committed == PACK_AGGREGATE_EVALS_PER_DAY, 20/20), which is independent of which branch the code takes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): the resume path re-save resurrects a cancelled record Review flagged this branch for ignoring transition()'s return value. It is that, but the measurement came back worse than the diagnosis: the record ends ACTIVE, not merely mis-reported. set_enabled() argues that cancelling is sufficient on its own because `cancelled` is terminal -- "the guard lives in the state machine, not in a caller that could forget to ask". That holds for transition(). It does NOT hold for save(), which is a blind whole-record overwrite (json.dump + os.replace) consulting no state machine. So the real sequence on this path is: 1. the disable sweep cancels the pre-existing draft; 2. this re-save OVERWRITES it back to `draft`; 3. the activation then legally succeeds. A disabled entry goes ACTIVE, and the terminality the sweep depends on is silently undone by a writer that never asked. Fix mirrors the other two paths: re-read after the save, cancel if the entry went away, and honour transition()'s return. The regression's control is deliberately NOT "the sweep cancelled it" -- that was true for a moment and then overwritten. It asserts the end-state invariant: a disabled entry is never left with a live record, however many writers touched it. Reverting only this hunk gives 'active' + 'resumed' and exit 1; restored, exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): serialize seed/disable/transition over a per-store lock Closes the two P1 multi-writer races by making the authority check and the write one step instead of two correct steps. 1. transition() was a non-atomic read/modify/write: a disable cancelling a record between its get() and its save() was silently overwritten. It now holds the store lock across both. 2. Budget reservation was check-then-write: two seeds each passed _budget_allows() before either committed, then both activated (measured 18/20 -> committed 22). The reservation now spans budget-check -> save -> activate in one critical section. 3. set_enabled's commit + sweep runs under the same lock, closing the window where a seed writes between the two halves. The lock is process-wide RE-ENTRANT, keyed on the store DIRECTORY -- not on the SubscriptionStore instance and not on the fd. flock attaches to the open file description, so a second open()+LOCK_EX from the same process blocks forever (verified: fd1 holds, fd2 blocks, LOCK_NB gives EWOULDBLOCK). That matters because set_enabled() constructs its own store while a seed path holds a different instance, so per-instance re-entrancy would deadlock on exactly the nesting the racing-seed regressions exercise. LOCK_NB + bounded retry raises StoreLockUnavailable rather than hanging -- a hung seed on a room-join path takes the core with it. Also corrects SubscriptionStore's docstring. It said "Single-writer (the core), so no lock protocol needed"; both P1s trace back to code trusting that sentence, while this module ships a CLI that writes the same store as the running core. Regression: test_store_lock_serializes_a_SEPARATE_PROCESS, across a real fork, with three controls -- (A) lock free -> another process ACQUIRES, proving the probe can say yes; (B) held -> REFUSED; (C) inside a real transition() -> REFUSED, proving the shipped path takes it rather than a hand-rolled `with` in the test. Removing the lock from transition() flips C to ACQUIRED and the suite to FAILED(1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style(tests): one import per line — ruff E401 on the new lock regression CI caught what my local run could not: ruff is not installed on this host, so the suites passed while the lint gate failed. The offending line was inside a test function (`import subprocess, textwrap, json as _json`); the remaining comma-import in that diff lives inside the child-process source STRING and is not parsed by ruff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(observe): state the store lock's thread-awareness boundary Review caveat, non-blocking and confirmed dormant. The re-entrant fast path keys on the store directory alone, so two threads in one process would share the lock without being serialized against each other. Verified dormant rather than asserted: skills/observe contains no threading / asyncio / concurrent.futures reference, and set_enabled() has exactly one production caller (the CLI dispatcher in default_policy_pack.main), which is a separate process by construction. Documents the invariant that makes it safe today plus the exact change if threads are ever introduced -- key re-entrancy on (realpath, get_ident()) and guard with a per-directory threading.Lock, because the flock alone cannot help (two threads in one process contend on separate fds and take the LOCK_NB timeout instead of serializing). Deliberately not built: no threads exist, and a stated invariant a future reader can re-check beats untested concurrency machinery. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): the RESUME branch's budget check was outside the store lock Wrapping the first-seed reservation fixed a call site, not the class. seed_room() calls _budget_allows() TWICE -- the `status == "draft"` resume path has its own copy, and it was still unlocked, so reservation and activation were two steps there. Measured on 95149c7, starting at 18/20: two crash-interrupted drafts both passed the check and both activated -- committed 22, both writers "resumed". Fix: the resume branch's budget-check -> save -> activate now runs inside op.store_lock(store_dir), same as the first-seed path. Regression asserted CROSS-PROCESS, for the same reason as the fork test in observe-policy: an in-process synchronous injection is a nested same-thread call that the re-entrant lock lets through by design, so the property to assert is that another PROCESS cannot enter while this branch sits between its budget check and its activation. Two controls -- lock free -> ACQUIRED (proves the probe can say yes), inside the resume branch -> REFUSED. Reverting only this hunk flips it to ACQUIRED and the suite to FAILED(1). Note the in-process repro still reproduces after this fix, and that is expected rather than a miss -- same distinction as the previous commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): re-read the record inside the lock — a resume could resurrect a cancel Fifth instance of this class, and at the site my own audit cleared last round. `existing = store.get(pid)` is read BEFORE the lock, only to choose the branch. Now that transition() correctly holds the lock, a direct owner cancellation can complete FIRST; the resume branch then proceeds on a stale `draft` view, the blind save rewrites `cancelled` -> `draft`, and the activation legally succeeds. Every existing guard misses it for one reason: `_entry_still_live()` checks the pack ENTRY (enabled + generation), while this is a per-RECORD cancellation. The entry stays enabled, so the check passes and nothing inside the lock was re-reading the record's own status. Measured at 3572d09: transition_cancelled True, result "resumed", stored_status ACTIVE. Fix: compare-and-commit inside the critical section -- re-read the record under the lock and refuse unless it is still `draft`. Regression: test_owner_cancel_racing_a_resume_cannot_resurrect_the_record, with the cancel injected between the pre-lock read and the lock acquisition. Reverting only the guard gives 'active' / 'resumed' and FAILED(3). Why my audit missed it, since the same class has now recurred five times: it enumerated WRITES and asked whether each was locked -- and every write was. It never asked whether the READ that selects the branch could go stale. Enumerating the wrong noun looks exactly like completeness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): serialize same-policy idempotency with the write Sixth instance, and the SECOND of two blocking reviews at the same head — I fixed only the resume-branch one last round and shipped while this was still open. `existing = store.get(pid)` is read before the lock only to choose a branch. Two writers racing the same room both see None and both reach the first-seed section. The first activates at the final budget slot; the second, now over budget, blind-saves the SAME deterministic policy_id as an over_budget draft. A working subscription is silently downgraded while the first writer has already returned "seeded", and the freed aggregate can fund another grant. _entry_still_live() cannot catch it: it validates pack authority and generation, never whether another writer created this policy_id. Measured at 3572d09: results ['seeded','refused'], record_status 'draft', over_budget True, aggregate back to 18. After: ['seeded','skipped'], record_status 'active', over_budget False, aggregate 20. Fix: re-read the record at the top of the critical section and return `skipped` if one now exists -- idempotency is part of the critical section, not a pre-check. Regression: test_concurrent_seed_of_the_same_room_is_idempotent_not_a_downgrade, with controls that the concurrent writer ran and that writer A did seed. Reverting only this guard gives 'draft' + over_budget and FAILED(2). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style(observe): trim comments/docstrings to repo policy — constraint-only, ≤2 lines Per the comment policy in AGENTS.md: narration, history, and cross-references moved out of code; design rationale lives in default-pack-design.md and the PR body. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXgoNuguf17rwK9knwNLk8 --------- Co-authored-by: Qingyun Wu <qingyun@ag2.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
feat(telemetry): emit task_processed from every task source, not just… … messaging bridges (#2274) * feat(telemetry): emit task_processed from every task source, not just messaging bridges task_processed — the activation signal separating "installed" from "actually does work" — fired from only the discord/slack/telegram bridges. Voice, local chat, context-drop, phone, and the relay-voice / API / SMS / voicemail surfaces created tasks but never counted, so DAU/WAU under-reported anyone who doesn't route through a messaging platform (the owner's own computer-direct path included). Close the gap at each task-accept point, keyed on the task body's own `source:` header so every surface counts under its true bucket: - src/telemetry.py: add a `flush` kwarg to task_processed + a `_cli_main` CLI entrypoint (`python3 src/telemetry.py task_processed <source>`) so non-Python creators emit via one source of truth. Flush path guarantees the short-lived subprocess delivers before exit. - src/task-delegation.ts: LocalTaskBackend.submitTask emits (voice/chat/ context-drop); parseTaskSource exported + unit-tested. - skills/phone-conversation/scripts/conversation-server.ts: phone tasks emit. - src/agent-api.py: relay-voice delegation, incoming call, SMS, voicemail, and API task creation emit, reading source from the body. Fire-and-forget everywhere: never blocks, throws, or breaks task creation; no-ops when telemetry is opted out / unconfigured; carries only the coarse source bucket — never task text, ids, user, or channel. Live tests: tests/telemetry.test.py 23/23 pass (incl. CLI entrypoint delivers source-tagged event + _cli_main branch coverage); tests/task-delegation.test.ts 6/6 pass (incl. parseTaskSource); tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPBv1rU2s1VrdtZCVd2o8r * fix(telemetry): validate task_processed source against a fixed allowlist The API/web task path emits task_processed with a `source:` read from the caller-supplied task body, so an arbitrary unbounded string could reach PostHog as the `source` property — contradicting the coarse-only contract and risking accidental identifier/secret leakage or unbounded cardinality (CR #2274, qingyun-wu). Fix at the single source of truth: task_processed() now collapses its source through `_coarse_source()` against a fixed allowlist of known surfaces, mapping anything unrecognized to "unknown". This covers every caller — the Python in-process emit, the `python3 src/telemetry.py task_processed <source>` CLI (TS task-delegation/phone paths), and the agent-api emit — without duplicating validation per surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPBv1rU2s1VrdtZCVd2o8r * fix(telemetry): emit task_processed from cron + github webhook writers liususan091219 cold-review CR on #2274: the PR added 'cron' and 'github' to the telemetry _KNOWN_SOURCES allowlist, but neither task writer emitted task_processed — so DAU/WAU still under-reported cron- and webhook-driven activity and the allowlist advertised two buckets that could never fire. Add a fire-and-forget task_processed emit at each write site, mirroring the discord/slack/telegram bridges + agent-api: - src/cron-runner.py emit_task() -> task_processed('cron') - src/github-webhook.py do_POST -> task_processed('github') Behavioral regressions (not source-grep): cron-runner.test.py asserts emit_task fires task_processed('cron') once via a telemetry spy; github-webhook-access-tier.test.py drives do_POST end-to-end (signed request through the real handler) and asserts the 'github' emit. Full suites green (cron ALL PASSED, github 31/31, telemetry 24/24). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfqS1Z5rwiCqxMwQcp12dy * fix(telemetry): flush cron event before runner exits * fix(telemetry): cover context drops and phone spawn errors --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(discord): surface result delivery failures (#2217) * fix(discord): surface result delivery failures * test(discord): cover _report_delivery_failure owner-resolution + never-raise branches The diff-coverage gate flagged _report_delivery_failure: the existing test only hit the owner+DMChannel happy path. Extend it to exercise the remaining branches by calling the reporter directly: - non-owner/non-DM channel → resolve_owner_id resolves the canonical owner - resolve_owner_id None → fall back to scanning allowFrom for a non-bot user - no owner resolvable → log + return, no DM, no raise - owner DM resolves but send() raises → final handler swallows it (never raises) Test-only change; behavior unchanged. All cases pass locally. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LvRyLAxRugJ9ygj8BgNR8 * test(discord): cover the 3 remaining except-branches (emit/access-file/fetch-user) diff-cover still flagged 3 defensive except paths in _report_delivery_failure: the best-effort _emit_channel (except: pass), an unreadable ACCESS_FILE (except → {}), and a fetch_user error mid allowFrom scan (except: continue). Add/adjust cases so all three execute; local coverage of the function is now 100% (0 missing lines). Test-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LvRyLAxRugJ9ygj8BgNR8 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(station): stop headless core hanging on AskUserQuestion (#2009) The core agent runs non-interactively — start-cli.sh launches `claude` with --dangerously-skip-permissions inside tmux, driven over --remote-control, with no human at the terminal. When the model calls the built-in `AskUserQuestion` tool there is no UI to answer it, so the tool blocks the session INDEFINITELY (observed in the obs collector: a tool.call with tool_name=AskUserQuestion, permission_mode=bypassPermissions, that never returns). Add a PreToolUse hook that denies AskUserQuestion. A deny short-circuits the call before it can render and feeds the reason back to the model, which then proceeds autonomously instead of hanging. The hook is a no-op (exit 0) for every other tool and fails OPEN on any error, so it can never wedge the core. - hooks/skip-ask-user-question.py: the standalone PreToolUse deny hook. - src/agent/claude/cli/build-core-settings.mjs: composes the core --settings JSON, always including the guard and array-concat-merging the obs collector hooks when capture is on (one --settings flag carries both; multiple flags are undocumented). Obs event map stays owned by build-hook-settings.mjs. - start-cli.sh: always registers the guard (obs still gated on the endpoint). - Auto-registered every core launch — no per-node deploy step. - Tests for the hook (deny/allow/fail-open) and the composer (guard-always, obs-merge, adversarial path quoting). Co-authored-by: jsun-m <jsun-m@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Interaction-planes refactor: Local Task Protocol + TaskDelegationServ… …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>
fix(app): resolve menu-bar avatar from workspace, not repoRoot (#1931) Avatar lives under the workspace assets/, not the code checkout; align both load sites (setupMenuBar, avatarImage) with the workspace contract. Stand: Echo Act IV Pro Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
PreviousNext