Conversation
…2763) `_adopt_legacy_clone` decided whether a legacy `skills_library_url` named an already-configured source with raw string equality, while the rest of the platform stores that URL normalized. The same repository written two ways therefore never matched, the install took the ent#346 "already has sources" refusal branch on every sync, and filed a fresh un-deduped high-priority alert each time (#2744 is that flood). `validate_skills_library_url` is a validator AND a normalizer (`github.com/o/r` -> `https://github.com/o/r`), and `routers/skills.py:649` uses it as one when it stores a source. This path called it and discarded the return. Both representations occur on real installs by construction: the bundled default source is seeded from `config.TRINITY_DEFAULT_SKILL_SOURCE`, a bare literal that never passes through the validator, while a source created via `POST /api/skills/sources` is stored normalized. So either direction of the mismatch is reachable, and both are covered. Two changes: assign the validator's return, and compare through the new `_same_skills_repo`, which normalizes the STORED side too — normalizing only the setting would still miss every install whose source came from the seed. `reject_embedded_credentials` keeps seeing the ORIGINAL string; it must judge what was actually written, not a form we produced. This cannot weaken ent#346. A match returns an existing source id and creates no row, so it is the no-op branch; the grant branch (`count_skill_sources() == 0` -> `create_skill_source`) is untouched, and a key naming a genuinely different repo still reaches the refusal. Normalizing removes false positives from the detector without widening what may be granted — asserted by two of the eight tests, not just claimed here. Scope is deliberately the scheme/no-scheme split the platform itself creates. `…/repo.git`, a trailing `/` and the bare `owner/repo` shorthand stay distinct and are pinned by a test, so collapsing them later is an argued ent#346 decision rather than a quiet widening. Verified: reverting ONLY the call site (keeping the helper defined) fails exactly the two regression cases and leaves the other six green — the fix flips what it targets and nothing else. 496 passed across every skills-related suite. Fixes #2763 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…nstance (#2766) `_resolve_title_auth` preferred the instance-wide Anthropic API key outright and only fell back to the agent's subscription. On a fleet where every agent runs on a Claude subscription, thread titles were therefore billed to — and gated on — a console account no agent was assigned, so an unfunded or revoked instance key broke a Workspace feature for agents that were otherwise completely healthy. The reported symptom is HTTP 400 "credit balance is too low" on every attempt, the generator latching to `failing`, and every new chat keeping its first-message fallback title. The credential now FOLLOWS THE AGENT, matching how its chat is already billed. The mode comes from `subscription_service.derive_auth_mode` — the platform's one auth-mode derivation (#471) — rather than a second answer to "what is this agent authenticated as": subscription -> its own OAuth token; api_key -> the instance key; not_configured -> no credential and the derived title stands. A subscription-mode agent whose token cannot be read returns None rather than falling through to the instance key. Falling through is precisely the shadowing this fixes: a key the agent was never assigned is not a credential it holds, so a missing token means "no credential for this agent", not "use someone else's". Same class as #2114 on the agent side, one layer up. Second half of the issue: a non-200 now records the upstream reason rather than the bare status. `HTTP 400` alone reads as a transport fault and sends the operator after the wrong thing; `HTTP 400 · invalid_request_error · Your credit balance is too low...` points at billing. Only the API's own `error.type` and `error.message` are used, never the raw body, and the phrase is scrubbed for credential-shaped text before it reaches the operator-visible health record. Verified: reverting ONLY the precedence (keeping the new helper defined so the module still imports) fails exactly the two behavioural regressions plus the derivation guard, and leaves the other eight green — including the api_key-mode path, which must keep working unchanged. Note on the suite: `test_2638_subscription_switch_on_turn.py` is flaky on `origin/dev` independently of this change — the same selection gives 3/3/0 failures across three runs with this branch's code entirely absent, and 3/0/10/5 with it. It passes standalone both ways. Filed separately; not addressed here. Fixes #2766 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…holds (#2700) Trinity Rule #1 — the requirements change lands before the code that falsifies them. `POST …/voice/start` arms the thread's live-call marker before the audio WebSocket that is the only thing able to clear it exists, so a start whose socket never opens strands a ~32-minute `409 voice_call_active` on every typed turn in that thread, naming a call the person cannot end. - public-access.md §48.3 FR-3b: "no reply lands mid-call" becomes "…once the audio bridge is up", and a call whose socket never opens never holds the thread. - runtimes.md §29.10 VOICE-010 (Session lifetime): "this thread is on a call" is a property of the session's connection lifetime — an owned lease armed by the bridge at connect, renewed while it lives, never past the cap, released on every exit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
… connect and always releases it (#2700) `POST …/voice/start` armed the thread's live-call marker (TTL = the call cap + slack = 1920 s) before the audio WebSocket that is the only thing able to clear it existed. Both clear paths sit downstream of that socket, and so does the cap watchdog that would end the session — so a start whose socket never opened stranded a ~32-minute `409 voice_call_active` on every typed turn in that thread, from any tab and from the headless `/chat`, naming a call the person cannot end. Three moves; the first is the one the issue asked for, the other two are why it holds. 1. The party that opens the effect closes it. The bridge is now the only writer: it arms the marker as the first statement inside the same `try` whose `finally` releases it, and that release is unconditional, LAST, and keyed on the `portal_session_id` local captured before the `try` — never on `ended.portal_session_id`, where an `ended is None` return (the exact case the REST `/stop` clear was added for) would re-strand it. The reported orphan window goes to zero. 2. The marker is a lease, not a latch. Its TTL is 60 s, renewed every 15 s by a bridge-owned task, bounded at the call's own `max_duration + 120` so it can never outlive one. Without this, move 1 only relocates the harm: a SIGKILL, an OOM or a routine backend deploy mid-call would still strand the thread for 32 minutes with this issue's own symptom. The 4x TTL/tick ratio and its reason are `agent_call_limiter`'s, copied not invented; the renew write goes off-loop via `asyncio.to_thread` for the same reason. 3. The lease has an owner. The value stored is the call's `voice_session_id` and a release deletes only on match (`clear_turn_inflight`'s precedent), so a closing bridge cannot free the thread of a newer call — a reload, a second tab. `owner` is keyword-only and required on both primitives. The legacy `"1"` value is treated as unowned and released, so a marker stranded across the deploy is not immortal. Also, in blast radius: the close-out is wrapped in `except Exception` + `logger.exception` (not `BaseException` — cancellation still propagates), so a raising `end_session`/persist no longer skips the gemini cancel, the `saved` frame and the close. The renew task is cancelled as the first statement of the `finally`, before anything that can await, so no renewal can re-arm after the release. The read stays fail-OPEN and `_refuse_turn_during_voice_call` is byte- unchanged (#2735's `voice_call_id` exemption intact). No new user-visible surface, state, string or default; no frontend file touched. The three `test_2694_voice_delta_context.py` tests that pin the old set site are rewritten in the next commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…n shape end to end (#2700) The issue's third AC — "the existing `test_2694_voice_delta_context.py` marker tests still pass" — cannot hold literally: three of them pin what this fix moves. It is read instead as "the marker's behavioural guarantees stay pinned — armed while a call is live, released when it ends, held only by its owner, fail-OPEN on Redis, the TTL as the backstop — and every test that changes gets stronger". `test_2694_voice_delta_context.py` - the primitives test gains the owner token: `owner` is keyword-only and required on both, a release from a DIFFERENT call does not free a live thread, the legacy `"1"` value is releasable, bytes decode, and the real lease/tick/slack constants plus the >=4x TTL:tick ratio are pinned here (the bridge tests import them from a fake module and cannot). - NEW: the renewer ticks at the lease TTL and STOPS at the call's own cap — a fake `mark` that raises after 10 calls turns a missing bound into a fast red instead of a hang — and a cancelled renewer stops quietly. - `…marks_the_thread_live_for_the_cap_plus_slack` becomes `test_start_workspace_voice_does_not_arm_the_thread_before_a_socket_exists`. The recorder takes `owner=None` by default so a RESTORED two-arg `/start` arm is recorded rather than raising: `marks == []` is what must bite. - NEW, the AC-2 test: start a call, never open a socket, then type into the same thread. The REAL `mark_voice_call_active` runs against a fake Redis on purpose — a recorder stub would swallow the write and pass even with the `/start` arm restored, so `fake.store == {}` is the assertion that bites. The turn genuinely dispatches (`recorder.calls` non-empty), and a positive control arms the lease the way the bridge now does and gets the 409 back. - the 600-char proximity grep around `persist_voice_call_end(` is replaced by `test_the_bridge_resolves_the_real_marker_helpers`: it could never see the `if ended:` nesting that IS this bug. What survives is what a source read can honestly assert — that the real modules resolve each other (every bridge test runs against a fake), that the marker import is hoisted out of the `finally`, and that the release is keyed on the pre-`try` local, not on `ended`. `test_voice_auth.py` — new `TestWorkspaceLiveCallMarker`, here because the behavioural bridge harness (the importlib load of `routers/voice.py`, the stubbed voice service, `_FakeWebSocket`, the #762 restore net) exists in this file and nowhere else. Armed at connect and released on close; released when `end_session` returns None (Trap C); released when the close path raises, with the gemini cancel, the `saved` frame and the socket close all still reached; released under task cancellation; an Agent Detail call never arms a thread marker (labelled a guard — it passes against the old code too); and the REST `/stop` release is owner-matched (an API-only guard, D9). Three harness fixes the tests need: `_FakeVoiceSession` gains `portal_session_id`/`max_duration`/`end_reason`/`end_message` (the close-out reads the last two without getattr defaults), the fake `client_portal.voice` exposes `persist_voice_turn` (imported unconditionally for a portal-bound session), and `_YieldingWebSocket` awaits once in `receive_text` — the stock fake pops its queue without a single `await`, so a bridge driven by it never yields and its `create_task`ed children never start. Red-on-base proof (both source files restored from a scratch copy, never `git checkout --`): 10 of the 11 new/rewritten tests fail against `2c5cfe0e`; the one that passes is exactly the labelled Agent Detail guard. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
… connect gap is named (#2700) The feature flow said verbatim that the marker is "written by `start_workspace_voice` once the provider session exists (TTL = the cap + slack), cleared by the bridge's `finally` and by the REST `/stop`" — the sentence this fix falsifies. - `workspace-voice-conversation.md`: the claim is retitled "no reply lands mid-call **once the audio bridge is up**", and the mechanism is rewritten — an owned lease armed by the bridge at connect, renewed while it lives, never past the call's cap, released unconditionally and owner-matched on every exit. The rule is named in one clause (a marker whose only closer lives downstream of a connection that may never exist is an orphan generator), and all three load-bearing properties are stated with the failure each prevents. The REST `/stop` is described as what it is: idempotent, owner-matched, and API-only — the Workspace passes `restStop: false` and never calls it. - Known limits gains three bullets: the `/start`→connect gap in #2694's own words (the turn posts and is answered normally; what is lost is the voice model's opening context, which is exactly where "a reply between two spoken rows hides the call's first half" becomes possible again); a half-open peer holding the lease until the renewer's cap+slack lifetime ends, including the cap path's 5 s `saved`-timeout close; and the key being one per thread. - The Files row for `routers/voice.py` names the arm, the renew and the unconditional release. - `feature-flows.md` gains the dated index row. - `learnings.md` gains the durable class entry: arm the effect in the `try` whose `finally` releases it; relocating the setter is only a third of the fix (a marker held by a live process is a lease, and the renewer needs its own bound); an unconditional release needs an owner token; and a test that pins the old write site cannot "still pass" — rewrite it and prove it bites. `architecture/workspace.md` is deliberately NOT edited: its sentence ("a typed turn is refused (409) while a call is on") states no set site and stays true, and the detail's home is the feature flow (one home per feature). Also verified unchanged: the ent#551 `voice_call_id` exemption paragraph. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…ives, and say so (#2700) Three claims the #2700 branch makes are stronger than the implementation provides. All three are the same fact — the renewer's Redis write is off-loop — and each one is a comment or a Known-limits line a future reader would build on. 1. The bridge's `finally` claimed that cancelling the renewer first means "a renewal already in flight lands before the release, and the delete wins". It does not: `asyncio.to_thread` raises `CancelledError` in the awaiting coroutine immediately and lets the worker thread run to completion, so a renewal inside its `SET` can land after the release's `DEL`. Verified with a standalone `asyncio.run` reproduction, not by reading the docs. The consequence is bounded and benign — one lease (<= 60 s) of extra 409s after a call ends, self-healing, read still fail-OPEN — and it needs the renewer to be inside a ~1 ms write at that instant AND that write to outlast the whole close-out. 2. The REST `/stop` release is not durable while the bridge is up: the renewer re-arms within a tick, so that path frees the thread only once the socket is gone. An API-only path either way (`restStop: false`). 3. "a closing call cannot free a newer one" overstated the owner match for two concurrent calls on one thread: two live renewers flip-flop the value, so a closing call frees the newer one when its own id was the last write, and the newer call's next tick re-arms within 15 s. The permanent free an unconditional delete would have caused is gone; a <= 15 s window in a deliberately rare shape is not. No behaviour change: comments, one Known-limits bullet, and a fifth clause on the issue's learnings entry, since "cancelling a task does not cancel work it already handed to a worker thread" is the durable class here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…2693) A fresh install seeds scout/sage/scribe with only an avatar_prompt, and generation needs GEMINI_API_KEY, which no new instance has — so the first screen an operator sees is a fleet of initials. A local: template may now ship avatar.webp/avatar.png beside template.yaml. At create, when the template also declares avatar_prompt, the image is re-encoded through optimize_avatar into /data/avatars before the prompt is seeded as a DEFAULT avatar, so Generate Default Avatars still overwrites it once a key exists. Offline, no outbound call. A missing, oversized or undecodable image degrades to the old prompt-only seed. The three starter templates ship hand-drawn schematic glyphs (no Gemini key was available to render the prompts, and the schematic family matches the approved first-run illustrations). scribe gains the avatar_prompt it lacked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKzc3MG4Fa1sJ1KuMmBtcV
#2744) Rule #1: the requirements delta lands before the code. §21.1.3 described adoption as "idempotent and fail-soft" and said nothing about the refusal's ALERTING. `_adopt_legacy_clone` runs as the first statement of every `sync_library()`, so on an install that is past migration but still carries a non-matching `skills_library_url` the terminal refusal files a fresh `priority: "high"`, `expires_at: None` operator-queue item on every sync — unattended under the ent#236 auto-sync loop (300s floor ⇒ 288 rows/day), never expiring, and un-dismissable because each row carries a new timestamped `request_id`. The rule this states: that refusal is the designed resting state of a migrated install, not a failure, so it is `low` + `logger.info` with a stable URL-derived id whose family prefix is reserved; the two genuine failure branches keep `high` and their repeat-visible ids by product decision. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…cho (#2744) TDD — RED on this commit, green on the next. Proven red for the right reason, not merely red: test_n_syncs_..._exactly_one_item 5 distinct timestamped ids test_a_different_refused_url_... frozen clock ⇒ both URLs share one id test_the_terminal_refusal_is_not_high... priority "high", logger.error test_the_stable_id_is_reserved_... 'skills-legacy-adoption-<ts>' unreserved test_a_pat_bearing_url_is_never_echoed... the PAT is in context.url AND the log test_the_actionable_branches_keep_high... GREEN on base, by design (AC 4) The last one is the anti-regression half: it pins behaviour the fix must NOT change, and goes red only if the low/stable-id treatment is applied to all three call sites instead of the one the issue names. Harness notes, both load-bearing. `_record_adoption_failure` imports `utc_now_iso` INSIDE its body, so the patch target is `utils.helpers` — patching `services.skill_service.utc_now_iso` binds nothing and yields a vacuous test. And `validate_skills_library_url` does a live `socket.getaddrinfo`: on a sandboxed resolver a terminal-branch test would silently drive the validation-reject branch and fail as "5 distinct ids" / "priority is high", reading exactly like the fix regressing — so DNS is stubbed to the `gaierror` that function already tolerates, and every terminal-branch test additionally pins which branch produced its item. New file rather than an append to test_ent346_skills_source_injection.py: Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…ne per sync (#2744) `_adopt_legacy_clone()` is the first statement of every `sync_library()`. On an install that is past migration but still carries a `skills_library_url` matching no configured source, the terminal "already has sources" branch called `_record_adoption_failure`, which minted a TIMESTAMPED `request_id` at `priority: "high"` with `expires_at: None`. One permanent, high-priority, operator-unclearable row per sync, forever — 17 of them (~17% of everything pending) on the reporting install, and 288/day at the ent#236 auto-sync floor. Two behaviour changes, on ONE branch: * a STABLE, URL-keyed id (`skills-legacy-adoption-refused-{sha256(url)[:12]}`) so `create_item`'s `(agent_name, request_id)` ON CONFLICT DO NOTHING collapses N syncs to exactly one row — and, since that conflict target ignores `status`, an operator's dismissal finally sticks; * `priority: "low"` + `logger.info`, because this is the designed resting state of a migrated install, not a failure. Shaped as a keyword-only `steady_state` flag on the existing emitter rather than a second method: one #1677 `_ALLOWED_CALLERS` key, and a `False` default that leaves the two actionable call sites LITERALLY UNCHANGED lines — the strongest available proof of AC 4. The emitter keeps its name despite now serving a non-failure; renaming costs the allowlist key and churns a file two people are editing this week. The hash is over the RAW `url.strip()`, and is computed INSIDE the try: a non-str setting value must degrade to a warning and no alarm, not turn a decorative alarm into a raiser. Normalising the input instead would re-enter `validate_skills_library_url`, which does a live `socket.getaddrinfo` and can raise — a network call and a raise path inside a fail-soft alarm. Two things the stable id makes mandatory, both included: * `skills-legacy-adoption-` joins `_RESERVED_ID_PREFIXES`. An id derived from an admin-visible URL is guessable, so an agent could pre-create it and silence the alarm through the sink's ON CONFLICT (the #1632 C2 class); and `is_platform_minted` reads the same tuple to gate the ent#499 responded write-back and the ent#329 respond→resume dispatch, which this change makes an expected operator action. The FAMILY prefix, so all three call sites and the 17 historical rows classify correctly. * the URL echo is `strip_url_credentials`-scrubbed. The emitter's docstring claimed the credential case was handled, and that was true of `message` and of nothing else: `EmbeddedCredentialError` is a `ValueError` subclass, so the validation-reject branch is exactly the one a PAT-bearing URL reaches, and the raw value landed at ERROR in the Vector-captured log and durably in `operator_queue.context` — SQLite, every backup, rendered in the Operating Room (Invariant #12, Rule #5). The hash still keys on the raw value; scrubbing first would collide two different tokens on one repo. The #1677 justification is corrected in the same commit: "admin-driven sync cadence" is false (ent#236's loop is unattended), and the real bound — the only input is a setting blocked on the generic settings PUT — is co-located as a comment at the emitter, where it is likelier to stay true. Unchanged and deliberately so: the `count_skill_sources() > 0` guard itself, both validators, the grant branch, `expires_at: None`, the `title`/`question` copy, and `context["alert_type"]`. No schema change — `request_id` and its unique index shipped in #1631 — so Invariant #9 is not triggered: no `db/migrations.py` entry and no Alembic revision. Clearing the lingering `skills_library_url` key stays out of scope pending a separate investigation. Tests: tests/unit/test_2744_skills_adoption_alert_idempotency.py Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
… bound (#2744) The reservation of `skills-legacy-adoption-` is enumerated in three live places and all three now carry it: `requirements/security.md` §26.7's reserved-id guard, and `operating-room.md`'s two enumerations (the ingestion-guard list and the #1632 ingestion-caps paragraph). `operating-room.md`'s "Platform exemption & emitter budget (#1677)" bundled the skills alarm into a disjunction that includes "operator-driven". That was the same false claim the `_ALLOWED_CALLERS` justification made — ent#236's auto-sync drives `sync_library()` unattended on a 300s-86400s timer, so nothing admin- or operator-driven bounds it. The paragraph now names this emitter's actual bound, per branch: the terminal refusal is idempotent by a URL-keyed id (≤1 row per refused URL) and what makes it platform-only is that its only input is a setting blocked on the generic settings PUT. Plus the two dated rows (`operating-room.md` Revision History, the `feature-flows.md` change log) and the Operating Room catalog row. All three enumerations were ALREADY stale — each omits prefixes the live tuple carries. Ours is added; their pre-existing drift is deliberately not swept here (Rule #2) and is named as a follow-up instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…item for the PAT (#2744) Two gaps the review found in the new file, both in tests only. `context["reason"] = "already_migrated"` is the discriminator the emitter grew because one `alert_type` and one title now span both `low` (the benign resting state) and `high` (a URL that failed validation — the signature of an attempted injection). Nothing asserted it, so the field the fix added to be read by a machine could be dropped by a later edit in silence. The credential test asserted the PAT is absent from `context["url"]` and from the captured log, but not from `question`. `question` is credential-free only because neither ent#346 validator echoes the URL in its `ValueError` (`validate_skills_library_url` names the hostname or the resolved IP; `reject_embedded_credentials` names neither) — the scrub does not reach it. A validator message that starts echoing the URL would reopen the leak durably in `operator_queue.question` with no guard. Sweeping the serialized item covers every field the emitter writes, not the two that were remembered. Both were verified to bite: dropping the `reason` key fails the first, and reverting `strip_url_credentials` to the raw url fails the second. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…#2744) `QueueItemDetail.vue` renders every `context` key, so the discriminator the emitter grew would have surfaced to operators as a `reason | already_migrated` row. Put to the product owner as keep / drop / rename; the answer was drop — nothing reads the key today, so removing it is non-breaking. The steady-state branch is now discriminated by `priority: low` plus the `logger.info` level alone. The assertion added in 69274de7 to pin the key goes with it; it lived inside an existing test, so no test is removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…ct (#2734) Trinity Rule #1 — the requirements delta lands before the code. FR-5 stops describing a derived staleness mark and describes two unconditional facts instead: when the canvas was written, and when the agent last finished a run. The age-threshold rejection is kept verbatim and extended one step — the verdict it replaced could not know what a canvas is for either, and it fired on the writing run's own output, because a run completes after it writes and the stamp that would exclude it (`updated_by_execution_id`) is optional and absent on most live canvases. That is why "Updated just now" and "may be out of date" were rendered together. Also records the two properties a reader of the code would otherwise have to rediscover: the second fact is OMITTED, never narrated, because the field is null both for "never ran" and for a failed read and the payload cannot tell them apart; and `stale` stays computed and unrendered, kept so the derivation is recoverable rather than because it is endorsed. The feature flow section is retitled and rewritten with a Change Log row, the observability bullet follows it, and the user doc carries the new user-facing sentence. Refs #2734 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
`decorate` already reads `last_completed_execution_at` once per agent to derive the staleness verdict. It now also carries that instant on the payload as `agent_last_run_at`, so the header can state the fact instead of compressing it into a conclusion. Three properties are load-bearing: * The field is DECLARED on `CanvasSummary`, not merely set on the dict. Every canvas read route declares `response_model=`, and FastAPI filters a dict through it, dropping an undeclared key silently — while the voice panel route (no `response_model`) and the portal payload (plain dicts) would have kept it. Omitting the declaration would have shipped the fact on two surfaces out of three and looked like a frontend bug. A `Field(description=...)` rather than a comment, so `model_fields` can be asserted and the text reaches OpenAPI and the MCP tool schema. * The read is normalised beside the read, not in `db/canvas.py`. `MAX(completed_at)` is a raw boundary #1474 never covered; a naive stored row used to fail quiet in a lexicographic compare, and rendered it would be parsed as LOCAL time by the browser. `is_stale` keeps receiving the RAW value — it is retired in place and its comparison is not this change's to alter. * Absence is omission, never narration. The field is null both when the agent has never finished a run and when the read failed, and the payload cannot tell those apart — so it asserts neither. The failure is logged, so an operator sees what a reader cannot. `empty_canvas` declares the key too: it bypasses `decorate`, and two constructors of one response shape drift unless something pins them. Tests: 8 new, all shown failing on base (`KeyError: 'agent_last_run_at'`, `8 failed, 54 passed`) before the source change. The round trip starts from `decorate`'s own output, so a misspelled dict key cannot pass by being copied into the test. Refs #2734 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…ss verdict (#2734) The header said "Updated just now" and "may be out of date" at the same time. The two were not in conflict by accident: the verdict was derived from the same timestamp it contradicted, and it fired on the writing run's own output, because a run completes after it writes and the stamp that would exclude it (`updated_by_execution_id`) is optional and absent on most live canvases. So the verdict is gone and two neutral facts take its place: Updated 2h ago · agent last ran 40m ago Both are unconditional, both come from `freshness()`, and both are measured against the SAME injected clock — so whatever is wrong with that instant is wrong for both by the same amount in the same direction, and their relationship, the only thing a reader is judging, cannot invert. The warning pill, its title and the note paragraph are deleted; the header now carries no tooltip and nothing that appears and disappears as a derived value flips. Four details that are decisions, not incidentals: * The second fact is OMITTED when absent, never narrated. The field is null both for "never ran" and for a failed server read, and the payload cannot tell them apart — so the gate is `Date.parse`, not truthiness, because this module's `relativeTime` answers "at an unknown time" for a bad value and that is a narrated non-fact. * `basis-full` puts the line on its own row. The sibling h3 is `flex: 1 1 0%`, so it contributes basis 0 to line-breaking: inline, the span never wraps and the TITLE truncates instead — from ~35 characters to ~16 at 400px, on every canvas. It also pre-resolves the collision with the header buttons arriving in #2623. * A 60s tick drives `now`. Agent Detail does not poll, so a `computed` with no time dependency would keep saying "agent last ran just now" for hours — a liveness claim, not a provenance one. The tick refreshes the string, not the payload, so it can only make the agent look less recently active than it is. * `canvasChanged` compares the run time too. It is a property of the world, not of the loaded object, so comparing `updated_at` alone discarded every poll carrying only a fresher run time — on the one surface that polls every ~3s. `stale` is still computed and still ships; nothing renders it. The MCP read descriptions now say so, since they were the only text that ever explained the field to an agent. Tests: 8 new, all shown failing on base (`8 failed | 139 passed`) before the source change. Baseline: `CanvasPanel.vue` raw_gray 25 → 21, the four gray classes on the deleted note — the one entry edited in place, so no unrelated drift is absorbed with it. Closes #2734 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…it (#2693) CodeQL raised four py/path-injection alerts on _install_bundled_avatar: the template dir and agent name are already contained by _resolve_local_template_dir and name validation, but the analyzer cannot follow those callees. Normalize and prefix-check the final source and destination paths inline, the same barrier routers/avatar.py uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKzc3MG4Fa1sJ1KuMmBtcV
…2764) — mechanical, per the merge-train note on the PR #2777 passes steady_state=True to the same call; without **kw the stub raises TypeError on the merged tree and the ent#346 negative control fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtVjZEsxjyz2E4x5XdQg99
…) — mechanical, per the merge-train note on the PR The body says the share link was NOT widened; the wire payload was. The share payload now drops the field, pinned by a test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtVjZEsxjyz2E4x5XdQg99
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Integration surface for #2776, #2764, #2768, #2779, #2777, #2775. Never merged; members merge individually once green.