feat(session): recover warm-pool hits for effort-override sessions via post-claim effort push - #7096
Conversation
1bce326 to
381c5b8
Compare
381c5b8 to
1175b83
Compare
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of I have everything I need. Verified against the base tree: First-Principles-Verdict: CONCERNS The fix is real and lands at cause level, but two of three new ABC methods have zero consumers, the crew-pin sibling stays unfixed, and the description claims a What this change shipsIntent: let sessions with a reasoning-effort override start instantly from the warm pool instead of cold-starting (fixes #6248) — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 46623c0 |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of I have everything I need. The change is sound in shape — post-claim Design-Verdict: CONCERNS Sound latency fix, but delivery of an explicitly requested effort degrades from guaranteed (cold start) to best-effort with a log-only warning. Watch
Suggestions
[DESIGN-REVIEWED] 46623c0 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
6ef942d to
8b88bd2
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Description / code mismatch
The Description presents the post-claim effort push as a call on "the claimed provider (via provider.change_effort)", but provider is typed as the base LLMProvider, which does not declare change_effort — so the repo's blocking type-check gate rejects the call.
1. change_effort is called through the LLMProvider ABC, which does not declare it
The Description says —
Approach: Apply reasoning effort overrides post-claim on the claimed provider (via
provider.change_effort), which pushes/effortto the running ACP backend and updates thecli.jsonoverlay for respawn resilience, matching the authority and validation logic inprovider_factory.
The code does — the new block awaits provider.change_effort(...) on a handle statically typed as the LLMProvider ABC, which declares no change_effort, at src/kiro_crew/session_allocation.py:1232. The enclosing self._deps.is_acp_provider(provider) branch performs no narrowing, because that dependency is typed Callable[[LLMProvider], bool] (src/kiro_crew/session_allocation.py:73) rather than a TypeGuard[AcpProvider] — which is exactly why every adjacent ACP-only reach in the same block goes through an explicit cast: await cast(Any, provider).client.set_model(switch_model) at src/kiro_crew/session_allocation.py:1223, and the getattr(cast(Any, provider).client, "_model", None) peeks at src/kiro_crew/session_allocation.py:1233-1235 and 1244-1246. The new call is the one that does not. Backend Lint & Type Check (3.10) is red on this head and the PR is marked readiness: action required.
Risk — the change cannot merge as written: the blocking mypy src/kiro_crew gate fails. Structurally the call also reaches an ACP-only capability through a base-class handle, so if a non-ACP LLMProvider ever reaches this branch the failure is an AttributeError swallowed by the new except Exception at src/kiro_crew/session_allocation.py:1249-1256 and surfaced as "failed to apply reasoning effort" — a missing capability disguised as a transient /effort push failure.
Required change — either match the adjacent model-switch call (await cast(Any, provider).change_effort(_eff)), or, per AGENTS.md § Harness parity ("A new provider capability lands on the LLMProvider ABC with a safe default, never as a hasattr probe on the Kiro path"), declare change_effort on the LLMProvider ABC with a safe default of return False so the capability is registered at the seam rather than assumed. Then re-run mypy src/kiro_crew and confirm Backend Lint & Type Check goes green. If instead the intent is that this call is deliberately ACP-narrowed, the Description should say so rather than describing it as a call on "the claimed provider".
Audit note — part of this has already landed; the rest has notThis PR is not a duplicate and is not finished by anything on Already landed#7576 CONFIRMED MERGED (the issue/PR reference check: PR, closed, merged_at 2026-09-01T21:26:28Z; the landed-commit index for Which parts
|
b8d964c to
0df304b
Compare
|
Thank you for the detailed audit — this caught a real gap. What the audit found: PR #7576 (merged 2026-09-01) added a second What we fixed in
All 180 effort + pool + metrics tests pass locally. On the already-landed parts: The |
0df304b to
7443e38
Compare
|
Update: identified and resolved the test failure on Why it failed: assert 'pool_decision = "bypass_effort"' in srcWhen we renamed the Resolution:
|
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Apply reasoning effort overrides post-claim on warm-pool processes via provider.change_effort, keeping the warm-pool hit path active for slots carrying reasoning effort overrides. Fixes kirodotdev#6248
7443e38 to
46623c0
Compare
|
Thanks for the relationship audit note! Confirming that this PR (#7096) and PR #8257 are complementary:
I have rebased onto latest |
agent_skill_globs re-read and re-security-validated every agent spec
(77 files, ~115 ms) on every call, and it sits on the session-context
build path — paid at every session start, subagent spawn, and
full-context cron/monitor wake, plus the dashboard prompts listing.
Cache the result per (agents_dir, agent) against the same stat-only
directory signature the list_agents cache already uses, caching []
misses too; clear_list_agents_cache() drops both caches. Measured on a
77-agent dir: 115 ms -> 0.5 ms per call; warm build_session_context
87 ms -> ~14 ms.
fix(bench): warm real embedder before KB evaluation (#9295)
Wait for the configured in-process embedding backend in one-shot KB benchmark runs instead of refusing during a cold model load. Preserve non-blocking availability for normal Knowledge traffic and cover blocking and fallback backend contracts.
fix(acp): derive fallback spellings when the model config-option push is rejected (#9281)
The claude adapter advertises model variants under spellings its 'model'
config option does not accept (prefixed provider ids with a [1m] window
marker), and the fold in resolve_wire_model_id only works against a warm
advertised-model cache. On a cold cache the raw id reached the wire, the
adapter rejected it, and the session reset onto the default with no
explanation.
Derive fallback candidates from the id itself at both config-option wire
sites and let the adapter judge each: verbatim, then prefix-stripped, then
window-stripped. An explicit switch (set_model) raises the typed
AcpModelUnavailable instead of a generic AcpError when every spelling is
refused; a startup application of an inherited value keeps the session on
the backend default, mirroring the withhold contract and the effort
push's value-rejection ladder.
fix(usage): surface refused-transcript count so UNC home stops reading zero (#9254)
On a Windows roaming-profile (UNC) home the usage page counted zero sessions
silently: every transcript is refused by validate_file_path and the count was
only logged server-side. Carry refused_transcripts in the /api/usage/kiro
payload and render a warning banner on the usage page so the zero is shown as
incomplete rather than as a fact. Does not admit the sessions dir to the UNC
gate (that trust decision is open issue #8079); the refusal itself is unchanged.
Closes #6733
fix(dashboard): mark inherited default agent on composer chips (#8770) (#9253)
fix(mcp): expand header value references in the remote probe (#9249)
* fix(mcp): expand header value references in the remote probe
The dashboard's remote MCP probe sent configured headers verbatim, so a
static auth header whose value carries a documented ${VAR}/${env:VAR}
runtime reference reached the server as literal text and was correctly
rejected. Because _needs_authorization treats any Authorization key as a
supplied credential, the 401 rendered the row Error / HTTP 401 with a
hint advising the user to delete a header that authenticates fine in
every session.
The probe now resolves header value references through the gateway
rewriter's existing declared-env expander (same regex, same
credential-filtered source view, unresolved stays literal for kiro-cli
parity), uses the expanded map for the request and for
_needs_authorization's sent-headers view, and threads it into both
redact_mcp_error call sites so an error echoing a resolved secret is
scrubbed by the exact-value layer. An Authorization value still carrying
an unresolved reference supplied nothing, so it no longer suppresses
needs_auth.
Fixes #9206
* fix(mcp): scrub individually resolved placeholder values in probe errors
A partially expanded header value (${TOKEN}${MISSING}) sends
'<resolved>${MISSING}', so neither the full sent value nor the
Authorization suffix in redact_mcp_error's scrub set matches a server
echoing only the resolved fragment. _expand_header_placeholders now also
returns each individually resolved placeholder value, and both redaction
call sites pass them as a new extra_values leg, folded in under the same
credential length regimes (below the minimum length a value is skipped,
since no boundary rule separates it from prose words).
Adopts the GPT 5.6 review lane's blocking finding on
mcp_discovery.py:680.
---------
Co-authored-by: dwu96 <dwu96@amazon.com>
perf(dev-fleet): skip npm ci and rebuild on a backend-only sync (#9227)
A non-edition Pull + Build appended npm ci and npm build + stage
unconditionally, so a sync that moves only Python still deleted and
reinstalled website/node_modules from an unchanged lockfile and re-ran a
full vite build reproducing a byte-identical bundle.
The skip decision is made post-fetch by the preflight step -- the only
window where the incoming ref is pinned and the worktree has not yet been
merged to it. The preflight signals the verdict by exiting a reserved code
(EXIT_FRONTEND_SKIP) that the runner trusts ONLY from the preflight step's
label; the runner then suppresses the npm ci and build+stage steps, holding
the verdict in its own state. A worktree-run step (a pip lifecycle script)
exiting the same code is demoted to a plain failure, so it cannot forge a
skip and ship stale assets. Skipping a step also skips its node_modules
transaction, which must not run for a no-op or it would delete the tree.
feat(chat): Formatted/Raw toggle on markdown content cards (#9224)
Adds a Formatted | Raw segmented toggle to markdown content cards in the chat transcript, matching the control tool detail cards carry. Formatted renders through the transcript's markdown pipeline; Raw is the verbatim source with the edit affordance. Formatted is the default, overridable via a new Chat setting. Closes #9196.
fix(dashboard): swipe navigation for multi-image viewer (#9190)
The image viewer opens with a SET -- dispatchLightbox() collects every
img[data-lightbox-image] in the nearest [data-image-scope] -- but the only way
through it was ArrowLeft/ArrowRight. On a phone there is no keyboard, so every
image after the first was unreachable and nothing on screen said the set existed.
A horizontal drag now pages through the set. The one-finger overlay gesture locks
an AXIS once it crosses the slop, then either pulls down to dismiss (unchanged) or
sideways to page, so a diagonal drag can no longer do both. Four product decisions,
each matching an existing decision in the same component rather than inventing one:
* Commit on DISTANCE only, like the dismiss path, and at a shorter threshold (64px
vs 96px): paging is reversible, a dismiss destroys the viewing context, so paging
can commit on less travel.
* Rubber-band at index 0 and at the last image, reusing the divisor the upward
dismiss drag already used -- a silent no-op at the ends reads as a broken gesture.
* Show position in the set ("2 of 4") on an aria-live pill. Without an affordance
the gesture is invisible, and this is also the first thing to announce the image
change to a screen reader.
* Own the gesture only at fit zoom, exactly as dismiss does; above fit a horizontal
drag is already pan, handled on the <img>.
Owning the horizontal axis here is safe because the app-wide nav drawer yields to
any element whose computed touch-action is none, which the overlay already set to
take page zoom. The keyboard path, the pinch, the double-tap and the desktop
click-to-close are untouched.
Closes #5799
fix(security): never waive exfil redaction for model-authored URLs (#7820) (#9189)
A GitHub issue-prefill link is redacted in chat as
`[REDACTED: suspicious URL to github.com]`, which #7820 reports as a false
positive. Two earlier rounds of this change waived the aggregate query-LENGTH
signal for that shape -- first on the validated shape alone, then additionally
pinned to this project's own tracker. Both are withdrawn. Neither was safe, and
the difference between them was only who reads the payload.
What reaches `redact_exfiltration_urls` is MODEL-AUTHORED text:
injected content steers the model into emitting a prefill URL whose `body`
carries percent-encoded private context; the waiver skips the length check;
the link renders as the familiar "file an issue" affordance; the user submits
it; and the issue is PUBLIC, so the attacker reads it.
Pinning the repository does not close that -- this project's tracker is
world-readable by design. A URL's shape says nothing about who authored it, and
a marker placed IN the text travels in the channel the injection controls. So no
validation performed on a model-emitted URL can establish provenance, and that
is a property of the class rather than of any one spelling of the check.
- Remove the frontend waiver: `isPrefilledIssueUrl` and the `EXFIL_ISSUE_*`
constants in `website/src/utils/sanitize.ts` (they landed for #7824). The
aggregate-length signal there is unconditional again. This is the production
change in this commit.
- The BACKEND needed no deletion after rebasing onto #9183, which split
`security.py` into a package: `security/exfil.py` was re-derived from a base
that never carried the waiver, so its length gate is already unconditional.
What lands there is the reason it must stay that way, recorded at the gate so
the next person with a long legitimate URL narrows the heuristic for every
host instead of carving out a shape.
- Deliver #7820's feature through the TRUSTED STRUCTURED CHANNEL the product
already ships, rather than through the redactor. `diagnostics._issue_url`
assembles the prefill query from structured fields (version, channel,
what-happened, context, install) with `urlencode`; it travels as
`BundleResult.github_issue_url` on a JSON response no redactor scans; and
`ReportProblemModal` / `ReportProblemCard` render their own
`<a href={result.github_issue_url}>`. Nothing on that path is text a model
wrote, so nothing on it needs a waiver. `diagnostics.py` already documented
this split, with `terminal_issue_url` as the bounded variant for paths that DO
get relayed through prose.
- Repoint the parity guard from pinning a carve-out to pinning its ABSENCE on
both halves, reading each enforcing source at run time. The backend sweep runs
over the whole `security/` package rather than one module, because a waiver
reintroduced in any of the nine modules or in the facade would be just as live
-- the single-file path this replaces was left pointing at a deleted file by
#9183, which is exactly the failure mode. The length comparison is asserted at
its own site on each side, so a waiver spelled with fresh identifiers still
fails, and the structured seam is asserted present because deleting the waiver
is only free while that seam exists.
Red-first against the pre-fix head: a prefill link to this project's own tracker
with an encoded payload rendered as a live link.
The withdrawn allow-list held GitHub's classic prefill keys, while the trusted
builder uses issue-FORM keys, which that list never contained -- so the waiver
only ever served links the model typed, the exfiltration surface and nothing
else.
Refs, not Closes: #7820 asks for a narrower heuristic and also reports
`monitorportal.amazon.com`. Both are still redacted in chat, and a test pins
them as getting the same verdict. Narrowing aggregate query length is a per-host
security-ceiling decision on its own merits, and a per-shape escape hatch is not
a substitute for it.
Refs #7820
feat(connections): default-on launch with explicit card set (#9149)
The Connections gallery was merged on main but held behind an opt-in
`connections_ui: true`, so a shipped build reached it only after a
hand-edit of the instance's config.json. Flip that default: the gallery
is now on for every install that never touched the flag, while
`connections_ui: false` remains the escape hatch that empties the
Services panel and puts chat back as the only authorize prompt.
The key discovery is that holding GitHub back cost NO new mechanism.
A per-provider launch gate already exists on both sides of the seam --
`launch_gate_passed` in src/kiro_crew/connections/registry.json, filtered
by `get_visible_providers()` in registry.py and by `CONNECTION_PROVIDERS`
in website/src/pages/connections/registry.ts -- and GitHub is already
`launch_gate_passed: false` there because its OAuth-app registration
under the kirodotdev org is outstanding. So the launch set is the six
providers those filters already produce (Notion, Linear, Atlassian,
Stripe, Vercel, GitLab), and the flip adds zero production lines for
per-provider gating. `test_only_gated_launch_services_are_visible`
already pins GitHub's exclusion on the backend; this change adds the
matching assertion on the frontend's exported list.
The predicate keeps the old "never guess from a sloppy value" rule and
points it at the safe direction, which is now off. An absent key is ON,
an exact `true` is ON, and everything else present -- including a
hand-edited `"false"` -- is OFF, so an opt-out cannot silently fail to
take. A config that has not been read yet (undefined, and a failed
fetch) stays OFF: the opt-out lives in that config, so opening before
reading it would flash a gallery the user disabled and fire its status
queries on their behalf. That preserves today's loading behavior exactly
rather than introducing a new transient.
KNOWN DEPENDENCY, not fixed here: on a live gateway the flag never
reaches the browser at all, because GET /api/config/kirocrew strips
unknown top-level keys (`_masked_config_dict` pops `_extra_sections`).
The default-on half of this change works regardless -- it is the
absent-key path. The `connections_ui: false` escape hatch only becomes
functional once fix/connections-ui-flag-roundtrip makes the key
schema-known. Landing this before that fix therefore ships a gallery
with no working opt-out, which is why the flip is merge-gated on a human
re-test.
Prose that asserted the feature was unreleased is updated in the same
pass so the code no longer contradicts itself: the hook docstring,
CapabilitiesPage's flag docstring, ConnectionsPage's `servicesEnabled`
docstring and its empty-list comment, and McpTab's mint-engine comment.
ConnectionsPage's parameter default stays `false` so a caller that omits
the prop cannot open a gallery by omission.
Tests: ConnectionsUiGate.test.ts is rewritten for the new default
(absent key opens, explicit false closes, unread config closes, sloppy
values close) and gains a launch-set block asserting GitHub is absent
and every offered provider passed its gate. McpTab.test.tsx re-points
its baseline mock at the shipped default, converts the chat-prose
fallback case to an explicit `connections_ui: false`, and adds a case
proving the in-place sign-in reaches a managed row with no flag key at
all. No user-facing strings were added, so no i18n catalog changes.
test(issue-radar): pin auto-nudge crew turns resolve their own identity (#9071)
An Issue Radar crew driven by an auto-nudge cycle reaches its ledger
tools through the same _run_chat entry a human turn uses, which resolves
identity as effective_session_key(slot) and hands it to the one shared
publish_turn_identity writer. So a nudge turn and a direct turn on the
same slot resolve the SAME session identity, and the strict gate lets
the crew through -- the property #5905 assumed was broken.
This is the positive twin of the existing subagent refusal test: a
subagent resolves to its PARENT slot and is refused; an auto-nudge crew
is the same principal re-entered on a timer and is admitted. Nothing
pinned that the nudge fire path keeps routing through the shared writer,
so a future refactor of _fire_dashboard_nudge could silently reintroduce
the confusing refusal. These regression tests assert the property (same
identity, one shared writer), not the call ordering.
Also prunes the file from the black shrinking-baseline now that it is
black-clean.
fix(portability): keep the export archive inside the crew directory (#9070)
* fix(portability): keep the export archive inside the crew directory
`create_export_zip` walks `workspace`, `plan_memory` and `skills` with
`rglob("*")` and skips an entry that `is_symlink()`, so that an export never
carries bytes from outside the crew directory. That skip is blind to the escape
that costs the most: `rglob` DESCENDS a directory link, and the file on the far
side is an ordinary one — `is_symlink()` false — so it is written straight into
the archive. On Windows the link is typically a junction, which `is_symlink`
does not report at all, and a junction needs none of the privilege a directory
symlink needs there.
The two filters below the skip do not catch it either. `_is_excluded` and
`is_sensitive_path` both read the LEXICAL path, which runs through the link's
own name and therefore looks like ordinary crew content — so a sensitive file
behind a link passes the sensitivity check that exists to stop exactly this.
Measured on unpatched main with a real junction: `workspace/memory/linked/
not-ours.md` appeared in the export's namelist, sourced from outside the crew
directory. The consequence class is content the user then hands to someone else,
since an export archive is made to be moved off the host.
Fixed by requiring the RESOLVED path to stay under the resolved crew directory —
the repo's existing idiom (`apps/backend.py:651`). Resolving is what covers both
shapes at once: it follows every reparse point on the way down and answers where
the bytes actually live. `mc` is resolved once so a legitimately linked
`$KIROCREW_HOME` does not reject the whole export. The `is_symlink()` skip is
kept as-is; this adds a layer rather than replacing one.
The import side of this module is untouched.
`test_export_skips_symlinks` cannot cover this — it plants a link that IS the
entry, and it `pytest.skip`s where symlinks cannot be created, i.e. on the
platform where the junction spelling lives. The new tests use
`conftest.make_dir_link` and need no privilege.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(portability): archive the descriptor that was validated
`create_export_zip` walked `workspace/`, `plan_memory/` and `skills/` with
`rglob("*")`, which DESCENDS a directory link while the file on the far side
is an ordinary one, so the walk's own `is_symlink()` skip never fired for it.
Neither by-name filter below that skip is a containment test either:
`_is_excluded` is a rule about the archive name, and `is_sensitive_path` asks
whether a path is a PROTECTED location -- it does resolve links, so a linked
`~/.aws` was caught -- never whether it is inside the crew directory. An
ordinary file of the user's, reached through a link dropped in the workspace,
was sensitive to none of them and went into an archive the user hands on.
A resolve-and-compare would answer that for an instant only: `ZipFile.write`
takes a NAME and opens it again, so the file that was checked and the file
that is read are two separate lookups, and a running gateway gives agent
tools write access to that workspace while an export can be triggered.
`_open_verified` inverts the order -- open first, then ask the kernel where
the open thing is via `pinned_fs.fd_real_path` -- and `_add_from_fd` streams
the entry from that descriptor, so check and use address one object. A
hardlink alias, which no path-based guard can see, is refused on the
descriptor's link count; `force_zip64=True` keeps a source over `ZIP64_LIMIT`
archiving as `ZipFile.write` used to.
That settles what may be ARCHIVED. It does not settle what may be TOUCHED,
and on Windows those are different questions: resolving a path whose
component is a link to a UNC share IS an outbound SMB authentication, so a
refusal computed afterwards has already paid the cost it exists to prevent.
No pathname check fixes it -- every by-name check is a check-to-open window
and an adversary that can plant the link chooses when. The names have to be
held rather than inspected.
The enumeration was the last place that still inspected them. Pinning the
candidate's ancestors closed the swap race, but `rglob` had already descended
a PRE-PLANTED junction, and `is_file()` and `is_sensitive_path()` had already
resolved what it yielded, before any of it ran. Measured on this module,
exporting a workspace holding one planted junction: 14 resolving calls went
out through it -- `os.path.realpath` and `ntpath._getfinalpathname` -- while
the export correctly archived nothing from behind it. Nothing being archived
was never the question.
So the walk descends and verifies in one motion instead. `_walk_pinned` pins
a directory with `platform_compat.pin_directory` before listing it -- the
handle omits `FILE_SHARE_DELETE`, so a held directory can be neither renamed
nor deleted, nor can anything above it, and the open refuses to follow a
reparse point, so a junction at the name fails there instead of being
traversed -- then classifies each child from the listing Windows already
returned, where the attributes and reparse tag arrive inline and cost no
lookup THROUGH the child. A child directory is descended only by pinning it
in turn, so the path the kernel walks to reach any component runs entirely
through components already opened and verified, starting at the crew root
itself. This is the pattern `aws_control/backend/storage.py` already uses to
hold the path a sandboxed CLI writes through. The leaf goes through the new
`platform_compat.open_file_no_reparse`, which opens a reparse point AS ITSELF
rather than following it, so the refusal and the open are one operation;
POSIX keeps the `O_NOFOLLOW` open it already had, named.
`_pin_ancestors` and `_open_inside` are gone rather than kept alongside this:
the walk holds the same chain, for the whole subtree instead of re-walking it
per file, so keeping them would be two mechanisms doing one job.
The walk starts at the RESOLVED root, since `$KIROCREW_HOME` may itself be a
link and `pin_directory` refuses a reparse point at a name -- pinning the
configured spelling would return an empty export on such a host.
The exported set is unchanged: on a tree covering nesting, `EXCLUDE_DIRS`,
`EXPORT_EXCLUDE`, `.pid` and `skills/auto`, the archive is byte-identical to
the one the previous walk produced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
feat(knowledge): scope local_knowledge_search to a namespace (#9032)
The knowledge store already models a namespace label on items (with an index), and the dashboard already lists, assigns and browse-filters by it. Search was the one verb the label never reached: HybridRetriever and local_knowledge_search scoped only by source_id.
Add an optional namespace filter to the search seed legs (keyword + vector), mirroring the existing source_id scoping. namespace is a plain items.namespace column match -- a relevance/organisation filter, not a security boundary -- and composes with source_id. The graph leg stays unfiltered, as it is for source_id.
Refs #8266
Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
fix: persist the deferred-note hold with the slot so it survives a restart (#4093) (#8982)
POST /api/chat/slots/{slot}/note replies 200 with visibleDeferred: true for
a note held during a running turn, but both halves of the hold lived only
in _ChatSlot._deferred_notes — a gateway restart between the 200 and the
next turn silently voided the delivery promise.
The hold now persists through the slot's own metadata line under one
invariant: retirement is ROW-DERIVED. Every note carries an id; the flush
stamps each delivered inject row with it (meta.noteId) and records
rebind-dropped ids on the slot; the full save — reading the on-disk and
live holds UNDER the history lock — retires exactly the entries whose
rows are in the window it writes (or whose ids were dropped) and keeps
everything else. Row and retirement land in one atomic file replace; the
drop records are consumed only AFTER that write commits, since a dropped
note's row never exists and the record is its only retirement path.
- enqueue (durable-before-200): asyncio.to_thread + update_metadata_if,
everything read inside the lock-time guard; the write MERGES by note id
(union_deferred_notes), pins the posted note (ensure=) so a racing
turn-end flush cannot yield a 200 with no durable copy, and pins the
TARGET to the history key authorized at enqueue, re-verified under the
store lock — a cron/workflow rebind in the persist window is refused
(uniform not-found shape) instead of writing app content into a foreign
transcript's metadata. The union never evicts: at the 2x ceiling the
NEW note is refused (429). On EVERY failure branch, a rollback that
finds the note already drained means a flush DELIVERED it — the 200
stands, because any error would make the caller re-post a line the
user already saw. Other failures roll back BY IDENTITY and answer a
retryable 503 (including an UNREADABLE record)
- deferred notes are bounded at the ENQUEUE boundary (413 over 4000
chars); the durable copy is persisted VERBATIM, never truncated
- both restore paths replay the hold up to the durable CEILING (2x the
live cap — every durable entry is a 200-acknowledged note); restored
notes are sanitized fail-closed (no session stamp -> dropped;
over-bound content -> dropped; malformed context half dropped alone)
- the flush never writes metadata; a crash between flush and save
re-delivers on restore (at-least-once)
- docs updated in the same commit (App Kit api-reference, session.md,
endpoint docstring): do not re-post after a restart; 503 = retry,
413/429 = boundary refusals, 404 = ownership/rebind refusal
_pending_context (the /context queue) stays memory-only: only the context
halves embedded in held notes ride the same metadata shape naturally.
Closes #4093
Co-authored-by: Nick Bowers <nrb@amazon.com>
feat: speak replies with the host's own engine, no install needed (#8957)
Text-to-speech needed an install before it could say anything: Piper wants a
binary and a voice model on disk, Polly wants AWS credentials, so turning on
auto-speak on a fresh machine produced silence and a settings panel with two
paths that both start with homework.
Add a third provider, `system`, and make it the default. It drives the speech
engine the operating system already ships -- `say` on macOS, System.Speech
through Windows PowerShell 5.1, `espeak-ng` on Linux when present -- so
auto-speak works with nothing downloaded and nothing configured. Linux is the
one platform where the engine can be absent; that is reported as unavailable
with the package to install rather than failing silently at synthesis time.
The engine is resolved through `trusted_system_bin`, not PATH, so a shim in an
agent-writable directory cannot be handed model output, and the spoken text
never reaches argv: `say` and `espeak-ng` read it on stdin, and the Windows path
writes it to a temp file that a constant base64 -EncodedCommand script reads,
so no quoting decision is ever made about model output.
This provider deliberately does NOT route through `wrap_argv`. Windows has no
sandbox backend, so a wrap fails closed on the one platform whose built-in
engine users depend on, and there is nothing to confine in an OS component
resolved from a directory the user cannot write. Piper keeps its wrap: it runs a
downloaded model through a third-party binary. A test pins the distinction.
Also fixes two things the new provider surfaced: `_resolve_piper_binary` looked
for `~/piper-venv/bin/piper` on every platform, so a correct Windows install was
never found (`Scripts\piper.exe`), and the module documented Piper as having no
Windows build, which the `piper-tts` wheel has published since 1.3.0.
fix(config): hold the sidecar advisory lock in KiroCrewConfig.save() and offload async callers (#4767) (#8906)
KiroCrewConfig.save() was an unlocked whole-document replace: its rename
could land inside an update_config_locked holder's read-modify-write (CLI
writer, boot refresh, second gateway), silently discarding the other
writer's change -- every field in the document exposed.
Two halves, landing together per the recorded failure modes of the first
attempt (#4371):
HALF 1 -- lock the writer. The sidecar acquire in update_config_locked is
extracted into _config_write_lock() and save() now writes under it: the
SAME <config>.lock sidecar, beside the resolved symlink target, so both
writer families contend on one lock and the lifecycle stays one shared
file (no new residue -- the Windows orphan concern from #4371). The write
itself stays atomic + mode-preserving via write_config_atomically, and
the overlay-subtraction content is untouched. save()'s docstring now
states the staleness contract plainly: it publishes an in-memory
snapshot, so it is ONLY for single-flow callers with no suspension
between load and save (CLI one-shots, the boot default-config write);
read-modify-write flows belong on update_config_locked.
HALF 2 -- dashboard writers become DELTA read-modify-writes, off-loop.
Every .save() call site was enumerated and classified. Coroutine callers
no longer call save() at all: each persists the keys it owns as a delta
mutate through update_config_locked -- read, re-check, and write inside
ONE flock hold -- dispatched off the loop via run_config_write (the
transaction shape its docstring prescribes) or, where the asyncio config
lock is already held, via the module's cancellation-draining
_drained_to_thread:
- dashboard/handlers/updates.py api_log_level: delta on
agent.log_level
- dashboard/handlers/files.py workspace create/update/delete: delta on
the one workspaces entry, with the state-dependent preconditions
(name/dir collision, default-workspace and agent references) re-run
against the document as read inside the lock and surfaced as 4xx
conflicts. The copy_from tree is staged only after ALL path validation
passes, installed inside the locked mutate (atomic os.replace, or a
merge copy when the destination pre-exists), and every cleanup of a
staged tree runs in a worker, never inline on the loop
- dashboard/handlers/agents.py agents-sync + agent-create: delta on the
agents entries (adds re-checked, package/aim prunes re-derived, name
conflicts re-checked against the in-lock document), via
_drained_to_thread under the held asyncio lock
- dashboard/handlers/onboarding_import.py: both former manual-lock sites
route through run_config_write; the state PUT is a delta on
dashboard.import_onboarded; the unused local _get_config_lock wrapper
removed
- cli_server.py gateway-boot default-config write: asyncio.to_thread
(file absent, nothing to lose, no loop-side writers yet)
Sync CLI/wizard callers (cli_commands, cli_config, cli_cloud,
cloud/wizard) stay on save(): single-flow, no suspension between load
and save. crew_chat.py st.save()/self.save() are CrewStore, and mochi
stats_service self.save() is its own persistence -- not KiroCrewConfig.
Tests: red-before-green concurrency pair (save waits on a held sidecar;
a locked writer landing inside save's critical window survives), sidecar
lifecycle (only the one shared .lock beside config.json; symlinked
config locks beside the target), in-lock precondition re-check suite for
the workspace handlers (stale snapshot passes, fresh on-disk document
refuses, unrelated entries survive the delta), staging-lifecycle tests
(no destination mutation or staged residue on conflict or failed
validation), a runtime off-loop probe on the log-level handler, and a
self-tested AST ratchet forbidding any coroutine from calling
KiroCrewConfig.save() inline.
Closes #4767
Co-authored-by: Nick Bowers <nrb@amazon.com>
fix(members): dispatch server carries the gateway home override (#8611)
The crew member DM session's kirocrew-dashboard MCP entry carried only the
session key (and, since #8837, the bound port). The server resolves *which
gateway* to call from its data home, so on any install where KIROCREW_HOME is
set -- a pod, a second profile -- it presented the member's identity to the
default home's gateway, which has no such member slot and refused every verb
as caller_unidentified while tools/list looked healthy.
Carry the same home override every managed Crew server already carries
(_managed_mcp_env), through the same helper so the two cannot drift. On a
default install the helper returns {} and the emitted entry is unchanged.
The per-member conversation index (#8612) no longer rides in this PR; it
travels with its consumer, #8613, which is stacked on this branch.
feat(members): open the last member on arrival, mirror it in the URL (#8546)
The Crew Members page landed on an empty column every visit and forgot
which member was open when the user navigated away and back. Now:
- A visit that names no member opens the remembered one, else the first
row in display order — never the empty column.
- The last member opened is remembered per browser (mc-members-last-member,
via safeStorage), so leaving for Sessions/Settings and returning, or a
reload, lands on the same conversation. A remembered member that was
deleted or renamed falls back to the first row without an error.
- The open member rides the URL (?member=<name>): a reload keeps it and a
link lands on one. Switching members REPLACES the entry — the page holds
one history entry however many members are visited, so Back leaves it in
one press, the Sessions sidebar's rule; only the below-md roster->thread
step pushes. The value is the exact crew name, not the slug — the slug
is lossy and would misroute Oncall to oncall. A link naming a member
that is gone takes the same fallback and says so in a warn-toned status
above the thread, leading with the swap ("Showing X — “Y” is no longer
on the roster."; 13 catalogs + en-XA): the user asked for someone
specific, and a silently mounted other thread is the misroute this page
exists to prevent. The stand-in opened by that fallback is the page's
choice, not the user's, so it does NOT become the memory — one dead
link cannot overwrite the member the user had chosen.
- Below md the page stays a two-level list->detail navigation: no
?member= IS the roster, so no auto-open there (same rule as
SidePanelLayout's remembered tab). A stale ?member= returns to the
roster with the same notice above the list. The header back pops the
entry the roster pushed (no duplicate roster entry in history) and
drops the param in place for a deep link, which has no roster behind it.
Tests cover default, restore, stale fallback (memory: silent; URL: with
notice on both sides of md, retired on the next open, memory preserved),
URL-over-memory, click writes URL+memory, a driven-history walk over two
members that leaves the page in one Back, and the below-md back semantics
(the useNavigate mock records AND performs against the MemoryRouter).
fix(pod): scope pod kiro-cli oauth grants to the pod home (#8528)
A pod isolates KIROCREW_HOME and KIRO_HOME but deliberately keeps the real
HOME, and kiro-cli derives its MCP OAuth artifact directory from the spawned
process's own $HOME. So a pod REUSED the operator's machine-level grants (a
provider card read Connected from a grant minted on the real machine) and a
grant minted INSIDE a pod outlived pod down as a real, durable credential.
Both break test fidelity and the destroy-grants-after-each-smoke-test
requirement.
Two halves, one shared resolver:
- mcp_grant.kiro_oauth_cache_dir() -- the single default all four callers
(mint, status, disconnect, mcp_discovery's remote probe) reach with no
explicit cache_dir -- now resolves through the new
config.paths.kiro_oauth_cache_home(), which honours KIROCREW_OS_HOME and
rejects the same unsafe targets as kiro_home()'s KIRO_HOME. This moves the
pod gateway's own grant reads onto the pod's tree.
- acp.client._apply_pod_home_remap(), applied identically at both spawn
transports, remaps a pod-spawned kiro-cli child's HOME/USERPROFILE to that
same directory. kiro-cli offers no env override for just its OAuth cache,
so remapping the child's HOME is the only way to move its own WRITES.
Gated on KIROCREW_POD=1 plus positive membership in
ACP_BACKENDS_POD_HOME_REMAP -- its own set, not a reuse of the
internal-sandbox one, because "carries its own OS sandbox" and "relocating
HOME moves its credential store" are different questions (harness-parity H6).
Sign-in still works: pod.runtime._seed_pod_os_home create-only stages the
AGENT RUNTIME's own identity store -- the per-platform paths derived from
identity_stores.store_mappings(), which is where the harness actually resolves
its access token -- into the pod tree at boot. No host .aws/sso/cache contents
are copied: an earlier revision staged those and it was deleted, so a pod's
.aws/sso/cache starts EMPTY and holds only grants that pod itself mints. A pod
therefore cannot be pre-authorized to a provider nobody consented to inside
it, by construction rather than by a filename glob.
A boot viability probe (agent_sdk.pod_child_probe) is the last gate before
serving: it spawns the child through the same confinement production uses
(env scrub, sandbox wrap, cgroup scope, rlimit preexec) and routes an
unbootable child through _refuse, so a signed-out or dead child makes pod up
fail loudly with a terminal exit code instead of serving a broken pod.
AWS_CONFIG_FILE / AWS_SHARED_CREDENTIALS_FILE are deliberately NOT exported
into the pod child. An earlier revision pinned both at the real home so a pod
turn could still reach the operator's profiles; that made each name an alias
for a path the sensitive-path keystone fences, and three review rounds each
closed one spelling of the retrieval. The alias was deleted at its source
instead -- security.py records the full reasoning, including why a name-based
deny rule is defensible for the variables that hold a SECRET and not for the
ones that hold a PATH.
The real passwd home stays fenced: security.py's matchers run in the gateway
process against its own Path.home(), and the remap only ever mutates the dict
handed to the child spawn, never os.environ.
The os-home tree nests under the pod home, so cleanup_home's existing pod
down sweep reclaims a pod's grants with everything else.
feat(chat): add inline paste token composer (#8310)
Add a default-off Lexical composer path with atomic inline paste nodes, editor-neutral selection and focus controls, shared clipboard policy, lazy-load rollback, and focused regression coverage while preserving the textarea fallback.
fix(slack): report the real dashboard link-click window, not the constant (#7265)
`generate_token` mints `exp = now + min(LINK_WINDOW_SECS, session_ttl)` so a
link can never authenticate past the session it grants. Both Slack surfaces
that report that window still printed the unclamped constant, so
`/kirocrew dashboard 1m` promised "Click within 5m" about a link that dies in
one — a 5x overstatement of a window the user has to act inside.
Report the clamped value at both reporters: the DM built in
`allowlist.send_dashboard_link` and the ephemeral block built in
`events._handle_dashboard`. For a full-length caller both strings are
byte-identical to before, because the clamp only ever tightens.
Original change authored by Leon (@leonlaiyc).
Co-authored-by: Leon <276034861+leonlaiyc@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
feat(cron): allow assigning a Schedule-page folder when creating a cron (#7249)
cron_add/cron_update (MCP) accept a 'folder' argument (name or id; a
missing name is created through POST /api/cron-folders, the dashboard's
own endpoint, so the create shares the Schedule page's lock and
in-memory list). 'kirocrew cron add' gains --folder (existing folders
only). App manifests can set 'folder' on a cron entry (a NAME, resolved
at registration; unresolved degrades to ungrouped with a warning and is
re-applied on the next enable, so the assignment survives the
disable/enable delete-and-recreate cycle).
Shared read-only resolver in cron.py (load_cron_folders /
lookup_cron_folder_id): cron_folders.json stays dashboard-owned; no
non-dashboard surface writes it directly.
Co-authored-by: Aurélien Andrey <aurand@amazon.fr>
feat(session): recover warm-pool hits for effort-override sessions (#7096)
Apply reasoning effort overrides post-claim on warm-pool processes via provider.change_effort, keeping the warm-pool hit path active for slots carrying reasoning effort overrides.
Fixes #6248
feat(meetings): import a recording into a meeting (#5741)
POST …/{id}/import takes a host path to an audio file, transcribes it with
the gateway's own batch speech-to-text, and dispatches each line through the
same admission transaction live speech uses, so an imported recording is
persisted to the transcript and reaches the agents exactly as if spoken.
Every dispatched line requires the SESSION OBJECT admitted at import start —
a meeting id is a name, not an identity — so a meeting stopped, deleted, and
recreated under the same id mid-import gets a 410 (meeting_session_replaced)
instead of the old recording's lines.
A recording that would split into more than MAX_IMPORT_LINES lines is
refused whole with 413 (recording_too_long) rather than silently truncated:
a capped import that returns success while the recording's tail is missing
is data loss the user cannot see.
Original feature authored by Kai Mitsuzawa (kaizawa97). Rebase onto main,
locale conflict resolution, the session-identity fix, and the overflow
rejection by Kiro Crew.
Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
fix(issue-radar): open lock sidecars non-truncating before the acquire (#9275)
The 18 lock context managers in issue_radar's store.py and crew_store.py
opened the lock sidecar with open(lock_path, "w") and only then handed the
descriptor to platform_compat.file_lock(..., exclusive=True). "w" truncates
at open, before the lock is held. On Windows the acquire routes to
msvcrt.locking, and a truncating open of a lock file whose first byte another
holder already locked raises a sharing violation instead of waiting, so the
contending acquirer fails before it reaches the lock and the mutual exclusion
silently does not happen. POSIX flock tolerates it, which is why it is
invisible on Linux.
Each site now ensures the parent dir, lock_path.touch(exist_ok=True), then
open(lock_path, "r+") -- writable (msvcrt.locking needs it) but not
truncating -- acquiring on that handle exactly as before. Same shape and
rationale as work_ledger._open_lock (#9237) and session_pid.py (#9250).
Refs #9248
fix(apps): read the answer to the stop the Job SDK already performs (#9262)
A runner that submits to a pool and returns the unwaited
concurrent.futures.Future hands the work back, so the SDK holds the only
reference to it for one instant. _close_quietly already asked that
reference to cancel -- as hygiene -- and discarded the answer, while
_undriven_result had already worded the record as work that 'may still be
running ... and cannot stop'. So a run whose work provably never started
was recorded as work a retry could overlap.
_stop_returned_work now makes that stop deliberate, takes it before the
verdict is worded, and answers three ways: True only for a
concurrent.futures.Future whose cancel() returned True, which that class
documents as the call never running; False when the work is already
executing; None when no guarantee is available. An asyncio.Task can
answer cancel() with True and finish anyway, so a truthy cancel is never
read as a stop.
Refs #7814
Co-authored-by: Kiro Crew <noreply@kiro.dev>
fix(hooks): stop the hooks.json lock opens from truncating the lock file (#9279)
The two writers of hooks.json — webhooks.locked() and the register_hook
MCP tool (mcp_tools/control.py) — both opened their shared lock sidecar
with open(lock_path, "w") and only then acquired the lock. "w"
truncates AT OPEN, so the mutation happens before the lock that is
supposed to guard it is held.
On Windows the acquire routes to msvcrt.locking, and a truncating open
of a lock file whose first byte another holder already locked raises a
sharing violation instead of waiting — the contending acquirer fails
before it ever reaches the lock, and the mutual exclusion the lock
exists to provide silently does not happen. POSIX flock tolerates the
truncate, which is why the defect is invisible on Linux.
These two sites are ONE unit: both resolve to the SAME
<data-home>/hooks.json.lock from two different modules, which is exactly
the cross-process contention the defect needs — webhooks.locked() is
documented public precisely because register_hook must share its lock
discipline. Fixing one without the other would leave the pair
half-locked and the Windows failure fully live.
Both sites now touch(exist_ok=True) and open "r+" — writable, which
msvcrt.locking requires, but not truncating. Same shape as
work_ledger._open_lock (PR #9237), session_pid.py (PR #9250), and
dashboard/handlers/mcp.py's _McpFileLock, which was written this way
from the start.
Truncation is the direct, platform-independent observable, so the tests
seed the lock file, take and release the lock, and require the bytes to
have survived: they fail on every platform if a truncating open comes
back.
Part of the #9248 sweep (one subsystem per PR, per the issue); the
remaining subsystems get their own follow-up issues.
Co-authored-by: dwu96 <dwu96@amazon.com>
Co-authored-by: Kiro Crew <noreply@kiro.dev>
revert: restore windows startup by reverting #8117
This reverts commit 259db3970e91f4bf6842456b713ad031eed4902f.
Restore the Windows Kiro delegation contract: the macOS settings probe
must not prevent an explicitly classified official Kiro backend from
starting on a fresh Windows install. Kiro CLI remains a trusted runtime
dependency; its internal sandbox is outside this Crew-side validation.
Revert the whole PR for immediate recovery, including its UI-stream
deadline and debug-only precise-memory-info switch. Remove the obsolete
stalled-stream test together with its subsequent determinism changes.
Validation on this branch: 140 backend tests passed, 5 platform skips;
23 Electron native-logging tests passed. Changed Python files pass
isort and flake8; the staged diff passes the whitespace check.
revert: put the AI review lanes back on Opus 4.8 and Fable 5
This reverts commit ecf50cadd4336f40d2166c835bc780118372ec57 (#9158).
Fable 5.1 is listed ACTIVE on Bedrock in this account and its us.
inference profile exists, but it is not actually invokable from these
lanes: every first call is rejected at zero tokens. The three advisory
lanes hid that behind --fallback-model, so they reported success while
modelUsage named us.anthropic.claude-opus-4-8, and the GPT lanes'
adjudication pass carries no fallback, so it failed outright and turned
the required GPT 5.6 Review check red on four pull requests inside
sixteen minutes.
Listing a model as available was not evidence that the role may invoke
it. Re-landing the bump needs a real InvokeModel check first, and the
advisory lanes need it too: their green was the fallback, not Fable 5.1.
The two corrected labels for the Opus-4.8-running claude-review.yml lane
go back to saying Fable 5 with this revert. That is pre-existing wording,
wrong but harmless, and it returns with the retry rather than holding up
an urgent revert.
fix(instances): journal every warm-set write and the handshake drops behind a stuck pane (#9141)
A remote crew pane can sit on "loading pane" with nothing in
gateway-launch.log to explain it. The pane journal added in #8274/#8813
covers the viewport's own warm paths, but the one warm-writer it does not
own -- connectInstanceInto, shared by tab clicks and the auto-connect
fan-out -- was silent, and three state changes that put a pane into that
bucket left no line at all.
connectInstanceInto now journals `warm` / `warm-declined` / `warm-failed`
tagged `via=select|auto-connect`, in the same shape the viewport uses. A
connect that answers `connected` with no port or no token leaves the OLD
warm entry standing (a dead port), so the tab still renders an iframe and
the user sees only "loading"; that is now the one `warm-declined` line it
was missing.
InstancesViewport journals three more moments:
- `evict`: the K-cap LRU eviction, the only teardown the user never asked
for. The next click re-warms the pane as a brand-new load, so an evicted
pane that then fails reads, without this line, as a pane that was fine
until it suddenly was not.
- `watchdog-armed`: the 15s load watchdog starting. A pane that spins
forever WITHOUT ever producing `load-timeout` is a pane whose watchdog
never armed, and only the absence of this line says so.
- `ready-unattributed`: an `mc-embedded-ready` from a loopback origin the
parent cannot map to a warm pane -- the handshake dropped because the
origin map has not caught up with a port change or an eviction. Journaled
only for this message type and only for a loopback origin; the child
sends it at most six times per load, so it cannot flood.
No token material reaches the log: `paneLog` redacts secret-named fields
and the new test pins that the token string never appears.
Co-authored-by: Joe Guo <zejiangg@amazon.com>
fix(slack): stop a refused progress card from splitting the reply (#9260)
A task card is progress decoration and the 30s elapsed-time refresh
re-sends it for as long as a tool runs, so on a several-minute tool phase
it is the only thing appending to the stream. Both Slack pipelines
rotated the stream when Slack refused that append: the message the reader
was watching was stopped mid-answer and the rest of the reply continued
in a new one, which reads as a reply that failed followed minutes later
by an unexplained second reply.
_append_task now skips the refused card and keeps the stream.
_append_stream still rotates when real text is refused, which is the
moment a rotation is worth its price, and that replacement stream now
opens with a continuation marker so the two messages read as one answer.
test(work-ledger): name the cause when the binding race test shortfalls (#9257)
test_two_conductors_binding_one_worker_at_once_yield_exactly_one_binding
fails on Backend Tests (Windows) (4) intermittently and reports only
"assert 2 == 3". Its bind helper caught only WorkLedgerError, so anything
else (a bare OSError from a Windows sharing violation, per #9250's own
docstring) killed the thread silently, appended nothing, and shortened the
count -- discarding the one fact that identifies the cause.
Record one outcome per thread keyed by conductor, catch BaseException and
keep the type + message, and flag a thread still alive after join(timeout).
Put the per-thread outcomes in both assertion messages so the next Windows
failure names its exception instead of a count. The == 1 and == 3
assertions are unchanged: this adds diagnosis, not tolerance.
fix(session-pid): stop the PID lock opens from truncating the lock file (#9250)
The three PID lock helpers opened their lock file with `open(path, "w")`
and only then acquired the lock. `"w"` truncates AT OPEN, so the
truncation happens before the lock is held.
On Windows the acquire routes to `msvcrt.locking`, and a truncating open
of a lock file whose first byte another holder already locked raises a
sharing violation instead of waiting -- so the contending acquirer
crashes with a bare OSError before it ever reaches `file_lock`, and the
serialisation the lock exists to provide never happens. POSIX `flock`
tolerates the truncate, which is why this was invisible on Linux and
reddened only the Windows shards.
Issue #9248, which named `session_pid.py` as the site most likely to bite
in practice: PID files are read by other processes precisely while they
contend. It named two sites and asked whoever picked it up to re-run the
grep rather than trust the list; doing so found a third,
`_periodic_pid_sweep`, which is the worst of them because it runs on a
timer while `_track_session_pid` contends for the same lock.
All three now `touch(exist_ok=True)` and open `"r+"` -- writable, which
`msvcrt.locking` requires, but not truncating. Same shape as
`dashboard/handlers/mcp.py`'s `_McpFileLock`, which was already written
this way, and as `work_ledger._open_lock`.
Truncation is the direct, platform-independent observable, so the tests
seed the lock file, take and release the lock, and require the bytes to
have survived: they fail on every platform if the truncating open comes
back, rather than only on Windows.
`webhooks.py` carries the same pattern and is deliberately left alone --
issue #9248 asks for one change per subsystem so each gets its own
review.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fix(work-ledger): stop the lock-file open from truncating on Windows (#9237)
The store's _open_lock opened its lock file with open(path, "w"), which truncates on open. On Windows a truncating open of a lock file whose first byte another holder already locked under msvcrt.locking raises a sharing violation instead of waiting, so a second contending acquirer crashes with a bare OSError before it reaches file_lock -- the bind it was serialising is never mutually excluded. POSIX flock tolerates the truncate, so the defect was Windows-only. Fix mirrors dashboard/handlers/mcp.py: touch + open r+ (writable, no truncate).
fix(acp): give session/set_mode the session-start budget on Windows (#9225)
session/set_mode boots the switched-to agent's MCP servers, the same
(re-)initialization that gives session/new and session/load their 90s
budget, but both set_mode call sites used the generic 30s _REQUEST_TIMEOUT.
A switched-to server pending OAuth holds the response for its full 30s
wait, so the 30s budget races it exactly as it would session start. Pass
the already-resolved session-start budget to both set_mode calls.
Closes #9185
feat: add contributes.panelTabs app-manifest contribution (#7975)
test(acp): re-record frame-replay snapshots for the wire_title field (#9232)
The replay corpus recorded by #9161 predates the wire_title field #9073 added to every tool_call event 3 minutes later, so main's own corpus no longer matches main's own render. Regenerated with scripts/update_acp_frame_snapshots.py; the only delta is the new wire_title key.
refactor(electron): share typed preload bridge contracts (#9228)
Co-authored-by: DeryFerd <DeryFerd@users.noreply.github.com>
fix(messaging): keep prose that merely mentions [STEERING visible (#9209)
`_SteeringMarkerFilter` held any buffered tail that began `[STEERING` until it
found a `]`, and deleted it at flush if none arrived. That is a
locate-by-substring decision, not a grammar one: the filter already hands on a
CLOSED frame that fails `_STEER_MARKER_RE` as ordinary text, so the same
sentence survived when the writer happened to type a `]` later and vanished
when they did not.
Measured on main -- `"Use the [STEERING protocol to redirect"` reaches the
channel as `"Use the "`, and `"text [STEERING nonsense] tail"` arrives whole.
The filter runs in `TurnDriver` upstream of every renderer, so an agent
explaining the steering protocol loses the rest of its message on Discord,
Telegram and WhatsApp. It also negates
`test_unfinished_marker_prefix_grammar.py`'s
`test_prose_mentioning_steering_is_left_visible`, which pins that exact string
as visible one layer down.
The unterminated tail is now judged by the grammar the marker actually has: a
tail that can still extend into `[STEERING steer-<id>]` is held as before, and
one that has already diverged is prose and is handed on. The probe is compiled
from `constants._STEERING_TAIL_PREFIX_RE`'s pattern -- the same closure
`split_trailing_protocol_suffix` uses downstream -- so the grammar keeps one
source, with `IGNORECASE` added to match this module's own recognizer, since
here a tail judged prose is EMITTED and a probe stricter than the recognizer
beside it would leak part of a real frame.
Fail-closed behaviour is unchanged: a tail that was still a viable marker when
the stream ended is dropped, not emitted, and the 16 KiB ceiling is checked
before the grammar probe so an adversarial buffer cannot make it re-scan a
growing string.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fix(command-bar): attribute contributed rows to the validated app name (#9226)
* fix(command-bar): attribute contributed rows to the validated app name
A contributed command's attribution came from `app.displayName`, which is
free text the app chooses, so it could not do the one job it has.
An app declaring `displayName: "Kiro Crew"` put that string where the row
states its owner, and the row then read as native while its prompt goes to
an agent with tools. In the other direction `||` is not the emptiness test
it looks like: the manifest validator refuses only a falsy `displayName`,
so `" "` or a zero-width `\u200b` is "present", wins over the name, and
renders the empty label that `kindLabel` shows as a bare kind -- character
for character what a builtin row shows.
The label now comes from `name`, the install identity. `KEBAB_RE` limits it
to `[a-z0-9]` plus single hyphens, so it can neither be blank nor spell a
host word with a space or a capital, and it is unique per installed app.
The clip path already used `name`; the unclipped path now agrees with it.
* docs(command-bar): add before/after evidence for the attribution fix
Captured with the repo's own scripts/capture-command-bar-contributed.mjs harness,
with the external app's displayName set to "Kiro Crew" so the BEFORE frame shows
the impersonation the fix removes rather than merely a friendly name.
fix(autonudge): make an armed auto-nudge loop readable via monitor_inspect (#9221)
fix: treat a serve config naming only other ports as a free 443 (#7999)
A serve status document that keys mappings by port (TCP by bare number,
Web/AllowFunnel by host:port) and names no key for 443 is positive
evidence that 443 is vacant, not an unknown. Previously any unrelated
serve config -- another project on port 80 -- made the Phone access card
and `kirocrew tailnet up` refuse to publish with "could not identify what
is on port 443", on exactly the developer workstations most likely to run
other tailscale-serve projects.
serve_state() now sets a port_free reading via _has_port_shaped_keys(),
and publish() proceeds when the port is provably free; a shared _key_port()
parser keeps the port-evidence read and the 443 detector from ever
disagreeing (a leading-zero key must not read as evidence while hiding the
443 mapping it names). Documents with serve content but no port-shaped keys
keep refusing; a stranger's handler at 443/ keeps refusing and the Phone
access card derives `occupied` for it up front instead of offering a publish
that fails one click later. unpublish() leaves other ports' config untouched.
Co-authored-by: Antigravity AI <ai@gotad.net>
fix(connections): honest transport-error copy on the needs-attention card (#9148)
Gate the provider-verdict headline on auth-shaped evidence via errorIndicatesProviderRejection; transport failures (timeout, DNS, reset, unknown) now render connection_unreachable instead of claiming the provider invalidated the grant. Default is no-verdict. New connection_unreachable key in all 13 locale catalogs plus regenerated pseudolocale.
fix(protocol): tolerate a Markdown wrapper around the [OPTIONS:] marker (#9174)
A model sometimes wraps the whole trailing marker line in inline code or
emphasis -- `[OPTIONS: A | B]` in backticks, or **[OPTIONS: A | B]**. The
wrapper character lands after the closing bracket, which broke the
end-of-line anchor: the marker was neither parsed nor stripped, the turn
lost its follow-up pills, and the raw marker leaked to the user as
literal code-styled text. Every surface shares the grammar, so
dashboard, Slack, previews, and the Discord/Telegram trailer split all
lost their buttons on the same input.
The grammar already absorbs the same class of model tic (a stray
`](OPTIONS)` link close), so this widens it symmetrically and tightly:
- a LEADING wrapper run (` / * / _, at most 3) matches only at line
start after optional indent, so emphasis belonging to preceding prose
is never eaten;
- a TRAILING run only when it abuts the closer (or its `(...)` tic) AND
the marker is line-anchored: a mid-line marker keeps the pre-widening
grammar byte for byte, so an inline code span quoting a marker
(`Use [OPTIONS: A | B]` in backticks) never loses its closing
character. Python spells the condition as a `(?(lwrap)...)`
conditional group; JS, which has no conditionals, as a two-branch
alternation with (S, labels) group pairs (1,2)/(3,4);
- the Python `labels` group is named (the conditional group precedes
it), and the group-reading consumers (slack, messaging renderer,
dashboard) read `group("labels")` / branch pairs accordingly;
- frontend regex and both backend mirrors change in one commit so the
grammars cannot drift;
- the partial-marker stripper and the backend trailer splitter pull a
line-leading wrapper along with a still-streaming marker, so the
wrapper is never stranded in the visible half;
- ReDoS linearity is preserved: the wrapper class shares no character
with the indent or trailing whitespace classes, each alternation
branch is the proven linear shape, and both wrapper positions are
anchored by the required [OPTIONS: literal.
Tests in both languages cover backtick-wrapped, emphasis-wrapped,
leading-only, trailing-only, the negative rows that must stay negative
(trailing period, trailing prose, non-abutting wrapper, 4+ runs,
prose-owned emphasis, and the mid-line wrapped/quoted forms including
the inline-code corruption case), streaming-prefix behaviour, group
contracts in both languages, and adversarial wrapper runs against the
linearity guards.
Closes #9110
Co-authored-by: Joe Guo <zejiangg@amazon.com>
fix(directive): claim the out-of-band record by the call's input, not the result (#9073)
The out-of-band session-directive path parked the tool's validated payload on
the gateway and let the consumer claim it -- but the consumer learned WHICH
record to claim by reading the directive marker back out of the tool RESULT
text (`session_directive.peek`). That text is whatever the backend chooses to
put on the wire, and KAS reshaped it four ways in as many weeks: the envelope
re-serialised with every quote escaped (#8182), the result copied into both
`response` and `message` (#8841, kiro-agent bc5906adf), one field swapped for
an offload reference above a size threshold, and every string capped at 30k
chars with the tail-anchored marker falling off the end. Each shape was one
more repair branch in `acp/_dispatch.py`, which every backend shares, and the
next kiro-agent release could add another. Conductor frames were already at
22k against the 30k cliff.
The tool call's INPUT reaches both sides through no envelope at all. The MCP
server receives it as the `arguments` of `tools/call`; the consumer sees it as
the `rawInput` of the ACP `tool_call` frame, which kiro-agent emits uncapped.
So the record is now keyed by `session_directive.call_input_digest` of the tool NAME plus those
raw arguments (the name because every no-argument tool hashes `{}` alike; a
planted `reset_conversation({})` was otherwise claimable by any `{}` call):
* `mcp_core._call_tool` digests the raw args BEFORE validation (validation adds
schema defaults the model never typed) into a ContextVar;
`control._emit_directive` posts it beside the payload as `input_digest`.
* `api_session_directive` requires it (400 without -- a record nothing can
claim is worse than an honest refusal) and `directive_queue.publish` stores
it. `directive_queue.claim` matches on `(session_key, input_digest)` inside
the claiming turn; the `(kind, args)` correlation is gone.
* `chat_runner` records the digest of `raw_tool_params` on every
`EVENT_TOOL_CALL`, refreshes it from a `tool_call_update` carrying rawInput
(claude-agent-acp streams an empty input first), and claims by it on the
final result frame. The claim is attempted when the frame carries a marker
OR a record is parked for the session and the call has a digest, so a
result whose marker was capped off entirely still arms. `_meta` is dropped
before hashing: kiro-agent strips it before `callTool`, so the server never
sees the block the frame carries.
Same trust shape as the marker it replaces: model-controlled content, bound to
the session by arriving on its own event stream in the call the tool served. A
caller who can park a record for another session still needs THAT session's
model to make a call with identical arguments in the same turn. The applied
payload is always the record's; the digest only picks which record.
Deleted: `_repair_escaped_marker`, `_marker_bearing_text`, `_elide_marker_value`,
`_dumps_elided_siblings`, `ELIDED_MARKER_VALUE`, `session_directive.peek`,
`peek_failure_reason`, and the marker-envelope branches in both result
builders (`_dispatch` and the legacy `AcpClient`). The kiro-cli path
(`_meta.kiro` identity + `decode` from the marker) is unchanged; the messaging
TurnDriver never used the queue a…
Problem / Motivation
In
SessionManager.get_or_create, PR #6239 added a temporarybypass_effortwarm-pool disqualifier whenever a session had areasoning_effort_override. Pre-warmed providers in the pool were constructed with default effort settings, and post-claim fixups only handled re-keying and model switching without updating reasoning effort. As a result, sessions configured with custom reasoning effort were forced to take a cold-start path every time, bypassing warm-pool acceleration.Why it matters
Sessions using effort overrides (such as
highorxhigh) incurred full process spawn latency rather than claiming instantly from the pre-warmed pool, diminishing the benefits of the warm-pool infrastructure for reasoning-intensive workflows.What changed (motivation → approach → change)
provider.change_effort), which pushes/effortto the running ACP backend and updates thecli.jsonoverlay for respawn resilience, matching the authority and validation logic inprovider_factory.src/kiro_crew/providers/base.py: Declaredsupports_effort,change_effort, andclear_efforton theLLMProviderABC with safe defaults (return False), registering the capability at the seam perAGENTS.md§ Harness parity.src/kiro_crew/session_allocation.py: Removed thebypass_effortwarm-pool disqualifier inSessionAllocationService.get_or_create. Added post-claim reasoning effort validation and push (await provider.change_effort(_eff)) wrapped in a non-fatal try/except block along with standardized drop warnings for effort-incapable models.src/kiro_crew/session.py: Removed the unused"bypass_effort"outcome label fromPOOL_DECISIONS.test/test_effort.py: ReplacedTestPoolEffortBypasswithTestPoolEffortPostClaimtesting that warm-pool claims withreasoning_effort_overridesucceed (hit) and invokechange_effort, that unsupported models log the expected drop warning, and that push exceptions are handled non-fatally.docs/system-specs/modules/session.md&docs/system-specs/modules/providers.md: Updated module specifications to document post-claim effort application on warm-pool claims.Tests
test/test_effort.py::TestPoolEffortPostClaim::test_pool_claim_with_effort_override_applies_effort_post_claim: Verifies warm-pool claim with effort override callsprovider.change_effortand does not bypass pool.test/test_effort.py::TestPoolEffortPostClaim::test_pool_claim_with_unsupported_model_logs_warning: Verifies that claiming an effort-incapable model logs the standard drop warning.test/test_effort.py::TestPoolEffortPostClaim::test_pool_claim_with_change_effort_exception_logs_warning_and_spares_session: Verifies that an exception during live/effortpush logs a warning and does not hard-kill the claimed provider.test/test_session_pool.py: 76 pool unit tests passed.test/test_session.py: 290 session unit tests passed.Manual verification
N/A — comprehensive unit tests in
test_effort.py,test_session_pool.py, andtest_session.pycover pool claim logic, effort application, exception recovery, and fallback behavior.Related Issues
Fixes #6248
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)