Compare commits

...
1198 Commits
Author SHA1 Message Date
YHH e81658de81 Merge pull request #14939 from esengine/fx/notarize-staple-retry
fix(release): retry stapling when the connection to Apple drops
2026-11-02 00:27:53 -08:00
esengine 15a922a53c fix(release): retry stapling when the connection to Apple drops 2026-11-02 00:16:22 -08:00
YHH d05ef8a4f2 Merge pull request #14934 from esengine/fx/1x-protocol-compat-order
fix(provider): order compatibility evidence by request sequence, not clock readings
2026-11-01 22:07:52 -08:00
esengine 5154991f34 fix(provider): order compatibility evidence by request sequence, not clock readings 2026-11-01 21:50:02 -08:00
YHH 27113eb47d Merge pull request #14933 from esengine/docs/prepare-v1.39.11
docs(release): prepare v1.39.11 notes
2026-11-01 21:25:09 -08:00
esengine 65e1617575 docs(release): prepare v1.39.11 notes 2026-11-01 20:55:32 -08:00
YHH fae12d3ad5 Merge pull request #14928 from esengine/fx/1x-python-probe
fix(shellsafe): python -v is import tracing, not a version probe
2026-11-01 19:38:18 -08:00
esengine fbe60bb0a3 fix(shellsafe): python -v is import tracing, not a version probe
python3 -v script.py runs the script, but the read-only table lowercased the
second word and listed -v, so the call was allowed without approval. Match
python's version probes by exact spelling (--version, -V) at all three lookup
sites through one helper.

GHSA-qrrg-f72x-hx7r
2026-11-01 19:08:57 -08:00
YHH 0809983446 Merge pull request #14903 from esengine/dependabot/go_modules/main-v2/go-1x-3cca210df6
chore(deps): bump the go-1x group with 6 updates
2026-11-01 04:57:41 -08:00
esengine e5ea0f9e2f chore(deps): tidy the desktop module for the go-1x group bump 2026-11-01 04:27:50 -08:00
dependabot[bot] 53e1242122 chore(deps): bump the go-1x group with 6 updates
Bumps the go-1x group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/Microsoft/go-winio](https://github.com/Microsoft/go-winio) | `0.6.2` | `0.6.4` |
| [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `4.10.2` | `4.10.4` |
| [github.com/godbus/dbus/v5](https://github.com/godbus/dbus) | `5.2.2` | `5.2.4` |
| [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) | `0.0.30` | `0.0.31` |
| [github.com/pkg/sftp](https://github.com/pkg/sftp) | `1.13.11` | `1.13.12` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.48.0` | `0.49.0` |


Updates `github.com/Microsoft/go-winio` from 0.6.2 to 0.6.4
- [Release notes](https://github.com/Microsoft/go-winio/releases)
- [Commits](https://github.com/Microsoft/go-winio/compare/v0.6.2...v0.6.4)

Updates `github.com/bmatcuk/doublestar/v4` from 4.10.2 to 4.10.4
- [Release notes](https://github.com/bmatcuk/doublestar/releases)
- [Commits](https://github.com/bmatcuk/doublestar/compare/v4.10.2...v4.10.4)

Updates `github.com/godbus/dbus/v5` from 5.2.2 to 5.2.4
- [Release notes](https://github.com/godbus/dbus/releases)
- [Commits](https://github.com/godbus/dbus/compare/v5.2.2...v5.2.4)

Updates `github.com/mattn/go-runewidth` from 0.0.30 to 0.0.31
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.30...v0.0.31)

Updates `github.com/pkg/sftp` from 1.13.11 to 1.13.12
- [Release notes](https://github.com/pkg/sftp/releases)
- [Commits](https://github.com/pkg/sftp/compare/v1.13.11...v1.13.12)

Updates `golang.org/x/sys` from 0.48.0 to 0.49.0
- [Commits](https://github.com/golang/sys/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: github.com/Microsoft/go-winio
  dependency-version: 0.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/bmatcuk/doublestar/v4
  dependency-version: 4.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/godbus/dbus/v5
  dependency-version: 5.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/pkg/sftp
  dependency-version: 1.13.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: golang.org/x/sys
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-01 11:57:58 +00:00
YHH 493c6811af Merge pull request #14839 from esengine/fx/compaction-far-over-window
fix(agent): keep partial fold progress so a session far over the window can be brought back
2026-10-30 05:26:11 -07:00
esengine 4eeca4a58c fix(agent): keep partial fold progress so a session far over the window can be brought back
An automatic fold was discarded whenever its result was still at or above the
physical ceiling. A view several times the window can never get under it in one
summary request, so every digest was thrown away, the source kept growing and
compaction never recovered; only a manual /compact (which skips that check)
made progress.

A candidate that strictly shrinks the view is now accepted for every trigger,
and a pressure fold over the ceiling gets the same batch allowance as a manual
one, so repeated attempts converge.
2026-10-30 04:33:17 -07:00
YHH 3f1008d4ab Merge pull request #14228 from SivanCola/fix/historical-image-replay
fix: unblock text turns after image failures / 修复历史图片阻断文字对话及错误详情丢失
2026-10-27 23:35:22 -07:00
SivanCola 19abe6a878 fix: deduplicate image replay warnings within each turn
Problem: each sampling round persisted another warning for the same failed
historical image, growing history throughout a tool turn.

Root cause: image preprocessing correctly retries failures per request, but
notice emission had no turn-scoped ownership or cause identity.

Fix: bind a synchronized set of redacted causes to admitted turn contexts.
Native attachment and image-understanding failures share this notice gate;
each request still retries image processing and fresh turns reset the set.
Document the warning lifetime without changing persisted formats or model
request content.

Verification: full internal/control suite passed (242.7s), focused image
regressions passed under race detection, pinned golangci-lint v2.12.2 found
0 issues, repolint and docs/cache impact checks passed. Two-round tool-turn
tests verify one persisted notice, continued degraded requests, and reset
on the next turn for both native and image-understanding failures. Removing
the deduplication reproduces two notices in both regression cases.
2026-10-28 04:09:35 +08:00
YHH 1ff581ef86 Merge pull request #14506 from SivanCola/fix/session-switch-sidebar-followup
Fix topic archive fencing for canonical sidebar rows / 修复规范会话行的话题归档屏障
2026-10-25 20:48:28 -07:00
zumac 88c3a736ba refactor: give remote state publication and reviewed keys one owner
Problem: The generic remote event bridge recovered a tab ID by parsing the
event name to stamp hydration revisions, reviewed-version detection matched a
substring of the source key, and the integrity-reuse comment claimed the SQLite
header detects checkpoints that WAL-mode commits need not record there.

Root cause: Revision stamping was attached to the transport rather than to the
state publisher, and the reviewed-version key format had no named parser.

Fix: Route all twelve state publications through publishRemoteTabState, which
stamps the revision before emitting; emitRemoteEvent no longer inspects state
payloads. Parse reviewed keys with historicalVersionSourceKey, which requires
both a source key and a version. Correct the header comment: file identity,
size and mtime remain the primary change signal.

Verification: The reviewed-key test failed before the parser existed. Full
reasonix/desktop package (717s), remote hydration/publication and source alias
race tests, internal/projectiondb, go vet, gofmt and repolint passed.
2026-10-25 20:22:25 -07:00
zumac ddd0f11cd0 fix: keep topic archive tombstones fencing canonical session rows
Problem: Archiving a conversation through the topic path left its sidebar row
visible. A row that carries a SessionRef was neither optimistically removed
after commit nor fenced from runtime overlays or stale pages until reload.

Root cause: Canonical identity isolation made projectSessionExcluded answer
only from the SessionRef identity. Topic archive commits and optimistic removal
key their tombstone by topicId, which the canonical branch no longer read.

Fix: Check the topicId tombstone before resolving identity. Topic tombstones
hold raw topic IDs, not identity aliases, so lifecycle fence isolation between
canonical sessions sharing a legacy alias is unchanged.

Verification: The new canonical topic-archive case failed before the fix and
passes afterward. test:session-switch-regression, 30 adjacent sidebar/archive
suites, both frontend typechecks, eslint and repolint passed.
2026-10-24 21:28:29 -07:00
YHH 53b94a785e Merge pull request #14389 from SivanCola/fix/session-switch-sidebar
Fix session switching stalls and duplicate sidebar rows / 修复会话切换卡顿与侧栏重复
2026-10-24 20:29:30 -07:00
SivanColaandsituerte37 ab89a3d94e fix: scope remote hydration revisions to the desktop service lifetime
Problem: A surviving renderer could reject ready notifications after the Go
desktop service restarted because restored remote tab IDs kept their identity
while the per-tab publication revision restarted from zero.

Root cause: The hydration owner retained the old service's high-water revision
and installed subscription across native service-generation changes.

Fix: Retire hydration ownership when the native service becomes unavailable or
its generation changes, then use the existing desktop:resync read to adopt the
replacement service. Fence late state-RPC replies and baseline completions;
preserve resident content and avoid cleanup RPCs to a replacement service.
Keep same-service duplicate readiness and recovery signals coalesced.

Verification: The real-hook restart regression failed before the fix and passes
afterward, including missed shutdown events and stale state RPCs. Owner tests,
Chromium local/remote regressions, frontend typechecks, remote/transcript suites,
production build and lint passed. Recovery adds 311 raw initial bytes; the
measured raw ceiling preserves the existing producer margin and all compressed,
CSS, locale, chunk and residency limits remain unchanged.

Co-authored-by: situerte37 <[email protected]>
2026-10-25 10:59:04 +08:00
SivanColaandsituerte37 cbd8487a0e fix: validate session identity at projection path construction
Problem: CodeQL flagged request-controlled session identity reaching the new
integrity-certificate file reads. Projection path builders also silently
reduced invalid identities to basenames instead of rejecting them locally.

Root cause: The constructors relied on SessionRef admission validation in their
callers. Taking a basename can alias another canonical session, and dot IDs can
leave the intended per-session cache directory if that precondition is missed.

Fix: Route history and search through one projection-path constructor that
checks locality and canonical identity before joining the host-owned root,
fixed cache directory and fixed index filename. SQLite and certificate reads
continue to use exactly that same path. Preserve all integrity reuse guards.

Verification: The new invalid-identity regression failed before the fix and
passed afterward. Related session/projection race tests and lint passed. Local
CodeQL go/path-injection analysis reproduced both reported integrity_reuse.go
alerts before the change and reports neither after it, without suppressions.

Co-authored-by: situerte37 <[email protected]>
2026-10-25 10:46:04 +08:00
SivanColaandsituerte37 7ee39c1d69 fix: isolate session identities and coalesce local and remote hydration
Problem: Same-workspace navigation could read a history baseline repeatedly,
while independent historical sessions sharing a source alias could acquire
duplicate sidebar keys, collapse during paging, or affect each other's archive
visibility. Small reads also repeated full integrity scans of large indexes.

Root cause: Navigation and passive ready/rebuilt events competed for hydration;
source aliases crossed canonical ownership boundaries; and each read entry point
opened disposable projections with a fresh full-database audit.

Fix: Give canonical navigation one hydration owner, version remote publication
barriers, and preserve distinct canonical identities across keys, pages, runtime
overlays and archive fences. Centralize history/search projection opens and reuse
successful integrity proofs only for unchanged files without outstanding WAL.

Verification: Root Go tests passed across 158 packages. All 12 Desktop CI test
partitions passed locally, including historical writer fixtures. Related race,
frontend typecheck/transcript/remote/lifecycle, Chromium regression, production
build, lint, generated-contract and Windows cross-build checks passed. The
terminal notification regression validates the additive publication revision.

Co-authored-by: situerte37 <[email protected]>
2026-10-25 01:28:53 +08:00
YHH 8cc804e687 Merge pull request #14083 from SivanCola/fix/composer-close-draft-errors
fix(desktop): preserve drafts across close and async input / 修复关闭窗口与异步输入的草稿保护
2026-10-24 08:20:16 -07:00
SivanCola 98622dad1e fix(desktop): present attachment warnings through shared error UI
Problem:
The frontend CI error-display contract rejected the attachment warning in
SessionInputRecovery because it rendered raw error text directly.

Root cause:
Separating attachment feedback from durable save failures introduced an
inline error label that bypassed the existing ErrorMessage presenter.

Fix:
Use ErrorMessage for the warning while retaining its independent dismissal
and non-blocking close behavior. Extend the close regression to verify the
summary, initially collapsed diagnostic details, explicit expansion, and
dismissal without blocking an otherwise blank window.

Verification:
The failing error-display contract and related presentation/close suites
passed (18 tests). Production and test typechecks, ESLint, repolint and the
Electron production bundle budget passed; no budget settings changed.
2026-10-24 22:33:30 +08:00
SivanCola 5159ce9c74 fix(desktop): keep purge receipt handling out of startup imports
Problem:
The PR's desktop-prepare build exceeded the initial payload ceiling by 70
bytes, blocking the dependent Desktop checks.

Root cause:
The eager bridge dynamically imported composer persistence for purge
receipts. Its generated preload dependencies grew the startup payload and
made every successful RPC callback async, even without any draft owner.

Fix:
Let the persistence module register purge receipt handlers before it can
create owners. Look up the handler at RPC completion so an owner loaded
while a purge is pending is still retired. Keep cold purges independent of
the persistence module and restore synchronous success callbacks.
Add a regression covering cold purges and owner registration during an RPC.

Verification:
- Reproduced the CI payload exactly: 2106642 B against a 2106572 B ceiling.
- Fixed production payload: 2106068 B, 574 B smaller and 504 B below the
  unchanged ceiling; all bundle gates passed with the same embedded SHA.
- 52 focused frontend tests, production/test typechecks, ESLint, repolint,
  and all five native macOS Electron close/restart scenarios passed.
2026-10-24 22:21:34 +08:00
SivanCola a2e2b38ba6 fix: keep image error copy within locale budget and satisfy lint
Problem: PR CI rejected the Traditional Chinese bundle and the historical
image replay regression's loop syntax, blocking downstream Desktop checks.

Root cause: the added error copy increased zh-TW gzip to 65,870 bytes against
a 65,843-byte budget; the pinned modernize linter requires integer ranges.

Fix: shorten the two error summaries while retaining the details control,
and use an integer range for the two repeated replay attempts. Keep all
bundle budgets and regression assertions unchanged.

Verification: Node 24.20.0 production bundle and all budgets pass, with
zh-TW gzip at 65,840 bytes. Pinned golangci-lint reports zero issues;
repolint, frontend test typecheck, error-presentation tests, focused
historical-image replay regression, docs-impact and diff checks pass.
2026-10-24 22:17:40 +08:00
SivanCola 48c5b29acb fix(images): keep text turns usable and preserve failure causes
Problem: historical image failures could block text-only follow-ups, and
reopened conversations displayed a generic error instead of the cause.

Root cause: replay preparation propagated optional image failures to the
entire turn; interrupted records and cleanup retained classification only.
Pre-Agent image failures also lacked a durable display record.

Fix: preserve text with explicit unavailable-image notes for historical
and tool images, while requiring current user attachments and honoring
parent cancellation. Persist bounded redacted causes through existing
recovery fields and optional notices, with localized image-error display.

Verification: root Go suite (158 tested packages), relevant Desktop tests,
focused Controller race regressions, frontend typecheck and three-locale
error tests, Chrome detail expansion, and v1.39.8/v1.39.9 replay/append/reopen
compatibility passed. Correct the heartbeat test to measure compaction
before transport cleanup; 30 repeated runs pass without changing timeouts.
2026-10-23 12:25:21 +08:00
YHH 7aa034224b Merge pull request #14094 from SivanCola/fix/provider-protocol-compat
fix(provider): scope HTTP/1.1 compatibility to later requests without replay
2026-10-21 06:29:01 -07:00
YHH 3a96b59bc1 Merge pull request #14108 from esengine/fix/attestation-verify-retry
fix(release): each provenance check retries on its own
2026-10-21 05:23:26 -07:00
esengine ab5e5074a5 fix(release): each provenance check retries on its own 2026-10-21 05:13:38 -07:00
YHH 136dad0094 Merge pull request #14106 from esengine/fix/attestation-verify-prime
fix(release): verify one payload alone before the parallel provenance checks
2026-10-21 05:03:51 -07:00
esengine 91ea60ade6 fix(release): explain the intentional single quotes the shell lint flags 2026-10-21 04:53:04 -07:00
esengine 3e03f73c6f fix(release): verify one payload alone before the parallel provenance checks 2026-10-21 04:47:24 -07:00
YHH 7461ea823f Merge pull request #14105 from esengine/fix/recover-orphan-signature
fix(release): recovery drops a Desktop signature whose payload never uploaded
2026-10-21 04:26:03 -07:00
esengine 132bcf98ab fix(release): recovery drops a Desktop signature whose payload never uploaded 2026-10-21 04:10:56 -07:00
zumac e5354ea072 Merge branch 'main-v2' of https://github.com/esengine/DeepSeek-Reasonix into fix/pr-14094-protocol-compat
# Conflicts:
#	desktop/frontend/scripts/check-bundle-budget.mjs
2026-10-21 03:54:08 -07:00
YHH f5282fe481 Merge pull request #14102 from esengine/fix/1x-bundle-budget-zh
fix(desktop): raise the zh and initial-raw bundle ceilings for the merged 1.x copy
2026-10-21 03:52:59 -07:00
zumac d558ac1f4e fix(desktop): ratchet the zh and raw initial budgets for transport kinds
The typed transport-failure copy from #14080 put main-v2 over two bundle
gates, failing desktop-prepare on every push and PR based on it. Under the
Node 24.20 zlib CI uses, the zh chunk measures 64826 B against a 64819 B
ceiling; once that passes, initial raw JavaScript and CSS measures 2107767 B
locally (about 2107953 B with the documented Linux producer difference)
against 2107596 B. zh-TW measures 65838 B and still fits.

Raise zh to 63.4 KiB and the raw initial gate to 2058.6 KiB, the smallest
one-decimal ceilings covering those measurements. Every other budget stays.
2026-10-21 03:28:04 -07:00
esengine 0af618a66b fix(desktop): raise the zh and initial-raw bundle ceilings for the merged 1.x copy 2026-10-21 03:27:45 -07:00
YHH 47d6f06ae1 Merge pull request #14081 from SivanCola/chore/provider-network-probe
chore(provider): add bounded network probe and diagnostic compatibility checks / 网络对照探针与兼容性检查
2026-10-21 03:04:17 -07:00
zumac 207a921f0d fix(provider): keep existing recovery on the compatibility route
A failed HTTP/1.1 compatibility request was wrapped as terminal, so an EOF,
reset or send timeout on that route lost the stale-connection/send-timeout
resend and the agent's stream recovery, and ClassifyRecovery reported it as a
"protocol" failure with an empty code. Any typed HTTP/2 code was also treated
as terminal, not just the three compatibility triggers, which changed how
REFUSED_STREAM, CANCEL and GOAWAY were classified.

- Drop protocolCompatibilityError: the wrapper returns transport errors as
  they are, so existing retry owners judge them by their real cause. A failed
  probe still disables the route; any owed resend uses normal negotiation.
- protocolFailureIsTerminal covers only INTERNAL_ERROR, PROTOCOL_ERROR and
  HTTP_1_1_REQUIRED; other HTTP/2 codes keep their pre-existing handling.
- INTERNAL_ERROR is also a generic upstream reset, so it now records a hint
  only when a request started after the first observation fails again.
  Concurrent streams reset by one event count once. PROTOCOL_ERROR and
  HTTP_1_1_REQUIRED still record a hint immediately.
2026-10-21 03:01:48 -07:00
SivanCola b61bc97dc0 Merge main-v2 transport diagnostics into protocol compatibility
Problem:
PR #14094 conflicts with the transport diagnostics merged by #14080.

Root cause:
Both branches extend request observations, send completion bookkeeping and
the provider diagnostics documentation at the same locations.

Fix:
Preserve the typed error, phase and send timing fields alongside scoped
protocol fallback evidence. Keep the terminal compatibility error marker
when resolving send guard cancellation, without adding another retry.
Retain both diagnostic and protocol compatibility documentation.

Verification:
Provider and adapter tests pass. Focused provider, agent and control race
tests pass, including no tool replay, no stacked resends and cold exports.
Add joint real-HTTP regression assertions for fallback EOF/send timeout
classification and timing. Desktop history failure tests pass.
Repolint and pinned golangci-lint v2.12.2 pass; diff checks are clean.
2026-10-21 17:42:36 +08:00
YHH e894514bb2 Merge pull request #14080 from SivanCola/feat/provider-transport-diagnostics
feat(provider): attribute transport failures with typed causes / 传输失败类型化诊断
2026-10-21 02:04:08 -07:00
SivanCola f0897cf797 fix(provider): scope automatic protocol compatibility to later requests
Problem: A provider path returning typed HTTP/2 protocol errors keeps failing
on subsequent requests, while replaying an ambiguous failed generation could
duplicate processing or tool execution.

Root cause: Built-in provider clients have no bounded per-path protocol hint,
and generic stream/reset recovery can treat protocol failures as retryable.

Fix: Remember allowlisted HTTP/2 failures by provider, adapter, endpoint and
resolved proxy route for ten minutes. Try HTTP/1.1 only on a subsequent new
request, limit unproven probes to one, and disable failed compatibility paths.
Keep protocol failures terminal across transport and agent recovery so no
resend allowance is stacked. Preserve sibling streams and record only safe
selection evidence in optional versioned diagnostics.

Verification: Provider and adapter suites pass, as do focused real TLS H1/H2,
request-count, tool execution, cancellation and concurrent recovery tests with
the race detector. Desktop diagnostic/provider tests, repository lint and
Go lint pass. No live provider call or native package validation is claimed.
2026-10-21 16:51:00 +08:00
YHH 07d6c66929 Merge pull request #14089 from SivanCola/feature/studio-discovery-1x
feat(desktop): introduce Studio to 1.x users / 为 1.x 用户提供 Studio 引导
2026-10-21 01:36:01 -07:00
YHH 990375ba71 Merge pull request #14074 from SivanCola/fix/provider-send-timeout
fix(provider): bound request sending and resend an unfinished upload once / 补齐请求发送阶段保护
2026-10-21 01:21:28 -07:00
SivanCola 6f95b0166b feat(desktop): introduce Studio to stable 1.x users
Add a one-time idle startup introduction and a permanent Settings > About entry. Respect setup, active work, overlays and user interaction, and persist the campaign independently of patch versions.

Open the official Studio download section on explicit action, explain shared-data limitations, and preserve the 1.x updater. Include three locales, keyboard focus handling, bilingual documentation and measured bundle allowances.

Verification: focused discovery, updater and preferences tests; frontend and test typechecks; production build and bundle checks; CSS syntax; repository lint; browser interaction and download checks.
2026-10-21 15:51:57 +08:00
SivanCola 2c81a74225 fix(desktop): preserve accepted composer input across send and close
Problem:
Accepted attachments and clipboard input could lose a race with submission.
An asynchronous cut could apply stale selection offsets to newer draft text
or finish after the window had already closed.

Root cause:
Pending-input tracking started after asynchronous target capture, omitted
menu reads and native fallback timers, and was absent at the durable
submission boundary. Cut resumed against current text without validating
the captured content and updated a local cache for background sessions.

Fix:
Track accepted input for its full lifetime, block submission while input
is pending, and retain close draining without counting submission itself
as input. Apply cuts through the authoritative draft owner only when the
captured text, invocations and pasted blocks still match. Preserve content
when clipboard writes fail. Add deterministic interleaving regressions
with independent per-scenario deadlines.

Verification:
- Focused frontend regression run: 52 tests passed, including the existing
  composer draft suite with 127 assertions and purge/save-failure protection.
- Frontend production build, production/test typechecks and repolint passed.
- Native Electron composer-close smoke: all five scenarios passed, covering
  empty windows and unsent text/reference recovery after restart on macOS.
2026-10-21 15:30:36 +08:00
zumac 9fafd7ce3a chore(provider): add bounded network probe and diagnostic compatibility checks
provider-network-probe sends exactly one synthetic request (SendOnce, no
redirects, no body replay, max_tokens 1) and prints safe observation fields,
for comparing network paths and protocols on an affected machine. The
compatibility script replays framed stores across v1.39.8/v1.39.9 in both
directions; the packaged smoke drives stop, history, live EOF and export.
2026-10-21 00:28:43 -07:00
zumac 1019b2811d feat(provider): attribute transport failures with typed causes
Transport failures now carry a whitelisted cause code (eof, reset, timeout,
dns, http2, request_send_timeout) and the phase it happened in, without
persisting error prose, URLs, headers or bodies. FailureDiagnostic gains the
matching kinds; request observations gain errorCode, errorPhase,
timeoutSource and send timing, carried by optional diagnostic/provider
schema 2 so older readers skip it. The desktop presents the new kinds in all
three locales for live and history errors.
2026-10-21 00:28:43 -07:00
YHH 9ee6ad0a81 Merge pull request #14072 from SivanCola/fix/tui-scroll-footer-14065
fix(cli): preserve footer and Unicode layout / 修复底栏重复、面板溢出与组合字符丢失
2026-10-21 00:15:47 -07:00
zumac 83a9b00566 fix(provider): bound request sending and resend an unfinished upload once
A provider upload could stall before either ResponseHeaderTimeout (which
starts only after the body is written) or the SSE idle watchdog took
ownership. Each attempt now has a 300-second send-stage budget, ended by a
successful WroteRequest and rearmed per redirect and Transport retry.

A send timeout means the body never finished uploading, so the provider
cannot have answered; it shares the existing single-resend allowance with a
stale idle pooled connection. SendOnce keeps one-request callers exact.

The per-attempt context is detached from the caller's cancellation tree and
released, not cancelled, when the response body finishes: cancelling a
finished HTTP/2 stream raced its teardown and made the next request see the
pooled connection as busy, which broke stale-connection detection.
2026-10-21 00:13:35 -07:00
SivanCola a9fc7d8a27 fix(desktop): preserve accepted input and retire purged drafts
Problem:
Accepted attachments and clipboard results could disappear during autosave
recovery, while failed drafts of permanently deleted sessions could prevent
an otherwise blank window from closing.

Root cause:
Draft publication shared the recovery admission lock, silently dropping
already accepted asynchronous work. Successful purge receipts did not revoke
cached composer ownership or release close barriers waiting on that owner.

Fix:
Allow accepted patches to join the retained local draft during recovery,
without clearing unreadable-store errors or bypassing submission locks.
Retire composer owners only after successful canonical purge receipts,
including committed items in partial bulk results. Fence late callbacks and
release in-flight close waits while preserving other unsaved draft failures.

Verification:
- 41 composer persistence, race, lifecycle and attachment regression tests.
- 6 archive, trash and historical recovery UI suites.
- 33 Electron lifecycle and startup-window tests.
- 3 backend composer lifecycle tests with the Go race detector.
- Frontend and test typechecks, production Electron frontend build, repolint.
- 5 native Electron close/restart smoke cases, including blank windows,
  immediate unsent text and durable external references.
2026-10-21 15:09:48 +08:00
SivanCola f2d59e6fa0 fix(desktop): drain late composer work and retry failed close saves
Problem:
Closing a window could miss late work on an earlier session, and another
close attempt could keep failing after draft storage had recovered.

Root cause:
The close barrier visited each session only once, native input admission
ignored the exit lock, and cached persistence errors prevented a fresh save.

Fix:
Block new edits during close while retaining accepted task completions.
Recheck all session owners until their work and saves have drained. Reconcile
cached failures once per explicit close attempt, preserving retained input
and keeping fresh storage failures as close vetoes. Never resend ambiguous
submissions during recovery.
Await each native smoke RPC poll and retain the matching saved snapshot so
the restart assertion cannot compare against stale pre-reference content.
Target the main renderer's native window and check its actual hidden state.
Bound inspector cleanup after native window destruction; only terminate the
isolated fixture group after logs confirm service shutdown, resource cleanup,
and debugger teardown.

Verification:
Six new deterministic regression scenarios and the related composer and
submission suites passed: 35 runner tests, including 256 Composer assertions.
The Electron-targeted frontend build, test TypeScript checks including the
new TSX suite, ESLint, and repository lint passed. The native macOS smoke
passed all five blank-window and draft close/restart checks. Windows native
behavior has not been exercised locally.
2026-10-21 14:48:58 +08:00
SivanCola 4e9ae7baba fix(cli): report pinned panel focus from selection state
Problem: Pinned panels could scroll to ordinary todo text that resembled
a selection marker, or lose focus when display wording changed.

Root cause: The viewport inferred selection from ANSI-stripped prefixes
and used the rendered row text as its focus identity.

Fix: Have panel builders record selected row boundaries and logical
indices, then return explicit terminal-row metadata after styling and
wrapping. Apply this to every pinned renderer, including native managers,
and restore the original elicitation Submit presentation.

Verification: Preserve existing scroll, footer and selection tests; add
misleading todo prefixes, wording-independent manual scroll, windowed
selection, Unicode wrapping and native-manager focus regressions. The
todo regression fails on the previous PR head for all three prefixes.
go test ./internal/cli -count=1, go build -p 2 ./..., make lint, and the
documentation-impact check pass.
2026-10-21 14:35:18 +08:00
SivanCola 732176487f fix(desktop): preserve drafts without blocking blank window close
Problem:
Closing a blank window could fail with unsupported: attachments-v2,
while real unsaved input must remain protected during shutdown.

Root cause:
Attachment operation failures shared the durable input error state,
and loading saved external references required a live attachment controller.
Close also checked dirty state before pending reads and recovery settled.

Fix:
Separate dismissible attachment feedback from persistence failures, defer
external reference activation to submission, and await input lifecycle
barriers before flushing. Keep genuine save failures as close vetoes.

Verification:
Add eight lifecycle regression cases and a native Electron close/restart
smoke covering blank windows, latest text, and saved external references.
Focused frontend tests, TypeScript checks, lint, Electron lifecycle tests,
builds, and repository lint passed. All five native smoke checks passed
on macOS. Windows native behavior has not been exercised locally.
2026-10-21 14:14:58 +08:00
SivanCola be1658c697 fix(cli): preserve terminal layout while scrolling and wrapping
Problem: Scrolling could duplicate the pinned footer, long panels could hide
status rows, and keycap or combining characters could disappear at line ends.

Root cause: The renderer's cursor model could disagree with the terminal's
Unicode width model, optional panels lacked a shared height budget, and the
upstream wrapping fast path could split ASCII-led grapheme clusters.

Fix: Update the official renderer, share a bounded scrollable panel layout
between painting and sizing, and wrap styled terminal text by complete
grapheme clusters. Keep keyboard selection and composer geometry in sync.
Document panel scrolling in the English and Chinese guides.

Verification: Final complete CLI tests, make lint, module verification and
Ghostty WASM replays pass. Root-module tests passed after the dependency
update. Before/after probes reproduce the original rendering failures.
Native Ghostty window acceptance remains unverified.

Fixes #14065
2026-10-21 14:03:40 +08:00
YHH 195fc08132 Merge pull request #14058 from esengine/docs/release-1.39.10-notes
docs(release): prepare v1.39.10 notes
2026-10-20 20:33:44 -07:00
esengine baa8ff0f56 docs(release): prepare v1.39.10 notes 2026-10-20 20:31:15 -07:00
YHH 92855769ae Merge pull request #14051 from esengine/fix/go-1.26.10-x509-1x
build: use Go 1.26.10 for the crypto/x509 fix (GO-2026-6809)
2026-10-20 20:31:03 -07:00
esengine aeadc04396 build: use Go 1.26.10 for the crypto/x509 fix (GO-2026-6809) 2026-10-20 20:02:17 -07:00
YHH 0a019e294e Merge pull request #14029 from SivanCola/fix/opencode-go-inference-v41
Fix OpenCode Go inference and DeepSeek V4.1 compatibility / 修复 OpenCode Go 接口与 DeepSeek V4.1 适配
2026-10-20 11:25:38 -07:00
SivanCola 8e58a1fd3d refactor: simplify OpenCode Go migration eligibility check
Problem: the migration model scan triggered the required modernize lint rule.

Root cause: a manual loop only tested whether any model has the DeepSeek Go contract.

Fix: use slices.ContainsFunc while preserving the one-time model and pricing updates.

Verification: make lint passes with zero golangci-lint issues and clean repolint; the full config package passes after the change.
2026-10-21 01:52:46 +08:00
SivanCola df6aae6f07 test: isolate V4.1 tool replay acceptance through upstream outages
Problem: independent smoke cases could not complete the dependent V4.1 tool-history probe during intermittent Go upstream 503 failures.

Add a focused three-request conversation probe and an optional synthetic session identity for routing controls. Preserve pending messages across at most five explicitly logged 503-only probe retries, spaced ten seconds apart. Production request behavior is unchanged.

Verification: the live chain completed tool invocation, result replay and multi-turn recall with stable session headers and retained reasoning. It used five HTTP requests, including two recorded 503 failures, then returned 200 at each stage. Usage was 1137 input tokens, 62 output tokens and 640 cached input tokens. Tagged compilation, focused boot checks and go vet passed; temporary state and binary were removed.
2026-10-21 01:48:31 +08:00
SivanCola 34289879ad test: add bounded OpenCode Go V4.1 live acceptance
Exercise the production boot factory on new and legacy Go routes, default and explicit effort modes, stable per-conversation identity, tool-result replay, multi-turn history, shared discovery and an Anthropic control.

Limit inference calls to eight, cap each response at 768 output tokens and 60 seconds, use disposable Reasonix state, and log only sanitized transport and usage summaries. Independent cases retain upstream failures without automatic retry.

Live verification found two successful V4.1 responses followed by six upstream HTTP 503 Endpoint is unavailable responses across both route generations. The same key succeeded on Qwen and shared discovery. Full V4.1 tool replay remains blocked by upstream availability; no live credential is persisted. Tagged compilation and focused deterministic boot checks passed.
2026-10-21 01:41:47 +08:00
SivanCola fff1350970 fix: align OpenCode Go routes and DeepSeek V4.1 contracts
Problem: OpenCode Go V4.1 Flash requests could bypass DeepSeek thinking and history replay, while newly documented inference URLs missed Go-specific transport and discovery handling.

Root cause: exact endpoint and model catalogs recognized only the legacy route generation and older DeepSeek IDs.

Fix: recognize both endpoint generations, add the V4.1 contract, adopt documented inference presets and shared model discovery, and atomically migrate standard saved connections with schema 13 while preserving selections and custom configuration.

Verification: root Go suite, affected Desktop/provider tests, schema-12 reader/writer round trip, migration interruption and Windows encoding fixtures, frontend catalog/protocol tests, TypeScript and go vet passed. Desktop full-suite execution exhausted its 10-minute package budget; the test active at timeout passed individually in 0.49 seconds. No live Go credential was available.
2026-10-21 01:34:11 +08:00
YHH 5207eaeff6 Merge pull request #14020 from esengine/notes/1.39.9-content-index
docs(release-notes): add the content index self-heal to 1.39.9
2026-10-20 06:20:42 -07:00
YHH d6c0cda861 Merge pull request #14019 from esengine/fix/content-index-self-heal
fix(sessioncontent): rebuild a lost integrity index from its object
2026-10-20 06:10:33 -07:00
esengine cadcfd3c44 fix(sessioncontent): rebuild a lost integrity index from its object 2026-10-20 05:54:11 -07:00
esengine a0aab3373e docs(release-notes): add the content index self-heal to 1.39.9 2026-10-20 05:24:49 -07:00
YHH 8b7eaac0f0 Merge pull request #14014 from esengine/fix/shellrun-mergepath-test-windows
test(shellrun): build the mergePath fixture with the platform list separator
2026-10-20 04:02:41 -07:00
esengine 301fdea4a8 test(shellrun): build the mergePath fixture with the platform list separator 2026-10-20 03:46:20 -07:00
YHH 39c6204bdf Merge pull request #14008 from esengine/a9/release-notes-1.39.9
docs(release): prepare v1.39.9 notes
2026-10-20 03:23:51 -07:00
esengine 81e6b67426 docs(release): prepare v1.39.9 notes 2026-10-20 01:59:56 -07:00
YHH 302535d501 Merge pull request #14005 from SivanCola/codex/settings-model-capability-consistency
fix(desktop): align model settings validation / 统一模型设置校验并保留默认值
2026-10-20 01:34:13 -07:00
YHH bbb42d3c1c Merge pull request #13543 from SivanCola/fix/native-file-streaming-main-v2
fix(desktop): reference native files and stream attachment uploads / 大文件路径引用与流式上传
2026-10-20 00:40:26 -07:00
SivanCola 2f34afada8 fix(desktop): align model settings capabilities and preserve defaults
Problem:
Settings can reject image-capable models, display incorrect reasoning choices
or planner state, and change connection defaults during unrelated edits.

Root cause:
Vision selection used the legacy capability check. Reasoning choices read
persisted declarations instead of resolved model metadata. The planner picker
collapsed explicit default-model assignments, and provider saves reconstructed
defaults from model order and explicit effort lists.

Fix:
Use the shared vision resolver and resolved reasoning options, retain explicit
planner assignments until cleared, and preserve connection defaults on save.
Retain declared reasoning choices for older hosts without resolved metadata.

Verification:
Focused Desktop Go and config tests; new settings component regressions;
provider editor, settings refresh, and search assignment suites; frontend and
test typechecks; focused ESLint; repolint; browser component interactions.
2026-10-20 15:34:02 +08:00
YHH 12bdc4fb8f Merge pull request #13966 from SivanCola/fix/reasoning-only-delivery
fix(agent): recover reasoning-only turns and report failure / 修复仅思考空转与失败提示
2026-10-20 00:15:50 -07:00
SivanCola 76d4d51145 test(control): use http.StatusOK in no-deliverable fixture
Satisfies the usestdlibvars lint flagged by lint-code CI.
2026-10-20 13:00:18 +08:00
SivanCola 156c1f9c1a fix(agent): let budgets win recovery exhaustion and pin cancel semantics
Problem: A task budget spent by the recovery round surfaced as the
non-resumable no_deliverable_output failure, response diagnostics attributed
recoveryAttempts to every later round in the Run, and the cancel-after-commit
outcome had no test or documented contract.

Root cause: The budget check ran after the exhaustion check, and the response
summary read the Run-wide no-answer counter instead of whether that response
answered a recovery prompt.

Fix: Check the task budget before counting a no-answer stop so a spent budget
returns the resumable pause. Track answeringRecovery in terminalProtocolState
so only the reply to a recovery prompt carries a non-zero count. Keep
cancellation winning until Run returns even after a committed answer, matching
DeepSeek Harness, and document it with the existing cancelled-turn replay
policy.

Verification: New regressions fail on the previous code and pass now
(TestNoDeliverableBudgetSpentDuringRecoveryPauses, recovery attribution
[0 1 0], TestCancelAfterCommittedAnswerEndsRunCancelled). go vet, gofmt,
repolint, and race tests for agent, control, guardian, and transcript passed.
2026-10-20 12:54:15 +08:00
SivanCola 8d2df779c0 fix(agent): reject reasoning-only turns without a delivered answer
Problem: Ordinary chat turns could complete with only reasoning, leaving no
answer or tool action and no durable failure notice.

Root cause: Empty-stream validation accepted reasoning, while visible-answer
recovery only covered explicit callers or turns that had already used tools.

Fix: Bound no-answer recovery to one continuation across the entire run, retain
completed tool results, and persist a typed failure when recovery is exhausted.
Unify terminal diagnostics, add optional content-free response summaries, and
share structured JSON redaction between live and cold exports.

Verification: Root Go suite, focused Agent/Controller/Guardian race tests,
Desktop export regressions, go vet, repolint, frontend error tests/typecheck/lint,
and wide/narrow browser error-presentation checks passed.
2026-10-20 11:42:37 +08:00
YHH b0efcb3b93 Merge pull request #13927 from PeterHepta/codex/fix-login-shell-env
fix: load login shell PATH for user commands
2026-10-19 04:12:56 -07:00
PeterHepta 9fe9c00907 fix: load login shell PATH for user commands 2026-10-19 18:55:42 +08:00
YHH d178d0a76e Merge pull request #13805 from esengine/dependabot/go_modules/desktop/main-v2/go-1x-d0657610f5
chore(deps): bump github.com/godbus/dbus/v5 from 5.2.2 to 5.2.4 in /desktop in the go-1x group
2026-10-18 06:00:10 -07:00
dependabot[bot] b69a587842 chore(deps): bump github.com/godbus/dbus/v5
Bumps the go-1x group in /desktop with 1 update: [github.com/godbus/dbus/v5](https://github.com/godbus/dbus).


Updates `github.com/godbus/dbus/v5` from 5.2.2 to 5.2.4
- [Release notes](https://github.com/godbus/dbus/releases)
- [Commits](https://github.com/godbus/dbus/compare/v5.2.2...v5.2.4)

---
updated-dependencies:
- dependency-name: github.com/godbus/dbus/v5
  dependency-version: 5.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-18 11:57:18 +00:00
SivanCola 1b8c281bc7 test(boot): classify request-owned attachment policy
Problem: Ubuntu, race and Windows boot CI reject the newly added
Attachments config as unclassified.

Root cause: the exhaustive runtime-config ownership inventory was not
updated alongside the user-only upload policy. Admission reloads that
policy per request; it is not an assembled controller dependency.

Fix: classify the container and MaxStreamBytes as host-owned exclusions,
retain exhaustive nested-field checks, and cover future attachment fields
with the mutation guard. Assert that custom, unlimited and default policies
leave the controller reuse fingerprint unchanged.

Verification: reproduced the original classification failure locally;
runtime-config regressions, full boot race suite, native Windows 11 ARM64
boot suite, pinned lint and repolint passed. No tests or CI guards disabled.
2026-10-18 09:59:12 +08:00
SivanCola 6bd9fa74c9 fix(attachments): bound streamed bytes with user-only policy
Problem: a pathless upload can fill workspace disk, and a typed size refusal
is hidden behind generic draft/error presentation in the composer.

Root cause: the stream owner has no byte ceiling; project config must not be
allowed to choose that safety policy or mutate a held user pointer value.

Fix: default to a named 1 GiB limit, count actual bytes with a bounded excess
probe, and retain rooted cleanup. Add a user-only optional ceiling (zero means
unlimited), strict user reads and round-trip persistence. Return typed limit
and source facts, keep retired-target precedence, and show a localized explicit
summary for the known refusal without reclassifying it as a generic exception.

Verification: fail-first limit/config regressions, config and streaming tests,
root/Desktop race tests, native Windows ARM64 upload/cleanup cases, frontend
and toast regressions, production build, real Electron large-file/limit flow,
pinned lint and repolint. No workflow guards or test timeouts are weakened.
2026-10-17 23:48:37 +08:00
SivanCola af35e5408f merge: include the reviewed history fix from main-v2
Problem: the attachment PR's Desktop and Windows checks fail on the base
branch's legacy recovery identity mismatch.

Root cause: the separately reviewed history fix in PR #13594 was not yet part
of main-v2 when the attachment candidate was tested.

Fix: merge official main-v2 after #13594 landed as 0a2d2a377. Keep the PR diff
limited to attachments while inheriting the base's history and lifecycle fix.

Verification: previously failing covered/unmodified recovery tests, canonical
inactive history and catalog lifetime regressions pass with race detection.
Attachment, external-reference, rooted-storage and legacy-identity race tests
also pass. Repository lint and diff checks are clean; no tests are skipped.
2026-10-17 22:59:59 +08:00
YHH 0a2d2a3776 Merge pull request #13594 from SivanCola/codex/history-catalog-recovery-main-v2
Fix legacy recovery identity and catalog reconciliation lifetime / 修复历史恢复身份与目录校验生命周期
2026-10-17 07:58:30 -07:00
SivanCola e4be215d14 revert: move history and catalog repair to its own main-v2 PR
Problem: the attachment PR also contains an independently reviewable legacy
history identity and catalog lifecycle repair, blocking scope approval.

Root cause: commit fe060ae88 combined the requested baseline CI follow-up with
the attachment transport fix instead of keeping a separate review boundary.

Fix: revert fe060ae88 with an ordinary commit. Preserve its implementation
and regressions in independent main-v2 PR #13594, including explicit-ID
compatibility coverage. Do not rewrite the attachment branch history.

Verification: the resulting tree equals e045489e4 exactly. The independent
history branch passes focused root/Desktop race tests, native Windows
history and lifecycle repetitions, pinned lint and repository lint. Existing
main-v2 history failures remain tracked in #13594 until that fix reaches base.

This reverts commit fe060ae88f.
2026-10-17 17:34:09 +08:00
SivanCola 21e3ceb01e test(sessioncatalog): preserve explicit recovery-prefixed topic IDs
Problem: splitting the history repair needs direct compatibility evidence for
topic IDs already persisted with the old recovery-prefixed spelling.

Root cause: synthetic cache IDs and explicit authoritative IDs can share a
prefix, so prefix matching alone cannot define a safe migration boundary.

Fix: cover explicit root, recovered-member and rootless authority, asserting
that projection preserves both identity and title in each case.

Verification: the focused legacy identity and compatibility race tests pass
three repetitions, including certified-cache reopen and parent deletion.
2026-10-17 17:31:37 +08:00
SivanCola 991e7dbebd fix(desktop): preserve recovery identity and retire catalog reconciliation
Problem: legacy recovery rows change topic identity after content proof or
parent deletion. Catalog rebuild can leave an explicit reconcile waiting on
paused discovery, and the Windows canonical-open test races a five-second
filesystem-poll deadline.

Root cause: metadata and content projections choose different fallback IDs;
reconcile jobs borrow the application lifetime and can attach to another
catalog generation; readiness assertions assume a particular disk speed.

Fix: anchor legacy groups to the existing legacy identity while preserving
explicit topics and authoritative files. Bind reconcile jobs to the captured
catalog and cancellation owner, detach them atomically on stop, release them
on invalidation, and fence old-job cleanup by pointer identity. Join the
existing history preparation before asserting readable history under a gated
runtime, and fence metadata/content-proof phases in recovery tests.

Verification: full session-catalog race suite, deterministic pre-fix failures
and focused Desktop race regressions, native Windows C group and repeated
canonical/recovery/lifecycle tests, pinned root/Desktop/Windows lint and
repolint. Complete Desktop inventory and historical version/scope coverage
are recorded in the PR validation.
2026-10-17 17:30:31 +08:00
SivanCola fe060ae88f fix(desktop): preserve recovery identity and retire catalog reconciliation
Problem: legacy recovery rows change topic identity after content proof or
parent deletion. Catalog rebuild can leave an explicit reconcile waiting on
paused discovery, and the Windows canonical-open test races a five-second
filesystem-poll deadline.

Root cause: metadata and content projections choose different fallback IDs;
reconcile jobs borrow the application lifetime and can attach to another
catalog generation; readiness assertions assume a particular disk speed.

Fix: anchor legacy groups to the existing legacy identity while preserving
explicit topics and authoritative files. Bind reconcile jobs to the captured
catalog and cancellation owner, detach them atomically on stop, release them
on invalidation, and fence old-job cleanup by pointer identity. Join the
existing history preparation before asserting readable history under a gated
runtime, and fence metadata/content-proof phases in recovery tests.

Verification: full session-catalog race suite, deterministic pre-fix failures
and focused Desktop race regressions, native Windows C group and repeated
canonical/recovery/lifecycle tests, pinned root/Desktop/Windows lint and
repolint. Complete Desktop inventory and historical version/scope coverage
are recorded in the PR validation.
2026-10-17 17:23:58 +08:00
SivanCola e045489e46 fix(attachments): contain file creation and cleanup with directory handles
Problem: CodeQL reports uncontrolled attachment paths in creation, interrupted
uploads and stale desktop-owner cleanup. A deterministic directory replacement
can make cleanup delete an external same-name file and leave the owned partial.

Root cause: validation and later absolute-path operations resolve the attachment
directory independently, so they do not retain one filesystem owner during I/O.

Fix: anchor creation to the workspace with os.Root, retain the attachment
directory handle through byte/stream write cleanup, and use rooted removal for
stale desktop writes and failed image previews. Preserve missing workspace
creation, extension validation, exclusive file creation and existing limits.

Verification: controller suite, attachment desktop regressions, deterministic
read-error/cancellation races, focused race detector, vet, pinned linter and
repolint passed. Native Windows 11 ARM64 controller/desktop regressions passed,
including junction escape rejection, protected cleanup and 80 MiB streaming.
The independent recovery catalog failure also reproduces with the PR's Go
changes removed through a base-source overlay; it is not changed here.
2026-10-17 13:08:06 +08:00
SivanCola 18d2427316 test(desktop): verify native Windows file attachment transport
Problem: Review found no native Windows evidence for absolute file tokens
reaching the tool-side read-root matcher, and the streaming description could
be mistaken for a total disk-byte budget.

Root cause: The previous regression exercised context parsing without wiring
the actual PathResolver and read_file tool, and the live harness had no
explicit Windows Electron/service override.

Fix: Cover native and drive-shaped tokens through registration and read_file,
including spaces and rejection of parent/sibling grants. Support explicit
runtime/service paths and the Windows service executable suffix in the live
regression. Clarify in both host protocol docs that streaming bounds memory,
while total file size remains subject to available storage.

Verification: Native Windows 11 ARM64 controller/desktop attachment suites and
real Electron 44.2.0 passed: 120 MiB picker, paste and drop references plus an
exact 80 MiB pathless upload; renderer and service stayed responsive. Focused
macOS tests, repository lint, script syntax and whitespace checks passed.
2026-10-17 11:42:28 +08:00
SivanCola 0e04f484df test(desktop): share ordinary file upload fixtures
Problem: Updated attachment regressions exceed the existing per-file test
budgets in the composer Goal and session-draft suites.

Root cause: Both suites inline the same raw-file fetch setup, and the Goal
suite also owns a paste-event builder that belongs with the upload fixture.

Fix: Extract shared raw upload assertions and file-paste dispatch while
preserving the suites' assertion counters, pending-upload ordering, failure
sanitization and session-ownership checks. Do not raise any lint baseline.

Verification: Composer Goal and session-draft suites, frontend test typecheck,
repository lint and whitespace checks pass.
2026-10-17 11:13:41 +08:00
SivanCola 8c5efcdada fix(desktop): reference native files and stream attachment uploads
Problem: Attaching a large ordinary file can terminate the desktop service.

Root cause: The composer reads and Base64-encodes the entire file before
sending it through JSON-RPC. The expanded request can exceed the 64 MiB
transport frame limit before the legacy attachment size guard runs.

Fix: Prefer native paths and exact external-file read references. Send
pathless files through an authenticated raw-body upload with bounded disk
writes, captured workspace ownership, and cancellation cleanup. Reject
oversized outbound RPC frames locally without closing the connection.
Preserve image admission, legacy Base64 limits, and persisted field formats.

Verification: Desktop Go suite passed with a 20-minute local timeout;
control and builtin tests, attachment race tests, 47 related frontend
suites, typechecks, lint, production builds, and Windows service cross-build
passed. Real macOS Electron exercised native picker, paste and drop with
120 MiB files and an exact 80 MiB pathless upload. Windows native runtime
verification remains outstanding.
2026-10-17 11:08:58 +08:00
YHH 99c1626d01 Merge pull request #13444 from esengine/fix/13439-ignore-v4-imports
fix(session): ignore Studio's v4- imports in session scans and maintenance
2026-10-16 05:06:20 -07:00
esengine 3f85cdad07 fix(session): ignore Studio's v4- imports in session scans and maintenance 2026-10-16 04:47:11 -07:00
YHH 3768de2569 Merge pull request #12951 from xxoingr/chore/main-v2-go1269-xnet060
chore(deps): bump Go toolchain to 1.26.9 and golang.org/x/net to v0.60.0
2026-10-13 09:37:05 -07:00
esengine 8ef15184db chore(lint): keep recognising x/net's deprecated GoAwayError
x/net v0.60.0 marks http2.GoAwayError deprecated while its Transport still
returns it, so the transport classifier must keep matching it. Scope the
SA1019 exclusion to that file.
2026-10-13 08:49:14 -07:00
xxoingr 1b9afa89c7 chore(deps): bump Go toolchain to 1.26.9 and golang.org/x/net to v0.60.0
govulncheck on main-v2 reports ten vulnerabilities the code reaches, in
the go1.26.6 standard library (net/http) and golang.org/x/net v0.58.0,
fixed in go1.26.9 and x/net v0.60.0. studio closed the same findings in
c8622f7a8; this is that change for main-v2, in both modules.

x/net v0.60.0 raises x/crypto to v0.57.0, x/text to v0.42.0, x/term to
v0.46.0 and x/sync to v0.23.0, the versions studio uses; go mod tidy
changes nothing else.
2026-10-13 20:47:56 +08:00
YHH afbc1c0a07 Merge pull request #12822 from esengine/fix/metadata-catalog-recovery-lineage
fix(desktop): prove recovery lineage after discovery so copies stop listing as sessions
2026-10-12 07:45:33 -07:00
yhh 8965290c35 fix(desktop): prove recovery lineage after discovery so copies stop listing as sessions
The desktop opens the session catalog metadata-only, which never reads
content, so a freshly created catalog file lists every conflict-recovery
copy as an ordinary conversation. Nothing in that mode ever proved which
copies a parent covers. After discovery completes the desktop now calls
ProveRecoveryLineage, which runs the existing full reconcile only for
directories that hold recovery copies, under the maintenance budget.
Metadata discovery itself still reads no content.
2026-10-12 06:30:46 -07:00
YHH bddb48202a Merge pull request #12526 from XTLine/fix/responses-incomplete-tool-stream
fix(responses): reject incomplete tool-stream completions / 修复工具调用未完成时静默结束
2026-10-09 20:17:56 -07:00
YHH 97576fd387 Merge pull request #12502 from XTLine/fix/goal-work-duration
fix(goal): restore cumulative active work duration / 恢复目标累计工作时长
2026-10-09 19:52:34 -07:00
XTLine 1ba983bdda fix(responses): preserve terminal error identity and verify parallel calls 2026-10-10 10:51:30 +08:00
YHH d6d3326140 Merge pull request #12535 from esengine/fix/v1-canonical-source-key
fix(desktop): settle duplicate source receipts on the one live session
2026-10-09 19:45:49 -07:00
yhh cdbbff8a41 test(desktop): cover retired receipts under duplicate-source settlement 2026-10-09 19:28:38 -07:00
yhh 505a3d7759 fix(desktop): settle duplicate source receipts on the one live session
One legacy source registered under several keys (path spellings or heads)
and held by two sessions made every lookup answer a state conflict, so the
topic's history never loaded. Receipts that agree on workspace and content
now resolve to the single active owner; retired copies keep their receipts
and files, and ambiguity with zero or several live owners still conflicts.
2026-10-09 19:02:39 -07:00
XTLine 1ff464b855 fix(responses): reject unsafe tool-stream completions 2026-10-10 09:20:54 +08:00
YHH c766bf7174 Merge pull request #12501 from XTLine/fix/compaction-session-state
fix(desktop): release compaction admission and apply live context settings / 修复压缩后的发送阻塞与实时设置更新
2026-10-09 18:14:11 -07:00
XTLine 2ff95b934e test(boot): guard config fields and canonical external paths 2026-10-10 08:40:39 +08:00
YHH b3e93df5e9 Merge pull request #12503 from XTLine/fix/history-recovery-status
fix(history): settle recovery status after successful import retry / 修复历史导入重试成功后的恢复状态残留
2026-10-09 09:27:21 -07:00
XTLine ee4214da3e fix(boot): apply live compaction settings without replacing sessions 2026-10-10 00:08:49 +08:00
XTLine 7b46a724b5 test(history): cover settlement failure after receipt completion 2026-10-09 23:52:53 +08:00
XTLine b4ecf529e3 fix(goal): isolate observational work duration 2026-10-09 22:36:36 +08:00
XTLine d483ab15a2 fix(desktop): regenerate goal work duration contract 2026-10-09 22:36:20 +08:00
XTLine 56a46b6c99 fix(history): settle reviewed and archived recovery receipts 2026-10-09 22:33:24 +08:00
XTLine 8d8579bb95 fix(control): preserve external folder access across runtime rebuilds 2026-10-09 22:28:12 +08:00
XTLine cac2d3b6fd fix(history): settle recovery status after successful import retry 2026-10-09 21:26:16 +08:00
XTLine e4c8a27b73 fix(goal): restore cumulative active work duration 2026-10-09 21:26:04 +08:00
XTLine 468efe6d29 fix(desktop): release compaction admission and apply live context settings 2026-10-09 21:25:54 +08:00
YHH 18a8a95d1f Merge pull request #11513 from XTLine/fix/remote-session-badges
fix(remote): align session read badges and prevent false connection warnings / 统一远端会话已读徽标并修复连接状态误报
2026-10-08 22:26:43 -07:00
YHH eca6834dad Merge pull request #12418 from esengine/fix/attachment-oversize-before-read
fix(attachments): refuse oversized files before reading them into memory
2026-10-08 04:55:41 -07:00
yhh 694b096ffc fix(attachments): refuse oversized files before reading them into memory
The composer's file picker, paste and pathless drops hashed and base64-encoded the whole File before any size check, and the kernel decoded the whole data URL before comparing against the 25 MB cap. A large archive therefore exhausted memory instead of being refused.
2026-10-08 04:39:29 -07:00
YHH 4e7831ba12 Merge pull request #12307 from esengine/docs/release-1.39.8-notes-refresh
docs(release): refresh v1.39.8 stable notes
2026-10-07 07:26:49 -07:00
YHH 4024bc66dd Merge pull request #12302 from esengine/fix/image-gate-blocks-only-declared-text-only
fix(images): block image input only for models declared text-only
2026-10-07 05:32:07 -07:00
yhh e5b701091d docs(release): refresh v1.39.8 notes with fixes merged since the draft 2026-10-07 04:06:04 -07:00
yhh 92104fdf8a fix(images): block image input only for models declared text-only
An undeclared model was treated as unable to read images, so attaching one
failed with no image understanding model configured. Images left in a tool
result or in older history made every later request fail as a whole, even a
plain-text one.

Undeclared models now pass images to the provider unless a fallback image
model is configured; a vision_models list that omits the model declares it
text-only; tool and historical images that no model can read become a
host-written note instead of failing the request.
2026-10-07 03:46:24 -07:00
XTLine c204fe1a83 Merge branch 'main-v2' into fix/remote-session-badges 2026-10-05 16:47:26 +08:00
YHH 60204c3030 Merge pull request #12015 from esengine/fix/desktop-release-not-repo-latest
fix(release): desktop releases no longer claim the repository-wide latest
2026-10-04 20:56:09 -07:00
yhh b83ad671b4 fix(release): desktop releases no longer claim the repository-wide latest
Cause: stable desktop-v* releases were created with --latest, and the 1.x
CLI release smoke check fetched releases/latest/download/latest.json. Studio
is about to become the repository's GitHub latest; with this script still
claiming it, each 1.x desktop patch would take the badge back, and the smoke
check would read whichever release holds it.

Blast radius: GitHub release metadata for new desktop-v* stable releases
(latest flag only; prerelease and manual-exception paths already used
--latest=false) and one step of release.yml for stable v* tags. The updater
reads R2 and the release gateway, which do not use GitHub's latest. Comments
in release-desktop.yml, desktop/README.md and desktop/updater.go no longer
say desktop releases own the badge.

Neighbouring behaviours tested: scripts/release-workflows.test.sh (new
assertions pin --latest=false on the stable path and require the new CLI
asset verification step; both fail without the change); actionlint on
release.yml and release-desktop.yml; shellcheck on the publisher. The
verification step itself cannot run locally and was not exercised.

Why this layer: the flag is set in the one publisher script every desktop
release goes through. The replaced smoke check proved a public GitHub path
that is meant to go away; the new step checks what the CLI release serves
(asset set, sizes and digests against SHA256SUMS, uploaded latest.json
byte-identical) through the existing publication decider, failing closed.
2026-10-04 20:26:48 -07:00
YHH 3b4319cf9a Merge pull request #11783 from aron-intframe/fix/10456-derived-record-identity
fix(transcript): keep derived history record identities unique
2026-10-04 10:01:29 -07:00
YHH da81a9e600 Merge pull request #11583 from SilenceLurker/codex/qq-approval-delivery
fix(bot/qq): restore approval keyboard delivery and text fallback
2026-10-02 21:53:16 -07:00
INTFRAME a7c634dc63 fix(transcript): keep derived history record identities unique
Interrupted-attempt records are persisted without a message id, so
stampHistoryRows derived "m::notice:0" for every one of them and
NewProjection rejected the whole baseline, leaving the session
unopenable across restarts. Disambiguate the derived address the same
way repairCheckpointRecordIdentities already does. Canonical duplicates
are untouched and still rejected.

Refs #10456
2026-10-03 02:58:31 +07:00
YHH 8166f35a08 Merge pull request #11746 from esengine/docs/release-1.39.8-notes
docs(release): prepare v1.39.8 notes
2026-10-02 07:03:19 -07:00
esengine c55502f890 docs(release): prepare v1.39.8 notes 2026-10-02 06:58:10 -07:00
YHH 428877db31 Merge pull request #11717 from esengine/fix/11581-lineage-archive
fix(desktop): archive a whole recovery lineage in one action
2026-10-02 06:56:20 -07:00
esengine 756050ddf9 fix(desktop): archive a whole recovery lineage in one action
Recovery snapshots written by older builds chain each ParentID to the previous
snapshot, so matching on an equal ParentID retired only the few snapshots that
shared a parent per action. Follow the ParentID chain to its root, archive each
snapshot under its own runtime lock hold, and report an unfinished lineage as a
committed archived_partial outcome so a retry finishes it.

Refs #11581
2026-10-02 06:24:51 -07:00
YHH 3dae084f94 Merge pull request #11722 from esengine/fix/11693-catalog-reconcile-loop
fix(desktop): stop non-session churn from rescanning catalog roots
2026-10-02 06:01:42 -07:00
esengine da58ed7454 fix(desktop): stop non-session churn from rescanning catalog roots
Every filesystem event in a watched session directory that was not a
transcript or its .meta sidecar dirtied the whole root, and in the
advisory (metadata-only) catalog every admitted root runs a full scan
that always starts a new scan generation. Lock files, temp files of
atomic writes, sidecars other writers keep beside transcripts (2.x
.wire/.execution logs, .context.json), and subdirectory timestamps all
qualified, so a directory whose sessions never changed was rescanned
on every 250 ms batch for as long as anything touched it (#11693:
ready<->scanning every ~3 s, scan_generation +1 per sample, WAL growth).

- Events are admitted only for what a metadata scan reads: transcripts,
  their branch sidecar and their event log go to the exact-path index;
  removal of a transcript or a root event still reconciles the root;
  everything else in the directory is ignored.
- Full-root admissions are paced per root with exponential backoff
  (1 s doubling to 60 s, reset after two quiet minutes), so any trigger
  that never settles converges; a failing journal write backs off too
  instead of retrying every batch. A removed or renamed transcript,
  sidecar or event log expedites its root past the backoff without
  growing it; pacing state is dropped with the root.
- User-initiated archive, trash-topic and remove-topic paths poll the runtime mutation locks for up to
  1.5 s instead of a single TryLock that loses to every brief background
  holder (each discovery completion requests legacy receipt
  reconciliation, which can take the same locks). Busy refusals now log
  which holder refused them; the user-facing code is unchanged.

Refs #11693
2026-10-02 05:34:38 -07:00
YHH 39c861412a Merge pull request #11690 from esengine/fix/11338-subagent-rows
fix(desktop): keep sub-agent tool rows inside their parent card
2026-10-02 02:00:21 -07:00
esengine 406b761ff0 fix(desktop): nest sub-agent tool calls under their parent in record-projected transcripts
convertRecord built tool items from assistant records without copying
the call's parentId, so every sub-agent call (task and fleet workers)
rendered as a flat sibling row in the main list on the transcript v2
path, live and after reopening a session. Also pin that the runtime tags
a background task's tool events with the parent call id.
2026-10-02 01:34:11 -07:00
YHH dbaaf72c01 Merge pull request #11666 from Harbor404/fix/main-v2-11617-commandcode-400-retry
fix(responses): retry Command Code transient 400 once
2026-10-02 00:17:15 -07:00
Harbor404 f3cc04465f fix(responses): keep Command Code retry within lint limits 2026-10-02 14:45:13 +08:00
YHH 6dbabcbf71 Merge pull request #11669 from esengine/docs/release-1.39.7-notes
docs(release): prepare v1.39.7 notes
2026-10-01 22:49:57 -07:00
esengine 8d61174ca0 docs(release): prepare v1.39.7 notes 2026-10-01 22:47:39 -07:00
Harbor404 6c32116500 fix(responses): retry Command Code transient 400 once 2026-10-02 13:27:54 +08:00
YHH da0ed97148 Merge pull request #11647 from esengine/fix/v1-legacy-scope
fix(desktop): derive the scope of tab-derived legacy sessions from their project directory
2026-10-01 20:49:29 -07:00
esengine 5421b86ed3 fix(desktop): derive the scope of tab-derived legacy sessions from their project directory
A saved tab pointing into a project's session directory that no registered
scope owned inherited the tab's own scope, so sessions recorded under that
project were judged workspace_conflict against Global. The scope now comes
from the session's recorded root when that root's session directory is the
source directory; any other mismatch still reports a conflict.
2026-10-01 20:17:00 -07:00
YHH 3c8695b470 Merge pull request #11628 from esengine/feat/v1-telemetry-v5
fix(desktop): keep session telemetry across switches for canonical sessions
2026-10-01 12:20:04 -07:00
SilenceLurker 38782678d4 chore: merge latest main-v2 into QQ approval fix 2026-10-02 03:19:44 +08:00
esengine dd2485645a fix(desktop): persist session telemetry for canonical sessions
Sessions in the v5 store have no transcript path, so the telemetry
sidecar was never written and every session switch reopened with an
empty snapshot. Key the sidecar by session id under the desktop state
home and restore it when a session is opened.
2026-10-01 12:04:23 -07:00
YHH 1eb620fb71 Merge pull request #11625 from esengine/feat/v1-history-head-fold
fix(desktop): fold a turn cut by a page boundary and load the next page at the bottom
2026-10-01 11:58:32 -07:00
esengine 81b255a538 fix(desktop): fold a turn whose user message is on an older page and load the next page at the window bottom
A history page that cuts a turn leaves its tail without a user message, and the fold logic required one, so those tool cards stayed expanded with no fold button. The head group now folds like any settled group, and its manual choice follows the turn once the earlier page loads.

After loading older pages the window drops the newest page and offered only manual buttons to get it back. Reaching the bottom of such a window now requests the next page once, under the same selection and reader-intent guards as the other paging paths.
2026-10-01 11:11:54 -07:00
YHH f8a5df2669 Merge pull request #11597 from esengine/fix/second-fork-source-receipt
fix(desktop): a second fork of one source no longer fails the workspace load
2026-10-01 05:59:02 -07:00
esengine b777bde623 fix(desktop): a second fork of one source no longer fails the workspace load
attachDesktopSession registered the manifest source for every session whose
own receipt was missing, but RecordSource owns a source key globally, so the
second fork naming the same parent source hit ErrMutationConflict and made the
whole workspace history unloadable. Skip registration when the source key is
already mapped.

Refs #11587
2026-10-01 05:42:56 -07:00
YHH a879773823 Merge pull request #11576 from esengine/fix/goal-edit-no-prompt
fix(desktop): edit Goal in an in-app form instead of window.prompt
2026-10-01 05:02:21 -07:00
esengine fccddf170d fix(desktop): polish the inline Goal edit form
Compact layout, side-by-side Save/Cancel, error colour, Esc closes only the form and returns focus. Refs #11573
2026-10-01 04:38:56 -07:00
SilenceLurker aec7849199 fix(bot/qq): restore approval keyboard delivery and text fallback 2026-10-01 19:10:24 +08:00
esengine cbba10ae8c fix(desktop): edit Goal in an in-app form instead of window.prompt
Electron renderers throw on window.prompt(), so Edit goal did nothing.
Refs #11573
2026-10-01 03:19:16 -07:00
YHH a649c72188 Merge pull request #11512 from XTLine/fix/remote-switch-history-stall
fix(desktop): preserve remote submissions and history across busy session switches / 修复远端忙碌会话切换时的消息提交与历史加载
2026-10-01 02:17:00 -07:00
XTLine 30f5ff1e2b Merge generated metadata refresh into remote session badges 2026-10-01 16:05:20 +08:00
XTLine eaafef675b fix(desktop): refresh generated metadata after module extraction 2026-10-01 16:04:32 +08:00
XTLine a308987f1b Merge branch 'fix/remote-switch-history-stall' into fix/remote-session-badges 2026-10-01 15:08:52 +08:00
XTLine 3db246f5fc refactor(remote): extract session submission and retry modules 2026-10-01 15:08:50 +08:00
XTLine bbd4e65c69 test(desktop): wait for remote controller teardown 2026-10-01 15:05:53 +08:00
XTLine cede083695 fix(remote): route identity runtime badge updates 2026-10-01 11:10:41 +08:00
XTLine 9168b3439d test(remote): cover identity runtime badge routing 2026-10-01 11:07:57 +08:00
YHH 164cfef2dd Merge pull request #11491 from esengine/docs/release-1.39.6-notes
docs(release): prepare v1.39.6 notes
2026-09-30 18:11:43 -07:00
esengine 2ce60e12f0 docs(release): prepare v1.39.6 notes 2026-09-30 18:07:31 -07:00
YHH 136bd5fcd1 Merge pull request #11487 from esengine/ci/fix-release-control-timeout-assert
test(ci): accept the 25m desktop Go timeout the Windows groups now pass
2026-09-30 18:03:27 -07:00
esengine 78395619f1 test(ci): accept the 25m desktop Go timeout the Windows groups now pass 2026-09-30 17:51:18 -07:00
XTLine 430010cc40 fix(desktop): align remote session read badges 2026-10-01 00:48:14 +08:00
XTLine 7ca9dc39b0 test(desktop): cover remote session read activity 2026-10-01 00:48:14 +08:00
XTLine 2c04fece87 fix(desktop): paint resident remote sessions on switch 2026-10-01 00:48:13 +08:00
XTLine 0b3cad44cc fix(remote): fence stale session badge updates 2026-10-01 00:48:13 +08:00
XTLine 38028e0f86 test(desktop): cover resident remote session paint 2026-10-01 00:48:13 +08:00
XTLine 6259f77bd0 test(remote): cover session switch badge states 2026-10-01 00:48:13 +08:00
XTLine aaaf521163 fix(desktop): show busy submits in the queue 2026-10-01 00:48:13 +08:00
XTLine 02c1732e68 test(desktop): cover busy submit queue receipts 2026-10-01 00:48:13 +08:00
XTLine 219aadd409 fix(desktop): retry transient submit windows 2026-10-01 00:48:13 +08:00
XTLine 9fb4dd8fb0 test(desktop): cover transient submit windows 2026-10-01 00:48:13 +08:00
XTLine 0004d81e52 fix(serve): preserve busy identity sessions on switch 2026-10-01 00:24:44 +08:00
XTLine 8b6b63c92c test(serve): cover busy identity session switches 2026-10-01 00:23:44 +08:00
YHH 4115cc702e Merge pull request #11431 from SivanCola/codex/config-upgrade-compat-fix
Fix legacy project configuration warnings in 1.x / 修复 1.x 旧项目配置误告警
2026-09-30 08:54:05 -07:00
SivanColaandxiaoyuangede 28b3300cc5 fix(config): stop warning on compatible legacy project declarations
Problem: The 1.38.3 to 1.39.5 upgrade produces persistent configuration
banners for equivalent paths and historical project permission declarations.

Root cause: Declaration filtering runs before saved project grants are merged,
and exact string comparisons miss permission coverage and directory identity.
Unapproved permission declarations are also treated as configuration failures.

Fix: Fold existing workspace grants into the held authority before narrowing,
reuse permission coverage and filesystem identity checks, and retain unapproved
declarations in the existing refusal view without banner warnings. Keep grants
workspace-scoped and preserve all approval, ask and deny constraints.

Keep doctor's sandbox layering hint with accurate narrowing wording and retain
key/value warnings. The existing CLI config/language loading and plain warning
origin are preserved; no new diagnostic attribution or wire contract is added.

Verification: Permission/path/grant revocation regressions, read-only file
preservation, doctor detail/source and CLI language tests passed. Focused race
checks and repository structural lint passed; full root and Desktop caller
verification are recorded in the PR.

Refs #11430

Co-authored-by: xiaoyuangede <[email protected]>
2026-09-30 23:01:02 +08:00
YHH 369f55cbf5 Merge pull request #11415 from jf200s/pr/main-v2-bugfixes
fix: stop goroutine leaks and repair Windows-hostile tests / 修复 goroutine 泄漏与 Windows 环境测试失败
2026-09-30 07:10:34 -07:00
F b0b7cfa34c test(desktop): use an integer range in the emit regression loop / 修复 golangci-lint intrange 2026-09-30 21:20:46 +08:00
YHH 77eab9e8e2 Merge pull request #11414 from esengine/fix/11412-loadfull-streaming
fix(desktop): keep 'load full' on a streaming reasoning block
2026-09-30 05:38:06 -07:00
esengine f3452e3a82 fix(desktop): keep chat.loadFull on a streaming reasoning block
The loaded full text was only honoured while the item was the same object or
the text was unchanged. A streaming block replaces its item on every chunk, so
the view snapped back to the 8000-char preview and the button reappeared.
While streaming, loading now follows the live reasoning; on settlement the
canonical full text is loaded once.
2026-09-30 05:23:27 -07:00
F a311dd464e chore: pin desktop suite timeout to 25m / 为桌面套件固定 25 分钟超时
Makefile desktop-test/-short/-times and ci.yml (via scripts/desktop-windows-go-tests.mjs testArgs) pass -timeout=25m: the full suite needs ~25 min locally, past go's 10-minute default panic. The -short target alone was measured at 933s.
2026-09-30 20:05:22 +08:00
F 9e32035f32 test(desktop): skip packager manifest test when bash cannot run / PATH 中的 bash 无法执行时跳过打包清单测试
Same root cause as the e2ebench fix: the System32 WSL relay stub satisfies LookPath("bash") but cannot run scripts, so the packager dies with a WSL error instead of its own validation output. Pre-existing on main-v2; deterministic failure under the native Windows registry PATH (3/3 subtests), green under a real bash. The probe requires bash -c true to succeed within 3s and skips otherwise.
2026-09-30 20:05:11 +08:00
F b124e2a5fa test: repair Windows-hostile tests and the HTTP/2 mock flake / 修复 Windows 环境测试失败与 HTTP/2 mock 偶发
All pre-existing on main-v2; upstream CI (Linux) never satisfies the exposure conditions (native Windows PATH, 8.3 short TEMP, load windows).

- authentication_app_test: emit callback sends non-blocking instead of wedging the shared asyncRuntimeEmitter on a second send
- tabs_topic_test: waitForTabReady deadline 5s -> 15s under load
- shellrun / clientio / project_scope_effect: PowerShell equivalents for POSIX-only commands
- e2ebench: requireRealBash probe; LookPath matches the System32 WSL stub
- statusline_test: cat -> more on Windows, mirroring TestRunStatuslineCmd
- persistentshell: expand 8.3 short TEMP paths via GetLongPathName
- provider HTTP/2 mock: drain inbound frames before close; a Windows close with unread data sends RST and clobbers the GOAWAY/RST_STREAM just written (upstream #11152; -count=200 green vs ~5/100 before)
- resume_test: restore desktop-projects.json so -count>=2 stays clean
2026-09-30 20:05:11 +08:00
F 7dcfb7fa46 fix: stop botBridgeHub and recordDispatcher goroutine leaks / 修复 botBridgeHub 与 recordDispatcher goroutine 泄漏
Both leaks exist verbatim on main-v2; a 10-minute goroutine dump showed 1279 botBridgeHub and 112 recordDispatcher goroutines stranded.

botBridgeHub: idempotent Close (closed chan + sync.Once) wired into App shutdownBody via stopBotBridge; the queue channel is never closed because enqueues race the close. recordDispatcher: per-directory shutdown via CloseRecordDispatchers in closeCLIUsageCatalogs; late enqueues keep the existing best-effort drop. Regression: TestBridgeCloseStopsRunGoroutine.
2026-09-30 20:05:11 +08:00
YHH 9a165ea6dc Merge pull request #11405 from esengine/fix/turn-fold-manual-11338
fix(desktop): let every ended turn be folded by hand
2026-09-30 02:32:17 -07:00
esengine 5a7fd42d6a fix(desktop): let every ended turn be folded by hand
The process fold control was only projected for a turn that ended cleanly
with a final answer. A stopped turn, an errored turn, or one holding an
unfinished tool or a leftover streaming item stayed expanded and showed no
control at all.

Every ended turn now gets the control. Only a settled turn still starts
folded; the others keep starting open, and a manual choice is stored
explicitly per turn so it survives republication and a session revisit
even when the turn's settled state changes afterwards.
2026-09-30 02:12:54 -07:00
YHH 7da1197efc Merge pull request #11401 from esengine/docs/readme-certum-signing
docs: say Windows installers are signed with Certum, not SignPath
2026-09-30 02:07:57 -07:00
esengine 9aad6d8b4c docs: say Windows installers are signed with Certum, not SignPath 2026-09-30 01:50:29 -07:00
YHH 3da4a00263 Merge pull request #11396 from esengine/ci/cli-publish-frozen-switch
ci(release): add a CLI_PUBLISH_FROZEN switch so 1.x can stop writing the CLI channels
2026-09-30 01:09:13 -07:00
esengine a9cd939c1f ci(release): add a CLI_PUBLISH_FROZEN switch that stops 1.x from writing the CLI channels
With the repository variable CLI_PUBLISH_FROZEN set to 'true', a 1.x release no
longer writes the Homebrew cask, the R2 cli/{stable,preview}/latest.json
pointers, or npm latest/canary/next. npm packages are still published, under
the legacy-v1 dist-tag (npm rejects v1: it parses as a semver range). The
GitHub release, its immutable R2 record, and Desktop are unchanged. Unset, the
workflows behave exactly as before.
2026-09-30 00:50:06 -07:00
YHH ddf6ca6820 Merge pull request #11386 from esengine/fix/interrupted-recovery-precedence
fix(agent): new user message outranks the interrupted-turn recovery block
2026-09-29 23:34:19 -07:00
esengine c2d5560aac fix(agent): state that the new user message outranks the interrupted-turn recovery block
The recovery block said the interrupted turn was not a new task but never
said which instruction wins, so a fresh, unrelated user message could be
read as a cue to resume the old task. The block now says the user's new
message takes precedence.
2026-09-29 23:06:15 -07:00
YHH 51a3ed710c Merge pull request #11339 from esengine/revert/11257-http1-compat
Revert "fix(provider): add config-only HTTP/1.1 compatibility" (#11257)
2026-09-29 08:45:01 -07:00
esengine dd7625ca58 Revert "fix(provider): add config-only HTTP/1.1 compatibility" (#11257)
main-v2 (1.x) takes bug fixes only. #11257 added a new configuration
setting (http1_only) and, as a side effect, changed the default proxy
route used by the desktop credential proxy for SSH remote workspaces:
those routes now go through netclient.NewTransport(cfg.NetworkProxySpec())
even when http1_only is off, which is a default-behaviour change, not a
fix. Any default-behaviour change on the fixes-only 1.x line is reverted
per project policy.

The feature continues on studio in #11293.

This reverts commit 20134210be.
2026-09-29 08:20:58 -07:00
SivanCola 20134210be fix(provider): add config-only HTTP/1.1 compatibility / 连接级 HTTP/1.1 兼容修复 (#11257)
* Add per-connection HTTP/1.1 compatibility for model providers

Problem: Some DeepSeek and compatible endpoints negotiate HTTP/2, accept a
written request, then fail with PROTOCOL_ERROR before returning headers.

Root cause: Provider transports always attempted HTTP/2 and offered no
connection-scoped fallback. Responses also silently ignored invalid proxy
settings, and credential-proxy routes used inconsistent network policy.

Fix: Add an explicit HTTP/1.1 option for each provider connection, expose it
in Desktop settings and structured protocol errors, and apply it to provider
requests, probes, discovery, and credential-proxy upstreams. Preserve proxy
and TLS validation, never replay a failed request automatically, and fail
closed on invalid Responses proxy settings. Record the selected HTTP mode.

Verification: Root Go suite, Desktop model-settings/proxy tests, focused race
tests, frontend typecheck/build/tests, browser interaction, repolint, and a
v1.39.5 previous-reader configuration round trip passed before publication.

* test(desktop): scope provider address assertions to connection fields

Problem: desktop-frontend fails two provider editor assertions, which also
fails the lint aggregate on the HTTP/1.1 compatibility PR.

Root cause: address migration tests prohibit radio buttons across the entire
editor, incorrectly rejecting the independent HTTP transport controls.

Fix: share a scoped assertion for new and existing connections that requires
exactly one request-address input and no address-mode radio buttons within
the connection fields. Keep transport controls and address migration checks.

Verification: reproduced both original failures; all 57 provider editor
assertions now pass. Production and test TypeScript checks, targeted ESLint,
repository lint and git diff checks pass. The full CI frontend test plan is
running separately; cross-platform CI will validate this commit.

* fix(provider): restrict compatibility advice to protocol errors

Problem: HTTP/2 overload errors could suggest a protocol change, and the TLS regression matched error text.

Root cause: the UI gated only on the transport category; the fixture bypassed the existing typed error classifier.

Fix: require PROTOCOL_ERROR and classify the real transport error structurally. Cover overload, refusal, missing, and non-protocol codes.

Verification: focused HTTP/1.1 TLS tests and compatibility action tests pass.

* fix(provider): narrow HTTP compatibility to persisted connection policy

Problem: the maintenance PR bundled a transport workaround with new desktop
settings, RPC fields and a one-click workflow, before user-network validation.

Root cause: the user-facing feature and the minimum connection policy were
delivered as one change despite different release and validation boundaries.

Fix: retain only opt-in config transport behavior and preserve the full UI on
a separate branch. Existing desktop edits retain the config-only field; probes
and model discovery read it from the saved connection. Remove the new desktop
contract and diagnostic fields. Document manual restart and same-network A/B
validation without replaying written requests or claiming an EOF repair.

Verification: provider/netclient suites, config/boot protocol tests, desktop
save/probe/discovery/credential-proxy regressions, root and desktop lint,
generated-contract freshness and diff checks pass. User-network validation
remains outstanding.
2026-09-29 22:35:16 +08:00
YHH f41512ed00 Merge pull request #11319 from KHG420/codex/1x-active-legacy-version-11312
fix(desktop): keep mapped active legacy versions when archiving siblings
2026-09-29 07:29:25 -07:00
xiexian df6b737fba fix(desktop): preserve mapped active legacy versions when archiving siblings 2026-09-29 21:04:36 +08:00
YHH d0572a4f5a Merge pull request #11303 from KHG420/codex/1x-manual-sort-reset-11296-clean
fix(desktop): allow resetting manual session order
2026-09-29 05:47:32 -07:00
YHH ee72c66af6 Merge pull request #11309 from esengine/fix/v1-11301-legacy-trash-rematerialize
fix(desktop): archive unnamed-fork and concurrent versions of a legacy row together
2026-09-29 05:43:10 -07:00
esengine a0df39a7a1 fix(desktop): archive unnamed-fork and concurrent versions of a legacy row together
#11187 grouped the versions of a legacy transcript by walking parent links
only through rewind heads. Transcripts written by 1.38.x also carry versions
as unnamed fork heads ("fork from this turn", /branch <turn>) and as rewinds
made on a second writer's concurrent head; each of those stayed its own
group, so every "Move to trash" still archived one version and brought the
next same-title row up.

A head now starts a conversation of its own only when it is a fork with a
name. Siblings already archived or removed are skipped: re-archiving them
compares the whole transcript's fingerprint, which any other head's write
moves, and materialized another copy of an unchanged version.
2026-09-29 05:07:19 -07:00
YHH 43dc1778d2 Merge pull request #11273 from KHG420/codex/1x-bot-session-readonly-7197
fix(desktop): open embedded bot sessions through shared runtime
2026-09-29 04:10:37 -07:00
xiexian 923370645c fix(desktop): allow resetting manual session order 2026-09-29 18:37:58 +08:00
YHH aad51d1970 Merge pull request #11294 from esengine/fix/v1-11274-import-failure-cause
fix(desktop): show why a historical import failed and where to review it
2026-09-29 03:33:44 -07:00
esengine 3575809b3e fix(desktop): show why a historical import failed and where to review it
A historical preparation that failed reached the user as one sentence for
every cause: runHistoricalPreparation mapped each error that was not a busy
source to import_failed and kept the cause only in service.log, and the
banner discarded thrown errors and treated an open failure after a
successful import as a failed import. The sentence promised recovery
details that nothing on the banner led to.

The preparation now projects the identity the producer attached (workspace
conflict, replay limit, damaged history, unsupported format, state
conflict, changed source) and carries a bounded detail. The banner and the
historical list render the class with the detail behind the details
toggle, an open failure is reported as such, and a recovery-details action
opens Settings > Storage, where the historical list and recovery review
live.

Refs #11274
2026-09-29 03:14:14 -07:00
YHH b63d4151a2 Merge pull request #11282 from esengine/fix/v1-offline-session-load
fix(desktop): show why a session's history failed to load
2026-09-29 02:39:39 -07:00
YHH 9b6437fe06 Merge pull request #11271 from esengine/fix/v1-11254-stale-conn-retry
fix(provider): resend once when a pooled idle connection was already dead
2026-09-29 02:38:53 -07:00
YHH 9eab98f5e7 Merge pull request #11270 from esengine/fix/v1-11241-new-session-list
fix(desktop): keep new sessions reachable in a manually ordered sidebar
2026-09-29 02:38:08 -07:00
YHH 425c59b2c5 Merge pull request #11086 from KHG420/codex/1x-remote-session-presets
fix(serve): persist permission presets per remote session
2026-09-29 02:24:44 -07:00
YHH 00b14555bd Merge pull request #11253 from esengine/fix/v1-powershell-prelude
fix(shell): defensive PowerShell prologue, UTF-8 redirects on 5.1, Store pwsh detection
2026-09-29 01:50:49 -07:00
esengine c92312d04d fix(desktop): show why a session's history failed to load
Both local history-read failure paths replaced the reader's error with
the generic "failed to load history" sentence, so the recovery banner's
Details repeated the summary and the composer stayed on "Send after
recovery" with no visible cause. Keep the summary and append the
follow client's stage.reason identity and the host message.
2026-09-29 01:44:41 -07:00
esengine c9e9da581c fix(provider): resend once when a pooled idle connection was already dead
After an idle pause, the next turn could fail with `unexpected EOF` (HTTP/2)
or `EOF` (HTTP/1.1) although the request never reached the provider: the
transport reused a pooled connection that a local proxy or the peer had
dropped while idle, and it closed on the first write. The standard library
does not retry a POST in this state, and since #10730 SendWithRetry sends
exactly once, so the failure reached the user instead of being absorbed by
the old backoff loop. Regression since 1.39.0.

SendWithRetry now resends once on a fresh connection when, and only when,
all of these hold, read from httptrace and error identity rather than text:
the attempt went out on a reused idle connection, no response byte arrived,
the error is io.EOF / io.ErrUnexpectedEOF / a connection reset or abort
(Winsock codes on Windows), and the context is not done. Idle connections
are closed before the resend. Both attempts are counted, so usage reports
the real request count. Fresh-connection failures, TLS handshake timeouts
and every non-OK status still return to the caller unchanged.

Closes #11254
2026-09-29 01:39:46 -07:00
esengine 2b6128ff27 fix(shell): make the PowerShell prologue defensive and fix 5.1 redirect encoding
The prologue was one chained assignment,
`$OutputEncoding=[Console]::OutputEncoding=[System.Text.Encoding]::UTF8`.
When the Console assignment throws (no console attached, or
ConstrainedLanguage refusing .NET property writes) neither encoding was
set and every command printed a 7-8 line error first. Each assignment is
now its own try/catch and .NET properties are only touched in
FullLanguage. Both encodings are BOM-less UTF8Encoding instances, so text
piped to native programs no longer starts with EF BB BF.

On Windows PowerShell 5.1 only, the shell tool also sets
$PSDefaultParameterValues['Out-File:Encoding']='utf8', so '>' and Out-File
write UTF-8 instead of UTF-16LE. '*:Encoding' is deliberately not set: it
would make Get-Content misread ANSI (GBK) files. Hooks keep only the output
part of the prologue, since they run the user's own scripts.

PowerShell discovery adds the Store install's execution alias
(%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe). An alias is a zero-byte
reparse point that stats fine but may fail to start the packaged app, so an
alias candidate (from the list or from PATH) must launch once before it is
selected; regular executables are taken as found.

The Windows PowerShell tool description now points file reads and writes at
read_file/edit_file/write_file instead of Get-Content/Set-Content/Out-File/'>',
which changes the Windows tool-schema golden (one-time prefix-cache miss).
2026-09-29 01:19:18 -07:00
esengine 2370aa5aad fix(desktop): keep new sessions reachable in a manually ordered sidebar
Dragging one conversation turns on manual ordering for its project. The
manual order was seeded from the registry's attach order, which is oldest
first, so the first drag flipped the whole list; and every new session was
then appended to the tail. The sidebar pages the list from the top, so with
hundreds of conversations a new one was visible only while active and
vanished from the list as soon as the user switched away (#11241).

A new unparented session now goes to the head of a manual order, and the
first move seeds the manual order from the listing the sidebar was showing,
in the sort mode the sidebar sends with the move.
2026-09-29 01:19:05 -07:00
YHH 1a1ccbef33 Merge pull request #11238 from SivanCola/codex/fix-goal-lifecycle-arguments
fix(goal): tolerate and disclose unused lifecycle fields / 兼容并说明未生效的目标操作参数
2026-09-29 01:00:28 -07:00
xiexian 7cefa9c194 Merge remote-tracking branch 'origin/main-v2' into codex/1x-bot-session-readonly-7197 2026-09-29 15:51:22 +08:00
xiexian 522001b7be fix(desktop): open embedded bot sessions through shared runtime 2026-09-29 15:46:34 +08:00
SivanCola 213b8fa2db fix(goal): disclose fields ignored by lifecycle actions
Problem: lifecycle calls accept inactive fields, but a successful result did
not explicitly tell the model that conflicting supplied values were unused.

Root cause: action-specific mutation filtering had no corresponding result
feedback, leaving callers to infer it from the returned Goal snapshot.

Fix: identify unused non-null text fields and supplied unused round limits
in the result instruction after a successful owner update. Preserve terminal
instructions and authoritative state without another read or new schema.
Remove upgrade-history notes from contract docs and the redundant comment.

Verification: ignored-field regression fails against the preceding code;
update-goal and cold-restored continuation tests pass, including race checks.
Builtin/boot suites, both shell golden guards, go vet, and make lint pass.
2026-09-29 15:38:34 +08:00
SivanCola 8580ea084b fix(goal): consume update arguments by lifecycle action
Problem: resume calls fail when models echo the objective and populate unused
optional fields after get_goal.

Root cause: update_goal rejects fields for other actions, including empty
blockers and null round limits, before reaching the lifecycle owner.

Fix: consume only the selected action's mutation fields and allow nullable
text placeholders. Preserve exact revisions, authority checks, edit semantics,
and propagated lifecycle failures. Document the contract in both languages.

Verification: goal argument and cold-recovery regressions, Bash/PowerShell
golden guards, go vet ./..., and make lint pass. Static tool schema changes
cause a one-time cache-prefix change on upgrade; system prompts are unchanged.
2026-09-29 14:28:05 +08:00
YHH 14ed15ba5e Merge pull request #11232 from esengine/fix/v1-11229-windows-command-line-limit
fix(bash): name the Windows command-line limit instead of "filename too long"
2026-09-28 23:08:04 -07:00
xiexian a42067d245 test(serve): authenticate remote preset route fixture 2026-09-29 13:44:49 +08:00
xiexian de2c07c46f Merge remote-tracking branch 'origin/main-v2' into codex/1x-remote-session-presets 2026-09-29 13:43:05 +08:00
esengine 1ad0716a37 fix(bash): name the Windows command-line limit instead of "filename too long"
A shell command whose command line exceeds CreateProcessW's 32,767 UTF-16
unit ceiling never starts; the model was told "fork/exec powershell.exe:
The filename or extension is too long." and read that as a file-size
problem. On Windows the PowerShell tool passes the whole script as one
-Command argument, so writing a large block of text (e.g. a Chinese
document in a here-string) hits this every time.

The host now counts the command line exactly as os/exec builds it and,
when it will not fit, refuses the call before launch with a typed error
(errCommandLineTooLong) that states the size, the limit, and that nothing
ran, and points at write_file/edit_file. The execution is recorded as
not run / not started.

Refs #11229
2026-09-28 22:09:56 -07:00
YHH e6b520c39b fix(config): workspace configuration only narrows the user's settings (#11209)
A workspace's reasonix.toml and .reasonix/settings.json are read as
untrusted input.

- [sandbox]: bash may only move to enforce and network only to false;
  forbid_read adds to the user's; allow_write entries and workspace_root
  apply only when they resolve inside the workspace. ${VAR} in sandbox
  and proxy paths expands from the process environment only.
- [permissions] mode, allow and allow_dynamic_bash, [network] proxy
  settings, [bot], and [desktop] default_tool_approval_mode, language
  and currency are read from the user config only; deny and ask add to
  the user's. An "always allow" or write-access grant given in a
  workspace is stored under the Reasonix home (project-grants.json)
  rather than in the checkout. The desktop no longer adopts a workspace
  reasonix.toml into the user config.
- Programs a workspace declares (hooks, language servers, providers,
  tools.search.rg_path, tools.shell.path and [browser] launch settings)
  run only once approved for that folder. An approval records the
  declaration and a digest of the files it names, is kept under the
  Reasonix home, and is checked again before each start; a change
  refuses the start with ErrProjectProgramChanged. `reasonix trust`
  lists and approves them, and the desktop hooks settings show waiting
  project hooks with an Approve action. Model settings that named only a
  held-back provider fall back to the user's choice.
- Single program paths may not point into the workspace, a temporary
  directory, an allow_write root or a toolchain cache.
- Sandbox write directories are resolved without following links that
  sit inside a writable directory, and host write directories keep the
  identity first seen. On Linux the toolchain caches are listed whether
  or not they exist yet.
- Every ignored value is reported as a load warning with a typed reason
  (Config.IgnoredProjectSettings); held-back hooks raise a notice.
2026-09-28 18:24:30 -07:00
YHH f733345587 Merge pull request #11210 from esengine/docs/v1.39.5-notes
docs(release): prepare v1.39.5 notes
2026-09-28 18:11:41 -07:00
esengine 4f1637e05f docs(release): prepare v1.39.5 notes 2026-09-28 18:09:38 -07:00
YHH 8f7e4a0748 Merge pull request #11190 from KHG420/codex/1x-project-state-collision-10682
fix(desktop): isolate newly added project slug collisions
2026-09-28 11:41:32 -07:00
xiexian 3770cb3856 fix(desktop): satisfy project update lint checks 2026-09-29 01:30:41 +08:00
xiexian 2835c52d2b fix(desktop): preserve legacy project state on re-add and import 2026-09-29 01:19:38 +08:00
YHH 64638bccd1 Merge pull request #11185 from KHG420/codex/1x-validate-permission-rules-6692
fix(desktop): reject permission rules for unregistered tools
2026-09-28 08:52:33 -07:00
YHH ae31f7388e Merge pull request #11188 from esengine/fix/desktop-11168-logo-theme
fix(desktop): tint the title-bar logo with the theme accent
2026-09-28 08:42:49 -07:00
xiexian ade8df6c1f Merge main-v2 into project collision fix 2026-09-28 23:35:09 +08:00
xiexian 420718b717 fix(desktop): explain MCP permission rules require exact tools 2026-09-28 23:21:43 +08:00
esengine 4e8dc79184 fix(desktop): tint the title-bar logo with the theme accent
The title-bar mark was an <img> of logo-symbol.svg, whose fill is fixed
blue, so it ignored the theme and was hard to see on dark themes (about
2.8:1 against the bar). It is now the same SVG used as a mask over
var(--accent), so it follows each theme's accent (4.6-9.7:1 on dark).

The startup splash and the static boot shell keep the brand blue: they
show before or while the theme loads.
2026-09-28 08:21:16 -07:00
xiexian 97f5a265de fix(desktop): accept configured disconnected MCP permission tools 2026-09-28 23:18:08 +08:00
YHH b76822bc19 Merge pull request #11187 from esengine/fix/desktop-legacy-archive-rematerialize
fix(desktop): archiving a legacy version row archives every version of it
2026-09-28 08:17:15 -07:00
xiexian c2974487dd fix(desktop): isolate newly registered project slug collisions 2026-09-28 23:12:33 +08:00
esengine 1d75becebc fix(desktop): archiving a legacy version row archives every version of it
Every rewind keeps the head it left as a live version, and the sidebar
expands each live head of a legacy transcript into its own row carrying
the conversation's title. "Move to trash" archived only the clicked head:
it materialized that version into desktop-sessions-v5 (one mapping, one
session state, one presentation entry) and the next version, same title,
moved up into its place. Repeated clicks walked the versions one by one,
so the list never visibly shrank and each click added a ~2 MB record
pointing at the same legacy transcript.

Re-archiving the same head was already idempotent; the records are
distinct versions, not copies of one.

Archiving a legacy head now also archives the other live heads that are
versions of the same conversation: heads linked to it through rewind
parents, read from the transcript's own head list. Named forks start a
conversation of their own and are left alone, as are retired and
concurrent heads. Each version goes through the existing source archive
path, so it is materialized at most once and a repeat is a no-op.
2026-09-28 07:44:23 -07:00
YHHandesengine d39c0ff888 fix(migrator): refuse a flat unit without the Electron shell and restore the previous release (#11182)
The 1.18.0-1.19.2 file updaters accept the current electron-v1 manifest and
publish only reasonix-desktop, reasonix and reasonix-guard out of the Linux
tarball; app/ never reaches the install. The migrator shipped as
reasonix-guard then activated versions/<v> without a shell, wrote
current.json, committed the legacy update transaction as healthy and removed
the flat files. The new desktop exits with "no Electron desktop shell", and
nothing is left to roll back to.

An absent or incomplete app/ tree now stops the flat migration before any
commit: no version directory, no current.json, no health mark, no cleanup.
When the old updater's transaction is still pending for this install and
version, the migrator rolls it back through the repair transaction (Unix
only) and relaunches the restored release through its Guard, the way the
legacy updater itself relaunches. The refusal carries errShellMissing and is
appended to <home>/desktop-shell/logs/legacy-migrate.log, since a migrator
started by an old updater has no terminal.

Tests reproduce the legacy updater's exact transaction steps from inside the
install (subprocess copies of the test binary, as the repair transaction
binds the running executable to the install directory), then assert the
rollback, and that a unit carrying app/ still commits the transaction.

Co-authored-by: esengine <[email protected]>
2026-09-28 07:29:54 -07:00
xiexian 24dc82f313 fix(desktop): keep permission command contract owner stable 2026-09-28 22:10:42 +08:00
xiexian b73f1174b2 test(desktop): preserve existing rules on rejected save 2026-09-28 21:59:06 +08:00
xiexian d62e151fcb fix(desktop): reject permission rules for unregistered tools 2026-09-28 21:53:03 +08:00
YHH d5c4e196a6 Merge pull request #11171 from SivanCola/codex/context-compaction-recovery
Bound compaction waits and preserve context on failure / 限制整理等待并保留失败上下文
2026-09-28 04:33:53 -07:00
SivanCola e8e7af5996 Document intentional exact error identity checks
Problem: Errorlint rejects direct comparisons in the error-ownership regression
and in the bare-deadline normalization branch.

Root cause: These checks intentionally distinguish an unchanged error from an
additional wrapper; errors.Is alone cannot prove that stronger invariant.

Fix: Document narrowly scoped errorlint exceptions for exact identity checks.
The bare sentinel may be replaced, while enclosing errors retain their wrappers.
No execution behavior changes.

Verification: Repository-pinned golangci-lint reports zero issues, repolint is
clean, and the focused error-boundary regressions pass with race detection.
2026-09-28 19:02:25 +08:00
SivanCola 7e45e66110 Preserve provider failure diagnostics across cleanup and restart / 保留收尾与重启后的模型请求失败诊断 (#11152)
* fix: preserve provider failure diagnostics across cleanup and restart

Problem: Failed requests could reopen without their failure identity or the
transport evidence needed to investigate them.

Root cause: Cleanup replaced terminal recovery metadata, HTTP/2 protocol
errors fell through classification, and request observations were process-local.

Fix: Preserve failure metadata at the shared cleanup boundary, recognize
HTTP/2 transport codes without enabling retries, and persist bounded redacted
request evidence atomically with turn completion. Localize failure summaries
and document additive persistence compatibility.

Verification: Full root Go suite, owning-package and focused race tests,
desktop history/export regressions, frontend tests and typecheck, Chromium
interaction checks, Windows provider cross-compilation, and repolint passed.

* fix: align diagnostic contracts and isolate lifecycle test fixtures

Problem: CI rejected stale desktop contracts and an error type assertion;
Windows lifecycle tests also timed out around filesystem finalization.

Root cause: The additive diagnostic field was not regenerated into the host
contract. Transport classification used a direct error assertion. Functional
checkpoint and cancellation tests unnecessarily coupled live state assertions
to multiple durable writes under short completion deadlines.

Fix: Regenerate the contract, reject wrapped API errors using errors.As, and
use existing in-memory checkpoint/controller fixtures for live semantics.
Retain all assertions and dedicated durable maintenance, checkpoint reload,
and cold-export coverage without changing production timeouts or CI gates.

Verification: Pinned golangci-lint 2.12.2, repolint, focused provider/control
tests, three race-test repetitions, checkpoint persistence reload, frontend
typecheck, and diff checks passed locally.

* fix: structure transport diagnostics and bound failed-turn evidence

Problem: Transport classification depended on error wording, UI projections
dropped structured diagnostics, successful requests persisted peer addresses,
and bounded request history could silently appear complete after eviction.

Root cause: Diagnostic identity was converted back to prose across transport
and presentation boundaries, while lifetime-wide request eviction lacked
bounded per-turn accounting and unsuccessful-turn persistence gating.

Fix: Classify exported HTTP/2 types and exact stdlib type/code identities;
carry diagnostic fields through all notice/history paths; persist only failed,
interrupted, or recovery-required turn evidence; and record per-turn dropped
counts and truncation, marking unavailable accounting incomplete.

Verification: Typed and real TLS connection/stream/GOAWAY tests, provider/control
race checks, cold-export and success-persistence regressions, per-turn loss
tests, frontend projection tests, Chromium three-locale interactions, production
frontend build, and bundle budgets passed. Preserve model bytes and retry policy.

* fix: keep optional diagnostics from blocking terminal commits

Problem: Encoding optional provider evidence could prevent required turn-end
events from committing. Persisted free-text transport errors duplicated the
structured evidence, and generated detail text duplicated HTTP/2 codes.

Root cause: Optional diagnostic errors escaped the shared terminal-event
builder, while transport prose was retained at multiple presentation boundaries.

Fix: Warn and omit failed diagnostic records while preserving required terminal
events. Remove transportError and its byte truncation. Leave transport-code
rendering to the structured frontend presenter. Update export documentation.

Verification: Real invalid-timestamp regression covers failed/interrupted/
recovery-required closures; synchronous and Desktop-send failures retain durable
turn/end and cold-export identity. Structured-evidence, no-prose, presentation,
and model-context tests pass, as do frontend typecheck and affected suites.
2026-09-28 19:01:57 +08:00
SivanCola bf979b7831 Preserve error ownership across compaction entry points
Problem: Compaction entry points relabelled image, extension, persistence and
validation failures as summary errors. An expired safe-pressure operation could
replace a later error with its budget timeout and discard recovery guidance.

Root cause: The shared finish wrapper classified every returned error using the
work context state instead of the owner and identity of the failure.

Fix: Classify provider failures only at the summary request and known summary
outcomes at their owner. Normalize deadlines only when the returned error and
work budget both identify expiry. Preserve wrapped causes and apply localized
context recovery guidance last. Restore oversized protected tool-result
coverage through successful summarization without truncation.

Verification: Regression tests reproduce missing-image classification, safe
timeout followed by extension blocks, persistence identity, compress validation
and localized hard-limit recovery. Check actual summary failure typing and the
protected-result replay with intact canonical history. Run focused race tests,
pinned lint, repolint and the complete root suite; track exact-head PR CI.
2026-09-28 18:57:11 +08:00
SivanCola 1405c2a043 fix: narrow compaction recovery to the 1.x maintenance scope
Problem: The full progress and retry implementation expands the main-v2
maintenance surface beyond the reported compaction hang and truncation defects.

Root cause: The initial change coupled operation deadlines and failure handling
to new runtime, wire, persistence, and UI contracts.

Fix: Retain one shared five-minute compaction budget and typed failure recovery
without automatic lossy truncation. Remove the new observer/progress protocol,
UI, retry API, and receipt fields from the final diff. Preserve the existing
range-compression behavior and last committed projection. Keep the complete
implementation on a separate branch for independent 2.x evaluation.

Verification: Focused Agent/Control and race tests, actual SSE heartbeat parsing
under virtual time, nested/chunk budgets, queue cancellation, late completion,
partial commits, persistence reopening, pinned Go lint, repolint, and diff checks.
The complete root suite and current-head CI provide separate broad evidence.
2026-09-28 18:35:43 +08:00
SivanCola ca4fbe80aa test: complete the compaction runtime snapshot fixture
Problem: The Desktop CI frontend suite rejects the new compaction fixture.

Root cause: The snapshot omitted the required activityRevision field, which
the production-only typecheck excludes along with test sources.

Fix: Supply the initial activity revision in the orphaned-operation snapshot.

Verification: pnpm test:typecheck and the focused context-compaction-progress
test both pass. No production behavior changes.
2026-09-28 18:14:52 +08:00
SivanCola 9c3579256d fix: bound context compaction and preserve failed-session state
Problem: Near-full conversations can wait indefinitely for a summary and lose
additional model-visible context when summarization fails.

Root cause: Auxiliary response buffering hides progress, heartbeat traffic
extends transport idle timers, and overflow recovery falls back to lossy
truncation without an operation-wide deadline.

Fix: Share a cancellable five-minute compaction budget, observe content-free
generation progress, remove truncation recovery, preserve atomic save ownership,
and expose durable terminal state with session-bound compaction-only retries.
Keep initial and switched session runtime bindings available to the Stop UI.

Verification: Root Go suite, affected Desktop suites, deterministic and race
checks, frontend typecheck/transcript/browser gates, generated contracts, and
repository lint. An actual macOS Electron package passed the production
300-second heartbeat timeout, stop, switching, restart, message/draft retention,
retry-only behavior, and clean shutdown. Real providers and Windows/Linux native
packages remain unverified; an extra unpartitioned Desktop sweep timed out.
2026-09-28 18:06:36 +08:00
YHH 5d88b179b2 Merge pull request #11156 from esengine/docs/v1.39.4-notes
docs(release): prepare v1.39.4 notes
2026-09-28 00:40:41 -07:00
esengine 1132215f4a docs(release): prepare v1.39.4 notes 2026-09-28 00:39:28 -07:00
YHH d9ba90ed04 Merge pull request #11037 from esengine/dependabot/go_modules/main-v2/github.com/modelcontextprotocol/go-sdk-1.8.0
build(deps): bump github.com/modelcontextprotocol/go-sdk from 1.8.0-pre.2 to 1.8.0
2026-09-27 23:41:28 -07:00
esengine 4a09d5546b build(deps): tidy the desktop module for go-sdk 1.8.0 2026-09-27 23:26:08 -07:00
dependabot[bot] 4a5f86a0d1 build(deps): bump github.com/modelcontextprotocol/go-sdk
Bumps [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) from 1.8.0-pre.2 to 1.8.0.
- [Release notes](https://github.com/modelcontextprotocol/go-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/go-sdk/compare/v1.8.0-pre.2...v1.8.0)

---
updated-dependencies:
- dependency-name: github.com/modelcontextprotocol/go-sdk
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-28 05:55:05 +00:00
YHH 555b96966c Merge pull request #11144 from esengine/fix/v1-chat-tui-budget
refactor(cli): move the custom status-line command out of chat_tui.go
2026-09-27 22:38:25 -07:00
esengine 790c6be08e refactor(cli): move the custom status-line command into its own file
chat_tui.go crossed its repolint file-size budget once #11114 and #11062
landed together. The status-line command runner is self-contained, so it
moves verbatim to chat_tui_statusline.go; no behaviour changes.

The freed budget is reclaimed for chat_tui.go only (file-size 4112 -> 4067,
function-size 1056 -> 1030, complexity 274 -> 271, essay 77 -> 75) and the
repo-wide limits drop by the same amounts.
2026-09-27 22:03:08 -07:00
YHH 52f465e4f5 Merge pull request #11038 from esengine/dependabot/npm_and_yarn/desktop/frontend/main-v2/npm-1x-60baa74da5
build(deps): bump the npm-1x group in /desktop/frontend with 4 updates
2026-09-27 21:04:35 -07:00
YHH 3d129f0d6f Merge pull request #11036 from esengine/dependabot/go_modules/main-v2/go-1x-b6c41911f7
build(deps): bump the go-1x group with 5 updates
2026-09-27 21:03:13 -07:00
YHH e66c50c76b Merge pull request #10949 from boscocp/fix/v1-6692-validate-rule-tool-names
fix(permission): warn about a rule that names no tool / 提示匹配不到任何工具的权限规则
2026-09-27 21:02:31 -07:00
YHH b474d12fdd Merge pull request #11076 from esengine/fix/v1-8327-deterministic-reset-fixture
test(openai): pin the body-phase reset fixture to the body phase
2026-09-27 21:01:48 -07:00
YHH b66fdb30f6 Merge pull request #11089 from esengine/fix/v1-9029-drop-into-group
fix(desktop): dropping a session on a group member row adds it to that group
2026-09-27 21:01:03 -07:00
YHH 092ab722cc Merge pull request #11085 from esengine/fix/v1-8859-needs-checks-chime
fix(desktop): ring the attention chime when a turn stops for delivery checks
2026-09-27 21:00:18 -07:00
YHH 390f996103 Merge pull request #11122 from esengine/fix/v1-11010-rotate-typed-key-in-place
fix(setup): rotate a key in place when its providers alone read the variable
2026-09-27 20:59:32 -07:00
YHH 46dd766262 Merge pull request #11093 from esengine/fix/v1-9652-review-step-cap
fix(agent): stop capping review sub-agents at eight rounds
2026-09-27 20:58:46 -07:00
YHH 4cf81e89fe Merge pull request #11062 from esengine/fix/v1-4806-mcp-retry-async
fix(cli): run MCP manager connects off the UI loop
2026-09-27 20:58:01 -07:00
YHH 63582bc7c8 Merge pull request #11092 from KHG420/codex/1x-remote-context-panel
fix(desktop): load remote session context in overview
2026-09-27 20:57:19 -07:00
YHH 54c73168c3 Merge pull request #11131 from esengine/docs/v1.39.3-notes
docs(release): prepare v1.39.3 notes
2026-09-27 19:38:39 -07:00
esengine 36c1faba14 docs(release): prepare v1.39.3 notes 2026-09-27 19:33:09 -07:00
YHH 1808ff6d63 Merge pull request #11124 from esengine/fix/v1-remote-transcript-v2
fix(serve): advertise transcript-v2 whenever the foreground can follow
2026-09-27 19:30:53 -07:00
YHH 9c3123c7a3 Merge pull request #11114 from esengine/fix/v1-hardening-0928
fix: harden project config, serve approvals, host git and sandboxed git metadata
2026-09-27 19:23:42 -07:00
esengine aecf4e6960 fix(serve): advertise transcript-v2 whenever the foreground can follow
The handshake announced transcript-v2 only while the serve's foreground
controller was bound to an exclusive (canonical) session. Opening a
historical path-backed session over SSH makes POST /resume swap the
foreground for a native-legacy controller, and from then on every
/auth/token answer dropped transcript-v2 even though GET
/transcript/follow keeps serving that controller through the legacy
projection. Any remote tab that attached or reconnected in that state
kept a capability set without transcript-v2 for its lifetime, so the
desktop refused Follow before sending a request ("transcript v2 is
required; upgrade Serve and Desktop together") and the composer refused
to send ("Transcript v2 is not synchronized"). The detached serve
survives desktop restarts, so the state persisted across upgrades.

transcript-v2 is now announced from control.TranscriptFollowAPI, the
same interface the route enforces, as transcript-outline-v1 already is.

ServeCapsToken is bumped so a desktop carrying this fix retires a live
serve started from an older binary and replaces a remote binary that
still advertises the old token; otherwise a reused serve would keep the
old handshake until it happened to exit. The same bump retires 1.38.0 to
1.38.9 binaries, which carried the previous token but predate Follow v2.

Test: TestRemoteFollowAttachesToServeWithPathBackedForeground puts a
path-backed controller in a real serve, performs the real token
handshake and follows through RemoteTranscriptFollowForTab. Before the
change it fails with "transcript v2 is required"; after it, the follow
returns a protocol-2 snapshot from the legacy backend.

Fixes #11118
Fixes #11105
2026-09-27 18:58:09 -07:00
esengine d4e11410cb perf(gitcmd): skip the driver listing for plumbing that converts no content
Every hardened invocation listed the repository's filter and merge drivers
with a second git process first, doubling the process count of host git
work. On Windows, where a process start is expensive, that pushed the
worktree suite past its 8-minute limit.

rev-parse, rev-list, merge-base, the ref commands, write-tree and
commit-tree never pass content through a filter or merge driver and never
honour submodule.recurse, so any overrides the listing produced were inert
for them. They now run without it; every subcommand that can convert
content keeps the listing and still fails closed on an unaddressable
driver name.
2026-09-27 18:57:32 -07:00
esengine 2f7809c10d fix(setup): rotate a key in place when its providers alone read the variable
Saving a new key for a provider that already had one always moved the
provider to a fresh REASONIX_CONNECTION_*_KEY slot and left the previous
value under the old variable. A name the user typed at setup (kept since
#11005) therefore went stale on the first rotation, and scripts reading
it kept seeing the old key.

RotateModelCredentialLocked rewrites the variable in place when the edit
targets the user config, the providers being saved are its only readers
there before and after the edit, no bot or remote-host setting reads it,
and the global .env already holds its value. A project reasonix.toml
cannot see the user config's readers, so its providers never rotate.
Anything else still gets a private slot.

Rollback needs the previous value back. It is written to the global .env
under a random REASONIX_ROTATION_*_KEY in the same atomic file write as
the new value, so it has exactly the protection every stored key has;
the journal records only the two names and a keyed digest of the new
value. Cleanup of a failed save and crash recovery restore the previous
value only while the variable still holds the digest this edit wrote,
then drop the backup; a published edit drops the backup and keeps the
new value.

Used by the CLI setup commit, CommitConnectionCredential and the
desktop's journalled credential edits. Desktop edits that run without a
journal keep minting private slots, since they have nothing to restore
from.

Closes #11010
2026-09-27 18:52:02 -07:00
esengine d2cb0aa894 fix(sandbox): collapse over-budget git groups to one bwrap mount
An over-budget worktrees/ or modules/ group was bound read-only as a whole,
but the exact mounts for the gitdirs inside it were still emitted first, so
planting submodule gitdirs kept growing the bubblewrap argument list. Paths
beneath a read-only tree are now left to that tree.

The serve launch-token sandbox test placed its files under /tmp outside the
write root, which bubblewrap masks with a tmpfs: the control read failed and
the denied reads passed without exercising the forbid-read mounts. The files
now live inside the write root on every platform.

The auth-disabled serve launch-token test now closes the process-wide usage
catalog serve.New starts before its REASONIX_HOME is removed, which Windows
otherwise refuses. The partial-clone test compares errors with errors.Is.
2026-09-27 18:37:19 -07:00
esengine 3cb73b3bf4 fix(sandbox): keep repository git config and hooks read-only inside the bash sandbox
The host's own git reads the workspace repository's configuration and hooks,
so a confined command that can rewrite them can make the host run a program
of its choosing. The OS sandbox now denies writes to them while leaving the
rest of .git writable.

For the repository git itself discovers from each writable root, protected
paths are: config, config.worktree, commondir and hooks/ of the gitdir and
of the common dir; config, config.worktree and commondir of every existing
linked worktree entry (new entries stay creatable); config, config.worktree,
commondir and hooks/ of every submodule gitdir under modules/. A .git file is followed to the gitdir it names
exactly as git resolves it, and the pointer file and commondir are
protected, so a repository cannot aim the protection at a decoy while git
reads something else. The .git entry, the gitdir, the common dir, every
symlink on the way to them and each writable ancestor are pinned: they
cannot be removed, renamed away or replaced by a symlink, while their own
mode and times stay writable (touch and chmod on the workspace root work).

- macOS: deny rules after every write allowance. Existing worktree and
  submodule gitdirs (up to 128 each, found without following symlinks) get
  exact rules and pins; patterns anchored at the common dir cover the rest
  and gitdirs created later, skipping refs/ and logs/ segments so a branch
  or tag named config or hooks stays writable. The profile stops growing
  past the limit.
- Linux: pinned directories become their own bind mounts and protected files
  and hook trees are bound read-only; only real directories and regular
  files are mount targets. Up to 512 gitdirs per group get exact mounts;
  past that the whole group directory is bound read-only, so planted HEAD
  files cannot overflow the argument list. Only existing paths can be
  mounted and symlinks cannot be pinned.
- Windows has no shell sandbox; the guide says so.

A protected file with another hard link refuses the confined launch with
sandbox.git_metadata_linked (ErrGitMetadataLinked), since no path rule sees
a write through the other name. Whenever a confined command's output names
a protected path, also on exit 0 (git reports some refused config writes
and exits 0), the bash result carries sandbox.git_metadata_protected.

Real-sandbox tests cover each protected path, pointer and symlink
retargeting, patterns for gitdirs created later, bounded rule size with
2000 submodule gitdirs, ordinary git work, and the git operations that now
fail or silently skip their config write.
2026-09-27 17:35:16 -07:00
esengine ec866730e0 fix(gitcmd): host git never runs repository-configured programs
Every git invocation the host makes goes through gitcmd, and each one must
leave the programs a repository's own configuration names unexecuted:
filter and merge drivers, textconv and external diff, fsmonitor, hooks,
signature verifiers, submodule-boundary programs and lazy-fetch transport.
Driver neutralization now covers every subcommand, not diff alone, and takes
driver names from git itself rather than from a separate reading of .git/config.

- Fixed keys are overridden with -c: core.fsmonitor (empty, not "false",
  which git before 2.35.2 reads as a hook name), core.hooksPath,
  log.showSignature, merge.verifySignatures, diff.submodule, gc.auto and
  maintenance.auto and safe.bareRepository=explicit; submodule.recurse=false
  for checkout, switch, restore, reset, merge and read-tree (host branch
  switches and merges never update submodules, whatever the user's global
  submodule.recurse), and elsewhere only where the repository's own config
  sets it.
  GIT_NO_LAZY_FETCH=1 stops a missing object becoming a fetch through the
  repository's remote (git 2.44+); a historical patch that fails for that
  reason returns gitcmd.ErrObjectNotLocal, decided by git's own
  --missing=print listing.
- diff, log and show carry --no-ext-diff and --no-textconv; status and diff
  carry --ignore-submodules=dirty. The subcommand is found past global
  options, so a call passing -C inside args is hardened too.
- Filter and merge drivers defined at local or worktree scope are listed by
  git itself before each invocation (includes followed) and overridden empty;
  user and system scope stay live; a name -c cannot address fails closed
  (ErrRepositoryDrivers).
- worktree add uses --no-checkout and populates through gitcmd inside the new
  worktree, so a conditional include keyed on the new gitdir or branch is
  seen by the driver listing there rather than run by the add's checkout.
  The reset takes the worktree-mutation timeout, and a failed reset removes
  both the worktree and the branch the add created.
  worktree remove uses --force after the existing hardened clean-tree check,
  so git does not run status inside the linked worktree with its config.
- Launcher ls-remote runs Detached with gitcmd's environment (only PATH is
  taken from the launcher's declared env), requires an https://, ssh:// or
  git:// remote (case-insensitive; http:// and local paths are refused), and
  passes the remote and ref past -- so a leading-dash value is an operand.
- worktree git calls with extra environment keep gitcmd's environment.

- Repository identity is resolved once, when a session opens: boot runs
  gitcmd.Open (rev-parse --absolute-git-dir --git-common-dir --show-toplevel)
  before any command, the controller carries the resulting gitcmd.Repo, and a
  rebuild of the same workspace passes it on. Every later host call runs
  through the Repo with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set, so a
  nested .git, a commondir file, a rewritten .git file or a damaged HEAD
  written afterwards cannot substitute another repository or its config; a
  damaged HEAD fails closed. Threaded through the TUI status line, cli
  review (resolved once before the review agent runs), the desktop changes
  panel, change detail, history, commit detail, branch list/checkout/create
  and tab branch label (the tab session's identity; a tab with no session
  yet resolves at display), worktree Inspect/Create/RollbackCreate, and the
  whole merge-back flow: the source and worktree identities are recorded in
  the merge metadata at creation (the worktree's resolved right after
  worktree add) in metadata.repo.json beside metadata.json, whose shape
  older builds decode strictly and so is unchanged; every inspect,
  auto-commit, merge and cleanup call runs against them, and a worktree
  without that file resolves them from the roots.
  InspectMerge finds the allocation root from the managed layout instead of
  asking git in the worktree.
- Repo.Verify applies Open's test to a stored identity; the identities read
  from metadata.repo.json are verified before the merge flow uses them.
- Auto-commit staging uses gitcmd Repo.StageAll: gitlinks present as
  directories and untracked nested repositories are excluded with
  :(exclude,literal) pathspecs, because add -A checks a submodule by running
  git inside it under its own config. A gitlink keeps the commit the index
  records; an untracked nested repository leaves the worktree unverifiable
  and the auto-commit refuses. The worktree state token no longer runs git
  inside a changed nested repository.
- Open accepts only a git dir belonging to the work tree that holds the
  directory: the work tree's own .git (sharing no other common dir) or a
  linked worktree's admin dir under <common>/worktrees whose gitdir file
  names that work tree's .git. A .git file or core.worktree pointing at
  another checkout is ErrNotRepository. Submodule checkouts and
  --separate-git-dir repositories opened directly are therefore treated as
  not version-controlled.

Regression tests use real git with payload scripts: the gitcmd config-key
matrix (signatures, lazy fetch, submodule boundary), the worktree call sites
(conditional includes on Create, linked-worktree config on RollbackCreate),
the status readout, the launcher's non-URL remote rejection, identity
pinning after a nested .git, a commondir file and a damaged HEAD (config
origin and filter marker), Open refusing another checkout's git dir (.git
file, core.worktree, foreign admin dir), StageAll with nested repositories
named like pathspec magic or globs, metadata.json read by a strict older
decoder, merge-back without the identity file, StageAll and merge-back auto-commit past a gitlink
with its own filter, merge-back after the worktree's .git file is pointed at
another git dir, the status line and desktop changes panel of a workspace
that later gains its own .git, and a global submodule.recurse=true against
host checkout and reset. The list-then-run race is left to a follow-up that
denies sandboxed writes to .git config and hooks.
2026-09-27 17:35:16 -07:00
esengine 3065db7a49 fix(serve): changes and approvals require the launch token
With authentication off (still `serve`'s default), reads stay open but every
state-changing request needs the per-launch token, as an Authorization bearer
or the cookie /auth/token issues, and otherwise refuses with 403
`launch_token_required`. The gate runs inside the host and CSRF guards, and a
configured token is honoured so a fronting proxy can inject it. Token mode also
accepts the bearer header, and the web UI explains the refusal.

The token never reaches the terminal: in both token and none modes serve writes
it to a 0600 file under the remote state directory and prints the path, or
names the --token-file of a managed launch. Runtime sandboxes and read tools
deny that directory and any registered token file.

[serve] is read from the user config only, so a project reasonix.toml cannot
choose the token, the auth mode, or trust in forwarded headers.
2026-09-27 17:35:00 -07:00
esengine ae2e9053f5 fix(mcp): project-declared MCP servers and the statusline wait for the user
MCP servers a workspace's reasonix.toml or .mcp.json declares, and the
[statusline] command, come with the checkout. Neither may start before the
user decides; this change covers those two surfaces only.

MCP servers:
- Without a recorded decision a project-declared server (stdio command or
  HTTP/SSE URL) is off. User-level servers keep following auto_start. An
  unreadable activation store and an entry of unknown provenance fail closed.
- A decision holds for the declaration it was made on: the activation row
  stores a digest of the declaration as written, the project .env values it
  expands, and the content of workspace files named directly as its command
  or arguments. A change to any of these reads as changed_since_enabled and
  keeps the server off. Files loaded indirectly, PATH-resolved interpreters
  and unpinned package launchers are outside the digest.
- Edits made through Reasonix at the user's request (desktop edit, tier,
  clear-auth, the /mcp mode and clear-auth actions) carry the decision only
  when the file afterwards holds the declaration that edit wrote and the
  .env values and named files it draws on read as in the approved state.
- MCPDecision is typed: enabled, disabled, awaiting_user_decision,
  changed_since_enabled. boot.Build emits one notice per process for each
  such server; `reasonix mcp list` shows the state; the model's refusal
  carries the code instead of "disabled in this session". The launch line
  names every env key, shows values of loader, interpreter and package-index
  variables, redacts endpoint credentials, and points to `reasonix mcp get`.
- `reasonix mcp enable/list/get` resolve the workspace a session there would
  use (nearest git root), and enable prints the approved launch line.
- Starting a project server by hand (/mcp connect, desktop reconnect or tier)
  records the decision. boot.Build, and with it CLI/TUI, `reasonix run`,
  serve, ACP, bot and desktop tabs, reads the same state, as do the /mcp
  view, desktop capabilities, controller fallbacks and `doctor --live`.
- A project .env expands only project-declared servers.

Statusline: user-level only. LoadForRoot restores the user value after the
project merge, project renders never write it, and the TUI spawns it with
NoDefaultCurrentDirectoryInExePath set.
2026-09-27 17:35:00 -07:00
xiexian 28e37a1c0a test(control): keep preset regression portable 2026-09-28 01:04:08 +08:00
xiexian 3822d577eb fix(serve): retain preset ownership through model rebuilds 2026-09-28 00:52:09 +08:00
xiexian 88751263bf fix(serve): scope session preset restore to remote frontend 2026-09-28 00:44:52 +08:00
esengine c2d10fb7e0 fix(agent): stop capping review sub-agents at eight rounds
A review, security-review or team-architect sub-agent paused after 8
tool-call rounds whenever the call did not pass max_steps, which is how
every skill-run review and most model-dispatched reviews start. The pause
message tells the user to raise max_steps, but no setting reaches this
budget, so a multi-file review could not finish at all (#9652).

Root cause: applyReviewBudget stamped a hardcoded defaultReviewMaxSteps = 8
on review-family children, and PrepareReviewSubagentContext handed the same
8 to skill runners, overriding the step budget the runner had already
resolved from the parent. Under the default uncapped parent that turned
"no limit" into 8.

Fix: the review contract keeps what it is for (the 2048-token cap per
response, the structured output format and the parent facts pack) and no
longer sets a step budget. Review children take the ordinary child budget:
an explicit max_steps wins, otherwise half the parent's cap, or uncapped
under an uncapped parent. The skill path keeps the runner's own steps.

Test: TestReviewSubagentUnderUncappedParentFinishesPastEightRounds runs a
real review task through TaskTool.Execute with a scripted 12-round child;
before the fix it fails with "sub-agent: paused after 8 tool-call rounds",
after it returns the verdict. TestReviewBudgetKeepsInheritedStepsAndBoundsOutput
(steps = 8, want 0 before) and TestReviewSkillKeepsRunnerStepBudget
("runner steps 0 reached the child as 8" before) cover the task and skill
budgets. go test ./internal/agent/ ./internal/boot/, go vet, repolint and
golangci-lint on both packages pass on Windows.
2026-09-27 09:35:34 -07:00
esengine 053136c488 fix(desktop): dropping a session on a group member row adds it to that group
A drop on a member row of a session group ran the row's reorder, so a
session dragged from outside the group stayed outside it; only the group
header accepted drop-to-group. The row drop now reads the target row's
group from the organization state: a session that is not yet a member is
set into that group, and a session already in it is reordered as before.
2026-09-27 09:15:30 -07:00
xiexian 49939444ff fix(desktop): load remote session context in overview 2026-09-27 23:57:53 +08:00
esengine 3a946c048d fix(desktop): ring the attention chime when a turn stops for delivery checks
A turn that ends with the final-readiness check failing ("need checks"),
or with another typed pause, finished silently: the runtime notification
owner played the success chime only when turn_done carried no error, and
these pauses carry the typed pause as their error. The user got no
signal that the turn was over and waiting on them.

turn_done now reads its structured outcome: final_readiness,
recovery_paused, incomplete_read and completion_uncertain - the outcomes
desktop/topic_status.go records as paused or awaiting_delivery - ring
the attention chime ("Awaiting response"). A clean finish keeps the
success chime and an ordinary failure stays silent.

Test: runtime-turn-done-chime.test.ts drives createRuntimeNotifications
with a fake AudioContext. On the base it fails ("a turn that stopped for
delivery checks must alert the user": 0 !== 1); with the fix it passes.
2026-09-27 08:47:03 -07:00
xiexian e30096d678 fix(serve): persist permission presets per remote session 2026-09-27 23:31:33 +08:00
esengine 54f4898b2c test(openai): pin the body-phase reset fixture to the body phase
TestStreamSurfacesEarlyConnResetAsInterrupt and
TestStreamDoesNotReplayAfterOutput forced a real TCP RST right after
writing the 200 head. Whether net/http observes that RST before or after
the head is up to the OS scheduler (#8327). When it lands first the
failure is a request error returned by Stream itself, not a body-phase
interruption, so the test fails on "Stream:" instead of exercising the
contract it names. On the studio line, which still retries that phase,
the same race shows up as extra server requests.

The fixture is now a scripted RoundTripper: a 200 SSE response whose body
yields the prelude and then fails with a net.OpError(ECONNRESET). The
phase is fixed, the request counter is atomic, and no socket is involved.

Test: both tests pass 500/500 on Windows. Disabling the IsConnReset branch
in streamOnce makes both fail, so the fixture still guards the contract.
2026-09-27 08:26:46 -07:00
esengine 03254360ee fix(cli): run MCP manager connects off the UI loop
Choosing Retry (or Reconnect / Enable and connect) on a server in the /mcp
manager called ConnectConfiguredMCPServer synchronously inside the Bubble
Tea Update. The initialize + tools/list handshake is bounded only by the
server's startup timeout (30s by default), so an unresponsive server froze
every keypress and redraw of the chat until the handshake gave up, which
reads as a hang. Saving a connection mode and the reconnect after MCP
OAuth made the same synchronous call.

All three now start the connect as a tea.Cmd and report back through
mcpConnectDoneMsg, whose cause picks the notice. chatTUI.mcpConnecting
records servers with a connect in flight; it lives on the chat model
rather than the manager, so closing and reopening /mcp cannot start a
second handshake. While a server is connecting, Mode, Retry, Auth, Clear
auth, Disable and Remove in the manager, and /mcp connect|remove, are
refused, so a late result cannot re-enable something the user just turned
off. If a connect still succeeds for a server that is no longer configured
(for example the config was edited meanwhile) or is disabled for the
session, the result disconnects it instead of keeping it live. The result
refreshes the manager without moving it to another page.

Test (internal/cli/mcp_manager_connect_test.go, gated HTTP MCP fake):
- TestMCPManagerRetryDoesNotBlockTheUILoop: on main-v2 applyMCPAction held
  the loop for the full startup timeout (3.0s with a 3s timeout) and
  failed. Now it returns at once; after closing and reopening /mcp a
  second Retry is refused; Disable and Remove are refused while in flight;
  the successful result clears the marker, leaves the manager's stage, and
  a Disable afterwards takes effect.
- TestMCPConnectFinishingAfterRemovalIsDropped: the entry is removed from
  reasonix.toml while initialize is held; the finished connect is
  disconnected. Without the completion check it stayed live (fails).
- TestApplyMCPModeRecordsPluginConnectFailure (moved from mcp_test.go)
  now drives the returned command and still records the host failure.
2026-09-27 07:34:09 -07:00
esengine 35fc8b746a build(deps): tidy the desktop module for the go-1x bumps
desktop/ replaces reasonix with the parent module, so the root bumps raise
its graph too and its go test stops with "updates to go.mod needed".
go mod tidy in desktop/ moves only the versions the root already requires.
2026-09-27 07:22:11 -07:00
esengine befb5e258c build(deps): sync desktop/pnpm-lock.yaml with the npm-1x bumps
Dependabot updated desktop/frontend/package.json but not the workspace
lockfile at desktop/pnpm-lock.yaml, so every frozen install failed with
ERR_PNPM_OUTDATED_LOCKFILE. Regenerated with pnpm 10.34.5 --lockfile-only;
only the four bumped packages and their own dependencies move.
2026-09-27 07:10:35 -07:00
Adalberto Pereira 8e9f7cfed0 fix(permission): name an unclosed subject and stop widening allow rules / 提示未闭合括号并避免放宽 allow 规则
A rule missing its closing parenthesis is now reported as that typo and
repaired instead of being wrapped in a second Bash(...) that parses but
still matches nothing, and a bare command on the allow list is suggested
in its exact form, since the prefix form would also grant every argument.
2026-09-27 10:58:48 -03:00
YHH 96297a2146 Merge pull request #11047 from KHG420/codex/1x-acp-skill-raw
fix(acp): preserve typed skill input in 1.x turns
2026-09-27 06:41:47 -07:00
YHH 48b340a877 Merge pull request #11051 from esengine/fix/v1-release-without-website
ci(release): publish 1.x without deploying or verifying the website
2026-09-27 06:16:22 -07:00
esengine d67f2835ac ci(release): publish 1.x without deploying or verifying the website
The website is released from its own branch and renders the changelog from
that branch's catalog, so the 1.x publication could dispatch Pages and still
fail on a changelog page it does not own (v1.39.2: "public changelog returned
HTTP 404" after every product surface had shipped).

- Postflight now verifies tags, CLI and Desktop releases, npm, the Desktop
  Stable manifest on dl.reasonix.io and the Homebrew cask, attaches
  release-event.json, and completes. sync-release-site.sh becomes
  reconcile-release-publication.sh without the Pages dispatch/watch stages.
- The stable manifest check stays: it reads dl.reasonix.io/latest/latest.json,
  the Desktop updater pointer on R2, not the website. The Homebrew check moves
  out of the site verifier and reads the tap through the contents API.
- Drop the homepage/changelog/hydrated-DOM probes, observe-release-site.sh,
  release-site-fetch, and the pages.yml dispatches in release-stable.yml and
  release-desktop.yml together with the actions: write they needed.
- Ledger: the site fragment becomes a pointer fragment (stableManifest,
  homebrew); homepage and changelog surfaces are gone.
2026-09-27 05:55:11 -07:00
xiexian 6512f0db40 fix(acp): exclude resolved 1.x skills from routing 2026-09-27 20:35:52 +08:00
dependabot[bot] a0ac937c65 build(deps): bump the npm-1x group in /desktop/frontend with 4 updates
Bumps the npm-1x group in /desktop/frontend with 4 updates: [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual), [katex](https://github.com/KaTeX/KaTeX), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) and [tsx](https://github.com/privatenumber/tsx).


Updates `@tanstack/react-virtual` from 3.14.11 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/[email protected]/packages/react-virtual)

Updates `katex` from 0.18.7 to 0.18.9
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.18.7...v0.18.9)

Updates `@typescript-eslint/parser` from 8.70.0 to 8.70.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.1/packages/parser)

Updates `tsx` from 4.23.11 to 4.23.15
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.11...v4.23.15)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: katex
  dependency-version: 0.18.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.70.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: tsx
  dependency-version: 4.23.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:54:01 +00:00
dependabot[bot] 15a1f262fb build(deps): bump the go-1x group with 5 updates
Bumps the go-1x group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [charm.land/bubbletea/v2](https://github.com/charmbracelet/bubbletea) | `2.0.9` | `2.0.10` |
| [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `4.10.0` | `4.10.2` |
| [github.com/klauspost/compress](https://github.com/klauspost/compress) | `1.18.4` | `1.18.7` |
| [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) | `0.0.29` | `0.0.30` |
| [mvdan.cc/sh/v3](https://github.com/mvdan/sh) | `3.14.0` | `3.14.1` |


Updates `charm.land/bubbletea/v2` from 2.0.9 to 2.0.10
- [Release notes](https://github.com/charmbracelet/bubbletea/releases)
- [Commits](https://github.com/charmbracelet/bubbletea/compare/v2.0.9...v2.0.10)

Updates `github.com/bmatcuk/doublestar/v4` from 4.10.0 to 4.10.2
- [Release notes](https://github.com/bmatcuk/doublestar/releases)
- [Commits](https://github.com/bmatcuk/doublestar/compare/v4.10.0...v4.10.2)

Updates `github.com/klauspost/compress` from 1.18.4 to 1.18.7
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.18.4...v1.18.7)

Updates `github.com/mattn/go-runewidth` from 0.0.29 to 0.0.30
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.29...v0.0.30)

Updates `mvdan.cc/sh/v3` from 3.14.0 to 3.14.1
- [Release notes](https://github.com/mvdan/sh/releases)
- [Changelog](https://github.com/mvdan/sh/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mvdan/sh/compare/v3.14.0...v3.14.1)

---
updated-dependencies:
- dependency-name: charm.land/bubbletea/v2
  dependency-version: 2.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/bmatcuk/doublestar/v4
  dependency-version: 4.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.18.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: mvdan.cc/sh/v3
  dependency-version: 3.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:53:34 +00:00
YHH 7574f8b4c9 Merge pull request #11031 from esengine/docs/v1.39.2-notes
docs(release): prepare v1.39.2 notes
2026-09-27 04:39:01 -07:00
esengine d945284cec docs(release): prepare v1.39.2 notes
Bilingual Stable notes for everything merged to main-v2 since v1.39.1: unopenable and unarchivable sessions, live streaming on legacy-backed sessions, typed skill invocations, the setup API key variable name, and code-page output on Chinese Windows. CI-only changes (#10863, #10870, #10983) are left out.
2026-09-27 04:37:09 -07:00
xiexian c55c6bee78 fix(acp): preserve raw text across slash prompt expansion 2026-09-27 19:30:06 +08:00
YHH 2a3855f16a Merge pull request #11023 from KHG420/codex/1x-inline-skill-raw
fix(skill): route 1.x inline skills from raw user input
2026-09-27 04:20:36 -07:00
YHH 6d0c756228 Merge pull request #11022 from esengine/fix/v1-archived-row-stays-listed
fix(desktop): hide a historical row once any workspace has adopted its source
2026-09-27 03:41:03 -07:00
esengine dd9b441f99 fix(desktop): hide a historical row once any workspace has adopted its source
The sidebar lists a legacy transcript under the scope its sidecar names, and
the persist observer indexes a save under the global fallback when the sidecar
names none. Archive and import assign the owning workspace from the store the
file physically lives in. When the two disagree, the adopted source was only
filtered from the workspace that owns the mapping, so the row stayed listed in
the other one: archiving it "succeeded" again and again, and pin, rename and
open resolved to the archived conversation and were refused with
"Restore this session before ...".

Adoption is a property of the source identity, so the sidebar's adopted-row
filter and the lazy page's path exclusions now consider every mapping, as the
history listing already did. A head-keyed mapping owned by another workspace
that cannot prove a path alias still does not disable the lazy adapter here.
2026-09-27 03:21:47 -07:00
YHH acf3bfdcbc Merge pull request #11003 from KHG420/codex/1x-worktree-git-write-hint
fix(sandbox): guide worktree Git metadata write approval
2026-09-27 03:00:13 -07:00
xiexian 96b296cd90 test(skill): reject slash mentions as implicit invocations 2026-09-27 17:55:14 +08:00
xiexian b18f84ee22 fix(skill): route inline invocations from raw user input 2026-09-27 17:38:47 +08:00
YHH 29c8349c3a Merge pull request #11007 from esengine/fix/v1-11004-inline-skill-pin
fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
2026-09-27 02:26:16 -07:00
YHH 51fe2e9255 Merge pull request #11005 from esengine/fix/v1-11002-api-key-env-name
fix(setup): keep an API key variable name the user types
2026-09-27 02:17:10 -07:00
xiexian 8cf5b64418 test(sandbox): compare JSON-escaped worktree hints on Windows 2026-09-27 17:16:21 +08:00
esengine 463840813f fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
A bare "/name" line or a lone skill chip reached the provider as the raw
rendered skill body in plain user prose: no <skill-pin> sentinel, and on the
structured (composer) path the typed text trailed as an unlabeled paragraph
instead of an Arguments line. The model-invoked path (run_skill,
read_only_skill, read_skill) already sent the pinned form, so one skill had
two payload shapes depending on who invoked it.

Every invocation site now renders through skill.RenderInvocation: the
string submit path, the structured path, RunSkill (ACP prompts) and the
slash_command tool. A lone inline chip takes the typed text as its
arguments, matching "/name task"; several inline chips, or chips mixed with
subagents, keep appending the shared text once. Nothing moves into the
cache-stable prefix. Store.Render is removed and Render is unexported, so the
pinned form is the only one an invocation can take.

An effect test through the real Build asserts the pinned payload at the
provider boundary for the bare and argument-carrying forms of both paths.

Fixes #11004
2026-09-27 02:09:43 -07:00
esengine c04f7dd247 fix(setup): keep an API key variable name the user types
`reasonix setup` asked for the API key variable name, then saved every
new key under a freshly allocated REASONIX_CONNECTION_*_KEY slot, so a
typed name was dropped from config.toml and .env without a word.

A typed name is now kept when saving under it changes nothing another
reader resolves: no other provider, bot or remote-host setting reads it,
the credential store holds no value or tombstone for it, and the process
environment does not already set it (compared case-insensitively on
Windows). The wizard checks that as soon as the name is typed, says what
holds it, and asks again; Enter still means a private slot. The commit
repeats the check under the edit locks, so a name claimed in between
refuses the save with config.ErrCredentialKeyInUse instead of
overwriting it.

The staging journal records a keyed digest of each staged value, and
cleanup and crash recovery remove a slot only while the store still holds
that value, so a named slot another writer took over survives recovery.
Whether the config still references a slot is read from the parsed
config instead of a substring match, so API_KEY is no longer "referenced"
by DEEPSEEK_API_KEY and left behind as an orphan.

The prompt no longer shows a bracketed default it would not use.

Fixes #11002
2026-09-27 01:59:35 -07:00
xiexian a44319982f fix(sandbox): validate and narrow worktree metadata hints 2026-09-27 16:56:29 +08:00
YHH a8bea84e68 Merge pull request #10914 from esengine/fix/remote-prompt-card
fix(desktop): a remote Ask shows once and can be answered
2026-09-27 01:30:14 -07:00
YHH f457bbbd61 Merge pull request #10883 from esengine/fix/archive-recovered-copies
fix(desktop): archiving a recovered legacy conversation archives all its copies
2026-09-27 01:21:53 -07:00
YHH 3007c94084 Merge pull request #10997 from KHG420/codex/1x-history-reload-identity
fix(desktop): accept refreshed history after cache eviction
2026-09-27 01:12:16 -07:00
xiexian c09dbf56b9 fix(sandbox): include common Git metadata in retry hint 2026-09-27 16:08:19 +08:00
xiexian 26984605be fix(sandbox): guide worktree Git metadata write approval 2026-09-27 16:04:14 +08:00
esengine 85759d074c fix(desktop): archiving a recovered legacy conversation archives all its copies
A 1.38 write conflict saved one recovery copy per writer, each with the
recovered flag and the same parent in its metadata. The desktop catalog
runs metadata-only, which never folds them, so every copy is its own
sidebar row with the same title and the "legacy" and "recovered" badges.
Archiving a row archived exactly that copy and the next one took its
place, which reads as "cannot archive".

Archiving a recovered legacy source now also archives the other recovered
copies in its directory whose metadata names the same parent, each through
the same source archive path. Unrelated conversations are untouched, and
everything lands in the archive where it can be restored.

Reproduced on Windows with 1.39.1 and two copies written by
SaveConflictRecoveryBranch: one archive left the second row; with the fix
both rows go and both appear under Archived.
2026-09-27 00:59:53 -07:00
xiexian e47c32200c test(desktop): type history reload fixture for CI 2026-09-27 15:06:52 +08:00
xiexian 861a7b8d12 fix(desktop): accept refreshed older history after cache eviction 2026-09-27 14:47:40 +08:00
YHH 32107d7bb6 Merge pull request #10983 from esengine/fix/v1-release-notes-authors
feat(release-notes): credit pull request authors and issue fixers in rendered notes
2026-09-26 23:37:37 -07:00
esengine d5c9f2d739 feat(release-notes): credit pull request authors and issue fixers in rendered notes
Rendering now resolves every refs number through the GitHub GraphQL API.
A pull request gains "by @author". An issue gains "fixed in #M by @author"
for each merged pull request GitHub links as closing it (the ClosedEvent
closer and closedByPullRequestsReferences; mentions are not fixes); an
issue fixed by a direct push stays plain. Bot authors are never credited.
A number that names a discussion or nothing stays plain with a warning.

The closing Contributors / 贡献者 section lists exactly the credited
humans, once each, in first-appearance order. It replaces the old Thanks
section, which linked the catalog's contributors (github-actions[bot]
included) without mentioning them; that field no longer feeds the notes.

Lookups are cached per run, bounded in concurrency, and retried with
backoff on 5xx, rate limits and dropped connections. A failure that
persists, or an authorisation failure, renders the ref as its plain link
and fails the command with a release_credits.* code.

Because rendering now reads GitHub, the seal job renders the notes once
into the candidate payload (evidence/release-notes.md, recorded as
notes.renderedPath and bound to renderedSha256). Promotion publishes
those sealed bytes with no network call, so a rename, a newly linked fix
or an outage after sealing cannot change or block the release. Candidates
sealed before this carry no renderedPath and render offline as before.

Every job that renders, and every caller of a reusable workflow that
does, gains issues: read and pull-requests: read.
2026-09-26 23:24:50 -07:00
YHH 1a74c7450f Merge pull request #10979 from KHG420/codex/fix-1x-sidebar-loading
fix(desktop): show initial project loading state on 1.x
2026-09-26 22:59:20 -07:00
xiexian 947ce21896 chore(desktop): stay within project tree line budget 2026-09-27 13:30:40 +08:00
xiexian 872ad88677 fix(desktop): settle project shell loading after read failure 2026-09-27 13:21:07 +08:00
YHH 4512a84cbb Merge pull request #10977 from KHG420/codex/fix-reopened-session-index
fix(session): extend cached index after reopening writer
2026-09-26 22:17:13 -07:00
YHH 9424d402fb Merge pull request #10973 from esengine/fix/v1-10968-stream-generation
fix(transcript): stream live deltas for sessions on the legacy projection
2026-09-26 22:00:21 -07:00
esengine 2dfa1ca44c fix(transcript): stream live deltas for sessions on the legacy projection
A session opened from a legacy topic path follows its transcript through
the legacy projection (storageBackend "legacy"). There every turn event,
text and reasoning deltas included, takes the next ledger sequence, so
each follow change advanced commitSeq by one but carried no firstSeq.
The desktop follow client treats a change without firstSeq as a display
frame that must ride the current cut, rejected the very first change of
the turn as "transcript frame cut mismatch", dropped the subscription
and re-baselined once a second. No delta reached the reducer, so neither
thinking nor answer streamed and the reply appeared whole at turn end.

The stream_attempt commit on that path also asked for a reset, because
the legacy path never recorded a result sequence. Recording the message
event's sequence covers rounds with text or reasoning, but a tool-call-
only round and an interrupted round publish no message event at all.

Projection.Apply now publishes the sequence each envelope occupies as
the change's firstSeq. A commit settles on the recorded message sequence
or, when the round had no message event, on its own sequence: the agent
emits the commit only after the round's record is durably appended, so
that sequence is already covered. The entry is dropped at the commit so
the map stays bounded. ApplyFrame (the exclusive runtime path) is
unchanged.

Test: TestLegacyTopicTranscriptFollowStreamsDeltasTheRendererAccepts
opens a legacy topic through StartTopicActivation on a real App with a
scripted streaming provider, follows through TranscriptFollowForTab and
applies the renderer's acceptance rules (mirroring
transcriptFollowClient.ts validate: coverage, sampling, resultKind,
records-based result identity with its 192 cap). Subtests run a
streamed reply, a tool-call-only round and a cancelled round, each
followed by a streamed reply.
- main-v2: all three fail with "frame_cut_mismatch at revision 3".
- message-sequence fix only: tool-call-only and cancelled rounds fail
  with "revision_gap" at the commit.
- this change: all pass, every delta delivered with no re-baseline.
Replaying captured follow responses through the real
TranscriptFollowClient accepted 0 deltas before and all of them after.

Fixes #10968
2026-09-26 21:32:08 -07:00
YHH e5795e5e0f chore: extract website and platform from product tree
Move the website and Cloudflare platform services to their dedicated protected branches. Keep main-v2 focused on the product and retain only the product-side release integration contract.
2026-09-27 04:29:09 +00:00
xiexian 86db9b4df3 fix(session): extend cached index after reopening writer 2026-09-27 12:22:14 +08:00
xiexian 8829515d5e style(desktop): keep project tree within lint budget 2026-09-27 12:19:16 +08:00
xiexian 8b2504fb6b fix(desktop): show loading state until first project tree snapshot 2026-09-27 12:04:12 +08:00
YHH a2d8541ed8 Merge pull request #10961 from esengine/fix/v1-10953-opencode-go-route
fix(provider): serve OpenCode Go minimax-m2.7 on the Anthropic route only
2026-09-26 20:26:26 -07:00
YHH c7ac943307 Merge pull request #10955 from esengine/fix/v1-10891-remote-in-local-picker
fix(desktop): keep SSH projects out of the composer's local folder list
2026-09-26 20:06:44 -07:00
esengine 8cd9d97eef fix(provider): serve OpenCode Go minimax-m2.7 on the Anthropic route only
The embedded Pi catalog (sky-valley/pi v0.84.20, unchanged in v0.87.24)
annotates minimax-m2.7 as openai-completions. Both the Endpoints table on
opencode.ai/docs/go and models.dev (the catalog the OpenCode client reads)
serve it on /v1/messages with @ai-sdk/anthropic. Because the route
catalogs are the union of Pi and the declared tables, minimax-m2.7 was
listed on the Chat Completions route too: the curated "opencode-go" chat
preset shipped it, discovery kept it for a chat connection, and
OpenCodeGoRecommendedRoute (Pi first) answered "chat".

A declared correction table in pi_catalog.go now pins minimax-m2.7 to the
Anthropic route, and Pi entries are dropped from any other route. The Qwen
models keep their current dual-route state: models.dev marks
qwen3.6-plus, qwen3.7-max, qwen3.7-plus and qwen3.8-max
@ai-sdk/openai-compatible, so the chat route is not shown to be wrong.

Only connections that are ours move. The load-time route repair and the
journaled pre-v10 upgrade both leave a route-corrected model where it is,
unless the connection has preset_id "opencode-go" on the official chat
route and its models equal a chat list v1.37.0-v1.39.1 shipped (two
declared generations). Such a connection gets an opencode-go-anthropic
sibling for minimax-m2.7, and references to it are retargeted. Hand-made
connections, including config_version>=10 manual choices, are untouched.

Test: on the merge base, TestOpenCodeGoMiniMaxM27ServedOnlyOnAnthropicRoute
(recommended route "chat") and
TestOpenCodeGoShippedChatPresetMovesMiniMaxM27ToAnthropicSibling (no
anthropic sibling) fail. Both pass after the change.
TestOpenCodeGoManualConnectionKeepsItsRoute covers a manual
"custom-opencode-ai" connection with [glm-5.3 qwen3.7-plus],
[qwen3.7-plus], [glm-5.3 minimax-m2.7] and [minimax-m2.7] through both the
load-time repair and the journaled upgrade. Against an ungated rerouting
rule it fails: the connection is split, or its kind flips to anthropic
with preset_id opencode-go-anthropic. It passes here.
TestOpenCodeGoQwenModelsKeepBothRoutes pins the Qwen models to both
routes.
2026-09-26 20:03:11 -07:00
YHH 8bb2c9e163 Merge pull request #10925 from esengine/fix/v1-7781-context-runtime
fix(desktop): keep the context panel runtime counting during a running turn
2026-09-26 19:47:40 -07:00
esengine cc24c0a05c fix(desktop): keep SSH projects out of the composer's local folder list
The project menu above the composer lists every "project" node from
GetProjectTreeSnapshot. Pinned SSH workspaces are project nodes too
(root "remote-project:<host>:<workspace>", Remote set), so they showed
up with the local folder icon, and picking one sent that synthetic root
to SwitchWorkspace, which stats it as a local path and fails.

The menu now drops nodes that carry a remote reference. SSH projects
remain reachable from the sidebar and the menu's "Remote connection"
action. This is a different path from #10755, which fixed remote tab
selection after restart and does not touch this menu.

Test: composer-workspace-context.test.tsx adds a remote node to the
snapshot. Before: AssertionError "remote projects never appear among
local folders to switch to". After: PASS; tsc and eslint clean.

Fixes #10891
2026-09-26 19:34:05 -07:00
YHH 8b6f783036 Merge pull request #10918 from esengine/fix/v1-8213-shell-output-codepage
fix(shellrun): read a command's output in the code page it was written in
2026-09-26 14:21:31 -07:00
YHH 72dcd82792 Merge pull request #10912 from esengine/fix/v1-7857-mcp-stderr-codepage
fix(plugin): decode an MCP server's code-page stderr before reporting it
2026-09-26 14:20:47 -07:00
esengine e18aba9003 fix(shellrun): read a command's output in the code page it was written in
Root cause: shellrun kept a child's bytes as a Go string unchanged. On a
Chinese Windows install, console tools such as tasklist and find answer
in cp936, so the bash tool result held invalid UTF-8 and JSON turned
every bad byte into U+FFFD on the way to the transcript. The model and
the user saw mojibake instead of the command's output or its error.

Fix: both buffers decode through encoding.DecodeOutput, the one decoder
for process output, and each passes the edges it really cut:
- the failure tail keeps the last N bytes, so tailWriter records when it
  dropped bytes and passes Cut{Head: cut};
- combined output is decoded whole with Cut{} until it crosses the cap;
  after that the head lost its end and the rolling tail its start, so
  they are decoded apart with Cut{Tail: true} and Cut{Head: true} on
  either side of the truncation notice.
Output that is not UTF-8 is decoded from the raw bytes. The shell
package's own edge trimmers (completePrefix/completeTail) are gone:
they trimmed edges nothing had cut and removed the GBK lead byte of
output that starts with a CJK character.

Tests:
- TestRunForegroundDecodesCodePageOutput runs a real shell that prints
  GB18030 bytes and exits non-zero.
- TestBuffersDecodeCodePageOutputStartingWithCJK covers GBK that starts
  with a CJK character in the tail and in combined output, uncut and cut.
All of them fail on origin/main-v2 and pass with this change.

Fixes #8213
2026-09-26 13:52:59 -07:00
esengine 9083943095 fix(plugin): decode an MCP server's code-page stderr before reporting it
On a Chinese Windows, cmd.exe reports a missing command in the console
code page (GBK), not UTF-8. The stdio transport kept the stderr tail as
raw bytes, so an MCP startup failure such as "'read-only-mysql-mcp-server'
is not recognized as an internal or external command" reached the settings
page as U+FFFD mojibake and the user could not see why the server failed.

The stderr tail now goes through encoding.DecodeOutput, the one decoder
for process output. The buffer keeps the last 16 KiB, so only its head
can be cut: tailBuffer records when it dropped bytes and passes
Cut{Head: cut}. The end is never cut, because it is whatever the server
last wrote. Output that is not UTF-8 is decoded from the raw bytes, so a
GBK tail that begins with a CJK character keeps that character.

Tests:
- TestStdioFailureDecodesCodePageStderr starts a real child that writes
  GBK to stderr and exits.
- TestStderrTailDecodesTheServersCodePage covers GBK that starts with a
  CJK character, both uncut and cut, plus UTF-8 cut mid-character.
All of them fail on origin/main-v2 and pass with this change.

Refs #7857
2026-09-26 13:52:39 -07:00
YHH 430cbf336a Merge pull request #10948 from esengine/fix/v1-6488-utf8-partial-tail
fix(encoding): read uncut UTF-8 that ends in half a character as UTF-8
2026-09-26 13:51:20 -07:00
Adalberto Pereira 42503a4d36 fix(permission): ask unicode.IsSpace on both sides of the invariant / 检查与其不变量使用同一套空白判断
The check rejected " " and "\t" while the invariant test that justifies it
pinned " \t\r\n", so a tool name broken across lines was unmatchable but not
reported, and the two could drift further apart with neither failing. Both now
ask unicode.IsSpace, which also covers the non-breaking space a settings panel
can paste in.
2026-09-26 17:46:53 -03:00
Adalberto Pereira 598ecbf1b2 fix(permission): warn about a rule that names no tool / 提示匹配不到任何工具的权限规则
A permission rule is "ToolName" or "ToolName(glob)", but the rules people
write are bare shell commands: deny = ["rm", "git reset", "git push --force"].
ParseRule treats only an empty tool name as malformed, so those entries parse,
install, and then match nothing — the author believes a deny guards them and it
never fires. ParseRule's own doc comment states the intent ("so the caller can
warn rather than silently install a rule that matches nothing"); no caller did.

UnmatchableRules reports a rule whose tool name contains whitespace and renders
the Bash(...) rewrite that expresses the intent. Whitespace is an exact test
rather than a guess: a tool name is an identifier and never contains any, which
is now pinned by TestNoBuiltinToolNameHasWhitespace. That keeps the warning off
a plugin or MCP tool that is simply not registered yet — the per-run registry is
still filling when the policy is built, so membership cannot be tested there
without accusing rules that work.

The notice goes to the event sink rather than Config.LoadWarnings because only
the desktop reads those, and a rule typed into config.toml has to reach a CLI
user too.
2026-09-26 17:40:45 -03:00
YHH 8797bf9a02 Merge pull request #10942 from esengine/fix/v1-9156-bot-replay-once
fix(desktop): push a replayed approval or question to bot watchers once
2026-09-26 13:25:40 -07:00
YHH 470d6ad1af Merge pull request #10938 from esengine/fix/v1-9675-history-tool-output
fix(desktop): history content search finds tool output
2026-09-26 13:24:24 -07:00
esengine ee275cf443 fix(encoding): read uncut UTF-8 that ends in half a character as UTF-8
DecodeOutput trims a partial rune only at an edge the caller cut. Output
that nothing cut but that stops inside a character still has invalid raw
bytes, so it went through charset detection and fell back to GB18030,
and the whole output turned into mojibake: "编译成功…正在运行" plus half
of "中" read as "缂栬瘧鎴愬姛…". This is common in real use:
- a process killed on timeout mid-write;
- a persistent shell's partial() read;
- a block-buffered pipe.

The output is now read as UTF-8, less the final incomplete rune, when
two things hold: that rune is the only invalid part, and a multi-byte
UTF-8 character has already appeared. Both are structural evidence. When
only ASCII precedes the half rune, nothing says UTF-8, so the bytes
still go through detection. The uncut GBK probes (价, 啊\r\n, ok 洹,
啊 ok) are unchanged.

Test: TestDecodeOutputReadsUncutUTF8EndingInPartialRune fails before
this change with the mojibake above and passes after it. It also pins
ASCII plus half a rune (read as a GBK pair) and uncut, tail-cut and
head-cut GBK.

Refs #6488
2026-09-26 13:23:40 -07:00
YHH 79db828846 Merge pull request #10934 from esengine/fix/v1-9156-notify-once-per-prompt
fix(notify): do not announce a replayed approval or question again
2026-09-26 13:13:41 -07:00
YHH ef50bccc17 Merge pull request #10892 from esengine/fix/v1-6488-gb18030-lossless
fix(encoding): a file is GB18030 only if a write can restore its bytes
2026-09-26 13:02:28 -07:00
YHH 08c782e773 Merge pull request #10924 from esengine/fix/v1-8272-responses-output-calls
fix(responses): close tool calls the completed response lists
2026-09-26 12:55:00 -07:00
YHH b2a8ae55d7 Merge pull request #10930 from esengine/fix/v1-9129-stepfun-context-window
fix(config): StepFun presets declare their 262144-token context window
2026-09-26 12:54:35 -07:00
YHH 480ccdd496 Merge pull request #10929 from esengine/fix/v1-8385-subagent-bare-model
fix(subagent): a bare model name stays on the parent's provider
2026-09-26 12:47:44 -07:00
esengine a3ca5ac370 fix(desktop): history content search finds tool output
The history panel sends no kinds, and the desktop filled that with
user_text, assistant_text, tool_input and tool_error. tool_output was
indexed but never searched, so a result the user saw in a conversation
(for example an MCP server listing) produced no "Content matches".

The panel's default now searches tool_output in place of tool_error.
The index writes a tool_output document for every tool result, errors
included, so tool_error would only list failed results a second time.
The agent-facing history tool keeps its own narrower default.

Searching tool output multiplies candidates, and the snapshot builder
reloaded a session file whenever consecutive bm25-ranked hits came from
different sessions, so interleaved hits cost one full file read each.
The builder now collects the needed rows per session in a first pass,
reads each session once to cut its snippets, and emits hits in the
catalog's rank order in a second pass.

Tests:
- TestHistoryContentSearchDefaultsCoverEveryToolResultOnce: before,
  "zulumarker" (a normal tool result) returned no items; after, each
  query returns exactly one tool_output hit.
- TestHistorySearchBuildReadsEachSessionOnceAndKeepsRankOrder: four
  hits alternating across two sessions. Before: 4 session loads, want 2.
  After: 2 loads, hits in rank order.
- 40 sessions x 80 tool results of 4-6 KB, term only in tool output:
  first page 7.09s before, 0.89s after (single local run).
2026-09-26 12:33:20 -07:00
esengine 3b8687d7a3 fix(desktop): push a replayed approval or question to bot watchers once
Problem: a desktop session watched through the IM bot bridge re-sent the
same approval or question card to the chat every time the desktop replayed
the waiting prompt while reconciling a tab (#9156).

Root cause: botBridgeHub.observe enqueued a watcher message for every
ApprovalRequest/AskRequest, and a replay carries the same kind as the
original request.

Fix: skip the watcher message when the event is marked Replayed; the pending
prompt is still remembered so a reply from the chat routes to the tab.

Test: TestBridgeReplayedApprovalPushesOnce fails before the change with an
unexpected second notification and passes after; the other TestBridge*
tests pass.
2026-09-26 12:21:56 -07:00
esengine 45018f14d5 fix(notify): do not announce a replayed approval or question again
Problem: while an approval or ask card waited for input, Windows kept
raising "Approval needed" toasts, one about every minute (#9156).

Root cause: the desktop replays pending prompts whenever it reconciles a tab
(backend status sync, tab switch, navigation), and Controller replay emits
through the same notification-wrapped sink as the original request. Nothing
on the event said it was a replay, so the notify sink treated each one as a
new request for attention.

Fix: the controller, which is the only party that knows it is replaying,
marks the events emitPendingPrompts re-emits with event.Event.Replayed, and
notify sends nothing for a marked event. The sink keeps no state, so a
controller rebuilt behind the TUI's shared sink, whose prompt ids restart at
"1", still notifies its new prompts.

Test: TestReplayedPromptDoesNotNotifyAgain fails on origin/main-v2 with
"notifications for one approval replayed 3 times = 4, want 1";
TestRebuiltControllersSharingASinkEachNotify fails against an id-set dedupe
with "notifications for two controllers' approval 1 = 1, want 2". Both pass
after, with the existing replay tests and the notify and event suites.
2026-09-26 12:07:44 -07:00
YHH 0137818108 Merge pull request #10888 from esengine/fix/v1-5914-symlink-explorer
fix(desktop): list a symlink in the file panel only when it stays in the tree
2026-09-26 11:53:10 -07:00
esengine 55c307834e fix(encoding): a file is GB18030 only if a write can restore its bytes
edit_file on a UTF-8 file carrying CJK text rewrote bytes it never touched:
continuation bytes of characters far from the edit came back as 84 31 A4 37.

Detect took a nil error from the GB18030 decoder as proof the file was
GB18030. The decoder never fails: it maps every invalid sequence to U+FFFD.
So any file that was not valid UTF-8 -- one stray byte is enough -- was read
as GB18030, and the read-modify-write tools (edit_file, multi_edit,
delete_range, write_file over an existing file), checkpoint restore and the
config migrations then re-encoded every unreadable sequence as the GB18030
form of U+FFFD.

Detect now names a charset only when decoding and re-encoding restores the
input byte for byte. GB18030 is tried first, then GBK, which restores what
GB18030 cannot such as CP936's single-byte euro 0x80, so such a file stays
editable in its own encoding; anything else is LossyUTF8, whose bytes pass
through untouched. The new Kind is appended because checkpoints persist it
by value.

GBK cannot hold every character, so Encode now returns an error instead of
falling back to UTF-8, which rewrote a whole CP936 file as UTF-8 when an edit
added a character such as an emoji. A legacy charset that cannot represent a
character answers an *UnencodableError naming it, its byte offset and the
charset, with ErrUnencodable as its identity; the file tools, checkpoint
restore and the config migrations refuse the write and leave the file as it
was. Fixtures encode through MustEncode.

Readers that hold only part of a stream say so rather than inherit the
whole-file answer:
- DetectFragment runs the round trip on the prefix the decoder consumes when
  not at EOF, so a character the cut split is dropped structurally. grep's
  8 KiB peek, read_file's 256 KiB sample and the desktop's 2 MiB file preview
  use it; the preview's next page starts at the split character, and lossy
  text is shown as text rather than as binary.
- DecodeOutput is the one entry point for bytes that are only displayed.
  The producer says which ends its bound cut (Cut{Head, Tail}); UTF-8 is read
  without a character split at a cut end, and an uncut end keeps its bytes,
  since a GBK pair there is output rather than half a rune. What no charset
  restores is still read as GB18030. Hook output passes its tail cut when
  truncated.
- DetectAndDecode decodes a legacy charset once; the edit tools and
  DecodeToUTF8 use it instead of detecting and decoding separately.

Tests that fail before this change (on the base or an earlier revision of it)
and pass here:
- TestE2EEditKeepsBytesOfUTF8FileWithStrayByte: e4 b8 ad became
  e4 b8 84 31 a4 37 outside the edit (fails on origin/main-v2).
- TestDetectUTF8WithStrayByteIsNotGB18030 (fails on origin/main-v2).
- TestWritesRefuseCharacterCP936CannotHold (edit_file, multi_edit,
  write_file): nil error and the whole CP936 file rewritten as UTF-8.
- TestDecodeOutputTrimsCutUTF8Edges: UTF-8 cut at the front read as mojibake.
- TestDecodeOutputKeepsUncutCodePageEdges, TestDecodeHookOutputKeepsUncutCodePageEdges:
  GBK pairs at an uncut edge (bc db, b0 a1 0d 0a, 6f 6b 20 e4 a1,
  b0 a1 20 6f 6b) were dropped as partial UTF-8 runes.
- TestEditKeepsCP936FileWithEuroByte: an edit adding Chinese wrote UTF-8
  into a CP936 file.
- TestGrepGB18030PeekWithoutNewline, TestGrepGB18030PeekCutMidCharacter:
  no matches past a peek cut mid-character.
- TestReadFileGB18030SampleWithoutNewline: garbled text past the sample.
- TestReadFileGB18030TruncatedWithoutNewline,
  TestReadFileGB18030TruncatedMidCharacter (desktop): the 2 MiB preview of a
  GB18030 file was garbled or ended in U+FFFD.
- TestDecodeHookOutputReadsTruncatedCodePageText,
  TestDecodeHookOutputReadsCP936Euro: mojibake for cut or euro-carrying cmd.exe output.
- TestEncodeRefusesCharacterTheCharsetCannotHold, TestDetectGBKWithEuroByte,
  TestDetectFragmentDropsTheSplitCharacter, TestDecodeOutputReadsCutCodePageText:
  the API's contract.
2026-09-26 11:52:22 -07:00
YHH 7dc55e0144 Merge pull request #10909 from esengine/fix/v1-7853-fakeip-dual-stack
fix(web_fetch): dial an allowed address when a host also resolves to a blocked one
2026-09-26 11:50:23 -07:00
esengine feecfeda8e fix(responses): close tool calls the completed response lists
A Responses turn could end with a tool call the model issued and never
run it. The stream reader dispatched a function call only when its
function_call_arguments.done or output_item.done event arrived; a relay
that forwards the added and delta events but not the done events (or
sends deltas without the item id) left the call open, the terminal
response.completed ended the attempt, and the turn committed as a clean
answer with no calls. The run then finished ok after one round.

The terminal response's output array is the response's own list of what
it issued. On response.completed and response.incomplete, every
function_call item there that the stream did not already close (matched
by call id) is now completed from that item; one whose status is set and
is not "completed" is left alone, so a call truncated at the output
limit is still not dispatched. An item listed without a call id is not
dispatched, and a call id listed twice is dispatched once.

The output array now decodes one item at a time. Decoded as a typed
slice, a single item with a non-string field (arguments sent as an
object by a non-conforming relay) failed the whole terminal event, which
was then skipped and the turn ended with unexpected EOF; the reasoning
snapshot reader shared that exposure and now reads the same per-item
decode. A call with no name, such as an arguments.done event that named
no item, is no longer dispatched as an empty call; it stays open for a
later event to name. The done branches share one completion helper.

Test: output_calls_wire_test.go drives the real HTTP stream reader. On
origin/main-v2 the "done events never came" and "deltas without item id"
cases fail with calls = nil; both pass after the change, and the
no-duplicate and truncated-call guards pass on both. The
unreadable-item, empty-call-id, duplicate-call-id and
unnamed-arguments.done cases fail before the per-item decode and the
skips, and pass after.
go test ./internal/provider/... ./internal/agent/ passes.
2026-09-26 11:35:01 -07:00
esengine c76e17b36f fix(config): StepFun presets declare their 262144-token context window
Root cause: none of the five StepFun presets (stepfun, stepfun-responses,
stepfun-anthropic, stepfun-api, stepfun-api-anthropic) set context_window,
and 1.x has no model-level fallback, so a StepFun session ran with a zero
window. agent.Options documents that ContextWindow <= 0 disables automatic
compaction, and the context gauge has nothing to show, so the conversation
grew until StepFun refused it ("maximum context length is 262144 tokens").

Fix: every StepFun preset declares 262144, the limit the API enforces for
all models they list. Installed entries that still carry no window are
brought forward on load and on load-for-edit, only when the preset id, kind,
official host (stepfun.com or stepfun.ai) and served models all still match;
a custom endpoint, a foreign model or any window the user set is left alone.

Test: internal/config/stepfun_context_test.go fails on origin/main-v2
(stepfun context_window = 0; stepfun-anthropic/step-3.5-flash context_window
= 0 after LoadForRoot) and passes with this change. go test
./internal/config/ ./internal/boot/, go vet, gofmt, golangci-lint and
repolint are clean.

Fixes #9129
2026-09-26 11:34:00 -07:00
YHH b96d6e3665 Merge pull request #10893 from esengine/fix/v1-duplicate-message-id-archive
fix(session): refuse a completion that reuses a durable message id, and keep damaged sessions archivable
2026-09-26 11:32:58 -07:00
esengine 3cd5dde097 fix(subagent): a bare model name stays on the parent's provider
A task call may name a child model without a provider, e.g.
"deepseek-v4-flash". The sub-agent resolver passed that straight to
Config.ResolveModel, which answers a bare name with the first provider that
lists it. With the official DeepSeek entry listed before a relay serving the
same model, a child of a session running on the relay was silently sent to
the official API instead.

The model-override resolution moves out of the Build closure into
subagentModelSelection, and a bare name the parent's provider serves is
qualified with that provider first, in both the runtime resolver and the
identity the usage record reports. A provider name, a provider/model
reference, or a model only another provider lists resolve as before.

Test: TestSubagentBareModelStaysOnParentProvider fails on origin/main-v2
(selection for "shared-flash" = "first/shared-flash", want
"relay/shared-flash") and passes after the fix; go test ./internal/boot/
passes, repolint clean.
2026-09-26 11:14:00 -07:00
esengine 1d589535cc fix(desktop): keep the context panel runtime counting during a running turn
The context panel's "Runtime" stat froze between model requests: during
long tool execution it kept showing the value from the last usage event,
so a two-hour session could read a few minutes.

Root cause: the host accumulates runtime per turn and adds the running
turn's share only at the moment a snapshot is taken, while the panel
refetches that snapshot only when a usage event arrives. Nothing refetches
while tools run, so the number stood still.

Fix: ContextPanel now reports finished-turn runtime (elapsedMs) and the
running turn's start (activeTurnStartedAt), read under the same telemetry
lock. The panel and the context ring popover add the running share locally
and tick once a second while a turn is active, without polling the host.

Test: TestContextPanelSplitsRuntimeIntoCompletedAndRunningTurn (desktop Go)
does not build on main-v2 and passes after. context-panel-runtime.test.tsx
fails on main-v2 ('10s' !== '15s', value frozen across 60s with no usage
event) and passes after. Frontend run-tests: 477 suites passed; tsc clean;
repolint clean.
2026-09-26 11:05:37 -07:00
YHH d730238e83 Merge pull request #10902 from KHG420/codex/fix-environment-shell-label
fix(boot): distinguish tool shell from user login shell
2026-09-26 11:01:17 -07:00
esengine d730d8c8f8 fix(desktop): a remote Ask shows once and can be answered
#10865: in an SSH remote workspace an Ask showed twice and neither card
answered. The remote surface's card was disabled with "This card's exact
request identity is unavailable", and the footer's copy looked live but
its Submit went nowhere.

- RemoteSessionSurface required a runtimeEpoch on every prompt. A CLI
  serve never binds desktop routing metadata, so its prompts carry none;
  the serve and the kernel fence on the epoch only when they have one,
  and the desktop bridge forwards it unchanged. The check now stops at
  the host, session, generation and turn it can actually expect.
- The shell's decision footer also docked the remote prompt from shared
  state, but its answer path targets a local session a remote tab does
  not have, so it was cancelled as not ready. On a remote tab the footer
  leaves approvals, asks, MCP interactions and extension forms to the
  remote surface; shell decisions (clear context, close, conflicts) stay.

Reproduced on Windows with 1.39.1 against a Linux container over SSH
(same two cards, same error); with this build one card appears, both
questions answer and the turn continues.
2026-09-26 10:38:56 -07:00
esengine 7b0fd0d00c fix(desktop): list a symlink in the file panel only when it stays in the tree
listDirForWorkspaceTarget sorted entries by DirEntry.IsDir and kept a
non-directory only when its lstat mode was regular. A symlink is neither,
so every link vanished from the file panel and from the "@" reference
menu, even one pointing at a folder or file inside the workspace.

Containment was also lexical only: workspacePathForBase joins and cleans
the path but never resolves links. So ListDir and ReadFileForTab, given a
path through a link, listed and previewed whatever sat behind it outside
the workspace, while the chat's "@" references already refuse that
through canonicalPathWithin.

A link entry is now listed as its target only when canonicalPathWithin
places that target inside the real workspace. A link that dangles or
leaves the tree stays out. So does one that resolves onto the folder
being listed or one of its ancestors: that folder would contain itself,
and the panel's compact-folder probe would follow it without a click.
ListDir refuses a folder whose real path is outside the tree.
ReadFileForTab refuses a workspace path whose real location is outside
it; a path that does not exist still reports its stat error.
Session-authorized external folder refs are unchanged.

Test: TestWorkspacePanelListsOnlyLinksInsideTheTree covers in-tree folder
and file links, out-of-tree folder and file links, a link to /etc, a
dangling link, "loop -> ." and "docs/up -> ..". On origin/main-v2 it
fails: the in-tree links are missing, ListDir("etc") = 72 entries,
ListDir("out-link") = 1, and ReadFile previews out.md,
out-link/shared.md and etc/hosts. It passes with the fix, and the other
listing and preview tests still pass.

Fixes #5914
2026-09-26 10:24:25 -07:00
esengine 3228c21350 fix(web_fetch): dial an allowed address when a host also resolves to a blocked one
Under a fake-ip resolver (Clash / mihomo) every public host answers with a
routable A from 198.18.0.0/15 beside a unique-local AAAA from fd00::/8.
The direct dial path refused the whole host as soon as any resolved address
was on the SSRF blocklist, so web_fetch failed for every site with
"refusing to fetch internal address ... (resolves to fdfe:...)".

Root cause: the guard treated one blocked address as a verdict on the host,
although the dial is pinned to a resolved address rather than the name. A
blocked address in a mixed answer can simply be skipped; the connect still
only ever reaches an address that passed the check. A host whose addresses
are all blocked is refused exactly as before.

The direct path moves into dialVettedAddress, which tries the allowed
addresses in resolver order, and the lookup becomes a package seam so the
test can supply a resolver answer.

Test: TestWebFetchDialsAllowedAddressBesideBlockedOne resolves a name to
[fdfe:dcba:9876::152, 127.0.0.1] and fetches from a loopback server. On the
base it fails with "refusing to fetch internal address api-docs.example
(resolves to fdfe:dcba:9876::152)"; with the fix it passes.
TestWebFetchRefusesHostWithOnlyBlockedAddresses keeps the all-blocked
refusal. go test ./internal/tool/builtin/, go vet, go run ./tools/repolint and
golangci-lint are clean.
2026-09-26 10:23:19 -07:00
YHH 2813034ac8 Merge pull request #10904 from esengine/fix/v1-7352-cloud-mount-config-path
fix(config): load a project config under a cloud-drive mount
2026-09-26 10:20:04 -07:00
esengine 48df338b53 fix(session): refuse a completion that reuses a durable message id, and keep damaged sessions archivable
A Service-owned session drops durable message bodies from its live
projection at open and after every recovery checkpoint. The writer's only
duplicate check for message/complete looked at those bodies, so it saw
only the ids accepted since the last checkpoint. A completion that reused
an older id (the approved-plan turn admitting its host message under the
user's reserved id did exactly this before bb6b7f608) was accepted and
written. Every full replay then refused the log as damaged: the session
could not be opened, and archive failed too, because archive proves the
target exists by taking a full snapshot.

The writer now keeps the set of live message ids beside the projection.
It is seeded from every open path, carried in the recovery checkpoint
(projection version 8, so older checkpoints rebuild once from the log),
and consulted before a commit is accepted, so a reused id is refused
with ErrDuplicateMessageID however long ago it became durable.

Logs already on disk keep the first message for a repeated
message/complete in every reader (projection, bounded startup including
the model workset, the checkpoint tail, catalog, history index with its
turn summaries, search), matching #10647's rule for replacements and what
the writer would have refused.

Archive and restore no longer read content to prove existence: a damaged
store is still movable to and from the trash. A damaged store reaches the
frontend as session_damaged instead of an unclassified failure, with a
message that says the session's file is damaged and can still be moved to
the trash.

The toast is now a no-drag region in the Electron shell. It sits over the
titlebar drag region, which swallowed clicks on its "show details" toggle.
2026-09-26 10:11:54 -07:00
esengine 08845855e9 fix(config): load a project config under a cloud-drive mount
A workspace below a Box Drive mount on Windows failed every config load
with "resolve project config path ...: The system cannot find the path
specified", whether or not reasonix.toml existed.

Root cause: resolveConfigAccessPath resolves the physical path through
pathidentity.Resolve (GetFinalPathNameByHandle on Windows) before it
confines a project config link to its directory. Below a cloud-drive
reparse point that resolution fails while the files themselves open
normally, and the failure was treated as fatal even when reasonix.toml
was a plain file or absent.

Fix: when resolution fails and the config entry itself is absent or a
non-link file, use the logical path. Such a file lies in its own
directory whatever its ancestors resolve to, so the confinement check has
nothing to decide. A link still has to resolve, so an unresolvable link
is refused as before.

Test: path_access_test.go stubs resolution to fail below a mount
directory. Without the fallback both LoadForRoot cases fail with the
reported "resolve project config path" error; with it they load (and the
project value is applied), and a reasonix.toml link under the same mount
is still refused.
2026-09-26 09:55:26 -07:00
xiexian 4d50f3be01 fix(boot): distinguish tool shell from user login shell 2026-09-27 00:24:02 +08:00
YHH f0669ea3b3 Merge pull request #10890 from KHG420/codex/fix-session-temp-write-approval
fix(control): allow writes to owned session temp without scope prompt
2026-09-26 09:22:55 -07:00
YHH e0f872bf47 Merge pull request #10886 from esengine/fix/v1-6369-single-tilde
fix(desktop): a single tilde reads as a range, not strikethrough
2026-09-26 08:55:53 -07:00
esengine 6d0b308bb2 fix(desktop): a single tilde reads as a range, not strikethrough
remark-gfm defaults to singleTilde: true, so a reply such as
"500~1000 or 2000~3000" struck out everything between the first tilde and
the second. The shared remark policy, used by the live renderer, the
markdown worker and session export, now passes singleTilde: false; a
doubled ~~text~~ still strikes.

Test: markdown-pipeline.test.tsx renders two ranges plus one ~~...~~ span
on both the live and the worker path. Before: 4 FAIL (2 <del> each,
ranges not literal). After: 88 passed. pnpm test (476 suites), tsc for
the app and test configs, and repolint are clean.
2026-09-26 08:28:37 -07:00
YHH 4ae1075ff4 Merge pull request #10877 from esengine/fix/v1-5496-blockquote-wrap
fix(cli): wrap blockquote text to the width beside its rail
2026-09-26 08:26:02 -07:00
xiexian 8bf6e2cd51 fix(control): allow writes to owned session temp without scope prompt 2026-09-26 23:05:38 +08:00
YHH 18ecc523ae Merge pull request #10870 from esengine/ci/faster-release-candidate
ci(release): cut the candidate and publish critical path
2026-09-26 08:01:04 -07:00
esengine 766549d4d0 fix(cli): wrap blockquote text to the width beside its rail
A quoted paragraph was wrapped to the full terminal width and then
shifted right by the "▎ " rail (plus any list indent), so every full
row ran two or more columns past the edge. The terminal soft-wrapped
the overflow, leaving a stray rail on one row and the last characters
of the line on the next — most visible with CJK text, where every row
is filled to the edge.

renderBlockquote now lays the quote's body out at the width left after
its prefix and restores the renderer's width afterwards, so nested
quotes and quotes inside list items narrow once per level.

Test: TestBlockquoteRowsFitTheRenderWidth renders top-level, nested,
list-indented and Latin blockquotes at widths 20/41/80 and asserts no
row exceeds the width. It fails on the base (an 84-column row at
width 80, 24 at width 20) and passes with the fix.
2026-09-26 08:00:20 -07:00
esengine 191a398e76 ci(release): validate embedded notes beside the native builds, not before them
Every native build waited on the input job, which spent 2 of its 2.5
minutes building the embedded docs and running the cache guard. Neither
is an input to a build. They move to their own job that runs alongside
the builds and gates only the seal; source CI, version and candidate
identity still resolve before anything builds.
2026-09-26 07:31:14 -07:00
esengine e7f547773b ci(release): verify candidate provenance eight files at a time
Before approval the publish run proved each payload file's provenance
with one gh attestation verify after another, a network round trip per
file: 2.5 of its 6 minutes. The same check now runs eight at a time; any
file that fails still fails the step.
2026-09-26 07:26:45 -07:00
esengine 9ffbac547c ci(release): stop compressing the Windows installer twice and package both architectures at once
The candidate spent most of its 48 minutes compressing a 580 MB payload
with NSIS LZMA, a single-threaded pass of 5-6 minutes, three times per
architecture's worth of work on the critical path:

- the Windows build job built an unsigned installer and portable archive
  that nothing uses when the release is signed (windows-sign rebuilds both
  from the signed payload): ~8.5 minutes of the arm64 build. Signed
  release builds now stop at the signing payload
  (REASONIX_WINDOWS_PAYLOAD_ONLY); every other caller packages as before.
- windows-sign signed and packaged amd64, then arm64, so the two
  compressions ran back to back (5.6 + 5.1 minutes). The finalize script
  now runs in three phases: Certum signing of both payloads in the one
  session, both packages built at once in separate worktrees (no
  credentials involved), then Certum sealing of each installer in turn.
  Without FINALIZE_PHASE it still does all three.

Installer bytes and compression settings are unchanged.
2026-09-26 07:22:04 -07:00
YHH 677186eaea Merge pull request #10863 from KHG420/codex/fix-app-memory-rerun
fix(ci): preserve app memory artifacts across failed-job reruns
2026-09-26 07:19:14 -07:00
YHH f6e815550f Merge pull request #10868 from esengine/docs/release-notes-1391-fixes
docs(release): 1.39.1 notes cover the upgrade, Windows window and import fixes
2026-09-26 06:12:31 -07:00
xiexian 1dba841e93 test(ci): replace all app memory artifact wildcards
Expand every wildcard in the expected shard artifact pattern so CodeQL accepts the rerun contract test. The test still compares each shard report name with the workflow download pattern.
2026-09-26 21:02:12 +08:00
esengine af2690466c docs(release): 1.39.1 notes cover the upgrade, Windows window and import fixes 2026-09-26 05:47:14 -07:00
YHH 7991996c87 Merge pull request #10853 from esengine/fix/legacy-empty-draft-tab
fix(desktop): 1.38 → 1.39 upgrade keeps sessions intact; package grants no longer kill the window
2026-09-26 04:56:11 -07:00
esengine 614b4c297d fix(desktop): a package grant on the install tree no longer kills the window on Windows
#10858: desktop 1.39.0 on Windows never showed a window. Every sandboxed
Chromium child exited with STATUS_BREAKPOINT, the GPU process crash-looped
until the browser quit, and the launcher reported that startup was not
verified within 30 seconds; --no-sandbox started it.

An allow entry for a specific AppContainer package SID on the DLLs next to
the executable does this (studio #10435 isolated it). The retired Windows
sandbox of 1.38.8 to 1.38.10 granted its package read/execute with
inheritance on the directory of the tool it launched, so such entries stay
on machines that ran it.

The shell now asks the service, synchronously and before Chromium starts
its first child, to remove allow entries naming a specific app package from
the tree the shell runs from (reasonix-desktop -strip-package-grants -app).
The judgement reads SID structure only; only allow entries go, so access
only narrows; an entry inherited from above is cut by protecting the object
that inherits it, leaving the parent untouched. Packaged Windows builds
only. If a removal is refused and the renderer dies before the window
shows, the shell names that cause instead of exiting silently.
internal/packagegrant is studio's package unchanged.

Verified on Windows 11 with the 1.39.1 build and the SID from the studio
recipe: the stock build shows the reported startup_failed dialog with no
window; the patched build starts and lists sessions with the grant on the
app directory and with it inherited from the install root (root and
service untouched). internal/packagegrant's Windows tests pass there.
2026-09-26 04:37:12 -07:00
YHH 72c9400e6f Merge pull request #10857 from esengine/fix/v1-10802-headless-ephemeral
fix(desktop): keep one-shot `reasonix -p` stores out of the sidebar
2026-09-26 04:11:16 -07:00
YHH 48bf9fda81 Merge pull request #10852 from KHG420/codex/fix-opencode-go-tool-name
fix(openai): omit tool name for OpenCode Go chat requests
2026-09-26 04:10:34 -07:00
YHH f943b5467c Merge pull request #10856 from esengine/fix/v1-10768-global-new-session
fix(desktop): keep conversations found during a catalog scan in the sidebar
2026-09-26 04:04:26 -07:00
esengine 758f1f4bd6 fix(desktop): previewing or importing a legacy session keeps its files
A legacy transcript opened from the sidebar runs in place as a preview:
sends are refused, so it never holds anything its source lacks. It was
still persisted like any runtime. Moving to the imported session flushed
the preview's events and rewrote the transcript and meta (a resumed
preview carries the current system prompt, so the bytes differ), which
broke "the original file is kept" and made the import report the source
as updated and offer a branch right after it finished. The tab meta
writer rewrote the source .meta on open as well.

Every desktop path that persists a runtime (navigation, rebuild,
takeover, topic archive, removal, shutdown) and the tab meta writer now
skip a historical preview. Reproduced on Windows: transcript, event log
and both indexes stay byte-identical through preview, import and
navigation, and the update check reports unchanged.
2026-09-26 04:00:42 -07:00
YHH 0d1aeb9c10 Merge pull request #10849 from esengine/fix/v1-review-untrusted-checkout
fix(cli): reasonix review ignores tools and skills the reviewed checkout configures
2026-09-26 03:58:31 -07:00
xiexian 171a15bae1 fix(ci): preserve app memory artifacts across failed-job reruns 2026-09-26 18:53:44 +08:00
esengine 3f2d24f3ae fix(desktop): an opened legacy session stays on its sidebar row
A tab opened from a legacy row published only its transcript path, while
the row is listed by its source identity, so the renderer could not tell
they were one session and added a second row titled with the raw file id
and stuck on "indexing". The runtime node now carries the same source
identity (path and head through desktopSourceKey), and the existing
overlay marks the listed row open instead.
2026-09-26 03:47:04 -07:00
esengine 842116180d fix(desktop): keep one-shot reasonix -p stores out of the sidebar
A headless `reasonix run`/`-p` binds a fresh v4 store in the project's
sessions-v4 root exactly like an interactive conversation, and nothing in
the store says it was a one-shot run. The desktop's historical catalog
lists every canonical store it finds there, so each agent-driven
`reasonix -p` became a permanent row labelled with its raw 32-hex id and
the Legacy badge.

The run kind is now persisted at creation: session.CreateOptions and the
v4 manifest carry an optional `kind`, the controller's
EnsureHeadlessRunSessionPath binds fresh stores with
kind=headless-run, and runAgent uses it. SessionInfo projects the kind
and the desktop's canonical catalog drops such stores from the sidebar,
the history list and the historical management listing. The store stays
resumable through --resume/--continue. The field is omitempty in the
manifest, so older binaries ignore it and keep reading these stores.
No header.json is written, so desktop ownership logic is unaffected.

Test:
- internal/cli TestRunPrintRecordsHeadlessRunKind drives runAgent -p
  against a fake OpenAI endpoint; with runAgent still calling
  EnsureSessionPath it fails with `store kinds = [""]`, and it passes after.
- desktop TestHistoricalCatalogOmitsHeadlessRunStores; without the
  catalog filter it fails listing the store as a row labelled
  0123456789abcdef0123456789abcdef in the sidebar, history and management
  lists, and it passes after.

Stores created before this change carry no kind and stay listed.
2026-09-26 03:43:58 -07:00
esengine adc1951c3b fix(desktop): keep conversations found during a catalog scan in the sidebar
A new conversation in a folder with many sessions disappeared from the
sidebar as soon as another conversation was selected.

Root cause: while any of a folder's session directories is scanning,
pending or degraded, ListProjectTopics answers with complete=false.
ProjectTree kept the last complete window by returning early for such
a first-page refresh, before mergeIncompleteProjectTopicPage ran and
before the page's keys reached itemKeys. The window renders only rows
whose key is in itemKeys plus the active row, so a new conversation
showed only while it was active. Once the user switched away it was no
longer active and not accepted, so it vanished until a complete page
arrived. With few sessions a full reload follows quickly; with many,
each refresh reconciles every catalog target and the Global scan stays
incomplete long enough, or permanently when a directory is degraded.

Fix: drop the early return. An incomplete first page goes through the
existing merge: residents keep their complete-page rows and order,
only newly discovered keys are added, and the old snapshot and cursor
are retained as before.

Test: project-tree-loading.test.tsx gains a case where a busy folder
(12 rows, 5-row window) receives an incomplete refresh carrying a newer
conversation. On base it fails ("a conversation discovered while the
catalog is incomplete is accepted into the window": A-0 is first, the
new row is absent); with the fix it passes. All 476 frontend suites
pass, tsc --noEmit is clean, repolint is clean.
2026-09-26 03:42:39 -07:00
esengine 023896158f fix(cli): reasonix review ignores tools and skills the reviewed checkout configures
`reasonix review` runs inside the checkout it reviews, which is untrusted.
Two routes let that checkout decide what review executes or asks for:

- The tool registry was built from the merged config, so a checkout's
  reasonix.toml `[tools.search] engine = "rg"` + `rg_path = "tools/rg"` made
  review's grep execute the checkout's own binary (unsandboxed on Windows),
  and its `[sandbox]` chose the bash sandbox mode.
- The review skill resolved with project scope, so a checkout's
  `.reasonix/skills/review/SKILL.md` replaced the built-in prompt and its
  allow-list (e.g. adding web_fetch under an attacker-written prompt).

Review now builds its tool registry from the user-only config
(config.LoadUserConfigReadOnly) and resolves its skill from a store with no
project scope, so only built-in and user-level skills are candidates.
Credential env names from the merged config are still registered for env
filtering.
2026-09-26 03:26:03 -07:00
esengine e179a5be65 fix(desktop): legacy history stays visible after upgrading from 1.38
Two paths made a 1.38 user's sessions look gone on the first 1.39 launch,
both reproduced on Windows with data written by the 1.38.3 desktop:

- With no tab to restore, the Global folder started collapsed, so its
  first page was never requested and the sidebar showed no sessions at
  all. The folder now starts expanded when nothing is open; a manual
  collapse still wins.
- The chat 1.38 opens at launch is saved as the active tab. When it was
  never sent it has sidecars (.meta, goal state, inbox lock) but no
  transcript, event log, turn log or subagent, and restore classified it
  as recoverable legacy artifacts: every launch opened on a startup error
  and "unable to load session". Such a tab is now dropped as stale
  presentation, on both the unmapped and the archived-mapping path. No
  file is touched.
2026-09-26 03:24:02 -07:00
YHH a6cb1f9a28 Merge pull request #10845 from esengine/fix/v1-hooks-planner-guardian-review
fix(hooks): run configured hooks in the planner, reasonix review and the desktop try run
2026-09-26 03:19:42 -07:00
YHH 108eb3fb0f Merge pull request #10848 from KHG420/codex/stable-skill-hook-session
fix(hooks): keep resumed skill hook session identity stable
2026-09-26 03:02:52 -07:00
xiexian 438ff50d2b fix(openai): omit tool name for OpenCode Go chat requests 2026-09-26 17:44:16 +08:00
esengine 5fd5cb6c6e fix(hooks): run configured hooks in the planner, reasonix review and the desktop try run
#10843 gave task, skill, fleet and background subagents their own hook
session, but three agents that call tools still ran with no hook runner:

- the dual-model planner (plannerOpts in boot.build) runs read-only
  research tools such as read_file, and its Options carried no Hooks, so
  a PreToolUse deny on reading a secret did not cover the planner's reads;
- `reasonix review` builds its subagent outside boot and passed Options
  with no Hooks at all;
- the desktop settings-page try run (App.TrySubagentProfile) runs
  read_file, search and policy-wrapped bash in the active workspace, also
  with no Hooks.

Options.Hooks is the whole ToolHooks surface, so each of these agents now
runs PreToolUse, PostToolUse and PostToolUseFailure around its tool calls,
PostLLMCall on each model turn (its stdout replaces the reasoning stored in
that agent's session) and PreCompact before its compaction (its stdout is
added to the summary guidance), not only tool hooks. No agent reads hooks
while building its prompt or tool schema, so the cache-stable prefix is
unchanged.

Hook sources and sessions:

- Planner: hook.Runner.ForRole("planner") keeps the session's resolved
  hooks, cwd and spawner, and derives its session ID as
  "<parent session>:planner" each time a hook fires, so a SetSessionID on
  the parent (/new, /clear, resume) reaches it and hooks keyed on
  session_id do not merge sessions.
- reasonix review: loads only the user's own hooks, global settings.json
  and installed plugins. hook.LoadOptions gains SkipProject, which leaves
  out <root>/.reasonix/settings.json, so reviewing an untrusted checkout
  never executes a command that checkout configures. Its hooks keep the
  checkout as cwd, which is what they inspect, and run with
  NoDefaultCurrentDirectoryInExePath=1 (Runner.WithoutCwdCommandSearch), so
  on Windows cmd.exe never resolves a bare `python` in a user hook to a
  python.exe the checkout ships. Each run is its own session,
  "review:<random>".
- Desktop try run: loads project and global hooks the way a chat session
  in that workspace does, under "try-subagent:<random>".

boot.NewCommandHookRunner now takes the hook.LoadOptions to resolve, so a
caller states its hook sources. The hook package doc no longer claims
project hooks load only for trusted projects.

The guardian is left as is. On main-v2 nothing outside internal/guardian
calls Session.Review or ReviewVerdict, the only paths that run its agent,
so it executes no tool. Wiring it is not a one-liner either: its agent is
built inside guardian.NewSession, which would need a new parameter across
29 call sites for a path no production code reaches.

Tests:
- TestBuildRunsPreToolUseInsidePlanner (through boot.Build): a plan-only
  turn whose planner calls read_file is blocked by PreToolUse, and the hook
  sees "<parent>:planner" where <parent> is the ID the UserPromptSubmit
  hook got. After ClearSession a second turn's planner follows the new
  parent ID.
- TestReviewCommandRunsOnlyUserHooks: with a global deny hook and a
  project hook, the global one blocks read_file and the project one never
  runs; two runs get distinct review:<id> sessions.
- TestTrySubagentProfileRunsWorkspaceAndUserHooks (desktop): both the
  project hook and the global deny run for the try run's read_file under
  a try-subagent:<id> session, and the file contents never reach the model.
- TestReviewHooksNeverResolveCommandsAgainstTheCheckout: every review hook
  spawn carries NoDefaultCurrentDirectoryInExePath=1 with the checkout cwd.
- TestForRoleDerivesSessionFromParentAtFireTime and
  TestWithoutCwdCommandSearchReachesEverySpawnOfItsChildren (internal/hook).

Docs: GUIDE.md and GUIDE.zh-CN.md describe which hooks each read-only
child runs and under which session ID.

Refs #9255
2026-09-26 02:31:00 -07:00
github-actions[bot]andesengine 90b380ba9a docs(release): 准备 v1.39.1 中英更新日志 (#10847)
* docs(release): prepare v1.39.1 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): describe the session catalog fix by what users saw

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: esengine <[email protected]>
2026-09-26 01:57:51 -07:00
YHH 3536cfa8e4 Merge pull request #10836 from KHG420/codex/fix-session-rename-ime
fix(desktop): ignore IME Enter while renaming sessions
2026-09-26 01:47:08 -07:00
YHH e46f9cecaf Merge pull request #10833 from KHG420/codex/fix-memory-index-read-errors
fix(memory): preserve index when reads fail
2026-09-26 01:42:41 -07:00
xiexian 1cc9aa939d fix(hooks): keep resumed skill hook session identity stable 2026-09-26 16:30:42 +08:00
YHH 53840c2d85 Merge pull request #10844 from esengine/fix/v1-keep-cli-channels
Revert: the 1.x line keeps publishing the CLI to npm, Homebrew and the CLI pointers
2026-09-26 01:16:04 -07:00
YHH 2530d031e2 Merge pull request #10767 from esengine/fix/ignore-2x-session-sidecars
fix(store): 2.x sidecars in a shared session directory are not conversations
2026-09-26 01:15:21 -07:00
YHH 6e52ae4a27 Merge pull request #10843 from KHG420/codex/fix-subagent-hooks
fix(agent): apply configured tool hooks in subagents
2026-09-26 01:05:37 -07:00
YHH f9fa0b90e7 Merge branch 'main-v2' into fix/ignore-2x-session-sidecars 2026-09-26 00:55:57 -07:00
esengine c130bd7191 Revert "chore(release): the 1.x line stops publishing the CLI to npm, Homebrew, and the CLI pointers (#10757)"
This reverts commit 1aa4a1fb3.

The 1.x line keeps the CLI's install channels (npm `reasonix`, the
Homebrew cask and the CLI update pointers) until 2.x is stable; the
studio line now publishes them only when STUDIO_PUBLISHES_CLI is set.
Without this, 1.39.1 would ship its fixes while npm and Homebrew stayed
at 1.39.0.

Restored workflow steps are pinned to the same full-length SHAs the
rest of main-v2 uses, and the contract test that cuts release-npm.yml at
setup-go matches the pinned form.
2026-09-26 00:51:06 -07:00
xiexian e562582713 test(desktop): load SVG assets in discovered CI suites 2026-09-26 15:43:28 +08:00
xiexian 79acaf6e99 refactor(memory): keep index read fix within lint budgets 2026-09-26 15:43:22 +08:00
YHH e55dd95ff1 Merge pull request #7263 from esengine/chore/update-acknowledgments
Update acknowledgments contributors / 更新致谢贡献者
2026-09-26 00:37:13 -07:00
esengine 34eb376cf3 docs: update acknowledgments contributors 2026-09-26 07:34:51 +00:00
YHH 360310b177 Merge pull request #10842 from esengine/chore/ack-token-v1
ci(acknowledgments): open the weekly PR with a token that triggers CI
2026-09-26 00:33:42 -07:00
YHH 33393dc200 Merge pull request #10839 from esengine/fix/v1-flake-schedule-snapshot-coalesces
fix(desktop): autosave tests clocked the disk instead of the save loop
2026-09-26 00:33:39 -07:00
YHH bf2eb20ed0 Merge pull request #10831 from esengine/fix/v1-flake-browser-transcript-smoke
fix(desktop): native browser smoke re-observes after a page_not_ready refusal
2026-09-26 00:25:34 -07:00
YHH 28c71ef0eb Merge pull request #10837 from esengine/fix/v1-flake-browser-smoke
fix(desktop): recorder track dropped the only frame a static page sends
2026-09-26 00:24:38 -07:00
esengine e63ea1a02b ci(acknowledgments): open the weekly PR with a token that triggers CI
A pull request authored with GITHUB_TOKEN never starts other workflows, so
the weekly acknowledgments PR never reported the checks branch protection
requires and could only land by bypassing it. The step now uses the
dedicated ACKNOWLEDGMENTS_TOKEN secret when it is set, and falls back to
GITHUB_TOKEN otherwise.

The contributor table also listed github-actions[bot]; bot accounts are
now filtered before the top 20 are taken (30 are fetched to keep 20).
Checked by running the script against the live API: the previous version
writes one github-actions row, this one writes none.
2026-09-26 00:13:25 -07:00
YHH f36b3dfc30 Merge pull request #10834 from esengine/fix/v1-flake-desktop-ip-timeout
fix(desktop): split the Windows I-P test group that outgrew Go's package alarm
2026-09-26 00:06:19 -07:00
YHH e5fc72edb2 Merge pull request #10835 from esengine/fix/v1-flake-legacy-topic-archive
fix(desktop): a timed-out catalog stop no longer forgets its close
2026-09-26 00:05:23 -07:00
YHH d87c834040 Merge pull request #10832 from esengine/fix/v1-flake-shutdown-drains-startup-worker
fix(desktop): let both startup tree publishers park in the drain test
2026-09-26 00:04:27 -07:00
xiexian 896faf36dd fix(agent): apply configured tool hooks in subagents 2026-09-26 14:51:30 +08:00
YHH c9f1a86a92 Merge pull request #10830 from KHG420/codex/fix-provider-model-name-overflow
fix(desktop): keep long provider model names within two lines
2026-09-25 23:35:17 -07:00
esengine 7c1b04b89e fix(desktop): autosave tests clocked the disk instead of the save loop
TestScheduleSnapshotCoalesces and TestCloseTabNoResurrectionFromAutosave
polled the session file for 10s and then gave the loop another fixed 10s
to go idle. Neither bound is anything the autosave loop promises: one pass
can wait sessionFileLockWait (5s) on the compatibility lock, ReplaceFile
retries a locked destination for up to ~1.5s per replace, and a failed pass
is retried twice more. On a loaded hosted Windows disk the first save of a
test binary missed both windows.

CI evidence (desktop-windows-go-group, windows-latest):
- 36162070209/108163266006: TestScheduleSnapshotCoalesces 59.13s; the
  first saveLocked entry is logged ~50s after the test started, both waits
  expire, then "open ...session.jsonl: The system cannot find the file
  specified" followed by "file already closed".
- 36144229234/108103306720: same test, 25.12s, file never had the content.
- 36164121404/108168781324: TestCloseTabNoResurrectionFromAutosave, 19.97s,
  same symptom with a single TurnDone.
The two trailing errors are teardown, not the save: once the idle wait gave
up, the remaining cleanups closed the controller's event store and removed
the TempDir under the still-running loop.

Diagnostic runs on windows-latest (run 36221953315, CI-shaped
`go test -run ^Test[Q-S] ./...` x4 and ^TestC x4) put the same tests at
0.7-1.8s with every phase I/O-bound and no scheduler stall, versus 0.2s
unloaded: latency scales with the disk, which the test does not control.

Fix: wait for the loop to go idle - its termination is guaranteed by the
bounded waits and retries above - and only then read the file once. The
only cap left is the test binary deadline. Every autosave test in the file
uses this, including the retry test's former 5s bound.

Reproduction (test-only, not committed): fileutil.CrashPoint sleeping 1s
before every durable write/replace, then running the original test body.
- origin/main-v2: TestScheduleSnapshotCoalesces 3/3 FAIL and
  TestCloseTabNoResurrectionFromAutosave 3/3 FAIL, with the CI lines
  (app_autosave_test.go:139/:497 and :92) and the same teardown pair
  "no such file or directory" then "file already closed".
- this change: 3/3 and 2/2 PASS, no teardown errors.
Unmodified stress: the five autosave tests -count=200 clean,
-race -count=20 clean.
2026-09-25 23:29:23 -07:00
esengine 8693949c1e fix(desktop): recorder track dropped the only frame a static page sends
The macOS native browser smoke (electron/scripts/browser-runtime-smoke.mjs,
job desktop-macos) failed with
  RpcError: cancelled: browser request cancelled or expired
    at Timeout.<anonymous> (main.cjs:8:34337)
10 times across 6 PRs and main-v2 (e.g. runs 35905864950/job 107334745447,
35924725548/107398259097, 36143253798/108099365388,
36144902003/108109092740, 36166759544/108177697220,
35847129273/107137474207). main.cjs:8:34337 is the 10s timer in
BrowserRecorder.start that waits for the recorder renderer's "started"
message; passing runs reach "started" in under 2s.

Root cause: the recorder asked getDisplayMedia for { frameRate: 25 }. The
fixture page is static, so tab capture emits only its first frame and the
refresh frame a newly attached video sink requests. The first frame can
reach the track before the <video> sink connects; when the capturer answers
the sink's refresh with a frame that trails the first by under 40ms, the
track's 25fps limiter discards it. Nothing else is ever produced, the video
never reaches HAVE_METADATA, the canvas is never painted, no chunk arrives
and start aborts at 10s. Instrumented stalls show exactly this in
MediaStreamTrack.stats: deliveredFrames 1, discardedFrames 1, totalFrames 2,
unchanged for 9s, while the page, capture host and capturePage stay healthy.

Fix: request the capture track without a frame-rate cap. The output rate is
already set by the canvas paint interval (40ms) and requestFrame, so the cap
only ever discarded frames.

Reproduction (local macOS, Electron 44.2.0): a test-only hook attaches the
video sink immediately after the track's first frame, the interleaving from
the stalls.
  before (origin/main-v2): 9/40 recordings abort at 10s; all 9 are exactly
  the runs whose track discarded a frame, the 31 without a discard pass.
  after: 0/200 fail, 0 discarded frames, although 115/200 runs had a second
  frame within 40ms of the first.
Full smoke minus the foreground-input block, 6 parallel workers under CPU
load: before 4 recorder aborts in 540 runs, after 0 in 192.

recorderRenderer.test.ts pins that the capture track is requested without a
frame-rate limit; it fails on the previous renderer.
2026-09-25 22:28:07 -07:00
esengine 3d6420e338 fix(desktop): a timed-out catalog stop no longer forgets its close
TestLegacyTopicArchiveVisibility failed in desktop-windows-go-group (I-P)
8 times across 7 branches, including main-v2, e.g. runs/jobs
36141013690/108092129567, 36158613176/108151570952,
35910881048/107351239668, 35979938768/107570884455:

  topic_archive_visibility_test.go:41: session catalog did not stop
  before directory cleanup

(the two older runs, before the cleanup check existed, show the same
handle as "TempDir RemoveAll cleanup: ... rebuilt-catalog.sqlite: being
used by another process").

Root cause, two parts:

- stopSessionCatalog dropped the catalog pointer, then gave up at its
  deadline while Catalog.Close kept running in the background. Nothing
  kept the in-flight close, so the next stop found nothing to wait for
  and reported true while the SQLite handle was still open. This is
  reachable in production: a rebuild whose stop times out restarts the
  watcher, and the next rebuild's stop then reports the old handle
  released. In the test, the mid-test stop's result was ignored and the
  old owner's cleanup check passed vacuously.
- The test bounded the whole orderly shutdown (reconcile drain, worker
  drain, TRUNCATE checkpoint, close) by 1s of wall clock. Under load the
  checkpoint alone was measured at up to 1.87s, so the bound was below
  the observed latency of work the test does not control.

Fix: each stop records its close; the close runs to completion in the
background and waits for any earlier one, and stop reports true only
once every handle the App opened is released. The reconcile wait still
ends at the deadline, since a reconcile can wait on discovery nobody
resumes once stopped. The test waits on that condition with the shared
sessionCatalogTestDeadline hang detector, and checks the mid-test stop.

Reproduction:
- TestStopSessionCatalogWaitsForEarlierTimedOutClose holds a read
  snapshot so the closing checkpoint waits out its busy timeout, stops
  with a 1ms deadline, then stops again: origin/main-v2 fails 20/20
  ("stop reported the catalog stopped while it was ready"); fixed passes
  -count=200.
- 24 concurrent GOMAXPROCS=1 processes of TestLegacyTopicArchiveVisibility
  -count=4: origin/main-v2 17/24 processes fail with the CI line (48
  hits); fixed 0/24.
2026-09-25 22:22:42 -07:00
esengine 4350fd8d36 fix(desktop): split the Windows I-P test group that outgrew Go's package alarm
desktop-windows-go-group (I-P) ran `go test -run ^Test[I-P] ./...` and
reasonix/desktop hit Go's default 10m per-package alarm:

  run 36165720818 / job 108174359373 (main-v2)  FAIL reasonix/desktop 600.369s
  run 36161103402 / job 108158948888            FAIL reasonix/desktop 600.159s
  run 36166824914 / job 108179614742            FAIL reasonix/desktop 600.199s

The reruns of the same commits passed with reasonix/desktop at 348.6s
(job 108167833614) and 401.9s (job 108309233796).

Root cause: no test hangs. At the alarm the running test had been going
for 1-15s (TestLegacyTopicArchiveVisibility,
TestOpenCanonicalSessionAfterWorkspaceRemovalFromNativeSurface,
TestProjectTreeMigratesNewCLISessionAfterProjectDirMarker), and the
largest gap in the product log (66-86s after the parallel-NN.jsonl
writes) covers TestParallelDesktopTabs... through
TestPurgeMigratedSourcesBusyWriterResumesFromTombstone, which take 27.9s
locally: the same ~3x Windows slowdown seen everywhere else. I-P holds
651 tests and is the heaviest group by far (other groups: 66-244s on
the same runners, Q-S up to 398s), so on a slow hosted runner its
package run alone crosses 600s.

Fix: split I-P into I-M (367 tests, 69s locally) and N-P (314 tests,
67s locally) in the partition driver and in both matrices that use it
(desktop-windows-go-group and desktop-go-race). The partition test now
also holds the race matrix to the driver's group list so the two cannot
drift. The alarm stays at Go's default: it is the hang detector, and
each group now has roughly twice the headroom under it.

Reproduction (macOS, CGO_ENABLED=0, same alarm scaled to local speed;
I-P takes ~138s here, so 120s stands for 600s on a runner ~5x slower,
where the failing jobs were):
  before: go test -run '^Test[I-P]' -timeout 120s .
          panic: test timed out after 2m0s, FAIL 120.874s
  after:  go test -run '^Test[I-M]' -timeout 120s .  ok 78.2s / 86.7s / 96.8s
          go test -run '^Test[N-P]' -timeout 120s .  ok 67.1s / 86.5s / 71.1s
2026-09-25 22:22:10 -07:00
xiexian e644498169 fix(desktop): keep session rename open during IME composition 2026-09-26 13:21:54 +08:00
esengine 8e2051929d fix(desktop): let both startup tree publishers park in the drain test
TestHistoricalRegressionShutdownDrainsStartupWorker panicked with
"close of closed channel" on Linux desktop-go, killing the whole
reasonix/desktop package run:
  run 35905864950 / job 107334745459 (main-v2 3a0a3d91)
  run 36160668180 / job 108157764097 (main-v2 c0b98408)
  run 36152302442 / job 108137534322 (PR, rerun passed)
The panicking goroutine is the catalog discovery started by
requestHistoricalCatalogWithContext, emitting from
discoverHistoricalSessions' deferred notify (historical_catalog.go:125).

Root cause: after tabs are restored the startup worker calls
requestHistoricalCatalog (spawning a discovery worker) and then
emitProjectTreeMetadataChanged. Both publish a project-tree change, and
both are legitimate: the discovery replaces a nil catalog with an empty
one, so it reports a change. The test's hook closed `entered` on every
call, assuming exactly one emission before shutdown. Normally the
discovery is still running when the test cancels the coordinator and it
returns without notifying; when the test goroutine is descheduled after
<-entered, the discovery finishes first, emits, and closes `entered`
again. The product's behaviour is correct; the test asserted on an
ordering it does not control.

Fix: the hook signals `entered` once and parks every caller until
release. Stop must drain both workers either way, so the assertion that
shutdown waits for the parked publisher is unchanged in meaning.

Reproduction: a copy of the test with a 2s pause between <-entered and
stopHistoricalImports fails every run on origin/main-v2 with the CI's
exact stack; with the fixed hook it passes -count=20. The real test
passes -count=200 -cpu 1,4 and -race -count=10 over TestHistoricalRegression*.
2026-09-25 22:13:36 -07:00
xiexian 2eb878989e fix(memory): preserve index when reads fail 2026-09-26 13:12:33 +08:00
esengine 6b3afe22e3 fix(desktop): native browser smoke re-observes after a page_not_ready refusal
Flake: desktop-browser-group (transcript), step "Test native browser
foreground runtime" (xvfb-run node electron/scripts/browser-runtime-smoke.mjs).
Four failures, all at the first cross-origin click, all with

  RpcError: page_not_ready: input target has not reached a stable
  rendered frame; observe the page again   data: { kind: 'page_not_ready' }

  run 35833016485 / job 107091102092 (main-v2)
  run 35983968809 / job 107583562246 (main-v2)
  run 35954377998 / job 107490367842 (main-v2)
  run 35866024386 / job 107199844899 (PR fix/transcript-render-isolation)

Root cause: dispatchMouseInput refuses a mouseMove, before dispatching
anything, when pageInputReady does not see two animation frames with
unchanged geometry within 500ms. The refusal is typed and retryable; its
contract is "observe the page again". The smoke sent its first click right
after iframe onload, which is not a rendered-frame boundary, and treated the
refusal as fatal. In every failing log the run ends ~0.7s after Electron
starts versus ~0.34s to the first click in passing runs, consistent with the
500ms unpainted-frame deadline on a slow runner. The product refusal is
correct; the smoke asserted a timing it does not control.

Fix: every native smoke click goes through clickObserved, which follows the
contract: on page_not_ready it re-observes (snapshot + ref resolution, or
the zoom-point read) and retries within 5s; any other failure, or the
refusal at the deadline, still fails. A refused child-frame attempt retires
its OOPIF session, so the shared-session assertion counts from the
observation that clicked, and the idle-release assertion counts from the
attachments before idle.

Reproduction: REASONIX_BROWSER_UNSETTLED_INPUT=1 keeps the fixture page
moving until the first input is refused, then stops it before
re-observation, and asserts a refusal happened. The Linux CI step now sets
it, so the refusal/re-observe path runs on every build.

Local, macOS (Electron 44.2.0; screen locked, so run with
--disable-gpu-vsync --disable-frame-rate-limit to keep frames flowing):
  origin/main-v2 + hook only:    0/20 pass, 20/20 the CI page_not_ready line
  fix, REASONIX_BROWSER_UNSETTLED_INPUT=1: 29/30 pass (1 failure in the
    later recording-takeover phase, "cancelled: browser request cancelled
    or expired", after all input sites had passed)
  fix, default:                  15/15 pass
2026-09-25 22:05:39 -07:00
xiexian 671fdb6eca fix(desktop): keep long provider model names within two lines 2026-09-26 12:53:49 +08:00
YHH 7aff0b79ab Merge pull request #10825 from esengine/fix/v1-10335-session-preset
fix(desktop): restore each canonical session's own permission preset
2026-09-25 21:28:11 -07:00
esengine 51f6a0ad6d fix(desktop): restore each canonical session's own permission preset
Root cause: canonical (v5) sessions had nowhere to keep their permission
preset. The legacy BranchMeta sidecar is refused for them, and neither the
Session Service projection nor workspace presentation carries a preset, so
the value lived only on the tab. OpenSession applied the source surface's
live preset to the target session (prepareCanonicalControllerRuntime read
it from the outgoing controller). Session B inherited A's
danger-full-access, and a surface built from New Chat forced workspace-write
onto every session opened into it. The renderer had the same leak one layer
up: its composer profile table is keyed by tab, and the push that follows a
navigation sent the previous session's preset to the new one.

Decision: keep the preset per canonical SessionID in a desktop-owned record
(desktop-session-presets.json next to desktop-tabs.json), with no change to
the session log schema. Opening a session, and restoring tabs at startup,
use only the value recorded for that exact SessionID. A session with no
record gets the new-session default. The value on the surface is never
carried over.

Recording rule: a record is written only by SetPermissionPresetForTab, the
one preset setter fenced by session. The caller passes the SessionID of the
permission snapshot it read; under the tab's turnStartMu, which also
serializes navigation, the call is refused with
reasonix_error:permission_session_changed (errPermissionSessionChanged)
unless that id equals the live controller's session. The record is keyed by
that same session and the file is written outside App.mu. The revision alone
cannot fence this: it is per controller, so two freshly opened sessions share
a number. SetToolApprovalModeForTab and SetModeForTab name no session and so
never record; saving tabs, submissions, composer-profile sync and draft
settings never record either.

Fix:
- sessionPresetStore exposes restore, record and forget. A failed write
  removes the file, or empties it in place when the directory refuses the
  removal, so a restart opens every session at the default; the full set is
  written again on the next record or forget.
- The renderer passes the snapshot's sessionId to SetPermissionPresetForTab.
  On permission_session_changed it re-reads the tab and does not retry the
  choice on the session the tab now shows.
- Whenever a tab's canonical SessionID changes (syncTabSessionIdentity:
  New Session, clear, legacy continue, rebuild), the tab and its controller
  take that session's own record or the new-session default.
- New Session on a blank tab that is reused drops the blank session's record,
  resets it to the default, and advances the controller's permission
  revision (Controller.InvalidatePermissionSnapshots), so a snapshot read
  before the reuse cannot record a choice for the session handed out as new.
- A fork tab starts at the new-session default rather than the source tab's
  preset.
- The canonical open path passes the target session's restored preset to
  prepareCanonicalControllerRuntime, and startup restore does the same for
  tabs bound to a SessionID. Legacy tabs keep their existing sidecar path.
  A session that is permanently deleted drops its record.
- docs/desktop-migration inventory regenerated for the new signature and
  line positions.
- Composer profiles in the renderer record which session they were read for
  (owner). A profile, or a meta snapshot, whose owner belongs to a different
  session than the tab's current one is discarded, not reconciled.

Tests:
- TestCanonicalSessionPresetFollowsSessionAcrossNavigationAndRestart and
  TestRestartDoesNotTrustSurfacePresetForUnrecordedSession fail on
  origin/main-v2 (session-B / session-A tab preset = "danger-full-access",
  want "workspace-write").
- TestNewSessionInTabDoesNotInheritSourcePreset: A=full, New Session in the
  same tab -> the new session is the default, has no record, and stays the
  default after restart through desktop-tabs.json and after reopening; A is
  still full.
- TestNewSessionOnBlankTabDropsItsEarlierChoice and
  TestForkStartsAtNewSessionDefault cover the reused blank and fork paths.
  These three fail without the recording rule above with
  tab preset = "danger-full-access", want "workspace-write".
- TestUnreadablePresetRecordsRestoreDefault: a corrupt record file restores
  the default.
- TestStalePresetChoiceDoesNotLandOnNavigatedSession: snapshot A, open B
  (same revision), then set full access with A's snapshot -> refused with
  errPermissionSessionChanged, B not recorded, B stays at the default. The
  same scenario through the unfenced signature fails with "stale choice for
  session-A accepted on session-B" and B recorded as danger-full-access.
- TestPresetWriteFailureInReadOnlyDirFailsClosed: full access recorded, the
  config directory made read-only, read-only chosen -> restart does not
  restore full access. Without the in-place truncation it fails with
  narrowed to read-only, write failed, restart restored "danger-full-access".
- TestSnapshotBeforeBlankSessionReuseIsRefused: snapshot the blank tab, New
  Session reuses it, set full access with the old snapshot -> refused and
  nothing recorded. Without the revision advance the set is accepted and
  recorded as danger-full-access.
- The desktop preset tests pin a host that offers every preset
  (control.SetPresetSandboxForTest), so they assert the same thing on a
  runner without an OS sandbox, where read-only and workspace-write are not
  offered. Pinned to "no sandbox", three of them fail with
  permission preset "read-only" is unavailable: shell sandbox requested but
  unavailable on this host.
- TestUnfencedModeSettersDoNotRecord: SetToolApprovalModeForTab and
  SetModeForTab leave no record.
- frontend permission-preset-session-fence.test.tsx: a navigation between the
  snapshot and the set neither applies nor records the stale choice, the
  refusal is not retried, and the tab is re-read.
- frontend composer-profile-session-navigation.test.tsx runs the real
  projection, lifecycle and controller-profile hooks through A -> B -> A.
2026-09-25 21:07:39 -07:00
YHH 2764312ea4 Merge pull request #10826 from esengine/fix/v1-10540-conhost-width
fix(cli): fit glyphs a conhost CJK font draws double-width
2026-09-25 19:47:39 -07:00
YHH 1b7effb4f2 Merge pull request #10822 from esengine/fix/v1-10369-wayland-x11-default
fix(desktop): start the shell on X11 under Wayland when XWayland is there
2026-09-25 19:46:27 -07:00
YHH a45c4bd24c Merge pull request #10821 from esengine/fix/v1-9719-kill-durable
fix(session): keep a killed turn's prompt and partial reply across restart
2026-09-25 19:31:58 -07:00
SivanCola 9ec23094c9 fix(release): stop the manual disclosure from doubling the v prefix (#10373)
The v1.38.9 desktop release page reads "This vv1.38.9 desktop release":
resolve already emits the version with its v prefix, and the disclosure
template added another one.

Normalize the prefix in the step instead of assuming either convention,
so the public text stays correct if the resolver output ever changes, and
assert the shape in the release workflow contract test.
2026-09-25 19:21:07 -07:00
YHH fa793fae74 Merge pull request #10819 from esengine/fix/v1-9758-reasoning-only-after-tools
fix(agent): retry once when a tool round ends in a reasoning-only stop
2026-09-25 19:19:32 -07:00
YHH 75e4d1373a Merge pull request #10805 from esengine/fix/v1-10458-missing-key-message
fix(cli): point a missing model key at the credentials file, not the shell
2026-09-25 19:09:08 -07:00
esengine 8b9b85eda2 fix(cli): fit glyphs a conhost CJK font draws double-width
Root cause: legacy conhost with a CJK TrueType font (the default on
Chinese Windows) sizes East Asian Ambiguous runes by the glyph, so it
draws a font-dependent subset two cells wide: · … “ ” ‘ ’ — – ● → ↑ ↓
◆ ° × and Greek/Cyrillic letters among them. The TUI and its renderer
count all of them as one cell, so every full-width row carrying k of
them is k cells too wide on screen: conhost wraps it, the alt-screen
frame grows past the window and scrolls, and later repaints land on
shifted rows, which is the stacked-frame garbage in the report.

Decision: measure instead of guess. When stdout is a console, a
never-activated screen buffer of that console (same font) measures
each non-ASCII rune the layout counts as one cell, once, via
WriteConsoleW and a cursor read-back. A rune the console draws wider is
swapped, after layout, for a one-cell stand-in both sides agree on:
Unicode's halfwidth variant when it has one (→ becomes U+FFEB), else
the OS US-ASCII best-fit (· becomes ., “ becomes "), else the OS
default character. Each candidate is itself measured before it is
used. The swap is rune for rune at equal counted width, so applying it
to the finished frame changes no row's layout; escape sequences and
OSC payloads pass through untouched. A pseudoconsole (Windows Terminal,
VS Code, SSH) measures these runes as one cell, so nothing is replaced
there; other platforms get a nil fit and are unchanged.

Test: TestChatFrameFitsConhostMeasuredWidths renders the real chat
View at 120 columns with the width table measured on a Chinese
Windows 11 conhost and asserts no row draws wider than the terminal.
With the substitution disabled it fails:
  row 0 draws 121 cells on conhost, terminal has 120
With the fix it passes on macOS and on the Windows box. In a real
conhost window, the unfixed binary's status row wraps ("EFFORT aut" /
"o") and the header scrolls away; the fixed binary keeps the 30-row
frame intact. Under ConPTY the fixed binary still emits the original
glyphs.

Fixes #10540
2026-09-25 19:07:59 -07:00
esengine c5bdfb0477 fix(session): keep a killed turn's prompt and partial reply across restart
Root cause: a force-killed turn lost state in two ways. Sessions opened
from a legacy transcript write the model-visible .jsonl only on the 30s
autosave tick or at turn end, so a kill before the first tick dropped the
prompt from the model's context even though the display ledger kept it.
Event-log (v3) sessions record the prompt on admission, but streamed
assistant text reaches the log only when the message completes, so a kill
mid-stream lost the partial reply entirely.

Decision (option A): two bounded changes, no change to the cache-stable
prompt prefix.
- The agent announces a new advisory checkpoint boundary right after the
  user message is admitted. A controller without an event store saves the
  legacy transcript there (one extra save per turn); stores that already
  hold the message ignore it, and a failed save only logs.
- The controller accumulates the open stream's deltas and, on the existing
  30s autosave tick, appends an optional stream/checkpoint event carrying
  the same local-only interrupted record a cancelled stream leaves. It
  holds commitMu so the checkpoint can never land after the commit of the
  message it describes. The projection keeps the newest checkpoint of the
  open turn and drops it on any later message commit or turn boundary;
  restart recovery upserts it before turn/end interrupted. A completed
  stream therefore never shows twice, and the record stays out of
  provider-visible history.

Tests (internal/control/turn_kill_durability_test.go), each killing a
child process with os.Exit and no TurnDone, then reopening:
- TestKilledLegacyTurnKeepsItsPrompt fails on main-v2 with
  "prompt of the killed turn lost after restart"; passes with the fix.
- TestKilledTurnKeepsItsPartialOutput fails on main-v2 with
  "partial output of the killed turn not durable after reopen: 0 rows";
  passes with the fix, and the partial text stays out of ModelMessages.
- TestCompletedStreamSupersedesItsCheckpoint and
  TestKilledAfterCommittedStreamShowsItOnce fail on main-v2 with
  "no partial-output checkpoint was written while the stream was open";
  with the fix the completed reply appears exactly once, including after
  a kill between its commit and turn end. Disabling the supersede rule
  makes the latter fail with the checkpoint replacing the reply.
2026-09-25 18:49:12 -07:00
esengine aed114c057 fix(desktop): start the shell on X11 under Wayland when XWayland is there
Root cause: Electron 44 picks its Wayland Ozone backend by itself in a
Wayland session. On drivers whose EGL platform init fails there (Kylin V10
SP1 with the Mali stack in #10369) the GPU process exits and the app dies
with 139 before a window appears; the same machine works with
--ozone-platform=x11. Nothing in the launch chain chose a backend. The
.desktop entry runs reasonix-launcher, which runs reasonix-desktop, whose
bootstrapShell builds the Electron argv and starts it detached, so that
spawn is the one place that controls the shell's argv. The flag has to be
in argv: Chromium selects Ozone before the main script runs.

Decision: default to X11 whenever it is available. bootstrapShell prefixes
--ozone-platform=x11 on Linux when the session is Wayland
(XDG_SESSION_TYPE, else WAYLAND_DISPLAY) and DISPLAY is set.
REASONIX_OZONE_PLATFORM=wayland keeps native Wayland, x11 forces X11, auto
is the default. A pure Wayland session without DISPLAY and an X11 session
are unchanged, and an explicit --ozone-platform or --ozone-platform-hint
argument wins. Relaunches through the launcher re-decide; Electron's own
relaunches reuse process.argv and keep the flag.

Test: TestBootstrapShellChoosesOzonePlatformFromSession drives
bootstrapShell with a fake shell that records its argv, over the session
table. On origin/main-v2 it fails:
  shell argv = ["--flag"], want ["--ozone-platform=x11" "--flag"]
With the fix all nine cases pass.
2026-09-25 18:48:57 -07:00
YHH 58079d322a Merge pull request #10808 from esengine/fix/v1-10574-global-folder
fix(desktop): hide an empty Global folder once a project exists
2026-09-25 18:45:00 -07:00
YHH 8ea3391019 Merge pull request #10806 from esengine/fix/v1-10142-markdown-content-key
fix(desktop): address the parsed-markdown cache by content, not row id
2026-09-25 18:43:49 -07:00
esengine 2467b3d7d6 fix(agent): retry once when a tool round ends in a reasoning-only stop
Root cause: handleFinalResponse accepts any reasoning-only clean stop as
the end of the turn unless RequireVisibleFinal is set, which only
internal callers set. When the model runs tools and then stops with
reasoning only, the tool results never get a visible synthesis and the
turn goes idle until the user sends another message (#9758).

Decision: a narrow structural retry. When this turn ran tools, no
visible assistant text follows the last tool result in the transcript,
and the stop is reasoning-only, the existing empty-final retry runs
once. A second reasoning-only stop is accepted. A reasoning-only answer
to a turn that ran no tools is still accepted as final, as #9788 set
out. The judgement reads structure only: usedAnyTool, empty content,
and message position; no wording is inspected.

Tests: TestRunRetriesReasoningOnlyStopAfterToolRoundOnce and
TestRunAcceptsSecondReasoningOnlyStopAfterToolRound drive a scripted
provider through Agent.Run. On the base both fail with
"provider calls = 2, want tool round + reasoning-only stop + one retry";
with the fix they pass. The existing no-tool reasoning-only tests
(TestRunAcceptsReasoningOnlyFinalAnswer and siblings) pass unchanged,
as do internal/agent and its direct dependents.
2026-09-25 18:37:11 -07:00
YHH f3a6109fc4 Merge pull request #10803 from esengine/fix/v1-10482-retire-prose-mutation-ban
fix(runtimepolicy): stop turning user prose into a turn-wide write ban
2026-09-25 18:29:20 -07:00
YHH c799e1fe81 Merge pull request #10809 from esengine/fix/v1-10372-linux-static-service
fix(desktop): build the Linux desktop service without cgo
2026-09-25 18:23:59 -07:00
YHH a0df0ff8c3 Merge pull request #10811 from esengine/ci/darwin-roots-crashreport
ci(macos): the darwin pull-request lane covers internal/crashreport
2026-09-25 11:07:15 -07:00
YHH f5392e626f Merge pull request #10804 from esengine/fix/v1-10738-path-identity-cache
fix(desktop): resolve each project root once when loading the projects file
2026-09-25 11:02:55 -07:00
esengine 634ba8b369 ci(macos): the darwin pull-request lane covers internal/crashreport
internal/crashreport gained process_unix.go (built on darwin) with the CLI
fatal-crash capture, but the package was not in darwinRoots, so pull
requests touching it skip the macOS lane. release-control's
"every darwin-specific package is inside the pull-request group" test
caught it on the main-v2 push; the pull request itself skipped
release-control by path.

scripts/macos-go-tests.test.mjs: 2 pass / 1 fail before, 3 pass after.
2026-09-25 10:37:06 -07:00
esengine 7a509f9981 fix(desktop): build the Linux desktop service without cgo
The Linux reasonix-desktop service was the one Go binary in the package
built with cgo left at its default. On the ubuntu-22.04 release runner that
links it against glibc 2.35, so the desktop-v1.39.0 tarball's service names
/lib64/ld-linux-x86-64.so.2 and needs GLIBC_2.32/2.34: it will not start on
glibc 2.31 systems (Debian 11, Ubuntu 20.04, Kylin V10, UOS 20), while the
launcher, migrator, CLI and update helper beside it are already static.

Nothing on Linux needs cgo: every `import "C"` file is darwin-only, sqlite
is pure Go, and the Linux StatusNotifierItem tray is pure-Go D-Bus. The
tray files were gated on cgo, though, so a plain CGO_ENABLED=0 build would
have swapped in the no-op tray; they now select the D-Bus tray on linux
regardless of cgo.

- scripts/desktop-build.sh builds the Linux service with CGO_ENABLED=0.
- verify.mjs reads the ELF program headers of every Go member of the Linux
  tarball and .deb and refuses one that names a program interpreter, so the
  release build fails instead of shipping a glibc floor again.

Test: lib.test.mjs adds ELF parsing and archive-member cases; on the base
it fails at import ("does not provide an export named
'checkStaticGoMembers'"), with the fix 23/23 pass. Against real binaries,
checkStaticGoMembers reports the desktop-v1.39.0 release service as
"reasonix-desktop is dynamically linked (interpreter
/lib64/ld-linux-x86-64.so.2)" and returns no errors for the service built
by the fixed script path. `GOOS=linux CGO_ENABLED=0 go list` now selects
tray.go, tray_loop_external.go and tray_health_unix.go instead of
tray_nocgo.go; linux vet/test compile and darwin vet pass.

Fixes #10372
2026-09-25 10:32:45 -07:00
esengine e6d2a7c9ff fix(desktop): hide an empty Global folder once a project exists
The sidebar is meant to show Global only while it holds conversations
or has a custom title, or while no project exists. The registry merge
in mergeCanonicalWorkspaceShellsFromProjection broke that: it re-adds
every workspace whose registry record is Visible, and
ensureDesktopWorkspace marks Global Visible the first time anything
targets it, including opening the Global composer draft
(OpenSessionDraftForTarget), even when no session gets created. From
then on Global stayed in the sidebar with no conversations, whether or
not any project existed. The reporter saw this on one machine and not
on another because it depends on whether Global was touched before the
first project was added, not on the OS.

Projects are added on purpose, so their Visible flag stays
authoritative. Global is never added on purpose. The registry now keeps
it only while it owns an unarchived session or a source mapping.

Test: TestGlobalFolderAfterProjectCreatedFollowsGlobalConversations
- base: draft_target_without_a_conversation fails with
  "Global folder listed = true, want false after a project was created"
- fix: all three cases pass (untouched, draft target, blank
  conversation, which still keeps Global)
2026-09-25 10:24:39 -07:00
esengine e85edb9201 fix(desktop): address the parsed-markdown cache by content, not row id
The transcript's parsed-markdown cache was keyed on `${item.id}@${revision}`.
A settled assistant message carries a live id (p3) while the controller owns
it and a history id (m:<messageId> / he:<entryId>) once the store projection
renders it, so the same text parsed under one id missed under the other and
went back through the markdown worker.

A parse is a pure function of its source text, so the cache is now keyed by
the text's content revision and a hit additionally requires the stored source
to equal the requested text, which keeps a 32-bit revision collision a miss.
The row id still opts a row into caching; it no longer names the entry.

Test: markdown-natural-flow renders a settled body under cacheKey "p3",
remounts it under "m:msg-1", and asserts no second worker parse. On base it
fails with `4 !== 3` ("a row re-keyed by the history projection reuses the
parse of the same text"); with the fix it passes. transcript-store covers the
LRU budget, pinning and the collision miss (382 passed).
2026-09-25 10:23:10 -07:00
esengine 37fbfba727 fix(cli): point a missing model key at the credentials file, not the shell
An explicit --model whose provider has no key failed with
`provider "deepseek-flash" requires DEEPSEEK_API_KEY`. Provider keys
resolve only from <Reasonix home>/.env (storedCredentialValue), so the
message sent users to export the variable in their shell or VM-wide
environment, which Reasonix never reads. It also labelled the model ref
as a provider. Reported through `reasonix acp --model deepseek-flash`
from the VS Code extension in #10458.

The error now names the model, the variable, the credentials file path
and `reasonix setup`, and says shell environment variables are not read.

Test: TestResolveModelForCLIMissingKeyNamesCredentialFile sets the key
only in the process environment. On the base it fails with
  error "provider \"deepseek-flash\" requires REASONIX_CLI_TEST_KEYLESS_KEY"
  does not mention ".../.reasonix/.env"
and passes with the fix; go test ./internal/cli/ passes.
2026-09-25 10:22:36 -07:00
esengine e3ad203bc6 fix(desktop): resolve each project root once when loading the projects file
loadProjectsFile runs on every sidebar read and catalog pass. Its
normalizers (project dedupe, pinned list, sidebar order, organization
sidecar) find a root with projectIndexByRoot/projectRootInList, and each
comparison went through pathidentity.Same, which resolves both paths from
scratch. One load therefore cost O(projects^2) identity resolutions; on
Windows each resolution opens every ancestor directory and queries its
case-sensitivity flag, which matches the Procmon trace in #10738 (about
57k Read-Attributes opens on every project root, 533k
QueryCaseSensitiveInformation calls, almost no file content read).

pathidentity gains a pass-scoped Matcher that resolves and stats each
distinct path once; Same delegates to it, so a single comparison behaves
exactly as before. The project-file normalizers share one matcher per
pass. Nothing is cached across passes, so a directory whose identity
changes is seen fresh on the next load.

Test: TestLoadProjectsFileResolvesEachRootLinearly loads 24 projects
(pinned, sidebar order, organization sidecar, one symlink alias) and
counts identity resolutions. With only the counting seam in place it
failed: "loading 24 projects resolved path identity 4400 times, want at
most 100"; with the shared matcher it passes, and the alias still folds
into its target. TestMatcherResolvesEachDistinctPathOnce checks the
matcher agrees with Same on every pair while resolving each path once.

Refs #10738
2026-09-25 10:18:43 -07:00
YHH 35bec101de Merge pull request #10801 from esengine/fix/v1-upgrade-fixture-prepare-phase
fix(desktop): upgrade acceptance checks the import where the harness asks for it
2026-09-25 10:16:02 -07:00
YHH 2e8be93ed7 Merge pull request #10798 from esengine/ci/desktop-race-on-push
ci(desktop): run the race sweep on main-v2 pushes, not on every pull request
2026-09-25 10:13:03 -07:00
esengine 9f5156a260 fix(runtimepolicy): stop turning user prose into a turn-wide write ban
Problem: a turn such as "按方案实现登录模块,数据库结构不要改。" had every
write_file/edit call refused with "blocked: the current constraints forbid
state mutation"; a follow-up "continue" turn then wrote fine.

Root cause: ParseConstraints set ForbidMutation from a phrase table over the
user's text (hasGlobalMutationBan) and inspected only the words after the
negated verb. Chinese places the object before the verb, so a scoped
protection ("X 不要改", "其他不要改", "The schema: do not modify") left an empty
tail and was read as a global read-only order for the whole turn.

Fix: retire the wording-based mutation ban. ForbidMutation is now set only
structurally, by plan mode or an inherited parent constraint; the user's
prose still reaches the model as an instruction. This matches the studio
line, where the prose parser has already been retired.

Test: internal/agent TestUserProseDoesNotBanWritesForTheTurn drives a real
Agent.Run whose model issues write_file. On the base it fails for every case,
e.g. "write_file executed 0 times, want 1; result="blocked: the current
constraints forbid state mutation"". With the fix it passes, and
TestPlanModeStillBlocksWrites confirms plan mode still refuses the write.
go test ./internal/runtimepolicy ./internal/agent ./internal/control pass.

Fixes #10482
2026-09-25 10:08:36 -07:00
YHH be32f9b8ab Merge pull request #10800 from esengine/test/fill-width-after-usage-css
test(desktop): measure fill-width options after the usage stats CSS has loaded
2026-09-25 09:57:58 -07:00
esengine 4b0e748375 fix(desktop): upgrade acceptance checks the import where the harness asks for it
desktop-windows-package has failed on main-v2 since 27d99bbdb with
"windows-upgrade-fixture: startup imported or recreated the legacy
session". ffdd87ef8 (#10759) made a restored legacy tab report its
historical source, so the tab now shows the import banner, and made the
banner action a real PrepareSession import. The first-launch acceptance
ran with -PrepareHistoricalSession, clicked that action, and the fixture
then found the import it forbids. Startup itself still imports nothing.

- The first launch no longer prepares. The UI wait accepts the import
  banner beside a visible transcript body when it was not asked to
  prepare, and the fixture keeps its no-import verification.
- The restart launch prepares. `verify --phase restart` now requires
  exactly one legacy SourceMapping for the fixture's legacy path whose
  session is the global workspace's only session, no operation left
  open, the active tab carrying that session and topic, the authored
  legacy history unchanged, and the topic databases and backups intact.
- ci-paths routes the acceptance scripts and the upgrade fixture to
  the packaging surface. They run only inside desktop-windows-package,
  and a pull request that changed only them skipped that job.

Test: TestWindowsUpgradeFixtureImportsLegacyOnlyWhenPrepared restores
the saved tab, prepares its source through PrepareSession and opens the
result with OpenSession. The restart verification fails on the untouched
state, and the first-launch verification fails on the imported state.
Against the previous fixture the test fails with:
  prepared verification passed before the legacy source was imported
and with that pre-import check removed it reproduces the CI error:
  restart: startup imported or recreated the legacy session
TestVerifyPreparedImportRequiresOneCommittedLegacyImport covers the
rejected import shapes.
ci-paths.test.mjs pins the new packaging routes; it fails on the
previous pattern for scripts/test-windows-upgrade-startup.ps1.
2026-09-25 09:38:35 -07:00
esengine 2704e35724 test(desktop): measure fill-width options after the usage stats CSS has loaded
UsageStatsPanel is lazy()-loaded, so UsageStatsPanel.css ships only in a
lazy chunk. verifyFillWidthOptions opened a fresh page and went straight
to the theme gallery, so a too-broad rule in that stylesheet was never on
the page it measured: with the old global rule
`:root .settings-options.settings-options--content > button` placed in
UsageStatsPanel.css the check still passed.

The check now opens Usage statistics first and waits for its groups, so
the lazy stylesheet is loaded before the theme gallery is measured.

With the old global rule in UsageStatsPanel.css:
  before: PASS chromium/windows fill-width theme preview options
  after:  AssertionError: chromium/windows/1600px: fill-width options span
          their group (99.375 of 206px)
With the current scoped rule the full settings-layout bench passes.

Refs #9980
2026-09-25 09:32:32 -07:00
esengine 937421aed8 ci(desktop): run the race sweep on main-v2 pushes, not on every pull request
desktop-go-race is eleven parallel partitions of the whole desktop module
under -race, on every pull request that touches native code. The same
partitions already run without -race on each pull request (desktop-go and
the Windows groups), so the race sweep mostly repeated that work per PR head.

It now runs on pushes and manual dispatch only. The desktop aggregate gets
RACE_REQUIRED, which mirrors the job's own condition, so a pull request
expects the sweep to be skipped and still fails if it unexpectedly runs.
A data race introduced by a pull request is reported by the push run after
merge.

scripts/ci-workflow.test.mjs evaluates the job condition and the aggregate
expectation over every event/native/notes-only combination and runs the
aggregate script for the new pull-request cases; both fail against the
previous ci.yml.
2026-09-25 09:29:18 -07:00
YHH c0b9840810 Merge pull request #10774 from esengine/fix/v1-10006-panel-shows-tmp-bin-stage
fix(desktop): the file panel shows top-level tmp/, bin/ and stage/
2026-09-25 09:25:29 -07:00
YHH b6a200129a Merge pull request #10777 from esengine/chore/pin-actions-sha
ci: pin every third-party action to a full-length commit SHA
2026-09-25 09:24:19 -07:00
YHH 28dc982d99 Merge pull request #10791 from esengine/fix/v1-10145-websearch-off-persists
fix(config): keep the web search switch off when Settings submits the derived request URL
2026-09-25 09:23:26 -07:00
YHH 47c6877e07 Merge pull request #10784 from esengine/fix/v1-10425-recall-keeps-draft
fix(cli): an accidental history recall no longer loses the draft
2026-09-25 09:22:34 -07:00
YHH e824e84c3f Merge pull request #10788 from esengine/fix/v1-9795-cli-crash-capture
fix(cli): keep fatal crash output and the diagnostics tail of long sessions
2026-09-25 09:21:54 -07:00
YHH d220256c5b Merge pull request #10783 from esengine/fix/v1-10503-inherited-subagent-effort
fix(boot): inherited agent.subagent_effort yields to the execution model
2026-09-25 09:21:00 -07:00
YHH f0043d4099 Merge pull request #10782 from esengine/fix/v1-10405-ambiguous-resume
fix(cli): --resume with an ambiguous query lists or offers its matches
2026-09-25 09:20:07 -07:00
YHH 581cf4115c Merge pull request #10781 from esengine/fix/v1-9806-admission-turn-id
fix(desktop): answer a turn start with the admitting controller's receipt
2026-09-25 09:19:14 -07:00
YHH ff8c4f3b3e Merge pull request #10778 from esengine/fix/v1-10254-err-on-interrupted-turn
fix(transcript): an interrupted turn keeps its provider error visible
2026-09-25 09:18:22 -07:00
YHH 2bfa9c8a31 Merge pull request #10776 from esengine/fix/v1-10394-rename-canonical-session
fix(cli): /rename titles the active canonical session
2026-09-25 09:17:28 -07:00
YHH 61e39366fc Merge pull request #10775 from esengine/fix/v1-10202-crash-restart
fix(desktop): the crash overlay can restart Reasonix
2026-09-25 09:17:09 -07:00
YHH 37bba1f2c3 fix(capability): route an MCP tool only when the user names it (#10773)
routeEntry promoted MCP tools to prefer through two phrase tables.
looksLikeGitHub fired on "issue" or " pr " and then matched every tool of
any server whose name contains "github"; looksFreshData fired on "latest"
or "查一下" and matched every tool named *search*/*fetch*/*read*. Each MCP
tool is its own entry, and limitRouteCandidates caps only suggest
candidates, so one GitHub server with forty tools put forty prefer lines
in the capability-route block and the model spent turns declining them.
explicitMCP ("<server> mcp") had the same shape: naming a server selected
all of its tools.

An MCP tool is now routed only when the request contains the identifier
the host minted for it (mcp__<server>__<tool>). Anything less specific is
left to the semantic router, which already runs when deterministic routing
finds no strong candidate and caps what it appends.

TestRouteDoesNotFloodOneMCPServer builds forty github tools plus
search/fetch/read tools on other servers: on the base a GitHub question
routes 43 prefer candidates; with the fix it routes none, and naming
mcp__github__tool_7 routes exactly that tool.
2026-09-25 09:14:51 -07:00
YHH b6d8eec28b fix(desktop): content-sized settings options show their labels whole (#10772)
Every SettingsOptions button takes flex: 1 1 0 with min-width: 0 and an
ellipsis. The usage statistics range and source groups are content-sized
inline-flex boxes whose width is the sum of their labels, so the equal
share handed spare width to short labels and cut every label longer than
the average. In Chinese that clipped "最近 14/30/90 天" and "远程工作台".

The usage statistics groups now give each option flex: 0 1 auto, so it
sizes to its label and still shrinks with an ellipsis when max-width
binds. The rule is scoped to those groups: other content-layout callers,
such as the theme gallery preview controls, make the group full-width
and rely on equal shares to fill it.

The settings browser gate opens the usage statistics page under a zh-CN
locale and asserts no range or source button is narrower than its label,
and opens the theme gallery and asserts each preview control's buttons
span their full-width group.
2026-09-25 09:10:04 -07:00
YHH c466347c28 fix(agent): a depth-0 delegation does not wait on its own tool call's write claim (#10771)
When tool hooks are configured, every depth-0 tool call holds a
whole-workspace parent write claim for the duration of Execute, including
run_skill/task. A writer-capable subagent that call starts (explore,
research, any non-read-only skill) asks the scheduler for a whole-workspace
writer slot at depth 0, which is not Nested, so the conflict with that
parent claim queues instead of failing. The claim is released only after
Execute returns, and Execute is waiting on the queue: the turn hangs with
no timeout.

The parent claim now carries an id. The tool call puts it into its Execute
context, and a subagent acquired under that context is not refused by that
claim, nor by it from Realize/MarkOpaque on the child's own writes, nor is
it ordered behind a queued whole-workspace writer (which that same claim is
blocking). Every other parent claim and every other request is scheduled
as before.

Test: TestDepthZeroDelegationIsNotBlockedByItsOwnHookParentClaim drives
executeOne on a depth-0 agent with hooks and a delegate tool that acquires
a whole-workspace writer slot and runs a depth-1 child write; on the base
it times out waiting on the claim. TestDelegationInsideParentClaimIsNot
QueuedBehindWritersThatClaimBlocks covers the queued-writer ordering.

Fixes #9688
2026-09-25 09:09:22 -07:00
YHH 793a0f16e7 fix(provider): "Input length N exceeds the maximum length M" is a context overflow (#10770)
A 400 whose message reads "Input length 1048826 exceeds the maximum
length 1048566" is a provider-confirmed context overflow, but the
context-limit parser had no shape for it. ParseContextLimitError
returned nil, SendWithRetry handed back a plain APIError, the user saw
"Malformed request ... likely a bug", and the agent's overflow recovery
never ran because AsContextLimitError found nothing.

The parser now accepts this shape alongside "prompt is too long: N > M",
under the same invariant: the input must exceed the maximum, or the
body stays unclassified.

Test: TestSendWithRetryInputLengthOverflowIsContextLimit serves the
reported body from an httptest server and asserts SendWithRetry returns
a ContextLimitError with prompt 1048826 and window 1048566; it fails on
the base with a *provider.APIError. TestParseContextLimitErrorInputLengthNeedsOverflow
holds the invariant for equal, smaller and zero inputs and for a 401.
2026-09-25 09:04:41 -07:00
YHH e0aa45fa13 fix(desktop): say why a new scheduled task will not save (#10769)
A new scheduled task needs a title and instructions, but the editor
disabled Save while either was empty and said nothing about it. The
title is a borderless heading-style input whose placeholder reads like
a filled-in value, so a user who set the project, permissions and
schedule saw a dead Save button with no reason. Suggestions prefill a
title, which is why editing one of those did save.

Save is now enabled for a new draft; pressing it with a required field
empty does not persist anything, marks the empty field aria-invalid,
shows an inline message tied to it via aria-describedby, and moves
focus to it (switching back to the configuration tab if needed).

Test: "heartbeat editor required fields" in heartbeat-editor.test.tsx
renders a new task with instructions but no title and presses Save. On
the base the title is neither marked invalid, focused, nor explained;
with the fix all five checks pass.
2026-09-25 09:02:08 -07:00
esengine affdd21f6e ci: pin every third-party action to a full-length commit SHA
A tag is a mutable pointer: whoever controls the action's repository can
move v7 to new code and every workflow here runs it on the next event,
with this repository's token and on its self-hosted runners. A commit SHA
cannot be moved.

Each reference keeps its tag as a trailing comment, which is the form
Dependabot's github-actions ecosystem (already configured) reads and
updates. The composite actions pulled in by reference
(attest-build-provenance, upload-pages-artifact, the Windows signing
setup) already pin their own nested actions, so the repository-level
"require full-length SHA" policy can be enabled once this lands.

Six workflow contract tests asserted the literal tag spelling
(upload-artifact@v7, setup-node@v7, attest-build-provenance@v3). They now
require the pinned form "@<sha> # vN": the major version is still checked,
and a reference that drifts back to a floating tag fails them.
2026-09-25 08:44:12 -07:00
esengine d43c4f30bf fix(desktop): the file panel shows top-level tmp/, bin/ and stage/
The workspace file panel and its change watcher reused fileref.SkipEntry,
the "@" reference search's skip predicate. That predicate also skips
skipDirPaths, a table of this repository's own generated-output paths keyed
by workspace-relative path, so "tmp", "bin" and "stage" matched the
top-level directory of every user workspace and vanished from the panel,
and changes under them were dropped by the watcher.

Split the predicate: fileref.SkipBrowseEntry keeps only the name-based
noise (VCS stores, dependency trees, build outputs) and is what the panel
and the watcher use; SkipEntry, used by "@" search, still adds the
repository paths.

Tests: TestWorkspacePanelListsGenericTopLevelDirectories and
TestWorkspaceWatchSeesChangesUnderGenericTopLevelDirectories fail on the
base (tmp, bin and stage are missing from the listing and their changes
are skipped) and pass with the fix; TestSearchSkipsRepoOutputPathsThatBrowsingShows
keeps "@" search skipping tmp/.

TestSearchFileRefsFindsNestedBasename now holds the new contract: ListDir
shows bin/, stage/, tmp/ and desktop/frontend/wailsjs while "@" search
still skips them.
2026-09-25 08:09:29 -07:00
esengine 8015f4bbdf fix(config): keep the web search switch off when Settings submits the derived request URL
The Settings editor submits the request URL it displays, which for the
official DeepSeek Responses and Anthropic routes is the standard URL
derived from the base URL. IsOfficialDeepSeekSearchEndpoint treated any
non-empty request or chat URL as a custom override, so saving the provider
cleared WebSearch to nil, and nil reads back as on for official endpoints:
turning server-side search off never persisted.

A request or chat URL that normalizes to the base URL's standard route is
no longer an override; any other URL still is. The legacy Messages default
restore keeps skipping entries that carry an explicit request or chat URL.

Fixes #10145
2026-09-25 08:07:07 -07:00
esengine c4cc34f567 fix(cli): an accidental history recall no longer loses the draft
Pressing up on the first line of the chat input recalls the previous
prompt and saves the draft so down can bring it back. Every key other
than up, down and enter reset that recall state, including pure cursor
movement, so up followed by right or Home and then down left the
recalled prompt in place and the draft was gone with no way back.

The recall now resets only when the input no longer holds the recalled
entry, i.e. when the user actually edited it; the edited text then
becomes the draft for the next recall, as before. Cursor keys keep the
recall session and its saved draft.

Because cursor keys no longer leave recall, recall is line-aware while
it is active: up moves to an older entry only from the first line of
the input and down to a newer entry or the draft only from the last
line; on any other line of a multi-line entry the textarea moves the
cursor instead of replacing the text.

TestSubmittedInputRecallKeepsDraftAcrossCursorKeys fails on the base
("down after cursor keys should restore the draft, got \"old\"") and
passes with the fix; TestSubmittedInputRecallEditedEntryBecomesDraft
pins that an edited entry is left alone by down;
TestSubmittedInputRecallMovesWithinMultiLineEntry pins that up, left,
up, down inside a recalled three-line entry keeps that entry and that
down from its last line restores the draft.
2026-09-25 07:55:06 -07:00
esengine 77af6236eb fix(cli): keep fatal crash output and the diagnostics tail of long sessions
A long-running CLI session that died silently left nothing to diagnose.
The CLI only recovered panics on the main goroutine and never called
debug.SetCrashOutput, so a panic on any other goroutine or a fatal runtime
error (out of memory, concurrent map writes) went only to the console's
stderr, which is gone once the terminal closes. The TUI diagnostic log
also kept its first 4 MiB and dropped everything after, so a session that
ran for days lost exactly the entries leading up to the crash.

The CLI now mirrors runtime crash output into a per-PID file under the
Reasonix home, and the next start queues any dump left by a dead process
as a pending crash report (runtime classification and goroutine stacks,
panic value redacted). A panic already reported through recover detaches
that file first so it is not queued twice. The TUI log now rotates within
the same total bound: the live file holds half the budget and replaces a
single .prev.log sibling, so the newest diagnostics survive.

Tests: TestGoroutinePanicIsReportedOnNextStart re-runs the test binary
as a child that panics on a background goroutine, then asserts the next
start lists the report; it lists none on the base. TestTUIDiagnostics-
KeepLatestEntriesWithinTotalBound writes three times the limit and a
marker, and asserts the marker is on disk within the bound; on the base
only the head is kept.
2026-09-25 07:26:33 -07:00
esengine 712faa3ba4 fix(boot): inherited agent.subagent_effort yields to the execution model
agent.subagent_effort is a cross-model default, but when agent.subagent_model
is unset the subagent role inherits the execution model and the preflight
validated the global value as a hard requirement for it. Switching to a model
that does not declare that level (for example a custom OpenAI-compatible model
without supported_efforts) aborted Build with UNKNOWN_MODEL_REASONING before
any request was sent. Build also fed the same value into the task tool and
skill subagents, so skipping it at preflight alone would only move the failure
to the first subagent request.

inheritedSubagentEffort now decides once, from the execution model's reasoning
capability, whether the inherited global default applies. When it does not,
subagents fall back to the model's own default, Build emits a warning notice
naming the dropped value, and the persisted setting is left untouched so it
applies again on a model that supports it. agent.subagent_efforts.<profile>
and an effort paired with an explicit agent.subagent_model stay strict.

Tests: subagent_effort_inherit_test.go covers the preflight, the resolved
default, the Build notice, and the strict companions; the preflight and Build
cases fail on the base with UNKNOWN_MODEL_REASONING.

Refs #10503
2026-09-25 07:19:59 -07:00
esengine f6b3c79d06 fix(cli): --resume with an ambiguous query lists or offers its matches
resolveSessionQuery reported several matches as a bare sentence, so
neither caller could recover which conversations matched: headless and
TUI starts both printed "session query ... is ambiguous (N matches)" and
exited, with no ids to narrow the query by and no way to open any of the
matched sessions.

The ambiguity now carries identity: ambiguousSessionQueryError holds the
matched entries. resumeQueryTarget reads it with errors.As. On a terminal
the TUI start opens the session picker over just those matches. Headless
starts, and TUI starts without a terminal, still exit 1, but list each
candidate (time, the id --resume matches exactly, summary) and a hint to
rerun with one of those ids. The picker's menu building moves into
chooseResumeEntry so both paths share it.

TestHeadlessResumeAmbiguousQueryListsCandidates fails on the base (stderr
only carries the ambiguity sentence) and passes now;
TestInteractiveResumeAmbiguousQueryOffersOnlyMatches checks the chooser
receives exactly the two matching sessions and its pick is used.
2026-09-25 07:19:08 -07:00
esengine 799ea15876 fix(desktop): answer a turn start with the admitting controller's receipt
StartTurnForTab admitted the submission on one controller, discarded the
SubmissionReceipt that SubmitIdentified returned, released the tab
admission locks, and then re-resolved the tab's controller to ask it for
the turn id. A controller rebuilt in that window (compaction, maintenance,
settings) cannot be relied on to hold the receipt, so an accepted turn was
reported to the user as "turn admission did not produce a durable turn id".

control.SubmitResult now carries the TurnID of the receipt, filled by
submitToTabResult from SubmitIdentified (and from a known-submission
lookup), and StartTurnForTab answers with it. The re-resolved lookup
remains only as a fallback for controllers that return no receipt.
StartTurnForTabWithDrafts uses its receipt directly as well.

Test: TestStartTurnForTabAnswersWithAdmittingControllerReceipt swaps the
tab's controller for one without the receipt while the turn starts; it
fails on the base with the reported error and passes with the fix.
2026-09-25 07:19:07 -07:00
esengine 50f3d33a50 fix(transcript): an interrupted turn keeps its provider error visible
A turn_done with status interrupted or recovery_required took the generic
"turn interrupted" branch in both renderers (the transcript projection and
the desktop reducer), which ran before the err branch and dropped err,
detail and diagnostic. Turns persisted by 1.38.7 as recovery_required with
an HTTP 402 therefore replay as an unexplained interruption.

The interrupted branch now also emits the provider_request_failed notice
when the turn carries an error whose diagnostic is not a cancellation. The
cancellation test reads the diagnostic kind, now named
provider.FailureKindCancelled, rather than the error text; a user stop still
shows only the interrupted notice.

Tests: TestInterruptedTurnKeepsProviderFailure (internal/transcript) and
interrupted-turn-provider-error.test.ts both fail on the base and pass here.
2026-09-25 07:12:24 -07:00
esengine 1295f9498b fix(cli): /rename titles the active canonical session
/rename gated on Controller.SessionPath() and wrote the legacy .meta
sidecar. Since the CLI binds exclusive sessions-v4 sessions, the legacy
path is never set, so "/rename <title>" always answered "no active
session to rename"; "/rename <n> <title>" refused every final-format
row outright.

The current session now resolves to its canonical identity when the
controller is bound to one and is titled through
Controller.SetSessionTitle, the same event owner the set_session_title
tool uses. A picked canonical row that is not the bound session goes
through session.Service.SetTitle. Legacy transcript paths keep the
sidecar write.

Test: TestRenameCurrentCanonicalSessionWritesTitleEvent and
TestRenameIndexedCanonicalSessionWritesTitleEvent in
internal/cli/rename_canonical_test.go run /rename against an exclusive
sessions-v4 controller and read the title back from the session
projection. Both fail on the base (RenameNoSession / the final-format
refusal) and pass with this change.
2026-09-25 07:11:52 -07:00
esengine a2a1786687 fix(desktop): the crash overlay can restart Reasonix
The crash overlay is the last surface a broken renderer paints, and it
offered only "Send report" and "Copy". Nothing on it could recover the
app, so users had to kill the process from the OS to get back in.

The overlay now carries a "Restart Reasonix" action. It resolves the
host's RestartApplication command at click time through the host
adapter, as the send action does, so it works without React or the
bridge module. When no desktop host is present or the relaunch is
refused, it reloads the renderer instead, which still recovers a
renderer-only crash. The button lives in its own module to keep
crash.ts inside its size budget.

Test: src/__tests__/crash-overlay-restart.test.ts paints the overlay
under jsdom and clicks the restart action. It checks that the host
relaunch runs once, and that a refused relaunch or a missing host falls
back to a reload. On the base the overlay has no restart action and 4
of 5 assertions fail.
2026-09-25 07:02:41 -07:00
esengine 235d7124d7 fix(store): 2.x sidecars in a shared session directory are not conversations
Reasonix 2.x shares the session directory with 1.x and writes
<id>.wire.jsonl, <id>.adjudication.jsonl and <id>.execution.jsonl beside
its transcripts. 1.x classified them by their .jsonl ending, so every 2.x
session showed up as extra empty conversations in the resume picker, and
-c could land on one of them as the newest.
2026-09-25 06:23:59 -07:00
YHH 27d99bbdb0 Merge pull request #10763 from esengine/maint/v1-issue-fixes
fix(v1): issue fixes for 1.39.x stability and regressions
2026-09-25 06:03:44 -07:00
yhh 74d04d1e66 fix(desktop): read a tab's native historical source off the app lock
The #10759 change asked the controller whether it runs a legacy source
while metaForTab still held a.mu, which breaks the rule the rest of the
function keeps and that TestMetaForTabRevalidatesBindingAndReadsController
Unlocked holds: controller reads happen after the lock is released. Its
stub controller panicked there on CI. The tab's recorded source and head
are taken under the lock; the controller is asked once it is released.
2026-09-25 05:42:56 -07:00
yhh a606427918 Merge fix/v1-10759-recovery-tabs into maint/v1-issue-fixes 2026-09-25 05:19:00 -07:00
yhh 6d7fca61bb Merge fix/v1-10726-follow-reset into maint/v1-issue-fixes 2026-09-25 05:17:08 -07:00
yhh 90a62bab80 refactor(desktop): the post-open local surface selection is its own helper
#10755 grew session_workspace_api.go past the 800-line ceiling and added a
comment over the essay budget, and a test comment read as stage narrative;
repolint failed lint-code on all three. The selection block moves into
session_local_surface.go unchanged in behaviour, and the comments state
the constraint instead.
2026-09-25 05:17:08 -07:00
yhh cd6f481288 fix(weixin): a saved account id refuses either path separator on every OS
f9883482d made a login id name one file, but on Linux a backslash is an
ordinary character, so `..\evil` passed as a local name and
TestSaveAccountRejectsTraversalID has failed the ubuntu, macOS and race
jobs on main-v2 since. The same config directory can be read on Windows,
where that name climbs out of the account folder, so both separators are
refused everywhere.
2026-09-25 05:17:08 -07:00
yhh abc62266d2 fix(desktop): restore a changed recovery source at its current version
checkAdoptedMigrationSource quarantines an adopted legacy source whose
fingerprint changed as a pending recovery entry. Restoring that entry
refused with "historical source changed; rescan before restoring" once
the source moved again, and nothing ever rescanned it, so the entry
stayed pending on every startup (#10759).

Restore now rebases the entry onto the fingerprint read under the source
lock and converts the version on disk. A source held by another writer,
missing, or unreadable is still refused. Because entries are keyed by
version, a version already restored settles every entry that names it:
the restore commit marks sibling entries for the same version restored,
RecordRecovery no longer re-quarantines a restored version on the next
startup, and rebasing onto a restored version opens that session instead
of importing it twice.
2026-09-25 05:15:04 -07:00
yhh ffdd87ef8b fix(desktop): a historical-source tab states why it cannot send
A saved tab kept in recovery with reason historical_source_pending is
restored onto a native legacy runtime. That runtime continues the
transcript in place but has no durable session store, and every desktop
send carries a submission ID, so the controller refused each one with
"durable submission identity unavailable". The tab offered a composer
that could only fail, and the banner's import action reopened the same
source in place instead of preparing it (#10759, #10760).

- Turn admission refuses an identified submission to a native legacy
  runtime before it reaches the controller, with the typed session code
  historical_source_pending carried in the not-accepted RPC error.
- Tab and runtime meta report the historical source for such a runtime,
  so the frontend projects the tab as pending: the composer is disabled
  and says to import before sending.
- The banner action prepares the source (PrepareSession) and opens the
  resulting canonical session, reusing the source-update polling path.
- The controller's refusal is an exported sentinel,
  ErrSubmissionIdentityUnavailable, instead of an anonymous error.
2026-09-25 05:14:54 -07:00
yhh ec915df218 fix(event): coalesce tool output deltas so a verbose command cannot reset the transcript follower (#10726)
Text and reasoning deltas already merge in event.Coalesce (at most one
event per 16 ms window), but ToolProgress bypassed it: every pipe write of
a running shell command became its own transcript frame. A build printing
a few thousand short lines published ~1000 frames/s, so any renderer poll
cycle slower than ~250 ms overran the follower's 256-change queue, and
each overrun cost a full snapshot baseline in the desktop renderer.

Measured through the real controller follow path with a client that
applies the desktop acceptance rules and takes 400 ms per batch:
- 3000 reasoning/text deltas: 20 changes, 0 resets (already coalesced)
- 3000 shell output chunks: 6 resets in 2.3 s before, 0 after
  (~160 changes, largest batch ~39 of the 256 allowed)

A ToolProgress event carrying only a tool ID and Output now merges with
the next one for the same tool under the same window and byte cap.
Progress with any other field (Verifying, sub-agent channel names,
parent ids) still passes through unmerged and flushes the buffer first,
so ordering and every consumer's append semantics are unchanged. The
burst identity is grouped into one deltaKey instead of more scalars.

The follower's queue bound is untouched: a genuine overflow still
requires a new baseline (TestFollowOverflowRequiresNewBaseline).
2026-09-25 05:13:42 -07:00
yhh 66d17f5278 Merge fix/v1-10665-shutdown-race into maint/v1-issue-fixes 2026-09-25 05:03:00 -07:00
yhh 35ee2d86e9 fix(desktop): a late shutdown success no longer reads as a failed quit (#10665)
The shell waited 10 s for the desktop/shutdown reply, then polled
shutdownStatus for a fixed 10 s and declared shutdown_incomplete. A
service still closing after that window was reported as failed, and its
completed/success reply, arriving seconds later, was dropped as an orphan
response, so the user saw a failure dialog for a quit that succeeded.

The shutdown reply now stays awaited for the whole wait, so a late
completed result is taken as the verdict. After the reply window the
shell follows status: it keeps waiting while the service is alive and its
phase or outcome still advances, and fails only when the service exits
without success (service_exited), stays in one phase past the stall
window, or the explicit 60 s ceiling passes. Failures carry their code on
a typed ShutdownError, and the timing is injectable for tests.
2026-09-25 05:02:17 -07:00
yhh 5f9dc14b03 Merge fix/v1-10747-catalog-heads into maint/v1-issue-fixes 2026-09-25 05:02:02 -07:00
yhh 0b05ffcfd1 Merge fix/v1-10647-duplicate-message-id into maint/v1-issue-fixes 2026-09-25 05:02:01 -07:00
yhh 0d80fa3d5c fix(sessioncatalog): rebuild a stale head index in repair and land its heads (#10747)
A schema-2 session whose <id>.event-index.json no longer matches its log
(an older build appended without rewriting it, or the index is gone) is
projected with head_count from the sidecar but no catalog_heads rows, and
queued for repair. Repair then took the listing-index shortcut, returned
already_current without touching the head index, and marked the row valid.
The index stayed stale forever, so a fresh catalog generation held
sessions declaring heads it never had rows for: v10 with 0 head rows
against sum(head_count)=9.

Repair now skips the listing-index shortcut when a schema-2 log's head
index is stale, so the replay path rewrites the head index. The catalog
lands that session's head rows, head_count and selected head in the same
transaction that marks it valid, so a repaired row never names heads it
does not carry.
2026-09-25 05:00:13 -07:00
yhh bb6b7f6081 fix(control): give the approved-plan execution turn its own user message id (#10647)
executeApprovedPlan runs the execution turn in the planning turn's
context, which carries the user message identity reserved for that turn.
The agent admits the host "plan approved" message under that identity, so
the session holds two messages with the user's id. The durable
message/complete is refused as a duplicate, and a later full
history/replace of the in-memory session can write the duplicate to disk,
after which the session can no longer be hydrated.

runComposedSyntheticTurn now reserves a fresh identity for the synthetic
message it admits.
2026-09-25 04:58:51 -07:00
yhh 77aeb94fe0 fix(session): reopen logs with duplicate replacement ids and refuse new ones (#10647)
A history/replace or legacy/import payload that repeats a message id was
accepted by the projection, but the history index re-indexes a replacement
without upsert and fails with "duplicate indexed message id". Every
transcript follow of such a session failed, so the desktop could never
hydrate it again.

Readers now take the first occurrence of each id in a replacement, in the
projection, history index, search index and transcript metadata alike, so
logs already on disk open and all views agree on one list. The first
occurrence is kept because the log already treats an id as claimed by its
first message: message/complete refuses a later duplicate.

Writers are refused at PrepareBatchContext with ErrDuplicateMessageID, and
message/complete's duplicate refusal now carries the same sentinel, so a
new duplicate never reaches the log.
2026-09-25 04:58:42 -07:00
yhh 5572e2491a Merge fix/v1-workspace-10638-10659-10735 into maint/v1-issue-fixes 2026-09-25 04:53:59 -07:00
yhh 25105d8b75 Merge fix/v1-updater-wal-10732-10714 into maint/v1-issue-fixes 2026-09-25 04:53:58 -07:00
yhh a8cac73f93 Merge fix/v1-go-10614-10654-10604 into maint/v1-issue-fixes 2026-09-25 04:53:58 -07:00
yhh 00ae4cdfeb Merge fix/v1-ui-10746-10743-10718 into maint/v1-issue-fixes 2026-09-25 04:53:57 -07:00
yhh 9854d12396 Merge pr-10755 into maint/v1-issue-fixes 2026-09-25 04:53:56 -07:00
yhh 45c9accbbb fix(desktop): make clearing an already-removed source survive a restart (#10735)
Archiving a historical source whose session was already deleted returned
Committed with outcome already_removed but persisted nothing. The frontend
only dropped the row from memory, so after a restart the residual topic was
listed again. That row survives when the deleted session's purge receipt
carries no topic ownership, which earlier purge writers omitted, so
purgedCanonicalTopicIDs cannot retire it.

On already_removed the legacy source's topic now joins deletedTopics
through the existing projects-file helper, which every sidebar projection
honours. The topic is left listed when a non-deleted session or a pending
create still carries it, or another transcript beside the source carries
it without being adopted by a deleted session.
2026-09-25 04:03:24 -07:00
yhh 397ee644ea fix(desktop): rebind the global workspace when the data directory moves (#10638, #10659)
The global workspace is a singleton derived from the state root
(<home>/global-workspace), but workspace-state-v1.json stores its absolute
root. After a portable build or REASONIX_HOME moved, every ensure of the
global workspace saw a different root under the fixed "global" ID and
EnsureWorkspaceResolved refused it with ErrMutationConflict, so Global
failed to load and no global session could be created or opened.

A different stored root for global can only be where the data directory
used to be, so both EnsureWorkspaceResolved and ReconcileDiscoveredSession
now rebind global to the current root instead of conflicting, keeping the
old root in formerRoots. Canonical sessions recorded their CWD under the
old root; binding, restore, recovery choices and discovery accept a former
global root as global instead of treating it as a conflicting project.
An empty root still conflicts, and project workspaces are unchanged.

Legacy sessions-v4 stores keyed by the old global root's slug are not
rebased here.
2026-09-25 04:03:23 -07:00
yhh 0c2f15cefe fix(desktop): keep the Ask card open when the custom-answer row is clicked (#10718)
The Ask card is collapsible: PromptShelf toggles collapse on any click
that does not land on a recognised control. The custom-answer row is a
div role="group" that focuses its input, so a click on its pencil or
padding both focused the input and bubbled up to collapse the card,
hiding the answer the user had just started.

Stop the row's click from reaching the card; the row is itself a
control. A new interaction test clicks the row outside its input and
checks the card stays expanded, and that the card's own chrome still
collapses it.
2026-09-25 03:58:47 -07:00
yhh 150d97c3b9 fix(desktop): portal the Git graph dialog above the workspace dock (#10743)
The Git graph dialog rendered in place inside the composer frame, and
.composer-workspace-frame--context sets isolation: isolate. Its fixed
backdrop and z-index therefore only ranked inside that stacking context,
and the right workspace dock, painted later as a sibling column, covered
the part of the dialog that extended over it.

Render the dialog through createPortal to document.body, as the other
modal dialogs do, so it stacks at the overlay layer of the whole window.
The workspace-context test now asserts the dialog is mounted outside
the composer tree.
2026-09-25 03:58:47 -07:00
yhh 186df7ad3d fix(serve): resume a session from the web UI by its identity (#10746)
Canonical /sessions rows expose a session through sessionId/hostId and
leave the legacy path empty, but the web UI's session list posted only
{path}. The server answered 400 "missing path or sessionId" and the click
did nothing visible, so no older session could be opened.

Send sessionId, hostId and name alongside the path so both canonical and
legacy rows resolve, and show a warning notice carrying the server's
status and reason when the resume is refused or the request fails.
2026-09-25 03:58:47 -07:00
yhh faf76e284f fix(control): widening the permission preset keeps the running turn (#10604)
Every preset change cancelled the active turn, so moving from
workspace-write to full access mid-turn threw away the work in progress.
The cancel exists so an approval granted under an older snapshot cannot
authorize work the new one forbids; a widening revokes nothing the turn
holds, so it has nothing to protect. Only a change that does not raise
permissionPresetRank cancels now, matching how background jobs are
already treated.
2026-09-25 03:45:58 -07:00
yhh abc9e69196 fix(pathidentity): tolerate volumes that do not answer case or type probes (#10654)
On an sshfs/macFUSE project root, pathconf(_PC_CASE_SENSITIVE) returns
EINVAL, and platformIdentityKey returned that error, so the workspace
could not be opened at all. An undeclared answer (EINVAL, ENOTSUP,
EOPNOTSUPP) now means case-sensitive, which keeps distinct names distinct;
other pathconf errors stay fatal. A statfs failure no longer fails the
lookup either: the volume is not known to normalize names, so NFD is
skipped.

The rules move into a platform-neutral darwinIdentityKeyBy with the
volume probes injected, so they are tested on every host; key_darwin.go
binds them to statfs(2) and pathconf(2).
2026-09-25 03:45:57 -07:00
yhh 7d9e05a587 fix(provider): root object schemas always carry a required array (#10614)
Relays that re-serialize tool schemas into typed structs turn an omitted
"required" into null, and strict upstreams then reject the whole request:
"Invalid schema for function 'get_goal': null is not of type 'array'".
get_goal's schema has no required list, and neither do many MCP tools.

CanonicalizeSchema now adds "required": [] to every root object schema
that lacks one (including the no-argument fallback), so builtin and MCP
tools reach the provider with an explicit array. The effect test checks
every tool in the real provider request.

Cache impact: the tool schema bytes change once (get_goal gains
"required": []), so ToolsHash and PrefixHash move in both golden
baselines and the first request after upgrade misses the prefix cache.
Goldens regenerated with REASONIX_UPDATE_GOLDEN=1.
2026-09-25 03:45:56 -07:00
yhh 19851e170a fix(projectiondb): bound the WAL of disk projections
Disk projections ran in WAL mode with no journal_size_limit, so a WAL
that grew while a reader pinned an old snapshot kept its peak size after
every later checkpoint: a session catalog was seen at 22 MB of WAL beside
a 0.4 MB database. The limit is per connection, so it rides the DSN
pragmas to reach every pooled connection rather than one Exec'd pragma.
The session catalog also truncates its WAL on close; the checkpoint is
best-effort because another process's reader may still hold it.

Fixes #10714
2026-09-25 03:38:18 -07:00
yhh f853f3fddb fix(repair): keep a host-mode macOS handoff live and retire unverifiable stale records
In host mode the update handoff owner is the Electron parent
(updateHandoffOwnerPID returns os.Getppid()), but the superseded-update
check only recognised os.Getpid(). The service therefore took its own
freshly prepared transaction for a stale missing-backup record, archived
it to repair/legacy-updates on shutdown, and the install helper then
failed to claim a handoff whose transaction no longer existed. The owner
is now recognised as either this process or its parent.

A record whose rollback backup still existed was never retired, and the
health commit re-hashes that backup, so a .DS_Store written by Finder
made it fail forever with a digest mismatch and pinned every later
update. Once the running version has reached the target and the health
commit cannot prove the record, it is retired and the backup moved aside
without requiring its digest. A record the health commit can still prove
is left to it.

The existing current-process handoff test wrote metadata ReadPendingUpdate
rejects, so it passed without reaching the PID check; it now records a
valid handoff.

Fixes #10732
2026-09-25 03:38:18 -07:00
yhh 7493b2814c ci(pages): deploy the site only by hand
A push to main-v2 no longer redeploys the site; the release workflows
already dispatch pages.yml when a release needs it, and anything else is a
deliberate run.
2026-09-25 02:08:41 -07:00
yhh f7f707fd8f ci(workers): deploy the 1.x workers only by hand
A push to main-v2 no longer deploys the accounts, crash-report or forum
Worker. The maintenance line takes community fixes in batches, and a merge
that touches workers/ must not reach production as a side effect; a deploy
is now a deliberate workflow_dispatch.
2026-09-25 02:05:21 -07:00
yhh a0d58429c8 test(desktop): drop the import the Young-diagram split left unused 2026-09-25 01:15:07 -07:00
yhh 348fff0686 Merge remote-tracking branch 'origin/main-v2' into maint/v1-sweep
# Conflicts:
#	.goreleaser.yaml
2026-09-24 23:47:41 -07:00
yhh 349cdaa9b3 test(agent): a repaired DeepSeek retry keeps an emptied reasoning key
#10084 sends reasoning_content on every assistant turn under the deepseek
protocol in thinking mode, because the API rejects a turn without the key
and accepts an empty one. The stale-history repair retry therefore carries
the key emptied rather than absent; asserting absence asked the retry to
reproduce the 400 it exists to recover from.
2026-09-24 23:47:24 -07:00
yhh 92d9e7df7e Merge maint/fix-D into maint/v1-sweep 2026-09-24 23:46:19 -07:00
yhh dfc825d341 Merge maint/fix-C into maint/v1-sweep 2026-09-24 23:46:18 -07:00
yhh 2846e2b655 Merge maint/fix-B into maint/v1-sweep 2026-09-24 23:46:17 -07:00
yhh 1622932506 Merge maint/fix-A into maint/v1-sweep 2026-09-24 23:46:16 -07:00
yhh 2a8f5f24dc Merge #9321: docs(hooks): replace macOS-only osascript Stop example with cross-platform command 2026-09-24 23:34:30 -07:00
yhh 6a6c5df721 fix(cli): size setup menus from the output console (follow-up to #10379)
#10379 clips every menu row to the terminal width, but reads that width
with term.GetSize on stdin's fd. On Windows the console input handle has
no screen buffer, so GetSize fails with "The handle is invalid", the
width falls back to 80, and a 50-column console still soft-wraps the
header and stacks a copy of it on every keypress.

termSize now reads os.Stdout's fd, which the menu writes to anyway, in
both selectOne and selectMany. Raw mode still applies to stdin.

Verified in a 50x24 ConPTY running `reasonix setup`: the header is
clipped with an ellipsis and the frame redraws in place.
2026-09-24 23:34:24 -07:00
yhh 8e59c55173 fix(boot): keep build within budget and the host watcher closed after shutdown (follow-up to #10636)
The PR raised boot.go's repolint budgets to fit the shared-watcher
wiring. Choosing between the caller's host-lifetime service and one this
build owns now lives in buildSkillWatchService in skill_watch.go, so
build() is shorter than before the PR; the boot.go budgets are restored
and function-size lowered to what build uses.

closeSharedSkillWatchService used to clear App.skillWatch, so a
controller build racing shutdown would create a fresh service, and a
helper process, that no later step closes. The closed service now stays
in place; a closed service hands out dead subscriptions, so such a
build keeps its initial scan and spawns nothing. A test pins that a
build after close gets the same closed service.
2026-09-24 23:34:02 -07:00
yhh 1dc4eb050a Merge #10379: fix(cli): clip setup menu rows to the terminal width 2026-09-24 23:29:18 -07:00
yhh 73b6d9b4bf fix(provider): compare endpoint overrides against base_url as URLs (follow-up to #9464)
#9464 treated a request_url equal to base_url as unset, but compared the
two as strings in three slightly different ways. An override differing
from base_url only in scheme or host case still POSTed to the API root,
and the legacy OpenAI chat_url, which editors mirror the same way, was
not covered at all.

provider.EndpointOverrideRepeatsBase now decides this once, following
the stricter comparison from #10658: scheme and host compare
case-insensitively, the path compares exactly apart from a trailing
slash, and an override carrying a query or fragment is deliberate and
always stays. The openai provider applies it to request_url and chat_url,
the anthropic provider to its root with and without /v1, and the
responses provider to its base URL.

Endpoint resolution moves out of anthropic.New and responses.New into
endpoint.go in each package, which also brings both files back inside
their repolint budgets. Tests cover the case, query, fragment and
chat_url cases per provider and the comparison itself.
2026-09-24 23:29:12 -07:00
yhh d80c856642 Merge #10636: fix(boot): the skill watcher is host-lifetime again / skill watcher 恢复 host 级生命周期 2026-09-24 23:26:51 -07:00
yhh 87d57bea71 fix(provider): give a non-streaming endpoint its own error identity (follow-up to #8870)
#8870 named the non-streaming response in the message but still wrapped
io.ErrUnexpectedEOF, so IsConnReset matched it, the stream was wrapped as
an interruption, and the transcript hint told the user the provider or
proxy dropped the connection — the opposite of the new diagnostic.

The error now wraps provider.ErrNonStreamingResponse instead. IsConnReset
refuses it, so the attempt is not replayed as a transport cut, and
explainError reads the sentinel with errors.Is to show a dedicated hint
(en, zh, zh-TW) pointing at request_url/base_url. Tests assert the
identity on the stream error and the hint chosen for it.

The diagnostic moves into nonstream.go as an sseProbe so readStream does
not grow, the thought-signature fallback becomes a chatToolCall method,
and the PR's tests move to nonstream_test.go, keeping openai.go and its
test file inside their repolint budgets.
2026-09-24 23:26:48 -07:00
yhh d613d22947 fix(desktop): clear topic status only after a manual compaction (follow-up to #9231)
CompactionDone cleared the tab's activity status for every trigger. A
manual /compact runs outside a turn, so nothing else clears it and the
reset is right; pressure, overflow and tool compactions happen inside a
running turn, and clearing there briefly marks a working tab idle.
Non-manual CompactionDone keeps the tab thinking, as before #9231, and a
test pins the automatic triggers.
2026-09-24 23:25:52 -07:00
yhh 4432e99f73 refactor: bring the files grown by #7768, #9532 and #10205 back within budget
Merged together, three verified community fixes pushed files past their
repolint size budgets. Each block moves out whole; nothing is compressed
and no baseline changes.

- chat_tui_test.go: #7768's working-line wrap tests move to
  chat_tui_working_line_test.go.
- math-golden.test.ts: the Young-diagram cases move to
  math-young-diagrams.test.ts. The runner discovers it, and 242 + 28 cases
  pass, the 270 the file held before.
- RemoteConnectWizard.tsx: formatBytes and parentOf move to
  remoteWizardFormat.ts. Typecheck and the wizard suites (88 and 28) pass.
2026-09-24 23:23:58 -07:00
yhh d9dc6b22a3 Merge #9231: fix(desktop): reset topic to idle on CompactionDone so /compact stops spinning — #9230 2026-09-24 23:23:25 -07:00
yhh 3ec96c1c64 fix(cli): let resolveSetupTargets keep its doc comment (follow-up to #9687)
setupUsage was inserted between resolveSetupTargets' doc comment and its
func, so the comment attached to setupUsage instead. setupUsage now sits
above the comment; no behaviour change.
2026-09-24 23:23:20 -07:00
yhh d7b79dcb55 Merge #9687: fix(cli): stop reasonix setup from turning flags into config files 2026-09-24 23:22:27 -07:00
yhh 0fc440e453 fix(remote): keep the strings import bootstrap/launch.go still uses (follow-up to #8158)
The PR dropped "strings" from bootstrap/launch.go when shellQuote moved
to remote.ShellQuote, but launch.go uses strings elsewhere on this
branch, so the merged tree did not compile. The import is restored.

The PR also reflowed the passphrase-KDF comment in client_test.go with
no change in meaning; that hunk is reverted to keep the diff to the fix.
2026-09-24 23:22:21 -07:00
yhh de25f8cae5 Merge #8158: fix(remote): feed scripts to sh via stdin instead of a parsed exec string 2026-09-24 23:21:33 -07:00
yhh 9cb87b54d1 fix(fileref): keep NaturalLess in a utility leaf its callers may import (follow-up to #6084)
internal/fileref is a utility-layer package and may not import
internal/textutil, so the comparator moves into fileref itself; desktop,
cli and control already import fileref, and there is one copy.

internal/cli/complete.go went over its size budget with the new sort
closure. The @-reference completion block (activeAtToken, file and MCP
resource items, path splitting) moves to complete_at.go, and the file's
file-size budget is lowered to what it now uses.
2026-09-24 23:21:26 -07:00
yhh c66b0e2381 fix(release): escape Homebrew's staged_path in the cask template (follow-up to #10270)
GoReleaser renders the whole cask, custom_block included, as a Go
template, so the bare {{staged_path}} that Homebrew's postflight_steps DSL
expects was parsed as a GoReleaser function and the release failed with
'function "staged_path" not defined'. Emit it as the string literal
{{ "{{staged_path}}" }}, the same escape GoReleaser uses for desc. With
goreleaser v2.18.2 a snapshot release now succeeds and the rendered cask
carries run "/usr/bin/xattr", args: [..., "{{staged_path}}/reasonix"].
2026-09-24 23:17:27 -07:00
yhh 67fc25f12e fix(bot/feishu): replace the operator-id narrative comments (follow-up to #7928)
The comments added with the open_id-first ordering narrated the incident
in Chinese, and the one in feishu.go pushed the file past its size budget.
The code comment is removed, since even one line keeps the file over its
budget; the constraint (allowlists record open_id, and card callbacks must
resolve like handleSDKMessage) now lives in English on the regression test
that pins the order. No behaviour change.
2026-09-24 23:15:00 -07:00
yhh c5249f81c1 Merge #7928: fix(bot/feishu): prefer open_id when resolving card action operator 2026-09-24 23:13:02 -07:00
yhh 8939d9dc50 fix(capability): cut the live-catalog comment to budget (follow-up to #9707)
The comment explaining why registry presence only suppresses per-tool
duplicates ran to five lines, over the three-line comment budget. It now
states the same constraint in three. No behaviour change.
2026-09-24 23:13:02 -07:00
yhh 36ab575f88 Merge #9707: fix: Merge live proxy-observed MCP tools into the capability catalog 2026-09-24 23:12:28 -07:00
yhh 0dae397e06 fix(agent): move skill argument normalization into its own file (follow-up to #8553)
The normalization helpers and their two tests pushed usecapability.go and
usecapability_test.go past their file-size budgets. They move unchanged
into usecapability_skillargs.go and usecapability_skillargs_test.go, one
concern per file. No behaviour change.
2026-09-24 23:12:27 -07:00
yhh ddea37af36 Merge #10270: fix(cask): emit valid postflight_steps DSL in custom_block 2026-09-24 23:12:26 -07:00
yhh 48d079ee02 fix(boot): keep the managed-config comment within budget (follow-up to #9770)
The comment on managedConfig grew by a line to describe the new rule, and
the PR raised the repolint baseline (essay, file-size, function-size for
boot.go and the tree totals) to make room for it. The baseline is not
widened to land a change, so restore it and state the same rule in the
original four lines. The fail-closed behaviour and its tests are unchanged.
2026-09-24 23:12:07 -07:00
yhh 7e33336f1b Merge #8553: fix(agent): normalize skill capability arguments 2026-09-24 23:11:29 -07:00
yhh 39ac17c95a fix(config): keep legacy DeepSeek members with different efforts apart (follow-up to #8365)
#8365 drops effort from the provider-wide projection so a level /effort
wrote to the canonical member no longer blocks canonicalization. The same
projection also compares two legacy members with each other, and there
dropping effort let deepseek-flash and deepseek-pro with different levels
merge silently: officialProviderFromLegacy starts from the first member,
so the other member's level was discarded.

Legacy members are now compared on effort directly, so differing levels
keep the family uncanonicalized exactly as before #8365, and each member
keeps what the user chose. Canonical-vs-legacy comparison still ignores
effort. The projection comment is cut to three lines and the regression
test's doc states the constraint rather than the incident.
2026-09-24 23:11:23 -07:00
YHH 836f571a19 Merge pull request #10756 from nanami-0713/fix/billing-holiday-off-peak
fix(billing): bill Chinese public holidays as DeepSeek off-peak (main-v2)
2026-09-24 23:09:22 -07:00
yhh ae1a7b3613 Merge #8365: fix(config): stop per-provider effort from breaking DeepSeek family canonicalization 2026-09-24 23:09:20 -07:00
yhh a233e8b13f fix(config): move RemovePluginModelRefs into its own file (follow-up to #8198)
RemovePluginModelRefs pushed edit.go past its file-size budget and its doc
comment, like the two in repairPluginModelRefs, ran past the comment budget.
The method moves unchanged into plugin_model_refs.go and the three comments
are cut to state the constraint only. No behaviour change.
2026-09-24 23:09:12 -07:00
yhh dca5be6339 Merge #6084: fix: use natural sort for file/directory name ordering 2026-09-24 23:06:58 -07:00
yhh 7e5643d380 Merge #8198: fix(config): repoint model refs when a plugin package is removed 2026-09-24 23:06:50 -07:00
yhh 67dcfbe8c5 Merge #8870: fix(provider): diagnose non-streaming responses instead of bare unexpected EOF 2026-09-24 23:06:46 -07:00
yhh d67b34197b Merge #9770: fix(tools): always gate managed config writes behind fresh approval 2026-09-24 23:06:41 -07:00
yhh 467fe5f397 Merge #10736: fix(desktop): dedicated macOS tray template icon (black-block menu bar glyph) 2026-09-24 23:04:36 -07:00
yhh b5778dc0d5 Merge #10419: fix(capability): route skills through use_capability, not retired connect_tool_source / 让技能改由 use_capability 路由,不再使用已退役的 connect_tool_source 2026-09-24 23:04:33 -07:00
yhh 0ddd2258fd Merge #10301: fix(cli): stop the headless run dropping a reasoning-only answer / headless 运行不再丢弃只有推理的回答 2026-09-24 23:04:29 -07:00
yhh 5561a71506 Merge #10298: test(worktree): stop the hook tests overwriting a real git hook / 钩子测试不再覆盖开发者的全局 git 钩子 2026-09-24 23:04:25 -07:00
yhh 2c2624afd4 Merge #10245: fix(agent): detect a prompt the provider silently truncated / 识别被服务端静默截断的提示词 2026-09-24 23:04:21 -07:00
yhh 6b82884553 Merge #10205: fix(desktop): restore remote wizard choice states / 修复远程向导选中态 2026-09-24 23:04:16 -07:00
yhh 712c55cea4 Merge #10084: fix(provider): replay reasoning_content on plain DeepSeek turns, fixes repeated Deepseek 400 errors / 修复 DeepSeek 思考模式重复 400 错误 2026-09-24 23:04:12 -07:00
yhh b067ba4dce Merge #9704: fix(cli): chooser number keys reach the Type-something and Chat-instead rows / 修复 ask 选择器数字键无法选中「输入其他内容」与「直接聊天」行 2026-09-24 23:04:08 -07:00
yhh 5d8b523fc4 Merge #9532: fix(desktop): pure-number inline math renders by default; currency dollars escaped at source / 纯数字行内公式默认渲染,货币美元在源码层转义 2026-09-24 23:04:04 -07:00
yhh fd42507567 Merge #9464: fix(provider): ignore request_url that merely repeats base_url 2026-09-24 23:04:00 -07:00
yhh 22a2dc7d88 Merge #9157: fix(desktop): update budgetWhyOverflow copy to match current numeric-card UI / 修正压缩提示文案引用 2026-09-24 23:03:56 -07:00
yhh 6dcd887cc9 Merge #8649: fix(cli): hide API keys during setup input 2026-09-24 23:03:51 -07:00
yhh d41c814b19 Merge #8646: fix(desktop): preserve Unicode filenames in commit details / 修复提交详情中文文件名显示 2026-09-24 23:03:47 -07:00
yhh 80edc419ca Merge #8504: fix(desktop): keep an explicit [bot] enabled = false through a QR scan / 扫码不再覆盖用户显式设置的 [bot] enabled = false 2026-09-24 23:03:44 -07:00
yhh 123898c212 Merge #8366: fix(skill): stop registering plugin documentation markdown as skills 2026-09-24 23:03:39 -07:00
yhh 2cb6c5c5d8 Merge #8344: fix(memory): archive every duplicate of an ID, not just the first 2026-09-24 23:03:35 -07:00
yhh 61aa6aeae9 Merge #8319: fix(memory): surface read errors in quick-add instead of overwriting 2026-09-24 23:03:31 -07:00
yhh 4ba2fde3d1 Merge #8204: fix(plugin): fail closed when confined MCP loses its sandbox backend 2026-09-24 23:03:27 -07:00
yhh 16357a35f7 Merge #8168: fix(config): bound legacy keyring lookup so serve cannot hang at startup (#8129) 2026-09-24 23:03:23 -07:00
yhh 70079caeb5 Merge #7992: fix(installsource): reject oversized remote manifests 2026-09-24 23:03:19 -07:00
yhh 0d01043252 Merge #7768: fix(cli): account for wrapped working line in composer cursor offset 2026-09-24 23:03:15 -07:00
yhh 222a5f093d Merge #7767: fix(site): exclude auth pages from sitemap and add noindex 2026-09-24 23:03:11 -07:00
yhh e2258dae7b Merge #7697: fix(npm): validate optionalDependencies match build targets 2026-09-24 23:03:07 -07:00
yhh e5e2e191aa Merge #7673: fix(scripts): balance semver regex groups in release scripts 2026-09-24 23:03:03 -07:00
yhh 85b54b3118 Merge #6269: fix(serve): make rewind checkpoint click go directly to scope selection 2026-09-24 23:02:59 -07:00
YHH 1aa4a1fb31 chore(release): the 1.x line stops publishing the CLI to npm, Homebrew, and the CLI pointers (#10757)
The Studio line now owns the CLI install channels: npm `reasonix` with its
`@reasonix/cli-*` platform packages, the esengine/homebrew-reasonix cask, and
the R2 CLI update pointers (`cli/{stable,preview}/latest.json`) that
`reasonix upgrade` and the site read through the release gateway. A 1.x
release must never write any of them again.

- release-npm.yml is removed, and release-promote/release-stable no longer
  call it or wait on it; the legacy recovery input publish_npm goes with it.
- release.yml no longer hands GoReleaser a tap token, no longer pushes the
  prepared cask, no longer advances the CLI channel pointers, and no longer
  requires npm `latest` to have moved. It still creates the vX.Y.Z GitHub
  release, its notes, the immutable cli/releases/<tag>/latest.json record,
  and the Desktop compatibility asset.
- .goreleaser.yaml drops homebrew_casks.
- verify-stable-release-artifacts.sh and the publication ledger stop
  requiring npm package identities, npm dist-tags, and the cask version.
- Publisher helpers with no remaining caller are removed:
  publish-homebrew-cask, finalize-npm-official-release, resolve-npm-release,
  npm/publish-candidate, decide-cli-pointer-update, compare-cli-release-tags.
- release-workflows.test.sh now fails if any workflow holds NPM_TOKEN or
  HOMEBREW_TAP_TOKEN, names the tap, runs npm publish or dist-tag, or
  writes a CLI channel pointer.

Candidate preparation, tags (including npm-vX.Y.Z), Desktop publication, the
Stable manifest, Pages, and the release-event record are unchanged.
2026-09-24 22:55:29 -07:00
yhh bf8f55b7d2 Merge commit 'refs/pr/10756' into maint/v1-sweep 2026-09-24 22:47:56 -07:00
nanami-0713 224b55eb73 fix(billing): bill Chinese public holidays as DeepSeek off-peak
DeepSeek's pricing page defines peak as weekday windows "excluding
Chinese public holidays", but DeepSeekRateBand only knew weekends, so
holiday weekdays (e.g. National Day Oct 1) kept billing at peak rates
(#10724).

Add the 2026 statutory-holiday date table from the State Council's
notice (evaluated on the Beijing-local date) ahead of the window check.
Make-up workdays stay off-peak: they are calendar weekends and the same
page keeps weekends off-peak in full. The table needs a yearly extension
when the next notice publishes (~Nov 2026 for 2027); noted in a comment.
2026-09-25 13:34:45 +08:00
yhh 8d8ed17cba fix(sysproxy): honour WinINET bypass wildcards and the implicit loopback bypass
bypassed() matched only exact entries and a leading "*". The bypass list
Clash, v2rayN and xray write by default is `<local>;localhost;127.*;10.*;
...;192.168.*`, so every trailing-wildcard entry missed. It also lacked
WinINET's implicit rule that loopback goes direct unless `<-loopback>` is
listed.

In the default auto mode, on Windows with such a system proxy, requests
to local and LAN model endpoints went through the proxy: Ollama or LM
Studio on 127.0.0.1, vLLM on 192.168.x. That added a hop, failed outright
when the proxy routed to a remote exit, and made a cancelled stream keep
the local model generating. Cancel closed only the client-to-proxy hop,
and the proxy held its upstream connection open.

This is why TestStreamCancelDoesNotReconnect hung on a developer machine
with a system proxy. CI's Windows runners have no system proxy, and the
package is not in the PR smoke set.

"*" is now a wildcard anywhere in an entry. Only "*" is special, and
brackets are trimmed from IPv6 entries. localhost, 127/8 and ::1 bypass
unless `<-loopback>` is present. TestBypassed gains the trailing-wildcard,
near-miss (192.169.* vs 192.168.*, 110.* vs 10.*), mid-pattern and
loopback cases. It fails on 8 of them without the fix.
2026-09-24 22:31:35 -07:00
yhh f9883482dc fix(serve, weixin): an HTTP client names no migration source; a login id names one file
Two weaknesses found while triaging the open CodeQL alerts on main-v2,
none of which they flagged:

- POST /submit {"input":"/migrate --from <dir>"} read and imported
  sessions from any host directory the client named, and told it whether
  the path existed. The HTTP entry now refuses a /migrate or /migration
  that names a source, next to its refusal of `!` shell input. A plain
  /migrate still works there; the terminal UI and desktop keep --from.
- The Weixin bot saved its token under the ilink_bot_id from the upstream
  login response. A malicious or intercepted upstream could place the file
  outside the accounts directory. The id must now be a single local file
  name.

Each has a test that fails without its fix.

The 196 open alerts themselves are false positives: session ids are
validated by SessionRef.validate, legacy transcript paths are confined by
resolveSessionPath, the model-settings probe is pinned to loopback, and
proc.Command runs argv without a shell.
2026-09-24 22:12:33 -07:00
yhh db8f28dca0 fix(workers): lift hono, vitest and sharp past their advisories
Dependabot flags the three Cloudflare Workers:
- hono <4.13.5 (medium, three advisories): a runtime dependency of every
  deployed worker.
- sharp <0.35.4 (high): dev-only, reached through wrangler's miniflare.
- vitest / @vitest/mocker <4.1.11 (medium): dev-only.

The package.json ranges already allowed the fixed versions; the lockfiles
held the old ones. hono moves to 4.13.9 and wrangler to 4.139.0, which
brings sharp 0.35.4. pnpm/npm audit reports no known vulnerabilities for
accounts, forum and crash-report, and each passes typecheck, its vitest
suite and `wrangler deploy --dry-run`.
2026-09-24 21:30:36 -07:00
XTLine afb8b9555e fix(desktop): retain canonical sidebar route 2026-09-25 11:57:51 +08:00
XTLine ba163f2c79 test(desktop): gate remote switch phases 2026-09-25 11:55:16 +08:00
XTLine 35cb5ad9b3 fix(desktop): open local sessions after remote 2026-09-25 11:04:12 +08:00
XTLine 724be574de fix(desktop): restore local navigation from remote 2026-09-25 10:50:44 +08:00
Stephen R. van den Berg 9f0da63ac7 fix(capability): route skills through use_capability, not retired connect_tool_source
The capability-route block instructed the model to call connect_tool_source
for the skills source, but that tool is retired and registered nowhere. In
production the routing catalog was built from ToolContractEntries()
(provider-visible only), so SkillEntries always saw skillToolReady == false
and marked every skill StatusConfigured with ConnectSource "skills",
yielding "source:skills ... first call connect_tool_source with source=skills".

Build the catalog from AllToolContractEntries() so hidden optional tools
(run_skill/read_skill/read_only_skill) count toward readiness, and make
RenderTransientBlock treat ClosedLoop||CapabilityProxy as "the proxy is the
only connector": never rewrite to source: or emit connect_tool_source, and
route a configured skill through use_capability with its concrete id.
2026-09-25 01:51:16 +02:00
Stephen R. van den Berg 0a0b69bea3 chore(repolint): regenerate baseline for rebase onto synced base 2026-09-25 01:50:04 +02:00
Stephen R. van den Berg 8639383c1f fix(tools): always gate managed config writes behind fresh approval
confineWrite routed a managed config file (config.toml, legacy v0.x
config.json) to the per-write fresh human approval only when the target sat
outside the write roots; a root widened to cover the Reasonix home made the
same files plain allowed writes, silently editable in YOLO. Those files
configure providers, sandbox rules, and permissions for future sessions, so
no posture and no widened root may write them unattended: check
managed.Match(target) before root confinement and always consult the
approver. The fail-closed message no longer points at allow_write, which no
longer authorizes config edits.
2026-09-25 01:50:04 +02:00
Stephen R. van den Berg e13852d050 fix(provider): replay reasoning_content key on plain DeepSeek thinking turns
DeepSeek rejects an assistant history turn in thinking mode when the
reasoning_content key is absent, plain text turns included. The builder
gated the key on hasReasoningOrToolCall, so a plain turn with no
reasoning and no tool call serialized without it and the request 400'd
("The reasoning_content in the thinking mode must be passed back to the
API").

Emit the key on every assistant turn under the deepseek reasoning
protocol in thinking mode, an empty string included. The contract hinges
on reasoning_protocol = "deepseek", not generic thinking: other
protocols (GLM, Kimi K3, generic thinking) keep the narrower tool-call
guard, and thinking-disabled DeepSeek keeps its existing guard.
2026-09-25 01:49:45 +02:00
Stephen R. van den Berg dbb7478821 fix(cli): chooser number keys reach the Type-something and Chat-instead rows
The ask-tool chooser renders len(options) real options followed by the
'Type something' and 'Chat about this' rows, and numbers every row — but the
number-key handler only mapped digits onto real options, so the last two
rows were unreachable by digit and silently ignored. Extend the mapping to
rowCount() so a digit on those rows does what Enter does: open free-text
entry, or dismiss the prompt to chat.
2026-09-25 01:49:11 +02:00
github-actions[bot]andSivanCola 6845b6de3c docs(release): Prepare v1.39.0 notes / 准备 v1.39.0 更新日志 (#10742)
* docs(release): prepare v1.39.0 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): clarify v1.39.0 provider failure behavior

Problem: The generated notes imply every provider failure returns immediately, but semantic protocol repair may still make a bounded follow-up request.
Root cause: The draft described HTTP retry removal too broadly.
Fix: Scope the claim to ordinary HTTP and network failures in both languages.
Verification: Release catalog validation and all release-notes tests pass.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: SivanCola <[email protected]>
2026-09-24 23:58:13 +08:00
F 81054f7ec2 chore(repolint): raise the internal/boot/boot.go budgets for the host skill-watch wiring / 为 host skill-watch 接线调高 boot.go 预算 2026-09-24 23:24:17 +08:00
F ce87ad4653 Merge origin/main-v2 into fix/skill-watch-host-lifetime / 合并上游并解 desktop/app.go 字段冲突 2026-09-24 23:22:04 +08:00
nanami-0713 4e35f893f9 fix(desktop): ship a dedicated macOS tray template icon
The macOS tray template-ized the full-canvas color appicon, so the menu
bar showed a solid black rounded block instead of the logo (#10715).

- Add build/trayTemplate.png + @2x: black/alpha-only silhouette of the
  logo path from build/appicon.svg, authored at 18px/36px with even
  padding; macOS paints template images from the alpha channel.
- icons.ts: darwin tray candidates prefer the template asset.
- tray.ts: recognize macOS template naming ([email protected]), attach the
  @2x sibling as a retina representation, and never setTemplateImage a
  non-template fallback (small color icon instead of a black block).
- packaging: stage the template pair into resources/icons and require
  them in the artifact member manifests.
2026-09-24 20:40:57 +08:00
SivanCola 438566dd6c fix: recover blocked conversation replay (#10731)
Problem: invalid historical tool arguments could permanently block newly created or resumed conversations at the transcript gate. Missing historical images and optional invalid extension replacements could similarly prevent later requests.

Root cause: runtime rebuilds consumed provider projections that had discarded host-recorded refusal evidence. Recovery and extension validation occurred too late or used cached failures.

Fix: preserve refusal evidence by stable result identity; archive unreplayable calls as quoted historical observations; recover unavailable past image inputs; validate optional extension replacements before adoption; preserve the latest authored request when abbreviating oversized tool results; and provide localized recovery guidance. Canonical session history and healthy provider request bytes remain intact.

CI follow-ups: make configuration-error injection portable and register catalog cleanup per App generation after its temporary directory is created, preventing open SQLite handles during Windows cleanup while preserving all behavioral assertions.

Verification: full local Go suite, go vet, full golangci-lint, focused race regressions, prompt-cache guards, and the required PR CI checks pass. Synthetic tests cover fresh-session resets, cold recovery, repeated identities, unknown execution outcomes, image loss, extension policies, protected-tail truncation, and cross-platform configuration retry. No CI gates were removed or weakened.
2026-09-24 17:58:08 +08:00
SivanCola 47087375ac Fail fast on provider errors and localize error messages / 取消供应商自动重试并统一错误提示语言 (#10730)
Problem: provider failures keep turns running through nested retries and long
waits, and raw exceptions bypass the selected Desktop UI language.

Root cause: HTTP, agent sampling and auxiliary requests own overlapping retry
loops; independent UI surfaces render backend exceptions directly. Broad
net.Error matching also misclassifies request and filesystem failures.

Fix: return transport and upstream failures directly while preserving causes,
partial output, usage accounting and bounded protocol/context corrections.
Centralize locale-aware summaries and expandable original diagnostics. Update
legacy assertions and fixture provider hierarchies without removing functional
coverage or disabling CI workflows.

Verification: root Go suite, focused Desktop checks, all 521 frontend CI
commands, production builds and budgets, lint, browser error interactions, and
the latest PR CI including cross-platform, race and memory checks passed.

This merges the fix into main-v2; it does not publish a release.
2026-09-24 17:53:14 +08:00
SivanCola 675b5741f5 fix(desktop): prevent archived sessions from resurfacing (#10728)
Problem: Purged archived sessions could reappear as legacy filename rows, and late runtime snapshots could republish removed tabs.

Root cause: Head observations were cached against the transcript checkpoint instead of the independently published index. Missing indices manufactured path-only identities, lazy pages and lifecycle receipts used different source keys, and binding validation ended before serialized runtime publication.

Fix: Validate cached head indices against their own file and log, suppress unidentified rows for mapped sources, use the head-aware adapter when flat identity is ambiguous, include lazy source aliases in receipts, and revalidate bindings at publication.

Verification: Focused archive/purge/fork and late-publication regressions, Desktop Go race tests and broad lifecycle tests, Desktop build, golangci-lint, repolint, and documentation-impact check.
2026-09-24 16:12:10 +08:00
SivanCola b12145f9d6 fix: keep session titlebar rename scoped to session (#10722)
Problem: Long session names consumed the titlebar, and renaming a mock session could change the project folder or leave the visible title stale.

Root cause: Unsaved local tabs omitted their topic identity from the rename selector; the mock resolver accepted an empty target and the mock rename did not synchronize tab metadata or notify tree subscribers.

Fix: Cap displayed title width, require a concrete session target, pass the topic identity, and update the mock tree and tab title together for manual and AI renames.

Verification: Project/topic lifecycle regression, frontend typecheck, ESLint, CSS syntax check, and browser rename interaction.
2026-09-24 12:16:33 +08:00
SivanCola 6448d8c985 fix(transcript): reconcile queued guidance by message ID / 修复队列引导重复记录 (#10723)
* fix(transcript): reconcile queued guidance by message identity

Problem: A queued guidance insertion could appear twice when its live event and durable history row reached the Desktop in different orders. Unapplied guidance warnings had the same duplicate-row failure.

Root cause: Live receipts and canonical records used unrelated display identities, while some legacy history rows omitted the owning message ID.

Fix: Assign one message ID to each guidance insertion, retain legacy record addresses, normalize older history identities, and merge event and canonical rows by identity. Preserve inbox consumption receipts independently of display and cover applied and unapplied guidance in local, remote, and browser paths.

Verification: Focused Go and frontend regressions, root Go suite, frontend transcript suite, Chromium DOM transcript workload, typecheck, and repolint passed.

* test(frontend): typecheck steer transcript regressions

Problem: The new transcript regressions executed successfully but their fixture types failed the repository test-typecheck gate.

Root cause: Inferred literal state types were narrower than reducer results, while broad item and history interfaces hid the notice-specific fields required by assertions.

Fix: Type the reducer state at its public boundary and narrow notice items explicitly without changing the behavioral assertions.

Verification: pnpm test:typecheck, canonical user display test, and steer transcript test passed.
2026-09-24 12:08:32 +08:00
Adalberto Pereira 076802e0ad fix(cli): stop the headless run dropping a reasoning-only answer
A thinking model can finish a turn with non-empty reasoning and an empty
visible message. That shape is accepted on purpose, and the transcript renderer
handles it: a "thinking" marker always, the reasoning itself under
--show-thinking.

The headless sinks have neither. `runOutputSink.Emit` kept only `e.Text`, so a
reasoning-only turn left `-p` printing nothing at all and `--output-format json`
reporting `result: ""` beside `subtype: "success"`, `is_error: false` and exit
0. A caller cannot tell that apart from a turn that produced nothing, and the
run looks successful either way.

Measured against a local provider: the turn's whole output was a malformed tool
call the model wrote as prose in the reasoning channel instead of emitting as a
tool call. 84 characters, the only thing the turn produced, dropped on the
floor - the automation contract said success with an empty result.

`result` now falls back to the turn's reasoning when the visible message is
empty, which is the remedy this repository already applies elsewhere for the
same provider shape. `result_from_reasoning` reports the substitution, so the
reasoning is not passed off as visible text the model never emitted; it is
additive and omitted whenever the model answered normally.

The answer and its provenance are one value rather than two fields, because
they are only ever correct together: a stale fromReasoning beside a fresh
answer would misreport where a later visible answer came from. repolint asked
for the same grouping independently, a thirteenth scalar on runOutputSink being
over its ceiling.

Nothing here reclassifies a reasoning-only turn as an error - that shape stays
accepted, and the Plan/Goal side of it is #8023's. --show-thinking is not the
lever either: buildRunSink never passes it to this sink, so gating the fallback
on it would leave `-p` printing nothing exactly as before.
2026-09-23 23:48:47 -03:00
SivanCola 62862f48a6 fix: verify release site with standard browser identity (#10712) 2026-09-24 06:46:23 +08:00
SivanCola 592b0b637a chore(release): probe Chromium profiles for site verification (#10711)
* chore(release): probe Chromium profiles for site verification

* chore(release): keep browser probe to standard request profiles
2026-09-24 06:39:10 +08:00
SivanCola 178b44cc50 fix(release): verify public site with updater HTTP transport (#10710) 2026-09-24 06:33:56 +08:00
SivanCola b8b3e877e2 chore(release): probe public site transports on Actions (#10709) 2026-09-24 06:24:00 +08:00
SivanCola b19562d8da fix(release): verify sealed signing policy during recovery (#10708) 2026-09-24 05:48:52 +08:00
SivanCola c59d0021ed fix(release): reuse Desktop artifacts from earlier candidate attempt (#10707) 2026-09-24 05:25:44 +08:00
SivanCola d728bdab0e fix(release): fetch public Stable manifest with updater Go client (#10706)
Problem: v1.38.12 candidate preparation is blocked by Cloudflare challenges against curl, even with the updater User-Agent.

Root cause: Cloudflare scores the transport as well as the header; protected Actions probes repeatedly returned HTTP 200 for the shipped Go updater client and 403 for curl/Node profiles.

Fix: Reuse the Go updater HTTP request identity for every Stable manifest gate and postflight, with explicit Go setup, bounded response size, HTTP/challenge rejection, and JSON version validation.

Verification: three protected Go probes returned HTTP 200 for v1.38.12; focused Go and shell fixtures, release control contracts, release workflow contracts, and local public preflight pass.
2026-09-23 19:53:50 +00:00
SivanCola 8ea7439cb6 fix(release): diagnose manifest access with Go updater client (#10705)
Problem: Candidate preflight still receives a Cloudflare challenge after adding the updater User-Agent to curl, while Node fetch with an updater User-Agent succeeds.

Root cause: User-Agent alone does not establish whether the shipped Go HTTP client can reach the public endpoint; client transport behavior may also matter.

Fix: Add read-only protected probes for the current and target Stable updater using Go net/http and Node fetch. Do not expose the Cloudflare token to the Go probe.

Verification: Go probes return HTTP 200 locally; gofmt, node --check, and git diff --check pass.
2026-09-23 19:40:18 +00:00
SivanCola cebd689b6f fix(release): verify public manifest with updater request identity (#10704)
Problem: GitHub runners receive Cloudflare challenge 403 for the public Stable manifest when the release gate uses curl default headers, blocking candidate preparation although the Desktop updater can read the same endpoint.

Root cause: Public-access checks and postflight used a generic request identity that Cloudflare classifies differently from the shipped updater.

Fix: Centralize manifest requests through a versioned Stable updater User-Agent across candidate, publication, site recovery, and public verification. Keep HTTP and JSON validation fail-closed.

Verification: release control contracts and site ownership fixtures pass; three protected Actions diagnostics showed default 403 and updater 200.
2026-09-23 19:26:37 +00:00
SivanCola b88f56d063 fix(release): inspect download configuration rules / 检查下载路径配置规则 (#10703)
* fix(release): inspect Cloudflare config rules for download access

Problem: GitHub runners receive a Cloudflare challenge for the Stable manifest, and the existing diagnostic does not inspect path-scoped configuration rules.

Root cause: The read-only diagnostic omits the http_config_settings phase, so it cannot report whether Browser Integrity Check is already overridden for the download path.

Fix: Include the configuration settings phase in the protected ruleset inspection.

Verification: git diff --check and node --check scripts/inspect-cloudflare-download-access.mjs.

* fix(release): probe the actual updater request profile

Problem: The public manifest diagnostic uses only Node default headers, while the Desktop updater sends a distinct User-Agent to avoid edge bot challenges.

Root cause: A 403 for the diagnostic client alone cannot distinguish runner-wide blocking from User-Agent scoring.

Fix: Report a second read-only probe using the current Stable updater request profile.

Verification: git diff --check and node --check scripts/inspect-cloudflare-download-access.mjs.
2026-09-23 19:19:26 +00:00
SivanCola 3a0a3d9136 fix(release): classify Cloudflare manifest access diagnostics (#10702)
Problem: the protected release candidate is blocked by a Cloudflare challenge, while the existing Actions diagnostic collapses GraphQL failures into a generic error. Root cause: the query includes fields and filters without a verified schema contract and the diagnostic hides whether failure is schema or permission. Fix: query documented fields over a short window, filter only the public manifest in memory, and report allowlisted error classes without visitor or credential data. Verification: node syntax check, repository lint, and staged diff check passed.
2026-09-23 18:55:52 +00:00
github-actions[bot]andSivanCola 6b9b7abbaf docs(release): prepare v1.38.12 notes / 准备 v1.38.12 中英更新日志 (#10701)
* docs(release): prepare v1.38.12 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): review complete v1.38.12 release scope

Review the full first-parent range from v1.38.11, including direct sidebar merges, model catalog updates, Windows compatibility, browser runtime changes, and manual compaction behavior. Preserve existing model defaults and document the cache effect of explicit compaction.

Verification: release catalog validation, release-notes tests, guide target checks, privacy review, and git diff --check passed.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: SivanCola <[email protected]>
2026-09-23 18:31:58 +00:00
SivanCola ab15e65955 Merge refreshed Desktop migration inventory
Problem: full main-v2 push tests and coverage failed after the sidebar repair because the generated Desktop migration inventory was stale. Root cause: workspace-removal extraction shifted source locations and GetProjectTreeSnapshot gained an error result. Fix: regenerate the Markdown and JSON inventory without changing runtime code or weakening the gate. Verification: inventory tests, repository lint, PR CI, and diff checks passed.
2026-09-24 02:09:30 +08:00
SivanCola 2ee6daabac fix: refresh desktop migration inventory after sidebar repair
Problem: the main-v2 push CI failed root tests and coverage because the generated Desktop migration inventory was stale after the sidebar repair merged.

Root cause: the inventory records source locations and Desktop RPC signatures; extracting workspace removal moved methods, and GetProjectTreeSnapshot now reports read errors.

Fix: regenerate both inventory artifacts from the current main-v2 source without changing runtime code or weakening the inventory gate.

Verification: go test ./tools/desktopinventory, go run ./tools/repolint, and git diff --check passed.
2026-09-24 02:06:54 +08:00
SivanCola 7fde3413f9 Merge project sidebar and session navigation consistency
Problem: project folders and conversations could disappear, duplicate, or fail canonical navigation after workspace removal. Root cause: workspace and session identity transitions were not reconciled consistently across backend and mounted sidebar. Fix: serialize and reuse workspace conversations, restore explicit visibility, fence stale sidebar reads, and preserve native-history navigation. Verification: full PR CI passed across supported runners, plus focused race, frontend, and native Windows ARM64 package checks.
2026-09-24 01:59:59 +08:00
SivanCola ae298e40f0 Merge current main-v2 and isolate workspace removal ownership
Problem: recent historical-session and creation changes advanced main-v2 while this sidebar fix was in flight, and the removal implementation exceeded repository complexity budgets.

Root cause: the generated Desktop contract contained independent additions, and removal snapshot, durable bookkeeping, and runtime unlinking lived in one oversized function.

Fix: regenerate the combined contract; keep both upstream changes; extract removal phases into owning helpers and move focused regressions into dedicated test files.

Verification: focused Desktop workspace/navigation tests, frontend sidebar tests, production frontend checks, repolint, and diff checks passed on the integrated tree.
2026-09-24 01:40:09 +08:00
SivanCola c62fe5c630 fix: notify mounted sidebar when readding hidden workspace
Problem: a re-added folder was visible in the registry snapshot but remained absent from an already-mounted Windows sidebar.

Root cause: selecting an existing topic after restoring workspace visibility emitted no project-tree change notification.

Fix: publish the completed visibility change after successful workspace activation.

Verification: failing notification regression is green; Windows package reproduction established the mounted renderer symptom.
2026-09-24 01:23:54 +08:00
SivanCola 7b4e16f948 fix: make workspace selection and sidebar reconciliation consistent
Problem: folder addition could disappear, reopen could allocate extra conversations, and removal failures could leave visibility and runtime bindings inconsistent.

Root cause: workspace selection created topics unconditionally, refresh retries repeated successful opens, unordered shell reads mixed registry membership with catalog revisions, and removal crossed persistence and runtime boundaries without admission fencing.

Fix: reuse existing conversations under serialized navigation; restore explicit re-add visibility; reserve removal and roll back failed bookkeeping; retain completed blank-project opens across refresh retry; reject stale shell responses using a separate workspace generation and fail registry reads explicitly.

Verification: repeated/concurrent/restart opens, removal rollback and admission, mounted shell ordering, registry integrity, project-tree and canonical navigation regressions, focused race tests, frontend typecheck/build and sidebar browser regression passed.
2026-09-24 01:02:49 +08:00
SivanCola d9be6b56ee fix: reconcile workspace placeholders by owned session aliases
Problem: a placeholder and its live or canonical projection could appear as two new conversations.

Root cause: projection ownership was missing for pathless tabs and first durable sessions.

Fix: publish exact placeholder aliases and cache workspace alias maps; keep canonical branches with shared topics distinct.

Verification: placeholder identity, branch ambiguity, runtime projection and organization compatibility regressions passed.
2026-09-24 01:02:04 +08:00
SivanCola 91767d9504 fix: allow canonical navigation from native history runtimes
Problem: canonical navigation from native history reported an unavailable identity protocol, including after workspace removal.

Root cause: the source runtime was required to carry a reusable canonical binding.

Fix: route supported native controllers through prepare-and-swap while retaining strict checks for unsupported runtimes.

Verification: real-controller removal, no-removal and cancelled replacement tests; canonical navigation and focused race tests passed.
2026-09-24 01:01:49 +08:00
SivanCola c0654a4a5e Simplify new-session creation and recovery / 简化新建会话与失败恢复 (#10697)
Problem: New Session could fail after workspace identity reconciliation, delay access to the new input, and display unrelated creation failures.

Root cause: Creation compared journal workspace IDs outside the registry transaction; navigation waited for runtime readiness; global recovery duplicated the active request observer.

Fix: Resolve directory ownership at reservation and publication, preserve retry identities, expose the persisted input before runtime readiness, and scope recovery feedback to the selected composer. Keep send readiness, input persistence, lifecycle protection, and provider-visible contracts intact.

Verification: Creation and workspace interleaving regressions; focused Go race tests; frontend lifecycle and browser scenarios; production build and bundle budgets; native Windows portable-package creation/restart checks; PR cross-platform CI.
2026-09-24 01:01:29 +08:00
SivanCola 117d6a04c1 Fix historical session reconciliation / 修复历史会话展示与生命周期恢复 (#10696)
* Fix historical session reconciliation and ordinary lifecycle

Problem: Historical conversations could remain visible but fail to open or archive after upgrades.

Root cause: Strict receipt ownership checks had no ordinary discovery reconciliation, and navigation mislabeled underlying failures as missing projects.

Fix: Recover proven mappings and absent targets through lifecycle-fenced journals, retain authoritative adoption and tombstones, filter unusable rows, and preserve classified errors.

Verification: Catalog and workspace-state tests; desktop E-H, Q-S and history-10-11 partitions; focused race and restart tests; frontend navigation, typecheck, browser loading and production build; Windows cross-compilation.

* Index completed historical migration receipts during discovery

Problem: Ordinary history discovery repeatedly scanned every migration receipt for each legacy head, causing quadratic metadata lookup cost.

Fix: Build a completed-receipt key index once per discovery pass while preserving reviewed-version and interrupted-operation completion evidence.

Verification: Focused historical discovery, visibility, and receipt-index tests pass; desktop golangci-lint and repository lint pass.

* Use readable sessions in the read-only sidebar fixture

Problem: The project-tree read-only test attached eight registry identities without session files and expected them to be visible, contradicting the new unavailable-history contract.

Fix: Create real sessions before attaching them while preserving the unmigrated organization and byte-for-byte registry immutability assertions. Separate coverage continues to require missing members to stay hidden.

Verification: Reproduced the original assertion locally; focused read-only, organization, and missing-member regressions pass with race detection. Pinned desktop lint and repolint pass.
2026-09-24 00:13:34 +08:00
SivanCola a7d98804bb fix(desktop): isolate transcript rendering during streaming (#10691)
Problem: Transcript streaming could rerender unchanged tool rows, reasoning, and file cards, contributing to renderer long tasks. The full CI suite also contained a fork assertion tied to obsolete prop plumbing and a native browser smoke fixture that released its observation lease before input.

Root cause: Rows observed full process summaries, fork state flowed through every row, and reasoning projections retained unrelated answer fields. The auxiliary checks modeled implementation details or a shorter observation lifetime than production.

Fix: Subscribe rows to primitive visibility, scope fork state to tail controls, isolate reasoning-owned fields, and memoize file cards. Cover unchanged renders and positive interactions; align the fork assertion and native browser smoke lifecycle with their product contracts.

Verification: Local frontend transcript suite, all 463 frontend suites, Electron shell typecheck, 307 shell unit tests, native macOS browser smoke, production frontend build and bundle budgets, and repository lint passed. PR #10691 CI passed across frontend, Linux browser transcript, memory, CodeQL, Desktop Go/race, macOS, and Windows jobs.
2026-09-23 21:45:48 +08:00
SivanCola 3b0ee47e6d fix: preserve background gateways through model settings changes / 修复切换配置后后台网关阻塞发送 (#10688)
* fix(model settings): preserve session processes across runtime rebuilds

Problem: saving model settings could strand a Desktop session when an independent background gateway kept a job registered, repeatedly rejecting new messages.

Root cause: rebuild admission treated every background job as runtime-bound, while replacing a controller would cancel its job manager and kill independent processes.

Fix: move background process ownership to a session scope, classify runtime-bound work explicitly, publish rebuilt controllers atomically, and carry structured application status and one-submit validated applied-settings recovery through local and remote Desktop paths.

Verification: root Go suite, inventory-partitioned Desktop Go suite, focused race suites, pinned lint, repolint, frontend typecheck/build/tests, browser interaction and Electron smoke passed. Windows shell tests cross-compiled.

* fix(desktop): keep remote sends and CI component loading compatible

Problem: the frontend CI component runner could not load the recovery CSS import, and a remote submission guard rejected different messages sent in the same tick.

Root cause: the component imported CSS in Node test scope, while the guard treated every in-flight send as an unresolved receipt.

Fix: include recovery styles through the existing global stylesheet, deduplicate matching in-flight sends, and reserve receipt reuse/blocking for genuinely unknown outcomes.

Verification: the failed decision-slots test, remote-session-surface test with same-tick and unknown-result assertions, browser recovery interaction, CSS syntax, production frontend build, TypeScript and repolint passed.

* test(frontend): await both file-reference batches before cache assertion

Problem: the full frontend CI runner could fail the cached-verdict assertion before the second host batch had settled.

Root cause: the test assumed six Promise continuations always covered two async batch applications; that scheduling count varies by runtime.

Fix: yield through the host batch completion turn before asserting the unchanged per-candidate cache invariant.

Verification: the exact file-reference test passed, and the complete inventory-verified frontend CI runner passed all 516 commands locally.

* test(desktop): await catalog fixture shutdown on Windows

Problem: The retired projection test can remove a temporary SQLite database while its asynchronous shutdown still holds a Windows file handle.\nRoot cause: stopSessionCatalog has a bounded deadline and may return before Catalog.Close finishes.\nFix: Join the catalog close before advancing and register a cleanup fallback for early test failures.\nVerification: go test -run '^TestMissingProjectDoesNotResurrectDeletedTopic$' -count=1 . (desktop)

* chore(desktop): regenerate inventory after base merge

Problem: The merged historical-session changes shifted Desktop source locations, leaving the checked-in migration inventory stale.\nRoot cause: The inventory records line hints, and the upstream changes were merged after the prior generation.\nFix: Regenerate both inventory artifacts with the repository tool.\nVerification: go test ./tools/desktopinventory; go run ./tools/desktopinventory -check.
2026-09-23 21:24:26 +08:00
SivanCola b69aed4464 fix(desktop): prevent preview and session removal deadlocks (#10690)
Problem: Session switching and removal can hang during preview operations,
AI renaming, or autosave; cancellation can also leak a browser page.

Root cause: Preview cleanup reenters App locking, removal reverses title
lock ordering, and runtime teardown joins autosave under locks it needs.

Fix: Separate preview publication from lifecycle revocation, standardize
removal lock ordering, defer joins outside title/index locks, and reclaim
cancelled agent-owned browser pages while preserving user takeover.

Verification: Deterministic lifecycle regressions, focused Go race tests,
broader lifecycle suites, 307 Electron tests, typecheck, build, repolint,
and pinned Desktop golangci-lint.
2026-09-23 20:59:16 +08:00
SivanCola e934b6a6df Repair retained historical session archives / 修复历史会话残留归档 (#10689)
* fix(desktop): reconcile retained historical archives

Problem: retained converted sessions could fail archive, remain as ghost rows, or collide with deleted target identities.

Root cause: source receipts and pending imports did not distinguish identical retired copies from later source history, and archive could publish an active import before committing its lifecycle.

Fix: prove exact conversion ownership and content, repair identical retired receipts, stage changed history directly under an archive transaction, and fence recovery and sibling session operations.

Verification: desktop partitioned regression suite, focused archive and damaged-history tests, workspacestate tests, frontend typecheck and tests, repolint, and Windows amd64 cross-compile.

* fix(desktop): handle historical proof service shutdown errors

Problem: Windows desktop lint rejected unchecked shutdown results for the short-lived historical proof services.

Root cause: newly added read-only proof paths deferred Shutdown without handling its error.

Fix: close proof services through a shared helper that records cleanup errors in the local service log.

Verification: Windows-target golangci-lint v2.12.2 with isolated cache, archive regression tests, repolint, and gofmt.
2026-09-23 20:51:33 +08:00
SivanCola 2a2dbbeaef Fix archive pagination and quiet sidebar refreshes / 修复归档分页卡死与侧栏刷新闪烁 (#10683)
* fix(desktop): retire archived list reads and quiet background refreshes

Problem: Archiving during a pending sidebar read could strand a four-row
list in loading forever, and routine background reads showed loading on
each conversation update.

Root cause: Session archive advanced request generations without retiring
pending/page state. Foreground and background reads also shared one visible
loading indicator despite an already resident page.

Fix: Use the shared list invalidation owner for both archive paths. Keep
request ownership separate from background loading presentation, retaining
resident rows, expanded windows, pagination controls, and reading anchors.

Verification: All 20 project-tree suites, frontend typecheck, targeted
ESLint, repository lint, and diff checks passed. Browser checks confirmed
quiet refresh, stable reading position after insertion, and foreground
pagination feedback with no console errors.

* test(desktop): align topic removal fixture with list invalidation owner

Problem: The broader frontend CI suite failed in the topic-removal
controller fixture after the archive controller dependency changed.

Root cause: This sibling fixture still passed the old raw page refs instead
of the shared invalidateProjectTopicLists callback.

Fix: Type the fixture against the controller parameters, provide a real
project identity and the new callback, and assert only committed removals
invalidate their owning list, including a successful idempotent retry.

Verification: The previously failing topic-removal-controller suite,
targeted ESLint, repository lint, and diff checks pass. The full frontend
CI test entry point is also running locally.

* test(desktop): cover exhausted sidebar lists after archive and refresh

Problem: An exhausted folder can appear to keep loading even when only one
session remains, so large-list coverage does not directly express this case.

Root cause: Existing regressions covered an empty background refresh and a
multi-page archive race but not their one-row and terminal-page boundaries.

Fix: Exercise zero and one resident row, plus archive completion with twelve,
two, and one initial rows under old-first, fresh-first, and obsolete-error
ordering. Verify terminal pages expose no next-page control, finish loading,
and make no additional requests across six minutes of simulated time.

Verification: project-tree-loading.test.tsx, targeted ESLint, repository lint,
and git diff --check passed. No production behavior is changed by this commit.
2026-09-23 18:58:24 +08:00
SivanCola a5eba463bc fix(desktop): recover interrupted session creation / 自动恢复中断的会话创建 (#10680)
* fix(desktop): recover interrupted manual session creation

Problem: pending manual creations can remain starting after a competing
process releases its creation lock.

Root cause: startup recovery was a one-shot attempt and build notification
completion did not establish that an executor had actually stopped.

Fix: centralize creation admission and retries, preserve OS-lock ownership
through execution and result persistence, join actual build executions on
shutdown, and expose transient progress plus bounded diagnostics. Preserve
session identity, persisted schema, and unknown payload fields.

Verification: native macOS subprocess handoff/exclusion, targeted Go race
and shutdown tests, frontend interaction/type checks, Chromium recovery UI,
Desktop lint, and Windows cross-compilation passed. The full Desktop sweep
retains a separately reproduced baseline activation-ordering failure.
Raise one existing test-file allowance by three measured lines to retain
pending-attachment source and target isolation assertions.

* fix(desktop): claim activation terminal state before background pruning

Problem: rapid navigation can cancel an activation that already emitted ready.

Root cause: readiness was published while the request remained eligible for
supersession until background pruning completed.

Fix: group activation bookkeeping and atomically claim the terminal event
under App.mu while retaining generation ownership for deferred pruning.
Both direct-tab and topic switches preserve cancellation of unfinished
requests without cancelling already-completed requests again.

Verification: a deterministic channel-gated regression fails on the original
base for both switch paths. Activation race tests, ten repeated fast-path
and regression runs, Desktop lint, and repository lint pass.

* chore(desktop): refresh command inventory after recovery changes

Problem: the root CI inventory freshness test rejects the new diagnostic RPC
and shifted source locations.

Root cause: the independent Desktop contract generators do not regenerate
the migration command inventory.

Fix: regenerate both inventory artifacts with the existing owner tool.

Verification: go test ./tools/desktopinventory passes; the inventory includes
ExportManualCreationDiagnostics and retains existing command classifications.

* test(desktop): own catalog fixtures before waiting for admission

Problem: Windows CLI discovery coverage could fail a three-second publication
poll and then leave its SQLite owner running during temporary-dir cleanup.

Root cause: a functional discovery test used startup speed as readiness and
registered cleanup only after that assertion succeeded.

Fix: reuse the existing initial-admission and reconcile-execution barriers;
register checked cleanup in the shared fixture before startup. Remove the
redundant completion hook and preserve topic identity, ordering, count, and
no-migration-marker assertions. No production timeout or platform is changed.

Verification: an intentionally failing subprocess proves the old helper leaks
its owner and the new helper closes it. Relevant catalog/discovery race tests
pass, as do Desktop lint and repository lint. Native Windows CI follows.
2026-09-23 18:46:41 +08:00
SivanCola 383e9abd1c fix(desktop): prevent throughput spikes after session restoration (#10684)
## Summary

Switching back to a running conversation could divide thousands of restored output tokens by a newly started provider timer, briefly showing an enormous rate that then decayed. Restored sessions now measure newly observed output against its matching provider-active intervals while retaining the output total separately.

The shared local/remote snapshot path resets the observation window. Live subscriptions keep the composer current, and usage settlement, cumulative tool arguments, retry backoff, reconnects, and completed-turn readings retain the same measurement boundary. A new turn returns to ordinary billed telemetry. Throughput tooltips explain restored-session sampling in all three locales.

## Verification

- Deterministic restored-turn regression across local/remote and both snapshot protocol entry points: large restored prefix, fresh deltas, CJK output, tool gaps, cumulative arguments, child isolation, usage settlement, reconnect, completion, and retry.
- Composer DOM regression: live-store updates, popover, token totals, and session isolation.
- Existing turn metrics, controller stream progress, stream batching, live context, snapshot reconciliation, conversation projection, and composer run-strip suites passed.
- Frontend `pnpm typecheck`, `pnpm test:typecheck`, and `pnpm lint:hooks` passed; root `go run ./tools/repolint` and `git diff --check` passed.
- Browser verification of the actual composer: restored 13K-token total remains visible, fresh samples show 10 then 20 t/s, and switching clears the previous rate.

Documentation-impact: none - Existing embedded documentation remains accurate; the affected throughput tooltip is updated directly in all supported locales. No new configuration or workflow is introduced.
Cache-impact: none - Renderer-only telemetry and presentation; no provider-visible prompts, tools, messages, or persisted protocol fields change.
Cache-guard: existing provider cache contracts are unchanged; the diff is confined to renderer telemetry, UI copy, and regression tests.
System-prompt-review: N/A
2026-09-23 18:30:12 +08:00
SivanCola f8b47d3439 fix(desktop): simplify running inbox recovery (#10681)
## Summary

Restore queue and current-turn guidance for canonical Desktop sessions, and simplify input recovery.

## Problem and root cause

Canonical sessions carry a session ID with an empty legacy path. Inbox capture, runtime selection, and receipt lookup still relied on that path, rejecting legitimate running-session messages. A registration failure could then retain a pending marker and stale revision, leaving input locked behind ineffective version choices.

## Changes

- Route capture, receipt ownership, runtime selection, and guidance through canonical session identity while preserving generation fences.
- Automatically reconcile rejected registrations and lost replies. Release only a registration whose owning renderer never invoked send; uncertain delivery is checked without replay.
- Preserve newer edits during late settlement and retain unsent input when another submission is pending.
- Replace the three version/recovery actions with automatic recovery and one Retry action when confirmation remains unavailable.
- Retire composer recovery-copy creation and display. Keep revision checks, current-input autosave, and queue-item editing conflict handling. Keep the old list RPC as an empty compatibility response; do not change the database schema or delete existing records.
- Document the simplified experience in English and Chinese.

## Verification

- Focused canonical queue/guidance, composer persistence, deterministic race, lifecycle, and follow-up tests passed; integrated-head queue, race, persistence, wiring, receipt, and stream suites passed.
- Desktop inbox and sessionui race tests passed again on the integrated head.
- Frontend source/test type checks, hook lint, app-layer checks, and repository lint passed.
- All 459 discovery-runner frontend suites passed on the final head, including the unchanged goal-recovery guard. Goal restoration remains suppressed when history changed even though retained text can be edited.
- Browser interactions verified automatic unlock after rejection, retained text, lost-response confirmation, and no duplicate enqueue on Retry.
- The runtime Chromium scenario initially failed because mock-created tabs omitted binding generations. The mock producer and runtime frames now publish matching identities; all finishing, queue receipt, reconnect, and remote ownership assertions pass locally unchanged.
- Production frontend build and bundle budgets passed. Pinned Desktop golangci-lint passed. CI supplies the remaining platform gates.

## Scope and compatibility

The removed copy/version-choice behavior is intentional. Existing persisted input and submission records remain readable. Provider stream EOF and historical transcript recovery are separate issues and are not claimed fixed here. Native Windows installer qualification and release packaging are outside this PR.

Related work: #10680 concerns manual-session creation recovery; #10657 concerns historical-version recovery. Neither implementation is adopted here.

Documentation-impact: updated - bilingual composer controls describe running-turn messages, automatic input recovery, and retired version choices.
Cache-impact: none - provider-visible prompts, tools, and message serialization are unchanged.
Cache-guard: inbox identity and receipt regressions verify recovery without replay; provider serialization is untouched.
2026-09-23 18:18:52 +08:00
SivanCola 63d0bd338f fix(desktop): keep overview context usage bound to its session (#10679)
Problem: Session changes could retain an old context estimate and overview
metrics, while failed-attempt estimates could inflate the context gauge.

Root cause: The estimate cache omitted session identity; overview reads could
re-key live telemetry; frontend budget and usage paths preferred stale snapshots
and dropped the final throttled event.

Fix: Scope estimates and overview reads to a session, preserve live context
authority, schedule a trailing usage refresh, and exclude estimated usage from
context occupancy while retaining its billable accounting.

Verification: Root Go tests, Desktop context and telemetry tests with race
detection, frontend regression tests, typechecks, repository lint, and browser
interaction checks passed.
2026-09-23 17:27:28 +08:00
SivanCola d5a95e8993 fix(desktop): retire legacy drafts and prevent purged topic resurrection (#10677)
Problem: Legacy workspace drafts confuse users, and emptying trash can add
new conversation rows that create more sessions when opened.

Root cause: The draft surface and replay lifecycle remained connected after
manual creation returned to formal sessions. Purge removed canonical topic
ownership but left metadata that looked like independent empty topics.

Fix: Disconnect draft UI, replay, and restore reads while preserving formal
input persistence and exit barriers. Keep minimal purge topic ownership,
filter stale metadata, and reject opens that would recreate deleted sessions.
Preserve formal sessions on restore conflicts and draft retirement on failed
tab-preference writes. Document compatibility and shared-topic behavior.

Verification: Frontend 457-suite runner and typechecks passed; focused Go
race suites and workspace-registry package passed; repository lint and
whitespace checks passed. Full Desktop aggregate run previously timed out;
broader cross-platform verification is delegated to PR CI.
2026-09-23 15:41:11 +08:00
SivanCola fba4febbb6 Unify migrated session identity and lifecycle / 统一新旧会话身份与生命周期 (#10676)
* fix(desktop): keep purged history out of topic placeholders

Problem: Permanently deleted history could reappear as a metadata-only topic and fail subsequent archive requests with session not found.

Root cause: Sidebar adoption filtering depended on live membership and presentation, both removed by purge, while legacy metadata and source receipts remained.

Fix: Derive an immutable topic adoption index from durable committed evidence, preserve minimal topic identity in purge receipts, and share the index across materialized, lazy, and pinned projections.

Verification: Workspace state tests with race detection, archive/restore/purge/restart integration coverage, shared-topic and independent-source preservation, pinned/manual-order regressions, previous operation codec round-trip, incremental lint, and diff checks passed. Native packaged Windows/macOS UI was not revalidated.

* fix(desktop): unify migrated session identity and lifecycle

Problem: A migrated conversation could appear as both a canonical session and a historical source, highlight twice, and reappear after lifecycle operations.

Root cause: Physical path normalization changed source hashes while adoption receipts and pending imports retained older keys. Lookup and lifecycle callers did not share compatibility aliases.

Fix: Derive immutable source aliases from durable mapping receipts and use them across discovery, navigation, tab restore, import preparation, sidebar projections, and lifecycle guards. Preserve independent head and reviewed-version identities. Persist explicit source-cleanup intent with purge tombstones, clean only unchanged exclusive canonical originals under ownership locks, and retain shared, changed, offline, and multi-head sources.

Verification: Focused identity, archive/restore/purge, shared-source protection, writer contention, and process-restart tests passed. Workspace state race tests, filesystem purge tests, incremental lint, and diff checks passed. The complete Desktop CI partition run is in progress; the initial unpartitioned run hit its six-minute package timeout. Packaged Windows and macOS UI has not been revalidated.

* fix(desktop): separate migration identity and purge planning owners

Problem: The PR failed repository size and complexity checks and lacked the required documentation-impact declaration.

Root cause: Source alias lookup and purge-plan encoding grew existing lifecycle coordinators beyond their file budgets, and historical head resolution remained embedded in the generic source resolver. The historical migration guide still implied that all original files survive explicit permanent deletion.

Fix: Move source mapping lookup and versioned purge-plan construction into focused owners, extract historical head resolution, and document unified display/lifecycle and guarded source cleanup in both language guides. Preserve all behavior and test coverage without raising budgets or disabling checks.

Verification: Repository repolint, the documentation-impact validator, isolated-cache golangci-lint 2.12.2, migration identity/import/head/purge regressions, workspace-state package tests, and diff checks passed.
2026-09-23 15:34:46 +08:00
SivanCola 07fd2fb0df Fix repeated fork sidebar visibility (#10673)
## Summary

Fix repeated forks from the same source conversation remaining absent from the paged sidebar until restart. Canonical fork attachment now publishes a workspace scoped catalog revision when membership becomes durable. The fork's topic presentation is written and invalidated before tab publication, so a child remains visible with its title even if opening its tab fails.

When a failed tab open is retried, the new tab reuses the durable topic ID and projected title. The legacy session directory path continues to use its catalog reconciliation flow.

The background notification browser fixture now serves its injected runtime state through focus and periodic reads as well. A forced focus read while notification loading is gated covers the test race that previously marked the snapshot failed and suppressed the expected toast.

## Root cause

The sidebar paginates durable workspace membership. Runtime projection events alone do not refresh page item keys. Separately, asynchronous topic presentation could be written after the first page reload; a failed tab open skipped the later title notification, and retry could generate a different topic ID.

## Verification

- Focused Desktop Go fork, worktree, legacy session, and catalog tests under `go test -race` passed.
- Frontend `project-tree-loading.test.tsx` passed, including two forks from the same source without remounting.
- Frontend typecheck passed for the original sidebar fix; no frontend production files changed in the follow-up commit.
- `git diff --check` passed.
- Repository lint passed. The attention notification browser test passes its forced focus ordering and all existing sound, toast, draft, focus, navigation, and deduplication assertions.
- The native macOS browser runtime smoke passed locally, including recording and 100 lifecycle cycles.

The earlier local full Desktop suite exceeded its default ten minute timeout. The final PR CI run passed on `f772a0e4a0d979e286690557735d8b4a437eb677`, including Desktop Go, all Go race and Windows Go groups, frontend/browser checks, native macOS/Windows checks, lint, and the required root checks. The previously failing notification browser group and native macOS recording/lifecycle smoke both passed. All 56 reported checks passed; no tests were removed or timeouts increased.

Documentation-impact: none - This restores existing fork visibility and title behavior without changing any documented workflow or user-facing interface.
2026-09-23 14:40:23 +08:00
SivanCola 5518ea8d39 fix(desktop): keep retired history out of the sidebar (#10671)
Problem: Deleted legacy conversations could return to the sidebar after a catalog rebuild, then fail to open or archive.

Root cause: The lazy and materialized history projections ignored saved topic tombstones, and the materialized projection lost path adoption when it expanded one source into a head row.

Fix: Exclude deleted topics before ordinary paging and materialization; honor adopted path aliases for single-head sources while preserving independent branches.

Verification: focused Desktop lifecycle regressions, session catalog tests, repolint, and a 100k-session first-page benchmark.
2026-09-23 13:57:03 +08:00
SivanCola fbf36f54d9 fix(desktop): restore live transcript order after tab return (#10670)
Problem: Returning to a running conversation could place reasoning above its user message.

Root cause: The live follower sorted canonical history positions together with indexes from a separate projection. Reconnect snapshots could also select an older alias body or miscount an existing user.

Fix: Align canonical and projected rows by message identity, retain native page ordering, and preserve the authoritative active body and turn count.

Verification: Frontend and test typechecks, ESLint, transcript and Chromium browser regressions, session-loading browser tests, and PR CI across Linux, macOS, and Windows.
2026-09-23 13:56:00 +08:00
SivanCola 3572f59194 test(desktop): align upgrade fixture with lazy history loading (#10668)
* test(desktop): verify legacy upgrade without implicit import

* test(desktop): remove obsolete upgrade fixture hashes
2026-09-23 12:30:08 +08:00
SivanCola 3476556ee8 Defer historical session loading and add bounded native paging / 延迟历史会话加载并加入有界原生分页 (#10664)
* Improve historical session startup and lazy loading

* Fix history preparation retirement across readers

Problem: source replacement and immediate reopening could expose a closed SQLite cache to retained history readers.

Root cause: reader cancellation did not follow preparation retirement, the last release removed cache ownership before close, and compatibility paging opened a separate owner.

Fix: share preparation across both APIs, propagate source cancellation, retain and drain cache-close barriers, and retire unbound borrowers during shutdown.

Verification: deterministic shared-reference, replacement, immediate reopen, compatibility cursor and shutdown tests passed with the related history paging race suite.

* Index history read fences and extend lazy catalog pages

Problem: paging repeatedly cloned the registry and scanned every source mapping, while existing groups, time filters and pinned shells bypassed lazy catalog reads.

Root cause: read invalidation lacked immutable reverse indexes and source fences could mix a captured WAL view with live metadata.

Fix: share exact-publication lifecycle tokens and one verification per validation; reuse lazy pages for all-session views, frozen time filters and automatic pinned shells; capture source fences from their listing transaction. Document remaining full-materialization and recovery limits.

Verification: registry, history lifecycle and catalog paging race suites; deterministic WAL-capture replacement regression; transcript tests; full root Go suite. Desktop full partition sweep remains in progress.

* Validate fresh macOS images and join history batch workers

* Page native history outlines and resolve direct read anchors

* Read schema-one history through cancellable native projections

* Handle unused history roots and verify packaged native readers

* Page grouped history without materializing every session

Problem: group filters expanded all legacy rows and loaded unrelated canonical
headers. Disappeared history roots could also be omitted from completeness.

Root cause: group membership forced the full snapshot adapter, and listing
availability independently skipped absent roots instead of using discovery.

Fix: retain immutable catalog pages with source-key membership predicates,
admit canonical group and pin members before Stat, and use metadata discovery
state for absent roots. Keep source hashes and persisted schemas unchanged.

Verification: catalog and Desktop pagination/organization regressions, related
race tests, sidebar browser interactions, and stronger packaged legacy reader
checks. Root-wide validation and the new packaged group check continue separately.

* Share native history preparation with compatibility content readers

Problem: Unbound history field requests could enter compatibility repair after
native paging had already prepared a read-only view of the same source.
Root cause: Paging and content RPCs borrowed different history preparation
owners and did not share cancellation and source validation boundaries.
Fix: Centralize native pager borrowing and use it for compatibility field
reads, including explicit targets, while retaining unsupported-format readers.
Verification: Focused paging/content regressions and race tests pass, including
Unicode chunk reads, unchanged authoritative bytes, and peer cancellation.
The native package harness now exercises unbound content reads as well.

* Bound queued history iterators and resolve original compatibility sources

Problem: Rotating discovery retained one descriptor per historical root, and
cold compatibility RPCs rejected global files after workspace restoration.
Root cause: Scan admission had no retained-iterator budget; read validation
mistook the destination workspace for the captured historical source root.
Fix: Limit queued discovery to eight iterators with a visible-workspace
reserve, retaining admitted progress. Share known-root source resolution
across cold paging, field reads, and legacy previews outside App.mu.
Verification: Catalog full race suite and Desktop focused race tests pass.
Tests cover retained progress, priority switching, eventual completion, old
global source reads, full Unicode content, and unknown/symlink root rejection.
The packaged smoke caught the original directory failure; retain compact
page diagnostics so an absent field ref identifies its actual read error.

* Filter historical metadata pages without materializing the library

Problem: Sidebar text queries bypassed lazy paging and built every legacy
session row before returning a small matching page.
Root cause: The bounded catalog adapter excluded text requests because SQL
case conversion could not preserve display-layer Unicode/title semantics.
Fix: Filter bounded catalog batches through the existing Go matching rules
inside the retained WAL snapshot. Read captured source identities instead
of resolving candidate files and account for retained query text.
Verification: Catalog and Desktop focused race tests cover sparse matches,
Unicode, cancellation, fixed-cut continuation, source keys and localized
titles. Sidebar browser interaction tests pass. Native smoke now also checks
text filtering within a group; sparse queries can still scan all metadata.

* fix(desktop): admit explicit group targets without scanning history

Problem: ordinary group edits enumerated all legacy source pages before
applying a workspace organization mutation.
Root cause: group commands shared manual reordering's full-source admission.
Fix: admit only resolved group targets inside the revision CAS, normalize
physical source keys, retain explicit ungrouping fences, and recheck source
adoption and lifecycle ownership inside the registry transaction.
Verification: organization and group regressions pass with the race detector,
including deterministic adoption-before-commit rejection and late preference
import. Document the remaining full-source manual and legacy preference paths.

* fix(desktop): decouple catalog admission from metadata refresh

Problem: catalog startup and filesystem event delivery waited for whole
project/topic metadata transactions and restored-path index writes.
Root cause: startup and the watch loop synchronously owned these writes,
while user edits started independent unjoined metadata goroutines.
Fix: register watches before restored identities settle, enqueue restored
paths, and coalesce all metadata refresh triggers into one cancellable worker
joined by the catalog lifecycle. Publish admission without waiting on writes.
Verification: catalog/sidebar regressions and race tests pass. Deterministic
coverage holds a real SQLite writer during admission, blocks refreshes to
verify coalescing, and checks cancellation and lifecycle wakeup ownership.
Whole-projection metadata reconciliation and synchronous database integrity
validation remain explicitly documented acceptance gaps.

* refactor(history): separate projection readers and preparation stages

Split native pager source validation, DAG projection, compatibility windows, catalog paging, and controller usage accounting into focused owners. Remove the unreachable continuation rebind in the empty-key topic branch. Preserve source validation, transaction boundaries, and snapshot ownership without increasing lint baselines.

Verification: repository lint; agent, sessioncatalog and session package suites; focused Desktop history/catalog/snapshot/navigation tests; frontend typecheck and transcript suite.

* fix(history): serialize discovery progress with catalog writers

Problem: the 100k native discovery fixture stalled with SQLITE_BUSY. Root cause: progress publication bypassed the process-local mutation lock while other catalog writers used it. Fix: route progress through the same boundary and preserve its generation predicate and cancellation behavior. Add a production-package scale harness and record the incomplete history.12 result without relaxing its gate.

Verification: focused metadata/reconcile/catalog race tests, repository lint, script syntax check; native scale harness smoke and history.12 measurement exposed the failure. A fresh package run is still required.

* fix(history): move metadata integrity audits behind startup admission

Problem: cached catalog opening still scanned every database page before returning a sidebar page. Fix: open advisory metadata separately from the full background audit while keeping content projection validation synchronous. Corruption revokes the generation, stops its work, closes all read leases, and reopens through the existing validated quarantine path with a monotonic revision floor. Old cursors return stale_cursor; canceled or transient audits cannot quarantine data, and retries remain bounded.

Verification: root Go suite; projectiondb and sessioncatalog race suites; Desktop catalog/history/snapshot race regressions; real CHECK-constraint corruption, source preservation, lease closure, late publication, cancellation and retry tests; repository lint. Native package performance acceptance remains pending.

* fix(history): avoid redundant discovery and source-wide metadata writes

Problem: Large legacy catalogs could finish discovery then immediately restart,
while registry refresh rewrote unrelated discovered topics.

Root cause: Startup recovery used the full-root invalidation notification
alongside watcher-owned discovery; metadata membership reset and orphan cleanup
scanned all topics.

Fix: Publish only registry metadata after reservation recovery, and use the
existing metadata_present flag with an optional partial index to constrain
membership refresh. Source mutation retains topic cleanup ownership. Record
incomplete history.13 scale evidence and warmup progress.

Verification: sessioncatalog suite, focused catalog race tests, Desktop
startup/catalog/history race tests, repolint, JavaScript syntax check, and diff
whitespace checks passed. New package scale acceptance remains required.

* fix(history): bound directory watching and merge undispatched invalidations

Problem: Large catalogs continued to restart discovery after the initial scan.

Root cause: Catalog monitoring bypassed the platform directory backend and used
macOS kqueue registration that enumerates children and opens per-file handles.
Unwatched roots were also invalidated on every metadata tick, and queued jobs
retained pre-dispatch changes as unnecessary follow-up scans.

Fix: Reuse native directory watching, preserve canonical event/access routing,
separate watch retries from the rotating audit, and merge invalidations before
scan dispatch while retaining genuine in-flight source changes.

Verification: Focused catalog queue and Desktop catalog/watcher race tests pass,
including 512-file native registration under a 256-descriptor process limit,
canonical events, recovery gaps, durable journals and late file discovery.
Repolint and diff checks pass. Record the incomplete history.14 scale evidence;
the new production package still requires measurement.

* fix(history): preserve cold reads when execution recovery fails

Problem: lease-blocked native navigation could leave a blank transcript, and cache failures could silently start full compatibility replay.

Root cause: passive readiness refresh switched startup-error tabs to controller-dependent Follow; activation failure settled independent history state. Native preparation did not distinguish ordinary errors from explicit unsupported formats, and root invalidation retained native watcher registrations.

Fix: coalesce matching cold reads, fence rebuild/reconnect paths, and let only read failures settle history errors. Restrict compatibility fallback to explicit unsupported results, positively identify ancient event envelopes, and remove replaced native watches.

Verification: deterministic activation regressions (59 assertions), frontend typecheck/lint, transcript suite, loading browser tests, focused agent/Desktop race tests and Go lint pass. Root full suite previously passed; Desktop default package timeout remains under investigation with exhaustive deterministic shards. Production history.15 failure is documented; new-package acceptance remains pending.

* refactor(history): share runtime rebuild subscription handling

Problem: the cold-read fence duplicated rebuild handling and exceeded the existing initial bundle budget by 380 bytes.

Root cause: single-tab and settings-wide rebuilds maintained equivalent subscription ownership logic in separate branches.

Fix: iterate the affected identities through one stop/invalidate/rebuild path, retaining the cold-read and activation guards without changing the asset budget.

Verification: activation regressions (59 assertions), prompt lifecycle tests and TypeScript checks pass; the complete production build will verify the unchanged bundle gate.

* fix(history): coalesce startup watches before resuming discovery

* fix(history): keep startup locale sync out of discovery

* feat(history): add shared on-demand native full-text search

* feat(history): add bounded discovery source diagnostics

* desktop: ignore noisy root watcher writes

* desktop: drain native watch backlog before initial history discovery

* refactor(history): remove retired startup migration workers

* fix(history): coalesce watcher overflow before journal writes

* fix(history): replace invalid scans at slice boundaries

Problem: large metadata discovery finished obsolete generations before replaying queued root changes, while unchanged rows rewrote topic projections.

Root cause: invalidations were consumed only at EOF and discovery always published full upserts.

Fix: supersede invalid iterators between slices with bounded coalescing; preserve incomplete journals and recheck unchanged rows transactionally before updating scan presence only.

Verification: sessioncatalog/historywork race suites, deterministic restart and projection regressions, Desktop watcher/startup race tests, and sessioncatalog lint pass. Native scale acceptance remains pending.

* perf(history): project registry metadata in bounded slices

Problem: registry refresh held the catalog writer for the entire registry and rewrote unchanged metadata.

Root cause: metadata-only discovery reused the atomic full-projection refresh and a whole-task timeout restarted large observations.

Fix: serialize cancellable observations, share background slice admission, compare persisted rows, defer paged retirement until input completes, and bound each writer transaction. Preserve atomic refresh for other catalog modes.

Verification: root Go suite, catalog/historywork race suites, Desktop catalog race coverage, frontend typecheck/transcript and loading/sidebar/navigation/scroll browser gates pass. Retain the preceding history.24 package's 90-run native scale evidence separately.

* feat(history): resume interrupted checkpoint index preparation

* test(history): verify checkpoint preparation across process exit

* feat(history): resume streamed legacy display index imports

* refactor(history): separate maintenance admission and publication steps

* feat(history): resume native event preparation across process exit

* fix(history): preserve cold reading windows during passive readiness refresh

* test(history): record production scale results and exercise native cache paging

* perf(history): load cold refresh proof on demand

* perf(history): defer session restoration policy with its reader proof

* perf(history): keep restoration policy outside the eager module graph

* feat(history): resume DAG preparation across all durable phases

* fix(history): include protected windows in resident cache capacity

* docs(history): document DAG resume and native cache findings

* fix(history): release evicted read bindings without affecting replacements

* test(history): qualify native cache resources and startup scale

* docs(history): record history.29 cache qualification

* test: align history migration fixtures with current schemas

Keep the junction migration regression focused on the last pre-invalidation schema and derive the projection schema assertion from the migration ledger. Complete the history hydration Meta fixture so frontend test type checking covers the current contract.

* fix: align startup bundle gate with measured producer output

Problem: the history startup PR exceeded the raw frontend bundle gate by 275 bytes on the Linux producer and the migration regression comment exceeded repolint's essay budget.

Root cause: the earlier budget ceiling was based on a pre-rebase producer measurement, while the test fixture note was four lines at a three-line position limit.

Fix: set the raw initial gate to the smallest measured 2056.6 KiB ceiling, keep all compressed and chunk budgets unchanged, and condense the migration note without removing coverage.

Verification: node scripts/check-bundle-budget.mjs; go run ./tools/repolint; focused sessioncatalog migration tests; git diff --check.

* fix: format desktop catalog runtime

Problem: the desktop producer gate rejected one unformatted composite literal in session catalog runtime code.

Root cause: the latest history loading changes left the pinned-only field alignment outside gofmt output.

Fix: apply the repository formatter without changing behavior.

Verification: gofmt -l ., git diff --check, and go run ./tools/repolint.

* fix: use passing producer bundle ceiling

Problem: the Desktop producer still exceeded the raw startup bundle gate by 112 bytes after the previous measured adjustment.

Root cause: the Linux CI build measured 2106070 bytes while the 2056.6 KiB ceiling allowed only 2105958 bytes.

Fix: set the raw initial budget to the smallest 2056.8 KiB ceiling that contains the producer result; compressed, chunk, CSS, locale, and residency budgets remain unchanged.

Verification: node scripts/check-bundle-budget.mjs, go run ./tools/repolint, gofmt -l ., and git diff --check.

* fix: allow Windows history sources to be replaced during paging

Problem: Windows pager fencing tests failed when a source or event log was replaced while a display index was being built.\n\nRoot cause: os.Open uses a Windows sharing mode that denies delete and rename, so the deterministic source-change interleaving failed before the pager could validate the new pathname identity.\n\nFix: add a platform-owned replaceable read helper. Windows opens sources with read, write, and delete sharing; Unix keeps the ordinary descriptor. All legacy pager builders, event-kind probing, and paged event/DAG reads use the shared helper.\n\nVerification: go test ./internal/agent -count=1; GOOS=windows GOARCH=amd64 go test -c ./internal/agent; go run ./tools/repolint; go test ./...; git diff --check.

* fix: match Windows replaceable read handle flags

Problem: the first shared-delete handle still left the DAG source replacement interleaving failing on Windows.\n\nRoot cause: the native source handle did not use the same reparse-aware backup semantics as the repository's proven replaceable regular-file reader.\n\nFix: align the fileops helper flags with the existing Windows repair reader while retaining read, write, and delete sharing.\n\nVerification: GOOS=windows GOARCH=amd64 go test -c ./internal/agent; focused pager tests pass on the host.

* fix: grant Windows pager handles replace access

Problem: atomic replacement of a DAG event log was still denied on Windows despite delete sharing.\n\nRoot cause: the replacement operation also needs delete access on the existing native source handle.\n\nFix: request DELETE together with GENERIC_READ while retaining read, write, and delete sharing.\n\nVerification: GOOS=windows GOARCH=amd64 go test -c ./internal/agent.

* fix: make Windows source replacement regression portable

Problem: the pager source-fencing regression used os.Rename(temp, existing) while the target was open, which Windows rejects; granting DELETE access then broke ordinary concurrent rewrites.\n\nRoot cause: the test encoded Unix replacement semantics that are unavailable for an open Windows target, while the production reader must remain compatible with normal writers.\n\nFix: keep the shared-delete read handle at read access and model replacement by moving the old pathname aside before installing the new file. The test still verifies a changed native identity is rejected before publication.\n\nVerification: focused pager source-change tests, GOOS=windows GOARCH=amd64 go test -c ./internal/agent, go run ./tools/repolint, and git diff --check.

* fix: join startup cold history on runtime ready

Problem
A runtime-ready event could start ordinary session hydration while startup cold history was still in flight, issuing a duplicate history read for the same session.

Root cause
The ready reconciliation only shared the normal session-load owner. Cold startup reads were tracked separately, so the readiness path did not join that owner.

Fix
Join matching cold-history promises before starting ordinary hydration. After the certified window settles, install the live follower and continue ancillary hydration with the cached window. Keep the failure path on the existing reader and record the measured 163-byte startup bundle increase with the smallest producer-safe budget update.

Verification
- pnpm exec tsx src/__tests__/ready-event-meta-sync.test.tsx
- pnpm typecheck
- pnpm test:typecheck
- pnpm lint:hooks
- pnpm test:transcript
- pnpm build
- go run ./tools/repolint
- git diff --check

* Fix protected history windows and catalog context races

Problem
Navigation could install a transcript before the active tab was pinned, allowing the resident cache budget to evict the just-opened window. Catalog callbacks could also reread the mutable App context while the lifecycle test replaced it, producing a data race.

Root cause
Active-tab pinning was deferred to a post-paint effect, and the restored catalog watcher used the App context accessor instead of its owning worker context.

Fix
Publish the active pin at the dispatch boundary before the first history page commits. Let the catalog watcher pass its existing context into historical discovery while retaining the default accessor for synchronous callers.

Verification
- tab-switch-hydration: 101 passed
- desktop TestConcurrentRebuildSessionCatalogCallersShareFailure with -race: 3 runs passed
- desktop TestHistorySliceSourceField/cold_index_hit: 100 runs passed
- pnpm typecheck and pnpm test:typecheck passed
- pnpm lint:hooks and pnpm test:transcript passed
- go run ./tools/repolint passed
- git diff --check passed

* Fix Windows history upgrade acceptance and test cleanup

Problem: Windows CI rejected visible upgraded history and could not remove a test source directory.

Root cause: the UI acceptance helper required an activation button even after lazy history had rendered, while the test closed its historical service after TempDir cleanup.

Fix: accept a healthy visible transcript without activation and close the test app services before removing the source directory. Keep pending and recovery-state rejection covered.

Verification: focused desktop Go test, go vet, repolint, golangci-lint, and diff checks passed. Windows PowerShell and installer checks remain for CI.

* Align history CI checks with lazy legacy loading

Problem: the Desktop CI matrix failed startup worker, cold index, and packaged upgrade checks after history loading changed.

Root cause: those checks still assumed startup discovery took the old coordinator lock, equal-timestamp sidecars were trustworthy, and opening a legacy tab automatically migrated it without rewriting JSONL. The current shutdown path snapshots an active legacy tab.

Fix: deterministically block the actual startup worker, establish sidecar ordering for the index-hit fixture, and verify legacy path, authored content, registry non-import, restart, and backups in the packaged fixture.

Verification: focused desktop tests with repeated and race runs, upgradefixture tests, go vet, repolint, golangci-lint, and diff checks passed. Windows packaged acceptance remains the terminal CI gate.
2026-09-23 11:54:15 +08:00
SivanCola 4d4700a39a Preserve session-fenced guidance receipts and transport diagnostics (#10660)
Problem: guidance could fail before persistence when turn discovery was missing, or refresh to a successor turn. Lost replies could also cause duplicate submissions, while session exports lacked transport evidence for silent requests.

Root cause: callers mixed observed turn identity with current tab discovery and did not share durable receipt ownership; diagnostics observed model attempts without request/header/body stages.

Fix: preserve captured session targets and observed turns, queue unavailable-turn guidance durably, and retain original idempotency keys for uncertain receipt recovery across entry points. Add bounded, content-free provider transport snapshots. Keep action-only request construction in the lazy submission owner. Remove an unrelated disk-speed cutoff from the long-history reachability test while retaining its correctness assertions and package timeout.

Verification: root, desktop, frontend, provider/control race, production build, and repository lint coverage passed. Final PR CI completed successfully across Linux, Windows, macOS, browser/native desktop, and independent memory screening. Provider-visible serialization, cache inputs, retry policy, and timeouts are unchanged.
2026-09-22 20:18:16 +08:00
SivanCola 9f286c6ee6 Fix Windows junction path identity and upgrade recovery / 修复 Windows Junction 路径身份与升级恢复 (#10656)
* fix: resolve Windows junction identities without breaking upgrade locks

Problem: Windows users whose existing data directory is a junction cannot
start sessions after configuration locks begin requiring physical identity.
Recovery also labels runtime-start failures as failed draft saves.

Root cause: EvalSymlinks can leave a junction leaf unchanged and reject its
children, splitting aliases or failing before the OS lock is acquired.

Fix: resolve existing Windows paths through native metadata handles, retain
legacy workspace lock spellings, isolate/rebuild derived catalog identities,
and separate historical draft submission errors from persistence errors.
Add a read-only probe and native regressions to the Windows PR selectors.
Preserve current eager session creation and authoritative user data formats.

Verification: owning Go suites, host/Windows golangci-lint, repolint, frontend
production/test typechecks, draft ownership and recovery regressions, real
Chromium recovery interactions, and Windows selector coverage passed.
Native Windows execution and complete upgrade qualification remain CI/manual
gates; cross-compilation does not establish those results.

* fix: preserve native Windows error kinds and verify historical lock spelling

Problem: native Windows CI rejected dangling-link and loop error kinds, and
the legacy-lock fixture expected the unexpanded runner temporary path.
The recovery UI also exceeded an almost exact startup-byte budget.

Root cause: os.IsNotExist does not traverse arbitrary wrapping, native
reparse exhaustion uses ERROR_CANT_RESOLVE_FILENAME, and EvalSymlinks expands
8.3 ancestors even when it preserves a junction leaf.

Fix: use wrapped-error matching and the native reparse-loop code; derive the
fixture's legacy key with the historical filesystem algorithm. Keep all
rejection and lock assertions. Remove a duplicated browser invocation.
Adjust only the raw startup ceiling to the measured producer size: same
source/dependencies/commit stamping adds 506 B raw (0.024%) and 120 B gzip;
Linux Electron measures 2103802 B, rounded to 2054.5 KiB.

Verification: pathidentity, workspacelease and probe tests; host/Windows
lint; repolint; bundle budget; same-toolchain base/candidate production builds.
All compressed, chunk, CSS, locale and residency budgets remain unchanged.

* test: exercise native junction identity across Windows volumes

Problem: the reported regression crosses system and data volumes, while the
initial regression fixtures only created same-volume junctions.

Root cause: filesystem alias tests did not exercise drive-prefix changes.

Fix: create a temporary junction from the system temp drive to RUNNER_TEMP
and compare entry and missing-tail identities with the physical target.
Require the two-volume fixture on GitHub Windows runners. Single-volume
local machines still run the unconditional same-volume regression suite.

Verification: Windows-target lint and cross-compilation; the native Windows
PR lane is required to execute the cross-volume fixture.

* Preserve credential ACL recovery and bind artifact consumers to producer Node

Problem: native identity resolution blocked credential ACL repair before edit locking, and prepared frontend consumers selected different Node patch versions.

Root cause: FILE_READ_ATTRIBUTES requires permissions that legacy read-deny ACLs remove; floating Node majors resolve independently on hosted runners.

Fix: use zero-access metadata handles, keep read denials intact until credential recovery, cover linked and unrecorded ACL cases, and propagate the producer Node version to every artifact consumer without weakening verification.

Verification: pathidentity/config tests, Windows cross-compilation and golangci-lint, 40 workflow/artifact/selector tests, repolint and diff checks passed. Native Windows execution remains a CI gate.

* Bind memory screening consumers to their build Node version

Problem: a memory shard rejected the prepared build before running because its floating Node major selected a different patch version.

Root cause: the independent app-memory workflow owns a second producer-consumer identity contract.

Fix: propagate its producer runtime to shards and aggregation, preserving exact identity validation. Add a workflow regression for both consumers.

Verification: 51 workflow and memory protocol tests, repolint, and diff checks passed.

* Open Windows identity handles with exact native access rights

Problem: CreateFile with zero desired access still fails against legacy credential DENY RX ACLs.

Root cause: Win32 adds SYNCHRONIZE and FILE_READ_ATTRIBUTES, as already documented by the credential ACL recovery owner.

Fix: open a zero-access NtCreateFile handle that follows reparse points, use the native namespace for long/UNC paths, and translate NTSTATUS failures back to Win32 codes for existing error classification.

Verification: Windows compilation and golangci-lint, repolint, and diff checks passed. Existing save/load/revision tests require identity to resolve while content reads remain denied; native CI verifies this contract.

* Request valid metadata access for native Windows identity queries

Problem: NtCreateFile rejects a zero access mask, breaking ordinary Windows identity queries.

Fix: request FILE_READ_ATTRIBUTES without implicit synchronization; when an ACL denies that right, try READ_CONTROL, which file owners retain for security metadata. Neither handle grants content access or mutates the ACL. Preserve NTSTATUS classification and all denial assertions.

Verification: Windows compilation and golangci-lint, repolint and diff checks passed; Windows execution is required before qualification.
2026-09-22 19:29:56 +08:00
SivanCola 2fb74e47d4 Clarify MiMo API and Token Plan access and upgrade models / 明确 MiMo 接入方式并升级模型 (#10655)
Problem: region-first filtering hid MiMo API versus Token Plan access and new connections retained the V2.5 model catalog.

Root cause: product selection depended on region, MiMo lacked a one-time model migration, and floating CI Node versions made frontend artifact producers and consumers disagree.

Fix: select access type before cluster and protocol, clarify independent credentials and usage, default new connections to V2.6, and atomically append models to eligible official connections without changing existing defaults or custom data. Preserve previous-writer TOML compatibility, share repeated frontend defaults within existing budgets, and pin artifact workflows to one exact Node runtime.

Verification: root and Desktop suites, previous-reader migration round trips, frontend and browser regressions, unchanged production bundle budgets, cross-platform CI, race checks, Windows packaging, memory screening, CodeQL, and release-control contracts passed. All review threads were clear before merge.
2026-09-22 18:46:01 +08:00
SivanCola 4fad310c93 Restore eager session creation with upgrade-safe input recovery / 回滚新建会话流程并保留升级数据 (#10650)
* fix(desktop): restore eager session creation with durable input recovery

Problem: deferred draft creation changed explicit New Session behavior and left upgraded users dependent on draft-only input and recovery paths.

Root cause: manual creation, composer persistence, and legacy draft submission shared identities and lifecycle assumptions that no longer match formal sessions.

Fix: create formal sessions through durable idempotent operations, persist unsent input separately with revision and receipt fencing, retain legacy drafts as recovery-only data, and retire automatic empty-session cleanup. Repair model, attachment, Goal, and submission wiring while preserving current canonical storage and history formats.

Verification: tagged 1.38.3 through 1.38.11 compatibility fixtures; all 11 desktop test partitions; focused race, composer and lifecycle regressions; source and test type checks; repository lint; signed macOS arm64 packaging and isolated restart/image/lifecycle smoke tests. Native Windows and Linux application validation remains pending.

* fix: repair rollback CI gates and confine session deletion

Problem: rollback CI failed lint and the frontend producer gate, while CodeQL reported existing deletion paths outside a rooted filesystem boundary.

Root cause: reserved initialization ignored binding release errors, historical tests used outdated lint syntax, and the raw budget sat below the measured main-v2 baseline. Deletion rebuilt absolute trash and cache paths after validating the session identity.

Fix: join release errors, modernize fixture loops without removing assertions, use rooted rename and removal for session deletion, and round only the initial raw bundle gate from 2053.9 to 2054.1 KiB. Same-toolchain main-v2 measures 2103208 bytes versus 2103296 bytes for this branch, an 88-byte increase.

Verification: root and Desktop golangci-lint 2.12.2, Desktop Windows lint, reserved initialization and historical fixture regressions, deletion confinement tests, production bundle guards, and repolint passed. No compatibility coverage was removed.

* test: preserve historical fixture bytes across Windows checkout

Problem: all Windows history partitions failed provenance hashes, and frontend CI still asserted the retired draft-on-new behavior.

Root cause: Git autocrlf rewrote JSONL and metadata bytes emitted by tagged writers. A source-regex chrome assertion duplicated navigation coverage while encoding the superseded product contract.

Fix: disable text conversion only for the two historical fixture trees, retaining original hashes and offsets. Remove the obsolete regex assertion; formal-creation, legacy recovery, remote navigation, and intent-fencing behavior tests remain.

Verification: a core.autocrlf=true checkout preserved all 264 tagged historical file hashes, and app-chrome tests passed. The complete frontend CI runner is running locally; replacement Windows CI will verify the native migration matrix.

* test: align recovery assertions and native observation lifecycle

Problem: frontend tests still expected active empty-session cleanup and hidden content-free drafts; the native browser smoke timed out reading its initial hidden macOS page.

Root cause: those assertions retained the retired draft-first product contract. The native fixture bypassed the observation lease that production host calls acquire before executing DOM scripts.

Fix: assert that cleanup cannot be resumed while manual restore remains available, keep settings-only legacy drafts discoverable with workspace isolation, and acquire/release the existing observation lease for the initial hidden-page snapshot and fixture setup.

Verification: all 505 frontend CI commands passed across the unchanged completed set and remaining set; Electron typecheck and repolint passed. Native macOS browser smoke passed 100 lifecycle cycles with no remaining pages. Timeouts, product code, and coverage counts are unchanged.
2026-09-22 15:54:22 +08:00
SivanCola 866ad4cb8e Fix canonical inbox identity and settled tool state / 修复会话队列身份与工具完成状态 (#10646)
* fix: preserve canonical inbox identity and settled tool status

Problem: canonical sessions could reject queued messages or count another
session's background jobs, while settled tool rows could remain running.
Diagnostic exports could also stop when text redaction damaged JSON.

Root cause: logical session locators were conflated with backing paths, and
sampled job states were treated as the output reader's invocation state.
Diagnostic redaction operated on serialized JSON instead of string values.

Fix: separate inbox identity from storage, use canonical identities for
receipt ownership and runtime metadata, settle background launch receipts,
retain output-reader completion during history restore, and redact JSON
structurally without changing counters or the persisted inbox format.

Verification: root Go suite; focused control and desktop race tests;
sessioninbox and secrets tests; frontend typecheck and transcript suite;
Chromium transcript regression; repolint and git diff --check passed.

* fix: close query workers and reconcile CI validation

Problem: Linux CI raced temporary-directory cleanup with query-cache writes,
and desktop preparation exceeded its initial raw bundle gate by 35 bytes.

Root cause: the mirror-listing fixture retired runtimes without shutting down
its service-owned query workers. Settled tool-state handling adds 104 bytes
to the same-toolchain base and consumes the previous rounded payload margin.

Fix: register terminal service shutdown before directory cleanup and isolate
the listing regression in its own test file. Round only the measured initial
raw bundle budget from 2053.8 to 2053.9 KiB; keep all other gates unchanged.
Also document the unchanged user workflow through PR impact metadata.

Verification: the listing regression passed 20 race-enabled repetitions;
identity lifecycle race tests, transcript and submission regressions, manual
compaction regressions, repolint, and documentation impact validation passed.
Both base and candidate Electron bundles were measured with one toolchain.

* fix: order snapshot eviction independently of clock precision

Problem: Windows CI retained an old read handle after the snapshot bound
was reached, failing the read-snapshot eviction regression.

Root cause: multiple accesses can share one clock sample. Comparing only
timestamps made LRU selection depend on nondeterministic map traversal.

Fix: assign a monotonic access sequence under the store lock at publication,
handle acquisition, and page access. Use that order for eviction and retain
elapsed time for idle and absolute expiry.

Verification: a forced-equal-timestamp regression covering insertion and
page promotion fails against the old implementation. The read-snapshot
suite passed five race-enabled repetitions; desktop go vet and repolint
passed. Native Windows validation remains in the replacement CI run.

* test: range directly over snapshot eviction expectations

Problem: CI's Windows desktop lint rejected the eviction regression loop.
Root cause: the test ranged over len(ordered) instead of the slice itself,
which violates the intrange rule enforced by the pinned linter.
Fix: range directly over ordered, preserving the same eviction assertions.
Verification: run golangci-lint 2.12.2 for Windows amd64, matching CI.
2026-09-22 13:24:35 +08:00
SivanCola f26283afb2 Add native graphics fault recovery for Desktop (#10644)
Problem: Graphics failures can leave the Desktop interface unusable before users can reach hardware acceleration settings.

Root cause: Renderer reloads had no bounded recovery policy, and explicit GPU failure evidence was not retained across launches.

Fix: Classify GPU and renderer failures, bound reloads, provide native compatibility restart with draft-loss confirmation, and retain versioned local fault records. Keep acceleration enabled by default and let users choose temporary or persistent software rendering. Preserve newer faults during dialog acknowledgement and rearm prompts after healthy recovery.

Verification: Electron unit tests, deterministic recovery and lifecycle regressions, typechecks, shell build, repository lint, CI workflow tests, and real macOS Electron recovery smoke tests passed. Windows recovery smoke coverage is wired into CI; real GPU driver failure reproduction remains unavailable.
2026-09-22 12:16:39 +08:00
SivanCola 0d1e6da2ea Fix /compact feedback and history detection / 修复压缩反馈与历史判定 (#10642)
* Fix manual compaction feedback and history detection

Problem:
The /compact command gave no immediate acknowledgement while admission was pending, and sessions could report no compactable history even when completed turns existed.

Root cause:
The composer derived pending compaction state from mutable draft text, while manual compaction bypassed the shared recovery path when automatic compaction had no configured context window. Forced tail sizing also counted fixed context as conversation history.

Fix:
Capture submission identity with the source draft, preserve edits made during admission, show pending compaction progress, and route explicit compaction through the normal overflow recovery ladder. Size forced retention from summarizable history only.

Verification:
Ran root Go tests, focused agent and control tests, frontend type checks, maintenance browser coverage, transcript tests, CSS validation, WAAPI validation, and scroll-writer validation.

* Satisfy the Composer file-size budget

Problem:
The repository lint check rejected the compaction fix because Composer.tsx exceeded its baselined file-size budget.

Root cause:
The initial implementation added seven more physical lines than the legacy oversized-file allowance permits.

Fix:
Keep the submission-kind state inline and compact the new admission expression so the file stays within its existing budget without changing behavior.

Verification:
Ran go run ./tools/repolint, frontend type checks, and the maintenance browser test.

* Keep compaction feedback within the bundle budget

Problem:
The app-memory workflow stopped during its frontend preparation because the new compaction feedback exceeded the initial raw bundle budget.

Root cause:
The first version added verbose localized hint text and redundant pending-state styling to a bundle already close to its enforced ceiling.

Fix:
Shorten the hint while preserving its guidance, reuse the existing compaction icon for progress, and remove CSS declarations already supplied by the parent layout.

Verification:
Ran the full frontend production build and bundle budget, maintenance browser and card tests, and repository lint.

* Record the compaction feedback bundle budget

Problem:
The Electron release build rejected the intended compaction progress feedback even after redundant UI bytes were removed.

Root cause:
The raw startup budget tracks measured feature growth in 0.1 KiB increments and had no remaining headroom for the new admission status and running hint.

Fix:
Record the Linux Electron producer measurement and raise only the raw startup ceiling from 2053.5 KiB to 2053.8 KiB. Keep every compressed, chunk, CSS, locale, and resident-history gate unchanged.

Verification:
Ran the Electron release build with a full REASONIX_COMMIT, repository lint, maintenance browser and card tests, and confirmed the merged GitHub Ubuntu test job passed.
2026-09-22 11:59:23 +08:00
SivanCola bb0d0cbc15 fix(desktop): preserve macOS Dock icon scale (#10641)
Problem: The Electron shell replaced the packaged macOS Dock icon with the full-canvas cross-platform PNG, making Reasonix appear larger than neighboring apps.

Root cause: Dock icon selection shared the Windows/Linux window-icon candidates and ignored whether Electron was running from a packaged app bundle.

Fix: Preserve the packaged ICNS, use matching inset artwork only during development, and cover both selection modes plus asset parity.

Verification: pnpm test; pnpm typecheck; go test appicon_asset_test.go -run 'TestDarwin' -count=1; native Electron Dock icon loading check.
2026-09-22 11:22:51 +08:00
SivanCola a785237e60 fix(transcript): keep the user row ahead of its live output / 修复用户消息被本轮输出插到前面 (#10639)
* fix(transcript): keep a user row ahead of its live output

Problem:
Streamed reasoning, answer text, and tool calls could render above the
user message that started the turn. Later segments of the same turn
then continued below the bubble.

Root cause:
acceptBusiness copied a business record into the follower change before
filling an empty TurnID. Export and reload read the buffer, which had
the turn identity; the live change did not. Desktop attaches
already-mounted output to that turn only when the durable user row
carries the same turnId, so the output stayed on the previous formal
anchor or ahead of the prompt.

Fix:
Publish the assigned TurnID with the change record. Place live rows
after the submission anchor captured at send time when that user row
has no turnId, and move same-turn formal output back behind its user.
Keep a resident turnId when a projection update omits it, and stamp the
active turn onto a live answer that arrived before the identity did.

Verification:
go test ./internal/transcript/ -count=1 -run TestAcceptBusinessPublishesTheTurnIDItAssigns
desktop/frontend src/__tests__/submission-handoff.test.ts

* Preserve sampling order and the offscreen reader during transcript settlement

Problem: assigning turn identities could move later live answers ahead of
earlier samples and tools. Batched deltas could also clear the newer-history
flag while the reader remained on an older page.

Root cause: the user anchor overrode every existing formal predecessor, and
predecessor lookup ignored newly settled rows while retaining reclaimed ones.
The offscreen batch path leaked a temporary history flag into visible state.

Fix: preserve surviving and newly formal predecessors within the owning user
segment, and keep the reader's history flag while updating its offscreen tail.
Document both invariants and add six deterministic regressions. Five fail on
the previous PR head; all pass after the fix, including the ordinary-event
sibling of the batched stream path.

The production raw bundle measures 2102574 B versus 2100795 B on the base
(+1779 B, 0.085%). Set only its existing ratchet to 2053.5 KiB, preserving the
established 186 B Linux producer difference. Other budgets are unchanged.

Verification: transcript Go package; frontend test:typecheck; submission
handoff and output-order suites; test:transcript; submission and transcript
Chromium suites; production build and check:bundle; repolint; diff check.
2026-09-22 11:13:11 +08:00
SivanCola 0d7a103fdd Upgrade browser runtime and session diagnostics / 升级内置浏览器运行时与会话诊断 (#10635)
* feat(browser): add task-bound runtime and scoped diagnostic evidence

Problem: production page injection and invalid captures prevent reliable browser inspection, while session exports lack browser troubleshooting evidence.

Root cause: host-coupled page scripts, missing render ownership and mixed viewport/input facts allow stale observations and callbacks across lifecycle changes.

Fix: bundle isolated semantic scripts; coordinate rendering, cancellation and identity; validate images; add optional query, wait, responsive, picker, diagnostics, recording and recovery capabilities; export bounded session-owned evidence without changing standing tool schemas.

Verification: 279 Electron tests and typecheck; prior full Go and native/package evidence documented in the acceptance report. Native OOPIF CDP stalls and expanded scaled-input routing remain explicit merge blockers; no retry or deadline workaround hides them.

* fix(browser): preserve cancelled capability write error chains

Problem: capability transport failures lost their underlying cancellation identity, and generated Desktop inventory locations were stale.

Root cause: the unknown-outcome wrapper formatted the original error as text instead of wrapping it; browser diagnostic additions shifted inventory source positions.

Fix: wrap both the unknown-outcome sentinel and original transport error, and regenerate the existing Desktop inventory.

Verification: a server-admission cancellation regression failed before the fix and now passes for pointer, viewport and recording writes; browser tests, root/Desktop golangci-lint and inventory tests pass.

* fix(browser): route pointer input through the owning renderer

Problem: zoom and native surface changes could drop clicks or send their press and release to different frames.

Root cause: root CDP input used asynchronous cross-process hit-test data from the previous surface; DOM geometry alone did not establish input routing readiness.

Fix: resolve the current frame in isolated worlds, use direct native input for in-process targets and the owning CDP session for OOPIFs, preserve coordinate scaling, and fence preparation against cancellation and identity changes. Strengthen diagnostic export URL assertions and align the native diagnostic fixture with host observation leases.

Verification: 284 Electron tests, TypeScript and production build pass; macOS production fixture passes three scale transitions and 100 mixed-frame lifecycle cycles. Windows ARM64 and Linux ARM64/Xvfb pass the renderer-owned input matrix. Historical CDP stalls remain a separate merge gate.

* fix(browser): own iframe sessions for the debugger connection lifetime

Problem: adjacent frame observations and actions repeatedly tore down and initialized the same OOPIF protocol domains, exposing world-creation and initialization stalls.

Root cause: target attachment ownership was tied to individual operations instead of the page debugger generation; cancellation and shared initialization therefore lacked a common owner.

Fix: pool target sessions per connection, retain operation-scoped contexts and object groups, retire failed sessions after their last borrower, and clean late attachments without affecting replacement sessions. Preserve the existing one-second protocol budget and unknown write outcomes.

Verification: 287 Electron tests and TypeScript pass; deterministic shared-waiter, cancellation, late-attach and cleanup-order regressions pass. Native macOS validates one attachment across snapshot/ref/input/upload, a fresh attachment after idle reconnect, the scale matrix and 100 mixed-frame lifecycle cycles. Windows ARM64 and Linux ARM64/Xvfb native fixtures pass.

* fix(browser): preserve renderer-local pointer coordinates

Problem: Scaled iframe clicks could hit a broad button away from its observed centre, and nested same-process descendants used the wrong coordinate origin.

Root cause: Child CDP sessions accept renderer-local CSS coordinates, but input reapplied outer Fit scaling and treated the innermost frame as the renderer owner.

Fix: Carry the session-owning frame through the operation runtime and dispatch its unscaled traversal point. Native fixtures assert exact received coordinates for same-process descendants inside OOPIFs.

Verification: 288 Electron tests, typecheck, macOS 100-cycle native fixture, Windows ARM64 and Linux ARM64 native matrices, and the complete macOS packaged browser toolchain passed.

* fix(browser): publish recording completion after cleanup

Problem: macOS native CI could reject a new recording immediately after the previous job reported completed.

Root cause: WebM validation published terminal status before asynchronous file cleanup released the global recording slot.

Fix: Keep cleanup in finalizing and publish the terminal result and artifact at the same synchronous boundary that releases admission. Apply the same invariant to cancellation and interruption.

Verification: A deterministic cleanup barrier fails on the prior implementation and passes for completed and cancelled jobs. All 289 Electron tests and TypeScript checks pass; native and packaged validation continue on this head.

* test(cli): join controller teardown before removing fixtures

Problem: Windows CI failed removing a clear-command fixture because its recovery database was still open.

Root cause: The CLI controller owner helper only requested asynchronous Close; a Running poll did not join final persistence and store release.

Fix: Wait for the existing Closed signal in the shared test owner, matching control tests, and remove the redundant timed cancellation poll.

Verification: Complete internal/cli tests, focused race tests, and Windows ARM64 clear/cancel native test cases passed. Product behavior and test deadlines are unchanged.
2026-09-22 00:00:38 +08:00
F 6a60771539 Merge origin/main-v2 into fix/skill-watch-host-lifetime / 合并上游并解 shutdown 步骤冲突 2026-09-22 00:00:11 +08:00
SivanCola 548b541ef0 Fix desktop history lifecycle and shutdown recovery / 修复桌面历史会话生命周期与关闭恢复 (#10634)
* fix(desktop): decouple history navigation from runtime startup

Problem:
Opening a saved canonical conversation can remain unresponsive even when
its persisted history is independently readable.

Root cause:
Canonical topic activation synchronously resumed the execution controller
before returning the identity needed by frontend history readers. Deferred
surface pruning could also hold the navigation lock while waiting for
runtime mutation. Rapid return clicks could reuse an already-cancelled
startup that would never publish a controller.

Fix:
Validate canonical membership and publish its identity through the shared
asynchronous topic-open lifecycle. Preserve history access on startup or
writer-ownership failure. Do not reuse cancelled startup shells. Acquire
async prune locks without waiting for runtime work under the navigation
lock, then revalidate the activation generation before removing surfaces.
Keep synchronous OpenSession and legacy prune callers on their contracts.

Verification:
Deterministic regressions reproduce blocked startup, blocked prior prune,
and cancelled-startup reselection. Focused desktop race tests, frontend
topic activation/navigation tests, transcript tests and Chromium transcript
checks pass. Root make lint, repolint and desktop golangci-lint pass.
Windows installed-package reproduction remains unverified.

* fix(desktop): read committed runtime states for history lists

History lists collect status from all open and detached controllers. A fresh
sample waiting on an unrelated controller could therefore block another
project's history query before its catalog deadline was established.

Publish an owned immutable snapshot at the controller's semantic commit
boundary and prefer that observation interface in Desktop. Reuse an existing
snapshot when building project nodes instead of sampling twice. Preserve the
fresh-sampling interface for existing callers and embedders.

Cover blocked producers, existing history identity, indexed and fallback
lists, open and detached sessions, cancellation, completion, and ownership of
mutable observations. The same list regression fails with the old reader.

Validation: root go test ./...; full control race suite; 129 Desktop targeted
tests/subtests under race; final four-case history regression and published
state tests; three frontend lifecycle checks; both module linters; repolint.

* fix(desktop): make history lifecycle cancellation and shutdown retryable

Problem:
History project lists could be blocked by live runtime sampling, Stop could wait behind the same observation path, and shutdown could report success while a session runtime remained active.

Root cause:
History loading mixed authoritative session identity with mutable runtime observation, while cancellation and service shutdown treated observation or cleanup errors as part of a best-effort path. The frontend also lacked request-stage evidence for distinguishing queueing, backend latency, and stale-result disposal.

Fix:
Use published runtime snapshots for history and Stop metadata, signal cancellation before observation, preserve failed shutdown checkpoints for retry, and record bounded privacy-safe history-list request stages. Add deterministic cancellation and shutdown regressions plus bilingual runtime-state documentation.

Verification:
- go test ./...
- go test -race . -run 'Test(Shutdown|ConnectionLoss|DetachedShutdown|ReleasedBinding|CancelSession)' -count=1
- go test -race ./internal/control -run 'Test(Cancel|Maintenance|StaleCancelling|PublishedRuntime)' -count=1
- pnpm typecheck, pnpm lint:hooks, pnpm build
- go run ./tools/repolint

* fix(desktop): reconcile history diagnostics with snapshot pagination

Problem: Latest main-v2 snapshot pagination paths had lost request completion diagnostics during merge reconciliation, and the legacy shutdown wrapper did not expose cleanup failures.\n\nRoot cause: The conflict resolution selected the newer snapshot reader without restoring the lifecycle diagnostics and result-returning shutdown helper.\n\nFix: Restore stale/completed project-tree request events for every accepted or discarded page, including preserved complete pages, and route the compatibility shutdown wrapper through the error-aware result path.\n\nVerification: go test -race ./desktop -run 'Test(Shutdown|ConnectionLoss|DetachedShutdown|ReleasedBinding|CancelSession)' -count=1; cd desktop/frontend && pnpm typecheck && pnpm lint:hooks && pnpm build; go run ./tools/repolint

* fix(ci): remove flaky recovery observer deadline

Problem: PR CI failed in golangci-lint on wrapped runtime-binding errors and Windows intermittently timed out while observing a valid recovery publication.\n\nRoot cause: The shutdown classifier used direct equality instead of errors.Is, while the watchdog regression used a five-second runtime observer deadline that also measured platform-dependent durable I/O latency.\n\nFix: Preserve wrapped released-binding classification with errors.Is and wait for the watchdog's durable terminal event before asserting recovery state, removing the unrelated observer timing budget while retaining all commit, recovery, late-worker, and admission assertions.\n\nVerification: go test -race ./internal/control -run 'Test(TerminationCommitWatchdogRejectsLateWorker|CancellationGraceSealsUncooperativeTurnAndPreservesQueue|RecoverySealDropsLateTranscriptOutput|CancelSessionAcknowledgesBeforeStatusBarrier)' -count=5 -timeout=2m; cd desktop && go test -race . -run 'Test(ShutdownServiceFailurePreservesEvidenceAndRetries|ReleasedBindingWarningDoesNotHideOtherShutdownErrors)' -count=1; golangci-lint run --timeout=5m ./...; go run ./tools/repolint

* ci(desktop): retry transient Electron binary fetches

Problem: The macOS desktop job failed while Electron's lazy binary download received an HTTP 504 from the GitHub release endpoint, after all product tests had passed.\n\nRoot cause: The CI step treated a transient dependency transport failure as a deterministic Electron diagnostics failure.\n\nFix: Retry only Electron binary installation errors caused by HTTP 429/5xx responses, up to three bounded attempts; all other performance-smoke failures still exit immediately and remain fatal.\n\nVerification: Reproduced the exact 504 failure from the macOS job log, validated the workflow diff with git diff --check, and preserved the three-minute step budget and product assertions.
2026-09-21 23:39:48 +08:00
F 589a03e6b8 test(skillwatch): a scan-only service makes the host-watcher lifetime testable / 加 scan-only 模式让 host watcher 生命周期可测
Two halves of the host-lifetime watcher could not be unit tested: any real
service spawns a helper through os.Executable(), and in a package test binary
that child is the test binary itself — it would re-run the suite. A zero-value
Service cannot stand in either: Service.backend is an interface, so Close()
ends at s.backend.close() on nil.

Options.ScanOnly refuses every registration, which is how the service already
handles a backend it cannot use: the root degrades to backoff scanning. A test
can therefore hold a real, closable service with no process, and both halves
are now pinned:

- a build must not close a caller-owned service, and must close one it created
- the host closes its watcher at shutdown

Each was verified to fail when the corresponding guard is removed.
2026-09-21 20:06:20 +08:00
F e908c510bf test(desktop): pin the shutdown step that closes the host watcher / 钉住关闭 host watcher 的 shutdown 步骤
shutdownStatus carries no step names, but the coordinator records them in
finished, which a package test can read. This fails if the step is dropped or
renamed, which is the wiring half of the shutdown path. The other half — a real
helper dying — needs a real service, and a test binary cannot create one.
2026-09-21 20:06:20 +08:00
F 1dd79a4a43 test(desktop): pin the host skill watcher's identity / 钉住 host skill watcher 的身份
Two halves of the host-lifetime watcher are cheap to test without spawning a
helper: the App reuses an existing service instead of replacing it (a second
service is a second helper process), and a package test binary never creates one
(that helper would be this test binary, re-running the suite in a child).

Both were verified to fail when the implementation is broken.
2026-09-21 20:06:20 +08:00
F e897ca5921 fix(boot): the skill watcher is host-lifetime again / skill watcher 恢复 host 级生命周期
BuildRuntime created a skill-watch service unconditionally, and
skillwatch.NewService spawns its helper process in the constructor. So every
controller build spawned a 56MB reasonix-desktop.exe helper, and because a
helper lives until its build is closed, rebuilds left several resident at once.

Measured on the desktop before this change: one helper spawn per
OpenSession/ArchiveSessionTarget (1:1), 24 spawns during a three-minute
archiving session, and two helpers resident for 16 minutes.

The service is now caller-owned, mirroring Options.SharedHost: pass
Options.SharedSkillWatchService and Build subscribes this controller's stores
to it instead of creating one. The desktop creates one per App and closes it at
shutdown. A build-owned service is still created and closed as before, so every
other frontend is unchanged.

boot.NewHostSkillWatchService applies the same production-binary gate, so a
package test binary still spawns no helpers.
2026-09-21 20:06:20 +08:00
SivanCola b8e2523f09 fix(desktop): support safe removal of new conversations (#10631) 2026-09-21 18:36:50 +08:00
SivanCola c625bf5e40 Merge pull request #10621 from SivanCola/fix/read-snapshot-pagination
Merge after resolving latest main-v2 conflicts and validating the exact head. All required and supplemental CI checks passed.
2026-09-21 17:36:11 +08:00
SivanCola 1471ef39d9 Merge origin/main-v2 into fix/read-snapshot-pagination
Regenerate the combined Desktop host contract and migration inventory to preserve both snapshot pagination and queued guidance operations. Verified frontend build and bundle limits, snapshot and inbox lifecycle tests, Desktop contract tests, repolint, and inventory freshness.
2026-09-21 17:15:42 +08:00
SivanCola 45374931bd Edit and reorder queued guidance / 编辑并调整待处理消息顺序 (#10626)
* feat(desktop): edit and reorder queued guidance

Problem: queued guidance could not be safely edited or reordered, and edit errors could strand the user draft.

Root cause: the inbox had no versioned edit/move contract or atomic dispatch check; the composer also mixed current-turn guidance with follow-up queue submission.

Fix: add versioned store/controller and local/remote queue commands, an inline editor with draft recovery and sorting, explicit current-turn guidance, pause controls, and lifecycle fences for delayed replies.

Verification: store/controller/Desktop focused tests, race interleavings, frontend typecheck and queue integration tests, production build, and browser preview interaction checks passed locally.

* Fix queue preview test setup and desktop inventory

Problem: Initial PR checks failed in frontend Node tests and desktop inventory validation.
Root cause: The browser preview assumed Vite env in Node, lazy asset hooks registered after tsx, legacy composer fixtures lacked authoritative queue state, and the generated desktop command inventory was stale.
Fix: Guard preview env, preserve optional mock queue capabilities, preload CSS/SVG hooks before tsx, align composer fixtures with next-turn queue semantics, and regenerate inventory.
Verification: 433/434 discovered suites passed before the final remote presentation assertion was aligned; the remaining suite passed in isolation. Typecheck, build, focused Go tests, and inventory validation passed.

* Keep composer fixtures within repository size budgets

Compact the new queue mock state in the existing composer tests without removing assertions. The published head exceeded repolint's existing per-file budget; go run ./tools/repolint and both affected test suites now pass.

* Avoid synthetic Inbox bindings in browser mocks

Inbox capabilities are supplied by the selected host or preview fixture. Skip the attachment fallback for absent Inbox methods, which also keeps the initial raw bundle within the existing budget. Verified with pnpm build and the composer queue fixture.
2026-09-21 17:02:51 +08:00
SivanCola 8a6c71437a Merge origin/main-v2 into fix/read-snapshot-pagination
Resolve the frontend bundle ratchet from the measured combined build and regenerate the Desktop host contract from the merged Go API. Preserve both snapshot pagination and the latest compaction receipt/provider catalog changes.
2026-09-21 16:46:57 +08:00
SivanCola 2c8259c16e test(ci): make snapshot matrix checks deterministic
Problem: the Windows workspace lease test released its first owner after an arbitrary 100 ms delay, and the archive wiring test required an obsolete untrimmed session-path expression.

Root cause: runner scheduling could let the second lease attempt start only after release, while the source assertion drifted behind the stronger normalized path implementation.

Fix: synchronize release on the actual wait callback with cleanup fallbacks, and assert the trimmed session-path wiring that the archive menu now uses.

Verification: go test ./internal/workspacelease -run ^TestOwnersSerializeSameWorkspaceAndNotifyOnce$ -count=100; go test -race ./internal/workspacelease -run ^TestOwnersSerializeSameWorkspaceAndNotifyOnce$ -count=20; golangci-lint run --timeout=5m ./internal/workspacelease/...; pnpm exec tsx src/__tests__/project-tree-archive-race.test.ts; pnpm exec eslint src/__tests__/project-tree-archive-race.test.ts; pnpm exec tsc --noEmit -p tsconfig.test.json; go run ./tools/repolint; git diff --check.
2026-09-21 16:41:52 +08:00
SivanCola ea64efc8e3 Fix duplicate compaction admission and session receipts / 修复重复压缩与会话回执 (#10628)
* Fix duplicate compaction admission and session receipts

Problem: Repeated /compact could show a failed chat bubble and leave stale running state after switching sessions.

Root cause: Management preflight ran outside tab admission, display submissions bypassed typed receipts, and management requests entered the optimistic chat lifecycle.

Fix: Correlate maintenance conflicts under the tab lock, route compact forms through typed admission, and keep management receipts separate from conversational turns while preserving queued follow-up targeting. Add deterministic browser coverage to PR CI.

Verification: Focused Desktop tests and race tests, frontend regressions and browser tests, transcript tests, Go vet, repolint, and bundle gates passed locally. On main-v2, initial raw assets measure 2099087 B before and 2099980 B after (+893 B); the narrowly rounded 2051.0 KiB gate preserves the observed 186 B Linux producer difference.

* Fix maintenance browser runtime lookup in CI

Problem: The transcript browser job could not launch Chromium after the new maintenance regression was added.

Root cause: Playwright downloaded to frontend/.pw-browsers, but a relative environment override resolved from the desktop shell directory.

Fix: Let the maintenance fixture use its frontend-root browser path and document that ownership in the CI step.

Verification: The exact pnpm --dir frontend test:maintenance-browser command passed from desktop with a local frontend browser runtime; workflow YAML parsed and diff checks passed.
2026-09-21 16:26:10 +08:00
SivanCola fee0145b68 fix(ci): satisfy desktop snapshot gates
Problem: the expanded Desktop CI matrix rejected four snapshot lint findings and the test TypeScript build could not observe the recovery marker added by the project-tree window loader.

Root cause: the Desktop module has independent lint gates, while the generic loader return type preserved only the inferred callback shape even though recovery adds replacedSnapshot.

Fix: apply the lint-safe equivalents, remove the superseded wall-clock helper, and expose the optional recovery marker in the loader return contract.

Verification: cd desktop && go test ./...; golangci-lint run --timeout=5m; GOOS=windows golangci-lint run --timeout=5m ./...; pnpm exec tsc --noEmit -p tsconfig.test.json; pnpm typecheck; pnpm exec eslint src/lib/projectTreeWindow.ts; pnpm exec tsx src/__tests__/read-snapshot-window.test.ts; go run ./tools/repolint; git diff --check.
2026-09-21 16:14:59 +08:00
SivanCola 4e0cc95405 Refresh official DeepSeek Flash catalog / 更新 DeepSeek 官方模型列表 (#10624)
* fix(config): refresh official DeepSeek model catalogs

Problem: New official connections offered retired Flash model IDs, while
existing official connections lacked the current deepseek-flash choice.

Root cause: The three protocol presets and runtime backfills had independent
legacy catalogs, and saved model lists were never upgraded once.

Fix: Use the current Flash/Pro catalog for new connections. Migrate existing
official lists once under the config edit lock, preserving selections,
user-managed deletions, historical model references, prices, and unknown TOML.

Verification: Config, boot, CLI, provider, Desktop, frontend contract and
browser checks; interrupted-write and previous-reader round trips; repolint.

* docs: describe canonical DeepSeek Flash catalogs

Problem: The user guides and TOML examples still presented retired Flash IDs
as selectable defaults and described current Flash as text-only.

Root cause: The catalog and vision changes had not been reflected in the
English and Chinese documentation.

Fix: Update new-connection examples, vision behavior, legacy-reference
compatibility, and the one-time version 11 catalog upgrade.

Verification: Reviewed paired English and Chinese sections and ran the
documentation impact guard on the updated PR body.

* test(desktop): align official DeepSeek catalog expectations

Problem: Desktop CI still expected retired official Flash and vision options after the catalog upgrade, and the protocol-upgrade fixture used a new connection that is no longer eligible.

Fix: Assert the current selectable models and telemetry bucket while preserving legacy defaults, and exercise protocol upgrade with an actual legacy single-model connection.

Verification: Focused Desktop Go tests covering all seven failing cases pass locally; git diff --check passes.
2026-09-21 16:09:45 +08:00
SivanCola 5eea67ab49 fix(ci): satisfy read snapshot validation gates
Problem: Linux CI rejected unchecked read-transaction rollbacks, and the Node 24 frontend build exceeded the raw initial bundle ceiling by 84 bytes.

Root cause: the new snapshot read views used deferred Rollback calls without an explicit ignored result, while the bundle ratchet only reflected the smaller local Node 26 producer output.

Fix: explicitly discard the expected rollback result and move only the raw initial asset ceiling to the next measured 0.1 KiB step. All compressed, chunk, CSS, locale, and resident-history limits remain unchanged.

Verification: go test ./internal/historycatalog ./internal/sessioncatalog; go run ./tools/repolint; golangci-lint run --timeout=5m; pnpm typecheck; node scripts/check-bundle-budget.mjs; git diff --check.
2026-09-21 15:50:38 +08:00
SivanCola 25796f606c Merge remote-tracking branch 'origin/main-v2' into fix/read-snapshot-pagination
# Conflicts:
#	desktop/frontend/scripts/check-bundle-budget.mjs
2026-09-21 15:39:03 +08:00
SivanCola 5721662c75 fix(ci): account for snapshot pagination bundle
Remove a duplicate project-tree key helper and record the measured 698-byte startup payload increase from immutable snapshot pagination. Keep gzip, chunk, CSS, locale, and resident-history limits unchanged.
2026-09-21 15:32:58 +08:00
SivanCola ed3294a53b docs(desktop): refresh host inventory
Record the snapshot release command and updated owners after splitting pagination code.
2026-09-21 15:17:50 +08:00
SivanCola e73d231e29 refactor(desktop): split snapshot read owners
Problem: snapshot pagination orchestration exceeded repository ownership limits in already-large components and functions.\n\nRoot cause: presentation, catalog search capture, snapshot build publication, and metadata paging remained embedded in their callers.\n\nFix: extract those responsibilities without changing cursor, generation, release, or migration semantics.\n\nVerification: repolint, focused Go and frontend tests, typecheck, ESLint, and focused race tests.
2026-09-21 15:15:36 +08:00
SivanCola ec0df71ec1 feat(desktop): simplify archived conversation management / 简化归档会话管理 (#10623)
* feat(desktop): simplify archived conversation management

Problem: Archive management repeated page headings and placed restore, delete, refresh, and clear actions together on every row.

Root cause: The management page and archived list each owned overlapping chrome, while row actions had no preview-centered hierarchy.

Fix: Use one archived page heading and a split list/preview layout. Move bulk actions to the existing menu, keep single-session actions with the read-only preview, and retain lifecycle generation, retry, and legacy placeholder behavior. Make bulk confirmation explicit even with one entry and bound dialog height on short windows.

Verification: Focused archive lifecycle, confirmation generation, placeholder, pagination, and bulk-scope tests; frontend typecheck and lint; desktop browser layout and interaction checks.

* docs(desktop): update archive management guide

Problem: The management guide still described an older archive layout and filtering model.

Fix: Document the split preview, menu actions, responsive breakpoint, and full-scope clear confirmation.

Verification: Reviewed against the implemented archive page and updated the PR documentation-impact declaration.
2026-09-21 15:09:07 +08:00
SivanCola 9d1b86adbe Merge main-v2 into read snapshot pagination
# Conflicts:
#	desktop/frontend/src/generated/desktopContract.generated.ts
2026-09-21 14:46:53 +08:00
SivanCola c84b4389bf Restore complete transcript turn navigation / 恢复完整会话轮次导航 (#10619)
* Restore complete transcript turn navigation

Problem: Follow v2 limited the turn rail to the bounded resident transcript window, so long conversations lost direct access to unloaded turns.

Root cause: the rail depended on runtime projection state and reused sequential history paging for jumps instead of reading a durable directory and locating by stable message identity.

Fix: add a paged durable history outline for local and remote sessions, sparse frontend caching, direct target-window loading, cancellable scroll transactions, compatibility handling, and full interaction documentation.

Verification: root and Desktop Go tests, focused race tests, frontend typecheck/lint/transcript suites, production build budgets, and 25,000-turn Chromium/Electron interaction fixtures.

* Fix history outline lint findings

Problem: the first PR head failed lint-code on unchecked rollback results, loop string concatenation, and numeric HTTP status literals.

Root cause: the new outline implementation and tests did not follow the repository golangci rules enforced in CI.

Fix: explicitly ignore rollback cleanup errors, use strings.Builder for query-plan assembly, and use http.StatusOK.

Verification: golangci-lint run --timeout=5m; go test ./internal/session ./internal/serve -run TestHistoryOutline; go run ./tools/repolint.

* Refresh Desktop migration inventory

Problem: CI detected stale Desktop migration inventory after adding the local and remote history outline commands.

Root cause: the generated inventory was not refreshed after the new App methods changed the command set and source locations.

Fix: regenerate both inventory artifacts with tools/desktopinventory.

Verification: go test ./tools/desktopinventory; go run ./tools/repolint; git diff --check.

* Stabilize legacy turn rail identities

Problem: the transcript browser CI reported duplicate React keys during optimistic-to-persisted submission handoff.

Root cause: hosts without history-outline-v1 only guarantee resident turn order, while transient projections can reuse an absolute history turn number; the fallback rail used that number as its React identity.

Fix: number the loaded-only fallback rail by resident order while leaving persistent outline ordinals unchanged.

Verification: pnpm test:submission-browser; pnpm test:submission-electron; pnpm test:transcript; pnpm test:typecheck; pnpm lint:hooks; pnpm check:scroll-writer; pnpm build; go run ./tools/repolint.

* Stabilize transcript async wait budget
2026-09-21 14:38:44 +08:00
SivanCola c21a13ce1e fix(desktop): stabilize paged session reads
Problem: Session, project and history lists could report stale_cursor or mix rows while metadata changed, background refreshes ran, or users switched between projects and sessions.

Root cause: Pagination validated mutable live metadata and several UI paths reused cursors, request generations and preview state across logical read identities.

Fix: Add bounded immutable read snapshots with lifecycle fences, consistent catalog read views, whole-window frontend staging and recovery, explicit snapshot release, and identity-scoped switch handling across project and history lists.

Verification: Focused Desktop and catalog tests, deterministic A-B-A switching regressions, race tests, frontend typecheck/lint, Chromium independent-session coverage and Windows cross-compilation passed. The broad Desktop suite remains limited by a pre-existing transcript performance hang and total runtime.
2026-09-21 14:38:25 +08:00
SivanCola 61cc393b20 fix(windows): restore Git Bash and shell settings / 恢复 Git Bash 与 Shell 设置 (#10617)
* fix(windows): restore Git Bash and shell settings

Problem:
- Windows settings no longer exposed the earlier shell layout or Git Bash runtime.
- WSL aliases and retained PowerShell paths could misroute interpreter selection, while custom runtimes could be absent from detection.
- ConPTY could corrupt Git Bash UTF-8 output and completion markers.

Root cause:
- Shell resolution and capability inventory applied different path compatibility rules.
- Machine-driven Windows Bash sessions reused a terminal-oriented transport.
- The settings presentation had diverged from the runtime state model.

Fix:
- Restore Git Bash discovery, selection, and the compact sandbox settings surface.
- Centralize configured-path compatibility, reject WSL aliases, and align inventory with resolution.
- Use a tracked persistent pipe for machine-driven Git Bash sessions and add Windows contract coverage.

Verification:
- go test ./...
- go test -race ./internal/persistentshell ./internal/sandbox -count=1
- focused desktop shell support tests
- frontend typechecks, lint, CSS and application-layer checks, and 154 focused assertions
- native Windows 11 ARM64 shell and integration regressions

* chore(frontend): calibrate sandbox bundle budget

Problem:
- The restored localized sandbox labels exceeded the raw startup budget by 1606 bytes.

Root cause:
- The raw budget was calibrated before the new runtime, status, and write-scope labels entered the startup locale payload.

Fix:
- Ratchet only the raw initial asset budget to the measured payload with bounded headroom.
- Keep compressed, chunk, stylesheet, locale, and resident-history limits unchanged.

Verification:
- pnpm build
2026-09-21 13:14:34 +08:00
SivanCola ba68484b00 Fix browser runtime binding and capability status (#10615)
Problem:
Built-in browser tools could be discovered as ready but fail at execution
after a desktop runtime detached, reattached, or moved to another surface.

Root cause:
Long-lived browser executors captured the surface ID used at boot instead of
following the runtime-owned event sink. Capability discovery also read the
stable catalog without applying the live execution-context gate.

Fix:
Resolve browser grants through the runtime binding, revoke grants safely on
surface transfer and cleanup, and pass candidate sinks through every rebuild
path. Share contextual availability diagnostics between discovery and
execution while keeping the stable catalog and provider tool schemas intact.

Verification:
Added deterministic detach, reattach, replacement, cleanup, preview, and
concurrent-transfer regressions plus discovery/execution consistency tests.
Root and Desktop suites, focused race tests, vet, and the Windows amd64
cross-compile pass.
2026-09-21 13:08:27 +08:00
SivanCola 5c4b343a4a fix(desktop): preserve legacy sidebar behavior on read-only snapshots / 保留只读快照的旧数据兼容性 (#10613)
* fix(desktop): keep project tree snapshots read-only

* fix(desktop): preserve legacy sidebar pins and manual order

Problem: Read-only project tree snapshots could hide pinned historical sessions in unregistered workspaces and show unmigrated sessions in the wrong order.

Root cause: Historical filtering required registry ownership, shells discarded legacy organization preferences, and normalization omitted global session-order fields.

Fix: Compare snapshot-cached physical roots without requiring owners, share a pure legacy organization planner between migration and shell projection, prefer migrated organization, and retain existing global ordering fields.

Verification: Affected project-tree, organization, historical, pin and topic-index suites passed; workspace-state tests, focused race regressions, golangci-lint and diff checks passed. Tests verify unchanged registry bytes, repeated refresh, expansion consistency, alias ordering and migrated preference precedence.
2026-09-21 12:16:04 +08:00
SivanColaandf1shsmell 8ad1f01bdf fix(desktop): reduce history and multi-session resource usage (#10603)
Problem: Large historical catalogs and concurrent sessions amplify CPU,
allocation, rendering, and layout persistence work during navigation.

Root cause: Settled reads repeat registry decoding and write transactions,
unchanged discovery invalidates catalogs, and runtime events resample unrelated
controllers. Evicted transcript entries can retain controller references.

Fix: Cache validated metadata and bounded topic orders, keep display reads
separate from operation journals, coalesce global runtime updates and layout
writes, isolate background rendering, release evicted projections, and refresh
dirty catalog directories with periodic fallback checks.

Integration: Preserve canonical transcript structural reconciliation during body
reclamation and derive metadata cache paths from confined storage. Calibrate
the raw bundle ratchet using the measured 1551-byte increment.

Verification: Root Go packages and all seven inventory-checked Desktop test
groups passed, with focused race tests, transcript and navigation regressions,
TypeScript checks, lint, Chromium interaction checks, and Windows cross-build.
Native Windows CPU reduction has not been measured.

Co-authored-by: f1shsmell <[email protected]>
2026-09-21 08:15:39 +08:00
SivanCola 3275eb3adc fix(release): add early contracts and site-only recovery / 前置契约检查与官网独立恢复 (#10602)
* fix(release): preflight executable contracts and recover site independently

Problem: late public-access failures required full release recovery and could leave incomplete site evidence. Root cause: static control checks did not execute critical contracts, while website reconciliation lived only inside publication YAML. Fix: check offline contracts and runner manifest access early, centralize public-state reconciliation, and add protected site-only recovery with a shared lock, owned Pages correlation, final ownership check, and persistent failure ledgers. Verification: executable preflight, shell-boundary failure regressions, release workflow contracts, CI path tests, and actionlint passed without live publication.

* fix(release): serialize legacy and site recovery

Problem: legacy recovery could overlap site reconciliation. Root cause: its lock was scoped per version instead of sharing the publication lock. Fix: join the common stable-release-publication concurrency group. Verification: workflow lint and site recovery lock regressions passed.
2026-09-21 07:44:11 +08:00
SivanCola 449b785022 fix(release): verify the tag publisher before approval / 审批前校验标签发布身份 (#10601)
* fix(release): bind atomic tag activation to an authorized publisher

Problem: release approval could precede a GH013 tag-creation failure. Root cause: activation inherited the default Actions token rather than an explicitly authorized maintainer identity. Fix: require a dedicated credential, inspect inherited rulesets before approval, bind the actor ID, and recheck before the existing atomic push without checkout-token fallback. Verification: identity CLI and policy regressions, local atomic tag tests, release workflow contracts, CI path tests, and actionlint passed.

* test(release): assert the dedicated tag credential boundary

Problem: the old workflow regression required a writable default Actions token during activation. Root cause: the assertion encoded the retired credential implementation rather than the approval and identity boundary. Fix: require read-only default permissions, explicit publisher credentials, actor binding, no checkout fallback, and activation after approval. Verification: CI workflow and identity regressions passed together.
2026-09-21 07:43:58 +08:00
SivanCola 355fa9fc12 Fix tool result identity, state handoff and historical projection (#10590)
Sparse completion events and later history hydration could erase durable tool identity, append duplicate result rows after the final answer, and downgrade completed state. Preserve canonical metadata, repair uniquely matchable legacy checkpoint identities, and reconcile bounded resident records through one conflict-aware projection with stable tool keys and anchored local notices.

Keep distinct conflicting formal results independently readable and prevent old session requests from updating a new binding. Preserve pagination, content-on-demand and existing resident-memory limits without changing provider payloads, prompts, session exports or the persistent message format.

Harden CI at the observed failure boundaries: carry frontend producer attempts into artifact consumers, attribute actual bounded Markdown Worker listeners, synchronize reconnect assertions with retained lifecycle publications, and use a fresh bounded functional wait for packaged upgrade history. Retain native geometry, visible history, restart integrity and independent startup gates.

Verification includes transcript/store/follower/reducer regressions, 240- and 1,000-turn browser scenarios, native Electron layout, backend and race suites, packaged Windows first-import/restart acceptance, and all three full memory-screening shards. Real remote-host validation remains unavailable; binding tests do not replace it. No release is published by this change.
2026-09-21 05:30:07 +08:00
SivanCola f0f11a6767 Merge pull request #10598 from SivanCola/codex/compaction-maintenance
Fix compaction lifecycle, cancellation, and recovery / 修复压缩生命周期、取消与恢复
2026-09-21 04:49:31 +08:00
SivanCola b1686fee8b Keep checkpoint reload verification independent of cancellation latency
Problem: Windows checkpoint CI passes the durable replay assertions but fails waiting five seconds for its unrelated cleanup TurnDone event.

Root cause: A reload-while-writing test adds a cancellation latency contract after its actual invariant has already been verified.

Fix: Use the owned test controller teardown to cancel and join all workers and stores. Remove only the unrelated timing assertion and duplicate cleanup; retain completed-tool and active-writer replay assertions.

Verification: The checkpoint regression and cancellation tests pass three race-enabled iterations. Dedicated cancellation tests remain intact.
2026-09-21 04:29:13 +08:00
SivanCola c0afd759a2 Use the durable inbox wait contract for steer test admission
Problem: Ubuntu CI fails before the steer ordering assertion because provider admission exceeds an unrelated one-second startup assumption.

Root cause: Durable inbox and lifecycle checkpoint I/O runs on shared CI storage; these correctness tests accidentally impose a latency SLA.

Fix: Share the existing bounded inbox-dispatch wait and state diagnostics across both steer-provider tests, and always close the controller before joining autosave cleanup. Keep durable ordering and exactly-once assertions unchanged.

Verification: Both affected regressions pass 10 race-enabled iterations; no production timeout or behavior changes.
2026-09-21 04:20:17 +08:00
SivanCola 733df2d45a Release CI aggregation jobs when their workflow is cancelled
Problem: Superseded runs retain queued always() aggregates and metrics, blocking replacement CI until manually force-cancelled.

Root cause: Unconditional aggregation survives workflow-level cancellation even after all useful worker jobs exit.

Fix: Gate aggregates and metrics on !cancelled(). This explicit status function still runs against failed dependencies, preserving fail-closed result assertions for live workflows.

Verification: 35 CI workflow/path contract tests and actionlint pass, covering cancelled aggregation and failed dependency execution.
2026-09-21 04:08:52 +08:00
SivanCola d8b3a1c504 Capture hidden Electron layout evidence through the native host
Problem: Windows layout CI times out taking its final screenshot after all 32 geometry assertions pass.

Root cause: CDP capture waits for a compositor frame from the intentionally hidden native fixture window.

Fix: Use Electron webContents.capturePage for native fixtures and reject empty images. Preserve Chromium screenshot capture, all geometry assertions, and evidence output without showing the window.

Verification: Native Electron layout suite passes all 32 scenarios and writes a nonempty 2560x1800 PNG; diff checks pass.
2026-09-21 04:06:41 +08:00
SivanCola 5ae4a16c5c Align merged startup budget with the Linux producer measurement
Problem: Desktop preparation rejects the integrated payload by two bytes.

Root cause: The Linux Electron producer emits 2,084,968 bytes while the previous one-decimal ceiling allows 2,084,966 bytes.

Fix: Document the measured platform result and retain the next tenth of a KiB, leaving 100 bytes of headroom. All gzip, per-chunk, CSS, locale, and structural gates remain unchanged.

Verification: Production build and bundle checks pass locally after sharing maintenance lookup and classification logic; the exact failing CI measurement establishes the bounded producer ceiling.
2026-09-21 03:50:25 +08:00
SivanCola 25ba59e179 Merge current Desktop delivery and loading changes
Problem: New main-v2 changes conflict with generated Desktop contracts and inventory.

Root cause: Both branches extend the shared host and transcript surfaces.

Fix: Regenerate contracts and inventory from combined sources, retain both behaviors, and share pending-compaction lookup and state classification without increasing bundle gates.

Verification: Affected root and Desktop suites, generated inventory, frontend production build, test typecheck, transcript suite, and Chromium transcript interaction/performance checks passed.
2026-09-21 03:40:11 +08:00
SivanCola 80c4c5ed39 Merge pull request #10600 from SivanCola/fix/cloudflare-release-manifest-access
chore(release): inspect Cloudflare download challenges / 在 Actions 内诊断下载挑战
2026-09-21 03:35:07 +08:00
SivanCola 869e8d1d41 Merge pull request #10599 from SivanCola/fix/session-loading-flicker
Delay session loading feedback to prevent switching flicker / 延迟会话加载提示以避免切换闪屏
2026-09-21 03:34:27 +08:00
SivanCola 3e8a80c65b Merge pull request #10596 from SivanCola/feature/html-browser-deliverables
Unify built-in HTML previews and responsive workspace delivery / 统一内置 HTML 预览与响应式工作区交付
2026-09-21 03:30:48 +08:00
SivanCola 54434989d9 Synchronize detached shutdown timeout test with the blocked save
Problem: CI can time out before the shutdown fixture reaches saving, despite the detached timeout returning the correct current phase.

Root cause: a 250ms deadline included earlier lifecycle checkpoint I/O, so the assertion depended on runner and filesystem speed.

Fix: start the shutdown transaction, wait for the blocked-save channel, then exercise the existing bounded detached waiter against that same transaction. Retain the 250ms timeout and interrupted-evidence assertions; always release and join the worker during cleanup.

Verification: the exact regression passes 10 iterations under the race detector, Desktop golangci-lint 2.12.2 passes, and repolint remains clean. Production shutdown behavior is unchanged.
2026-09-21 03:24:23 +08:00
SivanCola 580ac1fc5d chore(release): inspect Cloudflare manifest challenges inside Actions
Problem: the public updater manifest challenges hosted release verification.

Root cause: the responsible Cloudflare policy and existing token permissions need authoritative inspection.

Fix: add a main-v2-only read-only diagnostic workflow with scoped, sanitized output.

Verification: Node syntax, diff checks, and mocked request/credential-boundary validation pass.
2026-09-21 03:15:14 +08:00
SivanCola 4160cdf68e Merge origin/main-v2 into feature/html-browser-deliverables 2026-09-21 03:12:17 +08:00
SivanCola 058d3ac33c Merge release observation fixes and regenerate desktop inventory
Integrate current main-v2 without changing compaction behavior. Resolve the generated inventory overlap by rebuilding it from the combined source tree rather than selecting either stale copy.

Verification: desktop inventory classification and freshness tests pass; no compaction implementation files conflicted.
2026-09-21 03:05:16 +08:00
SivanCola e0b7634a12 Use modern Go iteration in maintenance code and regressions
Problem: CI lint rejects legacy integer and reverse slice loops, and Desktop lint reports an unused capability constant.

Root cause: new maintenance helpers did not use the repository-pinned Go iteration conventions; the remote capability declaration had no consumer.

Fix: use integer range and slices.Backward while preserving traversal and update order, and remove the unused declaration. Server capability advertisement is unchanged.

Verification: golangci-lint 2.12.2 passes in root and Desktop with isolated caches; targeted agent/control and Desktop maintenance tests pass; repolint remains clean.
2026-09-21 03:04:16 +08:00
SivanCola e86ef5febc fix(desktop): delay session loading feedback
Problem: Switching conversations briefly replaced the transcript with duplicated history-loading and recovery surfaces, causing a visible flash for fast loads.

Root cause: Normal hydration reused the error-recovery presentation and exposed loading feedback immediately across local, remote, and transcript surfaces.

Fix: Centralize staged loading feedback with a 250 ms grace period, preserve cached transcript content, reserve recovery UI for actual failures, and fence timers by session identity.

Verification: pnpm build; pnpm test:session-loading-browser; focused session loading/recovery and tab-switch tests; pnpm test:transcript; pnpm test:transcript-browser.
2026-09-21 03:00:29 +08:00
SivanCola 423f3a39b5 Merge pull request #10597 from SivanCola/fix/release-site-pointer-validation
fix(release): verify public site ownership before recovery / 修复恢复发布的官网归属校验
2026-09-21 02:59:49 +08:00
SivanCola 8fdec0ad5e Refresh desktop inventory after upstream recovery integration
Problem: the current combined tree introduces the upstream historical recovery writer.lock persistence entry, leaving the checked-in inventory stale.

Root cause: generated inventory predates the merged recovery implementation.

Fix: regenerate the classified inventory and source locations; record the final extracted frontend payload measurement without changing its budget.

Verification: the final root suite passed every package except the stale inventory guard; go test ./tools/desktopinventory passes after regeneration. Frontend assets and all existing budget limits pass.
2026-09-21 02:54:57 +08:00
SivanCola 0f6b629a1f Extract maintenance projections and lifecycle helpers within code budgets
Problem: the compaction implementation exceeded existing file, function, complexity, and scalar-state limits.

Root cause: maintenance behavior accumulated inside general-purpose controller, transport, event, and UI dispatchers.

Fix: separate immutable operation identity from result state, share duplicate-command conflict classification, centralize desktop operation row conversion, and extract CLI controls, frontend reducers, event types, prompt projection, and synchronous cleanup. Preserve existing behavior and all structural baselines.

Verification: repolint, affected Go tests, Desktop maintenance/history/contract/submission tests, frontend typechecks and transcript tests pass; control, CLI, and session maintenance race tests pass. Rebuilt frontend assets satisfy the combined startup budget.
2026-09-21 02:51:23 +08:00
SivanCola dddcc52f8c test(desktop): accept wrapped takeover errors
Use errors.Is for the file preview takeover assertion so Desktop lint accepts wrapped sentinel errors on every target.

Verification: focused file browser preview tests; desktop golangci-lint on the host and GOOS=windows; repolint.
2026-09-21 02:49:33 +08:00
SivanCola 503d373ffc fix(release): retain failed observation diagnostics and refresh inventory
Problem: public manifest failures lacked endpoint diagnostics, and base Desktop inventory had drifted.

Root cause: HTTP errors were terse and recent Desktop source changes were not reflected in generated indexes.

Fix: report selected public response headers on failure and regenerate the inventory without changing Desktop behavior.

Verification: executable observer regressions and desktopinventory tests pass.
2026-09-21 02:46:59 +08:00
SivanCola dfa6aab998 Merge current main-v2 into compaction maintenance fixes
Preserve upstream native service shutdown and historical-session recovery behavior. Resolve the startup budget overlap using the measured 2084732-byte combined payload and the existing 186-byte Linux producer allowance; other budget gates remain unchanged.

Verification: merged Desktop maintenance/history/contract tests, frontend test typecheck, transcript suite, build static gates and Vite build passed. The measured combined bundle passes the reconciled budget.
2026-09-21 02:45:52 +08:00
SivanCola 65067d2bbd Fix compaction ownership, cancellation, and durable operation recovery
Problem: manual compaction could appear stuck, ignore Stop, lose its outcome after reload, or overlap other foreground work. Automatic recovery could install a projection after cancellation.

Root cause: maintenance bypassed shared runtime ownership and turn-independent persistence; client history and snapshots lacked consistent operation ordering.

Fix: centralize maintenance admission, cancellation, commit fencing, durable lifecycle records, and queue handoff. Reconcile versioned operation cards across desktop, remote, and CLI; classify empty history safely and preserve recovery details.

Verification: root and Desktop Go suites, deterministic maintenance regressions, focused Go race tests, frontend typechecks, transcript tests, production build, contract checks, and Chromium interaction/scroll benchmarks passed. Updated English and Chinese behavior documentation.
2026-09-21 02:44:15 +08:00
SivanCola bb13c061c4 fix(release): fail closed when observing public site ownership
Problem: recovery could skip Pages and report success after an HTTP 403.

Root cause: a curl pipeline hid the failed read, and any unequal pointer was treated as newer.

Fix: share a checked manifest observation and require a strictly newer stable version before preserving the site.

Verification: executable HTTP-error and version-order regressions, publication ledger tests, and full release workflow contracts pass.
2026-09-21 02:41:59 +08:00
SivanCola 1c3a1828a9 fix(frontend): keep dock tab actions semantically distinct
The responsive workspace close action reused the add-tab class, so strict browser selectors matched both controls.

Introduce a shared visual action class while preserving unique add and collapse classes.

Verification: pnpm build; pnpm test:app-browser; pnpm typecheck; pnpm test:workspace.
2026-09-21 02:40:28 +08:00
SivanCola 879000bc08 Merge origin/main-v2 into feature/html-browser-deliverables
Bring the PR head onto the current main-v2 after historical version recovery changed Desktop inventory inputs.

Regenerate the Desktop migration inventory so the merged tree satisfies its freshness guard.

Verification: go test ./tools/desktopinventory ./internal/browser ./internal/boot; go test ./internal/workspacestate; focused desktop historical and browser preview tests; pnpm test:workspace; go run ./tools/repolint.
2026-09-21 02:30:33 +08:00
SivanCola c11d5117c4 test(frontend): load code styles in workspace interaction tests
The workspace resize test now reaches the lazily loaded code viewer, whose syntax stylesheet is a browser asset. Run that authoritative package-script entrypoint with the existing CSS stub loader so Node CI preserves the real component dependency without trying to execute CSS.\n\nVerification: pnpm test:workspace, CI plan inspection, and repolint passed.
2026-09-21 02:20:24 +08:00
SivanCola 33d67cde9e Merge pull request #10594 from SivanCola/fix/historical-session-branch-import
Fix historical version branch imports / 修复历史版本分支导入
2026-09-21 02:15:23 +08:00
SivanCola 2d03420e01 refactor(desktop): satisfy preview delivery lint budgets
Extract file preview request validation so the shared browser preview path stays within the repository complexity budget. Keep workspace HTML routing concise enough to avoid growing the existing oversized panel baseline.\n\nVerification: repolint, frontend typecheck and ESLint, and focused Desktop preview/browser tests passed.
2026-09-21 02:12:27 +08:00
SivanCola e6a2bb5be8 feat(desktop): adapt workspace dock to window size
Problem: The right dock opened at a fixed width and narrow-window presentation did not consistently accommodate browser previews.

Root cause: Dock preferences and constrained widths were mixed during restoration, and responsive styles were loaded only with the file panel.

Fix: Initialize new dock widths at 45 percent of the viewport, preserve saved preferences, protect chat space, and cap split widths at 70 percent. Add responsive navigation collapse and full-width dock presentation below 768px, shared panel styles, and a panel-close action.

Verification: Typechecks, lint, CSS checks, production build and unchanged bundle budgets passed. Layout and browser surface tests, transcript regressions, and Chromium and isolated Electron responsive dock checks passed, including pointer and keyboard resizing, terminal coexistence, reopening and reload restoration.
2026-09-21 02:06:21 +08:00
SivanCola 86d2ef9906 feat(frontend): refine themed streaming code blocks
Problem: Chat code fences lacked a persistent language toolbar, theme-aware semantic colors, and stable highlighting while streamed content grew.

Root cause: Code block controls lived inside the source surface, streaming fence tails bypassed the highlighted viewer, and syntax styles were loaded in the initial app stylesheet.

Fix: Add a framed Markdown code viewer with language and copy actions, contrast-corrected semantic palettes, retained highlighted line hosts, and a shared streaming fence path. Lazy-load code block and syntax styles with the viewer.

Verification: Frontend build and bundle budgets, typechecks, transcript and Chromium regressions, focused Markdown and theme tests, and 33 browser theme cases passed; minimum measured contrast was 4.87:1.
2026-09-21 02:06:20 +08:00
SivanCola 72d85bcf29 feat(desktop): unify HTML browser previews and deliverable cards
Problem: HTML file entry points could open externally or target tabs inaccessible to the current agent. Deliverable menus were clipped and changed-file summaries could misreport net edits.

Root cause: navigation, preview ownership and resource lifecycle were split across UI and executor paths. Card menus lived inside the transcript scroller and fallback counts were derived from mutation calls.

Fix: share authorized task/session-bound file previews, expose on-demand browser_preview, route HTML internally, and refresh/release preview resources. Add frozen-diff summaries and split file actions, portal menus, accurate incomplete statistics, and explicit replay-page limitations.

Verification: frontend typechecks, transcript and focused workspace tests, Chromium transcript regression, root browser/boot tests, focused Desktop Go preview/browser tests, Electron typecheck and grant/host-call tests passed. Full native HTML/agent end-to-end acceptance remains unverified.
2026-09-21 02:06:20 +08:00
SivanCola 3241ae3403 Merge pull request #10595 from SivanCola/fix/release-candidate-output-contract
fix(release): preserve candidate SHA outputs / 修复候选 SHA 输出契约
2026-09-21 02:01:55 +08:00
SivanCola 261ef4d110 fix(release): define candidate workflow outputs explicitly
Problem: publication receives an empty source SHA after provenance checks. Root cause: generic camel-case conversion splits SHA into s_h_a, violating consumer output names. Fix: explicitly map all workflow fields and reject malformed source/control identities. Verification: real CLI GITHUB_OUTPUT regressions cover publish and rehearsal, fail with the original conversion, and pass with the repair; candidate and release workflow contracts pass.
2026-09-21 01:57:59 +08:00
SivanColaandFucha 34360520ad fix(desktop): simplify reservation conflict lookup
Problem:
The Desktop Linux and cross-platform lint jobs rejected the historical-version reservation scan.

Root cause:
The new lookup manually iterated over a session ID slice, which the pinned modernize linter flags when slices.Contains expresses the same invariant.

Fix:
Use slices.Contains for the reservation membership check without changing conflict behavior.

Verification:
Desktop and Windows golangci-lint, workspace-state and historical-version tests, the focused race test, and repolint passed.

Co-authored-by: Fucha <[email protected]>
2026-09-21 01:56:40 +08:00
SivanCola fe40c2b94c Merge pull request #10593 from SivanCola/fix/windows-close-transcript-diagnostics
Fix Windows shutdown races and preserve transcript diagnostics / 修复 Windows 退出竞态并完善同步诊断
2026-09-21 01:45:05 +08:00
SivanColaandFucha 3acf299a8f fix(desktop): recover historical version imports
Problem:
Historical source updates could publish content but fail registry adoption, while the UI swallowed terminal failures and appeared inert.

Root cause:
Ordinary and versioned imports reused operation identity, restart replay lost version identity, and target recovery searched the unversioned source key.

Fix:
Give version imports distinct compatible identities, recover exact reservations, validate and atomically adopt safe content-ready targets as version branches, preserve the original mapping, and surface progress and errors in the banner.

Verification:
Desktop migration/recovery/workspace regressions, race tests, frontend interaction and type checks, browser bench coverage, and Windows amd64 GUI cross-build passed.

Co-authored-by: Fucha <[email protected]>
2026-09-21 01:42:09 +08:00
SivanCola bafaee2e0c fix(desktop): preserve packaged ready log contract
Problem:
The Windows packaged smoke timed out even though the shell log showed that the service had reached ready.

Root cause:
The new build-correlation fields changed the stable ready prefix from 'generation X, pid Y' to equals-delimited fields, so the packaged smoke parser never recognized readiness.

Fix:
Restore the existing generation and pid prefix, append version/channel/commit after it, and centralize the parser with a regression that accepts added fields while rejecting incompatible delimiters.

Verification:
- node --test desktop/packaging/smoke-poll.test.mjs
- cd desktop/electron && pnpm typecheck && pnpm test
- go run ./tools/repolint
- git diff --check
2026-09-21 01:20:46 +08:00
SivanCola 29bcc959ca Merge pull request #10592 from SivanCola/test/shutdown-cancellation-order
test(desktop): observe shutdown cancellation / 验证退出取消事件顺序
2026-09-21 01:12:51 +08:00
SivanCola c52f5354e8 fix(frontend): keep transcript diagnostics lazy
Problem:
The app-memory CI build loaded the transcript diagnostic publisher into the startup bundle and exceeded the bounded raw asset budget.

Root cause:
Crash formatting imported the publisher module only to read its current summary, which made the full publish path eager. Repeated native host adapters also added avoidable startup code.

Fix:
Store the bounded active transcript summary on the renderer global, read it without a static module edge, reuse formatted diagnostic strings, and share stable native host adapters and window error handling.

Verification:
- REASONIX_APP_MEMORY_PROFILE=full pnpm build
- pnpm typecheck
- pnpm test:typecheck
- pnpm test:window-state
- transcript-follow-client.test.ts
- windows-maximised-sync.test.tsx
- crash-reporting.test.ts
- go run ./tools/repolint
- git diff --check
2026-09-21 01:09:45 +08:00
SivanCola 5784628835 refactor(desktop): keep crash diagnostics within lint budget
Problem: Adding transcript context to the crash payload pushed crash.ts beyond its repository file-size allowance.

Root cause: The crash module duplicated access to the diagnostic snapshot and expanded formatting across additional local helpers.

Fix: Export the bounded snapshot reader from transcriptDiagnostics and keep the crash payload integration compact without changing its visible content.

Verification: Repolint is clean, frontend source and test typechecks pass, and all 22 transcript-follow diagnostics regressions pass.
2026-09-21 00:57:57 +08:00
SivanCola 5a40742476 docs(desktop): record shutdown review regression results
Problem: The follow-up race review and its exact evidence were not recorded with the Windows validation boundary.

Root cause: The original note predated the additional preparation, remote-follower, generation-cleanup, and diagnostic-aggregation regressions.

Fix: Record the repaired invariants and local verification matrix in both validation documents.

Verification: English and Chinese notes agree, links resolve, and repository diff checks pass.
2026-09-21 00:55:34 +08:00
SivanCola fee5c199b9 fix(desktop): fence shutdown preparation and transcript recovery
Problem: A quit during renderer resume or a draft-error dialog could strand shutdown in preparing, while remote or late transcript followers could retry and clean up after service stopping.

Root cause: Preparation released phase ownership before awaited renderer work completed, stop fencing lived only in the local controller map, cleanup policy was not generation-owned, and snapshot replacement was misclassified as recovery.

Fix: Retain preparation ownership across awaits, replay queued exit intent, make every follower observe service state, bind cleanup policy to its generation, require a successful delta before recovery, and make diagnostics capability-safe.

Verification: Deferred and fake-clock regressions cover quit, relaunch, dialogs, local and remote followers, late baselines, retries, cleanup ownership, diagnostic compatibility, and breadcrumb aggregation; all focused suites pass.
2026-09-21 00:55:22 +08:00
SivanCola a40c1eca5f docs(desktop): record Windows validation boundary
Problem: Clean exits can remove lifecycle files, making an empty diagnostics directory easy to misread as missing instrumentation.

Root cause: The durable shell-log boundary, native Windows qualification steps, and migration scope exclusion were not documented together.

Fix: Document shell-log correlation fields, expected lifecycle cleanup, the Windows package matrix, and the separate migration investigation model in English and Chinese.

Verification: Documentation links and repository diff checks pass; native Windows package qualification remains explicitly outstanding.
2026-09-21 00:55:22 +08:00
SivanCola 8dccdccf89 feat(desktop): persist classified transcript diagnostics
Problem: Transcript retry logs exposed only a final error string and could disappear with lifecycle evidence after a clean exit.

Root cause: Failure stage and reason were not captured at their owning validation sites, and renderer diagnostics depended on the Go service remaining callable.

Fix: Add stable transcript failure classifications, bounded aggregation and recovery records, crash summaries, and a validated rate-limited renderer-to-shell diagnostic channel.

Verification: Local and remote transport, protocol, snapshot, delta, deduplication, recovery, shutdown, typecheck, transcript, and production-build checks pass.
2026-09-21 00:55:22 +08:00
SivanCola df238f0887 fix(desktop): serialize native window shutdown
Problem: Repeated Windows title-bar closes could call the Go service after shutdown began, yielding a ready-state contradiction and an unhandled renderer rejection.

Root cause: Native close events, renderer window commands, and app quit maintained overlapping shutdown transactions while the service did not publish stopping at the shutdown boundary.

Fix: Route native window commands to Electron, centralize close and quit sequencing, coalesce preparation and shutdown, and publish stopping before rejecting business RPCs.

Verification: Electron lifecycle, IPC, service, typecheck, and build gates pass with deterministic repeated-close, quit-upgrade, save-failure, restart, and stopping-state regressions.
2026-09-21 00:55:22 +08:00
SivanCola 7b048b3f62 test(desktop): observe shutdown cancellation directly
Problem: Windows shutdown tests infer cancellation failure from a 750 ms total teardown budget. Root cause: unrelated persistence and cleanup share that wall-clock assertion. Fix: observe the cancellation channel, separately join teardown and the cancelled open, preserve the blocked-build ordering check, and bound failure cleanup. Verification: both focused tests pass ten repetitions and race detection; formatting and diff checks pass.
2026-09-21 00:53:11 +08:00
SivanCola 11f9705f08 Merge pull request #10591 from SivanCola/fix/windows-candidate-clean-checkout
fix(release): preserve Windows signing inputs / 修复 Windows 独立签名目录契约
2026-09-21 00:39:23 +08:00
SivanCola c318867c1c Merge pull request #10589 from SivanCola/test/release-live-write-history
test(agent): update live write resume contract / 更新实时写入恢复测试契约
2026-09-21 00:24:11 +08:00
SivanCola 42a9bee43f fix(release): preserve Windows signing checkout inputs
Problem: independent Windows signing fails before installer assembly. Root cause: finalization deletes tracked NSIS inputs and packaging assumes a native build created its output directory. Fix: preserve committed packaging sources and initialize generated output at the packager boundary. Verification: fresh-checkout finalization covers amd64 and arm64, both original defects fail the new regression, all release contracts and diff checks pass.
2026-09-21 00:13:58 +08:00
SivanCola 186b435135 test(agent): align live resume with immutable execution history
Problem: live write qualification called retired recovery mutation APIs. Root cause: its helper retained the old confirmation contract after history became immutable. Fix: continue from externally verified file state and assert the historical record stays byte-identical, while retaining no-repeat and checkpoint checks. Verification: live Chat, Responses, and Anthropic cases pass; retired recovery deterministic regression and diff checks pass.
2026-09-21 00:07:55 +08:00
SivanCola 084d3d1433 Merge pull request #10587 from SivanCola/fix/release-candidate-permissions
fix(release): allow candidate workflow startup / 修复候选发布工作流启动
2026-09-20 23:29:47 +08:00
SivanCola 13dc9afc3c fix(release): grant nested Desktop workflow permissions
Problem: candidate preparation fails before jobs start. Root cause: GitHub validates permissions of skipped nested publisher jobs against the read-only caller. Fix: grant the required permission union at the Desktop call and validate all candidate and publication callers. Verification: regression rejects original workflow; release workflow contracts, actionlint, and diff checks pass.
2026-09-20 23:25:55 +08:00
SivanCola 1528647cd3 Merge pull request #10586 from esengine/release-notes/v1.38.11
docs(release): prepare v1.38.11 notes / 准备 v1.38.11 更新日志
2026-09-20 23:24:16 +08:00
SivanCola 4036b311a7 docs(release): refine v1.38.11 user-facing notes
Clarify repaired image attachment support, model reasoning effort, and Windows PowerShell behavior. Reviewed the complete first-parent release range and linked documentation. Verification: release catalog validation, release-notes tests, and git diff --check.
2026-09-20 23:23:20 +08:00
github-actions[bot] 89ee37d85b docs(release): prepare v1.38.11 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-09-20 15:22:44 +00:00
SivanCola ec82bb3251 Merge pull request #10580 from SivanCola/fix/post-13810-integration
fix: stabilize session identity, exports, and Windows lifecycle tests
2026-09-20 22:25:45 +08:00
SivanCola 8715db7150 Prevent Bot test faults from corrupting Windows runners
Problem:
The native Windows Bot package intermittently exited with 0xc0000005 or a bare package failure during stop and cancellation tests.

Root cause:
Several Bot test controllers embedded a nil interface. Normal gateway calls intentionally dereferenced that interface and production wrappers recovered the panic. On Windows Server 2025 hosts with large CPU XSTATE, dispatching the hardware exception can corrupt the Go heap (golang/go#81238), so recover cannot make this test pattern safe. Removing the recover also exposed that a controller panic while the gateway lock was held could wedge later messages.

Fix:
Use a complete inert controller stub in tests, stop recovering controller method panics in production, and move the session claim decision behind a defer-unlocked boundary. Record the isolated Windows runner CPU and include Bot in the PR race suite.

Verification:
- go test -count=5 -timeout=5m ./internal/bot/...
- go test -race -timeout=10m ./internal/bot/...
- go vet ./internal/bot/...
- node --test scripts/ci-workflow.test.mjs scripts/windows-go-tests.test.mjs
- go run ./tools/repolint
- git diff --check
2026-09-20 22:00:25 +08:00
SivanCola 4611fb20fc Capture native Bot crash events
Problem:
The isolated Windows Bot package can terminate at process level with only a package FAIL line, leaving no test name or final event for root-cause analysis.

Root cause:
The Bot lane used the default go test text stream, which omits enough subprocess event detail when the test binary exits without a Go panic stack.

Fix:
Emit go test JSON for the existing single-process Bot lane and lock that diagnostic contract in the Windows selector test. This does not retry, split, or skip the package.

Verification:
- node --test scripts/windows-go-tests.test.mjs scripts/ci-workflow.test.mjs
- go run ./tools/repolint
- git diff --check
2026-09-20 21:49:58 +08:00
SivanCola 090e2ccd88 Observe desktop rebind phases on Windows
Problem:
The Windows Desktop I-P partition could report that an A-to-B rebind never restored its candidate while legacy-session migration was still retrying a transient Access denied rename.

Root cause:
The regression test waited on only the restored hook for ten seconds and ignored both the earlier built phase and an early rebind error, conflating migration latency with a stuck transaction.

Fix:
Observe candidate construction and restoration as separate ordered events, surface early rebind errors immediately, give the measured Windows restore phase its own hang guard, and always release the commit hook on failure.

Verification:
- go test -timeout=8m ./... -run TestModeRebuildAndABANavigationFenceLeaseFailureAndOldEpochEvent -count=20
- go test -race -timeout=8m ./... -run TestModeRebuildAndABANavigationFenceLeaseFailureAndOldEpochEvent -count=5
- go run ./tools/repolint
2026-09-20 21:44:25 +08:00
SivanCola 54499d2508 Separate ACP approval phases in Windows tests
Problem:
The Windows PR contract could time out after a valid permission response while the loaded runner was completing the approved tool turn.

Root cause:
The test used one idle timer for tool execution, status publication, and transcript persistence, so it could not distinguish a broken approval round trip from delayed post-tool finalization.

Fix:
Observe the approved tool execution explicitly, then apply a separate bounded finalization wait. Keep the shared prompt drain limit unchanged for other tests.

Verification:
- go test -timeout=5m ./internal/acp -run TestE2EApprovalRoundTrip\|TestE2ECancelMidTurn -count=20
- go test -race -timeout=8m ./internal/acp -run TestE2EApprovalRoundTrip\|TestE2ECancelMidTurn -count=5
- go run ./tools/repolint
2026-09-20 21:26:56 +08:00
SivanCola f7111d213f Make ACP cancellation delivery observable
Problem:
The Windows PR contract occasionally failed TestE2ECancelMidTurn after the tool had started because the test required all post-cancel finalization to finish within two seconds.

Root cause:
The fixture observed tool start but not cancellation delivery, so scheduler and transcript-finalization latency were conflated with a broken cancellation path.

Fix:
Signal when the blocking tool observes context cancellation, assert that event before waiting for the prompt result, and retain an independent hang guard for finalization.

Verification:
- go test -timeout=4m ./internal/acp -run TestE2EApprovalRoundTrip\|TestE2ECancelMidTurn -count=10
- go test -race -timeout=6m ./internal/acp -run TestE2ECancelMidTurn -count=5
- go run ./tools/repolint
2026-09-20 21:15:31 +08:00
SivanCola 3e23542ef4 Resolve remote export IDs through the session catalog
Problem:\nCodeQL still followed the validated request string into storage because syntactic validation alone does not establish server ownership of an identity.\n\nRoot cause:\nThe export resolver constructed SessionRef directly from the caller-selected ID, even though persistence later confined the path.\n\nFix:\nResolve explicit IDs by equality against paged server-side catalog entries and construct SessionRef only from the catalog-owned value. The request string is never passed to a filesystem-backed read.\n\nVerification:\ngo test -timeout=8m ./internal/session -run ResolveSessionID -count=1\ngo test -timeout=8m ./internal/serve -run SessionExport -count=1\ngo run ./tools/repolint
2026-09-20 21:01:19 +08:00
SivanCola f3afd6b3ac Validate fixed export session identities at the HTTP boundary
Problem:\nCodeQL traced the new explicit sessionId request values into session storage path operations, so the fixed-source endpoint did not make its path boundary locally auditable.\n\nRoot cause:\nThe session query validated IDs only after the HTTP resolver had propagated the caller-owned string into a SessionRef.\n\nFix:\nApply the canonical cross-platform SessionID validator at the shared export resolver, reduce accepted identities to one filepath.Base component, reject malformed query/header/body targets with 400, and cover traversal-shaped inputs.\n\nVerification:\ngo test -timeout=8m ./internal/serve -run SessionExport -count=1\ngo run ./tools/repolint
2026-09-20 20:55:09 +08:00
SivanCola b13e13aa4a Fix Windows builtin contract regressions
Problem:\nThe full native Windows contract still dereferenced the retired bash registration, expected OS-sandbox denial tokens on an unsupported platform, and repolint rejected the cold diagnostic serializer.\n\nRoot cause:\nWorkspace tests bypassed the platform shell contract, the denial retry test did not separate OS-sandbox capabilities, and commit streaming lived inside the top-level serializer.\n\nFix:\nDiscover and assert the single platform shell, limit exact denial retry coverage to supported OS sandboxes, and extract cold diagnostic commit streaming without changing its format.\n\nVerification:\ngo run ./tools/repolint\ngo test ./internal/control ./internal/tool/builtin\nnode --test scripts/windows-go-tests.test.mjs scripts/ci-paths.test.mjs\nGOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c for builtin, control, ACP, and Bot
2026-09-20 20:26:12 +08:00
SivanCola 2fbc4c6afc Make ACP cancellation tests observe event order
Problem:
ACP cancellation and approval tests reported a generic timeout when the prompt had already failed, and a two-second scheduling deadline was unstable in full Windows package runs.

Root cause:
The fixture waited for only one expected event and used elapsed time as the correctness assertion, hiding early RPC failures and conflating scheduler load with protocol ordering.

Fix:
Wait concurrently for tool or approval entry and the prompt response, report early RPC completion explicitly, and retain a separate ten-second hang guard. Native Windows CI now runs ACP and Bot as isolated whole packages so any remaining Bot process fault keeps its original signature.

Verification:
Focused ACP tests pass repeatedly and under the race detector; the package cross-compiles for windows/amd64. Native Windows CI remains the required evidence for the prior Bot access violation.
2026-09-20 20:00:05 +08:00
SivanCola e271506c50 Align Windows shell contracts with the product
Problem:
Windows PR checks asserted retired sandbox and POSIX shell assumptions, and ACP/Bot failures were mixed into a parallel residual package lane.

Root cause:
The focused selector did not follow platform shell registration, current schema requirements, unsupported OS sandbox behavior, or the packages that own lifecycle cancellation.

Fix:
Resolve the registered primary shell in tests, assert platform-specific schema, split supported and unsupported sandbox contracts, centralize Windows PR selections with coverage assertions, run the full builtin package for shell/permission ownership changes, and isolate ACP and Bot package execution. Defender setup now fails closed so native crash evidence has a known prerequisite.

Verification:
Selector and workflow contract suites pass, builtin tests pass on macOS, and builtin, ACP, and Bot packages cross-compile for windows/amd64.
2026-09-20 20:00:05 +08:00
SivanCola 2a3cd2d915 Pin remote exports to their captured session
Problem:
A read-only export of session A failed or drifted after the remote foreground switched, the session was taken over, or diagnostics lost their live controller.

Root cause:
Export endpoints revalidated against the current foreground binding instead of resolving the immutable snapshot identity through the owning session service.

Fix:
Resolve snapshot, document, validation, and diagnostic requests through one fixed-source path; require query, header, and snapshot identities to agree; keep storage authority server-local; and share cold diagnostic serialization across local and remote exports. Runtime metrics are included only while the same controller remains published for the source.

Verification:
Serve and Desktop regressions cover foreground switches, takeover, identity conflicts, deletion, snapshot boundaries, cold diagnostics, target preservation, and frozen remote routing. Focused race tests and the real Chromium PDF/image export flow pass.
2026-09-20 20:00:05 +08:00
SivanCola 22f06ae969 Fix historical preparation operation identity
Problem:
Late cancellation of a completed historical preparation could target a newer retry for the same source.

Root cause:
Preparation operation IDs were derived from the stable source ID, so distinct task instances shared one cancellable identity.

Fix:
Assign a fresh opaque operation ID to every new task, retain same-source joining only while the task is active, remove superseded terminal records, and fence stale completion publication. Deduplicate frontend cancellation by captured operation ID.

Verification:
Focused lifecycle regressions cover concurrent joining, late cancellation, cancellation retry, and interactive/batch demand isolation. The frontend banner regression and typecheck pass.
2026-09-20 20:00:05 +08:00
SivanCola 6b93e0a6b9 Merge pull request #10575 from esengine/fix/ai-title-budget-ownership
Let the model round trip own the AI session title budget / AI 会话标题预算归属模型调用
2026-09-20 19:40:30 +08:00
SivanCola 57f5a8eafa Let the model round trip own the AI session title budget
Problem
desktop-windows-go-group (A-B) failed
TestAIRenameCanonicalSessionUsesDurableHistoryInsteadOfEmptyLegacyFile/session-id
with AIRenameSession = "", session_operation:operation_failed. The subtest
ran 51.5s against roughly 30s for its siblings, and the generic product
text carried no cause.

Root cause
aiRenameCanonicalSession wrapped the whole operation in its own 30s
deadline, duplicating a budget the provider call already owns:
control.generateSessionTitle passes sessionTitleTimeout (30s) to
boundedllm.Call, which bounds the model round trip from the call. The
host clock therefore also bounded the durable snapshot, the runtime
flush, the history projection TitleMessages builds, two process mutexes
that do not observe a context, and the conditional commit. That
preparation costs 2.4s for this fixture's 113-event conversation on an
idle macOS host and 4.6s under load, and several times that on a Windows
runner, so a slow host spends the model's budget on local I/O and the
already generated title is refused by a deadline that belongs to the
provider. context.DeadlineExceeded matches no classifier, so
sessionOperationErrorForTarget collapsed it to operation_failed.
aiRenameLegacySession never had this second clock.

Fix
Drop the duplicated host deadline. The canonical rename now runs on the
operation's own cancellation, exactly like the legacy twin and like the
manual rename, whose commit already goes through
WithSessionUnchanged(a.bootContext(), ...). The model round trip stays
bounded at 30s where it is made. sessionOperationErrorForTarget also logs
the unclassified cause, so the next such failure is diagnosable from a CI
transcript instead of collapsing to the generic text.

Verification
New TestAISessionTitleBudgetBelongsToTheModelRoundTrip asserts the model
is handed its full budget; it fails before this change ("durable
preparation of 1.33s left the model only 28.67s of its 30s budget") and
passes after. go test ./ -count=1 (full desktop suite, 390s), the failing
CI partition go test ./ -skip '^Test[C-Z]', and go test -race on the AI
title tests all pass. gofmt, go vet, golangci-lint on desktop are clean;
go run ./tools/repolint reports clean (1185 baselined findings). The root
module is untouched.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 19:26:34 +08:00
SivanCola 8bf4297b63 Merge pull request #10566 from SivanCola/fix/on-demand-history
fix(desktop): migrate historical sessions on demand / 桌面历史会话按需迁移
2026-09-20 18:57:04 +08:00
SivanCola 320e5f1984 Merge main-v2 and preserve explicit historical tab preparation 2026-09-20 18:37:39 +08:00
SivanCola 26fa584800 Merge pull request #10320 from XTLine/fix/sessionid-migration-gaps
fix(remote): complete session-id migration with cross-client session takeover / 补全 session-id 迁移并实现跨端会话接管
2026-09-20 18:14:31 +08:00
SivanCola f5e7d46314 fix(desktop): clear historical preparation on canonical activation
Problem: native upgrade evidence showed readable history with a stale Import and open action.

Root cause: canonical bind and resume paths assigned identity fields directly; frontend seed merges also retained omitted historical-source metadata.

Fix: route canonical binding through the identity setter, explicitly clear absent preparation metadata during seeding, and require native acceptance to reject leftover preparation controls.

Verification: reproduced failures in canonical binding, open, and both compatibility resume paths before the fix. Focused race tests, frontend banner and tab metadata tests, test typecheck, production build/budget, lint and inventory checks now pass. Startup payload remains within the existing adjusted budget.
2026-09-20 18:07:34 +08:00
SivanCola d0ef69956c test(desktop): close upgrade fixture database between launches
Problem: Windows could not remove the upgrade fixture directory after all functional assertions passed.

Root cause: production saved-tab reconciliation opens the draft SQLite store, but the fixture only closed session services.

Fix: drain preparation work and close session services plus the draft database exactly once per simulated launch, including early test failure cleanup.

Verification: focused upgrade-fixture race test passes locally. The unchanged production package passed native Windows installation, explicit historical preparation, visible transcript, data preservation, restart and normal exit on the preceding commit.
2026-09-20 18:02:54 +08:00
SivanCola 5824dbe4f9 fix(desktop): restore historical tabs as explicit preparation choices
Problem: packaged upgrade startup treated retained historical tabs as corrupt and expected automatic conversion.

Root cause: saved-tab reconciliation had no unprepared-source state, and upgrade acceptance still asserted the retired eager migration flow.

Fix: project discovered sources into neutral pending tabs, route the explicit action through normal navigation, and omit empty probe directories. Update native acceptance to click only on first import while preserving transcript, restart, and data-integrity assertions. Record the measured 243-byte startup metadata increment with a bounded raw-size allowance.

Verification: focused startup/reconciliation tests; historical and saved-tab race tests; TypeScript, navigation, banner, recovery and controller-meta tests; frontend production build and bundle checks; golangci-lint, repository lint and generated inventory. Native Windows acceptance remains for CI.
2026-09-20 17:47:04 +08:00
SivanCola 3f1b0b69d7 Release writer-holding test services at the terminal boundary
Problem
test (windows-latest) failed: TestCanonicalTakeoverCommandReportsRefusedGrant
could not remove its TempDir because sessions-v4/.held.ownership.lock was
still open. Linux and macOS allow unlinking an open file, so the same code
passed there.

Root cause
Two tests open a second session.Service to really hold a session's writer, and
discard the ClientBinding that Open returns. Their cleanup called CloseAll,
which Service documents as the runtime-only primitive: it does not close a
runtime whose client never unbound, so the ownership lock survived the test.
Service.Shutdown is the documented lifecycle boundary a short-lived service
must use before its root directory is removed, and it is terminal.

Fix
Shut both holder services down instead of closing their runtimes, so the
writer lease and recovery handles are released before TempDir removal.

Verification
go test ./internal/cli/ and go test -race ./internal/cli/ pass; gofmt and
repolint clean. The Windows-only unlink behaviour cannot be reproduced on
macOS, so CI is the confirming run.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 17:43:02 +08:00
SivanCola 84bde1ce54 Follow the CI Linux build for the initial bundle ceiling
Problem
The desktop-prepare CI job failed its bundle budget: the Linux stable build
measured 2073980 B (2025.4 KiB) against the 2025.3 KiB ceiling set from a
macOS build, 73 B over.

Root cause
The ceiling was ratcheted from the same-toolchain macOS build (2073794 B).
The CI producer builds 186 B larger, and 0.11 KiB of headroom did not cover
that platform delta.

Fix
Set the ceiling from the CI measurement, 2025.5 KiB, and record both numbers
so the next ratchet accounts for the platform delta. Other budgets are
unchanged.

Verification
pnpm check:bundle locally: 2025.2 KiB / 2025.5 KiB passes; CI value 2025.4 KiB
also fits.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 17:38:18 +08:00
SivanCola 0dea460c7c fix(desktop): keep historical shell discovery read-only and drainable
Problem: Source-only pinned shells could mutate the workspace registry during startup listing, and initial metadata discovery was not tracked by shutdown.

Root cause: Shell projection reused organization initialization, while startup discovery bypassed coordinator worker ownership.

Fix: Project unprepared pins from read-only catalogs without creating workspaces. Track startup discovery with the coordinator context and worker lifetime so shutdown cancels and drains it before closing services.

Verification: Historical, startup, source-only pin, and catalog revision regressions pass, including deterministic shutdown ordering and focused race coverage. Go lint, repository lint, generated inventory, and diff checks pass.
2026-09-20 17:19:58 +08:00
SivanCola dc657ccd5d Stop the takeover manager when its test fixture ends
Problem
go test -race ./... reported a data race in internal/cli: a fixture's
background takeover loop read the serve-discovery seam while the next test's
fixture swapped it, failing TestResumeCanonicalSessionHeldElsewhereKeepsMirror.

Root cause
newCanonicalTakeoverTUI builds a cliTakeoverManager but never shuts it down.
Activate starts the manager's run loop, which calls readoptLocked and reads
the package-level discoverCLIServesForTakeover. That loop outlived its test,
so it was still reading when withFakeCanonicalDiscovery installed or restored
the fake for another test. Every test that activates a manager shares this
fixture, so the leak was one per activating test.

Fix
Register the manager's Close with the fixture that creates it, after the
controller and service cleanups so it runs before them. Close already closes
the stop channel and waits for the loop to exit, so the goroutine is joined
rather than merely signalled, and the seam can no longer be written while a
reader is live. Production code is unchanged.

Verification
go test -race -count=1 ./internal/cli/ three consecutive times: no races, no
failures. gofmt clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 17:17:30 +08:00
SivanCola 6ce184e439 test(desktop): isolate remote fixtures from canonical workspace state
Problem: Removing the only remote surface passed alone but failed in the full Desktop suite with a workspace mutation conflict.

Root cause: Remote fixtures replaced the config home while retaining the package-wide canonical registry. Its global workspace still referred to an earlier config home.

Fix: Reuse the complete Desktop directory-isolation helper for remote host fixtures. Add a deterministic regression that seeds an existing global workspace before creating the next fixture.

Verification: The new regression failed before the fixture repair. Remote and host-removal tests and focused race checks pass after repair; Go lint, repository lint, and inventory freshness checks pass.
2026-09-20 17:13:58 +08:00
SivanCola fcaccbc081 fix(desktop): preserve historical preparation ownership and recovery
Problem: Unprepared canonical sources and saved pins were missing from normal navigation, shutdown could discard a batch, concurrent sidecar writers could erase each other, and late cancellation could replace a newer page.

Root cause: Discovery was isolated from ordinary catalog projections, queue lifetime was not drained with conversion workers, writes replaced cached whole-state snapshots, and recovery and UI responses depended on mutable source or navigation state.

Fix: Publish historical metadata into scoped lists and route sidebar opens through preparation. Preserve durable batch selections with worker ownership and revisioned read-modify-write updates. Recover published targets independently of retained sources and fence asynchronous UI responses by identity, revision, and navigation intent. Preserve unknown sidecar fields and document rollback constraints.

Verification: Deterministic historical and sidecar regressions, focused Desktop race tests, Go lint and repository lint, inventory freshness, TypeScript checks, all 417 discovered frontend suites, production frontend build and unchanged bundle budgets passed. Browser smoke checks covered the real app layout with mock data. Desktop full-module and exact-head CI checks are tracked separately.
2026-09-20 17:09:20 +08:00
SivanCola ad16ccb531 Reuse the dormant local tab before creating an open surface
Problem
After merging main-v2, TestOpenSessionUsesDormantLocalTabWithoutAnActiveTab
and TestActiveOrSingleLocalTabRecoversLeaseBlockedStartup failed: opening a
canonical session with no active tab built a second surface next to the tab a
remote-only layout had restored for local work, leaving that tab unbound.

Root cause
6cdb5fedf replaced this branch's activeOrSingleLocalTab call in
openSessionWithNavigation with surfaceForCanonicalSession, which resolves the
active tab, then a tab already running the session, and otherwise creates a
surface. It has no step for the dormant tab this branch restores when the
visible surface is remote, so that tab could never receive an open.

Fix
Give surfaceForCanonicalSession that missing step: after the running-session
match and before creating a surface, reuse the first visible tab that resolves
through tabAndCtrlByID, activating and persisting it like the other reuse
paths. activeOrSingleLocalTab now shares that resolver, so both entry points
agree on which tab local work targets and both retry a startup that a session
lease blocked. Reuse is deliberately not restricted to tabs without a session:
the active-tab path already installs a session into whichever tab is current,
and restricting only this path would have changed behavior the branch shipped.

Verification
go test ./ -run 'TestOpenSessionUsesDormantLocalTabWithoutAnActiveTab|
TestActiveOrSingleLocalTabRecoversLeaseBlockedStartup|
TestOpenSessionWithoutSurfaceCreatesOneForTheSession|
TestOpenSessionActivatesVisibleTabAlreadyRunningTheSession|
TestDiscardUnboundSurfaceRemovesOnlyTheEmptyTab|
TestRemoteOnlyRestoreBuildsOwnedDormantTabWithoutTakingTheSurface|
TestOpenSessionAdoptsRuntimePublishedWhileWaitingForTheRebuildLock' passes;
gofmt, go vet, golangci-lint and repolint clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 17:05:26 +08:00
SivanCola 5da1c7d0f9 Merge branch 'main-v2' into fix/sessionid-migration-gaps
Conflict resolutions:
- desktop/session_workspace_api.go: keep main-v2. 6cdb5fedf replaced the
  active-tab lookup in openSessionWithNavigation with
  surfaceForCanonicalSession, which creates a surface when none exists.
  Its first step is still tabAndCtrlByID(""), so the startup-lease
  recovery this branch restored on that path is preserved.
- desktop/frontend/scripts/check-bundle-budget.mjs: keep this branch's
  ledger entry and re-measure against the advanced base. The merged
  Electron build is 2073794 B (2025.2 KiB), 7754 B over main-v2's
  2066040 B; the ceiling moves to 2025.3 KiB with 0.11 KiB headroom.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 16:49:52 +08:00
SivanCola fb88e75dfc Announce remote attach identity only for routed attaches
Problem:
Opening a remote project tab with New Session stopped publishing a ready
remote-tab:updated as that tab's first meta: the bootstrap emitted a
"connecting" meta first, so the sidebar's new project group re-pulled the
session listing while the fresh session did not exist yet.
TestBootstrapNewSessionPublishesTabUpdateForSidebarBlankRow failed with
`bootstrap update state = ready:false remote:"connecting"`.

Root cause:
installRemoteTabAttachPump announced every attach for history-first
hydration, including a fresh-session attach, which installs no route
(installRoute is !opts.NewSession) and therefore publishes a meta with no
session identity at all. That meta can prime no persisted window, and it
front-runs the ready meta that bootstrapRemoteTab publishes for a fresh
session, which is the sidebar's only signal to re-pull the blank row. The
announcement also emitted after the route fence was released, so it could
interleave with another publication for the same tab.

Fix:
Publish the attach identity only when the attach committed a session route,
and publish it inside the routeEventMu fence that installed that route, so
the announcement keeps the file's single publication order. A fresh
bootstrap now stays silent until its own ready meta; routed attaches (cold
open of a saved session, revived shell, serve restart) still announce while
connecting, so history-first hydration is unchanged. The new
TestRoutedAttachAnnouncesIdentityWhileConnecting pins that half so the
announcement cannot be dropped to satisfy the sidebar contract.

Verification:
- cd desktop && go test ./ -count=1 (full suite, ok 367.9s)
- cd desktop && go test ./ -count=1 -race -run
  'RemoteTab|Reclaim|Takeover|Runtime|TabSelection|RemoteListing|
  RemoteProject|RemoteTranscript|Bootstrap|Persist|Mirror' (ok 71.4s)
- gofmt -l desktop/ clean; cd desktop && go vet ./ and golangci-lint run ./
  clean; go run ./tools/repolint clean (1213 baselined findings)
- Event order for a New Session bootstrap is now state:connecting,
  remote-tab:opened, session_start/ready frames, state:ready,
  remote-tab:updated(ready, sessionPath set); a routed open keeps its
  connecting remote-tab:updated carrying the session path before ready.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 16:39:49 +08:00
SivanCola def2b0accc Merge remote-tracking branch 'origin/main-v2' into codex/pr-10566-update
# Conflicts:
#	desktop/frontend/src/generated/desktopContract.generated.ts
2026-09-20 16:28:29 +08:00
SivanCola 44fcc11c93 feat(desktop): integrate historical sessions into normal flow 2026-09-20 16:18:59 +08:00
SivanCola 688804334a Keep exported host commands in their contract source file
The generated host contract records the source file of every exported App
method, so moving ReclaimRemoteTabSession to remote_tab_reclaim.go made
TestHostCommandOwnersMatchSource and TestHostContractGeneratedFilesAreCurrent
fail on a stale digest.

Return ReclaimRemoteTabSession to remote_tab.go and take the unexported
spectator-pin trio (markRemoteTabSpectatorIfLocalOwned,
probeSpectatorAfterNotice, clearRemoteTabSpectator) out to
remote_tab_spectator.go instead, which keeps remote_tab.go under its size
budget without touching the contract. Compress the two comments that moved
with them.

Verified with `go run . -emit-contract` into a scratch directory: both
generated files are byte-identical to the committed ones.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 16:15:08 +08:00
SivanCola 8acafeee9f Extract the remote archive guard and the surface test DOM
ProjectTree.tsx and the remote surface suite were each over their recorded
size by a few lines.

Move the "can this remote row be trashed" predicate next to the identity
helper it calls, as remoteSessionArchiveBlocked in ProjectTreeRemoteGroups,
and call it from the trash menu item. Same expression, same operand order.

Move the remote surface suite's jsdom bootstrap into
helpers/remoteSurfaceDom.ts. The globals are installed at the same point in
module evaluation as before, so import order and every stubbed measurement
are unchanged.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 16:15:08 +08:00
SivanCola d57d8aa775 Split the oversized remote tab and takeover test files
Three test files were over the 800-line ceiling because each carried more than
one scenario. Split them along the scenario they cover, with no test text
changed:

- remote_tab_session_transition_test.go takes the /resume and /new transition
  outcomes (busy, rejected, lease conflict, list failure, frame buffering).
- remote_serve_fake_test.go takes the shared fakeServe harness and its
  helpers, which several suites already use.
- session_reclaim_test.go takes the ownership-probe and reclaim-barrier cases,
  leaving the takeover grant and mirror cases behind.

Each moved function is byte-identical to its original; only the import lists
differ, per file. Verified by comparing the sorted non-import body lines of
each original against its two successors.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 16:15:08 +08:00
SivanCola 9847df5100 Split the takeover mirror loop and extract remote status steps
session_takeover.go held both the takeover command and the whole mirror
transport loop. Move run/pushOnce/retryAdoptOrDemote/drainQueue/flushOnce and
their locked halves into session_takeover_mirror_loop.go, leaving the command
and mirror registry behind.

Break recordRemoteTabSessionStatus into named steps —
remoteTabStatusRouteRejectedLocked, resolveRemoteTabOwnershipBarrierLocked and
remoteTabStatusMetaChanged — and lift the resume pre-check out of
resumeRemoteTabSessionPathForOpenSelection into admitRemoteTabResume. The two
long explanations become doc comments on the steps they describe.

Move terminalReadOnlyForTab next to the read-only policy it belongs to, and
compress three inline comments to the invariant they carry.

Behaviour-preserving: identical conditions, lock order and return values; the
route/ownership guards keep their original evaluation order.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 16:15:08 +08:00
SivanCola 435ddff748 Group the remote tab ownership fences and split the reclaim path
remoteTab carried 14 independent scalars, two of which — reclaimRevision and
pendingReadyBarrier — share one lifetime: a committing reclaim sets both and
the re-hydrated surface clears both. Group them into remoteTabOwnershipState
so the pair can only be read as one state, dropping the struct to 12 scalars.

Move the reclaim command (remoteTabReclaimObservation,
observeRemoteTabForReclaim, ReclaimRemoteTabSession and the
remoteSessionTakenOver refusal predicate) out of remote_tab.go and next to
the ownership probe that already lived in remote_tab_reclaim.go, and compress
three over-long inline comments to the invariant they carry.

Pure relocation and renaming: no call, lock order, fence comparison or emitted
event changed.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 16:15:08 +08:00
SivanCola 9aa4ed1aa6 Merge pull request #10572 from SivanCola/fix/session-draft-default-model
fix(desktop): align new-session draft models and titles / 对齐新会话默认模型与标题
2026-09-20 15:57:39 +08:00
SivanCola 2827393c9c Merge pull request #10570 from SivanCola/fix/workspace-duplicate-directory-owner
fix(desktop): repair duplicate workspace directory owners on contact / 修复同一目录被重复注册为工作区后侧边栏永久加载失败
2026-09-20 15:37:57 +08:00
SivanCola 54826e46af fix(desktop): inherit live default model in new drafts
Problem: New draft sessions could keep a stale model, expose canonical session routes as titles, and diverge from their frozen retry model.

Root cause: Draft storage did not distinguish inherited defaults from explicit model choices, async catalog projections were not version-fenced, and runtime labels treated canonical routes as filenames.

Fix: Track model provenance, resolve inherited defaults live until submission, freeze canonical execution settings, fence frontend refreshes, preserve retry identity, and keep internal session routes out of display labels.

Verification: Go focused and race tests, go vet, repolint, generated contract refresh, frontend lifecycle tests, typecheck, lint, and production build.
2026-09-20 15:37:31 +08:00
SivanCola 7ef51fde61 Lift the reclaim gates and slash fallback out of chat_tui.go
chat_tui.go was over all four of its repolint budgets after this PR
series added reclaim gating to update() and runSlashCommand().

- The three reclaim flags become one embedded reclaimState in
  chat_tui_reclaim.go. It is embedded, so m.sessionReclaimed and friends
  still resolve through field promotion and no caller or test changes.
  The flags share a lifetime, which is why they now share a struct.
- The composer and slash-command gates become reclaimBlocksInput and
  slashInputBlockedNotice next to sessionDetached, the predicate they
  already duplicated. reclaimInputAllowed on a bare typed command is the
  same four-command test the slash gate spelled out inline.
- runSlashCommand's default branch (docs page, custom command, skill,
  extension action, prose fallback) moves verbatim to
  runUnrecognizedSlash in chat_tui_slash_fallback.go. It always returned,
  so the switch arm is now a single return.

No condition changed its operands or its evaluation order. chat_tui.go
4945 -> 4881 lines, runSlashCommand 265 -> 214 lines / complexity 82 ->
62, update 997 -> 993 / 255 -> 252.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:24:48 +08:00
SivanCola 64e00b7e29 Extract CLI startup resume resolution into its own file
runAgent and chatREPL each inlined the same two steps this PR series
grew: turning --resume/--continue/--copy into a cliResumeTarget, and
committing that target with a canonical takeover when the writer is held
elsewhere. Both functions were past their repolint size and complexity
budgets, and cli.go was past its file ceiling.

cli_resume_start.go now owns headlessResumeTarget, interactiveResumeTarget,
normalizedResumeFlag, continueResumeTarget, copySessionExitCode and
commitStartupResume, plus the two takeover-approval policies (--takeover
answers yes up front, the TUI asks). The callers keep the exit codes they
returned inline.

The predicate chain in commitStartupResume is the original condition with
De Morgan applied, so canonical(), errors.Is and the approval callback are
still evaluated in that order and the prompt still runs only for a held
final-format session. Counts: cli.go 2799 -> 2724 lines, runAgent 326 ->
293 lines / complexity 63 -> 51, chatREPL 418 -> 377 / 76 -> 60.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:24:48 +08:00
SivanCola ce22d6dcca Split serve discovery out of the CLI takeover file
session_takeover_cli.go had grown to 1137 lines, 142 weighted units past
its repolint budget, by stacking two concerns: finding the resident serve
that holds a session, and the mirror manager that owns the session once
the serve lets go.

The discovery half moves verbatim to session_takeover_serve.go: the
handoff wire types, discoverCLIServes and its PID probe, cliServeClient,
cliTakeoverHeldSession, postCLITakeoverHandoff, cliTakeoverFromServe, the
takeover-candidate predicate and the terminal prompt. The manager, its
binding and the pending-return machinery stay behind (873 lines). The one
orphaned doc line naming cliTakeoverFromServe is reattached to it.

Also compresses three over-long comments to three lines each: the file
header, cliTakeoverBinding.canonical and cliTakeoverManager.yielded.

Declarations move between files in the same package and keep their
bodies, so no call resolves differently.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:24:48 +08:00
SivanCola a12fbe2564 Split mirror tests and compress reclaim comments
The takeover review series pushed four CLI files past their repolint
budgets with commentary rather than logic.

- session_takeover_canonical_test.go crossed the 800-line ceiling; the
  mirror-return cases (resumeEntryIndex, assertMirrorLeft and the five
  Resume*Mirror tests) move verbatim to session_takeover_mirror_test.go.
  Same package, same assertions, no case added or dropped.
- resume.go, resume_test.go, chat_tui_reclaim.go and chat_tui_shutdown.go
  had 4-6 line block comments where the rule allows 3. Each is rewritten
  to state the same invariant in three lines; none is deleted.

Comment text and test placement only, so behavior is unchanged.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:24:48 +08:00
SivanCola e8114dc352 Split serve session ownership by handoff and reclaim concern
session_ownership.go carried the whole single-writer takeover protocol
in one 1749-line file, far past its size budget. Move whole declarations
into files named after the concern they own:

- session_handoff.go: the /handoff request, the quiet-then-release loop,
  and both the identity and path release transactions.
- session_reclaim.go: /reclaim, the identity and mirrored reclaim
  transactions, the lease rollback, and the stale-mirror auto-reclaim.
- session_ownership.go keeps ownership resolution, the mirror registry
  and the read-only views a spectator sees.

The 15-line protocol essay that opened session_ownership.go is package
explanation, so it moves to the new doc.go together with the package
comment that used to sit on serve.go; each file now points at it in
three lines. Remaining over-long blocks are compressed to the invariant
they state.

Pure moves: no declaration changed body, order, lock scope or receiver,
so behavior is unchanged.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:20:38 +08:00
SivanCola b263b021f1 Split the serve status and delete surfaces out of serve.go
serve.go and sessions_http.go both carried PR-added code past the
repository size, function-size and complexity ceilings. Move whole
declarations into files named after the concern, and extract helpers
that state the step they perform:

- status_http.go takes Server.status and splits it into the identity
  route, the spectator view, the foreground snapshot, the effort block,
  the mirrored override and the optional balance fetch.
- session_delete.go takes deleteSession and its locked helpers.
- serve.go keeps switchModelLocked but hands the profile system-message
  splice and the session-axis inheritance to named functions.
- sessions_http.go splits Server.sessions into the legacy rows, the
  canonical catalog walk and the merge, with the migration fold stated
  once on mergeSessionRows.
- control/session_reclaim_events_test.go takes the two reclaim/handoff
  tests that pushed session_events_test.go over the file ceiling.

Over-long comment blocks are compressed to the invariant they carry.
No behavior change: every moved statement keeps its order, lock scope
and early returns, the canonical preview fallback keeps its own clamp
rather than adopting previewTitle's paste-label stripping, and the
runtime-only query read is the same predicate after De Morgan.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:20:38 +08:00
SivanCola c9795f7711 Tighten this series' comments and extract the farewell claim
Problem
The review-fix series pushed three files further past their recorded
repolint debt: comment blocks over the 3-line limit for their position,
and closeTabRuntime grew past the function-size ceiling.

Root cause
Several new comments restated the commit message rather than the
invariant, and the mirror farewell claim was written inline in
closeTabRuntime instead of behind the boundary that owns it.

Fix
Trim the new comments to the invariant they protect and move the claim
and its release into claimTakeoverMirrorFarewell. The release is now
unconditional: it runs once the close has finished with the writer, so
the loop is only ever unmuzzled when announcing the writer is safe.

Verification
cd desktop && go test ./ -run 'ClosingTabOwns|UndeliveredMirror|AbandonedClose|IdentityRouted|OwnershipProbe|Reclaim|ResumeAcceptsCommitted|SyntheticCurrentRow|CloseTab|Takeover' -count=1
cd desktop && go vet ./ && golangci-lint run ./
repolint: the essay and complexity overages this series added are gone;
closeTabRuntime is back under its ceiling.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:13 +08:00
SivanCola 21063015a1 Fence the ownership probe against a completed reclaim
Problem
Serve stopped dropping takeover and reclaim notices on identity routes,
so those notices now reach the remote tab. One landing around an
explicit take-back could re-pin the spectator banner and re-lock the
composer immediately after ownership returned.

Root cause
A takeover or reclaim notice drives an ownership probe whose answer is
applied later. That apply path fenced the tab, generation, client,
selection and route, but not the reclaim epoch, so an answer describing
the ownership a reclaim had just ended was still treated as current.
Status payloads already carry that fence; the probe bypassed it.

Fix
Capture the reclaim epoch with the rest of the probe's fences and drop a
locally-owned answer when a reclaim completed while the probe was in
flight. Clearing the pin stays unfenced: it agrees with the reclaim.

Verification
cd desktop && go test ./ -run 'Takeover|Reclaim|Spectator|IdentityRouted|RemoteTab' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestOwnershipProbeCannotRepinAcrossACompletedReclaim fails without
the fence; TestIdentityRoutedTakeoverNoticeProbesAndPinsSpectator,
TestIdentityRoutedReclaimedNoticeClearsSpectator and
TestIdentityRoutedNoticeForAnotherSessionIsIgnored pin that identity
routed notices probe and route coherently now that they arrive.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:13 +08:00
SivanCola 3427cc53b0 Send the mirror farewell after the closing tab releases its writer
Problem
Closing a tab that mirrored a session to a resident Serve announced the
writer as gone while that writer was still being released, so Serve had
to wait for the writer to drop before handing the session back.

Root cause
The farewell was driven by the mirror loop's tab-gone poll, which fires
as soon as the tab binding disappears from the registry. The close then
still had to quiesce autosave, close the controller and release the
lease — all after the announcement. Shutdown already had the correct
epilogue (endTakeoverMirrors); a single tab close had no equivalent.

Fix
The closing tab claims the farewell while it still owns the writer, and
sends it from the close epilogue once the lease is released. The loop's
tab-gone branch keeps detaching and stays the backstop for a tab that
disappears without that epilogue, and a close that returns early —
a detached runtime, or a tab that changed underneath — hands the claim
back. A delivered farewell is recorded so the two paths cannot both
announce it; an undelivered one stays retryable.

Verification
cd desktop && go test ./ -run 'Takeover|Mirror|CloseTab|Close' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestClosingTabOwnsTheMirrorFarewellUntilTheWriterIsReleased,
TestUndeliveredMirrorFarewellStaysRetryable and
TestAbandonedCloseReturnsTheFarewellClaim.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:12 +08:00
SivanCola 1b260cf6a0 Adopt the dormant tab's runtime under the rebuild lock
Problem
Opening a session on the workspace tab a remote-only layout restores
could still fail with "tab runtime changed while opening session" when a
concurrent activation built that tab's runtime, even though the open had
resolved the tab precisely because it had none.

Root cause
The nil-controller adoption read the tab's runtime at the top of
resumeCanonicalSessionForTranscript, before runtimeRebuildMu was held. A
runtime published during that wait made the later authoritative read
differ from the adopted value, so the equality fence rejected the open.

Fix
Adopt the runtime at the authoritative read inside the rebuild lock and
validate its identity protocol there, through a shared helper that
treats a nil runtime as a dormant tab rather than a protocol failure. A
build that publishes after this point is still rejected, by the
publication fences that own that boundary.

Verification
cd desktop && go test ./ -run 'Dormant|Restored|OpenSession|Resume|RemoteOnly|SingleSurface' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestOpenSessionAdoptsRuntimePublishedWhileWaitingForTheRebuildLock
fails on the PR head, and
TestRemoteOnlyRestoreBuildsOwnedDormantTabWithoutTakingTheSurface pins
the restored layout: a dormant tab with a resolvable workspace identity,
the remote shell keeping the visible surface, and a runtime built only
on first activation.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:12 +08:00
SivanCola 57f5d1cbc4 Restore lease-blocked startup recovery on the OpenSession path
Problem
Opening a session on a tab whose startup had been refused by a held
session lease failed with a runtime error, even after the lease holder
exited. Before this PR the same click recovered the tab and opened the
session.

Root cause
OpenSession moved from tabAndCtrlByID("") to activeOrSingleLocalTab,
which read tab.Ctrl directly. tabAndCtrlByID is the resolver that
retries a startup blocked by a lease (tryRecoverStartupLeaseHeldTab);
reading the field skipped that recovery and reported the nil runtime.

Fix
Resolve both the active tab and the dormant fallback through
tabAndCtrlByID, so every local-command target gets the same recovery
attempt before a missing runtime is reported. The controller is still
returned with its tab, so a rebuild in between cannot look like a
replaced runtime.

Verification
cd desktop && go test ./ -run 'TabSelection|OpenSession|Dormant|LeaseBlocked|ActiveOrSingle|Restored' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestActiveOrSingleLocalTabRecoversLeaseBlockedStartup fails on the
PR head.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:12 +08:00
SivanCola 133c9b8510 Prove the reclaim target exists before the long poll captures it
Problem
Pressing take-back could panic inside a Wails binding — a nil pointer
dereference that takes the window down — when the remote tab closed in
the moment between resolving the command target and snapshotting the
tab, because the reclaim then locked that nil tab after its 20s poll.

Root cause
The reclaim read a.remoteTabs[tabID] into observedTab without checking
the result, treating the earlier remoteTabCommandTarget call as proof
the entry still existed. Those are two separate lock acquisitions.

Fix
Snapshot the reclaim's fences through observeRemoteTabForReclaim, which
owns that lifetime boundary: a missing entry or a client replaced by a
reconnect is reported as a disconnected tab instead of being carried
forward as a nil or retired binding.

Verification
cd desktop && go test ./ -run 'Reclaim|Takeover|RemoteTab' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestReclaimObservationRejectsMissingOrReplacedBinding and
TestReclaimRemoteTabSessionRejectsClosedTab.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:12 +08:00
SivanCola a51aae8eb4 Carry forward-compatible tab keys through the local-kept collapse
Problem
Collapsing the persisted layout while a remote surface was active
dropped unknown top-level keys from desktop-tabs.json, so state written
by a newer build did not survive a run of this one.

Root cause
The new local-kept branch of singleSurfaceTabsFile builds its result
literal without the extra field every sibling branch clones.

Fix
Clone the forward-compatibility fields in that branch too.

Verification
cd desktop && go test ./ -run 'Restore|Persist|SingleSurface|RemoteTabRestore' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestSingleSurfaceLocalKeptBranchPreservesUnknownKeys fails on the
PR head and also pins that the clone does not alias the caller's map.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:12 +08:00
SivanCola dbf71be491 Discard opaque restored titles only for canonical sessions
Problem
After a restart an untitled legacy remote session showed the workspace
name in the tab while the sidebar showed the transcript basename, so the
same conversation carried two different names.

Root cause
The restore path cleared the persisted title whenever it equalled the
session name. For a legacy row the name is the transcript basename,
which is the legitimate user-facing label the sidebar also renders; only
a canonical session ID is the opaque token the cleanup targets.

Fix
Apply the cleanup only when a canonical SessionID is present. A legacy
row keeps its persisted basename title; a canonical row still falls back
to the localized workspace name.

Verification
cd desktop && go test ./ -run 'Restore|Persist|SingleSurface|RemoteTabRestore' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestRestoreKeepsLegacyBasenameTitleAndDropsOpaqueIdentity fails on
the PR head.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:12 +08:00
SivanCola 2246995abf Report disconnected remote background routes as stale
Problem
A background remote session whose last observation was "executing" kept
being reported as synced after the tunnel dropped, so the project tree
showed a frozen snapshot as live runtime state.

Root cause
Dropping takenOver from the background freshness rule also dropped the
connection and sync-failure checks. runtimeStates are seeded once and
never cleared on suspend or park, so without those checks a retired
stream still presents its last snapshot as current.

Fix
Keep the disconnected and sync-failed conditions for background routes
and remove only the takenOver term, which is the one fact about the
foreground that says nothing about another session.

Verification
cd desktop && go test ./ -run 'Runtime|Freshness|RuntimeState' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestBackgroundRouteFreshnessFollowsTheTabConnection fails on the PR
head and pins that a taken-over foreground keeps background routes
synced.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:12 +08:00
SivanCola 362ea67c2b Keep pinned never-chatted remote sessions in the listing
Problem
Pinning a remote session that had not been chatted in yet removed it
from the sidebar: the row the user explicitly asked to keep disappeared
on the next listing refresh.

Root cause
The new blank-row filter dropped zero-turn canonical rows before the
loop computed Pinned, so the pin preference could not influence the
decision that hid the row.

Fix
Resolve the pin preference before the filter and keep any pinned row.
An unpinned never-chatted session still disappears, matching the local
blank-tab contract.

Verification
cd desktop && go test ./ -run 'Pinned|RemoteProjectSessions|RemoteListing' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestPinnedZeroTurnSessionSurvivesTheBlankRowFilter fails on the PR
head.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:12 +08:00
SivanCola 3e7fb4fdf0 Represent identity routes as sessionId in synthesized rows
Problem
Clicking the current blank row of a migrated project raised an error
banner. The row's resume request reached Serve as
{"path":"session-id:X"}, which Serve resolved as a filesystem path and
rejected with 400.

Root cause
After the identity-first route flip, tab.routing.currentPath holds
"session-id:X" for canonical sessions, but the synthetic current row
copied that route straight into RemoteSessionView.Path and left
SessionID empty. The ID-only resume path that could have repaired it
also required a non-empty name, so the nameless synthetic row fell
through to a listing lookup keyed on "" and reported "not found".

Fix
Invert the route when building the synthetic row: an identity route
becomes SessionID, a legacy path stays Path. Accept a committed session
identity independently of the display name, and label an ID-only target
with the workspace name until the title refresh lands. The fake Serve's
/resume now mirrors the real handler (sessionId is sufficient, and the
identity header comes back), so identity routes are exercised at all.

Verification
cd desktop && go test ./ -run 'TestSyntheticCurrentRow|TestResumeAcceptsCommittedIdentity|RemoteListing|RemoteProjectSessions|RemoteSession' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestSyntheticCurrentRowCarriesIdentityAsSessionID and
TestResumeAcceptsCommittedIdentityWithoutAName fail on the PR head;
TestSyntheticCurrentRowKeepsLegacyPathRoute pins the legacy shape.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:12 +08:00
SivanCola bbc2a23fe8 Own the provisional route gate for its whole resume lifetime
Problem
Re-clicking the session a remote tab already shows put the tab into the
"switching sessions" state, and a /resume whose response the tunnel lost
left that state behind for good: after the reattach every command,
including RemoteTabStatus and RemoteTabSnapshot, was refused and live
frames for the route buffered forever.

Root cause
commitRemoteTabOpenRegistration opened routing.rehydratingPath for every
committed route, including the confirmed current one, but only the
different-route provisional-resume transaction owned the field. Its
success drain and rollback were the only writers that cleared it, and
the transport-failure branch that hands the tab to reconnect cleared
nothing. More generally, any pump generation retired mid-resume (dead
stream, host suspend, Serve park) left the gate open, because every
commit and rollback path fences on that generation.

Fix
Open the gate only when a ready tab commits a route Serve has not
confirmed (registration and the deferred-selection commit alike); a
same-route re-selection never enters it. Close the epoch whenever a pump
generation is retired (retire, reconnect, suspend, park), since a retired
generation can no longer drain or roll back and the reattach reconciles
the committed route against Serve's foreground.

Verification
cd desktop && go test ./ -run 'Resume|Switch|PendingSelection|Reattach|Reconnect|Provisional|Reselect|RemoteTabCommand' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestRetiringPumpGenerationClosesProvisionalRouteEpoch,
TestRemoteResumeTransportFailureReattachAcceptsCommands and
TestReselectingCurrentRemoteSessionDoesNotEnterSwitchingState all fail
without this change.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:12 +08:00
SivanCola d82dfa57be Re-enter the selected session on every remote tab reattach
Problem
A reattach only re-entered a session when the Serve instance had
changed. A first-open tab (no instanceID yet) or a tab whose Serve
foreground another client had moved published ready on whatever Serve
was running; the next /status adopted that foreground and the tab
silently showed a different session than the one the user clicked (or
the New Topic they asked for). Before the PR routed refused /events
opens into the reattach loop this surfaced as an error with retry.

Root cause
reattachRemoteTabOnce treated "same Serve instance" as "Serve still
holds this tab's session" and never compared the foreground against the
tab's committed selection.

Fix
Snapshot the tab's selection (route, name, identity, New Topic intent)
before the network work. On a surviving Serve, probe the foreground and
re-enter the selection through /new or /resume when it differs; a
replacement Serve always re-enters. Busy and taken-over refusals follow
the first-attach contract (follow the current session / spectator pin),
and a recovered New Topic keeps bootstrap's reusable-blank contract.

Verification
cd desktop && go test ./ -run 'Reattach|Reconnect|Relaunch|Replacement|ServeDown|FollowsHost|TitleRefreshKeepsBlank|DefersCached|PumpConnectionFailure|RevivesServeDown|ReviveApplies|FocusOnly|SavedSessionLookup|HostStatus|Suspend' -count=1
cd desktop && go vet ./ && golangci-lint run ./
New: TestRemoteTabReattachReentersSelectedSessionWhenServeMoved and
TestRemoteTabFirstOpenReattachEntersRequestedNewSession fail on the PR
head; TestRemoteTabReattachKeepsStreamWhenServeStillRunsSelection pins
the no-transition path.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 15:19:12 +08:00
SivanCola 52c340c9af fix(desktop): repair duplicate workspace directory owners on contact
Problem:
The Global folder and one project both failed to load in the sidebar with
"workspace path matches multiple persisted workspaces: global, project-<hash>",
and nothing in the app could recover them.

Root cause:
A registry written before physical workspace identity could record one
directory twice, for example the Global folder's own directory also added as a
project. Path-to-owner resolution refused such a registry outright. Every
sidebar listing resolves an owner through EnsureWorkspaceResolved, so both
records stayed unreachable forever. Resolution also failed as a whole when any
single persisted root could not be resolved.

Fix:
Repair the duplicate where the registry owns the state, inside the writer
transaction. One owner is chosen deterministically (Global, else the oldest
registration, else the sorted ID) and the losing records fold into it together
with their sessions, organization and every record that addresses the
workspace by ID. ResolveWorkspaceID answers with the same owner. Records fold
only when the candidate directory exists now: a path resolved through a missing
leaf borrows an ancestor's links and case rules, so distinct directories on an
unreachable volume can share a key, and folding is not reversible. A persisted
root this host cannot resolve is skipped instead of failing every lookup. The
single-owner path still returns without rewriting persisted state, and the
registry schema is unchanged.

Verification:
cd desktop && go test ./internal/... && go test -timeout 25m .
go vet ./... ; gofmt ; go run ./tools/repolint
The end-to-end regression fails on the previous code with the reported message
and passes with the fix; a saved tab naming a folded workspace is restored.
2026-09-20 15:07:54 +08:00
SivanCola bd1532ece3 Offer /takeover only when a serve can hand the session over
Problem
A session lease held by another CLI on this machine was advertised with
"run /takeover to take this session over", but /takeover takes a session
from a resident serve; with no serve running the command can only fail
with "no local serve identity to take over from".

Root cause
cliSessionTakeoverCandidate returned true for any lease error carrying
holder info, without checking that a handoff endpoint exists at all.

Fix
Also require at least one resident serve on this machine. The holder's
PID still need not match a discovered serve — state-file PIDs drift
across serve restarts and desktop reconnects, and the execution already
falls back to trying every local serve — but with no serve at all the
refusal stays at naming the holder and the close hint.

Verification
New TestCLISessionTakeoverCandidateRequiresAResidentServe covers no
serve, one serve with a non-matching PID, a non-lease error, and a lease
error without holder info. Resume, takeover, and reclaim suites stay
green; gofmt/vet/golangci-lint clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:17 +08:00
SivanCola 9c63fee719 Resolve final-format sessions for --resume and free /takeover targets
Problem
`--resume QUERY` matched only legacy transcripts although the CLI
advertises final-format support, so a session id, identity route, or
title that exists only in the catalog failed with "no session matches".
And `/takeover` for a canonical session the desktop had already closed
failed with "no resident serve on this machine holds this session",
contradicting the reclaim notice's promise that /takeover takes it back.

Root cause
resolveSessionQuery listed legacy sessions only. runCanonicalTakeoverCommand
treated a failed handoff as terminal, even when the failure meant nobody
holds the identity any more, so there was nothing to hand over.

Fix
Resolve queries against the same universe the picker offers
(scanWorkspaceResume: engine mirrors folded, migrated sources hidden, so
a transcript and the identity it became cannot compete as two matches):
exact session ids and session-id: routes, then title/preview substrings
across both stores, keeping the existing ambiguity and not-found errors.
For /takeover, the serve handoff stays the primary path — it is what
establishes the mirror — and only when no serve granted it does the
command consult the writer lock: an unheld identity is resumed ("session
resumed; no other runtime holds it"), while a still-held one reports why
the handoff failed. Re-taking the session this controller already writes
is rejected instead of replaying it.

Verification
New TestResolveSessionQueryMatchesCanonicalSessions (id, route, preview,
legacy preview, ambiguity across stores, not-found),
TestCanonicalTakeoverOfFreeSessionResumes (serve exited, and a resident
serve that refuses after the desktop closed the session), and
TestCanonicalTakeoverOfActiveSessionIsRejected — all three fail on the
previous code. TestCanonicalTakeoverCommandReportsRefusedGrant now holds
the writer through a second service so the refusal it asserts is the one
that matters; it passes before and after. docs/CLI.md and
docs/CLI.zh-CN.md record the /takeover fall-through. gofmt/vet/golangci-lint
clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:17 +08:00
SivanCola 0a8f4ceb7f Retry canonical takeover discovery only on transport failures
Problem
A canonical /takeover could block the TUI's update loop for roughly
2 x N x 2m15s: after every resident serve had answered (including the
wait-mode "still running; retry with mode=interrupt" verdict that already
cost a full drain window), the command re-discovered and asked every
serve again, with no spinner and no way to interrupt.

Root cause
cliTakeoverIdentityHeldSession re-ran the whole round on any error. The
second pass exists for a desktop reconnect that respawns the serve and
rewrites its state file, which only manifests as a transport failure; an
HTTP verdict was treated the same way.

Fix
Classify failures at the boundary that knows: cliServeClient and
postCLITakeoverHandoff wrap dial/request errors in
cliServeUnreachableError (transparent to %w chains). The round is
repeated once only when no serve answered; a verdict ends the command
and is the error reported. The wait-mode drain itself is unchanged and
still synchronous (noted for follow-up).

Verification
New TestCanonicalTakeoverDoesNotRetryOnServeVerdict (one handoff, one
discovery pass after a 409 "still running") and
TestCanonicalTakeoverRediscoversAfterTransportFailure (a refused dial
triggers exactly one re-discovery that then succeeds). Canonical
takeover/reclaim and manager readopt suites stay green;
gofmt/vet/golangci-lint clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:17 +08:00
SivanCola e5d762d26f Route /takeover after a legacy reclaim through the path lease
Problem
After the desktop reclaimed a legacy-path takeover, "/takeover takes it
back" posted session-id:<branch id> to a serve that holds a legacy path
lease, so the re-take was refused; "/takeover <n>" on a legacy row while
detached failed with "exclusive v3 controller has no bound session
runtime". A reclaim also left an open /resume picker showing stale rows.

Root cause
rememberReclaimedTarget preferred SessionRef(), which the exclusive
engine binds for legacy imports too (the transcript is mirrored under an
identity named by its branch id), so the path fallback was dead code. The
legacy branch of runTakeoverCommand always snapshotted the current
session and never cleared the reclaim state, even though a reclaimed
runtime is already released.

Fix
The mirror manager records the binding a reclaim yielded, and the TUI
routes by that binding's own kind: a legacy mirror is remembered as its
path, a canonical mirror as the identity its route names. The legacy
takeover branch skips the snapshot and lease-follow while detached,
clears the reclaim state on success, and says "resumed" rather than
"taken over" when no other runtime held the target (no mirror was
established). handleSessionReclaimed closes a stale picker overlay.

Verification
New TestLegacyMirrorReclaimRoutesTakeoverByPath (reclaim of a legacy
mirror is remembered as the path, the stale picker is dropped, and
/takeover posts no identity handoff) and
TestTakeoverOfFreeLegacySessionAfterReclaimSkipsSnapshot (detached
/takeover of a legacy row attaches and leaves reclaimed mode). Both fail
on the previous code with "reclaimed target = ref fresh-cli" and "left
the TUI in reclaimed mode". Canonical takeover/reclaim, manager, and
shutdown suites stay green; gofmt/vet/golangci-lint clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:17 +08:00
SivanCola 69857c3a3c Compute the recovery GC resume index after the active mirror settles
Problem
TestRunResumeKeepsCompletedIndexStableAcrossRecoveryGC fails once
mergeResumeStores orders canonical rows correctly: /resume <n> lands on
recovery-parent.jsonl instead of wanted.jsonl.

Root cause
The fixture computed the numeric index before creating the controller.
Creating a controller with SessionDir and an unsaved session path leaves
an engine mirror in sessions-v4 that the catalog lists as the newest row
while its metadata is pending, so the list the command resolved against
had one more leading row than the list the index was read from. The test
passed on the PR head only because the inverted comparison parked that
newer row at the tail, and the row's visibility also raced the
asynchronous metadata rebuild (pending rows stay listed; a ready empty
row is hidden).

Fix
Create the controller first, wait for the mirror's catalog metadata to
settle, then read the index — the order a user experiences, since the
list is rendered while the controller is alive — so both listings see
the same rows deterministically.

Verification
go test ./internal/cli/ -run TestRunResumeKeepsCompletedIndexStableAcrossRecoveryGC
passes three consecutive runs; gofmt/vet/golangci-lint clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:17 +08:00
SivanCola 7331bcee49 Return the active mirror on every session switch path
Problem
Switching away from a live canonical takeover never returned the mirror:
after /resume <canonical> or /takeover <canonical B> while mirroring A,
the desktop tab for A stayed read-only and rendered the new session's
frames under A's mirror id; /resume <legacy> while mirroring A either
leaked the mirror the same way or failed with "no detached session lease
held".

Root cause
Only the legacy-to-legacy path (cliAcquireFreeSession -> priorMirror ->
commitPrevious) returned the prior mirror. commitCanonicalSessionSwitch
did OpenSession + Rebind("") and left m.binding untouched, so Emit kept
forwarding the controller's frames; runCanonicalTakeoverCommand's
Activate overwrote the binding; commitPriorMirror assumed the detached
source keeper held a legacy lease with a reverse reservation to publish,
which is false after a canonical attach (the live keeper holds nothing).

Fix
Add cliTakeoverManager.leaveMirror as the single "leave the current
binding" step every switch takes before binding the next session. It
retires the source by kind: a legacy lease publishes its reverse
reservation from whichever keeper still holds it (detached or live; a
lease nobody holds has nothing to publish), a canonical identity only
ends the mirror because its writer lock travels with the controller
binding. commitPrevious/commitPriorMirror route through it, including
when the detached keeper is nil or lease-less. commitCanonicalSessionSwitch
now follows the legacy order — secure the target with a probe grant
(service.Open, so a held target fails with ErrWriterOwned before anything
changes hands), leave the mirror, publish via OpenSession, release the
probe — which also keeps the runtime-state frame emitted while publishing
out of the old mirror's queue. runCanonicalTakeoverCommand inherits the
fix because it commits through the same function before Activate.

Verification
New tests: TestResumeCanonicalSessionReturnsActiveCanonicalMirror (a),
TestCanonicalTakeoverOfSecondIdentityReturnsFirstMirror (b),
TestResumeLegacySessionReturnsActiveCanonicalMirror (c),
TestResumeCanonicalSessionReturnsLegacyMirrorReservation (legacy mirror
publishes its reservation), TestResumeCanonicalSessionHeldElsewhereKeepsMirror
(failure atomicity). Existing takeover, reclaim, resume, switch-recovery
and lease tests stay green; gofmt/vet/golangci-lint clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:17 +08:00
SivanCola c23c19cb5b Let signals quit the TUI once a session has been reclaimed
Problem
After the desktop reclaimed a session, SIGHUP/SIGTERM no longer exited
the CLI: the process lingered as an orphan after an SSH drop and
systemctl stop had to escalate to SIGKILL, until the user typed /resume
or /takeover.

Root cause
shutdownAndQuit consumed every non-user tuiShutdownMsg while
sessionReclaimed or takeover.Returned() was set. That guard targeted a
stale exit the yield callback used to send, but the callback now sends
tuiSessionReclaimedMsg, so the only remaining senders were the signal
loop (no escalation) and the watchdog, whose requests were silently
dropped for the rest of the session.

Fix
Guard only while a reclaim transaction is actually in flight
(takeover.Reclaiming()), where the manager goroutine owns the final
snapshot and mirror return. Even then the request is deferred, not
dropped: completeSessionReclaim honors it as soon as the reclaim
callback lands. Once the session is reclaimed or returned, a signal
quits like any other exit and skips the shutdown snapshot, since the
runtime binding of that session is already released and the remote side
writes it.

Verification
TestTUIShutdownSignalQuitsAfterReclaim pins tea.Quit with zero
SnapshotForShutdown calls both after the callback and in the gap after
the return; TestTUIShutdownSignalDeferredWhileReclaiming keeps the
in-flight case and proves the deferred signal fires with the callback;
TestBubbleTeaKeepsRunningWhenShutdownRacesReclaim now checks the real
program loop survives the race and then exits gracefully on the
callback. TestCanonicalReclaimKeepsTUIAliveAndSwitchesSession and the
watchdog suite stay green; gofmt/vet/golangci-lint clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:17 +08:00
SivanCola 9e9b3ce9bf Read migration maps directly for other-project resume rows
Problem
Every /resume, picker open, /takeover <n>, and each keystroke completing
"/resume <n>" opened a session.Service for every other known project,
cached it for the process lifetime (never closed), and scheduled a
metadata rebuild for each unindexed catalog row there.

Root cause
otherProjectResumeEntries reused the current-workspace scan
(scanWorkspaceResume -> cliSessionService(root) -> Query().List) merely
to learn which legacy transcripts had been migrated, although the only
input that decision needs is the workspace's migration-map.json.

Fix
Split migration-map loading (readMigrationMapping, a plain file read)
from the index fold (migratedLegacyIndexWith, parameterized by a
"successor is live" predicate). The current workspace keeps its
"visible catalog row" rule; foreign workspaces use
foreignProjectResumeRows, which accepts a successor when its session
directory exists and never instantiates a service for that root.

Verification
New TestOtherProjectResumeRowsUseMigrationMapWithoutForeignService
registers a foreign project with a migrated and a live transcript and
asserts only the live one is offered and no service entry exists for the
foreign sessions-v4 root. TestResumeEntriesIncludeOtherProjects and the
merged-store tests still pass; gofmt/vet/golangci-lint clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:17 +08:00
SivanCola 60dcf66a56 Walk the whole session catalog before ranking resume rows
Problem
With more than 100 final-format sessions the newest conversation can be
missing from the --resume picker and /resume list, and --continue
(newestResumeTarget, scan.canonical[0]) can pick an older session.

Root cause
canonicalResumeEntries stopped paging once it had collected 100 visible
rows. The catalog pages in session-id (lexical) order, so the loop
returned the first 100 ids, and only then sorted those by UpdatedAt; the
newest session was never read when its id sorted after the first page.

Fix
Page the cursor through the catalog up to a documented hard cap
(canonicalResumeWalkCap = 20 pages of 100 rows), then sort by recency
and apply the display cap. The catalog dependency is expressed as a
small lister interface (*session.Query satisfies it) so the paging and
ranking contract is testable without hundreds of on-disk sessions.
Recency ordering from the owner would remove the walk entirely, but the
session package is outside this change's scope; the cap bounds the scan
until the catalog offers a recency index.

Verification
New TestCanonicalResumeEntriesRankNewestAcrossCatalogPages builds 250
sessions whose newest sorts last by id and asserts the newest 100 are
returned newest-first across 3 pages (fails on the old loop with
"newest row = s00099"); TestCanonicalResumeEntriesBoundTheCatalogWalk
pins the page bound. go test ./internal/cli/ -run
'TestCanonicalResumeEntries|TestMergedResumeEntries|TestNewestResumeTarget'
passes; gofmt/vet/golangci-lint clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:17 +08:00
SivanCola 968f8d4ae1 Emit newer canonical rows before legacy runs in merged resume list
Problem
With ten or more legacy transcripts a desktop-created final-format
session never appears in /resume, the --resume picker, /rename <n>, or
/takeover <n>, even though it is the newest conversation.

Root cause
mergeResumeStores interleaves two newest-first lists but negated the
recency comparison: canonical rows older than or equal to a legacy run
were emitted before it while newer rows were deferred to the tail loop,
where capResumeEntries truncated them. scanWorkspaceResume also mutated
canonical ModTime through the migration borrow after
canonicalResumeEntries had sorted, so scan.canonical[0] (used by
--continue) was not guaranteed to be the newest row.

Fix
Emit a canonical row ahead of a legacy run exactly when it is newer than
the run's latest activity (ties keep the legacy row first so numeric
indices of an unchanged legacy list stay put), and re-sort scan.canonical
after the borrows so both the merge and newestResumeTarget see
newest-first input.

Verification
New TestMergeResumeStoresOrdersByRecency pins [L10,L5]+[C12] ->
[C12,L10,L5], [L10,L5]+[C7] -> [L10,C7,L5], older/tie/multi-row cases;
TestMergeResumeStoresCapKeepsNewestCanonicalRow proves the cap drops the
oldest legacy row rather than the newest canonical one. Both fail on the
inverted comparison and pass with the fix. go test ./internal/cli/ -run
'TestMergeResumeStores|TestMergedResumeEntries|TestNewestResumeTarget'
passes; gofmt/vet/golangci-lint clean.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:17 +08:00
SivanCola 057674abb8 Fix lint findings in resume and takeover code
Problem
The required CI `lint` job fails on the PR: intrange in
session_takeover_canonical.go, staticcheck S1011 in resume.go (manual
append loop), QF1003 in resume_picker.go (if/else chain on one tag), and
a gofmt blank-line violation in resume_test.go.

Root cause
New code was written against older idioms the repository's golangci
configuration rejects; the test file was not gofmt'd.

Fix
Use `for range 2`, `append(out, base...)`, a tagged switch on the row
index, and gofmt the test file. Behavior is unchanged; the tagged switch
keeps the "active" status winning when both indices match.

Verification
gofmt -l internal/cli/ is empty; go vet ./internal/cli/ and
golangci-lint run ./internal/cli/ report 0 issues (the remaining
resume_catalog_test.go intrange items land with the ordering fix that
rewrites that file); go test ./internal/cli/ -run
'TestResumePicker|TestResumeEntries|TestCanonicalTakeoverCommand' passes.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:17 +08:00
SivanCola 0a91b9c15b Drop the unused sessioncontext.IsSnapshot predicate
Problem
IsSnapshot was added by this PR with no caller anywhere in the
repository: internal/, cmd/, the desktop module and the frontend were
all swept and none reference it.

Root cause
A helper introduced for a call site that the final shape of the change
never needed.

Fix
Remove it. The package already exports IsContent for this question, and
IsContent is the stronger test every existing caller uses (internal/boot,
internal/agent, internal/provider/anthropic): it parses the block instead
of matching the opening tag as a prefix, so a truncated or malformed
block is correctly rejected. Keeping both would invite the weaker one to
be picked by a future caller. No provider-visible bytes change: the
removed function was never called and built no prompt content.

Verification
grep -rn IsSnapshot over *.go/*.ts/*.tsx/*.js/*.md (repo-wide, incl.
desktop/ and cmd/): no callers
go test ./internal/sessioncontext/ ./internal/provider/anthropic/ -run SessionContext
go vet ./internal/sessioncontext/ && golangci-lint run ./internal/sessioncontext/

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:16 +08:00
SivanCola bcda3064f8 Reclaim on mirror-end and re-check identity history reads
Problem
mirrorEndIdentity answered 204 the instant it saw the writer lock still
held, leaving the remote side read-only until the 30 s stale
auto-reclaim driven by /status polls noticed. The desktop sends
mirror-end at tab close and releases its runtime in the same teardown
with no ordering guarantee, so the common case was a farewell that
arrived microseconds before the drop and cost a 30 s recovery. The
identity branch of history() also lacked the "runtime changed during
read" re-check transcriptBoundRead performs.

Root cause
The farewell treated a held lock as terminal instead of as the expected
ordering, and the identity history read published a transcript captured
outside bindMu without re-resolving its route.

Fix
mirrorEndIdentity waits for the writer drop outside bindMu for a bounded
2 s (25 ms polls) and then reclaims through the existing locked path; a
writer that outlives the bound is still accepted, so the auto-reclaim
fallback and the mirror entry it needs are unchanged. The identity
history branch re-resolves the controller under bindMu after the read
and returns the same 409 as transcriptBoundRead when it changed.

Verification
go test -race ./internal/serve/ -run 'TestIdentityMirrorEnd|TestHistoryIdentityRoute|TestIdentityReclaim|TestAutoReclaim|TestMirrorEnd'
go vet ./internal/serve/ && golangci-lint run ./internal/serve/

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:16 +08:00
SivanCola be6c4a4265 Fence identity handoff on busy re-check and detached holders
Problem
handoffIdentityLocked released the foreground without re-checking under
bindMu whether a turn had been admitted after the unlocked quiet probe.
POST /chat admitted in that window ran on a runtime the handoff then
unbound; service.Close returned ErrRuntimeBusy and the caller got a 500
with the foreground already rotated, no mirror entry and an orphaned
running runtime. Separately, serveHoldsIdentity, quietIdentityForHandoff,
ownershipIdentity and handoffIdentityLocked looked only at the
foreground, so an identity run by a detached background session was
reported as "other" and /adopt accepted a foreign claim over this
process's own writer.

Root cause
The identity path skipped the legacy handoffLocked invariants: the
locked busy re-check (turn admission holds bindMu, so an idle target
observed under the lock stays idle) and the detached-registry scan.

Fix
handoffIdentityLocked resolves the holder through identityHolderLocked
(foreground, else the detached session bound to the identity), returns
errHandoffBusyAgain when the holder reports work or the runtime is still
finalizing, and only then releases and closes; a close refused as busy
also maps to busy-again after the holder is re-attached. A detached
holder is taken from the watcher, released, and its controller retired
exactly like the legacy detached handoff. detachedIdentityHolder backs
every identity ownership predicate so /ownership answers "serve",
/adopt refuses, and /status does not pin a detached identity read-only.
A test hook runs between the quiet probe and the lock.

Verification
go test -race ./internal/serve/ -run 'TestIdentity|TestHandoff|TestReclaim|TestMirrorEnd|TestAdopt|TestOwnership|TestExclusiveV3'
go vet ./internal/serve/ && golangci-lint run ./internal/serve/

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:16 +08:00
SivanCola 7ae3fd71c4 Release identity handoffs without persisting a replacement
Problem
Every identity /handoff rotated the foreground with BindFreshSession,
which runs PrepareCreate, seeds, flushes and publishes a brand-new
canonical session. Each handoff/reclaim cycle therefore left an empty
row in /sessions, whereas the legacy SessionLeaseKeeper.ReleaseForHandoff
only unbinds authority.

Root cause
The controller had no primitive to release a bound identity without
allocating a successor, and NewSession failed outright on an exclusive
controller with no bound runtime, so the host rotated eagerly to keep
the foreground usable.

Fix
control.Controller.ReleaseSessionForHandoff flushes the runtime, drops
the client binding, empties the in-memory transcript and rebinds the
turn-event state to "no store", leaving the controller in the same
never-bound exclusive state a fresh controller starts in; the next turn
allocates lazily through ensureWriteAuthorityReady, and
rotateExclusiveSession now allocates on demand when nothing is bound.
Serve's handoffIdentityLocked uses the primitive, closes the runtime to
drop the writer lock, and re-attaches the foreground through OpenSession
if that close is refused, so a failed release never leaves the writer
held by an unbound foreground. The turn_events.go gofmt drift from the
PR is folded in.

Verification
go test ./internal/control/ -run 'TestReleaseSessionForHandoffLeavesControllerAllocatable|TestOpenSessionReopensRuntimeClosedByReclaim'
go test ./internal/serve/ -run 'TestIdentity|TestHandoff|TestReclaim|TestMirrorEnd|TestAdopt|TestExclusiveV3'
gofmt -l, go vet and golangci-lint run on internal/control and internal/serve

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:16 +08:00
SivanCola 61a3cfd69b Make the writer-lease probe non-stealing for acquirers
Problem
session.ProbeWriterHeld answered "who holds this identity" by taking
writer.lock exclusively (LOCK_EX|LOCK_NB plus the in-process exclusive
slot) for an instant. A legitimate acquireSessionWriter landing in that
instant (the desktop opening after /handoff, or serve's own OpenSession
in reclaim racing a /status poll) saw ErrHeld and surfaced
ErrWriterOwned for a session nobody owned. The probe ran twice per
/status poll on identity routes and five times a second in the reclaim
loop, and created writer.lock as a side effect.

Root cause
The probe and the acquirer shared one lock with no way to tell a
transient probe hold from a real owner, and this process had no record
of the writer leases it already holds.

Fix
directory_ownership.go now owns a process-local writer registry plus a
mutex that makes an in-process probe and an in-process acquire mutually
exclusive, so that collision is impossible rather than retried. The
probe answers from the registry when this process holds the lease and
never touches the lock file; a never-created writer.lock counts as free
without creating it. For a probe in another process, the acquirer
tolerates ErrHeld on the inner writer.lock with a bounded, documented
retry (5 x 10 ms); the outer directory claim is never probed, so ErrHeld
there is still refused at once. Desktop and CLI acquirers share this
code through internal/session.

Verification
go test -race ./internal/session/ -run 'TestProbeWriterHeld|TestAcquireSessionWriter|TestStorageRevisionExternalLease|TestPurge|Maintenance'
go vet ./internal/session/ && golangci-lint run ./internal/session/

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:16 +08:00
SivanCola 78a4fec561 Route identity notices verbatim through every emit path
Problem
Broadcaster.Emit and EmitTo still canonicalized SessionPath as a
filesystem path, so a "session-id:<id>" route became "<cwd>/session-id:
<id>". The taken-over, reclaim-requested, reclaimed and adopted notices
for final-format identities therefore carried a mangled route, were
marked SessionCurrent=false and were dropped for current-only (browser)
subscribers. Only EmitWire had been taught about identity routes.

Root cause
Route normalization was repeated per emit path instead of living once
in the broadcaster, and the current-only filter compared an identity
route against a path key it can never equal.

Fix
sessionRouteKey is the single normalization rule for every broadcaster
key (Emit, EmitTo, EmitWire, SetCurrentSession, ResetSessionPath and the
usage ledger): identity routes stay verbatim, legacy paths canonicalize.
hiddenFromCurrentOnly applies one filter rule in Emit and EmitTo:
identity routes are not path-keyed and pass to current-only subscribers
exactly like identity-tagged live frames already do. mirrorKey now
delegates to the same rule so registry keys and emitted routes agree.

Verification
go test ./internal/serve/ -run 'TestBroadcaster|TestExternalFrames|TestMirrorGeneration|TestIdentity|TestReclaim|TestAdopt|TestHandoff|TestOwnership'
go vet ./internal/serve/ && golangci-lint run ./internal/serve/

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:16 +08:00
SivanCola 990ac341b6 Guard migrated legacy source removal on canonical delete
Problem
Deleting a canonical session through POST /delete-session removed the
legacy transcript recorded as its migration source without checking
whether another canonical row still referenced it, and without any of
the ownership or containment guards the legacy delete applies. A source
that /sessions still lists as a distinct session (two targets), a source
a local window is writing, or a path outside the session dir could all
be deleted with their sidecars. The lookup also called Close() on the
shared session query, stopping its background rebuilds.

Root cause
Listing and deletion used two different migration-index rules:
loadMigrationIndex hid a source only when it had exactly one live
target, while migrationSourceForTarget returned the first entry whose
TargetID matched and passed the recorded path straight to
removeSessionFiles.

Fix
One shared loadMigrationIndex(roots, exists) builds the sole-target
index for both /sessions and the canonical delete. The delete resolves
the source before the catalog row disappears (so the row still counts as
a live target), validates it with resolveSessionPath (session-dir
containment, transcript name, cleanup marker) and the extracted
legacyTranscriptDeleteRefusalLocked gate (active, detached, mirrored),
and removes it through destroyLegacyTranscriptLocked, the same teardown
the named legacy delete uses. A refused source fails the request before
anything is removed; an unresolvable source is left alone and logged.

Verification
go test ./internal/serve/ -run 'TestDeleteCanonicalSession|TestDeleteSession|TestSessions|TestExclusiveV3'
go vet ./internal/serve/ && golangci-lint run ./internal/serve/

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:58:16 +08:00
SivanCola 1aee63332b Record the measured initial bundle ceiling for this branch
Problem
`pnpm check:bundle` failed on the merged tree: the initial raw payload is
2022.6 KiB against the branch's 2020.5 KiB ceiling. The ledger comment above
that ceiling also mixed measurements from two different scales (2437.x KiB
lines beside 206x xxx B lines), and the PR had deleted two main-v2 entries,
so the recorded history no longer explained the value.

Root cause
The 2020.5 ceiling was carried over from an earlier point of the branch and
left only 0.2 KiB of headroom, which this review series' fixes consume. The
2437.x lines predate the chunking change that moved the initial payload from
~2.44 MiB to ~2.02 MiB, so they describe a payload that no longer exists.

Fix
Restore the two main-v2 ledger entries the PR removed, drop the stale
mixed-scale lines, and record one paragraph with the measured bytes and the
attributable cause: 2071101 B, 5952 B (0.288%) over main-v2's recorded
2065149 B, driven by this branch's remote session identity work (remote
telemetry and status become static imports of useRemoteSession and leave
their lazy surface chunk, plus the canonical session-id plumbing, spectator
reconcile loop and ownership classification, the pre-activation history
prime, the rebased-submission settlement and the tree's canonical row
identity). Set the ceiling to the next one-decimal value, 2022.7 KiB,
keeping 0.14 KiB headroom in line with the recent 0.15 KiB precedent. The
gzip, CSS and chunk limits are untouched and still pass.

Verification
pnpm build:electron && pnpm check:bundle: initial raw JavaScript and CSS
2022.6 KiB / 2022.7 KiB, largest initial chunk 518.8 KiB / 1000.0 KiB, all
gzip limits passing.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:55:11 +08:00
SivanCola cded0dafb9 Open canonical remote sessions by id in the browser mock
Problem
With the Serve moving its synthetic "current" row to a real `sessionId`,
clicking that row in browser/dev mock mode opened the workspace surface but
bound no session: the tab kept the previously synthesized basename route,
no row became current, and TabMeta carried no `sessionId`, so the runtime
identity key fell back to the tab/topic key.

Root cause
`mockRemoteProjects.OpenRemoteProjectTab` only honoured `opts.sessionName`.
The real bridge already prefers `opts.SessionID` over the path and name
(desktop/remote_projects.go, remote_tab_multisession.go) and publishes it as
TabMeta.SessionID, so the mock had drifted from the contract it implements.
The click path itself is correct: `openRemoteSessionNode` forwards
`sessionId` with no path or name for a canonical row (pinned by the tree
test added earlier in this series).

Fix
Resolve the requested session by canonical identity first (`sessionId`, else
the legacy name), bind `tab.sessionId`, adopt the row's durable path when it
has one instead of synthesizing a basename route, and mark that row current.
Legacy rows keep their existing basename behaviour unchanged.

Verification
pnpm typecheck; pnpm test:typecheck; mock-remote-catalog.test.ts (extended:
canonical open binds the id and drops the stale legacy route, legacy open
keeps its basename route and becomes the current row);
remote-tab-opened.test.tsx; add-project-entries.test.ts;
remote-session-cache.test.ts; statusbar-workspace.test.tsx.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:46:57 +08:00
SivanCola ed8a9f7fa9 Give the active remote session reference a stable identity
Problem
`buildSidebarRegionProps` built `activeRemote = { ...remote, sessionId }`
inline during the AppRuntimeView render, which runs on every transcript
delta. The project tree keys ancestor expansion, active-row marking, the
read-activity effect and its whole row projection on that value, so each
streaming delta invalidated those memos and re-ran full tree walks over the
project/session tree.

Root cause
The PR introduced the composed reference (host, workspace and the bound
session id) inside a pure prop-assembly function, which by contract owns no
state and therefore cannot memoize. The reference had no owner.

Fix
Add `useActiveRemoteRef`, which owns the value and rebuilds it only when the
host, workspace or bound session id changes. It keys on those primitives
rather than on `tab.remote`'s object identity, because RemoteTabRef carries
exactly hostId and workspace (desktop/remote_projects.go): an unrelated
tab-meta refresh (running flag, turn counters) replaces the nested object
but must not churn the sidebar. AppRuntimeView calls the hook and passes the
result through the assembly, which now forwards it untouched; the input is
required so a future caller cannot silently drop it.

Verification
pnpm typecheck; pnpm test:typecheck; check-app-layers;
sidebar-active-remote-identity.test.tsx (new: identity stable across
re-renders and unrelated meta refreshes, new reference on session rotation
and host change, undefined for local tabs and drafts, forwarded by identity
through the assembly); independent-session-boundaries.test.ts;
remote-project-tree.test.tsx.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:44:53 +08:00
SivanCola 2012268011 Track the remote composer profile route per tab
Problem
Switching remote tabs A -> B -> A reverted A's in-flight composer choice:
the mode, approval, goal or quality-floor the user had just picked snapped
back to the backend value until the next status refresh.

Root cause
`useRemoteComposerProfileSync` kept a single `lastRouteRef` holding only the
most recently synced route, while its `routeKey` includes `activeTabId`.
Every tab switch therefore compared A's key against B's and reported a route
change, which passes `undefined` as the existing profile to
`reconcileComposerProfile` and drops `pending: {}` — exactly the path meant
for a real session rotation.

Fix
Replace the single ref with a per-tab `Map` of the last synced session
route. A tab is only a rotation when that tab's own route changed; a tab
seen for the first time reconciles over whatever the profile table already
holds, as before. Entries for tabs the profile table no longer contains are
pruned on the next sync, so the memory stays bounded to live tabs and a tab
id reused after removal reads as a first sync rather than a rotation.

Verification
pnpm typecheck; pnpm test:typecheck; remote-composer-profile-sync.test.tsx
(new: A -> B -> A preserves A's pending flags and its visible mode, B stays
untouched, A's own route change adopts the backend profile, and a forgotten
tab id is not a rotation but still rotates on its next real change);
remote-composer-presentation.test.tsx; remote-composer-commands.test.tsx;
controller-profile-lifecycle.test.tsx.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 14:42:35 +08:00
SivanColaandFuchaZ de4adfdc8e fix(desktop): migrate historical sessions on demand
Co-authored-by: FuchaZ <[email protected]>
2026-09-20 13:57:58 +08:00
SivanCola 38a51ca735 Bind the remote surface harness to the remote canonical reader
Problem
Once remote-session-surface.test.tsx ran to completion, three hydration
race/rotation checks failed: "a ready generation re-hydrates after
discarding the stale in-flight snapshot", "ready-to-ready rotation hydrates
the adopted session while old reconciliation is pending" and "session
generation fence rejects stale history and hands reconciliation to the new
generation".

Root cause
Those scenarios count RemoteTabSnapshot calls (deferred resolvers indexed
by call order). The PR's pre-activation history prime issues one canonical
window read at mount. The harness mounts useRemoteSession without a
controller, so `identityFor(tabId)` resolves to "local" and the read goes
to the mock reader's SessionHistoryWindowForTab, whose mockReaderSlice
feeds from the fixture's HistoryForTab -> RemoteTabSnapshot. The prime
therefore consumed a scenario deferred and shifted every index. This was
masked at PR head because the suite aborted earlier.

Fix
Declare the remote binding identity in the harness
(`setTranscriptBindingIdentity(() => "remote")`), which is what the
controller does for every remote tab in production, and stub
RemoteSessionHistoryWindowForTab like a serve whose tab has not attached
yet. The prime stays inert and the mock Follow path (local window) is
untouched, so the scenarios again count only Follow-driven snapshots.

Verification
remote-session-surface.test.tsx: 115 passed, 0 failed.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 13:57:54 +08:00
SivanCola d49752a89e Gate the legacy remote hydration fallback on a take-over verdict
Problem
useRemoteSession's hydrate() treated every `follower.start()` or status
failure as a take-over: a transient network error or a restarting serve
retried the legacy status snapshot up to 60x500 ms, then installed the
protocol-1 history view and reported `state="ready"`, `hydrated=true`,
`error=""`. From there submit() threw "Transcript v2 is not synchronized"
and nothing re-ran hydrate(): the spectator poll only flipped `spectator`
back to false. When the fallback itself failed, the error escaped the catch
block as an unhandled rejection (remote-session-surface.test.tsx aborted on
"history exceeds bridge limit").

Root cause
The catch block did not discriminate the failure. The serve's take-over
surface is specific: a spectator's Follow request fails with "remote
transcript read failed (HTTP 409)" (desktop/remote_transcript_api.go) and
status/meta carry `takenOver`. The fallback also ran outside any try, and
ownership returning had no owner: `spectator` was written from two places
(status, tab meta) but no code observed the true -> false transition.

Fix
Add `isRemoteTakeoverError` (mirrors remoteSessionTakenOver's wordings plus
the HTTP 409 surface) and `remoteStatusTakenOver`. hydrate() now falls back
only when the error is a take-over or a one-shot status probe reports
`takenOver`; every other failure keeps `setError` as before, and a failing
fallback surfaces its own error instead of rejecting hydrate(). Ownership
observations funnel through one `noteOwnership` seam used by both the status
and meta paths; it mirrors the last verdict in a ref and re-runs hydrate()
on the spectator -> owner transition, so the follower re-attaches and the
composer can send again. The legacy install retires the early history prime
(it owns the surface from then on). The observation resets per mounted tab.

Verification
pnpm typecheck; pnpm test:typecheck; remote-session-takeover-hydration
(new, 13 checks: transient error surfaced and no legacy install; 409 falls
back to the legacy view; spectator submit refused; owner return via tab meta
and via the status poll re-attaches exactly once; status verdict admits the
fallback for a non-409 failure; fresh tab hydrates once). remote-session-
surface.test.tsx now runs to completion (previously aborted); its three
remaining race/rotation failures are a harness coupling with the history
prime, fixed in the following commit.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 13:57:54 +08:00
SivanCola 16ecc7937b Keep the early history prime from writing over the follower
Problem
useRemoteSession's pre-activation history prime mutated the resident
transcript store before its "blank transcript" guard ran, and every
`remote-tab:updated` publication retried it while `hydratedRef` was false.
A late legacy `/history` window could therefore replace records and cursors
the follower had already installed, and each retry bumped the resident
session generation, retiring the follower's in-flight reads.

Root cause
`TranscriptStore.loadLatest` bumps `session.generation` and calls
`replaceRecords` before it consults the caller's `current` predicate, so the
PR's guard (placed after `await loadLatest`) ran only after the damage. The
prime also keyed its lifetime on `hydratedRef`, which flips only after the
status snapshot completes, long after the follower's install cut has become
the store owner. Two further latent defects: the prime's `current` predicate
compared against the hydrate generation, which `hydrate()` bumps
synchronously right after the mount-time prime starts, so the mount-time
prime was always discarded; and two independent booleans
(`primedRef`/`primeInFlightRef`) encoded a four-state lifecycle.

Fix
Replace the booleans with one named substate `primeRef: idle | loading |
primed | retired`. Check for existing content (items or a live tail) before
calling `loadLatest`, marking the prime retired without touching the store.
Retire the prime when the follower dispatches its install cut
(`transcript_v2_snapshot`; connection frames precede it and own nothing), so
a window landing afterwards is dropped by `loadLatest`'s `current` predicate
before `replaceRecords`, and later attach publications are no-ops. Scope the
prime to the mounted identity (`cancelled`) and store ownership instead of
the hydrate generation, so a stalled or failed follower still leaves the
durable baseline visible; the follower cut supersedes it when it arrives.

Verification
pnpm typecheck; pnpm test:typecheck; remote-session-history-prime.test.tsx
(new, 11 checks: late window cannot replace transcript or store records,
generation unchanged, retries no-op, resident content skips the read,
prime-first then follower supersedes); remote-tab-opened.test.tsx;
remote-fork-targets.test.tsx.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 13:50:14 +08:00
SivanCola ee743e4779 Remove the write-only pendingTurnRef from useRemoteSession
Problem
`pendingTurnRef` in useRemoteSession.ts is assigned on mount reset, on the
ready republication and in submit(), but never read. The PR's ready-state
hunk added a comment claiming the assignment clears a stale optimistic
pending submission and prevents a zombie "processing" indicator; the
assignment has no such effect, so the comment misleads readers into
believing a reconciliation exists.

Root cause
The ref predates the transcript-store ownership of pending submissions
(`localSubmissions`/`pendingSubmissionId` live in reducer state, settled by
localSubmissionState) and was left behind as dead state. The PR built on it
instead of on the reducer state, which is the boundary the previous commit
fixes.

Fix
Delete the ref, its three writes and the misleading comment. The ready
handler again simply re-hydrates; stale echoes are settled by the reducer's
snapshot path.

Verification
pnpm typecheck; remote-tab-opened.test.tsx; remote-fork-targets.test.tsx;
conversation-projection.test.ts.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 13:44:07 +08:00
SivanCola 33416b629d Settle rebased optimistic echoes at the local-submission seam
Problem
After a snapshot rebase (runtime rebuild, serve restart, model switch) a
submission the server had already journaled kept rendering as a second user
bubble stuck at "processing". The PR's regression suite failed 5 of 11
checks and the zombie indicator was not fixed.

Root cause
The PR added its absorption inside transcriptSnapshotState by filtering
`state.items` for user items carrying the pending submission id. Optimistic
submissions never live in `items`: `startLocalSubmission` records them in
`localSubmissions`/`localSubmissionOrder` and the chat view projects them
from there. The filter was therefore always empty and `running`,
`pendingSubmissionId` and the duplicate bubble stayed governed by the
untouched `hasLocalSubmission` path. Two of the PR's checks also looked for
the echo in `state.items`, i.e. at the wrong seam.

Fix
Add `settleRebasedSubmissions` next to `settleLocalSubmissions` in
localSubmissionState.ts, the owner of echo lifetime, and call it from
transcriptSnapshotState after the id/messageId settlement. It retires an
echo when a durable record repeats its submission id (no messageId needed)
and, only while the runtime is idle, the oldest echo whose text the newest
durable user record repeats. The text fallback is bounded by the echo's own
anchor: durable copies must outnumber the identical user items that were
already on screen when the echo was created, so an earlier identical message
can never absorb a newer lost re-send (the review nit). An active runtime
never absorbs by text. The PR's dead `optimistic`/`represented` code is
removed and `running`/`cancellable`/`pendingUser` return to main-v2's form,
now settling naturally because the absorbed echo is gone.

The test measures the echo where the user sees it (durable items plus
unmatched local submissions, mirroring chatViewSource) instead of
`state.items`, and adds the id-only link, the confirmed-echo duplicate, the
identical-message bound, and its release once the second copy is journaled.

Verification
pnpm typecheck; pnpm test:typecheck; transcript-snapshot-optimistic-orphan
(17 checks); submission-handoff (25); transcript-session-follower (27);
transcript-store (367); history-preparation; remote-running-reconcile (18);
session-submission-lifecycle. remote-session-surface.test.tsx still aborts
at PR head on an unhandled rejection thrown from useRemoteSession's legacy
hydration fallback (the PR's catch block re-throws); that is fixed with the
take-over gating commit, after which the suite is re-run.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 13:43:36 +08:00
SivanCola 045551fe69 Key remote tab runtime identity by canonical session id
Problem
Two canonical remote sessions opened in the same remote tab produced the
same runtime identity key, so the session fence, resource operations and
controller profile targets could not tell them apart. The PR's regression
check in session-control-commands.test.ts failed (both inputs keyed to
`topic\0\0\0\0remote`) and did not compile under tsconfig.test.json.

Root cause
`sessionIdentityKey` derives the canonical key from `identity.session`
(a SessionRef). Remote tab metas never carry a SessionRef: desktop's
remoteTabMetaLocked publishes the canonical id only as the top-level
`sessionId` compatibility field (local metas build the SessionRef from it in
tabs.go). The frontend therefore fell through to the topic/tab fallback for
every remote tab, and the fallback was only unique by accident of the Go
topic id embedding the session id.

Fix
Make `sessionIdentityKey` the owner of the invariant: `SessionIdentityInput`
accepts the TabMeta compatibility fields (`sessionId`, `remote.hostId`) and,
when no SessionRef is present, keys on `ref\0<host>\0<sessionId>`. The
generation is deliberately excluded on this path because a remote tab's
`sessionGeneration` is its event-pump reconnect counter (remote_tab_publication.go),
not a session identity; including it would re-fence every reconnect. All
three callers (AppRuntime, controllerProfileOwner) pass the new fields.
The test keeps the real TabMeta shape (top-level sessionId) and now also
carries the remote host; it additionally asserts that a generation bump does
not change identity, that the same id on two hosts differs, and that a
host-less compatibility id still keys the session rather than the tab.

Verification
pnpm typecheck; pnpm test:typecheck; session-control-commands.test.ts;
session-identity.test.ts; independent-session-boundaries.test.ts;
navigation-surface-lifecycle.test.tsx; remote-composer-commands.test.tsx;
controller-profile-lifecycle.test.tsx; active-tab-mirror.test.tsx.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 13:37:58 +08:00
SivanCola 4cfb9e4624 Assert the current-row remote session contract in the tree test
Problem
remote-project-tree.test.tsx reported 24 pass / 1 fail: its source regex
still required the `remoteSession` mapping to end at `title: row.title }`
while ProjectTreeRemoteGroups.tsx now also carries `current: row.current`.
The suite also had no functional check that a sessionId-only synthetic
"current" row forwards its id to OpenRemoteProjectTab.

Root cause
The PR extended the row mapping (the `current` flag drives the trash guard
for the not-yet-catalogued session) without updating the contract regex, and
the Go side is moving the synthetic current row from a `session-id:` path to
a real `sessionId`, so the click path needed an explicit forwarding test.

Fix
Tighten the regex to the full mapping including `current: row.current`
instead of relaxing it. Add a functional case: a row with only `sessionId`
merges without a synthesized path, `openRemoteSessionNode` forwards
`sessionId` (no `sessionPath`/`sessionName`) to the open port, and
`remoteSessionActionIdentity` addresses the row by id, decoding the legacy
`session-id:` path only as a fallback.

Verification
tsx src/__tests__/remote-project-tree.test.tsx (28 passed).

Co-authored-by: SivanCola <[email protected]>
2026-09-20 13:35:43 +08:00
SivanCola 3b5ac4222d Restore active-tab remote flag after main-v2 merge
Problem
The merged tree fails `pnpm typecheck`: AppRuntimeView.tsx references
`workspaceContextTab`, a name that no longer exists after the merge took
main-v2's shape of the composer workspace-context block.

Root cause
The PR renamed the workspace-context source tab to `workspaceContextTab`
while main-v2 (da9adbd05) already isolates the draft workspace context in
decisionFooterBuilders and keeps `activeTab` as the only tab in scope. The
merge resolved the surrounding block to main-v2's form but kept the PR's
identifier on the `remote` line.

Fix
Use `remote: Boolean(activeTab?.remote)`, matching main-v2 and the sibling
`gitBranch`/`workspaceName` lines that already read `activeTab`. The PR's
takeover-aware terminal `readOnly` predicate is unchanged.

Verification
pnpm typecheck; composer-workspace-context.test.tsx; workspace-layout.test.ts.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 13:34:12 +08:00
SivanCola e28effb70b Merge pull request #10562 from SivanCola/fix/credential-store-acl-independent
Make credential saves independent of ACL repair / 让凭据保存不再依赖 ACL 修复
2026-09-20 13:17:51 +08:00
SivanCola 9ffcddc868 Merge pull request #10561 from SivanCola/fix/remove-windows-sandbox-backend
Remove the retired Windows sandbox backend / 移除已退役的 Windows 沙箱后端
2026-09-20 13:15:16 +08:00
SivanCola 7ecd40c982 Merge branch 'main-v2' into fix/sessionid-migration-gaps
Conflict resolutions:
- desktop/app.go: keep main-v2; e7a5b149e moved AttachDropped and its
  helpers to attachments_app.go, so the branch's edit to that block is
  obsolete.
- desktop/frontend/src/app-shell/AppRuntimeView.tsx: keep main-v2;
  da9adbd05 already isolates the draft workspace context in
  decisionFooterBuilders, making the branch's draft-aware hunks duplicates.
- internal/serve/session_ownership.go: keep the session import only; the
  store import became unused when 08ca31641 moved resolveSessionPath.
- desktop/frontend/scripts/check-bundle-budget.mjs: keep the branch ceiling
  pending a re-measurement of the merged build.

Co-authored-by: SivanCola <[email protected]>
2026-09-20 13:13:58 +08:00
SivanCola 023e26e94f Merge pull request #10559 from SivanCola/fix/windows-sandbox-force-off
Retire the Windows shell sandbox from enforcement / 让 Windows 退出 OS 级 Shell 沙箱强制执行
2026-09-20 13:00:53 +08:00
SivanCola 5f25014615 Merge pull request #10560 from SivanCola/fix/draft-session-archive
Stop regenerating a blank session after archiving the last one / 归档最后一个会话后不再重新生成空白会话
2026-09-20 13:00:35 +08:00
SivanCola 3e4fa04600 Inspect reset ACEs directly and use CutPrefix for NT paths
SDDL renders well-known accounts as aliases (the CI runner's
administrator shows as LA), so the reset test compares the ACE trustee
SID and the protected control bit instead of the rendered string. The
Windows-tagged lint run also asked for strings.CutPrefix.
2026-09-20 12:53:38 +08:00
SivanCola 189bb280d1 Open the locked credential store with exact NT access rights
Windows CI showed the WRITE_DAC-only reset failing with access denied:
CreateFile silently adds SYNCHRONIZE and FILE_READ_ATTRIBUTES to every
open, and both rights are inside the legacy DENY RX mask, so the Win32
open could never succeed against the very file the reset exists for.
The same applies to MoveFileEx behind os.Rename.

Route both operations through NtCreateFile with the exact access mask
(WRITE_DAC for the DACL reset, DELETE for the quarantine rename via
FileRenameInformation), opening reparse points as themselves. The
quarantine now uses that rename so a read deny cannot block it either.
2026-09-20 12:53:38 +08:00
SivanCola 08af3331b9 Make credential saves independent of ACL repair
A credential store locked by a deny entry could only be recovered when a
retired-sandbox marker proved the entry came from Reasonix and the
process token owned the file; otherwise the repair's own DACL read
failed and its error replaced the original one, so users could neither
read nor re-enter their API key.

Plain reads keep the original permission error and attach the repair
outcome as a wrapped cause. Explicit saves gain a recovery ladder in the
config package: after the provenance-checked repair, reset the file's
DACL to a protected current-user grant through a WRITE_DAC-only handle
that never reads the existing descriptor, and if that is denied too,
move the locked file aside as .env.locked-<timestamp> and write a fresh
store. The reset lives in winaclresidue and is reserved for the global
credential file; both steps log what they did.
2026-09-20 12:53:38 +08:00
SivanCola d89292b786 Expect unwrapped Bash argv on Windows in the non-darwin Command test
The Windows branch still encoded the retired shell policy that rejected
Bash before launch. Without a backend, Windows takes the same unwrapped
path as a Linux host without bubblewrap.
2026-09-20 12:53:32 +08:00
SivanCola c9a741a94e Keep lint baseline slack for unchanged files
The first baseline refresh ratcheted every budget to the branch's exact
measurements, so the PR merge with main-v2 (desktop/app.go grew by one
line in #10558) failed repolint. Drop only the entries for deleted files
and keep the previous budgets and repo ceilings for everything else.
2026-09-20 12:53:32 +08:00
SivanCola 2f0e350dc7 Remove the retired Windows sandbox backend
Windows no longer enforces an OS-level shell sandbox, so the
restricted-token and AppContainer backend in internal/winsandbox, the
hidden helper subcommand dispatched by the CLI and Desktop entry points,
the helper report channel, and the Spec fields only that backend read
are deleted. Callers that classified helper failures or issued
runner-authorization denial hints are simplified to the ordinary
process-launch outcomes.

The one part older installs still depend on moves to a leaf package,
internal/winaclresidue: the conservative legacy credential DENY RX
repair and a sweep of the crash-residue markers that v1.38.8 through
v1.38.10 wrote before mutating ACLs. The sweep now runs in the
background at process start instead of only before the next sandboxed
command, and a marker is deleted only after every recorded ACE removal
succeeded so a failed cleanup keeps its evidence. The repair no longer
takes the sandbox root lock; live-owner markers still veto it.

CI drops the sandbox wait knob and points the Windows credential ACL
identity step at the new package. The Windows sandbox architecture
document, its evidence directory, and the native verification script
are removed with the backend.
2026-09-20 12:53:32 +08:00
SivanCola 46600ad09e Wait for settled activity indicators and name lingering ones
The visible-indicator count still raced on CI runners after the first
deterministic rewrite. Poll until no indicator is rendered, bounded, and
put the surviving indicators' labels into the failure message so a real
regression identifies the row that stayed lit.
2026-09-20 12:14:58 +08:00
SivanCola 6cdb5fedf9 fix(desktop): open sessions without a surface and keep hello ids present
Problem: sending from a draft over an open session created the new session
in the background while the surface stayed on the old one, and clicking a
session row after the last visible session was archived failed. A service
built with version "dev" (pnpm dev:desktop) or with desktop telemetry off
could not complete the shell handshake.

Root cause: OpenSession installed the target into the active tab and
returned "workspace is not ready" when none existed; draft acceptance never
activates the tab it creates, and the removed replacement blank session had
been masking the gap. HelloResult.RunID/IncidentID carried omitempty, so a
service without a lifecycle tracker dropped keys the shell validator
requires to be present (empty allowed).

Fix: surfaceForCanonicalSession resolves the surface for OpenSession (active
tab, else a visible tab already running the session, else an empty surface
created for the session's workspace and bound by the resume path, discarded
if the bind fails). The hello identifiers are always serialized; the
protocol document states they are empty when diagnostics are disabled.

Verification: desktop go test ./..., hostrpc tests, Electron shell tests
and typecheck, and a live dev-version shell run driven over CDP.
2026-09-20 12:07:35 +08:00
SivanCola 38f12aa815 Make the runtime-state activity assertion deterministic
The "last job completion clears project activity" check waited for the
activity indicator to become hidden and then required zero matching
elements in the whole document. Folder rows can hold a fallback indicator
that is not visible at that instant, so the count raced with sidebar
updates and failed twice on CI for a copy-only change while passing
locally and on main-v2.

Wait until every indicator has no rendered box, then assert that no
visible indicator remains. A stale visible indicator still fails the
wait, so the user-facing invariant is unchanged.
2026-09-20 12:02:24 +08:00
SivanCola c27083df30 fix(desktop): stop regenerating a blank session after the last archive
Archiving or trashing the last visible session ran openFallbackRuntime,
which synthesized a "transient" blank session. The canonical registry now
publishes that blank as a real sidebar row, so "新的会话" reappeared after
every archive, each attempt left a desktop-* entry in the trash, and the
workspace could never become empty. The draft landing already owns the
empty state, so the fallback only duplicated it.

- openFallbackRuntime only re-activates a topic that still owns content;
  with nothing left, zero tabs stand and the frontend lands on the draft of
  the workspace the user was in instead of always Global.
- archiveSessionRefs* no longer return a fallback target; the transient
  blank runtime path is removed.
- The workspace draft badge marks unsent content or an unsettled save; a
  clean empty draft is only the landing surface. HasContent is derived from
  the content fields, since an emptied composer saves a full blank object.

Regressions cover archiving the last visible session, trashing the last
topic, the legacy delete path, draft content detection, the landing target,
and the badge rule.
2026-09-20 11:33:43 +08:00
SivanCola 7b26ad50fd Retire the Windows shell sandbox from enforcement
Windows has no OS-level shell sandbox any more. The restricted-token and
AppContainer backend re-introduced by #10209 implemented forbid_read as a
temporary DENY RX ACE on the current user's own SID, which locked the host
out of its credential store during every sandboxed command and permanently
after a crash, stop, or silently failed cleanup (#10494, #10496, #10531,
#10541). The same lane failed closed on unreachable write roots (#10505),
demoted the persistent PowerShell on cold hosts (#10534), ignored
bash = "off" (#10292), and refused Bash/MSYS2 (#10523).

Forcing the config default to off was not sufficient because the preset
mapping in specForCall re-enabled enforcement per preset. The decision now
lives at the platform-capability boundary: sandbox.OSSandboxSupported()
is false on Windows, sandbox.Available() is always false there, config
normalization resolves every [sandbox].bash value to off, the preset
mapping never demands enforce, and the capability snapshot reports no
backend while keeping every preset selectable. No Windows launch path
wraps a command, so the helper never mutates ACLs or creates restricted
tokens; restricted presets remain Reasonix tool-layer boundaries and
shell commands run as the current OS user.

Desktop copy gains Windows variants for the Workspace-write description
and the sandbox hint, and the shell network toggle is inert on Windows.
Docs describe the retired backend; WINDOWS_SANDBOX.md keeps the design
as a historical record until the code is removed. Windows-only tests
that exercised the retired helper are replaced by contract tests that
assert no launch path wraps and no preset demands confinement.
2026-09-20 11:31:32 +08:00
SivanCola fcc339e8b1 Merge pull request #10558 from SivanCola/fix/session-title-projection
Fix desktop session title divergence after AI rename / 修复 AI 重命名后桌面端会话标题不一致
2026-09-20 10:40:03 +08:00
SivanCola 0335542fa1 fix(desktop): derive session names from one projection
A canonical session had three name stores with different owners: the
session log, the workspace presentation row, and the legacy topic map. The
sidebar fell back to the first user turn, the topicbar bind fell back to
the presentation default, and a restored or rebuilt tab read only the
topic map, which no canonical rename ever wrote. AI rename updated the
live tab in memory, so the name looked right until the tab was rebuilt.

- sessionDisplayTitle is the single chain for canonical sessions: log
  title, migrated presentation title, first user turn, default. The
  sidebar rows and every tab bind use it.
- Controller builds re-derive the tab name from the log after binding and
  seed a manual topic name chosen before any session existed into the
  presentation row.
- publishCanonicalSessionTitle is the single exit of every canonical
  rename: it mirrors the log into the presentation row, open tabs, the
  prompt-history cache, and the project tree.
- Legacy renames now project the custom title into the open tab as well.

Regression tests cover an untitled session, rename followed by switch and
restart rebuild, a cleared title, and the legacy path.
2026-09-20 10:27:09 +08:00
SivanCola 964da45fce Merge pull request #10553 from SivanCola/fix/windows-pwsh-workspace-runtime
fix(windows): align workspace shell execution with pwsh / 对齐 Windows 工作区 Shell 执行
2026-09-20 03:58:45 +08:00
SivanCola 1cc600f650 test(windows): expect pwsh in try registry 2026-09-20 03:38:07 +08:00
SivanCola 37f3db6044 perf(desktop): lazy-load shell settings styles 2026-09-20 03:27:37 +08:00
SivanCola 15de9a737e Merge remote-tracking branch 'origin/main-v2' into fix/windows-pwsh-workspace-runtime 2026-09-20 03:20:45 +08:00
SivanCola dcb0169a74 perf(desktop): trim shell settings bundle 2026-09-20 03:14:16 +08:00
SivanCola 92775eade9 Merge pull request #10551 from SivanCola/fix/draft-landing-surface
fix(desktop): restore the clean new-session draft landing / 恢复简洁的新会话草稿页
2026-09-20 03:12:18 +08:00
SivanCola d642b58811 docs(windows): define restricted stdio boundary 2026-09-20 03:07:48 +08:00
SivanCola 071fba77ff Merge pull request #10552 from SivanCola/fix/approval-confirm-freeze
Fix approval confirmation freeze / 修复授权确认后卡死
2026-09-20 03:02:05 +08:00
SivanCola 8cb60ac5e4 Merge latest main-v2 into fix/draft-landing-surface
Incorporate main-v2 through 31d84501e, including the restored tab identity extraction. The upstream extraction reduces restoreOrBuildTabs below the checked function-size baseline, so restore the baseline from the temporary 372 normalization to 368.
2026-09-20 02:56:32 +08:00
SivanCola b192a62e09 style(windows): keep sandbox rationale concise 2026-09-20 02:53:47 +08:00
SivanCola a53ec2de8b Merge remote-tracking branch 'origin/main-v2' into fix/windows-pwsh-workspace-runtime 2026-09-20 02:53:17 +08:00
SivanCola 577f155587 fix(windows): allow restricted child stdio pipes 2026-09-20 02:49:16 +08:00
SivanCola dbdc44f78e Retain the original desktop lint baseline
Problem: the previous main-v2 integration temporarily required a four-line desktop/app.go baseline carry-forward.

Root cause: main-v2 advanced again while CI ran and now extracts restored-tab identity setup, removing those excess lines.

Fix: restore the original 368 per-file function-size baseline so the PR does not widen the lint allowance.

Verification: go run ./tools/repolint passes with 1230 baselined findings on main-v2 at 31d84501e.
2026-09-20 02:47:29 +08:00
SivanCola fcdc3f683e Merge latest main-v2 restored tab lint fix
Integrate main-v2 at 31d84501e, which extracts restored-tab identity initialization from desktop/app.go and restores the existing repolint budget.

The approval file-reference and animation regressions, repolint, and the Desktop exact prompt race test pass on the combined tree.
2026-09-20 02:47:22 +08:00
SivanCola 31d84501eb Merge pull request #10555 from SivanCola/codex/fix-restored-tab-lint
refactor(desktop): extract restored tab identity setup / 提取恢复标签身份初始化
2026-09-20 02:41:47 +08:00
SivanCola bc43c0098f fix(desktop): keep shell settings within bundle budget 2026-09-20 02:28:09 +08:00
SivanCola 6aff486092 Normalize the latest main-v2 desktop lint baseline
Problem: lint-code fails after integrating current main-v2 because desktop/app.go reports 372 excess function lines against its recorded 368 baseline.

Root cause: already merged session routing changes increased that per-file metric while the baseline remained at the prior value; this PR does not otherwise change desktop/app.go.

Fix: carry forward only the four-line per-file function-size delta. Repository-wide limits and every other file baseline remain unchanged.

Verification: go run ./tools/repolint passes with 1231 baselined findings.
2026-09-20 02:26:42 +08:00
SivanCola 3ebc3c0f88 test(desktop): wait for project activity ownership
Problem:
The app-settings-motion browser group intermittently failed after the Composer
showed two background jobs because the test immediately sampled the project
tree's activity indicator.

Root cause:
The Composer run strip and project tree are separate React consumers. Waiting
for one consumer does not guarantee that the other has committed the same
runtime frame under CI load.

Fix:
Wait directly for the animated project activity indicator to appear and for
all activity indicators to disappear before asserting both ownership states.

Verification:
- node bench/runtime-state.mjs
- go run ./tools/repolint
2026-09-20 02:24:34 +08:00
SivanCola a5a168abec Merge remote-tracking branch 'origin/main-v2' into fix/windows-pwsh-workspace-runtime 2026-09-20 02:21:57 +08:00
SivanCola 8416b720c4 chore(lint): normalize latest main-v2 function baseline
Problem:
The latest main-v2 increases desktop/app.go's function-size weight from 368
to 372, so repolint fails on an unchanged base checkout.

Root cause:
The merged session route ownership work added four weighted function-size
units without updating the checked-in baseline.

Fix:
Carry forward exactly the four units already present on main-v2. No source
limit or unrelated lint budget is changed.

Verification:
- reproduced on a detached eeb643911 main-v2 worktree
- go run ./tools/repolint
2026-09-20 02:21:28 +08:00
SivanCola 97ab4af600 Merge latest main-v2 into fix/draft-landing-surface
Base:
Integrate main-v2 at eeb6439111.

Reason:
Validate and deliver the draft landing repair against the latest session route,
transcript follower, and Desktop workspace ownership changes.

Verification:
- focused draft presentation and workspace context tests
- runtime-state browser regression
- pnpm build
- pnpm test:app-lifecycle
- desktop go test ./...
2026-09-20 02:21:28 +08:00
SivanCola c3a832c802 Ratchet the approval recovery startup budget
Problem: the latest main-v2 integration measures 2065149 bytes of initial raw JavaScript and CSS, 151 bytes above the previous budget.

Root cause: the bounded approval outcome and cancellation ownership wiring adds about 1.1 KiB over the same-toolchain main-v2 baseline of 2015.6 KiB.

Fix: move only the initial raw payload ratchet to 2016.9 KiB, retaining about 0.15 KiB headroom while leaving gzip, CSS, and chunk budgets unchanged.

Verification: the production build completed compilation and bundling; the exact generated assets pass every bundle budget with the new ratchet.
2026-09-20 02:20:34 +08:00
SivanCola f200adb207 Merge latest main-v2 into approval recovery fix
Integrate session routing, Windows workspace ownership, and transcript identity changes from main-v2 at eeb643911.

The upstream changes do not overlap the approval UI files. Focused approval, cancellation, frontend typecheck, and Go exact-approval tests pass on the combined tree.
2026-09-20 02:20:28 +08:00
SivanCola 7d9e4de4bb refactor(desktop): extract restored tab identity setup 2026-09-20 02:19:50 +08:00
SivanCola 34e8b25cfa Keep approval state across workspace scope refreshes
Problem: Desktop frontend CI lost the current plan revision file suggestions when the workspace scope changed.

Root cause: the approval component key included workspaceScopeKey, so the same prompt was remounted while its scope-specific file request was in flight.

Fix: key local decision state only by the prompt and tab identity; the file reference owner continues to fence and refresh requests by workspace scope.

Verification: approval-modal-file-reference (59 assertions), approval-animation (27 assertions), and frontend test typecheck passed.
2026-09-20 02:18:41 +08:00
SivanCola eeb6439111 Merge pull request #10549 from SivanCola/codex/fix-session-route-boundary
fix(desktop): isolate session-id routes from legacy paths / 隔离 session-id 路由与旧版文件路径
2026-09-20 02:16:30 +08:00
SivanCola 81870c31b9 fix(windows): normalize legacy shell allowlists 2026-09-20 02:16:18 +08:00
SivanCola 4756bc56ce Merge pull request #10550 from SivanCola/fix/transcript-snapshot-record-identity
fix(transcript): make snapshot record identity total / 修复快照记录身份缺失
2026-09-20 02:07:15 +08:00
SivanCola 775d968395 test(windows): build background output efficiently 2026-09-20 02:04:15 +08:00
SivanCola 7ff88a70f5 Merge pull request #10554 from SivanCola/fix/windows-workspace-runtime
Fix Windows workspace ownership and cancellable session loading / 修复 Windows 工作区归属与会话加载取消
2026-09-20 02:01:25 +08:00
SivanCola 348e5c1ff9 test(windows): assert native provider shell identity
Problem:
Windows boot tests and diagnostic inventory still assumed the provider-visible shell was bash.

Root cause:
The schema migration registered pwsh at runtime, but shared boot expectations and KnownToolNames retained the POSIX-only identity.

Fix:
Centralize the platform shell expectation in boot tests, keep legacy bash in skill fixtures as compatibility input, and register pwsh as a known diagnostic identity.

Verification:
Focused boot contract tests, tool tests, boot/tool go vet, Windows amd64 boot/tool test-binary builds, repolint, and golangci-lint v2.12.2 pass.
2026-09-20 01:50:43 +08:00
SivanCola fd0d0027bd test(windows): expect pwsh in shell dispatch events
Problem:
The Windows control suite still expected RunShell to dispatch under the legacy bash tool name.

Root cause:
The cross-platform event test hard-coded the POSIX provider identity after Windows moved to pwsh.

Fix:
Assert the platform contract: pwsh on Windows and bash elsewhere.

Verification:
The focused control test, control go vet, Windows amd64 control test-binary build, and golangci-lint v2.12.2 pass.
2026-09-20 01:48:37 +08:00
SivanCola 47517fd5b9 fix(desktop): avoid redundant workspace match initialization
Problem:
- The Windows Desktop golangci-lint leg rejected an assignment that was always
  overwritten before use.

Root cause:
- EnsureWorkspaceResolved initialized the match slice before the only branch
  that consumes it and then replaced that value immediately.

Fix:
- Declare the match result at the call site and return its dedicated error.

Verification:
- GOOS=windows golangci-lint run --timeout=5m ./... in desktop.
- go test ./internal/workspacestate in desktop.
- go run ./tools/repolint.
2026-09-20 01:48:02 +08:00
SivanCola fed1e7e1d6 test(desktop): use native paths in Windows groups 2026-09-20 01:44:48 +08:00
SivanCola 353e9028af Preserve prompt cancellation ownership and failure outcomes
Problem: Stop could stay disabled after a failed cancellation, target another tab after navigation, or submit duplicate requests from question cards.

Root cause: the local view discarded the cancellation promise, the controller erased failure receipts, approval Stop deferred source capture, and question Stop had no independent pending lock.

Fix: share the prompt Stop lifecycle, capture the source synchronously, expose local cancellation failures to decision cards, propagate remote failures, and clear question drafts only after accepted cancellation.

Verification: production build and bundle gates, test typecheck, 27 approval assertions, 36 controller assertions, session command and question draft regressions, 115 remote assertions, and browser Stop failure/retry checks passed.
2026-09-20 01:42:10 +08:00
SivanCola db1afcff31 fix(desktop): preserve workspace ownership during session load
Problem:
- Windows path identity changes could render one physical workspace twice and
  make both sidebar rows share expansion state.
- A blocked session open could hold the runtime rebuild lock and prevent a
  tray-initiated shutdown from completing.

Root cause:
- Desktop projections re-derived workspace IDs from path spelling instead of
  resolving the persisted physical owner.
- Session navigation cancellation was not admitted atomically with shutdown,
  and request cleanup could also cancel an already published controller.

Fix:
- Resolve workspace ownership by physical identity at the persistence boundary
  and revalidate discovery under the cross-process writer lock.
- Cancel pending navigation before shutdown waits for runtime locks, reject
  stale or late requests, and separate request cancellation from the published
  controller lifetime.
- Record precise shutdown lock-wait phases and add deterministic regressions
  for identity, navigation, and shutdown ordering.

Verification:
- Focused Desktop regression tests with the race detector.
- go vet ./... in desktop.
- go run ./tools/repolint.
- Windows amd64 Desktop test binary cross-compilation.
2026-09-20 01:40:07 +08:00
SivanCola 30d78d7af6 fix(agent): avoid claim merge capacity overflow
Problem:
CodeQL reports that adding two attacker-influenced slice lengths can overflow before allocating scheduler claim state.

Root cause:
mergeRealized used len(existing)+len(add.Paths) directly as map and slice capacity.

Fix:
Use the larger input length as the allocation hint. Appends retain identical deduplication behavior without overflow-prone arithmetic.

Verification:
Focused scheduler and write-claim tests, agent go vet, and golangci-lint v2.12.2 pass.
2026-09-20 01:37:33 +08:00
SivanCola 60613b53a6 test(windows): bind write roots through the native shell name
Problem:
The Windows isolated agent suite looked up bash after ConfineBash correctly registered the executor as pwsh.

Root cause:
The cross-platform write-root test hard-coded the POSIX tool name instead of using the confined shell identity.

Fix:
Derive lookup and removal assertions from shell.Name so the same sandbox invariant is tested on POSIX and Windows.

Verification:
The focused test, agent go vet, and a Windows amd64 agent test-binary build pass.
2026-09-20 01:36:47 +08:00
SivanCola d3b3d91b44 fix(windows): scope runner report handle to Windows
Problem:
Linux golangci-lint treated the Windows-only runner report environment constant as unused.

Root cause:
The declaration lived in a cross-platform file while every reference was behind the Windows build tag.

Fix:
Move the constant into the Windows diagnostics implementation so each platform compiles only the declarations it owns.

Verification:
golangci-lint v2.12.2, repolint, sandbox tests, winsandbox tests, and go vet pass.
2026-09-20 01:35:28 +08:00
SivanCola 6e1a45bd3d test(transcript): satisfy integer range lint
Problem:
The CI lint job rejected a fixed-count checkpoint regression loop under the Go 1.22 intrange rule.

Root cause:
The test used the pre-Go-1.22 three-clause form for a constant integer range.

Fix:
Use the native integer range syntax.

Verification:
- go test ./internal/transcript -run 'Test.*Checkpoint' -count=1
- golangci-lint run --timeout=5m
- git diff --check
2026-09-20 01:31:36 +08:00
SivanCola ee78c06e84 fix(windows): align workspace shell execution with pwsh
Problem:
Windows workspace-write sessions could stall in nested shell probes, expose mixed Bash and PowerShell contracts, and replace repeated tool results with placeholders. Background services such as OpenMAIC could not start reliably.

Root cause:
The Windows path reused the persistent Bash lifecycle, ran a second PowerShell launch check before every command, modeled background work outside the job owner, and deduplicated results across distinct call ids.

Fix:
Expose a stable pwsh contract on Windows, run one-shot commands directly through the restricted-token runner, classify signed runner failures, route services through job_output/job_kill, preserve one result per call, and keep legacy aliases behind the same sandbox. Simplify Settings to platform-relevant runtimes and move diagnostics into an expandable section.

Verification:
Focused agent, boot, sandbox, shellrun, tool, winsandbox, desktop, frontend typecheck, CSS, and shell settings contract tests passed on macOS. Native Windows coverage is delegated to the PR CI matrix.
2026-09-20 01:28:32 +08:00
SivanCola a9deba5d8b fix(transcript): preserve canonical tool message identity
Problem:
App memory screening could not open tool-heavy benchmark sessions after snapshot validation became strict.

Root cause:
The frontend treated a toolCallId-derived identity as a mandatory match even when canonical history legitimately addressed the same tool result by messageId.

Fix:
Keep explicit snapshot identities authoritative, use derived identities only as compatibility fallbacks, and retain messageId as the canonical history merge key. Add a follower regression for tool rows carrying both identifiers.

Verification:
- pnpm test:transcript
- pnpm exec tsc --noEmit -p tsconfig.test.json
- pnpm build
- REASONIX_APP_MEMORY_PROFILE=short node bench/app-memory.mjs
- go run ./tools/repolint
2026-09-20 01:26:55 +08:00
SivanCola da9adbd054 fix(desktop): isolate draft context and recovery layout
Problem:
Draft Git controls could target the preceding formal session, while an empty
backing tab could hide draft recovery actions. The runtime file also exceeded
the repository's existing size budget.

Root cause:
Draft presentation inherited formal-tab identity and combined the hidden tab's
hero state with the active draft's layout. Shell presentation logic had been
added to the already large runtime lifecycle owner.

Fix:
Project draft workspace labels in the composer builder and clear inherited
tab IDs and Git branches. Give active drafts sole ownership of hero visibility
and extract presentation predicates into the shell layer.

Verification:
- focused workspace-context and draft-presentation regressions
- pnpm test:typecheck
- pnpm build
- go run ./tools/repolint
- browser check for the clean project draft and project selector
2026-09-20 01:26:46 +08:00
SivanCola 6083a44b3b fix(desktop): recover approval submissions
Problem: write-access confirmation could leave a prompt disabled indefinitely, and initial Desktop sessions could not submit exact approvals.

Root cause: session generation zero was treated as missing across the frontend and host boundary, while cancelled or rejected prompt operations lost their outcomes before reaching the card.

Fix: accept explicit initial generations while preserving exact identity fencing, propagate command and transport outcomes, keep Stop independent, reset state by full prompt identity, and add bounded diagnostic events.

Verification: frontend production/test typechecks, production build and budgets, focused prompt/UI/remote/export tests, Desktop exact-prompt race tests, controller consecutive-write authorization tests, and browser interaction checks.
2026-09-20 01:23:33 +08:00
SivanCola 8fb3b5fc26 Merge remote-tracking branch 'origin/main-v2' into codex/fix-session-route-boundary
# Conflicts:
#	internal/control/controller.go
2026-09-20 01:22:00 +08:00
SivanCola 317b1e5bea refactor(control): split clear session lifecycle 2026-09-20 01:18:00 +08:00
SivanColaandF 6c62b430ef fix(desktop): restore the clean draft landing
Problem:
New-session drafts opened a full draft management page and looked like failed session creation.

Root cause:
The durable draft lifecycle was presented as a separate management surface, so the UI exposed persistence details instead of the ordinary new-session experience. The composer workspace also inherited the backing tab instead of the draft owner.

Fix:
Render healthy drafts on the standard welcome composer, keep recovery states explicit, move draft controls into the topic bar, and derive workspace context from the draft owner.

Verification:
- pnpm build
- pnpm test:app-lifecycle
- focused draft presentation and workspace context tests
- browser interaction checks for draft restore and new-session entry points

Co-authored-by: F <[email protected]>
2026-09-20 01:15:32 +08:00
SivanCola 7c91c2e88f fix(transcript): make snapshot record identity total
Problem:
Interrupted and outside-turn display frames could reach transcript snapshots without a record identity. Desktop then rejected the snapshot during hydration, and persisted checkpoints could reproduce the failure on every reopen.

Root cause:
The projection only derived identities from tool calls, message IDs, or positive business sequences. Zero-sequence display frames had no fallback identity, snapshot publication did not validate uniqueness, and Desktop mutated resident transcript state before all validation completed.

Fix:
Assign projection-scoped identities at the transcript owner, validate every frozen snapshot, repair legacy checkpoint rows, and normalize compatible old-peer snapshots. Stage Desktop store replacement until the reducer accepts the cut while preserving in-flight content ownership.

Verification:
- go test ./... -count=1
- go vet ./internal/transcript ./internal/control
- go run ./tools/repolint
- pnpm test:transcript
- pnpm build
- TypeScript production and test typechecks
2026-09-20 01:12:51 +08:00
SivanCola 5c6e248e97 Merge pull request #10548 from SivanCola/feature/pr-10457-performance
[4/4] Cache image variants and harden publication / 缓存图片变体并加固发布
2026-09-20 01:11:45 +08:00
SivanCola 8de94ca79b Merge pull request #10547 from SivanCola/feature/pr-10457-history-export
[3/4] Render history and export image closure / 展示历史图片并导出内容闭包
2026-09-20 01:11:32 +08:00
SivanCola dd074f2f98 Merge pull request #10546 from SivanCola/feature/pr-10457-reliability
[2/4] Persist queued image admission facts / 持久化队列图片接纳事实
2026-09-20 01:11:16 +08:00
SivanCola 62874c47e1 Merge pull request #10545 from SivanCola/feature/pr-10457-core
[1/4] Admit images at the session boundary / 在会话边界接纳图片
2026-09-20 01:11:05 +08:00
SivanCola f45d0006ea fix(control): release inbox lock before clear 2026-09-20 01:10:51 +08:00
SivanCola 9382ee2cba fix(desktop): preserve Windows transcript path identity
Problem: Windows CI rejected an upper-case spelling of a valid transcript path,
so one file could produce an empty runtime key instead of sharing its lease key.
The invalid-route regression also expected an absent-path error for a spelling
that Windows rejects before filesystem lookup.

Root cause: the new Desktop transcript validator applied the store's
case-sensitive filename check before Windows path identity folding.

Fix: fold transcript names only on Windows before applying the existing file
type exclusions, and accept the native invalid-name result when asserting that
malformed route artifacts were not created.

Verification: focused tests and race tests passed; Desktop go vet, repolint,
git diff --check, and Windows amd64 test compilation passed.
2026-09-20 00:58:00 +08:00
SivanCola af8b4a212d fix(desktop): preserve blocked session identities through restore and prune
Problem: blocked startup records could still write pinned-context sidecars,
exact routes ignored conflicting pending operations, and canonical tabs with
conflicting stored locators could pass hidden-tab persistence checks.

Root cause: sidecar restoration ran outside the restore-block branch, pending
identity resolution omitted registry operations, and metadata validation only
examined an optional requested path instead of the stored tab identity.

Fix: preserve blocked goal and pin fields without file access, validate pin
migration targets, include operation targets and mappings in identity checks,
and reject conflicting stored identities before skipping legacy metadata.

Verification: focused restore, reconciliation, metadata, and pruning tests;
focused race tests; Desktop go vet; repolint and git diff --check. Regression
coverage includes unchanged registry state and preservation of original data.
2026-09-20 00:33:46 +08:00
SivanCola 0a6278c1ce Revert "test(control): expose clear-session stalls"
This reverts commit fbbe07a644.
2026-09-20 00:22:49 +08:00
SivanCola ee848bae1e Revert "test(control): expose clear-session stalls"
This reverts commit fbbe07a644.
2026-09-20 00:22:49 +08:00
SivanCola d55281d6c3 Revert "test(control): expose clear-session stalls"
This reverts commit fbbe07a644.
2026-09-20 00:22:49 +08:00
SivanCola 9e240f9371 Revert "test(control): expose clear-session stalls"
This reverts commit fbbe07a644.
2026-09-20 00:22:39 +08:00
SivanCola a661d23215 refactor(desktop): split session boundary helpers 2026-09-20 00:20:21 +08:00
SivanCola 3d308c8719 fix(control): release inbox lock before clear
Problem:
Windows /clear failed while removing session.inbox/transaction.lock because the live Controller still held the lock file open.

Root cause:
ClearSession deleted old session artifacts before rebinding and closing the old inbox Store. Unix permits unlinking an open file, which hid the ordering bug, while Windows rejects it with a sharing violation.

Fix:
Join active inbox scans, close and detach the old inbox Store before destructive cleanup, and rebind the current inbox if cleanup or fresh-session binding fails.

Verification:
- go test ./internal/control -run '^(TestSubmitClearDiscardsCurrentContextWithoutSavingTranscript|TestClearSession|TestSessionEventsFireAtLifecyclePoints)' -count=20 -timeout=10m
- go test ./internal/control ./internal/sessioninbox -timeout=15m
- golangci-lint run --timeout=5m
2026-09-20 00:19:55 +08:00
SivanCola 1cb4324209 fix(control): release inbox lock before clear
Problem:
Windows /clear failed while removing session.inbox/transaction.lock because the live Controller still held the lock file open.

Root cause:
ClearSession deleted old session artifacts before rebinding and closing the old inbox Store. Unix permits unlinking an open file, which hid the ordering bug, while Windows rejects it with a sharing violation.

Fix:
Join active inbox scans, close and detach the old inbox Store before destructive cleanup, and rebind the current inbox if cleanup or fresh-session binding fails.

Verification:
- go test ./internal/control -run '^(TestSubmitClearDiscardsCurrentContextWithoutSavingTranscript|TestClearSession|TestSessionEventsFireAtLifecyclePoints)' -count=20 -timeout=10m
- go test ./internal/control ./internal/sessioninbox -timeout=15m
- golangci-lint run --timeout=5m
2026-09-20 00:19:55 +08:00
SivanCola 8dcdfb9431 fix(control): release inbox lock before clear
Problem:
Windows /clear failed while removing session.inbox/transaction.lock because the live Controller still held the lock file open.

Root cause:
ClearSession deleted old session artifacts before rebinding and closing the old inbox Store. Unix permits unlinking an open file, which hid the ordering bug, while Windows rejects it with a sharing violation.

Fix:
Join active inbox scans, close and detach the old inbox Store before destructive cleanup, and rebind the current inbox if cleanup or fresh-session binding fails.

Verification:
- go test ./internal/control -run '^(TestSubmitClearDiscardsCurrentContextWithoutSavingTranscript|TestClearSession|TestSessionEventsFireAtLifecyclePoints)' -count=20 -timeout=10m
- go test ./internal/control ./internal/sessioninbox -timeout=15m
- golangci-lint run --timeout=5m
2026-09-20 00:19:55 +08:00
SivanCola da55fccfff fix(control): release inbox lock before clear
Problem:
Windows /clear failed while removing session.inbox/transaction.lock because the live Controller still held the lock file open.

Root cause:
ClearSession deleted old session artifacts before rebinding and closing the old inbox Store. Unix permits unlinking an open file, which hid the ordering bug, while Windows rejects it with a sharing violation.

Fix:
Join active inbox scans, close and detach the old inbox Store before destructive cleanup, and rebind the current inbox if cleanup or fresh-session binding fails.

Verification:
- go test ./internal/control -run '^(TestSubmitClearDiscardsCurrentContextWithoutSavingTranscript|TestClearSession|TestSessionEventsFireAtLifecyclePoints)' -count=20 -timeout=10m
- go test ./internal/control ./internal/sessioninbox -timeout=15m
- golangci-lint run --timeout=5m
2026-09-20 00:19:47 +08:00
SivanCola 79faed2f3d fix(desktop): isolate session routes from legacy paths (#10498) 2026-09-20 00:11:41 +08:00
SivanCola bba7bc2712 test(control): expose clear-session stalls
Capture a classified clear failure immediately and include all goroutine stacks when the asynchronous /clear completion exceeds its deadline. This keeps the regression actionable on Windows instead of reporting only a silent timeout.
2026-09-20 00:08:45 +08:00
SivanCola e7a66c7c3d test(control): expose clear-session stalls
Capture a classified clear failure immediately and include all goroutine stacks when the asynchronous /clear completion exceeds its deadline. This keeps the regression actionable on Windows instead of reporting only a silent timeout.
2026-09-20 00:08:44 +08:00
SivanCola 26b7d35a72 test(control): expose clear-session stalls
Capture a classified clear failure immediately and include all goroutine stacks when the asynchronous /clear completion exceeds its deadline. This keeps the regression actionable on Windows instead of reporting only a silent timeout.
2026-09-20 00:08:43 +08:00
SivanCola fbbe07a644 test(control): expose clear-session stalls
Capture a classified clear failure immediately and include all goroutine stacks when the asynchronous /clear completion exceeds its deadline. This keeps the regression actionable on Windows instead of reporting only a silent timeout.
2026-09-20 00:05:54 +08:00
SivanCola 65ab2f0a15 fix(control): keep session verbs outside turn admission
Problem:
Windows control CI repeatedly timed out waiting for /clear after the attachment admission refactor.

Root cause:
Known session-management commands entered the model-turn attachment preparation and admission lifecycle even though they never admit a turn and may rotate the attachment owner asynchronously.

Fix:
Route /new, /clear, /context, and /compact through the original submission gate while keeping ordinary prompts, references, and attachments on the unified admission path.

Verification:
- go test ./internal/control -run '^(TestSubmitClearDiscardsCurrentContextWithoutSavingTranscript|TestClearSession|TestNewSession)' -count=20 -timeout=10m
- go test ./internal/control -timeout=15m
- golangci-lint run --timeout=5m
2026-09-19 23:57:07 +08:00
SivanCola 0a12c011fd fix(control): keep session verbs outside turn admission
Problem:
Windows control CI repeatedly timed out waiting for /clear after the attachment admission refactor.

Root cause:
Known session-management commands entered the model-turn attachment preparation and admission lifecycle even though they never admit a turn and may rotate the attachment owner asynchronously.

Fix:
Route /new, /clear, /context, and /compact through the original submission gate while keeping ordinary prompts, references, and attachments on the unified admission path.

Verification:
- go test ./internal/control -run '^(TestSubmitClearDiscardsCurrentContextWithoutSavingTranscript|TestClearSession|TestNewSession)' -count=20 -timeout=10m
- go test ./internal/control -timeout=15m
- golangci-lint run --timeout=5m
2026-09-19 23:57:07 +08:00
SivanCola 2cfa975f20 fix(control): keep session verbs outside turn admission
Problem:
Windows control CI repeatedly timed out waiting for /clear after the attachment admission refactor.

Root cause:
Known session-management commands entered the model-turn attachment preparation and admission lifecycle even though they never admit a turn and may rotate the attachment owner asynchronously.

Fix:
Route /new, /clear, /context, and /compact through the original submission gate while keeping ordinary prompts, references, and attachments on the unified admission path.

Verification:
- go test ./internal/control -run '^(TestSubmitClearDiscardsCurrentContextWithoutSavingTranscript|TestClearSession|TestNewSession)' -count=20 -timeout=10m
- go test ./internal/control -timeout=15m
- golangci-lint run --timeout=5m
2026-09-19 23:57:07 +08:00
SivanCola 5a56983c60 fix(control): keep session verbs outside turn admission
Problem:
Windows control CI repeatedly timed out waiting for /clear after the attachment admission refactor.

Root cause:
Known session-management commands entered the model-turn attachment preparation and admission lifecycle even though they never admit a turn and may rotate the attachment owner asynchronously.

Fix:
Route /new, /clear, /context, and /compact through the original submission gate while keeping ordinary prompts, references, and attachments on the unified admission path.

Verification:
- go test ./internal/control -run '^(TestSubmitClearDiscardsCurrentContextWithoutSavingTranscript|TestClearSession|TestNewSession)' -count=20 -timeout=10m
- go test ./internal/control -timeout=15m
- golangci-lint run --timeout=5m
2026-09-19 23:57:01 +08:00
SivanCola e8ba90beaf fix(attachments): keep history layer lint-clean
Problem:
The history/export PR carries the alpha helper first consumed by the performance layer, so standalone lint reports it as unused.

Root cause:
The helper is intentionally staged before the bounded variant encoder commit.

Fix:
Add a narrow, documented unused suppression until the final layer activates it.

Verification:
- golangci-lint run --timeout=5m
2026-09-19 23:14:22 +08:00
SivanCola fdf97fc472 fix(attachments): keep reliability layer lint-clean
Problem:
The reliability PR still carries helpers first consumed by later stack layers, so standalone lint reports them as unused.

Root cause:
Those helpers are intentionally staged before the history and performance commits that activate them.

Fix:
Add narrow, documented unused suppressions on the staged helpers.

Verification:
- golangci-lint run --timeout=5m
2026-09-19 23:14:02 +08:00
SivanCola 3af704cf5b fix(attachments): keep split layers lint-clean
Problem:
The first attachment PR defined helpers consumed only by later stack layers, so standalone golangci-lint rejected the branch.

Root cause:
The stack was tested at the final head, while the intermediate branch exposed staged unexported helpers to the unused analyzer.

Fix:
Document and narrowly suppress unused analysis for helpers until their owning downstream layer activates them.

Verification:
- go test ./internal/attachment ./internal/control ./internal/session
- golangci-lint run --timeout=5m
2026-09-19 23:13:40 +08:00
SivanCola b03365fdfe perf(attachments): cache variants and harden file publication
Problem: Repeated image preparation duplicated bounded transforms, and content
publication and legacy path reads needed stronger cross-platform path safety.

Root cause: Variants were computed per request without a deterministic policy
key, while no-replace publication and file identity checks were spread across
platform-specific call sites.

Fix: Add deterministic variant caching with cancellation-safe authorization,
centralize no-replace writes in fileutil, harden legacy path validation, and add
platform CI inventory plus packaged attachment smoke documentation.

Verification: Focused attachment, control, fileutil, and view-image tests passed
with and without the race detector. macOS group and CI workflow contract tests,
repolint, Desktop inventory, and git diff checks passed on latest main-v2.
2026-09-19 23:01:28 +08:00
SivanCola 46d1d8ef43 feat(attachments): render history and export image closure
Problem: Accepted images were durable for execution but historical messages and
session exports could not reliably recover the authorized image bytes.

Root cause: History projection omitted attachment references and export copied
event payload content without collecting the image refs embedded in those
payloads.

Fix: Project digest-backed attachment cards into history, add bounded session
attachment reads, collect authorized image refs while exporting, and render the
same closure in exported session documents.

Verification: Session and session-export suites, Desktop attachment tests,
frontend history and draft-read tests, frontend typecheck, repolint, and git
diff checks passed on the latest main-v2 path-identity implementation.
2026-09-19 22:58:55 +08:00
SivanCola ad6bc15ddf feat(attachments): persist queue image admission facts
Problem: Queued image submissions could outlive their temporary credentials or
source files, and retries could create a second logical turn.

Root cause: The inbox persisted text and transient references without one
durable fingerprint for the accepted image batch and submission identity.

Fix: Persist compatible image fingerprints and content snapshots in inbox
schema 3, preserve edit failure atomicity, and make queued replay reuse the
original admitted submission fact.

Verification: Focused session-inbox and control attachment, schema, submission,
and queue tests passed with and without the race detector. Repolint and git diff
checks passed.
2026-09-19 22:57:24 +08:00
SivanCola e7a5b149e3 feat(attachments): admit images at the session boundary
Problem: Composer and CLI image references could lose their bytes before the
provider request, leaving path text to fall through to tools and Bash.

Root cause: Image preparation was split across focus-sensitive Desktop RPCs,
temporary draft credentials, controller state, and request-time model routing
instead of one durable session admission boundary.

Fix: Admit ordered image batches before creating the user turn, freeze accepted
sources into session-owned image inputs, bind Desktop targets to session
identity, and preserve legacy provider serialization and CLI path behavior.

Verification: Focused attachment, control, provider, boot, serve, tool, Desktop,
and frontend suites passed on the latest main-v2 base. Frontend typecheck,
repolint, and git diff checks also passed.
2026-09-19 22:56:34 +08:00
SivanCola fceba04079 Merge pull request #10518 from SivanCola/fix/path-identity-lock-protocol
Unify filesystem identity and cross-version locks / 统一目录身份与跨版本锁
2026-09-19 22:52:11 +08:00
SivanCola f2be9e8438 Merge main-v2 and preserve filesystem identity handshake
Problem:
The path identity branch conflicted with the protocol v11 diagnostics lifecycle merged into main-v2, leaving its terminal checks unable to run.

Root cause:
Both changes extended the Desktop hello result and startup logging from the older protocol baseline.

Fix:
Keep protocol v11 run and incident diagnostics plus the ShellLifecycle flow, layer the optional identity summary into the same hello result, document both fields in English and Chinese, and add a symlink escape regression at the Serve session boundary.

Verification:
- go test focused identity, lock, agent, repair, serve, and catalog packages
- go test -race for pathidentity, filelock, identitylock, and workspacelease
- Desktop hostrpc, instanceidentity, and workspacestate tests
- Electron typecheck and 224 tests
- repolint, workflow tests, and git diff --check
2026-09-19 13:34:56 +08:00
SivanCola 245d4ddfc4 Merge remote-tracking branch 'origin/main-v2' into codex/pr10518-identity-fixes 2026-09-19 11:25:48 +08:00
SivanCola 4f4bf01cb6 Fix identity lock creation races and Windows compatibility
Problem: concurrent identity lock initialization could reject a lock file created between filesystem observations, while symlink replacement and Windows-only diagnostics still had coverage and lint gaps.\n\nRoot cause: path resolution treated an observation race as a dangling link, repair validation compared only native file identity, and Windows tests exercised invalid relative fixtures.\n\nFix: check entry existence before resolving links, retain symlink destinations during repair validation, use errors.As for NTStatus, and make Windows fixtures use absolute temporary roots. Add deterministic regressions for the race and inode-reuse redirect.\n\nVerification: go test ./..., desktop go test -timeout 20m ./..., focused race tests, cross-platform golangci-lint, repolint, and Windows workspacestate cross-compilation.
2026-09-19 11:25:43 +08:00
SivanCola 86d2424ce3 Merge pull request #10520 from SivanCola/fix/diagnostics-lifecycle-v4
Fix desktop shutdown and diagnostics attribution / 修复桌面退出与诊断归因
2026-09-19 01:03:01 +08:00
XTLine f375e5cec6 chore: carry the merged-branch bundle budget ceiling / 保留合并分支的初始包预算上限 2026-09-19 00:26:31 +08:00
XTLine 092354bb07 Merge branch 'main' into fix/sessionid-migration-gaps 2026-09-19 00:21:16 +08:00
SivanCola b96206cd24 Report legacy HTTP turn admission synchronously
Problem
A fast HTTP turn could run to completion before Serve sampled Controller.Running, causing the accepted request to receive 409. This failed TestServeSubmitRunsAndBroadcastsTurnDone on Ubuntu after the runner had already executed.

Root cause
Legacy requests without a submission ID inferred admission from transient runtime state. That state cannot distinguish an immediately completed turn from a rejected turn.

Fix
Route legacy Serve turns through the controller's synchronous submission boundary and use its monotonic turn token to report whether the request was admitted. Keep rejected submissions as explicit ErrSubmissionNotAccepted results and route the typed final-readiness compatibility action through the same boundary.

Verification
- go test ./...
- go test ./internal/control ./internal/serve
- go test ./internal/serve -run '^TestServeSubmitRunsAndBroadcastsTurnDone$' -count=500
- go test ./internal/control ./internal/serve -run 'TestLegacySubmissionReportsSynchronousAdmission|TestServeSubmitRunsAndBroadcastsTurnDone' -count=50
- go test -race ./internal/control ./internal/serve -run 'TestLegacySubmissionReportsSynchronousAdmission|TestServeSubmitRunsAndBroadcastsTurnDone' -count=5
- go run ./tools/repolint
- git diff --check
2026-09-18 21:54:25 +08:00
SivanCola 1fe69b54b9 Fix recovery topic migration on Windows
Problem
Windows Desktop CI could show a recovered transcript as a second ordinary topic, while the worktree matrix could be killed by its outer step timeout before the Go timeout reported a result.

Root cause
Legacy topic migration and catalog recovery folding projected the same recovery file independently, so execution order changed the catalog. The worktree suite also normally approaches the workflow's ten-minute ceiling even though its own Go timeout remains eight minutes.

Fix
Skip recovery transcripts as standalone legacy topics while their parent transcript exists, while still promoting them after parent removal. Give the isolated Windows step enough reporting headroom and lock that budget in its workflow test.

Verification
- cd desktop && go test ./...
- cd desktop && go test ./... -run 'TestLegacyRecoveryMigrationWaitsForParentRemoval|TestAmbiguousLegacyRecoverySessionsMigrateIntoTopics' -count=10
- cd desktop && go test -race ./... -run 'TestLegacyRecoveryMigrationWaitsForParentRemoval|TestAmbiguousLegacyRecoverySessionsMigrateIntoTopics' -count=3
- node --test scripts/windows-go-tests.test.mjs scripts/ci-workflow.test.mjs
- go run ./tools/repolint
- go run github.com/rhysd/actionlint/cmd/[email protected] .github/workflows/ci.yml
- git diff --check
2026-09-18 21:34:11 +08:00
SivanCola 652542acb0 Include frontend sourcemaps in CI artifacts
Problem:
Windows packaging reused the verified frontend artifact but failed because the artifact omitted the commit-scoped sourcemap archive required by package.mjs.

Root cause:
Desktop preparation uploaded only dist and the artifact identity manifest even though the frontend build also produces frontend/sourcemaps/<commit>.

Fix:
Upload the exact GitHub SHA sourcemap directory with both stable and canary frontend artifacts, and lock the two-variant contract in the CI workflow test.

Verification:
- ci-workflow tests: 22 passed
- actionlint: passed
- repolint: clean
- git diff --check: passed
2026-09-18 21:05:26 +08:00
SivanCola 0d798998ff Align lifecycle tests with event identity
Problem:
Desktop CI failed Go 1.22 copyloopvar lint and a migration test still expected equivalent failures from separate startups to collapse into one upload.

Root cause:
The shutdown loop retained a pre-Go-1.22 closure copy, while the migration assertion encoded the retired DedupKey transport-dedup behavior.

Fix:
Remove the redundant loop copy and assert that separate startup failures keep distinct EventIDs while sharing the same correlation key.

Verification:
- desktop full test suite passed
- I-P race partition passed
- targeted migration test passed 10 runs
- targeted migration race test passed 3 runs
- golangci-lint and repolint passed
- git diff --check and gofmt passed
2026-09-18 20:54:44 +08:00
SivanCola acbb24d41d Fix diagnostics module size lint
Problem:
The diagnostics lifecycle change exceeded the repository's existing-file line budgets in the frontend crash reporter and Worker entrypoint, causing lint-code to fail.

Root cause:
New report attribution, regression, and crash-family logic was added directly to already-large owner modules.

Fix:
Extract Worker report classification and version/regression helpers into a focused module, update tests to import their owner, and compact the small frontend crash-family addition without changing behavior.

Verification:
- frontend: 407 suites passed; typecheck passed
- crash-report worker: 137 tests passed; typecheck passed
- repolint: clean
- git diff --check: passed
2026-09-18 20:31:58 +08:00
SivanCola f17fead081 Merge latest main-v2 into diagnostics lifecycle fix
Preserve the new catalog initial-reconcile ownership gate alongside the staged shutdown coordinator. Regenerate the Desktop v11 contract and command ownership against the combined App API.

Verification:
- Focused Desktop Go lifecycle and host RPC tests
- Electron 224 tests and typecheck
- Crash worker 137 tests and typecheck
- Frontend typecheck
2026-09-18 20:17:35 +08:00
SivanCola 39ab7e2aeb Fix desktop shutdown and crash attribution
Problem:
Desktop shutdown, connection loss, and startup failure followed separate cleanup paths. Save failures could be followed by service termination, while crash reports mixed observer and failing-process identities and could reopen resolved historical groups.

Root cause:
Shutdown ownership and progress were duplicated across Go and Electron, lifecycle evidence lacked stable run/event identities, and worker projection used transport metadata as fault attribution.

Fix:
Introduce a retryable staged shutdown coordinator and shell status protocol, preserve lifecycle evidence through EOF and signals, add diagnostics v4 event/incident attribution and idempotent projection, separate historical/regression decisions, and validate release sourcemaps plus the Top 10 governance dry-run.

Verification:
- Desktop Go full package tests and focused race tests
- Electron 224 tests and typecheck
- Frontend 406 suites and typecheck
- Crash worker 137 tests and typecheck
- macOS arm64 package, sourcemap manifest, and offline symbolization
2026-09-18 20:14:16 +08:00
SivanCola 161da6c35c Merge pull request #10507 from SivanCola/fix/session-interaction-isolation
Fix session state and interaction ownership / 修复会话状态与交互卡归属
2026-09-18 20:09:15 +08:00
SivanCola 438def904d Fix identity lock lint regressions
Problem:
The final PR lint job rejected a nil context in the link-redirection regression and a lease publication helper left unused after compatible lock ownership moved onto SessionLease.

Root cause:
The regression used Acquire nil-context compatibility directly, while the lock refactor replaced the standalone publication path without deleting its superseded helper.

Fix:
Pass an explicit background context in the regression and remove the dead standalone lease publication function.

Verification:
- golangci-lint run --timeout=5m
- go run ./tools/repolint
- go test ./internal/identitylock ./internal/agent ./internal/serve ./internal/workspacelease
- cd desktop && go test -timeout 20m . ./internal/workspacestate ./internal/instanceidentity ./internal/hostrpc
- cd desktop && go test -run ^$ .
2026-09-18 20:06:31 +08:00
SivanCola 6c9cee23ea Merge pull request #10519 from SivanCola/fix/deterministic-session-delete-grace
Make ACP teardown grace check deterministic / 消除 ACP 销毁等待的计时误报
2026-09-18 19:59:11 +08:00
SivanCola f25488231d Merge pull request #10517 from SivanCola/fix/windows-credential-path-alias
Harden Windows credential ACL PR coverage / 强化 Windows 凭据 ACL PR 覆盖
2026-09-18 19:45:23 +08:00
SivanCola 4e8f5d7de4 Revalidate identity-aware file locks
Problem:
An identity-aware lock could resolve an alias, then open a different file if the final link was redirected between resolution and acquisition.

Root cause:
The composition layer passed the original identity key into the process-local queue but did not verify that the access path still resolved to that key after the OS lock was acquired.

Fix:
Re-resolve every ordinary identity-aware lock after acquisition, release it on resolution failure or identity change, and return a closed error. Add a deterministic symlink-redirection regression that also proves the rejected acquisition releases its lock.

Verification:
- go run ./tools/repolint
- go test ./internal/identitylock ./internal/filelock ./internal/pathidentity
- go test -race ./internal/identitylock ./internal/filelock
- Windows amd64 cross-compile for identitylock, pathidentity, and filelock
2026-09-18 19:44:34 +08:00
SivanCola 08ca31641a Fix Windows path identity and lock layering
Problem:
Windows native tests failed every identity-aware file lock with ERROR_NOACCESS, while the first path-identity integration also exceeded repository size, complexity, and layering limits.

Root cause:
The Windows implementation queried FileCaseSensitiveInfo through GetFileInformationByHandleEx, which is rejected on the CI runner. The utility-layer filelock package also imported the higher-level pathidentity package directly, and several identity workflows remained embedded in oversized owners.

Fix:
Query per-directory case sensitivity with NtQueryInformationFile, preserving explicit unsupported-information fallbacks. Add an identitylock composition layer that resolves access paths and passes comparison keys into filelock's explicit local-queue API, then migrate production file locks to it. Keep custom session, repair, and workspace lock protocols on explicit keys, and extract path and acquisition logic into focused owner files.

Verification:
- go run ./tools/repolint
- go test -run '^$' ./...
- go test -race ./internal/filelock ./internal/identitylock ./internal/workspacelease
- Windows amd64 cross-compile for affected root packages
2026-09-18 19:32:41 +08:00
SivanCola 052ea431cf Merge main-v2 into session interaction isolation
Problem
The PR conflicted with new saved-tab reconciliation and release-pipeline changes merged into main-v2.

Root cause
Both branches regenerated the desktop migration inventory, while the saved-tab work also changed the tab persistence owners touched by session interaction isolation.

Fix
Merge current main-v2, retain its persistence reconciliation and unknown-field preservation alongside generation-aware interaction state, and regenerate the 860-entry desktop inventory from the combined code.

Verification
- go run ./tools/desktopinventory -check
- go test . -run 'Test(MetaForTab|RuntimeState|SavedTab|TabPersistence|Restore|RemoteTab)' -count=1 (desktop)
- go test ./internal/control ./internal/event ./internal/eventwire ./internal/extension/uihub ./internal/serve
- pnpm exec tsx src/__tests__/use-controller-send-fallback.test.tsx (29/29)
- pnpm typecheck
- pnpm test:typecheck
- go run ./tools/repolint
- git diff --check
2026-09-18 19:30:15 +08:00
SivanCola d54ed2892f Make ACP teardown grace regression deterministic
Problem: Windows push CI failed TestSessionDeleteWithStuckJobReturnsAfterSingleGrace when a loaded runner exceeded a wall-clock allowance by 11.7 ms.

Root cause: The test inferred that session deletion waited only once from elapsed time, so scheduler and filesystem load could fail a correct teardown path.

Fix: Record teardown timeout events, use a zero-duration grace, and assert exactly one destroy-session wait with no close wait. Move the teardown fixture into a focused test file to keep the existing server test within its lint budget.

Verification: focused regression 100 iterations; race regression 20 iterations; full internal/acp tests; go vet; repolint; Windows amd64 test compilation.
2026-09-18 19:29:40 +08:00
SivanCola 2b26f0ddad Align send fallback test with exact prompt identity
Problem
The full desktop frontend CI suite still modeled Ask fallback submission through the retired tab-scoped endpoint and failed once production required exact session submission.

Root cause
The fixture omitted session generation, runtime epoch, and pending-prompt identity lookup, so its successful completion could not match the card's immutable interaction target.

Fix
Model the current session-bound prompt contract, resolve missing turn identity only through PendingPromptIdentitiesForTab, and assert that submission never borrows the latest tab turn.

Verification
- REASONIX_TEST_CONCURRENCY=2 node frontend/scripts/run-ci-tests.mjs (459/459)
- pnpm exec tsx src/__tests__/use-controller-send-fallback.test.tsx (29/29)
- pnpm typecheck
- pnpm test:typecheck
- go run ./tools/repolint
- git diff --check
2026-09-18 19:19:09 +08:00
SivanCola d867563982 Merge latest main-v2 and preserve Windows ACL coverage
Problem: main-v2 merged #10502 with the same credential path-alias repair, creating conflicts in the production code, native test, and CI contract.

Resolution: adopt main-v2's production implementation and retain this branch's isolated TMP/TEMP fixture plus explicit config and winsandbox PR coverage.

Verification: Windows cross-compile and vet for config/winsandbox; CI workflow contract tests; actionlint; repolint; diff checks.
2026-09-18 19:11:07 +08:00
SivanCola f8d91b3852 Merge pull request #10502 from SivanCola/feature/release-candidate-pipeline
Prepare immutable release candidates for fast recovery / 建立可复用的快速发版候选流程
2026-09-18 19:07:32 +08:00
SivanCola 971d226001 Merge latest main-v2 into Windows credential fix
Problem: main-v2 advanced with the unrelated saved-tab reconciliation merge while the Windows credential fix was being prepared.

Fix: merge the latest protected branch so the pull request candidate is tested against the current integration baseline.

Verification: the base delta only touches Desktop session persistence files and does not overlap the ACL or CI changes.
2026-09-18 19:05:06 +08:00
SivanCola 4370201d4b Fix Windows credential marker path aliases
Problem: main-v2 Windows full CI rejected stale credential markers when the credential path resolved from an 8.3 alias to its long spelling.

Root cause: legacy ACL recovery compared marker and repair paths as strings even though both names referred to the same protected file.

Fix: expand Windows long paths before matching, cover a real 8.3 alias, and run the native credential ACL regressions on pull requests.

Verification: Windows cross-compile and vet for config/winsandbox; CI workflow contract tests; actionlint; repolint; diff check.
2026-09-18 19:03:15 +08:00
SivanCola b85ac51ce5 Fix saved tab reconciliation on startup / 修复启动时保存会话恢复冲突 (#10510)
* Fix saved tab reconciliation during desktop startup

Problem: stale saved tabs after session migration could repeatedly fail startup, recreate fallback sessions, or hide recovery-owned state.

Root cause: tab restoration trusted missing canonical identities before migration and lifecycle reconciliation converged, while startup writes were not fenced against newer tab mutations.

Fix: reconcile persisted tabs against canonical, draft, recovery, migration, and durable-artifact ownership before publication; conditionally archive only proven-empty sessions; preserve unknown JSON fields; and fence repaired snapshots with monotonic versions.

Verification: focused desktop reconciliation and cleanup tests, focused go test -race, go vet ./..., Desktop A-C and D-F test shards, and all Desktop internal/cmd packages pass on the latest main-v2.

* Refine saved tab recovery ownership

Problem: the saved-tab recovery change exceeded repository lint budgets, and its pending-operation guard also treated committed migration receipts as active owners.

Root cause: startup reconciliation and tab persistence remained embedded in oversized owner files, while legacy cleanup did not distinguish unfinished operations from durable committed receipts or its own archive operation.

Fix: extract startup reconciliation, persistence I/O, workspace validation, and persistent-owner checks into focused owners; protect only unfinished external operations while preserving recovery ownership.

Verification: repolint, git diff --check, Desktop go vet, focused saved-tab and legacy-cleanup tests, focused race tests, Desktop internal/cmd tests, and the isolated v3 upgrade test pass.

* Fix Windows-tagged reconciliation lint

Problem: the cross-platform lint leg rejected two reconciliation assignments under Windows build tags.

Root cause: one returned snapshot version was intentionally discarded after the restore fence, while migration evidence was initialized before a branch that always replaced it.

Fix: discard the unused return explicitly and scope post-migration evidence to the branch that consumes it.

Verification: GOOS=windows golangci-lint run --timeout=5m ./..., Windows go vet, repolint, git diff --check, and focused Desktop tests pass.

* Make catalog migration tests event-driven

Problem: the Windows I-P Desktop shard could fail while waiting for a migrated CLI session to appear, even though the catalog scan was still making progress.

Root cause: catalog topic tests approximated completion with a five-second polling deadline, and each ListProjectTree call could itself block long enough to overrun that deadline on a loaded Windows runner.

Fix: publish a generation-scoped completion signal for the initial catalog reconciliation, wait on existing explicit reconcile job completion for later mutations, and assert the tree only after the authoritative work has finished. Add a deterministic restored-tabs barrier regression.

Verification: repolint, git diff --check, Desktop and Windows go vet, Windows golangci-lint, repeated focused and race tests, CI partition contract tests, and the complete local I-P shard pass.
2026-09-18 19:02:21 +08:00
SivanCola f6a846c070 Refresh packaged session qualification
Problem:
The packaged navigation checks targeted retired workspace-browser markup and pre-draft blank-session behavior.

Root cause:
The qualification scripts had not followed the ProjectTree and persistent draft lifecycle, so they failed before exercising the product invariants.

Fix:
Drive the current sidebar and ProjectTree, assert blank drafts leave no canonical residue, and retain exact SessionID, transcript, rapid-switch, archive, restore, and restart checks.

Verification:
Packaged navigation smoke
Independent native session smoke with fork, background completion, archive, restore, and two restarts
2026-09-18 19:01:20 +08:00
SivanCola 0172e5859b Bind desktop startup to directory identity
Problem:
Updater and ordinary startup could derive different instance identities, while the host handshake exposed only an opaque legacy hash.

Root cause:
The comparison key was reused as an access path and Shell and service did not exchange a versioned filesystem identity summary.

Fix:
Keep REASONIX_HOME as an absolute access path, derive instance identity separately, validate homes through pathidentity, and add optional identity version, digest, and legacy ID fields to the compatible hello contract.

Verification:
Electron typecheck and 219 tests
Generated desktop contract freshness check
Packaged shell/service handshake and clean-exit smoke
2026-09-18 19:01:20 +08:00
SivanCola 97994c24af Preserve cross-version lock exclusion
Problem:
Replacing legacy path keys directly would let new and v1.38.10 processes write the same physical workspace or session under different lock names.

Root cause:
File I/O paths, process-local queue keys, and historical lock identities were coupled, and multi-lock acquisition lacked one ordered compatibility protocol.

Fix:
Acquire deduplicated current and frozen legacy locks in deterministic order, release partial holds on contention, revalidate identity before writes, and retain directory-entry semantics for repair and hierarchy operations.

Verification:
go test -race -count=1 ./internal/filelock ./internal/workspacelease ./internal/agent ./internal/repair
Bidirectional legacy/current lease contention and link-redirection regressions
Windows and Linux amd64 cross-compilation
2026-09-18 19:01:20 +08:00
SivanCola 8955c156f7 Unify workspace and session ownership
Problem:
Equivalent directory spellings could register separate workspaces or fail session creation and recovery, including Global startup and macOS case aliases.

Root cause:
Registration, binding, resume, reattach, and Serve ownership checks used different string-based path rules outside one cross-process decision boundary.

Fix:
Resolve physical identity inside the registry transaction, preserve existing IDs and unknown fields, reject ambiguous legacy owners, and route session ownership and Global bootstrap through the shared resolver.

Verification:
cd desktop && go test -timeout 20m .
go test ./internal/serve
go test -race -count=1 ./internal/serve
Packaged Global create, restart, and A/B/A navigation smokes
2026-09-18 19:01:20 +08:00
SivanCola b66b80028f Unify filesystem path identity
Problem:
Workspace and session catalogs implemented platform path comparison independently, so aliases could diverge across case, link, and missing-path forms.

Root cause:
There was no shared representation separating an access path, resolved physical path, and comparison key with explicit link semantics.

Fix:
Add the versioned pathidentity resolver, move catalog identity handling onto it, rebuild the derived identity projection at schema 13, and wire the platform test selectors.

Verification:
go test ./...
go test -race -count=1 ./internal/pathidentity ./internal/sessioncatalog
Windows and Linux amd64 cross-compilation for the affected packages
2026-09-18 19:01:20 +08:00
SivanCola 412ab1393e Update prompt failure regression targets
Problem
The full frontend CI suite crashed because a stale-prompt regression still dispatched the retired id-only submit failure action.

Root cause
The implementation now requires submit failures to carry the immutable interaction target, but this test fixture had not adopted the new action contract.

Fix
Give the fixture a generation-aware session owner and use exact old/new interaction targets for optimistic clears and failure callbacks.

Verification
pnpm exec tsx src/__tests__/pending-prompt-stale-status.test.tsx
pnpm typecheck
pnpm test:typecheck
go run ./tools/repolint
git diff --check
2026-09-18 18:44:26 +08:00
SivanCola fa27241507 Merge pull request #10514 from SivanCola/test/windows-credential-marker-isolation
Isolate Windows credential marker tests / 隔离 Windows 凭据标记测试
2026-09-18 18:35:05 +08:00
SivanCola 2fafede0e6 Make Windows path and handshake checks deterministic
Problem: native Windows CI still rejected short/long aliases after the first ACL fix, while an SSH cancellation check failed on runner timing.

Root cause: file identity lookup reopened a file after its deny ACL was active; the SSH fixture could fail on an invalid pipe remote address before installing its watcher and asserted a 40 ms scheduler budget.

Fix: compare exact or Win32-expanded long paths without reopening protected files, exercise a real 8.3 alias, and synchronize SSH cancellation on the watcher deadline event with a valid remote address.

Verification: SSH regression 100 iterations; Windows cross-compile and vet for winsandbox, config, and remote; repolint and diff check.
2026-09-18 18:29:06 +08:00
SivanCola 8f144d6568 Fix browser prompt identity fixtures
Problem
The approval animation browser gate timed out because the preview card remained mounted after confirmation.

Root cause
Browser mock metadata omitted session generation and prompt events omitted turn/runtime identity, so the exact interaction path could not own or resolve the rendered request.

Fix
Give established mock sessions stable identities, attach per-tab turn identity to approval, Ask, recovery, plan, and MCP prompts, and add actionable diagnostics to the browser gate. Keep blank draft tabs without a session identity and extract the state owner so the repository lint baseline does not grow.

Verification
pnpm typecheck
pnpm test:typecheck
pnpm test:motion
pnpm build
pnpm test:motion-browser
go run ./tools/repolint
git diff --check
2026-09-18 18:18:39 +08:00
XTLine 7fbea6a38b fix(desktop): never let the history-first prime wipe a live remote transcript / 历史先行不覆盖已建立的远端转录
Problem:
A remote session could render an empty transcript (0 turns) while the serve
served the whole conversation: the early history read replaced the transcript
after the follower had published the authoritative live cut.

Root cause:
primeEarlyHistory dispatched history_replace with whatever the window read
returned. history_replace has no revision guard in the reducer, and the prime
only checked whether hydration had finished: a stale or empty window resolving
between the follower's first cut and hydration's completion wiped the
conversation. The local prime fingerprints its read against the tab meta; the
remote one had no equivalent guard.

Fix:
Only a blank transcript may be primed: skip the replacement when the mounted
transcript already holds items or a live tail, so the live cut always wins.

Verification:
- desktop/frontend: npx tsc --noEmit, npm run build (bundle budgets pass)
- manual: the AionCore remote tab renders its transcript (1 turn) again instead
  of the empty hero
2026-09-18 18:06:02 +08:00
SivanCola 2c50e8c51c Match legacy credential markers by Windows file identity
Problem: main-v2 Windows full CI rejects a valid stale credential marker when a short user path is resolved to its long form before ACL repair.

Root cause: the marker guard compared path spelling even though the repair owner acts on the same underlying file; this also made the native config regression absent from PR CI.

Fix: compare os.Stat file identities, retain the exact ACL and exited-owner checks, add a hardlink-alias regression, and run focused config ACL tests on Windows PRs.

Verification: Windows cross-compile and vet for winsandbox/config, CI workflow contract tests, actionlint, repolint, and inventory check. Native execution remains the new PR CI gate.
2026-09-18 17:59:09 +08:00
XTLine c7f70480c3 fix(desktop): keep the remote workspace out of the new-session composer / 新建会话组合器不再串入远端工作区
Problem:
With a draft (new session) surface visible while a remote tab was the active
tab, the composer's workspace chip showed the remote workspace (for example
AionCore) even though the workspace picker lists only local workspaces.

Root cause:
AppRuntimeView derived the composer workspace context from the raw active tab,
remote tabs included, while the draft surface only clears the topicbar's active
tab: the shared composer then rendered another surface's workspace.

Fix:
Derive the context from the visible surface. A visible draft contributes its own
scope and root (the label falls back to that root's basename, so no other
surface's name leaks in), and the remote tab no longer contributes
name, branch, or the remote flag there.

Verification:
- cd desktop/frontend && npx tsc --noEmit
- cd desktop/frontend && npm run build (eslint, contract checks, vite build, bundle budgets)
- frontend unit tests cannot run in this environment: the runner needs Node >= 24
  (module.registerHooks), this host has Node 22.14
2026-09-18 17:55:19 +08:00
SivanCola 4319cbfb87 Isolate Windows credential ACL marker tests
Problem: The main-v2 push CI intermittently failed TestCredentialAccessRepairsLegacyCredentialDeny because its synthetic residue marker disappeared before credential repair.

Root cause: The test overrode TEMP but not TMP. Windows os.TempDir prefers TMP, so the marker remained in the runner-wide directory and was exposed to concurrent residue sweeps from other package tests.

Fix: Point both TMP and TEMP at the test-owned temporary directory for the direct and linked-home repair cases.

Verification: go test ./internal/config -count=1; GOOS=windows GOARCH=amd64 go test -c ./internal/config; git diff --check.
2026-09-18 17:50:38 +08:00
XTLine 86dcdfd5a7 fix(desktop): adopt the tab runtime when a session open starts without one / 会话打开时采纳标签已有 runtime
Problem:
Opening a local session from a remote-only layout failed with "tab runtime
changed while opening session": the command resolved the dormant tab (no
runtime) while activating the same row built one, so the resume path compared
a nil controller against the freshly published one.

Root cause:
activeOrSingleLocalTab returned a nil controller for the dormant tab, and
resumeCanonicalSessionForTranscript treats a controller that no longer matches
the tab as a replaced runtime.

Fix:
- activeOrSingleLocalTab reads the tab's controller under the same lock.
- resumeCanonicalSessionForTranscript adopts the tab's current runtime when the
  caller hands it none, so a concurrent activation cannot fail the open; a real
  replacement (non-nil mismatch) still refuses.

Verification:
- go test ./desktop -run "TestResumeAdoptsTabRuntimeWhenControllerIsNil|TestOpenSessionUsesDormantLocalTabWithoutAnActiveTab|TestRemoteOnlyLayoutRestoresOneDormantLocalTab|TestSingleSurfaceTabsFile"
- go build root+desktop
2026-09-18 17:48:54 +08:00
SivanCola 759fb37a8e Merge current main-v2 transcript initialization fixes
Problem: the protected base advanced with transcript initialization repairs while this release-control PR was under review.

Root cause: the PR integration result had not yet incorporated the merged runtime and migration changes.

Fix: merge main-v2 without rewriting the release candidate commits or changing upstream fixes.

Verification: focused session, serve, transcript, Desktop migration and signing guard tests; desktop inventory and diff checks.
2026-09-18 17:48:20 +08:00
SivanCola 51631eaa4a Merge main-v2 and harden release qualification
Problem: PR CI rejected obsolete workflow assumptions and incomplete control tests while native candidate reuse lacked isolated proof.

Root cause: the signing guard encoded the old trigger and inline tag operation, shell probes short-circuited producers under pipefail, and candidate identity was not separated from non-publishing rehearsals.

Fix: retain upstream reasoning changes, repair exact CI contracts, isolate rehearsal artifacts, verify downloaded GitHub archive digests, and add a protected read-only cross-run verifier.

Verification: actionlint, release workflow contracts, 102 Node release tests, source/preflight scripts, inventory check, repolint, focused Desktop and session Go tests, and a real GitHub artifact archive digest.
2026-09-18 17:47:06 +08:00
SivanCola 166bd2f134 Refresh desktop migration inventory
Problem: CI rejected the branch because the generated desktop migration inventory was stale after adding exact interaction commands and moving command owners.

Root cause: The inventory generator had not been run after the session isolation implementation and repository-lint refactor.

Fix: Regenerate the Markdown and JSON desktop migration inventories with the repository tool.

Verification: go test ./tools/desktopinventory; a second generator run produced identical files; staged diff and sensitive-data scans passed.
2026-09-18 17:40:35 +08:00
XTLine 9ac19c9d84 fix(desktop): keep a local workspace tab in remote-only layouts / 远端单界面布局下保留本地工作区标签
Problem:
A layout that ended on a remote session restored no local workspace tab at
all, so opening a local session failed with "workspace is not ready"; dropping
a folder into the app failed the same way.

Root cause:
singleSurfaceTabsFile collapsed a remote-active layout to the remote entries
only, and restoreOrBuildTabs refused to grow a local tab for a remote-only
layout. Remote tabs live in their own registry, so a remote-only start left
the workspace-tab set empty and every local command had nothing to target.

Fix:
- Keep one local entry when the remote surface is the active one, and restore
  it without a runtime so no hidden startup work returns; the remote tab keeps
  the visible surface (it stays first in the strip order and the frontend
  resolves it through activeTabFromBackend).
- A remote-only layout publishes one dormant local tab for local commands.
- Local commands resolve the active tab, else that dormant tab
  (activeOrSingleLocalTab); the dormant tab builds its controller on first
  activation or use, and the session-open path already tolerates no controller.

Verification:
- go test ./desktop -run "TestSingleSurfaceTabsFile|TestRemoteOnlyLayoutRestoresOneDormantLocalTab|TestOpenSessionUsesDormantLocalTabWithoutAnActiveTab"
- go test ./desktop (TestBrowserUpload*/TestBootstrapNewSessionPublishesTabUpdateForSidebarBlankRow fail at the parent commit too)
- go build root+desktop, frontend tsc --noEmit, repolint adds no new findings
2026-09-18 17:39:18 +08:00
SivanCola 0c19f3b595 Merge pull request #10501 from SivanCola/fix/transcript-initialization-crash
Fix transcript initialization crash / 修复 transcript 初始化崩溃
2026-09-18 17:34:13 +08:00
SivanCola 1bdb71fd22 Refactor session isolation owners for repository lint
Problem: CI repolint detected growth in existing oversized owner and test files.

Root cause: New session ownership logic and regression scenarios remained in legacy monoliths.

Fix: Extract metadata sampling, runtime snapshots, exact remote interactions, extension form instances, frontend identity reducers, and reusable test scenarios. Regenerate command ownership metadata without changing the repolint baseline.

Verification: repolint; frontend typechecks and production build; remote, session-switch, approval animation, and extension form tests; focused Go tests and race tests.
2026-09-18 17:31:36 +08:00
SivanCola cbb62f854a Authorize protected candidate preparation after Notes merge
Problem: Automatic candidate preparation could not call the reusable Desktop workflow.

Root cause: The shared release authorization helper treated every push as a legacy tag event, while candidate preparation now starts from a protected main-v2 Notes push.

Fix: Accept main-v2 push only for the protected candidate workflow with matching workflow/product SHA, remove the unused privileged PR event, and cover both valid entrypoints and invalid contexts by executing the helper.

Verification: Focused authorization tests, release workflow contracts, actionlint, release control preflight, and repolint passed.
2026-09-18 17:23:13 +08:00
SivanCola 644d970b77 Recover release activation before tags exist
Problem: Recovery could not continue when publication stopped before atomic tag creation.

Root cause: Recovery assumed all three tags existed instead of observing absent, complete, partial, and conflicting identities.

Fix: Share remote tag observation and atomic activation between preflight and recovery, reject partial/conflicting sets, and recheck candidate revocation before activation.

Verification: Actual local Git remote tests cover absent tags, idempotent recovery, annotated tags, conflicts, partial sets, atomic server rejection, and retry. Workflow contracts, 41 Node tests, actionlint, control preflight, and repolint passed.
2026-09-18 17:19:02 +08:00
SivanCola c234f1a1ab Fix candidate input identity and deterministic CI readiness
Problem: Manual candidate records could diverge from built source, control errors waited behind source CI, and history tests depended on runner speed.

Root cause: Source resolution read the moving branch tip; preflight followed the CI wait; test cleanup did not join projection workers.

Fix: Freeze the protected event SHA, validate transitive acceptance helpers before source CI, and wait on index completion events with lifecycle cleanup.

Verification: Release workflow and control preflight contracts, 41 focused Node tests, actionlint, repolint, the full session package, and focused session race tests passed.
2026-09-18 17:16:00 +08:00
SivanCola c45b168a87 Build reusable release candidates for fast recovery
Problem:
Stable releases rebuild, resign, and rerun broad validation after publication failures or unrelated main-v2 changes, making recovery slow and fragile.

Root cause:
Build, signing, acceptance, and publication were coupled to one run. Candidate identity and evidence could not be verified across runs, platform scheduling introduced unrelated waits, and control-plane faults were found after expensive jobs started.

Fix:
Introduce immutable, provenance-bound release candidates; split preparation, promotion, recovery, and public verification; share CLI/npm binaries; decouple Desktop platform scheduling; reuse one Certum session; add a publication ledger; move release-control checks ahead of builds; route CI by affected surface; and document the new workflow.

Verification:
- node --test scripts/*.test.mjs npm/*.test.mjs
- bash scripts/validate-release-control-plane.test.sh
- bash scripts/validate-release-candidate-source.test.sh
- bash scripts/verify-release-push-ci.test.sh
- bash scripts/release-stable.test.sh
- bash scripts/release-workflows.test.sh
- actionlint .github/workflows/*.yml
- go run ./tools/repolint
- go vet ./...
- (cd desktop && go vet ./...)
- go test ./internal/agent ./internal/control ./tools/desktopinventory
- (cd desktop && go test ./...)
2026-09-18 17:00:33 +08:00
SivanCola 025edf9dcc Merge main-v2 and fix CI failures
Problem:
The PR was behind main-v2, Desktop lint rejected a bytes.Split range, and the Windows serve auto-reclaim regression timed out before its asynchronous recovery completed.

Root cause:
The migration test used an allocation-based split that modernize rejects. The serve test polled for two seconds even though reclaim reloads the session and may take longer on Windows, then tore down resources while the reclaim goroutine was still active.

Fix:
Merge the latest main-v2, range over bytes.SplitSeq, and expose the auto-reclaim completion signal so the regression waits for the actual operation before asserting or cleaning up.

Verification:
- go test ./internal/serve -run '^TestAutoReclaimCompletesOutstandingReclaim$' -count=20
- go test ./internal/transcript ./internal/session -count=1
- cd desktop && go test . -run '^TestLegacyMigrationTranscriptFailurePreservesSourceAndContinues$' -count=1 -timeout=5m
- golangci-lint run --timeout=5m ./internal/serve/... ./internal/transcript/... ./internal/session/...
- cd desktop && golangci-lint run --timeout=5m ./...
- go vet ./internal/serve ./internal/transcript ./internal/session
- go run ./tools/repolint
- git diff --check
2026-09-18 17:00:12 +08:00
SivanCola 52eb62bd1e Fix session state and interaction ownership
Problem:
Session-scoped todos and interactive cards could briefly appear in another
session, and delayed callbacks could submit or clear the currently visible
request instead of the request that originated the action.

Root cause:
Runtime projections from independent sources were merged without a shared
producer version, while prompt and extension callbacks resolved mutable active
session state after the user action. Card-local state also used identifiers that
could be reused across request instances.

Fix:
Add generation-aware session bindings, versioned runtime projections, immutable
interaction targets, request-scoped drafts and component state, exact prompt
submission, and host-generated extension form instance identities. Gate exact
remote interactions with explicit capabilities and document the ownership
contracts.

Verification:
Passed frontend typechecks, lint and architecture gates, production build,
focused runtime/prompt/form/remote tests, browser lifecycle suites, affected Go
package tests, and race tests for extension UI and serve ownership. Broad root
and Desktop Go suites were attempted but exceeded the local execution window
without a related assertion failure.
2026-09-18 16:42:58 +08:00
SivanCola 9b0cc56e5c Merge pull request #10500 from SivanCola/fix/reasoning-capability-resolution
Fix model-scoped reasoning capabilities / 修复模型级思考能力解析
2026-09-18 16:38:21 +08:00
XTLine f7613908a4 test(serve): align lifecycle cleanup with idle runtime retirement / 对齐生命周期收尾与空闲退位模型
Problem:
newLifecycleTestServer's cleanup asserted that the session writer
retired from the service within 5s of server.Close(); the test has
failed deterministically since its introduction (9ddb8d57a).

Root cause:
The ownership model releases the controller's client binding
synchronously but retires the now-idle runtime on a 60s TTL
(scheduleIdleRetirement), so a 5s poll on service.Runtime can never
observe the retirement.

Fix:
Force the retirement with service.Close in the cleanup loop: the normal
close path settles immediately, while a genuinely leaked binding still
fails as ErrRuntimeBound and a busy finalizing turn is waited out, so
the check keeps its teeth.

Verification:
- go test ./internal/serve/ (suite fully green, was failing on
  TestDeleteSessionDeletesCanonicalIdentity since 9ddb8d57a)
- repolint: zero new findings
2026-09-18 16:31:33 +08:00
XTLine 4783030af4 fix(desktop): restore the transcript 409 identity-refresh retry lost in a merge / 恢复被合并丢失的转录 409 身份刷新重试
Problem:
A /transcript read that hit 409 after the serve rotated its foreground
(model switch, resume, takeover) surfaced as a terminal "remote
transcript read failed (HTTP 409)" error instead of refreshing the tab's
identity and retrying on the live route.

Root cause:
Merge 6885a9d88 resolved desktop/remote_transcript_api.go by taking
main's structure and silently dropped the branch's
remoteTranscriptReadAttempt wrapper (409 -> RemoteTabStatus refresh ->
one retry), the same class of merge loss as the spectator read path
re-added by 72511d36d; TestRemoteTranscriptRefreshesIdentityAfterConflict
has been red since that merge.

Fix:
Restore the wrapper: remoteTranscriptRead delegates to
remoteTranscriptReadAttempt(refreshOnConflict=true), whose 409 case
refreshes via RemoteTabStatus and retries exactly once with the updated
route, keeping the current follow-stream context handling intact.

Verification:
- go test ./desktop -run TestRemoteTranscriptRefreshesIdentityAfterConflict (now green)
- go test ./desktop remote-tab/transcript suites
- repolint: zero new findings
2026-09-18 16:31:32 +08:00
XTLine a83ecc7967 feat(remote): render persisted history before the runtime activates / 远端会话历史先行
Problem:
Opening a remote tab waited for POST /resume to activate the remote
runtime before any transcript content appeared, so every open paid the
activation latency up front; the identity-addressed history endpoints
refused every session except the bound foreground.

Root cause:
canonicalSessionQuery gated /session-history/* and /session/open on the
bound foreground identity even though the underlying session.Query reads
are cold and store-wide, and the desktop attach flow published nothing
until /resume completed, so the frontend had no signal to read early.

Fix:
- Serve: the identity gate now resolves any requested sessionId (or
  session-id: route) straight from the store via a Stat probe and answers
  cold; unknown identities keep the 409, and the live /transcript/*
  protocol still requires the owning runtime.
- Desktop: installRemoteTabAttachPump announces the tab once its route,
  client, and negotiated capabilities are live but before the foreground
  rotation starts.
- Frontend: useRemoteSession primes a one-shot readable baseline through
  the shared transcript store (loadLatest + historyReplaceAction, the
  same contract as local primeReadableHistoryForTab), retried on attach
  publication; the live follower still replaces it once ready and the
  409 spectator fallback is unchanged. No new UI states.

Verification:
- go test ./internal/serve/ (new cold-identity test; pre-existing
  TestDeleteSessionDeletesCanonicalIdentity failure reproduces on the
  pre-merge branch tip and is unrelated)
- go test ./desktop remote-tab/transcript sets (pre-existing
  TestRemoteTranscriptRefreshesIdentityAfterConflict likewise)
- go build root+desktop, frontend tsc --noEmit, npm test, repolint adds
  zero new findings versus the merge commit
2026-09-18 16:16:44 +08:00
SivanCola fa7d5a3b77 Fix reasoning capability CI gates
Problem: the model capability recovery copy exceeded the raw startup bundle budget, and the new WorkspaceTab method used a receiver name rejected by the Windows build-tag lint.

Root cause: the prior budget had only 261 bytes of headroom while the localized recovery copy added 287 bytes, and rebindEffortModel did not follow the type's established receiver convention.

Fix: ratchet the measured raw bundle ceiling to the next 0.1 KiB boundary and rename the receiver to t.

Verification: pnpm build; node scripts/check-bundle-budget.mjs; GOOS=windows golangci-lint run --timeout=5m ./...; go run ./tools/repolint; git diff --check.
2026-09-18 16:05:45 +08:00
SivanCola 2797455a1d Fix transcript initialization crash during migration
Problem: malformed legacy transcript baselines could crash the Desktop service while startup migration restored a nil projection.

Root cause: session runtime construction discarded NewProjection errors and continued into RestoreRuntime without a valid projection.

Fix: propagate typed initialization failures, release session ownership on every failed path, keep healthy migrations running, add content-free local diagnostics, and queue a deduplicated telemetry-gated exception for the crash dashboard.

Verification: session and transcript package suites, focused Desktop crash/migration tests, targeted race tests, crash worker tests, and worker typecheck pass.
2026-09-18 16:01:36 +08:00
SivanCola 7fc2fd46eb Fix model-scoped reasoning capability resolution
Problem:
Legacy OpenAI-compatible connections could reject valid reasoning levels with
supported=[] at startup, and session effort could leak across model changes.

Root cause:
Built-in capability facts, persisted user overrides, UI metadata, boot
validation, and adapter construction resolved reasoning independently. Missing
metadata was also treated as an explicit lack of reasoning controls.

Fix:
Resolve connection, protocol, and model reasoning through one contract; keep
built-in defaults separate from explicit overrides with backward-readable
snapshots; bind session effort to model identity; and represent unknown metadata
separately from unsupported controls across Desktop, CLI, ACP, and serve.

Verification:
- go test ./internal/config ./internal/boot ./internal/cli ./internal/acp ./internal/serve
- go test -race ./internal/acp for model/effort queue ordering
- affected Desktop Go tests and frontend typecheck/regression tests
- Chromium settings interaction check
- bash scripts/cache-guard.sh
- go run ./tools/repolint
2026-09-18 15:52:06 +08:00
XTLine 1bb072efbc Merge branch 'main' into fix/sessionid-migration-gaps
# Conflicts:
#	desktop/frontend/src/app-shell/chromeRegionBuilders.ts
#	desktop/frontend/src/components/ProjectTree.tsx
#	desktop/frontend/src/components/ProjectTreeRemoteGroups.tsx
#	desktop/frontend/src/lib/projectTreeTopic.ts
#	desktop/frontend/src/lib/transcriptSnapshotState.ts
#	desktop/frontend/src/lib/useRemoteSession.ts
#	internal/cli/turn_lifecycle.go
2026-09-18 15:51:05 +08:00
SivanCola bd0733b972 Merge pull request #10490 from SivanCola/fix/archive-history-session-identity
Fix archived historical sessions reappearing / 修复归档历史会话重复出现
2026-09-18 14:01:07 +08:00
SivanCola 3703136864 Fix archived history session identity
Problem: Archived single-head historical sessions could reappear in the sidebar and history list, where a second archive attempt reported that the session was already archived.

Root cause: v5 migration records a head-specific source identity, while single-head historical sessions are displayed by their legacy file path. Projection filters did not treat those identities as aliases.

Fix: Recognize the selected head of a single visible-head source as the path alias across sidebar, history, and organization projections while preserving independent multi-head identities.

Verification: go test ./...; focused race tests; go vet ./...; golangci-lint run --timeout=5m ./...; git diff --check.
2026-09-18 13:35:57 +08:00
SivanCola 93fb728707 Merge pull request #10487 from SivanCola/fix/site-follow-stable-desktop
Follow the current Stable Desktop release / 官网跟随当前正式版
2026-09-18 12:10:45 +08:00
SivanCola 5aa7c5fc6d fix(site): follow the current stable desktop release
Problem: The public Desktop download pane remained pinned to v1.38.3 after v1.38.10 was published.

Root cause: A temporary website-only version pin survived the incident it was introduced for, while release postflight did not cover the homepage configuration.

Fix: Clear the production pin and add a contract test that requires normal builds to resolve the current Stable manifest.

Verification: cd site && npm test; cd site && npm run build
2026-09-18 12:05:18 +08:00
SivanCola b1a4e016b3 Complete fixed-snapshot session exports / 完善固定快照会话导出 (#10471)
* Complete fixed-snapshot session exports

Problem:
Session exports could silently contain only the resident history window, omit tool payloads, and show unloaded tool results as stopped after workspace switches.

Root cause:
Export commands read renderer-owned items instead of an authoritative fixed session boundary, while tool execution state and content availability were inferred from the same paginated projection.

Fix:
Add fixed export snapshots, watermark-bounded persistence, complete forward history traversal, host-owned local and remote export operations, streamed visual output, reliable publication, cross-page tool observations, and expanded lifecycle diagnostics.

Verification:
Run root and Desktop test suites, focused race tests, repository and Go linters, frontend type checks and transcript suites, browser visual export probes, and native macOS save/cancel smoke tests.

* test(desktop): align remote export contract

Problem:
The desktop frontend CI still asserted that remote exports serialize the visible resident transcript.

Root cause:
The source-contract test predated fixed host-owned export snapshots and contradicted the new export boundary.

Fix:
Assert that remote exports pass an explicit session selector through runSessionExport to BeginSessionExportForTarget, and guard against restoring the resident-transcript Markdown path.

Verification:
- pnpm exec tsx src/__tests__/remote-project-tree.test.tsx
- pnpm test:typecheck
- pnpm test:transcript

* test(desktop): update export behavior contracts

Problem:
The complete frontend CI still encoded two pre-snapshot export assumptions: direct renderer imports from the command hook and missing tool results being treated as stopped.

Root cause:
The source and status contracts were not updated when export orchestration moved behind a lazy host-snapshot operation and tool execution state was separated from content availability.

Fix:
Verify the two-stage lazy export import path and require missing tool results to remain unknown without authoritative termination evidence.

Verification:
- pnpm exec tsx src/__tests__/bundle-contract.test.ts
- pnpm exec tsx src/__tests__/history-tool-status.test.ts
- pnpm test:typecheck
- REASONIX_TEST_CONCURRENCY=2 node frontend/scripts/run-ci-tests.mjs (452/452)
2026-09-18 12:00:00 +08:00
SivanCola e8762acc9c Make local drafts durable and clean up legacy empty sessions / 持久化本地草稿并整理历史空会话 (#10469)
* fix(desktop): make local session drafts durable

Problem:
Local session creation could mix draft edits, navigation state, and runtime configuration across asynchronous submission, retry, cancellation, and recovery.

Root cause:
Draft content and execution ownership were split across Composer-local state, active-page inference, and partially durable operation records.

Fix:
Persist workspace-scoped drafts and submission operations with CAS revisions, explicit identities, durable receipts, runtime admission checks, and target-bound attachment/navigation handling. Apply retry settings to the real Controller and reconcile cancellation and unknown results by operation identity.

Verification:
Passed root and Desktop module test suites, focused race tests, frontend typechecks/build and lifecycle regressions, Electron shell tests, browser entrypoint/performance checks, and isolated native clipboard/drop/restart smoke tests.

* fix(desktop): clean up legacy empty sessions safely

Problem
Historical default-title sessions created before draft-backed new-session flows could leave unused entries in local project history, while a blind cleanup risked deleting content, racing active runtimes, or fabricating session identities for legacy topic placeholders.

Root cause
Older creation paths persisted blank sessions and topic indexes eagerly. The lifecycle layer had no frozen upgrade candidate set, conservative whole-artifact classifier, or conditional archive fence spanning runtime, registry, project index, and topic metadata ownership.

Fix
Add a versioned one-shot cleanup sidecar and cross-process worker that freezes pre-upgrade identities, classifies canonical sessions, migrated legacy sources, and topic-only placeholders, and moves only provably empty entries to Trash. Reuse canonical archive and restore identities, add placeholder-specific Trash recovery, fence final checks with maintenance locks and revisions, suppress fallback runtime creation, and expose cleanup status, retry, notifications, generated contracts, translations, and bilingual documentation.

Verification
- go test ./...
- cd desktop && go test ./... -timeout 20m
- go run ./tools/repolint
- go run ./tools/desktopinventory -check
- generated contract freshness tests
- focused race, frontend, production build, hooks, app-layer, CSS, and bundle-budget checks

* fix(control): remove obsolete clipboard wrappers

Problem
The Linux lint job rejected three platform clipboard wrappers as unused after draft attachments began saving through explicit Workspace roots.

Root cause
The root-aware clipboard entry point switched directly to injected platform helpers, but the old Windows and macOS default-root wrappers remained unreachable.

Fix
Remove only the three obsolete wrappers while preserving the shared platform implementations, Linux test adapter, and macOS injected-reader tests.

Verification
- golangci-lint run --timeout=5m
- go test ./internal/control -count=1
- go run ./tools/repolint
- git diff --check

* fix(desktop): preserve startup navigation ownership

Problem:
Startup draft probing could invalidate formal-session hydration before it knew whether a draft needed to be shown. Existing sessions then remained non-writable and the approval browser gate could not send.

Root cause:
The draft initializer eagerly claimed a navigation intent, while passive startup hydration also advanced the same intent generation. The two startup projections raced without distinguishing observation from user navigation.

Fix:
Keep passive backend hydration on the current intent, expose a stable intent snapshot, and let draft startup claim a new intent only after confirming a restored draft or a genuinely empty startup. Fence late restore reads against newer user navigation and add a deterministic ordering regression.

Verification:
- session-draft startup, ownership, store-race, and lifecycle tests
- frontend test typecheck
- frontend production build
- approval motion browser gate
- repolint

* fix(desktop): preserve IME composition across draft renders

Problem:
Persistent draft setters change identity on each render, so the IME guard repeatedly detached its native listeners during composition. This cleared provisional text, left the submit grace state active after programmatic inserts, and exposed merged Go code to CI lint failures.

Root cause:
The listener lifecycle depended on render-scoped setter functions instead of the textarea mount boundary. The merged branch also retained unchecked rollback cleanup, obsolete helpers, and non-wrapping error chains.

Fix:
Keep native composition listeners stable while reading current setters through refs. Preserve programmatic resync behavior, make rollback intent explicit, retain wrapped decode errors, and remove unreachable merged helpers.

Verification:
- composer IME and full Composer suites
- 457/457 frontend CI commands
- frontend production build and motion browser gate
- Desktop go test ./... -timeout 20m
- macOS and GOOS=windows golangci-lint
- repolint and desktop inventory
2026-09-18 10:45:50 +08:00
SivanCola 9a4f4b4511 Fix Windows credential ACL self-lockout / 修复 Windows 凭据 ACL 自锁 (#10466)
* Fix Windows credential ACL recovery

Problem: Windows automatically added the global credential store to forbid_read, which denied the host settings process and could leave API keys unreadable after a crash.

Root cause: WRITE_RESTRICTED does not isolate reads, while the protected-read path applied a deny ACE to the same user identity as the host. Initial recovery also risked waiting on sandbox locks during ordinary reads and could not prove ACL provenance from its mask alone.

Fix: keep the Windows credential file out of automatic forbid_read, preserve child environment filtering, and repair denied credential access only when a matching dead sandbox residue record proves provenance. Consume repaired residue records and leave ambiguous or live ACL state untouched.

Verification: go test ./...; go test -race ./internal/config ./internal/sandbox; Windows amd64 cross-compilation; Go vet; native Windows ARM64 credential and ACL regressions.

* Handle linked Windows credential homes

* Keep boot checks within repository budgets
2026-09-18 10:42:52 +08:00
SivanCola 6e73b44be7 ci(release): select final platform bundles without unsigned intermediates (#10486)
Problem: Desktop publication downloaded eight bundles and failed the strict six-platform handoff contract before publication.

Root cause: the artifact prefix wildcard also matched the two unsigned Windows intermediates retained by the same preflight run.

Fix: restrict download selection to the exact six final platform suffixes; keep the collector and its signature, identity, digest and completeness validation unchanged.

Verification: the new regression fails with the original workflow and passes with the fix. All 39 focused Node tests, release workflow shell contracts, actionlint, repolint and diff checks passed.
2026-09-18 10:17:34 +08:00
SivanCola e183b8f413 ci(release): include Windows upgrade evidence helper in protected checkout (#10480)
Problem: signed Windows installer preflight failed because the startup wrapper dot-sources windows-upgrade-ui-evidence.ps1, which was omitted from the sparse protected release checkout.

Root cause: the sparse-checkout allowlist covered wrapper scripts but not their shared helper.

Fix: include the helper in the protected release verifier checkout. Published product tags remain immutable; this is a control-plane repair.

Verification: workflow diff checked; release recovery must run the repaired control plane against existing v1.38.10 tags.
2026-09-18 08:47:35 +08:00
github-actions[bot]andSivanCola 366e0eb6b8 docs(release): finalize v1.38.10 notes / 确认 v1.38.10 中英更新日志 (#10479)
* docs(release): prepare v1.38.10 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): retain reviewed v1.38.10 notes and bind final CI repair

Preserve the reviewed bilingual product changes and citations. Only update six guide links to the latest main-v2 source after the test and CI lifecycle repair. Catalog validation and all 11 release-note tests pass.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: SivanCola <[email protected]>
2026-09-18 07:16:38 +08:00
SivanCola 26f1984e97 test(ci): fix fixture lifetimes and Windows validation latency / 修复测试生命周期与 Windows 验证长尾 (#10478)
* Wait for runtime admission fixture lifecycle instead of host speed

Problem: the workspace-repair admission regression intermittently failed its ten-second completion wait on Windows after the lock-order assertion had already passed.

Root cause: positive synchronization and filesystem repair completion were measured with fixed five/ten-second wall clocks even though the test guards lock ownership, not latency.

Fix: retain all channel and error assertions, but bound positive waits by the test lifecycle and existing package alarm. This does not change product timeouts or concurrency behavior.

Verification: the exact test passed five consecutive local race runs in 9.208s; repolint and diff checks passed. Native Windows CI remains required before merge.

* test(ci): own history fixture shutdown and parallelize Windows long suites

Problem: release qualification failed in history HTTP readiness and left content workers writing during temporary-directory cleanup; long Windows packages also queued behind the residual suite.

Root cause: fixtures used runtime-only CloseAll instead of terminal Shutdown, and readiness assertions encoded host-speed deadlines. Serve, session and worktree shared the residual package queue.

Fix: use checked terminal shutdown across serve fixtures, pace readiness polling under the test lifecycle, and isolate the three long suites in native PR and push CI with exhaustive disjoint routing.

Verification: history HTTP race tests passed three times; vet and repolint passed; 23 CI contracts passed; live inventory verified complete disjoint coverage of 157 packages. Native Windows validation remains required.

* ci: monitor diagnostic overhead outside release qualification

Problem: threshold-free diagnostic measurement extended every packaging push by about five minutes after installer acceptance.

Root cause: observational metrics were serialized into the release-critical CI workflow although they do not qualify a package.

Fix: preserve the exact native benchmark and visible failure artifacts in an independent source-filtered, daily and manually dispatchable workflow. Keep all installation and upgrade assertions in required CI.

Verification: all 23 CI contracts, actionlint, repolint and diff checks passed. The previous native benchmark completed successfully in 4m55s; the standalone workflow will be verified after merge.
2026-09-18 07:11:46 +08:00
github-actions[bot]andSivanCola 4d57e8e458 docs(release): finalize v1.38.10 notes / 确认 v1.38.10 中英更新日志 (#10477)
* docs(release): prepare v1.38.10 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): retain reviewed v1.38.10 notes on qualified source

The follow-up changes only native validation fixtures and PR coverage. Keep the already reviewed user-facing release notes and pin the six guide links to the new main-v2 code ancestor.

Verification: catalog validation, all 11 release-note tests and rendered bilingual output passed; final diff only refreshes six commit-pinned guide links.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: SivanCola <[email protected]>
2026-09-18 06:34:41 +08:00
SivanCola c71b77b37f Merge pull request #10476 from SivanCola/fix/windows-native-validation
Make Windows validation deterministic / 消除 Windows 验证中的时间假设
2026-09-18 06:29:28 +08:00
SivanCola f32f79a92d Make Windows native validation independent of filesystem timing
Problem: release-only native tests assumed consecutive writes must have distinct timestamps, while a serve fixture treated five-second cleanup latency as a correctness failure.

Root cause: timing assumptions replaced explicit metadata and lifecycle invariants, and fileops native coverage was absent from PR smoke.

Fix: explicitly change native ChangeTime, separately test real replacement with restored mtime, include fileops in Windows PR coverage, and wait for the actual cleanup idle boundary under the package alarm.

Verification: Windows test compilation, owning-package vet, three focused race repetitions, Windows grouping tests, repolint and diff checks pass. Native Windows CI is required before merge.
2026-09-18 06:19:39 +08:00
github-actions[bot]andSivanCola fe5171e27f docs(release): prepare v1.38.10 notes / 准备 v1.38.10 中英更新日志 (#10474)
* docs(release): prepare v1.38.10 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): preserve reviewed notes and include migration recovery repair

Retain the reviewed v1.38.10 catalog instead of unrelated regenerated prose churn. Add the merged session-adoption and recoverable-history error fix from #10472 and refresh pinned guide links to the qualified code commit.

Verification: release catalog validation, all 11 release-note tests, rendered bilingual output and the first-parent release range were reviewed; only release-notes/releases.json changes.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: SivanCola <[email protected]>
2026-09-18 06:01:47 +08:00
SivanCola 1a6d490ec3 Merge pull request #10472 from SivanCola/fix/release-source-identity-validation
Preserve release qualification and validate packaging before merge / 根治发版重复验证与打包晚发现
2026-09-18 05:53:10 +08:00
SivanCola d609535a7c Keep recoverable history failures out of persistent chat notices
Problem: native upgrade restart displayed recovered history alongside a stale failure notice, which acceptance incorrectly passed.

Root cause: hydration duplicated its recoverable error into a local chat notice that authoritative snapshots intentionally preserve.

Fix: keep recovery errors in their existing state/banner owner and reject unresolved recovery UI or obsolete failure notices during native acceptance.

Verification: real-controller regression fails before the fix for ready-event recovery and manual retry, then passes all 12 assertions. Transcript suite, Chromium browser suite, recovery UI, tab-switch and topic activation tests, frontend production build/typecheck/hook lint, repolint and diff checks pass. Windows selector boundary regressions run in native CI.
2026-09-18 05:25:22 +08:00
SivanCola 4a93942a55 Route uninstaller verification changes to native packaging CI
Problem: A verifier-only edit would otherwise be classified as independent tooling.

Root cause: Packaging routing lists owned script entrypoints explicitly.

Fix: Include the real NSIS uninstaller verifier in the packaging surface.

Verification: Routing tests prove packaging and native coverage without unrelated memory selection.
2026-09-18 04:55:59 +08:00
SivanCola 2c4dd548f3 Remove redundant Windows installer compression and fix acceptance identity
Problem: Packaging spent nearly five minutes compressing a full installer that was discarded solely to extract its uninstaller; native upgrade verification also rejected equivalent drive spellings after successful history restore.

Root cause: Bootstrap and distributable assembly shared the full install payload path, while the fixture compared raw paths instead of file identity.

Fix: Compile the shared uninstaller without embedding Electron, keep final installer assembly and signing unchanged, and compare fixture mappings through canonical session identity.

Verification: Real NSIS AMD64/ARM64 compilation preserves normalized uninstall instructions and rejects missing real payloads. Native contract is enforced before packaging. Fixture regression, 56 routing/packaging tests, build contract, actionlint, inventory, repolint, and diff checks pass; full local Desktop tests passed in 700 seconds.
2026-09-18 04:55:03 +08:00
SivanCola 7f2d3f2433 Validate inventory contracts without source line churn
Problem: Release qualification failed when an unchanged persistence declaration moved by 21 lines.

Root cause: Inventory freshness compared navigation hints byte-for-byte and the no-Git test mistook golden-file drift for Git dependence.

Fix: Share a comparison that ignores only source line hints; preserve exact paths, entries, classification, ownership and signatures. Compare actual builds with and without Git and refresh the generated links.

Verification: Inventory tests, vet, repolint and actual CLI checks pass. The CLI accepts the previous line-only inventory and rejects changed persistence names.
2026-09-18 04:39:12 +08:00
SivanCola 71e9e2510c Reuse adopted session identities during legacy tab startup
Problem: Native Windows upgrade acceptance opened a second identity and failed with a registry mutation conflict.

Root cause: Tab boot bypassed Desktop adoption and refroze the legacy source after mutable catalog metadata changed the core import digest.

Fix: Share adoption-first resolution between startup and transcript continuation, resolve selected DAG heads, and preserve fail-closed handling of changed history and missing canonical targets. Persisted formats and keys are unchanged.

Verification: Product-code regression failed before the fix with the same conflict. Focused startup, restart, transcript, DAG, and history tests pass with race detection (21.091s); Desktop vet, repolint, and diff checks pass.
2026-09-18 04:29:50 +08:00
SivanCola a0fd8004e0 Align release qualification with real build and lifecycle contracts
Problem: Production frontend identity exceeded the short-SHA CI budget, restart acceptance rejected legitimate registry snapshots, and Windows event tests raced teardown after fixed five-second waits.

Root cause: Validation assumed different build inputs, a single lifetime backup, and fixed filesystem completion speed instead of identity and ownership invariants.

Fix: Use full-SHA prepared frontends and measured browser/Electron byte budgets, hash-bind the original registry backup, and join controller finalization before deleting fixtures.

Verification: 35 workflow/routing/partition tests; failing-then-passing startup-backup regression; actual macOS packaged history/API/restart and data integrity acceptance; control lifecycle tests repeated 10 times and race 3 times; actionlint, vet, repolint, and diff checks.
2026-09-18 04:12:44 +08:00
SivanCola 00969ed34b Partition native validation and repair upgrade acceptance fixtures
Problem: Desktop race and Windows test partitions exhausted the cumulative package alarm; packaged upgrade acceptance opened onboarding and then lost its failure artifact to a cache lock.

Root cause: Growing suites shared a ten-minute process budget, the legacy fixture omitted provider configuration, and artifact collection included live installed and Chromium files.

Fix: Inventory-check seven complete Windows and race partitions, retain one cache writer and push-only diagnostic measurements, seed a credential-free offline provider, and collect only reports, screenshots, and logs with native UI evidence.

Verification: 35 routing, workflow, and partition regressions; actual race-tagged inventory; fixture migration/restart and onboarding test; fixture vet; release contracts; actionlint; repolint; diff checks. Native replacement CI remains the acceptance gate.
2026-09-18 03:53:36 +08:00
SivanCola adbcb6299f Stop cancelled CI workers from blocking replacement runs
Problem: Superseded PR checks kept expensive workers alive and delayed replacement validation.

Root cause: Job-level always() remains true during workflow cancellation and retains the concurrency slot.

Fix: Gate expensive workers with !cancelled(), retaining unconditional aggregate reporting and conservative validation after failed change detection.

Verification: All 29 routing/workflow regressions, release workflow contracts, repolint, and git diff --check passed.
2026-09-18 03:22:03 +08:00
SivanCola 495ee5f876 Preserve release qualification and catch packaging failures before merge
Problem: release Notes pushes cancelled the code CI that tagging still required, packaging defects surfaced only after merge, and unrelated CI edits selected full app-memory screening.

Root cause: concurrency used branch identity instead of candidate identity; supersession ignored unchanged product trees; packaging was push-only; independent workflow routing was coupled.

Fix: isolate push concurrency by SHA and preserve ancestors with identical non-Notes trees, gate packaging PRs on native installer/upgrade acceptance, and separate CI from app-memory routing. Normalize the unchanged frontend baseline from 2443.1 to 2443.2 KiB: base and head both measure 2501760 B, only 26 B over the previous cap. Report exact bytes on failure.

Verification: 28 CI routing/workflow tests, including real Git histories and mocked cancellation API mutations, passed. Frontend base/head builds and bundle guard, repolint, and diff checks passed; complete session race passed in 360 seconds.
2026-09-18 03:16:17 +08:00
SivanCola 9dd10c1611 Fix packaged source identity and release validation budgets
Problem: Windows upgrade acceptance rejected build.json and full CI stopped on validation timing limits.

Root cause: desktop packaging received the CLI short revision; the ACP test client imposed a two-second filesystem/RPC deadline; the session race suite consumed 580 seconds before a final test hit the default ten-minute package alarm.

Fix: pass the full source SHA to packaging with an executable contract regression, bind ACP test calls to the test context, and allow fifteen minutes per package in the full race sweep while retaining every assertion.

Verification: desktop build contract, CI workflow tests, ACP package tests and vet, focused title-history race regression, shell syntax, repolint, and diff checks passed. Native push validation remains required before release.
2026-09-18 03:06:13 +08:00
github-actions[bot] 7fb2ceb1b3 docs(release): prepare v1.38.10 notes (#10470)
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-18 02:32:49 +08:00
SivanCola 9ec875a684 Stabilize Windows release validation / 稳定 Windows 发版验证 (#10468)
* Stabilize Windows release validation

* Respect test file size baseline

* Make lazy cache-miss test await publication

Problem:
The race CI could observe the MCP host client before the lazy registry swap completed, making TestLazyCacheMissAsyncSpawn fail even though the product state machine was still progressing correctly.

Root cause:
The test polled Host.ServerNames, but host registration occurs before lazySpawn publishes real tools and closes its completion signal.

Fix:
Drive the advertised second-turn connect call, which waits on the production completion signal, before asserting the registry contents.

Verification:
- go test -race ./internal/plugin -run '^TestLazyCacheMissAsyncSpawn$' -count=50
- go test -race ./internal/plugin
- go run ./tools/repolint
- git diff --check

* Remove machine-speed budget from transcript test

Problem:
The Windows control CI failed when a correct transcript history preparation exceeded a test-local five-second deadline on a loaded runner.

Root cause:
The regression tied the durable-cut contract to filesystem and runner throughput even though adjacent lifecycle coverage already uses the test context and the product operation correctly honors caller cancellation.

Fix:
Use the test lifecycle context while preserving every ready-state, durable-sequence, page-boundary, and final-message assertion.

Verification:
- go test ./internal/control -run '^TestTranscriptFollowInitialHistoryIsReadyAndPinnedToDurableCut$' -count=50
- go test ./internal/control
- GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c ./internal/control
- go run ./tools/repolint
- git diff --check
2026-09-18 02:25:37 +08:00
github-actions[bot]andSivanCola 88352b31b9 docs(release): 准备 v1.38.10 中英更新日志 (#10465)
* docs(release): prepare v1.38.10 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): review refreshed v1.38.10 notes

Summary: Correct the Desktop and CLI target mapping for Windows path handling, persistent PowerShell, and the cold-start handshake fix, and restore the supported first-request prompt-cache disclosure.

Verification: Validate and render the 69-entry catalog, run all 11 release-note tests, verify the affected target mappings, scan for local paths and token patterns, and run git diff --check.

* Test recovery visibility instead of marker timing

Problem: Desktop push CI intermittently failed after deleting a migrated parent because a background catalog reconcile recreated a valid topic repair marker before the test's immediate absence assertion.

Root cause: the test treated a transient internal marker state as a product contract even though marker invalidation exists only to trigger reconciliation and the worker may complete that reconciliation before deleteSession returns.

Fix: remove the timing-sensitive marker absence assertion and retain the observable recovery visibility, identity, and project-tree assertions that fail if invalidation or reconciliation is broken.

Verification: focused test passed 20 consecutive runs; focused race test passed 5 consecutive runs; gofmt and git diff --check passed.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: SivanCola <[email protected]>
2026-09-18 00:58:33 +08:00
SivanCola 7f85e5f371 Bound PowerShell startup with a cold-host budget (#10464)
Problem: Windows PowerShell 5.1 could establish its control pipe but miss the fixed 10-second ready-frame deadline on a cold or contended CI host, blocking a release-qualified main-v2 commit.

Root cause: PowerShell reused the POSIX startup timeout and independently reset that budget after accepting the control connection, so the bound did not represent one complete handshake and was too short for measured Windows PowerShell 5.1 cold starts.

Fix: give PowerShell a measured 30-second startup budget shared by connection acceptance and ready-frame receipt while preserving the POSIX and per-command timeout contracts.

Verification: go test ./internal/persistentshell; go vet ./internal/persistentshell; git diff --check.
2026-09-18 00:20:59 +08:00
SivanCola 9eb3ccc50d Unify model credential recovery across Desktop and CLI / 统一桌面端与 CLI 模型凭据恢复 (#10460)
* Unify model credential recovery across Desktop and CLI

Problem:
Missing or rejected credentials could still reach model requests, setup edits were not transactionally isolated, and Desktop/CLI recovery behavior diverged.

Root cause:
Credential persistence, request receipts, authentication admission, and recovery UI state were owned by separate paths without a shared atomic connection contract.

Fix:
Introduce shared model-settings and credential diagnostics services, durable receipt recovery with isolated credential slots, connection-scoped authentication gates, Desktop recovery actions, and CLI setup/auth flows. Preserve drafts and running snapshots while applying new settings at the next admission boundary.

Verification:
Passed root and Desktop full Go suites, focused race tests, go vet, frontend build/typecheck and lifecycle tests, browser recovery scenarios, Electron smoke, generated inventory checks, and Windows cross-compilation.

* Align provider probing with frontend dependencies

Problem:
The shared provider probe imported the boot frontend from a lower-level package, violating the repository dependency contract and leaving the lint ratchet stale.

Root cause:
Provider construction ownership was separated from the boot package even though both CLI and Desktop are frontend callers.

Fix:
Move the bounded request-local probe into boot, update both callers, regenerate inventory, and record the measured lint budget changes for the cross-client recovery feature.

Verification:
Passed boot, CLI, and focused Desktop tests, repolint, diff checks, and desktop inventory freshness.

* Satisfy credential recovery lint contracts

Problem:
The CI-pinned linter rejected one rollback error path and one receipt assertion.

Root cause:
The rollback path wrapped only one of two errors, while the test converted a byte slice through fmt.Sprint before string formatting.

Fix:
Join and wrap both verification errors, and convert receipt bytes directly to string.

Verification:
Passed the config suite, go vet, repolint, diff checks, and golangci-lint v2.12.2 with zero issues.

* Close CodeQL credential disclosure paths

Problem:
CodeQL found that configuration contents were hashed as password-like data and install-source responses could carry credential-bearing internal state to stdout.

Root cause:
Config revisions used an unkeyed digest, while public action responses copied the complete internal action before clearing selected fields.

Fix:
Derive config revisions with the private durable HMAC key and construct public install actions field-by-field, excluding environment, headers, and internal config state.

Verification:
Added keyed-revision and output-redaction regressions; passed config, installsource, and CLI tests, go vet, repolint, diff checks, and golangci-lint v2.12.2 with zero issues.

* Redact plugin install command output

Problem:
CodeQL continued to treat the install-source tool result as credential-bearing when the CLI printed the raw JSON response.

Root cause:
The CLI trusted a generic tool output string at its stdout boundary, so upstream sanitization could not prove that future response fields were safe.

Fix:
Parse only the boolean result and status, normalize status through a closed enum, and emit newly encoded summary JSON. Raw tool output never reaches stdout.

Verification:
Added status allowlist coverage; passed focused CLI tests, go vet, repolint, diff checks, and golangci-lint v2.12.2 with zero issues.

* Fix connection recovery admission and approval identity

Problem: saved credentials could leave Desktop and CLI unable to submit, auxiliary authentication could bypass or poison the wrong connection, and install previews lost their approval contract.

Root cause: admission consulted stale runtime authentication, request errors lacked the actual model identity, project saves rewrote typed documents, and approval hashing reused a redacted display shape.

Fix: apply saved settings before CLI admission, expose runtime-bound Desktop pending state, gate actual requests without wrapping provider capabilities, preserve project document deltas, bind approvals to complete execution inputs with HMAC, restore sanitized CLI previews, and retain Windows repair error chains.

Verification: focused regression suites, root/shared and Desktop authentication race suites, frontend typecheck and Composer tests, browser draft/recovery checks, generated contract/inventory checks, repository lint, and Windows cross-platform lint pass. Full root and Desktop suites are also running; exact-head CI will provide native platform evidence.
2026-09-17 23:59:32 +08:00
github-actions[bot]andSivanCola c14c1067e7 docs(release): 准备 v1.38.10 中英更新日志 (#10463)
* docs(release): prepare v1.38.10 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): review v1.38.10 notes

Summary:
Correct the Desktop and CLI target mapping for Windows path handling and persistent PowerShell, add the supported first-request cache impact, and link the Windows Agent shell guide.

Verification:
Validate and render the release catalog, run the release-note tests, verify referenced documentation exists at the candidate source commit, scan the edited record for local paths and token patterns, and run git diff --check.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: SivanCola <[email protected]>
2026-09-17 23:51:06 +08:00
SivanCola e38c581a8c Bound generated release note output (#10462)
Problem:
Large release ranges can make the release editor exhaust its 8,000-token output budget and return truncated JSON. The existing retry repeats the same unconstrained request and fails again.

Root cause:
The generation prompt defines the record shape but does not cap editorial item counts or body lengths, and retry behavior does not adapt to a length finish reason.

Fix:
Add explicit standard and compact editorial budgets, detect length truncation, and retry once with the smaller complete-record budget.

Verification:
node --test scripts/release-notes.test.mjs
bash scripts/release-workflows.test.sh
git diff --check
2026-09-17 23:44:07 +08:00
SivanCola 30bd8ae1ba Fix Windows path URIs and legacy session upgrades / 修复 Windows 路径 URI 与旧会话升级 (#10459)
* Fix Windows path URIs and legacy session upgrades

Problem:
Windows upgrades and path-based features could misinterpret SQLite file URIs,
double-decode file references, lose UNC identities, or report incomplete
session diagnostics as healthy. The packaged upgrade acceptance also used a
current-format fixture and could accept sidebar text as restored history.

Root cause:
Disk database callers built URIs independently, path consumers decoded parsed
URLs twice, LSP conversion dropped remote authorities, and inspection ignored
query failures and live WAL state. The Windows harness did not exercise the
real legacy import boundary or validate the packaged commit field.

Fix:
Centralize SQLite disk URI construction, publish database backups without
replacement, make inspection fail closed, decode file paths once, preserve UNC
file identities, and harden the CI entrypoint. Replace the Windows fixture with
real legacy JSONL data and scope visible-history evidence to the transcript.

Verification:
- go test -p 1 ./... -timeout=20m (root and desktop modules)
- targeted Go race tests for backup, catalog, migration, and media paths
- frontend typecheck, transcript tests, and browser file-preview test
- Node and PowerShell CI contract tests
- Windows amd64 cross-compilation of Desktop tests and the upgrade fixture

* Fix LSP URI conversion on Windows CI

* Encode upgrade fixtures with JSON APIs
2026-09-17 23:21:18 +08:00
SivanCola 8dfe505ee4 Fix Windows installer version identity and acceptance / 修复 Windows 安装版本身份与验收 (#10451)
* Fix Windows installer release identity

Problem: Windows test versions with suffixes could install under a truncated numeric identity, and release validation could test intermediate artifacts or affect an everyday installation.

Root cause: NSIS reused the numeric Windows resource version for install/runtime identity, while installer acceptance lacked final-artifact ordering, complete environment isolation, and post-uninstall state checks.

Fix: preserve the canonical version tag across installation, add native x64/ARM64 final-installer acceptance gates, isolate all Reasonix test state, and verify uninstall cleanup while retaining user data.

Verification: Windows PowerShell 5.1 acceptance harness (34 cases); Node packaging/workflow tests (36 cases); focused Desktop Go packaging tests; release workflow contract suite; repolint; desktop inventory; YAML parse; git diff --check.

* Normalize Windows acceptance test paths

Problem: the Windows installer acceptance harness compared an 8.3 TEMP path with the equivalent expanded path and failed before exercising the installer scenarios.

Root cause: the mock environment assertion compared path strings literally even though the production environment helper canonicalizes DataHome with GetFullPath.

Fix: canonicalize the expected home before comparing isolated environment variables and cover a dot-segment input in the nested environment regression.

Verification: node scripts/ci-workflow.test.mjs; git diff --check; release workflow contract suite.

* Canonicalize Windows acceptance fixture roots

Problem: after the initial path assertion fix, the Windows harness still treated mock-created release directories as missing.

Root cause: production canonicalized the evidence directory while the mock containment boundary retained the equivalent 8.3 TEMP spelling.

Fix: canonicalize the fixture root once so all mocked filesystem and process boundaries share the same Windows path identity.

Verification: node scripts/ci-workflow.test.mjs; git diff --check.

* Preserve native Test-Path semantics in Windows mocks

Problem: the acceptance mock shadowed Test-Path without supporting its Path parameter, so archive commands could misbind Path as PathType.

Root cause: the filesystem boundary mock implemented only the call shape used directly by the test and not the standard command surface used by PowerShell archive modules.

Fix: accept both Path and LiteralPath, forward fixture paths to the native cmdlet with their original binding, and keep account paths virtual.

Verification: Windows 11 Windows PowerShell 5.1 mock acceptance harness passed all 34 cases; node scripts/ci-workflow.test.mjs; git diff --check.
2026-09-17 18:05:23 +08:00
SivanCola f6c11b1c0e Fix project tree disclosure caching / 修复项目树展开与缓存逻辑 (#10447)
* Fix project tree disclosure caching

Problem: project session disclosure used inconsistent 5/10 windows, exposed a competing collapse action, and either reloaded initialized lists or reused stale caches after collapsed catalog changes.

Root cause: visibility, cache validity, and request scheduling were coupled across folder effects and group initialization, so the same list could be fetched twice while collapsed lists missed invalidation.

Fix: use five-row disclosure increments, reset only view quotas on collapse, invalidate affected caches independently from visibility, and route initialization, search, sorting, and event refreshes through one guarded loader.

Verification: frontend typechecks, project-tree suites, production build, sidebar Chromium regression, and deterministic stale-request ordering tests pass.

* Extract project tree list runtime state

Problem: the disclosure cache repair pushed the already-large ProjectTree component beyond its repository file-size budget.

Root cause: request generations, page cache state, and disclosure window quotas were owned inline by the view component.

Fix: move that cohesive runtime state and its invalidation/reset operations into a dedicated hook without changing the list behavior.

Verification: repolint, frontend test typecheck, all project-tree suites, production build, and sidebar browser regression pass.

* Test project catalog reconciliation by completion
2026-09-17 17:23:39 +08:00
SivanCola 6c2a25866b Default upgraded session sorting to creation time / 升级后默认按创建时间排序 (#10444)
* Default upgraded session sorting to creation time

Problem: Users upgrading to the next desktop release must start with creation-time sorting, even when they previously selected updated-time sorting.

Root cause: The preference loader treated every value except an explicit created choice as updated, and had no versioned migration boundary for a deliberate default reset.

Fix: Add a one-time localStorage migration that writes created to the existing cross-version preference key, then preserve either manual choice after the marker is set. Cover fresh installs, legacy updated choices, restart persistence, and the real sidebar menu flow.

Verification: pnpm build; project-tree sort preference/runtime/window tests; pnpm test:sidebar-browser; go run ./tools/repolint; git diff --check.

* Test interrupted sort preference migration

Problem: The migration coverage proved successful restarts but did not exercise interruption between the preference write and migration marker write.

Fix: Simulate a marker-write interruption, verify creation-time sorting remains active, and prove the next launch retries and completes the migration.

Verification: focused preference test, frontend typecheck, ESLint, repository lint, and git diff --check passed.
2026-09-17 15:37:15 +08:00
SivanCola 981d4b64cd Reject Windows portable launches from remote storage / 拦截 Windows 便携版共享目录启动 (#10442)
* Reject Windows portable launches from remote storage

Problem:
Windows portable builds launched from UNC or mapped remote storage can start the Electron shell without bringing the service to readiness, leaving users with a generic 30-second timeout.

Root cause:
The launcher entered desktop instance coordination without validating whether the portable release was executing from storage that supports the service lifecycle.

Fix:
Classify the resolved Windows launch volume before coordination, reject portable UNC and remote-drive locations with actionable bilingual guidance, preserve installed and local launch paths, and write path-free structured diagnostics.

Verification:
- go test ./internal/desktopinstance ./internal/desktoplauncher
- go test -p 4 ./...
- Windows 11 ARM64 native launcher and instance tests
- Windows amd64 and arm64 test-binary compilation
- Parallels UNC launch fixture exits immediately with code 1 and no service process

* Fix Windows Go matrix cache teardown

Problem:
The Windows desktop Go matrix could pass every test and still fail during actions/setup-node cleanup when no pnpm store existed.

Root cause:
The Go-only matrix enabled pnpm caching even though it never installs JavaScript dependencies or creates the configured store path.

Fix:
Keep Node and pnpm available for artifact identity checks, disable the unused pnpm cache for all four Windows Go groups, and lock the invariant with a workflow contract test.

Verification:
- node --test scripts/desktop-windows-go-tests.test.mjs scripts/ci-workflow.test.mjs
- go run ./tools/repolint
- actionlint v1.7.7 .github/workflows/ci.yml

* Make takeover re-adoption test deterministic

Problem:
The desktop race job intermittently failed TestTakeoverMirrorReadoptsAfterServeMoves even though the re-adoption implementation remained correct.

Root cause:
The fixture closed an httptest server and assumed its ephemeral port would not be reused. Under the full race suite, the replacement server could receive that same port, so the stale client reached the new endpoint and skipped re-adoption.

Fix:
Use an HTTP transport that deterministically rejects stale-serve requests while retaining the real replacement server for handshake, adoption, generation swap, and frame delivery.

Verification:
- go test -race -run '^TestTakeoverMirror(ReadoptsAfterServeMoves|DemotionReturnsRefreshedGeneration)$' -count=50 .
- go test -run '^TestTakeoverMirror(ReadoptsAfterServeMoves|DemotionReturnsRefreshedGeneration)$' -count=100 .
- go run ./tools/repolint

The full local desktop race suite exceeded Go's default 10-minute package timeout on macOS; the focused failure mechanism passed and the Linux CI race leg will provide the full-suite result.
2026-09-17 15:24:08 +08:00
SivanCola 8e2e6ef0a2 Fix independent session identity and lifecycle / 修复独立会话身份与生命周期 (#10440)
* fix(desktop): make session identity and organization independent of topics

Problem: same-topic sessions shared presentation, selection and operations;
late runtime projections could revive archived rows and corrupt unread state.

Root cause: topic grouping crossed durable identity, runtime and organization
ownership, while sidecar ordering and unversioned projections lost intent.

Fix: use exact host/session or verified source selectors, registry v3
organization CAS and source adoption, lifecycle fences, snapshot-bound paging,
authoritative unread repair and recoverable fork publication. Preserve history
and explicit ungrouped choices; reject unsupported v3 writers. Extract the
legacy-row, source, fork and blank-model owners to keep complexity bounded.

Verification: focused session regressions, registry race/upgrade tests,
frontend typechecks/build, Chromium product-component interactions, repository
lint and Desktop golangci-lint passed. Full module/frontend suites and native
qualification are recorded separately in the PR. The same-toolchain raw
startup payload rises from 2440.7 to 2443.0 KiB (0.095%); only its bound moves
to 2443.1 KiB. Three existing bridge/type/activation file ratchets gain 6/1/3
lines; extraction lowers total production and test file-size ceilings.

* test(desktop): bind background notification fixtures to exact sessions

Problem: the attention browser gate expected a named toast from a topic-only
fixture after notifications switched to exact session matching.

Root cause: the fixture omitted the durable identity supplied by runtime
snapshots, so the safe generic conversation title was correctly selected.

Fix: publish matching SessionID/SessionRef values and put a same-topic sibling
first in the directory to prove the title does not fall back to that sibling.

Verification: the complete pnpm test:app-browser command passed, including
background Ask/approval audio, toast, focus, draft and navigation deduplication.
2026-09-17 14:17:29 +08:00
SivanCola 8934a59e07 Unify Windows desktop entry / 统一 Windows 桌面启动入口 (#10439)
* Unify Windows desktop entry / 统一 Windows 桌面入口

Problem:
Fresh Windows packages expose both Reasonix.exe and reasonix-launcher.exe, while installers, relaunch paths, and shortcuts reference both names.

Root cause:
The signed transport launcher name and the user-facing installed entry were represented by one shared name, so compatibility requirements leaked into the installation layout.

Fix:
Separate the canonical installed entry from the legacy payload name, preserve an existing legacy entry inside the activation transaction, migrate owned shortcuts, and enforce explicit canonical or legacy-dual artifact contracts.

Verification:
- go test -p 4 ./...
- desktop Go tests in repository groups A-D, E-H, I-P, and Q-Z
- Electron typecheck and tests
- 64 packaging and workflow regression tests
- focused race tests and Windows amd64/arm64 cross-compilation
- canonical amd64/arm64 package builds and legacy-dual v1.38.9 ZIP verification

* Refactor activation request validation

Problem:
The Windows root-entry policy pushed activateVersion beyond the repository's function-size and cyclomatic-complexity budgets, causing the lint-code job to fail.

Root cause:
Request validation, lock coordination, Windows entry selection, and publication were all expressed in the transaction coordinator.

Fix:
Extract request validation and lock-protected Windows root-entry resolution into focused helpers while preserving the existing validation order, lock boundary, and transaction behavior.

Verification:
- go run ./tools/repolint
- go test -race ./internal/installlayout ./internal/appidentity ./internal/desktoplauncher ./cmd/reasonix-legacy-migrator
- git diff --check
2026-09-17 13:57:43 +08:00
SivanCola 990bc4aa0b Fix user-message history pagination and display handoffs (#10438)
Problem: User messages could survive history-page reclamation, duplicate on reload, or remount their conversation group during confirmation.

Root cause: Optimistic submission identity was mixed with canonical history identity, and window filtering discarded confirmations needed to settle local echoes.

Fix: Separate local submission state, preserve canonical message IDs, settle confirmations atomically with window projections, and use bounded presentation handoffs. Fence late bindings and callbacks, align local and remote lifecycle outcomes, and preserve explicit placement and scroll ownership.

Verification: Frontend CI completed 442/442 commands. Deterministic follower/Store/reducer and DOM regressions cover message ordering and offscreen confirmation. Chromium and Linux/Xvfb Electron completed 1,000-turn history scenarios with 20 paging round trips, continuous nodes, and bounded history. Native X11 wheel, keyboard, and scrollbar input passed. Three-process full app-memory screening passed. Production bundle remains within the unchanged budget.
2026-09-17 13:53:18 +08:00
SivanCola 81fe2f1fde Merge pull request #10437 from SivanCola/fix/trash-purge-restore-serialization
Serialize trash restore and permanent deletion / 串行化回收站恢复与永久删除
2026-09-17 13:12:28 +08:00
SivanCola 2c9d8a75ef ci: exercise purge crash recovery on Windows pull requests
Problem: the Windows PR smoke selector omits internal/session, leaving filesystem crash and receipt-before-rename recovery untested on Windows.

Root cause: Desktop subprocess tests cover registry boundaries but do not execute the root package's private filesystem interruption hooks.

Fix: run the focused root purge safety and crash-recovery tests as a Windows PR step, without widening the full smoke selector or changing existing timeouts.

Verification: the exact focused command passes locally, generated inventory remains current, repolint and diff checks pass; native execution is delegated to the added Windows CI step.
2026-09-17 12:19:29 +08:00
SivanCola 4f19a9b98d test: align purge regression fixtures with repository lint
Problem: the CI-pinned Go linter rejected three counter loops in new purge tests, and confirmation replacement needed an explicit regression.

Root cause: the fixtures used legacy counter syntax and cancellation coverage did not exercise replacing an already-open dialog.

Fix: use integer ranges without changing iteration counts and assert that replacing confirmation submits only the replacement snapshot.

Verification: golangci-lint 2.12.2 reports zero issues; purge command tests, confirmation interaction tests, root full suite, and all Desktop test partitions pass.
2026-09-17 12:13:47 +08:00
SivanCola ec5fc2e779 fix(desktop): finalize purge conflicts after runtime lock waits
Problem: a restore while a purge waits for the runtime lock was reported as retryable. New crash hooks also exceeded the App file-size guard and generated inventory locations were stale.

Root cause: the absent-operation branch checked lifecycle before the original target generation; test hooks grew the central App declaration, while inventory snapshots retained previous source locations.

Fix: classify newer target generations as terminal conflicts, group lifecycle test hooks in an instance-local embedded type, and regenerate the Desktop migration inventory without changing budgets or classification rules.

Verification: deterministic restore-before-lock and purge command regressions, desktopinventory tests, repolint, and git diff --check pass.
2026-09-17 12:09:15 +08:00
SivanCola 4128038c30 fix: preserve purge intent through confirmation and recovery
Problem: refreshed trash state could change a pending confirmation, valid legacy
preparations could not resume from the UI, and unrelated changes blocked purges.
Root cause: request construction happened after confirmation, normal deletion
could not adopt an observed transaction, and command admission required global
version equality. Wrapped already-exists errors also blocked staging recovery.
Fix: freeze complete requests, preserve unknown-result retries, validate observed
purge identities under the registry lock, and admit purge snapshots per target.
Recognize wrapped staging-receipt errors without weakening directory validation.
Verification: focused Go/race and frontend interaction tests, Chromium, typechecks,
and the existing bundle budgets pass. Add real subprocess crash checkpoints,
frozen previous-v2 reader/writer coverage, and a runtime-lock benchmark. Broad
local and cross-platform CI results are tracked in the PR.
2026-09-17 12:02:13 +08:00
SivanCola 4c8cec3b9f perf: keep trash conflict handling within startup budget
Problem:
The clean production build exceeded the initial raw JavaScript and CSS budget by 0.1 KiB after adding terminal conflict handling.

Root cause:
The new state used undefined sentinels and duplicated reset work, while the conflict locale key and copy were longer than the interaction required.

Fix:
Use compact null and empty-string sentinels, avoid a redundant failed-row reset, simplify selected-row lookup, and retain the same refresh-required guidance with shorter locale data.

Verification:
- clean worktree pnpm build
- focused trash interaction test
- frontend source and test typechecks
2026-09-17 11:04:35 +08:00
SivanCola 815e03095b chore: keep project-tree copy out of trash fix
Problem:
Three project-tree locale entries from parallel working-tree changes were staged with the trash lifecycle update because they shared the same locale files.

Root cause:
File-level staging captured unrelated hunks that were present before the trash changes.

Fix:
Remove the project-tree-only strings from this branch's final diff while preserving the trash conflict guidance in all supported locales.

Verification:
- git diff --cached --check
- clean worktree production build rerun after this commit
2026-09-17 10:57:54 +08:00
SivanCola 6fffc31a55 fix: serialize trash restore and permanent deletion
Problem:
Concurrent restore and permanent-delete requests could leave a restored session blocked by a stale prepared purge, or let an old retry regain permission to delete content after the session changed.

Root cause:
The registry persisted purge preparation separately from the deletion tombstone, and retry paths treated any existing purge record as authority to bypass generation checks. Command replay and the trash UI also mixed retryable failures with terminal state conflicts.

Fix:
Atomically validate the archived session generation and publish deleted+tombstoned under the registry write lock. Classify legacy purge records, cancel stale prepared records during lifecycle commits, bind recovery to the observed operation identity, preserve resumable file cleanup, and make command and UI retries retain only retryable items. Update trash controls and bilingual lifecycle documentation for the commit-point semantics.

Verification:
- go test -race ./internal/workspacestate
- go test -run 'Test(Lifecycle|Purge|Recover|WorkspaceLifecycle)' ./...
- go test -race ./internal/session
- pnpm test:typecheck
- pnpm typecheck
- node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/trash-management.test.tsx
- git diff --cached --check
2026-09-17 10:52:40 +08:00
SivanCola c837e24464 docs: align transcript navigation contracts with Follow v2
Replace superseded full-outline and cumulative-history promises with the current bounded history window and loaded-turn navigation contract. Mark the earlier outline acceptance record as historical and align English and Chinese documentation, including protocol compatibility and history reachability.

Verification: git diff --check and validation of 27 relative documentation links and anchors.
2026-09-17 09:58:36 +08:00
SivanCola 1f598bc9d6 Add expandable sessions and composer workspace context / 添加可展开会话与工作区上下文 (#10426)
* Add expandable project session lists

Problem:
The desktop sidebar hid older conversations behind a time filter and only paged project-wide results, so custom groups could not independently reveal their history.

Design:
Replace the retired time and organization controls with per-project and per-group 5/10 session windows, isolated search pagination, current-session fallback rows, and bounded request coordination. Add group-aware catalog filters and cursor bindings so pagination happens before rendering and remains stable across metadata, index, and live-session paths.

Compatibility:
Keep the existing unfiltered ListProjectTopics behavior when the new optional fields are omitted. Remove only retired UI preferences at startup and leave persisted sessions, groups, sorting, collapse state, and history time filtering unchanged.

Verification:
- Frontend test suite and production build
- Sidebar browser interaction suite
- Desktop Go test suite
- Root go test ./...
- Contract generation and inventory validation
- git diff --check

* Fix grouped session refresh consistency

Problem:
- A complete session catalog could still resurrect an absent topic from legacy metadata.
- Archiving or deleting a topic in a custom group refreshed only the ungrouped list, leaving visible group windows underfilled.

Root cause:
- GetTopicSummary did not distinguish an incomplete catalog continuity fallback from an authoritative complete catalog miss.
- Project refreshes reloaded only the default project topic list even when custom group page states were active.

Fix:
- Bound metadata summary fallback to unavailable or incomplete catalog states.
- Reload the ungrouped list and every known custom group list while keeping search refresh isolated.
- Add regressions for catalog completeness and grouped refresh coverage.

Verification:
- cd desktop && go test ./... -timeout 20m
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm build
- cd desktop/frontend && pnpm test:sidebar-browser
- go test ./...
- go run ./tools/repolint
- go run ./tools/desktopinventory -check
- git diff --check

* Add composer workspace context controls

Problem:
New sessions could only inherit their workspace implicitly, while project and
branch selection lived elsewhere in the desktop. Adding a separate context bar
also left the creation-state accent attached to the lower composer, producing a
broken U-shaped outline instead of one continuous control.

Root cause:
The creation composer owned both the workspace-independent frame styling and
the animated border. Project and group list refresh identities also omitted two
visibility inputs, which allowed stale pinned or collapsed-group state to reuse
an incompatible request.

Fix:
Add a lazy new-session workspace context bar with project search, local and
remote entry points, branch switch/create flows, and Git history. Mount it only
for empty sessions, wrap it and the composer in one shared frame, and move all
focus and running accents to that frame. Include pinned exclusion in list request
identity and avoid fetching collapsed custom groups.

Verification:
- composer workspace interaction and focus regression
- composer shared-frame CSS regression
- project tree group-window regression
- frontend hooks lint, typecheck, CSS and layer checks
- frontend production build and bundle budgets
- sidebar browser, workspace, and app lifecycle suites

* Extract composer guidance receipt tracking

Problem:
The workspace context controls added enough lines to Composer.tsx to exceed its
repository file-size budget, causing the lint-code CI job to fail.

Root cause:
Composer.tsx still owned the standalone guidance receipt bookkeeping state
machine even though it has no rendering responsibility.

Fix:
Move the tracker and its type into a focused lib module and keep Composer as the
consumer. This reduces the component below its existing budget without changing
the baseline or runtime behavior.

Verification:
- go run ./tools/repolint
- pnpm test:composer
- composer workspace interaction regression
- pnpm build
- git diff --check

* Teach browser fixtures to expand session windows

Problem:
Browser and memory fixtures waited directly for sessions that may now live beyond the default five-row project window, causing deterministic 30-second navigation timeouts.

Root cause:
The shared revealSession helper still assumed every project session was rendered eagerly and never exercised the project's Show more control.

Fix:
Expand only the requested project's session window until the target appears or no further page is available, while retaining the existing visible-target assertion as the terminal failure.

Verification:
- pnpm test:app-browser
- pnpm test:sidebar-browser
- go run ./tools/repolint
- git diff --check

* Refresh the Desktop migration inventory

Problem:
The root CI inventory tests rejected the PR because the generated Desktop inventory did not include the native drag-region marker added to the new composer workspace stylesheet.

Root cause:
The inventory was generated before the final workspace-frame CSS marker was present, leaving both generated inventory artifacts one entry behind the source tree.

Fix:
Regenerate the Markdown and JSON inventories so ComposerWorkspaceContextBar.css owns the --reasonix-draggable shell marker and the totals remain consistent.

Verification:
- go test ./tools/desktopinventory -count=1
- go run ./tools/desktopinventory -check
- go run ./tools/repolint
- git diff --check

* Align recovery banner tests with the composer frame

Problem:
The Desktop frontend suite still asserted that the recovery banner was immediately followed by the composer card, so it rejected the new shared workspace frame even though the banner remained in the correct visual position.

Root cause:
The regression encoded the old card-owned outline DOM instead of the composer-surface contract. The workspace context change intentionally makes composer-workspace-frame the outer owner for both plain and context layouts.

Fix:
Assert that the banner is immediately above the shared frame and that the frame directly contains the composer card.

Verification:
- pnpm exec tsx src/__tests__/recovery-wait-banner.test.tsx
- go run ./tools/repolint
- git diff --check
2026-09-17 06:23:29 +08:00
SivanCola 7728dfb9bd Preserve compacted context across restart and migration / 重启与迁移后保留压缩上下文 (#10430)
* fix: persist context maintenance projections

Problem:
Compacted model context was lost after switching or reopening a session even though the canonical transcript remained available.

Root cause:
Summary, prune, and truncate updated the Agent projection without one generation-checked durability boundary owned by the Controller.

Fix:
Record the exact model-visible projection through model/context-replace with a stable operation ID, keep canonical history unchanged, and make completion events notification-only.

Verification:
Added manual and automatic summary, prune, and truncate persistence regressions and passed the owning Go suites, race checks, and strict cache guard.

* fix: preserve legacy compacted context on migration

Problem:
Migrating a legacy session restored the full transcript but discarded a valid compacted model projection.

Root cause:
The migration pipeline imported canonical history without validating and replaying the frozen compaction sidecar before marking migration complete.

Fix:
Validate legacy projection coverage, hashes, pinned context, and lineage; persist canonical history first and then append model/context-replace. Invalid projections fall back to full history with a diagnostic.

Verification:
Added valid, missing, corrupt, mismatched, interrupted, and already-continued migration regressions and passed session migration and restart suites.

* fix: share validated image reference resolution

Problem:
Image prompts could report that visual input was attached when parsing failed, and reference paths could upload the same image more than once.

Root cause:
User messaging and provider request construction resolved image references independently.

Fix:
Resolve and validate references once, share the frozen result with messaging and model input construction, and return explicit errors for empty or invalid images.

Verification:
Added invalid-image, edited-reference, inbox-reference, and Files API upload-count regressions and passed the control and Desktop session suites.

* fix: block model dispatch on pending projection durability

Problem:
A model/context-replace event accepted by the session log could fail its durability wait while the next model request still proceeded.

Root cause:
The Agent retained only a pending status string, not the exact accepted operation needed for idempotent recovery.

Fix:
Retain the frozen operation ID and projection payload, retry it before request preparation and dispatch, and block provider calls until the durable receipt covers the commit.

Verification:
Added a deterministic accepted-then-failed retry regression proving zero provider calls until the identical commit becomes durable; passed full root, Desktop, race, and strict cache suites.

* refactor: isolate reference and migration persistence helpers

Problem:
The compaction persistence change pushed refs.go past the repository file-size guard and left frozenLegacyHead.publish above the function-size and complexity budgets.

Root cause:
Image input resolution, turn reference resolution, and legacy migration event appends were still owned by broad orchestration functions, so the new persistence paths increased already dense files instead of living behind focused helpers.

Fix:
Move image and turn reference resolution into dedicated control files, and split legacy message and metadata appends out of frozenLegacyHead.publish. Keep behavior and operation IDs unchanged while restoring the repository lint boundaries.

Verification:
- go run ./tools/repolint
- go test ./internal/control ./internal/session -count=1
- git diff --check

* fix: remove unused image-ref controller wrappers

Problem:
The root lint job failed after the persistence changes because two Controller image-reference forwarding methods had no callers.

Root cause:
Turn image dispatch was consolidated on turnOrchestrator and frozen image inputs, but the superseded Controller-level image-ref wrappers remained in turn_images.go.

Fix:
Remove the two unused forwarding methods while retaining the active orchestrator and frozen-image entry points.

Verification:
- golangci-lint run --timeout=5m
- go run ./tools/repolint
- go test ./internal/control -count=1
- git diff --check
2026-09-17 06:22:31 +08:00
SivanCola a3b9d56384 Fix unread baseline during catalog migration (#10428)
Problem:
Canonical sessions could seed a zero read baseline while v5 catalog metadata was still rebuilding, then show every historical result as unread when the real result sequence arrived.

Root cause:
The frontend initialized session read state before checking whether the canonical metadata projection was ready, and early test builds could persist the placeholder zero baseline.

Fix:
Defer canonical session baselines until metadata is ready and migrate read-state storage once to remove only placeholder session zero entries while preserving durable session and legacy topic progress.

Verification:
- pnpm exec tsx src/__tests__/project-tree-runtime.test.ts
- pnpm build
- go run ./tools/repolint
- go run ./tools/desktopinventory -check
- git diff --check
2026-09-17 02:55:32 +08:00
SivanCola ec45635b5e Fix unread state for canonical sessions (#10424)
Problem:
Session attention dots were driven by file activity timestamps and topic identity, so metadata-only writes could re-arm dots and session switches could leave the previous row selected.

Root cause:
The catalog exposed only the latest event sequence, while the desktop read state lacked a stable canonical session key and the active tab did not adopt the target session presentation during rebinding.

Fix:
Track the latest visible terminal assistant result sequence, persist read state by host and session ID, mark sessions read only after a successful open, suppress duplicate plan-state writes, and synchronize the target topic identity when rebinding a canonical session.

Verification:
- go test ./internal/session -count=1
- go test ./internal/control -count=1
- cd desktop && go test ./... -count=1
- frontend project tree regressions, typecheck, and production build
- go run ./tools/repolint
- go test ./tools/desktopinventory -count=1
2026-09-17 02:22:53 +08:00
SivanCola 31c896e25f Fix canonical history reuse across reopened tabs / 修复重开会话的历史缓存复用 (#10423)
* fix(desktop): align tab metadata with canonical history

Problem:
Resident transcript validation compared canonical history pages with legacy branch-sidecar revisions, causing valid warm entries to miss.

Root cause:
TabMeta sourced sessionRevision and sessionDigest from the legacy JSONL projection even when the tab had a canonical SessionRef.

Fix:
Read the canonical session snapshot sequence and storage generation for canonical tabs, retain legacy metadata only for path-only sessions, and leave unknown canonical identity unfingerprinted.

Verification:
Added Desktop tests for production-shaped mismatched legacy metadata and the safe unknown-identity fallback.

* fix(desktop): rebind resident transcripts across tab ids

Problem:
Single-surface navigation prunes the old tab and assigns a new tab ID when reopening the same session, so tab-keyed resident history could not be reused.

Root cause:
Transcript ownership combined durable session identity with ephemeral UI tab identity, and tab eviction deleted the only resident entry.

Fix:
Key canonical residents by the stable SessionRef plus generation, keep tab bindings separate, atomically rebind ownership, and fence late follower and lazy-content completions with the session generation.

Verification:
Added deterministic A-to-B-to-A rebind, fingerprint mismatch, different-session, missing-fingerprint, stale follower, lazy-content, and resident-count coverage.

* test(desktop): fence legacy topic migration before archive

Problem:
The Windows Q-Z shard intermittently failed TestRestoreGlobalTopicSessionReindexesProjectTree with a sharing violation while archiving restore-global.jsonl.meta.

Root cause:
The test treated catalog publication as completion of legacy branch-meta migration, but the catalog row can become visible before the asynchronous sidecar writer releases its Windows file handle.

Fix:
Wait for the migrated branch metadata and expected topic identity before invoking the production archive path.

Verification:
The focused restore-global test passed 20 consecutive runs, the Windows partition contract passed, git diff checks passed, and repolint remained clean.

* test(desktop): decouple model ordering from Windows speed

Problem:
The Windows E-H shard failed TestEnsureBlankTabConcurrentModelSwitchKeepsLastSelection while waiting five seconds for a full default-model rebuild to reach its deterministic handoff hook.

Root cause:
The test validates last-click ordering, not model-switch latency, but its synchronization deadline was shorter than a valid controller rebuild on a loaded Windows runner and it did not surface an early EnsureBlankTab result.

Fix:
Use the desktop suite's 30-second lifecycle bound for both synchronization points and report an unexpected early EnsureBlankTab completion directly.

Verification:
The focused concurrency test passed 10 consecutive runs, the Windows partition contract passed, git diff checks passed, and repolint remained clean.
2026-09-17 01:46:04 +08:00
SivanCola 41d2ae37fe Decouple durable local session operations from runtime tabs / 本地会话持久化操作与运行时解耦 (#10409)
* Resolve durable sessions for sidebar AI rename

Problem: AI rename rejected saved sessions without an open target tab.

Root cause: title generation resolved the conversation through a live
controller rather than the persisted session identity.

Fix: add durable target resolution, use target history and model selection,
serialize title mutations, deduplicate per-session requests, and localize
operation codes with independent sidebar progress state.

Verification: root and Desktop Go suites, title regressions, frontend
typechecks and production build passed locally during implementation.

This is a draft implementation. Review must still address controller-free
provider resolution, ambiguous topic routing, and lifecycle fencing.

* Remove provider and lifecycle coupling from durable AI rename

Problem: The first implementation still required an unrelated open controller,
could bind an explicit session to a sibling topic controller, and accepted a
title after the target was archived.

Root cause: Provider ownership, display-group identity, and durable lifecycle
were conflated with the optional conversation runtime.

Fix: Resolve cold-session model settings in the target workspace, prefer exact
session identity in sidebar requests and runtime matching, reject ambiguous
topic-only requests, and guard title commits with the registry lifecycle file
lock and captured generation. Keep provider calls outside mutation locks and
sanitize unclassified frontend errors.

Verification: Focused Desktop title/target tests and race checks, shared title
tests, frontend typechecks, title target/error tests and production build pass.
Full Go suites are running. Remote/offline operations and the broader operation
contract migration remain outside this draft's completed scope.

* Cover cold session title ownership and lifecycle interleavings

Problem: Existing title tests did not cover zero open controllers, sibling
topic identities, archive-before-completion, or real sidebar interactions.

Root cause: Fixtures supplied a provider controller and frontend assertions
mostly inspected source text instead of delayed host results.

Fix: Add cold-provider HTTP fixtures, exact identity and archive interleaving
regressions, a registry file-lock and archive/restore generation guard test,
target/error unit checks, and a Chromium sidebar gate with delayed responses.

Verification: Focused Go race tests and frontend typechecks pass. The browser
gate verifies independent per-target loading, out-of-order completion, error
redaction, and stable transcript/composer nodes and input draft.

* Extract sidebar title operation ownership within frontend budgets

Problem: The PR lint job rejected growth in ProjectTree and bridge beyond
their existing file-size budgets.

Root cause: Target-specific async state and mock target lookup were added
inline to already oversized orchestration modules.

Fix: Move title-operation state, completion/error handling and mock target
lookup into focused owning modules. Preserve the existing size ratchets.

Verification: The exact CI command go run ./tools/repolint passes unchanged.
Frontend typecheck, optimistic rename tests and the Chromium delayed-response
sidebar gate pass after extraction. Production build is running.

* Make session title updates version-safe

Problem: delayed AI title generation could overwrite manual changes after an A-to-B-to-A rename, and legacy metadata writers could roll title state back.\n\nRoot cause: canonical title CAS compared text, while legacy titles had no independent cross-process mutation identity.\n\nFix: track canonical title-event sequence, add an opaque legacy title revision, preserve title ownership across transcript/listing writes, and add ABA and same-value regression coverage.\n\nVerification: go test ./internal/agent ./internal/session

* Add target-scoped auxiliary provider access

Problem: cold-session AI rename could not resolve extension-owned models without borrowing an open conversation runtime.\n\nRoot cause: provider assembly was coupled to controller boot, while the cold path only created a config-backed resolver.\n\nFix: add a provider-only handle, start only the owning extension package and declared dependencies, bind sidecars to the target session/workspace, and release resources idempotently.\n\nVerification: go test ./internal/boot ./internal/extension/sidecar

* Route durable session operations by explicit target

Problem: sidebar operations, cold history, and fork paths could depend on the active controller or fail when the target session was closed.\n\nRoot cause: durable identity resolution, runtime binding, provider access, and UI error transport were mixed across per-feature handlers.\n\nFix: add SessionSelector resolution, structured mutation/error results, target-bound AI and manual rename, cold canonical history/search/fork, bounded legacy target history/search, lifecycle cancellation/events, and cold fork creation without EnsureExecution.\n\nVerification: desktop focused session tests; go test ./internal/historycatalog; generated Desktop contract freshness

* Protect sidebar session operations by target

Problem
Sidebar session actions still mixed target identity with shared loading and editing state, so late responses or active-session changes could update the wrong row or expose backend details.

Root cause
The renderer used one operation path shaped around topic strings and did not preserve per-target request ownership through the generated target APIs.

Fix
Route manual and AI title actions through SessionSelector, track in-flight requests per target and operation, add target-scoped menu/edit state, localize structured session errors, and document the local compatibility contract in English and Chinese.

Verification
pnpm typecheck
pnpm lint:hooks
pnpm build
pnpm test:transcript
pnpm exec tsx src/__tests__/session-title-operation.test.ts
pnpm exec tsx src/__tests__/project-tree-rename-optimistic.test.ts
PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/session-title-rename.mjs

* Complete explicit local session operations

Problem
Sidebar and background session operations still had target, lifecycle, provider, and history paths that could depend on an open runtime or accept stale results.

Root cause
Durable mutations and reads were split across legacy path helpers, canonical workspace state, active-tab adapters, and controller-owned provider access without one explicit target and lifecycle contract.

Fix
Harden selector resolution and lifecycle CAS, add target-scoped history/content/location APIs, full-history copy and idempotent fork publication, adopt legacy move/copy through existing journals, invalidate auxiliary providers by generation, and route frontend archive state and errors by target.

Verification
- go test ./...
- desktop focused target/title/lifecycle/history/fork/copy tests
- desktop contract and command-owner freshness tests
- frontend typecheck, build, transcript tests, target operation tests, and browser rename scenario
- focused race tests for title metadata, canonical title CAS, fork, and copy

* Update repository lint baseline

Problem
The explicit session-operation implementation intentionally expanded existing Desktop owner files and one App lifecycle counter beyond the repository's frozen lint budgets.

Root cause
The repository standards baseline still described the pre-implementation file and state counts, so exact-head CI rejected the otherwise intentional carry-forward.

Fix
Regenerate the repolint baseline for the measured final diff without disabling any lint rule.

Verification
- go run ./tools/repolint -update
- go run ./tools/repolint

* Fix exact-head session operation gates

Problem\nExact-head CI exposed frontend type inference and Go lint failures, while the compatibility purge entry point could no longer resume a tombstoned purge after content removal. The selector documentation also made the topic compatibility fallback look like a replacement for session identity.\n\nRoot cause\nThe frontend test inferred an empty array as never[], new code had not been normalized to current Go lint rules, and PurgeCanonicalSession was routed through fresh target resolution even though recovery intentionally removes the durable body before finalizing the journal. Session IDs were present inside the host-qualified SessionRef but not stated explicitly.\n\nFix\nMake the test type-safe, modernize and clean the Go call sites, restore journal-first purge recovery for the compatibility API, clarify that SessionRef carries hostId plus sessionId and topicId is only a lowest-priority compatibility lookup, and refresh the Desktop inventory.\n\nVerification\n- go test . -timeout=20m (desktop, 745.949s)\n- golangci-lint run --path-mode=abs --timeout=5m (desktop)\n- pnpm typecheck\n- pnpm exec tsx src/__tests__/project-tree-session-archive.test.ts\n- go test ./tools/desktopinventory\n- go run ./tools/repolint

* Make explicit delete recovery idempotent

Problem\nA self-review found that an explicit DeleteSessionTarget retry could return target_not_found after a purge had durably tombstoned the registry and removed the session body. Its mutation result also reported the pre-delete lifecycle generation.\n\nRoot cause\nThe explicit API resolved the durable body before consulting the existing purge journal, even though the journal intentionally survives content removal and is the authority for recovery.\n\nFix\nResolve deleted targets from the purge journal when the body is gone, allow the explicit API to resume or acknowledge the idempotent purge, and reload the committed lifecycle generation before emitting the result/event. Extend the interrupted-purge regression through DeleteSessionTarget.\n\nVerification\n- go test . -run 'Test(PurgeInterruptedTombstoneRemainsActionableAndResumes|ExplicitTargetCanonicalLifecycleDoesNotNavigate)' -count=1 -timeout=5m\n- golangci-lint run --path-mode=abs --timeout=5m\n- go run ./tools/repolint

* Reconcile lint baseline with current main

Problem\nThe PR branch baseline was generated before main-v2 added early-readable transcript ownership. GitHub evaluates the PR merge ref, so the combined tree exceeded the stale useController and repository file-size budgets even though both branches were clean independently.\n\nFix\nMerge the current main-v2 head and regenerate the measured repository lint baseline for the combined tree without disabling a rule.\n\nVerification\n- go run ./tools/repolint -update\n- go run ./tools/repolint\n- pnpm typecheck\n- focused session title/archive frontend tests\n- focused Desktop session target/title/lifecycle/history/fork/copy tests\n- Desktop golangci-lint
2026-09-17 01:07:08 +08:00
SivanCola db98cad268 Show local history before runtime activation / 本地会话历史先于运行时显示 (#10414)
* Show local session history before runtime activation

Problem: local navigation keeps history and draft input blocked behind runtime setup.

Root cause: transcript presentation and cache lifetime follow controller activation and single-surface teardown.

Fix: prime bounded canonical history independently, preserve resident windows on navigation, keep runtime write fences, and report navigation milestones. Fence lazy body resolution across generation handoffs.

Verification: frontend typecheck, build, lifecycle, transcript, remote and browser suites passed. Desktop Go tests and race tests passed. Browser mock first-paint P95 was 86.6ms over 20 warm switches; native packaged latency is not measured.

* Fence detached history and lazy content ownership

Problem: retained history could keep stale live pins or resolve content into a reopened owner; local presentation changes also hid remote transition semantics.

Root cause: renderer detach and explicit close shared cleanup, content retries re-resolved across owner replacement, and the presentation gate did not distinguish local and remote sessions.

Fix: release detached renderer pins, retain one same-owner generation handoff, wait for pending page replacement, preserve remote gates and live topic reselects, and keep failed runtime readiness separate from history readiness. Remove synthetic matching mock fingerprints.

Verification: transcript suite, tab navigation and topic activation regressions, frontend typecheck, lifecycle, remote, production build, and repository lint pass. The earlier browser-mock latency measurement does not establish native cache-hit latency; canonical fingerprint and tab rebinding gaps remain under review.

* Cover content ownership and early history navigation interleavings

Problem: the first implementation did not force close/reopen and pending-page body-read orderings, and its benchmark wording overstated native evidence.

Root cause: successful generation rollover coverage did not distinguish replacement within one resident owner from a newly created owner.

Fix: force Follow replacement, close/reopen, page replacement, and repeated churn with deferred gates. Cover detached pins, live reselect, early readable history, and runtime failure before history arrives. Label the latency gate as browser-mock evidence.

Verification: transcript suite, tab-switch hydration (100 assertions), topic activation (42 assertions), frontend and test typechecks, lifecycle, remote, build, and repository lint pass. Browser integration runs separately; no native latency claim is made.

* Document local history readiness and verification boundaries

Problem: the user-visible navigation change had no matching ownership documentation, and the PR omitted the required documentation-impact declaration.

Root cause: history readability and runtime readiness were previously described as one navigation boundary.

Fix: update both language versions of the App session ownership guide with the independent readiness rules, detached cache lifetime, content generation ownership, and native verification limitations.

Verification: documentation-impact checker and git diff --check pass; no runtime changes.

* Invalidate transcript owners when a same-path binding changes

Problem: the exact-head CI rebind regression showed history from a prior session generation during navigation.

Root cause: retained transcript entries were keyed by tab and path, but early cache lookup did not fence the independent session binding generation. The rebind fixture also blocked only the first of the now-independent baseline and Follow reads.

Fix: bind resident owners to the shared session identity key before navigation reads or Follow installation. Replace old owners and stop their followers when the binding changes; late body reads cannot transfer into the replacement. Keep both readers behind the fixture's generation gate without weakening the source-restoration assertions.

Verification: the reproduced rebind test passes all 13 assertions, topic activation passes 42 assertions, transcript tests and explicit body-ownership interleavings pass, and the production build/typecheck and repository lint pass. The complete frontend CI command plan and browser integration are running separately.

* Stabilize transcript and memory CI evidence

Problem:\n- transcript bounded retries shared a browser process with the retained 1000-turn primary page, so retries measured scheduler contention rather than isolated repeats\n- memory screening treated a settled decrease in DOM or listener population as retention drift\n\nRoot cause:\n- browser contexts isolate storage, not renderer scheduling or process resources\n- the retention classifier used any final displacement instead of directional population growth\n\nFix:\n- run bounded transcript retries in a dedicated browser process while preserving per-attempt contexts and traces\n- classify settled counter decreases as released capacity while retaining growth and unsettled-tail blockers\n- add deterministic retry-host and memory-classifier regressions\n\nVerification:\n- pnpm test:ci-contract\n- node --test bench/app-memory-evidence.test.mjs bench/app-memory-shards.test.mjs bench/app-memory-paths.test.mjs bench/app-memory-timing.test.mjs\n- exact headless transcript reader browser benchmark\n- go run ./tools/repolint
2026-09-16 23:25:10 +08:00
SivanCola ce27854546 Repair official provider endpoint protocol conflicts / 自动修复官方供应商协议与端点冲突 (#10411)
* Fix official provider endpoint protocol conflicts

Problem:
Official provider connections could persist a protocol that contradicted an
exact catalog request URL, causing runtime validation to fail before the first
request and leaving users to repair several coupled fields manually.

Root cause:
Protocol selection, endpoint overrides, startup migration, desktop saves, and
runtime construction did not share an authoritative normalization boundary.
The validator could identify conflicts but intentionally could not mutate user
configuration.

Fix:
Add a conservative catalog-backed repair contract that changes protocol only
for a unique exact official route. Persist repairs narrowly in the global TOML
while preserving comments and unknown fields, repair project configuration only
in memory, normalize desktop saves and runtime construction, and emit a
localized one-time notice. Keep custom gateways and ambiguous URLs under the
existing validation path.

Verification:
- go test ./...
- cd desktop && go test -timeout 20m ./...
- cd desktop/frontend && pnpm test
- go run ./scripts/generate-provider-catalog.go
- go run ./tools/desktopinventory
- git diff --check

* Keep provider repair within lint budgets

Problem:
The first PR head exceeded existing file-size, function-size, and complexity
ratchets in oversized startup and Desktop owner files.

Root cause:
The endpoint-repair orchestration was added inline at each startup exit and
included explanatory lines in files whose baselines allow no growth.

Fix:
Extract the locked startup repair sequence into focused helpers, consume repair
receipts only at the notice emission boundary, and keep the Desktop save call
line-neutral. Regenerate the Desktop inventory after restoring command offsets.

Verification:
- go run ./tools/repolint
- go test ./internal/config ./internal/boot ./tools/desktopinventory ./tools/repolint
- cd desktop && go test . -run 'Test(SaveProviderRepairsExactCatalogProtocolMismatch|SaveProviderRejectsProtocolEndpointMismatch|ProtocolSwitchKeepsStableProviderAndModelReferences)' -count=1
- git diff --check

* Use the repository backward iterator in endpoint rewrites

Problem:
The PR head failed the pinned golangci-lint modernize check because the lexical
provider rewrite used a manual reverse index loop.

Root cause:
The new rewrite path did not use the repository's Go 1.26 slices.Backward
convention for reverse iteration.

Fix:
Replace the manual loop with slices.Backward while retaining the original index
order and edit semantics.

Verification:
- go test ./internal/config
- go run ./tools/repolint
- golangci-lint run --timeout=5m ./...
- git diff --check

* Refresh Desktop inventory after latest-base merge

Problem:
The latest main-v2 updater merge moved Desktop command locations, so the PR
merge tree failed the generated Desktop inventory freshness test on Linux.

Root cause:
The feature branch was created before the updater change landed and its stored
inventory still described the earlier line locations.

Fix:
Merge the latest main-v2 without rewriting the published branch and regenerate
both Desktop inventory artifacts from the combined tree.

Verification:
- go run ./tools/desktopinventory
- go run ./tools/repolint
- golangci-lint run --timeout=5m ./...
- git diff --check
2026-09-16 22:21:32 +08:00
Adalberto Pereira 558fc04060 fix(agent): detect a prompt the provider silently truncated / 识别被服务端静默截断的提示词
Ollama 在提示词超出其上下文长度时不会报错:返回 200,静默丢掉超出的部分,并在
usage 里如实报出被截断后的 prompt_tokens。Reasonix 已有的溢出恢复阶梯只能由可
解析的 400/413/422 触发,因此完全看不到这一类退化——用户看到的只是"模型很笨"。

Measured against a stock Ollama 0.33.3 model (`OLLAMA_CONTEXT_LENGTH` unset, no
Modelfile `num_ctx`, so it serves the documented 4096 default). Reading an 87 KB
file, on `main-v2`:

    · 4042 tok · in 3974 (0 cached / 3974 new) · out 68
    -> read_file {"path":"big.go"}
    · 4579 tok · in 2051 (0 cached / 2051 new) · out 2528

The second request reports 2051 prompt tokens for a transcript far larger than
that, and the model answers "you've shared a snippet of rule definitions … I'm
not sure exactly what you'd like me to do" — it never received the question.
Reasonix reported nothing and kept displaying the configured 131072 window.

Ollama's OpenAI-compatible surface has no field for num_ctx, so the window
cannot be raised per request; it has to be set on the server. Verified against
the same instance: /v1/chat/completions carrying options.num_ctx=32768 still
allocated 4096, while /api/chat with the same options allocated 32768.

That number also poisons the estimator. calibratedPromptTokens accepts a
tokens-per-character density between 0.05 and 2, so a moderate truncation lands
inside the band and becomes the calibration; every later prompt is then
estimated at a fraction of its true size, compaction fires later, and more of
the next prompt is dropped. A severe truncation falls below the floor and is
discarded, leaving the fallback density in place. Neither path tells anyone.

Detection is arithmetic on what the response already carries: a provider that
reports far fewer prompt tokens than the characters on the wire can encode
truncated them. Cache fields do not confound it — CacheHitTokens is a subset of
PromptTokens, not a deduction from it. The check runs in setPromptTokenCalibration,
the one place the observed count meets the shape Reasonix believes it sent, so
a truncated reading is refused as calibration on sight rather than learned from.
Refusing it is strictly safe: the fallback density is larger, so estimates get
more conservative rather than less.

The signal is protocol-level rather than vendor-specific, so it also covers an
LM Studio instance set to truncateMiddle or rollingWindow — policies an
OpenAI-compatible client cannot select per request. It is not claimed for
llama.cpp server or vLLM: both reject an over-long prompt with HTTP 400 instead
of truncating, so they are already visible.

Two guards keep it quiet on healthy sessions: a request must be large enough to
demonstrate a ceiling, and a reported size below 512 tokens is a provider
reporting meaningless usage rather than a runtime serving a tiny window. The
latter is what stub providers in the existing suite report. A calibration that
is itself below the density floor does not suppress detection — the absolute
floor still applies underneath it. The warning is claimed with a compare-and-swap
so concurrent turns produce one warning per session, not one per racing turn.

It deliberately does not clamp the context window to the observed ceiling.
Feeding it to learnContextBudget makes every later prompt that cannot fit fail
admission outright — verified, the session dies with "truncated view still
21307 >= 2051" — which converts a session that degrades into one that stops.
That is a larger behavior change than detection and needs its own evidence.
Warning without clamping also keeps the cost of a wrong reading at one line.

The notice carries a wire-stable code so frontends localize it by code rather
than by matching English text.
2026-09-16 10:43:43 -03:00
Adalberto Pereira 574d5fc200 test(worktree): detach the git tests from the machine's git config
These tests write a pre-commit hook to whatever `git rev-parse --git-path
hooks/pre-commit` reports. On a machine with `core.hooksPath` set in the user's
global config - a common setup, and the documented way to share hooks across
repositories - that path is not inside the temporary repository. It is the
developer's own hooks directory, and `os.WriteFile` destroys the file already
there. One `go test ./internal/worktree/` leaves behind

    printf ran > '/tmp/TestMergeBack...1202124349/003/hook-ran'
    exit 1

so every later `git commit` on that machine fails, in every repository, naming
a temporary directory that no longer exists.

The same configuration also makes those tests pass for the wrong reason: the
hook they plant is not the hook git would run, so "the hook did not run" holds
whatever MergeBack does.

`initRepo` and `gitTest` already passed GIT_CONFIG_NOSYSTEM=1 per command, so
the intent to detach from ambient configuration was there and covered only the
system file. Rather than add the user file in those two places, both move into
`requireGit`, which every test in the package already calls, and the per-command
copies go: one mechanism instead of three that have to stay in step. It has to
be process-level regardless, because the code under test spawns git itself and
inherits os.Environ() - per-command environment would isolate the helpers and
leave MergeBack's own invocations reading the user file.

The two new tests assert the property rather than the call sites that depend on
it, so a third hook-planting test cannot be added outside their cover: one that
the resolved hook path stays inside the test repository, one that a hook
configured outside it never runs during the commits these tests make.
2026-09-16 10:43:29 -03:00
SivanCola d9d152b3f7 Record signing evidence without privileged tokens / 修复签名证明记录权限 (#10413)
* Record signing preflight evidence without variable-write permission

Problem: verified x64 and ARM64 signing ended with an attestation HTTP 403.
Root cause: GITHUB_TOKEN cannot write repository Actions variables.
Fix: upload run-bound fingerprint evidence and document explicit maintainer promotion for standalone recovery; preserve same-run release gates.
Verification: 15 workflow tests and signing contract validation pass. Both production Windows signing jobs passed in run 35092958494.

* Refresh desktop inventory after signing evidence documentation

Problem: CI detected stale generated desktop inventory.
Root cause: the signing documentation update changed a tracked inventory source.
Fix: regenerate both inventory artifacts from the current sources.
Verification: desktopinventory tests and whitespace checks pass.
2026-09-16 13:05:38 +00:00
SivanCola 07bf226497 fix(desktop): gate updater by stable build identity / 按正式构建身份限制桌面更新器 (#10406)
* Disable updater for non-stable desktop builds / 禁用非正式版桌面更新器

Problem:
Desktop test and prerelease builds could still expose and invoke the production updater, while automatic checks could run on every focus or visibility event.

Root cause:
Updater availability was inferred from normalized configuration instead of immutable build identity, and automatic checks had no persisted throttle shared across lifecycle triggers.

Fix:
Gate every updater boundary behind exact stable channel and vX.Y.Z build identity, expose the capability through the Desktop RPC contract, hide updater controls for non-stable builds, rename the user-facing settings page to About, and persist a six-hour automatic-check throttle while keeping manual checks explicit.

Verification:
- cd desktop && go test -timeout 20m ./...
- pnpm --dir desktop/frontend test:updater
- pnpm --dir desktop/frontend test:app-lifecycle
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend build
- Windows x64 Electron shell packaging reached the unsigned NSIS stage in a Win11 ARM64 VM using x64 emulation; packaged installation validation was intentionally not performed.

* Keep updater policy changes within repository standards

Problem:
The updater policy implementation added lines to three files already governed by repolint carry-forward budgets, causing the PR lint job to fail.

Root cause:
The new build policy, policy regressions, and startup settings contract were placed in existing oversized owner files instead of focused package-local files.

Fix:
Move the build capability helper, its regression tests, and the lightweight startup settings view into dedicated files without changing runtime behavior or the generated Desktop contract.

Verification:
- go run ./tools/repolint
- cd desktop && go test -run 'TestDesktopUpdaterEnabledRequiresExactStableReleaseBuild|TestDisabledDesktopUpdaterBoundariesHaveNoSideEffects|TestDesktopUpdaterCapabilityAndConfigFailureDefaults|TestUpdaterReconcilesBeforeDownloading|TestHostContractFilesAreCurrent' ./...
- cd desktop && go test -timeout 20m ./...
- Desktop contract drift comparison passed
- git diff --check
2026-09-16 20:24:51 +08:00
XTLine 8c08f10b1d docs: describe /takeover and final-format identity handoff / 补充 /takeover 与最终格式身份交接文档 2026-09-16 19:57:08 +08:00
SivanCola 95b21354e3 Run Certum signing after skipped ancestor gates (#10408)
Problem: a successful standalone preflight skipped Windows signing.
Root cause: the implicit success() job guard propagated the skipped orchestrator ancestor.
Fix: explicitly evaluate status while requiring successful resolution, build and signing contract, and rejecting cancellation.
Verification: all 15 CI workflow tests pass, including failure, cancellation and skipped-input regressions; signing contract and whitespace validation pass.
2026-09-16 11:54:39 +00:00
SivanCola f0022664a2 Fix Windows signing archive paths in Git Bash (#10407)
Problem: the Certum preflight fails when archiving Windows signing inputs.
Root cause: GNU tar interprets the colon in RUNNER_TEMP drive paths as a remote archive host.
Fix: normalize temporary archive paths with cygpath for both creation and extraction.
Verification: release workflow contract tests, signing contract validation, and git diff --check passed. Full Windows preflight remains required.
2026-09-16 11:16:23 +00:00
SivanCola 42cdbf706e Migrate Windows release signing to Certum / 将 Windows 发布签名迁移至 Certum (#10401)
* ci: migrate Windows release signing to Certum

Problem: SignPath capacity prevents reliable Windows release signing. Move native-tested x64 and ARM64 payloads to serialized x64 SimplySign jobs, sign every manifest PE before repackaging, sign the installer, and require the configured signer and timestamps before publication. Preserve release fingerprint and artifact-reuse gates; refresh their coverage for Certum. Add a shared setup action and signing probe covering both architectures. Validation: signing contract tests, 31 workflow/artifact tests, actionlint latest, and diff checks. Original live Certum probe succeeded; shared implementation awaits post-merge Windows verification.

* test: align release inventory and legacy receipt checks with Certum

Problem: CI still expects SignPath submission steps and classifies no standalone Windows signing job. Root cause: release workflow contracts were not migrated with their owner. Fix: register the signer job, regenerate inventory, and retain receipt unit tests while asserting production no longer submits SignPath requests. Verification: inventory and signing-contract Go tests plus all 79 focused release-script tests pass.

* test: migrate remaining shell release contracts to Certum

Problem: the shell release contract still required removed SignPath request steps. Root cause: it duplicated provider-specific assertions outside the JavaScript tests. Fix: assert the Certum credential gate, protected signer checkout, both signing stages, trusted expected certificate and native smoke handoff order. Verification: complete release-workflows.test.sh, desktop Windows workflow tests, and public release contract validation passed.

* test: verify Certum release ordering in desktop packaging contracts

Problem: Desktop module tests still asserted removed SignPath step names. Root cause: an independent packaging contract retained provider-specific workflow ordering. Fix: verify the native-build handoff, shared Certum signer, payload binding, installer signing, and expected certificate verification in order, including the separate signer job. Verification: all tests in windows_signing_packaging_test.go passed, including legacy recovery and artifact configurations.
2026-09-16 10:30:31 +00:00
SivanCola 23b5d68ecf Unify session archive, trash and historical recovery / 统一会话归档、回收站与历史恢复 (#10396)
* Unify archived sessions, trash recovery and durable lifecycle identity

Problem: restored sessions could disappear from workspace lists, legacy archive state diverged from canonical membership, and stale runtime aliases could route tasks to a replacement session.

Root cause: directory projections, runtime indexes and migration adoption were independent authorities without a durable lifecycle transaction.

Fix: integrate registry schema 2 with upstream multi-head migration receipts; add idempotent archive/restore/purge commands and deletion tombstones; preserve historical originals and unknown fields; unify trash and historical recovery UI under protocol 10; bind runtime and task ownership to published session identities.

Verification: focused lifecycle, migration, interruption and race regressions passed; frontend 379 suites, typecheck and production build passed. Full post-review Go suites and integrated production package qualification remain in progress and are tracked in the validation document.

* Separate lifecycle owners and validate durable migration inputs

Problem: CI rejected oversized lifecycle owners, while concurrent legacy catalog repair could make a valid archive import fail its source revision check.

Root cause: lifecycle commands and projections accumulated in large entrypoints; stat-only import validation treated identical JSONL rewrites and derived metadata as changed history.

Fix: extract cohesive RPC, validation, sidebar and runtime-control owners without raising lint budgets. Remove superseded migration/deletion helpers. Retain a durable-input digest for sources requiring import, ignore disposable indexes and known derived listing fields, and verify content when a stat stamp changes. Keep workspace, ancestry and unknown metadata fields in validation. Add legacy purge and deterministic source-repair regressions.

Verification: repository lint and both Go lint modules passed; focused migration/archive tests passed including ten repetitions of the original failure; frontend typecheck and trash/recovery tests passed. The first integrated Electron package passed archive/restart/preview/restore/purge/restart; final package will be rebuilt from this revision.

* Refresh desktop inventory after lifecycle owner extraction

Problem: source owner moves and final comment edits changed inventory coordinates.
Root cause: inventory was generated before the last source edits.
Fix: regenerate the classified Desktop inventory from the reviewed tree.
Verification: desktopinventory tests and generation checks pass.

* Keep recovery terminal publication alive through controller close

Problem: race CI caught session-directory writes after controller cleanup, and
the build contract check rejected a current uncommitted contract.

Root cause: recovery completion dropped its live-turn marker before terminal
fanout; the watchdog could finalize resources while another terminal publisher
remained active. The build guard test pinned git syntax rather than behavior.

Fix: retain turn ownership through recovery fanout and explicitly track watchdog
publication. Verify contract guard behavior for unchanged, changed, added,
removed and failed generation. Record qualification evidence and CI assessment.

Verification: the deterministic regression fails before the fix; recovery and
cancellation race tests pass ten repetitions. Root lint, repository lint,
inventory freshness and build contract checks pass. Broad qualification continues.

* Retire archived navigation caches before permanent deletion

Problem: immediately purging an archived session failed while its released
canonical writer remained in the 60-second navigation cache.

Root cause: client detachment intentionally retains idle runtimes, but archive
did not retire them and purge treated every cached runtime as a live client.
The package smoke also accepted async false predicates as completed waits.

Fix: retire archived runtimes through Service.Close, retaining its bound-client
and executing-turn guards. Poll awaited RPC results in both package smoke
scripts and capture registry checkpoints for restart diagnostics.

Verification: both cache regressions fail before the fix and pass afterward;
related lifecycle race tests pass three repetitions. All 29 packaging tests,
Desktop lint, repository lint, build guard and inventory freshness pass.
2026-09-16 18:05:39 +08:00
SivanCola 0bd1cb7bc6 ci: add manual Certum cloud signing verification (#10398)
Add a release-environment gated probe on the protected main-v2 branch. Verify the SimplySign installer publisher, pin the community login action, and require the expected Authenticode signer and timestamp. No release assets are published. Validation: YAML parsing and git diff --check; real Windows signing is pending the manual workflow.
2026-09-16 09:27:01 +00:00
XTLine 42026ee90a Merge branch 'main' (a4b83056a) into fix/sessionid-migration-gaps
transcriptSnapshotState: main's recovery-copy restore and ours fix the same
optimistic-submission leak from opposite sides — main absorbs by id
(including snapshot.runtime.submissionId), ours by content when the serve
never persisted ids. Keep both: main's broader id filter as the base, with
the branch's content fallback still able to absorb the id-less rebase case.
2026-09-16 16:14:24 +08:00
XTLine 72511d36da fix(desktop): fall back to identity history when the transcript follow 409s
main's follower-based hydration assumes the serve always owns the viewed
session's runtime. A session taken over by a local runtime rotates the serve
foreground away, so /transcript/follow answers 409 and the surface parked on
"history failed, retry". Re-add the branch's fallback: when the follower
cannot start, hydrate from the legacy status snapshot (session open plus
identity history reads) and render it — the same spectator read path the
branch used before the merge.
2026-09-16 15:51:50 +08:00
SivanCola a4b83056a9 Restore legacy Desktop history without duplicate recovery copies / 完善旧版对话迁移并排除重复恢复副本 (#10395)
* Restore legacy Desktop history with durable migration receipts

Problem: Desktop upgrades can omit older conversations, duplicate converted
generations, or expose automatically generated conflict recovery copies.

Root cause: discovery depended on disposable metadata, legacy migration only
adopted selected heads, and repeated imports compared against mutable targets
without accounting for conversion provenance or recovery identity.

Fix: discover durable v1-v4 sources, stage all live legacy heads and preview
formats, reconcile proven conversion lineages, and persist per-source adoption
receipts with revision checkpoints. Preserve source bytes and independent
continuations while excluding automatic recovery artifacts and their conversions.

Verification: Desktop full module coverage passed in three measured partitions
and its cmd/internal packages. Migration race regressions passed, including
restart, deletion, interruption, source repair, recovery artifacts, and submission
identity. Root suite had one goal-driver deadline failure; clean baseline and
working-tree isolated reproductions passed without code or timeout changes.
Generated inventory, repository lint tests, formatting, and diff checks passed.

* Separate migration discovery and lineage reduction responsibilities

Problem: CI rejected oversized migration functions and an outdated loop form.
Root cause: source discovery, paired-store admission, and lineage reduction
were embedded in orchestration functions beyond repository complexity limits.
Fix: extract the existing decisions into focused helpers and use an integer
range while retaining restart diagnostics. Keep all CI gates and thresholds.
Verification: repository lint, Desktop golangci-lint, generated inventory
freshness, diff checks, and the focused Desktop migration race suite passed.
2026-09-16 15:51:15 +08:00
SivanCola 8b426dc870 Fix submission identity and persistent PowerShell / 修复消息身份、工具状态与持久 PowerShell (#10392)
* Preserve submission identity and unify shell execution state

Problem: optimistic messages could duplicate canonical history, tool views disagreed on terminal state, and Windows terminal rendering could corrupt completion markers.

Root cause: submission identity was not durable across transports and projections, and execution completion depended on rendered terminal text.

Fix: persist optional submission receipts at Controller admission, reconcile transcript identities, share tool presentation, and run persistent PowerShell with a private control channel while preserving legacy message formats.

Verification: owning Go packages, frontend typecheck and focused UI suites, Desktop module tests, PowerShell 7 live tests, compatibility reader checks and browser transcript benchmarks. Native Windows and packaged application qualification remain required.

* Document persistent Windows Agent shell behavior

Explain PowerShell selection, session state, isolated calls and emergency one-shot rollback in English and Chinese. Documentation-only validation: checked paths and behavior against the implementation.

* Include Windows pipe dependency in the Desktop module

Problem: the native Windows package build failed with a missing go.sum entry.
Root cause: Desktop is an independent module and did not inherit root module checksums.
Fix: record go-winio in Desktop module metadata.
Verification: go mod tidy completed; Windows package build is being rerun.

* Preserve explicit POSIX hooks under Windows Agent shell policy

Problem: Windows CI found explicit sh hooks were redirected through Agent shell selection, and the cross-platform golden fixture could no longer force Bash.
Root cause: hook dialect selection shared the newly Windows-native Agent policy; the cache fixture assumed a Bash preference overrode that policy.
Fix: retain explicit Bash discovery for POSIX hooks with a deterministic regression, record a separate pinned Windows PowerShell provider golden, and extract the durable submission-start barrier to keep event routing within its complexity budget.
Verification: focused control/sandbox/hook/boot tests and clean-tree repolint passed; Windows PowerShell golden passed natively. Explicit hook validation is continuing after installing Git for Windows.

* Retain submission identity in rejected-send regression

Problem: the legacy checkpoint test expected send failure to erase submission identity.
Root cause: the assertion encoded transient correlation semantics before durable identity reconciliation.
Fix: assert that identity remains available for late canonical receipts while keeping stale-turn and failure isolation checks.
Verification: checkpoint-turn-event.test.ts passed all 61 assertions.
2026-09-16 14:34:13 +08:00
XTLine 6885a9d88c Merge branch 'main' into fix/sessionid-migration-gaps
Conflict resolutions:
- serve transcript routing: keep the branch's identity routing
  (resolveReadControllerLocked, session-id mirror 501) and adopt main's
  lock discipline (release bindMu across reads, re-verify after) plus the
  new /transcript/follow endpoint; the post-read check re-resolves the
  reference so detached/identity reads verify correctly.
- remote transcript API: keep main's /transcript/follow stream client and
  re-apply the branch's per-route capability requirements.
- runtime-state sampling: keep main's extracted sampleRemoteRuntimeSessions
  (projection mutex no longer held across controller reads) with the
  branch's background-freshness rule inside it.
- useRemoteSession: take main's follower-based hydration as the base and
  re-apply the branch's static telemetry import, ready-publication pending
  reset, and spectator status reconcile loop.
- SessionRef frontend plumbing, catalog preview helpers, and the bundle
  budget baseline follow main; remote tabs keep the flat sessionId until
  the identity migration.
2026-09-16 13:57:30 +08:00
XTLine 3d6cb0b127 fix(desktop): keep the reclaim banner from re-pinning and never polling again
An in-flight /status response reserved before an explicit reclaim could land
after ownership returned, still reporting the pre-reclaim takenOver=true;
the recorder applied it and re-pinned the spectator banner. Because a
spectator surface idles with no status traffic (the running watchdog only
reconciles turns and the read-only composer blocks sends), nothing ever
polled again and the banner stayed until the user switched tabs.

Reclaim now stamps an ownership epoch on the tab: status payloads reserved
before the epoch have their takenOver=true dropped, so late mirrors of the
old ownership can no longer undo the reclaim. The frontend additionally
tracks the spectator flag from status and tab meta and runs a slow 5s
status reconcile while it holds, so any future ownership staleness
self-heals instead of sticking.
2026-09-16 13:12:59 +08:00
SivanCola 703c310f5b Move command palette search to sidebar footer / 将命令面板搜索移至侧栏底部 (#10390)
* Move command palette search into sidebar footer

Problem:
The command palette search action lived in the top-right topic bar, while the sidebar footer had no direct search entry.

Root cause:
The palette opener was owned by TopicbarActionsStack instead of the shared sidebar utility region.

Fix:
Route the existing palette command and shortcut label into SidebarRegion, render search as the first footer utility, remove the duplicate topic-bar entry, and expand the footer grid to four equal slots. Add a regression that fixes the placement contract.

Verification:
- pnpm exec tsx src/__tests__/automation-regions.test.tsx
- pnpm build
- Browser DOM and interaction verification at 1600x1000

* Update sidebar utility layout contract

Problem:
The desktop-frontend CI job still expected the workbench sidebar footer to contain three equal-width utility actions.

Root cause:
Moving command palette search into the footer intentionally changed that layout to four actions, but the source-level bundle contract retained the previous three-column invariant.

Fix:
Update the contract assertion and description to require four equal-width workbench footer actions while leaving the creation footer contract unchanged.

Verification:
- pnpm exec tsx src/__tests__/bundle-contract.test.ts
- pnpm exec tsx src/__tests__/automation-regions.test.tsx
- pnpm build
- git diff --check
2026-09-16 13:08:55 +08:00
SivanCola 0ce3d4262a Collapse the workspace dock when its last tab closes / 关闭最后一个标签页后自动收起工作区 (#10388)
* fix(desktop): collapse workspace dock when its last tab closes

Problem: Closing every workspace tab leaves an empty right dock occupying
conversation space.

Root cause: The tab store collapses its container, but the workspace panel
owner never receives the final-tab transition.

Fix: Observe same-project transitions to an empty tab list and invoke the
existing panel close command synchronously. Preserve project restoration
and later open commands while resetting resize/maximize state and saving
the collapsed preference.

Verification: Focused workspace regressions, TypeScript, ESLint, production
frontend build, browser close/reopen interactions, and packaged macOS arm64
signature, handshake, renderer Version, and clean-exit checks passed.

* test(desktop): align dock browser checks and rebind lifecycle assertions

Problem: The dock browser gate waits for a picker in a collapsed panel;
the Windows rebind regression reports slow controller boot as deadlock.

Root cause: The browser scenario retains the old final-tab behavior, while
a three-second timer covers disk migration and controller construction and
allows fixture cleanup to race an unfinished rebind goroutine.

Fix: Assert dock removal and reopen it explicitly before selecting Files.
Run rebind synchronously, verify both mirror/sink bindings, and retain the
suite timeout as the deadlock watchdog instead of timing machine speed.

Verification: Full app browser suite, focused Go rebind/takeover tests,
race checks, JavaScript syntax, diff checks, and repository lint passed.
2026-09-16 12:52:39 +08:00
XTLine 05e7450467 fix(cli): /takeover after a desktop reclaim re-takes the remembered session
The reclaim notice promises "/takeover takes it back", but the command only
consulted pendingTakeoverPath — set exclusively by a refused /resume — so
after a desktop reclaim it answered "no refused session" and forced the user
through /resume and a second /takeover. Fall back to the session remembered
at reclaim time, and pin the round-trip test to that user-facing path instead
of hand-seeding the pending route.
2026-09-16 12:35:57 +08:00
SivanCola 153e6e870f Fix AI session rename to use canonical conversation storage (#10389)
Problem: AI rename reported no user messages for conversations with visible
history after the desktop storage migration.

Root cause: the action read an empty legacy JSONL checkpoint instead of the
controller's canonical session identity.

Fix: read the first authored turns through the durable history index, commit
the title conditionally through the session owner, and synchronize the sidebar
label. Preserve concurrent manual renames and reject stale controller results.
Keep legacy reads compatible and distinguish read failures from empty history.
Refresh the generated desktop inventory.

Verification: focused desktop and session regressions, race checks, session
package tests, and generated inventory checks passed. The root suite's stale
inventory failure was fixed and rechecked. The desktop full suite reached its
10-minute deadline; the remaining 32 tests passed separately.

Known limitation: large authored messages are decoded in full before title
truncation. Resource auditing found high transient allocations for large
messages; reducing those allocations remains follow-up work.
2026-09-16 12:24:08 +08:00
XTLine 48fcfd613e fix(cli): replay the durable display history instead of the provider workset
Controller.History returns the provider workset for exclusive sessions:
session-context and reasoning-language wrappers included, Origin/RawContent
stripped by the provider projection. replayActiveBranch rendered that list,
so a resumed or taken-over session leaked raw </session-context> blobs and
drowned earlier turns in host preamble noise while the desktop — which reads
the transcript snapshot projection — showed the clean conversation.

Read the session service's display projection instead (the same source the
desktop renders), and drop host-generated user wrappers in the replay
renderer with user bubbles preferring the raw submitted text. /copy and
/export now read the same display view.
2026-09-16 12:23:11 +08:00
XTLine baf74310c6 fix(serve): list every canonical session by following the catalog cursor
Query().List caps one page at 100 rows ordered by the random session id, and
the /sessions handler read only that first page: any workspace beyond 100
sessions hid an arbitrary id-ordered subset — including a session another
runtime had just taken over, which then became unfindable on the desktop
while the CLI (reading the directory) still listed it. Follow NextCursor
until the catalog is exhausted, bounded at 500 rows with a non-advancing
cursor guard.
2026-09-16 12:23:11 +08:00
XTLine 7520a73baa fix(control): reopen the turn-event store when a reclaim rotates the runtime
Taking a session back closes the CLI writer's runtime and its recovery
database, but the controller keeps rendering that conversation. When a second
/takeover re-opens the same identity, two cached fast paths served the closed
instance instead of the fresh one, so turn admission answered "turn event
ledger unavailable ... database not open" and the turn never persisted:

- OpenSession returned immediately for a same-ref controller binding without
  checking that the service still has that runtime active.
- rebindTurnEvents reused its cached session store on a path match; exclusive
  sessions share one path across instances, so the closed store stayed cached
  after the rotation. The cache now pins the runtime instance it resolved.
2026-09-16 11:25:10 +08:00
XTLine 68d4fb163f chore(desktop): ratchet the startup budget for the orphan reconciliation 2026-09-16 10:38:30 +08:00
XTLine 4d4f09d100 fix(desktop): absorb orphaned optimistic submissions when snapshots re-base
A runtime rebuild (model switch at submit, serve restart, reconnect) re-bases
the transcript from server history, and those records carry no submission
ids. The optimistic submission therefore never linked back: its bubble kept
rendering a duplicate turn stuck at "processing" beside the server's own
completed copy — the zombie the e2e flow reproduced with a live event stream.

transcriptSnapshotState now also reconciles by content: when no turn is in
flight and the snapshot's newest user record already carries the pending
submission's text, the server owns the message and the orphaned copy is
dropped. The id-linking path and the active-runtime deferral stay unchanged.
2026-09-16 10:09:59 +08:00
XTLine 2ddc5cceb9 fix(desktop): recover remote tabs from transport drops instead of stranding them
A dropped tunnel or restarted serve killed the /events stream, and the only
recovery was a three-attempt, half-second reattach whose first EnsureServer
raced the tunnel's own healing. Observed drops exhausted it and parked the
tab in serve_down, which nothing revisits: HTTP keeps working, so sends
succeed while replies never render and the composer sticks at "processing"
beside a completed turn.

- Reattach retries across the healing window (250ms..8s) and host recovery
  re-arms serve_down tabs, so a healthy tab always regains its stream.
- A refused or non-200 /events open routes through the same reattach loop
  instead of parking in a terminal error; recovery is scheduled before the
  opener is signalled so the two cleanups cannot race.
- Reattach rebuilds tab.capabilities from the fresh handshake: restored tabs
  previously kept an empty set and every send failed as read-only. Transcript
  reads now require the capability that actually guards each route.
- The ownership-return ready barrier defers while a turn is in flight, and a
  ready publication clears stale optimistic pending submissions when it
  re-hydrates the view, so neither can leave a zombie pending indicator.
- Remote runtime-state freshness only degrades on a failed observation of
  the same route, not on foreground switches.
2026-09-16 09:44:48 +08:00
SivanCola 738e9b956e Unify transcript recovery and completion with Follow v2 / 统一会话同步与完成机制 (#10385)
* Unify transcript recovery and completion through runtime-owned v2

Problem: completed or reconnecting chats could lose visible output, infer incorrect run state, or display an intermediate duration.

Root cause: independent history, event-ledger and runtime snapshots described different cuts, and sampling settlement could precede its business commit.

Fix: publish canonical batches and independently versioned sampling frames from the session runtime; use one mandatory Follow v2 consumer for local and remote chat, persistence barriers, stable identities and bounded canonical paging. Preserve old session logs and provider serialization.

Verification: root and Desktop Go suites, transcript/session/control race tests, 371 frontend suites, typechecks, production build, Chromium and 42 macOS Electron layout scenarios; Windows cross-compilation. Native Windows verification follows on this branch.

* Keep contributor prompt instructions outside transcript migration

Problem: changing contributor AGENTS text affects repository instruction inputs. Root cause: protocol documentation had also been copied into the contributor instruction file. Fix: retain the existing instructions and keep migration details in the bilingual user documentation. Verification: cache and provider serialization guards passed; no runtime code changed.

* Separate transcript bookkeeping and honor native viewport geometry

Problem: transcript additions exceeded existing complexity and file-size budgets, and the Windows layout fixture assumed odd native widths were exact CSS widths. Root cause: runtime reduction, settlement publication and store contracts were embedded in larger owners; 200% Windows scaling rounded a requested 1009px surface to 1010px. Fix: extract cohesive helpers/types without changing publication order or provider bytes; test representable sizes on both sides of the launcher threshold and measure actual CSS geometry. Verification: repolint, transcript/type/hooks checks and focused Go tests pass; Windows native tests and race checks exercise the extracted boundaries.

* Migrate remote regression fixtures to transcript Follow

Route mock remote output through the mandatory Follow stream and keep metadata separate from snapshot cuts. Assert prefix preservation and ordered completion instead of legacy history rebases. Use unique mock sampling identities across turns and wait for pane transitions in native layout tests.

* Use reverse slice iteration for transcript retractions

* Give explicit transcript retries a fresh snapshot

A retry during ancillary hydration could join an already-installed old snapshot. Mark explicit retry reads as fresh so they supersede the previous load while ordinary startup reads still coalesce. Force a pending ancillary read in the regression and verify its late completion cannot overwrite the replacement.
2026-09-16 09:20:23 +08:00
SivanCola f75ad32097 Fix background conversation attention notifications / 修复后台对话 Ask 与审批通知 (#10382)
* fix: notify pending prompts from background conversations

Problem: Questions raised by conversation A stayed silent while the user edited conversation B.

Root cause: Detaching a runtime intentionally disconnects its transcript event stream, but attention notifications only consumed that stream.

Fix: Share a notification owner across view events and authoritative runtime snapshots. Deduplicate by turn and request identity across reattachment, show localized background alerts, and preserve draft focus and sound preferences. Load notification presentation and audio after initial rendering while retaining early events.

Verification: Regression failed on the original code and passes with the fix. Focused Desktop Go tests and the race detector pass. Frontend typechecks, sound/runtime regressions, App lifecycle suite, real Chromium/Web Audio checks, and production build including unchanged bundle budgets pass.

* fix: retain pending notification identities and resolve browser cache

Problem: Long-pending questions could alert again after notification history eviction, and the new browser regression failed under the CI entrypoint.

Root cause: Active pending identities shared a bounded historical set, while Playwright resolved the relative browser cache against the invoking desktop directory.

Fix: Retain live pending identities independently until resolution, including remote disconnection, and normalize the CI browser cache path against the frontend root before importing Playwright.

Verification: Retention regression reproduces the original eviction failure and covers 600 pending requests, replay, and reconnect. Notification lifecycle regressions, both typechecks, the production build with unchanged budgets, and the complete CI app-browser command pass.

* ci: split Windows desktop test suffixes

Problem: The Windows desktop test step timed out while running the combined Q-Z suffix on a loaded hosted runner.

Root cause: Three sequential go test invocations shared one 25-minute step, so the slow Q-Z tail could consume the entire step before its result was reported.

Fix: Run the disjoint Q-R and S-Z suffixes as separate bounded steps, preserving all test names and the existing timeout policy.

Verification: Both suffixes pass locally in 87s and 105s respectively; the targeted TestRecord regression passes five times; git diff --check passes.

* ci: isolate Windows desktop test partitions on parallel runners

Problem: Sequential Windows Desktop test partitions exceeded a shared step deadline, and the workflow coverage guard still assumed the old three-command layout.

Root cause: Independent test partitions shared one runner and timing budget, while the contract test did not describe execution across jobs.

Fix: Dispatch four complete, disjoint test partitions to isolated runners and retain desktop-windows-go as a fail-closed aggregate. Preserve the job, step, and Go test timeouts; run ConPTY and systray checks only in the final partition. Verify dispatch, complete coverage, and aggregation for success, failure, cancellation, and skipped work.

Verification: Actionlint passes; 77 workflow/release contract tests pass, including execution of the actual partition dispatch and aggregate shell scripts. Q-R and S-Z Desktop tests and five focused concurrent-display test runs pass locally.

* ci: classify Windows test matrix in Desktop inventory

Problem: The root inventory checks rejected the newly added Windows Desktop test matrix as unclassified.

Root cause: The migration inventory explicitly catalogs every Desktop CI job and had only the former single Windows Go job.

Fix: Classify the isolated partition job and the retained aggregate, then regenerate both inventory artifacts.

Verification: go test ./tools/desktopinventory passes, generated artifacts are current, and git diff --check passes.

* test: make rebuild and fork ordering fixtures deterministic

Problem: Native Windows CI failed two Desktop ordering tests under slow storage and controller startup.

Root cause: The rebuild fixture needed real SQLite shutdown to beat a three-second deadline before producing a path error. The fork fixture used a nil app context, synchronously booted a real controller, and left its resources unowned on timeout.

Fix: Hold the published rebuild flight until its follower joins and force the exact stop-timeout failure with a blocked reconcile. Switch fork focus at the existing publication hook, use the normal asynchronous build path, and cancel and join the gated build during cleanup. Keep real rebuild failure and restart coverage in the lifecycle suite.

Verification: Focused tests and five repetitions pass; related rebuild/fork tests pass with the race detector. Desktop go vet, repository lint, inventory freshness, workflow contract tests, and diff checks pass.

* test: compare shared rebuild failures with errors.Is

Problem: Desktop CI errorlint rejects direct comparison of error interfaces in the shared-failure regression.

Root cause: The fixture compared error identity directly even though the repository requires wrapped-error-aware assertions.

Fix: Assert that the follower matches the leader through errors.Is while retaining the explicit stop-timeout sentinel check.

Verification: golangci-lint v2.12.2 reports zero issues for the Desktop module; the focused rebuild regression and git diff --check pass.

* test: remove wall-clock races from rendering and close fixtures

Problem: Merge validation exposed a Mermaid frame-initialization failure and a Windows synchronous-close test timeout.

Root cause: Thirty zero-delay polls raced two animation frames, while a one-second completion deadline conflated execution-owner ordering with physical session flush and close speed.

Fix: Drive Mermaid animation frames explicitly and assert the separate initialization/layout phases. Retain the synchronous terminal barrier and ownership assertions, wait under the test context, and always release and join the worker during cleanup.

Verification: All 375 frontend suites pass; test typecheck, focused control race tests, golangci-lint, repository lint, and diff checks pass.

* test: isolate autosave retry policy from disk failure latency

Problem: Windows A-D CI exceeded the autosave retry fixture's five-second wait while repeatedly provoking a physical filesystem error.

Root cause: The retry-policy test included platform-dependent failed-write work in every attempt, although its invariant is bounded retries followed by recovery on the next TurnDone.

Fix: Inject a snapshot error through the existing controller decorator for the failure burst, assert the exact retry count, then restore the real controller and verify persisted recovery content.

Verification: Autosave, persistence, and coalescing regressions pass under the race detector. Desktop golangci-lint, repository lint, and diff checks pass.
2026-09-16 08:06:51 +08:00
SivanCola 5113a8c0bc Merge pull request #10383: append-only pause and recovery
Merge append-only turn termination after reconciling the latest main-v2 catalog and history changes. Preserve revision-2 upgrade safeguards, authored previews, bounded catalog replay and deterministic termination/recovery. Integrated CI and native Windows ARM64 session verification passed.
2026-09-16 07:33:07 +08:00
SivanCola 74a90bb7e3 Merge main-v2 and reconcile bounded catalog retraction
Problem: The ProjectTree/session-recovery merge conflicted with append-only termination projections and introduced a bounded catalog reducer unaware of message retraction.

Root cause: Both branches evolved derived cache formats, while the new reducer discarded input ownership required to hide and restore closed turns.

Fix: Preserve both cold-history and complete-batch indexing behavior, share authored input metadata with the bounded reducer, retain identity-only retraction ownership, and advance all four projection versions beyond both parents. Keep bodies and full turn boundaries out of catalog reduction.

Verification: Session catalog, retained-heap, retraction, history-boundary, preparation and upgrade regressions passed. Control cancellation/provider-policy tests, golangci-lint 2.12.2 and repository lint passed. Full integrated CI follows this merge commit.
2026-09-16 07:13:04 +08:00
SivanCola b17c1b5c3b Restore ProjectTree and integrate session recovery fixes / 恢复项目树并整合会话恢复修复 (#10380)
* fix(session): keep history index checkpoints on complete batches

Bound history and search scans to their captured log snapshot and persist
the byte offset and sequence from the same validated complete transaction.
Reject replaced history logs before publishing. Rebuild a mismatched derived
history checkpoint from the authoritative log, preserving real corruption
errors instead of discarding session data.

Treat history preparation as pending during startup and pagination. Stop
polling after navigation or generation changes and keep lifecycle callbacks
out of IPC payloads.

Add deterministic regressions for append interleavings, persisted mismatched
checkpoints, partial tails, replacement, corruption and preparation lifecycle.
Verified a disposable copy of the reported session recovers 32 messages.

Frontend initial raw bundle: 2498286 B at base to 2499170 B (+884 B, 0.035%).
Raise only its budget from 2439.9 to 2440.7 KiB with 0.1 KiB headroom; gzip,
CSS and individual chunk budgets remain unchanged.

* fix(desktop): bind session navigation to its canonical workspace

* test(session): model replaced readers without renaming open files

* fix(desktop): preserve active sessions across navigation

Transfer active controllers, event sinks, writer leases and shared-host references to detached ownership when navigating. Reattach the exact owner on return and replay pending prompts. Build independent runtimes for blank targets, isolate display and telemetry, and fence retired sinks. Cover running, pending approval, background jobs, shared hosts, failed opens, rapid navigation and repeated active-owner swaps.

* fix(session): derive catalog previews from authored user text

Problem: Session lists displayed truncated internal prompt wrappers instead of user requests.

Root cause: Shared message previews preferred provider content and truncated it before display extraction; persistent projections retained those previews.

Fix: Reuse authored-message extraction before truncation, exclude host messages and steers from catalog labels, and rebuild older catalog, history, search, and recovery projections. Preserve custom titles and durable conversation content.

Verification: Session package tests, authored-text and legacy-checkpoint regressions, root Go suite excluding the subsequently repaired stale inventory gate, and desktop workspace-list tests passed.

* fix(desktop): restore compact workspace navigation and recovery access

Problem: The SessionID sidebar lost compact rows, project ordering, and its bottom Trash entry; archive access occupied a separate sidebar row.

Root cause: The replacement workspace browser omitted existing presentation and navigation behaviors.

Fix: Restore single-line titles and folder names with full-name tooltips, durable drag ordering with insertion feedback, and the bottom Trash entry. Combine archived and deleted sessions in that page while preserving their distinct actions. Share archive notification behavior across browser fixtures and isolate workspace styles with their component.

Verification: Browser checks cover light and dark themes, narrow layouts, drag ordering, failed saves, archive/restore and deleted-session navigation. Workspace persistence tests, Trash management regressions, typecheck, frontend production build and repository lint passed.

* docs(desktop): refresh generated migration inventory

Synchronize generated source locations after the session navigation changes. The inventory content and classification remain unchanged.

Verification: go test ./tools/desktopinventory -count=1 passed.

* fix(desktop): route sidebar sessions through navigation owner

Problem: Sidebar session clicks can change the backend session while leaving the previous transcript visible.

Root cause: Direct OpenSession and CreateSession calls bypass the frontend navigation owner. Ready events for a different active tab are correctly ignored as background events, leaving the visible surface stale.

Fix: Route canonical session opening and blank-session creation through the shared navigation queue, synchronize the active surface after a successful open, and preserve last-intent-wins behavior. Load the navigation executor on demand to retain the existing startup bundle budget.

Verification: Frontend build, type and hook checks, bundle budgets, repository lint, navigation lifecycle tests, browser lifecycle checks, and packaged macOS sidebar and startup smoke tests passed. The packaged sidebar regression reproduces the stale transcript on the prior build and passes with this source tree.

* fix(desktop): render authored chat text across history readers

Problem: Chat history exposes host context and prompt wrappers as user bubbles.

Root cause: The canonical message adapter renders provider content without respecting message origin or authored raw text; reference-only previews can retain the same internal content.

Fix: Centralize authored-text projection for inline and hydrated messages, hide host messages, unwrap legacy protocol content, and rebuild derived preview caches. Keep durable messages unchanged and load SVG preview styles with their component.

Verification: Root Go suite, owning session regressions, transcript tests, browser transcript and authored-text scenarios, type checks, frontend build and bundle budgets, and repository lint passed.

* test(desktop): verify history errors through shared reader

Problem: CI checks for slice error handling in a file that no longer owns it.

Root cause: History preparation moved fetching and failure propagation into the shared reader while the source-only assertion retained the old location.

Fix: Assert the store delegates to the shared reader and verify that reader rejects an actual error response.

Verification: History failure and preparation lifecycle tests passed.

* fix(shell): preserve non-ASCII commands across interactive PTYs

Problem: Desktop launches without a UTF-8 locale can hang on Chinese
commands until the persistent Bash deadline expires.

Root cause: Readline interprets raw high bytes as meta input before Bash
parses the command wrapper. Variable-width control escapes can also absorb
following octal digits.

Fix: Encode non-ASCII and control bytes as fixed-width octal escapes in
the shared ANSI-C quoting boundary. Preserve the original command bytes,
user locale, and persistent shell state.

Verification: Reproduced the pre-fix timeout; passed locale, Unicode path,
grep, emoji, quoting, heredoc, long-input and cancellation/recovery PTY
regressions, Bash/Zsh byte round trips, public Bash tool/schema tests,
the persistent-shell race suite, and targeted Go vet.

* fix(runtime): preserve UTF-8 across terminal and output boundaries

Problem: Integrated Bash still corrupts non-ASCII keystrokes under C locale,
tool progress can split characters into invalid JSON text, and GUI-launched
children without a locale use unintended byte-oriented text semantics.

Root cause: Terminal and subprocess environments lacked explicit text
defaults, while progress and bounded output sliced arbitrary byte offsets.

Fix: Select an installed UTF-8 locale only for unconfigured child environments.
Preserve user inputrc settings while enabling eight-bit Bash terminal input.
Buffer incomplete progress characters through EOF/cancellation and align
bounded output slices without raising byte limits. Preserve the head on the
first oversized write and document the shared contracts bilingually.

Verification: Native Bash PTY tests cover missing/C/UTF-8 locales and retained
editing preferences. JSON round-trip, byte-cap, head/tail, cancellation, locale
priority, sed character semantics, tool schema and focused race tests pass.
Targeted Go vet and documentation/cache metadata guards pass.

* fix(locale): use the shared background process constructor

Problem: The desktop process-constructor guard rejected the new locale probe.
Root cause: The helper called os/exec directly instead of the repository's
shared process entry point.
Fix: Route the bounded locale discovery through internal/proc while retaining
its explicit environment and deadline.
Verification: The desktop constructor guard, native Unicode terminal tests,
locale/secrets package tests and targeted Go vet pass.

* docs(desktop): refresh terminal migration inventory locations

Problem: The generated desktop inventory was stale after the terminal edit.
Root cause: Source line locations changed when locale defaults were wired in.
Fix: Regenerate both inventory artifacts with their owning generator.
Verification: go test ./tools/desktopinventory passes, including the no-Git
reproducibility check.

* fix(session): restore cold history without clicks and bound catalog rebuild memory

Problem: Cold historical sessions could remain loading or appear as empty new
sessions until another navigation action refreshed them.

Root cause: Busy rebuild slots dropped metadata work, pending metadata was
treated as blank, and completion did not refresh the sidebar. Each workspace
also scanned the entire catalog, while metadata replay retained full bodies.

Fix: Queue deduplicated metadata work with two workers, preserve pending titles,
refresh only while metadata is pending, and prepare obsolete history indexes
asynchronously. Read workspace members directly and stream catalog reduction
while retaining only IDs, short previews, and required metadata.

Verification: Root Go suite, Desktop workspace/history tests, targeted race
tests, Windows amd64 session suite, sidebar and transcript browser tests,
frontend build and typechecks, and repository lint passed. Repeated 512 MiB
synthetic history reductions passed retained-heap and reclamation checks.

* fix(windows): disable restricted Bash and validate native shell startup

Problem: Windows Git Bash can fail during MSYS initialization before even a
simple command executes, and repeated startup attempts hide the useful error.

Root cause: host discovery does not validate the actual sandbox environment;
automatic selection favored MSYS, persistent startup discarded its diagnostics,
and interactive shells used a different Windows sandbox lane than one-shot shells.

Fix: use only native PowerShell for Windows auto selection, reject explicit
POSIX shells in restricted execution, and retain explicit Bash in full-access
mode. Preflight shells and child processes under the actual policy, retain
bounded startup evidence, suppress repeated failed starts, and align interactive
shell sandbox selection with one-shot execution. Preserve sandbox enforcement.

Verification: full root Go suite, owning-package tests, race tests, go vet,
Desktop Shell/Terminal tests and frontend TypeScript checks passed on the local
fix branch. Windows test binaries cross-compiled. Added native Windows policy
and write-boundary acceptance tests; real Windows execution remains pending.
This change does not claim to repair MSYS restricted-token compatibility.

* Restore the v1.38.6 project-tree sidebar and drop the creation layout / 恢复 v1.38.6 项目树侧边栏并移除创作布局

Problem: The SessionID cutover replaced the workbench sidebar session list
with the workspace-browser grouping (archived toggle, per-workspace rows),
which lost the v1.38.6 project-tree UI and interactions.

Fix: Render the ProjectTree in the workbench sidebar again (variant
workbench, trash entry restored) while keeping the current SessionID-era
bridge underneath. Delete the creation layout end to end — sidebar feature
zone, creation geometry clamps, dock/panel creation modes, the composer
context-ring gate and the desktopLayoutStyle preference chain — so the
desktop ships the workbench layout only, and remove the "Desktop style"
settings row with its locale copy and mocks. WorkspaceSessionBrowser and
its CSS/test/locale keys are removed with it.

The Go config field and host bindings stay untouched for config
compatibility; the frontend simply no longer reads or writes them.

Verification: pnpm typecheck; pnpm build (lint/check gates + bundle
budgets); node scripts/run-tests.mjs --keep-going (368/369; the single
topicbar-actions-lifecycle failure reproduces identically on the pristine
base commit under suite load and passes standalone).

问题:SessionID 切换把工作台侧边栏的会话列表换成了 workspace-browser
分组(已归档开关、按工作区行展示),丢失了 v1.38.6 的项目树 UI 与交互。

修复:工作台侧边栏重新渲染 ProjectTree(workbench 变体,恢复回收站入口),
底层继续使用当前 SessionID 架构的 bridge。端到端删除创作布局 —— 侧边栏
功能区、创作几何钳制、dock/面板 creation 模式、composer 上下文环开关以及
desktopLayoutStyle 偏好链路 —— 桌面端固定只提供工作台布局,并移除设置中的
「桌面风格」行及其文案与 mock。WorkspaceSessionBrowser 及其 CSS/测试/文案
一并删除。

Go 侧配置字段与宿主绑定保持不变以兼容既有配置,前端不再读写。

验证:pnpm typecheck;pnpm build(lint/check 门禁 + bundle 预算);
node scripts/run-tests.mjs --keep-going(368/369;唯一失败的
topicbar-actions-lifecycle 在未改动的基线提交上同样复现,单测独立通过)。

* fix(desktop): keep running sessions switchable after archive and project change / 归档与跨项目切换时保留正在运行的会话

Problem: Archiving a session, or creating a new session in another project
and then switching back to a running session, froze the Desktop UI.

Root cause: GetRuntimeStateSnapshot held its projection mutex across
controller reads, so archive emission deadlocked with a live turn.
keepOnlyVisibleTab snapshotted running sessions before detaching them.
ActivateTopic and OpenSession looked up detached runtimes by file path
only, so they built a competing controller instead of reattaching the
session:<id> owner.

Fix: Sample runtime projection off the projection mutex. Detach running
sessions without snapshotting. Look up live runtimes by session identity
and unique topic, and reattach that owner on ActivateTopic/OpenSession.
Prove OpenSession identity with Stat instead of a full live history
snapshot.

Verification: go vet .; go test -count=1 -run
TestRuntimeStateProjectionDoesNotHoldMutexAcrossControllerRead|TestPruneRunningSessionDoesNotSnapshot|TestActivateTopicReattachesDetachedRunningSession|TestBlankOnOtherProjectThenOpenSessionReattaches

* refactor(shell): keep launch validation within repository lint limits

Problem: The integrated shell fixes exceed function-size limits and introduce
small file-size and comment-budget regressions detected by pre-push repolint.

Root cause: Launch preflight and result classification expanded existing
execution functions; UTF-8 and Windows probe wiring added three source lines
across two already-baselined files.

Fix: Extract Bash launch validation and foreground result classification while
preserving execution order, environment selection, failure metadata, and lease
ownership. Shorten the octal-escape comment without losing its invariant.
Adjust only terminal.go's measured file-size baseline by two lines and
controller.go's by one; retain function-size and global ceilings.

Verification: Repository lint, Go vet for the three affected shell packages,
their full package tests, and git diff --check pass.

* fix(shell): acknowledge bounded PTY input before executing long commands

Problem: Linux CI exposed silent truncation of long Unicode commands, while
root/Desktop lint and Windows fixtures rejected the integrated branch.

Root cause: ASCII escaping expanded a command beyond canonical PTY input
limits. Readline mode changes also made queued multiline input unreliable.
An interrupted staging attempt could fall back to one-shot execution because
the user command had not started. Windows fixtures still expected restricted
Bash execution and an auto-selected Bash cache contract.

Fix: Stage bounded ASCII blocks with per-block acknowledgements, then eval the
assembled value in the same shell. Keep partial commands unexecuted on failure
and prevent fallback after the persistent runtime resets. Align Windows policy
assertions with native shell selection and pin the existing Bash golden fixture
without changing its artifacts. Resolve root/Desktop lint findings using range,
slices.Contains, and strings.CutPrefix without weakening checks.

Verification: Long-line contract regression failed before the transport fix;
the fallback regression failed before its guard. Five repeated Unicode/native
PTY runs, full persistent-shell/builtin/sandbox suites, focused race tests,
boot goldens, navigation/terminal regressions, root/Desktop lint, repository
lint, inventory freshness, and whitespace checks pass locally. Native Linux
and Windows acceptance is delegated to the replacement CI run.

Cache-impact: No new provider-visible changes; the golden fixture explicitly
selects Bash while existing Windows auto-selection changes remain documented.

* test(shell): select canonical input mode before Bash starts reading

Problem: The Linux canonical-input regression exited during setup before
submitting its long command.

Root cause: Switching off Readline inside eval with stdin redirected to
/dev/null changes Bash's input reader during a temporary descriptor binding.
The fixture unintentionally tested that transition instead of canonical input.

Fix: Launch the fixture with --noediting so its input mode is established
before setup. Retain long Unicode input, shell-state, and output assertions.

Verification: The focused native PTY regression passed five consecutive runs;
package lint, repository lint, and whitespace checks passed. Native Linux
acceptance is checked by the replacement CI run.

* fix(ci): isolate native Windows desktop test groups

Problem: Native Windows desktop checks exhausted a shared 25-minute step deadline before the final group completed.

Root cause: The first two sequential groups consumed about 19 minutes 42 seconds including build overhead; the first group itself approached the default package timeout.

Fix: Partition the suite across four independent Windows runners. Validate current native inventory for unique coverage, preserve the required aggregate check and existing deadlines, and retain the separate ConPTY probe and systray test once.

Verification: All 17 partition/workflow tests, actionlint, release workflow contracts, repository lint, native inventory validation, and whitespace checks passed.

* fix(ci): classify Windows shards and remove duplicate full sweep

Problem: The new native Windows matrix was missing from the desktop migration inventory. Non-PR Windows runs also executed the entire root module before executing the same coverage through partitioned jobs.

Root cause: The explicit migration classifier was not updated with the new job, and the generic full-suite condition included Windows despite its existing disjoint selectors.

Fix: Classify the matrix and its aggregate, regenerate both inventory artifacts, and restrict the generic full sweep to Linux and non-PR macOS runs. Windows retains full plus agent, boot, and control groups.

Verification: Full root Go tests, 18 partition/workflow contracts, inventory freshness, actionlint, repository lint, and whitespace checks passed. The unchanged protocol-recovery test also passed ten consecutive native local runs.

* fix(ci): let superseded Windows workers cancel

Problem: Superseded native Windows desktop groups kept their concurrency slot and blocked the next PR run.

Root cause: always() kept the costly matrix workers running even after workflow cancellation.

Fix: Gate workers with !cancelled() while retaining preparation checks and the always-running result aggregate.

Verification: The cancellation regression, all 18 workflow/partition tests, actionlint, inventory freshness, repository lint, and whitespace checks passed.

* test(ci): verify macOS routing by behavior

Problem: The macOS workflow contract rejected the corrected Windows deduplication condition even though macOS routing was unchanged.

Root cause: The assertion matched the obsolete expression text instead of evaluating platform and event behavior.

Fix: Check actual macOS PR, push, manual-run, and disabled-step conditions for the full and platform suites.

Verification: The exact complete release workflow contracts CI step passed locally, along with repository lint and whitespace checks.
2026-09-16 07:06:32 +08:00
SivanCola 8c7da64809 Preserve retracted input ownership across repair and restart
Problem: A repair batch restoring a retracted input outside an active turn restored its body but left the original turn hidden in catalog metadata.

Root cause: Retraction discarded the message-to-turn association, and an unscoped upsert could not recover it.

Fix: Persist and clone identity-only retraction ownership through checkpoints and bounded log replay, then consume it when restoring the same ID. Remove obsolete cleanup helpers reported by CI.

Verification: The new checkpoint/log-replay regression failed before the fix and passes now. Retraction and provider-policy focused tests, session race tests, golangci-lint 2.12.2 and repository lint pass. Existing native package evidence predates this follow-up.
2026-09-16 03:49:33 +08:00
SivanCola c3694a0e07 Make turn termination append-only and fence late writes
Problem: Stopping a turn and reopening another conversation could make durable history unreadable.

Root cause: Cancellation replaced display history through divergent strict decoders and reset message-version watermarks; asynchronous terminal paths also lacked one durable acceptance boundary.

Fix: Share event payload validation, preserve version watermarks, add required message retraction with revision-2 writer migration, and unify cleanup/recovery through atomic turn finalization. Fence stale generations and synchronize history, search, catalog and fork projections without rewriting pause history.

Verification: Focused deterministic and race regressions, provider HTTP byte comparisons, root and Desktop module suites, frontend transcript/browser tests, old-binary compatibility probes, and macOS/Windows ARM64 portable production-mode pause/switch/reopen workflows passed. Final reply-anchor refinement passed its owning session suite. Installer lifecycle and Windows x64 hardware remain unverified; see docs/session-termination.md.
2026-09-16 03:44:33 +08:00
Suryaprakash Pandey f7d61f4e65 fix(cli): clip setup menu rows to the terminal width
Problem: in a terminal narrower than the header or a row, every arrow
keypress in the selectOne/selectMany menus (reasonix setup) left a copy
of the previous frame's header and wrapped rows on screen.

Root cause: redraw moves the cursor up by the number of lines printed,
but a line wider than the terminal soft-wraps onto extra rows, so the
cursor-up stopped short of the frame's top and the new frame was drawn
below the old one.

Fix: emit every frame line through menuFrame, which clears the row, ends
with CR LF, and clips the content to the terminal width; termSize now
reports columns as well as rows. Scroll indicators and the search bar no
longer end with a bare LF, which left the cursor mid-row in raw mode.

Verification: go test ./internal/cli; go vet ./internal/cli;
go run ./tools/repolint; golangci-lint run ./internal/cli; before/after
run of reasonix setup in a 60-column pty.
2026-09-15 22:36:01 +05:30
XTLine 1381c08408 fix(serve): answer free-identity status polls with the queried route
A serve restart loses the mirror registry. A spectator tab pinned by a
writer that then exits could never clear its banner by polling: the
identity was neither held nor mirrored, so /status fell through to the
foreground snapshot — which names a different session, and a pinned tab
discards foreign-route payloads. Answer an explicit route-matching view
with takenOver=false instead; the desktop's polled transition barrier
then re-hydrates the tab without a manual switch.
2026-09-15 22:50:06 +08:00
XTLine c6e8fa1e83 fix(cli): prune dead serve state files from takeover discovery
A restarted desktop respawns the serve on a new port while the old state
files stay behind; /takeover then dialed the dead address and surfaced a
bare connection-refused that could shadow (or, during a restart window,
replace) the live record's result. Discovery now skips records whose
recorded PID no longer exists, and the identity takeover re-runs discovery
once when every record fails, so a takeover racing a serve restart heals
instead of erroring.
2026-09-15 22:33:58 +08:00
XTLine c9fa88f559 fix(desktop): re-hydrate the remote view after ownership returns
A successful reclaim (or a polled takenOver true->false transition after an
auto-reclaim) cleared the spectator pin but never published the ready
barrier, so the surface kept the spectator-era projection — a frozen runtime
epoch that also dropped the re-owned writer's live frames — until the user
switched sessions away and back. Publish the same ready->ready barrier a
session rotation relies on; the view re-hydrates and accepts live frames
again.
2026-09-15 22:24:32 +08:00
SivanCola b21deef03e fix(site): temporarily pin Desktop downloads to v1.38.3 (#10375)
Problem: The website needs to offer Desktop v1.38.3 temporarily.
Root cause: Download selection always prefers the newest published Desktop release.
Fix: Add a website-only version pin with exact manifest and GitHub fallback validation; keep labels and fallback links pinned, and hide newer-shell guidance and unavailable architecture DMGs. Clear the pin to resume latest selection.
Verification: All 67 site tests pass, Astro production build passes, browser download URLs resolve to desktop-v1.38.3 while CLI remains v1.38.9, and all eight manifest assets return HTTP 200.
2026-09-15 22:23:33 +08:00
SivanCola a8d4a82e5d fix(release): read the manual exception allowlist in postflight too (#10374)
verify-stable-release-artifacts.sh kept its own copy of the v1.38.8 pin,
so postflight refused to certify v1.38.9 even though every publisher had
already succeeded and every public surface was correct. It also asserted
one release's prior updater version rather than the invariant that matters.

Route it through scripts/manual-desktop-exception.sh like the other three
callers, and assert that neither updater entry point serves the release
being published. Pin desktop-v1.38.9 to its now-immutable candidate, which
makes further runs against it recoveries, as the row shape intends.
2026-09-15 14:09:25 +00:00
SivanCola 8966856b5f ci: run only darwin-specific packages on pull-request macOS legs (#10366)
Problem:
test (macos-latest) runs the full root `go test ./...` on every pull request
(351s measured) while the Windows legs run a smoke subset and the race job
sweeps only concurrency packages. macOS is the one platform with no
pull-request tiering, and public repositories get five concurrent macOS
runners of which a single pull request already claims three.

Root cause:
The test step's condition is `runner.os != 'Windows'`, so Linux and macOS
share one branch. Nothing distinguished the darwin-specific packages - the
only root-module code a Linux leg cannot prove - from the rest.

Fix:
Split the step the way Windows and race already are: Linux keeps `./...` on
every event, macOS runs the darwin group on pull requests and the full sweep
on pushes. The ten owning packages are selected by a script rather than an
inline list, and a test walks the tree for _darwin sources and build tags to
fail when a package owns darwin code outside the group, or when a declared
root no longer owns any - so the list cannot rot in either direction.

Extract the package listing, prefix test and runner shared by both platform
lanes into scripts/go-test-groups.mjs. Windows group membership, its -p
choices and its exported surface are unchanged, so its own tests still pin
the same contract.

The trade this makes explicit: a macOS-only regression in a portable package
now surfaces on the main-v2 push rather than on the pull request. That is the
risk the Windows smoke lane already accepts.

Verification:
- node --test over the full lint-code script set (74 tests)
- node scripts/macos-go-tests.mjs darwin on macOS: 10 packages, all ok, 45s
- python3 -c 'yaml.safe_load(...)' on ci.yml
- go run ./tools/repolint
2026-09-15 22:05:45 +08:00
SivanCola b94b4e3162 ci: gate every validation job behind a required aggregate (#10360)
Problem:
PR #10326 merged into main-v2 while desktop-windows-go was failing. The
ruleset requires only lint, race and the three test legs, so the entire
desktop suite and several root jobs decide nothing at merge time.

Root cause:
The skip-aware aggregate jobs exist precisely to be required checks, but the
desktop aggregate never listed the native platform jobs - desktop-macos,
desktop-windows, desktop-windows-go and desktop-windows-package only fed
ci-metrics, which summarizes timing and asserts nothing. windows-control,
windows-isolated, sdk, site and coverage had no aggregate at all. A related
hole was worse than the reported one: on a desktop-Go-only pull request a
failed desktop-prepare skips every desktop job, and both the lint and
desktop-browser aggregates take their "not required" branch, so the pull
request goes green with no desktop validation at all.

Fix:
Extend the desktop aggregate over the four native jobs and add a root
aggregate over the remaining ones. PACKAGE_REQUIRED mirrors
desktop-windows-package's own condition rather than testing for push, so a
workflow_dispatch run does not expect a skip from a job that runs. Normalize
site's condition to include notes_only like every other gated job, so a
release-notes-only push cannot run it while the aggregate expects a skip, and
give coverage the always() the rest of the file uses. govulncheck stays out
and says why: continue-on-error makes its result success even when it fails.

Route site and sdk through the CI routing contract as well, so a pull request
that edits the gates actually exercises them instead of satisfying their
skip expectations trivially.

Close the mechanism rather than this instance: a new test walks the needs
graph from the required checks and fails when a job is reachable from none of
them, with an explicit advisory list that must stay exhaustive.

This commit changes no ruleset. Adding the new names as required checks is a
separate step that must follow a soak on real push events.

Verification:
- node --test scripts/ci-workflow.test.mjs scripts/ci-paths.test.mjs
- node --test over the full lint-code script set (73 tests)
- python3 -c 'yaml.safe_load(...)' on ci.yml
- go run ./tools/repolint
2026-09-15 22:05:22 +08:00
XTLine f73fe7e3fc fix(desktop): preserve terminals across session takeover
A takeover switch must not tear down the tab's terminal surfaces; the
budget accommodates the guard.
2026-09-15 21:41:55 +08:00
XTLine dce96fe9b1 fix(cli): keep the TUI alive after session reclaim
Reclaiming one session no longer terminates the CLI: the yield releases
the canonical runtime binding and writer, the reclaimed conversation stays
rendered with a notice, and only the switch/takeover/exit commands are
accepted while other conversations remain usable. The canonical marker
survives mirror re-adoption so a reclaimed session can be retaken, and
shutdown races between the reclaim marker and user-initiated quits are
fenced so the process neither hangs nor exits with a held writer.
2026-09-15 21:41:54 +08:00
XTLine b28cf91040 feat: take over final-format sessions across serve, CLI, and desktop
Final-format identities had no takeover path: /handoff, /ownership,
/reclaim and /mirror-end only resolved legacy transcript paths, and the CLI
resume of a writer-held session surfaced a raw refusal. The session service
gains a writer-lock occupancy probe and directory resolver; the ownership
endpoints accept session-id routes (mirror registry keys routes verbatim);
/handoff quiesces the foreground, rotates it, and closes the handed-off
runtime so the writer lock drops before the grant is answered; /reclaim,
/mirror-end and stale auto-reclaim re-own through OpenSession; /resume
mounts a caller as a read-only spectator when another runtime owns the
writer (204 + taken-over header), /history serves the cold event log, and
/status answers ownership both ways so spectator pins clear over polling.
The CLI records the identity route on refusal, /takeover asks every
resident serve for a handoff and attaches via OpenSession, and the desktop
keeps a held tab attached read-only so the reclaim banner drives the
give-back. Live frames follow the re-owned/rotated identity.
2026-09-15 21:41:12 +08:00
XTLine 9840044f47 fix(cli): resume surfaces list final-format sessions beside legacy transcripts
After a workspace migrates into sessions-v4 the resume pickers still
scanned only legacy .jsonl files: migrated conversations vanished while
their frozen sources stayed listed. The pickers now merge the catalog with
legacy rows through one workspace scan — migrated sources and engine mirrors
fold into a single row, migration borrows the frozen source's label, and
never-chatted placeholders hide. --continue compares the newest across both
stores; /rename's index space follows the merged list.
2026-09-15 21:39:03 +08:00
XTLine ad399b2d63 fix(cli): make /takeover work when the lease holder PID misses every serve
The takeover offer required the holder PID to match a discovered serve.
Stale state files and serve restarts break that match, so the offer now
accepts any lease error and the execution falls back to trying every local
serve (#9477).
2026-09-15 21:39:03 +08:00
XTLine 89ffbd36d0 fix(desktop): never mark a background remote session as interrupted
Only the foreground path may carry the unknown-freshness marker; a
background session the desktop is not watching must never render the
connection-interrupted badge.
2026-09-15 21:39:03 +08:00
XTLine 9ddb8d57a9 fix(remote): close the session-id migration gaps across serve and desktop
Legacy-to-identity migration left the desktop tree, the serve listing, and
the CLI pickers disagreeing about which sessions exist. The serve rows now
carry preview/turns/metadata readiness, route identity sessions by
session-id:, and fold engine mirrors of in-flight transcripts; the desktop
prefers the session id over the legacy path everywhere it routes; frame tags
keep the session id across controller swaps, migrations re-point the tag at
the new identity, and the serve boot seeds it for exclusive sessions.
2026-09-15 21:39:02 +08:00
github-actions[bot]andSivanCola dc915ab97b docs(release): 准备 v1.38.9 中英更新日志 (#10361)
* docs(release): prepare v1.38.9 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): restore v1.38.9 upgrade notes after regeneration

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: SivanCola <[email protected]>
2026-09-15 20:43:55 +08:00
SivanCola e4bfeb67f9 test(session): assert v5 header identity in platform-portable form / 修复 v5 头部测试的跨平台断言 (#10358)
* test(session): assert v5 header identity in platform-portable form

main-v2 has been red on Windows since the SessionID-only persistence
merge; its own push CI was cancelled by a later merge, so the breakage
only surfaced when the release gate demanded a green code ancestor.

Four failures share one mechanism: the new header tests assert POSIX-only
expectations against behavior the writer deliberately normalizes.
header.go cleans CWD, so a session created with "/workspace" is stored as
"\workspace" on Windows; clean the expectations the same way the writer
does. Windows has no POSIX permission bits and always reports 0666 for a
regular file, so assert the owner-only header mode only where the OS can
carry it.

Also repair TestRebuildSlotsCancelDoesNotDropGrant, which cancels strictly
before releasing and then demanded that the grant win. Both interleavings
are correct; the invariant that matters is that no grant is lost. Assert
that instead: a successful waiter owns the slot, and a cancelled waiter
must leave the freed slot available.

* test(session): fit the grant-invariant comment in the lint budget
2026-09-15 11:51:12 +00:00
SivanCola b4c6315f8d fix(cli): restore YOLO permission shortcuts (#10356)
Problem: Ctrl+Y no longer entered YOLO, and Shift+Tab omitted the unrestricted permission posture.

Root cause: CLI shortcut handling treated permission selection and Plan as separate paths while retaining legacy conservative YOLO migration semantics.

Fix: route mode shortcuts through one state machine, map the visible YOLO label to danger-full-access, preserve safe restore behavior, and document the four-state cycle.

Verification: go test ./...; go test ./internal/cli ./internal/i18n; go run ./tools/repolint; git diff --check.
2026-09-15 19:27:05 +08:00
github-actions[bot]andSivanCola 4a6d98283b docs(release): 准备 v1.38.9 中英更新日志 (#10351)
* docs(release): prepare v1.38.9 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): record v1.38.9 upgrade notes for manual downloads and Session v5

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: SivanCola <[email protected]>
2026-09-15 18:50:22 +08:00
SivanCola b752d4f95c test(desktop): close session services before their store root is removed (#10354)
Problem:
desktop-windows-go failed only on TestDesktopSessionServiceIsSharedAcrossWorkspaces
with "TempDir RemoveAll cleanup: ... recovery-v1.bolt: The process cannot
access the file because it is being used by another process".

Root cause:
The test registered t.Cleanup(app.closeSessionServices) before calling
t.TempDir() for the canonical root. t.Cleanup is LIFO, so the directory was
removed while the service still held its recovery database. POSIX permits
unlinking open files, which is why only Windows reported it.

Fix:
Pin the root through one helper that creates the directory first and registers
the service close afterwards, so the order cannot be written the wrong way
round, and use it from the fork fixture that already had to get this right by
hand.

Verification:
- cd desktop && go test . -count=1 -run 'TestDesktopSessionServiceIsSharedAcrossWorkspaces|TestCreateForkForTab|TestNewAppPinsDesktopV5SessionRootBeforeStartup|TestShutdownReleasesCachedSessionOwner'
2026-09-15 18:50:02 +08:00
SivanCola 601563b08d fix(site): probe every approved manual desktop tag instead of only the newest (#10353)
Pinning the manual-download source to the next tag before that release
exists makes both of its sources 404, so the page silently falls back to
the updater pointer and serves an older version than the one already
published for manual download.

Probe every approved manual tag and keep selecting the newest model that
actually resolves. Listing the next tag ahead of its release is now inert
until the release exists, and a later signed stable release still
supersedes the whole list.
2026-09-15 10:09:38 +00:00
SivanCola 77d9f640d9 release: own the manual Desktop signing exception in one place (#10352)
The unsigned manual-download exception was spelled out three times: the
stable orchestrator preflight, the desktop signing-mode gate, and the
GitHub release publisher each pinned v1.38.8 and its candidate SHA
inline. Approving a second release that way needs a near-duplicate patch
in every copy, and any copy that drifts silently widens the exception.

Move the allowlist into scripts/manual-desktop-exception.sh and have all
three callers validate against it. A row pins a candidate SHA only when
its tag was already immutable at approval time; those rows stay recovery
-only. A row approved before its candidate exists carries no SHA and is
rejected under allow_recovery=true, so the signing exception can no
longer be used to skip candidate and push-CI validation.

Approve desktop-v1.38.9 as such a row, make the release-page disclosure
name the release it belongs to instead of a fixed version, and point the
website's independent manual-download source at v1.38.9. Desktop update
pointers still never advance while the exception is active.
2026-09-15 10:06:48 +00:00
SivanCola 8f93ea2b2e Adopt SessionID-only Desktop persistence / 升级 Desktop SessionID 会话架构 (#10326)
* feat(session): add immutable desktop session headers

Problem: Desktop session identity and persistence diverged across tabs, topics, and workspaces.

Root cause: Local routing depended on project paths and disposable Topic projections instead of one durable SessionID authority.

Fix: Advance the staged SessionID-only Desktop cutover while preserving remote and provider-visible behavior.

Verification: Focused owning-package tests cover this stage; the assembled branch is validated by local gates and the pull request CI matrix.

* feat(desktop): add durable workspace session registry

Problem: Desktop session identity and persistence diverged across tabs, topics, and workspaces.

Root cause: Local routing depended on project paths and disposable Topic projections instead of one durable SessionID authority.

Fix: Advance the staged SessionID-only Desktop cutover while preserving remote and provider-visible behavior.

Verification: Focused owning-package tests cover this stage; the assembled branch is validated by local gates and the pull request CI matrix.

* refactor(desktop): use one canonical session service

Problem: Desktop session identity and persistence diverged across tabs, topics, and workspaces.

Root cause: Local routing depended on project paths and disposable Topic projections instead of one durable SessionID authority.

Fix: Advance the staged SessionID-only Desktop cutover while preserving remote and provider-visible behavior.

Verification: Focused owning-package tests cover this stage; the assembled branch is validated by local gates and the pull request CI matrix.

* feat(desktop): bind canonical sessions to workspaces

Problem: Desktop session identity and persistence diverged across tabs, topics, and workspaces.

Root cause: Local routing depended on project paths and disposable Topic projections instead of one durable SessionID authority.

Fix: Advance the staged SessionID-only Desktop cutover while preserving remote and provider-visible behavior.

Verification: Focused owning-package tests cover this stage; the assembled branch is validated by local gates and the pull request CI matrix.

* feat(desktop): make session lifecycle registry transactional

Problem: Desktop session identity and persistence diverged across tabs, topics, and workspaces.

Root cause: Local routing depended on project paths and disposable Topic projections instead of one durable SessionID authority.

Fix: Advance the staged SessionID-only Desktop cutover while preserving remote and provider-visible behavior.

Verification: Focused owning-package tests cover this stage; the assembled branch is validated by local gates and the pull request CI matrix.

* feat(desktop): complete session id lifecycle cutover

Problem: Desktop session identity and persistence diverged across tabs, topics, and workspaces.

Root cause: Local routing depended on project paths and disposable Topic projections instead of one durable SessionID authority.

Fix: Advance the staged SessionID-only Desktop cutover while preserving remote and provider-visible behavior.

Verification: Focused owning-package tests cover this stage; the assembled branch is validated by local gates and the pull request CI matrix.

* feat(desktop): replace topic sidebar with session browser

Problem: Desktop session identity and persistence diverged across tabs, topics, and workspaces.

Root cause: Local routing depended on project paths and disposable Topic projections instead of one durable SessionID authority.

Fix: Advance the staged SessionID-only Desktop cutover while preserving remote and provider-visible behavior.

Verification: Focused owning-package tests cover this stage; the assembled branch is validated by local gates and the pull request CI matrix.

* docs(desktop): document session v5 authority

Problem: Desktop session identity and persistence diverged across tabs, topics, and workspaces.

Root cause: Local routing depended on project paths and disposable Topic projections instead of one durable SessionID authority.

Fix: Advance the staged SessionID-only Desktop cutover while preserving remote and provider-visible behavior.

Verification: Focused owning-package tests cover this stage; the assembled branch is validated by local gates and the pull request CI matrix.

* refactor(desktop): isolate session persistence plumbing

Problem: The SessionID cutover added lifecycle, fallback, and persistence logic directly to legacy hotspot files, exceeding incremental repository quality budgets.

Root cause: New Desktop session authority state and controller fallback paths were represented as independent App and WorkspaceTab scalars and inline branches.

Fix: Group Desktop session state by lifetime, extract controller/model fallback and prune persistence helpers, relocate the focused prune regression test, and ratchet repolint baselines to the new lower aggregate counts.

Verification: go test focused Desktop SessionID persistence/migration/fallback cases; go vet for root session/control and Desktop modules; frontend typecheck, hooks lint, workspace browser test, CSS checks; repolint and git diff checks.

* fix(frontend): account for SessionRef bridge payload

Problem: App memory preparation failed because the SessionID cutover exceeded the initial raw JavaScript and CSS budget by 4.6 KiB, while every compressed and per-chunk budget remained green.

Root cause: SessionRef bridge methods plus Workspace Session browser locale and type bindings add a small unavoidable startup payload.

Fix: Ratchet only the initial raw payload ceiling from 2434.2 KiB to 2439.0 KiB, preserving 0.2 KiB headroom and leaving gzip, CSS, and chunk limits unchanged.

Verification: pnpm build passes all lint, architecture, CSS, typecheck, Vite build, and bundle budget checks at 2438.8 KiB measured raw payload.

* fix(desktop): complete SessionRef navigation contract

Publish an explicit hydration-ready signal after an exact SessionRef commits, and expose active identity plus bounded expansion in the Workspace Session browser.

Move all local browser benchmarks onto one layout-independent session navigation helper, align the browser mock with native hydration semantics, and refresh generated inventory and lint contracts.

* fix(bench): await session projection readiness

Treat Composer readiness and Workspace Session projection readiness as independent phases before deciding whether a benchmark target requires bounded expansion.

* fix(frontend): preserve tab identity on mock session open

* fix(session): close projection workers at shutdown

* test(desktop): close session services deterministically

* fix(desktop): fence canonical sessions by SessionRef

Problem:
Canonical Desktop tabs could still key caches, hydration and imported workspace membership through compatibility paths. Empty paths could collide, and legacy imports could publish a session before immutable workspace ownership existed.

Root cause:
SessionRef was not carried through every Meta and clear transition, async fences compared partial identities, and the legacy import publication did not atomically include the v5 SessionHeader.

Fix:
Use full SessionRef plus generation for frontend cache and hydration identity; return SessionRef from Meta and clear; validate registry attachment against immutable header cwd; publish legacy and paired-preview imports with manifest and header in one atomic directory publication; add targeted identity, import and workspace tests. Merge the latest main-v2 and regenerate protocol and Desktop inventory artifacts.

Verification:
- go test ./internal/session ./internal/control ./tools/desktopinventory -count=1
- go test ./desktop targeted protocol/session/workspace regressions
- go test -race targeted internal/session and desktop identity tests
- frontend SessionRef/hydration/cancel suites and test:typecheck
- pnpm build with existing bundle budgets
- go run ./tools/repolint

* fix(session): remove obsolete import wrapper

Problem:
The SessionHeader import refactor left the old headerless publication wrapper unreachable, and CI's unused check correctly rejected the branch.

Root cause:
All callers had moved to publishLegacyImportWithHeader, but the compatibility helper remained after the cutover.

Fix:
Delete the dead wrapper and document the single atomic Header-aware publication path.

Verification:
- golangci-lint run --timeout=5m ./...
- go test ./internal/session targeted import and query tests
- git diff --check

* fix(session): publish host-owned headers on legacy rebuild import

Problem:
Desktop model and settings changes that rebuilt a legacy-path session failed
with "session header does not match requested ownership", so the workspace
could not restart with the corrected root and history stayed unloadable.

Root cause:
Rebuild imported the legacy transcript through the one entry point that
published no immutable SessionHeader. The later create validation compared the
requested Desktop ownership against a session directory that carried no header,
so every rebuild-imported session was permanently unusable rather than merely
unlabelled.

Fix:
Carry SessionCreateOptions through boot.Options and rebuildWithPrevious so the
rebuild import publishes header and manifest in the same atomic transaction as
the other two Desktop import entry points. Desktop derives those options from
the canonical runtime root, the resume path uses the Header-aware control call,
and the superseded headerless rebuild wrapper is deleted now that it has no
callers. Header validation reports which field disagrees instead of one opaque
message.

Verification:
- go test ./internal/boot -run TestRebuild -count=1
- cd desktop && go test . -count=1 -run 'TestModelSettingsRunningToolContinuationKeepsOldConnection|TestEnsureBlankTab'

* fix(desktop): pin the canonical v5 session root at App construction

Problem:
Fork creation failed with "publish fork workspace membership: session: nil
session query", and canonical sessions could be created without the immutable
workspace header the Workspace registry attaches by.

Root cause:
desktopSessionState left root empty until initializeDesktopSessionRoot ran
during startup. Any path that reached the session service before that point -
fork membership publication in particular - resolved a nil service and could
not query or publish canonical state at all.

Fix:
Give newDesktopSessionState the production v5 root so a constructed App always
owns one canonical root, and keep the legacy-derived fallback only for
zero-value Apps in narrow tests. Cover the construction-time invariant
directly, and make the fork and v3 catalog fixtures create sessions through
real workspace ownership instead of headerless shortcuts.

Verification:
- cd desktop && go test . -count=1 -run 'TestCreateForkForTab|TestNewAppPinsDesktopV5SessionRootBeforeStartup|TestDesktopSessionServiceIsSharedAcrossWorkspaces|TestDesktopV3CatalogResumeRenameAndDeleteUseSessionIdentity|TestShutdownReleasesCachedSessionOwner'

* fix(session): stop cold-query workers before closing live runtimes

Problem:
TestDesktopSessionServiceIsSharedAcrossWorkspaces failed only on Windows, where
the temporary session store could not be removed after shutdown.

Root cause:
Shutdown ran CloseAll first and closed the query side afterwards. A cold-query
worker could open a recovery projection after CloseAll had already collected
the runtime set, so that Bolt handle outlived shutdown and kept the file locked
on Windows.

Fix:
Close and join the query workers before CloseAll, so no worker can reopen a
projection once live runtimes are being released.

Verification:
- go test ./internal/session -count=1
- cd desktop && go test . -count=1 -run TestDesktopSessionServiceIsSharedAcrossWorkspaces

* fix(desktop): keep the unavailable fork action a single stable control

Problem:
The app-memory gate reported a persistent three-node DOM drift after GC in
every shard, so the memory attribution check never settled.

Root cause:
An unavailable fork action rendered a second, id-bearing sr-only element whose
useId changed on each transcript remount. Tooltip and React lifecycle kept the
superseded text nodes referenced briefly, so repeated navigation left three
extra nodes behind every settle.

Fix:
Move the unavailability reason into the button's accessible name and delete the
generated description node and its id, so the action is one control with no
remount-sensitive identity.

Verification:
- cd desktop/frontend && node --test src/__tests__/turn-fork-transcript.test.tsx

* test(desktop): target the visible new-session action in browser fixtures

Problem:
The app-settings-motion browser group timed out for 30s waiting for
.sidebar__quick-action after the remote-project assertion.

Root cause:
The fixture hard-coded the old sidebar markup. The Workspace session browser
renders the new-session action as .sidebar__new, so the locator could never
resolve once that surface replaced the Topic sidebar.

Fix:
Resolve the new-session action through one shared helper that accepts either
visible surface, so layout changes do not silently strand the fixture.

Verification:
- cd desktop/frontend && pnpm test:app-browser

* fix: keep allocation size hints free of arithmetic

Problem:
CodeQL reported four high-severity "size computation for allocation may
overflow" alerts on this branch.

Root cause:
Three capacity hints added a length to a constant or to a second length. The
sum is only a hint, but CodeQL correctly flags unchecked arithmetic that feeds
an allocation size.

Fix:
Size each allocation from the single length that is known to bound it and let
append grow the rare extra element.

Verification:
- go build ./...
- go test ./internal/plugin -count=1

* fix(session): make Shutdown release runtimes a client never unbound

Problem:
desktop-windows-go failed every fork and shared-service test with
"TempDir RemoveAll cleanup: ... being used by another process" on the session
ownership lock and the recovery Bolt file, alongside
"close session service err=session runtime still has client bindings".

Root cause:
Shutdown is the documented boundary a host must cross before releasing the
store root, but it delegated to CloseAll, which refuses a bound runtime and
returns without closing it. A binding a client failed to release therefore
stranded the writer lease and recovery handles for the rest of the process.
POSIX hides this because unlinking an open file succeeds; Windows reports it
as an undeletable directory.

Fix:
Split the refusal policy from the teardown. CloseAll keeps refusing bound
runtimes, because tearing one down under a live client is worse than leaving
it. Shutdown is terminal: it drops the leaked binding, closes the runtime so
the lease and recovery handles are released, and still reports ErrRuntimeBound
so the leak stays visible rather than being swallowed.

Verification:
- go test ./internal/session -count=1
- go test ./internal/session -race -run 'TestService(CloseAll|Shutdown)' -count=1

* fix(desktop): keep workspace runtime activity visible in the session browser

Problem:
The desktop-browser group failed on "background jobs keep project activity
visible" once the Workspace session browser replaced the Topic sidebar in the
workbench layout.

Root cause:
The activity indicator lived inside ProjectTree, so replacing that surface
silently dropped the capability: a workspace running background jobs no longer
showed activity anywhere in the workbench sidebar. The paired assertion that
activity clears also became vacuous, because an element that never renders
trivially satisfies a count of zero.

Fix:
Move the rule to its own owner. RuntimeActivityIndicator matches the runtime
store against a workspace identity instead of a ProjectNode, so both sidebars
render the same activity state from one implementation, and the indicator
class is no longer named after one surface. Both fixtures now assert the
shared indicator, so the clearing check has something real to observe.

Verification:
- cd desktop/frontend && node bench/runtime-state.mjs
- pnpm test:typecheck && pnpm lint:hooks && pnpm build

* test(desktop): warm every measured fixture before the memory baseline

Problem:
All three app-memory shards reported post-gc-dom-or-listener-drift: the node
count stepped up by exactly 3 on the first windowed round trip and then stayed
flat for the remaining 128+ round trips across every phase.

Root cause:
The prelude warms the geometry and full fixtures with real round trips, but
the only windowed visit before the baseline is the safety excursion, which
runs with deliberately invalid geometry. The windowed surface therefore
reached its first healthy render after the baseline was taken, so its one-time
bounded setup was measured as displacement from a baseline that was never warm
for that fixture. Per-navigation instrumentation confirmed the step happens
once and never repeats, and the same sequence on the legacy sidebar is flat.

Fix:
Warm the windowed fixture the same way as the other two before sampling. The
gate keeps its full accumulation power: every phase still samples against this
baseline every 32 round trips, and the drift rule is unchanged.

Verification:
- cd desktop/frontend && REASONIX_APP_MEMORY_PROFILE=short node bench/app-memory.mjs
  (baseline 6533 stable, flat through full/windowed/safety/mixed/settled, verdict PASS)
2026-09-15 17:47:41 +08:00
SivanCola cbc05ce00b feat(bash): persist cwd and environment across foreground calls / 前台 bash 跨调用保留工作目录与环境 (#10327)
* feat(bash): persist cwd and environment across foreground calls / 前台 bash 跨调用保留工作目录与环境

Foreground bash now reuses a session-scoped PTY so cd, exported variables,
and shell functions survive across ordinary calls. Background jobs, per-call
write-root escalations, and host terminals stay one-shot. The bash tool
schema and description are unchanged.

Verification: persistentshell PTY tests (cwd, env, timeout reset, nonzero
exit), builtin bash persistence and schema guards, owning boot/agent tests,
repolint clean.

* Make the persistent bash PTY faithful to one-shot execution

Problem: the session PTY changed what the bash tool returns. A command whose
output did not end in a newline (printf without \n, echo -n, cat of a file with
no final newline) ran to the full foreground deadline, returned nothing, and
took the session shell with it. Large output cost ~88s of CPU for 6.9 MiB
against a 120s deadline. Terminal line-discipline artifacts injected blank
lines into results, a foreground command's background child leaked its output
into the next command, stdin never reached EOF so any prompting command burned
the deadline, live progress was unbounded, and the sandbox spec was rebuilt
without the session-private temporary directory the same call's environment
pointed at.

Root cause: the marker protocol required the status marker to start a line and
the reader re-scanned the whole accumulated transcript on every 4 KiB read. The
wrapper spanned two physical lines, normalisation mapped every carriage return
to a newline, and the persistent launch path duplicated sandbox and output-cap
decisions that the one-shot path owns.

Fix:
- Match markers as substrings and require status digits terminated by a
  newline, so a mid-line marker settles the command and echoed wrapper source
  still cannot fabricate a completion.
- Stream each PTY read once through a sanitizer and a tail-anchored capture:
  cost is linear in output, terminal control sequences are stripped, and a
  carriage-return run before a newline is one line break.
- Send the wrapper as one physical line via ANSI-C quoting and detach stdin, so
  multi-line commands raise no PS2 and prompting commands fail as they did
  under one-shot execution.
- Report partial output plus an explicit reset notice when a command times out,
  is cancelled, or loses its shell.
- Route commands with a background operator to the one-shot path, which is the
  only place the #3702 process-group reap and output isolation hold.
- Reuse the shared foreground caps from shellrun for both final output and live
  progress instead of re-deriving them, and carry the session-private temporary
  directory into the persistent sandbox launch.

Verification:
- go test ./internal/persistentshell ./internal/shellrun ./internal/tool/...
  ./internal/boot ./internal/agent ./internal/control
- go test ./... (145 packages, no failures)
- go test -race ./internal/persistentshell ./internal/shellrun
- go vet, gofmt, golangci-lint (0 issues), go run ./tools/repolint (clean)
- 6.9 MiB of output: 88s before, 760ms after

* Resolve the persistent launch sandbox spec once

The per-call spec is derived twice for the same launch, and specForCall
recomputes effective write roots each time. Bind it once so the enforce
check and the sandbox argv cannot drift apart.

* Do not park on an end marker whose trailer is not a status

Problem: a shell tracing its own input (set -x) prints the end marker
followed by wrapper source. The reader withheld from that marker and
waited for digits that never came, so the command consumed its whole
deadline and lost the shell.

Root cause: status parsing could not distinguish an incomplete trailer
from a complete one that is not digits, so both were treated as pending.

Fix: parseStatus reports pending separately. A complete non-status
trailer is emitted as output and scanning continues; \r before the
terminator is accepted for CRLF hosts.

Verification: go test ./internal/persistentshell ./internal/tool/builtin
./internal/shellrun; golangci-lint; go run ./tools/repolint

* Run persistentshell in the Windows smoke lane

The PowerShell persistence test only runs on Windows, and the Windows
smoke package list did not include internal/persistentshell, so the
ConPTY and PowerShell wrapper paths had no CI evidence on any lane.

Verification: node --test scripts/ci-workflow.test.mjs; selectPackages
now returns reasonix/internal/persistentshell for the smoke group.

* Keep PowerShell hosts on one-shot execution

Problem: adding internal/persistentshell to the Windows smoke lane showed
the PowerShell session shell never starts: TestPersistentShellPowerShellKeepsLocation
fails with "persistent shell startup: context deadline exceeded". The
path had no CI coverage on any lane, so the failure shipped unnoticed.

Root cause: a PowerShell host echoes submitted input and settles readiness
through a prompt protocol. The marker wrapper here speaks neither, so the
ready token never arrives and startup exhausts its deadline.

Fix: persistentshell.Supports reports PowerShell as unsupported, Manager.Run
rejects such requests with ErrUnavailable, and the bash tool checks the
shell before building a persistent launch. PowerShell hosts keep one-shot
execution, which is fully correct; the unusable PowerShell wrapper and its
helpers are removed rather than left as dead code.

A real persistent PowerShell needs a prompt-marker readiness protocol and
echo stripping, which is follow-up work, not a wrapper tweak.

Verification: go test ./internal/persistentshell ./internal/tool/builtin
./internal/shellrun; golangci-lint; go run ./tools/repolint
2026-09-15 16:43:52 +08:00
SivanCola c0f904bb45 Attach a CDP browser to sessions with no Electron shell / 为无 Electron 外壳的会话接入 CDP 浏览器 (#10328)
* Add a CDP browser executor for sessions without an Electron shell / 为无 Electron 会话增加 CDP 浏览器执行器

The browser tools already run on a host-neutral Executor that the desktop
shell and the SSH broker each implement, so CLI, Serve, and headless
sessions register the tools and then fail closed with no host behind them.

This adds a third implementation that drives an external Chrome over the
DevTools Protocol. A raw browser keeps no ledger, so the package owns the
contract's refusals: operationIds are single-use forever, a snapshot mints
the opaque documentToken its refs are bound to, navigation and take-over
retire it, and only a failure after input already reached the page reports
an unknown outcome. Refs and the take-over listeners live in a per-document
isolated world the page cannot reach, and only tabs this executor opened
are visible so an attached Chrome's own tabs stay the user's.

Nothing here is provider-visible: the tool schemas stay in internal/browser
and the system-prompt prefix is unchanged.

* Attach the CDP browser to sessions with no shell / 为无外壳会话接入 CDP 浏览器

The browser tools now have a host in CLI, Serve, and headless sessions. A
new [browser] config section turns the CDP backend on; a host that already
brought a browser (the desktop shell, Serve's SSH broker) keeps it and stays
responsible for its lifetime.

Attaching is lazy: the browser appears on the first browser tool call and
never at session start, so a session that does not use one pays nothing. The
controller's cleanup chain reaps a browser this process launched.

The page helper grew an accessible-name order that puts an element's label
above its placeholder, and stopped naming a container after everything inside
it, both found by running the new opt-in end-to-end test against real Chrome.
The page struct's document state moved into one value that navigation and
take-over retire together, which is also what clears its struct-state finding.

Cache-impact: none. The tools stay registry-only behind use_capability, so
the request's tool array and the prompt prefix are byte-identical with the
backend enabled.
Cache-guard: internal/boot TestConfiguredBrowserBackendStaysOffTheProviderSurface

Baseline: boot.go file-size 2020→2021 and function-size 1713→1714 for the one
line that returns the backend's shutdown; config.go file-size 1514→1515 for
the [browser] field; render.go file-size 955→961, function-size 906→912 and
complexity 227→228 for the two render call sites (the renderer itself lives in
the new config/browser.go); repo function-size 8090→8097.

* Confine browser_upload to the task's own directories / 把 browser_upload 限制在任务自己的目录内

Problem: browser_upload took any path the model named and handed it to a file
input. A page is untrusted, so an unrestricted file input is an exfiltration
channel: a visited site could receive any file this process can read. CodeQL
flagged the same two flows as go/path-injection on #10328.

Root cause: the Electron host enforces task ownership in the shell, and the
new CDP backend inherited the tool's promise that only task-owned files can be
attached without inheriting anything that keeps it.

Fix: resolve every candidate through EvalSymlinks and refuse it unless it sits
under the session's write roots or the executor's own artifact directory, so a
downloaded file stays attachable while a symlink out of the workspace does not.
Boot passes the same write roots the file-writing built-ins use. Screenshots
now name their file from the tab ID this executor minted rather than the
caller's argument, and artifactPath refuses any name that is not a leaf.

Verification: go test ./internal/browser/... ./internal/boot/ ./internal/config/;
go test -race ./internal/browser/cdp; REASONIX_LIVE_CHROME=1 live Chrome test;
golangci-lint on the changed packages; go run ./tools/repolint.

* Enforce upload containment with os.Root / 用 os.Root 强制上传包含性

Problem: the containment check compared cleaned strings after os.Stat had
already run on the model-supplied path, so the filesystem was touched before
the path was proven to be the task's. CodeQL kept reporting go/path-injection
at that stat.

Root cause: EvalSymlinks-then-compare puts the sanitizer after the first
filesystem access and expresses containment as string arithmetic, which is
also weaker than it looks once a root is itself reached through a link.

Fix: open each root with os.OpenRoot and stat the candidate through it. A root
refuses traversal and a symlink leaving it, so containment is enforced where
the file is actually reached rather than inferred beforehand. Roots are also
recorded in their symlink-resolved form so /tmp and /private/tmp name the same
directory for a caller who used either spelling.

Verification: go test ./internal/browser/... ./internal/boot/ ./internal/config/;
REASONIX_LIVE_CHROME=1 live Chrome test; golangci-lint; go run ./tools/repolint.
2026-09-15 14:55:14 +08:00
SivanCola a28df91fa0 Release the cold-open session writer lease in sessioncapacity / 释放 sessioncapacity 冷启动阶段的会话写者租约 (#10325)
Problem: `test (windows-latest)` failed on main-v2 immediately after #10321 with
`TestRunExercisesColdOpenAndHistoryIndex` reporting `TempDir RemoveAll cleanup:
unlinkat ...\sessions-v4\.recovery-cache\small-capacity\recovery-v1.bolt: The
process cannot access the file because it is being used by another process`.
The PR leg never ran this package: the Windows job runs a reduced test step on
pull requests and `test (full)` on pushes, and only the push step covers
tools/.

Root cause: run opens a second service for the cold-open phase and deferred
only `second.Query().Close()`. Closing the query releases the query cache but
not the runtime, so the writer lease and the bbolt recovery store stayed open
until process exit. POSIX unlinks open files, so this was invisible off Windows.

Fix: release the query cache together with the writer lease and recovery store
through one helper. The helper keeps run within the repository function-size
limit. Add the testenv lease guard to this package's TestMain so the same leak
fails on every platform instead of only in the Windows push leg.

Verification: reverting the fix makes the new guard report the leaked
`.small-capacity.ownership.lock` and fail the package, and it passes with the
fix; go test ./tools/sessioncapacity -count=2, go test -race on the owning and
session packages, go test ./... (144 packages), and make lint (golangci-lint 0
issues, repolint clean with no baseline change) all pass.
2026-09-15 14:12:10 +08:00
SivanCola b568386bcc Repair the Windows test job: writer leases, watch helper, stream cost / 修复 Windows 测试任务:写者租约、监听 helper、流式开销 (#10321)
The push CI `test (windows-latest)` job has failed on every completed run since
#10267 merged, and its 20-minute budget also expired. Four independent root
causes are fixed here; each was reproduced and re-verified on a real Windows 11
virtual machine by cross-compiling the affected package test binaries before and
after the change.

* fix(skill): serve the watcher helper entry from test binaries

Problem: internal/skill and internal/skill/skillwatch failed on Windows with
"timed out waiting for physical watch registration" and "physical watches = 0",
and the two packages took 90s and 70s.

Root cause: Windows always routes watching through the helper backend, which
starts by re-executing os.Executable(). Under `go test` that is the package test
binary, which never calls MaybeRunHelper, so each spawned child re-ran the whole
suite instead of serving the pipe. Registration timed out, the client burned its
restart budget, and every root degraded to scan fallback.

Fix: call MaybeRunHelper from the TestMain of the affected test binaries. Add
TestDefaultHelperCommandServesTheRealBinaryEntry, which forces the same default
command so a missing entry fails on every platform rather than only on Windows.
Also wait for registration instead of asserting it immediately, and compare
discovered catalog content rather than two stores' independent rebuild counters,
which were never required to be equal.

Verification: on Windows the baseline binary left 501 orphaned test processes
behind and failed; the fixed binary passes and leaves none. Locally the guard
reproduces the exact timeout when the entry is removed (69.5s) and passes with it
(0.5s).

* fix(session): release session writer leases at their owning boundary

Problem: about thirty tests across internal/bot, internal/serve and
internal/session failed Windows cleanup with "The process cannot access the file
because it is being used by another process" on a sessions-v4 ownership lock.

Root cause: a Service holds its writer lease until CloseAll or its idle TTL, and
ClientBinding.Release only detaches a client. Test fixtures created services and
never closed them, so the lease was still held when t.TempDir removed the
directory. POSIX unlinks open files, so the leak was invisible outside Windows.
BotGateway.Stop had the same gap in shipping code: it retired session states but
never released the per-root service cache, so a stopped gateway kept its leases
for the process lifetime.

Fix: close the service at each fixture's owning boundary, and release the cached
services from BotGateway.Stop after the last state is retired, mirroring the
existing desktop shutdown. Two tests that left a running execution bound now idle
it first, because a bound runtime refuses to close. Add
filelock.HeldPathsForTest and testenv.VerifyNoLeakedFileLocks, wired into
RunWithIsolatedUserState and the session and control TestMain, so a leaked lease
fails on every platform and names the leaking test through its temp path.

Verification: the guard reproduces the exact Windows failure set on macOS. On
Windows, internal/session went from 17 failures to none, internal/serve from 16
to none, and internal/bot from 1 to none.

* fix(control): wait for close finalization in the controller fixture

Problem: TestTurnDoneCarriesValidatedCheckpointAcrossSuccessAndError
intermittently failed cleanup with "directory not empty" under load.

Root cause: Controller.Close only starts teardown; finalizeControllerClose joins
the goal-driver workers that may still write under SessionDir. The fixture waited
on closeFinalized only when a SessionService was supplied, so other controllers
raced t.TempDir removal.

Fix: make that wait unconditional.

Verification: six concurrent suite runs reproduced the failure before the change
and were clean after it.

* test(serve): drain the message-field stream without thousands of round trips

Problem: TestCanonicalSessionMessageFieldHTTPStreamsAlignedFragments took 88.8s
on Windows CI and was the largest single contributor to the job timeout.

Root cause: it read a 144 KB body 64 bytes at a time, costing about 2250
localhost round trips. Rune- and escape-safe cutting is a property of the cut
size, not of the number of cuts.

Fix: keep narrow 64-byte reads for the first fragments so boundaries still land
inside multi-byte runes, then drain the remainder in large fragments. Every
existing assertion is retained.

Verification: on Windows the test went from failing at 10.66s to passing at
0.20s; round trips dropped from about 2250 to about 11.
2026-09-15 13:08:36 +08:00
SivanCola faaaa1b790 Replace dual session turn authority with a Harness-style loop / 用 Harness 式 turn-loop 取代双重会话执行权威 (#10291)
* refactor(session): replace dual Activity/Controller turn authority with a Harness-style loop

Problem
Stop raced history/replace and TurnDone against session.Activity, which
revoked commit authority and poisoned the turn ledger. The next send then
failed to mint a durable turn ID.

Root cause
Controller running/finishing/parkedTurns and Runtime.Activity were two
lifecycle authorities. Cancel stopped the Activity permit before
finalization wrote terminal session facts.

Fix
Make the session-scoped turn-loop the only execution authority and keep
Session as persistence authority. Bind the loop to Runtime by generation
so an old Controller unbind cannot clear a replacement. Commit Session
first, then the compatibility ledger. Stop uses a fresh cancel context
per turn and no longer blocks history/replace, turn/end, or later
admission.

Verification
- go test ./internal/session/ ./internal/control/
- go test -race ./internal/session/ ./internal/control/
- go test ./... (cli WSL clipboard and desktopinventory are unrelated)
- cd desktop && go test ./...
- GOOS=windows GOARCH=amd64 go build ./cmd/reasonix
- GOOS=windows GOARCH=amd64 go test -c ./internal/control ./internal/session desktop

* fix(control): keep the turn-loop compatible with fork and Harness close latch

Problem
CI on #10291 failed after main-v2 landed persistent turn fork: tests still
assigned Controller.running, which the turn-loop refactor removed. lint-code
also failed because two test files grew past their repolint exceptions.

Root cause
The fork running-turn fixture wrote the deleted field. Orchestrator cancel
tests also set RuntimeCancelling when CancelRequested() only needs the
cancelRequested flag.

Fix
Map the fork fixture onto turns.phase, keep orchestrator cancel fixtures on
cancelRequested only, and lock Close to drop a latched wake so a disposed
session never starts the next turn.

Verification
- go run ./tools/repolint
- go test ./internal/control ./internal/session ./internal/serve
- go test -race ./internal/control ./internal/session
- go test ./...
- cd desktop && go test ./...
- GOOS=windows GOARCH=amd64 go test -c ./internal/control ./internal/session
- GOOS=windows GOARCH=amd64 go build ./cmd/reasonix

* fix(control): drop unused turn-loop submissionID field

Problem
lint-code failed on unused: turnLoop.submissionID is never read or written.

Root cause
The durable submission ID already lives on the turn-event ledger, so the
loop field was leftover from the initial struct.

Fix
Remove the unused field. Admission still mints turn IDs through the ledger.

Verification
- golangci-lint run --enable-only unused ./internal/control/
- go test ./internal/control -run 'TestIdleCancelThenSendProducesNewTurnID|TestCloseDropsLatchedWake'

* fix(session): fence rebuild execution ownership

* fix(control): finalize orphaned runtime markers

Problem: Windows control tests retained ownership files after controller shutdown.

Root cause: Close treated a phase marker without a live turn as active, and the test cleanup stopped retrying while an asynchronous finalizer still held a client binding.

Fix: defer teardown only for a live done channel or terminal fanout, and retry test service cleanup for both busy and bound runtimes.

Verification: focused close, terminal-barrier, rotation, branch, and persistence-failure tests pass 10 times.

* chore(lint): ratchet session ownership growth

Problem: the host-wide execution cutover checks exceed existing per-file and aggregate repolint budgets.

Root cause: the safety boundary must be enforced at Desktop, ACP, CLI, boot, and runtime-state publication sites that already carry legacy baselines.

Fix: update only the exact affected file metrics and the measured aggregate function-size ceiling; retain unrelated baseline reductions for future cleanup.

Verification: go run ./tools/repolint reports clean with 1232 baselined findings.

* test(control): await final resource release

Problem: asynchronous controller teardown could let shared-runtime and timeout-recovery tests remove temporary persistence directories before the owning cleanup had completed, producing intermittent file-lock and directory-not-empty failures. The new turn-loop test also tripped the repository WaitGroup modernization lint.

Root cause: tests inferred completion from the runtime pointer or phase, but both can change before binding release and final cleanup finish. The timeout-recovery test also released its deliberately stalled body without joining its exit.

Fix: publish an internal close-finalized boundary, wait for it in controller-owned test cleanup, join the released timeout body, and use WaitGroup.Go for concurrent stop coverage.

Verification: focused ownership/recovery tests passed 30 runs; go test ./internal/control passed; go test -race ./internal/control passed; repository, darwin, windows, and desktop-windows golangci-lint passed.

* test(control): join panic terminal fanout

Problem: the root race job intermittently failed TestRunGuardedPanicEmitsTurnDone even though the panic produced the expected terminal event.

Root cause: the buffered sink exposed TurnDone to the test before the emitter returned and the turn-loop left its intentional finalizing phase, so an immediate Running assertion raced terminal fanout.

Fix: wait on the exact TurnIdleDone lifecycle boundary before asserting that panic recovery returned the controller to idle.

Verification: the focused race test passed 100 runs and the full internal/control race suite passed.
2026-09-15 06:47:25 +08:00
SivanColaanddev 643ad75236 Bound desktop session resources / 限制桌面会话资源占用 (#10295)
* feat(skill): host-shared watch service with Windows helper

Move skill catalog watching from per-Store ownership to a host-lifetime
skillwatch.Service: physical watches are shared across stores per canonical
root, events coalesce per root (100ms window / 500ms max delay), and the
watch scope derives from discovery rules so scripts/assets/references body
churn no longer triggers catalog rebuilds.

Windows registers through a helper process (the host executable re-entered
via an internal entry in cmd/reasonix and desktop) with a framed pipe
protocol, 5s control timeout, a 2-restarts-per-30s budget, and generation-
fenced late-event dropping; macOS/Linux keep in-process native watches
through the same subscription interface. A failed registration degrades the
root to bounded signature scans with 2/5/15/30s backoff — replacing the
retired 250ms Windows polling generation — and diagnostics expose physical
watches, logical subscriptions, scans, scanned entries, notifications,
degraded roots, and helper restarts. Boot wires one shared service into the
enabled and all-skill stores and closes it with the controller.

* feat(session): priority scheduling for the shared index-build slots

The two concurrent background index builds now grant freed slots by
priority: user-requested history pages and recovery first, search second,
catalog/prefetch metadata last. Catalog metadata keeps its drop-on-full
semantics via tryAcquire; history locator and search builds queue per
priority and a granted slot always belongs to its waiter even when
cancellation races the grant, so no build leaks or double-releases a slot.

* feat(session): bidirectional history windows and per-field body reads

ReadHistoryWindow (capability history-window-v1) pages a bounded window
around an anchor — newest, a target message, a target turn, or an opaque
cursor — in either direction, resolving anchors through the locator index
instead of walking pages from the newest position. Pages stay inside the
2 MiB canonical budget, small bodies inline, referenced bodies gain range
credentials, and cursors pin a fixed snapshot: appends keep them valid,
storage replacement or projection rebuild answers stale_cursor. The
preparing/ready/failed/stale_cursor/not_found statuses stay typed.

ReadMessageField streams one top-level field of one message by stable
identity with a 256 KiB bounded fragment, UTF-8 rune alignment, and
never splitting a JSON escape, so concatenated ranges re-parse as the
original value. Field extraction decodes only the requested field, and
reads without a range credential are rejected.

* feat(session): bounded history windows in the resident store

The transcript store now keeps a bounded reading window instead of every page
it has ever fetched. Pages are the unit it holds and reclaims (default 3 of
32-message pages); paging past the budget reclaims from the end the reader is
moving away from and reports the ids the caller must drop, so a caller cannot
render rows the store has already released. The reclaimed direction stays
reachable through its cursor, and page changes replay the session's derived
identity maps over exactly the surviving records, so reclaiming cannot strand
a tool result from its call.

This also removes the active-session exemption from the cache budgets: a live
or pinned session still cannot be evicted, but it no longer holds its whole
history. The page budget applies to every session, and pins protect the
session's identity and live edge rather than an unbounded record set.

Paging becomes bidirectional. loadNewer consumes the newer cursor that
history-window-v1 returns and appends into the same window; a protocol-7
binding reports no newer cursor and keeps its forward paging rather than
being asked to simulate one through full downloads.

History reads now route by the tab's binding identity, resolved from the tab
metadata the controller already loads. The previous local-then-remote retry
let a transient local failure be answered by a different service holding a
different session; a binding that never negotiated history-window-v1 now
answers with a typed unsupported status instead of an error.

Three defects found by the new Serve and Desktop contract tests:

- ReadHistoryWindow dropped the anchor identity on the success path: the page
  builder starts from a fresh value, so anchorMessageId/anchorTurn only
  survived the not_found and stale_cursor returns. Clients place the reading
  anchor and the visible turn range from those fields.
- A cursor the server could not decode was a transport error (HTTP 409)
  rather than the typed stale_cursor answer a client re-anchors from.
- A field the message does not carry (an optional reasoning body) was an
  error rather than a finished empty read.

The skillwatch Windows helper now spawns through internal/proc; a bare
exec.Command left Windows to allocate a console for it and tripped the
desktop process-spawning gate.

* perf(markdown): stamp block identity during the parse

Keeping a previous block's AST object across stream publications used to cost
two JSON.stringify calls per block per commit on the main thread — O(blocks x
block size) of string allocation inside the frame that must stay under the
input-latency budget. The parse already walks every block, so it now stamps
each one with a content fingerprint (FNV-1a over the JSON-visible shape) and
the render path compares two integers plus the key.

The fingerprint is equal exactly when the block's rendered content is equal,
which is what the reuse decision needs. The pipeline test pins the cases a
positional identity would get wrong: an appended reference definition that
resolves a link in an earlier paragraph, and inline emphasis the source does
not spell out.

Streaming commits also stop hashing the whole body for a cache key: a
streaming body is never stored, so the revision is only computed for settled
content and the settled-content lookup is skipped while streaming.

* docs: align the scroll contract with natural flow and bounded history

The scroll contract still described TimelineProjection and TranscriptKernel,
which the transcript retired: it told readers to preserve a window adapter and
a measurement ledger that no longer exist, and it treated every loaded block
as permanently resident. Replace it with the contract the code actually keeps
(natural flow, single writer, stable identity, generation fence) and add the
parts this branch introduced: the bounded reading window with its page budget
applying to the active session, bidirectional paging with reclaim ids,
binding-identity routing, the typed unsupported and stale_cursor answers, and
the renderer budgets with their bounded-preview and explicit-export rule.

The Chinese mirror and the two referring documents follow.

* refactor(session): extract window anchor resolution

ReadHistoryWindow had grown past both the function-size and complexity
ceilings. The anchor switch is a self-contained decision — resolve a request
anchor to a boundary and direction, or answer with a typed status — so it
becomes resolveWindowAnchor, and carrying the anchor identity onto the built
page becomes attachWindowAnchor.

Folding the cursor branch into the helper dropped the snapshot pin, which the
session tests caught immediately: a cursor that does not restore its own
snapshot reads an empty page. Restored, with the reason recorded.

* fix(session): settle the page budget before reading a paged projection

loadOlder and loadNewer captured their projection before enforceBudgets ran,
so a session trimmed down by the budget could hand the caller an item list the
store had already released. Settle first, then read.

* docs(session): list the unsupported status on the window and field pages

* feat(diagnostics): report the bounded window in the session pipeline report

The transcript cache line still described the store as resident sessions plus
byte budgets. The bounded window is what now decides how much history a reader
holds, so the report carries the window's entry count, its page budget and how
many pages were reclaimed — counts and sizes only, no message text.

* fix(session): stop a reclaimed page from stranding its tool results

Reclaiming the oldest page could leave a result row on screen whose call had
just been released: the reader sees an output with no call it belongs to. The
reclaim widens past such rows, and which calls survive has to come from the
records that stay — collecting them while walking the reclaimed page kept the
calls that were leaving and dropped exactly the rows the guard exists for.

The regression test drives page boundaries through a call/result pair, walks
to the head, then grows forward so the oldest page is reclaimed. It fails
without the widening.

Regenerate the desktop migration inventory: four new host commands landed and
the drift gate is part of the root suite.

* feat(desktop): complete session resource governance

Problem: long-running desktop sessions could retain unbounded history and live tails, repeatedly transfer full Markdown sources, overcommit detail reads, and leave decoder or watcher resources without one observable owner.

Root cause: transcript pages, live rows, Markdown work, detail bodies, media, and skill watching evolved under independent lifetime and capacity rules. Active sessions also bypassed parts of the resident-page budget.

Fix: add bounded bidirectional history with typed recovery, shared read and presentation budgets, retained worker document lifecycles, progressive Markdown publication, atomic host-side export, explicit media release, and skillwatch diagnostics. Preserve protocol-7 and remote export fallbacks, and document the compatibility boundaries in English and Chinese.

Verification: frontend production build, typechecks, pretest, transcript/workspace/performance suites, Chromium long-session fixture, targeted Go suites, session/skillwatch race tests, desktop go test ./..., repolint, and generated contract/inventory drift gates. Root go test ./... has only the three pre-existing reasonix-legacy-migrator failures recorded in the upgrade report.

* fix(ci): satisfy Go 1.26 lint contracts

Problem: the hosted golangci-lint job rejected six resource-governance helpers for pre-Go-1.22 loop forms, manual WaitGroup spawning, manual map copying and sorting, plus one unused helper.

Root cause: the local verification covered gofmt, tests and repolint but had not run the CI-pinned golangci-lint v2.12.2 analyzer.

Fix: use integer ranges, WaitGroup.Go, maps.Copy and slices.Sort, and remove the unreachable availability accessor without changing scheduler or watcher behavior.

Verification: golangci-lint v2.12.2 reports zero issues; go test ./internal/session/... ./internal/skill/skillwatch/... and go run ./tools/repolint pass.

* fix(skillwatch): make scope registration deterministic

Problem: Linux CI observed a notification for a change under a discovery-skipped scripts directory, while the same test passed locally.

Root cause: the test scope contradicted its contract by including skipped body directories, and native Subscribe returned before its asynchronous registration settled, making the assertion platform-timing dependent.

Fix: exclude the same skipped body directories from the test scope, wait for native registration before returning from Subscribe, and assert that only the discovery root is watched.

Verification: repeated skillwatch tests 50 times, repeated catalog watch tests 20 times, go test -race ./internal/skill/skillwatch/..., go test ./internal/session/... ./internal/skill/..., golangci-lint 2.12.2, repolint, and git diff --check.

* fix(desktop): preserve legacy history compatibility

Problem: the full frontend CI plan failed when an older desktop binding exposed SessionOpenForTab but not the new history-window command; cancellation coverage also counted the expected terminal-turn window reconcile as a cancellation reload.

Root cause: canonicalHistoryWindow invoked optional window methods without capability detection, and the cancellation test began its measurement before the independent bounded-window settlement completed.

Fix: treat an absent local or remote window method as typed unsupported so protocol-7 paging remains available, while retaining errors from implemented methods; wait for terminal history settlement before asserting cancellation performs no reload.

Verification: session-reader-mock, five repeated cancellation reconcile runs, TypeScript typecheck, ESLint hooks, the exact 415-command frontend CI plan, Go skill/session tests, golangci-lint 2.12.2, repolint, and git diff --check.

---------

Co-authored-by: dev <dev@local>
2026-09-15 03:06:59 +08:00
SivanCola 55c7705913 Own dock file navigation in the running instance / 由运行实例持有 Dock 文件导航 (#10280)
* fix(desktop): own dock file navigation in the running instance

Problem:
Opening a file from a transcript row, a Markdown link, the file tree or a
preview control published a module-level request that WorkspaceDockRegion
rebuilt during render and merged into the panel props, while a single global
AbortController cancelled every file navigation on any dock, tab or workspace
change. Reopening the same file could re-enter that path until React reported
"Maximum update depth exceeded" (#301), and a path opened in one session kept
the presented tool scope a previous session had read it with.

Root cause:
File navigation had no owner. Its request state, its cancellation and its
source/permission cache lived in module-level singletons that any panel could
write to and any unrelated action could cancel, so the same file was identified
by object identity and by a path-keyed tool-call cache rather than by the
resource and the access context a command actually carried.

Fix:
- Add FileNavigationOwner: records keyed by session tab and dock instance,
  holding the resource identity, its access context, the navigation parameters,
  a display revision, a content revision and the lifetime signal. Only an
  explicit command advances a revision, and an unchanged record returns the
  same snapshot reference.
- Split resource identity (host, session, canonical path) from navigation
  parameters (preview/source/reveal-tree, files/changed), so switching the mode
  reuses the preview tab and never changes what the file is.
- Bind access context to each entry: a workspace reference carries no presented
  tool scope, so reopening a path from another entry point cannot inherit an
  earlier presentation's permissions.
- Create the owner per running app instance, register it for the compatibility
  entry points, reconcile it against the open dock tabs and dispose it with the
  runtime; panels only read committed results.
- Remove lib/fileNavigationLifetime.ts and the module-level presented request,
  and keep child dock requests on DockNavigation without a second file-source
  or cancellation path of its own.
- Report command results as FileNavigationOutcome so a cancelled command shows
  nothing and a failed one reports where it was asked from.
- Release a browser preview URL exactly once: the tab owns it after hand-off,
  and the command revokes a URL no tab took.

Verification:
- run-tests.mjs: 352/352 suites pass, including new navigation-instance,
  dock-tab/generation and lifecycle suites; tsc (app and tests), eslint and the
  production build with bundle budgets pass.
- bench/dock-file-navigation.mjs passes in Chromium and Electron for the local
  and remote preview/source/reveal-tree paths and for save isolation.
- Packaged macOS app (commit 09cdab3866 plus this change) reaches
  "desktop service ready" and exits cleanly; a click smoke inside the package
  was not executed, see the PR body.

* fix(desktop): navigate a dock panel from its own contents

Problem:
A click on a file row inside the dock went through the command that resolves
which dock to open. A WorkspacePanel mounted without a dock tab id therefore
wrote its record under whichever file tab the activity bar store returned while
reading another, so the click produced no preview. Three owned workspace suites
(workspace-resize-interaction, workspace-context-menu,
workspace-turn-verification) timed out on it.

Root cause:
FileNavigationOwner offered one entry point that always asked the revealDock
port for a target, even for a command issued by a panel that already is the
target dock. That dock identity is not something a panel-local click resolves.

Fix:
Split the command. `open` still resolves the target dock for callers outside a
dock (a transcript row, a Markdown link); `openIn(scope, command)` commits to a
dock the caller named. WorkspacePanel's local open uses `openIn` with its own
scope, which also restores the previous behaviour of a tree click not touching
the activity bar.

Verification:
- pnpm test:workspace, pnpm test:remote, pnpm test:app-lifecycle and the full
  run-tests.mjs suite pass; tsc and eslint are clean.
- bench/dock-file-navigation.mjs passes in Chromium and Electron.
- New owner assertion: opening in a named dock never calls the reveal port.

* fix(desktop): keep a dock's previews across a session change

Problem:
Switching topic inside one project dropped the dock's previews: the preview
area unmounted, the selected file was lost, and the browser gate plus three
owned workspace suites failed. A record's revision counter also restarted with
the record, so a panel could mistake a new record's first navigation for one it
had already applied. Two cancellation paths in the browser preview leaked the
URL they had created, and a panel acting on its own contents asked the activity
bar which dock to open.

Root cause:
The record conflated the dock instance with the session that authorized reading
it, so any session change looked like a new dock. The navigation revision
belonged to a record rather than to the running instance, so two records could
hand out the same revision. The preview URL was released only after hand-off.

Fix:
- Key a record by dock instance and split the bound scope into the resource
  space (project, remote host) and the session. Another resource space replaces
  the record; another session keeps what is on screen, keeps the preview DOM,
  and rebinds every entry to workspace credentials so a presented tool scope
  never crosses a session.
- Move the navigation revision to the owner, so no two records repeat one.
- Release a browser preview URL at every point where the operation can lose its
  dock, not only after hand-off.
- Split the open command: `open` resolves the target dock for callers outside a
  dock (a transcript row, a Markdown link), `openIn` commits to a dock the
  caller named, which is what a panel acting on its own contents uses.
- Extract the file preview read into `useWorkspaceFilePreview`, keeping
  WorkspacePanel inside its file-size budget instead of raising it.

Verification:
- run-tests.mjs: 354/354 suites pass; pnpm test:workspace, pnpm test:remote and
  pnpm test:app-lifecycle pass; tsc, eslint, pnpm build and repolint are clean.
- bench/app-browser.mjs passes, including the same-project session switch that
  preserves the WorkspacePanel, tree, preview DOM node and selected file.
- bench/dock-file-navigation.mjs passes in Chromium and Electron.
- New regression coverage: a session change keeps the previews and drops the
  presented scope (file-navigation-session), a reopened dock still applies its
  first navigation with a monotonic revision (file-navigation-revision), and a
  superseded browser preview releases the URL it created
  (file-navigation-races); each was confirmed to fail against the defect.

* test(desktop): compare revoked preview URLs exactly

CodeQL flags a URL substring test (js/incomplete-url-substring-sanitization) in
the browser-preview race fixture. The assertion only checks that the preview URL
was revoked, so compare the whole revocation list instead: same guarantee, no
sanitization-shaped pattern.

* fix(desktop): a click always wins over an earlier open

Problem:
Two orderings could still lose the user's action. A synchronous command (a
preview tab, a tree row, the source toggle) did not take the dock over from an
open command that was still resolving, so that command's late result replaced
the file the user had just picked. And a command that moved the remote dock to
another host was cancelled by the panel binding that very host, so the first
open after a host switch did nothing.

Root cause:
Only a newer open command superseded a pending one, and a scope rebind aborted
whatever was resolving even when the aborting bind was caused by that command.

Fix:
- Every synchronous command supersedes the dock's pending open, so a late
  resolution is reported as cancelled instead of replacing the selection.
- A pending open records the resource space it is moving the dock to. When the
  panel binds that space, the record drops the previous space's previews and
  keeps the command, so the bind that the command caused cannot cancel it.

Verification:
- New owner assertions: a click during a slow open keeps its selection and the
  open reports cancelled; a host switch caused by the command still opens the
  file. Both were confirmed to fail with the fix reverted.
- run-tests.mjs 354/354, pnpm test:workspace, pnpm test:remote,
  pnpm test:app-lifecycle, tsc, eslint, repolint and pnpm build all pass;
  bench/app-browser.mjs and bench/dock-file-navigation.mjs (Chromium and
  Electron) pass.

* docs(desktop): state the remote read boundary

A remote presented file is read through the host-scoped entry point and shown
read-only; parity with the local presented entry points would need a bridge
entry point this change does not add. Record that where the read happens so the
next reader does not assume the local guarantee holds remotely.

* fix(desktop): a mount reads the dock record without replaying it

Problem:
Reopening a dock view reset its file filter, so a per-view filter did not come
back when the user switched between two file views (bench/dock-view-state.mjs
"second view restores selection": expected "mod", got "").

Root cause:
The navigation effect applied the record's last navigation on mount. Its side
effects include clearing the filter and dismissing a scoped list, and the
previous implementation never delivered a restored request to a remounted panel
("restoration exposes resources, never old commands"). The selection and view
already come from the record, so replaying the intent only repeated commands the
user did not ask for again.

Fix:
Seed the applied-revision guard from the record on the first render, so a mount
reads the committed navigation instead of re-running it; commands that arrive
while the panel is mounted still apply.

Verification:
- bench/app-browser.mjs, bench/dock-view-state.mjs and
  bench/dock-file-navigation.mjs pass locally; the eleven dock/workspace suites
  pass; tsc, eslint and pnpm build are clean.

* Fix dock resource identity and action outcomes

Problem: file aliases could create duplicate preview tabs, direct bridge failures escaped the typed outcome contract, and the navigation guide described stale lifecycle and cache behavior.

Root cause: preview entries keyed the caller path, local navigation skipped backend path resolution, non-navigation actions did not contain rejected bridge promises, and the standing-instruction task link changed provider-visible prefix bytes.

Fix: add a backend-resolved identity path while preserving read coordinates, revalidate restored paths without outranking live commands, return failed outcomes for bridge errors, remove the standing-instruction link, and align the English and Chinese contracts.

Verification: pnpm test (354 suites); pnpm test:workspace; pnpm test:remote; pnpm test:app-lifecycle; pnpm build; go run ./tools/repolint.

* Update dock navigation fixture path resolvers

Problem:
The real browser and Electron navigation fixture did not expose the canonical path resolver commands used by dock navigation, so clicks failed before content could load.

Root cause:
The fixture still modeled the pre-canonicalization bridge contract.

Fix:
Stub local, presented, and remote path resolution using stable coordinates that match the fixture's read contract.

Verification:
pnpm test:app-browser
pnpm test:dock-electron

* Clarify dock resource identity contract

Problem:
The navigation guide described the session tab as part of resource identity even though the implementation keeps preview identity stable across same-project session changes.

Root cause:
The documentation combined the canonical file coordinate with the access context that authorizes each read.

Fix:
Describe preview identity, access context, and navigation parameters as separate parts in both language variants.

Verification:
- git diff --check
- go run ./tools/repolint
- bash scripts/check-cache-impact.sh

* Stabilize controller idle lifecycle

Problem:
The Windows desktop Go shard could time out waiting for a cancelled planner, and migrator tests could read a user's real pending update.

Root cause:
Shared test helpers polled the combined running and finishing state behind a fixed scheduler timeout instead of observing the controller-owned lifecycle. The legacy migrator package did not install the repository's user-state isolation guard.

Fix:
Add a turn idle boundary covering execution, TurnDone fan-out, and parked turn chains. Use it across control, serve, and desktop tests while retaining a compatibility fallback. Isolate migrator package tests with RunWithIsolatedUserState.

Verification:
- go test ./...
- cd desktop && go test ./...
- cd desktop && go test -race ./...
- focused cancellation and lifecycle repetitions, including -race
- cd desktop && pnpm test
- cd desktop && pnpm build
- frontend browser and Electron dock benchmarks
- go vet for changed root packages and desktop
- go run ./tools/repolint
- bash scripts/check-cache-impact.sh

* Refresh the desktop migration inventory

Problem:
The macOS and Ubuntu root test jobs rejected the checked-in desktop migration inventory as stale after the latest main-v2 integration.

Root cause:
The history recovery change moved HistoryContentForTab from line 1186 to line 1179, while the generated Markdown and JSON inventory still recorded the old source location. The no-Git determinism test compared against the same stale artifact and failed with it.

Fix:
Regenerate both desktop migration inventory artifacts from the final source tree. Keep the cross-platform drift and no-Git checks intact.

Verification:
- go test ./tools/desktopinventory -count=20
- go run ./tools/repolint
- bash scripts/check-cache-impact.sh
- git diff --check
2026-09-15 02:40:04 +08:00
SivanCola 2553bb31c3 Fork a completed turn from the persisted session log / 从持久化会话日志分叉已结束轮次 (#10284)
* feat(session): fork a completed turn from the persisted log

Problem: forking a conversation required the checkpoint engine. A turn whose
checkpoints had been pruned, a session read cold, a read-only session, and a
session another process owns could not be forked at all, and a running session
could not fork a turn that had already finished.

Fix: the session projection records each completed turn's final reply identity
(MessageID) and the last sequence of the commit that closed it
(BoundarySequence). ForkTarget/ForkTargetSet answer from committed events only,
so a live source, an externally-owned source, and a cold read produce the same
targets; the open turn is listed as unavailable with its own reason instead of
disabling every turn. Session.Fork accepts a source with no leased writer and
reads the parent directory from its cold handle, so a child copies the parent's
owned files without taking the parent's writer lease. Service.CreateFork
publishes the child without opening a runtime, switching a controller, or
writing the parent, and one OperationID maps to one child.
Controller.CreateForkSession takes no rotation gate, so a running parent keeps
running.

A cut now lands on the closing commit rather than on the turn/end event: a turn
end and the state that ends with it can share one commit, and a cut inside that
commit would inherit half an operation. Both refusals (open authority, non-atomic
cut) are typed errors that keep their own reason and never trim the commit.

Verification: go test ./internal/session/ ./internal/control/ -count=1; the fork
tests also pass under -race. Provider-request bytes are covered by the existing
v4 round-trip test.

* feat(serve): create a child session without taking over the parent

The existing POST /fork switches the foreground controller and moves the
session lease to the fork, so a remote client cannot branch a completed turn
while the parent keeps running.

Add GET /fork-targets, which lists the foreground session's turns with the
reason each one is or is not forkable, and POST /fork-session, which creates an
independent child from one of them. Neither switches the controller, rebinds
the lease, nor touches the broadcast binding. Both are advertised as the
session-fork-targets-v1 capability so an older desktop refuses clearly instead
of falling back to /fork.

POST /fork and every existing route keep their previous semantics.

Verification: go test ./internal/serve/ ./internal/servecontract/... -count=1.

* feat(desktop): expose create-only fork targets to the shell

Add ForkTargetsForTab and CreateForkForTab to the host bindings: the first
reports a tab's forkable turns without requiring a running turn or a writable
source, the second creates the child and opens it in a new tab. When the child
exists but its tab cannot open, the result still carries the child session id
with Opened false, so the caller recovers that child instead of creating a
second one. Opened is always emitted: a missing field would read as "this build
cannot tell you", which is not the same answer as a failed open.

The remote tab adds ForkTargetsRemoteTab and CreateForkRemoteTab behind the
session-fork-targets-v1 capability, and TabMeta carries forkTargetsSupported so
the renderer can tell "this serve cannot fork" apart from "this session has no
completed turn". A serve without the capability is refused, never downgraded to
the switching /fork.

Generated host contract regenerated with: cd desktop && go run . -emit-contract
frontend/src/generated

Verification: cd desktop && go test -count=1 .

* feat(desktop-ui): gate the fork entry on persisted turn boundaries

The branch entry was enabled from checkpoint data and disabled for the whole
session whenever any turn was running, so a completed turn could not be forked
while the session was busy, and a pruned checkpoint removed the entry even
though the turn's boundary is still in the session log.

The transcript now matches a turn to its target by stable message identity
(ForkTargetView.messageId against the rendered answer's message id), so live
completion, paged history, and cold restore share one mapping, and fork no
longer reads checkpoints or the session-wide running flag. Checkpoints keep
driving file rewind only. Every refusal names itself - turn not finished,
targets still loading, boundary unverifiable, server unsupported, creation in
flight - localized in English, Simplified Chinese, and Traditional Chinese, and
failures reach the existing notice channel instead of being swallowed.

A fork whose tab could not open remembers the child per source tab and turn
target, so repeating the fork shows the same child instead of creating a second
one.

Verification: pnpm typecheck, eslint, and the frontend suite (352/352). Browser
evidence from the real Transcript and from the built app is in
docs/persistent-turn-fork-report.md.

* docs: record the persistent turn fork contract and its evidence

Add the implementation report for the persistent turn fork change: delivered
behavior, the compatibility table for every touched format, the cache contract,
the verification evidence with the exact commands, the known gaps, and the
evidence deliberately not gathered. Note the fixture finding that a lazily
loaded locale dictionary makes a language switch asynchronous, so browser
checks that read localized text must wait on the rendered value.

Document the two new remote endpoints and the session-fork-targets-v1
capability in REMOTE_SESSIONS.md and its Chinese counterpart.

* fix(session): read fork targets without rebuilding session history

Problem: Service.ForkTargetSetFor and Service.CreateFork resolved the source
projection through Query.Snapshot, which for a live runtime returns
Session.Snapshot(). Service runtimes carry ExternalHistory, so that call pages
the entire durable log from disk and rebuilds every provider message, while
ForkTargets reads only Projection.Turns. The remote surface refreshes fork
targets on every turn_done, so each connected viewer paid a whole-log read and
a full projection rebuild per turn, next to the running turn.

Root cause: the read path asked for the UI transcript when it needed turn
boundaries only.

Fix: a live runtime now answers through ExecutionSnapshot, the history-free
projection that already exists for exactly this purpose; a source with no
runtime keeps the cold read unchanged.

Measured on a live runtime with 2000 committed turns
(internal/session/fork_target_bench_test.go, -benchtime=50x -count=5):
median 90.2 ms/op -> 2.43 ms/op, 39.5 MB/op -> 4.3 MB/op,
~244k allocs/op -> ~3.1k allocs/op.

Also in this change:
- Two concurrent creates with the same operation id used to leave the loser
  with a bare rename error, so the serve answered 500 for a child the winner
  had published correctly. A losing race against an identical fork now
  resolves as the idempotent success by re-reading the child's manifest.
  TestCreateForkConcurrentSameOperationIDPublishesOneChild fails 3/3 before
  the fix and passes 3/3 after.
- writeForkChild rejects an empty parent directory instead of passing a
  relative path into the owned-file copy.

Verification: go test ./internal/session/ -count=1; the fork tests also pass
under -race; go test ./internal/control/ ./internal/serve/ -count=1;
go run ./tools/repolint clean.

* fix(desktop-ui): scope fork memory to its session and name the refusal

Problem: the remote fork memory that keeps a child whose surface did not open
was keyed by turn id alone. A remote fork navigates the same tab to the child,
and a child inherits its parent's turn ids verbatim, so forking turn X while
viewing the child returned that same child instead of creating one, and opening
a sibling child in that tab could navigate it to an unrelated session.

Root cause: the remembered child outlived the session identity that produced it.

Fix: the remote memory now records the publishing session alongside the map and
reads it only while the tab still names that session. The check is applied at
read time rather than by clearing in an effect, because the child is remembered
after navigation settles and can land after the identity change.

Also: active_authority is its own block reason with an accurate message
("its boundary cannot carry a new session") instead of reusing the
unverifiable-boundary copy, which was untrue for it; the new key is translated
in English, Simplified Chinese, and Traditional Chinese.

Verification: pnpm typecheck; remote-fork-targets 23/23; fork-child-memory
12/12; turn-fork-transcript; pnpm test:remote 54/54; the new memory tests fail
with the session scope check neutralised.

* chore(desktop): regenerate the desktop inventory for the new bindings

The inventory check (tools/desktopinventory) fails when INVENTORY.md and
inventory.json do not match the exported App surface. This branch adds
ForkTargetsForTab, CreateForkForTab, ForkTargetsRemoteTab, and
CreateForkRemoteTab without them.

Verification: go test ./tools/desktopinventory/ -count=1.

* fix: bind turn forks to durable source anchors
2026-09-15 00:15:51 +08:00
SivanCola 3e33a03ebb Recover session history when model startup fails / 模型启动失败时仍可恢复会话历史 (#10287)
* fix(desktop): recover history when model startup fails

Problem:
A session whose saved model connection no longer exists cannot start its controller, and Desktop reports its durable conversation history as unavailable.

Root cause:
Canonical v4 history reads were routed through the live controller. The frontend also treated the presence of the snapshot API as authoritative even when that API failed before controller startup.

Fix:
Allow canonical history queries to resolve a tab's durable SessionID without a controller, and fall back to the windowed history reader when transcript snapshot installation fails.

Verification:
- cd desktop && go test ./...
- cd desktop/frontend && pnpm typecheck
- cd desktop/frontend && pnpm lint:hooks
- focused session recovery and tab-switch frontend tests

* fix(desktop): recover cold history content

Problem:
Controller-less recovery could restore a protocol 6 history preview but could not expand referenced large message bodies. The initial recovery patch also exceeded existing repository file-size budgets.

Root cause:
HistoryContentForTab only selected canonical storage through a live controller, while the cold branch expected a legacy session path even when the tab already had a canonical SessionID.

Fix:
Resolve cold content through the shared canonical session query, cover open, paging, search, locate, and content reads without a controller, and extract the cold slice helper to keep touched files within their budgets.

Verification:
- cd desktop && go test ./...
- cd desktop/frontend && pnpm exec tsx src/__tests__/new-session-load-race.test.tsx
- cd desktop/frontend && pnpm exec tsx src/__tests__/tab-switch-hydration.test.tsx
- cd desktop/frontend && pnpm typecheck
- cd desktop/frontend && pnpm lint:hooks
- go run ./tools/repolint
2026-09-14 22:37:46 +08:00
SivanCola 98fb5610d7 Resolve and preview files named in chat answers / 识别并预览回答中提到的文件 (#10277)
* feat(desktop): resolve and preview files named in chat answers

Problem: A file the model wrote was only clickable when the turn also called
`present`. Answers that printed a path, files produced by a script, and POSIX
paths on Linux/macOS stayed inert, so readers had to find the file themselves.
An ```svg fence rendered as unhighlighted source, and an SVG on disk offered no
source view because the menu guessed its affordances from the file extension.

Root cause: Body references were never resolved. The link map only held
`present` declarations, path parsing was duplicated per component with a
Windows-only identity rule, and nothing asked the host whether a path an answer
named actually existed. SVG had a sanitizer for remote images but no shared
entry point for model-authored markup.

Fix: One host-verified reference path for every way an answer can name a file.
- desktop/local_path_source.go is the single local-path parser (Windows drive
  and UNC, POSIX, relative, file://); it is extracted from the Markdown image
  resolver rather than reimplemented.
- ResolveChatFileReferencesForTab verifies deduplicated candidates with
  metadata only: workspace containment is proven on real locations after
  resolving symlinks, the current forbid_read policy is re-applied, and
  non-regular files are refused. Batches are capped at 64 items and 4096
  characters, and every read, open, reveal, save, or path resolution
  re-resolves the path, because a successful resolution is not an authorization.
- The renderer extracts candidates from the parsed Markdown AST only (inline
  code spans and local-path links), never from a command, a fenced block, or
  raw markup. A bare filename is matched against the turn's known file set
  instead of being guessed. A scanned POSIX path stays ordinary text until the
  host confirms it, and an unavailable candidate keeps its original text.
- Presented files, written files, and verified references now share one link
  map and one navigation lifecycle: click previews in the file panel, and the
  menu keeps source, reveal, copy path, save a copy, and the system opener.
- SanitizeMarkdownSVG exposes the existing SVG sanitizer as a pure conversion
  with a 1 MiB, 10,000-element, 128-level preview ceiling. An svg fence renders
  the sanitized bytes as an image source, never as markup in the app DOM, with
  a preview/source toggle that survives re-renders. SVG leaves the binary
  extension set so a disk SVG keeps its source view.

Compatibility: No provider-visible bytes change — no system prompt, tool
schema, history, or persisted format is touched, so prompt-cache behaviour is
unaffected. The desktop host contract gains eight commands and regenerates its
digest. Old sessions rebuild references from their existing tool records at
render time; no migration is required.

Verification: New Go resolver, path, and sanitizer tests including a race run;
new frontend tests for extraction, the session owner's batching, dedupe,
failure refresh, and late-reply rejection; TypeScript check, hooks lint, scroll
writer and app-layer checks, production build and bundle budgets; a real
Chromium bench proving the click publishes a reference preview request and the
SVG block renders a picture with a working toggle; existing transcript and dock
navigation browser benches.

* fix(desktop): repair chat reference navigation and SVG preview after review

Problem: Four defects found reviewing the previous commit, two of them
user-visible blockers.

1. Clicking a reference opened the dock with a blank pane. The read was stored
   under a key that included the reader, but the render looked up a key that
   did not, so the preview never matched and the pane fell through to "no
   content" — with no spinner and no error.
   Root cause: the preview key did not carry the reader, and the reader flag
   lived in a ref React could not observe.
2. A model-authored SVG never rendered as a picture. `<svg>` without xmlns is
   how the model usually writes it, and a namespace-free document is not SVG:
   the browser refuses it as an image source. The duplicate-xmlns claim from
   review is rejected — Chromium loads that form — but the missing namespace
   was a real, common-case failure, and the output was malformed XML besides.
3. StrictMode's mount replay disposed the session store permanently, so a dev
   build never resolved a reference.
   Root cause: the cleanup called a terminal dispose while the memoized store
   was not recomputed.
4. The file-facts re-ask could not be reached through the reporting hook, so a
   turn whose text settled before its facts arrived kept a file as plain text.

Fix: Carry the reader in the preview key and keep the reference-owned paths as
render state. Always declare the SVG namespace on the root and drop explicit
xmlns declarations so the encoder emits exactly one; children inherit it.
Count attach/detach with a deferred dispose so a mount replay revives the same
store. Re-report when the reporter identity changes, which is when the turn's
file facts change.

Verification: The browser bench previously stubbed the sanitizer it was meant
to verify, which is how a preview that never loads passed. It now renders the
host's real sanitized bytes, pinned byte-for-byte by
TestMarkdownSVGBenchFixtureMatchesTheSanitizer, covers the namespace-free input,
and asserts the browser decoded the image rather than merely laying out a box.
New tests for the preview-key mismatch, the namespace-free document, and the
StrictMode replay — each confirmed to fail before its fix. Full frontend suite,
typecheck, hooks lint, production build, and the desktop Go suite pass.

* fix(desktop): harden chat file references after review

Problem:
- Crafted SVG prefixes could trigger exponential matching, literal POSIX filename characters could resolve to another file, and ambiguous relative paths were folded with Windows rules before the host verified them.
- CSS-escaped SVG references survived sanitizing, and generated inventory plus size ratchets did not describe the added host surface.

Root cause:
- Prefix recognition used a backtracking expression, chat paths reused Markdown URL parsing, and renderer identity guessed a default platform.
- SVG attribute validation did not account for CSS escapes, while the sanitizer function and generated metadata exceeded repository gates.

Fix:
- Scan SVG prologs and comments linearly, preserve ordinary chat paths for host-native resolution, and keep ambiguous renderer paths literal until the owning host supplies semantics.
- Reject escaped SVG attribute values, split sanitizer token handling into bounded helpers, regenerate the desktop inventory, and raise only the two measured per-file ratchets (18 and 40 lines).

Verification:
- cd desktop && go test ./...
- cd desktop && golangci-lint run --timeout=5m ./...
- pnpm test, typecheck, lint:hooks, build
- pnpm test:chat-file-browser
- go run ./tools/repolint
- go run ./tools/desktopinventory -check

* test(desktop): release chat reference app resources

Problem:
The Windows desktop CI could not remove a chat-reference test directory because a session ownership lock remained open.

Root cause:
The shared chat-reference test helper created an App without registering its shutdown lifecycle. Windows rejects deletion of an open lock file, while Unix unlinks it and hid the leak.

Fix:
Register App shutdown in the helper before TempDir cleanup so controllers, leases, and catalog resources are released on every test path.

Verification:
- cd desktop && go test . -run ^TestChatFileReference -count=10
- GOOS=windows go test -c .
- go run ./tools/repolint

* test(desktop): close direct chat reference app fixtures

Problem:
The Windows desktop test job still could not remove the authorized-external-folder fixture because recovery-v1.bolt remained open after the test.

Root cause:
That test creates two App instances directly and therefore bypassed the shared helper cleanup added earlier. Both instances retained session recovery resources on Windows.

Fix:
Register shutdown cleanup for both directly-created App instances so controller leases and session recovery stores close before TempDir cleanup.

Verification:
- cd desktop && go test . -run '^TestChatFileReference' -count=20
- cd desktop && GOOS=windows go test -c -o /tmp/reasonix-desktop-chat-ref-windows.test .
- go run ./tools/repolint
- git diff --check
2026-09-14 21:40:07 +08:00
SivanCola 236db8c16b Merge pull request #10275 from SivanCola/fix/anchored-popover-lifecycle
Fix anchored overlays and deterministic desktop CI / 修复锚定弹层与桌面 CI
2026-09-14 20:46:23 +08:00
SivanCola f2a0cfb8de Stabilize effort switcher lifecycle test
Problem: CI could attempt to reopen the effort menu before its animation-frame close completed.

Root cause: The test used a fixed 5 ms delay instead of observing the component lifecycle.

Fix: Wait for the closing animation frame and its queued callback before reopening.

Verification: Ran the focused test 20 times, frontend ESLint, the complete 350-suite frontend test run, and test TypeScript checking.
2026-09-14 20:29:08 +08:00
SivanCola 8bb921c033 Keep Composer within the repolint size budget
Problem:
The overlay wiring added three lines to Composer.tsx and exceeded its existing
incremental file-size budget.

Root cause:
New dismissal props were placed on separate JSX lines in a file already at its
allowed baseline.

Fix:
Keep the same props and behavior while grouping them with their adjacent control
props, avoiding a new file-size baseline exception.

Verification:
- go run ./tools/repolint
- pnpm test:composer-menu-viewport
- pnpm exec tsx src/__tests__/composer-run-strip.test.tsx
2026-09-14 20:29:08 +08:00
SivanCola 58b9570537 Fix anchored overlay lifecycle and positioning
Problem:
Model and composer popovers could remain mounted after their trigger became
hidden, briefly rendering at the viewport corner and reopening after a decision
surface released the composer.

Root cause:
Anchored overlays treated any DOMRect as a usable anchor, only repositioned on
selected layout events, and did not consistently clear owner state when the
anchor or composer controls became unavailable.

Fix:
Share strict anchor validation between popovers and tooltips, track valid
anchors with one bounded animation-frame loop, keep unmeasured overlays inert,
and close composer menu owners on suspension, session changes, and transient
dismissal signals.

Verification:
- pnpm test (350 suites)
- pnpm test:composer-menu-viewport
- pnpm typecheck
- pnpm test:typecheck
- pnpm build
- pnpm test:popover-browser
- pnpm test:popover-electron
2026-09-14 20:29:08 +08:00
SivanCola f3e8479fa7 feat(session): add fast recovery and independent history / 增加快速恢复与独立历史 (#10267)
* feat(session): add durable recovery checkpoints

Persist execution projections, recent messages, and operation identities in a per-session bbolt store. Validate the last durable transaction and replay only the post-checkpoint tail during runtime open.

* feat(session): separate recent history and search reads

Route normal session opening through a bounded recent snapshot, an incremental history locator, and an independent FTS search database. Add generation-bound content grants, unified Desktop/Serve/Remote readers, and runtime reuse so index failures cannot block chat.

* refactor(session): split fast recovery responsibilities

Problem: the session recovery change extended several already-large owner files and tripped repository file-size, function-size, and complexity ratchets.

Root cause: locator queries, recovery publication, protocol adapters, and capacity polling were added at legacy integration points instead of dedicated modules.

Fix: extract those responsibilities into focused Go and TypeScript modules, split history index construction helpers, and keep every existing repolint baseline unchanged.

Verification: go run ./tools/repolint; go test ./...; cd desktop && go test ./...; pnpm --dir desktop/frontend typecheck; pnpm --dir desktop/frontend test:typecheck; pnpm --dir desktop/frontend test:transcript.

* fix(desktop): preserve protocol 7 mock hydration

Problem: App memory shards timed out while opening benchmark sessions after the frontend switched to the protocol 7 session reader.

Root cause: The browser mock advertised protocol 7 with an empty recent snapshot, and canonical body hydration changed the stable message id used by history item patches.

Fix: Adapt the existing paged benchmark fixtures to protocol 7, preserve canonical message ids and lazy content reads, and cover the bridge with a focused regression test.

Verification: frontend typecheck and test typecheck; transcript suite; production build; repolint; shortened real-browser app-memory flow completed all 14 navigation and hydration checkpoints with no page errors.

* fix(session): release test owners and adapt protocol fixtures

Problem: PR checks failed on Windows cleanup, Go lint, and frontend session fixtures.
Root cause: idle runtime retention kept test writer leases alive, and legacy test command tables did not expose the new reader contract.
Fix: add explicit service shutdown for unbound runtimes, retire idle bindings after flush errors, clean up test owners, and adapt mock readers while preserving explicit older-host coverage and canonical message fields.
Verification: session, boot, CLI, and control suites; focused lifecycle race checks; root and Desktop lint; frontend typechecks; 70 navigation race assertions; mock hydration regression; repolint. Full suites and native Windows CI continue on this candidate.

* test(desktop): keep host stub compatible with the TypeScript target

Problem: test typechecking rejected Object.hasOwn. Root cause: the test compiler library target predates ES2022. Fix: use Object.prototype.hasOwnProperty.call for explicit protocol overrides. Verification: frontend typecheck, test:typecheck, repolint, and diff checks pass.

* test(control): wait for successful goal runtime retirement

Problem: goal test cleanup intermittently raced temporary-directory deletion. Root cause: the retirement helper reported success even when Close returned runtime busy or bound. Fix: keep waiting until the exact runtime leaves the service. Verification: the affected test passed 30 repetitions and 10 race repetitions; repolint and diff checks passed.

* fix(desktop): preserve recent history identity and pagination

Problem: bounded recent snapshots could hide older history and zero-watermark fixtures defeated cache reuse. Root cause: pagination assumed 100 entries and always marked sequence zero as known; mock reads also discarded source fingerprints and duplicated full-history calls. Fix: derive older availability from the first position/turn, preserve source watermarks, and only materialize fixture bodies for lazy references. Verification: 97 tab hydration assertions, regression for a one-entry bounded window, transcript suite, frontend build and typechecks, real app browser scenarios, and repolint pass.

* fix(desktop): accept empty recent snapshots without locator fallback

Problem: an empty session triggered an extra history read. Root cause: recent snapshot validity was inferred from its generation or non-empty entries. Fix: accept the explicit recent snapshot, including an empty one, without consulting the locator. Verification: empty-snapshot regression, 70 new-session and 97 tab hydration assertions, frontend typechecks, build budgets, and real browser lifecycle/runtime scenarios pass.

* test(control): isolate unlimited goal semantics from disk sync latency

Problem: Windows exceeded the 60-second bound while verifying 257 automatic goal rounds. Root cause: the semantic test also synchronously published recovery checkpoints and performed host fsync for every round. Fix: use controlled sync and skip derived recovery publication only in this fixture; retain all 257-round and completion assertions and real persistence/restart tests. Verification: focused semantic, flush-failure, and restart tests passed three repetitions; unlimited-goal race passed three repetitions; the full control suite and lint passed.

* fix(desktop): release cached session owners during shutdown

Problem: Windows cleanup retained a session writer lock, and the full Desktop package exhausted Go’s default ten-minute timeout. Root cause: shutdown closed controllers but left idle session owners cached; the monolithic test invocation applied one timeout to thousands of cases. Fix: close unbound service owners after controller teardown and split Windows tests into three complete, disjoint name groups without increasing their timeout. Verification: all three Desktop groups passed locally; shutdown/worktree tests passed three normal and race repetitions; lint and repolint passed. Windows inventory: 2,251 functions across groups of 804, 429, and 1,018.

* test(ci): settle controller owners and validate Windows partitions

Problem: Windows control cleanup retained a writer lease, and the CI workflow guard rejected the partitioned Desktop suite. Root cause: controller close does not join foreground completion, while the guard still required a single test invocation. Fix: wait for the owned foreground and busy runtime to settle before fixture teardown, and validate complete non-overlapping test selection without extending Go timeouts. Verification: the full control package and 20 repeated race regressions passed; all 67 workflow tests, control lint, and repository lint passed.

* test(desktop): fence inbox completion by runtime revision

Problem: the remote inbox integration test intermittently inspected a follow-up before its durable consumption. Root cause: its buffered sink still contained an idle notification from the first turn. Fix: require the completion notification to be newer than the observed running follow-up while retaining all durable item and revision assertions. Verification: 100 focused repetitions, 30 race repetitions, the full Desktop Go suite, Desktop lint, and repository lint passed.

* fix(ci): preserve producer identity and bound transcript retries

Problem: rerunning a Desktop consumer compared an attempt-1 artifact with the consumer attempt, while one noisy long-task sample failed the entire transcript job without enough attribution. Root cause: artifact verification used GITHUB_RUN_ATTEMPT from the consumer, and the browser benchmark combined all work into one maximum. Fix: publish the producer attempt, require every consumer to verify against it, split transcript measurements into four timestamped phases, and allow exactly two isolated retries when only the 500 ms long-task limit is exceeded. Functional failures and the 200 ms input P95 remain immediate failures. Verification: artifact and workflow contracts, bounded-decision tests, all 398 frontend CI commands, repolint, and an evidence-producing 1,000-turn Chromium run passed.

* fix(ci): benchmark the production streaming path

Drive transcript performance samples through ControllerLiveStore so the 1,000-turn fixture measures the same node-local streaming path used by the application instead of rebuilding the complete history array for every token.

Persist each completed sample before strict validation and stop immediately when a retry reports an input regression. This keeps the 200 ms input gate and 500 ms long-task gate unchanged while preserving failure evidence.

* test(desktop): drain ConPTY output through exit

Keep the Windows smoke-test reader alive after it observes the marker and queue the exit command in the same write. This removes the race where the test stopped draining ConPTY before the shell could terminate.

Close the pseudo console after Wait and require the reader to finish within the existing 30-second bound. No production behavior or CI timeout changes.

* fix(ci): measure input at the next paint

Record composer input latency at the browser's first animation frame, which is the next paint opportunity. The previous double-frame probe included an unrelated scheduling interval and could exceed the strict gate while no product long task was present.

Keep the 200 ms P95 limit, immediate functional failure behavior, sample count, transcript sizes, and long-task thresholds unchanged.

* fix(ci): classify nondeterministic desktop checks

Apply the same bounded three-sample median rule to input latency and long tasks while keeping functional browser failures immediate. Isolate the hosted Windows ConPTY probe from deterministic Go test partitions so it remains visible without turning a host scheduling stall into a correctness failure.

* test(ci): remove runner timing from correctness gates

Replace small wall-clock upper bounds with existing synchronization and state evidence in Desktop lifecycle tests. Document that shared-runner performance limits belong in explicit sampled benchmarks while unit tests use timeouts only as liveness watchdogs.
2026-09-14 20:27:23 +08:00
SivanCola c725dcbacc Merge pull request #10276 from SivanCola/feature/turn-outline-navigation
feat(desktop): index the complete conversation in the turn navigator / 对话导航条索引完整会话轮次
2026-09-14 19:42:52 +08:00
SivanCola 7f46556dc7 Merge pull request #10281 from SivanCola/chore/app-memory-tiering
Tier App memory screening by risk / 按风险分层 App 内存筛查
2026-09-14 19:39:14 +08:00
SivanCola 636c24fd26 fix(desktop): keep turn jump retries recoverable
Problem:
A failed snapshot refresh discarded the outline reader and refresher, so the navigation retry could never recover. The refresh race test also did not actually overlap a refresh, and the DOM regression slept for six seconds.

Root cause:
Snapshot loading and permanent release emitted the same empty-cut notification. Retry swallowed refresh failures and reused the old entry without resolving it against the rebuilt outline.

Fix:
Distinguish loading, installed, and released cut events; invalidate outline state without unbinding its owner during refresh; propagate refresh failures; await outline synchronization and resolve the stable target again. Replace timing-based tests with controlled interleavings and correct the partial-index copy.

Verification:
pnpm test:transcript
pnpm test:typecheck
pnpm typecheck
pnpm build
pnpm check:scroll-writer
go test ./internal/transcript/ ./internal/serve/
go run ./tools/repolint
2026-09-14 18:57:31 +08:00
SivanCola de46b2ba01 test: make memory workflow routing check portable
Problem:
The workflow routing test writes GitHub output through /dev/stdout, which fails on the hosted Ubuntu runner even though it works locally on macOS.

Root cause:
/dev/stdout is not a portable writable path for a spawned Bash process in this test environment.

Fix:
Use per-case temporary files for GitHub output and clean the temporary directory after the test.

Verification:
- node --test scripts/ci-workflow.test.mjs
- git diff --check
2026-09-14 18:44:51 +08:00
SivanCola 12fbed798e fix(desktop): finish the jump transaction and make its retry recover
Third review round. All three findings reproduced against head 06f6de5 before
being changed.

Problem 1: a jump that stopped early because its snapshot or session was
replaced returned without clearing anything, so the promise settled but the
state stayed `loading` with the reader subscription still attached — the mark
pulsed forever. Every early exit now releases the state, but only when this
interaction still owns it, so a superseding click keeps the state it just set.
The existing replacement test asserted only that nothing was paged or scrolled;
it now also asserts the terminal state and that the subscription is released.

Problem 2: the retry entry was gated on the outline's own failure, so a jump
that failed while the outline read perfectly offered no button at all. The
button is now rendered whenever a retry exists and names what it recovers —
a recycled snapshot, an exhausted page budget, or an unreachable turn.

Problem 3: retrying a recycled cut re-ran the same jump against the same dead
cut, so it could only fail again. A retry now asks the owning session to
install a fresh snapshot first and re-resolves the target from it by its stable
identity. The refresh is part of the transaction: a newer click or a cancel
abandons it exactly like a pending page loop.

Also surfaced, which were previously internal only: the reason a jump failed
(the retry's title says which) and a truncated outline (the rail marks itself
and explains that only the most recent turns are indexed).

Verification:
- pnpm test:transcript — retry installs a fresh snapshot and then reaches the
  target; a click during the refresh wins; a replaced snapshot ends as idle
  with no reader subscription; the DOM test drives a failing jump and asserts
  the retry entry renders, is labelled, and re-runs the jump
- pnpm test:typecheck, pnpm build, pnpm check:scroll-writer
- go test ./internal/transcript/ ./internal/serve/, go run ./tools/repolint
2026-09-14 18:42:19 +08:00
SivanCola aef783fa6d ci: tier App memory screening by risk
Problem:
Every frontend pull request runs three complete App memory shards, consuming about the same runner time as high-risk lifecycle changes.

Root cause:
The workflow matrix and memory evidence protocol hard-code one three-shard profile, so path routing can only run or skip the full screen.

Fix:
Add explicit short and full protocol profiles, route ordinary frontend pull requests to one short shard, and retain full screening for lifecycle-sensitive changes, main pushes, daily schedules, and manual runs. Label aggregate evidence by screening level and document both profiles.

Verification:
- node --test scripts/ci-paths.test.mjs scripts/ci-workflow.test.mjs desktop/frontend/bench/app-memory-evidence.test.mjs desktop/frontend/bench/app-memory-shards.test.mjs desktop/frontend/bench/app-memory-paths.test.mjs desktop/frontend/bench/app-memory-timing.test.mjs
- go run github.com/rhysd/actionlint/cmd/[email protected] .github/workflows/app-memory.yml
- cd desktop/frontend && pnpm build
- go run ./tools/repolint
2026-09-14 18:26:04 +08:00
SivanCola 06f6de5dc7 docs(desktop): record why the legacy item key space has no outline identity
The sweep for the record-identity defect found a second producer of user item
ids: the windowed legacy store keys them `he:<entryId>`, in a different key
space from the snapshot protocol's record ids. Those tabs never install a
snapshot, so they never have an outline and the rail stays in its loaded-turn
mode; returning no identity is correct there, and guessing one could match the
wrong turn.
2026-09-14 18:20:14 +08:00
SivanCola 4b832fbdd5 fix(desktop): bind the turn jump to one transaction and one snapshot
Second review round. All five findings reproduced against head 23bca69 before
being changed.

Problem 1 (navigation race): a click on an already-loaded turn called
`scroll.jump` directly, bypassing the jump transaction. A pending jump then
landed afterwards and overwrote the newer selection — a DOM probe saw the
writes ordered `u4` then `u1`. Every click now goes through one entry point:
`jumpTo` invalidates a pending loop before writing, so the newest click always
wins.

Problem 2 (false "turn unavailable"): history exhaustion failed immediately,
but the last page mounts 24 nodes per frame, so a target revealed on a later
frame was declared missing. Exhaustion now waits out the progressive mount
(bounded by a wall-clock budget) before failing, and a page that leaves history
exhausted is no longer confused with a recycled cut.

Problem 3 (record identity): history rows without a message id are keyed
`record:<recordId>` by the transcript while the outline carries the bare record
id, so the fallback never matched and older turns stayed wrongly unloaded. The
conversion now lives in one place (`recordIdOf`) instead of being re-derived.
The previous tests passed only because the fixture made both sides equal.

Problem 4 (snapshot binding): `isCurrent` checked the component lifetime only,
and both paging paths installed a fresh snapshot on `stale`. A navigation jump
now carries the snapshot identity it was resolved against and ends when it
changes, and older-history paging reports `stale` as its own outcome rather
than silently replacing the body — matching the plan's rule that only an
explicit retry replaces what the reader is looking at. `loadOlderHistory` and
its consumers now carry `HistoryLoadOutcome`; the remote path shares it.

Problem 5 (quadratic merge): `findLoadedTurn` rescanned the whole mounted order
per outline entry. The rail now builds one identity index per render and
resolves in constant time; the same helper serves the jump. A regression test
asserts 4000 lookups over 4000 nodes stay well inside a linear budget.

Also from the same review:
- Budget exhaustion is reported as `pageBudgetExhausted`, not as a missing turn.
- An oversized outline keeps the turns that did arrive and marks itself
  truncated, instead of discarding navigation for a huge conversation.
- A failed jump offers to retry its own target, and a running jump can be
  cancelled from the rail.
- `RemoteSessionSurface` now passes `totalTurns`, so the remote rail keeps its
  area while the outline loads.

Verification:
- pnpm test:transcript (new regressions: supersession by a loaded click, mount
  after the last page, recycled cut, replaced snapshot, page-budget exhaustion,
  record-key history identity, and a 4000-entry merge cost bound)
- pnpm test:typecheck, pnpm build, pnpm check:scroll-writer
- go test ./internal/transcript/ ./internal/serve/, go run ./tools/repolint
2026-09-14 18:19:16 +08:00
SivanCola 23bca69f88 fix(desktop): resolve rail turns by identity and bound outline fetching
Review fixes for the turn-outline change. Four findings, all reproduced
against the code before being changed.

Problem 1 (user-visible): a question written in this app session kept its
optimistic `u<seq>` anchor key after the authoritative message settled — the
reducer only attaches `messageId` — while its outline entry is keyed by the
Go record id `m:<uuid>`. `loadedTurnKey` matched mounted keys, so that turn was
marked unloaded even though its body was on screen, and clicking it started a
jump that paged to the beginning of history and then failed. Once a cut was
re-installed mid-session the same turn was also listed twice: once as the
outline's unloaded entry and once as the trailing "not yet in the outline"
item.
Fix: `findLoadedTurn` matches on node identity — `messageId` first, then the
record id — never on the shape of the anchor key. The mark keeps the outline
record id as its stable identity for its whole life, so finishing a load no
longer remounts it, and `anchor.key` carries the DOM target separately. The
rail tracks both emitted identities and consumed nodes so no turn is listed
twice. The jump resolves through the same helper.

Problem 2: `readAll` paged until `done` with no cap, so a hostile or buggy host
answering `{nextOffset: offset+1, done: false}` forever drove an unbounded
request-and-append loop (unbounded renderer memory and network). This broke the
convention the codebase already uses for server-driven loops.
Fix: bound at 64 pages / 20,000 entries, with a regression test that hangs
without the bound.

Problem 3: `settleMounts` was bounded only by animation frames, and a hidden or
occluded window stops delivering them, leaving the mark pulsing until the
window was shown again.
Fix: add a wall-clock ceiling alongside the frame budget, and cancel the
pending frame and timer when it settles. Matches the existing guidance to use a
bounded wall-clock recovery budget rather than frame counts.

Problem 4: rebinding a tab to another session (`resumeSession`,
`openChannelSession`) called `snapshotClient.load` without announcing that the
previous cut was going away, so the rail could briefly describe the previous
session's turns under the same tab id.
Fix: `load` announces the gap before the round trip; the install re-announces
with the new identity.

Also regenerates docs/desktop-migration/INVENTORY.md and inventory.json, which
the two new host commands made stale.

Verification:
- pnpm test:transcript (incl. the DOM test now using the real optimistic
  `u<seq>` item shape, which fails without problem 1's fix)
- pnpm test:typecheck, pnpm build, pnpm check:scroll-writer
- go test ./internal/transcript/ ./internal/serve/, go run ./tools/repolint
- go test ./... — the two failures it reports
  (TestPortableUnixArchiveMigratesWithFlatCLIName, TestInventoryIsClassifiedAndCurrent)
  were checked against untouched origin/main-v2; the first fails there
  identically (pre-existing), the second is fixed by the regenerated inventory.
2026-09-14 18:02:26 +08:00
SivanCola 306efd7286 chore(lint): record the transcript outline wiring budget
The turn-outline wiring adds 17 lines to desktop/frontend/src/lib/useController.ts,
which already carries a baselined file-size exception. Record the new overage
for that file only; the three unrelated baseline entries the updater would also
have shrunk are left untouched to keep this change scoped.
2026-09-14 17:52:21 +08:00
SivanCola 658fe6eb06 feat(desktop): index the complete conversation in the turn navigator
Problem:
The turn navigator derived its entries from the mounted transcript window. A
long tool-heavy conversation fills the first body page with execution records,
so a window holding fewer than two user questions hid the rail entirely until
the reader pressed "load earlier messages" repeatedly. Switching away and back
rebuilt the window and could hide it again, loaded markers were renumbered from
1 because the ordinal came from the window, and a turn outside it could not be
reached at all.

Root cause:
Navigation had no source of truth for "which turns exist". The only index was
the loaded prefix, so completeness, identity and ordering were all coupled to
how much body a client had paged in.

Fix:
- internal/transcript publishes a bounded turn outline beside the body pages,
  bound to the same immutable snapshot, revision and event coverage. Entries
  carry the user record's stable RecordID, optional MessageID, the absolute
  turn ordinal, the record's order inside that snapshot, and two previews built
  from display bodies only: 50 grapheme clusters of the prompt and 120 of the
  last non-empty assistant body in that turn's group. Reasoning, tool output,
  submitted text and injected context never enter an entry.
- The index is built once per frozen cut, so repeated reads reuse one pass, body
  paging never shrinks it, and its previews count against the existing snapshot
  cache budget and lifetime.
- GET /transcript/outline serves it, advertised to remotes as
  transcript-outline-v1. TranscriptOutlineAPI is an optional capability beside
  TranscriptProjectionAPI, so an existing controller keeps compiling and an
  unsupported route answers 404/405/501 rather than an empty page.
- The frontend keeps one shared outline store for local and remote sessions,
  fenced by tab generation and snapshot identity. A recycled cut is reported as
  stale; the body is only replaced when the reader explicitly retries.
- The rail merges the complete outline with loaded turns, matching by MessageID
  first and stable RecordID second, so numbering stays absolute and loading an
  earlier page neither renumbers nor drops a mark.
- Selecting an unloaded turn starts a jump transaction that reuses the ordinary
  older-history paging one page at a time, waits for the progressive mount to
  advance, and writes the viewport only once the target node is really mounted.
  Reader intent, an explicit cancel, a newer target, or a session/snapshot
  replacement ends the pending transaction without taking scroll control back.
- Manual paging and navigation jumps share one queue: a page already in flight
  is awaited rather than submitted twice.

Verification:
- go test ./internal/transcript/ ./internal/control/ ./internal/serve/
- go test -race ./internal/transcript/
- cd desktop && go test ./...
- cd desktop/frontend && pnpm test:transcript && pnpm test:remote &&
  pnpm test:typecheck && pnpm build
- CHAT_BROWSER=chromium node bench/chat-transcript.mjs (240 and 1000 turns,
  zero errors; input P95 137.6ms, switch P95 38.2ms, longest task 218ms, heap
  growth 0.33MiB)

Compatibility:
Additive. No persisted session format, provider message, tool schema,
permission or prompt-cache byte changes.
2026-09-14 17:45:43 +08:00
SivanCola f5f1752053 Merge pull request #10230 from Yizking/fix/windows-desktop-console-flash
fix(desktop): stop Windows startup console flash / 修复 Windows 启动控制台闪窗
2026-09-14 17:05:53 +08:00
SivanCola 3d87a51a67 Merge main-v2 into fix/windows-desktop-console-flash
Refresh PR #10230 onto the latest main-v2 before delivery; the new upstream changes are isolated to the website release-channel UI.
2026-09-14 16:34:43 +08:00
SivanCola 7e68cef7f7 test(desktop): guard Windows service GUI subsystem
Problem: the Windows desktop service could silently return to the CONSOLE subsystem, bringing back the startup console flash even though launcher checks still passed.

Root cause: native startup CI built the service without the production Windows linker flag, and the build contract did not distinguish the service linker flags from the launcher.

Fix: align the Windows smoke build with production, inspect the generated PE subsystem, and lock the service-specific linker contract with unit coverage.

Verification: Desktop Go tests, release workflow contracts, actionlint, repolint, contract tests, and an amd64 Windows cross-build PE check passed.
2026-09-14 16:34:08 +08:00
SivanCola ff287104de Merge pull request #10274 from SivanCola/fix/site-manual-desktop-download
fix(site): update manual desktop downloads / 修正官网桌面版手动下载链接
2026-09-14 16:33:23 +08:00
SivanCola 882c25938d fix(site): separate manual desktop downloads from updater selection
Problem: The website still offered v1.38.7 after the manual-only v1.38.8 release.

Root cause: Desktop download links followed the intentionally retained updater manifest.

Fix: Validate the published v1.38.8 manifest independently and select the newer of it and the live stable release, with exact GitHub fallback. Preserve updater pointers and release tags.

Verification: All 63 site tests pass, including future-version selection, CDN fallback, and invalid release rejection.
2026-09-14 16:32:24 +08:00
SivanCola 0a9ba21102 Merge main-v2 into fix/windows-desktop-console-flash
Refresh PR #10230 onto the latest main-v2 before adding the Windows service subsystem regression guard.
2026-09-14 16:25:50 +08:00
SivanCola e4824a44fb Merge pull request #10273 from SivanCola/fix/v1.38.8-reuse-desktop-artifacts
fix(release): reuse verified artifacts after smoke setup failure / 修复验证依赖并复用发布产物
2026-09-14 15:54:22 +08:00
SivanCola cde0d65b50 fix(release): reuse verified manual artifacts after smoke setup failure
Problem: the Intel Universal smoke job omitted its Playwright dependency after all native builds succeeded.

Root cause: the standalone smoke runner installed Node but not the locked Desktop workspace dependencies.

Fix: install the dependencies and recover only the exact protected v1.38.8 producer artifacts, preserving source identity, digests, signatures and manual-only distribution.

Verification: workflow contract suite, producer tests, live producer validation and actionlint passed.
2026-09-14 15:44:15 +08:00
SivanCola 09cdab3866 Merge pull request #10271 from SivanCola/fix/v1.38.8-manual-desktop-release
fix(release): recover v1.38.8 manual downloads / 恢复桌面版手动下载发布
2026-09-14 15:05:34 +08:00
SivanCola 72205d52b2 fix(release): recover v1.38.8 as manual desktop downloads
Problem: SignPath rejected both Windows requests before stable publication.\nRoot cause: the signed-only release path cannot publish while signing is unavailable.\nFix: add an owner-approved exception scoped to the immutable v1.38.8 candidate, retain minisign and native checks, disclose manual installation, and preserve all Desktop update pointers.\nVerification: release workflow contract suite, SignPath validation, and actionlint pass.
2026-09-14 14:57:14 +08:00
SivanCola 7a362bfd4f Fix Dock file navigation lifecycle / 修复 Dock 文件导航生命周期 (#10268)
* fix(desktop): isolate dock file navigation lifecycle

Problem: File-card navigation could repeatedly re-enter Dock rendering and surface React error #301. Rapid session, view, or file changes also allowed stale navigation, reads, and save receipts to target the replacement view.

Root cause: Request consumption depended on object identity and component render timing, while async work lacked a view-instance owner and independent generations.

Fix: Add instance-scoped navigation revisions, committed-effect delivery, exact Dock routing, cancellation boundaries, and generation checks for file reads, saves, and browser previews. Preserve workspace view memory while preventing closed or replaced instances from accepting stale work.

Verification: Focused lifecycle and race suites, workspace and remote suites, type checks, hooks lint, production build, Chromium click coverage, Electron component coverage, and packaged macOS arm64 shell/service smoke passed.

* test(desktop): compare preview cleanup URL exactly

Problem: CodeQL classified the browser-preview cleanup assertion as incomplete URL substring sanitization.

Root cause: The test used Array.includes with a URL literal even though the expected cleanup list is deterministic and singular.

Fix: Assert the complete revoked URL list with deep equality.

Verification: The navigation race test, focused ESLint, and test TypeScript check pass.

* fix(desktop): preserve workspace panel across session switches

Problem: Same-project session switches remounted the workspace panel and preview DOM.

Root cause: Session identity was included in local panel keys, and a transient missing navigation snapshot removed the mounted panel.

Fix: Keep local panel identity scoped to the workspace and dock tab. Retain session-scoped navigation cancellation independently of the mounted view.

Verification: Production build, complete app browser suite, and Electron dock navigation tests passed, including six file navigation paths and stale save isolation.

* fix(desktop): restore dock resource identity before remount

Problem: Restored presented previews timed out, and the CI browser fixture could not find Chromium.

Root cause: A returning view mounted before its retained resource metadata was available. The browser fixture also interpreted the relative browser cache path against the parent working directory.

Fix: Expose retained view resources without replaying commands or changing activation. Normalize the default browser cache path before loading Playwright.

Verification: Dock component and lifecycle regressions, test typecheck, production build, complete app browser suite from the CI working directory, and Electron navigation tests passed.
2026-09-14 14:50:18 +08:00
nkchivasandClaude Sonnet 5 dcb2ebbb5c fix(cask): emit valid postflight_steps DSL in custom_block
The current custom_block emits Ruby-block syntax (`system_command`,
`#{staged_path}`, `if OS.mac?`) inside the declarative
postflight_steps stanza. Homebrew rejects the generated cask:

    Cask 'reasonix' is unreadable: undefined local variable or method
    'staged_path' for an instance of Homebrew::InstallSteps::DSL

The InstallSteps DSL provides `run`, `on_macos` guards, and
`{{staged_path}}` path templates instead. custom_block is not
Go-template processed (internal/pipe/brew/brew.go only splits it), so
the `{{staged_path}}` token passes through to the .rb file where
Homebrew resolves it.

Verified by hand-applying the exact emitted block to the generated
cask in esengine/homebrew-reasonix and running:
- brew info --cask reasonix (no deprecation warning)
- brew reinstall --cask reasonix (step runs, quarantine cleared)

Companion tap PR: esengine/homebrew-reasonix#2

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-14 14:48:11 +08:00
YizandClaude Code c9efeec8ae fix(desktop): build Windows service as GUI subsystem
reasonix-desktop.exe was compiled as a CONSOLE-subsystem image, so every
time the Electron shell spawns it with --host-rpc, Windows allocates a
conhost window and briefly flashes a black box on startup.

The service communicates over inherited pipes and never needs a console,
so build it with -H windowsgui, matching how the thin launcher is built.

Fixes #10148

Co-Authored-By: Claude Code <[email protected]>
2026-09-13 16:31:55 +08:00
XTLine ef9cebf758 fix(desktop): preserve saved remote install policies / 保留已保存的远端安装策略 2026-09-12 23:13:49 +08:00
XTLine 0fc3742a3c fix(desktop): restore remote wizard selection states / 恢复远程向导选中态 2026-09-12 23:13:48 +08:00
SillyZir b4566ce0b4 fix: Merge live proxy-observed MCP tools into the capability catalog
Refs #9516
2026-09-01 09:53:08 -04:00
dadgrand ab9faff14a fix(cli): stop reasonix setup from turning flags into config files
resolveSetupTargets treats every unrecognized argument as the config path,
so a dashed argument silently becomes the file setup writes:

  $ reasonix setup --help
  Wrote --help
  $ ls
  --help

Shell completion already advertises --help for setup, so following the
suggestion creates a junk file in the working directory instead of printing
usage, and any mistyped flag does the same.

Handle -h/--help with the existing commandHelpRequested helper, and reject
other dashed arguments with usage and exit 2, matching how the doctor and
mcp subcommands already behave. Positional paths and --local/-l keep working.
2026-09-01 14:15:35 +03:00
Xingbo Zhao d43bf83eb8 fix(desktop): render pure-number inline math by default, escape currency dollars
Problem: Pure-number spans such as `$1$` rendered as raw text unless narrow
numeric context (range endpoint, operator, scientific unit) surrounded them,
so tables and answers full of bare numbers leaked literal `$1$` into chat.
Separately, remark-math v6 pairs any two `$` on a line, so a stray currency
amount could merge with a later formula's opening delimiter (`budget is
$100 and the answer is $42$`) and destroy the math — a failure the
word-context currency layer could not repair because the junk span had
already replaced the real formula boundary.

Fix:
- mathClassify: pure numbers with glued delimiters are math by default; the
  currency-word, range, and unit context machinery is removed together with
  its source-slicing plumbing in remarkMathPolicy
- mathNormalize: port assistant-ui's escapeCurrencyDollars as a new pre-pass
  step — a `$` immediately followed by a digit is escaped unless the span up
  to the next `$` reads as a math body, so cross-amount prose (`$5 and $10`)
  and stray amounts stay literal while a later `$42$` still renders; escaped
  dollars emit the internal token so later `$`-scans cannot re-pair them
- golden contract rewritten (270 cases): glued `$N$` renders as math in
  prose, tables, and around price words; cross-amount pairs, escaped `\$`,
  `$PATH$`, and junk spans restore verbatim

Verification: tsc --noEmit; 270 math golden cases; run-tests.mjs (all 229
frontend suites); markdown streaming worker, bundle contract, table
virtualization, and katex CSS suites.
2026-08-28 20:24:41 +08:00
李国怿 4164b145e0 fix(provider): ignore request_url that merely repeats base_url
request_url equal to base_url (e.g. the settings UI mirroring base_url into
the exact-URL field) was used verbatim, so anthropic/openai/responses
POSTed to the base route itself and got a 404 on the base path. Treat it
as unset and derive the canonical endpoint (v1/messages, chat/completions,
responses) from base_url instead.

Refs #8781

Tests: request_url equal to base root (with and without v1 suffix) is
ignored; exact custom request_urls are still honored.
2026-08-27 11:59:42 +08:00
c020627 a8e1900d02 docs(hooks): replace macOS-only osascript Stop example with cross-platform command 2026-08-23 13:39:59 +08:00
Linearleaf f46cf7bd97 fix(desktop): reset topic to idle on CompactionDone so /compact stops spinning — #9230
A manual /compact completed successfully but the session's topic stayed in the
"thinking" spinner. Root cause: desktop/tabs.go topicActivityStatusFromEvent
mapped CompactionDone (an ending state) into topicStatusThinking alongside
CompactionStarted, and topic state reset only ever happens on TurnDone — which
manual compact turns don't always fire — so the session stayed spinning.

Fix:
- desktop/tabs.go: remove event.CompactionDone from the "thinking" case;
  add a dedicated CompactionDone case that resets the topic to idle
  ("", update=true), independent of TurnDone.

Tests:
- tabs_runtime_status_test.go: new TestTopicActivityStatusClearsOnCompactionDone
  asserts CompactionStarted => thinking and CompactionDone => cleared. Existing
  topic-status tests still pass.

Guard:
Cache-impact: none - desktop host-only; no provider-visible change
Cache-guard: go test -C desktop -run 'TestTopicActivityStatus|TestCatalogRuntimeStatus'
Documentation-impact: none - internal state fix; no docs updated
System-prompt-review: @SivanCola - desktop host change; no system-prompt/boot/config changes
2026-08-21 15:54:04 +08:00
Linearleaf 3f4cf4bd11 fix(desktop): rewrite budgetWhyOverflow copy per review — describe card fields, drop stale ratio
Addresses SivanCola review on #9157 (fixes #9084).

- en/zh/zh-TW `context.budgetWhyOverflow` now describe the three fields the
  `ContextBudgetCard` actually renders (prompt tokens, output reserve, physical
  remaining) instead of the removed "prompt ÷ window" ratio and fixed "63%".
- Keeps the causal point: the provider reserves completion tokens in the same
  context window, so a modest prompt can still overflow.
- No other functional change.

Verification: pnpm typecheck; pnpm exec tsx src/__tests__/context-budget-card.test.ts (13 passed); eslint on the three locale files.
2026-08-21 09:35:04 +08:00
Linearleaf 660245fab3 fix(desktop): update budgetWhyOverflow copy to match current numeric-card UI
The previous copy referenced a 'usage ring' (圆环/圓環) which only appears
in the creation layout's ContextWindowRing; the budget note is rendered
inside ContextBudgetCard (numeric card, not a ring). Replace 'ring'
references with 'budget card' / '预算卡片' / '預算卡片'.

Fixes #9084
2026-08-20 14:09:26 +08:00
jiangrenhang 259c142e42 fix(provider): diagnose non-streaming responses instead of bare unexpected EOF / 中转站根路径返回非流式响应时给出明确诊断 (#8781) 2026-08-14 16:03:26 +08:00
glyahh 14fc3c1392 Merge remote-tracking branch 'upstream/main-v2' into fix/api-key-hidden-input 2026-08-13 01:42:32 +08:00
glyahh 27e11bb081 fix(cli): hide API keys during setup input 2026-08-13 00:25:12 +08:00
xiaojia-afk 528eaed5c6 fix(desktop): preserve Unicode commit filenames 2026-08-13 00:07:26 +08:00
paradoxSCH fffed668fb fix(agent): normalize skill capability arguments 2026-08-12 15:47:02 +08:00
William a82a19ca4c fix(desktop): keep an explicit [bot] enabled = false through a QR scan
PollBotConnectionInstall stamped c.Bot.Enabled = true on every successful
scan, for both the WeChat and the Feishu/Lark branch. A user who runs the
CLI bot separately and turned the embedded gateway off got it switched
back on by a rescan, and every message was then answered twice.

Deleting the write is not an option: config.Default() leaves Bot.Enabled
at the zero value, so new desktop users would connect a bot that never
starts. Make it conditional instead. The first connection is onboarding
and still enables; after that the write happens only when the user config
does not define [bot] enabled itself, through the same tomlFileDefinesKey
provenance check that backs ConfigFileDefinesCompactRatio.

Fixes #8041
2026-08-11 23:43:02 -04:00
Will 85e23bd290 fix(memory): archive every duplicate of an ID, not just the first 2026-08-11 20:45:40 +08:00
Will a09a33ad47 fix(plugin): fail closed when confined MCP loses its sandbox backend 2026-08-11 20:23:50 +08:00
Will 702c30626b fix(remote): feed scripts to sh via stdin instead of a parsed exec string 2026-08-11 20:23:36 +08:00
onionviolet 04553c3bd4 fix(config): stop per-provider effort from breaking DeepSeek family canonicalization 2026-08-11 02:41:19 -05:00
onionviolet 5358d539fb fix(skill): stop registering plugin documentation markdown as skills
Plugin skill roots were scanned with the permissive custom-root mode where
any flat .md file becomes a skill. Plugin packages ship documentation
markdown next to their <name>/SKILL.md skills, so those docs registered as
empty skill shells and inflated the model-visible index (20-some real
skills became 70+ entries).

Treat plugin-package-owned roots like Claude convention roots: flat .md
files must carry skill frontmatter before they are registered. Directory
skills, built-ins, and single-.md skills in plain custom roots are
unaffected.

Fixes #8316
2026-08-11 02:31:24 -05:00
Will f7a4ff2ca4 fix(memory): surface read errors in quick-add instead of overwriting 2026-08-11 11:58:00 +08:00
Majid Ali 83a9452194 fix(config): repoint model refs when a plugin package is removed
SetDefaultModel accepts plugin-namespaced refs
(plugin/<plugin>/<provider>/<model>) without proof, because the config
catalog cannot vouch for models that live in an extension sidecar; boot's
merged resolver gates them instead. Removing the plugin therefore stranded
every ref that named it. The next launch failed with `unknown model` and no
session could start until the user found and edited config.toml by hand.

RemoveProvider already migrates dangling refs when a provider disappears.
Give plugin removal the same treatment: Config.RemovePluginModelRefs moves
default_model, planner_model, subagent_model, and subagent_models off the
removed plugin, and applyRemovePluginPackage calls it after the package is
gone.

Unlike provider removal this never refuses. The package is already deleted
by the time it runs, so with no configured provider to fall back on the refs
are cleared and ResolveNewSessionChatModel's keyless-default policy picks
the boot model.

A config that names no model of the plugin is left byte-for-byte alone, and
a malformed config is left untouched for the user to recover rather than
rewritten.

Fixes #8187
2026-08-10 15:18:42 +05:00
LI Xinyu a56e745978 fix(config): bound legacy keyring lookup so serve cannot hang at startup (#8129)
On Windows/macOS the legacy-credential migration probe calls the platform
keyring synchronously with no context support: a wedged OS keyring (locked
macOS login keychain, hung Windows Credential Manager) blocks `reasonix
serve` forever — even `serve --help` — before anything prints, because
the migration hook runs ahead of command dispatch.

Run each platform Get under the shared migration budget via
legacyKeyringGetBounded (goroutine + select): the caller-side wait is
bounded by ctx even though the OS call itself cannot be interrupted; a
late result is dropped via a buffered channel without leaking or blocking
the probe goroutine.

Classification stays in the per-platform probe so a found value is never
demoted to timeout when it wins the select race against an expiring ctx.

Tests: new platform-independent tests for value/error/hang-timeout
semantics (Linux CI covers them too, since the helper lives outside the
build-tag file); existing four-state batch tests still pass.

Cache-impact: none|fix only affects legacy keyring migration on Windows/macOS (credential import path)
Cache-guard: none
System-prompt-review: none — no prompt, config schema, memory, output style, or skill behavior changes
Documentation-impact: none
2026-08-10 15:25:22 +08:00
NgoQuocViet2001 f336d556ad fix(installsource): reject oversized remote manifests 2026-08-09 00:15:31 +07:00
st 5fa7c17622 fix(bot/feishu): prefer open_id when resolving card action operator
Card action callbacks can carry both union_id and open_id; the previous
resolution preferred union_id, while access/allowlist configs normally
record open_id. When Feishu returned both IDs, the union_id was matched
against the open_id allowlist, a legitimate user looked unknown, and the
pairing flow was triggered on card-button approval.

Align with the identity resolution used for regular messages (open_id
first) and add a regression test covering the union_id+open_id case.
2026-08-08 11:59:00 +08:00
Wooseong Kim 536eb200d2 fix(cli): account for wrapped working line in composer cursor offset 2026-08-06 21:57:57 +09:00
Wooseong Kim 84c9822f31 fix(site): exclude auth pages from sitemap and add noindex 2026-08-06 21:57:56 +09:00
Wooseong Kim 817d515b69 fix(npm): validate optionalDependencies match build targets 2026-08-06 16:23:18 +09:00
Wooseong Kim a3d9457cf9 ci: trigger release-notes checks on release-event.mjs changes 2026-08-06 14:47:24 +09:00
Wooseong Kim ea30e0eccf fix(scripts): balance semver regex groups in release scripts 2026-08-06 14:47:24 +09:00
HaoyueQin d8ca4c7fc3 fix: use natural sort for file/directory name ordering
Replace lexicographic (dictionary) string comparison with natural
sort (human sort / alphanumeric sort) across all file listing paths.
Numeric substrings are now compared by integer value instead of
character-by-character, matching the behavior of Windows Explorer
and macOS Finder.

Example: "第10卷.md" now correctly sorts after "第2卷.md"
instead of before it.

Changes:
- Add textutil.NaturalLess function (internal/textutil/natsort.go)
  with comprehensive tests
- desktop/app.go ListDir: sidebar file tree
- desktop/frontend WorkspacePanel.tsx: frontend filtered search
- internal/control/refs.go: external folder refs + @ dir expansion
- internal/cli/complete.go: CLI @ file tab completion
- internal/fileref/search.go: @ file search results

Closes #6042
2026-08-05 21:21:02 +08:00
Yu Zhu a0547186c9 fix(serve): make rewind checkpoint click go directly to scope selection 2026-07-09 20:26:20 +08:00
4354 changed files with 376561 additions and 76695 deletions

No files matched your search

+3
View File
@@ -26,3 +26,6 @@
*.yml text eol=lf
.githooks/** text eol=lf
benchmarks/**/*.sh text eol=lf
# Tagged storage fixtures include byte digests and offsets; never rewrite EOLs.
desktop/testdata/session-history-upgrade/** -text
desktop/testdata/manual-session-upgrade/** -text
+20 -7
View File
@@ -12,6 +12,11 @@ Unknown paths and unavailable diffs fail closed. Pull requests use merge-base
diffs; pushes use `before..sha`; normal `main-v2` pushes keep the complete
qualification matrix after the existing release-notes-only exception.
The desktop race sweep (`desktop-go-race`, eleven partitions) runs on `main-v2`
pushes and manual dispatch, never on pull requests. Pull requests still run the
same partitions without `-race` (`desktop-go` and the Windows groups), so a data
race introduced by a pull request is reported by the push run after it merges.
`desktop-prepare` regenerates the desktop host contract (failing on drift) and
produces the required `electron/stable` and `electron/canary` frontend variants
once on Linux. Each artifact carries a versioned manifest with checkout,
@@ -51,20 +56,28 @@ step execution time from the Actions API without wrapping the test process.
## Memory screening
Protocol v2 still requires three independent processes, each completing
128 full, 128 windowed, 128 safety and 512 mixed round trips, with the same
checkpoints, five heap snapshots, GC, frame settling and screening thresholds.
Protocol v4 records the selected screening profile in every manifest, shard and
aggregate. Ordinary frontend pull requests use the `short` profile: one process
completes 32 full, 32 windowed, 32 safety and 128 mixed round trips. Pull requests
that change App lifecycle, Transcript, navigation, subscription ownership, memory
fixtures or CI routing use the `full` profile. Pushes to `main-v2`, the daily
scheduled run and manual dispatches also use `full`: three independent processes
each complete 128 full, 128 windowed, 128 safety and 512 mixed round trips.
Both profiles keep the same evidence requirements: exact checkpoints, five heap
snapshots per process, GC, frame settling, source/build identity and screening
thresholds. Aggregation rejects missing shards and profile or protocol mismatches.
Only the explicit mock memory-soak URL removes the fixture's artificial
1.5-second hydration latency. Hydration still crosses an asynchronous timer
task. Default browser and native geometry fixtures retain the delayed path.
The pointer rests outside topic rows and the warmed baseline follows a complete
round trip after layout switching, avoiding samples of temporary menu state.
Reports declare the protocol, and aggregation rejects older protocols.
Reports declare the profile and protocol, and aggregation rejects older protocols.
`timings.json` records host-side counts, total time and maximum time for
navigation, frame settling, GC and heap capture/analysis. Short profiling runs
are diagnostic only and cannot pass the complete screening gate. A green gate
still does not prove offline heap-retainer attribution.
navigation, frame settling, GC and heap capture/analysis. Aggregate results label
their `screeningLevel`, so a passing short PR screen cannot be mistaken for full
qualification. A green gate still does not prove offline heap-retainer attribution.
## Signed release artifacts
+17 -6
View File
@@ -10,6 +10,10 @@ Go、生成协议、Electron、原生平台和打包输入。`desktop/AGENTS.md`
未知路径或无法获得可靠 diff 时保守失败。PR 使用 merge-base 差异,push 使用
`before..sha`;保留纯 release-notes 例外后,普通 `main-v2` push 仍运行完整验证矩阵。
桌面 race 检测(`desktop-go-race`,11 个分片)只在 `main-v2` push 和手动触发时运行,
PR 上不运行。PR 仍以不带 `-race` 的方式运行同样的分片(`desktop-go` 与 Windows 分组),
因此 PR 引入的数据竞争会在合并后的 push 运行中报告。
`desktop-prepare` 重新生成桌面宿主契约(有漂移即失败),并在 Linux 上分别生成一次
`electron/stable` 与 `electron/canary` 前端。每份产物都带版本化 manifest,记录
checkout、workflow attempt、变体、构建输入、工具链以及每个 `dist` 文件的摘要。
@@ -41,17 +45,24 @@ verbose 测试缓存;统一耗时报告直接从 Actions API 记录该步骤
## 内存筛查
协议 v2 仍要求三个独立进程,每个完成 128 轮 full、128 轮 windowed、
128 轮 safety 和 512 轮 mixed 往返,保持相同检查点、五份堆快照、GC、
稳定帧等待与判定阈值。只有明确启用 mock 内存 soak 的 URL 会去掉夹具人为
协议 v4 会在 manifest、分片报告和汇总中记录筛查档位。普通前端 PR 使用
`short`:一个进程执行 32 轮 full、32 轮 windowed、32 轮 safety 和 128 轮
mixed 往返。涉及 App 生命周期、Transcript、导航、订阅所有权、内存夹具或
CI 路由的 PR 使用 `full`。推送到 `main-v2`、每日定时任务及手动触发也使用
`full`:三个独立进程各执行 128 轮 full、128 轮 windowed、128 轮 safety 和
512 轮 mixed 往返。
两个档位都要求精确检查点、每进程五份堆快照、GC、稳定帧等待、源码与构建身份
及相同判定阈值。汇总会拒绝缺失分片、档位不一致和协议不一致。只有明确启用
mock 内存 soak 的 URL 会去掉夹具人为
设置的 1.5 秒 hydration 延迟;加载仍经过异步定时器任务。
普通浏览器和原生几何测试继续使用原来的延迟路径。
采样时鼠标停在会话行之外;布局切换后先完成一次会话往返,再记录预热基线,
避免采到菜单的临时状态。报告声明协议版本,汇总拒绝旧协议。
避免采到菜单的临时状态。报告声明档位和协议版本,汇总拒绝旧协议。
`timings.json` 在测试宿主侧记录导航、稳定帧等待、GC、堆快照与解析的次数、
总耗时和最大耗时。短程 profiling 仅用于诊断,不能通过完整筛查门禁。
筛查通过仍不等于完成离线堆引用归因。
总耗时和最大耗时。汇总结果通过 `screeningLevel` 标明档位,短筛查通过不会被
误认为完整验收。筛查通过仍不等于完成离线堆引用归因。
## 签名发版产物
+12 -1
View File
@@ -6,10 +6,21 @@
# same explicit review boundary as the machine-readable contract.
/.signpath/ @SivanCola @esengine
/.github/workflows/release-stable.yml @SivanCola @esengine
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
/.github/workflows/release-candidate.yml @SivanCola @esengine
/.github/workflows/release-candidate-verify.yml @SivanCola @esengine
/.github/workflows/release-promote.yml @SivanCola @esengine
/.github/workflows/release-verify.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
/scripts/signpath-checkpoint.mjs @SivanCola @esengine
/scripts/package-windows-desktop.sh @SivanCola @esengine
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
/scripts/finalize-windows-signed-candidate.sh @SivanCola @esengine
/scripts/release-candidate.mjs @SivanCola @esengine
/scripts/verify-release-artifact-archive.mjs @SivanCola @esengine
/scripts/release-candidate-tags.sh @SivanCola @esengine
/scripts/verify-release-authorization.sh @SivanCola @esengine
/scripts/resolve-release-candidate.mjs @SivanCola @esengine
/scripts/validate-release-control-plane.sh @SivanCola @esengine
/scripts/verify-stable-release-artifacts.sh @SivanCola @esengine
/cmd/signpath-contract/ @SivanCola @esengine
+1 -1
View File
@@ -40,7 +40,7 @@ runs:
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: actions/cache/restore@v4
- uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ${{ steps.key.outputs.paths }}
key: ${{ steps.key.outputs.key }}
+31
View File
@@ -0,0 +1,31 @@
name: Connect to Certum cloud signing
description: Verify the SimplySign installer and connect to the configured certificate.
inputs:
username:
required: true
description: SimplySign account
otp-uri:
required: true
description: TOTP provisioning URI
thumbprint:
required: true
description: Expected SHA-1 certificate thumbprint
runs:
using: composite
steps:
- name: Verify SimplySign installer
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Invoke-WebRequest 'https://files.certum.eu/software/SimplySignDesktop/Windows/9.4.3.90/SimplySignDesktop-9.4.3.90-64-bit-en.msi' -OutFile SimplySignDesktop.msi
$signature = Get-AuthenticodeSignature SimplySignDesktop.msi
if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Asseco Data Systems') {
throw 'SimplySign installer signature or publisher verification failed'
}
- name: Authenticate cloud certificate
uses: dismine/windows-app-signing-setup-action@89ae3b032d4bc7a5b98d1a42a34e61ecb6faad64
with:
certum-username: ${{ inputs.username }}
certum-otp-uri: ${{ inputs.otp-uri }}
certum-key-id: ${{ inputs.thumbprint }}
capture-diagnostics: 'false'
+62 -29
View File
@@ -5,6 +5,8 @@ on:
branches: [main-v2]
push:
branches: [main-v2]
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions:
@@ -23,21 +25,23 @@ jobs:
runs-on: ubuntu-22.04
outputs:
memory: ${{ steps.paths.outputs.memory }}
memory_profile: ${{ steps.profile.outputs.profile }}
memory_shards: ${{ steps.profile.outputs.shards }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ env.EXPECTED_SOURCE_SHA }}
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
node-version-file: .node-version
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
EVENT_NAME: ${{ github.event_name }}
run: |
args=(--head "$EXPECTED_SOURCE_SHA" --github-output "$GITHUB_OUTPUT" --summary "$GITHUB_STEP_SUMMARY")
if [ "$EVENT_NAME" = workflow_dispatch ]; then
if [ "$EVENT_NAME" = workflow_dispatch ] || [ "$EVENT_NAME" = schedule ]; then
args+=(--full)
elif [ "$EVENT_NAME" = pull_request ]; then
args+=(--base "$BASE_SHA" --mode pull_request)
@@ -45,26 +49,48 @@ jobs:
args+=(--base "$BASE_SHA" --mode push)
fi
node scripts/ci-paths.mjs "${args[@]}"
- name: Select memory profile
id: profile
env:
EVENT_NAME: ${{ github.event_name }}
MEMORY: ${{ steps.paths.outputs.memory }}
MEMORY_FULL: ${{ steps.paths.outputs.memory_full }}
run: |
if [ "$MEMORY" != true ]; then
profile=off
shards='[1]'
elif [ "$EVENT_NAME" = pull_request ] && [ "$MEMORY_FULL" != true ]; then
profile=short
shards='[1]'
else
profile=full
shards='[1,2,3]'
fi
echo "profile=$profile" >> "$GITHUB_OUTPUT"
echo "shards=$shards" >> "$GITHUB_OUTPUT"
printf 'App memory profile: **%s**; shards: `%s`\n' "$profile" "$shards" >> "$GITHUB_STEP_SUMMARY"
prepare:
needs: changes
if: needs.changes.outputs.memory == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 20
env:
REASONIX_APP_MEMORY_PROFILE: ${{ needs.changes.outputs.memory_profile }}
defaults:
run:
working-directory: desktop/frontend
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ env.EXPECTED_SOURCE_SHA }}
- uses: pnpm/[email protected]
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
node-version-file: .node-version
cache: pnpm
cache-dependency-path: desktop/pnpm-lock.yaml
- name: Install memory dependencies
@@ -73,45 +99,48 @@ jobs:
- name: Build memory frontend
run: pnpm build
- run: node bench/app-memory-prepare.mjs "$RUNNER_TEMP/app-memory-build"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: app-memory-build-${{ github.run_id }}-${{ github.run_attempt }}
name: app-memory-build-${{ github.run_id }}
path: ${{ runner.temp }}/app-memory-build
overwrite: true
include-hidden-files: true
if-no-files-found: error
retention-days: 7
shard:
needs: prepare
needs: [changes, prepare]
runs-on: ubuntu-22.04
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: ${{ fromJSON(needs.changes.outputs.memory_shards) }}
env:
REASONIX_APP_MEMORY_PROFILE: ${{ needs.changes.outputs.memory_profile }}
defaults:
run:
working-directory: desktop/frontend
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ env.EXPECTED_SOURCE_SHA }}
- uses: pnpm/[email protected]
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
node-version-file: .node-version
cache: pnpm
cache-dependency-path: desktop/pnpm-lock.yaml
- name: Install memory dependencies
run: pnpm install --frozen-lockfile
- name: Install browser runtimes
run: pnpm exec playwright install --with-deps chromium
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: app-memory-build-${{ github.run_id }}-${{ github.run_attempt }}
name: app-memory-build-${{ github.run_id }}
path: ${{ runner.temp }}/app-memory-build
- run: cp -a "$RUNNER_TEMP/app-memory-build/dist/." dist/
- name: Run complete independent memory process
@@ -121,11 +150,12 @@ jobs:
REASONIX_APP_MEMORY_ARTIFACTS: ${{ runner.temp }}/app-memory-results
PLAYWRIGHT_BROWSERS_PATH: /home/runner/.cache/ms-playwright
run: node bench/app-memory.mjs
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: app-memory-shard-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }}
name: app-memory-shard-${{ matrix.shard }}-${{ github.run_id }}
path: ${{ runner.temp }}/app-memory-results
overwrite: true
if-no-files-found: error
retention-days: 7
@@ -140,33 +170,36 @@ jobs:
SHOULD_RUN: ${{ needs.changes.outputs.memory }}
PREPARE_RESULT: ${{ needs.prepare.result }}
SHARD_RESULT: ${{ needs.shard.result }}
MEMORY_PROFILE: ${{ needs.changes.outputs.memory_profile }}
run: |
test "$CHANGES_RESULT" = success
if [ "$SHOULD_RUN" = true ]; then
test "$MEMORY_PROFILE" = short || test "$MEMORY_PROFILE" = full
test "$PREPARE_RESULT" = success
test "$SHARD_RESULT" = success
else
test "$SHOULD_RUN" = false
test "$MEMORY_PROFILE" = off
test "$PREPARE_RESULT" = skipped
test "$SHARD_RESULT" = skipped
fi
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: needs.changes.outputs.memory == 'true'
with:
ref: ${{ env.EXPECTED_SOURCE_SHA }}
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
if: needs.changes.outputs.memory == 'true'
with:
node-version: "24"
- uses: actions/download-artifact@v8
node-version-file: .node-version
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
if: needs.changes.outputs.memory == 'true'
with:
pattern: app-memory-shard-*-${{ github.run_id }}-${{ github.run_attempt }}
pattern: app-memory-shard-*-${{ github.run_id }}
path: ${{ runner.temp }}/app-memory-reports
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
if: needs.changes.outputs.memory == 'true'
with:
name: app-memory-build-${{ github.run_id }}-${{ github.run_attempt }}
name: app-memory-build-${{ github.run_id }}
path: ${{ runner.temp }}/app-memory-build
- name: Verify same-build complete protocol
if: needs.changes.outputs.memory == 'true'
@@ -174,14 +207,14 @@ jobs:
node desktop/frontend/bench/app-memory-aggregate.mjs \
"$RUNNER_TEMP/app-memory-reports" "$RUNNER_TEMP/app-memory-build/identity.json" \
"$RUNNER_TEMP/app-memory-report.json" "$EXPECTED_SOURCE_SHA"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: success() && needs.changes.outputs.memory == 'true'
with:
name: app-memory-aggregate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/app-memory-report.json
if-no-files-found: error
retention-days: 7
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: always() && needs.changes.outputs.memory == 'true'
with:
ref: ${{ env.EXPECTED_SOURCE_SHA }}
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
- name: Upload Apple notarization diagnostics
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: apple-notary-log-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/apple-notarization/*.json
+1 -3
View File
@@ -10,9 +10,7 @@ on:
# conservative: when in doubt, run.
paths-ignore:
- 'docs/**'
- 'site/**'
- 'release-notes/**'
- 'workers/**'
- 'benchmarks/**'
- 'npm/**'
- '*.md'
@@ -29,7 +27,7 @@ jobs:
cache-impact:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.pull_request.base.sha }}
@@ -0,0 +1,71 @@
name: Certum signing smoke test
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: certum-signing
cancel-in-progress: false
jobs:
sign:
if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/main-v2' && github.ref_protected
runs-on: windows-2022
environment: release
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Check signing configuration
shell: pwsh
env:
CERTUM_USERNAME: ${{ secrets.CERTUM_USERNAME }}
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
run: |
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($env:CERTUM_USERNAME)) { throw 'Missing CERTUM_USERNAME' }
if ($env:CERTUM_OTP_URI -notmatch '^otpauth://totp/') { throw 'CERTUM_OTP_URI must be a complete TOTP URI' }
if ($env:CERTUM_KEY_ID -notmatch '^[0-9a-fA-F]{40}$') { throw 'CERTUM_KEY_ID must be the 40-character SHA-1 certificate thumbprint' }
- name: Build open-source signing probes
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# This minimal probe is covered by the repository's MIT license.
New-Item -ItemType Directory payload | Out-Null
'package main; func main() { println("Reasonix Certum signing probe") }' | Set-Content probe.go
$env:GOOS = 'windows'
$env:CGO_ENABLED = '0'
foreach ($arch in @('amd64', 'arm64')) {
$env:GOARCH = $arch
go build -trimpath -o "payload/probe-$arch.exe" probe.go
if ($LASTEXITCODE -ne 0) { throw 'Probe compilation failed' }
}
@('probe-amd64.exe', 'probe-arm64.exe') | Set-Content payload/signing-files.txt
Copy-Item payload/probe-amd64.exe installer-probe.exe
- name: Connect to SimplySign cloud certificate
uses: ./.github/actions/setup-certum
with:
username: ${{ secrets.CERTUM_USERNAME }}
otp-uri: ${{ secrets.CERTUM_OTP_URI }}
thumbprint: ${{ secrets.CERTUM_KEY_ID }}
- name: Sign and verify payload and installer probes
shell: pwsh
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
run: |
./scripts/sign-certum.ps1 -PayloadDirectory payload
./scripts/sign-certum.ps1 -FilePath installer-probe.exe
'Certum x64/ARM64 payload and installer signing, expected signer, trust, and timestamps passed.' >> $env:GITHUB_STEP_SUMMARY
+497 -218
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,53 @@
name: Inspect Cloudflare download access
run-name: Inspect public release manifest access
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: cloudflare-download-access
cancel-in-progress: false
jobs:
inspect:
if: github.ref == 'refs/heads/main-v2'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: desktop/go.mod
- name: Inspect the public manifest and its challenge policy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: node scripts/inspect-cloudflare-download-access.mjs
- name: Probe the shipped updater HTTP client
run: go run scripts/inspect-cloudflare-download-access.go 1.38.11 1.38.12
- name: Probe the public site with headless Chromium
run: |
browser="$(command -v google-chrome || command -v google-chrome-stable || command -v chromium)"
for path in '?download=desktop&release-postflight=v1.38.12' 'changelog/v1.38.12/'; do
for profile in default updater browser; do
output="$(mktemp)"
profile_dir="$(mktemp -d)"
flags=()
case "$profile" in
updater) flags+=(--user-agent='Reasonix-Updater/v1.38.12 (linux/amd64; build=stable; update=stable)') ;;
browser) flags+=(--user-agent='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36') ;;
esac
"$browser" --headless=new --disable-gpu --no-sandbox --virtual-time-budget=10000 \
"--user-data-dir=$profile_dir" "${flags[@]}" \
--dump-dom "https://reasonix.io/$path" >"$output" 2>/dev/null || true
node -e 'const fs=require("fs"); const body=fs.readFileSync(process.argv[1],"utf8"); console.log(JSON.stringify({kind:"chromium-site",profile:process.argv[2],path:process.argv[3],hasHydratedDesktopVersion:body.includes("data-release-version=\"desktop\">v1.38.12<"),hasDesktopAsset:body.includes("data-desktop-asset="),looksChallenged:body.includes("Just a moment...")}))' "$output" "$profile" "$path"
rm -rf "$output" "$profile_dir"
done
done
+3 -3
View File
@@ -38,13 +38,13 @@ jobs:
- language: actions
build-mode: none
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Initialize CodeQL
uses: github/codeql-action/[email protected]
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/[email protected]
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:${{ matrix.language }}"
@@ -1,51 +0,0 @@
name: Deploy accounts worker
on:
push:
branches: [main-v2]
paths:
- 'workers/accounts/**'
- '.github/workflows/deploy-accounts-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/[email protected]
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- name: Deploy
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [ -n "$RESEND_API_KEY" ]; then
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
-173
View File
@@ -1,173 +0,0 @@
name: Deploy crash worker
on:
push:
branches: [main-v2]
paths:
- 'workers/crash-report/**'
- '.github/workflows/deploy-crash-worker.yml'
workflow_dispatch:
inputs:
firebase_data_action:
description: Firebase crash history operation (migration actions do not deploy the Worker)
required: true
default: none
type: choice
options:
- none
- dry-run
- apply
- verify-only
confirmation:
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
required: false
default: ''
type: string
permissions:
contents: read
concurrency:
group: deploy-crash-worker
cancel-in-progress: false
jobs:
deploy:
if: github.event_name == 'push' || inputs.firebase_data_action == 'none'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Apply and verify diagnostics v2 D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:diagnostics-v2
- name: Apply and verify Firebase crash D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:firebase-crash
- name: Apply dashboard performance indexes
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:dashboard-indexes
- name: Apply registry performance indexes and install rollup
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:registry-indexes
- name: Sync Firebase crash secrets
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
run: |
configured=0
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
echo "Firebase crash secrets must be either all configured or all absent."
exit 1
fi
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
- name: Deploy
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler deploy
# Mirrors the ALERT_WEBHOOK repo secret into the worker so the ingest
# sentinel can push alerts (see runIngestSentinel). Managed here so
# nobody needs local Cloudflare credentials; when the repo secret is
# unset the sentinel stays log-only.
- name: Sync alert webhook secret
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ALERT_WEBHOOK: ${{ secrets.ALERT_WEBHOOK }}
run: |
if [ -z "$ALERT_WEBHOOK" ]; then
echo "ALERT_WEBHOOK repo secret not set; removing any existing worker secret."
printf '{"ALERT_WEBHOOK":null}' | npx wrangler secret bulk
exit 0
fi
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
migrate-firebase-data:
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
runs-on: ubuntu-latest
environment: canary
steps:
- uses: actions/checkout@v7
- name: Require the protected production branch
run: |
if [ "$GITHUB_REF" != "refs/heads/main-v2" ]; then
echo "Firebase crash data migration must run from main-v2."
exit 1
fi
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Run Firebase crash history operation
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
run: |
configured=0
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 4 ]; then
echo "Cloudflare and Firebase migration secrets must all be configured."
exit 1
fi
case "$FIREBASE_DATA_ACTION" in
dry-run)
npm run migrate:firebase-data
;;
apply)
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
exit 1
fi
npm run migrate:firebase-data -- --apply
npm run migrate:firebase-data -- --verify-only
;;
verify-only)
npm run migrate:firebase-data -- --verify-only
;;
*)
echo "Unsupported Firebase crash data action."
exit 1
;;
esac
-41
View File
@@ -1,41 +0,0 @@
name: Deploy forum worker
on:
push:
branches: [main-v2]
paths:
- 'workers/forum/**'
- '.github/workflows/deploy-forum-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-forum-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/[email protected]
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- name: Deploy
working-directory: workers/forum
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler d1 execute reasonix-forum --remote --file=migrate-performance-indexes.sql
npx wrangler deploy
+56
View File
@@ -0,0 +1,56 @@
name: Diagnostic overhead monitoring
on:
push:
branches: [main-v2]
paths:
- desktop/electron/**
- desktop/frontend/**
- desktop/package.json
- desktop/pnpm-lock.yaml
- desktop/pnpm-workspace.yaml
- .github/workflows/diagnostic-overhead.yml
schedule:
- cron: "43 4 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: diagnostic-overhead-${{ github.ref }}
cancel-in-progress: true
jobs:
measure:
runs-on: windows-latest
timeout-minutes: 15
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/pnpm-lock.yaml
- name: Install the Electron workspace
run: pnpm install --frozen-lockfile
- name: Measure Electron diagnostic overhead
timeout-minutes: 10
run: node electron/scripts/performance-benchmark.mjs
- name: Upload diagnostic benchmark evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: diagnostic-overhead-windows-${{ github.sha }}
if-no-files-found: error
retention-days: 7
path: |
desktop/electron/artifacts/performance/overhead.json
desktop/electron/artifacts/performance/diagnostic-report.png
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
# Execute only the trusted base-branch checker; never run contributor code
# from a pull_request_target workflow.
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.pull_request.base.sha }}
+4 -4
View File
@@ -32,7 +32,7 @@ jobs:
environment: e2e-bot
steps:
- name: Acknowledge
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
await github.rest.reactions.createForIssueComment({
@@ -42,16 +42,16 @@ jobs:
# Default-branch checkout: this is where the harness (cmd/e2ebench), the
# suite, and a run --metrics-capable agent live.
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-python@v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.12'
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
classify:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
with:
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const body = context.payload.issue.body || '';
+5 -5
View File
@@ -14,20 +14,20 @@ jobs:
environment: release
timeout-minutes: 35
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/[email protected]
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
@@ -74,7 +74,7 @@ jobs:
test -s dist/Reasonix-darwin-universal.dmg
- name: Upload Apple notarization diagnostics
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: apple-signing-check-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/apple-notarization/*.json
-47
View File
@@ -1,47 +0,0 @@
name: Deploy site
on:
push:
branches: [main-v2]
paths: ['site/**', 'release-notes/**', '.github/workflows/pages.yml']
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: site/package-lock.json
- name: Build
working-directory: site
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm ci
npm run build
- uses: actions/upload-pages-artifact@v5
with:
path: site/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v5
+1 -1
View File
@@ -23,6 +23,6 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v7
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7
with:
sync-labels: false
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const lineByBase = { v1: 'v1', 'main-v2': 'v2', studio: 'v3' };
-105
View File
@@ -1,105 +0,0 @@
name: Prepare release
on:
workflow_dispatch:
inputs:
version:
description: "Official version without a tag prefix (for example 1.20.0)"
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
draft:
name: generate reviewable bilingual draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main-v2
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::version must be MAJOR.MINOR.PATCH"
exit 1
fi
branch="release-notes/v${VERSION}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
git merge --no-edit origin/main-v2
else
git checkout -b "$branch"
fi
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
- name: Generate and validate release notes
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
DEEPSEEK_MODEL: deepseek-v4-pro
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
# The review branch may already contain an older generated Notes
# commit. Generate strictly from the public release line so a local,
# not-yet-pushed merge commit and the Notes PR itself cannot enter
# the release range.
node scripts/generate-release-notes.mjs --version "$VERSION" --to origin/main-v2
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
{
echo "## Review draft for v${VERSION#v}"
echo
cat /tmp/release-notes.md
} >> "$GITHUB_STEP_SUMMARY"
- name: Open or update the review PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git add release-notes/releases.json
if ! git diff --cached --quiet; then
git commit -m "docs(release): prepare v${VERSION#v} notes" \
-m "Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.
Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
fi
git push -u origin "$RELEASE_NOTES_BRANCH"
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
if ! gh pr create \
--base main-v2 \
--head "$RELEASE_NOTES_BRANCH" \
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
--body-file /tmp/release-notes.md; then
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
{
echo "## Manual PR handoff"
echo
echo '```sh'
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
fi
fi
@@ -0,0 +1,129 @@
name: Verify release candidate rehearsal
run-name: Verify rehearsal ${{ inputs.candidate_id }}
on:
workflow_dispatch:
inputs:
candidate_id:
description: "Sealed non-publishing rehearsal candidate ID"
required: true
type: string
permissions:
actions: read
attestations: read
contents: read
jobs:
verify:
name: verify signed files across runs without rebuilding
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- name: Require protected control and resolve exact record
id: record
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
run: |
set -euo pipefail
test "$GITHUB_REPOSITORY" = esengine/DeepSeek-Reasonix
test "$GITHUB_REF" = refs/heads/main-v2
test "$GITHUB_REF_PROTECTED" = true
node scripts/resolve-release-candidate.mjs resolve-rehearsal "${{ inputs.candidate_id }}"
- name: Download and verify producer record
id: inspected
env:
GH_TOKEN: ${{ github.token }}
RECORD_ID: ${{ steps.record.outputs.record_artifact_id }}
PRODUCER_RUN: ${{ steps.record.outputs.producer_run_id }}
run: |
set -euo pipefail
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$RECORD_ID" > "$RUNNER_TEMP/record-artifact.json"
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$PRODUCER_RUN" > "$RUNNER_TEMP/producer-run.json"
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$RECORD_ID/zip" > "$RUNNER_TEMP/record.zip"
node scripts/verify-release-artifact-archive.mjs "$RUNNER_TEMP/record-artifact.json" "$RUNNER_TEMP/record.zip"
mkdir record
unzip -q "$RUNNER_TEMP/record.zip" -d record
test -s record/record.json
signer_sha="$(jq -r .head_sha "$RUNNER_TEMP/producer-run.json")"
gh attestation verify record/record.json --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "$signer_sha" --source-ref refs/heads/main-v2 --deny-self-hosted-runners
node scripts/resolve-release-candidate.mjs inspect-rehearsal "${{ inputs.candidate_id }}" \
record/record.json "$RUNNER_TEMP/record-artifact.json" "$RUNNER_TEMP/producer-run.json"
- name: Verify exact signed payload and native acceptance evidence
env:
GH_TOKEN: ${{ github.token }}
PAYLOAD_ID: ${{ steps.inspected.outputs.payload_artifact_id }}
PAYLOAD_NAME: ${{ steps.inspected.outputs.payload_artifact_name }}
PRODUCER_RUN: ${{ steps.inspected.outputs.producer_run_id }}
CONTROL_SHA: ${{ steps.inspected.outputs.candidate_control_sha }}
run: |
set -euo pipefail
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$PAYLOAD_ID" > "$RUNNER_TEMP/payload-artifact.json"
test "$(jq -r .name "$RUNNER_TEMP/payload-artifact.json")" = "$PAYLOAD_NAME"
test "$(jq -r .workflow_run.id "$RUNNER_TEMP/payload-artifact.json")" = "$PRODUCER_RUN"
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$PAYLOAD_ID/zip" > "$RUNNER_TEMP/payload.zip"
node scripts/verify-release-artifact-archive.mjs "$RUNNER_TEMP/payload-artifact.json" "$RUNNER_TEMP/payload.zip"
mkdir payload
unzip -q "$RUNNER_TEMP/payload.zip" -d payload
signer_digest="$CONTROL_SHA"
# Each check retries on its own, because the public-good verifier can fail
# to initialise on a shared runner; the first runs alone so the others
# find the trust cache built, then the rest run eight at a time and any
# single failure fails the step.
verify_provenance() {
local attempt=1
until gh attestation verify "$1" --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "$signer_digest" --source-ref refs/heads/main-v2 \
--deny-self-hosted-runners >/dev/null; do
test "$attempt" -lt 5 || return 1
attempt=$((attempt + 1))
sleep 6
done
}
export -f verify_provenance
export signer_digest
files=()
while IFS= read -r -d '' file; do files+=("$file"); done < <(find payload -type f -print0)
test "${#files[@]}" -gt 0
verify_provenance "${files[0]}"
if [ "${#files[@]}" -gt 1 ]; then
# shellcheck disable=SC2016 # the child shell must expand "$1" itself
printf '%s\0' "${files[@]:1}" | xargs -0 -P 8 -I{} bash -c 'verify_provenance "$1"' _ {}
fi
node scripts/release-candidate.mjs verify-rehearsal payload record/record.json >/dev/null
git fetch origin main-v2
git merge-base --is-ancestor "${{ steps.inspected.outputs.source_sha }}" origin/main-v2
jq -n \
--arg candidate "${{ inputs.candidate_id }}" \
--arg source "${{ steps.inspected.outputs.source_sha }}" \
--arg producer "$PRODUCER_RUN" \
--arg verifier "$GITHUB_RUN_ID" \
--arg record "$(sha256sum record/record.json | awk '{print $1}')" \
--arg payload "$(sha256sum "$RUNNER_TEMP/payload.zip" | awk '{print $1}')" \
'{candidateId:$candidate,sourceSHA:$source,producerRunId:$producer,verifierRunId:$verifier,recordSha256:$record,payloadArchiveSha256:$payload,publication:false,result:"verified"}' \
> rehearsal-verification.json
{
echo "### Independent rehearsal verification"
echo
echo "- Candidate: \`${{ inputs.candidate_id }}\`"
echo "- Producer run: \`$PRODUCER_RUN\`"
echo "- Record SHA-256: \`$(jq -r .recordSha256 rehearsal-verification.json)\`"
echo "- Payload archive SHA-256: \`$(jq -r .payloadArchiveSha256 rehearsal-verification.json)\`"
echo "- No tag, release channel, or update pointer was changed."
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-rehearsal-verification-${{ github.run_id }}-${{ github.run_attempt }}
path: rehearsal-verification.json
if-no-files-found: error
retention-days: 90
+467
View File
@@ -0,0 +1,467 @@
name: Prepare release candidate
run-name: Prepare release candidate ${{ inputs.version || github.sha }}
on:
workflow_dispatch:
inputs:
version:
description: "Official version without a tag prefix"
required: true
type: string
rehearsal:
description: "Build and accept isolated files that cannot be published"
required: false
default: false
type: boolean
push:
branches: [main-v2]
paths:
- release-notes/releases.json
concurrency:
group: release-candidate-${{ inputs.rehearsal && 'rehearsal' || 'release' }}-${{ inputs.version || github.sha }}
cancel-in-progress: false
permissions:
actions: read
contents: read
jobs:
resolve:
name: validate candidate inputs before native builds
runs-on: ubuntu-latest
outputs:
version: ${{ steps.candidate.outputs.version }}
candidate_id: ${{ steps.candidate.outputs.candidate_id }}
catalog_sha256: ${{ steps.candidate.outputs.catalog_sha256 }}
reuse: ${{ steps.existing.outputs.reuse }}
artifact_namespace: ${{ steps.candidate.outputs.artifact_namespace }}
purpose: ${{ steps.candidate.outputs.purpose }}
permissions:
actions: read
attestations: read
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Resolve reviewed source
id: candidate
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
PUSH_SHA: ${{ github.sha }}
REHEARSAL: ${{ inputs.rehearsal || false }}
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
run: |
set -euo pipefail
git fetch origin main-v2 --tags
if [ "$EVENT_NAME" = push ]; then
version="$(jq -r '.releases[0].version // empty' release-notes/releases.json)"
source_sha="$PUSH_SHA"
else
version="$INPUT_VERSION"
source_sha="$PUSH_SHA"
fi
purpose=release
artifact_namespace=release-candidate
if [ "$REHEARSAL" = true ]; then
purpose=rehearsal
artifact_namespace=release-candidate-rehearsal
fi
bash scripts/validate-release-candidate-source.sh "$version" "$source_sha" "$purpose"
git show "$source_sha:release-notes/releases.json" > /tmp/release-catalog.json
catalog_sha256="$(sha256sum /tmp/release-catalog.json | awk '{print $1}')"
candidate_id="$(node scripts/release-candidate.mjs id "$version" "$source_sha" "$catalog_sha256")"
node scripts/resolve-release-candidate.mjs active "$candidate_id"
{
echo "version=$version"
echo "source_sha=$source_sha"
echo "catalog_sha256=$catalog_sha256"
echo "candidate_id=$candidate_id"
echo "purpose=$purpose"
echo "artifact_namespace=$artifact_namespace"
} >> "$GITHUB_OUTPUT"
{
echo "### Candidate input"
echo
echo "- ID: \`$candidate_id\`"
echo "- Product: \`$source_sha\`"
echo "- Control: \`$GITHUB_SHA\`"
echo "- Purpose: \`$purpose\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Verify release control helpers and signing configuration
env:
OFFICIAL_REPOSITORY: ${{ github.repository == 'esengine/DeepSeek-Reasonix' }}
HAS_APPLE_SIGNING: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
HAS_CERTUM_SIGNING: ${{ secrets.CERTUM_USERNAME != '' && secrets.CERTUM_OTP_URI != '' && secrets.CERTUM_KEY_ID != '' }}
HAS_MINISIGN: ${{ secrets.MINISIGN_PRIVATE_KEY != '' && secrets.MINISIGN_PASSWORD != '' }}
run: |
bash scripts/validate-release-control-plane.sh
bash scripts/check-release-public-access.sh "${{ steps.candidate.outputs.version }}"
if [ "$OFFICIAL_REPOSITORY" = true ]; then
for configured in "$HAS_APPLE_SIGNING" "$HAS_CERTUM_SIGNING" "$HAS_MINISIGN"; do
[ "$configured" = true ] || { echo "::error::release signing configuration is incomplete"; exit 1; }
done
fi
- name: Reuse an existing sealed candidate
if: inputs.rehearsal != true
id: existing
env:
GH_TOKEN: ${{ github.token }}
CANDIDATE_ID: ${{ steps.candidate.outputs.candidate_id }}
RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
run: |
set -euo pipefail
resolved="$RUNNER_TEMP/existing-candidate.outputs"
GITHUB_OUTPUT="$resolved" node scripts/resolve-release-candidate.mjs resolve-optional "$CANDIDATE_ID"
output() { sed -n "s/^$1=//p" "$resolved" | tail -1; }
if [ "$(output found)" != true ]; then
echo "reuse=false" >> "$GITHUB_OUTPUT"
exit 0
fi
record_id="$(output record_artifact_id)"
run_id="$(output producer_run_id)"
mkdir -p "$RUNNER_TEMP/existing-record"
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$record_id" > "$RUNNER_TEMP/existing-record-artifact.json"
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id" > "$RUNNER_TEMP/existing-run.json"
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$record_id/zip" > "$RUNNER_TEMP/existing-record.zip"
node scripts/verify-release-artifact-archive.mjs "$RUNNER_TEMP/existing-record-artifact.json" "$RUNNER_TEMP/existing-record.zip"
unzip -q "$RUNNER_TEMP/existing-record.zip" -d "$RUNNER_TEMP/existing-record"
jq -e '
.validity.revoked == false and
((.validity.createdAt | fromdateiso8601) < (.validity.expiresAt | fromdateiso8601))
' "$RUNNER_TEMP/existing-record/record.json" >/dev/null
if ! jq -e '(.validity.expiresAt | fromdateiso8601) > now' \
"$RUNNER_TEMP/existing-record/record.json" >/dev/null; then
echo "reuse=false" >> "$GITHUB_OUTPUT"
echo "The sealed record expired; preparing fresh files." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
signer_sha="$(jq -r .head_sha "$RUNNER_TEMP/existing-run.json")"
gh attestation verify "$RUNNER_TEMP/existing-record/record.json" --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "$signer_sha" --source-ref refs/heads/main-v2 --deny-self-hosted-runners
inspected="$RUNNER_TEMP/existing-inspected.outputs"
GITHUB_OUTPUT="$inspected" node scripts/resolve-release-candidate.mjs inspect "$CANDIDATE_ID" \
"$RUNNER_TEMP/existing-record/record.json" "$RUNNER_TEMP/existing-record-artifact.json" \
"$RUNNER_TEMP/existing-run.json"
payload_id="$(sed -n 's/^payload_artifact_id=//p' "$inspected" | tail -1)"
if ! gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$payload_id" \
> "$RUNNER_TEMP/existing-payload.json" 2> "$RUNNER_TEMP/existing-payload.error"; then
if grep -Eiq 'HTTP 404|Not Found' "$RUNNER_TEMP/existing-payload.error"; then
echo "reuse=false" >> "$GITHUB_OUTPUT"
echo "The sealed record exists but its payload is unavailable; preparing fresh files." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
cat "$RUNNER_TEMP/existing-payload.error" >&2
exit 1
fi
test "$(jq -r .expired "$RUNNER_TEMP/existing-payload.json")" = false || {
echo "reuse=false" >> "$GITHUB_OUTPUT"
echo "The sealed record exists but its payload expired; preparing fresh files." >> "$GITHUB_STEP_SUMMARY"
exit 0
}
test "$(jq -r .name "$RUNNER_TEMP/existing-payload.json")" = "release-candidate-payload-$CANDIDATE_ID"
test "$(jq -r .workflow_run.id "$RUNNER_TEMP/existing-payload.json")" = "$run_id"
echo "reuse=true" >> "$GITHUB_OUTPUT"
echo "A sealed candidate with an active exact payload already exists; native builds are skipped." >> "$GITHUB_STEP_SUMMARY"
- name: Verify source CI after control preflight
if: steps.existing.outputs.reuse != 'true'
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_SOURCE_SHA: ${{ github.sha }}
RELEASE_CI_WAIT_SECONDS: 1800
run: bash scripts/verify-release-push-ci.sh "$RELEASE_SOURCE_SHA"
# Release-specific but not an input to the native builds: it runs beside
# them and gates only the seal, so it adds nothing to the critical path.
notes:
name: validate embedded notes and cache identity alongside native builds
needs: resolve
if: needs.resolve.outputs.reuse != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Validate embedded notes and cache identity inputs
env:
DOCS_BUILD_VERSION: v${{ needs.resolve.outputs.version }}
DOCS_SOURCE_REVISION: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
run: |
node scripts/release-notes.mjs validate
node scripts/release-notes.mjs render --version "$DOCS_BUILD_VERSION" --output /tmp/release-notes.md
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
./scripts/cache-guard.sh
cli-npm:
name: build shared CLI and npm candidate
needs: resolve
if: needs.resolve.outputs.reuse != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- name: Build each CLI binary once and package both surfaces
env:
RELEASE_SOURCE_SHA: ${{ github.sha }}
run: |
RELEASE_BUILD_TIME="$(git show -s --format=%cI "$RELEASE_SOURCE_SHA")"
export RELEASE_BUILD_TIME
node scripts/build-release-cli-candidate.mjs "${{ needs.resolve.outputs.version }}" candidate-cli
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: candidate-cli-${{ needs.resolve.outputs.candidate_id }}
path: |
candidate-cli/cli
candidate-cli/npm
if-no-files-found: error
overwrite: true
retention-days: 30
desktop:
name: build, sign, and accept Desktop candidate
needs: resolve
if: needs.resolve.outputs.reuse != 'true'
# GitHub validates every nested job before evaluating its condition, even
# though candidate preparation skips the publisher and mirror jobs.
permissions:
actions: write
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
tag: desktop-v${{ needs.resolve.outputs.version }}
approved_cli_tag: v${{ needs.resolve.outputs.version }}
approved_sha: ${{ github.sha }}
orchestrated: true
orchestrator: candidate
signing_preflight: true
candidate_preparation: true
candidate_rehearsal: ${{ inputs.rehearsal || false }}
secrets: inherit
seal:
name: seal reusable release candidate
needs: [resolve, notes, cli-npm, desktop]
if: >-
always() && !cancelled() && needs.resolve.outputs.reuse != 'true' &&
needs.resolve.result == 'success' && needs.notes.result == 'success' &&
needs.cli-npm.result == 'success' && needs.desktop.result == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
attestations: write
contents: read
id-token: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: candidate-cli-${{ needs.resolve.outputs.candidate_id }}
path: payload
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-darwin-arm64
path: payload/desktop/darwin-arm64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-darwin-amd64
path: payload/desktop/darwin-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-darwin-universal
path: payload/desktop/darwin-universal
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-windows-amd64
path: payload/desktop/windows-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-windows-arm64
path: payload/desktop/windows-arm64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-linux-amd64
path: payload/desktop/linux-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-receipt-windows-amd64
path: payload/evidence
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-receipt-windows-arm64
path: payload/evidence
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-receipt-macos-universal-intel
path: payload/evidence
- name: Normalize acceptance receipt names
run: |
test -s payload/evidence/windows-amd64.json
test -s payload/evidence/windows-arm64.json
test -s payload/evidence/macos-universal-intel.json
# Rendered once and sealed: promotion publishes these bytes and never
# reads GitHub again, so a later rename or outage cannot change them.
- name: Render reviewed notes into the sealed payload
env:
GH_TOKEN: ${{ github.token }}
run: |
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ github.sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output payload/evidence/release-notes.md
git worktree remove "$worktree"
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: payload/**/*
- name: Upload immutable candidate payload
id: payload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ needs.resolve.outputs.artifact_namespace }}-payload-${{ needs.resolve.outputs.candidate_id }}
path: payload
if-no-files-found: error
overwrite: true
retention-days: 30
- name: Retain native acceptance evidence
id: evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ needs.resolve.outputs.artifact_namespace }}-evidence-${{ needs.resolve.outputs.candidate_id }}
path: payload/evidence
if-no-files-found: error
overwrite: true
retention-days: 90
- name: Seal candidate record
env:
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
RELEASE_CANDIDATE_PURPOSE: ${{ needs.resolve.outputs.purpose }}
RELEASE_SOURCE_SHA: ${{ github.sha }}
RELEASE_BUILD_CONTROL_SHA: ${{ github.sha }}
RELEASE_ACCEPTANCE_CONTROL_SHA: ${{ github.sha }}
RELEASE_NOTES_SOURCE_SHA: ${{ github.sha }}
RELEASE_CATALOG_SHA256: ${{ needs.resolve.outputs.catalog_sha256 }}
RELEASE_DESKTOP_PREFIX: ${{ needs.desktop.outputs.artifact_prefix }}
RELEASE_DESKTOP_FINGERPRINT: ${{ needs.desktop.outputs.signing_fingerprint }}
RELEASE_PAYLOAD_ARTIFACT_ID: ${{ steps.payload.outputs.artifact-id }}
RELEASE_PAYLOAD_ARTIFACT_NAME: ${{ needs.resolve.outputs.artifact_namespace }}-payload-${{ needs.resolve.outputs.candidate_id }}
RELEASE_EVIDENCE_ARTIFACT_ID: ${{ steps.evidence.outputs.artifact-id }}
RELEASE_EVIDENCE_ARTIFACT_NAME: ${{ needs.resolve.outputs.artifact_namespace }}-evidence-${{ needs.resolve.outputs.candidate_id }}
RELEASE_WORKFLOW: .github/workflows/release-candidate.yml
RELEASE_ACCEPTANCE_JSON: >-
[{"kind":"windows-amd64","status":"passed","evidencePath":"evidence/windows-amd64.json"},{"kind":"windows-arm64","status":"passed","evidencePath":"evidence/windows-arm64.json"},{"kind":"macos-universal-intel","status":"passed","evidencePath":"evidence/macos-universal-intel.json"}]
run: |
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum payload/evidence/release-notes.md | awk '{print $1}')"
RELEASE_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RELEASE_EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"
export RELEASE_RENDERED_NOTES_SHA256 RELEASE_CREATED_AT RELEASE_EXPIRES_AT
node scripts/release-candidate.mjs seal payload record/record.json
if [ "$RELEASE_CANDIDATE_PURPOSE" = rehearsal ]; then
node scripts/release-candidate.mjs verify-rehearsal payload record/record.json
else
node scripts/release-candidate.mjs verify payload record/record.json
fi
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: record/record.json
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ needs.resolve.outputs.artifact_namespace }}-record-${{ needs.resolve.outputs.candidate_id }}
path: record/record.json
if-no-files-found: error
overwrite: true
retention-days: 90
- name: Report publish command
env:
PURPOSE: ${{ needs.resolve.outputs.purpose }}
run: |
{
echo "### Candidate ready"
echo
echo "\`${{ needs.resolve.outputs.candidate_id }}\`"
echo
echo '```sh'
if [ "$PURPOSE" = rehearsal ]; then
echo "gh workflow run release-candidate-verify.yml --ref main-v2 -f candidate_id='${{ needs.resolve.outputs.candidate_id }}'"
else
echo "./scripts/release-stable.sh '${{ needs.resolve.outputs.candidate_id }}'"
fi
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
metrics:
name: record candidate timing
needs: [resolve, notes, cli-npm, desktop, seal]
if: ${{ always() && !cancelled() }}
continue-on-error: true
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
- name: Record queue, execution, and critical-path evidence
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > "$RUNNER_TEMP/release-run.json"
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=all&per_page=100" > "$RUNNER_TEMP/release-jobs.json"
node scripts/ci-timings.mjs \
--run "$RUNNER_TEMP/release-run.json" \
--jobs "$RUNNER_TEMP/release-jobs.json" \
--summary "$GITHUB_STEP_SUMMARY" \
--output "$RUNNER_TEMP/candidate-timing.json" \
--title "Release candidate timing"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-candidate-timing-${{ needs.resolve.outputs.candidate_id || github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/candidate-timing.json
if-no-files-found: ignore
overwrite: true
retention-days: 90
File diff suppressed because it is too large. Load diff
+5 -11
View File
@@ -4,15 +4,9 @@ on:
pull_request:
paths:
- "release-notes/**"
- "scripts/generate-release-notes.mjs"
- "scripts/release-notes*.mjs"
- "site/src/pages/changelog/**"
- "site/src/components/ChangelogPage.astro"
- "site/src/data/releases.ts"
- "site/src/styles/changelog.css"
- "desktop/frontend/src/App.tsx"
- "desktop/frontend/src/components/SettingsPanel.tsx"
- "desktop/frontend/src/components/UpdateBanner.tsx"
- "scripts/release-credits*.mjs"
- "scripts/release-event.mjs"
permissions:
contents: read
@@ -21,9 +15,9 @@ jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- run: node scripts/release-notes.mjs validate
- run: node --test scripts/release-notes.test.mjs
- run: node --test scripts/release-notes.test.mjs scripts/release-credits.test.mjs
+44 -9
View File
@@ -61,6 +61,16 @@ on:
required: false
default: ""
type: string
candidate_artifact_name:
description: "Same-run artifact containing a verified sealed release candidate"
required: false
default: ""
type: string
candidate_verified:
description: "Protected Stable preflight verified candidate provenance and bytes"
required: false
default: false
type: boolean
permissions:
contents: read
@@ -79,7 +89,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
@@ -94,12 +104,16 @@ jobs:
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
APPROVED_CHANNEL: ${{ inputs.orchestrator == 'promote' && 'stable' || inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
if [ -n "${{ inputs.candidate_artifact_name }}" ] && [ "${{ inputs.candidate_verified }}" != "true" ]; then
echo "::error::prepared npm packages require verified candidate provenance"
exit 1
fi
release-gate:
name: approve standalone npm release
@@ -117,15 +131,18 @@ jobs:
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
if: ${{ !inputs.candidate_verified }}
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
- if: ${{ !inputs.candidate_verified }}
run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
if: ${{ !inputs.candidate_verified }}
env:
DOCS_BUILD_VERSION: v${{ inputs.base_version }}
DOCS_SOURCE_REVISION: ${{ inputs.approved_sha }}
@@ -151,7 +168,7 @@ jobs:
# Orchestrated releases have already passed their GitHub environment
# approval. Direct prereleases and manual Stable recovery pass release-gate.
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- name: Load approved npm publication control plane
@@ -162,15 +179,23 @@ jobs:
git fetch --no-tags --depth=1 origin "$RECOVERY_CONTROL_SHA"
git restore --source="$RECOVERY_CONTROL_SHA" -- \
npm/publish.mjs \
npm/publish-candidate.mjs \
scripts/finalize-npm-official-release.mjs
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
if: ${{ inputs.candidate_artifact_name == '' }}
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Download sealed release candidate
if: ${{ inputs.candidate_artifact_name != '' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ inputs.candidate_artifact_name }}
path: ${{ runner.temp }}/release-candidate
# Stable publication uses the exact npm-v* tag. build.mjs strips the
# leading `npm-`/`v`; historical Preview inputs remain only for old
# workflow-call compatibility and are not reachable from a public entry.
@@ -193,12 +218,22 @@ jobs:
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
- name: Publish or recover immutable npm packages
if: ${{ inputs.candidate_artifact_name == '' }}
env:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
NPM_STABLE_DIST_TAG: ${{ vars.CLI_PUBLISH_FROZEN == 'true' && 'legacy-v1' || 'latest' }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/build.mjs "$VERSION_ARG" --publish
- name: Publish or recover prepared npm packages
if: ${{ inputs.candidate_artifact_name != '' }}
env:
VERSION: ${{ inputs.base_version }}
APPROVED_SHA: ${{ inputs.approved_sha }}
NPM_STABLE_DIST_TAG: ${{ vars.CLI_PUBLISH_FROZEN == 'true' && 'legacy-v1' || 'latest' }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/publish-candidate.mjs "$RUNNER_TEMP/release-candidate/npm" "$VERSION" "$APPROVED_SHA"
- name: Align legacy aliases with the official release
if: ${{ inputs.channel == 'stable' }}
if: ${{ inputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' }}
env:
VERSION: ${{ inputs.base_version }}
APPROVED_SHA: ${{ inputs.approved_sha }}
+406
View File
@@ -0,0 +1,406 @@
name: Publish release candidate
run-name: ${{ inputs.operation }} ${{ inputs.candidate_id }}
on:
workflow_dispatch:
inputs:
candidate_id:
description: "Sealed candidate from Prepare release candidate"
required: true
type: string
operation:
description: "Publish a new identity or recover an interrupted publication"
required: true
default: publish
type: choice
options: [publish, recover]
concurrency:
group: stable-release-publication
cancel-in-progress: false
permissions:
actions: write
attestations: read
contents: write
issues: read # granted to the nested release workflows, which can render notes
pull-requests: read
jobs:
preflight:
name: verify sealed candidate before approval
if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/main-v2' && github.ref_protected
runs-on: ubuntu-latest
permissions:
actions: read
attestations: read
contents: read
outputs:
tag_actor_id: ${{ steps.identity.outputs.actor_id }}
candidate_id: ${{ steps.record.outputs.candidate_id }}
version: ${{ steps.record.outputs.version }}
source_sha: ${{ steps.record.outputs.source_sha }}
producer_run_id: ${{ steps.record.outputs.producer_run_id }}
producer_run_attempt: ${{ steps.record.outputs.producer_run_attempt }}
candidate_control_sha: ${{ steps.record.outputs.candidate_control_sha }}
signing_fingerprint: ${{ steps.record.outputs.signing_fingerprint }}
desktop_prefix: ${{ steps.record.outputs.desktop_prefix }}
approved_artifact: approved-release-candidate-${{ github.run_id }}-${{ github.run_attempt }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Validate executable contracts and public access before payload or approval
env:
CANDIDATE_ID: ${{ inputs.candidate_id }}
run: |
bash scripts/validate-release-control-plane.sh
version="${CANDIDATE_ID#v}"
bash scripts/check-release-public-access.sh "${version%%-*}"
- name: Resolve candidate record artifact
id: artifact
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
run: node scripts/resolve-release-candidate.mjs resolve "${{ inputs.candidate_id }}"
- name: Download exact candidate record
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_ID: ${{ steps.artifact.outputs.record_artifact_id }}
PRODUCER_RUN_ID: ${{ steps.artifact.outputs.producer_run_id }}
run: |
mkdir -p record
gh api "repos/${{ github.repository }}/actions/artifacts/$ARTIFACT_ID" > /tmp/record-artifact.json
gh api "repos/${{ github.repository }}/actions/runs/$PRODUCER_RUN_ID" > /tmp/producer-run.json
gh api "repos/${{ github.repository }}/actions/artifacts/$ARTIFACT_ID/zip" > /tmp/record.zip
node scripts/verify-release-artifact-archive.mjs /tmp/record-artifact.json /tmp/record.zip
unzip -q /tmp/record.zip -d record
test -s record/record.json
- name: Verify record provenance and resolve exact payload
id: record
env:
GH_TOKEN: ${{ github.token }}
run: |
signer_sha="$(jq -r .head_sha /tmp/producer-run.json)"
gh attestation verify record/record.json --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "$signer_sha" --source-ref refs/heads/main-v2 --deny-self-hosted-runners
node scripts/resolve-release-candidate.mjs inspect "${{ inputs.candidate_id }}" \
record/record.json /tmp/record-artifact.json /tmp/producer-run.json
- name: Check the configured tag publisher before approval
id: identity
env:
GH_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }}
RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }}
run: node scripts/verify-release-tag-identity.mjs "${{ steps.record.outputs.version }}"
- name: Download exact candidate payload
env:
GH_TOKEN: ${{ github.token }}
PAYLOAD_ID: ${{ steps.record.outputs.payload_artifact_id }}
run: |
gh api "repos/${{ github.repository }}/actions/artifacts/$PAYLOAD_ID" > /tmp/payload-artifact.json
test "$(jq -r .name /tmp/payload-artifact.json)" = "${{ steps.record.outputs.payload_artifact_name }}"
test "$(jq -r .expired /tmp/payload-artifact.json)" = false
test "$(jq -r .workflow_run.id /tmp/payload-artifact.json)" = "${{ steps.record.outputs.producer_run_id }}"
mkdir -p payload
gh api "repos/${{ github.repository }}/actions/artifacts/$PAYLOAD_ID/zip" > /tmp/payload.zip
node scripts/verify-release-artifact-archive.mjs /tmp/payload-artifact.json /tmp/payload.zip
unzip -q /tmp/payload.zip -d payload
- name: Verify payload provenance, bytes, source, and operation
env:
GH_TOKEN: ${{ github.token }}
OPERATION: ${{ inputs.operation }}
VERSION: ${{ steps.record.outputs.version }}
SOURCE_SHA: ${{ steps.record.outputs.source_sha }}
run: |
set -euo pipefail
signer_digest="${{ steps.record.outputs.candidate_control_sha }}"
# Each check retries on its own, because the public-good verifier can fail
# to initialise on a shared runner; the first runs alone so the others
# find the trust cache built, then the rest run eight at a time and any
# single failure fails the step.
verify_provenance() {
local attempt=1
until gh attestation verify "$1" --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "$signer_digest" --source-ref refs/heads/main-v2 \
--deny-self-hosted-runners >/dev/null; do
test "$attempt" -lt 5 || return 1
attempt=$((attempt + 1))
sleep 6
done
}
export -f verify_provenance
export signer_digest
files=()
while IFS= read -r -d '' file; do files+=("$file"); done < <(find payload -type f -print0)
test "${#files[@]}" -gt 0
verify_provenance "${files[0]}"
if [ "${#files[@]}" -gt 1 ]; then
# shellcheck disable=SC2016 # the child shell must expand "$1" itself
printf '%s\0' "${files[@]:1}" | xargs -0 -P 8 -I{} bash -c 'verify_provenance "$1"' _ {}
fi
node scripts/release-candidate.mjs verify payload record/record.json >/dev/null
git fetch origin main-v2 --tags
git merge-base --is-ancestor "$SOURCE_SHA" origin/main-v2
git show "$SOURCE_SHA:release-notes/releases.json" > /tmp/catalog.json
test "$(sha256sum /tmp/catalog.json | awk '{print $1}')" = "$(jq -r .notes.catalogSha256 record/record.json)"
bash scripts/release-candidate-tags.sh check "$OPERATION" "$VERSION" "$SOURCE_SHA"
{
echo "### Sealed release approval"
echo
echo "- Candidate: \`${{ inputs.candidate_id }}\`"
echo "- Operation: \`$OPERATION\`"
echo "- Product SHA: \`$SOURCE_SHA\`"
echo "- Candidate record SHA-256: \`$(sha256sum record/record.json | awk '{print $1}')\`"
echo "- Notes SHA-256: \`$(jq -r .notes.renderedSha256 record/record.json)\`"
echo "- Signing policy: \`$(jq -r .signing.desktopFingerprint record/record.json)\`"
echo "- Payload files: \`$(jq '.files | length' record/record.json)\`"
echo "- Native acceptance: \`$(jq -r '[.acceptance[] | select(.status == "passed") | .kind] | join(", ")' record/record.json)\`"
} >> "$GITHUB_STEP_SUMMARY"
# A candidate sealed with its notes publishes those bytes without touching
# the network. One sealed before that renders offline from its own source.
- name: Bind reviewed release notes
run: |
sealed="$(jq -r '.notes.renderedPath // empty' record/record.json)"
if [ -n "$sealed" ]; then
cp "payload/$sealed" release-notes.md
else
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
git worktree remove "$worktree"
fi
test "$(sha256sum release-notes.md | awk '{print $1}')" = "$(jq -r .notes.renderedSha256 record/record.json)"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: orchestrator-reviewed-release-notes
path: release-notes.md
if-no-files-found: error
retention-days: 1
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: approved-release-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: payload
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-darwin-arm64
path: payload/desktop/darwin-arm64
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-darwin-amd64
path: payload/desktop/darwin-amd64
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-darwin-universal
path: payload/desktop/darwin-universal
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-windows-amd64
path: payload/desktop/windows-amd64
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-windows-arm64
path: payload/desktop/windows-arm64
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-linux-amd64
path: payload/desktop/linux-amd64
if-no-files-found: error
retention-days: 7
authorize:
name: approve sealed Stable candidate
needs: preflight
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
outputs:
version: ${{ needs.preflight.outputs.version }}
source_sha: ${{ needs.preflight.outputs.source_sha }}
steps:
- run: echo "Approved ${{ needs.preflight.outputs.candidate_id }} at ${{ needs.preflight.outputs.source_sha }}"
activate:
name: atomically activate release identity
needs: [preflight, authorize]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
persist-credentials: false
- name: Create or verify all implementation tags
env:
GH_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }}
RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }}
RELEASE_TAG_ACTOR_ID: ${{ needs.preflight.outputs.tag_actor_id }}
OPERATION: ${{ inputs.operation }}
VERSION: ${{ needs.preflight.outputs.version }}
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
CANDIDATE_ID: ${{ needs.preflight.outputs.candidate_id }}
RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
run: |
set -euo pipefail
node scripts/verify-release-tag-identity.mjs "$VERSION"
gh auth setup-git --hostname github.com
node scripts/resolve-release-candidate.mjs active "$CANDIDATE_ID"
git fetch origin main-v2 --tags
bash scripts/release-candidate-tags.sh activate "$OPERATION" "$VERSION" "$SOURCE_SHA"
cli:
name: publish CLI and Homebrew from candidate
needs: [preflight, activate]
uses: ./.github/workflows/release.yml
with:
tag: v${{ needs.preflight.outputs.version }}
approved_cli_tag: v${{ needs.preflight.outputs.version }}
approved_sha: ${{ needs.preflight.outputs.source_sha }}
orchestrated: true
orchestrator: promote
candidate_artifact_name: ${{ needs.preflight.outputs.approved_artifact }}
candidate_verified: true
secrets: inherit
npm:
name: publish npm from candidate
needs: [preflight, activate]
permissions:
contents: read
id-token: write
uses: ./.github/workflows/release-npm.yml
with:
channel: stable
base_version: ${{ needs.preflight.outputs.version }}
tag: npm-v${{ needs.preflight.outputs.version }}
approved_cli_tag: v${{ needs.preflight.outputs.version }}
approved_sha: ${{ needs.preflight.outputs.source_sha }}
orchestrated: true
orchestrator: promote
candidate_artifact_name: ${{ needs.preflight.outputs.approved_artifact }}
candidate_verified: true
secrets: inherit
desktop:
name: publish Desktop from candidate
needs: [preflight, activate]
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
tag: desktop-v${{ needs.preflight.outputs.version }}
approved_cli_tag: v${{ needs.preflight.outputs.version }}
approved_sha: ${{ needs.preflight.outputs.source_sha }}
orchestrated: true
orchestrator: promote
signing_preflight_verified: true
preflight_artifact_prefix: ${{ needs.preflight.outputs.desktop_prefix }}
candidate_id: ${{ needs.preflight.outputs.candidate_id }}
candidate_source_run_id: ${{ needs.preflight.outputs.producer_run_id }}
candidate_source_run_attempt: ${{ needs.preflight.outputs.producer_run_attempt }}
candidate_control_sha: ${{ needs.preflight.outputs.candidate_control_sha }}
candidate_signing_fingerprint: ${{ needs.preflight.outputs.signing_fingerprint }}
candidate_verified: true
secrets: inherit
postflight:
name: verify every public Stable surface
timeout-minutes: 45
needs: [preflight, cli, npm, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Require every publisher
env:
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do test "$result" = success; done
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Reconcile public artifacts, release event, and durable ledger
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
RELEASE_OPERATION: ${{ inputs.operation }}
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger.json
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
run: bash scripts/reconcile-release-publication.sh
- name: Upload publication ledger
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-publication-ledger-${{ needs.preflight.outputs.candidate_id }}-${{ github.run_attempt }}
path: /tmp/publication-ledger.json
if-no-files-found: error
overwrite: true
retention-days: 90
metrics:
name: record publication timing
needs: [preflight, authorize, activate, cli, npm, desktop, postflight]
if: ${{ always() && !cancelled() }}
continue-on-error: true
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
- name: Record queue, execution, and critical-path evidence
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > "$RUNNER_TEMP/release-run.json"
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=all&per_page=100" > "$RUNNER_TEMP/release-jobs.json"
node scripts/ci-timings.mjs \
--run "$RUNNER_TEMP/release-run.json" \
--jobs "$RUNNER_TEMP/release-jobs.json" \
--summary "$GITHUB_STEP_SUMMARY" \
--output "$RUNNER_TEMP/publication-timing.json" \
--title "Release publication timing"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-publication-timing-${{ needs.preflight.outputs.candidate_id || github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/publication-timing.json
if-no-files-found: ignore
overwrite: true
retention-days: 90
@@ -1,42 +0,0 @@
name: Relay stable release
run-name: Relay stable release ${{ github.ref_name }}
# The tag event itself has a tag-shaped origin. Relay the immutable tag name to
# release-stable.yml on protected main-v2 so the production SignPath policy can
# allow exactly one branch name without trusting wildcard tag-like branches.
on:
push:
tags:
- 'v*'
- '!v*-*'
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected stable control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release-stable.yml on main-v2
uses: actions/github-script@v9
env:
RELEASE_TAG: ${{ github.ref_name }}
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
with:
script: |
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
return;
}
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release-stable.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: {
tag: process.env.RELEASE_TAG,
allow_recovery: 'false',
},
});
+48 -20
View File
@@ -1,12 +1,9 @@
name: Release stable
run-name: Release stable ${{ inputs.tag || github.ref_name }}
name: Legacy release recovery
run-name: Legacy recovery ${{ inputs.tag || github.ref_name }}
# Stable publication has exactly one GitHub environment gate. Push the CLI,
# npm, and desktop tags atomically; release-stable-trigger.yml relays the vX.Y.Z
# tag to this workflow on protected main-v2. Preflight verifies that all three
# tags point to the reviewed Notes candidate on main-v2 history, and one GitHub
# approval releases every surface. After approval, a zero-publication Windows
# preflight verifies both architectures and signing stages before publication.
# Compatibility recovery for releases created before sealed candidates. New
# releases use release-candidate.yml followed by release-promote.yml. This path
# retains one GitHub environment gate and verifies all three existing tags.
# Keeping the control-plane ref on main-v2 lets SignPath restrict
# production signing to that one protected origin instead of trusting wildcard
# tag-like branch names. Manual recovery uses the same fixed control plane while
@@ -38,9 +35,19 @@ on:
required: false
default: true
type: boolean
desktop_manual_only:
description: "Approved exception: unsigned Windows, manual Desktop downloads only"
required: false
default: false
type: boolean
reuse_manual_artifacts:
description: "Recover the verified v1.38.8 artifacts from run 34816299501 (v1.38.8 only)"
required: false
default: false
type: boolean
concurrency:
group: stable-release-${{ inputs.tag || github.ref_name }}
group: stable-release-publication
cancel-in-progress: false
# Reusable release workflows can only reduce caller permissions, so the
@@ -48,6 +55,8 @@ concurrency:
permissions:
actions: write
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
jobs:
preflight:
@@ -56,6 +65,8 @@ jobs:
permissions:
actions: read
contents: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
outputs:
version: ${{ steps.release.outputs.version }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
@@ -63,17 +74,17 @@ jobs:
desktop_tag: ${{ steps.release.outputs.desktop_tag }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
# A recovery dispatch uses the fixed workflow/scripts from protected
# main-v2. Publishers still check out the immutable approved tag SHA.
ref: ${{ github.sha }}
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- name: Resolve stable release
@@ -82,6 +93,16 @@ jobs:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }}
run: bash scripts/resolve-stable-release.sh
# scripts/manual-desktop-exception.sh owns which tags may ship without
# Windows Authenticode. A tag approved before its candidate exists keeps
# the normal candidate and push-CI validation below.
- name: Restrict manual Desktop distribution
if: ${{ inputs.desktop_manual_only }}
run: |
bash scripts/manual-desktop-exception.sh validate \
"${{ steps.release.outputs.desktop_tag }}" \
"${{ steps.release.outputs.sha }}" \
"${{ inputs.allow_recovery }}"
- name: Revalidate normal release candidate and exact push CI
if: ${{ !inputs.allow_recovery }}
env:
@@ -94,12 +115,14 @@ jobs:
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
- name: Validate reviewed release notes
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
# Recovery builds deliberately check out the immutable tagged candidate,
# which can predate its reviewed release-note entry. Carry the exact file
# validated by this protected control-plane job into both publishers.
- name: Upload reviewed release notes
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: orchestrator-reviewed-release-notes
path: /tmp/release-notes.md
@@ -182,6 +205,8 @@ jobs:
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
signing_preflight: true
desktop_manual_only: ${{ inputs.desktop_manual_only || false }}
reuse_manual_artifacts: ${{ inputs.reuse_manual_artifacts || false }}
secrets: inherit
desktop:
@@ -196,6 +221,8 @@ jobs:
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
signing_preflight_verified: true
desktop_manual_only: ${{ inputs.desktop_manual_only || false }}
reuse_manual_artifacts: ${{ inputs.reuse_manual_artifacts || false }}
preflight_artifact_prefix: ${{ needs.signpath-preflight.outputs.artifact_prefix }}
secrets: inherit
@@ -205,7 +232,6 @@ jobs:
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every publisher to succeed
@@ -232,14 +258,18 @@ jobs:
exit 1
fi
done
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Postflight belongs to the trusted control plane, not the old build
# candidate, which may predate this verifier.
ref: ${{ github.sha }}
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Verify public artifacts and npm latest
env:
GH_TOKEN: ${{ github.token }}
@@ -247,6 +277,8 @@ jobs:
RELEASE_VERSION: ${{ needs.authorize.outputs.version }}
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }}
DESKTOP_MANUAL_ONLY: ${{ inputs.desktop_manual_only || false }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
run: bash scripts/verify-stable-release-artifacts.sh
- name: Publish exact Stable release record
env:
@@ -277,7 +309,3 @@ jobs:
else
gh release upload "$CLI_TAG" /tmp/release-event.json
fi
- name: Refresh public changelog
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+2 -2
View File
@@ -39,10 +39,10 @@ jobs:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
- name: Test notifier
+40
View File
@@ -0,0 +1,40 @@
name: Verify release
run-name: Verify v${{ inputs.version }}
on:
workflow_dispatch:
inputs:
version:
description: "Official version without a tag prefix"
required: true
type: string
permissions:
contents: read
jobs:
verify:
name: verify immutable files and public pointers
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Verify public release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_OPERATION: recover
CLI_TAG: v${{ inputs.version }}
DESKTOP_TAG: desktop-v${{ inputs.version }}
VERIFY_PUBLIC_POINTERS: "true"
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
run: bash scripts/verify-stable-release-artifacts.sh
+106 -37
View File
@@ -51,6 +51,16 @@ on:
required: false
default: false
type: boolean
candidate_artifact_name:
description: "Same-run artifact containing a verified sealed release candidate"
required: false
default: ""
type: string
candidate_verified:
description: "Protected Stable preflight verified candidate provenance and bytes"
required: false
default: false
type: boolean
permissions:
contents: write # create the release and upload archives
@@ -75,7 +85,7 @@ jobs:
prerelease: ${{ steps.release.outputs.prerelease }}
sha: ${{ steps.candidate.outputs.sha }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
@@ -131,7 +141,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
@@ -146,12 +156,16 @@ jobs:
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
APPROVED_CHANNEL: ${{ inputs.orchestrator == 'promote' && 'stable' || inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
if [ -n "${{ inputs.candidate_artifact_name }}" ] && [ "${{ inputs.candidate_verified }}" != "true" ]; then
echo "::error::prepared CLI artifacts require verified candidate provenance"
exit 1
fi
release-gate:
name: approve standalone CLI release
@@ -171,15 +185,18 @@ jobs:
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
if: ${{ !inputs.candidate_verified }}
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
- if: ${{ !inputs.candidate_verified }}
run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
if: ${{ !inputs.candidate_verified }}
env:
DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.tag }}
DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }}
@@ -195,18 +212,22 @@ jobs:
needs: [resolve, cache-guard]
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
# The Stable caller has already passed the single GitHub release approval.
# Official standalone recovery passes release-gate above. This job therefore
# must not add a second GitHub environment approval.
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ needs.resolve.outputs.sha }}
# Recovery may build an immutable tag that predates the current recovery
# policy. Keep product sources pinned above, but execute publication
# decisions from the protected workflow commit.
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
path: release-control
@@ -225,16 +246,26 @@ jobs:
printf 'product checkout is dirty before release:\n%s\n' "$dirty" >&2
exit 1
fi
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- name: Download sealed release candidate
if: ${{ inputs.candidate_artifact_name != '' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ inputs.candidate_artifact_name }}
path: ${{ runner.temp }}/release-candidate
- name: Verify prepared CLI checksums
if: ${{ inputs.candidate_artifact_name != '' }}
working-directory: ${{ runner.temp }}/release-candidate/cli
run: sha256sum -c SHA256SUMS
- name: Download orchestrator-reviewed release notes
if: ${{ inputs.orchestrated }}
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: orchestrator-reviewed-release-notes
path: /tmp/orchestrator-reviewed-release-notes
@@ -247,6 +278,7 @@ jobs:
if: ${{ !inputs.orchestrated }}
env:
RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
GH_TOKEN: ${{ github.token }}
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
- name: Revalidate approved release ref
env:
@@ -278,6 +310,12 @@ jobs:
"$validation_channel" "$TAG" "${{ github.repository }}" \
"$release_json" "$checksums"
)"
if [ -n "${{ inputs.candidate_artifact_name }}" ]; then
cmp -s "$checksums" "$RUNNER_TEMP/release-candidate/cli/SHA256SUMS" || {
echo "::error::existing CLI release differs from the sealed candidate"
exit 1
}
fi
echo "existing CLI release $TAG is complete and checksum-bound; reusing it"
elif grep -Eiq 'HTTP 404|Not Found' "$release_error"; then
decision="$(
@@ -291,19 +329,41 @@ jobs:
fi
test "$decision" = "publish" -o "$decision" = "reuse"
echo "decision=$decision" >> "$GITHUB_OUTPUT"
- uses: goreleaser/goreleaser-action@v7
if: ${{ steps.publication.outputs.decision == 'publish' }}
- uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name == '' }}
with:
version: '~> v2'
args: release --clean
args: release --clean${{ vars.CLI_PUBLISH_FROZEN == 'true' && ' --skip=homebrew' || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && secrets.HOMEBREW_TAP_TOKEN || '' }}
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' && secrets.HOMEBREW_TAP_TOKEN || '' }}
# workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even
# though checkout is on the release tag. Pin GoReleaser explicitly, and
# avoid ambiguity from the three release tags sharing one commit.
GORELEASER_CURRENT_TAG: ${{ needs.resolve.outputs.tag }}
- name: Publish prepared CLI archives
if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name != '' }}
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
gh release create "$TAG" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-darwin-amd64.tar.gz" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-darwin-arm64.tar.gz" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-linux-amd64.tar.gz" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-linux-arm64.tar.gz" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-windows-amd64.zip" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-windows-arm64.zip" \
"$RUNNER_TEMP/release-candidate/cli/SHA256SUMS" \
--title "Reasonix CLI $TAG" --notes-file /tmp/release-notes.md --latest=false
- name: Publish prepared Homebrew cask
if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' }}
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: node release-control/scripts/publish-homebrew-cask.mjs "$RUNNER_TEMP/release-candidate/cli/reasonix.rb"
- name: Publish product release notes
env:
GH_TOKEN: ${{ github.token }}
@@ -315,6 +375,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
NOTES_TAG: ${{ needs.resolve.outputs.notes_version }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
@@ -418,6 +479,11 @@ jobs:
exit 0
fi
if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
echo "CLI publication is frozen; the $channel pointer stays where it is and $TAG keeps only its immutable record"
exit 0
fi
current_tag=""
pointer_error="$(mktemp)"
if aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
@@ -493,17 +559,14 @@ jobs:
' latest.json >/dev/null
gh release upload "$TAG" latest.json --clobber
# The compatibility asset exists for pre-v1.16 desktop updaters that
# still poll GitHub's repository-wide latest URL. Desktop releases now
# own that Latest badge, but this check still exercises the public fallback
# path exactly the way those clients fetch it: anonymously, over the public
# edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection
# 403s Actions egress — see the R2 note above), GitHub serves its own
# runners, so this can hard-fail. #5826/#5858 shipped a broken update check
# for weeks precisely because nothing exercised the public path. Retries
# cover the release CDN propagating the freshly uploaded asset.
- name: Smoke public compatibility manifest
# Verifies what the CLI release actually serves after the compatibility
# upload: exactly the approved asset set, each uploaded, with the size and
# sha256 digest GitHub recorded, SHA256SUMS downloaded from the release,
# and the compatibility latest.json byte-identical to the one uploaded
# above. Any mismatch or API failure fails the job.
- name: Verify published CLI release assets
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
@@ -518,18 +581,19 @@ jobs:
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
exit 0
fi
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
for attempt in 1 2 3 4 5 6; do
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
exit 0
fi
echo "attempt $attempt failed; retrying in 10s"
sleep 10
done
echo "::error::public compatibility manifest unreachable at $url"
exit 1
release_json=/tmp/published-cli-release.json
checksums=/tmp/published-cli-SHA256SUMS
gh api "repos/${{ github.repository }}/releases/tags/$TAG" >"$release_json"
gh release download "$TAG" -R "${{ github.repository }}" \
--pattern SHA256SUMS --output "$checksums" --clobber
decision="$(bash release-control/scripts/decide-cli-release-publication.sh \
stable "$TAG" "${{ github.repository }}" "$release_json" "$checksums")"
test "$decision" = "reuse"
jq -e '[.assets[].name] | index("latest.json") != null' "$release_json" >/dev/null
want="sha256:$(sha256sum latest.json | cut -d' ' -f1)"
got="$(jq -r '.assets[] | select(.name == "latest.json") | .digest' "$release_json")"
test "$want" = "$got"
echo "published CLI release $TAG assets verified against SHA256SUMS and the uploaded manifest"
# A stable CLI release must never leave the npm line behind: v1.17.5
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
@@ -552,6 +616,7 @@ jobs:
- name: Check npm latest dist-tag freshness
env:
TAG: ${{ needs.resolve.outputs.tag }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
run: |
set -euo pipefail
case "$TAG" in
@@ -565,6 +630,10 @@ jobs:
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
exit 1
fi
if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
echo "CLI publication is frozen; npm latest belongs to another line, only the npm-v$version tag is required"
exit 0
fi
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
if [ -n "$got" ]; then
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
if: github.event.comment.user.type != 'Bot' && github.event.comment.author_association == 'NONE'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const EMOJI = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/gu;
+2 -2
View File
@@ -40,10 +40,10 @@ jobs:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
- name: Test sweeper
+10 -7
View File
@@ -1,11 +1,9 @@
name: Supersede CI
# ci.yml declares `cancel-in-progress: true`, but when a Windows job is mid
# step GitHub sometimes never delivers the cancel: the old run keeps the
# concurrency group and the run for the new head sits `pending` until someone
# force-cancels by hand. Twice on 2026-09-11 that cost the release candidate
# 10-20 minutes each. This workflow is not in that group, so it runs anyway
# and cancels only runs whose head is a strict ancestor of the pushed commit.
# Push runs have separate concurrency groups so Notes can finish without
# cancelling their required code ancestor. Only a changed code tree supersedes
# earlier evidence. Force cancellation releases stuck Windows workers after
# the normal cancellation grace period.
on:
push:
@@ -20,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
@@ -41,6 +39,11 @@ jobs:
echo "run $id ($sha) is not an ancestor of $GITHUB_SHA; leaving it alone"
continue
fi
code_changes="$(git diff --name-only "$sha" "$GITHUB_SHA" -- . ':!release-notes')"
if [ -z "$code_changes" ]; then
echo "run $id ($sha) provides the unchanged code evidence required by $GITHUB_SHA; preserving"
continue
fi
echo "run $id ($sha) is superseded by $GITHUB_SHA; cancelling"
gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$id/cancel" >/dev/null || true
status="unknown"
+5 -4
View File
@@ -13,8 +13,8 @@ jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
- name: Update README contributor tables
@@ -22,9 +22,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/update-acknowledgments.mjs
- name: Open pull request
uses: peter-evans/create-pull-request@v8
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
# GITHUB_TOKEN-authored pull requests never trigger CI, so required checks would never report.
token: ${{ secrets.ACKNOWLEDGMENTS_TOKEN || secrets.GITHUB_TOKEN }}
branch: chore/update-acknowledgments
delete-branch: true
title: 'Update acknowledgments contributors / 更新致谢贡献者'
+2 -2
View File
@@ -24,10 +24,10 @@ jobs:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
- name: Test chart generator
+3
View File
@@ -25,6 +25,8 @@ mem.prof
/desktop/build/*
!/desktop/build/appicon.png
!/desktop/build/appicon.svg
!/desktop/build/trayTemplate.png
!/desktop/build/[email protected]
/desktop/desktop
/desktop/frontend/dist/*
!/desktop/frontend/dist/.gitkeep
@@ -79,3 +81,4 @@ site/.generated/
# Committed pointer at REASONIX.md; overrides a global CLAUDE.md ignore rule.
!/CLAUDE.md
desktop/desktop.exe
+6
View File
@@ -64,6 +64,12 @@ linters:
linters:
- staticcheck
text: SA5011
# x/net's Transport still returns GoAwayError although the type is marked
# deprecated, so the classifier has to keep recognising it.
- path: internal/provider/transport_diagnostic
linters:
- staticcheck
text: SA1019
# SA4023 is trivially true on GOOS=windows: dialAgent's Windows stub
# (agent_windows.go) always returns a non-nil error until named-pipe
# transport lands. Suppress only this call site; the comparison stays
+1
View File
@@ -0,0 +1 @@
24.20.0
+11 -5
View File
@@ -5,16 +5,22 @@ repository_url: https://github.com/esengine/DeepSeek-Reasonix.git
allowed_branch_names:
- main-v2
allowed_build_definitions:
- .github/workflows/certum-signing-smoke.yml
- .github/workflows/release-candidate.yml
- .github/workflows/release-promote.yml
- .github/workflows/release-stable.yml
- .github/workflows/release-desktop.yml
fingerprint_files:
- .github/workflows/certum-signing-smoke.yml
- .github/actions/setup-certum/action.yml
- scripts/sign-certum.ps1
- scripts/install-nsis.ps1
- .github/workflows/release-candidate.yml
- .github/workflows/release-promote.yml
- .github/workflows/release-stable.yml
- .github/workflows/release-stable-trigger.yml
- .github/workflows/release-desktop.yml
- .signpath/artifact-configurations/windows-payload.xml
- .signpath/artifact-configurations/windows-installer-v2.xml
- scripts/complete-signpath-request.ps1
- scripts/signpath-checkpoint.mjs
- scripts/finalize-windows-signed-candidate.sh
- scripts/validate-release-control-plane.sh
- scripts/package-windows-desktop.sh
- scripts/verify-windows-authenticode.ps1
- cmd/signpath-contract/main.go
+52
View File
@@ -6,8 +6,39 @@ branch.
## Unreleased
### Desktop conversation creation
- Local **New Conversation** creates a distinct formal session immediately,
including when another empty conversation already exists. First send uses
that identity. Closing or archiving the last conversation leaves the welcome
page; it does not create a replacement.
- Unsent local inputs are saved independently of chat history. Previous project
drafts remain available through **Previous drafts**. Unconfirmed submissions
are retained for inspection and are never automatically replayed.
- Automatic historical empty-session cleanup is retired. Existing trash and
manual restore remain available. Session v5 and workspace registry v3 remain
unchanged. Downgrading to 1.38.9 is unsupported; 1.38.10 also cannot read
revision-3 sessions introduced by #10545. Returning from 1.38.11 preserves
new input files and requires review when session history has advanced. See
[compatibility and recovery](docs/manual-session-rollback.md).
### Added
- **Discover Reasonix Studio (Desktop 1.x):** a one-time introduction on an idle
stable desktop launch, plus a permanent entry in **Settings → About**. Opens
the official Studio download page and explains coexistence and data limits;
it does not install Studio or change the 1.x updater. See
[the introduction guide](docs/STUDIO_DISCOVERY.md).
- **Editable message queue (Desktop):** full-text editing in the original queue row,
pointer and keyboard reordering, move-to-top/bottom actions, and a pause
control. Saves preserve message identity, order and attachments; conflicting
or unconfirmed saves retain the user's draft. Remote editing requires the
additive `inbox-mutations-v1` capability. Editing temporarily hides the main
composer; saving or cancelling restores its draft, attachments and focus.
Switching sessions protects newer edits from delayed replies; loading the
latest version can recover after the active session selection changes.
- **Live file observations:** structured file tools now protect mutations with
a host-owned current-version observation. Any successful text window is
sufficient, successful writes refresh the version, and external changes
@@ -28,6 +59,22 @@ branch.
### Changed
- Sending while a task is running now queues a follow-up by default. Use
**Guide current turn** for explicit mid-turn guidance. Stopping the current
task retains pending messages; the queue has separate pause/delete controls.
- **Persistent bash PTY:** ordinary foreground `bash` calls in a session now
share one PTY, so `cd`, exported variables, and shell functions survive
across calls. Output stays byte-identical to one-shot execution, stdin stays
detached, and a timeout or cancel reports partial output and says the shell
was reset. Background jobs, commands that background a child, per-call
write-root escalations, host terminals, and PowerShell hosts stay one-shot.
The bash tool schema and description are unchanged.
- **CLI YOLO shortcuts:** the CLI displays the unrestricted preset as YOLO;
`Ctrl+Y` toggles the canonical `danger-full-access` permission, while
`Shift+Tab` cycles Read only → Workspace write → YOLO → Plan.
- **Harness-style scheduling and recovery:** calls take effect in execution
order, including same-batch read/edit sequences. Bounded reads create no
completion debt. Unknown external effects are durable advisory facts and no
@@ -56,6 +103,11 @@ branch.
### Fixed
- **Setup menus in narrow terminals:** `reasonix setup` and the other
arrow-key menus clip every row to the terminal width. A row that soft-wrapped
used to throw off the redraw, stacking a fresh copy of the header and the
wrapped rows on screen with each keypress.
- **Read evidence recovery:** partial reads no longer freeze independent work
or ordinary final answers. Explicit full reads retain bounded completion
checks. Rejected edits track operation/version requirements so successful
+12 -4
View File
@@ -137,10 +137,18 @@ checks only when new changes or unresolved risks justify it.
When adding an internal import, check the target package's test imports for a
reverse dependency and run the target package tests to catch setup cycles.
Desktop transcript scroll changes follow the
[scroll contract](docs/TRANSCRIPT_SCROLL_CONTRACT.md), including the single writer
and deterministic regression cases via `pnpm test:transcript` in
`desktop/frontend/`.
Desktop transcript scroll and history changes follow the
[scroll and history contract](docs/TRANSCRIPT_SCROLL_CONTRACT.md), including the
single writer, the bounded reading window, and deterministic regression cases via
`pnpm test:transcript` in `desktop/frontend/`.
Keep correctness gates deterministic. Prove concurrency and lifecycle ordering
with channels, injected clocks, state transitions, or emitted events instead of
asserting that an operation finishes within a small wall-clock interval on a
shared CI runner. Use a generous timeout only as a liveness watchdog. Performance
limits belong in an explicit benchmark that records evidence and uses the
benchmark's documented sampling rule; host integration probes may report an
advisory result when the runner cannot provide a controlled environment.
### Code style
+5 -3
View File
@@ -52,14 +52,16 @@ lint-cross:
test:
go test ./...
# The desktop suite needs ~25m locally, past go's default 10m test alarm
# (the TaggedHistory migrations alone are ~389s), so these targets pin a wider one.
desktop-test:
cd desktop && go test .
cd desktop && go test -timeout=25m .
desktop-test-short:
cd desktop && go test -short .
cd desktop && go test -short -timeout=25m .
desktop-test-times:
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
cd desktop && go test -count=1 -timeout=25m -json . | python3 ../scripts/desktop-test-times.py
sdk-test:
cd sdk/go && go test ./...
+5 -6
View File
@@ -100,8 +100,7 @@ for the latest desktop build.
| Windows | Installer `.exe` or portable `.zip` | x64 / ARM64 |
| Linux | `.deb` or `.tar.gz` | x64 |
Windows installers are code-signed through [SignPath.io](https://signpath.io/)
with a free certificate provided by the [SignPath Foundation](https://signpath.org/).
Windows installers are code-signed with a Certum code-signing certificate.
### Path C: VS Code extension
@@ -210,10 +209,10 @@ A small list of folks whose work has shaped Reasonix the most — the current to
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
| **merge-order-check** | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) | [**lanshi17**](https://github.com/lanshi17) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+5 -6
View File
@@ -92,8 +92,7 @@ brew install esengine/reasonix/reasonix # macOS
| Windows | 安装器 `.exe` 或便携 `.zip` | x64 / ARM64 |
| Linux | `.deb` 或 `.tar.gz` | x64 |
Windows 安装器通过 [SignPath.io](https://signpath.io/) 完成代码签名,证书由
[SignPath 基金会](https://signpath.org/) 免费提供。
Windows 安装器使用 Certum 代码签名证书签名。
### 路径 C:VS Code 扩展
@@ -202,10 +201,10 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
| **merge-order-check** | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) | [**lanshi17**](https://github.com/lanshi17) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+53 -6
View File
@@ -56,9 +56,9 @@ Supported boundaries include:
- Sandbox behavior for built-in shell execution where the platform supports it.
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
headers, and credential-store fallback files.
- HTTP `serve` protections for the unauthenticated local server, including
localhost binding assumptions, JSON-only state-changing requests, and CORS
restrictions.
- HTTP `serve` protections, including the launch token every state-changing
request requires when authentication is off, localhost binding assumptions,
JSON-only state-changing requests, and CORS restrictions.
- Desktop and bot session isolation, including per-workspace session metadata
and configured bot allowlists.
- Updater, install, and release verification paths.
@@ -67,10 +67,10 @@ The following are normally treated as trusted local/operator-controlled inputs
unless another bug lets an untrusted actor supply them:
- CLI arguments and text typed directly by the local user.
- Project configuration files intentionally loaded from the current workspace.
- The user's own configuration under the Reasonix home directory.
- Explicit `@path` references supplied by the local user to attach local files.
- MCP servers, language servers, hooks, and slash commands installed or enabled
by the local user.
- MCP servers, language servers, hooks, and slash commands installed, enabled,
or approved by the local user.
- Provider base URLs and model names configured by the local user.
The following can be security issues when reachable by an untrusted actor or
@@ -89,6 +89,53 @@ when they bypass the intended boundary:
- Trusting unverified update artifacts, plugin definitions, or downloaded
binaries.
## Project Configuration
Files that arrive with a workspace are untrusted input: `reasonix.toml`,
`.reasonix/settings.json`, `.mcp.json` and the project `.env`. Cloning a
repository is not a decision to trust its author, so these files may narrow
what the user configured and never widen it.
| Setting | What a workspace file may do |
| --- | --- |
| `sandbox.bash` | Move `off` to `enforce`, never back |
| `sandbox.network` | Move `true` to `false`, never back |
| `sandbox.forbid_read` | Add to the user's list |
| `sandbox.allow_write`, `sandbox.workspace_root` | Apply only when they resolve inside the workspace, symlinks followed; `${VAR}` in any sandbox path expands from the process environment only |
| `permissions.deny`, `permissions.ask` | Add to the user's rules |
| `permissions.mode`, `permissions.allow`, `permissions.allow_dynamic_bash` | Nothing |
| `desktop.default_tool_approval_mode` | Nothing |
| `[network]` proxy settings, `[bot]` | Nothing |
| `[[providers]]` | Declare them; they stay out of use until approved, and model settings naming them fall back to the user's |
An "always allow" answer, or extra write access, granted in a workspace is
stored under the Reasonix home, not in the workspace.
Programs a workspace names for the host to run stay off until the user
approves them for that workspace:
- Hooks, language server commands, `tools.search.rg_path`,
`tools.shell.path` and `[browser]` launch settings are approved with
`reasonix trust`, or for hooks from the desktop hooks settings.
- An approval is stored under the Reasonix home with a digest of the
declaration and of the workspace files it names; any change needs approval
again. Approvals and grants belong to one workspace folder.
- Hooks and language servers are checked against their approval again before
each start, and refused if a file they name changed, appeared or now
resolves elsewhere.
- A single program path (`tools.shell.path`, `tools.search.rg_path`,
`browser.chrome_path`) may not point into the workspace, an `allow_write`
root, a temporary directory or a toolchain cache the bash jail leaves
writable, and may not climb with `..`; its approval covers the program
file's content.
- Without an interactive frontend they stay off.
- MCP servers a workspace declares stay off until the user enables that
declaration for that workspace.
A project value that is not applied is reported as a load warning with the
reason. A way for workspace files to widen any of the above without the
user's approval is a security issue.
## `@` File References
Reasonix supports `@path` references so users can include local files and images
+1
View File
@@ -20,6 +20,7 @@ func writeTaskVerify(t *testing.T, taskDir, script string) {
}
func TestGradeCheckpointsFindsEarliestCorrectState(t *testing.T) {
requireRealBash(t)
taskDir := filepath.Join(t.TempDir(), "task")
writeTaskVerify(t, taskDir, "#!/usr/bin/env bash\ngrep -q done answer.txt\n")
+23 -4
View File
@@ -1,11 +1,13 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
const corpusDir = "../../benchmarks/e2e"
@@ -56,6 +58,24 @@ func gradeSeed(t *testing.T, work string) error {
return cmd.Run()
}
// requireRealBash skips hosts whose bash cannot actually run commands. On
// Windows exec.LookPath("bash") happily resolves to the System32 WSL launcher,
// so a LookPath guard passes while every grade fails inside a distro that may
// not even carry /bin/bash. The graders are POSIX fixtures; probing mirrors
// what sandbox's own shell resolution does before trusting a bash. Timeout
// bounded in case the stub blocks on an install prompt.
func requireRealBash(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash unavailable; the graders are POSIX shell fixtures")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "bash", "-c", "true").Run(); err != nil {
t.Skipf("bash cannot run commands (%v); the graders need a working POSIX shell", err)
}
}
// forbiddenArtifact names, per task, a file whose mere existence is the
// documented way to fake that task's missing piece. Probing it keeps the
// absence checks honest; inferring intent from a missing manifest does not,
@@ -157,10 +177,9 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
// The graders are POSIX shell and python3 fixtures, and the suite they
// belong to only ever runs on POSIX CI. Where either is missing this
// checks the host, not the corpus.
for _, bin := range []string{"bash", "python3"} {
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("%s unavailable; the no-solution graders need a POSIX shell and python3", bin)
}
requireRealBash(t)
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 unavailable; the no-solution graders need a POSIX shell and python3")
}
tasks, err := loadTasks(corpusDir)
if err != nil {
+2
View File
@@ -11,6 +11,7 @@ import (
// produced. For an exploration task the grader's "want X, got Y" line is the
// entire finding — a bare false would leave every failure indistinguishable.
func TestGradeVerboseKeepsWhatTheGraderSaid(t *testing.T) {
requireRealBash(t)
taskDir := t.TempDir()
verify := "#!/usr/bin/env bash\necho \"answer.txt normalized to 'askrigg', want 'gorsefen'\" >&2\nexit 1\n"
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(verify), 0o755); err != nil {
@@ -27,6 +28,7 @@ func TestGradeVerboseKeepsWhatTheGraderSaid(t *testing.T) {
}
func TestGradeVerboseStaysQuietWhenTheTaskPasses(t *testing.T) {
requireRealBash(t)
taskDir := t.TempDir()
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte("#!/usr/bin/env bash\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
+150
View File
@@ -0,0 +1,150 @@
// provider-network-probe performs one bounded, synthetic Chat Completions call.
// It never executes tools and outputs only the provider's safe diagnostic model.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"reasonix/internal/provider"
"reasonix/internal/secrets"
)
type singleTransport struct {
base http.RoundTripper
used atomic.Bool
}
func (t *singleTransport) RoundTrip(r *http.Request) (*http.Response, error) {
if t.used.Swap(true) {
return nil, errors.New("probe request limit reached")
}
return t.base.RoundTrip(r)
}
func main() {
endpoint := flag.String("endpoint", "https://api.deepseek.com/chat/completions", "complete endpoint URL; no credentials or query")
model := flag.String("model", "deepseek-v4-flash", "fixed model for the comparison")
protocol := flag.String("protocol", "h2", "h1 or h2; observed protocol is included in output")
bodyBytes := flag.Int("body-bytes", 1024, "target synthetic JSON bytes (1024..4194304)")
expect := flag.Bool("expect-continue", false, "independent Expect: 100-continue experiment")
budget := flag.Duration("timeout", 360*time.Second, "whole probe budget (1s..10m)")
flag.Parse()
if err := run(*endpoint, *model, *protocol, *bodyBytes, *expect, *budget); err != nil {
// Validation errors are fixed local prose, never transport error strings.
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(endpoint, model, protocol string, bodyBytes int, expect bool, budget time.Duration) error {
u, err := url.Parse(endpoint)
if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "http") {
return errors.New("endpoint must be an HTTP(S) URL without credentials, query or fragment")
}
if protocol != "h1" && protocol != "h2" {
return errors.New("protocol must be h1 or h2")
}
if bodyBytes < 1024 || bodyBytes > 4<<20 || budget < time.Second || budget > 10*time.Minute {
return errors.New("body size or timeout outside probe limits")
}
key := os.Getenv("REASONIX_PROBE_API_KEY")
if key == "" {
return errors.New("set REASONIX_PROBE_API_KEY in the environment")
}
if u.Scheme != "https" && u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" && u.Hostname() != "::1" {
return errors.New("remote probes require HTTPS")
}
body := syntheticBody(model, bodyBytes)
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DialContext = (&net.Dialer{Timeout: 30 * time.Second}).DialContext
tr.TLSHandshakeTimeout = 30 * time.Second
tr.ResponseHeaderTimeout = 300 * time.Second
tr.ExpectContinueTimeout = time.Second
tr.Protocols = new(http.Protocols)
tr.Protocols.SetHTTP1(protocol == "h1")
tr.Protocols.SetHTTP2(protocol == "h2")
defer tr.CloseIdleConnections()
client := &http.Client{Transport: &singleTransport{base: tr}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
ctx, cancel := context.WithTimeout(context.Background(), budget)
defer cancel()
var mu sync.Mutex
var observation provider.RequestObservation
ctx = provider.WithRequestObserver(ctx, func(v provider.RequestObservation) { mu.Lock(); observation = v; mu.Unlock() })
resp, requestErr := provider.SendOnce(ctx, client, provider.SendOptions{Provider: "probe", Protocol: "openai"}, func(ctx context.Context) (*http.Request, error) {
r, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
if err != nil {
return nil, err
}
// Prevent request-body replay inside Transport. A new transport is used
// for this invocation; the wrapper also rejects any application resend.
r.GetBody = nil
r.Header.Set("Authorization", "Bearer "+key)
r.Header.Set("Content-Type", "application/json")
if expect {
r.Header.Set("Expect", "100-continue")
}
return r, nil
})
traceID := ""
if resp != nil {
traceID = provider.DiagnoseFailure(&provider.APIError{TraceID: resp.Header.Get("x-trace-id")}).TraceID
if traceID == "" {
traceID = provider.DiagnoseFailure(&provider.APIError{TraceID: resp.Header.Get("trace-id")}).TraceID
}
if traceID == "" {
traceID = provider.DiagnoseFailure(&provider.APIError{TraceID: resp.Header.Get("trace_id")}).TraceID
}
_, readErr := io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if requestErr == nil {
requestErr = readErr
}
}
mu.Lock()
result := struct {
SchemaVersion int `json:"schemaVersion"`
RequestedProtocol string `json:"requestedProtocol"`
ExpectContinue bool `json:"expectContinue"`
TraceID string `json:"traceId,omitempty"`
Request provider.RequestObservation `json:"request"`
Failure *provider.FailureDiagnostic `json:"failure,omitempty"`
}{1, protocol, expect, traceID, observation, nil}
mu.Unlock()
if requestErr != nil {
d := provider.DiagnoseFailure(requestErr)
result.Failure = d
}
encoded, err := json.Marshal(result)
if err != nil {
return errors.New("cannot encode safe probe result")
}
encoded, err = secrets.RedactJSON(encoded)
if err != nil || !json.Valid(encoded) {
return errors.New("cannot redact probe result")
}
fmt.Println(string(encoded))
return nil
}
func syntheticBody(model string, size int) []byte {
request := map[string]any{"model": model, "stream": true, "max_tokens": 1, "messages": []map[string]string{{"role": "user", "content": "Reply OK. Synthetic padding: "}}}
base, _ := json.Marshal(request)
if padding := size - len(base); padding > 0 {
request["messages"].([]map[string]string)[0]["content"] += strings.Repeat("x", padding)
}
body, _ := json.Marshal(request)
return body
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestSyntheticProbeIsBoundedAndDoesNotFollowRedirects(t *testing.T) {
for _, size := range []int{1024, 3_248_572} {
body := syntheticBody("test-model", size)
var payload map[string]any
if len(body) != size || json.Unmarshal(body, &payload) != nil || payload["max_tokens"] != float64(1) || payload["tools"] != nil {
t.Fatalf("invalid synthetic request length=%d", len(body))
}
}
var calls atomic.Int32
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
io.Copy(io.Discard, r.Body)
http.Redirect(w, r, "/redirect", http.StatusTemporaryRedirect)
}))
defer s.Close()
t.Setenv("REASONIX_PROBE_API_KEY", "local-only-key")
if err := run(s.URL, "test-model", "h1", 1024, false, time.Second); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("calls=%d", calls.Load())
}
tr := &singleTransport{base: http.DefaultTransport}
tr.used.Store(true)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL, nil)
if _, err := tr.RoundTrip(req); err == nil || calls.Load() != 1 {
t.Fatal("probe allowed a retry")
}
}
+52 -32
View File
@@ -190,8 +190,8 @@ func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error
}
}
shellMembers, err := installlayout.ShellMembers(installRoot, runtime.GOOS)
if err != nil {
return fmt.Errorf("migrate shell: %w", err)
if err != nil || len(shellMembers) == 0 {
return refuseShellMissing(installRoot, activeVersion, relaunch, err)
}
for _, member := range shellMembers {
members = append(members, member)
@@ -311,10 +311,6 @@ func activateInstallerStagingWithRecovery(installRoot, activeVersion, stagingRoo
{Name: cliName, Path: cliEntrySource},
}
requiredRootNames := []string{launcherName, cliName}
if alias := installlayout.PortableAliasName(); alias != "" {
rootMembers = append(rootMembers, installlayout.Member{Name: alias, Path: launcherSource})
requiredRootNames = append(requiredRootNames, alias)
}
home := config.ReasonixHomeDir()
release, err := desktopinstance.PrepareInstall(installRoot, home, interactive)
@@ -324,7 +320,7 @@ func activateInstallerStagingWithRecovery(installRoot, activeVersion, stagingRoo
defer release()
// The installer only shows an exit code; the recovery log keeps the reason.
finish := desktopinstance.AttemptLog(home, "activate-staging", installRoot)
err = installlayout.ActivateVersion(installlayout.ActivationRequest{
request := installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: activeVersion,
RequestID: "signed-installer-" + activeVersion,
@@ -333,7 +329,15 @@ func activateInstallerStagingWithRecovery(installRoot, activeVersion, stagingRoo
RequiredNames: requiredNames,
RootMembers: rootMembers,
RequiredRootNames: requiredRootNames,
})
}
if runtime.GOOS == "windows" {
request.RootMembers, request.RequiredRootNames = nil, nil
request.WindowsRootEntries = &installlayout.WindowsRootEntrySources{
LauncherPath: launcherSource,
CLIEntryPath: cliEntrySource,
}
}
err = installlayout.ActivateVersion(request)
finish(err)
if err != nil {
return fmt.Errorf("activate signed installer staging: %w", err)
@@ -346,7 +350,8 @@ func runningAsLauncher() bool {
if err != nil {
return false
}
return strings.EqualFold(filepath.Base(exe), installlayout.LauncherBinaryName())
return strings.EqualFold(filepath.Base(exe), installlayout.LauncherBinaryName()) ||
strings.EqualFold(filepath.Base(exe), installlayout.CanonicalLauncherBinaryName())
}
func optionalRegular(path string) string {
@@ -382,6 +387,22 @@ func acquireMigrationLock(installRoot string) (func(), error) {
}
func ensureLauncherEntry(installRoot string) error {
if runtime.GOOS == "windows" {
for _, name := range []string{installlayout.CanonicalLauncherBinaryName(), installlayout.LauncherBinaryName()} {
info, err := os.Lstat(filepath.Join(installRoot, name))
if os.IsNotExist(err) {
continue
}
if err != nil {
return fmt.Errorf("migrate: inspect launcher %s: %w", name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("migrate: thin launcher %s is not a regular file", name)
}
return nil
}
return fmt.Errorf("migrate: thin launcher is missing; install a signed package")
}
launcher := installlayout.LauncherBinaryName()
path := filepath.Join(installRoot, launcher)
if info, err := os.Lstat(path); err == nil {
@@ -390,14 +411,6 @@ func ensureLauncherEntry(installRoot string) error {
}
return nil
}
// On Windows also accept Reasonix.exe as the alias.
if runtime.GOOS == "windows" {
if _, err := os.Lstat(filepath.Join(installRoot, "Reasonix.exe")); err == nil {
return nil
}
return fmt.Errorf("migrate: thin launcher %s is missing; install a signed package", launcher)
}
// Legacy Linux archives cannot deliver a fourth member through the old
// updater. Copy this signed multicall migrator as the permanent thin launcher.
exe, err := os.Executable()
@@ -418,25 +431,35 @@ func ensureLauncherEntry(installRoot string) error {
return nil
}
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
// readLegacyPendingUpdate returns the transaction an old file updater left for
// this install and version, or nil when there is none.
func readLegacyPendingUpdate(installRoot, activeVersion string) (*repair.UpdateTransaction, error) {
tx, err := repair.ReadPendingUpdate()
if os.IsNotExist(err) {
return nil
return nil, nil
}
if err != nil {
return fmt.Errorf("migrate: read legacy pending update: %w", err)
return nil, fmt.Errorf("migrate: read legacy pending update: %w", err)
}
if tx.TargetKind != "file" {
return fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
return nil, fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
}
if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
return fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
return nil, fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
}
for _, target := range append([]repair.UpdateTransactionFile{{TargetPath: tx.TargetPath}}, tx.Files...) {
if !pathWithinInstallRoot(installRoot, target.TargetPath) {
return fmt.Errorf("migrate: pending update target escapes install root")
return nil, fmt.Errorf("migrate: pending update target escapes install root")
}
}
return tx, nil
}
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
if err != nil || tx == nil {
return err
}
id := repair.UpdateTransactionID(tx)
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
@@ -532,17 +555,14 @@ func archiveInstallRootLegacyMarkers(installRoot string) error {
}
func startLauncher(installRoot string) error {
launcher := "reasonix-launcher"
if runtime.GOOS == "windows" {
launcher += ".exe"
}
path := filepath.Join(installRoot, launcher)
if _, err := os.Lstat(path); err != nil {
if runtime.GOOS == "windows" {
path = filepath.Join(installRoot, "Reasonix.exe")
path := ""
for _, name := range []string{installlayout.CanonicalLauncherBinaryName(), installlayout.LauncherBinaryName()} {
if candidate := optionalRegular(filepath.Join(installRoot, name)); candidate != "" {
path = candidate
break
}
}
if _, err := os.Lstat(path); err != nil {
if path == "" {
// Migration succeeded; user can start manually.
return nil
}
+32 -1
View File
@@ -7,8 +7,17 @@ import (
"testing"
"reasonix/internal/installlayout"
"reasonix/internal/testenv"
)
func TestMain(m *testing.M) {
// A legacy-updater child keeps the home its parent test chose.
if mode := os.Getenv(legacyChildModeEnv); mode != "" {
os.Exit(runLegacyChild(mode))
}
testenv.RunWithIsolatedUserState(m)
}
func installerVersionNames() []string {
names := []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()}
if runtime.GOOS == "windows" {
@@ -64,6 +73,7 @@ func writeInstallerStaging(t *testing.T, root, label string, includeLauncher boo
func TestMigrateFlatInstallToVersioned(t *testing.T) {
root := t.TempDir()
writeFlatUnit(t, root, "flat")
writeShellTree(t, root, "flat")
// Thin launcher entry must already exist (packaging places it). Use a
// non-executable marker so startLauncher fails closed without hanging.
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher"), []byte("launcher"), 0o644)
@@ -150,7 +160,7 @@ func TestActivateInstallerStagingPublishesVersionAndRootEntries(t *testing.T) {
if got, err := os.ReadFile(activeDesktop); err != nil || string(got) != "new-"+installlayout.DesktopBinaryName() {
t.Fatalf("active desktop=%q err=%v", got, err)
}
for _, name := range []string{installlayout.LauncherBinaryName(), installlayout.CLIBinaryName()} {
for _, name := range []string{installlayout.CanonicalLauncherBinaryName(), installlayout.CLIBinaryName()} {
if info, err := os.Lstat(filepath.Join(root, name)); err != nil || !info.Mode().IsRegular() {
t.Fatalf("root entry %s missing or invalid: %v", name, err)
}
@@ -159,6 +169,9 @@ func TestActivateInstallerStagingPublishesVersionAndRootEntries(t *testing.T) {
if got, err := os.ReadFile(filepath.Join(root, alias)); err != nil || string(got) != "new-"+installlayout.LauncherBinaryName() {
t.Fatalf("portable alias=%q err=%v", got, err)
}
if _, err := os.Lstat(filepath.Join(root, installlayout.LauncherBinaryName())); !os.IsNotExist(err) {
t.Fatalf("fresh installation created a legacy entry: %v", err)
}
}
}
@@ -205,6 +218,24 @@ func TestActivateInstallerStagingPrefersThinCLIEntryAndRejectsInvalidEntry(t *te
}
}
func TestActivateInstallerStagingKeepsExistingLauncher(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, installlayout.LauncherBinaryName())
if err := os.WriteFile(legacy, []byte("old"), 0o755); err != nil {
t.Fatal(err)
}
staging := writeInstallerStaging(t, root, "new", true)
if err := activateInstallerStaging(root, "v1.39.0", staging); err != nil {
t.Fatal(err)
}
for _, name := range []string{installlayout.LauncherBinaryName(), installlayout.CanonicalLauncherBinaryName()} {
body, err := os.ReadFile(filepath.Join(root, name))
if err != nil || string(body) != "new-"+installlayout.LauncherBinaryName() {
t.Fatalf("%s=%q (%v)", name, body, err)
}
}
}
func TestActivateInstallerStagingFailureKeepsPreviousPointer(t *testing.T) {
root := t.TempDir()
oldStaging := writeInstallerStaging(t, root, "old", true)
@@ -0,0 +1,102 @@
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"reasonix/internal/config"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
// errShellMissing identifies a flat release unit whose desktop cannot start:
// the desktop needs the Electron app/ tree beside it, and the 1.18–1.19.2 file
// updaters publish only the three binaries they know by name.
var errShellMissing = errors.New("migrate: flat release unit has no Electron desktop shell")
const migrateLogName = "legacy-migrate.log"
// refuseShellMissing commits nothing for an absent or incomplete shell: no
// version directory, no current.json, no health mark and no flat cleanup.
// Where an old updater's transaction is still pending it restores the release
// unit that transaction replaced.
func refuseShellMissing(installRoot, activeVersion string, relaunch bool, cause error) error {
refusal := fmt.Errorf("%w: %s has no %s/ tree", errShellMissing, installRoot, installlayout.AppShellDirName)
if cause != nil {
refusal = fmt.Errorf("%w: %s/ tree is unusable: %w", errShellMissing, installlayout.AppShellDirName, cause)
}
result, rollbackErr := rollbackLegacyPendingUpdate(installRoot, activeVersion)
switch {
case rollbackErr != nil:
refusal = fmt.Errorf("%w; restore the previous release: %w", refusal, rollbackErr)
case result.RolledBack:
refusal = fmt.Errorf("%w; restored %s", refusal, result.FromVersion)
if relaunch {
if err := startRestoredRelease(installRoot); err != nil {
refusal = fmt.Errorf("%w; start the restored release: %w", refusal, err)
}
}
}
logMigrateRefusal(installRoot, result.RolledBack, refusal)
return refusal
}
func rollbackLegacyPendingUpdate(installRoot, activeVersion string) (repair.UpdateRollbackResult, error) {
// Windows cannot replace the running migrator image, and its installer
// carries the shell into update mode, so this state is Unix-only.
if runtime.GOOS == "windows" {
return repair.UpdateRollbackResult{}, nil
}
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
if err != nil || tx == nil {
return repair.UpdateRollbackResult{}, err
}
result, err := repair.RollbackPendingUpdateExact(tx)
if err == nil && result.MixedInstall {
err = fmt.Errorf("rollback left a mix of two releases")
}
return result, err
}
// startRestoredRelease relaunches the way the legacy updater itself does:
// through its Guard when the restored unit has one, else the desktop directly.
func startRestoredRelease(installRoot string) error {
if guard := optionalRegular(filepath.Join(installRoot, "reasonix-guard")); guard != "" {
return startDetached(installRoot, guard, "launch", "--detach")
}
if desktop := optionalRegular(filepath.Join(installRoot, installlayout.DesktopBinaryName())); desktop != "" {
return startDetached(installRoot, desktop)
}
return fmt.Errorf("restored release has no entry point")
}
func startDetached(dir, path string, args ...string) error {
cmd := exec.Command(path, args...)
cmd.Dir = dir
return cmd.Start()
}
// logMigrateRefusal persists the refusal: a migrator relaunched by an old
// updater has no terminal, so stderr alone reaches nobody.
func logMigrateRefusal(installRoot string, rolledBack bool, refusal error) {
home := config.ReasonixHomeDir()
if home == "" {
return
}
path := filepath.Join(home, "desktop-shell", "logs", migrateLogName)
if os.MkdirAll(filepath.Dir(path), 0o700) != nil {
return
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer f.Close()
_, _ = fmt.Fprintf(f, "%s event=legacy_migrate_refused code=shell_missing version=%s rolled_back=%t root=%q detail=%q\n",
time.Now().UTC().Format(time.RFC3339), version, rolledBack, installRoot, refusal.Error())
}
@@ -0,0 +1,290 @@
package main
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
const (
legacyChildModeEnv = "REASONIX_LEGACY_MIGRATOR_TEST_CHILD"
legacyChildRootEnv = "REASONIX_LEGACY_MIGRATOR_TEST_ROOT"
legacyFromVersion = "v1.19.2"
legacyToVersion = "v1.39.4"
childShellMissing = 3
)
func writeShellTree(t *testing.T, root, label string) {
t.Helper()
for _, name := range installlayout.ShellRequiredNames(runtime.GOOS) {
p := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(label+"-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
}
func snapshotFlat(t *testing.T, root string) map[string]string {
t.Helper()
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
out := map[string]string{}
for _, entry := range entries {
if !entry.Type().IsRegular() {
out[entry.Name()] = "<dir>"
continue
}
data, err := os.ReadFile(filepath.Join(root, entry.Name()))
if err != nil {
t.Fatal(err)
}
out[entry.Name()] = string(data)
}
return out
}
func assertNothingCommitted(t *testing.T, root string) {
t.Helper()
if _, err := os.Lstat(filepath.Join(root, installlayout.CurrentFileName)); !os.IsNotExist(err) {
t.Fatalf("current.json written for a release unit without a shell: %v", err)
}
if _, err := os.Lstat(filepath.Join(root, "versions")); !os.IsNotExist(err) {
t.Fatalf("versions/ created for a release unit without a shell: %v", err)
}
}
func assertRefusalLogged(t *testing.T, want ...string) {
t.Helper()
data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), "desktop-shell", "logs", migrateLogName))
if err != nil {
t.Fatalf("refusal was not logged: %v", err)
}
for _, w := range append([]string{"code=shell_missing"}, want...) {
if !strings.Contains(string(data), w) {
t.Fatalf("refusal log %q lacks %q", data, w)
}
}
}
func TestMigrateRefusesFlatUnitWithoutShell(t *testing.T) {
cases := map[string]func(t *testing.T, root string){
"absent": func(*testing.T, string) {},
"incomplete": func(t *testing.T, root string) {
p := filepath.Join(root, installlayout.AppShellDirName, "resources", "app.asar")
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("asar"), 0o644); err != nil {
t.Fatal(err)
}
},
}
for name, shell := range cases {
t.Run(name, func(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
root := t.TempDir()
writeFlatUnit(t, root, "new")
if err := os.WriteFile(filepath.Join(root, "reasonix-guard"), []byte("migrator"), 0o755); err != nil {
t.Fatal(err)
}
shell(t, root)
before := snapshotFlat(t, root)
err := migrateWithRelaunch(root, legacyToVersion, false)
if !errors.Is(err, errShellMissing) {
t.Fatalf("migrate = %v, want errShellMissing", err)
}
assertNothingCommitted(t, root)
if after := snapshotFlat(t, root); fmt.Sprint(after) != fmt.Sprint(before) {
t.Fatalf("install root changed on refusal:\nbefore %v\nafter %v", before, after)
}
assertRefusalLogged(t, "rolled_back=false")
})
}
}
// legacyUpdatedInstall reproduces what a 1.18–1.19.2 updater leaves behind: it
// runs the updater's own transaction steps from inside the install, publishing
// only desktop, CLI and the migrator under the Guard name.
func legacyUpdatedInstall(t *testing.T, withShell bool) (root string, oldDesktop []byte) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("the file-by-file legacy updater is the Unix portable path")
}
t.Setenv("REASONIX_HOME", t.TempDir())
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
oldDesktop, err = os.ReadFile(self)
if err != nil {
t.Fatal(err)
}
root = t.TempDir()
for name, body := range map[string][]byte{
"reasonix-desktop": oldDesktop,
"reasonix-guard": []byte("old-guard"),
"reasonix": []byte("old-cli"),
} {
if err := os.WriteFile(filepath.Join(root, name), body, 0o755); err != nil {
t.Fatal(err)
}
}
if withShell {
writeShellTree(t, root, "new")
}
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-desktop"), "legacy-update", root); code != 0 {
t.Fatalf("legacy updater child exited %d: %s", code, out)
}
if got, _ := os.ReadFile(filepath.Join(root, "reasonix-desktop")); string(got) != "new-desktop" {
t.Fatalf("legacy updater did not publish the new desktop: %q", got)
}
return root, oldDesktop
}
func runLegacyChildProcess(t *testing.T, exe, mode, root string) (int, string) {
t.Helper()
cmd := exec.Command(exe, "-test.run=^$")
cmd.Env = append(os.Environ(), legacyChildModeEnv+"="+mode, legacyChildRootEnv+"="+root)
var out bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &out
err := cmd.Run()
var exit *exec.ExitError
if errors.As(err, &exit) {
return exit.ExitCode(), out.String()
}
if err != nil {
t.Fatalf("run %s child: %v", mode, err)
}
return 0, out.String()
}
func TestLegacyUpdaterWithoutShellRestoresPreviousRelease(t *testing.T) {
root, oldDesktop := legacyUpdatedInstall(t, false)
code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root)
if code != childShellMissing {
t.Fatalf("migrator child exited %d, want errShellMissing: %s", code, out)
}
assertNothingCommitted(t, root)
for name, want := range map[string][]byte{
"reasonix-desktop": oldDesktop,
"reasonix-guard": []byte("old-guard"),
"reasonix": []byte("old-cli"),
} {
if got, err := os.ReadFile(filepath.Join(root, name)); err != nil || !bytes.Equal(got, want) {
t.Fatalf("%s was not restored to the previous release (err %v)", name, err)
}
}
if _, err := os.Lstat(filepath.Join(root, installlayout.LauncherBinaryName())); !os.IsNotExist(err) {
t.Fatalf("thin launcher installed beside a restored legacy release: %v", err)
}
if repair.PendingUpdateExists() {
t.Fatal("legacy transaction still pending after rollback")
}
assertRefusalLogged(t, "rolled_back=true")
}
func TestLegacyUpdaterWithShellStillCommitsTransaction(t *testing.T) {
root, _ := legacyUpdatedInstall(t, true)
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root); code != 0 {
t.Fatalf("migrator child exited %d: %s", code, out)
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != legacyToVersion {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
desktop, err := installlayout.ActiveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(desktop); string(got) != "new-desktop" {
t.Fatalf("active desktop = %q", got)
}
if !installlayout.HasActiveShell(root) {
t.Fatal("active version lost its shell")
}
if repair.PendingUpdateExists() {
t.Fatal("legacy transaction was not committed")
}
}
func runLegacyChild(mode string) int {
root := os.Getenv(legacyChildRootEnv)
var err error
switch mode {
case "legacy-update":
err = applyLegacyUpdate(root)
case "migrate":
err = migrateWithRelaunch(root, legacyToVersion, false)
if errors.Is(err, errShellMissing) {
fmt.Fprintln(os.Stderr, err)
return childShellMissing
}
default:
err = fmt.Errorf("unknown child mode %q", mode)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
// applyLegacyUpdate follows applyLinux from v1.19.2 desktop/updater.go step
// for step, with the running desktop as the updater.
func applyLegacyUpdate(root string) error {
exe, err := os.Executable()
if err != nil {
return err
}
migrator, err := os.ReadFile(exe)
if err != nil {
return err
}
desktop := filepath.Join(root, "reasonix-desktop")
guard := filepath.Join(root, "reasonix-guard")
cli := filepath.Join(root, "reasonix")
tx, err := repair.PrepareFileUpdate(legacyFromVersion, legacyToVersion, desktop, guard, cli)
if err != nil {
return fmt.Errorf("prepare: %w", err)
}
claimed, release, err := repair.ClaimPendingFileUpdateExact(tx.ToVersion, tx.CreatedAt, repair.UpdateTransactionID(tx), desktop, []string{desktop, guard, cli}, 2*time.Minute)
if err != nil {
return fmt.Errorf("claim: %w", err)
}
defer release()
if err := repair.MarkUpdateApplyFailedExact(claimed, "Linux update publish did not complete"); err != nil {
return err
}
var receipts []repair.FileUpdateInstallReceipt
for _, member := range []struct {
path string
body []byte
}{{cli, []byte("new-cli")}, {guard, migrator}, {desktop, []byte("new-desktop")}} {
receipt, err := repair.PublishClaimedFileUpdateMemberExact(claimed, member.path, member.body, 0o700)
if err != nil {
return fmt.Errorf("publish %s: %w", filepath.Base(member.path), err)
}
receipts = append(receipts, receipt)
}
if _, err := repair.RecordClaimedFileUpdateInstalled(claimed, receipts...); err != nil {
return fmt.Errorf("record installed: %w", err)
}
return repair.ClearUpdateApplyFailureExact(claimed)
}
+14 -1
View File
@@ -9,6 +9,7 @@ import (
"reasonix/internal/config"
"reasonix/internal/crashreport"
"reasonix/internal/plugin"
"reasonix/internal/skill/skillwatch"
// Blank imports wire compile-time built-ins into their registries.
_ "reasonix/internal/provider/anthropic"
@@ -37,14 +38,26 @@ var runCLI = func(args []string, buildVersion string) int {
}
func main() {
// Internal watcher-helper entry: the host-shared skill watch service
// re-enters this executable so Windows directory watching never runs
// in-process. Dispatch before any application initialization.
if skillwatch.MaybeRunHelper() {
return
}
plugin.SetMCPClientVersion(version)
os.Exit(runWithCrashCapture(os.Args[1:], version))
}
func runWithCrashCapture(args []string, buildVersion string) (exitCode int) {
home := config.ReasonixHomeDir()
crashreport.CaptureFatalDumps(home, buildVersion)
releaseFatalOutput := crashreport.InstallFatalOutput(home)
defer func() {
// A panic recovered here is already reported, so the runtime's copy of
// the re-raise must not queue a second one.
releaseFatalOutput()
if recovered := recover(); recovered != nil {
_ = crashreport.CapturePanic(config.ReasonixHomeDir(), buildVersion, recovered, debug.Stack())
_ = crashreport.CapturePanic(home, buildVersion, recovered, debug.Stack())
panic(recovered)
}
}()
+48
View File
@@ -1,6 +1,8 @@
package main
import (
"os"
"os/exec"
"strings"
"testing"
@@ -57,3 +59,49 @@ func TestRunWithCrashCapturePassesThroughExitCode(t *testing.T) {
t.Fatalf("exit code=%d", got)
}
}
const fatalChildEnv = "REASONIX_TEST_FATAL_CHILD"
func TestGoroutinePanicIsReportedOnNextStart(t *testing.T) {
if os.Getenv(fatalChildEnv) == "1" {
runCLI = func([]string, string) int {
go func() { panic("private prompt from background goroutine") }()
select {}
}
runWithCrashCapture([]string{"chat"}, "v1.20.0")
return
}
home := t.TempDir()
child := exec.Command(os.Args[0], "-test.run=^TestGoroutinePanicIsReportedOnNextStart$")
child.Env = append(os.Environ(), fatalChildEnv+"=1", "REASONIX_HOME="+home)
output, err := child.CombinedOutput()
if err == nil {
t.Fatalf("child exited cleanly; output:\n%s", output)
}
if !strings.Contains(string(output), "panic:") {
t.Fatalf("child did not die from the goroutine panic:\n%s", output)
}
t.Setenv("REASONIX_HOME", home)
previous := runCLI
t.Cleanup(func() { runCLI = previous })
runCLI = func([]string, string) int { return 0 }
runWithCrashCapture([]string{"version"}, "v1.20.0")
reports, err := crashreport.List(home)
if err != nil || len(reports) != 1 {
t.Fatalf("pending reports after restart = %d (err %v), want the goroutine panic", len(reports), err)
}
report := reports[0].Report
if !strings.Contains(report.Stack, "goroutine ") || !strings.Contains(report.Stack, "TestGoroutinePanicIsReportedOnNextStart") {
t.Fatalf("stack = %q, want the panicking goroutine", report.Stack)
}
preview, err := crashreport.Preview(report)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(preview), "private prompt") {
t.Fatalf("panic value leaked into report: %s", preview)
}
}
+1 -1
View File
@@ -288,7 +288,7 @@ func parseWorkflow(data []byte) (workflowInfo, error) {
if uses := mappingScalar(job, "uses"); strings.HasPrefix(uses, "./.github/workflows/") {
info.calls = append(info.calls, strings.TrimPrefix(uses, "./"))
}
if nodeContains(job, "secrets.SIGNPATH_API_TOKEN") {
if nodeContains(job, "secrets.SIGNPATH_API_TOKEN") || nodeContains(job, "secrets.CERTUM_OTP_URI") {
info.directSigning = true
}
steps := mappingValue(job, "steps")
+22
View File
@@ -27,7 +27,10 @@ func TestTopLevelSignPathWorkflowCallGraph(t *testing.T) {
t.Fatal(err)
}
want := []string{
".github/workflows/certum-signing-smoke.yml",
".github/workflows/release-candidate.yml",
".github/workflows/release-desktop.yml",
".github/workflows/release-promote.yml",
".github/workflows/release-stable.yml",
}
if !reflect.DeepEqual(got, want) {
@@ -66,3 +69,22 @@ jobs:
t.Fatalf("token-backed workflow was not classified as a signing entry point: %+v", info)
}
}
func TestWorkflowUsingCertumTokenIsSigningEntryPoint(t *testing.T) {
info, err := parseWorkflow([]byte(`
on: workflow_dispatch
jobs:
sign:
runs-on: windows-2022
steps:
- uses: ./.github/actions/setup-certum
with:
otp-uri: ${{ secrets.CERTUM_OTP_URI }}
`))
if err != nil {
t.Fatal(err)
}
if !info.externallyTriggered || !info.directSigning {
t.Fatalf("Certum signing entry point not detected: %+v", info)
}
}
+44 -67
View File
@@ -1,78 +1,55 @@
# Chat presentation design QA
# Sandbox settings — selected direction 3
## Harness source port, 2026-09-12
final result: passed
- Source provenance and host adaptations: `desktop/frontend/src/components/harness-chat/README.md`.
- Ported disclosure, reasoning, turn-process, context-record, tool-row, terminal,
diff, web-source, status and folding primitives; copied scoped Markdown styling.
- Preserved Reasonix's safe-link host, Markdown workers, composer, approvals,
fork capability checks and content API. No feedback voting controls.
- Removed Settings → Conversation experience → Standard/Deep and its search
summary. Kept the approval control and persisted compatibility field.
- Completed tool errors can fold with a successful final answer; a visible
failed-call count remains in the process summary. Interrupted/terminal
failure records stay visible. Permission text is disclosed on demand.
- In real Electron, opened the user's existing weather turn, expanded and
collapsed its seven tools, and verified the two failed calls remained visible
in the summary. Inspected the settings screen and confirmed there are only
the three default approval choices in that section.
- Production ZIP: SHA256 `3409b930ca85eebc7dae8e266821300e2358518827736394602769c4ddf303ce`.
Build `v0.0.0-dev`, commit label `0e5319ad145b` plus current worktree changes,
build time `2026-09-12T12:54:05Z`, macOS arm64 / Electron 44.2.0.
- Ad-hoc signature verification and packaged smoke passed: 3.1s handshake,
real renderer `Version` call, normal shell and service exit. The user-facing
launch uses the isolated `Reasonix-Test-ChatRefactor` profile.
- The final font-only correction uses a root-owned token for portaled usage/time
dialogs; the typography contract passed all 165 assertions. Long-history
replay numbers above were captured before that portal-only correction.
- Browser evidence: `docs/evidence/harness-chat-port/`. Synthetic weather
screenshots use fixture data, not a fresh weather query. Each tool header is
24px high; collapsed processes do not mount tool bodies.
- WebKit 26.6: 1,000 turns input P95 103ms, switch P95 40ms. Its Long Tasks
API is unavailable, so no maximum-long-task claim is made for WebKit.
- Chromium 153: 1,000 turns input P95 120.7ms, max long task 138ms,
switch P95 32.8ms, released heap growth 125,396 bytes over 20 switches.
- Electron 44.2.0 replay: 1,000 turns input P95 43.4ms, max long task 142ms,
switch P95 20.3ms. Stream anchor drift 0px; prepend drift 0.094px.
- Native WebView hosts on other platforms, native IME soak and exposed native
scrollbar dragging were not rerun in this pass. This is a local test build,
not a published or notarized release.
## Visual truth and evidence
## Earlier footer verification
- Selected source: the third generated direction shown during the design review; it supersedes direction 1.
- Preview route: `/dev/settings-layout-preview.html?page=sandbox&platform=windows`.
- Visual comparison artifacts were kept in the local QA workspace and are not part of this repository.
- Implementation screenshot: `option3-final.png` in the local QA artifacts.
- Full-view comparison: `comparison-final.png`; focused environment-table comparison: `comparison-detail.png`. Both were opened and inspected with source and implementation together.
- Source pixels: 1312 × 1200, normalized to the intended 1032 × 944 viewport. Implementation: 1032 × 944 screenshot pixels and CSS viewport, density 1. No device/browser chrome.
- Matched state: light graphite theme, Windows preview fixtures, Git Bash selected and active, no pending shell reload, default workspace, no additional writable directories. The saved indicator follows a successful add/remove operation.
- Additional responsive captures: `option3-600.png`, `option3-390.png`, and `option3-390-fields.png`. Temporary viewport overrides were reset after verification.
## Reference
## Findings and comparison history
- DeepSeek Harness commit: `c291e7961a`
- Source components:
- `<deepseek-harness>/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx`
- `<deepseek-harness>/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx`
- Visual references supplied by the user:
- Reference screenshot: assistant footer actions and turn metrics.
- Reference screenshot: token usage detail popover.
- Reference screenshot: elapsed-time detail popover.
1. **P2, first desktop comparison: excess vertical spacing.** `comparison-first.png` showed the directory entry reaching the bottom edge, unlike the target. Reduced table row padding, aligned the root label to its input, narrowed the directory label column, and removed unnecessary field spacing. `comparison-final.png` shows the complete form and empty state visible within the comparison viewport.
2. **P2, narrow-window review: fragmented executable paths.** At 600 px, three columns left too little room for Windows paths. Below a 660 px content width, each environment uses a name/status row followed by a full-width path. Post-fix 600 px and 390 px captures retain readable paths, accessible copy controls, and no horizontal overflow. The lower form was scrolled into view and checked at 390 px.
3. **P2, misleading save feedback and stale drafts.** Rule additions now clear only after a successful save and reject concurrent Enter submissions. Failed root saves retain input; refreshed authoritative roots replace stale values. Saved feedback uses a dedicated label and appears only after confirmed success. Deterministic tests cover both writable directories and the sibling permission-rule form.
## Implemented target
No actionable P0/P1/P2 findings remain.
- Assistant turn footer contains copy, branch-in-new-chat, usage, duration, and timestamp.
- Like and dislike actions are intentionally omitted.
- Usage popover shows exact total tokens, provider/model route, cache hit rate, uncached input, cache-read input, output, and reasoning output.
- Duration popover shows total turn time and live throughput when the active turn exposes it.
- Compact values use the same `K tok` treatment as Harness while popovers retain exact values.
- Long provider/model routes remain on one line with ellipsis and expose the full route as a native title.
- Branch action uses the existing checkpoint and capability checks and creates a real conversation fork.
- Popovers dismiss with Escape or outside interaction and return focus to their trigger.
## Required fidelity surfaces
## Native verification
- **Typography:** existing system/PingFang fallback retained; 22 px page title, 16 px section headings, 13–14 px form and table text. Long paths wrap rather than being truncated. Native font rendering differs slightly from the raster mock; hierarchy and readability are preserved.
- **Spacing/layout:** current-shell strip, three-column diagnostic table, Windows boundary note, network status, and compact directory form match direction 3. Existing navigation, container radius and page routing remain intact. The final form sits roughly 20 px lower than the generated target; this is minor rhythm variation, with all controls visible at the matched viewport.
- **Colors/tokens:** existing warm graphite light surfaces, subtle border tokens, foreground/dim text and semantic success color retained. Dark mode inherits the existing theme tokens; no hardcoded light-only colors were introduced.
- **Assets/icons:** no raster assets are required by this settings screen. Existing Lucide library icons supply terminal, Git, information, copy and check affordances. Monochrome terminal icons intentionally preserve the application's icon system instead of introducing PowerShell branding from the generated mock.
- **Copy/content:** file-tool scope explicitly excludes Windows Shell commands; Windows network access is a factual unrestricted status. Git is labelled as a dependency. PowerShell keeps its existing precise runtime label. Scope and automatic saving are explained; manual reload remains a separate action. Successful save status is not fabricated on initial load.
- App: `~/Applications/Reasonix-Canary/Reasonix.app`
- Platform: macOS arm64, Electron 44.2.0
- Data directory: isolated `Reasonix-Test-ChatRefactor` profile
- Compared the Harness reference and the running app in one visual comparison input.
- Verified a real newly completed turn displayed `用量 7.4K tok`, `用时 2秒`, and a timestamp.
- Verified its usage details displayed 7,370 total tokens, 96.7% cache hit, 238 uncached input, 7,040 cache-read input, 92 output tokens, and 25 reasoning tokens.
- Verified the duration dialog, usage dialog, copy action, branch action, keyboard dismissal, and normal transcript scrolling.
- Found and fixed a queued-turn timing bug that could reuse the preceding turn start time; the new regression test covers this event order.
## Verification
## Result
- 55 shell/settings interaction assertions passed, including Git Bash preference compatibility, pending-reload state, Linux/macOS repair behavior, path copying, failed/retried writes, duplicate Enter prevention, reloaded root state, empty-root reset and permission-rule sibling behavior.
- 99 settings snapshot assertions passed.
- Frontend and test TypeScript checks passed; changed-file ESLint, CSS syntax and application-layer checks passed.
- Browser checks: Git Bash selection updates active state; root Enter save and keyboard clear restore default workspace; directory add/remove updates effective directories; copy returns the full executable path; narrow-window inputs and actions remain reachable.
- Console: locale-module hot replacement produced transient context errors during editing. After a clean reload, no new console errors occurred during the final interaction and responsive checks.
- Browser data are fixtures, so this visual pass does not claim native desktop-shell validation. Shell detection and execution have separate native Windows regression coverage.
PASS. The footer hierarchy, spacing, subdued metadata treatment, popover content, and interaction match the supplied Harness target within the existing Reasonix theme. No like or dislike control is present.
## Implementation checklist
- [x] Implement selected direction 3 in existing components.
- [x] Preserve Git Bash support and native PowerShell automatic preference.
- [x] Repair shared rule saving and authoritative-root synchronization.
- [x] Verify desktop and narrow layouts with source/render comparisons.
- [x] Keep the local preview available.
## Follow-up polish
P3 only: a later product-wide icon pass could introduce official runtime logos consistently. No functional work is blocked on it.
## Retrospective
The existing Reasonix skill already covers the reusable lessons applied here: repair shared draft ownership, test failure ordering, and inspect stylesheet specificity in the rendered UI. No additional skill rule was needed.
+3
View File
@@ -3,6 +3,8 @@
/build/*
!/build/appicon.png
!/build/appicon.svg
!/build/trayTemplate.png
!/build/[email protected]
# Commit only our customized per-user NSIS installer template. Everything else
# under build/ is generated by the packaging pipeline, so stay ignored.
@@ -21,6 +23,7 @@
!/build/darwin/entitlements.plist
!/build/darwin/icon.icns
!/build/darwin/appicon.svg
!/build/darwin/appicon.png
# Commit the hand-written Linux .deb packaging inputs and app icon assets. The
# .deb is built by scripts/desktop-build.sh's linux branch via nfpm. Parent
+27 -6
View File
@@ -2,12 +2,13 @@
Desktop Go is a separate module; root Go tests do not cover it.
For changes affecting transcript viewport, scrolling, virtualization,
measurement, or delayed geometry work, read the
[transcript scroll contract](../docs/TRANSCRIPT_SCROLL_CONTRACT.md)
For changes affecting transcript viewport, scrolling, loaded history, or
delayed geometry work, read the
[transcript scroll and history contract](../docs/TRANSCRIPT_SCROLL_CONTRACT.md)
([中文](../docs/TRANSCRIPT_SCROLL_CONTRACT.zh-CN.md)).
It preserves single-writer ownership, generation isolation, reader intent,
bounded rendering, and deterministic regression requirements.
bounded rendering, the bounded reading window, and deterministic regression
requirements.
Other Desktop work does not require the scroll-specific procedure.
@@ -30,8 +31,28 @@ window adapter, measurement ledger, geometry revision loop or logical selection.
threshold. Prepend and resize preserve a stable node plus viewport offset.
Old observers, requests and callbacks cannot act on a replaced session.
- Markdown, tables and loaded history use document flow. Parsing may be lazy,
but must not remove loaded text or create a nested virtual vertical scroller.
Collapsed process/tool bodies are mounted on demand.
but must not create a nested virtual vertical scroller. Collapsed
process/tool bodies are mounted on demand.
- History is a bounded reading window, not an ever-growing list. The resident
store keeps a small number of adjacent pages (`windowMaxPages`, default 3 of
32-message pages) per session, including the active one: paging past that
reclaims a page from the end the reader is moving away from and re-fetches
it on demand. Nothing is deleted — the persisted session is authoritative —
and the reclaimed direction stays reachable through its cursor. Do not add a
path that holds every loaded page resident, and do not treat "all history is
mounted" as a correctness property; assert reachability and bounded
residency instead.
- Paging is bidirectional. `loadOlder`/`loadNewer` reclaim from the opposite
end and hand the caller the ids to drop; a caller that ignores them will
render rows the store has already released. Window cursors pin a fixed
snapshot: appends keep them valid, a storage replacement answers
`stale_cursor`, and a cursor the server cannot read is that same typed
answer rather than a transport error.
- History reads route by the tab's binding identity, never by the result of a
failed call: a local error must not be answered by a remote service holding
a different session. A remote service that never negotiated
`history-window-v1` answers with the typed `unsupported` status and keeps
its protocol-7 pages.
- No geometry snapshots are stored in React state. Layout observers must
converge without a render/measurement feedback loop.
- Native selection is browser-owned. No cross-window selection overlay or
+5 -6
View File
@@ -156,10 +156,9 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
which builds on a native runner per platform (Electron's Chromium can't
cross-compile), packages each artifact, signs it with minisign, generates a
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
current desktop manifest to the matching CLI release for old clients that still
ask GitHub's repository-wide `latest` release for it.
`latest.json` manifest, publishes a GitHub release that does not claim the repository-wide `Latest`
badge, mirrors everything to R2, and attaches the current desktop manifest to
the matching CLI release for old clients.
The Linux artifact bundles Electron's Chromium and ships a root-owned
`chrome-sandbox` helper in the `.deb`; no system webview is required.
@@ -193,8 +192,8 @@ not depend on homepage badge semantics. Self-update behavior by platform:
### Code signing — first launch
- **Windows** — stable builds carry an Authenticode signature (SignPath, approved
per release; `release-desktop.yml` verifies every payload binary through
- **Windows** — stable builds carry an Authenticode signature (Certum certificate;
`release-desktop.yml` verifies every payload binary through
`scripts/verify-windows-authenticode.ps1` and fails the release otherwise). A
brand-new version can still show SmartScreen until the signature accumulates
reputation: *More info → Run anyway*.
+2 -2
View File
@@ -21,7 +21,7 @@ func TestSaveTabSessionMetaFoldsDeliveryFloorToStandard(t *testing.T) {
t.Fatal(err)
}
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: path, qualityFloor: control.QualityFloorDelivery}); err != nil {
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: legacySessionPath(path), qualityFloor: control.QualityFloorDelivery}); err != nil {
t.Fatal(err)
}
got, ok, err := agent.LoadBranchMeta(path)
@@ -44,7 +44,7 @@ func TestSaveTabSessionMetaStandardWritesCompatibilityValue(t *testing.T) {
if _, err := agent.EnsureBranchMeta(path); err != nil {
t.Fatal(err)
}
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: path, qualityFloor: control.QualityFloorStandard}); err != nil {
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: legacySessionPath(path), qualityFloor: control.QualityFloorStandard}); err != nil {
t.Fatal(err)
}
got, ok, err := agent.LoadBranchMeta(path)
+650 -967
View File
File diff suppressed because it is too large. Load diff
+48 -33
View File
@@ -19,8 +19,6 @@ import (
type stubProvider struct{}
const autosaveTestTimeout = 10 * time.Second
func (stubProvider) Name() string { return "stub" }
func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
@@ -42,34 +40,52 @@ func controllerWithContent(t *testing.T, path string) *control.Controller {
func waitForFile(t *testing.T, path, want string) {
t.Helper()
deadline := time.Now().Add(autosaveTestTimeout)
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("session file %q never contained %q", path, want)
t.Fatalf("file %q never contained %q", path, want)
}
// autosaveDeadlineMargin leaves room to report a stuck loop and run cleanups
// before the test binary's own timeout panics.
const autosaveDeadlineMargin = 5 * time.Second
// waitForAutosaveIdle waits for the loop to finish, which the bounded save
// waits and retries guarantee. It sets no bound of its own: those waits alone
// can outlast any fixed one on a slow disk, so only the binary deadline caps it.
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
t.Helper()
waitForAutosaveIdleWithin(t, tab, autosaveTestTimeout)
}
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
deadline, bounded := t.Deadline()
for {
tab.saveMu.Lock()
idle := !tab.saving && !tab.saveAgain
tab.saveMu.Unlock()
if idle {
return
}
if bounded && time.Until(deadline) < autosaveDeadlineMargin {
t.Fatal("autosave loop still running at the test binary deadline")
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("autosave loop did not become idle")
}
// requireAutosaved waits for the loop the caller started, then checks what it
// left on disk; polling the file instead would put a clock on the disk.
func requireAutosaved(t *testing.T, tab *WorkspaceTab, path, want string) {
t.Helper()
waitForAutosaveIdle(t, tab)
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read session file after autosave: %v", err)
}
if !strings.Contains(string(b), want) {
t.Fatalf("session file %q does not contain %q after autosave went idle", path, want)
}
}
func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
@@ -102,8 +118,7 @@ func TestTurnDonePersistsSession(t *testing.T) {
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
requireAutosaved(t, tab, path, "remember this turn")
}
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
@@ -136,8 +151,7 @@ func TestScheduleSnapshotCoalesces(t *testing.T) {
}
wg.Wait()
waitForFile(t, path, "acknowledged")
waitForAutosaveIdle(t, tab)
requireAutosaved(t, tab, path, "acknowledged")
}
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
@@ -146,16 +160,19 @@ func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
_ = a
ctrl := tab.Ctrl
// Retry ownership does not depend on how quickly the filesystem rejects
// a write. Inject that failure, then use the real controller for recovery.
tab.Ctrl = &snapshotErrorSessionController{SessionAPI: ctrl, err: os.ErrPermission}
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
waitForAutosaveIdle(t, tab)
tab.saveMu.Lock()
failures := tab.saveFailures
tab.saveMu.Unlock()
if failures == 0 {
t.Fatal("autosave failure should be recorded and retried")
if failures != maxTabSnapshotFailureRetries+1 {
t.Fatalf("autosave failures = %d, want %d attempts", failures, maxTabSnapshotFailureRetries+1)
}
if info, err := os.Stat(path); err != nil || !info.IsDir() {
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
@@ -164,9 +181,11 @@ func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
if err := os.Remove(path); err != nil {
t.Fatalf("remove blocked dir: %v", err)
}
a.mu.Lock()
tab.Ctrl = ctrl
a.mu.Unlock()
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
requireAutosaved(t, tab, path, "remember this turn")
tab.saveMu.Lock()
failures = tab.saveFailures
@@ -272,7 +291,7 @@ func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
}
nodes := app.ListProjectTree()
nodes := mustListProjectTree(t, app)
foundOriginal := false
var walk func([]ProjectNode)
walk = func(list []ProjectNode) {
@@ -488,8 +507,7 @@ func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
// Write the session file once via the autosave loop, then wait for idle so
// the next TurnDone reliably kicks off a fresh loop.
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "acknowledged")
waitForAutosaveIdle(t, doomedTab)
requireAutosaved(t, doomedTab, path, "acknowledged")
// Kick the autosave loop and close the tab in close succession. The loop
// will be in flight when CloseTab runs — exactly the #4384 window.
@@ -567,8 +585,7 @@ func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
a.activeTabID = "test_tab"
survivor.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, survivorPath, "acknowledged")
waitForAutosaveIdle(t, survivor)
requireAutosaved(t, survivor, survivorPath, "acknowledged")
if err := a.CloseTab("test_tab"); err != nil {
t.Fatalf("CloseTab: %v", err)
@@ -582,7 +599,7 @@ func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
}
}
func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
func TestLegacyDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
@@ -603,7 +620,7 @@ func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
t.Fatalf("snapshot: %v", err)
}
if err := app.DeleteSession(path); err != nil {
if err := app.deleteSession(path); err != nil {
t.Fatalf("DeleteSession: %v", err)
}
@@ -665,6 +682,7 @@ func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
activeTabID: "trash_open",
}
pinDesktopSessionRoot(t, app)
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("TrashTopic: %v", err)
}
@@ -672,8 +690,5 @@ func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
if got := ctrl.SessionPath(); got != "" {
t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
}
trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("topic session should be in trash: %v", err)
}
assertLegacyLifecycle(t, app, path, "archived")
}
+4 -5
View File
@@ -3,10 +3,6 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
@@ -14,6 +10,9 @@ import (
"reasonix/internal/provider"
"reasonix/internal/session"
"reasonix/internal/tool"
"strings"
"testing"
"time"
)
func carryingController(carried []provider.Message, path string) *control.Controller {
@@ -185,7 +184,7 @@ func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
}
nodes := app.ListProjectTree()
nodes := mustListProjectTree(t, app)
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
}
+92 -140
View File
@@ -1103,6 +1103,7 @@ status_bar_items = ["cost", "balance"]
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
approveWorkspace(t, project)
userCfg := config.LoadForEdit(config.UserConfigPath())
if err := userCfg.SetDesktopLanguage("en"); err != nil {
@@ -1345,54 +1346,26 @@ func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T)
t.Fatalf("settings provider missing from settings: %+v", got.Providers)
}
func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
// With no user config yet, Settings start from the defaults, not from the
// checkout's reasonix.toml, and the first edit writes only the user's choice.
func TestSettingsNeverSeedTheUserConfigFromTheProjectFile(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
default_model = "legacy-provider/legacy-model"
[desktop]
language = "zh"
layout_style = "workbench"
theme = "light"
theme_style = "glacier"
close_behavior = "quit"
status_bar_style = "text"
status_bar_items = ["model", "cache", "balance"]
`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntheme = \"light\"\n\n[permissions]\nmode = \"allow\"\n"), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
approveWorkspace(t, project)
t.Chdir(project)
app := NewApp()
got := app.Settings()
if got.ConfigPath != config.UserConfigPath() {
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
}
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "icon" {
t.Fatalf("Settings did not seed from legacy project config: %+v", got)
}
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want)
}
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
if got := app.Settings(); got.ConfigPath != config.UserConfigPath() || got.DesktopTheme == "light" {
t.Fatalf("Settings = path %q theme %q, want the user config's defaults", got.ConfigPath, got.DesktopTheme)
}
if err := app.SetDesktopLanguage("en"); err != nil {
t.Fatalf("SetDesktopLanguage: %v", err)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "icon" {
t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
}
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want)
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() == "light" || userCfg.Permissions.Mode == "allow" {
t.Fatalf("user config took the checkout's values: theme %q mode %q", userCfg.DesktopTheme(), userCfg.Permissions.Mode)
}
}
@@ -1489,7 +1462,7 @@ api_key_env = "DEEPSEEK_API_KEY"
if !p.BuiltIn {
t.Fatalf("deepseek provider should be marked built-in for official endpoint: %+v", p)
}
if !p.Added || !p.KeySet || len(p.Models) != 3 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Models[2] != "deepseek-v4-flash-vision-exp" || !slices.Equal(p.VisionModels, []string{"deepseek-v4-flash-vision-exp"}) || p.Default != "deepseek-v4-flash" {
if !p.Added || !p.KeySet || !slices.Equal(p.Models, []string{"deepseek-flash", "deepseek-v4-pro", "deepseek-v4-flash"}) || len(p.VisionModels) != 0 || p.Default != "deepseek-v4-flash" {
t.Fatalf("deepseek provider = %+v, want added repaired official model list", p)
}
if got.DefaultModel != "deepseek/deepseek-v4-flash" {
@@ -1680,14 +1653,17 @@ api_key_env = "DEEPSEEK_API_KEY"
if !ok {
t.Fatal("deepseek provider not saved")
}
if len(p.Models) != 3 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Models[2] != "deepseek-v4-flash-vision-exp" || !slices.Equal(p.VisionModels, []string{"deepseek-v4-flash-vision-exp"}) || p.Default != "deepseek-v4-flash" {
if !slices.Equal(p.Models, []string{"deepseek-flash", "deepseek-v4-pro"}) || len(p.VisionModels) != 0 || p.Default != "deepseek-flash" {
t.Fatalf("deepseek provider after add = %+v, want official model list", p)
}
if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
t.Fatalf("provider_access missing deepseek: %+v", cfg.Desktop.ProviderAccess)
}
if cfg.DefaultModel != "deepseek/deepseek-v4-flash" {
t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", cfg.DefaultModel)
t.Fatalf("default_model = %q, want preserved legacy Flash choice", cfg.DefaultModel)
}
if resolved, ok := cfg.ResolveModel(cfg.DefaultModel); !ok || resolved.Model != "deepseek-v4-flash" {
t.Fatalf("legacy default model no longer resolves: entry=%+v, ok=%t", resolved, ok)
}
}
@@ -1905,7 +1881,7 @@ func TestAddProviderPresetAccessSavesEditableProviderAndKey(t *testing.T) {
if !ok {
t.Fatal("mimo-api provider not saved")
}
if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.5-pro" {
if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.6-pro" {
t.Fatalf("mimo-api provider after preset add = %+v", p)
}
if p.PresetID != "mimo-api" || p.PresetVersion != config.ProviderPresetVersion {
@@ -2033,7 +2009,7 @@ func TestResetProviderPresetAccessOverwritesSameNameProvider(t *testing.T) {
if !ok {
t.Fatal("mimo-api provider missing after reset")
}
if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.5-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion {
if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.6-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion {
t.Fatalf("mimo-api provider after reset = %+v, want preset template", got)
}
if len(got.Headers) != 0 {
@@ -3045,8 +3021,8 @@ func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
t.Fatalf("Models() refs = %+v, should not include hidden provider %s", models, hidden)
}
}
if len(models) != 5 {
t.Fatalf("Models() len = %d, want 5: %+v", len(models), models)
if len(models) != 4 {
t.Fatalf("Models() len = %d, want only explicitly selected provider models: %+v", len(models), models)
}
}
@@ -3182,6 +3158,8 @@ func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
models := NewApp().Models()
refs := modelRefsFromView(models)
for _, want := range []string{
"mimo-api/mimo-v2.6-pro",
"mimo-api/mimo-v2.6-flash",
"mimo-api/mimo-v2.5-pro",
"mimo-api/mimo-v2.5",
} {
@@ -3189,8 +3167,8 @@ func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
t.Fatalf("Models() refs = %+v, missing %s", models, want)
}
}
if len(models) != 2 {
t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
if len(models) != 4 {
t.Fatalf("Models() len = %d, want 4: %+v", len(models), models)
}
}
@@ -3222,6 +3200,7 @@ api_key_env = "DEEPSEEK_API_KEY"
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
approveWorkspace(t, projectRoot)
app := NewApp()
tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
@@ -3231,7 +3210,7 @@ api_key_env = "DEEPSEEK_API_KEY"
models := app.ModelsForTab(tab.ID)
refs := modelRefsFromView(models)
for _, want := range []string{
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-flash",
"mimo-pro/mimo-v2.5-pro",
} {
if !refs[want] {
@@ -3949,6 +3928,7 @@ default_effort = "max"
if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, projectA)
staleConfig := `default_model = "stale/stale-model"
[[providers]]
name = "stale"
@@ -3961,6 +3941,7 @@ reasoning_protocol = "none"
if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, projectB)
topicID := "topic_effort_owner"
topicTitle := "Effort owner"
@@ -5694,14 +5675,14 @@ func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
app := &App{}
listed := app.ListDir("")
for _, hidden := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
if hasDirEntry(listed, hidden) {
t.Fatalf("ListDir should hide local noise %q, got %+v", hidden, listed)
for name, shown := range map[string]bool{".codex": false, ".npm": false, ".pnpm-store": false, "dist": false, "bin": true, "stage": true, "tmp": true} {
if hasDirEntry(listed, name) != shown {
t.Fatalf("ListDir shows %q = %v, want %v (only @-search skips generic names); got %+v", name, !shown, shown, listed)
}
}
desktopFrontend := app.ListDir("desktop/frontend")
if hasDirEntry(desktopFrontend, "wailsjs") {
t.Fatalf("ListDir should hide generated Wails bindings, got %+v", desktopFrontend)
if !hasDirEntry(desktopFrontend, "wailsjs") {
t.Fatalf("ListDir should show desktop/frontend/wailsjs, got %+v", desktopFrontend)
}
frontendEntries := app.ListDir("frontend")
for _, hidden := range []string{".DS_Store", "Thumbs.db"} {
@@ -5889,7 +5870,7 @@ func TestFileRefsIncludeRegisteredExternalFolderChildren(t *testing.T) {
}
}
func TestDeleteSessionCancelsActiveRuntime(t *testing.T) {
func TestLegacyDeleteSessionCancelsActiveRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -5913,7 +5894,7 @@ func TestDeleteSessionCancelsActiveRuntime(t *testing.T) {
app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
app.tabOrder = []string{"test", "keep"}
if err := app.DeleteSession(filepath.Base(path)); err != nil {
if err := app.deleteSession(filepath.Base(path)); err != nil {
t.Fatalf("DeleteSession(active basename): %v", err)
}
if _, ok := app.tabs["test"]; ok {
@@ -5931,7 +5912,7 @@ func TestDeleteSessionCancelsActiveRuntime(t *testing.T) {
}
}
func TestDeleteSessionCancelsPreReadyBlankBuild(t *testing.T) {
func TestLegacyDeleteSessionCancelsPreReadyBlankBuild(t *testing.T) {
isolateDesktopUserDirs(t)
globalRoot := globalTabWorkspaceRoot()
@@ -5965,7 +5946,7 @@ func TestDeleteSessionCancelsPreReadyBlankBuild(t *testing.T) {
activeTabID: "blank",
}
if err := app.DeleteSession(filepath.Base(path)); err != nil {
if err := app.deleteSession(filepath.Base(path)); err != nil {
t.Fatalf("DeleteSession(pre-ready blank): %v", err)
}
if !cancelled {
@@ -5982,7 +5963,7 @@ func TestDeleteSessionCancelsPreReadyBlankBuild(t *testing.T) {
}
}
func TestDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) {
func TestLegacyDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
@@ -6016,28 +5997,23 @@ func TestDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) {
activeTabID: "only",
}
if err := app.DeleteSession(path); err != nil {
if err := app.deleteSession(path); err != nil {
t.Fatalf("DeleteSession(last topic session): %v", err)
}
if _, ok := app.tabs["only"]; ok {
t.Fatalf("deleted topic session tab should be removed")
}
for id, tab := range app.tabs {
if tab.TopicID == topicID {
t.Fatalf("fallback tab %q reused deleted topic %q", id, topicID)
}
if strings.TrimSpace(tab.TopicID) != "" {
t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
}
}
// The deleted topic owns no content, so nothing is re-activated and no
// replacement blank session is created: the frontend lands on the draft.
assertNoVisibleRuntime(t, app)
trashPath := filepath.Join(dir, sessionTrashDir, "delete-last.jsonl", "delete-last.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("deleted session should be moved to trash: %v", err)
}
}
func TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {
func TestLegacyDeleteSessionWithStuckJobUsesSingleGrace(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -6069,14 +6045,12 @@ func TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {
app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
app.tabOrder = []string{"test", "keep"}
start := time.Now()
if err := app.DeleteSession(filepath.Base(path)); err != nil {
if err := app.deleteSession(filepath.Base(path)); err != nil {
t.Fatalf("DeleteSession(stuck job): %v", err)
}
elapsed := time.Since(start)
if elapsed > grace+2*time.Second {
t.Fatalf("DeleteSession took %s, want one teardown grace plus bounded metadata I/O", elapsed)
}
// The single timeout notice and cleanup marker prove that deletion used the
// bounded teardown path. Host filesystem latency after that boundary is not
// a Go correctness property and must not be sampled by this unit test.
assertSingleTeardownTimeoutNotice(t, teardownNotices, grace)
if !agent.IsCleanupPending(path) {
t.Fatalf("stuck delete should mark cleanup pending")
@@ -6086,7 +6060,7 @@ func TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {
}
}
func TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {
func TestLegacyDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -6109,7 +6083,7 @@ func TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {
ctrl.Submit("work")
<-runner.started
err := app.DeleteSession(filepath.Base(path))
err := app.deleteSession(filepath.Base(path))
if err != nil {
t.Fatalf("DeleteSession should succeed after cleaning empty trash dir: %v", err)
}
@@ -6128,7 +6102,7 @@ func TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {
waitNotRunning(t, ctrl)
}
func TestDeleteSessionValidTrashRemovesEmptyLiveStub(t *testing.T) {
func TestLegacyDeleteSessionValidTrashRemovesEmptyLiveStub(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -6159,7 +6133,7 @@ func TestDeleteSessionValidTrashRemovesEmptyLiveStub(t *testing.T) {
tabOrder: []string{"active"},
}
if err := app.DeleteSession(filepath.Base(path)); err != nil {
if err := app.deleteSession(filepath.Base(path)); err != nil {
t.Fatalf("DeleteSession should remove stale live stub: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
@@ -6170,7 +6144,7 @@ func TestDeleteSessionValidTrashRemovesEmptyLiveStub(t *testing.T) {
}
}
func TestDeleteSessionValidTrashRemovesDuplicateLiveSession(t *testing.T) {
func TestLegacyDeleteSessionValidTrashRemovesDuplicateLiveSession(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -6202,7 +6176,7 @@ func TestDeleteSessionValidTrashRemovesDuplicateLiveSession(t *testing.T) {
tabOrder: []string{"active"},
}
if err := app.DeleteSession(filepath.Base(path)); err != nil {
if err := app.deleteSession(filepath.Base(path)); err != nil {
t.Fatalf("DeleteSession should remove duplicate live session: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
@@ -6251,7 +6225,7 @@ func TestRestoreSessionRejectsOpenEmptyLiveStub(t *testing.T) {
}
}
func TestDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) {
func TestLegacyDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -6275,7 +6249,7 @@ func TestDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) {
app := NewApp()
app.setTestCtrl(activeCtrl, "")
if err := app.DeleteSession(filepath.Base(path)); err != nil {
if err := app.deleteSession(filepath.Base(path)); err != nil {
t.Fatalf("DeleteSession should move different live session to a unique trash item: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
@@ -6306,7 +6280,7 @@ func TestDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) {
}
}
func TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {
func TestLegacyDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -6336,7 +6310,7 @@ func TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {
activeTabID: "active",
}
installSessionCatalogForTest(t, app, dir, "global", "")
if err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {
if err := app.deleteSession(filepath.Base(inactivePath)); err != nil {
t.Fatalf("DeleteSession(inactive open basename): %v", err)
}
if _, ok := app.tabs["inactive"]; ok {
@@ -6554,12 +6528,10 @@ func TestRestoreSessionRejectsDestroyingSession(t *testing.T) {
if err := app.RestoreSession(trashPath); err != nil {
t.Fatalf("RestoreSession after finish: %v", err)
}
if _, err := os.Stat(sessionPath); err != nil {
t.Fatalf("session should be restored: %v", err)
}
assertLegacyLifecycle(t, app, trashPath, "active")
}
func TestDeleteSessionClearsAutoBotSessionMapping(t *testing.T) {
func TestLegacyDeleteSessionClearsAutoBotSessionMapping(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -6589,7 +6561,7 @@ func TestDeleteSessionClearsAutoBotSessionMapping(t *testing.T) {
app.setTestCtrl(ctrl, "")
defer app.activeCtrl().Close()
if err := app.DeleteSession(path); err != nil {
if err := app.deleteSession(path); err != nil {
t.Fatalf("DeleteSession: %v", err)
}
@@ -6913,39 +6885,6 @@ func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
return nil
}
func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[[plugins]]
name = "playwright"
command = "npx"
args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "playwright" {
if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
}
return
}
}
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
}
func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
isolateDesktopUserDirs(t)
reasonixHome := config.ReasonixHomeDir()
@@ -7010,6 +6949,8 @@ url = %q
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForTest(t, dir)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -7097,6 +7038,7 @@ args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -7120,6 +7062,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -7168,6 +7111,7 @@ url = "http://127.0.0.1:1/mcp"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
host := plugin.NewHost()
defer host.Close()
@@ -7206,6 +7150,7 @@ url = %q
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -7356,6 +7301,7 @@ url = %q
`, projectServer.URL), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
host := plugin.NewHost()
t.Cleanup(host.Close)
@@ -7509,6 +7455,7 @@ network = true
`, exe, singleInstanceAddr, gateConfig), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
entry := config.PluginEntry{
Name: "h", Command: exe, Args: helperArgs,
@@ -8021,7 +7968,7 @@ func TestBeginTabTurnWorkspaceRepairStaysOutsideLifecycleAdmission(t *testing.T)
case <-writerAdmissionLocked:
// The repair is still blocked on reconcileMu; acquiring the lifecycle
// writer here proves no slow repair/build I/O owns the read side.
case <-time.After(5 * time.Second):
case <-t.Context().Done():
fixture.tab.reconcileMu.Unlock()
t.Fatal("workspace repair held runtimeAdmissionMu while waiting")
}
@@ -8032,12 +7979,12 @@ func TestBeginTabTurnWorkspaceRepairStaysOutsideLifecycleAdmission(t *testing.T)
if err != nil {
t.Fatalf("beginTabTurn after workspace repair: %v", err)
}
case <-time.After(10 * time.Second):
case <-t.Context().Done():
t.Fatal("workspace repair did not complete after lifecycle writer released")
}
select {
case <-writerDone:
case <-time.After(5 * time.Second):
case <-t.Context().Done():
t.Fatal("lifecycle writer did not complete after repaired turn admission")
}
}
@@ -8327,6 +8274,7 @@ args = ["serve"]
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -8438,6 +8386,8 @@ command = "reasonix-missing-mcp-binary"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -8965,6 +8915,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -8996,6 +8948,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9026,6 +8979,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
@@ -9060,6 +9015,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
@@ -9119,6 +9076,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9196,6 +9154,7 @@ args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9234,6 +9193,8 @@ tier = "background"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9284,6 +9245,8 @@ name = "codegraph"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
reg := tool.NewRegistry()
reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
@@ -9342,6 +9305,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9435,6 +9400,8 @@ tier = "eager"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9676,21 +9643,6 @@ func startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath s
}
}
func waitNotRunning(t *testing.T, ctrl control.SessionAPI) {
t.Helper()
// Windows release runners can take more than one second to schedule the
// controller's asynchronous completion while the full desktop suite is
// active. Keep a bounded responsiveness check without treating scheduler
// delay as a leaked controller.
deadline := time.Now().Add(5 * time.Second)
for ctrl.Running() {
if time.Now().After(deadline) {
t.Fatal("controller still running")
}
time.Sleep(10 * time.Millisecond)
}
}
func newBackgroundJobController(t *testing.T, label string) *control.Controller {
t.Helper()
dir := config.SessionDir()
+23
View File
@@ -45,6 +45,29 @@ func TestDarwinICNSUsesMacOSIconSafeArea(t *testing.T) {
}
}
func TestDarwinDevelopmentIconMatchesBundleArtwork(t *testing.T) {
f, err := os.Open("build/darwin/appicon.png")
if err != nil {
t.Fatal(err)
}
defer f.Close()
dev, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
bundle := decodeICNSImage(t, "build/darwin/icon.icns", "ic10")
if dev.Bounds() != bundle.Bounds() {
t.Fatalf("development icon bounds = %v, want %v", dev.Bounds(), bundle.Bounds())
}
for y := dev.Bounds().Min.Y; y < dev.Bounds().Max.Y; y++ {
for x := dev.Bounds().Min.X; x < dev.Bounds().Max.X; x++ {
if color.NRGBAModel.Convert(dev.At(x, y)) != color.NRGBAModel.Convert(bundle.At(x, y)) {
t.Fatalf("development icon differs from bundle artwork at (%d, %d)", x, y)
}
}
}
}
func assertFullCanvasRoundedIcon(t *testing.T, img image.Image, size int) {
t.Helper()
+214 -6
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -79,6 +80,199 @@ func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
}
}
func TestReadSessionAttachmentForTabRequiresCanonicalSession(t *testing.T) {
app := &App{
tabs: map[string]*WorkspaceTab{
"a": {ID: "a", WorkspaceRoot: t.TempDir(), SessionGeneration: 1},
},
activeTabID: "a",
}
if _, err := app.ReadSessionAttachmentForTab("a", strings.Repeat("ab", 32), 0); err == nil {
t.Fatal("ReadSessionAttachmentForTab accepted a tab without a canonical session")
}
}
func TestStageImageForTabRequiresBoundController(t *testing.T) {
app := &App{
tabs: map[string]*WorkspaceTab{
"a": {ID: "a", WorkspaceRoot: t.TempDir()},
},
activeTabID: "a",
}
if _, err := app.StageImageForTab("a", "op-1", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err == nil {
t.Fatal("StageImageForTab accepted a tab without a session controller")
}
}
func TestLegacyAttachmentRPCRejectsMissingTarget(t *testing.T) {
t.Chdir(t.TempDir())
app := &App{tabs: map[string]*WorkspaceTab{}}
if _, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG); err == nil {
t.Fatal("legacy attachment RPC fell back to the process working directory")
}
if _, err := os.Stat(filepath.Join(".reasonix", "attachments")); !os.IsNotExist(err) {
t.Fatalf("missing target created a process-relative attachment directory: %v", err)
}
}
func TestGlobalAttachmentRPCUsesStableGlobalWorkspace(t *testing.T) {
isolateDesktopUserDirs(t)
launchRoot := t.TempDir()
t.Chdir(launchRoot)
app := &App{
tabs: map[string]*WorkspaceTab{"global": {ID: "global", Scope: "global"}},
activeTabID: "global",
}
rel, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(globalWorkspaceRoot(), filepath.FromSlash(rel))); err != nil {
t.Fatalf("global attachment missing from stable global workspace: %v", err)
}
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(rel))); !os.IsNotExist(err) {
t.Fatalf("global attachment was written under process cwd: %v", err)
}
}
func TestSavePastedImageForTabKeepsCapturedWorkspaceAcrossActiveSwitch(t *testing.T) {
launchRoot := t.TempDir()
projectA := t.TempDir()
projectB := t.TempDir()
t.Chdir(launchRoot)
app := &App{
tabs: map[string]*WorkspaceTab{
"a": {ID: "a", WorkspaceRoot: projectA},
"b": {ID: "b", WorkspaceRoot: projectB},
},
activeTabID: "a",
}
app.attachmentIOHook = func() {
app.mu.Lock()
app.activeTabID = "b"
app.mu.Unlock()
}
before, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
got, err := app.SavePastedImageForTab("a", "data:image/png;base64,"+desktopTinyPNG)
if err != nil {
t.Fatalf("SavePastedImageForTab: %v", err)
}
after, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if before != after {
t.Fatalf("process cwd changed from %q to %q", before, after)
}
if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
t.Fatalf("captured workspace attachment missing: %v", err)
}
if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
t.Fatalf("attachment drifted to active workspace B: %v", err)
}
}
func TestAttachmentDataURLForTabIsolatesSameRelativePath(t *testing.T) {
projectA := t.TempDir()
projectB := t.TempDir()
rel := ".reasonix/attachments/shared.png"
rawA, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
rawB := append([]byte(nil), rawA...)
rawB[len(rawB)-1] ^= 1
for root, raw := range map[string][]byte{projectA: rawA, projectB: rawB} {
path := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
}
app := &App{tabs: map[string]*WorkspaceTab{
"a": {ID: "a", WorkspaceRoot: projectA},
"b": {ID: "b", WorkspaceRoot: projectB},
}}
one, err := app.AttachmentDataURLForTab("a", rel)
if err != nil {
t.Fatal(err)
}
two, err := app.AttachmentDataURLForTab("b", rel)
if err != nil {
t.Fatal(err)
}
if one == two {
t.Fatal("same relative attachment path resolved to the same workspace bytes")
}
}
func TestSavePastedImageForTabRejectsClosedTargetAndRemovesWrite(t *testing.T) {
projectRoot := t.TempDir()
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: projectRoot}
app := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
var once sync.Once
app.attachmentIOHook = func() {
once.Do(func() {
app.mu.Lock()
delete(app.tabs, "a")
app.mu.Unlock()
})
}
if _, err := app.SavePastedImageForTab("a", "data:image/png;base64,"+desktopTinyPNG); err == nil {
t.Fatal("closed attachment target was accepted")
}
entries, err := os.ReadDir(filepath.Join(projectRoot, ".reasonix", "attachments"))
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("rejected attachment write left files: %v", entries)
}
}
func TestAttachmentDataURLForTabRejectsReplacedRuntime(t *testing.T) {
projectRoot := t.TempDir()
rel, err := control.SaveImageBytesInRoot(projectRoot, "image/png", mustDecodeBase64(t, desktopTinyPNG))
if err != nil {
t.Fatal(err)
}
first := control.New(control.Options{WorkspaceRoot: projectRoot})
second := control.New(control.Options{WorkspaceRoot: projectRoot})
t.Cleanup(first.Close)
t.Cleanup(second.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: projectRoot, Ctrl: first}
app := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
var once sync.Once
app.attachmentIOHook = func() {
once.Do(func() {
app.mu.Lock()
tab.Ctrl = second
app.mu.Unlock()
})
}
if _, err := app.AttachmentDataURLForTab("a", rel); err == nil {
t.Fatal("preview from replaced runtime was accepted")
}
}
func mustDecodeBase64(t *testing.T, value string) []byte {
t.Helper()
raw, err := base64.StdEncoding.DecodeString(value)
if err != nil {
t.Fatal(err)
}
return raw
}
func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
isolateDesktopUserDirs(t)
orig, _ := os.Getwd()
@@ -218,8 +412,12 @@ func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: cwd}},
activeTabID: "project",
}
got, err := (&App{}).AttachDropped(target)
got, err := app.AttachDropped(target)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
@@ -228,7 +426,7 @@ func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
}
}
func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
func TestAttachDroppedOutsideWorkspaceReferencesFile(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
@@ -241,13 +439,19 @@ func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
c := control.New(control.Options{WorkspaceRoot: root})
t.Cleanup(c.Close)
app := &App{
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: root, Ctrl: c}},
activeTabID: "project",
}
got, err := (&App{}).AttachDropped(outside)
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
t.Fatalf("got %+v, want copied pdf attachment", got)
if got.Kind != "workspace" || !filepath.IsAbs(got.Path) || got.DisplayPath == "" {
t.Fatalf("got %+v, want exact file reference", got)
}
}
@@ -264,8 +468,12 @@ func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o644); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: cwd}},
activeTabID: "project",
}
got, err := (&App{}).AttachDropped(filepath.Join(cwd, "shot.png"))
got, err := app.AttachDropped(filepath.Join(cwd, "shot.png"))
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
@@ -0,0 +1,56 @@
package main
import (
"encoding/base64"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
func TestStaleAttachmentCleanupDoesNotFollowSwappedDirectory(t *testing.T) {
a, root, _ := uploadFixture(t)
outside := t.TempDir()
attachments := filepath.Join(root, ".reasonix", "attachments")
want := []byte("external victim must survive stale attachment cleanup")
var victim string
a.attachmentIOHook = func() {
entries, err := os.ReadDir(attachments)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].IsDir() {
t.Fatalf("saved attachment entries = %v", entries)
}
victim = filepath.Join(outside, entries[0].Name())
if err := os.WriteFile(victim, want, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Rename(attachments, filepath.Join(root, ".reasonix", "original-attachments")); err != nil {
t.Fatal(err)
}
if runtime.GOOS == "windows" {
if out, err := exec.Command("cmd", "/c", "mklink", "/J", attachments, outside).CombinedOutput(); err != nil {
t.Fatalf("create attachment junction: %v: %s", err, out)
}
} else if err := os.Symlink(outside, attachments); err != nil {
t.Fatal(err)
}
a.mu.Lock()
a.tabs["a"].SessionGeneration++
a.mu.Unlock()
}
data := "data:application/octet-stream;base64," + base64.StdEncoding.EncodeToString([]byte("saved APK fixture"))
if ref, err := a.SavePastedFileForTab("a", "app.apk", data); err == nil || ref != "" {
t.Fatalf("stale write returned ref %q, err %v", ref, err)
}
got, err := os.ReadFile(victim)
if err != nil {
t.Fatalf("stale-owner cleanup deleted the external victim through the swapped attachment directory: %v", err)
}
if string(got) != string(want) {
t.Fatalf("external victim changed: %q", got)
}
}
+356
View File
@@ -0,0 +1,356 @@
package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"sync"
"reasonix/internal/attachment"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/session"
"reasonix/internal/sessioninbox"
)
type ComposerTarget struct {
Kind string `json:"kind"`
DraftID string `json:"draftId,omitempty"`
TabID string `json:"tabId,omitempty"`
Session *session.SessionRef `json:"session,omitempty"`
Generation uint64 `json:"generation,omitempty"`
}
type attachmentTargetState struct {
// Test hook after I/O and before target revalidation; set before use.
attachmentIOHook func()
attachmentJoinHook func()
attachmentTargetsMu sync.Mutex
attachmentTargets map[string]attachmentTarget
attachmentStageOps map[string]*attachmentStageOperation
}
type attachmentStageOperation struct {
fingerprint string
done chan struct{}
view DraftImageView
err error
}
func (a *App) releaseAttachmentStageOperations(ownerPrefix string) {
a.attachmentTargetsMu.Lock()
defer a.attachmentTargetsMu.Unlock()
for key, op := range a.attachmentStageOps {
if strings.HasPrefix(key, ownerPrefix) {
// In-flight operations retain their result until completion so existing
// waiters can observe the owner cancellation. Completed receipts can be
// dropped immediately with their credential family.
select {
case <-op.done:
delete(a.attachmentStageOps, key)
default:
}
}
}
}
type AttachmentTargetView struct {
Token string `json:"token"`
Capabilities []string `json:"capabilities"`
}
func (a *App) attachmentContext() context.Context {
if a.ctx != nil {
return a.ctx
}
return context.Background()
}
func (a *App) attachmentOperationContext(target attachmentTarget) context.Context {
if target.ctx != nil {
return target.ctx
}
return a.attachmentContext()
}
// CaptureAttachmentTarget must run before browser file reads or hashing.
// The opaque token carries no client-selected filesystem authority.
func (a *App) CaptureAttachmentTarget(composer ComposerTarget) (AttachmentTargetView, error) {
empty := AttachmentTargetView{Capabilities: []string{}}
target, err := a.attachmentTargetForComposerTarget(composer)
if err != nil {
return empty, err
}
if target.draftID == "" {
if _, ok := target.ctrl.(*control.Controller); !ok {
return empty, fmt.Errorf("unsupported: attachments-v2")
}
}
var id [24]byte
if _, err := rand.Read(id[:]); err != nil {
return empty, err
}
token := hex.EncodeToString(id[:])
a.attachmentTargetsMu.Lock()
defer a.attachmentTargetsMu.Unlock()
if a.attachmentTargets == nil {
a.attachmentTargets = make(map[string]attachmentTarget)
}
for key, previous := range a.attachmentTargets {
if !a.attachmentTargetCurrent(previous) {
if previous.cancel != nil {
previous.cancel()
}
delete(a.attachmentTargets, key)
}
}
if len(a.attachmentTargets) >= 256 {
return empty, fmt.Errorf("too many outstanding attachment operations")
}
if controller, ok := target.ctrl.(*control.Controller); ok {
target.ctx, target.cancel = controller.NewAttachmentOperationContext(a.attachmentContext())
} else {
target.ctx, target.cancel = context.WithCancel(a.attachmentContext())
}
a.attachmentTargets[token] = target
return AttachmentTargetView{Token: token, Capabilities: []string{"attachments-v2"}}, nil
}
func (a *App) attachmentTargetToken(token string) (attachmentTarget, error) {
a.attachmentTargetsMu.Lock()
target, ok := a.attachmentTargets[token]
a.attachmentTargetsMu.Unlock()
if !ok || !a.attachmentTargetCurrent(target) {
return attachmentTarget{}, fmt.Errorf("attachment target changed; please retry")
}
return target, nil
}
func (a *App) ReleaseAttachmentTarget(token string) {
a.attachmentTargetsMu.Lock()
if target, ok := a.attachmentTargets[token]; ok && target.cancel != nil {
target.cancel()
}
delete(a.attachmentTargets, token)
a.attachmentTargetsMu.Unlock()
}
func (a *App) StageImageForTarget(token, operationID, displayName, mime, dataURL string) (DraftImageView, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return DraftImageView{}, err
}
operationID = strings.TrimSpace(operationID)
if operationID == "" {
return DraftImageView{}, fmt.Errorf("attachment operationId is required")
}
fingerprint, err := stageImageFingerprint(displayName, mime, dataURL)
if err != nil {
return DraftImageView{}, err
}
key := target.ownerIdentity + "\x00" + operationID
a.attachmentTargetsMu.Lock()
if a.attachmentStageOps == nil {
a.attachmentStageOps = make(map[string]*attachmentStageOperation)
}
if existing := a.attachmentStageOps[key]; existing != nil {
if existing.fingerprint != fingerprint {
a.attachmentTargetsMu.Unlock()
return DraftImageView{}, fmt.Errorf("attachment operation conflicts with different input")
}
done := existing.done
a.attachmentTargetsMu.Unlock()
if a.attachmentJoinHook != nil {
a.attachmentJoinHook()
}
select {
case <-done:
return existing.view, existing.err
case <-a.attachmentOperationContext(target).Done():
return DraftImageView{}, a.attachmentOperationContext(target).Err()
}
}
count := 0
for existingKey := range a.attachmentStageOps {
if strings.HasPrefix(existingKey, target.ownerIdentity+"\x00") {
count++
}
}
if count >= 256 {
a.attachmentTargetsMu.Unlock()
return DraftImageView{}, fmt.Errorf("too many outstanding attachment operations")
}
op := &attachmentStageOperation{fingerprint: fingerprint, done: make(chan struct{})}
a.attachmentStageOps[key] = op
a.attachmentTargetsMu.Unlock()
if target.draftID != "" {
rel, stageErr := control.SaveImageDataURLInRoot(target.root, dataURL)
if stageErr == nil {
op.view = DraftImageView{Path: rel, DisplayName: attachment.NormalizeDisplayName(displayName), MIME: strings.ToLower(strings.TrimSpace(mime))}
}
op.err = stageErr
} else {
op.view, op.err = a.stageImageForTarget(target, displayName, mime, dataURL)
}
invalidReason := a.attachmentTargetInvalidReason(target)
ownerCurrent := invalidReason == ""
if op.err == nil && !ownerCurrent {
op.view = DraftImageView{}
op.err = fmt.Errorf("attachment target changed (%s); please retry", invalidReason)
}
a.attachmentTargetsMu.Lock()
close(op.done)
if !ownerCurrent {
delete(a.attachmentStageOps, key)
}
a.attachmentTargetsMu.Unlock()
return op.view, op.err
}
func stageImageFingerprint(displayName, mime, dataURL string) (string, error) {
const marker = ";base64,"
before, encoded, ok := strings.Cut(dataURL, marker)
if !ok || !strings.HasPrefix(before, "data:") {
return "", fmt.Errorf("unsupported pasted image")
}
raw, err := decodeBase64(encoded)
if err != nil {
return "", err
}
digest := sha256.New()
_, _ = digest.Write(raw)
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(strings.ToLower(strings.TrimSpace(mime))))
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(attachment.NormalizeDisplayName(displayName)))
return hex.EncodeToString(digest.Sum(nil)), nil
}
func (a *App) ReadDraftImageForTarget(token, draftID string) (string, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return "", err
}
if target.draftID != "" {
return control.ImageDataURLInRoot(target.root, draftID)
}
return a.readDraftImageForTarget(target, draftID)
}
func (a *App) SavePastedFileForTarget(token, name, dataURL string) (string, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return "", err
}
rel, err := control.SaveAttachmentDataURLInRoot(target.root, name, dataURL)
return a.finishAttachmentWrite(target, rel, err)
}
func (a *App) SaveClipboardImageForTarget(token string) (string, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return "", err
}
rel, err := control.SaveClipboardImageInRoot(target.root)
return a.finishAttachmentWrite(target, rel, err)
}
func (a *App) AttachmentDataURLForTarget(token, path string) (string, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return "", err
}
return a.attachmentDataURLForTarget(target, path)
}
func (a *App) AttachDroppedForTarget(token, path string) (DroppedItem, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return DroppedItem{}, err
}
return a.attachDroppedForTarget(target, path)
}
func (a *App) RebindDraftImageForTarget(token, draftID string) (DraftImageView, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return DraftImageView{}, err
}
c, ok := target.ctrl.(*control.Controller)
if !ok {
return DraftImageView{}, fmt.Errorf("unsupported: draft attachment rebind")
}
draft, err := c.RebindDraftImage(a.attachmentOperationContext(target), draftID)
if err != nil {
return DraftImageView{}, err
}
if !a.attachmentTargetCurrent(target) {
return DraftImageView{}, fmt.Errorf("attachment target changed; please retry")
}
return draftImageView(draft), nil
}
func (a *App) ReleaseDraftImageForTarget(token, draftID string) error {
target, err := a.attachmentTargetToken(token)
if err != nil {
return err
}
if c, ok := target.ctrl.(*control.Controller); ok {
c.ReleaseDraftImage(draftID)
}
a.attachmentTargetsMu.Lock()
for key, op := range a.attachmentStageOps {
if strings.HasPrefix(key, target.ownerIdentity+"\x00") && op.view.DraftID == draftID {
delete(a.attachmentStageOps, key)
}
}
a.attachmentTargetsMu.Unlock()
return nil
}
// StartTurnForAttachmentTarget shares controller admission for all structured
// sources. beginTabTurn holds the runtime replacement barrier during admission.
func (a *App) StartTurnForAttachmentTarget(token, submissionID string, req control.SubmissionRequest) (TurnStartView, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return TurnStartView{}, err
}
if submissionID == "" {
return TurnStartView{}, fmt.Errorf("submissionId is required")
}
req.ID = submissionID
c, ok := target.ctrl.(*control.Controller)
if !ok {
return TurnStartView{}, fmt.Errorf("attachment target is not a session")
}
if receipt, found, err := c.LookupSubmissionContext(a.attachmentOperationContext(target), req); found || err != nil {
return TurnStartView{TurnID: receipt.TurnID, SubmissionID: submissionID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted}, err
}
return a.startModelApplicationTurn(target.tabID, submissionID, req, nil, &target)
}
func (a *App) EnqueueForAttachmentTarget(token, submissionID, input, display string, invocations []control.InvocationRequest, attachments []control.SubmissionAttachment) (InboxReceiptView, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return InboxReceiptView{}, err
}
a.runtimeAdmissionMu.RLock()
defer a.runtimeAdmissionMu.RUnlock()
if !a.attachmentTargetCurrent(target) {
return InboxReceiptView{}, fmt.Errorf("attachment target changed; please retry")
}
c, ok := target.ctrl.(*control.Controller)
if !ok {
return InboxReceiptView{}, fmt.Errorf("attachment target is not a session")
}
receipt, err := c.TryEnqueueFollowupContext(a.attachmentOperationContext(target), control.InboxRequest{Intent: sessioninbox.IntentFollowup, Display: display, Raw: input, Submit: input, Source: "desktop", Idempotency: submissionID, Invocations: invocations, Attachments: attachments})
if err != nil {
return InboxReceiptView{}, err
}
a.emitInboxChanged(target.tabID)
return InboxReceiptView{ItemID: receipt.ItemID, Disposition: string(receipt.Disposition), Paused: receipt.Paused}, nil
}
+238
View File
@@ -0,0 +1,238 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/control"
)
func TestAttachmentTargetRejectsReplacementBeforeFileUpload(t *testing.T) {
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root})
t.Cleanup(c.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
a.mu.Lock()
tab.SessionGeneration++
a.mu.Unlock()
if _, err := a.StageImageForTarget(target.Token, "paste", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err == nil {
t.Fatal("stale target accepted")
}
}
func TestAttachmentTargetKeepsSourceAcrossFocusSwitch(t *testing.T) {
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root})
t.Cleanup(c.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab, "b": {ID: "b", WorkspaceRoot: t.TempDir()}}, activeTabID: "a"}
before, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
a.attachmentIOHook = func() { a.mu.Lock(); a.activeTabID = "b"; a.mu.Unlock() }
draft, err := a.StageImageForTarget(target.Token, "paste", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
if _, err := a.ReadDraftImageForTarget(target.Token, draft.DraftID); err != nil {
t.Fatal(err)
}
if after, err := os.Getwd(); err != nil || before != after {
t.Fatalf("cwd changed: %q %q %v", before, after, err)
}
}
func TestAttachmentDraftSurvivesSameSessionRuntimeReplacement(t *testing.T) {
root := t.TempDir()
opts := control.Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl")}
old := control.New(opts)
t.Cleanup(old.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: old, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
draft, err := a.StageImageForTarget(target.Token, "paste", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
next := control.New(opts)
t.Cleanup(next.Close)
if err := activateReplacementController(old, next); err != nil {
t.Fatal(err)
}
a.mu.Lock()
tab.Ctrl = next
a.mu.Unlock()
a.ReleaseAttachmentTarget(target.Token)
fresh, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(fresh.Token) })
retried, err := a.StageImageForTarget(fresh.Token, "paste", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil || retried.DraftID != draft.DraftID {
t.Fatalf("stage receipt did not survive replacement: %+v %v", retried, err)
}
rebound, err := a.RebindDraftImageForTarget(fresh.Token, draft.DraftID)
if err != nil {
t.Fatal(err)
}
if rebound.DraftID == draft.DraftID {
t.Fatal("runtime replacement did not renew draft credential")
}
again, err := a.RebindDraftImageForTarget(fresh.Token, draft.DraftID)
if err != nil || again.DraftID != rebound.DraftID {
t.Fatalf("rebind retry changed credential: %+v %v", again, err)
}
if next.RuntimeStatus().Running {
t.Fatal("rebind automatically started a turn")
}
}
func TestStageImageForTargetIsIdempotentAndRejectsConflicts(t *testing.T) {
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl")})
t.Cleanup(c.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
first, err := a.StageImageForTarget(target.Token, "same-operation", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
retry, err := a.StageImageForTarget(target.Token, "same-operation", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil || retry != first {
t.Fatalf("retry = %+v, %v; want %+v", retry, err, first)
}
if _, err := a.StageImageForTarget(target.Token, "same-operation", "different.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err == nil || !strings.Contains(err.Error(), "conflicts") {
t.Fatalf("different input conflict = %v", err)
}
if err := a.ReleaseDraftImageForTarget(target.Token, first.DraftID); err != nil {
t.Fatal(err)
}
afterRelease, err := a.StageImageForTarget(target.Token, "same-operation", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
if afterRelease.DraftID == first.DraftID {
t.Fatal("released credential retained its staging receipt")
}
}
func TestStageImageForTargetConcurrentRetrySharesOneOperation(t *testing.T) {
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl")})
t.Cleanup(c.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
ioEntered := make(chan struct{})
joinEntered := make(chan struct{})
releaseIO := make(chan struct{})
var ioOnce, joinOnce sync.Once
a.attachmentIOHook = func() {
ioOnce.Do(func() { close(ioEntered) })
<-releaseIO
}
a.attachmentJoinHook = func() { joinOnce.Do(func() { close(joinEntered) }) }
type result struct {
view DraftImageView
err error
}
firstDone := make(chan result, 1)
secondDone := make(chan result, 1)
go func() {
view, err := a.StageImageForTarget(target.Token, "concurrent", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
firstDone <- result{view: view, err: err}
}()
select {
case <-ioEntered:
case <-time.After(5 * time.Second):
t.Fatal("first stage did not reach the I/O barrier")
}
go func() {
view, err := a.StageImageForTarget(target.Token, "concurrent", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
secondDone <- result{view: view, err: err}
}()
select {
case <-joinEntered:
case <-time.After(5 * time.Second):
t.Fatal("retry did not join the in-flight operation")
}
close(releaseIO)
first, second := <-firstDone, <-secondDone
if first.err != nil || second.err != nil || first.view != second.view {
t.Fatalf("concurrent results = %+v / %+v", first, second)
}
}
func TestStageImageForTargetBoundsOwnerOperations(t *testing.T) {
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl")})
t.Cleanup(c.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
for i := range 256 {
if _, err := a.StageImageForTarget(target.Token, fmt.Sprintf("operation-%d", i), "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err != nil {
t.Fatalf("operation %d: %v", i, err)
}
}
if _, err := a.StageImageForTarget(target.Token, "operation-overflow", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err == nil || !strings.Contains(err.Error(), "too many") {
t.Fatalf("overflow = %v", err)
}
}
func TestAttachmentTargetCancellationIsIndependent(t *testing.T) {
c := control.New(control.Options{WorkspaceRoot: t.TempDir()})
t.Cleanup(c.Close)
a := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: t.TempDir(), Ctrl: c}}, activeTabID: "a"}
one, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
two, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(two.Token) })
a.ReleaseAttachmentTarget(one.Token)
if _, err := a.StageImageForTarget(one.Token, "one", "a.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err == nil {
t.Fatal("canceled target accepted")
}
if _, err := a.StageImageForTarget(two.Token, "two", "a.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err != nil {
t.Fatal(err)
}
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"reasonix/internal/config"
"reasonix/internal/control"
)
const attachmentUploadPath = "/__reasonix_attachment_upload"
func (a *App) attachmentUploadMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != attachmentUploadPath {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.Header.Get("Content-Type") != "application/octet-stream" {
http.Error(w, "unsupported upload type", http.StatusUnsupportedMediaType)
return
}
target, err := a.attachmentTargetToken(r.URL.Query().Get("target"))
if err != nil {
http.Error(w, "attachment target is unavailable", http.StatusConflict)
return
}
limit, err := config.LoadAttachmentStreamLimit()
if err != nil {
http.Error(w, "attachment upload configuration could not be read", http.StatusInternalServerError)
return
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
stop := context.AfterFunc(a.attachmentOperationContext(target), func() { cancel(); _ = r.Body.Close() })
defer stop()
rel, err := control.SaveAttachmentStreamInRoot(ctx, target.root, r.URL.Query().Get("name"), r.Body, limit)
rel, err = a.finishAttachmentWrite(target, rel, err)
if err != nil {
if !a.attachmentTargetCurrent(target) {
http.Error(w, "attachment upload could not be saved", http.StatusConflict)
return
}
var limitErr *control.AttachmentStreamLimitError
if errors.As(err, &limitErr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestEntityTooLarge)
_ = json.NewEncoder(w).Encode(struct {
Code string `json:"code"`
Message string `json:"message"`
LimitBytes int64 `json:"limit_bytes"`
LimitSource string `json:"limit_source"`
}{"attachment.too_large", limitErr.Error(), limitErr.LimitBytes, limitErr.LimitSource})
return
}
http.Error(w, "attachment upload could not be saved", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Path string `json:"path"`
}{Path: rel})
})
}
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/config"
)
func TestAttachmentUploadEnforcesUserStreamLimitAndCleansPartialFile(t *testing.T) {
for _, tc := range []struct {
name, user, project, body string
length int64
status int
}{
{"exact", "4", "", "1234", 0, http.StatusOK},
{"plus one", "4", "", "12345", 0, http.StatusRequestEntityTooLarge},
{"unknown length", "4", "", "12345", -1, http.StatusRequestEntityTooLarge},
{"misleading length", "4", "", "12345", 1, http.StatusRequestEntityTooLarge},
{"project cannot disable", "4", "0", "12345", -1, http.StatusRequestEntityTooLarge},
{"user unlimited", "0", "1", "123456", -1, http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
app, root, token := uploadFixture(t)
if err := os.WriteFile(config.UserConfigPath(), []byte("[attachments]\nmax_stream_bytes = "+tc.user+"\n"), 0600); err != nil {
t.Fatal(err)
}
if tc.project != "" {
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[attachments]\nmax_stream_bytes = "+tc.project+"\n"), 0600); err != nil {
t.Fatal(err)
}
}
req := uploadRequest(token, strings.NewReader(tc.body))
if tc.length != 0 {
req.ContentLength = tc.length
}
recorder := httptest.NewRecorder()
app.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, req)
if recorder.Code != tc.status {
t.Fatalf("status=%d want=%d body=%s", recorder.Code, tc.status, recorder.Body)
}
if tc.status != http.StatusRequestEntityTooLarge {
return
}
var result struct {
Code string `json:"code"`
Message string `json:"message"`
LimitBytes int64 `json:"limit_bytes"`
LimitSource string `json:"limit_source"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Code != "attachment.too_large" || result.Message == "" || result.LimitBytes != 4 || result.LimitSource != "user" {
t.Fatalf("untyped limit error: %+v", result)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
if err != nil || len(entries) != 0 {
t.Fatalf("partial files=%v err=%v", entries, err)
}
})
}
}
func TestAttachmentUploadRejectsUnreadableUserLimitBeforeCreatingFile(t *testing.T) {
app, root, token := uploadFixture(t)
if err := os.WriteFile(config.UserConfigPath(), []byte("[attachments]\nmax_stream_bytes = [broken"), 0600); err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
app.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, strings.NewReader("body")))
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("configuration failure accepted upload: %d %s", recorder.Code, recorder.Body)
}
if _, err := os.Stat(filepath.Join(root, ".reasonix", "attachments")); !os.IsNotExist(err) {
t.Fatalf("configuration failure created attachment files: %v", err)
}
}
type uploadLimitMutationReader struct {
body *strings.Reader
mutate func()
}
func (reader *uploadLimitMutationReader) Read(p []byte) (int, error) {
if reader.mutate != nil {
reader.mutate()
reader.mutate = nil
}
return reader.body.Read(p)
}
func TestAttachmentUploadRetiredOwnerWinsOverStreamLimit(t *testing.T) {
app, root, token := uploadFixture(t)
if err := os.WriteFile(config.UserConfigPath(), []byte("[attachments]\nmax_stream_bytes = 4\n"), 0600); err != nil {
t.Fatal(err)
}
reader := &uploadLimitMutationReader{body: strings.NewReader("12345"), mutate: func() { app.tabs["a"].SessionGeneration++ }}
recorder := httptest.NewRecorder()
app.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, reader))
if recorder.Code != http.StatusConflict {
t.Fatalf("retired owner error=%d body=%s", recorder.Code, recorder.Body)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
if err != nil || len(entries) != 0 {
t.Fatalf("partial files=%v err=%v", entries, err)
}
}
+241
View File
@@ -0,0 +1,241 @@
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/control"
)
func uploadFixture(t *testing.T) (*App, string, string) {
t.Helper()
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
if err := os.WriteFile(filepath.Join(home, "config.toml"), nil, 0600); err != nil {
t.Fatal(err)
}
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root})
t.Cleanup(c.Close)
a := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
return a, root, target.Token
}
type uploadZeros struct{}
func (uploadZeros) Read(p []byte) (int, error) {
clear(p)
return len(p), nil
}
func uploadRequest(token string, body io.Reader) *http.Request {
query := url.Values{"target": {token}, "name": {"large app.apk"}}
r := httptest.NewRequest(http.MethodPost, attachmentUploadPath+"?"+query.Encode(), body)
r.Header.Set("Content-Type", "application/octet-stream")
r.Header.Set("Authorization", "Bearer test-secret")
return r
}
func TestAttachmentUploadLargeFileKeepsCapturedWorkspace(t *testing.T) {
a, root, token := uploadFixture(t)
other := t.TempDir()
a.tabs["b"] = &WorkspaceTab{ID: "b", WorkspaceRoot: other}
a.activeTabID = "b"
const size = 80 << 20
recorder := httptest.NewRecorder()
handler := bearerAuth("test-secret", a.attachmentUploadMiddleware()(http.NotFoundHandler()))
handler.ServeHTTP(recorder, uploadRequest(token, io.LimitReader(uploadZeros{}, size)))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body)
}
var result struct {
Path string `json:"path"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(result.Path)))
if err != nil || info.Size() != size {
t.Fatalf("file=%v err=%v", info, err)
}
if !strings.HasSuffix(result.Path, ".apk") {
t.Fatalf("path=%s", result.Path)
}
if _, err := os.Stat(filepath.Join(other, filepath.FromSlash(result.Path))); !os.IsNotExist(err) {
t.Fatalf("wrote into focused tab: %v", err)
}
}
func TestAttachmentUploadRejectsUntrustedAndStaleRequests(t *testing.T) {
a, root, token := uploadFixture(t)
handler := bearerAuth("test-secret", a.attachmentUploadMiddleware()(http.NotFoundHandler()))
for _, scenario := range []string{"no auth", "bad type", "bad method", "unknown target", "released target"} {
t.Run(scenario, func(t *testing.T) {
r := uploadRequest(token, strings.NewReader("body"))
want := http.StatusConflict
switch scenario {
case "no auth":
r.Header.Del("Authorization")
want = http.StatusUnauthorized
case "bad type":
r.Header.Set("Content-Type", "text/plain")
want = http.StatusUnsupportedMediaType
case "bad method":
r.Method = http.MethodGet
want = http.StatusMethodNotAllowed
case "unknown target":
r.URL.RawQuery = "target=not-a-token"
case "released target":
a.ReleaseAttachmentTarget(token)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, r)
if recorder.Code != want {
t.Fatalf("status=%d want=%d", recorder.Code, want)
}
})
}
if _, err := os.Stat(filepath.Join(root, ".reasonix", "attachments")); !os.IsNotExist(err) {
t.Fatalf("rejected upload created files: %v", err)
}
}
func TestAttachmentUploadRemovesFileWhenTargetChangesBeforeCommit(t *testing.T) {
a, root, token := uploadFixture(t)
a.attachmentIOHook = func() { a.tabs["a"].SessionGeneration++ }
recorder := httptest.NewRecorder()
a.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, strings.NewReader("partial")))
if recorder.Code != http.StatusConflict {
t.Fatalf("status=%d", recorder.Code)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
if err != nil || len(entries) != 0 {
t.Fatalf("left partial files: %v err=%v", entries, err)
}
}
func TestAttachmentUploadCancellationClosesBlockedBodyAndCleansFile(t *testing.T) {
a, root, token := uploadFixture(t)
reader, writer := io.Pipe()
defer writer.Close()
done := make(chan struct{})
go func() {
defer close(done)
a.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(httptest.NewRecorder(), uploadRequest(token, reader))
}()
if _, err := writer.Write(make([]byte, 64<<10)); err != nil {
t.Fatal(err)
}
a.ReleaseAttachmentTarget(token)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("cancelled upload kept reading its body")
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
if err != nil || len(entries) != 0 {
t.Fatalf("files=%v err=%v", entries, err)
}
}
func TestAttachmentUploadLateCancellationRemovesCompletedFile(t *testing.T) {
a, root, token := uploadFixture(t)
a.attachmentIOHook = func() { a.ReleaseAttachmentTarget(token) }
recorder := httptest.NewRecorder()
a.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, strings.NewReader("complete but cancelled")))
if recorder.Code != http.StatusConflict {
t.Fatalf("status=%d", recorder.Code)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
if err != nil || len(entries) != 0 {
t.Fatalf("files=%v err=%v", entries, err)
}
}
func TestDroppedLargeExternalFileRemainsReference(t *testing.T) {
a, root, token := uploadFixture(t)
path := filepath.Join(t.TempDir(), "large app.apk")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(120 << 20); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
item, err := a.AttachDroppedForTarget(token, path)
if err != nil {
t.Fatal(err)
}
if item.Kind != "workspace" || !filepath.IsAbs(item.Path) || item.IsDir {
t.Fatalf("item=%+v", item)
}
if _, err := os.Stat(filepath.Join(root, ".reasonix", "attachments")); !os.IsNotExist(err) {
t.Fatalf("native file was copied: %v", err)
}
item2, err := a.AttachDroppedForTarget(token, item.DisplayPath)
if err != nil || item2.Path != item.Path {
t.Fatalf("restored=%+v err=%v", item2, err)
}
}
func TestDraftExternalFileReferenceEscapesSpacesAndPreservesOtherPaths(t *testing.T) {
a, _, _ := uploadFixture(t)
path := filepath.Join(t.TempDir(), "large app.apk")
if err := os.WriteFile(path, []byte("PK\x00binary"), 0o600); err != nil {
t.Fatal(err)
}
other := "@" + control.EscapeRefPath(path+".backup")
request := SessionDraftSubmissionRequest{Input: "inspect @" + control.EscapeRefPath(path) + " " + other, WorkspaceRefs: []DraftWorkspaceRef{{Path: path, DisplayPath: path}}}
if err := a.resolveDraftExternalRefs("a", &request); err != nil {
t.Fatal(err)
}
canonical, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
want := "inspect @" + control.EscapeRefPath(filepath.ToSlash(canonical)) + " " + other
if request.Input != want {
t.Fatalf("input=%q want=%q", request.Input, want)
}
}
func TestDraftNativeFileReferenceMatchesSessionReference(t *testing.T) {
a, root, _ := uploadFixture(t)
sourceRoot := t.TempDir()
canonicalPath := filepath.Join(sourceRoot, "large app.apk")
if err := os.WriteFile(canonicalPath, []byte("APK"), 0o600); err != nil {
t.Fatal(err)
}
path := canonicalPath
alias := filepath.Join(t.TempDir(), "parent alias")
if err := os.Symlink(sourceRoot, alias); err == nil {
path = filepath.Join(alias, filepath.Base(canonicalPath))
}
item, err := a.attachDroppedForTarget(attachmentTarget{root: root, draftID: "draft"}, path)
if err != nil {
t.Fatal(err)
}
restored, err := a.AttachDroppedForTab("a", item.DisplayPath)
if err != nil {
t.Fatal(err)
}
if item.Path != restored.Path || item.Path != filepath.ToSlash(item.Path) {
t.Fatalf("draft=%+v session=%+v", item, restored)
}
}
+534
View File
@@ -0,0 +1,534 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/attachment"
"reasonix/internal/control"
)
type attachmentTarget struct {
tab *WorkspaceTab
tabID string
draftID string
draftGeneration uint64
root string
generation uint64
ctrl control.SessionAPI
ctx context.Context
cancel context.CancelFunc
ownerIdentity string
ownerBound bool
runtimeEpoch string
}
func (a *App) attachmentTargetForComposerTarget(composer ComposerTarget) (attachmentTarget, error) {
if composer.Kind != "draft" {
target, err := a.attachmentTargetForTab(composer.TabID)
if err != nil || composer.Session == nil {
return target, err
}
if err := validateLocalSessionRef(*composer.Session); err != nil {
return attachmentTarget{}, err
}
a.mu.RLock()
matches := target.tab != nil && target.tab.SessionID == composer.Session.SessionID
a.mu.RUnlock()
if !matches || !a.attachmentTargetCurrent(target) {
return attachmentTarget{}, fmt.Errorf("attachment session changed; please retry")
}
return target, nil
}
record, err := a.draftStore().Get(a.bootContext(), strings.TrimSpace(composer.DraftID))
if err != nil {
return attachmentTarget{}, err
}
root := record.WorkspaceRoot
if record.Scope != "project" {
root = globalWorkspaceRoot()
}
absRoot, err := filepath.Abs(root)
if err != nil {
return attachmentTarget{}, err
}
return attachmentTarget{
draftID: record.ID, draftGeneration: composer.Generation, root: absRoot,
ownerIdentity: fmt.Sprintf("draft:%s:%d", record.ID, composer.Generation),
}, nil
}
func attachmentWorkspaceRoot(tab *WorkspaceTab) (string, error) {
root := strings.TrimSpace(tab.WorkspaceRoot)
if root == "" {
if tab.Scope == "project" {
return "", fmt.Errorf("attachment workspace is not ready")
}
root = globalWorkspaceRoot()
}
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
return absRoot, nil
}
func (a *App) attachmentTargetForTab(tabID string) (attachmentTarget, error) {
tab, _ := a.tabAndCtrlByID(tabID)
if tab == nil {
return attachmentTarget{}, fmt.Errorf("attachment workspace is no longer available")
}
a.reconcileTabWithPinnedSessionMeta(tab)
a.mu.RLock()
defer a.mu.RUnlock()
if a.tabs[tab.ID] != tab || tab.removed {
return attachmentTarget{}, fmt.Errorf("attachment workspace is no longer available")
}
root, err := attachmentWorkspaceRoot(tab)
if err != nil {
return attachmentTarget{}, err
}
target := attachmentTarget{
tab: tab, tabID: tab.ID, root: root, generation: tab.SessionGeneration, ctrl: tab.Ctrl,
runtimeEpoch: a.runtimeEpochForTabLocked(tab),
}
if c, ok := tab.Ctrl.(*control.Controller); ok {
target.ownerIdentity = c.AttachmentOwnerIdentity()
target.ownerBound = strings.Trim(target.ownerIdentity, "\x00") != ""
}
if !target.ownerBound {
target.ownerIdentity = fmt.Sprintf("tab:%s:%d:%s", tab.ID, tab.SessionGeneration, target.runtimeEpoch)
}
return target, nil
}
func (a *App) attachmentTargetCurrent(target attachmentTarget) bool {
return a.attachmentTargetInvalidReason(target) == ""
}
func (a *App) attachmentTargetInvalidReason(target attachmentTarget) string {
if target.ctx != nil && target.ctx.Err() != nil {
return "attachment operation cancelled"
}
if target.tab == nil {
if target.draftID == "" {
return "missing owner"
}
record, err := a.draftStore().Get(a.bootContext(), target.draftID)
if err != nil || record.Status != "active" {
return "draft is no longer active"
}
root := record.WorkspaceRoot
if record.Scope != "project" {
root = globalWorkspaceRoot()
}
absRoot, err := filepath.Abs(root)
if err != nil || !sameProjectRoot(absRoot, target.root) {
return "workspace changed"
}
return ""
}
a.mu.RLock()
defer a.mu.RUnlock()
tab := a.tabs[target.tabID]
if tab != target.tab || tab.removed {
return "tab closed"
}
if tab.SessionGeneration != target.generation {
return "session generation changed"
}
if tab.Ctrl != target.ctrl {
return "runtime changed"
}
if a.runtimeEpochForTabLocked(tab) != target.runtimeEpoch {
return "runtime epoch changed"
}
if c, ok := tab.Ctrl.(*control.Controller); ok && target.ownerBound && c.AttachmentOwnerIdentity() != target.ownerIdentity {
return "attachment owner changed"
}
root, err := attachmentWorkspaceRoot(tab)
if err != nil || !sameProjectRoot(root, target.root) {
return "workspace changed"
}
return ""
}
func (a *App) finishAttachmentWrite(target attachmentTarget, rel string, err error) (string, error) {
if err != nil {
return "", err
}
if a.attachmentIOHook != nil {
a.attachmentIOHook()
}
if a.attachmentTargetCurrent(target) {
return rel, nil
}
if isAttachmentRefPath(rel) {
_ = control.RemoveStoredAttachment(target.root, rel)
}
return "", fmt.Errorf("attachment workspace changed while saving; please retry")
}
func isAttachmentRefPath(path string) bool {
path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
return strings.HasPrefix(path, ".reasonix/attachments/")
}
// SavePastedImage stores a browser clipboard image data URL under the active
// tab's workspace .reasonix/attachments and returns the relative @-reference path.
func (a *App) SavePastedImage(dataURL string) (string, error) {
target, err := a.attachmentTargetForTab("")
if err != nil {
return "", err
}
rel, saveErr := control.SaveImageDataURLInRoot(target.root, dataURL)
return a.finishAttachmentWrite(target, rel, saveErr)
}
func (a *App) SavePastedImageForTab(tabID, dataURL string) (string, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return "", err
}
rel, saveErr := control.SaveImageDataURLInRoot(target.root, dataURL)
return a.finishAttachmentWrite(target, rel, saveErr)
}
// SaveClipboardImage reads the native OS clipboard image under the active tab's
// workspace .reasonix/attachments and returns the relative @-reference path.
func (a *App) SaveClipboardImage() (string, error) {
target, err := a.attachmentTargetForTab("")
if err != nil {
return "", err
}
rel, saveErr := control.SaveClipboardImageInRoot(target.root)
return a.finishAttachmentWrite(target, rel, saveErr)
}
func (a *App) SaveClipboardImageForTab(tabID string) (string, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return "", err
}
rel, saveErr := control.SaveClipboardImageInRoot(target.root)
return a.finishAttachmentWrite(target, rel, saveErr)
}
// SavePastedFile stores a dropped non-image file (the browser exposes its bytes
// as a data URL but not a real path) under the active tab's workspace
// .reasonix/attachments and returns the relative @-reference path.
func (a *App) SavePastedFile(name, dataURL string) (string, error) {
target, err := a.attachmentTargetForTab("")
if err != nil {
return "", err
}
rel, saveErr := control.SaveAttachmentDataURLInRoot(target.root, name, dataURL)
return a.finishAttachmentWrite(target, rel, saveErr)
}
func (a *App) SavePastedFileForTab(tabID, name, dataURL string) (string, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return "", err
}
rel, saveErr := control.SaveAttachmentDataURLInRoot(target.root, name, dataURL)
return a.finishAttachmentWrite(target, rel, saveErr)
}
// AttachmentDataURL returns a safe data URL for a stored image attachment.
func (a *App) AttachmentDataURL(path string) (string, error) {
target, err := a.attachmentTargetForTab("")
if err != nil {
return "", err
}
return a.attachmentDataURLForTarget(target, path)
}
func (a *App) AttachmentDataURLForTab(tabID, path string) (string, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return "", err
}
return a.attachmentDataURLForTarget(target, path)
}
func (a *App) attachmentDataURLForTarget(target attachmentTarget, path string) (string, error) {
dataURL, readErr := control.ImageDataURLInRoot(target.root, path)
if readErr != nil {
return "", readErr
}
if a.attachmentIOHook != nil {
a.attachmentIOHook()
}
if !a.attachmentTargetCurrent(target) {
return "", fmt.Errorf("attachment workspace changed while reading; please retry")
}
return dataURL, nil
}
// DroppedItem is one OS-dropped file resolved into a composer context entry.
type DroppedItem struct {
Kind string `json:"kind"`
Path string `json:"path"`
IsDir bool `json:"isDir,omitempty"`
DisplayPath string `json:"displayPath,omitempty"`
PreviewURL string `json:"previewUrl,omitempty"`
}
const attachmentsCapabilityV1 = "attachments-v1"
type DraftImageView struct {
DraftID string `json:"draftId"`
Path string `json:"path,omitempty"`
DisplayName string `json:"displayName"`
MIME string `json:"mime"`
Width int `json:"width"`
Height int `json:"height"`
Bytes int64 `json:"bytes"`
}
func (a *App) StageImageForTab(tabID, operationID, displayName, mime, dataURL string) (DraftImageView, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return DraftImageView{}, err
}
return a.stageImageForTarget(target, displayName, mime, dataURL)
}
func (a *App) stageImageForTarget(target attachmentTarget, displayName, mime, dataURL string) (DraftImageView, error) {
identified, ok := target.ctrl.(*control.Controller)
if !ok {
return DraftImageView{}, fmt.Errorf("unsupported: %s", attachmentsCapabilityV1)
}
draft, err := identified.StageImage(a.attachmentOperationContext(target), displayName, mime, dataURL)
if a.attachmentIOHook != nil {
a.attachmentIOHook()
}
if !a.attachmentTargetCurrent(target) {
identified.ReleaseDraftImage(draft.ID)
return DraftImageView{}, fmt.Errorf("attachment workspace changed while saving; please retry")
}
if err != nil {
return DraftImageView{}, err
}
return draftImageView(draft), nil
}
func (a *App) ReadDraftImageForTab(tabID, draftID string) (string, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return "", err
}
return a.readDraftImageForTarget(target, draftID)
}
func (a *App) readDraftImageForTarget(target attachmentTarget, draftID string) (string, error) {
identified, ok := target.ctrl.(*control.Controller)
if !ok {
return "", fmt.Errorf("unsupported: %s", attachmentsCapabilityV1)
}
draft, raw, err := identified.ReadDraftImage(a.attachmentOperationContext(target), draftID)
if a.attachmentIOHook != nil {
a.attachmentIOHook()
}
if !a.attachmentTargetCurrent(target) {
return "", fmt.Errorf("attachment workspace changed while reading; please retry")
}
if err != nil {
return "", err
}
return attachment.DataURL(draft.MIME, raw), nil
}
func (a *App) ReadSessionAttachmentForTab(tabID, digest string, offset int64) (SessionHistoryContentChunk, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return SessionHistoryContentChunk{}, err
}
ctx := a.attachmentContext()
var cancel context.CancelFunc
if controller, ok := target.ctrl.(*control.Controller); ok {
ctx, cancel = controller.NewAttachmentOperationContext(ctx)
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel()
target.ctx = ctx
if err := ctx.Err(); err != nil {
return SessionHistoryContentChunk{}, err
}
query, sessionRef, err := a.canonicalSessionQuery(tabID)
if err != nil {
return SessionHistoryContentChunk{}, err
}
if !a.attachmentTargetCurrent(target) {
return SessionHistoryContentChunk{}, fmt.Errorf("attachment workspace changed while reading; please retry")
}
data, total, err := query.ReadSessionAttachment(ctx, sessionRef, digest, offset, sessionHistoryContentChunkBytes)
if a.attachmentIOHook != nil {
a.attachmentIOHook()
}
if !a.attachmentTargetCurrent(target) {
return SessionHistoryContentChunk{}, fmt.Errorf("attachment workspace changed while reading; please retry")
}
if err != nil {
return SessionHistoryContentChunk{}, err
}
next := offset + int64(len(data))
return SessionHistoryContentChunk{
Data: base64.StdEncoding.EncodeToString(data),
NextOffset: next,
Done: next >= total,
}, nil
}
func (a *App) ReleaseDraftImageForTab(tabID, draftID string) error {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return err
}
identified, ok := target.ctrl.(*control.Controller)
if !ok {
return fmt.Errorf("unsupported: %s", attachmentsCapabilityV1)
}
identified.ReleaseDraftImage(draftID)
return nil
}
func draftImageView(draft attachment.DraftCredential) DraftImageView {
return DraftImageView{
DraftID: draft.ID,
DisplayName: draft.DisplayName,
MIME: draft.MIME,
Width: draft.Width,
Height: draft.Height,
Bytes: draft.Bytes,
}
}
func (a *App) AttachDropped(path string) (DroppedItem, error) {
return a.attachDroppedForTab("", path)
}
func (a *App) AttachDroppedForTab(tabID, path string) (DroppedItem, error) {
return a.attachDroppedForTab(tabID, path)
}
func (a *App) attachDroppedForTab(tabID, path string) (DroppedItem, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return DroppedItem{}, err
}
return a.attachDroppedForTarget(target, path)
}
func (a *App) attachDroppedForTarget(target attachmentTarget, path string) (DroppedItem, error) {
info, err := os.Lstat(path)
if err != nil {
return DroppedItem{}, err
}
if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) {
return DroppedItem{}, fmt.Errorf("dropped path must be a regular file or directory")
}
if isImageExt(path) {
if rel, saveErr := control.SaveImageFileInRoot(target.root, path); saveErr == nil {
rel, saveErr = a.finishAttachmentWrite(target, rel, nil)
if saveErr != nil {
return DroppedItem{}, saveErr
}
preview, previewErr := a.attachmentDataURLForTarget(target, rel)
if previewErr != nil {
_ = control.RemoveStoredAttachment(target.root, rel)
return DroppedItem{}, previewErr
}
return DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}, nil
} else {
return DroppedItem{}, saveErr
}
}
if rel, ok := workspaceRelativeIn(path, target.root); ok {
if !a.attachmentTargetCurrent(target) {
return DroppedItem{}, fmt.Errorf("attachment workspace changed while reading; please retry")
}
return DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}, nil
}
if target.draftID != "" {
abs, err := filepath.Abs(path)
if err != nil {
return DroppedItem{}, err
}
if !info.IsDir() {
abs, err = filepath.EvalSymlinks(abs)
if err != nil {
return DroppedItem{}, err
}
abs = filepath.ToSlash(abs)
}
return DroppedItem{Kind: "workspace", Path: abs, IsDir: info.IsDir(), DisplayPath: filepath.ToSlash(abs)}, nil
}
if info.IsDir() {
if target.tab == nil || target.ctrl == nil {
return DroppedItem{}, fmt.Errorf("workspace is not ready")
}
if err := a.ensureTabControllerWorkspace(target.tab); err != nil {
return DroppedItem{}, err
}
refreshed, err := a.attachmentTargetForTab(target.tabID)
if err != nil || refreshed.tab != target.tab || refreshed.generation != target.generation ||
!sameProjectRoot(refreshed.root, target.root) {
return DroppedItem{}, fmt.Errorf("attachment workspace is no longer available")
}
target = refreshed
ctrl := target.ctrl
if ctrl == nil || !a.attachmentTargetCurrent(target) {
return DroppedItem{}, fmt.Errorf("attachment workspace is no longer available")
}
token, displayPath, err := ctrl.RegisterExternalFolderRef(path)
if err != nil {
return DroppedItem{}, err
}
return DroppedItem{Kind: "workspace", Path: token, IsDir: true, DisplayPath: displayPath}, nil
}
if target.ctrl == nil || !a.attachmentTargetCurrent(target) {
return DroppedItem{}, fmt.Errorf("workspace is not ready")
}
token, displayPath, err := target.ctrl.RegisterExternalFileRef(path)
if err != nil {
return DroppedItem{}, err
}
if !a.attachmentTargetCurrent(target) {
return DroppedItem{}, fmt.Errorf("attachment workspace changed while reading; please retry")
}
return DroppedItem{Kind: "workspace", Path: token, DisplayPath: displayPath}, nil
}
func isImageExt(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp":
return true
}
return false
}
func workspaceRelativeIn(path, workspaceRoot string) (string, bool) {
root := workspaceRoot
if !filepath.IsAbs(root) {
abs, err := filepath.Abs(root)
if err != nil {
return "", false
}
root = abs
}
rel, err := filepath.Rel(root, path)
if err != nil {
return "", false
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", false
}
return filepath.ToSlash(rel), true
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"fmt"
"reasonix/internal/control"
)
// RetryAuthenticationForTab authorizes one explicit request attempt for a
// credential that the current controller generation previously rejected. It
// never replays a failed turn.
func (a *App) RetryAuthenticationForTab(tabID string) (control.AuthenticationState, error) {
a.runtimeRebuildMu.Lock()
tab, ctrl := a.tabAndCtrlByID(tabID)
defer func() {
a.runtimeRebuildMu.Unlock()
a.refreshTabMetaExtras(tab)
}()
if tab == nil {
return control.AuthenticationState{}, fmt.Errorf("session is unavailable")
}
tab.turnStartMu.Lock()
defer tab.turnStartMu.Unlock()
if a.tabIsReadOnly(tab) {
return control.AuthenticationState{}, readOnlyChannelErr()
}
if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil {
return control.AuthenticationState{}, err
}
retry, ok := ctrl.(interface {
AuthenticationState() control.AuthenticationState
RetryAuthentication() bool
})
if !ok {
return control.AuthenticationState{}, fmt.Errorf("authentication retry is unavailable for this session")
}
state := retry.AuthenticationState()
if !retry.RetryAuthentication() {
return state, fmt.Errorf("authentication retry is only available after a rejected credential")
}
return retry.AuthenticationState(), nil
}
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"reasonix/internal/control"
)
type desktopAuthenticationRunner struct{ calls atomic.Int32 }
func (r *desktopAuthenticationRunner) Run(context.Context, string) error {
r.calls.Add(1)
return nil
}
func newDesktopAuthenticationTab(t *testing.T, app *App, id string, state control.AuthenticationState) (*WorkspaceTab, *control.Controller, *desktopAuthenticationRunner) {
t.Helper()
runner := &desktopAuthenticationRunner{}
ctrl := control.New(control.Options{Runner: runner, Authentication: state})
t.Cleanup(ctrl.Close)
tab := &WorkspaceTab{ID: id, Ctrl: ctrl, Ready: true, Scope: "global"}
app.tabs[id] = tab
return tab, ctrl, runner
}
func TestDesktopAuthenticationAdmissionDoesNotCreateTurn(t *testing.T) {
app := NewApp()
tab, ctrl, runner := newDesktopAuthenticationTab(t, app, "missing", control.AuthenticationState{
Status: control.AuthenticationMissingCredential, ProviderName: "relay", ModelRef: "relay/chat",
})
app.activeTabID = tab.ID
err := app.SubmitToTab(tab.ID, "must remain a draft")
var authErr *control.AuthenticationError
if !errors.As(err, &authErr) || authErr.State.Status != control.AuthenticationMissingCredential {
t.Fatalf("SubmitToTab error = %v, want missing-credential AuthenticationError", err)
}
if runner.calls.Load() != 0 || ctrl.Turn() != 0 || len(ctrl.History()) != 0 {
t.Fatalf("blocked submit changed runtime: calls=%d turn=%d history=%d", runner.calls.Load(), ctrl.Turn(), len(ctrl.History()))
}
}
func TestRetryAuthenticationForTabIsSingleUseAndTabLocal(t *testing.T) {
app := NewApp()
first, firstCtrl, _ := newDesktopAuthenticationTab(t, app, "first", control.AuthenticationState{
Status: control.AuthenticationRejected, ProviderName: "first-provider", HTTPStatus: 401,
})
second, secondCtrl, _ := newDesktopAuthenticationTab(t, app, "second", control.AuthenticationState{
Status: control.AuthenticationRejected, ProviderName: "second-provider", HTTPStatus: 403,
})
app.activeTabID = second.ID
state, err := app.RetryAuthenticationForTab(first.ID)
if err != nil || !state.Ready() {
t.Fatalf("first retry = %+v, %v", state, err)
}
if _, err := app.RetryAuthenticationForTab(first.ID); err == nil {
t.Fatal("second retry was accepted without another rejection")
}
if got := firstCtrl.AuthenticationState(); !got.Ready() {
t.Fatalf("first tab state = %+v, want ready for one attempt", got)
}
if got := secondCtrl.AuthenticationState(); got.Status != control.AuthenticationRejected || got.ProviderName != "second-provider" {
t.Fatalf("retry leaked into second tab: %+v", got)
}
}
func TestRetryAuthenticationRejectsMissingCredential(t *testing.T) {
app := NewApp()
tab, _, _ := newDesktopAuthenticationTab(t, app, "missing", control.AuthenticationState{Status: control.AuthenticationMissingCredential})
if _, err := app.RetryAuthenticationForTab(tab.ID); err == nil {
t.Fatal("missing credential incorrectly accepted authentication retry")
}
}
func TestRetryAuthenticationPublishesAuthoritativeTabRefresh(t *testing.T) {
app := NewApp()
app.ctx = context.Background()
tab, _, _ := newDesktopAuthenticationTab(t, app, "retry-refresh", control.AuthenticationState{Status: control.AuthenticationRejected, ProviderName: "relay", HTTPStatus: 401})
app.activeTabID = tab.ID
installNoopRuntimeEvents(app)
refreshed := make(chan bool, 1)
app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
if name != tabMetaRefreshEventChannel {
return
}
for _, item := range app.ListTabs() {
if item.ID == tab.ID && item.Authentication != nil && item.Authentication.Ready() {
// 非阻塞发送:该回调跑在共享的 asyncRuntimeEmitter.run 上,
// 阻塞发送会把后续所有 runtime 事件永久卡死。
select {
case refreshed <- true:
default:
}
return
}
}
}
if _, err := app.RetryAuthenticationForTab(tab.ID); err != nil {
t.Fatal(err)
}
select {
case <-refreshed:
case <-time.After(5 * time.Second):
t.Fatal("retry did not publish a refresh of authoritative authentication state")
}
// 回归:缓冲已满时再次触发同一事件,emitter 泵必须继续排空而不是停在
// tabs.go 的 emit 调用点上(曾实证 chan send 卡死 7 分钟)。
for range 3 {
app.runtimeEvents.Emit(app.ctx, tabMetaRefreshEventChannel)
}
deadline := time.Now().Add(5 * time.Second)
for {
app.runtimeEvents.mu.Lock()
busy := app.runtimeEvents.running
app.runtimeEvents.mu.Unlock()
if !busy {
return
}
if time.Now().After(deadline) {
t.Fatal("asyncRuntimeEmitter pump stayed blocked after repeated refresh events")
}
time.Sleep(10 * time.Millisecond)
}
}
@@ -0,0 +1,58 @@
package main
import (
"context"
"os"
"reasonix/internal/config"
"testing"
)
func TestSavedCredentialAllowsAdmissionThroughPendingMetadata(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(config.ReasonixHomeDir(), 0700); err != nil {
t.Fatal(err)
}
body := "default_model='relay/chat'\n[[providers]]\nname='relay'\nkind='openai'\nbase_url='https://relay.invalid/v1'\nmodel='chat'\napi_key_env='MISSING_REVIEW_KEY'\n[desktop]\nprovider_access=['relay']\n"
if err := os.WriteFile(config.UserConfigPath(), []byte(body), 0600); err != nil {
t.Fatal(err)
}
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
t.Cleanup(app.closeSessionServices)
tab := modelSettingsBootTab(t, app, "review", t.TempDir(), "relay/chat")
original := tab.Ctrl
t.Cleanup(original.Close)
app.activeTabID = tab.ID
before := app.tabMeta(tab, true)
if before.Authentication == nil || before.Authentication.Ready() {
t.Fatal("expected missing-key fixture")
}
result, err := config.CommitConnectionCredential(config.ConnectionCredentialRequest{RequestID: "review-save", ConfigPath: config.UserConfigPath(), ProviderNames: []string{"relay"}, Key: "fixture-key"})
if err != nil || !result.Persisted {
t.Fatalf("save: %+v %v", result, err)
}
app.refreshTabMetaExtras(tab)
application := app.GetModelSettingsApplication()
meta := app.tabMeta(tab, true)
if application.Application != "pending" || meta.Authentication == nil || meta.Authentication.Ready() {
t.Fatalf("unexpected state: application=%s auth=%+v", application.Application, meta.Authentication)
}
if !meta.ModelSettingsPending {
t.Fatal("saved settings did not expose pending admission")
}
if len(tab.Ctrl.History()) != len(original.History()) {
t.Fatal("metadata refresh created a turn")
}
admission, _, err := app.beginTabTurn(tab.ID, false)
if err != nil {
t.Fatal(err)
}
admission.abort()
if fresh := app.tabMeta(tab, true); fresh.Authentication == nil || !fresh.Authentication.Ready() {
t.Fatalf("backend admission did not recover: %+v", fresh.Authentication)
}
if fresh := app.tabMeta(tab, true); fresh.ModelSettingsPending {
t.Fatal("replacement inherited stale pending metadata")
}
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"context"
"testing"
"time"
"reasonix/internal/control"
"reasonix/internal/event"
)
func TestDetachedAskPublishesGlobalPendingInteraction(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
path := writeTopicSessionWithPrompt(t, dir, "a.jsonl", "topic-a", "Conversation A", "", "fixture", time.Now())
a := NewApp()
a.ctx = context.Background()
pending := make(chan RuntimeSessionState, 1)
a.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
if name != "runtime-state:changed" {
return
}
for _, session := range payload[0].(RuntimeStateProjection).Sessions {
if !session.Open && len(session.State.Interactions) > 0 {
select {
case pending <- session:
default:
}
}
}
}
sink := &tabEventSink{tabID: "A", app: a, ctx: a.ctx}
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Sink: sink})
defer ctrl.Close()
tab := &WorkspaceTab{ID: "A", Scope: "global", TopicID: "topic-a", TopicTitle: "Conversation A", SessionPath: path, Ctrl: ctrl, sink: sink, Ready: true}
a.tabs[tab.ID] = tab
a.activeTabID = tab.ID
ctrl.Submit("fixture turn")
<-runner.started
if !a.detachRuntimeForReplacement(tab) {
t.Fatal("could not detach running A")
}
a.mu.Lock()
delete(a.tabs, "A")
a.tabs["B"] = &WorkspaceTab{ID: "B"}
a.activeTabID = "B"
a.mu.Unlock()
if sink.context() != nil {
t.Fatal("detached transcript must stay disconnected")
}
ctx, cancel := context.WithCancel(t.Context())
done := make(chan struct{})
go func() {
defer close(done)
_, _ = ctrl.Ask(ctx, []event.AskQuestion{{ID: "choice", Prompt: "Choose"}})
}()
defer func() {
cancel()
<-done
close(runner.release)
waitNotRunning(t, ctrl)
a.mu.RLock()
detached := a.detachedSessions[sessionRuntimeKey(path)]
a.mu.RUnlock()
a.quiesceTabAutosave(detached)
}()
select {
case session := <-pending:
prompt := session.State.Interactions[0]
if session.TabID == "B" || session.SessionPath != path || prompt.Kind != "ask" || prompt.RequestID == "" || prompt.TurnID == "" {
t.Fatalf("wrong background prompt identity: %+v", session)
}
case <-time.After(3 * time.Second):
t.Fatal("background Ask was not pushed without reopening A")
}
a.mu.RLock()
active := a.activeTabID
a.mu.RUnlock()
if active != "B" {
t.Fatalf("background Ask changed active tab to %q", active)
}
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"fmt"
"strings"
"reasonix/internal/config"
"reasonix/internal/control"
)
// alignReusableBlankTabModel makes a reused empty session obey the same
// provider/model default as a newly-created session. Ready runtimes use the
// normal failure-atomic model switch. A tab that is still starting has no
// controller to swap, so invalidate its startup generation, update the empty
// session's model metadata, and restart the build from the intended provider.
func (a *App) alignReusableBlankTabModel(tab *WorkspaceTab, model string) error {
model = strings.TrimSpace(model)
if tab == nil || model == "" {
return nil
}
a.mu.RLock()
if tab.removed || a.tabs[tab.ID] != tab {
a.mu.RUnlock()
return fmt.Errorf("blank session changed while applying the default model; retry")
}
currentModel := strings.TrimSpace(tab.model)
ctrl := tab.Ctrl
buildDone := tab.buildDone
a.mu.RUnlock()
if ctrl != nil {
if currentModel != model {
if err := a.SetModelForTab(tab.ID, model); err != nil {
return err
}
}
return a.ensureReusableBlankIdentity(tab)
}
if currentModel == model && buildDone != nil {
// Reusing an in-flight blank must reuse its pending create, too. Cancelling
// an identical build can leave durable content awaiting registry attach;
// startup recovery would later publish that as a second blank session.
select {
case <-buildDone:
case <-a.bootContext().Done():
return a.bootContext().Err()
}
if err := a.ensureReusableBlankIdentity(tab); err != nil {
return err
}
a.mu.RLock()
ready := !tab.removed && a.tabs[tab.ID] == tab && tab.Ctrl != nil && tab.SessionID != ""
startupErr := tab.StartupErr
a.mu.RUnlock()
if !ready {
return fmt.Errorf("create session runtime: %s", startupErr)
}
return nil
}
// Fence the old build before publishing its replacement model. Legacy
// sources stay read-only; the replacement commits the selected model as a
// session/config event when it publishes its canonical identity.
a.mu.Lock()
if tab.removed || a.tabs[tab.ID] != tab {
a.mu.Unlock()
return fmt.Errorf("blank session changed while applying the default model; retry")
}
if tab.Ctrl != nil {
a.mu.Unlock()
return a.alignReusableBlankTabModel(tab, model)
}
a.supersedeTabBuildLocked(tab)
tab.effort = config.RebindSessionEffort(nil, tab.model, model, tab.effort)
tab.model = model
tab.Label = model
tab.Ready = false
clearTabStartupError(tab)
a.saveTabsLocked()
a.mu.Unlock()
a.buildTabController(tab)
if err := a.ensureReusableBlankIdentity(tab); err != nil {
return err
}
a.mu.RLock()
ready := tab.Ctrl != nil && tab.SessionID != ""
startupErr := tab.StartupErr
a.mu.RUnlock()
if !ready {
return fmt.Errorf("create session runtime: %s", startupErr)
}
return nil
}
// Reusing an empty historical shell is still a New operation. It must bind a
// current-store identity rather than making the next user turn a legacy write.
func (a *App) ensureReusableBlankIdentity(tab *WorkspaceTab) error {
release := a.lockRuntimeMutation("create reusable blank session")
defer release()
tab.turnStartMu.Lock()
defer tab.turnStartMu.Unlock()
ctrl := a.controllerForTab(tab)
if ctrl == nil {
return nil // The caller reports the recorded startup failure.
}
identity, ok := ctrl.(control.IdentityLifecycle)
if !ok || identity.SessionService() == nil {
return nil
}
if _, bound := identity.SessionRef(); bound && identity.UsesExclusiveSession() && identity.SessionService() == a.desktopSessionService("") {
return nil
}
if controllerHasActiveRuntimeWork(ctrl) || messagesHaveConversationContent(ctrl.History()) {
return fmt.Errorf("blank session changed before creation; retry")
}
if err := ctrl.NewSession(); err != nil {
return err
}
a.syncTabSessionIdentity(tab, ctrl)
return nil
}
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"testing"
"time"
)
func TestBlankReuseWaitsForPendingCanonicalCreateInsteadOfCancellingIt(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
t.Cleanup(app.closeSessionServices)
done, cancelled := make(chan struct{}), make(chan struct{}, 1)
tab := &WorkspaceTab{ID: "starting-blank", Scope: "global", model: "fixture/model", buildDone: done,
buildGeneration: 1, buildDoneGen: 1, buildCancel: func() { cancelled <- struct{}{} }}
app.tabs[tab.ID], app.tabOrder, app.activeTabID = tab, []string{tab.ID}, tab.ID
result := make(chan error, 1)
go func() { result <- app.alignReusableBlankTabModel(tab, "fixture/model") }()
// Keep the original publisher at the durable-content -> registry boundary.
// It must finish before reuse can return; cancellation here reproduces the
// pending create that used to become an extra empty row after restart.
select {
case <-cancelled:
t.Error("reuse cancelled an identical in-flight canonical create")
case err := <-result:
t.Fatalf("reuse returned before its original publisher completed: %v", err)
case <-time.After(100 * time.Millisecond):
}
app.mu.Lock()
tab.Ctrl, tab.SessionID, tab.Ready = &activationStubController{}, "original-pending-session", true
if tab.buildDone == done {
close(done)
tab.buildDone = nil
}
app.mu.Unlock()
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("reuse did not resume after canonical publication")
}
if tab.SessionID != "original-pending-session" || tab.buildGeneration != 1 {
t.Fatalf("reuse replaced the original create: session=%s generation=%d", tab.SessionID, tab.buildGeneration)
}
}
+19 -4
View File
@@ -64,6 +64,10 @@ type botBridgeHub struct {
lastPersistSeq uint64
queue chan desktopBridgeNotification
// closed 终止 run worker;queue 本身不能 close——observe 仍可能在
// controller 事件 goroutine 上并发 enqueue,向已关闭 channel 发送会 panic。
closed chan struct{}
closeOnce sync.Once
}
type desktopPendingPrompt struct {
@@ -135,11 +139,17 @@ func newBotBridgeHub(deps botBridgeDeps) *botBridgeHub {
takeovers: make(map[string]bot.DesktopWatchRoute),
takeoverTabs: make(map[string]string),
queue: make(chan desktopBridgeNotification, botBridgeQueueSize),
closed: make(chan struct{}),
}
go h.run()
return h
}
// Close 停掉通知 worker goroutine。幂等;App 关停时调用。
func (h *botBridgeHub) Close() {
h.closeOnce.Do(func() { close(h.closed) })
}
// observe 接收某个桌面会话的一条事件。在 controller 事件 goroutine 上运行,
// 只做内存记账和入队,不做任何阻塞调用。
func (h *botBridgeHub) observe(tabID string, e event.Event) {
@@ -154,7 +164,7 @@ func (h *botBridgeHub) observe(tabID string, e event.Event) {
})
watching := len(h.watchers) > 0
h.mu.Unlock()
if watching {
if watching && !e.Replayed {
h.enqueue(h.approvalNotification(tabID, e.Approval))
}
case event.AskRequest:
@@ -166,7 +176,7 @@ func (h *botBridgeHub) observe(tabID string, e event.Event) {
})
watching := len(h.watchers) > 0
h.mu.Unlock()
if watching {
if watching && !e.Replayed {
h.enqueue(h.askNotification(tabID, e.Ask))
}
case event.TurnDone:
@@ -209,8 +219,13 @@ func (h *botBridgeHub) enqueue(n desktopBridgeNotification) {
}
func (h *botBridgeHub) run() {
for n := range h.queue {
h.deliver(n)
for {
select {
case <-h.closed:
return
case n := <-h.queue:
h.deliver(n)
}
}
}
+10 -1
View File
@@ -29,6 +29,12 @@ func (a *App) newBotBridge() *botBridgeHub {
})
}
func (a *App) stopBotBridge() {
if a.botBridge != nil {
a.botBridge.Close()
}
}
// bridgeSessions 枚举所有 live 会话:可见 tab 用完整 TabMeta,后台 detached
// 会话补一份轻量快照(controller 仍存活,审批/问答仍可路由)。
func (a *App) bridgeSessions() []bot.DesktopSessionInfo {
@@ -138,7 +144,10 @@ func (a *App) bridgeDrive(tabID, text string, route bot.DesktopWatchRoute) error
ChatType: route.ChatType,
}
generation := tab.sink.SetBotSink(newBotEventForwarder(a.botRuntime, []botForwardTarget{target}))
a.ensureTabTopicIndexedForUserTurn(tab)
if err := a.ensureTabTopicIndexedForUserTurn(tab); err != nil {
tab.sink.clearBotSink(generation)
return err
}
ctrl.SubmitDisplay(text, text)
// Confirm the submit actually started a turn. If nothing is running now, the
// controller was rotating and the submit no-oped — detach this exact
+40
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"log/slog"
"runtime"
"strings"
"testing"
"time"
@@ -129,6 +130,28 @@ func testGroupRoute() bot.DesktopWatchRoute {
return r
}
// newBotBridgeHub 启动一个 run worker;Close 必须让它退出,否则每个 App 实例
// 都泄漏一个 goroutine(转储曾观测到上千个)。enqueue 在 Close 后仍不得 panic:
// observe 可能在 controller 事件 goroutine 上与关停并发。
func TestBridgeCloseStopsRunGoroutine(t *testing.T) {
before := runtime.NumGoroutine()
env := newBridgeTestEnv(nil)
env.hub.SetWatch(testWatchRoute(), true)
env.hub.Close()
env.hub.Close() // 幂等
// 有订阅者时 observe 会真正 enqueue;Close 后不得 panic。
env.hub.observe("tab-1", event.Event{Kind: event.TurnDone})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= before {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("hub run goroutine still alive after Close: %d goroutines > baseline %d", runtime.NumGoroutine(), before)
}
func TestBridgeTakeoverRejectsGroupChat(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
if _, err := env.hub.Takeover(testGroupRoute(), "tab-1"); err == nil {
@@ -306,6 +329,23 @@ func TestBridgeApprovalNotifiesWatchersAndRoutesApproval(t *testing.T) {
}
}
// The desktop replays a waiting prompt whenever it reconciles a tab. A replay
// rebuilds a card; it is not a new request, so a watcher is pinged once (#9156).
func TestBridgeReplayedApprovalPushesOnce(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话"}})
env.hub.SetWatch(testWatchRoute(), true)
approval := event.Approval{ID: "appr-9", Tool: "bash", Subject: "go test ./..."}
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: approval})
env.waitNotification(t)
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: approval, Replayed: true})
env.expectNoNotification(t)
if _, err := env.hub.Approve("appr-9", true); err != nil {
t.Fatalf("Approve after a replay: %v", err)
}
}
func TestBridgePendingRecordedWithoutWatchers(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话"}})
+2 -2
View File
@@ -177,7 +177,7 @@ func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult,
Access: botInstallAccess(result.UserID),
Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
}, func(c *config.Config) {
c.Bot.Enabled = true
c.Bot.Enabled = c.Bot.Enabled || config.BotInstallMayEnableGateway(c)
c.Bot.Weixin.Enabled = true
c.Bot.Weixin.AccountID = result.AccountID
c.Bot.Weixin.APIBase = result.BaseURL
@@ -589,7 +589,7 @@ func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallS
Access: botInstallAccess(userID),
Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
}, func(c *config.Config) {
c.Bot.Enabled = true
c.Bot.Enabled = c.Bot.Enabled || config.BotInstallMayEnableGateway(c)
c.Bot.Feishu.Enabled = true
c.Bot.Feishu.Domain = domain
c.Bot.Feishu.AppID = appID
+101 -27
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
@@ -318,33 +319,7 @@ func TestFeishuInstallStoresFeishuSecretAndSurvivesReload(t *testing.T) {
func TestWeixinInstallStoresSavedAccountAndConnection(t *testing.T) {
isolateDesktopUserDirs(t)
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ilink/bot/get_bot_qrcode":
if r.URL.Query().Get("bot_type") != "3" {
http.Error(w, "missing bot type", http.StatusBadRequest)
return
}
writeJSON(t, w, map[string]any{
"qrcode": "qr-weixin",
"qrcode_img_content": "data:image/png;base64,abc",
})
case "/ilink/bot/get_qrcode_status":
if r.URL.Query().Get("qrcode") != "qr-weixin" {
http.Error(w, "wrong qr", http.StatusBadRequest)
return
}
writeJSON(t, w, map[string]any{
"status": "confirmed",
"ilink_bot_id": "weixin-account",
"bot_token": "token-1",
"ilink_user_id": "user-1",
"baseurl": "https://ilinkai.weixin.qq.com",
})
default:
http.NotFound(w, r)
}
}))
serveWeixinInstall(t)
app := NewApp()
start, err := app.StartBotConnectionInstall("weixin", "")
@@ -391,6 +366,105 @@ func TestWeixinInstallStoresSavedAccountAndConnection(t *testing.T) {
}
}
func serveWeixinInstall(t *testing.T) {
t.Helper()
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ilink/bot/get_bot_qrcode":
if r.URL.Query().Get("bot_type") != "3" {
http.Error(w, "missing bot type", http.StatusBadRequest)
return
}
writeJSON(t, w, map[string]any{
"qrcode": "qr-weixin",
"qrcode_img_content": "data:image/png;base64,abc",
})
case "/ilink/bot/get_qrcode_status":
if r.URL.Query().Get("qrcode") != "qr-weixin" {
http.Error(w, "wrong qr", http.StatusBadRequest)
return
}
writeJSON(t, w, map[string]any{
"status": "confirmed",
"ilink_bot_id": "weixin-account",
"bot_token": "token-1",
"ilink_user_id": "user-1",
"baseurl": "https://ilinkai.weixin.qq.com",
})
default:
http.NotFound(w, r)
}
}))
}
func writeUserConfig(t *testing.T, body string) {
t.Helper()
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
func completeWeixinInstall(t *testing.T, app *App) {
t.Helper()
start, err := app.StartBotConnectionInstall("weixin", "")
if err != nil {
t.Fatalf("StartBotConnectionInstall: %v", err)
}
poll, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall: %v", err)
}
if !poll.Done {
t.Fatalf("poll result = %+v, want done", poll)
}
}
func TestWeixinInstallKeepsExplicitBotDisabled(t *testing.T) {
isolateDesktopUserDirs(t)
serveWeixinInstall(t)
writeUserConfig(t, `[bot]
enabled = false
[[bot.connections]]
id = "weixin-weixin"
provider = "weixin"
domain = "weixin"
label = "微信"
enabled = true
status = "connected"
`)
completeWeixinInstall(t, NewApp())
cfg := config.LoadForEdit(config.UserConfigPath())
if cfg.Bot.Enabled {
t.Fatal("re-scanning an existing connection re-enabled the embedded bot over an explicit enabled = false")
}
if !cfg.Bot.Weixin.Enabled || cfg.Bot.Weixin.AccountID != "weixin-account" {
t.Fatalf("saved weixin config = %+v, want refreshed credentials", cfg.Bot.Weixin)
}
if len(cfg.Bot.Allowlist.WeixinUsers) != 1 || cfg.Bot.Allowlist.WeixinUsers[0] != "user-1" {
t.Fatalf("weixin allowlist = %+v, want installer user id", cfg.Bot.Allowlist.WeixinUsers)
}
}
func TestWeixinFirstInstallEnablesBotDespiteExplicitFalse(t *testing.T) {
isolateDesktopUserDirs(t)
serveWeixinInstall(t)
writeUserConfig(t, "[bot]\nenabled = false\n")
completeWeixinInstall(t, NewApp())
cfg := config.LoadForEdit(config.UserConfigPath())
if !cfg.Bot.Enabled {
t.Fatal("first install left the embedded bot off, so connecting from the desktop does nothing")
}
}
func TestFeishuRegistrationQRCodeURLAddsSDKMetadata(t *testing.T) {
qrURL, err := feishuRegistrationQRCodeURL("https://open.larksuite.com/page/launcher?user_code=ABCD-1234&source=old")
if err != nil {
+4 -2
View File
@@ -12,6 +12,7 @@ import (
"reasonix/internal/bot"
"reasonix/internal/botruntime"
"reasonix/internal/config"
"reasonix/internal/session"
)
type BotRuntimeStatusView struct {
@@ -123,7 +124,7 @@ func (a *App) refreshBotRuntime() {
a.botBridge.seedWatchers(bridgeRoutesFromConfig(cfg.Bot.DesktopWatchers), watcherVersion)
bridge = a.botBridge
}
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge)
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge, a.historicalSessionService)
}
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
@@ -151,7 +152,7 @@ func (a *App) BotRuntimeStatus() BotRuntimeStatusView {
return a.botRuntime.snapshot()
}
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge) error {
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge, sessionServiceForRoot func(string) (*session.Service, error)) error {
if r == nil {
return nil
}
@@ -234,6 +235,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
OnToolApprovalModeChange: onToolApprovalModeChange,
Desktop: bridge,
SessionServiceForRoot: sessionServiceForRoot,
}
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
if len(bindings) == 0 {
+30 -155
View File
@@ -265,19 +265,18 @@ func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.
}
}
func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.Default()
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
// A checkout's [bot] never reaches the bot runtime or the user's config, with
// or without a user config of its own.
func TestDesktopBotRuntimeIgnoresProjectBotSettings(t *testing.T) {
for _, withUser := range []bool{false, true} {
isolateDesktopUserDirs(t)
if withUser {
if err := config.Default().SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[bot]
enabled = true
@@ -289,154 +288,30 @@ feishu_users = ["ou-legacy"]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: the merge above stays in memory and
// the user config file is not rewritten.
preWrite := config.LoadForEdit(config.UserConfigPath())
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
}
// The first locked write path performs the on-disk migration.
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
if persisted.DesktopTheme() != "dark" {
t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
t.Fatalf("write project config: %v", err)
}
t.Chdir(project)
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if got.Bot.Enabled || len(got.Bot.Connections) != 0 {
t.Fatalf("desktop bot config = %+v, want the checkout's [bot] ignored", got.Bot)
}
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
if persisted := config.LoadForEdit(config.UserConfigPath()); persisted.Bot.Enabled || len(persisted.Bot.Connections) != 0 {
t.Fatalf("user config took the checkout's [bot]: %+v", persisted.Bot)
}
}
}
func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
theme = "dark"
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: it serves the legacy config from
// memory and must not create the user config file.
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("read path must not create the user config, stat err = %v", err)
}
// The first locked write path creates the user config with the migrated
// bot settings (adopting the legacy config, ConfigVersion-bumped).
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
}
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
theme = "dark"
close_behavior = "quit"
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
settings := NewApp().Settings()
if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
}
if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
}
}
func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
func TestDesktopBotRuntimeKeepsUserBotSettingsBesideAProjectBot(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.Default()
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"fmt"
"path/filepath"
"strings"
"reasonix/internal/config"
"reasonix/internal/session"
)
const embeddedBotSessionPrefix = "bot-session:local:"
// Embedded bot conversations keep their existing project-local store. Resolve
// the sidebar's stable session reference there before creating a surface; a
// forged reference must not open a session belonging to another workspace.
func (a *App) embeddedBotSessionPath(scope, workspaceRoot, route string) (string, error) {
id := strings.TrimPrefix(route, embeddedBotSessionPrefix)
if err := session.ValidateSessionID(id); err != nil {
return "", err
}
root := globalTabWorkspaceRoot()
if scope == "project" {
root = normalizeProjectRoot(workspaceRoot)
if root == "" {
return "", fmt.Errorf("workspaceRoot is required")
}
}
storeRoot := session.RootForLegacyDir(config.ProjectSessionDir(root))
service, err := a.historicalSessionService(storeRoot)
if err != nil {
return "", err
}
info, err := service.Query().Stat(a.bootContext(), session.SessionRef{HostID: localDesktopHostID, SessionID: id})
if err != nil {
return "", err
}
same, err := sameDesktopPathStrict(info.CWD, root)
if err != nil {
return "", err
}
if !same {
return "", errSessionWorkspaceConflict
}
path := filepath.Join(storeRoot, id)
if !nativeStoredDirectory(path) {
return "", session.ErrSessionNotFound
}
return path, nil
}
+8 -1
View File
@@ -27,7 +27,7 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
{"ListDirForTab", app.ListDirForTab("missing", "")},
{"SearchFileRefsForTab", app.SearchFileRefsForTab("missing", "file")},
{"ListTabs", app.ListTabs()},
{"ListProjectTree", app.ListProjectTree()},
{"ListProjectTree", mustListProjectTree(t, app)},
{"AvailableSubagentTools", app.AvailableSubagentTools()},
{"MCPServers", app.MCPServers()},
{"Plugins", app.Plugins()},
@@ -50,6 +50,13 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
}
forkTargets, forkTargetsErr := app.ForkTargetsForTab("__missing__")
if forkTargetsErr != nil {
t.Fatalf("ForkTargetsForTab(__missing__): %v", forkTargetsErr)
}
if forkTargets.Targets == nil {
t.Fatal("ForkTargetsForTab(__missing__).Targets is nil; frontend expects []")
}
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
+72 -14
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
@@ -16,6 +17,46 @@ import (
"reasonix/internal/remote/forward"
)
func (s *brokerSessionExecutor) BrowserCapability(ctx context.Context, name string, args json.RawMessage) (json.RawMessage, error) {
res, err := s.resolve(ctx)
if err != nil {
return nil, err
}
exec, ok := res.exec.(browser.CapabilityExecutor)
if !ok {
return nil, fmt.Errorf("capability_unsupported: %s", name)
}
out, err := exec.BrowserCapability(ctx, name, args)
if err != nil {
return out, err
}
if current, err := s.resolve(ctx); err != nil || current.workspace != res.workspace {
return nil, browser.ErrUnknownOutcome
}
if name != "record" {
return out, nil
}
var result map[string]json.RawMessage
if err := json.Unmarshal(out, &result); err != nil {
return nil, err
}
var localPath, state string
_ = json.Unmarshal(result["path"], &localPath)
_ = json.Unmarshal(result["state"], &state)
delete(result, "path")
if state == "completed" && localPath != "" {
remote, err := s.relay(ctx, res.workspace, localPath)
if err != nil {
return nil, err
}
result["path"], _ = json.Marshal(remote)
}
if current, err := s.resolve(ctx); err != nil || current.workspace != res.workspace {
return nil, browser.ErrUnknownOutcome
}
return json.Marshal(result)
}
// The desktop browser broker is the local end of the remote browser channel:
// a loopback listener behind SSH reverse forwards, keyed by per-generation
// tokens that a reconnect revokes at once.
@@ -478,35 +519,51 @@ func (a *App) resolveRemoteBrowserSession(hostID, sessionPath string) (browserSe
return browserSessionResolution{}, browser.ErrNoGrant
}
a.remoteTabMu.Lock()
var tab *remoteTab
defer a.remoteTabMu.Unlock()
for _, t := range a.remoteTabs {
if t == nil || t.ref.HostID != hostID {
continue
}
t.sessionMu.Lock()
path := strings.TrimSpace(t.session.path)
t.sessionMu.Unlock()
if path != "" && path == sessionPath {
tab = t
break
// Resolve identity and mint the immutable executor in the same lock
// epoch. sessionMu serializes transitions, not reads of these fields;
// acquiring it here would invert the resume path's lock order.
if exec := a.browserExecutorForRemoteTabLocked(t, sessionPath); exec != nil {
return browserSessionResolution{exec: exec, workspace: t.ref.Workspace}, nil
}
}
}
a.remoteTabMu.Unlock()
if tab == nil {
return browserSessionResolution{}, fmt.Errorf("%w: no desktop tab serves session %s", browser.ErrNoGrant, sessionPath)
}
return browserSessionResolution{
exec: a.browserExecutorForRemoteTab(tab, sessionPath),
workspace: tab.ref.Workspace,
}, nil
return browserSessionResolution{}, fmt.Errorf("%w: no stable desktop tab serves session %s", browser.ErrNoGrant, sessionPath)
}
// browserExecutorForRemoteTab returns the cached executor for one remote
// tab's browser surface; a session rotation re-scopes the grant.
func (a *App) browserExecutorForRemoteTab(tab *remoteTab, sessionPath string) browser.Executor {
a.remoteTabMu.Lock()
defer a.remoteTabMu.Unlock()
return a.browserExecutorForRemoteTabLocked(tab, sessionPath)
}
// Caller holds remoteTabMu. A provisional foreground route is not evidence
// that Serve has transferred this session's browser ownership.
func (a *App) browserExecutorForRemoteTabLocked(tab *remoteTab, sessionPath string) browser.Executor {
if tab == nil || !a.hostMode() || a.browserControl.off() {
return nil
}
if a.remoteTabs[tab.id] != tab || tab.session.path != sessionPath || tab.routing.rehydratingPath != "" {
return nil
}
canonicalID := tab.session.sessionID
if canonicalID != "" {
if tab.routing.currentPath != remoteSessionIDRoutePrefix+canonicalID {
return nil
}
} else if tab.routing.currentPath != "" && tab.routing.currentPath != sessionPath {
// Legacy peers may only have a path; never infer an ID from UI intent.
return nil
}
diagnosticScope := browserDiagnosticScope(tab.ref.HostID, canonicalID)
key := "remote/" + tab.id
a.browserExecMu.Lock()
defer a.browserExecMu.Unlock()
@@ -514,7 +571,7 @@ func (a *App) browserExecutorForRemoteTab(tab *remoteTab, sessionPath string) br
a.browserExecutors = map[string]*hostBrowserExecutor{}
}
if exec, ok := a.browserExecutors[key]; ok {
if exec.sessionKey == sessionPath {
if exec.sessionKey == sessionPath && exec.diagnosticScope == diagnosticScope {
return exec
}
// A session rotation creates a new immutable owner; mutating the old
@@ -524,6 +581,7 @@ func (a *App) browserExecutorForRemoteTab(tab *remoteTab, sessionPath string) br
exec := &hostBrowserExecutor{
app: a, host: a.hostShell.server, tabID: tab.id,
grantID: newBrowserGrantID(), sessionKey: sessionPath,
diagnosticScope: diagnosticScope,
}
a.browserExecutors[key] = exec
return exec
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"context"
"encoding/json"
"fmt"
)
func (e *hostBrowserExecutor) rememberFilePreview(ctx context.Context, tabID string, request FileBrowserPreviewRequest) {
// Optional metadata, never an authorization or a reusable preview token.
_ = e.call(ctx, "host/browser.preview.remember", map[string]any{"tabId": tabID, "source": request.Source, "path": request.Path, "toolCallId": request.ToolCallID}, nil)
}
func (e *tabBrowserExecutor) BrowserCapability(ctx context.Context, capability string, args json.RawMessage) (json.RawMessage, error) {
current, err := e.current()
if err != nil {
return nil, err
}
return current.BrowserCapability(ctx, capability, args)
}
func (e *hostBrowserExecutor) BrowserCapability(ctx context.Context, capability string, args json.RawMessage) (json.RawMessage, error) {
var supported struct {
Capabilities map[string]bool `json:"capabilities"`
}
if err := e.call(ctx, "host/browser.capabilities", nil, &supported); err != nil {
return nil, fmt.Errorf("capability_unsupported: %s: %w", capability, err)
}
if !supported.Capabilities[capability] {
return nil, fmt.Errorf("capability_unsupported: %s", capability)
}
var params map[string]any
if err := json.Unmarshal(args, &params); err != nil {
return nil, err
}
var out json.RawMessage
var err error
action, _ := params["action"].(string)
if capability == "record" && action == "start" {
directory, err := e.captureDir()
if err != nil {
return nil, err
}
params["directory"] = directory
}
switch capability {
case "query", "wait", "diagnostics":
err = e.call(ctx, "host/browser."+capability, params, &out)
case "viewport", "pointer", "record":
if action == "get" || action == "status" {
err = e.call(ctx, "host/browser."+capability, params, &out)
} else {
id, _ := params["operationId"].(string)
tab, _ := params["tabId"].(string)
err = e.write(ctx, id, capability+":"+action, tab, args, "host/browser."+capability, params, &out)
}
default:
return nil, fmt.Errorf("capability_unsupported: %s", capability)
}
return out, err
}
Loaded 100 of 4354 files, more files were not shown because too many files have changed in this diff. Show more