mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
shell-tool-batching-hint
3904 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
71d51cadac |
test(shell): pin each instruction, and name the test's real scope
Both from review: - the description test checked two keywords, so a description that kept the word `workdir` while dropping "instead of a leading `cd`" would have passed with the guidance gone. Now asserts each clause, and mutation-verified against exactly that partial regression. - renamed the second test to say validation + parsing, which is what it covers. Execution is genuinely not assertable here: this handler's scoped-path branch fails closed pending a process backend that can take a virtual cwd, so there is no executor to drive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e44b8b8868 |
test(shell): pin the calling guidance and the forms it recommends
Two assertions for the description change: - the manifest description names the command-line and `workdir` facts. Mutation-verified: reverting to the old description fails this test, which is the whole point of a regression gate. - the forms the description tells the model to use (`a && b`, a `for` loop, a heredoc, a pipe) all pass `validate_command`, and `workdir` parses as a per-call parameter. If validation is ever tightened to reject composition, the advice becomes a lie the model can only discover by burning a rejected call — this catches that instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0da56aac47 |
fix(shell): tell the model the tool takes a command line, not one primitive per call
`builtin.shell`'s description says what the tool does and nothing about how to call it. Benchmarked side by side against two container harnesses whose shell descriptions do carry calling guidance, reborn issues ~2.2x the tool calls for the same tasks on the same model (957 vs 426/639 over a 45-task corpus) — on the worst tasks 102 calls against 21 — because it treats the tool as one-primitive-per-call and pays a round trip per `ls`/`cat`. Two facts the description was leaving out, both already true of the handler: - `command` is a whole shell command line, so `a && b`, a `for` loop, or a heredoc script all work in one call. None of those forms trip `validate_command`/`detect_command_injection` — only specific exfil and escalation patterns are blocked. - `workdir` is a per-call parameter, so a leading `cd` buys nothing and the working directory does not carry over. (Deliberately stated this way rather than claiming state persists between calls, which it does not.) Description-only change: no handler, schema, or validation behaviour is touched, and no test pinned the old string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7d77a752f2 |
feat(documents): edit docx/xlsx/pptx structurally, render PDF from HTML, and fix the #7109 text-log regression (#7163)
* fix(coding): stop the binary-write backstop rejecting ordinary text logs Follow-up to #7109, which shipped with the binary backstop in `verify_read_before_edit` using the STRICT probe (any NUL in the first 8KiB) while `read_file` admits text carrying a few stray NULs via `reject_binary_probe_lenient`. The two disagree about what "binary" means, so a syslog the model had just read successfully became unwritable — and was reported as a "binary document", which it is not. `read_file_tolerates_stray_nul_and_invalid_utf8_in_text_logs` pins the lenient read; this makes the write path agree with it. `apply_patch` keeps applying the strict probe itself, where byte-fidelity for write-back demands it. Also adds the coverage #7109 landed without: - `builtin_write_file_still_overwrites_text_log_with_stray_nul` — fails before this fix, and is the regression above. - `builtin_write_file_rejects_extracted_read_representation_at_unlisted_extension` — the recorded-read-representation guard had no test at all. The existing docx test returns at the extension guard before `verify_read_before_edit` is ever reached, so `ReadState`, the `read_file` plumbing and the representation check were untested. `.rtf` is the one format `read_file` extracts that the extension lists omit, making it that guard's only live surface. - `builtin_apply_patch_rejects_a_binary_document_with_an_actionable_reason` — #6898 named apply_patch's opaque failure as part of the bug, and the fix addresses it, but nothing pinned it. - `reborn_integration_document_edit` — the whole journey at the integration tier: upload a .docx, ask for an edit, and the bytes served by the production `InboundAttachmentReader` (the WebUI download path) are byte-identical to the upload. Refs #6898, #7109 * feat(documents): structure-preserving docx/xlsx/pptx editing and HTML-to-PDF Issue #6898 item 3: the deferred "real document round-trip capability". This is the library layer; capability wiring follows. The governing rule is copy-through: rewrite only the parts an edit targets, copy every other zip entry byte-for-byte. A generator that rebuilds a document from the text a model saw drops everything the model never saw — styles, numbering, headers, images, embedded objects — and the file still opens, so the loss is invisible. Copy-through makes that impossible by construction rather than by diligence, which is why this is not "add docx-rs and write a docx". - `ooxml`: package read/write plus an event-level XML transform. An event the handler does not claim is re-emitted exactly as read, so attribute order, namespace prefixes and self-closing forms survive; a DOM round trip normalizes all three and churns untouched parts. - `docx`: paragraphs with `w:ins`/`w:del` surfaced as typed revisions (flat extraction shows deleted text as if it were still in the contract), and accept/reject. Rejecting a deletion converts `w:delText` back to `w:t` — without that Word renders the restored run empty. Revisions split across runs coalesce into one span. - `xlsx`: shared strings resolved so headers read as text, formula edits that drop the stale cached `<v>` and set `fullCalcOnLoad`, and new cells inserted in ascending column order (Excel repairs, and silently drops content from, an out-of-order row). - `pptx`: slide clone that duplicates the slide's rels part, so the copy inherits its layout — style lives in the layout chain, not the slide, which is why constructing a slide cannot preserve it. Registers the new part in content-types, presentation rels and slide order. - `html_pdf`: PDF is deliberately not edited. A documented HTML subset renders via printpdf's standard-14 fonts. printpdf's `html` feature is left off on purpose: it resolves fonts through rust-fontconfig, which scans system fonts and would make pagination depend on the host. 51 tests, including the copy-through invariants (unrelated parts stay bit-identical, a no-op edit is byte-stable) and every trap named above. Refs #6898 * feat(coding): document_edit and html_to_pdf, and read_file reads OOXML structurally Wires issue #6898 item 3's library layer to the model. Reads unify into read_file; writes stay typed. That asymmetry is the design, not a compromise: - read_file on a .docx/.xlsx/.pptx now returns the ADDRESSABLE STRUCTURE (paragraphs with tracked-change spans, cells with resolved headers and formulas, slides) and records `ReadRepresentation::Structured`. Folded in rather than offered as a `document_read` tool because a model reaches for read_file on whatever path it is handed — a tool it had to know to prefer would go unused while read_file kept returning tag-stripped text that shows a redline's DELETED words as if they were still in the contract. - write_file cannot be folded: its contract is (path, content: &str), so it cannot express "accept the revision in p3". Overloading it means regenerating the document from text — the corruption #6898 banned — or a sometimes-JSON `content`. apply_patch fails for the same reason plus ambiguity: its anchors match extracted text, and mapping a match back to runs is undefined when a string spans a revision boundary. The binary-write ban stays permanent. - document_edit takes typed ops and always writes to a NEW path, so a bad edit can never cost the user the original. It requires a prior Structured read of the source, keeping write_file's mid-air-collision guarantee on a fingerprint over the same raw bytes. - html_to_pdf renders; it refuses to overwrite an existing file, because silently replacing a PDF the user uploaded is the same class of loss the binary-write guard prevents. Two findings from writing the tests, both fixed here: 1. The surface test caught that a builtin capability is invisible to the model without a published input schema — registration alone is not enough. Both new tools now publish one. 2. The xlsx journey caught that set_cell_formula could not create a row. A totals row sits just below the data, so "the row is not there yet" is the ordinary case, not an edge case; the crate fixture happened to have the row already and masked it. Rows are now created in ascending order, with regression tests. Tests: 6 capability tests through real dispatch (including that a Structured read still does NOT authorize a raw write_file overwrite — the two guards must not cancel each other), and four integration journeys on real OOXML fixtures: docx redlines resolved into a clean copy, an xlsx total under a named column, a pptx slide cloned with the source's layout, and a PDF produced by authoring HTML and rendering it. Refs #6898 * test(reborn): register e2e coverage for document_edit and html_to_pdf `reborn_builtin_first_party_capability_e2e_coverage_is_complete` requires every always-on first-party capability to name where its Reborn e2e coverage lives. The two new document capabilities had that coverage — `reborn_integration_document_edit` drives all four journeys — but were not registered, so the guardrail failed. Caught by the pre-push hook, which runs the full workspace suite; the per-crate runs used while developing never touch this test. * ci(planner): classify tests/fixtures document fixtures `Detect Reborn test scope` failed with "unmapped test or CI path: tests/fixtures/contract.docx". The planner deliberately hard-errors on any unclassified path under tests/ to force a per-file decision, and binary document fixtures had no arm — only recorded LLM traces under tests/fixtures/llm_traces/ were mapped. These fixtures are consumed by integration tests through `include_bytes!`, so a changed fixture changes what those tests assert; it schedules a representative integration lane, matching how shared integration support is treated. Adds the matching planner test. * ci: satisfy the panic check in the test-only fixture builder `Fast deterministic checks` flagged four unwraps in `ironclaw_documents::test_fixtures`. The module is `#[cfg(test)]`-gated (`has_cfg_test_module_declaration` agrees), but the checker's main scan path does not consult that for crate-root modules, so it reads them as production code. Uses the inline `// safety:` suppression the checker documents rather than relaxing the checker, which guards a real invariant for everything else. The comments must be on the same line as the call to take effect. * fix(documents): nested paragraphs and leaked revision flags corrupted docx edits Two critical defects from CodeRabbit's review of #7163, both confirmed by tests that fail before the fix. 1. Nested `w:p` lost the outer paragraph and desynchronised ids. Word nests a paragraph inside a paragraph when a run holds a text box (`w:txbxContent`). The reader kept one `current` slot, so the inner Start overwrote the outer and the outer was never emitted; the writer meanwhile counted EVERY `w:p` Start. Read ids and write ids therefore addressed different paragraphs, so an edit landed on the wrong one. The reader now keeps a stack and assigns ids in Start order, matching how the writer counts, and both write paths keep a target stack so a nested paragraph's End restores the enclosing paragraph's state instead of clearing it. Note the mechanism: table cells do NOT reproduce this — `w:tbl`/`w:tc` paragraphs are siblings in document order. Only a text box nests. 2. Revision flags leaked past a dropped subtree and unbalanced the XML. Reject-insert and accept-delete set `dropping = 1` AND the `in_insert`/`in_delete` flag. The dropping branch then consumed the matching End, so the flag was never cleared and stayed set for the rest of the document — deleting a LATER paragraph's `</w:ins>`. Resolving revisions in one paragraph emitted `word/document.xml` with an unclosed element, which Word rejects. The flag is now set only on the unwrap paths, where the End genuinely must be dropped by the handler. On the drop-subtree paths the End is consumed by the dropping branch and no flag is needed. Both defects are invisible to single-paragraph, flat fixtures — which is what the crate's own fixtures were. * fix(documents): preserve cell styles, resolve sheets by relationship, keep text around comments Four more defects from the #7163 review, each with a test that fails before its fix. xlsx: - Replacing a cell dropped its `s` style index, silently reverting a currency or date column to General. That is the precise "preserve what you did not touch" promise the crate exists for, so the style now rides across the replacement. - Sheet names were paired to worksheet parts POSITIONALLY. I shortcut this deliberately and said so in a comment; the reviewer was right that it is wrong. Sheet declaration order does not have to match worksheet file numbering, so an edit could land in the wrong worksheet. Names now resolve through `r:id` in `xl/_rels/workbook.xml.rels`, falling back to positional pairing only when the rels part is absent. html_pdf: - A comment or declaration cleared the buffered text before it, so `<p>hello <!-- note --> world</p>` rendered as `world`. This directly contradicted the module's claim that wrapping markup never swallows content. Whitespace now also collapses across the resulting span join, so the repaired text reads `hello world` rather than `hello world`. - A stray `&` consumed up to ten following characters looking for `;`, swallowing a real entity behind it. The scan now stops at any character that cannot appear in an entity name. * fix(documents): reject duplicate zip entries, stop self-closing tags corrupting reads and rows Three more from the #7163 review. - A duplicate zip entry name kept both names but only the last bytes, so `write()` emitted the same content under both and silently rewrote a package we were asked to preserve. Now rejected at read. - `Event::Empty` latched `in_value`/`in_formula`. A self-closing `<v/>` has no matching `End`, so the flag stayed set and the NEXT cell's text was attributed to the empty one, corrupting every later value in the row. - A self-closing `<row r="N"/>` target produced a SECOND row with the same `r`, which Excel repairs by dropping content. The existing empty row is now replaced in place. * chore: retrigger Railway preview * fix(documents): address review findings * test(reborn): refresh read-file golden payloads --------- Co-authored-by: serrrfirat <f@nuff.tech> |
||
|
|
14f5790a4f |
fix(live-canary): align the bundled-skill marker owner with the runtime mint (#7590)
The scrub-verdict narration from #7579's first run (dispatched 31734252239) named the real cause of the weeks-long strict-scrub reds: "kept skill snapshot for scanning (marker failed verification)" on every skill, in every shard. The runtime mints markers with BUNDLED_MARKER_OWNER = "ironclaw_composition_bundled_skill" (crates/extensions/ironclaw_extension_host/src/bundled_skills.rs), while the scrubber still expected the retired "ironclaw_reborn_composition_bundled_skill" spelling — the WS6/WS7 crate renames changed the Rust side and this shell copy silently drifted, so the owner check failed for every marker and the bundled-skill pruning never engaged. (The markers looked absent in downloaded artifacts only because actions/upload-artifact drops hidden files by default; they were present at scrub time all along.) The owner string now matches the mint, the fixtures mint with the real owner, and a new lockstep test extracts BUNDLED_MARKER_OWNER and BUNDLED_MARKER_FILE from bundled_skills.rs and asserts the script's constants equal them — the next rename fails this self-test instead of silently disarming the scrubber. Sabotage-verified: the lockstep test fails against the pre-fix script; the full suite (22 tests) passes with it. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d82c9584e5 |
ci(check-guidance): extend the reference gate to the docs/ surface (doc-truth PR 2/5) (#7376)
* docs: fix live drift in extension, responses API, and channel docs
The public tutorial taught the retired manifest v2 authoring format
([[host_api]] / [capability_provider.tools] / runtime_credentials), which
the v3 parser hard-rejects, and never mentioned origin_gate_matrix; the
Responses API page claimed temperature is rejected (accepted 0.0-2.0 and
forwarded), claimed model must be "default" (any well-formed name <= 256
bytes), claimed max_output_tokens is rejected (accepted and ignored by DTO
policy), and omitted the required model field from every request example;
the channel tutorial pointed at two files that no longer exist.
- docs/extensions/building-a-tool.md: rewrite manifest sections to the v3
[[tools]] / [[tools.credentials]] / [auth.<vendor>] shape, document
origin_gate_matrix (origins, policies, ratchet), correct the hosted-MCP
[mcp] section, packaging via ironclaw_extension_support package modules,
and v3 test references; drop the nonexistent script runtime kind.
- docs/api/responses.mdx: correct model/temperature/tools/tool_choice
rejection rules, document unknown-field tolerance, add the required
model field to all 15 request examples.
- docs/channels/building-a-channel.mdx: replace dead
crates/ironclaw_first_party_extensions + available_extensions.rs
registration instructions with the current package-directory mechanism.
- docs/reborn/contracts/extensions.md: state that production manifests
author v3 (lowering into the v2 resolved model described there); label
the v2 examples as legacy.
- docs/reborn/how-to-port-tool-to-reborn.md: superseded banner pointing at
the v3 guides.
Part of #7317 (doc-truth pipeline, PR 1 of 5).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci(check-guidance): extend the reference gate to the docs/ surface
The public Mintlify tree had no path-reference validation — a published
tutorial told contributors to edit files that no longer exist and nothing
caught it. check-guidance.py already owned the machinery (tracked-tree
resolution, fence exclusion, suppress markers, shrink-only debt, fail-closed
floors), so the docs surface joins the same gate rather than a fork.
- discover_guidance() now collects every tracked docs/**.md|.mdx: published
pages, the zh/ locale mirror, and the living contract corpus
docs/reborn/contracts/. Dated archives (docs/internal/, the non-contract
parts of docs/reborn/) are excluded as classes — measured 2026-08-07,
705 of 709 dangling docs references sat in those historical corpora, and
forcing dated plans/ADRs to track today's tree would either rewrite
history or drown KNOWN_MISSING.
- docs/ files extract backticked inline paths only; Mintlify markdown link
targets are site routes (extensionless pages, site-absolute /using/cli),
a different namespace than the tracked tree, so the link extractor is off
there by design.
- _reference_lines learns MDX comments ({/* ... */}), including
{/* check-guidance: path-ok */} as the .mdx suppress-marker form, with the
same one-reference-per-marker and multi-line semantics as HTML comments.
- Floors re-measured and re-dated (364 files / 2276 references; floors
180/1100), plus a dedicated MIN_DOCS_FILES=60 floor: the aggregate floors
sit below the guidance-only remainder, so the docs branch of discovery
silently breaking needs its own refusal. --json now reports docs_files.
- Fixes the four real dangles the new scan found in docs/reborn/contracts/
(moved nested_dispatch_stream.rs test home, retired event-store migrations
directory, loop_driver_host tests->src move). KNOWN_MISSING stays empty.
- Self-tests: 8 new cases (dangling docs path fails; Mintlify links are not
references; MDX marker suppresses exactly one reference; multi-line MDX
comment hides content; zh discovered; archives excluded but contracts
scanned; docs fence fails closed; docs floor refuses).
- ws12_workflow_contracts.py: docs/api/responses.mdx and docs/zh/index.mdx
join the has_guidance in-scope probes so a narrowed trigger regex cannot
silently skip the gate for public docs.
Part of #7317 (doc-truth pipeline, PR 2 of 5); stacked on #7375.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: address Copilot and CodeRabbit review on doc-drift PR
- responses.mdx: tool_choice is rejected only without external-tools wiring;
with external tools enabled it passes validation and is currently ignored
(validate_responses_supported_fields_with_external_tools never checks it).
- building-a-tool.md: clarify that effect-derived host ports are validation
vocabulary against the HostPortCatalog allowlist; adapters are built by
host-runtime services after authorization/obligations, never from manifests.
- how-to-port-tool-to-reborn.md: mark the decision tree's RuntimeKind targets
historical (v3 accepts only wasm|first_party; MCP is top-level [mcp];
process/CLI work is the sandbox lane).
- building-a-channel.mdx: document the user install flow — virtual package
root /system/extensions/<id>/manifest.toml, ironclaw extension search /
install <extension-id> (ID, not path), WebUI Extensions lifecycle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(responses): align the limits bullet with the corrected tool_choice claim
The rejection list was corrected in the previous commit (tool_choice is
rejected only without external-tools wiring); the "Limits and quirks"
bullet still said "not supported ... rejected with 400". Same claim, one
wording.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: apply verified code-review findings on the drift PR
A full code review of this PR against live code surfaced claims the
original drift pass got wrong or missed; every fix below was re-verified
against the cited source before editing:
- responses.mdx: standard `ironclaw serve` deployments always wire
external tools (OpenAiCompatRouteMountPorts requires the store/resume
pair; mount.rs wires them unconditionally), so `tools` is accepted and
`tool_choice` is accepted-and-ignored on shipped binaries — the
conditional 400s apply only to custom compositions without the wiring
(now a Note). temperature is validated and carried in the submitted turn
payload but not applied as a provider sampling parameter. Non-streaming
wait timeout is 30 s (DEFAULT_RESPONSES_WAIT_TIMEOUT), not 120. usage on
retrieval is read best-effort from persisted run state incl. USD cost
(read_run_usage), not always zero.
- building-a-tool.md: the [auth.example] oauth2_code recipe gains the
required token_response map (deny_unknown_fields rejects the example as
previously written); Gmail/Google Calendar corrected to first_party
runtimes (their manifests declare kind = "first_party"); the worked
api_key recipe is github's, not slack's; the tail "Quick implementation
checklist" and reference list were still v2-era (script lane,
assets/<extension>/ path, "manifest v2", v2.rs pointer) and now teach
the v3 shape; composition/CLI package-naming claim narrowed (the binary
does link slack/telegram adapter crates).
- contracts/extensions.md: legacy-format paragraph no longer claims
host-bundled packages ship v2 (none do), and origin_gate_matrix is
attributed to capability.rs + building-a-tool.md instead of
extension-runtime/overview.md §3, which does not mention it.
- how-to-port banner: `script` manifest authoring is retired; the
RuntimeKind::Script symbol survives as the process-sandbox lane's kind.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(contracts): repoint delivery_resolution.rs to its family directory
PR #7157 (merged to main 2026-08-07) cited
crates/ironclaw_outbound/src/delivery_resolution.rs in the
communication-delivery-resolution contract; the crate lives at
crates/domains/ironclaw_outbound/. Caught by this branch's docs surface of
check-guidance.py on the first merge of main after the gate landed —
exactly the drift class it exists for.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci(check-guidance): harden the docs gate and fix review-surfaced doc drift
Applies the verified findings from the PR #7376 code review:
- The loop-exit and turn-runner contract docs claimed the deleted
loop_driver_host checkpoint-rejection test had 'moved into the
module'; it was deleted in #6696 and the fenced verification command
could not run. Both now cite the real surviving pins
(planned_driver.rs executor test + the ironclaw_turns projection
test mapped in scripts/reborn-e2e-rust.sh), with runnable commands.
- An unterminated comment now refuses at EOF like an unterminated
fence; before, one typo'd closer silently un-scanned the rest of the
file.
- Markdown links in the re-included corpora are now checked as repo
paths (they are never published, so the Mintlify-route rationale did
not apply); this alone added ~165 verified references.
- Each DOCS_REINCLUDED_PREFIXES entry must match at least one tracked
page or discovery refuses, so the planned docs/reborn consolidation
cannot silently drop the corpus from the scan.
- The living extension-runtime spec pages (overview.md,
standard-operations.md) and guidance-conventions.md join the scan;
guidance-conventions.md now describes the docs surface and the MDX
marker form, and its one dangling test path is repointed.
- Floors comment corrected (57 rule globs, not 38).
Also fixes four drifted claims from #7375's pages, verified against
live code: the interleaved function_call_output example was rejected
with 400 (resume input must be exclusively function_call_output items
with previous_response_id); model is echoed only on create (GET/cancel
report the 'reborn' placeholder); output_schema_ref is optional; and
the unknown-fields claim now names the two deliberate exemptions.
Self-tests: 43 pass (three new arms — unterminated comment refusal in
both syntaxes, re-included links as repo claims, stale re-included
prefix refusal).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci(check-guidance): sync module docstring with re-included link checking
CodeRabbit caught the docstring still claiming the link extractor is
off for all of docs/** — stale since
|
||
|
|
bd243b35f2 |
fix(loop): make repeated-call detection advisory-only (#7531)
* fix(loop): make repeated-call detection advisory-only * test(loop): align terminal warning integration coverage |
||
|
|
d8653a6331 |
fix(extensions): refresh bundled MCP state after auth (#7581)
* fix(extensions): refresh bundled MCP state after auth * fix(extensions): preserve bundled discovery trust pin * test(extensions): verify bundled hash migration * fix(extensions): serialize catalog discovery refresh |
||
|
|
c063394e2b |
fix(live-canary): widen the seeded slack grant to the manifest union and narrate scrub verdicts (#7579)
Run 31715187702 (the first scheduled canary after #7515 merged) surfaced two problems: 1. QA lanes crash at slack connect ("Extension setup completion did not produce a fully ready projection (required installation_state=active)"): SLACK_PERSONAL_OAUTH_SCOPES seeds the stored grant of the canary's personal account, and runtime account selection requires that grant to carry every tool's manifest scopes — the eight new standard ops widened the union with reactions:read/reactions:write/im:write, so the stale eleven-scope seed parks activation on the auth gate. This is the live twin of the merge-queue fixture widening in #7515 itself (the sweep there covered tests/, not scripts/). The list now carries the full union with a lockstep comment; the live Slack app must also grant the write additions to its user token for the reaction/DM ops to succeed vendor-side. 2. The same run still flagged skills/local-test placeholder text despite #7574, and the redacted report cannot distinguish "the pruning did not engage" from "the staged snapshot genuinely diverged" (live cases run a real agent inside that home). The strict skill pruning now narrates one verdict line per snapshot — pruned marker-verified / pruned source-identical / kept with the exact mismatch reason (file set or first differing file; names only, never content) — so the next scheduled run pins the cause from the step log alone. Scrub self-tests: 21 pass, with the divergent case now asserting both the kept-verdict line and the named mismatch. Live-QA runner tests: 218 pass. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
93522b7144 |
feat(webui): add non-admin model preference settings (#7440)
* feat(llm): add tenant model selection policy * fix(composition): move model policy store to operator * test(llm): specify per-user model preference behavior * feat(llm): persist per-user model preferences * feat(llm): add per-user model commands * feat(webui): add settings model preference selector * fix(webui): expose inference settings to members * test(e2e): cover settings model preference * fix(webui): add admin model policy controls * test(e2e): configure model policy through admin UI * fix(webui): contain long model selector labels * test(e2e): cover long model selector labels * fix(webui): stack model selector on narrow layouts * test(e2e): verify responsive model selector layout * test(webui): lock member inference navigation * fix(webui): harden model selector settings flow * fix(webui): clarify model selector failures * fix(webui): localize model selector settings |
||
|
|
b215bf0651 |
feat(slack): bind the remaining eight core standard messaging ops (#7515)
* feat(slack): bind the remaining eight core standard messaging ops Slack bound 8 of the 16 core standard messaging operations. The other eight were a named fast-follow the framework design spec deferred (§13 "Deliberately not built"); this lands them, so Slack now covers the full core vocabulary. Manifest: eight new standard_op-bound [[tools]] — edit_message, delete_message, add_reaction, remove_reaction, open_dm, get_message, resolve_user, list_members — appended after the v2-era eight so the projection-parity gate's positional comparison still holds. Guest: vendor mechanics over chat.update, chat.delete, reactions.add/remove/get, conversations.open/members/history/replies, and users.list. Three behaviours worth a reviewer's attention: - get_message has no Slack endpoint. It reads the conversation at the exact ts and falls back to the thread when the message is a threaded reply, accepting only an exact match — a near miss is unknown_message, never the neighbouring message. - remove_reaction's optional emoji is implemented, not rejected. Slack's endpoint requires a name, so the omit-emoji variant reads the message's reactions and removes each one the connected account added. That is why reactions:read joins the grant, and why that variant returns no emoji. - already_reacted / no_reaction are treated as success. Slack returns them only for a message it resolved, so the requested end state holds; an error would push the model into retrying a no-op. It also makes the removal loop converge on retry after a partial failure. Scopes: the [auth.slack] union gains reactions:read, reactions:write and im:write. COMPATIBILITY: there is no scope-upgrade re-consent flow, so an account connected before this change holds a token without the new scopes, and Slack answers those three tools with missing_scope -> mapped to messaging.permission_denied (a model-visible denial, deliberately NOT an AuthRequired re-auth gate, since re-running OAuth would request the same manifest scopes). Widening an existing grant means disconnecting and reconnecting Slack; the other 13 tools are unaffected. ROLLBACK: reverting this commit restores the 8-tool surface and the narrower grant; already -widened user tokens keep unused scopes, which is inert. Gates: two pins were evolved rather than relaxed. The v2->v3 parity suite gained a PackageAdditions declaration, so the v2-era eight still project identically and positionally while the additions and the exact scope delta are declared explicitly; the catalog scope pin moved from assert!(matches!(..)) to assert_eq! so a future drift prints which scope moved. Tests: 24 guest unit tests — canonical input serde, output shapes, the reaction authorship filter, exact-ts matching, the idempotent-success arms, directory matching, the error taxonomy, and capability-id dispatch — plus manifest-projection assertions that all 16 bind exactly the core set with host-synthesized schema refs and external_write on writes. The authorship filter and exact-ts matching were sabotage-verified, as were both evolved gate assertions. Not covered: the I/O orchestration inside each operation (which calls run, in what order, and that a failed auth.test aborts the omit-emoji removal) is unreachable without a host seam in the WASM guest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(slack): correct four guest edge cases and pin all eight new ops at the dispatch seam Guest fixes (artifact rebuilt, digest re-recorded): - reactions.get now sends full=true — without it Slack truncates per-reaction users arrays on popular messages and the omit-emoji remove_reaction could skip the connected account's own reaction while reporting success. - open_dm validates user_ref with is_slack_user_id before calling Slack: conversations.open takes a comma-separated list, so an unvalidated "U1,U2" silently opened a group DM against the 1:1 contract. - get_message's thread fallback pinches the range to the exact target ts (oldest=latest=ts&inclusive=true&limit=5) instead of scanning a fixed 999-message page — finds a reply at any depth and stops shipping ~1MB pages to keep one message. - resolve_user's users.list page size now equals the match limit (default raised to the full 200): Slack cursors are page-granular, so the old mid-page break dropped matches between the cap and next_cursor that no amount of paging could recover. - next_cursor extraction deduplicated into one helper across all four paging ops; add_reaction's per-tool scopes drop reactions:read (it only calls reactions.add — the read scope belongs to remove_reaction alone, exactly as the scope table documents). The manifest's scope-widening compatibility comment now describes the real mechanism: a pre-widening account is refused by the HOST's provider-scope gate at credential staging and lands on the AuthRequired re-auth gate, which reconnects the same account with the widened union (binding skips the scope gate). Slack's own missing_scope only fires for server-side drift after staging passed, and that maps to messaging.permission_denied. Coverage (contradicting the "no host seam exists" claim this PR shipped with): the existing host-runtime WASM harness drives the committed slack_user_tool.wasm through invoke_capability with scripted egress, so the conformance sweep now covers all sixteen ops, plus ten behavioral pins — missing_scope-vs-AuthRequired layering, both reaction end-state codes, the omit-emoji orchestration (ordering, full=true, ownership filter, fail-closed identity), near-miss/threaded get_message flows, zero-egress input rejections (blank query, empty emoji, malformed open_dm user_ref), missing provider evidence, the list_members clamp, and loss-free resolve_user paging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(e2e): classify the eight slack ops in the product-surface coverage map The eight new slack.* capability ids were shipped unclassified, which is what turned the "Validate product-surface evidence contracts" step red (summary.missing == 8) and cascaded into the Reborn E2E roll-up. - classifications.tested gains the eight ids. - Eleven typed ProviderOperationCases: five writes with provider readback and cleanup against the never-reset Slack world (edit and delete seed their own subject message; remove_reaction seeds its own reaction as the connected account; open_dm proves idempotence by re-opening directly), get_message driving the history-miss -> thread-fallback path at the seeded reply, resolve_user with a natural empty, and proxy-served list_members pages (Emulate answers conversations.members POST-only at the pinned ref while the guest reads via GET, as real Slack allows). - get_message's canonical output requires the message, so its empty class is the typed model-visible miss: ProviderOperationCase gains expected_status ("completed" default, "failed" for exactly this shape) and the runner asserts the declared status instead of hardcoding completed. All three gate files pass under the hermetic wrapper (98 passed), and the product-surface generator reports 131 capabilities, 0 missing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update guidance for the sixteen-tool slack surface - reborn-extension-surfaces no longer hardcodes a tool count for the slack manifest (the grep recipe is the source of truth) and names github as the plain schema-declaring exemplar now that every slack tool is standard_op-bound. - standard-operations.md repoints slack_error_to_standard_code at the live crates/extensions path instead of the dead pre-restructure assets/ path. - tests/e2e/CLAUDE.md stops pinning a literal capability count and defers to the generated product-surface report (131 as of this change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(e2e): declare the expected failed tool result on the get_message empty case The mock-LLM trace replayer treats any failed capability result as a replay error unless the step's request_hint names the expected failure — the same mechanism the provider fault cases already use. slack_get_message_empty declares expected_failed_tool_result_contains="messaging.unknown_message", and the operation-case runner forwards it onto the synthesized trace; the adjacent slack_list_members_empty failure in the provider-2-3 lane was collateral from the poisoned replay state and needs no change of its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tests): widen the seeded slack accounts to the manifest scope union The merge queue runs the root integration lanes that skip on PRs, and every one of them seeded the slack account with the pre-widening eleven-scope union. The manifest's [auth.slack] ceiling now includes reactions:read, reactions:write, and im:write, so slack installs parked on the auth gate (BlockedAuth) — the exact provider-scope-gate mechanism this PR documents, biting its own lockstep fixtures. All six seeds (extension_delivery, extension_runtime, delivery_user_journeys, tool_call, the slack lifecycle group scenario, and the QA harness profile that reborn_qa_smoke_scenarios composes) now carry the full union, and the group scenario's lockstep comment names the write additions. Locally green with Docker-backed postgres legs: extension_delivery 23/23, extension_runtime 25/25, delivery_user_journeys 25/25, group_extensions 16/16, tool_call 38/38, and the bundled-extension-surface QA smoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1f8eb0937d |
fix(live-canary): prune marker-less bundled skills and follow the session channel message route (#7574)
Two independent regressions have kept every scheduled Live Canary red: 1. Since #7171 moved skill mounts onto one backend-generic tree, the case homes exported into artifacts carry no .ironclaw-reborn-bundled.json runtime marker (verified: zero markers across all 31 materialized skills in the QA-10 artifact of run 31641918366), so the marker-keyed bundled-skill pruning from #6453 never engages and the long-committed placeholder text in skills/local-test/SKILL.md (docker examples with NEARAI_API_KEY=<your-key>) fails the strict scrub in all 12 shards — deterministically since the first scheduled run after #7171 (Aug 9, 21:11 UTC). The scrubber now also prunes a marker-less skill snapshot whose file set and bytes are identical to the source-controlled bundle; divergent or operator-authored content stays in scanning scope. 2. Since #7477 the WebChat composer posts messages on the session channel ingress route (/api/webchat/v2/channels/<extension>/messages), while the live-QA submission-identity capture waited on the retired thread-scoped route — so QA 10 (the shard whose cases capture submission identity) went 9/10 to 0/10 at the first post-#7477 scheduled run (Aug 12, 21:18 UTC) with every case timing out at expect_response on a healthy, streaming turn (the failure screenshots show the correct answers mid-stream). The predicate now accepts both routes — the same migration the stress client made in #7568 — so one harness spans binaries on either side of the split. Scrub self-tests: 21 pass including two new cases (marker-less identical snapshot pruned; marker-less divergent snapshot still fails strict), and the identical-snapshot test fails against the unfixed script. Live-QA runner unit tests: 218 pass including the new route-pattern regression test. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5308b83d06 |
fix(loop-host): repair unavailable capability calls without aborting runs (#7551)
* fix(loop-host): resolve deferred capabilities before guard * fix(loop-host): retain mixed-request suppression (#7551) * fix(loop-host): repair unavailable capability calls * test(loop-host): assert gateway repair feedback |
||
|
|
dc2581a4d8 |
fix(memory): ranked recall retrieval + a visible difference between broken and empty memory (#7185) (#7553)
* fix(memory): rank memory retrieval by relevance instead of requiring every term
Memory recall from a conversation almost never matched. `Filter::Fts` builds
an AND over every non-stopword term of the raw user message, so a question
worded even slightly differently from the saved sentence returned nothing: one
missing word was enough. A fact saved as "Sarah prefers the standup meeting
scheduled early on Thursday mornings" was invisible to "when does Sarah like
her standup scheduled" purely because the stored text has no "like".
Add an explicit ranked retrieval mode rather than flipping AND to OR for
everyone. `Filter::FtsRanked { key, query, limit }` matches a record carrying
ANY content term and orders by backend relevance — `bm25()` on libSQL,
`ts_rank` over an OR `tsquery` on PostgreSQL, and distinct-term coverage in the
in-memory reference so the three stay behaviorally consistent. Like
`Filter::VectorNearest` it is a top-k operation: `limit` truncates after
ranking and nesting it inside And/Or is `Unsupported` on every backend, because
a predicate position would discard the ordering.
`Filter::Fts` keeps its every-term semantics untouched. Its only production
consumer is memory-native's search path, which moves to the ranked variant;
the remaining uses are the filesystem crate's own contract tests.
Tests: a three-backend `ranked_fts_contract` in the filesystem contract suite
(libSQL + in-memory run locally, Postgres leg is docker-gated and unrun here)
that opens by asserting the AND filter finds nothing, so it cannot pass under
the old semantics; and a group_memory integration scenario driving the real
composition, verified to fail on the previous behavior with "no captured
system prompt containing \"Thursday mornings\"". It writes to a non-standing
document so the always-on MEMORY.md lane from #7365 cannot satisfy it.
Part of #7185, #7275
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(memory): make a failed memory retrieval distinguishable from an empty one
A memory backend that was down and a user with nothing relevant stored
produced exactly the same thing: an empty prompt section. Every failure on the
retrieval path degraded silently — the host adapter logged the lane error at
`debug!` and returned an empty list, and the loop host cached that empty list
in its per-run `OnceCell`, so one blip blanked memory for the whole run with no
way to tell afterwards whether memory was empty or broken.
Make the outcome typed instead of inferred. `MemoryPromptContextService` now
returns `MemoryPromptContextLoad { snippets, degradations }`, mirroring
`LoopContextBundle`'s existing shape of "successful payload plus an explicit,
typed description of what was lost" (`recent_window_truncation`). A
degradation names the lane (`short_term` / `long_term`) and a closed-vocabulary
failure kind (`input` / `unavailable`) — never a backend message, a query, or a
path. `degradations` is empty exactly when every queried lane answered, so an
empty result from a healthy backend is no longer confusable with an outage.
Retrieval stays best-effort and never fails a turn, and the per-run cache stays
(it exists to stop a slow backend being re-hit on every model step) — but the
cached value now RECORDS that it failed rather than laundering the failure into
"empty".
Operator visibility rides the milestone sink the context port already holds: a
degraded load emits one `LoopDriverNoteKind::Context` driver note per run,
which reaches the live work summary. This is the same route
`publish_personal_context_admitted` uses and the same rationale as
`EventSubscriptionTerminated` — a subsystem that stopped contributing must not
be silently invisible. Deliberately NOT promoted to `warn!`/`info!`: those
levels render in the REPL and corrupt the terminal UI, and this fires from a
background prompt build.
Tests: crate tier pins both directions in the host adapter (an outage records
both lanes, a partial failure records only the failing one, and a healthy empty
result records nothing); integration tier drives the real composition with two
byte-identical turns differing only in whether the bound provider's lanes
return `Err(unavailable)` or `Ok(vec![])`, verified to fail before the change
with "no driver note reporting degraded memory retrieval; saw []".
Part of #7185, #7275
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(memory): address review — backend parity, note retry, scenario ownership
Review feedback on #7553:
- libSQL answered a content-term-free ranked query with `Unsupported`
when the prefix had no declared FTS index, while PostgreSQL and the
in-memory backend answered "empty". The answer does not depend on an
index, so it must not depend on the bound backend: read the query
before resolving the FTS table. Pinned in the shared
`ranked_fts_contract`, which runs on all three backends.
- The degraded-retrieval driver note marked itself emitted before the
publish succeeded, so one transient milestone-sink error suppressed it
for the rest of the run — restoring the exact ambiguity between broken
and empty memory this change exists to remove. Adopt the two-step
guard `publish_personal_context_admitted` already uses (in-flight flag
+ set-on-success). Regression test verified red against the old guard.
- `Filter::Fts` and `Filter::FtsRanked` carried separate copies of the
in-memory tokenizer; they must agree on what counts as a term
occurrence, so share one `fts_tokens`.
- `scenario_paraphrased_prompt_recall_libsql` built its own group; the
group-scenario contract puts that in the binary. It keeps a dedicated
libSQL group rather than joining the shared one, because scenarios 6
and 7 assert which snippets reach the prompt and a ranked top-N lane
over one store would couple those assertions to sibling seed data.
- Register both new scenarios in `tests/CLAUDE.md` §3.4 and update counts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
d0ba975c10 |
fix(channels): sticker/voice attachments no longer brick Telegram — kind validator, truthful sticker MIMEs, ingress ack-discard for deterministic failures (#7563)
* fix(extension-contracts): make semantic attachment kinds constructible Telegram stickers (image/webp + Sticker) and voice notes (audio/ogg + Voice) failed ProductAttachmentDescriptor validation because validate_attachment_kind forced any image/audio/video MIME onto its mirror kind. The whole update then failed adapter parsing, ingress answered non-2xx, and Telegram's in-order redelivery wedged the chat behind the poison update. Agreement now runs one direction only: media-mirror kinds (Image, Audio, Video) must match the MIME base; semantic kinds (Sticker, Voice, Document, Other) accept any MIME. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(telegram): truthful sticker MIMEs; degrade permanent attachment failures instead of failing the update Stickers now carry their real content type (image/webp static, video/webm video, application/x-tgsticker animated) via the Bot API format flags. complete_message keeps retryable transfer failures fatal (ingress 503, vendor redelivery can succeed later) but degrades deterministic failures to the message minus that attachment — failing the whole update made Telegram redeliver a payload that could never improve, wedging the chat's in-order queue. An update degraded to neither text nor attachments is acknowledged as Ignore instead of starting an empty turn; batch fragments still ship so sibling media-group parts settle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(extension-host): acknowledge-and-discard deterministic ingress failures instead of handing vendors a redeliverable status Vendors with strictly ordered webhook redelivery re-send any non-2xx update and hold every later update in the conversation behind it — one deterministically unusable update bricked the whole chat until the update expired. The retired WASM channel acked malformed payloads for exactly this reason; the generic ingress regressed it to 400. Ingress now splits adapter and admission failures by what redelivery can do: transient faults (configuration, vendor wiring, retryable transfer, retryable sink/staging, panics, deadlines) keep 503 so redelivery can succeed later; deterministic faults (parse, render, permanent transfer, out-of-bounds messages, permanent sink rejections, batch tombstones and budget rejections) return 200 with an acknowledged_discarded body and a warn log, admitting nothing. The batch processor keys its terminal state on the discard marker so a consciously dropped merged message keeps the truthful rejected tombstone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(telegram): dropped-attachment diagnostics use debug! per REPL logging rule The host-level acknowledged-discard warns in the ingress router remain the update-level observability hook; the adapter's per-attachment degrade details are internal diagnostics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
318a6e6748 |
docs: consolidate docs/reborn/ into docs/internal/reborn/ (#7559)
* docs: consolidate docs/reborn/ into docs/internal/reborn/ Move-only migration; no content changes beyond path references. Executes the follow-up that PR #7259 left open: docs/.mintignore's reborn/ entry was kept only because the path was load-bearing, and its comment documented that it moves under internal/ once its consumers move with it. - git mv docs/reborn docs/internal/reborn (115 files, history preserved) - rewrite docs/reborn -> docs/internal/reborn across every consumer (crate AGENTS/READMEs and doc-comments, .claude/ skills and rules, AGENTS.md, CI scripts, reborn-e2e.yml path filters, Dockerfile, tests, docs/internal plans) - fix six relative internal/adr/ links inside the moved tree for the added directory level - drop reborn/ from docs/.mintignore and FROZEN_MINTIGNORE_PATTERNS in scripts/ci/docs_publication_boundary.py (the frozen list only ever shrinks); internal/ already fences the new location Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: classify tests/dockerfile_runtime_home.rs and shrink boundary self-test fixture Two CI gates failed on the docs/reborn consolidation and forced decisions this commit records: - The Reborn PR test planner failed closed on tests/dockerfile_runtime_home.rs (its path-rewrite edit is functional: the test reads the moved deploy doc). The file was deliberately unmapped because no lane inventoried it. Decide it now: _root_test_partitions() and run-reborn-root-partition.sh both inventory it alongside support_unit_tests.rs, so the hermetic root-partition lanes run it (they previously ran it nowhere) and a change to it selects its partition. With the reader laned, map the two config.hosted-single-tenant*.toml readers it owns in DOCKER_RUNTIME_CONFIG_OWNERS — root-test owners select their root partition, completing the per-file decision set the planner comments left open. docker/process-sandbox-entrypoint.sh stays fail-closed. - test_docs_publication_boundary.py's subset fixture still listed reborn/ in the frozen mintignore list; use the surviving entries. Verified: both self-test suites pass (77 planner + boundary), the planner emits a valid selected plan for this PR's full 342-path diff, shell and Python inventories agree on partition assignment (index 0), and dockerfile_runtime_home passes (19 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3ee9314e4a |
refactor(webui): remove standalone legacy missions surface (#7527)
* refactor(webui): remove legacy missions surface * test(webui): address missions removal review |
||
|
|
528993bcd7 | fix(stress): follow session channel message route (#7568) | ||
|
|
f371eb98bf | refactor(webui): remove admin analytics placeholders (#7529) | ||
|
|
0445e5a6d9 | refactor(webui): remove project mission placeholders (#7528) | ||
|
|
6d210584b8 |
refactor(webui): remove retired routines surface (#7526)
* refactor(webui): remove retired routines surface * test(webui): guard retired routines references * test(webui): cover absolute routines routes |
||
|
|
ad18aa188e |
feat(llm): add per-user model preferences and commands (#7439)
* feat(llm): add tenant model selection policy * fix(composition): move model policy store to operator * test(llm): specify per-user model preference behavior * feat(llm): persist per-user model preferences * feat(llm): add per-user model commands * fix(llm): address model preference review findings * fix(logging): preserve model preference error causes * fix(model): apply user preferences to channel turns * test(channel): cover model preference handoff * fix(composition): keep model config wiring concrete * fix(model): preserve resolved model across inbound replay * fix: preserve accepted model across replay failures * test(model): restore caller-scoped preference coverage * fix(model): close preference review gaps * fix(model): preserve concurrent replay identity * test(model): cover cross-tenant preference isolation * fix(cli): honor saved user model preference |
||
|
|
ed1c19dfd8 |
chore(agents): refresh codebase knowledge graph (#7564)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> |
||
|
|
d4fa8e1f60 |
feat(extensions): per-field help text on admin configuration forms + channel setup docs rewrite (#7550)
* feat(extensions): add per-field help text to admin configuration forms Manifest [admin_configuration] fields gain an optional `description` that renders as a hint under each input on the WebUI Admin -> Configuration form, so operators see what each value is and where it comes from while filling the form. Threaded additively through every layer: - registry: `AdminConfigurationField.description` (serde default — every existing manifest and persisted resolved record parses unchanged) - extension host: carried on `AdminConfigurationFieldState` redacted views - assistant: `RebornAdminConfigurationField.description` on the wire, omitted when empty - extension manager: mapped through the admin-configuration view provider - webui frontend: rendered under the input in configuration-tab.tsx The telegram manifest is the first consumer: one hint per field (BotFather token, invent-your-own webhook secret with the allowed charset, full webhook URL with local-tunnel pointer, @-less bot username), and the group description shrinks to a summary plus the tunnel note. Coverage at each seam: manifest v3 contract (declared description survives resolution, undeclared resolves empty), host service contract (help text reaches the redacted view), manager view unit test (passthrough beside the secret-redaction guard), frontend vitest (hint renders), and the webui_v2_product_api integration test asserts every telegram field carries non-empty help text on the wire through the production stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(channels): rewrite channel setup for the Admin -> Configuration flow The Telegram and Slack pages, the channels overview, and the onboarding page still taught the retired "Extensions -> Channels tab -> scroll to the bottom of the Built-in section -> Configure" flow. Deployment credentials now live under Admin -> Configuration, with the extension card's Configure handling only the personal half (pairing / OAuth). - telegram.mdx: setup rewritten around Admin -> Configuration with a table for all four fields; new step with an ngrok walkthrough for local installations (static domain, exact webhook URL, hostname-rotation note); troubleshooting covers stale tunnel hostnames and fail-closed activation. - slack.mdx: operator step now points at the Slack deployment configuration card; stale "can't find where to configure" answer fixed. - overview.mdx + onboard.mdx: setup taught as two halves (operator deployment config vs personal pairing), dead-end guidance updated. - zh mirrors of telegram and the overview updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webui): address review findings on admin-config field help Applies three CodeRabbit findings on #7550: - a11y: the field help paragraph gets a stable id and the input references it via aria-describedby, so assistive technology reads the guidance with the control; fields without help text carry no dangling reference (asserted both ways in configuration-tab.test.ts). - docs: the ngrok walkthrough now uses the current free-plan syntax — `ngrok http 3000` with the automatically assigned development domain — instead of the deprecated `--domain` flag and the retired claim-a-name flow; paid-plan reserved domains use `--url`. zh mirror updated. - test: the integration wire assertion now pins each Telegram handle to a distinctive fragment of its own manifest help text (plus a field-count guard), so a description copied across fields or attached to the wrong handle fails even though all four are non-empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(extensions): keep the description field doc at its schema owner only The pass-through copies on AdminConfigurationFieldState and the wire DTO restated the field name and broke their structs' undocumented-sibling idiom; the empty-means-undeclared convention lives once, on the registry schema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ee3146af9b |
fix(webui): soften failed-tool activity summary with a subtle badge (#7302) (#7305)
When a tool call failed mid-run, the whole collapsed activity summary
("Activity - 13 tools, 4 failed") was painted in the danger color, so a
run the agent recovered from still read as an alarming error.
Drop the whole-row danger recolor from both summary rows (ActivityRun and
the collapsed ToolRun) and keep them in the neutral muted text. A recovered
failure is now flagged only by a small warning-tinted `alert` badge — an
informational cue, not a red banner. The collapsed tool-run summary text
omits the failure count, so its badge carries an sr-only note (reusing the
existing activity.failed strings) for assistive technology.
Adds an `alert` glyph to the icon set and a source-level regression test
pinning: no whole-row danger recolor, the gated warning badge in each row,
and the accessible note on the tool-run badge.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
88fe2d01e9 |
refactor(channels): normalize ingress and split reply from delivery (#7477)
* fix(webui): only offer the web-app notification channel when a browser is enrolled
The "Web app" row in the notification-channels picker rendered READY with a
selectable checkbox even with zero enrolled browsers (no push subscription),
so a user could pick a channel that has nowhere to deliver. Selectability and
the pill now follow the account's enrollment count: with no enrolled browser
the web-app checkbox cannot be SELECTED and its pill drops from Ready to
Unavailable, while the nested "Enable notifications in this browser" affordance
shows how to fix it. An already-stored selection stays deselectable (disabled
only when unchecked), so a browser that unsubscribes never leaves a locked-on
checkbox.
The web-push row now owns its device hook (WebPushChannelRow) so the account
status query still mounts only when the row is present; the shared row label
was extracted (renderChannelRowLabel) so every other channel renders its
checkbox inline, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* wip(ingress): declare authenticated-session ingress recipe
Add IngressVerificationRecipe::AuthenticatedSession — the trust class for a
channel whose caller the host's authenticated transport (T1) already verified,
so it needs no webhook signature. Handle it fail-closed in the two webhook host
sites: no evidence mint in channel_host, and a NotWebhookVerifiable rejection in
the ingress verifier (a session channel mounts no webhook route and must never
be attested verified-inbound through the webhook path).
Incremental checkpoint toward the generic-inbound pipeline (PR2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(ingress): make channel route_suffix optional, paired with trust class
A channel's ingress mount now depends on its trust class. Webhook recipes (T2:
hmac/shared-secret/none) mount /webhooks/extensions/{id}/{suffix} and MUST
declare a route_suffix; an authenticated_session recipe (T1) is verified upstream
by the host transport, mounts no webhook route, and MUST NOT declare one.
- route_suffix becomes Option<RouteSuffix> (serde default+skip; existing webhook
manifests parse unchanged into Some).
- ChannelDescriptor::validate pairs the recipe kind with route_suffix presence,
fail-closed both ways (SessionIngressWithRouteSuffix / WebhookIngressWithoutRouteSuffix).
- Every mount/route-table consumer (active snapshot build + resolve + conflict,
deployment channels resolve, lifecycle reserved-route check) fails closed when
a session channel carries no route_suffix — it can never match a webhook route.
Groundwork for routing the web app's authenticated session through the one
generic inbound pipeline (PR2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(design): materialize the unified channel model (target architecture)
Every channel (web-app, Slack, Telegram) becomes one ChannelAdapter that
implements inbound + outbound/reply + notifications the same way. The only
per-channel variation is the declared ENTRYPOINT (webhook / api_key /
authenticated-session POST) and declared DELIVERY capabilities (reply mode
streaming|batched, optional max_message_chars, markdown, threads). Everything
between entrypoint and delivery is one abstract, channel-agnostic core:
idempotency -> bind(OwnedThread|ExternalRef) -> submit_turn -> durable reply
events -> per-mode reply sink.
Removes the current smell (two post-ingress cores; the web-app special-cased on
both inbound and reply). Records the migration deltas, the trust/security
invariants, and what stays on ProductSurface (the web-app's rich non-messaging
client API). Authoritative target for future agents touching channel code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(design): no channel-specific code — generic notification setup, kill web-push routes
Strengthen the unified channel model per direction: the hard invariant is that
NOTHING in the codebase is specific to a given channel for inbound, outbound, or
notifications. Every route is generic and extension_id-parameterized; channel
behavior lives only in the adapter (packages/*).
- Web-push enrollment becomes GENERIC channel notification setup: a channel
declares notifications_require_setup; a generic status/enable/disable surface
(by extension_id) dispatches to the adapter. VAPID/endpoints/subscription store
move behind the web-app adapter.
- Delete /web-push/{subscribe,unsubscribe,status} and the web-app-specific
message route; replace with generic session-inbound + notification-setup routes.
- Extend the specificity gate: zero channel names / channel-specific routes in
generic crates. Rename web-push -> web-app (id/routes/constants) is in-scope.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(design): notification send is a generic facade over ChannelAdapter::deliver_notification
Channels implement their notification logic in the adapter
(ChannelAdapter::deliver_notification); the DeliveryCoordinator is the generic,
any-caller facade that dispatches to it by extension_id. Routines are one caller
among several (the model's outbound_deliver already is another) — callers own
WHEN/WHAT, never HOW or which channel. Delivery is already adapter-based, so this
is exposing the facade + adapter method, not a rebuild. Setup stays a separate
generic surface (7b). Migration renumbered 8-11 accordingly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(inbound): trust-class + binding enums on the channel inbound contract
The unified-channel-model inbound vocabulary (§12.1 of
docs/internal/design/2026-08-10-unified-channel-model.md):
- ChannelInboundSurfaceRequest carries a trust-class enum
(VerifiedInbound { evidence } | SessionCaller { caller }) and a binding
enum (ExternalRef | OwnedThread { thread_id }) instead of bare webhook
evidence, plus the session transports' requested_model hint.
- ProductInboundEnvelope carries the same pair (ProductInboundTrust /
ProductInboundBindingDirective); auth_claim() is now Option, with
require_verified_auth_claim() failing closed for session envelopes on
every external-ref path (binding requests, command context, projection
subjects).
- TrustedInboundContext::from_session_caller mints the session-arm context;
the webhook constructors are unchanged in behavior.
- ProductInboundAck::Accepted gains optional submit-time metadata
(AcceptedTurnSubmission) and the busy variants gain an optional
BusyRunSnapshot, both serde-defaulted so ledger rows settled before this
change still deserialize (pinned by ack_rows_without_submit_metadata_
still_deserialize).
- ChannelInboundProductSurface gains a default-fail-closed inline-attachment
admission door for session transports.
- ProductSurfaceRejectionKind gains DuplicateAction and ReplayUnavailable
for the session-lane replay taxonomy; every exhaustive matcher classifies
them explicitly.
Mechanical fallout: constructors updated across extension_host, openai_compat,
composition and the integration/parity harnesses; no behavior change on the
webhook lane.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb
* feat(inbound): owned-thread session lane inside the one inbound core
The webhook core's InboundTurnService gains the session lane (§12.2): the
envelope's binding directive selects the arm, and everything below
TurnCoordinator::submit_turn stays shared.
- OwnedThread prepare: the authenticated caller is the binding authority —
ownership-probed through SessionThreadService (missing and foreign threads
are indistinguishable, no existence oracle), never created implicitly, and
the external binding resolver never runs.
- Session replay probes the exact persisted browser binding-id schemes
(caller-scoped primary + thread-scoped legacy) so messages accepted by
earlier builds replay instead of double-accepting; a client action id
replayed against a different thread fails as ClientActionReplayMismatch.
- The submit tail is lane-parameterized: webui-src/webui-reply ref prefixes,
the raw client action id as the coordinator idempotency key, and the WebUi
product context are preserved byte-for-byte for session turns; webhook
submissions are unchanged.
- Fresh submissions carry AcceptedTurnSubmission metadata; busy outcomes
carry the blocking-run snapshot; session busy replays report no run
metadata (the dedicated browser path's exact shape).
- Session skill-activation hooks record between acceptance and submission
and clear on busy/error, matching the browser path's ordering.
- New session-lane failures (OwnedThreadUnavailable 404,
ClientActionReplayMismatch 409/duplicate, ReplayUnavailable 409,
SkillActivationFailed internal, AttachmentLanderUnavailable 503) never
settle the idempotency ledger.
- submit_inbound_inner admits only user-message payloads from session
callers, and build_channel_envelope rejects mixed trust/binding arms fail
closed: webhook trust/pairing machinery can never run for a browser
message and vice versa.
- CapacityExceeded submissions now surface non-retryable, matching the
workflow's own settle decision (turn_error_is_retryable).
Covered by the new session_lane suite in inbound_turn_contract (ownership
probe and cross-thread guards sabotage-verified) plus the serde-compat pins
from the previous commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb
* refactor(inbound): route browser + OpenAI-compat submit_turn through the one core
RebornServices::submit_turn (the SUBMIT_TURN_COMMAND implementation both the
browser route and the OpenAI-compatible transport invoke) now builds the
neutral session inbound request and admits it through the same
DefaultProductSurface core webhook channels ride — durable idempotency
ledger → owned-thread binding → TurnCoordinator::submit_turn (§12.2–3) —
then renders the acks back into the unchanged RebornSubmitTurnResponse wire
shape (fresh Submitted from submit-time metadata; replays as
AlreadySubmitted with the run's current state; busy shapes with their
decision-time snapshots; ledger-replayed busy without run metadata).
The duplicate browser tail is deleted: replay_webui_send_message,
replay_accepted_message, AcceptedWebUiMessage, mark_message_submitted_or_
replay, reconcile_terminal_duplicate, resolve_webui_thread_metadata,
parse_replay_run_id, and the webui binding-id scheme fns now live only as
the session lane of the shared core (the schemes byte-identical, with
legacy replay fallback). The reborn_services module-charter map is updated
in the same change.
Composition wires the durable session ledger
(build_session_inbound_ledger over the extension filesystem, mirroring the
per-extension channel ledgers' mount/bounds/CAS discipline) into every
product-surface instance; standalone/test builds keep the in-memory
default. SessionLaneRejectingBindingResolver guards the session core's
external-ref door fail closed.
The full reborn_services_contract suite (278 tests) passes unchanged
through the re-plumbed path — caller-owns-thread, no implicit thread
creation, client_action_id replay (including legacy binding-id rows),
cross-thread reuse rejection, busy/deferred/steering shapes, attachment
landing, and skill-activation ordering all preserved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb
* feat(ingress): generic session-inbound route keyed by extension_id
The web-app-specific browser message route is deleted and replaced by the
generic session-inbound door (§12.4, §8):
- POST /api/webchat/v2/channels/{extension_id}/messages replaces
POST /api/webchat/v2/threads/{thread_id}/messages. No route names a
channel; the path extension_id overrides the body and the thread rides the
body (the caller owns it). Route descriptor policy is unchanged
(14 MiB body, 60/60s per-caller, TurnCoordinator effect path).
- ProductSubmitTurnRequest/SendMessage carry the optional extension_id; the
product surface validates it against the new
SessionChannelDirectory port (declared in
ironclaw_product_contracts::session_ingress, implemented by the extension
host over the deployment channel registry — manifest-derived, install-state
free). Unknown or non-session extensions are 404, indistinguishable from an
absent route; a missing directory fails closed as 503. Transports that
predate the parameter (OpenAI-compat) submit under the legacy session
surface identity, unchanged.
- The web-app manifest declares its entrypoint: inbound = true with the
authenticated_session verification recipe, no route_suffix (a browser
request can never reach the webhook mount), conversation_model isolated.
The manifest-lockstep pin now asserts exactly that.
- The deployment's session channel is advertised to the SPA on
GET /session (session_channel_extension_id, derived from the registry —
exactly-one resolves, otherwise none and sends fail closed client-side);
the frontend plugs it into the generic route and carries no channel name.
- e2e harness + raw-route scenarios read the session channel from
GET /session; Playwright mocks match the generic pattern.
Caller-level coverage: directory-missing 503 / unknown-extension 404 /
declared-channel admit in reborn_services_contract; the session-channel
directory contract in extension_host; route-table, handler, and charter
gates updated in the same change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb
* feat(reply): channel-declared reply mode — streaming sinks never batch
The reply model's declaration half (§12.5–7): every channel declares how its
reply sink consumes the durable reply-event stream.
- ChannelDescriptor gains reply_mode = streaming | batched (default batched;
validation pairs streaming with the authenticated-session entrypoint —
a webhook vendor has no projection stream to consume).
- The web-app manifest declares streaming: the existing SSE/WebSocket
projection forward IS this channel's reply sink, exactly as it runs today —
a consumer of durable reply events, never a replacement (the gateway-events
layering rule). Its max_message_chars is now undeclared: a streaming sink
never batches or splits, so the channel is unlimited (§6). Slack and
Telegram declare batched explicitly; their declared bounds are unchanged.
- ResolvedChannelDelivery carries the declared mode from the same
generation-pinned snapshot read, and the DeliveryCoordinator gates both
delivery doors: conversation-reply intents for a streaming channel return
NoDelivery before any attempt is persisted (the projection stream is the
delivery), while notification-class sends (BackgroundRunNotice,
ModelDelivery) flow regardless of mode so the notifications capability
keeps working. Pinned by
streaming_channel_conversation_reply_skips_batched_delivery and
streaming_channel_still_receives_notification_class_deliveries.
- max_message_chars stays adapter-enforced at render time (channel-specific
splitting is adapter behavior by charter); the declaration remains the
model-facing hint. The batched sink itself never splits for a streaming
channel by construction.
No behavior change for any existing delivery: no streaming channel receives
conversation-reply deliveries today, so the gate is the fail-closed
materialization of the current structure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb
* feat(notify): ChannelAdapter::deliver_notification + the generic notify facade
The §7a send half of notification generalization (§12.8):
- ChannelAdapter gains deliver_notification(envelope, egress) — the
channel-specific notification send, defaulting to the channel's ordinary
delivery (a conversational channel's notification is a message; a
notification-only channel's whole delivery IS this send). Only the generic
DeliveryCoordinator calls it, never feature code.
- The coordinator classifies each policy-lane delivery before the request is
consumed: a run-notification that is not source-routed (it targets a
notification channel, not the originating conversation) and is not an
explicitly routed final answer rides the adapter's notification send;
everything else rides ordinary delivery. Pinned by
notification_class_delivery_rides_the_adapters_notification_send /
conversation_reply_rides_the_adapters_ordinary_delivery. Zero behavior
change for shipped adapters — all three inherit the delegating default.
- run_delivery::notifications is the named any-caller facade over the
coordinator: notify(target, content) for one explicit catalog-resolved
channel target, notify_user(user, content) fanning out over
resolve_user_notification_targets (the picker set). The routine driver's
own notification internals now delegate to it — one send path, with the
routine lane as one caller among any number. Callers own WHEN/WHAT, never
HOW, and never name a channel.
- The coordinator's streaming-reply gate now reads the new lightweight
ChannelDeliveryResolver::channel_reply_mode lookup instead of performing a
second full resolution, preserving the single generation-pinned
resolve_channel_delivery read the OUT contract pins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb
* feat(notifications): generalize notification setup behind ChannelAdapter (§7b)
Replace the bespoke /web-push/{status,subscriptions,subscriptions/remove}
routes with one generic per-channel surface keyed by extension_id:
GET/POST /api/webchat/v2/channels/{extension_id}/notifications{,/enable,/disable}.
- contracts: web_push descriptor module deleted; notification_setup module
(status view + enable/disable command descriptors) and the
RebornNotificationSetup* wire family replace the RebornWebPush* DTOs;
body extension_id serde-defaulted (route path is canonical).
- assistant: reborn_services/web_push.rs deleted; notification_setup.rs adds
ChannelNotificationSetupService + fail-closed Unsupported default +
AdapterChannelNotificationSetupService dispatching to the channel adapter
via ChannelDeliveryResolver (unknown extension -> 404, no-setup channel ->
enabled:true + mutation 400, payload/detail byte bounds enforced).
- delivery coordinator: the streaming-reply gate now keys on the ROUTE, not
the intent — a notification-routed send (RunNotification + non-live-source
origin) flows to a streaming channel even with a conversation-shaped
intent; pinned at the contract tier and by the blocked-fire push journey.
- web-push package: adapter implements the three setup operations over the
slot runtime (scope byte-identical to the retired product service; detail
carries vapid_public_key/subscription_count/subscriptions with
endpoint_digest correlation).
- composition: wires AdapterChannelNotificationSetupService over the channel
delivery resolver; WebPushComposition handle family deleted (the slot
install inside assemble_web_push is now the single consumer).
- webui: route descriptors/router/handlers swapped to the generic surface;
CONTRACT.md route table + outbound charter row updated.
- frontend: api.ts gains getNotificationSetupStatus/enable/disable keyed by
extensionId; web-push.ts -> device-push.ts and useWebPushDevice ->
useDevicePush re-read the channel-opaque detail; the notification panel's
device row is matched by the GET /session-advertised session channel id —
no channel name remains in the frontend; webPush.* i18n keys renamed
devicePush.* across all 11 locales.
- tests: 5 new setup-dispatch contract tests + streaming-notification
regression pin; product-api round-trip and delivery journey rewritten onto
the generic surface; frontend suites updated (1241 pass).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(channels): rename web-push -> web-app and retire the old spelling (§12.11, §13)
Product identity rename: extension id / channel name / catalog target id are
now 'web-app'; package dir crates/extensions/packages/web-app (crate
ironclaw_web_app_extension); domain crate crates/domains/ironclaw_web_app;
WEB_PUSH_* constants -> WEB_APP_*, WebPush* types -> WebApp*. PROPOSAL §5
tree updated (check-target-tree: 66/66 OK). Space-separated 'Web Push'
protocol prose stays — the protocol keeps its RFC name; the CHANNEL does not.
Persisted coordinates deliberately keep pre-rename bytes, each commented in
place and pinned by the new gate's allowlist:
- secret-store credential handle value 'web_push_vapid' (renaming = VAPID
rotation = every existing browser subscription breaks cryptographically);
- enrollment document path /web-push/subscriptions.json plus composition's
/web-push per-user mount alias (the alias resolves to a physical subpath;
renaming would orphan enrollments);
- binding-ref grammar mints web-app/v1/ and decodes legacy web-push/v1/
forever (regression test added).
Documented residue, no migration: stored notification-channel selections
carrying the old 'web-push' target id render Unavailable until re-selected
(population ~QA-only; the channel shipped 2026-08-09).
The install-catalog hide for the host's own surface is no longer an id
match: is_builtin_host_surface consults the SessionChannelDirectory (the
manifest-derived authenticated_session fact), failing OPEN on an absent
directory; the production round-trip test covers the hidden-listing behavior
end-to-end.
Enforcement (§13): new architecture gate
reborn_web_push_vocabulary_retired.rs pins web-push/web_push/WebPush/
webPush/WEB_PUSH at zero occurrences across crates/ (frontend sources
included), tests/integration/, and skills/, with an exact-term shrink-only
allowlist over the five persisted-compat files, a stale-sanction check, and
an assertion that the session + notification-setup routes stay
{extension_id}-parameterized. The specificity gate's web-app carve-out doc
records the rename.
E2E journey vocabulary renamed on both the Rust and Python sides
(case ids, test names, delivery-target enum member).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump lru 0.18.1 -> 0.18.2 (RUSTSEC double-free advisory)
cargo-deny advisories began failing on every head when the lru advisory
published; 0.18.2 is the fixed release (lru-rs#238).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(ci): raise composition arc_dyn ceiling 816 -> 818 (unified channel model)
Two net-new dyn seams wired at assembly, both genuine inversion ports:
the session-inbound lane's SessionChannelDirectory + durable
IdempotencyLedger, and the generic ChannelNotificationSetupService —
offset by the deleted WebPushComposition handle family. Observed on the
merged branch: 833 = 818 + 15 tolerance exactly, no slack.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): migrate the last pre-unification callers and re-pin ratcheted ceilings
Everything here is fallout of surfaces this PR deliberately changed:
- smoke + composition webui_v2_e2e: the raw-HTTP browser-send helpers now
discover the session channel from GET /session and post the generic
/channels/{extension_id}/messages route (thread_id in the body) — the
same flow the SPA ships.
- InboundUserMessageDispatch::Accepted is boxed (clippy large_enum_variant:
the merged InboundTurnOutcome grew past the threshold; a rejection stays
slim).
- journey coverage: a channel whose ingress verification is
authenticated_session has no webhook mount — its inbound IS the WebUI
session route, so it maps onto the webui journey evidence instead of
demanding a per-channel label.
- attachments no-lander test pins the sharpened
AttachmentLanderUnavailable variant (503, never settles the idempotency
reservation) instead of the old generic rejection.
- body-limit contract test pins webui.v2.session_channel_message (14 MiB)
after the route rename.
- contracts size ceilings re-pinned to measured merged values with
rationale: extension_contracts 8_157 (AuthenticatedSession trust class,
reply modes, §7b setup adapter surface), product_contracts 16_119
(trust/binding enums, SessionChannelDirectory, setup descriptors + wire
family), host_api 19_003 (doc churn referencing the renamed crate).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(review): keep the catalog target id persisted, migrate e2e callers, pin notice routing
Review triage for #7477 (IronLoop + multi-agent review).
Persisted identity (IronLoop Medium, also flagged by the review): the
catalog target id now keeps its pre-rename `web-push` bytes. The
notification-channel picker stores its selection as target ids in each
user's communication preferences, so this is a persisted per-user identity
exactly like the VAPID handle, the mount alias, and the binding-ref prefix —
the three the PR already kept. Renaming it resolved every stored selection to
Missing and dropped those users from notification fan-out. Applying the PR's
own rule uniformly removes the documented residue rather than shipping it;
the integration test now pins the split (target id `web-push`, channel
`web-app`) so the two can't be conflated again.
E2E callers of the retired send route (the browser-lane CI failure): four
Playwright interceptions and one API helper still targeted
/threads/{id}/messages, so failure injection never fired. All now use the
generic /channels/{extension_id}/messages route, and every mocked GET
/session advertises session_channel_extension_id the way a real deployment
does — the SPA fail-closes without it.
deliver_notice asymmetry (review Medium, correctness): confirmed correct and
now pinned. Notice-class intents are source-routed, so none is ever
notification-routed and `deliver`'s carve-out cannot apply; for a streaming
channel the originating conversation IS the projection stream, and Retract /
React have no counterpart there (the adapter reports both unsupported). The
new test drives all seven notice intents plus the notification path in one
breath so they cannot drift.
Session surface is built once (review Low/Medium, hot path): submit_turn
rebuilt DefaultProductSurface plus ~5 Arc'd services per browser message;
every input is an immutable builder-wired Arc, so it is memoized behind a
OnceLock.
Docs the rename sweep left stale: tests/CLAUDE.md cited a test name that
never existed, the web-app README cited a VAPID handle value that doesn't
exist (the constant deliberately keeps the old value), the extensions
package-inventory row still called the channel outbound-only with no ingress,
and a merge left a duplicated comment block in inbound_turn.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(review): cover the notify_user fan-out and the web-app adapter's setup errors
Closes the two review findings that were test gaps rather than follow-ups —
the repo's own rule is that production-wired behavior ships with its
caller-level test, and both of these were new production surface with none.
notify_user (crates/product/ironclaw_assistant/tests/run_delivery_contract.rs):
the driver only ever calls the single-target notify, so the fan-out loop was
untested. Two contracts now pinned through the real facade: an unconfigured
user yields an empty result rather than an error, and a target whose channel
no longer resolves surfaces its own Err while its healthy sibling still
delivers (asserted at the adapter, not just the return value).
web-app adapter (crates/extensions/packages/web-app/tests/notification_setup_contract.rs):
the generic service tests drive a scripted adapter, so the real parse →
validate → store path had no error coverage. Six cases: non-JSON document,
missing key material, undecodable base64url keys, an endpoint on an
undeclared push host, a malformed unenrollment document, and every setup
operation with no runtime installed. Each asserts the store was never
touched, so a rejected payload can't leave the browser believing it is
enrolled with no server record behind it. All six passed on first run — the
arms were correct, just unproven.
observer.rs: the repeated fallible-from_envelope fallback is now one
`degradable_binding` helper — but only for the two sites that genuinely
merge 'no request' and 'no binding' into the same degrade. The delivery path
still propagates (a send with no binding is a fault), and the
rejection-hint path still distinguishes them (posted nothing vs handled by
staying silent); both reasons are documented on the helper rather than
flattened away.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(channels): close the audit findings — dead per-channel routes, gate gaps, session-surface fallback
Fallout from the four channel-specificity audits, plus two defects those
audits found in MY OWN branch that would have kept CI red.
Introduced by this branch, now fixed:
- a contracts-crate doc comment named Slack/Telegram, an untracked
specificity-gate violation (the gate strips #[cfg(test)], and this was
production code)
- deleting the dead telegram frontend modules made five ALLOWLIST rows
stale; that list is an EQUALITY ratchet (both <= and >=), so the rows are
removed and the baseline drops 117 -> 112
Pre-existing, found by the audit:
- 871 lines of orphaned telegram-setup frontend code holding the only two
channel-named URL literals in the SPA, pointing at routes the backend no
longer serves. Deleted.
- a dead ironclaw_assistant -> ironclaw_web_app dependency edge: a generic
product crate holding a compile-time edge to one channel's domain crate
for nothing
- telegram_extension_gates.rs still documented the retired per-channel
pairing route as live
New gate (§13's structural half): no source file may name a channel in a
/api/webchat/v2/channels/ route. It scans SOURCE rather than the descriptor
table, because the defect it exists to catch lived entirely in callers the
route table never knew about — the table was clean while 871 lines of
channel-named client code sat beside it. Sabotage-tested against the deleted
file, which it flags. Placeholders and test fixtures are exempt (tests may
name channels, per the extension-runtime overview).
Session-surface regression, found by CI on the composition e2e suite:
a deployment that installs no channel extension had NO route to submit a
browser turn, because the old /threads/{id}/messages route is gone and
/session advertised no channel id. That is a supported deployment shape
(assemble_web_app treats its slot as optional), so browser chat must not
depend on an installed extension. BUILTIN_SESSION_SURFACE_ID now lives in
product_contracts::session_ingress; composition advertises it when no channel
claims the surface, the product gate accepts it, and WebuiServeConfig
defaults to it rather than None — the transport defaulting the surface to
'absent' was the actual defect. 15/15 composition e2e tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(internal): channel output has two axes — reply and delivery
Design record for the follow-up train. The unified channel model unified the
pipeline; this reshapes the contract it drives. Recorded here rather than in
the follow-up PR so the decisions survive the conversation that produced them.
The root finding is not the eleven-method ChannelAdapter — that is the
symptom. It is that two independent concepts share one vocabulary:
- reply = answering the run's input, SOURCE-routed, never without a run
- delivery = reaching someone out-of-band, TARGET-resolved, runs optional
They are orthogonal, not alternatives: one run can stream an answer into an
open tab AND push a notification because the user is not looking. Dispatching
on intent rather than on this axis already produced a real defect on this
branch — a gate prompt is a reply when a human is in the thread and a delivery
when a 3am routine is blocked, and keying the streaming skip on the intent
silently dropped the second case.
Decisions: three manifest sections (ingress/reply/delivery) replacing the
inbound/outbound/notifications booleans; OutboundRoute plus two transport
enums so nonsense combinations are unrepresentable; DeliveryOrigin keeping
model-chosen targets from inheriting user-configured trust; a streaming
delivery returns a projection cursor as evidence instead of NoDelivery,
closing an audit hole where browser replies produce no record at all;
activate/cleanup become an ingress-registration recipe; the attachment fetch
moves AFTER the ack (the durable write currently depends on it, which is what
puts a vendor round-trip on the webhook deadline path); enrollment moves
host-side with no adapter method, keeping one generic pre-storage check that
exists to prevent an SSRF primitive.
Five open questions and a six-step sequencing table are recorded; step one is
the smallest and closes both the no-op and the audit hole.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): repair fmt, the last retired-route callers, and the contracts ceiling
Three red checks on
|
||
|
|
203cbecf15 |
ci: tolerate sccache install outages (#7552)
* docs: design sccache install fallback * ci: tolerate sccache install outages * ci: classify shared sccache action tests |
||
|
|
173f078bba |
Gate & ratchet audit: full-inventory report, five fail-opens armed, dead gates deleted (#7373)
* test(architecture): drop the dead ironclaw_storage row and arm the substrate list
Gate-audit finding (open-and-shut): SUBSTRATE_CRATES in
reborn_composition_boundaries.rs carried three rows of rot, all invisible
because the loop's `let Some(..) else { continue }` silently skipped any
entry that resolves to no workspace package:
- "ironclaw_storage": no such package exists (verified against
`cargo metadata --no-deps`; the only MISSING name of the 29 listed).
- "ironclaw_approvals" and "ironclaw_assistant" were each listed twice.
The silent skip is replaced with a panic naming the stale entry, so the
list can no longer rot invisibly. Verified by sabotage: adding a bogus
"ironclaw_zzz_probe" row now fails the test with
"is listed in SUBSTRATE_CRATES but is not a workspace package"; the
clean list passes (23/23).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(architecture): prune the dead sanctioned path from the specificity gate
Gate-audit finding (open-and-shut): SANCTIONED_PATHS in
reborn_extension_specificity.rs still exempted
`extension_host/extension_installation_store.rs` — a file deleted by
#6430. No scanned path matches the fragment (verified with rg across
crates/), so the entry exempted nothing; it is also the one exclusion
surface in this gate with no staleness check, which is how it outlived
its file. Full specificity suite green after removal (8/8).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(architecture): drop the v1 ironclaw_gateway/static exclusions from the telegram gates
Gate-audit finding (open-and-shut): both cross-tree scans in
telegram_extension_gates.rs still carved out `ironclaw_gateway/static`
— the v1 monolith's embedded UI, whose crate was deleted with the src/
monolith (no crates/*/ironclaw_gateway directory exists). The exclusions
matched nothing; scans now cover the whole tree with no dead carve-outs.
Suite green after removal (12/12).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(architecture): make the dto-collapse gate's header describe the gate that exists
Gate-audit finding (open-and-shut doc rot): the module doc still
described the pre-#6447 freeze design — a dangling doc-link to
FROZEN_COLLAPSE_DTOS (renamed RETIRED_COLLAPSE_DTOS in #6447), a
promised delete-without-trimming failure and an empty-allowlist
assertion that do not exist in the file, and a named owner for a
collapse that completed. The mechanism itself is armed and untouched;
the header now describes the permanent zero-gate it became, and records
the two originally-frozen names that deliberately left governance
(CapabilityOutcome via #6299 deletion, CapabilityDispatchRequest blessed
as the canonical port type). Suite green (2/2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(architecture): repoint the manifest-reparse allowlist note at the colocated asset
Gate-audit finding (open-and-shut doc rot): the BundledAsset allowlist
entry's justification still cited include_str! of
assets/memory_native/manifest.toml — a path retired when WS2 (#7037)
colocated packages; the live include in memory_native_extension.rs
reaches crates/extensions/packages/memory-native/manifest.toml. Comment
only; the gate's mechanism and counts are untouched. Suite green (2/2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(architecture): give the memory-vocabulary gate the partial-tree floor its twin has
Gate-audit finding: reborn_memory_retired_vocabulary.rs had no
MIN_SCANNED_FILES floor, unlike its explicit twin
reborn_retired_taxonomy.rs — so a partially-moved tree (the CHECKLIST
WS0 / #6963 'green while measuring nothing' shape) would scan a
fraction of the files and still report the vocabulary clean. The gate
was in fact born with an already-dead sanctioned path (its own header
records this), so the rot class is not hypothetical for this file.
Adds the same 500-file floor (real count ~4000), asserts it in the main
gate, and pins the premise on a fixture: a 10-file partial tree scans
clean and is rejected by the floor. Suite green (4/4); clippy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(architecture): close the transport gate's nested-use-group fail-open
Gate-audit finding (sabotage-verified): product_symbols_in's braced-group
branch closed at the FIRST '}' (group.find('}')), so a nested group —
use ironclaw_assistant::{m::{X}}; — truncated mid-element and recorded
zero symbols. Probed live before the fix: appending
use ironclaw_assistant::{zzz_audit::{ZzzProbe}}; to webui's lib.rs left
transports_name_only_the_frozen_residue_of_product_symbols GREEN, while
the plain-path spelling of the same import correctly failed. The same
truncation dropped qualified elements inside flat groups
({qualified_module::X} recorded nothing).
The group branch now does a balanced-brace walk, splits elements at
depth-0 commas only, and records a qualified/nested element's leading
path segment — the same key the single-path branch records for
ironclaw_assistant::module::X. Flat-element semantics are byte-for-byte
unchanged, so the frozen 100-row webui inventory is untouched (suite
green 6/6 on the live tree). Regression fixtures added to
import_scanner_reads_symbols_out_of_real_use_shapes; the original
sabotage now fails with the gate's own message (re-verified).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: delete check-e2e-matrix-files.sh — a gate for a workflow that no longer exists
Gate-audit finding (provably inert): the script's default target is
.github/workflows/e2e.yml, deleted when the v1 e2e suites were retired
(git log --diff-filter=D shows the removing commit); no workflow, script,
hook, doc, or guidance file references check-e2e-matrix-files.sh
(verified with rg across the repo including .github and .githooks).
A checker nothing runs, pointed at a file nothing provides, is dead
weight that reads as coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: delete the measured-broken check-boundaries.sh and its guidance references
Gate-audit finding (provably inert, previously measured): crates/AGENTS.md
recorded on 2026-08-05 that the script fails on a clean tree (check 5
false-positives on live test files) and that checks 1/2/3/6 target the
deleted v1 src/ tree, passing vacuously. No workflow or hook runs it; its
only callers were guidance files, two of which claimed it 'enforces'
root-tests feature gating — an enforcement claim the skill-maintainer
rules forbid for a check nothing executes.
Removed the script and every live reference: the crates/AGENTS.md warning
row becomes a tombstone note; the testing skill + exemplar reference drop
the false enforcement parenthetical; the architecture-review skill's
Verify line drops the dead command; deslop-reborn's allowed-tools drops
the permission; .coderabbit.yaml's driver-leak instruction now points at
the live enforcement (reborn_persistence_driver_boundary). Two dated
docs/internal/ plan snapshots keep their historical mentions.
Verified: python3 scripts/ci/check-guidance.py OK (2084 path references)
and its self-test OK.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(product): stop hardcoding charter sub-owner counts in the family map
Gate-audit finding (stale prose): crates/product/AGENTS.md said
'19-sub-owner reborn_services charter map' — the enforced map has had 20
sub-owners since #7235 added the inspector row (counted from the live
table). Rather than chase the number, drop both inline counts: the
owning maps and their gates are authoritative, and the re-verify
commands are already inline (skill-maintainer rule: no counts without a
regeneration recipe). check-guidance.py OK.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(architecture): correct the scanner-fixture file's name-filter claim
Gate-audit finding (doc rot with a false coverage claim): the header
said naming the FILE reborn_* makes code_style.yml's
'cargo test -p ironclaw_architecture_tests reborn' see it — but that
argument is a test-NAME filter (the measurement is documented in
reborn_contracts_vendor_census.rs), and none of this file's test fns
contains the substring, so that smoke lane runs 0 of them (11 collected
by the full plan). Comment-only; the note now records the real semantics
so file names are not trusted for lane coverage. Suite green (11/11).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(internal): gate & ratchet audit report + proposed preflight gauntlet
The audit the owner asked for after PR #7157 went red six times across
four gates: every architecture-test gate, module charter, CI script, and
committed baseline inventoried with a verdict and evidence; the handful
worth acting on ranked by friction x weakness; the CI-ergonomics analysis
(why failures surface one per ~1h round-trip: no --no-fail-fast anywhere
in CI, cancel-in-progress on push, sequential fast-checks steps —
measured: two broken gates report 1 failure in 18s under the CI shape vs
both in 211s with --no-fail-fast); and the sabotage log for every probe.
scripts/preflight-gates.sh is the concrete pre-push proposal: the
deterministic-gate classes only (script gates ~10s + architecture suite
--no-fail-fast + changed-crate charter tests), covering all four #7157
gate classes locally in one command. Unwired — nothing invokes it.
Validated end-to-end on this branch: exit 0, 'every deterministic gate
green', 402.8s including gate-binary recompiles.
Placement verified: python3 scripts/ci/docs_publication_boundary.py OK.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci(planner): classify preflight-gates.sh and the deleted check-boundaries.sh
The gate audit's own PR hit the planner's fail-closed arm — 'unmapped
test or CI path: scripts/check-boundaries.sh' — exactly the class the
arm exists to force a decision on (and the audit's report documents).
Per the PR_STATIC_CONTROL_PATHS membership rule (no Reborn test lane
exercises either file):
- scripts/preflight-gates.sh — the audit's proposed local pre-push
gauntlet; referenced by no workflow.
- scripts/check-boundaries.sh — deleted by the audit; the entry lets the
deletion diff (and any revert) classify instead of failing every
downstream Reborn lane.
Verified: the planner now produces mode=selected with the
architecture-misc bucket for this branch's diff, and
python3 scripts/ci/test_reborn_pr_test_plan.py is OK.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(internal): add the fold-tripped asymmetric-tolerance exhibit to the audit
The strongest single exhibit for shortlist item 2, contributed by the
#7157 branch steward after this audit's cutoff and verified against the
gate's code: TOLERANCE = 400 is consulted in exactly one direction (the
banked-slack check, ceiling.saturating_sub(lines) > TOLERANCE); the
growth check is a bare lines > ceiling. With the in-file 'set to
current, not padded' instruction, every ceiling is a hard cap at the
observed count — so one line landing on main in any contracts crate
reds every open branch at its next fold until someone re-captures.
Measured recurrence on #7157: loop_contracts re-captured four times,
~once per fold (14,479 -> 13,850 -> 13,949 -> 13,115 -> 13,181), the
last tripped by main's #7361/#7363 adding 66 lines to
instruction_bundle.rs — nothing the branch wrote. All four deltas were
<= 105 lines: either repair shape in §3.2 (one-line upward tolerance
using the existing constant, or mid-window pinning) would have absorbed
every one with zero red builds. This audit's own sabotage already
proved the jaws (+1 line host_api red / -1 line common red); the fold
history shows the operational cost. The repair stays a recommendation —
adding growth headroom to a ratchet is the owner's call, not this PR's.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(architecture): give the contracts size ceiling upward working slack
Owner-directed repair of the audit's sharpest finding (report §3.2): the
gate's TOLERANCE = 400 was consulted in exactly one direction — the
banked-slack check — while the growth check was a bare lines > ceiling.
Combined with 'set to current, not padded' pins, every ceiling was a hard
cap at the exact observed count, so one line landing on main in any
contracts crate redded every open branch at its next fold until someone
re-captured. Measured on #7157: four loop_contracts re-captures, roughly
once per fold, every delta <= 105 lines — the gate generating its own
busywork.
The growth check now allows GROWTH_TOLERANCE = 150 of working slack
above each pin (sized to composition-budget precedent; the reviewed
raises this gate has caught were +1,069 and +1,214 lines, far above it),
and all six ceilings are re-pinned to the counts the test itself
reported with every ceiling at 0 — which also removes the +400 seed
padding on common/loop_contracts/prompt_envelope that contradicted the
capture rule and put those crates one deleted line from the banked jaw.
Sabotage-verified both ways: +1 line in host_api and -1 line in common —
both red before this change — now pass; a +151-line probe still fails
with the effective-ceiling arithmetic in the message. Full
reborn_dependency_boundaries binary green (41/41); clippy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci(budget): re-equalize composition pins to observed — restore the working window
Owner-directed companion to the contracts-ceiling repair (same annoying
class, other mass gate): merged main-side growth since the 2026-08-05
equalization had drifted +101 LOC and +5 Arc<dyn> sites through the
tolerance windows, leaving 49 LOC / 10 sites of live headroom — the next
routine composition PR would have gone red on wiring alone (the gate
audit measured this the same day it was pinned).
Per the TOML's own maintenance instructions: loc_ceiling/loc_observed
40423 -> 40524 and arc_dyn 814 -> 819, measured with the gate's --print,
set to current not padded, dated notes appended (not overwritten), and
the arch-test record (COMPOSITION_ABSOLUTE_SRC_LOC) moved in the same
commit as its file requires. ceiling_bp stays 658 — the WS0 floor is
deliberately not re-set.
Verified: check-composition-budget.sh OK; its 76-case self-test green;
reborn_restructure_baselines green; probe +100 LOC now passes (was red
at 49 headroom), probe +160 LOC still fails.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(internal): record the landed zero-slack repairs in the audit report
The §3.2 repair moved from recommendation to landed at owner direction;
the report's answer, inventory rows, and §7 ledger now say so, with the
counting-rule fix promoted to the top remaining recommendation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* gates: pin the ceiling-window arithmetic; fail preflight discovery closed
Two review-round hardenings (the open CodeRabbit Majors):
- reborn_dependency_boundaries.rs: extract the size-ceiling comparison into
contracts_ceiling_verdict() and pin its four window edges with a committed
regression test (contracts_size_ceiling_window_edges_hold) — accept at
ceiling+GROWTH_TOLERANCE, reject one line past, accept at
ceiling-TOLERANCE, reject one banked line further, and a zero-measure scan
reads Banked, never a silent pass. The pre-repair asymmetry (tolerance
consulted only downward) can no longer return silently. Live-gate behavior
re-probed unchanged after the rewiring: +1 line to host_api passes, +151
fails with the same effective-ceiling message.
- preflight-gates.sh: setup and changed-file discovery now fail closed — a
missing repo root exits 2, and a failed merge-base/diff widens the charter
run to all five crates instead of silently skipping them (the same
fallback the missing-base branch already used). A broken setup may cost
compile time, never a silent skip.
Full boundary binary 42/42 green; clippy clean; preflight-gates.sh
end-to-end green on this tree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
09ac3c8af0 |
feat(memory): memory-save guidance + always-on MEMORY.md prompt lane (#7185) (#7365)
* feat(memory): tell the model when to save durable user facts Nothing in the system prompt explains that persistent memory exists, that the memory block it sees came from earlier conversations, or when a stated user preference is worth saving. Add a `memory_protocol.md` prompt asset, appended in memory on every resolve like the self-knowledge section, so existing installs get it rather than only freshly seeded ones (#7185). Also point `ironclaw.memory.write`'s prompt doc and both provider manifest descriptions at the durable-fact use case, so the tool description agrees with the protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(memory): inject MEMORY.md into every prompt without an FTS match Both proactive-recall lanes are full-text search over the current turn's query, so a fact saved in conversation A only reached conversation B when B's opening message happened to share vocabulary with it (#7185). Add a `read_curated` memory lifecycle hook and `MemoryService::read_curated`, implemented by the native provider as a plain read of the scope's `MEMORY.md` (absent or empty = an empty lane, not an error). The host queries it on every run with no query at all and admits it FIRST, ahead of the search lanes. The lane is split on line boundaries into per-snippet-sized chunks rather than admitted as one oversized snippet: a snippet's model-visible text is validated as a 512-byte `LoopSafeSummary`, which is also where the prompt denylist runs, so a single wide snippet would mean denylist-checking only the head of the document. Chunks are capped at 4 snippets / 2 KiB — half the aggregate — so the standing document can neither starve nor be starved by the search lanes, and a clipped document is marked truncated. The hook is opt-in per manifest, so mem0 (no standing-document concept) is never called on it and is unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(memory): pin cross-conversation recall and the memory-write gate Integration (real composition + libSQL): conversation A's model saves a durable fact with append mode, and conversation B — opening on an unrelated topic, sharing no content word with the fact, making no memory tool call — still finds it in its system prompt. Another user's MEMORY.md on the same composite does not. This is the case the existing proactive-recall scenario cannot cover, because both search lanes need the query to match. The lifecycle scenario now counts read_curated too, so "a full declaration drives every hook" stays true rather than silently skipping the new one. Composition: pin the approval posture the issue is really about — with the shipping default settings (global auto-approve on) ironclaw.memory.write is allowed, so saving a fact does not stop the turn on a prompt; with auto-approve deliberately off it still gates. The approval seam is per-capability (the gate policy sees the descriptor, never the invocation input), so "ungated for curated targets only" is not expressible there, and an unconditionally ungated save tool would override the choice of exactly the users the gate currently protects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(architecture): raise the extension-contracts size ceiling for read_curated The `read_curated` lifecycle hook adds one enum variant, its wire token, its `ALL` entry, and a doc comment to ironclaw_extension_contracts — 13 lines of declaration vocabulary. The lane's behavior (reading the standing document, line-aligned chunking, budgets, sanitization) lives in the native provider and ironclaw_host_runtime, so no logic reached the contracts tier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(memory): teach the model how to phrase a memory worth keeping The memory protocol told the model *when* to save a durable user fact but nothing about what a good one looks like, and three failure modes showed up in practice: - A memory saved as an imperative ("Always respond concisely") is re-read as part of the prompt on every later turn — and the new always-on lane re-injects it every turn — so it becomes a standing directive that can override what the user is asking for right now. Memories must be declarative facts ("User prefers concise responses"). - Task progress, session outcomes, PR numbers, and commit SHAs are stale within days and crowd out the durable facts the lane exists to surface. - With no priority framing the model saves whatever is nearest to hand rather than the fact that stops the user repeating themselves. Adds those three rules to `memory_protocol.md` and the declarative-form and staleness rules to the shared `ironclaw.memory.write` prompt doc, and clarifies that honoring a forget request means actually rewriting the document rather than only acknowledging it (raised in review — `write` with `append` unset replaces the document, so the guidance is executable). Two asset tests pin it: one for each doctrine phrase models actually copy (including both halves of the good/bad example pair), one keeping the protocol inside an 18-line budget, since it is appended to every prompt on every turn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory): address review findings on the always-on recall lane Four bot-review findings on #7365, all of them real: **The memory protocol claimed a surface a disabled deployment does not have.** `MEMORY_PROTOCOL_PROMPT` was appended unconditionally, but a `Disabled` memory binding resolves to no provider and registers no memory package, so the model sees no `ironclaw.memory.*` tools at all — the prompt was telling it persistent memory exists and to call tools absent from its surface. Composition now carries resolved memory availability into prompt assembly, gated the same way the tool-disclosure protocol is gated on the bridge tools existing. The three positional `bool`s on `DefaultSystemPromptIdentitySource::try_new` become a named `SystemPromptProtocols` struct: each one licenses the prompt to claim a capability, and a swapped pair would fail silently. Pinned by a production-caller test asserting the unbound prompt carries neither the section nor a memory tool id, with a bound arm so it cannot pass vacuously. **Two consecutive saved facts ran together.** The backend append is byte-exact and the protocol asks for one self-contained line per fact, so "likes tea" then "lives in Berlin" persisted as `likes tealives in Berlin` — and the curated lane splits `MEMORY.md` on line boundaries, so both facts reached later turns as one corrupted fact. The native service now terminates every appended entry with exactly one newline. Regression test drives two guided appends through the real service and asserts the curated lane reads back two lines. **The curated split chunked the whole document before truncating.** `MEMORY.md` is user-controlled and re-read on every run, so a large standing document allocated a `String` plus a snippet clone per ~400 bytes on the retrieve-before-run path and then discarded all but four. `split_curated_text` now takes a chunk cap and stops there; the caller passes `budget + 1`, the one extra chunk being what proves the document was longer than admitted. **`tests/CLAUDE.md` omitted the new scenario.** Added to the Memory section; group totals corrected 51 → 53 against the tree. Not applied: CodeRabbit also asked for a `coverage-floor.toml` row for the new scenario, but that file gates per-crate coverage and has no per-scenario rows. IronLoop asked to bound the curated *read* itself; that needs a byte-limited primitive on the repository/filesystem contract and is a separate change — the host-side cap above bounds the work that follows it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory): compose the always-on lane over the document-store read op The bespoke MemoryLifecycleHook::ReadCurated hook was unearned API: it duplicated a read every document-backed provider already serves as the ironclaw.memory.read tool, just under a new lane-specific contract. Replace it with a general MemoryService::read_document trait method (fail-closed `unavailable` default) that native and mem0 implement by delegating to their existing `read`. The host composes the always-on curated lane itself out of an ordinary document read of MEMORY.md, run unconditionally (gated only by the existing memory-disabled context-profile check, never by a manifest declaration) — the whole point of #7185 is that a user's standing facts do not depend on a provider opting in to a curated-specific hook. ReadCurated is removed everywhere: the enum variant, both manifests' lifecycle tokens, and the extension-contracts size-ceiling bump it required. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(memory): rename curated-lane test off retired vocabulary The reborn_memory_retired_vocabulary ratchet (landed on main) pins the literal `document_store` at zero occurrences; the curated-lane test name tripped it after the merge. Rename only — behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory): name the rewrite mode on the forget path, cover the gate at the caller Three review findings on #7365, all narrow: - `memory_protocol.md` established `append: true` as the save mode, then told the model to "rewrite the memory document" for a forget request without naming the mode. A model carrying `append: true` forward appends the correction and leaves the entry the user asked to drop, which the always-on lane then re-injects alongside it. Say `append: false`, and pin both modes in the asset test. - The persistent-memory gate is composed in `runtime.rs` from whether a provider actually resolved; the unit tests only prove the flag is honored once someone sets it. Assert at the production construction site that a runtime with a bound provider really does inject the protocol. - `tests/CLAUDE.md`: the §3 summary still counted five memory scenarios against the seven §3.4 lists, and the lifecycle row did not mention that the curated standing-document read runs ungated by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory): serve the standing document inside native's long-term lane (#7185) A fact the user states in one conversation was invisible in the next one that opened on an unrelated subject. Both proactive lanes were full-text search over the current turn's message, so recall depended on the reader happening to reuse the writer's vocabulary. The native provider now serves its standing `MEMORY.md` at the head of its own `read_long_term`, ahead of the full-text hits and independent of the query. That is the lane's stated job — "the user's general, durable memory" — and search alone cannot deliver it. Provider-internal, deliberately. An earlier revision made this a third host lane composed over a new `MemoryService::read_document` trait method; both are gone. `MemoryService` is byte-identical to main again and the host's prompt-context service is back to two lanes with no document paths in it. What the host sees is ordinary lane snippets it cannot tell from search hits, which is what keeps the whole safety envelope — scope filter, untrusted envelope, prompt denylist, per-snippet cap — applying unchanged. Moving with the retrieval, because they are the same decision: - The line-boundary splitter, the 4-snippet budget, and the truncation marker are now this provider's policy. A whole document as one wide snippet would be denylist-checked only at its head, so it is cut into chunks that each pass the host's per-snippet contract; a line carrying a denylisted secret is dropped on its own and the surrounding facts still reach the model. - The marker reserves its own room instead of pushing a chunk past its cap, so a clipped document cannot read as a complete one. - Append-mode writes terminate each entry with exactly one newline. The backend append is byte-exact, so without it two correct guided saves ("drinks tea", then "lives in Berlin") persist as one run-on line and reach later turns as one corrupted fact. - A memory-disabled context profile short-circuits before the document read, not only before the search, so a disabled profile still issues no provider read at all. mem0 is untouched: it serves no standing document, so its lane keeps its existing behavior and issues no extra read. That also takes the #7505 target-alias divergence off this path — it remains a real tool-path contract issue, but no lane code is involved in it any more. The libSQL integration scenario is unchanged and is the behavior-neutrality proof: the same user-facing property, asserted through real composition, survived the mechanism moving from the host into the provider. The lifecycle scenario returns to pure declared-hook counting, because there is no host-composed document read left to except from it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(memory): let the memory extension ship its own model guidance (#7185) Nothing ever told the model that persistent memory exists or when a stated preference is worth saving, so it usually never called `ironclaw.memory.write` at all. That guidance now ships with the memory extension instead of the loop tier. `[memory]` gains one optional field, `guidance_doc`, naming a bundled asset in the provider's own package. memory-native declares `prompts/memory-guidance.md`; composition appends whatever the BOUND provider declares and writes none of it. The text was previously `ironclaw_loop_host`'s `memory_protocol.md`, which was the wrong owner: it names concrete `ironclaw.memory.*` tools and describes one provider's recall behavior. mem0's recall is search-first and it serves no standing document, so native's "save it to `memory`, it comes back every turn" advice would be actively misleading under a mem0 binding — and there was no way to say so short of host code branching on provider identity. Declaring the doc in the manifest that already declares the tools puts the wording next to the thing it describes, and makes "ships no guidance" a first-class answer. mem0 declares none, with the reason in its manifest; absent means nothing is appended, never a fallback to another provider's wording. Consequences worth naming: - `SystemPromptProtocols` carries provider CONTENT (`Option<String>`), not a host flag, and `ironclaw_loop_host` is byte-identical to main again — the loop tier owns no memory prompt text. - Two conditions gate the append, both necessary: a provider must actually be resolved (a `Disabled` binding registers no package, so the model sees no memory tools and must not be told they exist), and that provider must declare guidance. - The ref is the existing validated `CapabilityProfileSchemaRef`, the same newtype `prompt_doc_ref` uses, so a path escaping the package fails the manifest parse. A valid ref no bundled provider claims is fail-quiet: guidance carries no authority and gates nothing, so an unknown or future declaration appends nothing rather than failing a boot. - The host resolves the ref through `ironclaw_memory_native`'s public API rather than `include_str!`-ing its asset tree. The first cut copied the inline-schema precedent and `reborn_cross_crate_include_scan` correctly rejected it — §11.2.7 is shrink-only. Exporting the ref and the text from one file also makes it impossible for the manifest and the asset to drift apart. - `ironclaw_extension_contracts`' §11.2.3 size ceiling is raised 7_892 -> 7_947 for the field, its doc, and two parse tests. Declaration vocabulary only: the text ships with the package, resolution lives in ironclaw_host_runtime, assembly stays in composition. Guidance content is unchanged from the reviewed version, including the `append: false` rewrite mode on the forget path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(memory): pin the guidance content where the guidance lives The guidance-content tests moved with the asset. Composition now asserts only what composition owns — that the bound provider's declared text is appended verbatim, survives a user-edited SYSTEM.md, is never seeded into the user's file, and is absent with no provider bound — against its own fixture rather than any real provider's wording. Asserting native's exact sentences there was testing one provider through the layer that is supposed not to know about it, and it silently became the only thing keeping the write-quality doctrine alive. The content pins themselves are re-homed in the memory-native package, where the text ships: the tool ids it names, the curated target and both write modes, the never-save carve-out, the declarative-form rule with its worked example pair, the staleness skip-list, the priority framing, and the heading + line budget it has to keep to be worth appending on every turn. Each of those is a failure the compiler cannot see. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory): keep the standing document out of its own lane's search half Two review findings on the curated prefix, both real: - A query whose words happen to match `MEMORY.md` re-admitted the standing document as a full-text hit behind the curated copy the model can already see. That spent a second snippet slot on a duplicate and displaced a different document that matched. The lane now excludes `MEMORY_PATH` from the search remainder for the same reason it excludes thread scratch: the prefix already owns it. Regression drives `max_snippets = 2` with both documents matching, so the displacement would be visible. - A single line longer than a chunk became an oversized chunk. Previously the host clipped it and stamped the marker; now that the prefix rides the ordinary lane, the host sanitizes it exactly like a search hit and truncates SILENTLY — so an over-long line reached the model shortened with nothing saying so, and the documented per-chunk limit was not actually enforced. The splitter now clips such a line at a char boundary and marks it before admission, and `clip_and_mark` is the one place that reserves room for the marker. One existing fixture moved off `MEMORY.md` onto an ordinary path: `native_context_retrieve_excludes_thread_scratch_from_long_term` used the standing document as its generic "durable doc", which after the first fix would have tested the new exclusion instead of the thread-scratch one it exists to pin. Intent and assertions unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): keep composition's measured mass honest by splitting its prompt tests `Fast deterministic checks` was red on the composition budget gate: 41868 production LOC against an effective ceiling of 41732, 136 over. The gate counts LOC and cannot parse an inline `#[cfg(test)]` module out of an otherwise-production file, so this branch's prompt-assembly tests were being counted as assembly code. Split `root/default_system_prompt.rs`'s inline test module verbatim into a `root/default_system_prompt/tests.rs` sibling, which the gate excludes — the same move #7151-era fixes used for `runtime_context.rs` and `host/run_context.rs`. No ceiling raise: composition now measures 41393, which is 302 LOC BELOW main, so the branch ratchets the crate down rather than spending budget on test code. Not a rename of the problem: the tests are unchanged and all 10 still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory): resolve guidance from the bound provider's own bundled assets Review finding on #7365: guidance_doc was declared per provider but the host resolved it through a hard-coded match against memory-native's exported constants — a non-native provider declaring guidance would silently resolve to nothing. Resolution is now generic host code over provider-supplied data: each provider crate exports its own MEMORY_GUIDANCE_ASSETS table, the shared bundle constructor resolves the declared ref against the bundled provider's own table at construction time, and BundledMemoryProvider carries the resolved content. host_runtime cannot name the mem0 crate (sanctioned-residue dependency ratchet), so mem0's bundle constructor takes the table as a parameter and composition — which legitimately depends on both providers — supplies it at the call site. Failure semantics strengthen from fail-quiet to fail-loud: a declared ref that does not resolve within the provider's own assets is a manifest/asset desync and fails bundle construction, same posture as the existing missing-[memory] check. Absent guidance_doc remains a normal no-guidance state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(ci): ratchet the composition mass ceiling down to this tree The memory-save guidance and its content pins moved out of composition into the memory-native package that owns them, and the prompt tests were split out of the production file. The gate's NUDGE fired at 283 LOC of slack, and its C11 self-test fails a ceiling that no longer binds — so lock the improvement in rather than bank it as headroom. Measured on the merged tree with scripts/ci/check-composition-budget.sh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): give the QA replay job the stack its own suite documents reborn_qa_recorded_behavior's module docs require RUST_MIN_STACK=67108864: its replay tests build a full Reborn runtime and drive whole turns, so the composed debug async frames sit within a few percent of whatever stack they get. The root-tests and group-suite jobs already set exactly this value and cite this binary's docs; the job that actually runs it never did. Measured on this suite (macOS, debug): it overflows at 1984 KiB and clears at 2048 KiB — main and this branch to the byte, so the libtest default IS the boundary and any layout shift decides it. That is the same 'unrelated layout shift' the group-suite comment already records. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): move the composition mass record with its ceiling The absolute-mass ratchet is two committed numbers that must agree: the gate manifest's loc_ceiling and the arch-test record that asserts it still binds. The previous commit lowered the ceiling for this branch's eviction but left the record at its pre-eviction value, so the record then exceeded the effective ceiling and reborn_restructure_baselines went red. Both now sit at 41533, re-measured on the merged tree with scripts/ci/check-composition-budget.sh after merging current main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(memory): refresh golden payloads for the memory.write description The memory tool's description now names when to save a durable user fact, so the two golden payloads carrying the tool surface move with it — the sentence itself, and the surface sha256 derived from those descriptions. No other bytes changed in either snapshot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ba5e07cb8b |
fix(safety): redact model-bound secrets without rejecting turns (#7509)
* fix(loop): allow security prose in recovered context * test(turns): align prompt safety contract coverage * fix(loop): address review feedback on prompt recovery (#7434) * fix(loop): reject filler-separated credentials (#7434) * fix(safety): redact model-bound secrets without rejecting turns * fix(safety): preserve non-secret sha256 fingerprints * test(safety): align channel context with gateway redaction * fix(gateway): address coderabbit review — preserve redacted JSON shape (#7434) * fix(safety): redact quoted structured credentials * fix(safety): scan encoded tool result content * fix(safety): redact structured credential values * fix(safety): redact character-dump credentials * fix(safety): close provider-bound redaction gaps (#7509) * fix(safety): close structured redaction review gaps (#7509) * fix(safety): redact nested schema and URL fragment secrets (#7509) * test(integration): align prompt trust expectation (#7509) |
||
|
|
202b97255f |
fix(automations): reject unusable scheduled output (#7530)
* fix(automations): reject unusable scheduled output * fix(ci): enable tokio sync for loop test support * fix(agent-loop): address review feedback (#7530) |
||
|
|
a3424a9d97 |
feat(agent-loop): add opt-in parallel tool batches (#7416)
* feat(agent-loop): add opt-in parallel tool batches * fix(agent-loop): address parallel batch review * fix(agent-loop): close parallel batch review gaps (#7416) * fix(agent-loop): preserve sibling exit state (#7416) * fix(agent-loop): preserve terminal batch sibling state * fix(agent-loop): drain bounded parallel terminal outcomes * fix(turn-runner): seal parallel batch configuration * style(turn-runner): format merged imports * test(agent-loop): pause parallel ordering clock * test(agent-loop): bound paused launch wait * fix(deps): update lru past unsound advisory * fix(agent-loop): preserve hooked batch semantics * fix(loop): address CodeRabbit review — fail closed batch ordering (#7416) * fix(ci): rebaseline loop contracts size ratchet (#7416) |
||
|
|
9da74f1876 |
feat(llm): add tenant-scoped model selection policy (#7428)
* feat(llm): add tenant model selection policy * fix(composition): move model policy store to operator |
||
|
|
07fe11b4ef |
fix(loop): compact context on window eviction (#7504)
* fix(loop): retain accepted task across context eviction * fix(threads): validate paged summary ranges * test(turns): pin truncation metadata propagation * fix(loop): compact context on window eviction * fix(loop): preserve steering across eviction compaction |
||
|
|
d97b35658a | chore: enable IronLoop tester (#7518) | ||
|
|
3795ed7b1d |
chore(agents): refresh codebase knowledge graph (#7519)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> |
||
|
|
307521f155 |
fix(processes): lease expiry recovers safe runs instead of failing them; isolate the journal heartbeat pool (#7471)
* fix(processes): resume runs whose lease expired at a safe checkpoint A hosted run that lost its lease died as a user-visible failure, even when it had committed nothing and was sitting idle waiting on the model. Lease recovery could only tell "has a checkpoint" from "has none", so every checkpointed run was treated as possibly-mid-side-effect and failed terminally with `lease_expired`. Record what the checkpoint actually was. `ProcessCheckpointKind` (`BeforeModel` / `BeforeSideEffect` / `BeforeBlock`) now rides on the process snapshot beside `checkpoint_ref`, because a recovery sweep reads process rows without loading checkpoint rows. Recovery requeues a run whose latest checkpoint replays no external effect, under the same bounded crash-reclaim budget; `BeforeSideEffect` and unknown kinds stay terminal, since no durable idempotency exists for a replayed capability call. The requeue waits one full lease TTL past expiry before acting. A worker starved of heartbeats and a dead worker look identical from the journal; a worker still running would have renewed its lease inside that window, so anything still expired afterwards is genuinely gone. Nothing else changes timing: cancellation and the checkpointless requeue stay immediate. Old journals deserialize with no kind, and an unrecognized kind degrades to "unknown" rather than failing the whole snapshot — both read as side-effecting, so the fail-closed path is the default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(composition): give the process journal its own Postgres pool The journal heartbeat is the liveness signal a run's lease depends on, and it was sharing one max-size-2 connection pool with every other Postgres consumer — the event store, triggers, result reads. One turn's read burst could saturate that pool and starve the run's own heartbeat until the lease expired underneath it. Two changes, both about not putting the heartbeat behind other traffic: - A Postgres deployment opens a small second pool (2 connections) and mounts the journal's filesystem over it. The mount set is byte-identical to the data plane's, so the journal addresses the same rows over a different connection — only the pool differs. libSQL and in-memory arms are untouched; libSQL is single-writer by design. - The default data-plane pool goes 2 -> 8. Two was small enough that one turn queued behind itself. Operators sizing connections should budget `pool_max_size + 2` per instance; docs, the shipped Docker config, and its smoke assertion follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(turn_runner): let a turn survive one slow store checkout Hosted turn runs heartbeat every 5 seconds, and the supervisor uses that interval as each heartbeat's timeout, so the generic budget of 3 consecutive failures tolerated about 30 seconds of stall — no more than a single Postgres connection-checkout timeout. One slow checkout was enough to abandon a healthy run. Turn runs now get 8, which clears a checkout stall while still giving up inside the 90-second lease TTL. That bound is the point: a worker that stops on its own leaves a live lease behind, whereas one still retrying past the TTL gets its run reclaimed out from under it. The budget is therefore derived from the configured heartbeat interval rather than fixed, so widening the interval shrinks the budget instead of producing an abandon window that outlives the lease. The generic `ProcessSupervisorConfig` default stays at 3 — the capability path heartbeats every 30 seconds, where 8 would run far past the TTL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): route the shipped Docker configs to the test that pins them `Detect Reborn test scope` failed closed on `docker/reborn/config.production.toml`, skipping every downstream Reborn lane. The planner's comment claimed the runtime configs have no owning lane, but `crates/app/ironclaw_cli/tests/smoke.rs` parses both `config.toml` and `config.production.toml` and pins their boot profile, storage backend, pool sizing and runtime policy — so a lane does read them, and static control would have skipped exactly the assertions such an edit can break. Add `ROOT_FIXTURE_TEST_OWNERS`, the read-at-test-time counterpart of `EMBEDDED_ASSET_OWNERS`, mapping each config to the `smoke` test target that asserts it. The two hosted-single-tenant configs stay unclassified: no test parses them, so they must keep failing closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(processes): pin the serde and grace-window edges review flagged Three review findings on the recovery path, none behavioural: - `lease_duration_millis` now converts the TTL once instead of three verbatim copies in claim, heartbeat and expiry recovery, so the bound and its rejection message cannot drift between them. - The in-grace sweep now runs strictly after expiry. Expiry is inclusive (`lease_expires_at <= now`), so the old instant did select the process and the assertion was not vacuous — but pinning the grace hold on a boundary instant made that a property of the comparison rather than of the grace window. - A persisted snapshot carrying an unrecognized `checkpoint_kind`, or none at all, now has coverage at the serde boundary: it degrades to `None` rather than failing the whole read, and `None` already recovers as side-effecting, so an older host fails closed. The retry-projection fixture also derives `kind` the way `put_loop_checkpoint` does instead of claiming `BeforeModel` in metadata while storing `None`, and now asserts the retried snapshot inherits the kind — the propagation was previously unasserted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(turn_runner): cap the heartbeat interval the lease cannot afford Both reviewers found the same hole. `heartbeat_failure_budget_within_lease` computed how many failures the lease TTL could pay for and then clamped the result up to 1 — but at an interval past half the TTL, even one failure costs more than the lease. An operator setting `heartbeat_interval_secs = 120` against the 90s default got a budget of 1 whose abandon window was 240s: the journal would declare the lease expired while the worker was still waiting on its first heartbeat, which is exactly the "worker is provably gone" premise recovery relies on. Cap the interval at half the TTL instead of clamping the budget, so `budget >= 1` is honest for every configurable value and the scheduler errs toward heartbeating more often than asked. The explicit budget setter is capped by the same lease-derived ceiling — it was the other way to construct a config past the TTL. The test's `|| budget == 1` escape hatch was the hole itself; it is gone, and the 120s case plus a 10x-TTL case are now asserted for real. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(composition): prove the journal pool reaches the data plane's rows The only coverage of the journal's separate Postgres pool built two independent `InMemoryBackend`s and wrote through each, which cannot prove the two pool-backed handles reach the same rows — a wrong connection config or a shared-pool fallback passed it unchanged. Drive `postgres_from_config_and_env` (the only public constructor that resolves a connection config, and so the only one that opens the second pool at all), submit a turn through the turn coordinator so the journal writes its process row over its own pool, and read that row back over a connection neither build pool owns. Docker-gated through the existing `postgres_pool_or_skip` harness; the in-memory test stays as the cheap mount-set parity check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(contracts): describe the expired-lease machine that ships Both contracts said an expired lease transitions to `RecoveryRequired`. Recovery converges directly to a settled state instead: cancellation to `Cancelled`; no checkpoint or a replay-safe one (`BeforeModel`, `BeforeBlock`) back to `Queued`, the checkpointed case only after a full lease TTL of grace; a side-effecting or unrecognized checkpoint, or an exhausted reclaim budget, to `Failed`. Code is the deliberately-reviewed behavior here, so the docs follow it. `turn-runner.md` §3 carries the full transition table and notes that the legacy `RecoveryRequired` status still exists in the vocabulary but is no longer produced by expiry; `turn-persistence.md` §6 gets the summary and points at it, so the two cannot drift into two half-descriptions again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(planner): classify the shipped docker/reborn configs by their parsing test `Detect Reborn test scope` failed closed on `docker/reborn/config.production.toml` (`unclassified pull-request path`), cascading into the whole `Tests (Reborn)` roll-up on #7471. The planner's comment asserted these configs have no owning lane. They do: `crates/app/ironclaw_cli/tests/smoke.rs` parses both `config.toml` and `config.production.toml` through `RebornConfigFile::parse_text` and asserts on the profile, storage backend and policy. So they are not static control (whose membership rule is "no Reborn test lane reads the file") and not prose — either would silently under-select the one lane that catches a broken production config. `DOCKER_RUNTIME_CONFIG_OWNERS` routes each to that test target instead. The two `config.hosted-single-tenant*.toml` siblings stay fail-closed: their reader is `tests/dockerfile_runtime_home.rs`, which `_root_test_partitions()` does not inventory, so no lane can be selected for them. Verified against #7471's real diff: the planner exited 1 before and exits 0 after, naming the owner in its reasons; all 75 planner self-tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(processes): fence stale executors on lease reclaim; address review comments - supervisor: never start a replacement executor while the reclaimed process's prior executor is still running; a definitive lease-lost heartbeat (InvalidLease/InvalidTransition) now cancels the stale task at the next tick instead of waiting out the failure budget - turn scheduler: clamp heartbeat intervals past half the lease TTL (budget-of-one no longer masks an unaffordable interval); CLI config rejects them with a clear error - extract lease_duration_millis helper shared by claim/heartbeat/recovery - tests: fence regression (no executor overlap), unknown checkpoint-kind wire degradation, in-grace sweep strictly past expiry, retry kind propagation, stale-worker reply assertions, Postgres pool isolation, planner owner pin, smoke test for the interval bound - docs: turn-runner expired-lease contract aligned with the state machine * chore(deps): bump lru 0.18.1 -> 0.18.2 (RUSTSEC-2026-0253) cargo-deny fails fast-checks on the lru 0.18.1 panic-safety advisory (use-after-free in LruCache::pop, patched in 0.18.2, issued 2026-08-11). All four dependents (composition, webui, extension_host, hooks) already require `lru = "0.18"`, so this is a lock-only patch bump. * fix(loop): lease-fence transcript writes so a reclaimed worker cannot ghost-reply Lease recovery requeuing a safe checkpoint opened a window the journal alone cannot close: run transitions are lease-fenced (ensure_lease), but transcript writes were not, so a worker whose lease recovery already reclaimed — starved of heartbeats while blocked in a model call, or suspended past the grace window — could wake and append a second assistant answer beside the replacement worker's. Time-based fencing (abandon window + one TTL of grace) bounds only a worker whose runtime is live to observe its heartbeat failures; it can never be total. The other half of the guarantee: ThreadBackedLoopTranscriptPort now carries the lease the run was claimed under and asks the journal — the only authority on ownership — before every transcript write (draft begin/update, finalize, capability-result append). A stale or unverifiable lease refuses the write as an explicit transcript-write failure; the zombie's loop exit then fails through its own lease-fenced claim, so nothing it produces can land on the run the replacement completed. The turn runner's host factory installs the fence for every claimed run. The recovery branch comment in the process journal now states the two-part guarantee honestly instead of over-claiming that the old executor has "provably given up". Regression coverage: the lease_wedge integration test releases the stale worker after the recovered run completes and asserts its output never reaches the transcript; a seam test pins that the production host build installs the fence at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(processes): drop duplicated lease_duration_millis from the merge Both reconciled lines extracted the same helper; the merge kept both copies and E0592'd. One definition remains, with the fuller doc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(runtime): address lease recovery review feedback (#7471) * ci: reseed composition budget for merged tree (#7471) * docs(turns): clarify legacy recovery lock release (#7471) * fix(architecture-tests): move the absolute-mass record with the reseeded ceiling The 2026-08-11 budget reseed (41582 -> 41810 for #7471's merged tree) updated the manifest ceiling but not the paired record constant this ratchet compares it against, leaving 228 LOC of apparent headroom — past the 200-LOC nudge window, so the merge-queue run failed reborn_restructure_baseline_ratchets_stay_armed. Re-measured on this tree: 41731 (`check-composition-budget.sh`). The 41810 ceiling was seeded from the merge-queue commit, where concurrent mainline growth adds ~79 LOC on top of this branch — inside the nudge window of the corrected record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c1491c3013 |
fix(loop): retain accepted task across context eviction (#7503)
* fix(loop): retain accepted task across context eviction * fix(threads): validate paged summary ranges * test(turns): pin truncation metadata propagation |
||
|
|
aa86b50b49 |
feat(llm): explicit Anthropic cache_control breakpoints on both transports (#6997)
* feat(llm): explicit Anthropic cache_control breakpoints on both transports Closes #6984 (P0 of the pi-harness adoption program, docs/research/ pi-agent-deep-dive.md §7.3). The rig transport previously relied solely on Anthropic automatic caching via a top-level cache_control field, and the OAuth transport emitted no cache markers at all. Now both place explicit breakpoints so the tool/system prefix and the growing conversation cache independently: - OAuth transport: apply_cache_breakpoints marks the system prompt block, the last tool definition, and the last content block of the last message, all carrying the retention TTL. Retention None keeps the legacy wire shape (plain-string system, no markers). - rig transport: build_rig_request marks the last tool by moving it into rig's raw additional_params.tools (appended after typed tools, order preserved, Anthropic-native input_schema shape) and keeps the top-level automatic marker; Short retention additionally enables rig's typed system/last-message breakpoints. Long must not enable the typed breakpoints: rig markers cannot carry a TTL and a 5m block marker beside a 1h automatic marker is an API error. All markers in a request share one TTL, satisfying Anthropic's longer-TTL-first ordering rule. Unsupported models downgrade to None via supports_prompt_cache on both paths. Wire shape is pinned by loopback capture-server tests in both files (three per transport: short, long, none), plus build_rig_request seam tests for the tool move. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(llm): close changed-line coverage gaps on cache breakpoints The Reborn integration-tier changed-coverage gate flagged uncovered branches in #6997: the unsupported-model retention downgrade on both transports, the Image/ToolUse marker arms, the empty-text guard, and the create_anthropic_from_registry wiring. - Extract the duplicated downgrade logic into rig_adapter::effective_cache_retention, shared by lib.rs and the OAuth constructor, with a direct unit test over all branches. - Wire test: an unsupported model (claude-2.1) with Short retention keeps the legacy no-caching shape end-to-end. - Direct apply_cache_breakpoints tests: tool_use tail without system/tools, image tail, empty-text tail, empty transcript. - Construction test driving create_anthropic_from_registry across all retention modes including the downgrade path. - Move the OAuth transport test suite to src/anthropic_oauth/tests.rs (same idiom as rig_adapter/tests/) to stay inside the file-size budget, with the matching coverage exemption entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(llm): cover remaining cache-breakpoint branch arms The changed-coverage gate flagged three remnants: the Text arm of set_cache_control, the empty-blocks tail, and the non-Text side of the system take-and-rebuild — which was also a latent drop: a system value already in block form was taken and never restored. Restore it untouched and pin all three paths with direct tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(llm): cover the empty-tools arm of apply_cache_breakpoints The last uncovered branch on the changed-coverage gate: Some(tools) with an empty vec (only constructible directly — complete_with_tools maps empty to None). Extend the empty-cases test to pin the no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: serrrfirat <f@nuff.tech> |
||
|
|
d83495889f |
fix(webui): reveal long conversation titles on hover (#7480)
* fix(webui): reveal long conversation titles on hover * test(webui): restore marquee resize observer * fix(webui): scope markdown CSS regression check |
||
|
|
1b559f296e |
fix(webui): bound SSE reconnect storms (#7284)
* fix(webui): bound SSE reconnect storms * chore(webui): restore chat bundle headroom * fix(webui): route mid-stream SSE failures through the reconnect coordinator (#7284) event-source-plus 0.1.x retries mid-stream body/network failures on its own 2ms clock: after a 200 handshake, a body error bypasses onRequestError/onResponseError and reopens via the package's internal retry path, exhausting the 30/minute stream budget before a 429 is ever handled. Disable that path (maxRetryCount: 0) so it surfaces as one 'error' abort event, and route it through the same jittered coordinator backoff as every other reconnect source. Regression tests cover the coordinator routing and the packaged client's behavior on an errored body after a successful handshake. * fix(webui): restore CLAUDE.md alias lost in the main merge (#7284) The guidance unification on main turned the crate CLAUDE.md into an AGENTS.md plus a CLAUDE.md -> AGENTS.md symlink alias. Resolving the merge conflict by deleting both conflicted copies dropped the alias; the guidance gate requires it for Claude Code auto-injection. * fix(webui): address coderabbit review — reset quiet stream backoff (#7284) * fix(deps): update lru for RUSTSEC-2026-0253 |
||
|
|
766ac92bfb |
fix(disclosure): parallelize read-only bridge lookups (#7500)
* fix(disclosure): parallelize read-only bridge lookups * fix(disclosure): execute parallel bridge batches |
||
|
|
2d6ec0a2f4 |
fix(triggers): add unattended scheduled-run protocol (#7497)
* fix(triggers): add unattended scheduled-run prompt * fix(deps): update lru past RUSTSEC-2026-0253 |
||
|
|
5ce21472bd |
fix(loop-host): unify token estimation (#7502)
* fix(loop-host): unify token estimation * chore(deps): update lru to 0.18.2 |
||
|
|
499394df4a |
fix(llm): prefer the authenticated NEAR AI session for default probes (#7492)
* fix(llm): reuse NEAR AI session for model probes * fix(llm): prefer runtime session for default probes * fix(llm): allow public NEAR AI model discovery * fix(llm): scope NEAR AI model auth by endpoint |
||
|
|
2b87cf53df |
Install the packages the catalog already publishes (#7442)
* Install the packages the catalog already publishes Skills publish a files list for the scripts and assets they ship, but the catalog entry never deserialized it and the install path passed an empty bundle, so only SKILL.md landed. Files now install alongside it, digest-verified through the same download path and bounded by the limits ironclaw_skills already enforces, and they feed the skill artifact digest while a skill with no files keeps the digest it has today. Tools using HTTP Basic could not publish an extension manifest, so they listed and failed at install; the new basic target carries only the username and the host owns the join and the base64, with a colon or control character rejected at the host boundary, at the channel descriptor, and again at injection. * fix(ironhub): address package install review findings (#7076) * fix(ironhub): address review round — header-collision rejection, constant-derived caps, egress contract tests (#7076) * refactor(skills): drop unused validate_install_bundle_relative_path wrapper (#7076) * fix(runtime): harden derived credential redaction (#7076) * fix(skills): reject bundle path collisions at domain boundary --------- Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com> |
||
|
|
81045020dc | test(memory): cover bounded search edge cases (#7494) | ||
|
|
2938f24e07 |
fix(memory): bound native search result snippets (#7436)
* fix(memory): bound native search result snippets * fix(memory): preserve bounded exact-match excerpts * fix(memory): bound conventional search output at shared boundary * docs(memory): add Mintlify validation to output-bounding plans (#7436) |
||
|
|
ce67ddaff6 | test(ci): restore main coverage gates (#7493) | ||
|
|
6f1ae709d5 |
feat(tool-search): complete fair discovery and benchmark arms (#7410)
* test(tool-search): add large-catalog baseline * feat(tool-search): return and use bounded signatures * feat(tool-search): add fair discovery benchmark arms * test(tool-discovery): add live benchmark harness * feat(tool-discovery): default to namespace summaries * ci(tool-discovery): classify benchmark harness * feat(tool-discovery): use semantic namespaces * fix(tool-search): harden discovery benchmark and mode wiring * docs(tool-search): record corrected benchmark verdict * fix(tool-search): address review feedback |