Skip to content

Release integration: reconcile dev and main session recovery - #1738

Draft
fluck-boss wants to merge 37 commits into
mainfrom
release-integration-20260926
Draft

fluck-boss wants to merge 37 commits into
mainfrom
release-integration-20260926

Conversation

@fluck-boss

Copy link
Copy Markdown
Collaborator

Validation-only integration branch for #1728. Do not merge or dispatch a release until the current merged-tree CI, review, crash pass and version/notes plan are cleared.

Merge parents: main 3170b381, dev 4fa78542. The sole textual conflict in BossAppStartupEffects.kt was resolved by retaining main's updateSessionSpace identity rebinding and dev's owner-only paired writeInSessionRecovery. The old saveSessionRecovery two-step helper was removed because it called the saveLastSessionRecord method dev removed. The shared session-set extractor reads the active in-memory Space first so a newly assigned identity is included. Local git diff --check passed; local Gradle could not start because the available JVM is 11 and this build needs 17+.

At preparation, main was already 9.5.27 with release notes while dev still read 9.5.25. The merged tree retains main's 9.5.27 and its existing notes. Do not release with skip_version_increment=true; the next version must be incremented and its own notes generated. #1728's old review covered earlier heads and explicitly did not evaluate the app-testing findings.

abhinavlenka and others added 30 commits September 25, 2026 09:41
…les, and lock zoom updates (#938)

Co-authored-by: abhinavlenka <abhinavlenka@users.noreply.github.com>
Windows lets the user move Downloads (Explorer, Properties, Location), for
instance to D:\Downloads, but BOSS kept answering %USERPROFILE%\Downloads.

- WindowsDownloadsFolder reads the shell's value for FOLDERID_Downloads with
  reg.exe, parses REG_SZ and REG_EXPAND_SZ, expands %NAME% variables and keeps
  only an absolute path. It needs no native binding, so the host and plugin
  processes read it the same way. Read once per JVM; a missing value, a
  missing reg.exe or a run past 5 s fall back to the old answer. It is only
  consulted when user.home is the Windows profile, so test tasks that
  redirect user.home never read the registry.
- DownloadsDirectory.current() passes it as the known folder, which still
  wins only when it names a real directory.
- FileSystemDataProviderImpl.writeFile/readFile admit the Downloads folder the
  provider hands out, and paths under it, in addition to home. Compared on
  real paths, so .., symlinks and junctions cannot lead into a sibling; a
  Downloads folder at a filesystem root admits nothing; delete stays
  home-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… reader tests test what they say

Review follow-up for this PR.

- The desktop getDefaultDownloadsDirectory logs once per JVM when the Windows
  known folder could not be read, so a failed registry read is no longer
  indistinguishable from a folder that was never moved. plugin-path-utils stays
  logger-free.
- The commonMain expect KDoc no longer says Windows is always
  %USERPROFILE%\Downloads.
- WindowsDownloadsFolder.current checks os.name itself instead of relying on
  its only caller to.
- "a successful run returns its standard output" ran java -version, which
  prints to stderr, so it passed on empty output; it now runs --version and
  checks the version is in what came back (a planted stdout-dropping mutation
  fails it). The timeout test uses a program that sleeps 60 s against a 1 s
  timeout instead of a 0 ms race.
- RelocatedDownloadsAccessTest's outside-home premise is a @BeforeTest
  assumption, so it gates every case instead of only itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ipc needs

plugin-api-ipc calls DownloadsDirectory from plugin-path-utils at runtime
(FileSystemDataProviderProxy), but assembleUpstreamJars shipped only four jars,
none of which contains it. The standalone microkernel runtime would compile
against plugin-api-ipc and then fail with NoClassDefFoundError on the first
getDownloadsDirectory() call.

- assembleUpstreamJars now also ships plugin-path-utils-<ver>.jar, with the
  same dependsOn and rename trick as the other plugin-platform jars, and the
  floor on produced jars is 5, so a missing one fails the task.
- plugin-path-utils gets version 1.0.0, like plugin-api-ipc and
  plugin-api-core, so its jar has a versioned name the runtime can pin.
- The task KDoc and the release.yml comment list the fifth jar.

Before: 4 jars, none containing ai/rever/boss/plugin/pathutils. After: 5 jars,
plugin-path-utils-1.0.0.jar contains DownloadsDirectory.class and
WindowsDownloadsFolder.class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ke the timeout test time itself

Review follow-up for this PR.

- isInsideDownloads fails closed: an IOException from canonicalFile or
  toRealPath now makes the check return false, so the plugin gets the
  SecurityException refusal instead of a generic I/O failure.
- realPath climbs with Files.exists(NOFOLLOW_LINKS), so a dangling symlink or
  junction inside Downloads is found as the link it is; toRealPath on it fails,
  and the write is refused instead of being checked as a plain name and then
  followed. The KDoc says so.
- The timeout test asserts the elapsed wall time is at least the timeout, so a
  child that fails at once (which also gives null) cannot pass it.
- A comment on the home branch of isReadableAndWritable says it compares
  canonicalFile paths and is knowingly weaker than the Downloads branch.
  Behaviour unchanged.

Seen red first: on the previous code both new RelocatedDownloadsAccessTest
cases fail (IOException: Invalid file path; FileNotFoundException through the
dangling junction), and the timeout test with a child that fails at once
fails after 124 ms, where the old version of the test passed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(auth): pin current passkey attempt and arguments

* test(auth): preserve superseded attempt loading assertion
…eap with it (#1696)

PerformanceSettings.validated() bounded pluginJvmInitialHeapMb by the stored
pluginJvmHeapMb, not the clamped one. A stored max below 32 made the range
empty, coerceIn threw, and PerformanceSettingsManager's load fell back to
defaults, dropping every performance setting in the file over one bad value.
A stored max above 8192 let the initial heap end up above the clamped max.

Clamp the max heap first and bound the initial heap by that value.

Fixes #1000
…hanged (#1671 follow-up) (#1700)

#1671's re-review found the one clamp term its test left unpinned: GREATEST(..., 1) on
search_plugins_for_viewer's page size. Without it, the null and 101 cases still give 20
and 100, so a viewer-only typo there would ship. One assertion at 0 pins it, mirroring the
search_plugins block.

The page-size-0 case also gets a comment saying the behaviour changed. LIMIT 0 is legal, so
a direct call used to return an empty page with a correct total_count; it now returns one
row, matching get_popular_tags. The note lives in the test rather than in the merged
migration, which is already applied.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…composeApp's Gradle run (#1699)

Review follow-up on #1667, which merged before these landed.

- The discovery-issue guard moves from composeApp's Test tasks to the root allprojects
  block, so every module's Gradle test run fails on a @test JUnit will not execute.
  Checked by adding a value-returning test to plugin-loader, whose run then failed with
  "must not return a value".
- composeApp repeats it in src/desktopTest/resources/junit-platform.properties, which
  JUnit reads however it is launched, so an IDE run meets it too. Checked with the root
  property removed: the same probe in composeApp still failed the run.
- The build comment says what a failure looks like (discovery aborts for the whole engine,
  and one initializationError names the method) and to relax the property to ERROR rather
  than delete it if a toolchain bump brings an unrelated warning.
- AGENTS.md records the rule beside the composeApp test-home note: declare an
  expression-bodied test `(): Unit =`, or write runBlocking<Unit>.
- McpApprovalGateTest's buffer test waits on pendingList instead of a 50 ms sleep, as its
  deny-all sibling already does. The unknown-surface test now observes the no-op: nothing
  appears, and a real surface survives.

All 29 modules' test tasks with the guard on: 8,414 tests, 0 failed. ktlint and detekt
clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…400 body, paged routes bounded (#1669) (#1702)

All seven items from #1669, the follow-ups to #1508.

1. The popular-tags handler's Number.isInteger guard was dead: the route's schema validates
   before the handler runs, so `limit` is already an integer in 1..100. It is deleted, the
   handler reads `const { limit } = ctx.req.valid("query")`, and the NaN / LIMIT NULL
   rationale moves onto the schema. The handler's comment claimed an invalid limit still
   spent rate-limit budget, and /search's claimed the limit was consumed before parsing;
   both were untrue for the same reason and now say what happens.
2. browse gets a defaultHook, so a request that fails its schema is answered with the
   ErrorResponseSchema { error } every route declares, naming the field, rather than the
   validator's own { success: false, error: <ZodError> }.
3. Each ceiling is defined once in types/schemas.ts: POPULAR_TAGS_LIMIT_MAX,
   CATALOGUE_PAGE_SIZE_MAX, CATALOGUE_PAGE_MAX. The popular-tags query schema moves there as
   PopularTagsQuerySchema.
4. schemas.test.ts imports that schema instead of restating it, so moving the route's cap
   can no longer leave its tests green. It also pins the cap's edge.
5. /list declares its 400. catalogue-paging-bounds.test.ts checks /list?pageSize=999999999
   gets that 400 before the database.
6. Both routes bound `page` (1..10,000) and /search gains .int() on page and pageSize.
   A deeper page is a 400 instead of an empty page from the SQL clamp.
7. The popular-tags recorder keeps the RPC name and asserts get_popular_tags.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…to writeText (#1701)

A regression watch for #1022, which routed thirteen writers through atomicWriteText
(#1011, #1659) with a test for only one of them. Asked for when #1661 was closed as
superseded.

SettingsAtomicReplaceTest has one case per writer. Each saves through the API the app
uses and asserts the file got a new identity: a file rewritten in place keeps its inode,
and one replaced by rename gets a new one.

- The old inode is pinned with a hard link before the measured save, because ext4 hands a
  just-freed number to the next file created and a correct replace could compare equal.
  That happened once on ubuntu in #1661.
- The file is created first when absent, so a writer that loads on a background thread
  reads it rather than writing a default that would hide a reverted save.
- Installed plugins adds its own entry and removes it, leaving the shared installed.json
  as it found it. The HTML and plugin-state stores run on temp files.
- Windows exposes no fileKey, so the cases are skipped there with the reason.

Mutation-checked against a fresh test home: switching each of the thirteen files from
atomicWriteText to writeText fails exactly that file's case, 13 of 13. 1,181 tests across
the settings, plugin, html and filetypes suites pass; ktlint and detekt clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…in its collision path (#1704)

Follow-ups from the #938 merge review.

renameAsideCorrupt promised best-effort failure through its return value,
but toPath() throws InvalidPathException for a name the filesystem cannot
represent, and the move and the permission narrowing can throw
SecurityException. The three settings managers call it from recovery paths
their object initializers reach, so an escape failed the whole manager.
Both are now caught and logged, and the call returns false (#1692).

The stamp can be supplied, so the collision test pre-creates the first
aside and checks the retry to -1 keeps both copies, instead of relying on
two moves landing in one millisecond. A second test fills every suffix and
checks that nothing is overwritten (#1693).

The keymap Json comment now says that coercion is not a round trip: the
older build's next write persists the default, so a downgrade still loses
that one field, and a test pins it (#1694).

Fixes #1692
Fixes #1693
Fixes #1694
… body it declares (#1705)

#1702 gave `browse` a defaultHook, so a request that fails its schema gets the
ErrorResponseSchema { error } its routes declare. The other five routers (publish,
api-keys, rating, download, admin) still answered with the validator's own
{ success: false, error: <ZodError> }. That body isn't the declared shape, and it hands the
caller the schema's internals. The #1702 review found this gap.

- utils/router.ts: newRouter() builds every plugin-store router with the hook and its
  invalidRequestMessage helper, moved out of browse.ts. It is a factory, not an app-level
  hook, because the hook is bound into a route's validators when router.openapi()
  registers it: a hook on the app in index.ts would never reach a sub-router's routes.
- All six routers use it.
- tests/router-hook.test.ts provokes one schema failure per router that has one (browse,
  rating, admin, api-keys, publish), using a client whose database and auth calls throw.
  Each must return 400 with only an `error` key, naming the field. download's routes take
  only path strings, which nothing fails, so a source scan covers it: no route file may
  construct OpenAPIHono directly, and the six routers must all be built by newRouter.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…er settings-test failures (#1708)

Both PRs merged before their non-blocking review points could land.

#1699:
- buildSrc sets junit.platform.discovery.issue.severity.critical=WARNING itself. It is a
  separate build the root allprojects block never reaches, and its tests run in CI's own
  `./gradlew -p buildSrc test` step. All four buildSrc test classes pass with it on.
- The root comment and AGENTS.md say an override belongs in the module's own
  build.gradle.kts, which wins, and that buildSrc carries its own copy.

#1701:
- An unchanged identity is now reported as one of two things, told apart by the file's
  modified time: rewritten in place, or not written at all. The writers log a failed save
  and swallow it, so the old single message sent a reader after a writeText that wasn't
  there. Checked by forcing both.
- The class KDoc records that each measured save is assumed to write unconditionally.
- The default-apps case restores the test-home file and resets the manager, since it is
  the one case that changes shared state.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ue guard is live (#1709)

#1708 added junit.platform.discovery.issue.severity.critical=WARNING to buildSrc, and its
review asked whether that line was actually read. It wasn't. buildSrc's only test dependency
was kotlin("test"), which resolves JUnit 5.10.1 / Platform 1.10.1 through Gradle's embedded
Kotlin. The property arrived in Platform 1.13, so nothing read it: a value-returning @test
in buildSrc was still skipped with the build green.

- buildSrc/settings.gradle.kts imports the root version catalog.
- buildSrc/build.gradle.kts adds the JUnit BOM at libs.versions.junit.jupiter (6.1.3, the
  root's) and the platform launcher. Every JUnit artifact resolves to 6.1.3, and a later bump
  of the root moves buildSrc with it.

Checked both ways with a throwaway `fun probe() = 42` test in buildSrc. On dev it passed
silently (exit 0); with this change it fails with "must not return a value". buildSrc's own
43 tests pass, the root build configures, and ktlint and detekt are clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…t only as a command (#1655) (#1698)

* fix(mcp): record escalated shell calls in the ledger, and match format only as a command

Two of the #1655 leftovers the Linux seat testing still hits.

The ledger row of a CRITICAL shell call that a saved ALLOW did not cover
now carries escalated = true. Under YOLO mode such a call is answered
YOLO_ALLOWED with policyApplied = ASK, exactly like a routine one, so an
audit could not tell a destructive call that ran unattended from any
other. The field is hashed only when set, like secretRefs, so every
existing record keeps its hash; boss mcp ledger tail/search show it.

The registry states the escalation instead of inferring it from
policy != savedPolicy, so the prompt and the ledger row read one answer.

The evaluator matched the text "format " anywhere, which rated
--format json, clang-format and English typed through send_input
CRITICAL and made a saved Always Allow ask again for all of them. format
now counts only as the command word of a segment, or when followed by a
drive such as d:.

Refs #1655

* fix(mcp): address the #1698 review - format with leading switches, and the escalated flag's scope

- format with its switches before the volume (cmd /c format /q e:) rated
  HIGH, because only the token right after format was checked for a
  drive. The whole rest of the segment is scanned now; none of the false
  positives the change removed carries a drive letter.
- The escalated flag means the destructive-shell gate overrode a saved
  ALLOW, not that a call was destructive. Under the default ASK policy a
  destructive call YOLO answers records false. The record KDoc and
  AGENTS.md now say so, and a test pins that case. The KDoc also names
  the secret-policy case and the downgrade caveat for ledger verify.
- authorizeInvocation's escalated parameter loses its default, so a new
  caller has to answer.
…, and why buildSrc pins the BOM (#1710)

Requested on #1709. junit.platform.discovery.issue.severity.critical is silently ignored on
JUnit Platform older than 1.13, and a passing run looks the same either way. buildSrc's BOM
pin (#1709) is what makes its guard live, so AGENTS.md now says so, and says how to check
a module with a throwaway value-returning test. That keeps a later "redundant dependency"
cleanup from quietly making the guard inert again.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…le's content (#1629, #1695) (#1703)

* fix(logging): log a failed decode by type and path, never with the file's content

kotlinx puts the document it failed on into the exception message, and
even the diagnostic before it can quote a value. Logging those exceptions
as error = e wrote visited URLs with their query strings (recent pages,
#1629), the domains a user zoomed, and keymaps into the log that people
attach to bug reports (#1695).

decodeFailure(e) returns log fields with only the exception type, the
offset and the JSON path, with map keys masked since the zoom settings key
by domain. The recent-pages load and history bootstrap, the three
corrupt-file paths from #938 and the keymap import now log it instead of
the exception. SerializationException is caught ahead of the general
catch, which it would otherwise fall into as an IllegalArgumentException.

Fixes #1629
Refs #1695

* fix(logging): address the #1703 review - read markers from the diagnostic only, never throw

- The offset and path are read from the diagnostic, never from the
  document kotlinx appends after "JSON input:". The offset must lead the
  message, before anything kotlinx quotes, and an out-of-range number is
  dropped with toIntOrNull: a throw here ran inside the caller's catch
  and skipped its recovery.
- The path comes from the LAST marker on the first line, since kotlinx
  appends the genuine one after any value it quotes, and after masking it
  must be pure structure or it is left out. A marker spelled by a value
  or hidden in a key is not logged.
- Map keys are masked by index from the first [' to the last '], so a
  key holding '] or a Unicode line separator cannot end the mask early.
- The rest of the #1629 data and its neighbours: UrlHistoryManager (the
  browser-history.json that recent pages bootstraps from), ResolvedHostsStore,
  RecentFilesManager and SelfHealingSettingsManager's settings load now
  log decodeFailure(e) too.

* fix(logging): stop user_data.json decode failures logging the user's email and id

Review on #1703: UserDataStorage's two corrupt-record arms logged the
decoder's exception, whose message quotes the record - the user's email
and id. Both now log decodeFailure(e), and a test drives a torn record
through both paths and checks no entry carries the exception, the email
or the id.

Also from the review:
- A quoted value with a newline moved the genuine path off the first
  line, leaving the value's own marker last. An odd number of quotes
  before the marker means the line ends inside a value, so the path is
  left out.
- The offset scan is bounded to the start of the diagnostic.
- The KDoc says a non-identifier serial name drops the path too, and the
  space-in-key test comment describes the current masking.

* fix(logging): cover the last two user_data.json decodes

Review on #1703: loadUserData (the startup path, at ERROR) logged the
decoder's exception, and readStoredWizardFlag logged its toString(),
which is the same message by another route. Both quote the record, the
user's email and id. Both now log decodeFailure(e).

The torn-record test now drives all four decodes - loadUserData,
saveUserData's stored-flag read (at DEBUG), and the two wizard paths -
and checks each logs once with no exception, and no entry carries the
email or id.

Also: the odd-quote KDoc says it is a heuristic a value's own
apostrophes can defeat, bounded by the structural check; and the
AGENTS.md bullet forbids the decoder's message by any route and points
at #1711 for the older sites.
…ll that already ran (#1712)

* fix(mcp): keep a long argument from crashing the audit record of a call that already ran

McpArgumentSanitizer.sanitizeMessage builds the ledger record inside invoke's
finally, after executeAuthorized has run, and the approval request before a
prompt. Its PEM rule repeated three GROUPS greedily - `(?:\s|\\n)*`, the
header-line body and the header lines themselves - and java.util.regex recurses
once per iteration of a greedy group repeat. `-----BEGIN PRIVATE KEY-----`
followed by 4,000 spaces overflows a default thread stack. The schema gate
ignores keys no schema declares, so a call under any ALLOW (saved rule, trusted
plugin, session trust, YOLO) could carry that payload next to its real command:
the tool ran, the ledger lost the row, no dropped-write was counted, and invoke
threw a StackOverflowError to its caller. With a prompt, the approval path
overflowed first and the call died unlogged. sensitiveAssignment's name suffix
`(?:[_-][A-Za-z0-9]+)*` overflows the same way on `password_a_a_a...`.

Every repeated group in the rules is now possessive (`*+`), which the engine
runs as a loop. Nothing that may follow any of those groups can be a character
the group consumed, so no match needs one of them to give anything back, and
the redactions are unchanged: the fuzz, auth-scheme, credential-shape, URI and
secret-reference suites pass as before.

sanitizeMessage also catches StackOverflowError and records
`[OMITTED: could not be sanitized]`: a backstop for a rule added later without
that care, which withholds the value rather than showing it raw.

McpArgumentSanitizerStackSafetyTest states the property: every rule's trigger
(25) followed by every pathological filler (18), at the ledger's 16,384-character
cap and at four times it, sanitizes on a 256 KB stack without throwing and
without reaching the backstop. Against dev it fails 62 cells, all
StackOverflowError, and both registry-level tests (a call run under ALLOW keeps
its ledger row; a prompted call reaches the operator and the ledger) fail with
the StackOverflowError escaping invoke.

AGENTS.md records the rule for anyone adding a sanitizer pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: leave AGENTS.md to the maintainers

The rule this change relies on is stated where it lives, in the KDoc beside
the code; AGENTS.md policy text is not merged from contributor PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…st before dispatch (#1716)

invokeCapability checked only that the target process was registered,
never that it advertised the requested action, while listCapabilities
advertised from the same registeredProcesses table - so the set the
kernel dispatched was strictly wider than the set it advertised. Check
the requested action against the registered process's own manifest at
the invokeCapability seam, before the broker is consulted:

- read registeredProcesses[pluginId] once;
- refuse "Process not found: <id>" when absent;
- refuse "Plugin <id> does not advertise capability: <action>" when the
  manifest has no matching action;
- only then hand the request to the broker. Advertise and dispatch now
  agree by construction.

Refusals go through one refuseCapabilityInvocation helper: the kernel
log copy is neutralized (IpcLogText.neutralize) because the reason
carries caller-supplied ids, while the response keeps the raw reason
for the authenticated caller. The verdict itself lives in a
capabilityAdmissionRefusal helper, which keeps the single registry
read and invokeCapability inside detekt's ReturnCount limit.

Four files:
- KernelServiceImpl.kt: the admission check and the refusal helper.
- KernelCapabilityAdmissionTest.kt (new): 5 refusal tests ported from
  #1363 to the new seam - unadvertised action, sibling-namespace
  action, respawn-with-fewer-capabilities, evicted-after-failure
  process, granted call dispatched with exactly the admitted pair -
  over the real authenticated transport, with a recording broker that
  is asserted never-consulted on refusals.
- KernelCapabilityRpcTest.kt: the unwired-broker test now registers
  capability("echo") so it pins the wiring refusal one step past
  admission.
- KernelLogForgingTest.kt: a refused invocation's log line cannot
  forge kernel log records.

Supersedes #1363, whose ProcessRegistryCapabilityResolver seam #1153
retired; the portable refusal tests are ported with credit preserved
per fluck-boss's 2026-09-25 13:23 UTC note.
Host-side BrowserHandle.awaitBrowserCallsQuiescent implementation and tests. Plugin-facing SDK release and boss-plugin-api pin bump tracked in #1726. Co-authored contribution remains with sidharth-vijayan through PR #1063.
…e stable (#1715)

* fix(db): the plugin store's name sort sorts by name, and its pages are stable

search_plugins_internal ordered by one sort_key, and that key had three defects, all
visible to a store client:

1. p_sort_by = 'name' sorted nothing: its key was the constant 0, so every row tied. The
   host sends 'name' for PluginSortOrder.NAME, and the local repository really sorts by
   displayName, so the same choice meant different things by source.
2. Ties were unordered for every sort, and LIMIT/OFFSET over an unstable order lets two
   pages of one listing repeat a plugin or skip one.
3. Every row's JSON was built before the offset: its version, rating, count, tag and
   organisation lookups ran for each row, then all but one page were thrown away.

The function is restated from 20260923133000 with only the ordering and the order of work
changed. Rows are ranked in a CTE by (lower(display_name) for 'name', sort_key DESC,
plugin_id), which is a total order because plugin_id is unique. Only the page is joined
back and projected. The filters, JSON keys, total_count, signature and ACL are unchanged.

search_plugins_order_test.sql uses five plugins whose plugin_id order differs from their
name order. It pins the name order, including on a second page, and that three pages over
five tied rows see each row exactly once. It also pins the unchanged count, the exact JSON
key set, the last and past-the-end pages, the ACL, and the name sort through the anon
wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(db): replace an assertion that could not fail; cover the filters on both copies

Review follow-up on #1715.

- The "same request twice" assertion compared one deterministic query with itself, so it
  would have passed against the pre-fix function too. It now asserts what it reached for:
  page 1 at size 2 is the first two rows of page 1 at size 5, which an unordered tie cannot
  promise.
- The filter block is written twice, once for total_count and once for the page, and no
  test in supabase/tests passed p_tags or p_verified_only. New assertions check a tag
  filter and verified-only on both the page and total_count, so a copy that drops a filter
  from one of them now fails.
- The migration header warns against folding the count into `ranked` as
  count(*) OVER (): on a page past the end `ranked` has no rows, so total_count would read
  0 instead of the number of matches.

plan(15) matches the fifteen assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…st does (#1553)

FileSystemDataProviderProxy joined paths by hand ("$parent/$name") in
createFile, createFolder and rename, and returned that string to the plugin.
The in-process provider returns File(parent, name).absolutePath, so on Windows
the two disagreed on every call (mixed separators), and on every OS a parent
passed with a trailing separator produced a doubled one. rename of a path with
no parent also sent a bare name to the kernel, where the in-process provider
refuses with "Cannot determine parent directory".

FileSystemCreatedPathsTest drives the real proxy against a fake
FileSystemService: 5 cases, all failing on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…l, and when (#1733)

* feat(cli): boss mcp ledger secrets - which tools received a credential, and when

The ledger has recorded `secretRefs` on every governed call since #822, and
`boss mcp ledger tail|search` printed them per record, but nothing could answer
the question an operator asks before rotating a credential after an incident:
which tools and plugins received this secret, how often, and when last.

- `boss mcp ledger secrets` prints one summary per secret the matching calls
  referenced, most recently used first: calls that named it, how many handed it
  to a tool's handler and how many were withheld, the fields named, the tools
  (with counts) and plugins that asked, and the first, last and last-delivered
  times. `--json` gives the same as a machine-readable document.
- `--secret <id>` (any field of that secret) or `--secret <id>.<field>` (that
  field only) and `--provider <id>` filter `tail`, `search` and `secrets`. A
  selector that is not a secret id, or names a field a reference cannot, is
  refused rather than silently matching nothing; ids are matched case-folded, as
  the parser records them.
- "Delivered" is an exhaustive `when` over McpApprovalDisposition
  (`reachedHandler`), so a disposition added later is a compile error here
  rather than silently counted either way. Every approval disposition means the
  handler ran, because an approval withdrawn at the revocation or secret fence
  is recorded as POLICY_DENIED or SECRET_FORBIDDEN instead; CANCELLED_IN_FLIGHT
  had already started its handler, so it counts as delivered.

It reads only what the ledger records - references, never values - so it needs
no vault access and works with BOSS closed, like the other ledger actions. The
summary lives in its own McpLedgerSecrets object so McpLedgerCli keeps the size
its other actions need.

Tests: six in McpLedgerCliTest (delivered and withheld counted apart, one call
naming two fields counted once, a field selector, search by secret, field and
provider, the human report, selector validation, and "no secret" as an answer)
and two through Clikt in BossMcpCliTest (--file/--secret/--json end to end, and
a bad --secret failing with exit 1). docs/CLI.md documents the action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): let ledger --provider match a plugin, and say what the secrets report covers

Plugin-contributed tools record their provider as `<pluginId>::<providerId>`, so
`--provider secret-manager` matched nothing and an audit read "this plugin never received the
credential". A plugin id now matches every provider the plugin registered, and the scoped form
still matches one; the help text and docs say both.

- The human report labels provider ids `providers:` (the JSON key), prints which ledger it read,
  says a use older than the oldest rotated backup is not counted, and points at
  `boss mcp ledger verify`, on an empty report as well as a full one.
- Legacy CANCELLED, which predates the in-flight split, counts as a possible delivery: no record
  carrying a reference should have it, and for a credential audit over-reporting is the safe way
  to be wrong. The KDoc gives the dates that make it unreachable.
- A comment on the dotless-reference default, and the shadowing parameter renamed.

With the fixes reverted, 4 of 24 McpLedgerCliTest tests fail, including
`expected [vault_list, vault_get] but was []` for the plugin id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* cap Chromium archive extraction against a zip bomb

Extraction now refuses an archive with too many entries or too many bytes, counted from what is actually read and named in the entries and never from ZipEntry.size. Directory entries and long paths draw from the same byte budget as file content, so they cannot create directories past it. The limits are parameters of extractWithJava with constant defaults. The macOS ditto path is unchanged.

* count backslash as a path separator in the extraction budget

Extraction runs on Windows, where a backslash in an entry name separates path components. Counting only the forward slash charged a deep backslash path as one component while it created one directory per segment. Both separators are counted now, with tests for the charge and for a deep backslash archive being refused before anything is created. Also corrects the limits comment: entries cost inodes, not file handles.
* feat: add authenticated RPCs for terminal session sharing

* test: define terminal session RPC replacement contract
* feat: add terminal broadcast relay and account settings backend

* fix: harden relay admission accounting and review validation

* Fix relay queue boundaries and restore synchronization before production

* feat: restrict production relay credentials to ticket admission

* fix: return HTTP conflict for stale terminal preference revisions

* test: verify deployed terminal settings and relay admission
QIU-Guanzong and others added 4 commits September 26, 2026 11:18
Co-authored-by: Gavin-Yau <2695188238@qq.com>
…rash-recovery record (#1713)

* fix(workspaces): keep the Space set as fresh as the crash-recovery record, from one writer

Last_Session_Set.json was written only at a clean shutdown, and restore reads
it in preference to Last_Session.json. So after a session that restored a set,
worked and was then killed, the next launch brought back the clean shutdown
BEFORE it, and the watcher then wrote those older layouts over the only fresh
copy. Separately, every window's layout watcher wrote Last_Session.json, so a
secondary window's layout could replace the primary's recovery record: #19's
symptom by the in-session route, which LastSessionCoordinator only prevents at
shutdown.

LastSessionCoordinator.ownsSessionRecord(windowId) answers who may keep the
recovery files current during the session: the window a shutdown at that
moment would write for (the primary while it is open, else a live window, the
same choice saveOnProcessExit makes), and nobody while a live window is
protecting a refused restore. writeInSessionRecovery is the watcher's write:
the owner writes the record AND the set, built from the same liveSessionSet
expression the shutdown path uses; any other window writes neither. A window
running fewer than two Spaces removes an earlier set, as the coordinator does.

This replaces the earlier delete-on-first-record approach (reviewed on #1713):
there is no second writer of the set, no lock, no latch, and multi-Space crash
recovery survives restored tabs settling their titles instead of being retired
seconds after launch.

InSessionRecoveryTest drives the real coordinator and one WorkspaceManager per
"session" over a shared directory. With writeInSessionRecovery reduced to dev's
behaviour (every window writes the record, nothing refreshes the set) 3 of its
6 tests fail: the stale set wins, a secondary window writes, and a leftover set
outranks the record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): write the in-session recovery pair as one locked, uncancellable unit, set first

The owner's in-session write was two suspending writes, record then set. Closing a window
cancels its watcher before the coordinator hears of the close, so a cancellation between them
left a fresh Last_Session.json beside the stale Last_Session_Set.json that restore prefers: the
bug this change exists to fix, by another door. And the pair ran outside the shutdown write's
claim, so a Cmd+Q landing mid-pair could let the watcher's set land after the hook's, even
re-creating a set the hook had just deleted.

- LastSessionCoordinator.writeInSession: the pair is one blocking call, set first, under a lock
  the shutdown write also takes, and it re-checks the claim inside it, so a watcher arriving
  after the shutdown write adds nothing.
- writeInSessionRecovery runs it on Dispatchers.IO + NonCancellable.
- WorkspaceManager: the watcher's record write is split into a blocking file write and the list
  refresh, which stays on the caller's dispatcher; the suspend set helper is gone.
- Docs: LastSessionSet names the in-session path; ownsSessionRecord says a window closed
  mid-restore latches the same protection.
- Tests: a cancel mid-pair, a shutdown arriving mid-pair, a write after the shutdown write, and
  the refused-restore case now asserts the disk. Restored to the previous head's shape (record
  then set, two hops, no lock), 3 of the 8 fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): keep a failed in-session recovery write from ending the layout watcher

The review's one remaining item, plus the two clauses and the two tests it named.

- LastSessionCoordinator.writeInSession never throws, like claimAndWrite: the set's serialization
  runs outside the file write's own catch, and a throw from it reached the window's unsupervised
  layout watcher and ended it for the window's life, with no log line. It is now logged under
  LogCategory.WORKSPACE and answered false.
- writeInSessionRecovery keeps a set that cannot be built apart from a null set (which removes the
  file), logs it, and writes nothing, rather than letting it escape into the watcher.
- KDoc: the pair is whole because it is one non-suspending call (a cancel only lands at a
  suspension point); NonCancellable is what keeps a cancel before the IO dispatch from dropping
  the last change. writeLastSessionRecordBlocking says the list refresh is skipped when the caller
  was cancelled.
- Tests: a throwing write is answered false and the next write lands; an unbuildable set writes
  nothing; a cancel before the dispatch still records the change (pins NonCancellable); a write
  whose window closed while it waited for the lock is refused (pins the re-check inside it). With
  the four fixes reverted, exactly these four fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(keymap): show recovery notice after corrupt settings reset

Record the preserved settings path before windows exist and offer a one-time reveal action after startup. Keep the public corrupt-file helper contract and cover preservation and delivery in regression tests.

Refs #1695

* fix(keymap): retain recovery notices until acknowledged

* fix(keymap): isolate startup helper to avoid JVM class clash

* fix(keymap): show preserved recovery path and remove stale imports
Keep #1736 session identity rebinding and #1713 paired owner-only in-session recovery writes together. Remove the obsolete two-step recovery helper.
@supabase

supabase Bot commented Sep 26, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project pcnwqamqdnsadranufjv due to reaching the limit of concurrent preview branches.
Go to Project Integrations Settings ↗︎ if you wish to update this limit.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@fluck-boss

Copy link
Copy Markdown
Collaborator Author

@claude Please review release integration #1738 at exact head 88c07f339ecda18981d49660b722a62c1c614bac against current main 3170b381b83bf0ce6b3cd179baa92887be60804a. This is the merged tree of dev 4fa78542 and main, not the old #1728 diff. Focus on the sole textual conflict in BossAppStartupEffects.kt: it combines #1736's updateSessionSpace identity rebinding with #1713's owner-only paired writeInSessionRecovery; the obsolete saveSessionRecovery was removed. Check that a newly assigned single-Space identity is preserved on clean shutdown and hard kill, that the in-session set/record stay paired, and that the last-window/shutdown path and current WorkspaceManager APIs remain correct. Also check the semantic interaction of main's #1736 with all newly integrated dev changes, version 9.5.27/release notes, and security/behavior regressions. Please give a specific blocking/non-blocking verdict for this head; do not rely on the old #1728 review. App crash testing is separate and pending. No merge or release dispatch is authorized from this review request.

@claude

claude Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Claude finished @fluck-boss's task in 9m 52s —— View job


Review: release integration #1738 @ 88c07f33

Verified locally: HEAD = 88c07f33, parents 3170b381 (main) + 4fa78542 (dev), main is an ancestor. Reviewed with git diff HEAD^1 HEAD and git diff HEAD^2 HEAD so the merge's own decisions are separable from what each parent brought — not the old #1728 diff.


Verdict

NON-BLOCKING on the conflict resolution. The BossAppStartupEffects.kt resolution is correct and, in one respect, better than either parent. Two release gates are unmet and one comment should be fixed before merge; none is a defect in the merged logic.

Conflict resolution correctness ✅ non-blocking
Identity preserved on clean shutdown ✅ verified by inspection
Identity preserved on hard kill ✅ verified by inspection
Set/record pairing ✅ with one inherited gap (F3)
Last-window / shutdown path, WorkspaceManager APIs ✅ no dangling refs, no API drift
Release notes cover the merged tree ❌ gate (G1)
Green 🔨 Build & Test at this head ⏳ gate (G2)

The conflict resolution is right, and the identityFor change is load-bearing

The merged settle job is:

updateSessionSpace(write.current, splitViewState)      // main / #1736
writeInSessionRecovery(                                 // dev / #1713
    windowId = windowId,
    record = write.record,
    set = { liveSessionSet(splitViewState, selectedProject.path, defaultWorkingDirectory) },
)

composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppStartupEffects.kt:839-848

I traced the identity chain end to end and it holds:

  1. layoutWatcherWrite returns current = record (id last-session) for a window with no Space.
  2. updateSessionSpace → prepareSessionSpace → sessionSpaceIdentity mints a new id + "Recovered Space" when enableLastSessionSpace is false, then rebindCurrentWorkspace(space.id) and updateCurrentWorkspace(space). Both are plain synchronous assignments (SplitView.kt:424, WorkspaceManager.kt:787).
  3. liveSessionSet therefore reads the new id from splitViewState.currentWorkspaceId for activeWorkspaceId, and extractRunningWorkspaces reads it from running.workspaceId. MINIMUM_SET_SIZE = 1 (main's value survived the merge, LastSessionSet.kt:100), so a one-Space window writes a set — which is what carries the id that Last_Session.json cannot. Pinned by LastSessionSetTest.kt:62.
  4. writeInSession writes set first, then record, so a kill mid-pair leaves the file restore prefers.

Ordering is the whole thing here and it is correct: updateSessionSpace runs before the set is built, so the set sees the rebound id. Reversing those two lines would silently write the pre-rebind identity.

The identityFor change is not cosmetic and is easy to mistake for one. The merge replaced dev's identityFor = { id -> workspaceManager.savedCopyOf(id) } with a currentWorkspace-first lookup (BossAppStartupEffects.kt:896-901). That is required, because prepareSessionSpace reaches WorkspaceManager.importWorkspace, which returns the new Space synchronously but persists it and inserts the list row inside a fire-and-forget scope.launch (WorkspaceManager.kt:625-639). At the moment liveSessionSet runs — the same tick — savedCopyOf(newId) can still be empty, so extractRunningWorkspaces would fall through to name = running.workspaceName.ifEmpty { running.workspaceId } and record the raw generated id as the Space's display name. updateCurrentWorkspace has already landed synchronously, so the currentWorkspace branch closes that window. The PR body's phrasing ("so a newly assigned identity is included") understates it slightly — the id comes from SplitViewState either way; what this fixes is the name/description, which is all identityFor supplies.

The shutdown path is safe, but for a non-obvious reason worth recording. claimAndWrite calls save(extractLayout()) → saveLastSessionBlocking, which overwrites _currentWorkspace.value with the last-session-stamped record (WorkspaceManager.kt:539), and only then calls window.extractSet() → liveSessionSet, which now reads currentWorkspace. The takeIf { it.id == id } guard is the only thing that stops the clobbered value being adopted as a running Space's identity: for a Space with a real id the guard fails and it falls back to savedCopyOf, which by shutdown has the imported row. Correct as written, but this is a coupling nobody would expect from reading either side alone — dropping the takeIf would corrupt the shutdown set specifically.

Other checks that came back clean:

  • No dangling references to the removed saveSessionRecovery or saveLastSessionRecord anywhere in the tree.
  • writeLastSessionRecordBlocking and saveLastSessionBlocking both resolve through fileNameFor(record) with the same last-session id, so the in-session and shutdown record writes target the same file.
  • noteLastSessionRecordWritten correctly sits after the withContext(Dispatchers.IO + NonCancellable) so a closing window skips the list refresh — matching its own KDoc.
  • writeLock is taken by both writeLastSession and writeInSession, no nesting, no deadlock.
  • ownsSessionRecord's canSave predicate matches the watcher's own workspaceRestorationComplete && !sessionRestoreRefused guards.
  • All 12 InSessionRecoveryTest methods use runBlocking<Unit>, satisfying the AGENTS.md "a @Test that returns a value never runs" rule — relevant because this merge moves that guard to allprojects.

Findings

F1 — liveSessionSet's KDoc contradicts the code it sits on (non-blocking, fix before merge).
composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppStartupEffects.kt:883

"Null for a window running fewer than two, which the single-Space record already describes on its own"

That is dev's rule. The merged code uses MINIMUM_SET_SIZE = 1 and returns a set for one Space — the identity fix is the entire point of #1736. The merge author correctly updated three of these comments (the extractSet call site at :163, the restore fallback at :611, and WorkspaceManager.loadLastSessionSet) and missed this one, which is the newly authored one sitting directly on the reconciled function. InSessionRecovery.kt:19 carries the same stale claim (inherited verbatim from dev), as does LastSessionCoordinator.register's extractSet param KDoc (pre-existing on main). In a repo where these comments are the spec, the risk is concrete: the next reader "fixes" sessionSetOf to match the comment and silently reverts #1736. Fix this →

F2 — the merge's own resolution has no test (non-blocking, strongest recommendation).
Neither parent's suite reaches it. InSessionRecoveryTest never calls updateSessionSpace and builds its sets from an explicit lambda, so the identityFor fallback and the rebind-then-extract ordering are untested; LastSessionSetTest covers sessionSetOf in isolation. The one behaviour this integration branch exists to establish — a window with no Space mints an identity, and both recovery files then carry it — is verified only by inspection. Given app crash testing is separately pending, a single test driving updateSessionSpace followed by writeInSessionRecovery against a real WorkspaceManager on a temp directory would be cheap and would pin the ordering that a future reader could reverse without any test noticing.

F3 — the in-session pair can still half-succeed without the caller knowing (non-blocking, inherited from #1713).
LastSessionCoordinator.writeInSession (:196-214) evaluates saveSet(set); saveRecord(record) and the block's value is saveRecord's, so a set write that returns false without throwing is dropped from the return value. writeInSessionRecovery then reports success and notes the record as written, while the stale set — which restore prefers — stays on disk. saveLastSessionSetBlocking does log its own failure at WARN (WorkspaceManager.kt:571), so this is not silent, but the pairing guarantee the reviewer asked about is "both or the set alone", and this is the one path that yields "record alone". Verbatim from dev, not created by the merge.

F4 — the two write paths order the pair oppositely (non-blocking, follow-up).
writeInSession writes set→record and documents why ("if the pair is ever cut short… the file that landed is the one restore reads"). claimAndWrite writes record→set (:262-267). By writeInSession's own argument the shutdown order is the wrong one: a kill between shutdown's two writes leaves a fresh record beside a set that is up to one settle interval stale, and restore prefers the set. The in-session writes shrink that window to ~2s, which is presumably why it was left, but having both orders in one class each documented as correct will not survive contact with the next editor.

F5 — WorkspaceManager.loadedFileNames is a plain LinkedHashMap read from two threads, and the merge adds an interleaving (PLAUSIBLE, non-blocking, pre-existing).
private val loadedFileNames = mutableMapOf<String, String>() (:161). The merge now runs, within one settle tick: updateSessionSpace → importWorkspace → scope.launch { … loadedFileNames[id] = fileName } on Main, immediately followed by writeInSessionRecovery → writeLastSessionRecordBlocking → fileNameFor reading the same map on Dispatchers.IO. A concurrent read during a rehash can misreport, and the consequence is the record written to last-session.json instead of a legacy Last_Session.json that restore reads. The class already had a worse version of this (the shutdown hook mutates the map from its own thread), so this is a pre-existing hazard newly widened rather than introduced — ConcurrentHashMap would close the class of problem.

F6 — prepareSessionSpace's checkNotNull sits above code that must not throw (theoretical).
SessionSpaceIdentity.kt:17 throws if importWorkspace returns null; it is called from the watcher's saveJob, one line above writeInSessionRecovery, whose KDoc goes to some length to guarantee "an exception escaping into it would end the watcher for the rest of the window's life". In practice importWorkspace returns null only if WorkspaceSerializer.deserialize throws on a string it just serialized, so this is close to unreachable — but the merge is what placed an unguarded line above a function built around that invariant.


#1736 × the integrated dev changes

Checked the pairings that could actually interact; no regressions found.

  • enableLastSessionSpace (main) × dev's corrupt workspace-settings recovery. Dev's new SerializationException branch in DesktopWorkspaceSettingsManager.loadSettingsSync resets to WorkspaceSettings(settingsVersion = CURRENT), i.e. enableLastSessionSpace = false — the same value a fresh install and every existing file get, so a corrupt settings file lands a user in the documented default rather than a new state. I specifically checked for an ordering race, since prepareSessionSpace reads currentSettings.value synchronously: the load is synchronous in init (:52-59), so there is no window in which a user who enabled the slot is read as having disabled it. Clean.
  • visibleWorkspaces (main) has no dev-side caller that collides.
  • junit.platform.discovery.issue.severity.critical moved composeApp→allprojects (dev). Main's eight new tests are all in composeApp, which already carried the property, so the move cannot newly break them. The move can surface pre-existing value-returning tests in other modules — that is G2's job, and the property's JUnit-1.13 floor means it is inert in modules that resolve an older platform, as AGENTS.md now records.
  • detekt baseline. The four baselined entries touching these files key on signatures and literal strings, not the KDoc the merge edited, so no entry is invalidated. WorkspaceManager's TooManyFunctions and BossAppStartupEffects's LongMethod/CyclomaticComplexMethod are baselined and their signatures are unchanged. The one baseline edit in the diff is a KeymapSettingsManager.loadSettingsSync signature refresh.
  • No new over-120-character lines in any touched session-recovery file; the three that exceed it are the pre-existing baselined ones.
  • Em-dash guard: clean. No U+2014 on any added *.md/*.html line across the whole origin/main...HEAD diff, so build.yml's added-lines check will not fail.

Spot-checked the dev security changes for merge-induced breakage rather than re-reviewing them: McpArgumentSanitizer's possessive-quantifier fix plus its StackOverflowError backstop, McpPolicyEngine.isProviderDenied (deny-only legacy inheritance, resolved at runtime rather than copied — correct per the AGENTS.md asymmetry rule, and all four former providerRules[x] == DENY sites were converted), McpRiskEvaluator.isFormatCommand (single call site, DESTRUCTIVE_WORDING has exactly one consumer so dropping "format " opens no gap; VOLUME = [a-z]:.* is a full matches so https: cannot satisfy it), and FileSystemDataProviderImpl's Downloads widening (real-path comparison, component-wise Path.startsWith rather than string prefix, root-level Downloads refused, delete deliberately left home-only). Nothing there depends on anything main changed.


Release gates

G1 — release notes do not describe this tree. version.properties is 9.5.27 and docs/release-notes/v9.5.27.md exists, both from main; the merge added nothing to either, which matches the PR body. But that file documents #1736 only — I grepped all of docs/release-notes/ for 1713, 1725, 1734, 1737, terminal-relay and terminal broadcast and got zero hits. So the ~90 files of dev content in this tree (#1713 session recovery, #1725 keymap recovery notice, the Cloudflare terminal relay, the MCP ledger CLI, Chromium extraction limits, decode-failure logging, atomic settings writes, relocated-Downloads access) are undocumented. Confirming the PR body: do not dispatch with skip_version_increment=true — the next version must be incremented and its own notes generated from the merged range, not from main's.

G2 — 🔨 Build & Test has not completed at this head. ⚡ Edge Functions ✅ and Filesystem resource limits ✅; 🔨 Build & Test and Authenticated IPC boundaries were still in progress when I finished. I could not compile locally — this runner's JDK is 11 and the build needs 17+, the same limitation the PR body reports — so compilation and the full test suite are unverified by me and G2 is the only signal for them. Worth watching specifically because of the allprojects JUnit discovery-guard move, whose failure mode is loud but module-wide: a DiscoveryIssueException makes every test in the affected module vanish and reports one initializationError in their place.

Nothing here authorizes a merge or a release dispatch, and app crash testing remains separate and pending.
· branch release-integration-20260926

@fluck-boss

Copy link
Copy Markdown
Collaborator Author

@claude Follow-up at new exact head 46fe12aeb651fb907899e726a9e860939104f18c: the three stale single-Space set KDocs from your review at 88c07f33 have been corrected in BossAppStartupEffects.kt, InSessionRecovery.kt, and LastSessionCoordinator.kt. Please confirm the comment fixes match the code and review this new head for any newly introduced blocker; CI is rerunning. The test suggestion and inherited pair-write/shutdown-order risks remain noted separately; no merge or release dispatch yet.

@claude

claude Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Claude finished @fluck-boss's task in 2m 38s —— View job


Follow-up review: release integration #1738 @ 46fe12a

  • Scope the new commit (88c07f33..46fe12a)
  • Verify the three KDoc fixes match the code
  • Check for newly introduced blockers (detekt baseline, ktlint, em-dash guard)
  • CI status at this head
  • Verdict

Verdict

No new blocker. F1 is resolved. 46fe12a is comments only - git diff 88c07f33 46fe12a is 9 insertions / 9 deletions across exactly the three files named, no code, no version.properties, no release notes, and it is an ordinary commit on the branch so merge parentage (3170b381 + 4fa78542) is unchanged. Everything in my 88c07f33 review stands as written; only F1 moves.

F1 - three stale single-Space KDocs ✅ resolved, all three now match the code
Newly introduced blocker at this head ✅ none found
detekt baseline invalidation ✅ none (checked, see below)
ktlint MaxLineLength / em-dash guard ✅ clean
Prior gates G1 (notes) / G2 (Build & Test) ⏳ still open, unchanged by this commit
F2 (no test for the resolution), F3, F4, F5, F6 ⏳ still open as previously described

The comment fixes match the code

I re-derived the contract from sessionSetOf rather than from the prose:

internal fun sessionSetOf(spaces, activeWorkspaceId): LastSessionSet? =
    activeWorkspaceId
        ?.takeIf { spaces.size >= MINIMUM_SET_SIZE && spaces.any { space -> space.id == it } }
        ?.let { LastSessionSet(activeWorkspaceId = it, spaces = spaces) }

/** Even a single Space needs its original identity on restart. */
private const val MINIMUM_SET_SIZE = 1

composeApp/src/commonMain/kotlin/ai/rever/boss/components/workspaces/LastSessionSet.kt:148-163

So the true rule is: a set for one or more Spaces; null only when the active id is absent, or names no Space in the list (spaces.size >= 1 is subsumed by the any once the id must match, but it is the explicit floor). All three edits now say that:

  • BossAppStartupEffects.kt:883 - "Even one Space needs a set to preserve its id; an empty session or unmatched active id produces no set." ✅ Sits directly on liveSessionSet, which is the function whose behaviour the old text denied.
  • InSessionRecovery.kt:19 - "One Space produces a set to preserve its id; an empty session or unmatched active id produces null and removes a stale set an earlier session left." ✅ And I verified the removal half of that sentence rather than taking it on trust: writeInSession → saveSet(null) → saveLastSessionSetBlocking(null) → fileManager.writeDocumentBlocking(LAST_SESSION_SET_FILE, null), and writeDocumentBlocking's content = null is the documented remove verb (WorkspaceManager.kt:565-579). The function body's own inline comment already said "a null result is a real answer (remove the set)", so the KDoc and the code comment now agree instead of contradicting.
  • LastSessionCoordinator.kt:88-93 (the extractSet param) - "Even one Space produces a set to preserve its id; an empty session or unmatched active id produces null." ✅ extractSet's default is still { null }, which is the "this window supplies no set" case and is unaffected by the wording.

All five sites are now consistent, which was the actual risk in F1 (a reader "fixing" sessionSetOf to match a comment and silently reverting #1736). grep -n 'fewer than two|needs no set|already describes on its own' across the tree returns one hit and it is an unrelated test name (ReloadJarPathTest.kt:262). The two the merge author had already fixed still read correctly: the extractSet call site at BossAppStartupEffects.kt:163-165 and the restore-fallback comment at :610-616, plus WorkspaceManager.loadLastSessionSet's "New builds write a set even for a single Space to retain its id" (:585). MINIMUM_SET_SIZE = 1 is unchanged at this head, and LastSessionSetTest still pins it.

AGENTS.md's own paragraph already reads "A set is written for one or more Spaces. Even one Space needs its own identity restored", so the code comments and the repo spec now say the same thing. Nothing needed there.


Nothing newly introduced

I checked the three things a comment-only commit can still break in this repo:

  • detekt baseline. This is the real hazard for a KDoc edit - AGENTS.md records that baseline signatures embed KDoc and string text, so editing a comment inside a baselined declaration invalidates its entry. It does not happen here. The only LastSessionCoordinator.kt entry is TooGenericExceptionCaught:LastSessionCoordinator.kt$LastSessionCoordinator$e: Exception, a catch-parameter signature. The BossAppStartupEffects.kt entries key on @Composable internal fun BossAppStartupEffects(state: BossAppState) (Cyclomatic/LongMethod), three MaxLineLength entries keyed on literal lines none of which changed, and one e: Exception. The edited KDoc sits on the private top-level liveSessionSet and on register's extractSet parameter, neither of which appears in the baseline at all. InSessionRecovery.kt has no baseline entries.
  • MaxLineLength. No line over 120 characters in any of the three files; the longest added line is ~96.
  • Em-dash guard. No U+2014 on any line added by 46fe12a. The em-dashes that exist in LastSessionCoordinator.kt (:25, :28, :262) and BossAppStartupEffects.kt are pre-existing code comments, which AGENTS.md puts out of scope entirely, and the guard only inspects added *.md/*.html lines regardless. The new prose uses spaced hyphens.

One cosmetic nit, explicitly not worth blocking on

The phrase "unmatched active id" covers two of sessionSetOf's three null paths precisely and the third only by implication: activeWorkspaceId is splitViewState.currentWorkspaceId, a String?, and a window with no Space loaded passes null, which is absent rather than unmatched. That is the ordinary state the previous review's shutdown-path analysis turns on, so it is the one a reader is most likely to look this comment up for. LastSessionSet.kt's own KDoc words it as "Empty sessions or an absent active id". Purely a wording nit - the behaviour is identical either way and "an id naming no running Space" is a fair reading of the phrase. Fix this →


Still open from the 88c07f33 review

Carried forward unchanged, since this commit touches no code:

  • F2 - the merge's own resolution (rebind-then-extract ordering, and the currentWorkspace-first identityFor fallback) has no test. Strongest recommendation, and the comment fix does not substitute for it: a comment cannot fail when someone reverses those two lines.
  • F3 - writeInSession's saveSet(set); saveRecord(record) block yields saveRecord's value, so a non-throwing false from the set write is dropped and the pair reports success. Inherited from fix(workspaces): stop an earlier session's Space set overruling the crash-recovery record #1713.
  • F4 - writeInSession writes set→record, claimAndWrite writes record→set. Both documented as correct.
  • F5 - WorkspaceManager.loadedFileNames is a plain LinkedHashMap read across Main and Dispatchers.IO. Pre-existing, newly widened.
  • F6 - prepareSessionSpace's checkNotNull sits one line above a function built on "nothing may throw into the watcher".

Release gates

  • G1 - unchanged. version.properties is still 9.5.27 and docs/release-notes/v9.5.27.md still documents feat(macOS): add native title-bar navigation and sidebar controls #1736 only; the ~90 files of dev content in this tree remain undocumented. Do not dispatch with skip_version_increment=true.
  • G2 - ⚡ Edge Functions ✅ at this head; 🔨 Build & Test, Authenticated IPC boundaries and Filesystem resource limits are in progress (runs started 18:56Z). I still cannot compile locally - this runner's JDK is 11 and the build needs 17+ - so G2 remains the only signal for compilation and the suite, and the allprojects JUnit discovery-guard move is the thing to watch there.

App crash testing remains separate and pending. Nothing here authorizes a merge or a release dispatch.
· branch release-integration-20260926

fluck-boss and others added 2 commits September 26, 2026 12:54
…crashing (#1739) (#1740)

* fix(crash): swallow supabase-kt heartbeat/disconnect race instead of crashing (#1739)

supabase-kt 3.8.0 nulls the realtime socket before cancelling the
heartbeat job, so a per-tick sendHeartbeat child already in flight hits
the throwing websocket getter and escapes the library scope uncaught
(upstream #1394, fixed but unreleased by #1395). The heartbeat loop is
a SupervisorJob sibling and keeps ticking, and reconnect rebuilds the
socket, so one missed heartbeat must not pop the fatal crash dialog.

Extend CrashHandler.isIgnorable with the heartbeat-path sibling of the
existing stale-rejoin containment: exact message plus the two-frame
prefix RealtimeImpl.getWebsocket -> RealtimeImpl.sendHeartbeat, which
is unique to the tick child (sendHeartbeat is private upstream).
Ignorable-test fixtures come from the #1739 release-testing trace.

* refactor(crash): extract realtime containments, pin the two-frame boundary

detekt flagged CrashHandler as LargeClass with the new matcher, so both
supabase-kt realtime containments move to CrashIgnorableRealtime.kt,
each rule keeping its evidence comment. In the ignorable test, replace
the redundant app-frame lookalike (already covered by the renamed-
package assertion) with the cases that pin this rule's actual
boundary: an empty stack stays fatal, and an app frame past the
two-frame prefix stays ignorable by design.

* style(crash): blank line between header and first containment

---------

Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>
@fluck-boss

Copy link
Copy Markdown
Collaborator Author

@claude Re-review release integration #1738 at new exact head 5355cfc830128f42839f329d5d1ab64f708416ae (parents: prior reviewed integration 46fe12ae, current dev 754711e). This adds merged #1740's supabase-kt heartbeat crash containment and its tests, with no merge conflict; inspect its interaction with the integrated tree and verify current main ancestry, version/notes, and any new release blocker. Earlier 46fe12ae review is not a verdict on this head. Exact-head CI is running, and the signed-in crash re-smoke is separate and pending. No merge or release dispatch from this request.

@claude

claude Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Claude finished @fluck-boss's task in 4m 42s —— View job


Re-review: release integration #1738 @ 5355cfc8


Verdict

No new blocker at this head. The #1740 containment is correct and correctly narrow. The two release gates from the prior reviews are unchanged and still unmet; G1 is now slightly worse because #1740 is a user-visible crash fix that this tree's notes also do not mention. Nothing here is a defect in the merged logic.

Merge parentage, main ancestry ✅ verified
Merge contributed no resolution of its own ✅ delta is byte-identical to 754711e6
#1740 containment correctness / narrowness ✅ non-blocking
No existing negative assertion weakened ✅ verified frame-by-frame
detekt baseline / ktlint / em-dash guard ✅ clean
Release notes cover the merged tree ❌ gate (G1), now also missing #1740
Green 🔨 Build & Test at this head ⏳ gate (G2)
F2-F6 from 88c07f33 ⏳ still open, untouched by this commit

Ancestry and scope

HEAD          5355cfc8   Merge remote-tracking branch 'origin/dev' into HEAD
  parent 1    46fe12ae   (prior reviewed integration head)
  parent 2    754711e6   fix(crash): ... (#1739) (#1740)
                 parent  4fa78542   (the dev head already reviewed)
origin/main   3170b381   ancestor of HEAD: YES

754711e6 has a single parent, 4fa78542, so the dev side advanced by exactly one commit and brought nothing else with it. More usefully: the merge made no decisions of its own. git diff 46fe12ae HEAD and git show 754711e6 are the same three-file, +130/-29 change, so there is no resolution to review here - only #1740's own content, now sitting on the integrated tree. The BossAppStartupEffects.kt / session-recovery reconciliation that the previous two reviews turned on is untouched at this head (git diff 46fe12ae HEAD names no file under app/ or components/workspaces/).


The containment is right, and the narrowness is load-bearing

The change splits the two supabase-kt realtime rules out of CrashHandler into CrashIgnorableRealtime.kt (isStaleRealtimeRejoin moved verbatim from private in the object to internal top-level in the same package), and adds one new term to isIgnorable:

isStaleRealtimeRejoin(t) ||
isRealtimeHeartbeatRace(t) ||

composeApp/src/desktopMain/kotlin/ai/rever/boss/crash/CrashHandler.kt:220-221

The new rule requires all three of: exactly IllegalStateException, exactly the message "Websocket not yet initialized", and frames 0-1 being exactly RealtimeImpl.getWebsocket then RealtimeImpl.sendHeartbeat, anchored at index 0 (CrashIgnorableRealtime.kt:53-67). The two-frame prefix rather than three is the one judgement call in the diff, and it is both argued in the KDoc (the third frame varies with coroutine stack recovery) and pinned by a test that asserts an app frame past the prefix stays ignorable (CrashHandlerIgnorableTest.kt:143-150). That is the right shape: the message is only ever produced by the library's own throwing getter, so the frames the rule refuses to look at cannot change who threw.

I checked frame-by-frame that the new term weakens no existing assertion, which was the one way this could have gone wrong - the two rules share frame 0, so a rule matching only getWebsocket would have silently swallowed the cases the rejoin rule's tests exist to keep fatal. They diverge at index 1 (RealtimeChannelImpl.unsubscribe vs RealtimeImpl.sendHeartbeat), so:

  • direct unsubscribe without a connection is still a crash (:155) - frames are getWebsocket / RealtimeChannelImpl.unsubscribe, no heartbeat match. Still false. ✅
  • other errors on the retry path remain visible (:165) - same, plus wrong type and wrong package variants. Still false. ✅
  • the installed-SDK test's directFailure from a real channel.unsubscribe() (:91) - the assertion that actually exercises the live SDK's frames rather than hand-built ones. Still false. ✅
  • matching message from application code is still a crash (:231) - real test-class frames, no match. ✅

And the new negatives are the right set: wrong message, wrong type, wrong package, empty stack (:120-142). Both new tests are block-bodied funs, so they return Unit and actually run - the trap AGENTS.md records, and relevant here because this tree moves that discovery guard to allprojects.

Other checks that came back clean:

  • No dangling references. isStaleRealtimeRejoin / isRealtimeHeartbeatRace are referenced only from isIgnorable and their own declarations; nothing else in the tree (including the detekt baseline XML) names the moved function.
  • detekt baseline intact. CyclomaticComplexMethod:CrashHandler.kt$CrashHandler$internal fun isIgnorable(throwable: Throwable): Boolean is baselined on an unchanged signature, so the extra || term is still suppressed; TooManyFunctions:CrashHandler.kt$CrashHandler is keyed on the object and only loses a member. No baseline entry existed for the removed private function, so nothing is stranded. The new file introduces no finding of its own (two returns, trivial complexity, comments ruleset inactive).
  • ktlint. No line over 120 characters in either new or changed file (.editorconfig allows 140); the file ends with a newline.
  • Em-dash guard. No U+2014 on any line added by this merge, and no *.md/*.html touched at all, so build.yml's added-lines check is unaffected. The new prose uses spaced hyphens. (The em-dashes that remain in CrashHandler.kt are pre-existing code comments, out of scope by AGENTS.md.)
  • Interaction with the integrated tree: none found. The three files touch nothing the dev or main side changed - CrashHandler.kt's isIgnorable was not modified by either parent, and the only other integrated crash-path work in this tree is CrashDisposition/chainOfCauses, which this change consumes unchanged. chainOfCauses is the shared bounded, cycle-guarded walk (CauseChain.kt:16), so extending isIgnorable inherits its twelve-cause bound rather than adding a new unbounded traversal.

Findings (all non-blocking)

N1 - the heartbeat rule has no SDK-drift guard, where the rejoin rule does.
composeApp/src/desktopTest/kotlin/ai/rever/boss/crash/CrashHandlerIgnorableTest.kt:78-96

installed SDK delayed rejoin produces the recognized failure drives the real supabase-kt and asserts the resulting failure is recognized, so a version bump that renames or restructures the rejoin path fails a test. The heartbeat rule has nothing equivalent - both its tests build StackTraceElements by hand, and the KDoc correctly says the frames were copied from the #1739 crash trace rather than inferred, which is the best available evidence but is not a guard. The failure mode is the asymmetric-bad one: after a supabase bump, isRealtimeHeartbeatRace silently stops matching and #1739 comes back as a fatal crash dialog with every test still green. That matters more than usual here because libs.versions.toml:47-58 already documents supabase/ktor as a coupled pin someone will bump, and the KDoc names the upstream fix (supabase-kt#1395) as landed-but-unreleased, i.e. a bump is expected. sendHeartbeat is private so it cannot be called from a test, but a reflection assertion that RealtimeImpl declares a method of that name would cost three lines and turn a silent regression into a red test. I could not verify the member against the installed jar myself - no dependency cache on a fresh checkout and no network - so this is also the one claim in the diff I am taking on the author's evidence rather than checking. Fix this →

N2 - the two rules duplicate their matcher exactly (simplification).
composeApp/src/desktopMain/kotlin/ai/rever/boss/crash/CrashIgnorableRealtime.kt:22-67

Both functions carry the same type-and-message guard, the same realtimePackage constant and the same nine-line expected.withIndex().all { … frames.getOrNull(index) … } prefix walk; only the expected list differs. The file's own KDoc says the split exists "so each rule carries its evidence comment beside the match", which is a good reason to keep two declarations and is fully compatible with factoring the matcher into one matchesRealtimeFramePrefix(throwable, expected) - each rule would then be its comment plus its frame list, which is the only part that is actually evidence. As it stands a fix to the walk (say, tolerating a Coroutine boundary frame) has to be made twice, and a third containment would copy it a third time.

N3 - inherited: isIgnorable matching anywhere in the cause chain means an unrelated fault that merely wraps this ISE is swallowed too.
composeApp/src/desktopMain/kotlin/ai/rever/boss/crash/CrashHandler.kt:210-211

The new test deliberately pins RuntimeException("heartbeat tick failed", realtimeHeartbeatRace()) as ignorable, which is right for the real wrapping the library does. The consequence is that any exception with this ISE anywhere in its (up to twelve-deep) cause chain is now also swallowed, including a genuine host fault that captured it as a cause. This is exactly the existing semantics of every other term in isIgnorable - the rejoin rule and TokenExpiredException have the same property, and the KDoc states it - so it is inherited rather than introduced, and mentioned only because this commit widens the set of chain members that can trigger it by one.


Version and release notes

G1 - unchanged, and now one item worse. version.properties is still app.version=9.5.27 with app.build.date=2026-09-26; docs/release-notes/v9.5.27.md still opens on #1736 and documents #1736 only. I grepped the whole of docs/release-notes/ for 1739, 1740, supabase-kt heartbeat and heartbeat - the only hits are unrelated older releases (v9.2.2, v9.5.22, v8.17.20, v9.4.9). So this merge adds a user-visible crash fix (BOSS no longer dies with a crash dialog on a realtime heartbeat/disconnect race) to the pile of undocumented dev content already noted at 88c07f33 (#1713, #1725, the Cloudflare terminal relay, the MCP ledger CLI, Chromium extraction limits, decode-failure logging, atomic settings writes, relocated-Downloads access). Confirming the PR body: do not dispatch with skip_version_increment=true; the next version must be incremented and its notes generated from the merged range rather than inherited from main's.

G2 - 🔨 Build & Test has not completed at this head. At the time of writing, for the runs created 19:55Z: ⚡ Edge Functions ✅, and 🔨 Build & Test, Authenticated IPC boundaries and Filesystem resource limits all still in progress. I could not compile or run the suite locally (this runner's JDK is 11, the build needs 17+ - the same limitation the PR body reports and the prior two reviews recorded), so G2 remains the only signal for compilation and tests. Two things specifically worth watching there: the allprojects JUnit discovery-guard move, whose failure mode is module-wide (a DiscoveryIssueException makes every test in the module vanish behind one initializationError), and CrashHandlerIgnorableTest's installed-SDK test, which is the one test in this file that instantiates a real SupabaseClient and so is the most environment-sensitive.


Carried forward from 88c07f33 / 46fe12a

Unchanged - this commit touches no code either review covered:

  • F1 ✅ resolved at 46fe12a (three stale single-Space set KDocs), re-verified as still correct at this head.
  • F2 - the session-recovery merge resolution (rebind-then-extract ordering, currentWorkspace-first identityFor fallback) still has no test. Still the strongest recommendation.
  • F3 - writeInSession's saveSet(set); saveRecord(record) block yields saveRecord's value, so a non-throwing false from the set write is dropped and the pair reports success. Inherited from fix(workspaces): stop an earlier session's Space set overruling the crash-recovery record #1713.
  • F4 - writeInSession writes set→record, claimAndWrite writes record→set, both documented as correct.
  • F5 - WorkspaceManager.loadedFileNames is a plain LinkedHashMap read across Main and Dispatchers.IO.
  • F6 - prepareSessionSpace's checkNotNull sits one line above a function built on "nothing may throw into the watcher".

The signed-in crash re-smoke is separate and pending, and nothing in this review authorizes a merge or a release dispatch.
· branch release-integration-20260926

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.