Compare commits

...
803 Commits
Author SHA1 Message Date
SivanCola fda4b7a857 Merge pull request #7901 from esengine/release-notes/v1.21.2
docs(release): 准备 v1.21.2 中英更新日志
2026-08-08 01:03:23 +08:00
github-actions[bot] 030b42bf1b docs(release): prepare v1.21.2 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-08-07 17:01:23 +00:00
YHH c2526217b9 Merge pull request #7898 from esengine/fix/frontend-suite-regressions
test(frontend): pin jsdom navigator so suites pass on zh-locale machines
2026-08-08 00:59:38 +08:00
SivanCola 27af9878ac Merge pull request #7900 from esengine/release-notes/v1.21.2
docs(release): 准备 v1.21.2 中英更新日志
2026-08-08 00:59:14 +08:00
YHH 7f2e52bc48 Merge pull request #7895 from esengine/perf/ui-latency-telemetry
feat(desktop): UI latency telemetry with performance budgets
2026-08-08 00:57:59 +08:00
github-actions[bot] 13ea060d46 docs(release): prepare v1.21.2 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-08-07 16:55:06 +00:00
esengine d74c26dd50 build(frontend): run discovered suites under an English locale
Mirrors the Go convention (LANG=en_US.UTF-8 go test): Node's built-in
navigator.language follows the machine's ICU locale and suites assert
English UI strings, so zh-locale machines failed locale-sensitive
suites the en CI runners never notice.
2026-08-07 09:43:40 -07:00
esengine a055f30429 build(frontend): replace the 4KB test chain with a discovery runner
The main pnpm test script chained 110 tsx invocations in one 4KB line;
every new suite needed manual registration (and three additions this
week alone), and seven committed suites were referenced by no script at
all — silently running never. scripts/run-tests.mjs discovers
src/__tests__/*.test.ts{,x}, runs each as its own tsx process (suites
own their jsdom globals and exit codes), fails fast like the old chain
(--keep-going to sweep), and keeps suites owned by dedicated pnpm
scripts excluded by name — a stale exclusion fails the run instead of
silently dropping coverage. The seven orphaned suites all pass and are
now part of the suite. The tsx entry resolves via require.resolve so
plain node invocation works outside pnpm's PATH.
2026-08-07 09:43:40 -07:00
esengine 28fc640c6a test(frontend): UI performance scenarios beyond the normal chat answer
Six scenario specs (UI-PERF-01..06) as a shared contract: normal 2KB
answer, 16KB DeepSeek reasoning at 150 chunks/sec, 10-fence code-heavy
markdown, interaction-under-streaming, 100-turn/30-tool long session,
and a background tab streaming while another is active. The simulated
driver asserts deterministic count budgets through the real pipeline —
one reducer pass per frame, markdown parses bounded by blocks (9 parses
for an 8-paragraph answer) or capped by the tier inside fences, O(1)
per-delta cost over a 231-item transcript, and zero whole-App bumps
from background streaming. UI-PERF-04 (typing/wheel/selection while
streaming) is specced for the real-browser driver, which owns
wall-clock and input-latency budgets.
2026-08-07 09:43:39 -07:00
esengine 4a987a9cf8 feat(desktop): UI latency telemetry with performance budgets
The desktop metrics pipeline records outcome facts (finish reason,
cache-hit bucket, errors) but nothing about UI latency. lib/uiPerf adds
turn-scoped, content-free sampling of the streaming render pipeline:
bridge events/sec, state commits/sec, stream-dispatch→frame P95, frame
P95 and >33ms frame share, input latency P95 (Event Timing, feature-
detected), markdown finalize P95, long tasks, DOM size, and JS heap —
each mapped to bounded (signal, bucket) pairs and reported once per turn
through a new RecordUIPerf binding that allowlists the signal set, so
webview input can never widen telemetry cardinality. Budgets ship as
documented performance targets, not hard gates, pending calibration
against the supported machine population. Frame and dispatch sampling
runs only while a turn is active.
2026-08-07 09:43:39 -07:00
YHH 4dc05143d8 Merge pull request #7892 from esengine/perf/markdown-block-commit
perf(frontend): parse streaming Markdown per completed block, not per tick
2026-08-08 00:43:16 +08:00
esengine c9eaa93c4b test(frontend): pin jsdom navigator so suites pass on zh-locale machines
Node >= 21 ships a built-in global navigator whose language reflects the
machine's ICU locale, and these two suites never replaced it with
jsdom's en-US one — so LocaleProvider rendered 提交/取消 on a zh-CN
machine and every English-string assertion failed, while CI's en
runners stayed green. Same class of issue as the Go convention of
running internal/cli tests under LANG=en_US.UTF-8.
2026-08-07 09:42:48 -07:00
SivanCola 23f4dd6e57 Merge pull request #7896 from esengine/release-notes/v1.21.2
docs(release): 准备 v1.21.2 中英更新日志
2026-08-08 00:35:00 +08:00
SivanCola 51c22287c6 chore(release): retrigger CI for v1.21.2 notes
Problem\nThe refreshed release-notes PR is authored by github-actions[bot], so required pull_request checks may remain action_required without jobs.\n\nRoot cause\nBot-authored updates do not activate the protected collaborator CI matrix.\n\nFix\nAdd a collaborator-authored no-op commit to request checks for the reviewed catalog.\n\nVerification\nThe commit changes no product files; only the PR check head changes.
2026-08-08 00:33:22 +08:00
github-actions[bot] a188af1bce docs(release): prepare v1.21.2 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-08-07 16:32:44 +00:00
YHH f58247d66c Merge pull request #7890 from esengine/perf/frontend-delta-batch-merge
perf(frontend): fold a frame's stream deltas into one reducer pass per tab
2026-08-08 00:29:24 +08:00
SivanCola ebd1ee463b Merge pull request #7894 from esengine/release-notes/v1.21.2
docs(release): 准备 v1.21.2 中英更新日志
2026-08-08 00:25:36 +08:00
SivanCola b34f308246 chore(release): retrigger CI for final v1.21.2 notes
Problem\nThe final refreshed release-notes PR is authored by github-actions[bot], so required pull_request checks can remain action_required without jobs.\n\nRoot cause\nThe workflow bot update does not activate the protected collaborator CI matrix.\n\nFix\nAdd a collaborator-authored no-op commit to request CI for the final reviewed catalog.\n\nVerification\nThe commit changes no product files; only the PR check head changes.
2026-08-08 00:24:32 +08:00
github-actions[bot] 201524a5d9 docs(release): prepare v1.21.2 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-08-07 16:23:56 +00:00
YHH 1aa0e54b8d Merge pull request #7889 from esengine/feat/stream-delta-coalescing
perf(event): coalesce streaming deltas before they reach a frontend
2026-08-08 00:21:31 +08:00
SivanCola 97b1dc057f Merge pull request #7893 from esengine/release-notes/v1.21.2
docs(release): 准备 v1.21.2 中英更新日志
2026-08-08 00:17:57 +08:00
SivanCola f676930734 chore(release): retrigger CI for refreshed v1.21.2 notes
Problem\nThe refreshed release-notes PR is authored by github-actions[bot], so required pull_request checks can remain action_required without jobs.\n\nRoot cause\nGitHub does not schedule the protected pull_request matrix for this bot-authored update.\n\nFix\nAdd a collaborator-authored no-op commit to request the normal CI matrix on the reviewed notes PR.\n\nVerification\nThe commit changes no product files; the PR checks will be re-evaluated at the new head.
2026-08-08 00:16:54 +08:00
github-actions[bot] 75d9c44bf1 docs(release): prepare v1.21.2 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-08-07 16:15:37 +00:00
esengine 97632a2375 perf(frontend): parse streaming Markdown per completed block, not per tick
The tiered commit interval (50/150/300ms) re-parsed the entire growing
document on every commit, so the most common 1-8KB answer paid a full
ReactMarkdown + remark/rehype pass up to 20x/sec — and below the 8KB
threshold the plain-text tail did not exist at all, so small answers got
the worst of both. streamingCommitTarget now advances the parsed prefix
only to the last completed block (blank line outside fences and display
math); the in-progress block always rides the appendData tail, from byte
zero. A stream now parses once per completed block plus one final pass,
instead of once per interval for its whole duration. An unclosed fence
or 76233 keeps the full text parsed so streaming code stays highlighted
live; the tier interval remains as the cadence ceiling.
2026-08-07 09:13:40 -07:00
esengine 0ae5ec7cdf perf(event): coalesce streaming deltas before they reach a frontend
A fast provider can emit hundreds of ChunkText/ChunkReasoning per
second, and every chunk crossed the controller sink as its own event —
one Wails EventsEmit, SSE frame, or redraw per chunk. event.Coalesce
merges bursts of consecutive pure Text/Reasoning deltas into at most one
event per 25ms window: the first delta forwards immediately (TTFT
unchanged), any other event flushes the buffer ahead of itself so total
order is preserved, and optional sink capabilities flush-then-forward.
The controller installs it around every frontend sink, so desktop, CLI,
serve, and ACP all inherit the cap.
2026-08-07 09:02:11 -07:00
YHH 0145113b8e Merge pull request #7885 from esengine/feat/e2ebench-trajectory
feat(bench): record per-task trajectories and attribute wall time
2026-08-08 00:00:07 +08:00
esengine 74ec16e4bc perf(frontend): fold a frame's stream deltas into one reducer pass per tab
The rAF batch confined token deltas to one flush per frame, but the
flush still dispatched per delta: N deltas meant N reducer calls, N
live-object allocations and string concats, and N liveStore
notifications. coalesceStreamDeltas now groups the batch by tab and
merges consecutive same-kind deltas into ordered segments — order is
authoritative because the reasoning→text boundary drives
reasoningComplete — and a single stream_batch action folds them in one
reducer pass via applyLiveSegments, which the per-delta text/reasoning
path also delegates to. stream_batch mirrors applyEvent's preamble
(discardTurn drop, pending-user flush, retry clear) and joins the
delta-only bump() skip.
2026-08-07 08:58:30 -07:00
YHH a9f4431675 Merge pull request #7886 from esengine/feat/trajectory-recorder
feat(cli): record a run's event trajectory with --trajectory
2026-08-07 23:43:04 +08:00
YHH 6f1d86d72e Merge pull request #7883 from esengine/fix/metrics-sink-double-emit
fix(cli): stop metricsSink double-forwarding every event
2026-08-07 23:42:47 +08:00
YHH d0626aceec Merge pull request #7862 from esengine/build/complexity-ratchet
build(lint): make function size and complexity a gate, not a report
2026-08-07 23:42:26 +08:00
SivanCola c1d7c9ec17 docs(release): prepare v1.21.2 notes (#7888)
docs(release): 准备 v1.21.2 中英更新日志
2026-08-07 23:10:35 +08:00
SivanCola d6f8657b08 chore(release): re-trigger CI for refreshed v1.21.2 notes
Problem
Prepare release opened the refreshed notes PR as github-actions[bot], so
required pull_request checks may stay action_required with no jobs.

Fix
Push an empty collaborator commit to re-trigger CI with a non-bot actor.

Verification
Required status checks should appear on #7888.
2026-08-07 23:09:30 +08:00
esengine 6afe68a0b3 feat(bench): record per-task trajectories and attribute wall time
e2ebench -trajectories DIR passes --trajectory through to each suite
task, keeps one <task-id>.trajectory.jsonl per run, and digests it into
the report: a tools-vs-model time-attribution line in the markdown and a
per-result trajectory summary in the JSON. Subagent tool calls overlap
the parent's wall clock and are excluded from the tool total; a
truncated final line from a killed run is skipped.
2026-08-07 08:09:27 -07:00
esengine bce93718bd feat(cli): record a run's event trajectory with --trajectory
internal/trajectory adds a Recorder — an event.Sink decorator that
appends every event (and readiness/protocol-recovery/turn-completion
audits) as one timestamped, sequenced JSONL record reusing the eventwire
contract, flushed per line so a killed run keeps every completed record.
Recording failures never block forwarding; Close reports the first one.

reasonix run --trajectory <path> wires it between metrics accumulation
and notifications. The run-sink assembly moves out of runAgent into
buildRunSink (run_sink.go) to keep cli.go inside the size ratchet.
2026-08-07 08:09:20 -07:00
github-actions[bot] 17cf53414c docs(release): prepare v1.21.2 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-08-07 15:08:46 +00:00
yhh 63d409359d build(lint): make function size and complexity a gate, not a report
The complexity monitor ran funlen and cyclop with issues-exit-code forced to 0
and said so in its own script: "Future work: block only new violations relative
to this baseline." A report nobody is blocked by is a report nobody reads, and
that future is now buildable — repolint's ratchet already carries five rules.

Measuring in repolint rather than parsing golangci-lint output keeps one set of
semantics (weight is the excess over the limit, per file and repo-wide) and
widens the scope from internal/agent to the whole tree. The thresholds are the
ones the monitor already used: 120 lines, complexity 30, tests excluded.

What that surfaces is not marginal: build is 1932 lines at complexity 309, and
chatTUI.update is 1005 at 252. Both are frozen now — they cannot gain a line or
a branch.

The report-only script, its config, and its two CI steps are deleted rather
than kept alongside: the same metrics now have a gate that stops people.
2026-08-07 08:04:10 -07:00
YHH 1d78975b26 Merge pull request #7882 from esengine/feat/tool-call-timestamps
feat(agent): stamp tool results with absolute start/end times
2026-08-07 22:55:20 +08:00
esengine 966bcdd46e fix(cli): stop metricsSink double-forwarding every event
record() kept its pre-#7733 trailing inner.Emit when Emit was
restructured around the snapshot lock, so every event reached the inner
sink twice on --metrics runs (doubled run output and notifications,
inflated downstream consumers) — and the second forward ran under mu.
2026-08-07 07:40:38 -07:00
esengine a509a17be3 feat(agent): stamp tool results with absolute start/end times
event.Tool gains StartedAt/EndedAt (unix ms), captured around the actual
execution in executeBatch and mirrored on the wire as startedAt/endedAt.
Calls that never ran (dependency-skipped, cancelled, synthetic) carry
zero so consumers can tell 'fast' from 'never executed'.

To stay inside the file-size ratchet, the batch-execution cluster
(executeBatch, batch barriers, partitioning, runParallel) moves from
agent.go to execute_batch.go, with its carried-over comments tightened
to the repolint limits.
2026-08-07 07:37:27 -07:00
SivanCola 0231c0e27e Merge pull request #7881 from esengine/release-notes/v1.21.2
docs(release): 准备 v1.21.2 中英更新日志
2026-08-07 22:19:57 +08:00
SivanCola b3744de49b chore(release): re-trigger CI for v1.21.2 notes review
Problem
The Prepare release workflow opened the notes PR as github-actions[bot], so the
required pull_request CI checks stayed in action_required with no jobs.

Fix
Push an empty commit as a human collaborator to re-trigger CI with a non-bot actor.

Verification
Required status checks (lint/race/test matrices) should appear on #7881.
2026-08-07 22:19:09 +08:00
github-actions[bot] ad5454c543 docs(release): prepare v1.21.2 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-08-07 14:15:47 +00:00
YHH 585d442d06 Merge pull request #7879 from esengine/build/frontend-size-gate
build(lint): extend the file-size ceiling to TypeScript
2026-08-07 20:52:48 +08:00
yhh bbe966d287 build(lint): extend the file-size ceiling to TypeScript
REASONIX.md has always said "one responsibility per file; 800 lines is the
ceiling" without qualifying it to Go, but repolint only ever collected .go
files -- so the rule has never applied to the ~40k lines of front-end code.
It shows: SettingsPanel.tsx is 7458 lines, App.tsx 5556, bridge.ts 5237,
Composer.tsx 4704. ProjectTree.tsx, the file that prompted this, is only
tenth at 2387.

checkSize reads s.lines and s.rel and nothing else, so covering TypeScript
needs no parser: collect takes .ts/.tsx, parseBytes skips the Go parser for
them and fills line counts only, and run hands a file without an AST to the
size rule alone. The comment and layering rules stay Go-only because they are
written against go/ast. isTest now also recognises __tests__/, .test. and
.spec. so front-end tests land under test-file-size.

Locale tables are exempt: they grow one entry per UI string, so a ceiling
there would fire on every new label without ever pointing at something worth
splitting. bridge.ts is deliberately not exempt -- a hand-maintained binding
contract is exactly what a size gate should keep visible.

This deliberately splits nothing. The 29 findings go into the baseline the way
the 1737 Go ones did: existing debt is tolerated, new debt is refused. Splitting
a file now would be splitting for its own sake; ProjectTree.tsx gets split when
there is a reason to open it.
2026-08-07 05:36:23 -07:00
YHH ba0f938f7e Merge pull request #7876 from esengine/docs/pr-metadata-gates
docs: correct and complete the PR metadata gate rules
2026-08-07 20:34:07 +08:00
YHH d0d733525a Merge pull request #7872 from esengine/fix/file-tools-overlay-and-atomic-writes
fix(tools): route file tools through the host FileOverlay, make writes atomic
2026-08-07 20:34:03 +08:00
yhh 1452348b2e docs: correct and complete the PR metadata gate rules
Two problems, both hit while landing #7872.

The Documentation-impact gate was not documented at all. It fires on any
user-visible diff and takes one of two forms depending on whether the same PR
edited docs/*.md, and its pattern only accepts an ASCII "-" or ":" separator --
the em dash this file used in its own Cache-impact example is rejected.

The line "Values n/a, none, todo, tbd are rejected" was wrong for the field it
appeared under. check-cache-impact.sh rejects only an empty value, todo, or tbd
for Cache-impact and Cache-guard; "none" is a legitimate impact when the
provider-visible prefix is unchanged. The stricter rule belongs to
System-prompt-review alone, which does reject none and n/a because it must name
a reviewer. As written it pushed authors toward overstating cache impact to get
past a gate that would have accepted the truthful answer.
2026-08-07 05:10:15 -07:00
yhh 22d266145d docs(acp): state that every file tool uses the client's unsaved buffers
The paragraph said Reasonix routes "eligible file operations" through the
editor's buffers, which was true only of read_file and write_file. Now that
every file tool routes through the overlay, say so, and name the one case
that stays local: a non-UTF-8 file, since the ACP file methods are text-only.
2026-08-07 05:00:48 -07:00
yhh 0ec0962f0c feat(tools): route every file tool through the host FileOverlay
FileOverlay already let a host serve file content from an unsaved editor
buffer, and ACP implements it -- but workspace.go only wired it into
read_file and write_file. edit_file, multi_edit, delete_range,
delete_symbol and notebook_edit all went straight to disk, so with an ACP
client attached the agent could read a buffer through read_file and then
edit_file the stale on-disk copy right over the user's unsaved work,
without either side noticing.

Two constraints shaped the fix.

Read and write must stay paired. Reading the buffer and writing the disk
flushes unsaved content; reading the disk and writing the buffer overwrites
it with stale text. editSource resolves the route once and carries it with
the content, so src.write returns where readEditSource came from. Non-UTF-8
files stay entirely on the disk route -- the overlay is text-only and would
rewrite GBK/UTF-16 as UTF-8.

Preview must see what Execute sees, which means it needs a ctx. edit_file's
NewText depends on the content it read, so a Preview against the disk while
Execute uses the buffer shows the user one diff and runs another -- and
checkpoint takes its preimage from that same Preview's OldText, so a wrong
OldText rewinds to the wrong state. tool.Previewer therefore takes a ctx;
it threads down three levels inside agent, all from functions that already
held one.

delete_symbol.findSymbol now parses the source it is handed rather than
re-reading the path, so the fset offsets index the same bytes the edit
applies to.

Note for future interface changes here: Go's implicit interfaces do not
fail the build when an implementation falls out of date. Two test fakes
silently stopped satisfying Previewer and only the assertions caught it.
2026-08-07 04:52:15 -07:00
yhh 62c9ca78d2 fix(tools): make file writes atomic, preserving mode and symlinks
The repo writes its own state files atomically (agent/save.go,
config/credentials.go, memory/quickadd.go and a dozen more all go through
fileutil.AtomicWriteFile / ReplaceFile) but wrote user source with a bare
os.WriteFile. That is O_TRUNC: when the write fails after the truncate --
a Windows filter driver holding a transient lock (#4193), a full disk --
the file is left empty or half-written.

Agent writes have a checkpoint preimage behind them, so the exposure was
bounded. That safety net does not extend to an editor's save path, which
is not driven by a tool call plan.

AtomicWriteFile alone is not a drop-in replacement: it chmods the temp file
to the caller's perm and the rename replaces a symlink with a regular file.
AtomicOverwriteFile keeps both -- it reuses the target's existing mode so a
chmod +x script does not come back 0644, and resolves symlinks so the write
goes through the link instead of clobbering it. defaultPerm applies only to
files that do not exist yet.
2026-08-07 04:52:01 -07:00
SivanCola 128b34b63b Merge pull request #7839 from clearnature/feat/output-budget-128k-compaction
feat: raise output budgets to 128K + cache-aware context projection / 提升输出预算与缓存感知惰性压缩
2026-08-07 19:28:59 +08:00
SivanCola 82851c80be Merge pull request #7807 from chuiwenwei/fix/sandbox-available-probe
fix(sandbox): probe sandbox-exec usability instead of PATH presence
2026-08-07 19:13:18 +08:00
SivanCola 6d7a7dff8b Merge pull request #7863 from SivanCola/fix/windows-hook-shebang-all-sources
fix(hooks): normalize Windows POSIX scripts across hook sources / 修复 Windows 多来源 Hook 的 POSIX 脚本执行
2026-08-07 19:04:51 +08:00
Yan Li 43d4945178 fix(agent): resolve golangci-lint issues in projection/compaction code
CI lint failures on PR #7839 (14 issues in the admin's projection work +
our compaction tests):
- preflight.go: errorlint (%w with errors.Join), modernize max()
- projection.go: slices.Backward for extractLatestSummary
- projection_test.go / projection_valid_test.go: intrange (Go 1.22 range N),
  slices.ContainsFunc
- compact_test.go: intrange, drop unused snapshotContents

Race job failures (TestBackgroundFleetRegistersReservationWhileItemsAreQueued,
TestSaveSnapshotToleratesEventIndexWriteFailure) are timing/rename flakes in
the agent package, unrelated to these lint fixes — will re-run CI to confirm.
2026-08-07 19:01:10 +08:00
SivanCola 308e3810d3 fix(sandbox): satisfy repository comment lint 2026-08-07 18:42:40 +08:00
SivanCola f0aabc9eb2 fix(sandbox): clarify unavailable backend guidance 2026-08-07 18:12:15 +08:00
SivanCola cfdc5addf9 Merge pull request #7817 from Nath-Vikky/fix/require-visible-subagent-final
fix(agent): prevent stale results after reasoning-only stops
2026-08-07 18:10:41 +08:00
SivanCola 846262953f fix(hooks): satisfy modernize lint for shebang scan
Problem: the PR CI lint job rejected the new shebang parser for using strings.Fields.

Root cause: the repository enables the Go modernize stringsseq check, and the project toolchain provides strings.FieldsSeq.

Fix: range directly over strings.FieldsSeq while preserving the existing shebang token normalization.

Verification: golangci-lint run --timeout=5m ./...; GOOS=windows golangci-lint run --timeout=5m ./...; go test ./internal/hook; go test -race ./internal/hook; go run ./tools/repolint.
2026-08-07 18:02:57 +08:00
SivanCola 2c964f1963 Merge main-v2 into fix/require-visible-subagent-final
Problem:
PR #7817 conflicted with the latest main-v2 guardian tests.

Root cause:
Both branches independently extended guardian_test.go imports and test coverage.

Fix:
Merge current main-v2 and preserve both the PR reasoning-only regression tests and the base slices-based test update.

Verification:
- go test ./internal/agent ./internal/guardian ./internal/boot -count=1 -timeout=10m
- go test -race ./internal/agent ./internal/guardian -count=1 -timeout=15m
- go test ./... -run ^$ -count=1
- go vet ./...
- go run ./tools/repolint
- git diff --check
2026-08-07 18:01:21 +08:00
SivanCola 9e015412da Fix projection stability and provider output defaults
Problem:
Projection eligibility depended on dynamic usage, the Anthropic provider applied a universal 128K default, the A1 regression test inspected canonical history, and the design document described removed cold-resume rewriting. The branch also needed the latest main-v2 lint baseline.

Root cause:
Provider usage changes from canonical to projected request shape after compaction, while Anthropic max_tokens support is model-specific. Tests and documentation still reflected the old history-rewrite contract.

Fix:
Use deterministic fallback token estimates for identity-sensitive early turns, select a conservative Anthropic default except for the official DeepSeek endpoint, add endpoint and prefix-stability regressions, assert rolling summaries through the model-visible projection, rewrite the design document, and regenerate the merged repolint baseline.

Verification:
- go test ./... -count=1
- go test -race ./internal/agent ./internal/control ./internal/guardian -count=1
- go vet ./...
- go run ./tools/repolint
- git diff --check
2026-08-07 17:58:09 +08:00
SivanCola 7f9b202339 fix(hooks): preserve Windows shell contracts
Problem: the first PR revision dropped auto-detected Bash from assembled plugin hooks, converted every implicit shell path to POSIX form, and exceeded the repository lint budgets.

Root cause: plugin assembly copied the manifest shell instead of the normalized execution contract, while path conversion ran before the actual shell/script decision. New logic and tests were also appended to already over-budget files.

Fix: propagate the normalized shell, convert paths only for explicit or shebang-confirmed Bash scripts, preserve native Windows implicit hook paths, and move the POSIX hook implementation and tests into dedicated files.

Verification: go test ./internal/...; go test -race ./internal/hook; GOOS=windows GOARCH=amd64 go test -c ./internal/hook -o /tmp/reasonix-hook.test.exe; go run ./tools/repolint; git diff --check.
2026-08-07 17:54:43 +08:00
SivanCola 3c4a2b3b5f Merge main-v2 into cache-aware compaction
Problem:
The PR branch conflicted with the latest main-v2 repolint baseline.

Root cause:
Both branches changed the generated repository lint baseline.

Fix:
Merge the latest main-v2 and regenerate the baseline from the merged source tree.

Verification:
- go run ./tools/repolint -update
- git diff --check
2026-08-07 17:49:05 +08:00
SivanCola c428f9166c Merge pull request #7820 from SivanCola/fix/updater-self-heal-pending
fix(update): self-heal stuck pending updates / 自愈卡住的待更新事务
2026-08-07 17:36:59 +08:00
SivanCola 57b6496b40 Merge origin/main-v2 into feat/output-budget-128k-compaction 2026-08-07 17:32:35 +08:00
SivanCola e83dd4d995 fix(agent): reconcile projection changes with PR head
Problem: rebasing the projection implementation left legacy cold-resume assertions and an outdated provider capability expectation. Root cause: old tests still assumed resume rewrote the canonical transcript and the compaction capability test expected the pre-128K output budget. Fix: remove the obsolete rewrite assertion, retain canonical-history regression coverage, update the provider expectation to the raised output budget, and refresh the repolint carry-forward baseline. Verification: go test ./...; go test -race ./internal/agent ./internal/control ./internal/guardian; go run ./tools/repolint; git diff --check.
2026-08-07 17:28:00 +08:00
SivanCola 3c63e430bf Merge pull request #7779 from SivanCola/feature/agent-work-practices
Harden agent runs: work-practice policy, actionable headless block, unhelped SWE-bench measurement / 加固 agent 运行:工作实践策略、可行动的 headless 拦截、不加特殊照顾的 SWE-bench 测量
2026-08-07 17:26:57 +08:00
SivanCola 01528449a4 feat(agent): add cache-aware context projection 2026-08-07 17:20:23 +08:00
YHH 5fddb5fa05 fix(agent): keep the session concurrency test inside the -race budget (#7821)
TestSessionConcurrentAddAndRead ran 16 readers x 5000 Snapshot calls against
a log growing to 5000 messages. Snapshot copies the whole log under the lock,
so that is roughly 200M message copies; -race instruments every one of them
and the test alone burned 9m48s of internal/agent's 10-minute package timeout,
which is what turned the race job red on main-v2.

Overlap is what detects the race, not volume, and the old shape wasted most of
it: the writer finished long before the readers, which then spun on a static
log. At 1000 appends with 8 readers doing 500 reads each, 82% of reads land
mid-append (observed snapshot lengths 1..583) while the test drops from ~32s
to ~2.6s.
2026-08-07 17:15:02 +08:00
SivanCola 697e32812e fix(hooks): normalize Windows POSIX scripts across hook sources
Problem: Windows could pass extensionless POSIX hook scripts from plugins, project settings, and global settings to cmd.exe, producing the not-an-internal-or-external-command warning. Claude implicit shell hooks could also retain Windows paths that Git Bash cannot resolve.

Root cause: hook execution mode and path normalization were applied only to explicit plugin conventions, while legacy and implicit shell sources were not inspected for POSIX shebang scripts.

Fix: detect POSIX shell shebangs across hook sources, route those scripts through the discovered Git Bash runtime, safely quote POSIX paths, normalize Claude plugin-root paths, and surface Bash requirements during runtime diagnostics.

Verification: go test ./internal/...; GOOS=windows GOARCH=amd64 go test -c ./internal/hook -o /tmp/reasonix-hook.test.exe; git diff --check.
2026-08-07 17:15:00 +08:00
SivanCola f3fc8c6888 fix(desktop): keep discard busy so check cannot steal abandon epoch
Problem
abandonPending began an operation without publishing a busy UI status and
used operation kind "checking". Settings Retry/Check only disable on
status.kind, so users could start a check while discard was still in
flight. The new check superseded the abandon epoch; discard success/failure
was then dropped and the recovery marker could remain.

Root cause
Discard owned exclusive recovery work but shared neither a busy status nor
a non-supersedable operation kind with the check path.

Fix
- Use operation kind "abandoning" and setStatus({ kind: "checking" }) so
  Settings busy logic disables Check/Retry/Discard immediately.
- Refuse check() while kind is abandoning so force-invoked checks cannot
  steal the epoch.
- Cover deferred AbandonPendingUpdate with check interleaving in
  updater-shared-state.test.tsx.

Verification
- pnpm test:updater (desktop/frontend)
2026-08-07 16:58:04 +08:00
SivanCola 4b25447b26 fix(e2ebench): bound patch extraction argv
Problem: retaining every text path in one docker exec command can exceed the host's argv limit and lose the entire SWE-bench patch.\n\nRoot cause: patch extraction had no byte budget for the pathspec list.\n\nFix: split literal pathspecs into conservative 16 KiB batches, run git diff once per batch, and concatenate the patch output. Oversized single paths fail explicitly.\n\nVerification: go test ./cmd/e2ebench and go test ./... pass; batching tests cover size limits, order preservation, and oversized paths.
2026-08-07 16:57:50 +08:00
SivanCola 43b411223f fix(update): wrap abandon dual errors with errors.Join
Problem
golangci errorlint failed on AbandonPendingUpdate:
fmt.Errorf used %w with a second %v for the companion error.

Fix
Join reconcile and commit errors with errors.Join, then wrap once with %w.

Verification
- golangci-lint run ./internal/repair/...
- go test ./internal/repair/ -run TestAbandonPending
2026-08-07 16:53:51 +08:00
SivanCola ec63d47f01 fix(update): keep updater_app essay budget clean after rebase
Problem
Rebase onto newer main-v2 left desktop/updater_app.go one essay unit over
the baselined budget from a multi-line AwaitingHealth retry comment.

Fix
Collapse the retry comment to a single line.

Verification
- go run ./tools/repolint
2026-08-07 16:53:51 +08:00
SivanCola 3a648549be fix(update): re-reconcile after awaiting-health heal retry
Problem
TestUpdaterBlocksInstallWhilePreviousReleaseAwaitsHealth failed: install
recovery returned nil when reconcile still reported AwaitingHealth.

Root cause
The AwaitingHealth retry path returned early when
CommitProbationaryPendingUpdate reported the marker gone. That helper also
returns true when no marker exists on disk, which does not prove reconcile
succeeded under the install-time recovery contract.

Fix
Always re-run reconcile after the heal retry instead of short-circuiting on
the commit helper alone.

Verification
- go test . -run TestUpdaterBlocksInstallWhilePreviousReleaseAwaitsHealth (desktop)
2026-08-07 16:53:51 +08:00
SivanCola 799440c42d fix(update): satisfy repolint budgets for pending-update heal
Problem
CI lint failed after the self-heal changes: update.go / update_test.go /
updater.go exceeded baselined file-size weights, and essay totals rose.

Root cause
The new heal/abandon paths and regression test intentionally grow the
owning files; long comments also inflated essay weight.

Fix
- Tighten comments on the new paths to stay within essay budgets.
- Ratchet tools/repolint/baseline.json for the deliberate file-size growth
  on the pending-update heal surface.

Verification
- go run ./tools/repolint
- go test ./internal/repair/
- go test . -run TestCapturePendingUpdateHealth (desktop)
2026-08-07 16:53:51 +08:00
SivanCola 345d4cf802 fix(update): harden abandon force-retire and install heal paths
Problem
After rebasing onto main-v2, desktop CI failed gofmt, AbandonPendingUpdate
bailed out when MarkUpdateHealthy failed on a drifted backup (so force-retire
never ran), and Exact health commit errors skipped CommitProbationary.

Root cause
- startup_health_test.go struct fields were not gofmt-aligned.
- CommitProbationaryPendingUpdate error short-circuited abandon before
  Reconcile/force-retire.
- force-retire gated on full health evidence, which for app-bundles still
  requires intact backups — the exact failure force-retire should cover.
- reconcilePendingUpdateForRequest used else-if so Exact commit failures
  skipped the broader probationary commit.

Fix
- gofmt startup_health_test.go.
- Continue abandon after probationary commit failure into reconcile and
  force-retire; gate force-retire on target-only install evidence.
- Always attempt CommitProbationaryPendingUpdate on install heal and on
  AwaitingHealth retry.
- Add TestAbandonPendingUpdateForceRetiresWhenBackupDigestBroken.

Verification
- go test ./internal/repair/
- go test . -run TestCapturePendingUpdateHealth (desktop)
2026-08-07 16:53:51 +08:00
SivanCola 7e1e5a9d07 fix(update): self-heal stuck pending updates and add discard action
Problem
In-app updates could permanently fail after a probationary transaction
never received startup health confirmation. Version strings with and
without a leading "v" skipped health capture, so AwaitingHealth blocked
every later update while the product was already usable.

Root cause
Health identity capture and MarkUpdateHealthy compared versions with
exact string equality. Missed post-DOM health commits left durable
pending-update.json markers with no automatic retirement path and no
user-facing abandon action.

Fix
- Normalize version comparison for pending-update health paths.
- Auto-commit stale probationary updates after 24h when install evidence
  still holds.
- Re-attempt health commit before install; expose AbandonPendingUpdate
  with banner/settings "Discard previous update".
- Document in-app update recovery steps in RECOVERY docs.

Verification
- go test ./internal/repair/
- go test . -run TestCapturePendingUpdateHealth (desktop)
- tsx desktop/frontend/src/__tests__/updater-shared-state.test.tsx
2026-08-07 16:53:51 +08:00
YHH 0c0429a8fa test: stop the CLI and SSH suites from reaching the outside world (#7856)
Three `runAgent` tests each carry a comment saying the isolated home has no
provider configured, so the run fails at setup. That is what the tests mean; it
was never what they did. "No config" means the *default* config applies, and
that points at api.deepseek.com — so `go test` was making real outbound calls
and the assertions rode on how that host happens to reject an unauthenticated
request. Two passed only because they never inspected the affected data; the
third compared message counts, failed locally against a real HTTP 401, passed
on CI, and was filed as an environment quirk.

pinProviderOffline writes a provider pinned to an unresolvable .invalid host
with an absent api_key_env, so those runs now fail with "missing env
REASONIX_TEST_ABSENT_KEY" — at setup, deterministically, with no network.

TestSSHConfigAliasesIncludeImportedFiles was the one test in its file that did
not stub resolveOpenSSH, so it shelled out to the host's ssh. Installed OpenSSH
refuses config files whose ACL is wider than the owner, which t.TempDir()
cannot guarantee, so the Include never loaded. Include handling is the parser's
contract here; the ssh -G path keeps its own stubbed tests.

The helper lives in its own file rather than cli_test.go, which is already
2237 lines and must not grow.
2026-08-07 16:38:23 +08:00
SivanCola f0ee22b4c7 fix(lint): use SplitSeq for patch entries 2026-08-07 15:48:34 +08:00
SivanCola daa2663440 fix(e2ebench): treat patch paths literally 2026-08-07 15:44:27 +08:00
YHH 4fbbca6a99 refactor(go): adopt modern stdlib idioms and gate them (#7831)
* refactor(go): adopt modern stdlib idioms across kernel and desktop

Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/
usestdlibvars fixers plus hand-fixes for what they could not rewrite:
slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor,
maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int.
Net -549 lines with no behavior change.

Three loops in session.go stay index-based: slices.Backward yields element
copies, so the fixer's rewrite silently dropped writes to DecisionReceipts
and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49
slices.Backward sites were audited for that shape; only these three write
through the index, and they carry a nolint with the reason.

CaptureAfter's scan loop had an empty body and never ran any logic, so the
fixer could not express it. Drop it and the accessor it was the only caller
of. omitzero is deliberately untouched: turning omitempty into omitzero
changes the JSON written for nested structs, which is a wire-format change
to session files and the extension protocol.

* build(lint): gate the modern-idiom linters

Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both
modules are now clean for modernize, intrange, copyloopvar, and
usestdlibvars, so enable them rather than leaving the sweep as a one-off.

omitzero stays disabled with the reason inline; usetesting, perfsprint, and
errorlint still have residue and land separately.
2026-08-07 15:30:33 +08:00
SivanCola 2b80d52408 fix(lint): isolate hardening tests and policy definitions 2026-08-07 15:09:09 +08:00
YHH e1079f2b58 fix(errors): compare errors through the unwrap chain, and gate it (#7834)
Every `err == Sentinel` and `err.(*T)` in the tree works today only because the
error happens to arrive unwrapped. That is not a property anyone maintains: one
`%w` added between producer and check silently stops the branch from firing, and
this same change adds 54 of them. So the comparisons are converted first, then
the wrapping.

Three helpers turned out to be hand-rolled reimplementations of the standard
library and are deleted rather than patched: remote.isPassphraseMissing,
remote.asKeyError (which open-coded the Unwrap() walk), and forward.containsErr
(which open-coded both the single- and multi-error walks). Call sites use
errors.As/errors.Is.

Two assertions deliberately keep `!=`: explainError and AnnotateToolSchemaError
must return the *same* error object, and errors.Is would still pass if they
started wrapping it. Both carry a nolint naming that.

errorlint is enabled in .golangci.yml; both modules are clean under it.
2026-08-07 15:05:46 +08:00
SivanCola eb43a9e952 fix(config): preserve offline environment setting 2026-08-07 14:44:59 +08:00
YHH c969509d66 style: strip section-banner decoration from comments (#7840)
312 banner separators across 63 files, the thing REASONIX.md bans outright and
nothing has ever checked. 64 were pure rules with no content and are gone; the
other 248 carried a label inside the dashes, so the decoration is stripped and
the label kept -- `// --- session/resume ---` becomes `// session/resume`, which
still orients a reader in a 1000-line test file without drawing a line across it.

The repolint ceiling for this rule drops 312 -> 0, so it is now zero-tolerance
rather than merely frozen.
2026-08-07 14:39:41 +08:00
YHH 7b17eaf059 build(lint): lint every build tag, not just linux/amd64 (#7837)
golangci-lint only type-checks the GOOS it runs under, so the 92 files behind
//go:build windows and //go:build darwin had never been linted by CI — the
Windows updater, installer, process reaping, sandbox, and the macOS PTY and
watchdog paths, which is exactly where bugs are hardest to reproduce and most
expensive to ship.

This is not theoretical: the modernization branch was verified clean on Windows
and still failed CI with six findings in !windows files, and a darwin-only run
then surfaced three more that neither Windows nor Linux could see.

Both modules cross-check without a toolchain except desktop under darwin, whose
main-thread watchdog is cgo; that leg runs natively in desktop-macos instead.
`make lint-cross` gives the same five-way sweep locally.

Existing findings are fixed: CloseHandle in a defer and LazyProc.Call join the
errcheck exclusions next to the Close entries they mirror (LazyProc.Call always
returns a non-nil errno; callers read r1), SetFileAttributes is explicitly
discarded, and updater_windows drops a redundant embedded-field selector.
2026-08-07 14:21:07 +08:00
YHH 08f497026c fix(agent): keep the session concurrency test inside the -race budget (#7821)
TestSessionConcurrentAddAndRead ran 16 readers x 5000 Snapshot calls against
a log growing to 5000 messages. Snapshot copies the whole log under the lock,
so that is roughly 200M message copies; -race instruments every one of them
and the test alone burned 9m48s of internal/agent's 10-minute package timeout,
which is what turned the race job red on main-v2.

Overlap is what detects the race, not volume, and the old shape wasted most of
it: the writer finished long before the readers, which then spun on a static
log. At 1000 appends with 8 readers doing 500 reads each, 82% of reads land
mid-append (observed snapshot lengths 1..583) while the test drops from ~32s
to ~2.6s.
2026-08-07 14:01:26 +08:00
Yan Li 0834e01f1c fix(agent): address review — A1 rolling merge actually folds old digests
Copilot review #7839 findings:
1. pinnedPrefixLen claimed to pin only the newest digest but advanced i past
   the whole contiguous summary run — older digests never re-entered the fold
   region, so A1 rolling merge never happened. Fix: the summary run now stays
   inside the region; partitionFold keeps only the NEWEST summary verbatim and
   folds the older ones (re-fed through the summarizer).
2. Small-user-turn keep window excluded digests (a digest is a user message
   and small digests were kept by B2); digests are governed by A1 only.
3. TestCompactRollsOldDigestsIntoNew now asserts the old digest is removed
   and the session holds at most 2 summaries (newest + merged).
4. Centralize the 128K budget as provider.DefaultHighOutputTokens and use it
   in openai.go / anthropic.go / responses vendor table so the three
   protocols cannot drift from DefaultReasoningOutputTokens (32K).
2026-08-07 13:43:39 +08:00
Yan Li 8f4296c51c Merge remote-tracking branch 'origin/main-v2' into feat/output-budget-128k-compaction 2026-08-07 13:24:32 +08:00
SivanCola 2acea4c873 Merge pull request #7841 from esengine/release-notes/v1.21.1
docs(release): 准备 v1.21.1 中英更新日志
2026-08-07 13:12:30 +08:00
github-actions[bot] 5b9d151e4c docs(release): prepare v1.21.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.
2026-08-07 05:04:04 +00:00
SivanCola f966dc37f9 Merge pull request #7431 from Lxiny-zy/fix/updater-reconcile-stale-target
fix(updater): quarantine stale self-describing transactions at reconcile
2026-08-07 12:55:05 +08:00
SivanCola b6733a26d1 Merge current main-v2 into updater recovery fix
Integrate main-v2 at 38e4702e8, including the TUI watchdog lifecycle changes from #7832. Resolve the shared repolint baseline by regenerating the combined per-file findings and repository totals.

Verification:
- go run ./tools/repolint
- go test -count=1 ./internal/repair ./internal/cli ./internal/provider/responses
- go test -race -count=1 ./internal/repair ./internal/cli ./internal/provider/responses
- go test -count=1 ./...
- go vet ./internal/repair ./internal/cli ./internal/provider/responses
- git diff --check
2026-08-07 12:42:34 +08:00
Vikky 3029a8f252 fix(agent): prevent stale results after reasoning-only stops 2026-08-07 12:40:40 +08:00
SivanCola bef2ffda94 chore(standards): record updater recovery budget
Problem
The updater recovery fix adds focused reconciliation logic and deterministic regression coverage, while the newly introduced repository standards baseline rejects any unrecorded growth.

Root cause
PR #7431 was updated with current main-v2 after the standards gate landed, so its intentional updater additions were not represented in the baseline.

Fix
Record only the essay and file-size findings for internal/repair/update.go and internal/repair/update_test.go, together with the generated repository totals.

Verification
- go run ./tools/repolint
- go test -count=1 ./internal/repair
- go test -race -count=1 ./internal/repair
- go vet ./internal/repair
- git diff --check
2026-08-07 12:39:19 +08:00
SivanCola 38e4702e87 Merge pull request #7832 from esengine/fix/tui-watchdog-lifecycle
fix(cli): prevent TUI watchdog false kills and recover active stalls
2026-08-07 12:25:15 +08:00
SivanCola 908eeb2dd1 Merge remote-tracking branch 'origin/main-v2' into HEAD 2026-08-07 12:23:40 +08:00
SivanCola 1cfe1ef50e fix(cli): guard watchdog cancellation generations and satisfy repolint 2026-08-07 12:17:44 +08:00
SivanCola 9c3878946b fix(updater): serialize stale marker quarantine
Problem
Reconciliation could quarantine a newly prepared pending transaction when another process replaced the marker between classification and rename. Self-describing transactions with non-layout validation failures were also treated as foreign-install state and abandoned automatically.

Root cause
The quarantine helper assumed callers held the pending lock, but ReconcilePendingUpdate called it without that lock. classifyPendingUpdate discarded the concrete validation error and exposed only a nil transaction.

Fix
- Recheck unusable markers under the pending lock and compare a SHA-256 marker digest before renaming.
- Classify only explicit foreign-install path failures as quarantine candidates.
- Preserve fail-closed behavior for invalid backup, hash, metadata, and other validation failures.
- Add regression tests for marker replacement and invalid self-describing transactions.

Verification
- go test -count=1 ./internal/repair
- go test -race -count=1 ./internal/repair
- go test -count=1 ./...
- go vet ./internal/repair
- gofmt and git diff --check
2026-08-07 12:10:56 +08:00
Yan Li 8b904ccf97 feat(agent): cache-aware compaction — C1 replay gate + A1 digest merge + B2 fixed small-turn window
mental-seal: prefix stability is the first principle. All three changes keep
the sent prefix byte-stable across compactions; only the folded middle is
replaced by a digest (one-time rewrite, then stable).

C1 replay gate (controller maybeColdResumePrune):
- Inside cache window (idle < vendor TTL): resume replays saved history as-is
  — the warm server prefix hits the cache (cheap) and the full multi-topic
  knowledge graph survives verbatim (zero-loss restoration, user insight:
  24h window replay is cheap knowledge recovery, not a gamble).
- Outside window: prune stale tool results + full compaction → first send is
  a small digest+tail prefix instead of a full-price ~1M cold replay.

A1 rolling digest merge (pinnedPrefixLen):
- Only the NEWEST digest is pinned; older digests enter the fold region and
  are re-fed through the summarizer (renderTranscript), so a long session
  cannot accumulate an unbounded chain of digests (was 10 digests / 27k).

B2 position-fixed small-turn window (partitionFold):
- Keep the first maxKeepSmallUserTurns=20 small user turns in the fold region
  verbatim; older ones fold. Position-fixed (never 'latest N') so the kept
  prefix stays byte-stable across compactions.

Tests: agent+control full suites green; new TestPartitionFold*,
TestCompactRollsOldDigestsIntoNew, TestColdResumeCompactsToDigestPrefix,
TestWarmResumeSkipsCompaction. gofmt/vet/golangci-lint clean.
2026-08-07 12:05:28 +08:00
SivanCola 6bec06aeb5 fix(cli): lifecycle TUI watchdog and Responses stream stall coverage / 生命周期 Watchdog 与流停滞覆盖
Problem
- Idle TUI was killed after ~10s (#7809): any Update refreshed progress, so no
  input looked like a stall.
- Active freezes only dumped goroutines without cancel/hard-kill recovery
  (#7811); stalled HTTP/2 provider bodies left the event loop wedged.
- Watchdog cancel could re-fire on the same turn after a grace heartbeat abort.

Root cause
- Watchdog had no booting/idle/running/closed lifecycle and treated every
  Update as a heartbeat.
- Escalation was a single kill with no cancel grace; cancelIssued was not
  sticky per generation.
- Responses stall tests used HTTP/1.1 httptest and fixed sleeps.

Fix
- State machine: booting → idle → running → closed; idle never kills.
- Active heartbeats only from elapsedTick and agent/shell work events.
- Running stall: dump → Cancel once per generation → 2s grace → Kill once.
- Wire NoteBooted/NoteRunning/NoteIdle/NoteActiveHeartbeat from chat TUI.
- Responses: real HTTP/2 TLS stall test, channel-driven cancel tests, optional
  sendChunk blocking hook for deterministic cancel coverage.

Verification
- go test ./internal/cli/ ./internal/provider/responses/
- go test -race ./internal/cli/ -run TestWatchdog
- go test -race ./internal/provider/responses/ -run 'TestStreamStall|TestSendChunk|TestReadStream|TestMissingTerminal'
2026-08-07 12:05:24 +08:00
Yan Li 4ff2766aed fix(provider): align output budgets across all three protocols
Align max_output_tokens / max_tokens defaults with vendor documentation:

- Responses API (responses.go): DeepSeek 32K→128K, MiMo 65K→128K,
  remove hardcoded DeepSeek fallback in New() (use vendor table only)
- Chat Completions (openai.go): DeepSeek 32K→128K
- Anthropic Messages (anthropic.go): defaultMaxTokens 32768→131072

Also merged from fix-mimo-reasoning-config:
- vendor: summaryMode, streamIdleTimeout, effort validation comments
- responses: buildRequestBody summaryMode wiring, WarnOnMissingToolCallReasoning
- MiMo singleSegmentReasoning: true→false
2026-08-07 12:02:58 +08:00
YHH ee8a85ec04 chore(standards): machine-enforce comment, file-size, and layering rules (#7806)
* chore(standards): gate comments, file size, and layering in CI

The comment and file-size rules lived only in an untracked CLAUDE.md written
for a TypeScript project, so nothing ever checked them, and REASONIX.md told
editors to match the surrounding comment density -- which is what reproduced
the essays. tools/repolint makes the rules machine-checkable and ratchets
them: recorded debt passes, anything new fails the required lint check.

Weights are excess lines rather than counts, so trading a short violation for
a long one still trips the gate. Layering encodes the contract REASONIX.md
already claimed and the import graph already satisfies, with one recorded
exception (capdiag reaching into boot).

* chore(standards): point CLAUDE.md at REASONIX.md

Claude Code loads CLAUDE.md and Reasonix loads REASONIX.md, so a repo with
only the latter leaves Claude Code sessions with no standing instructions.
A committed one-line pointer keeps the single source of truth without the
two files drifting apart again; the negation un-ignores it for contributors
whose global gitignore drops CLAUDE.md.
2026-08-07 11:25:52 +08:00
Will b3544d4958 fix(sandbox): probe sandbox-exec usability instead of PATH presence
macOS 10.14+ restricts sandbox_apply, so sandbox-exec may be on PATH yet
every invocation fails with "Operation not permitted" (exit 71). The old
Available() only checked PATH presence, so enforce mode silently failed per
command and status surfaces (doctor, TUI, ACP) over-reported isolation.

Mirror the Linux usableBwrap pattern: probe sandbox-exec with a minimal
profile under a 2s timeout, cache the result per resolved path, and have
Available() report true only when the probe succeeds. On restricted macOS
hosts this flips Available() to false, so the existing skip guards in
sandbox/boot/tool-builtin tests engage and boot prints the enforce
warning instead of failing silently.
2026-08-07 10:12:00 +08:00
YHH 1be7027e9c refactor(evidence): make the session-transcript context capability lazy (#7797)
Every tool execution eagerly copied the full session transcript
(a.session.Snapshot()) into the call context, though only complete_step
ever reads it. WithSessionMessages now carries a snapshot function; the
copy happens when SessionMessagesFromContext is resolved, so ordinary
tool calls pay nothing and the context carries a capability rather than
bulk data.
2026-08-07 09:02:24 +08:00
YHH 9d3d2411ed refactor(control): extract autoResearchManager collaborator (#7796)
The AutoResearch subsystem (~320 lines: task prepare/resume, heartbeats,
evidence recording, direction summaries, readiness) lived inline on
Controller. Per the leaf-collaborator playbook it moves to
autoresearch_manager.go: autoResearchManager wraps the optional
workspace autoresearch.Store and never touches Controller state —
assistant text and task IDs are passed in. Controller keeps thin glue
that resolves the active task from the goal machine and owns notices
(prepareAutoResearchTask fast-path, finalizeAutoResearchTask, the
AutoResearch* port methods). controller.go 6498 -> 6179 lines.
2026-08-07 08:40:05 +08:00
YHH 3f64e333e4 refactor(agent): group per-turn host flags into an embedded perTurnState (#7795)
Eleven fields that are valid for exactly one Agent.Run (delivery
expectations, readinessRecovered, recoveryTaskSummary, loop-guard and
blocked-streak counters, repeatSuccessCounts) were flat Agent fields
reset one by one in beginRunTurn — adding such a field meant
remembering to reset it by hand. They now live in an embedded
perTurnState value that beginRunTurn zeroes in a single assignment;
field promotion keeps every access site unchanged. Cross-turn state
(delivery checkpoint/scope, repeat-failure budgets, storm counters)
stays directly on Agent.
2026-08-07 08:39:43 +08:00
YHH 6ae39ed351 refactor(agent): extract task-intent classifier into internal/taskintent (#7788)
* refactor(agent): extract task-intent classifier into internal/taskintent

The CN/EN natural-language task classifier (~600 lines of keyword
tables and clause heuristics) lived inline in agent.go alongside the
run-loop orchestration it has no coupling with. It now forms the leaf
package internal/taskintent: the delivery intent type and Classify,
the task/chat heuristic, and the Goal write-budget classification.

Exported surface: Intent (+NeedsEvidence method), Classify,
NeedsEvidence, NeedsMutation, NeedsPersistentAction,
GoalNeedsWriteBudget. The redundant TaskNeedsMutation wrapper folds
into NeedsMutation; control/goal.go now calls
taskintent.GoalNeedsWriteBudget directly. Pure classifier tests move
with the package. agent.go 4667 -> 4087 lines.

* ci: retrigger checks after GitHub Actions outage
2026-08-07 08:07:19 +08:00
YHH d64aea65d5 refactor(control): split plan-todo seeding and module detection out of controller.go (#7787)
* refactor(control): split plan-todo seeding and module detection out of controller.go

Pure code motion: the plan-todo seeding cluster (seedPlanTodos,
PlanTodosJSON, parsePlanTodos, listItem and friends) moves to
plan_todos.go and the /plan-exec workspace module scan
(detectProjectModules and helpers) moves to project_modules.go.
controller.go 6818 -> 6498 lines, no signature or behavior changes.

* ci: retrigger checks after GitHub Actions outage
2026-08-07 08:06:30 +08:00
YHH 41cf534794 refactor(agent): return streamedTurn from streamWithFrozen (#7786)
* refactor(agent): return streamedTurn from streamWithFrozen

streamWithFrozen returned 13 positional values whose only consumer
immediately repacked them into streamedTurn via the streamTurnFrozen
wrapper. Return the struct directly, delete the wrapper, and share a
collect helper across the mid-stream exit paths. No behavior change.

* ci: retrigger checks after GitHub Actions outage
2026-08-07 08:06:18 +08:00
SivanColaandClaude Opus 5 a3aba3d1c5 fix(e2ebench): stop configuring and tidying the benchmark in the agent's favor
The run is meant to read what a default install of the software actually does,
so anything that helps the agent only inside the harness makes the score
unreadable. Two such helps existed.

The container config declared `[environment] offline = true`. It is true —
runSwebench requires -network with no off-box route — but a user whose network
is blocked does not know to set that flag, so the benchmark was measuring a
better-configured agent than the one that ships. Removed. An agent that burns
its budget retrying dead requests is a real weakness, and surfacing it is the
benchmark's job; the fix for it belongs in the product, where it would help
every user. `[environment] offline` itself stays for genuinely air-gapped
installs. `[sandbox] bash = "off"` stays too: bubblewrap is absent from the
official images and Reasonix would otherwise refuse to run bash at all, so it
is a necessity rather than a favor.

Patch extraction dropped `_build`, `__pycache__` and `*.egg-info` paths by
name. Those apply cleanly and do not change the grade, so removing them only
hid the agent's leftovers from the submitted diff. Now only binary entries are
dropped, which is the one thing a text patch genuinely cannot carry and the
defect that zeroed sphinx-11510 in the first place.

Tests pin both: the container config may carry no setting other than
sandbox.bash, and generated-looking text paths must survive into the patch.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 08:55:18 -07:00
SivanColaandClaude Opus 5 ede6fbac86 refactor(agent): keep environment facts out of the global work-practice policy
The work-practice policy is appended to every system prompt, so every clause
has to hold for any user on any machine. Two of them did not. The verification
budget ("avoid repeated full-suite runs", "confirm once that they pre-exist and
move on") and the network rule ("treat the environment as offline: stop
retrying") describe a benchmark container, not a developer's laptop: told to
everyone they push ordinary sessions toward under-verifying, and toward
abandoning a network call that a retry would have completed.

Drop both from the global policy and add `[environment] offline`, which the
deployment that knows its egress is blocked declares for itself. Deliberately a
declaration and not a probe: reaching out to test connectivity on every boot
would cost a request, leak the startup, and let one transient flap rewrite the
cache-stable prompt prefix. The note sits outside EnvironmentEnabled because
that switch only turns off tool probing, while a blocked network is a fact the
agent still needs.

The SWE-bench harness declares it in the container config, so the benchmark
keeps exactly the behavior the dropped clause provided: runSwebench already
requires -network with no off-box route, so egress is blocked there by
construction.

Tests pin the split in both directions: the global policy may not assert
offline/proxy/full-suite claims, and the note appears only when declared.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 08:35:46 -07:00
SivanCola 3f7dbfff0c Merge pull request #7785 from esengine/release-notes-v1.21.0-windows
docs(release): track Windows CI portability fix / 记录 Windows CI 兼容修复
2026-08-06 23:26:02 +08:00
SivanCola 8ee67b5f7c docs(release): track Windows CI portability fix
Problem: The stable candidate validator requires the reviewed v1.21.0 release record to change with the final candidate commit.

Root cause: The Windows portability fix landed after the release-notes PR merged, leaving the reviewed record unchanged in the candidate delta.

Fix: Record PR #7780 in the v1.21.0 upgrade metadata so the final candidate provenance includes the release-gate fix.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- git diff --check
2026-08-06 23:15:51 +08:00
SivanCola 1aab62f296 Merge pull request #7780 from esengine/fix/windows-sessiontemp-test
test(sessiontemp): handle Windows directory modes / 兼容 Windows 临时目录权限
2026-08-06 23:14:08 +08:00
SivanCola 990ccc3edb test(sessiontemp): account for Windows directory modes
Problem: The main-v2 push CI full Windows suite fails TestAcquireSharesGeneration before releasing its leases.

Root cause: Windows does not report POSIX directory permission bits, so the test sees mode 0777 instead of 0700 and t.Fatal leaves the owner lock held during TempDir cleanup.

Fix: Keep the 0700 assertion on Unix-like systems and validate the shared private-directory contract on Windows without asserting unsupported mode bits.

Verification:
- go test ./internal/sessiontemp
- GOOS=windows GOARCH=amd64 go test -c ./internal/sessiontemp
- git diff --check
2026-08-06 22:58:04 +08:00
SivanCola 2f671b738f fix(e2ebench): keep binary and generated files out of the SWE-bench patch
Problem: a SWE-bench submission was zeroed when the agent's Sphinx
repro build left a binary .pickle in /testbed: git add -A followed by
git diff --cached degraded it to a "Binary files differ" placeholder,
which git apply rejects wholesale — the correct source fix in the same
patch never reached the grader.

Root cause: patch extraction swept every untracked file into the
diff, including content a text patch cannot represent and build
output that is never the fix.

Fix: extract via numstat, then diff only the paths a text patch can
honestly carry: drop binary entries (unrepresentable) and well-known
generated trees (_build, __pycache__, *.egg-info). Plain text scratch
files stay: they apply cleanly, do not affect grading, and dropping
them by name would risk dropping a legitimate new source file. Paths
are passed as plain argv, never through a shell.

Verification: unit tests cover keep/drop classification (fix, text
deletion, binary, build output, egg-info, cache tree, scratch script)
and malformed input. go test ./cmd/e2ebench green.
2026-08-06 22:27:41 +08:00
SivanCola e015b7fab7 fix(control): name the in-session workaround in the headless dynamic-shell block
Problem: in non-interactive auto mode, a blocked inline interpreter
call (python -c, node -e) told the agent only to "use an interactive
session or YOLO mode" — something a headless run cannot do. Agents
burned dozens of calls retrying inline-code variants before stumbling
onto the allowed path on their own (88 blocked retries in one
SWE-bench arm).

Root cause: the block message offered only mode switches, not the
in-session alternative the policy actually allows.

Fix: keep the gate exactly as strict (inline code stays blocked
because the host cannot audit it), but make the message actionable:
write the code to a file with write_file and run that file instead,
or use read_file/grep for inspection.

Verification: new gate-level test pins that python -c stays blocked,
that the reason names the script-file workaround, and that running
the script file passes the same gate. go test ./internal/control and
./... green.
2026-08-06 22:27:41 +08:00
SivanCola 7c2d6842d0 feat(agent): append a work-practice policy to every system prompt
Problem: benchmark and real-world runs showed recurring, avoidable
agent behavior failures: substituting a "better" approach for the one
the issue explicitly named, leaving scratch/probe files in the repo,
declaring done with known regressions, re-running full test suites
until the time budget died, and retrying dead network paths dozens of
times.

Root cause: the default system prompt is three lines of persona; no
stable guidance covers baseline engineering discipline, and custom
personas could not inherit any either.

Fix: add config.WorkPracticePolicy and append it to every system
prompt next to UserDecisionPolicy, so custom personas cannot drop it
either. The rules are general engineering practice, not benchmark
hints: implement the requested approach exactly (state disagreements
explicitly), keep scratch out of the repo and review the final diff,
scale verification to the change, and treat proxy failures as an
offline environment.

Verification: new boot test asserts the policy is appended to custom
prompts and keeps deterministic order; golden provider-request and
prefix-shape baselines regenerated (REASONIX_UPDATE_GOLDEN=1) since
the stable prefix deliberately changes. go test ./... green.

Cache impact: one-time prefix change at upgrade; per-turn cache
stability is unaffected.
2026-08-06 22:27:22 +08:00
SivanCola 471d62a2d9 Merge pull request #7776 from esengine/fix/frontend-test-item-narrowing
fix(frontend): narrow tool items in todo metadata tests / 收窄待办元数据测试中的工具项类型
2026-08-06 22:15:51 +08:00
SivanCola e4b8a89e6b fix(frontend): narrow tool items in todo metadata tests
Problem:
The full desktop frontend test suite failed TypeScript test-config checking because find() results remained the broad Item union before accessing tool args.

Root cause:
The todo panel regression tests checked kind and name without expressing the discriminated-union narrowing to TypeScript.

Fix:
Use explicit Extract<Item, { kind: "tool" }> predicates for both todo_write lookups and record the no-migration upgrade note in the v1.21.0 release catalog so the candidate remains bound to its reviewed Notes change.

Verification:
pnpm --dir desktop/frontend test:all
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
git diff --check
2026-08-06 21:59:38 +08:00
SivanCola c21f0d2ee3 Merge pull request #7773 from esengine/release-notes/v1.21.0
docs(release): 准备 v1.21.0 中英更新日志
2026-08-06 21:49:57 +08:00
SivanCola 346cc58621 docs(release): ground v1.21.0 notes in merged PRs
Problem:
The generated v1.21.0 notes cited superseded or unmerged source PRs and omitted the merged CLI hardening PR.

Root cause:
The notes generator follows commit-associated pull requests, which can include closed source PRs that were later consolidated.

Fix:
Point consolidated CLI changes to merged PR #7656, add merged PR #7663, and remove the rejected #7600 item and unmerged source references.

Verification:
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
git diff --check
2026-08-06 21:48:54 +08:00
github-actions[bot] dc2f948bdc docs(release): prepare v1.21.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.
2026-08-06 13:44:52 +00:00
SivanCola a44361deba Merge pull request #7757 from SivanCola/fix/shell-execution-contract
fix(shell): execution contract and structured error presentation / Shell 执行契约与错误结构化呈现
2026-08-06 21:37:56 +08:00
SivanCola 593c074309 Merge pull request #7708 from skyzhao1223/fix/serve-replay-pending-on-sse
fix(serve): replay pending prompts when SSE clients attach
2026-08-06 21:13:40 +08:00
SivanCola aa98ce2dfe fix(shell): scope the batch barrier to real mutations, rename OutputTail
Problem
-------
1. A failed bookkeeping call stopped unrelated edits. evidence.ToolCallMutates
   deliberately exempts ask, todo_write, complete_step, bash_output, wait and
   the other non-mutation meta tools, but batchCallIsMutatingFailure ended in
   `ToolCallMutates(...) || !readOnly` and all of those report ReadOnly()==false.
   A failed todo update therefore tripped the dependency barrier, and every
   remaining edit in that batch came back as "an earlier modification in this
   tool batch failed or was blocked".
2. ShellExecution.StderrTail no longer held stderr. Stdout and stderr share one
   pipe so combined output keeps child-write order, which makes a stderr-only
   tail impossible; the field holds the tail of combined output.

Root cause
----------
1. The `|| !readOnly` fallback was meant to fail closed for targets the host
   cannot classify, but as a blanket OR it re-admitted the exact tool set the
   line above it had just excluded.
2. The field kept its original name after the pipe change landed.

Fix
---
- batchCallIsMutatingFailure fails closed only for unknown or ambiguous tools.
  Resolved writer proxies still return true earlier in the function, so the
  use_capability path is unchanged.
- Rename StderrTail -> OutputTail and `stderrTail` -> `outputTail` across the
  tool, event, eventwire, provider, control, shellrun, serve and Desktop
  surfaces, plus the frontend helpers that read it. Renaming now is free: the
  field is new in this PR and no released build has persisted it yet.
- Wire tool-card-shell-execution.test.tsx into `pnpm test`. It was added in this
  PR but never referenced by package.json or CI, so nothing would have caught
  the rename breaking it.

Verification
------------
- The barrier guard fails first: with `|| !readOnly` restored,
  TestBatchDependencyBarrierIgnoresFailedNonMutationMetaTool fails with the edit
  blocked by a failed todo_write. TestBatchDependencyBarrierStopsAfterFailed-
  WorkspaceWrite pins the other side, and the existing MCP-proxy and read-only
  diagnosis guards still pass.
- go build ./... and cd desktop && go build ./... pass; no StderrTail/stderrTail
  references remain outside internal/extension/sidecar, which has its own
  unrelated stderr-only tail.
- go test over agent, evidence, shellrun, shellparse, tool/..., provider/...,
  control, acp, eventwire, event — pass, except the pre-existing root-only
  TestMissingReasoningWarnStateReadFailureDoesNotOverwriteExistingIncidents,
  which fails identically on main-v2.
- pnpm typecheck, pnpm lint:hooks, and tool-card-shell-execution.test.tsx
  (33 passed) all pass after the rename.
2026-08-06 06:06:44 -07:00
SivanCola 836dd9d16a Merge pull request #7764 from esengine/codex/docs-i18n-cleanup-bundle
docs: consolidate documentation and i18n cleanup
2026-08-06 21:05:58 +08:00
SivanCola dc725b8d87 fix(serve): serialize prompt replay handoff 2026-08-06 20:56:07 +08:00
SivanCola 221e9a66ed Merge remote-tracking branch 'origin/main-v2' into HEAD 2026-08-06 20:56:00 +08:00
SivanCola 9da4b9e88c Merge pull request #7709 from skyzhao1223/fix/todo-panel-live-refresh
fix(desktop): refresh todo panel from live todo_write events
2026-08-06 20:51:58 +08:00
SivanCola 2405a7c108 fix(shell): keep combined output ordered and stop blocking && chains
Problem
-------
Two regressions rode along with the execution contract.

1. Every shell call lost stdout/stderr interleaving. A command printing
   progress on stdout and errors on stderr handed the model two separate
   blocks instead of the order the child actually wrote, so an error no
   longer sat next to the line that caused it.
2. Ordinary (non-Delivery) mode rejected the most common shell shape in
   real projects: `go build ./... && go test ./...`, `npm install && npm
   test`, `cargo build && cargo test`, `make build && make test`,
   `git pull && go test ./...`. Promoting the Delivery-only mixed and
   non-terminal-inline classifiers to global made every short-circuit
   chain a preflight block.

Root cause
----------
1. shellrun assigned two different io.MultiWriter values to cmd.Stdout and
   cmd.Stderr so stderr could be teed into its own tail. os/exec reuses a
   single pipe only while the two hold the same writer value; distinct
   values mean two pipes and two copy goroutines, and the merge order
   becomes a scheduling artifact. The pre-contract code passed one writer
   to both fields, which is why ordering held before.
2. The mixed/inline classifiers ask whether a call combines mutation with
   verification, not whether the exit status can lie. With `&&` bash
   short-circuits and reports the failing step, so those chains never had
   the failure-swallowing problem the contract exists to prevent.

Fix
---
- shellrun shares one writer between Stdout and Stderr, restoring
  child-write ordering and a single Progress caller. The bounded tail now
  covers combined output and is dropped on success, so a successful run
  no longer persists up to 16 KiB of ordinary stdout per tool card.
- Add shellparse.CanMaskEarlierFailure: `&&` chains are exempt, while `;`,
  newline, `||`, `|`, `|&`, and `&` can all hide an earlier failure. It
  reports ok=false for input it cannot analyze rather than guessing.
- Ordinary mode uses evidence.BashToolCallMixesMutationAndMaskableVerification
  and a maskability gate on the non-terminal inline interpreter rule.
  Delivery keeps the broad classifier: there a mutation invalidates the
  verification receipt even when the exit status is honest.
- Reword both recovery messages to name the real defect and offer '&&' as
  the fix, instead of telling the model never to combine the two.

Verification
------------
- Both regressions have failing-first guards: reverting either fix fails
  TestRunForegroundSharesOnePipeForStdoutAndStderr,
  TestRunForegroundPreservesInterleaving (10 runs), and the evidence and
  agent allow-list tests.
- go test ./internal/agent/... ./internal/evidence/... ./internal/shellrun/...
  ./internal/shellparse/... ./internal/tool/... ./internal/provider/...
  ./internal/control/... ./internal/acp/... — pass, except a pre-existing
  root-only failure (TestMissingReasoningWarnStateReadFailureDoesNotOverwrite-
  ExistingIncidents) that fails identically on main-v2.
- go build ./... and cd desktop && go build ./... pass; git diff --check clean.
2026-08-06 05:47:09 -07:00
SivanColaandinnocarpe ac995af888 chore: record consolidated PR attribution
Problem: the integration PR adopted contributor work without a commit-level public attribution trailer.

Root cause: the source PRs were consolidated into an existing maintainer commit without preserving the contributor trailer.

Fix: record the adopted work attribution in the integration history without changing runtime behavior.

Verification: source PRs and contributor identity were rechecked through GitHub before pushing.

Co-authored-by: innocarpe <[email protected]>
2026-08-06 20:41:59 +08:00
Wooseong Kim 14dccd9f91 fix(frontend): translate remaining zh/zh-TW locale values 2026-08-06 20:40:59 +08:00
YHH 0aea9ba62d site: lead with what the agent does, not what it caches (#7759)
* site: lead with what the agent does, not what it caches

The landing page spent its hero, one feature card, a full sticky screen
and the footer tagline on prefix-cache numbers, and every quantified
claim was a billing metric. Readers came away thinking the cache was the
product — and now that competing agents hit the same cache rates, that
framing reads as a pitch rather than a differentiator.

Lead on what is actually ours: one local engine behind four surfaces,
gated tool calls, per-turn checkpoints, plan mode. The cache diagram
survives, demoted to an Under-the-hood section that says outright it is
an implementation detail. Nav trades How-it-works for Surfaces.

* site: retune the visual language from SaaS landing to dev tool

Rounded geometric display type, 24px radii, pill buttons, floating
shadow cards and a full-screen particle canvas are the visual grammar of
a marketing page. That grammar undercut the copy rewrite: the page still
signalled 'pitch' before a word was read.

Inter replaces Outfit, radii drop to 13/9px, cards carry a hairline
border instead of a shadow, the cache-block divider becomes a plain
rule, and reveal travel halves. Drops the hero particle canvas and the
terminal 3D tilt, which leaves fx.js with no rAF loops at all.

* site: tighten spacing and hero sizing after the retune

Checked in the browser: the thinner divider left ~220px of dead air
between sections, the CN hero ran a size larger than the EN one now
that tracking is tighter, the accent underline read as a highlight
block on CJK glyphs, and Download desktop wrapped in its fixed-width
button.

* site: drop the Under-the-hood section

Demoting the cache explainer still left a full sticky screen arguing for
it, and arguing is itself a defensive posture on a landing page. The
mechanism belongs in the docs, which already cover it under #cache.

Removes the section, its 280vh sticky track, the scroll-driven block
diagram and the last scroll handler in fx.js. Repoints the two docs
links that targeted the deleted anchor. The home page is ~2500px
shorter.

* site: carry the new tokens into the community forum

The forum ships its own copy of the site tokens, so retuning global.css
alone left two typefaces and two radius scales across one domain — the
shared header rendered differently depending on which page you were on.

Syncs fonts, radii, ink and line values, and tightens the structural
controls; forum-native pills (badges, tags, categories) keep their round
shape. Outfit now has no importers and leaves the dependency tree.
2026-08-06 20:36:21 +08:00
SivanCola 708ab62bcf Merge pull request #7752 from Nontee22/fix/windows-shortcut-target
fix(desktop): repoint dangling versioned Windows shortcut targets to the launcher
2026-08-06 20:31:36 +08:00
SivanCola 93dbe7642c fix(serve): target replayed prompts to new SSE clients 2026-08-06 20:31:28 +08:00
SivanCola f625f0b344 test(desktop): cover live todo_write panel refresh path
Problem:
- The todo panel could stay frozen on a stale MetaForTab snapshot until
  turn_done or a tab focus change, even after a mid-turn todo_write.

Root cause:
- MetaForTab only refreshes on turn boundaries/focus changes, while the
  live transcript tool snapshot already carries the updated todos.

Fix:
- Keep preferring the live todo_write snapshot when present, and add a
  reducer-level regression that proves the panel uses fresh Meta while
  the live tool is delayed, then switches to the live snapshot once
  tool_dispatch/tool_result arrive (#7642, #7709).

Verification:
- npx tsx src/__tests__/use-controller-meta.test.ts (139 passed)
- node scripts/test-todo-visibility.mjs
2026-08-06 20:25:11 +08:00
SivanCola 69acd4fbe7 docs: consolidate documentation and i18n cleanup
Combine the provider translation, README and changelog corrections, extension-protocol and benchmark docs, locale cleanup, translation parity checks, and custom provider placeholder from the selected open PRs. Update benchmark guidance for the main-v2 base and SWE-bench mode as part of review.

Validated with: go test ./...; pnpm --dir desktop/frontend typecheck; pnpm --dir desktop/frontend test:typecheck.
2026-08-06 20:17:01 +08:00
SivanCola 0270bd3de6 fix(shell): clear lint findings 2026-08-06 20:12:47 +08:00
SivanCola 3e00809c80 fix(shell): execution contract, structured errors, and batch barrier
Problem
-------
Shell tool results lacked structured identity/exit/phase metadata, ordinary
mode still allowed high-confidence dangerous command shapes, mixed edit+verify
batches could mislead after a failed mutation, and Desktop/CLI/Serve could not
present consistent shell cards. Provider-visible tool cache bytes had to stay
unchanged.

Root cause
----------
bash returned only plain text; delivery-only guards were not split into a global
deterministic preflight layer; batch execution did not enforce a mutation
dependency barrier after proxy resolution; host terminal (ACP) outcomes lost
exit codes and timeout classification; Windows PR CI never ran the PowerShell
contract suite.

Fix
---
- Add ShellExecution + DetailedExecutor; shared shellrun foreground runner
- Global preflight: mixed mutation/verification, masked $?, non-terminal inline
- Batch mutation barrier with post-proxy re-check (use_capability cannot bypass)
- Persist execution as local session/UI metadata; ModelMessages + provider wire
  paths strip it (OpenAI/Anthropic/Responses golden tests)
- Desktop ToolCard / CLI / Serve show real shell name, exit, phase, verification
- ACP terminal returns typed TerminalExitError / TerminalTimeoutError
- Windows PR CI step runs PowerShell contract tests (Chinese path, UTF-8, exit)

Verification
------------
- go test focused packages (agent, builtin, acp, provider/*, shellrun, control)
- ToolCard shell DOM tests: 33 passed (live/history/archive/timeout/cancel)
- pnpm typecheck + lint:hooks
- Windows PR workflow step added for TestBashPowerShellExecuteDetailedContract
2026-08-06 19:53:33 +08:00
SivanCola cfc6c6f121 Merge pull request #7751 from SivanCola/fix/cache-metrics-accounting-followup
Fix cache accounting, snapshot races, and adaptive TTL churn / 修复缓存计费、快照竞态与 TTL 抖动
2026-08-06 19:47:17 +08:00
SivanCola 60148a2fe8 test(cache): use collision-safe request context
Problem:
- Staticcheck SA1029 rejected an anonymous empty struct used as a context key in the cache byte-stability regression.

Fix:
- Use a cancellable request context to provide a distinct context without introducing any value key.

Verification:
- golangci-lint run --timeout=5m
- go test -count=1 ./internal/provider/anthropic
2026-08-06 19:11:13 +08:00
SivanCola d91766a21b fix(cache): keep Anthropic TTL request bytes stable
Problem:
- A 120-second request gap switched Anthropic cache_control to the opt-in 1h TTL, invalidating the warm default-TTL prefix and increasing cache-write cost.

Root cause:
- Agent wall-clock timing was threaded through context into request construction and billing, so otherwise identical requests changed bytes and pricing based on elapsed time.

Fix:
- Remove request-gap state and the cache hint side channel.
- Always omit the TTL field and retain Anthropic default 5m cache controls.
- Account native Anthropic cache writes at the documented default-TTL 1.25x multiplier while keeping compatible gateways raw-only.
- Add byte-stability and default billing regressions.

Verification:
- go test -count=1 ./...
- go test -race -count=1 ./internal/agent ./internal/provider/anthropic ./internal/cli
- go vet ./...
- cd desktop && go test -count=1 ./...
- cd desktop/frontend && pnpm typecheck && pnpm test
2026-08-06 19:05:17 +08:00
SivanCola f3983af270 Merge main-v2 into cache accounting follow-up
Problem:
- PR #7751 conflicted with the stream interruption accounting changes merged in #7737.

Root cause:
- Both branches evolved usage aggregation ownership in internal/agent/run_loop.go.

Fix:
- Keep #7737 mergeSamplingUsage ownership and carry raw and billed cache-write counters through it.
- Preserve request-count and billable-prompt aggregation coverage from both branches.

Verification:
- go test -count=1 ./internal/agent ./internal/provider ./internal/provider/anthropic ./internal/provider/openai
- cd desktop && go test -count=5 . -run TestApplyLinuxVersionedActivatesWithoutPersistingGuard
2026-08-06 18:56:30 +08:00
SivanCola a4d85f6e2a Merge pull request #7740 from esengine/fix/cli-feedback-integration-de
feat(cli): version metadata, shell completion, MCP source display / 版本元信息、Shell 补全与 MCP 来源展示
2026-08-06 18:45:43 +08:00
SivanCola b9a6c405c1 fix(cli): serve --resume as path and clear SA9003 empty branches
Problem
- serve --resume used SessionValue and completed branch IDs that fail as file paths.
- Two empty test branches triggered staticcheck SA9003 and blocked lint CI.

Root cause
- serve loads resume via open/loadResumableSession (path-only); test stubs left
  comment-only if bodies.

Fix
- Model serve --resume as required PathValue (shell file fallback).
- Assert empty candidates for run --resume --m and remove redundant task branch.

Verification
- go test ./internal/cli -count=1 -run TestCLICompletion
2026-08-06 18:29:53 +08:00
SivanCola e6a1ad85c9 fix(cli): align completion resume and task flags with real parsers
Problem
- run/serve --resume were OptionalValue, so completion suggested --model after
  --resume while the real FlagSet consumed --model as the resume value.
- task operations shared one flag set, advertising invalid --project-root on
  status/events and omitting --jsonl/--after/--follow/--expected-version/etc.
- Inline --resume=QUERY dropped dynamic session IDs for OptionalValue.

Root cause
- Interactive optional resume was applied too broadly; task flags were not
  mirrored per FlagSet; inline value path did not reuse completionFlagValues
  session merge for optional.

Fix
- Interactive root --resume stays OptionalValue; run/serve use required
  SessionValue.
- Per-operation task completion flags matching task.go / task_machine.go.
- OptionalValue always merges dynamic session IDs (separated and = form).
- Tests cover required resume, inline sessions, and per-op task flags.

Verification
- go test ./internal/cli -count=1 -run TestCLICompletion
- go test ./internal/cli -count=1

Documentation-impact: none - help text already lists completion/version;
this only fixes completion registry contracts and docs-impact wording on the PR.
2026-08-06 18:29:53 +08:00
SivanCola 344af3b12a fix(cli): close Integration D/E audit P1/P2
Problem
- Official release builders (goreleaser/npm/desktop) only injected main.version,
  so version --verbose reported git_commit/build_time_utc as unknown.
- buildinfo used vcs.time as build time (commit timestamp, not build clock).
- Completion registry drifted: task nested commands and run --ablate missing.
- MCP tool descriptions could inject CSI/OSC into the TUI; invalid tools were
  listed twice; completion optional-value/path and help text incomplete.

Root cause
- Makefile-only ldflags; incomplete registry/help absorption; no sanitize at
  external text boundary.

Fix
- Inject main.gitCommit + main.buildTimeUTC from Makefile, .goreleaser.yaml,
  npm/build.mjs, desktop-build.sh, verify-embedded-docs.sh.
- BuildTimeUTC defaults to unknown without ldflags (no vcs.time).
- Expand task monitor/* and --ablate; optional --resume; path flags return empty
  for shell file fallback; fish drops -f.
- sanitizeExternalDisplayText for MCP external fields; valid tools exclude
  SchemaError; tri-lingual help lists completion and version flags.
- Tests: release-style verbose, control-seq sanitize, optional resume, nested task.

Verification
- go test ./internal/cli -count=1
- go test ./cmd/reasonix ./internal/plugin -count=1
- desktop TestDesktopBuildLinksSharedSourceRevision
- Smoke: go build -ldflags ... && reasonix version --verbose; completion __complete

Documentation-impact: low - tri-lingual help lists completion and version
--verbose/--json; no new config keys.
2026-08-06 18:29:53 +08:00
f6be547f49 feat(cli): version metadata, shell completion, MCP source display
Problem
- reasonix --version only printed a bare version string, which is hard to
  correlate with source builds and user bug reports.
- No first-party bash/zsh/fish completion script existed.
- /mcp status did not show which config plane registered a server/tool.

Root cause
- Version path was a one-line println; no completion registry; ServerStatus
  omitted Spec.ConfigSource.

Fix
- BuildInfo with version --verbose / --json (version, git commit, build time
  UTC, target, go). --version/-v stay single-line. No config path, no CST.
- reasonix completion bash|zsh|fish from a shared registry with Run dispatch
  drift guard (adapted from #7425).
- Plumb ConfigSource into ServerStatus and render source= on /mcp status and
  tool lists (from #6175 scope: display only).
- Makefile ldflags inject version, gitCommit, buildTimeUTC.

Verification
- go test ./internal/cli -count=1
- go test ./cmd/reasonix ./internal/plugin -count=1
- TestBuildInfo*, TestCLICompletion*, TestRenderMCPStatusShowsConfigSource

Refs #6476 #7425 #6175 #6578 #6833
Session lease / REASONIX_HOME / bash session-allow already covered on main-v2;
no --home flag added.

Co-authored-by: h4rK8s <[email protected]>
Co-authored-by: Zhiyao Wen <[email protected]>
2026-08-06 18:29:53 +08:00
SivanCola 102fbcf784 Merge pull request #7737 from SivanCola/feature/codex-stream-interrupt-recovery
fix(agent): replay interrupted streams atomically / 原子重放中断的模型流
2026-08-06 18:28:52 +08:00
Nontee a80f75bbf2 fix(desktop): repoint dangling versioned shortcut targets to the launcher 2026-08-06 18:24:07 +08:00
SivanCola cfdd76fbab fix(cache): account cache writes and isolate metrics snapshots
Problem:
- Cache reset snapshots aliased a live map and could race background usage events during JSON serialization.
- Anthropic cache creation tokens were priced as ordinary input, hiding the 5-minute versus 1-hour write premium from benchmark and UI cost totals.

Root cause:
- RunMetrics.clone omitted PrefixChangeReasonCounts after that map was introduced.
- Usage collapsed cache creation into generic misses, and run metrics duplicated the old flat pricing formula.

Fix:
- Deep-copy the cache-reason map and exercise concurrent marshaling under the race detector.
- Preserve raw and billed cache-write token units, apply official Anthropic 1.25x/2x tiers, retain compatible-gateway and legacy 1x fallbacks, and carry the accounting through merged usage and persisted Desktop telemetry.
- Route run metrics through Pricing.Cost as the single cost owner.

Verification:
- go test -count=1 ./...
- go test -race -count=1 ./internal/cli ./internal/provider ./internal/provider/anthropic ./internal/agent ./internal/guardian
- go vet ./...
- cd desktop && go test -count=1 ./...
- go run ./cmd/extension-protocol-gen -root . -check
2026-08-06 18:21:52 +08:00
SivanCola b5f210263a Align stream recovery with observational goal usage
Problem:
- main-v2 removed Goal token hard limits in #7725, while the recovery branch still depended on the deleted provider request-admission interfaces.

Root cause:
- Exact replay budget checks were designed against the previous hard-limit model and would have reintroduced request rejection semantics that main-v2 intentionally removed.

Fix:
- Keep the provider request frozen across stream attempts without request-budget admission, remove BudgetAccounted-specific state and tests, and retain conservative failed-attempt estimates strictly for billing and observational Goal usage.

Verification:
- go test ./... -count=1
- go test -race ./internal/agent ./internal/control ./internal/acp -count=1
- golangci-lint v2.12.2
- Desktop stream-progress 56/56 and context-panel 50/50
- pnpm exec tsc --noEmit
- focused Desktop rebind telemetry and workspace Git regression tests
2026-08-06 18:02:11 +08:00
SivanCola 2d9e24696a Fix stream recovery lint contracts
Problem:
- PR CI rejected the stream-recovery branch for root formatting and redundant state in the recovery and provider terminal paths.

Root cause:
- Attempt-local bookkeeping survived the recovery refactor even though every repair path returns immediately, and terminal parser variables were assigned before unconditional control-flow branches.

Fix:
- Remove dead missing-reasoning/context helpers, simplify Anthropic and Responses terminal error classification, and format the agent constants.

Verification:
- gofmt root module
- golangci-lint v2.12.2
- go test ./internal/agent ./internal/provider/anthropic ./internal/provider/responses -count=1
- go test -race ./internal/agent -count=1
2026-08-06 18:02:11 +08:00
SivanCola 8afb19fed3 Fix retry context usage after desktop rebind
Problem:
Recovered provider streams aggregate billable usage across attempts. Desktop rebind telemetry and the live context breakdown could treat those totals as the active context, inflating the gauge and token segments.

Root cause:
The usage contract carried only the latest prompt size, while completion, reasoning, and cache consumers fell back to multi-attempt billable fields. Providers without cache split data could also inherit aggregated cache misses.

Fix:
Carry the complete latest-attempt Context usage shape through eventwire, persist it for Desktop rebind fallback, consume it in the live context panel, and preserve an unreported cache split as 0/0 while retaining legacy fallbacks.

Verification:
- go test ./internal/agent ./internal/eventwire ./internal/provider ./internal/provider/openai ./internal/provider/responses ./internal/provider/anthropic -count=1
- go test -race ./internal/agent ./internal/control ./internal/acp -count=1
- cd desktop && go test ./... -count=1
- desktop context telemetry race tests passed 5 times
- frontend context-panel breakdown tests: 50/50
- frontend stream-progress tests: 56/56
- pnpm exec tsc --noEmit
- gofmt and git diff --check
2026-08-06 18:02:11 +08:00
SivanCola e2ae735917 Recover interrupted provider streams safely
Problem:
Long provider streams could terminate mid-response, leaving partial tool calls and UI state without a safe retry boundary.

Root cause:
Body-phase transport failures were handled below the agent lifecycle, where request identity, speculative output, tool execution, usage accounting, and Goal reservations could not be coordinated atomically.

Fix:
Add agent-owned exact-request replay with provider terminal contracts, speculative attempt rollback across Desktop/CLI/ACP, failure-safe Goal usage accounting, exact atomic request reservations, and compatible request-count aggregation.

Verification:
- go test ./... -count=1
- go test -race ./internal/agent ./internal/control ./internal/acp -count=1
- frontend stream-progress tests (56/56)
- pnpm exec tsc --noEmit
2026-08-06 18:02:10 +08:00
SivanCola 74f26c88ea Merge pull request #7724 from SivanCola/feature/cache-hit-rate-improvements
Improve prompt-cache hit rate: adaptive Anthropic TTL, accurate diagnostics, latch fix / 提升 prompt 缓存命中率:自适应 Anthropic TTL、诊断归因修正、压缩闩锁修复
2026-08-06 17:58:14 +08:00
SivanColaandClaude Opus 5 732780c1d4 fix(agent): clear the compaction latch anywhere below the trigger
Problem: auto-compaction could switch itself off permanently mid-session
and tell the user their context window was too small when it was not.
After the latch fired, every later turn returned early from maybeCompact,
so the prompt grew unbounded until the provider rejected it.

Root cause: maybeCompact cleared consecutiveCompacts/compactStuck in the
`u.PromptTokens < high` branch, but that branch is unreachable for the
whole [snip, high) band — the snip branch above it returns first (and the
soft-notice branch does the same for part of [soft, snip)). A healthy
compaction that settles the prompt at, say, 70% of the window therefore
left the run counter at 1 instead of resetting it, and the next legitimate
compaction incremented it to 2 and latched the session. The reset's own
comment states the intended contract — "a turn that sits under the trigger
is the breathing room a healthy compaction buys" — so the code contradicted
its documented intent.

Fix: hoist the reset to run for any prompt under the trigger, before the
soft/snip branches return. The `< high` branch keeps its early return with
the latch already cleared. Nothing else about the tiering changes.

Verification: new TestMaybeCompactClearsStuckLatchAnywhereBelowTrigger
covers all three sub-trigger bands and fails on the parent commit for the
soft and snip bands; new TestMaybeCompactStillLatchesWhenPromptStaysAboveTrigger
pins that a genuinely too-small window still pauses auto-compaction, so the
safety valve is not disabled. All 8 pre-existing TestMaybeCompact* tests and
go vet ./internal/agent/... pass unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 17:42:08 +08:00
SivanColaandClaude Opus 5 88c04eefe5 feat(bench): report cache resets by cause in the e2e benchmark
The benchmark reported a cache-hit percentage but nothing about why the
prefix was invalidated, so a hit-rate regression in a PR gave no signal
about which operation caused it.

metricsSink now tallies event.Usage's CacheDiagnostics.PrefixChangeReasons
into RunMetrics.PrefixChangeReasonCounts, e2ebench mirrors the field in its
parallel runMetrics struct, and render() prints a "Cache resets by cause"
line next to the existing failures-by-class breakdown. The field is
omitempty and the report line is skipped entirely when no run reported any
reason, so older metrics files and runs without diagnostics render exactly
as before.

Verification: go build ./... and go vet clean; new
TestRenderShowsCacheResetsByCause and
TestRenderOmitsCacheResetsLineWhenNoReasonsReported pass, and the existing
TestRenderLeadsWithCostPerSolvedAndFailureClasses is unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 17:42:08 +08:00
SivanColaandClaude Sonnet 5 6a93bf2899 feat(cache): adaptive Anthropic TTL and accurate diagnostics attribution
Two cache-hit-rate improvements identified while reviewing Reasonix's
prompt-cache architecture against current provider docs.

Adaptive Anthropic cache_control TTL:
- cache_control only ever sent the default 5-minute ephemeral window;
  Anthropic also supports an opt-in 1h window (GA, no beta header) at 2x
  write cost vs 1.25x, with identical 0.1x read cost either way.
- Agent.stream now stamps the wall-clock gap since the previous request
  and passes it through a new provider.WithCacheGapHint context key;
  anthropic.go switches to the 1h TTL only when that gap is >= 120s, so
  rapid turns keep paying the cheaper 5m write and only genuinely idle
  gaps (build/test waits, long reads) get the longer window before it
  would have expired anyway.

Cache-diagnostics reason granularity + false-positive fix:
- Session.rewriteVersion was bumped by both provider-visible rewrites
  (compact, snip/prune, summarize, rewind, guardian merge) and
  local-only metadata edits (decision receipts, tool-call
  preview/resolution, marking a message Edited) that ModelMessages
  strips or never serializes. CompareShape used a bare rewriteVersion
  diff as its only signal, so a local-only edit could spuriously report
  "cache prefix changed" even though the bytes sent to the provider
  never moved.
- Rewrite now takes an explicit reason and queues it on the session; a
  new ReplaceLocalMetadata covers the one call site (controller.go's
  markEditedForNewUser) that only changes local metadata, so it no
  longer contributes a reason. CompareShape reports specific reasons
  drained since the last request (compact_auto/compact_manual/snip/
  prune/summarize_from/summarize_up_to/rewind_truncate/rewind_restore/
  guardian_merge) instead of one generic "log_rewrite" label, and a bare
  rewriteVersion drift with nothing drained no longer reports a change
  at all.

Verification: go build ./... is clean; go test ./... has 3 pre-existing
failures unrelated to this change (a reasoning-warn-state incident
test, an X11-clipboard test that needs a display, and a
root-vs-unwritable-symlink test), each confirmed via git stash to fail
identically on unmodified main-v2. All 8 new/updated tests covering
this change pass explicitly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-06 17:42:08 +08:00
SivanCola 6c22ae0a3f fix(desktop): repair Windows uninstall registration / 修复 Windows 卸载注册项 (#7729)
* fix(desktop): repair Windows uninstall registration

Problem:
- Users upgrading from legacy 0.53 builds can retain a stale Apps & features entry whose uninstaller removes the current installation.
- Versioned Windows updates do not refresh the registered display version.

Root cause:
- The legacy Tauri and current Wails installers use different HKCU uninstall-key identities.
- Staged NSIS updates intentionally skip registry writes and left metadata stale.

Fix:
- Reuse and migrate a legacy install root only when its uninstaller is present, then delete the legacy alias only after writing the current entry.
- Reconcile owned Wails registrations after version activation while leaving portable, unrelated, and legacy-only trees untouched.
- Add focused migration, ownership, installer-ordering, and update-handoff coverage.

Verification:
- cd desktop && go test ./...
- GOOS=windows GOARCH=amd64 go test -c ./internal/winuninstall
- GOOS=windows GOARCH=amd64 go test -c ./cmd/update-helper
- git diff --check

* Prevent legacy installer from reclaiming current install

Problem: Re-running the Tauri 0.53 installer after a migration could overwrite the current Reasonix uninstaller and recreate the stale uninstall entry.

Root cause: Tauri persisted its install root in a separate manufacturer/product registry value that the migration left behind.

Fix: Clear only a same-root legacy default value before deleting the old uninstall alias, preserve named values and separate legacy roots, and cover the registry and NSIS ordering contracts.

Verification: desktop go test ./...; focused race test; Windows amd64 test cross-compiles; makensis macro smoke compile; git diff --check.
2026-08-06 17:37:42 +08:00
SivanCola fbab42452a fix(desktop): keep reused blank sessions on the default provider / 修复复用空白会话选错默认供应商 (#7736)
* fix(desktop): align reused blanks with default model

Problem:
New-session actions could reuse an empty tab whose same-named model still belonged to a different provider.

Root cause:
The blank-tab reuse path returned before applying the resolved new-session default, and stale empty-session metadata could override the corrected tab model during startup.

Fix:
Canonicalize new-session refs, retarget ready blank runtimes through the failure-atomic model switch, restart superseded startup generations, and persist the canonical provider/model identity.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -race . -run 'TestEnsureBlankTab(RetargetsReusedSameNameModelToDefaultProvider|RestartsStartingBlankWithDefaultProvider|UsesGlobalSessionDefaultsForModelAndToolApproval|ReusesExistingBlankTab|ReusesPrecreatedBlankBeforeControllerReady)' -count=1
- cd desktop && go vet ./...
- git diff --check

* fix(desktop): serialize blank model persistence

Problem:
A reused blank session could finish writing the default provider after a newer explicit model selection, leaving the live controller and persisted model metadata inconsistent.

Root cause:
The reuse path persisted the default model after SetModelForTab had released runtimeRebuildMu, so the stale completion was outside the model-switch last-click-wins boundary.

Fix:
Persist canonical model refs inside SetModelForTab while the rebuild mutex is held, conditionally repair same-model stale metadata through the same mutex, and remove the unlocked follow-up write.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -race . -run 'TestEnsureBlankTab(RepairsStaleStoredProviderWhenRuntimeAlreadyDefault|ConcurrentModelSwitchKeepsLastSelection|RetargetsReusedSameNameModelToDefaultProvider|RestartsStartingBlankWithDefaultProvider)' -count=1
- cd desktop && go test -race . -run 'TestSetModelForTab(ReusesCurrentSessionLease|WaitsForConcurrentBlankSessionLease|LeaseHeldKeepsCurrentController|ReattachesDetachedRuntime)' -count=1
- cd desktop && go vet ./...
- git diff --check
2026-08-06 17:34:19 +08:00
YHH 7703b22a12 feat(bench): run SWE-bench Verified, and keep the accounting honest (#7733)
* feat(bench): SWE-bench primitives verified against the real harness

Image naming, conda activation, prompt construction, predictions encoding and
grader-report parsing, each pinned by a test against output captured from
swebench 4.1.0 on a real gold run. The orchestration that drives containers
lands next; these are the pieces whose shape could only be learned by running
the harness, not by reading it.

* feat(bench): run SWE-bench inside the official instance images

The agent now runs where the harnesses it is compared against run: inside the
per-instance evaluation container, against /testbed, with the repo's own tests
available to it. Anything less measures a different task.

The container is the isolation boundary, so the in-container config turns the
bash sandbox off — Reasonix otherwise refuses to run bash at all when it cannot
find bubblewrap, which the official images do not ship, and an agent that
cannot run tests is not doing SWE-bench. Nothing else deviates from the shipped
defaults: the provider, model and context window are what a user gets on
install, because a hand-tuned config would make the published number
unreproducible.

Credentials stream into the container over stdin rather than through argv or an
image layer, and the issue text is fully shell-quoted before it becomes one
argv element — a problem statement is untrusted input that routinely contains
backticks and $().

Resolution is never decided here; predictions go to the official grader and its
report is parsed back into the published failure taxonomy.

* feat(bench): publish the SWE-bench Verified subset and how it was chosen

The subset is 50 of the 500 Verified instances, and the script that produced it
ships alongside it. Selection is deterministic — largest-remainder apportionment
across repos, then evenly spaced picks within each repo ordered by difficulty —
so re-running it against the same dataset revision reproduces the file byte for
byte. A sample nobody can regenerate is a sample that can be quietly retuned
after seeing results.

Composition tracks the real benchmark: django 23 of 50 against 46.2% of the
full set, sympy 8 against 15.0%, and difficulty lands at 18/28/4 against the
full set's 38.8/52.2/8.4%. Nothing is excluded, including the psf/requests
instance whose test suite makes live network calls; if it exhausts the grader
timeout it is published as eval_timeout, because dropping the instances a
harness handles badly is how a benchmark stops meaning anything.

* feat(metrics): keep accounting when the agent is killed, and say where it came from

A benchmark run that times out currently reports nothing: the metrics file is
written once, after the controller returns, so a killed agent leaves no trace
of the fifty tool calls it made. In the last SWE-bench run one solved instance
landed in the totals as a free solve, quietly making every per-task figure
cheaper than the truth.

The sink now publishes a throttled snapshot to a `.partial` sidecar as the run
proceeds, through the repository's existing atomic write so a reader only ever
sees a whole file. The final record is marked complete and retires the sidecar,
so the two can never both be counted. The harness prefers the final record and
falls back to the snapshot, reporting recovered numbers as lower bounds rather
than as fact.

Usage is also split by origin. Steps counts every billed model call — executor,
planner, sub-agents, compaction, classifier, router — which is why published
rows read above a max_steps budget the main loop never exhausted. An origin the
build does not recognise keeps its own key rather than being dropped, because a
total that cannot reconcile is worse than one with an unfamiliar row.

Snapshotting made an existing race consequential: Emit is serialized by the
session's event.Sync wrapper, but the run command read the same struct directly
while background jobs could still emit into it. The sink now owns a mutex and
hands out deep copies.

The race test needs `go test -race`, which cannot run on a host without cgo;
CI's race job is what covers it.
2026-08-06 17:27:58 +08:00
SivanCola 6af8d1f5b5 fix(goal): remove token hard limits (#7725)
Problem:
Goal mode could pause before the first provider request when the assembled context exceeded an inferred 200k/400k token quota. The request-budget gate could also remain attached after a long Goal and block later ordinary instructions.

Root cause:
The host conflated cumulative autonomous-work safety with per-request context size, enforcing class-derived token ceilings at every provider boundary.

Fix:
- remove provider request-budget admission and token-based Goal pauses
- retain turn/no-progress safeguards and observational token accounting
- migrate legacy budget_tokens sidecars without losing todos
- classify bare fault goals for the write turn quota and update CLI/Desktop/docs

Verification:
- go test ./internal/provider ./internal/agent ./internal/control ./internal/acp
- go test -race ./internal/agent ./internal/control
- git diff --check
2026-08-06 17:12:46 +08:00
SivanCola 5509b36df6 fix(cli): transcript scroll state, wrap cache, and mouse re-enable / 滚动状态机、wrap 缓存与鼠标重启用 (#7706)
* fix(cli): transcript scroll state, wrap cache, and mouse re-enable

Problem
- Opening approval/chooser panels shrank the viewport and flipped AtBottom
  false, which permanently disabled tail-follow until a manual return to
  bottom (#6430). Long sessions with frequent panels looked "broken" (#6978).
- Every streaming commit re-wrapped the entire joined transcript, so long
  sessions paid full O(history) cost on each keystroke of agent output.
- Windows ConPTY dropped mouse tracking after long turns/resize/focus loss;
  wheel then became Up/Down history cycling (#7583). /mouse toggle twice was
  the only recovery.
- Composer cursor coordinates were not clamped to terminal bounds after VS
  Code fullscreen/resize storms (#6282, #7236).

Root cause
- Tail-follow relied on a pre-Update AtBottom snapshot, not an explicit
  followTail/userScrolled mode; modal height changes were misread as user
  scroll. Wrap cache did not exist. Mouse mode was set only in View() without
  re-emitting enable sequences. Cursor position was raw offset math.

Fix
- Explicit scrollFollowMode: wheel/PgUp/scrollbar leave follow; empty Enter,
  Ctrl+End, forceGotoBottom restore it. Height-only modal changes keep
  followTail and re-pin when appropriate.
- Per-block wrap cache with append-only extension; full rebuild on width
  change, mid-history rewrite, or dirty reflow.
- Rate-limited mouse enable on resize, FocusMsg, and TurnDone.
- clampCursorToTerminal on both alt-screen and native-scrollback paths.

Verification
- go test ./internal/cli -count=1
- TestModalOpenDoesNotDisableTailFollow
- TestUserScrollBreaksAndEmptyEnterRestoresFollow
- TestWrapCacheAppendOnlyMatchesFullRebuild
- TestLongTranscriptAppendKeepsFollowAndDoesNotFullRebuildEachTime (2k blocks)
- TestMouseReenableRateLimit, TestClampCursorToTerminal

Refs #6282 #6430 #6978 #7028 #7180 #7236 #7537 #7583 #6563

* fix(cli): suffix wrap invalidation, drag scroll sync, trailing mouse re-enable

Problem
- streamAnswer set transcriptDirty → forceFullWrap rebuilt the entire wrap
  cache on every flushed paragraph, the long-session hot path #7706 claimed
  to remove.
- Scrollbar drag only synced scrollMode on MouseRelease, so agent events
  mid-drag still saw followTail and GotoBottom.
- Mouse re-enable rate limit dropped in-window requests with no trailing
  retry; the last resize in a storm could leave ConPTY without tracking.
- Lint: SA9003 empty branch, unused resetThenEnableMouseTracking/modalOpen.

Root cause
- Collapsed "dirty" with "full rebuild"; gesture state lagged YOffset; leading
  edge only rate limit.

Fix
- invalidateWrapFrom / rewriteTranscriptBlock for live rewrites; Update only
  force-full-wraps on width change or history shrink; suffix path re-wraps
  from wrapBlockCount; feedViewportContent uses SetContentLines.
- dragScrollbar and edge autoScroll sync scrollMode on every YOffset change.
- maybeReenableMouse sets pending + arms trailing timer (mouseReenableMsg);
  controllable mouseNowFn for tests without sleep.
- batchCmds collapses nil; mouse re-enable only on real size change.

Verification
- go test ./internal/cli -count=1
- TestStreamAnswerSuffixInvalidationNotFullRebuild
- TestScrollbarDragMotionSyncsBeforeRelease
- TestMouseReenableTrailingEdge (fake clock)
- BenchmarkStreamAnswerSuffixWrap hist=500/1000/2000 (~const allocs/op)

Refs #6430 #6978 #7583 #6282 #7236

* fix(cli): clear Integration C lint and docs-impact blockers

- Fill empty answerIdx branch with t.Fatal; use wrappedContentString
- Combine fakeNow declare/assign; drop unused mouseReenableSeq generation
- Trailing mouse timer is single-armed pending only (no stale seq)

Verification: go test ./internal/cli -count=1; staticcheck ./internal/cli
2026-08-06 17:02:56 +08:00
zhaotian1 61422d0499 fix(desktop): refresh todo panel from live todo_write events
MetaForTab only refreshes on turn_done / focus change, so mid-turn
todo_write status flips stayed frozen behind a stale canonicalTodos
snapshot. Prefer the live transcript tool snapshot when present (#7642).
2026-08-06 15:59:11 +08:00
zhaotian1 7281ba2749 fix(serve): replay pending prompts when SSE clients attach
Late-connecting browsers never saw the original approval/ask SSE frame, so
confirm-gated tools could block for hours with no card. Re-emit parked
prompts on /events subscribe (#7643).
2026-08-06 15:55:25 +08:00
SivanCola 835c827dea fix(cli): slash catalog cache and input key contracts / 斜杠补全缓存与输入快捷键契约 (#7690)
* fix(cli): cache slash catalog and harden input key contracts

Problem:
- Every keystroke rebuilt the full slash menu (skills, commands, MCP
  prompts, extension actions), causing lag after / and with many skills
  (#6417, #7090).
- Ctrl+D always quit, even with draft text in the composer (#7638-class).
- Shift+Tab failed on terminals that emit "backtab" (#6660).
- @ completion scanned to end-of-line, ignoring caret position (#6719).

Root cause:
- slashItems() reconstructed skill descriptions on every updateCompletion.
- chatTUI intercepted ctrl+d before the textarea forward-delete binding.
- activeAtToken assumed the caret was at EOF.

Fix:
- Immutable slash catalog rebuilt only when commands/skills/host sources
  change; keystroke filtering uses the cached snapshot.
- Ctrl+D forward-deletes when non-empty; quits only when idle and empty.
- Accept "backtab" as Shift+Tab for plan-mode cycling.
- activeAtToken / acceptCompletion respect caret byte offset; accept
  moves the caret only to the end of the inserted token.
- BenchmarkSlashCompletionKeystroke with 1000 skills (~0.2ms/op locally).

Verification:
- go test ./internal/cli -count=1
- go test ./internal/cli -bench=BenchmarkSlashCompletionKeystroke -benchtime=50x

* fix(cli): correct completion cursor span and slash catalog hot path

Problem
- inputCursorByteOffset treated textarea Cursor.X as content columns, but
  that coordinate includes the "❯ " prompt gutter (+2), so mid-line @ accept
  ate the trailing space (see @foo and more → see @foobar.mdand more).
- accept replaced only [@, cursor), leaving a token suffix (@foobar.mdo).
- slashItems still fingerprinted the full catalog on every keystroke
  (~0.9MB/op heap churn) despite the cached slice.
- Ctrl+D treated whitespace-only input as empty and quit.

Root cause
- Prompt-relative cursor without composerPromptWidth adjustment; incomplete
  token span; fingerprint-based invalidation on the filter hot path;
  TrimSpace emptiness check.

Fix
- Subtract composerPromptWidth and map rune→byte for the caret offset.
- Track replaceFrom/replaceTo for the full token; accept replaces [from,to).
- Rebuild slash catalog only after invalidateSlashCatalog (model switch,
  skill rescan, /reload-cmd); ordinary keystrokes only filter.
- Ctrl+D uses raw input.Value() != "".
- Tests: full-token accept, prompt-gutter cursor, whitespace Ctrl+D,
  shift+tab and backtab encodings; benchmark reports allocs.

Verification
- go test ./internal/cli -count=1
- go test ./internal/cli -bench=BenchmarkSlashCompletionKeystroke -benchmem -benchtime=50x
  → ~0.11ms/op, ~428KB/op, ~78 allocs/op (warm catalog; no fingerprint walk)

Refs #6719

* fix(cli): caret-limited @ query and host-driven catalog invalidation

Problem
- activeAtToken filtered with the full token past the caret (@fo|o → "foo"),
  so mid-token edits matched the trailing half instead of the typed prefix.
- MCP host updates (MCPSurfaceReady, connect/disconnect/remove/import, auth
  clear) refreshed m.host without invalidating the slash catalog, so prompt
  menus stayed stale (slashCatalogOnce remained true).
- SA9003 empty branch in TestCtrlDForwardDeletesWhitespaceOnly blocked lint.
- modeToggleKey lived only in tests and did not exercise production "backtab".

Root cause
- Collapsed filter query and replace span into one slice; host assignment
  paths bypassed catalog invalidation; test-only key helper.

Fix
- activeAtToken returns query = val[at+1:cursor] and end = tokenEnd (full
  replace span unchanged).
- refreshHostAndInvalidateSlashCatalog / setHostAndInvalidateSlashCatalog
  cover all host mutation paths including MCPSurfaceReady and /mcp add|remove.
- modeToggleKey is production; Update handles both encodings via the helper;
  tests drive KeyTab+Shift and Text:"backtab".
- Drop empty if-cmd branch.

Verification
- go test ./internal/cli -count=1
- TestMCPSurfaceReadyInvalidatesSlashCatalog
- TestActiveAtTokenFullSpanAndMidCursor (query fo, span @foo)
- TestShiftTabAndBacktabBothAccepted through Update

Refs #6719
2026-08-06 15:40:27 +08:00
SivanCola 914c691819 Persist private temp files across Bash calls / 让 Bash 临时文件在会话内持久 (#7664)
* fix(sandbox): persist private temp files per session

Problem:
Linux bubblewrap created a fresh tmpfs at /tmp for every Bash call, so agents lost temporary scripts and files between commands.

Root cause:
The sandbox lifetime was per process invocation and had no logical-session temporary resource or process lease ownership.

Fix:
Add a session-private temporary manager with generation rotation, owner and process leases, stale cleanup, cross-platform temp environment propagation, ACP terminal support, and hot-rebuild/sub-agent lifecycle wiring.

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/sessiontemp ./internal/filelock
- git diff --check

* fix(cli): preserve session temp across rebuilds

Problem:
The session-private temp contract was automatic, but the CLI rebuild paths lacked direct lifecycle coverage and the cross-platform guidance did not make the zero-configuration contract explicit.

Root cause:
CLI model/profile rebuilds carried the manager through inline wiring, while Windows PR CI did not execute the real standard-temp cross-call test.

Fix:
Centralize the CLI manager handoff, cover /model, /effort, and /reload manager/generation retention, document automatic TMPDIR/TMP/TEMP injection with portable examples, and add focused Windows CI coverage.

Verification:
- go test ./internal/tool/builtin
- go test ./internal/cli
- go test -race ./internal/cli -run 'TestCLIHotRebuildPathsKeepSessionTemp|TestSessionTempFromCLIControllerHelper' -count=1
- Windows amd64 test-binary cross-compilation for builtin and CLI packages
- git diff --check

* test(windows): cover selected session temp shell

Problem: the Windows regression assumed every host selects PowerShell, but Reasonix prefers Git Bash when it is installed, so CI fed PowerShell syntax to bash.\n\nRoot cause: the test chose command syntax from GOOS instead of the shell resolver used by production.\n\nFix: derive syntax from the resolved shell and explicitly exercise native PowerShell as a second Windows subtest.\n\nVerification: go test -count=1 -run '^TestBashSharesSessionTempAcrossCalls$' ./internal/tool/builtin; git diff --check.
2026-08-06 15:02:39 +08:00
84988e8cdb fix(cli): TUI diagnostics, keyring timeout, Reasonix-home paths / 启动诊断、keyring 超时与 home 路径加固 (#7663)
* fix(cli): harden TUI diagnostics, keyring migration, and home paths

Problem:
- Interactive TUI freezes left zero-byte cli-tui logs and unrecoverable
  terminals on Windows ConPTY and stuck D-Bus startups (#7435, #7507).
- Global hooks/doctor/capdiag could miss the real Reasonix home on Windows
  AppData and REASONIX_HOME isolation (#7411, #7331).
- Legacy keyring migration blocked startup when Secret Service hung.
- #7600's automatic HOME rewrite risked breaking isolation contracts.

Root cause:
- Diagnostics started after heavy work and never flushed milestones/heartbeats.
- Hooks resolved global settings via derived ~/.reasonix rather than the exact
  Reasonix home used by the rest of the product.
- Keyring probes ran unbounded per key with file import ordered after D-Bus.
- Subprocess HOME rewriting conflates service HOME with Reasonix isolation.

Fix:
- Start TUI diagnostics before config/controller work; record milestones and
  flush immediately; 1s heartbeat with 10s stall → goroutine dump + Program.Kill.
- Unify hook/capdiag/doctor on exact ReasonixHomeDir; public paths show
  <reasonix-home>.
- Legacy credential migration: files first, then shared 1s keyring budget with
  empty-success markers; timeout leaves no marker for retry.
- Doctor warns when process HOME ≠ account home without REASONIX_HOME; do not
  auto-rewrite subprocess HOME (#7600 not adopted).

Verification:
- go test ./internal/cli ./internal/config ./internal/hook ./internal/capdiag ./internal/doctor -count=1

Co-authored-by: Zhiyao Wen <[email protected]>
Co-authored-by: zhaotian1 <[email protected]>

* fix(cli): isolate keyring probes and tighten migration markers

Problem:
- In-process keyring timeouts could leave hung D-Bus goroutines and
  collapse found/absent/error/timeout into a single false, risking wrong
  migration-done markers (#7507 audit).
- Keyring markers also skipped legacy credential *file* import.
- doctor HOME warning leaked absolute account paths into shareable reports.
- lint failed on an unused diagnostics mutex; CodeQL flagged SHA-256 of env
  names as secret-like storage.

Root cause:
- Timeout wrappers cannot cancel platform keyring calls in-process.
- skip() treated keyring markers as global migration completion.
- redactHome only knows the process HOME, not the OS account home.

Fix:
- Run legacy keyring batch probes in an isolated helper child with a shared
  1s deadline; parent Kill+Wait on timeout (no parent goroutine leak).
- Four-state outcomes: found / absent / error / timeout; write markers only
  on confirmed absent.
- File import always runs first; markers only filter keyring probes.
- Marker paths use sanitized env names under legacy-keyring-checked/.
- doctor reports HOME mismatch without embedding either path.
- Watchdog WaitGroup drains before closing the diagnostic log file.

Verification:
- go test ./internal/config ./internal/doctor ./internal/cli ./internal/hook ./internal/capdiag -count=1
- go vet ./internal/cli ./internal/config ./internal/doctor

* fix(cli): keyring four-state to metal and secret-free helper

Problem:
- Platform keyring probes still returned (string, bool), so D-Bus/unlock
  failures were treated as absent and could write migration markers.
- The isolated helper printed keyring secrets on stdout, making any
  process that can launch reasonix a credential oracle.
- Watchdog Close waited only 2s, so a stuck Stack/Sync/Kill could write
  a closed diagnostic file.
- Marker names folded distinct keys onto "_" collisions.

Root cause:
- Four-state status stopped at the batch layer; the Secret Service path
  collapsed every error to false.
- Helper was designed as a value RPC instead of an in-child store step.
- Close used a timed wait instead of joining the watchdog fully.

Fix:
- legacyKeyringProbe returns found/absent/error at the platform layer
  (ErrNotFound/empty search → absent; transport/unlock/session errors →
  error).
- Helper stores found secrets via StoreCredentialLines and emits only
  key+status metadata on stdout (no value field).
- Parent never receives secret Values from the helper path; markers only
  on confirmed absent.
- Watchdog WaitGroup is joined fully before closing the log file.
- Marker basenames use base64.RawURLEncoding of the env name.

Verification:
- go test ./internal/config ./internal/cli ./internal/doctor -count=1
- New tests: error does not write marker; helper JSON has no secrets;
  RawURL marker collision-free; four-state in-process outcomes

* fix(cli): drop keyring helper oracle; atomic store-if-absent

Problem:
- The REASONIX_LEGACY_KEYRING_HELPER entrypoint let any process that can
  launch reasonix dump keyring secrets into a caller-controlled
  REASONIX_HOME/.env (disk oracle after stdout was scrubbed).
- Legacy import could overwrite a concurrent settings write or tombstone
  because presence was checked outside the credential lock.

Root cause:
- Migration used an external helper to bound D-Bus hangs, which reopened
  a secret export surface via StoreCredentialLines + controllable home.
- store used unconditional StoreCredentialLines after a TOCTOU check.

Fix:
- Remove the helper entrypoint entirely (main + env dispatch).
- Linux/BSD Secret Service probes use godbus CallWithContext under a
  shared 1s batch deadline so stuck D-Bus calls cancel in-process.
- Found secrets are written only via storeCredentialIfAbsentAndNotCleared
  (same advisory lock re-checks missing/not-cleared before write).
- Channel-forced tests cover user-value and tombstone races without sleep.

Verification:
- go test ./internal/config ./internal/cli ./internal/doctor -count=1
- REASONIX_LEGACY_KEYRING_HELPER=1 reasonix --version is a normal version
  print (no helper path, no oracle home writes)

* fix(cli): private ctx-bound D-Bus keyring and real probe races

Problem:
- dbus.SessionBus left process-global godbus workers that failed goleak
  under Ubuntu/race/coverage.
- GetProperty and session Close used non-context call paths, so a stuck
  bus could still hang CLI past the 1s migration budget.
- Race tests called store-if-absent directly and never forced the
  production probe→store window.

Root cause:
- Shared SessionBus + GetProperty(Background) escaped the batch ctx.
- Channel tests interleaving happened outside lookupLegacyKeyringBatch.

Fix:
- Open a private ConnectSessionBus per probe, race Dial/Auth/Hello
  against the shared ctx, and always Close the connection.
- Resolve Collections and Close sessions only via CallWithContext(ctx).
- Drive store-if-absent races through a blocking probe stub inside
  lookupLegacyKeyringBatch (user write and tombstone cases).
- go mod tidy promotes godbus to a direct dependency.

Verification:
- go test ./internal/config -count=1
- go test -race ./internal/config -count=1
- go test ./internal/control -count=1

* fix(cli): bind keyring bus connect to batch context

Problem:
- ConnectSessionBus had no dbus.WithContext, so a stuck Auth/Hello after
  Dial could keep the connect goroutine and late reaper parked forever
  even though the caller returned at 1s.

Root cause:
- Connection lifecycle was only raced externally; godbus never received
  the migration ctx to Close the transport on deadline.

Fix:
- SessionBusPrivateNoAutoStartup(dbus.WithContext(ctx)) then Auth+Hello,
  so cancel closes the transport and unblocks Auth/Hello I/O.
- Avoid dbus-launch (NoAutoStartup): missing session address fails closed
  instead of hanging in CombinedOutput.
- Outer select still bounds the caller; late *Conn is always closed.
- Document residual: pure Dial hang before newConn remains a godbus limit.

Verification:
- go test ./internal/config -count=1
- go test -race ./internal/config -count=1
- go test ./internal/control -count=1
- GOOS=linux go test ./internal/config -c

* ci: allow Windows smoke runner variance

Problem: The Windows smoke step regularly takes 7-9 minutes and the current run exceeded its 10-minute job budget even though vet and build succeeded.

Root cause: The job-level timeout leaves too little headroom for hosted Windows runner variance.

Fix: Raise only the pull-request Windows smoke job budget to 15 minutes while retaining the per-package 3-minute Go test timeout.

Verification: YAML parse; git diff --check; go test ./internal/cli ./internal/config -count=1.

---------

Co-authored-by: Zhiyao Wen <[email protected]>
Co-authored-by: zhaotian1 <[email protected]>
2026-08-06 14:39:56 +08:00
Weikeng ChenandSivanCola 44f749eae0 fix(desktop): support macOS workbench sidebar titlebar zoom (#4754)
* feat(desktop): double-click header areas triggers native macOS window zoom

On macOS with TitleBarHiddenInset, the WKWebView covers the title bar
region, preventing NSWindow from receiving double-click events for the
native zoom action.  Add onDoubleClick handlers to three top-bar
regions — sidebar spacer, topicbar, and workbench-dock tools — that
call Wails' built-in WindowToggleMaximise ([NSWindow zoom:]).

All handlers gate on macOS + workbench mode only, and exclude button
children from triggering zoom.

* fix(desktop): avoid double zoom on native drag regions

Remove the JS onDoubleClick handlers on topicbar and workbench-dock__tools.
Both are --wails-draggable regions, so macOS already fires the native
NSWindow zoom on double-click; adding WindowToggleMaximise on top toggled
twice (zoom in then immediately out). Keep the JS handler only on the
sidebar spacer, which is not a native drag region.

* fix(desktop): preserve macOS workbench titlebar geometry

Problem:
Replacing the 46px macOS workbench padding with a flex spacer stacked it
on top of the base 14px padding and shifted sidebar content to 60px.

Root cause:
The spacer changed layout ownership and bypassed the existing centralized
titlebar double-click path.

Fix:
Keep the existing 46px padding and recognize only that top sidebar strip
in handleChromeTitlebarDoubleClick. Reuse the app bridge and add regression
guards for geometry and routing.

Verification:
- pnpm exec tsx src/__tests__/app-chrome-tabs.test.ts
- pnpm build
- CI=true wails build -platform darwin/arm64
- Native macOS zoom/restore and below-titlebar boundary checks

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-08-06 04:13:35 +00:00
918d8b1a9a fix(cli): land independent CLI feedback PRs on main-v2 (#7656)
Rebase and verify the small, independently-testable CLI fixes onto latest
main-v2 so they stop rotting behind required checks.

Problem:
- Several high-value contributor PRs were MERGEABLE but BLOCKED: required
  lint/race/test matrix never re-ran against current main-v2.
- Users still hit missing --profile dispatch, machine-session resume,
  usage-receipt clutter, Ctrl+Backspace, Insert copy/paste, and long-session
  rewind list overflow.

Root cause:
- Fork heads drifted from main-v2; reusing their green checks was unsafe.
- Features are small and orthogonal, so they belong as focused landing work
  before the larger integration PRs.

Fix:
- #7621: route bare `--profile` into the interactive session path.
- #7608: resolve `run --resume` against machine session IDs and document QUERY.
- #7587: add `[ui].show_turn_usage` (default true) without disabling accounting.
- #7605: accept Ctrl+Backspace as DeleteWordBackward with Alt+Backspace/Ctrl+W.
- #7262: Ctrl+Insert copies selection without Ctrl+C side effects; Shift+Insert
  pastes via the native clipboard path (SSH-safe).
- #7202: store raw user text in checkpoints and window the rewind picker for
  long sessions.

Verification:
- go test ./internal/cli ./internal/config ./internal/control -count=1
- focused tests for profile, machine resume, show_turn_usage, word motion,
  Insert keys, rewind windowing, and raw checkpoint prompts

Co-authored-by: Hezi-crypto <[email protected]>
Co-authored-by: zhaotian1 <[email protected]>
Co-authored-by: azhonglee <[email protected]>
Co-authored-by: rixzkiye <[email protected]>
Co-authored-by: rongbaichuan <[email protected]>
Co-authored-by: mife <[email protected]>
2026-08-06 11:56:17 +08:00
SivanCola 77cf9aa708 Merge pull request #7629 from esengine/codex/fix-npm-registry-convergence
fix(release): tolerate npm registry visibility lag / 容忍 npm 注册表可见性延迟
2026-08-05 23:16:22 +08:00
SivanCola 8c1c338284 fix(release): tolerate npm registry visibility lag 2026-08-05 23:03:06 +08:00
SivanCola 260c89709e Merge pull request #7625 from esengine/codex/release-v1.20.0-gates
fix(release): restore v1.20.0 cross-platform gates / 恢复 v1.20.0 跨平台门禁
2026-08-05 22:01:12 +08:00
SivanCola 0a21bf327d docs(release): include v1.20 gate fixes
Summary:
- add bilingual release notes for the cross-platform candidate fixes
- link the reviewed entry to PR #7625

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- rendered English and Chinese v1.20.0 notes
2026-08-05 21:43:07 +08:00
SivanCola f2bc54fab3 fix(release): restore cross-platform v1.20 gates
Summary:
- make extension content eviction deterministic and plugin path checks portable
- stabilize the parallel-task cancellation coverage test
- keep POSIX mode assertions meaningful on Windows and serialize tmux fixtures safely

Verification:
- go test -covermode=atomic ./...
- go test -race on all changed packages
- go vet ./...
- Windows test binaries cross-compiled for all changed packages
2026-08-05 21:41:51 +08:00
SivanCola fe5afb2d02 Merge pull request #7622 from esengine/release-notes/v1.20.0
docs(release): prepare v1.20.0 notes / 准备 v1.20.0 中英更新日志
2026-08-05 21:24:08 +08:00
SivanCola e3b12e1c56 docs(release): correct v1.20.0 scope
Summary:
- Represent every affected product surface instead of describing the release as Desktop-only.
- Use a descriptive bilingual title and identify the Chinese-only Goal guide honestly.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- node scripts/release-notes.mjs render --version 1.20.0
- git diff --check
2026-08-05 21:21:38 +08:00
github-actions[bot] 2029ba1fe9 docs(release): prepare v1.20.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.
2026-08-05 13:18:08 +00:00
SivanCola 73bc4e0c1a Merge pull request #7576 from SivanCola/feature/compact-decision-surfaces
feat(desktop): compact decision surfaces and record local receipts / 压缩决策弹窗并记录本地回执
2026-08-05 21:12:39 +08:00
SivanCola ecd88aa3e4 Merge latest main-v2 into compact decision surfaces
Problem:
The PR conflicted with the new Extension Protocol UI and event-wire surfaces on main-v2.

Root cause:
Both branches extended the same footer state, frontend event types, controller return surface, CSS shell, and Go event contracts.

Fix:
Preserve extension forms, cards, events, handlers, and budgets while retaining compact decision mocks, plan resolution, decision receipts, and overflow disclosure behavior.

Verification:
Go provider, agent, control, and eventwire tests
Ask 87/87, decision surface 172/172, receipt 6/6, extension surface 43/43
frontend typecheck, CSS checks, and production build
2026-08-05 20:48:07 +08:00
SivanCola 8426c360f4 fix(decisions): preserve compact fallback behavior
Problem:
Long labels could lose their full decision text, completed Ask receipts repeated the outcome, and cancelled turns could drop structured receipt metadata after reload.

Root cause:
Overflow disclosure only measured descriptions, cancellation sidecars copied plain notice fields, and the cache-sensitive Ask contract lacked an explicit stability guard.

Fix:
Measure label and description overflow independently, keep raw answer values unchanged, persist decision receipts through cancellation sidecars, remove redundant receipt copy, and pin the existing Ask provider contract hash.

Verification:
go test ./internal/agent -count=1
focused Ask, decision-surface, and transcript frontend tests
frontend typecheck and CSS checks
git diff --check
2026-08-05 20:45:20 +08:00
SivanCola 627b05170b Merge pull request #7368 from SivanCola/fix/remote-workbench-reconnect-hardening
test(remote): lock Remote Web recovery after SSH drops
2026-08-05 20:36:29 +08:00
SivanCola 40dd497e11 Merge pull request #7373 from SivanCola/feature/extension-kernel-protocol-v1
Unified extension kernel and Extension Protocol v1 / 统一扩展内核与 Extension Protocol v1
2026-08-05 20:35:58 +08:00
SivanCola 159e8117b8 fix(extension): bound startup and redact peer errors
Problem:
Sidecar protocol errors could surface credential-shaped peer messages, and stalled optional runtimes could multiply boot latency by the installed package count.

Root cause:
The host preserved JSON-RPC error messages verbatim and started every runtime sequentially with an independent handshake timeout.

Fix:
Redact peer, startup, crash, and boot-warning messages at host boundaries. Start runtime packages with a four-worker pool under one shared 30-second generation budget while preserving deterministic result ordering. Document the lifecycle and performance contract.

Verification:
- focused redaction and bounded-start regressions repeated 10-20 times
- go test -race ./internal/extension/... ./internal/boot ./internal/control ./internal/serve -count=1
- go test ./...
- go test ./... in sdk/go and desktop
- cache guard, site test/build, targeted vet, and protocol generation check
2026-08-05 20:19:19 +08:00
SivanCola 2d78976c95 Merge updater recovery fixes into remote regression
Problem: main-v2 advanced again after the previous latest-base validation.

Root cause: the updater recovery PR merged while the Remote Web regression branch was being revalidated.

Fix: merge the new base without changing the focused Remote Web recovery test or documentation diff.

Verification: the merge is conflict-free, preserves the three-file +147/-1 three-dot diff, and passes git diff --check.
2026-08-05 20:09:59 +08:00
SivanCola 125437d9cd Merge remote-tracking branch 'origin/main-v2' into fix/pr-7373-performance-race-cache 2026-08-05 20:09:45 +08:00
SivanCola 62f38dfec1 Merge pull request #7615 from SivanCola/fix/updater-recovery-webkit-diagnostics
fix(desktop): recover stuck updates and legacy WebKit / 修复更新卡死与旧版 WebKit
2026-08-05 20:08:27 +08:00
SivanCola 6fd952a874 Merge latest main-v2 into remote recovery regression
Problem: PR #7368 no longer included the latest main-v2 after the task monitor and macOS alias fixes merged.

Root cause: the base branch advanced after the previous recovery regression validation, leaving the PR merge-base stale.

Fix: merge the current main-v2 while preserving the focused three-file Remote Web recovery regression and documentation diff.

Verification: the latest-base merge tree passed the SSH-drop regression ten times, the race-enabled regression three times, and git diff --check.
2026-08-05 20:07:26 +08:00
SivanCola 0ef143a996 fix(frontend): bound decision surface CSS growth
Problem:
The compact decision surfaces pushed the merged frontend CSS bundle slightly above the existing gzip budget.

Root cause:
Shared decision controls repeated several equivalent declarations, while the 112 KiB budget had no allowance for the approved always-loaded approval styles.

Fix:
Consolidate equivalent selectors, simplify the one-line truncation rule, and increase the CSS gzip budget by a bounded 1 KiB.

Verification:
- pnpm build
- decision-surface, ask-card-layout, approval-modal-file-reference, and footer-decision-overflow tests
- pnpm test:task-monitor
- pnpm test:typecheck
2026-08-05 19:20:57 +08:00
SivanCola 9444684d08 Merge remote-tracking branch 'origin/main-v2' into feature/compact-decision-surfaces 2026-08-05 19:18:28 +08:00
SivanCola f9be137949 fix(decisions): preserve approval history semantics
Problem:
Decision receipts could split an assistant tool call from its real result, and Plan start, revise, and exit choices collapsed into generic approval outcomes.

Root cause:
Receipts were stored as standalone local-only assistant messages, while the desktop bridge represented every Plan choice with the legacy approval boolean.

Fix:
Attach receipts to the owning assistant message, migrate the interleaved receipt shape on load, and add an explicit typed Plan decision path across the controller and desktop bridge.

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/agent ./internal/control
- pnpm test
- pnpm build
2026-08-05 19:13:01 +08:00
SivanCola 1de1b9934a Merge current main-v2 into extension protocol
Resolve the Task Monitor and extension UI bundle-budget overlap using measured production output. The combined build remains below bounded JavaScript, CSS, locale, and raw asset limits.

Validation:
- root tests and vet
- SDK tests and vet
- frontend test:all and production build
- site tests and production build
- Desktop vet and build
- extension/Serve/control/jobs race suite
- isolated main-v2 baseline comparison for the macOS TempDir cleanup failure
2026-08-05 19:08:10 +08:00
SivanCola f3e38181c5 fix(desktop): recover interrupted update transactions
Problem:
- macOS updates could launch the replacement bundle without ever committing startup health, permanently blocking later updates.
- Legacy WebKit could reject the Markdown chunk, and native installers hid layout activation failures.

Root cause:
- The visible-window lifecycle no longer called the exact update health commit, legacy app-bundle debris had no transaction-bound retirement path, local-path regexes used lookbehind, and installer/helper handshakes exposed only generic failures.

Fix:
- Commit only the startup-captured update identity and safely archive unrecoverable legacy app-bundle transactions.
- Remove RegExp lookbehind, return structured macOS handoff errors, surface Windows activator output, and mark the localized NSIS script as UTF-8.
- Document the signed overwrite-install recovery path in English and Chinese.

Verification:
- go test ./...
- cd desktop && go test ./...
- cd desktop/frontend && pnpm test:all && pnpm build
- go test -race ./internal/repair -run TestArchiveSupersededPending
- Native macOS arm64 Wails build and Parallels Windows 11 arm64 NSIS install/activation tests
2026-08-05 18:59:10 +08:00
SivanCola 1300dddd51 Merge pull request #6998 from mchenziyi/codex/task-monitor-pr
feat: add a native Task Monitor for Reasonix agents
2026-08-05 18:51:32 +08:00
SivanCola 59ca551ab2 Merge remote-tracking branch 'origin/main-v2' into feature/compact-decision-surfaces
# Conflicts:
#	desktop/frontend/src/App.tsx
#	desktop/frontend/src/generated/remoteProtocol.generated.ts
#	internal/remote/protocol/schema.generated.json
#	internal/remote/protocol/schema_hash.generated.go
2026-08-05 18:48:46 +08:00
SivanCola e9ef9178ad fix(desktop): keep decision details compact
Problem:
Decision rows expanded to two lines or repeated complete danger copy below the action, making approvals visually noisy and inconsistent across Ask, tool approvals, runtime blockers, and context clearing.

Root cause:
Decision descriptions expanded inside the action row, and destructive runtime actions rendered an external detail unconditionally even when their full consequence already fit.

Fix:
Keep desktop decision rows at a stable 38px with single-line summaries, move overflow copy to a separate disclosure, auto-open destructive details only when truncated, add canonical browser mock triggers, and clarify mode-switch and active-close decisions across all locales.

Verification:
- pnpm exec tsx src/__tests__/ask-card-layout.test.ts (75/75)
- pnpm exec tsx src/__tests__/decision-surface.test.tsx (172/172)
- pnpm test:typecheck
- pnpm build
- in-app browser: /mock-mode-jobs and /mock-close-active
2026-08-05 18:47:21 +08:00
SivanCola 2e8a53c56b Merge remote-tracking branch 'origin/main-v2' into fix/pr-7373-performance-race-cache 2026-08-05 18:39:20 +08:00
SivanCola 8743c5bea9 Add extension developer documentation
Problem:
Extension Protocol v1 had repository references but no website entry point or copyable installable starter, leaving developers without a clear path from concepts to a working sidecar.

Root cause:
The protocol implementation was not connected to the public discovery surfaces, SDK release guidance, or embedded-doc search contracts.

Fix:
Add bilingual website and README entry points, a buildable Manifest v1 starter sidecar, immutable SDK tag guidance, and regression coverage for the website and embedded docs.

Verification:
- go test ./... -count=1
- go vet ./...
- sdk/go tests, vet, and Windows cross-build
- site npm test and production build
- isolated plugin dry-run, linked install, and doctor
- desktop and mobile browser checks
2026-08-05 18:39:14 +08:00
SivanCola bd2c2a632a Merge pull request #7610 from SivanCola/fix/macos-alias-startup-crash
fix(desktop): contain macOS alias repair startup crashes / 修复 macOS 别名导致的启动崩溃
2026-08-05 18:31:21 +08:00
SivanCola e5b030505c merge: sync main-v2 and harden task monitor
Problem: The Task Monitor branch predated the current Remote Workbench architecture, its Simplified Chinese locale chunk exceeded the current frontend budget, workspace-controlled tmux mappings could target unrelated sessions, cleanup had a check-to-kill race, and generated names could exceed tmux limits.

Fix: Merge current main-v2; bind mappings to random tmux ownership markers; validate mapping identity and targets; use exact tmux targets with atomic ownership-checked deletion; fail closed for legacy or forged mappings; hash oversized default names; and explicitly budget the compact localized Task Monitor copy.

Verification: Backend, desktop, frontend, race, vet, Windows cross-compile, cache guard, real tmux, Remote Workbench, and live DeepSeek v4 flash Task Monitor tests pass.

Co-authored-by: SivanCola <[email protected]>
2026-08-05 18:19:01 +08:00
SivanCola c449605620 fix(desktop): contain macOS alias repair failures
Problem:
Desktop releases since v1.19.5 can abort before opening a window when a Finder alias resolves to a non-file URL.

Root cause:
The startup icon repair passed every resolved alias URL to NSBundle. Foundation raises NSInvalidArgumentException for non-file URLs, and that Objective-C exception crossed the cgo boundary and terminated the process.

Fix:
Reject non-file alias targets before bundle inspection, convert unexpected Foundation exceptions at every native alias entry point into ordinary errors, and preserve healthy, unrelated, upgrade, and first-install behavior with focused regression coverage.

Verification:
- go test . -run 'Test(RepairMacDesktopAliases|MacBundleOwnership)' -count=10
- go vet .
- go build -o /tmp/reasonix-desktop-icon-repair-check .
- git diff --check
2026-08-05 18:14:38 +08:00
SivanCola 433c399e44 Fix extension stream lint 2026-08-05 18:06:14 +08:00
SivanCola 018184eb74 Merge latest main-v2 2026-08-05 18:01:53 +08:00
SivanCola 2b81011464 Merge main-v2 and harden extension runtime 2026-08-05 18:01:50 +08:00
SivanCola 490706b49b Merge latest main-v2 into remote recovery regression 2026-08-05 17:49:15 +08:00
SivanCola 364630089d Merge pull request #7590 from SivanCola/fix/composer-overflow-stacks
fix(desktop): keep composer overflow stacks readable / 保持输入区溢出列表可读
2026-08-05 17:43:07 +08:00
SivanCola fb41167048 Merge main-v2 and lock Remote Web SSH recovery
Problem:
PR #7368 targeted the Remote Workbench and rpcwire stacks, but main-v2 removed both in favor of SSH Serve plus per-host Remote Web windows. Keeping the old implementation would restore a superseded architecture and leave the replacement recovery path without one end-to-end regression.

Root cause:
The original reconnect state machine and control/data scheduler were coupled to the deleted Workbench protocol. The current architecture recovers at the supervised SSH, persistent forward, and native window layers instead.

Fix:
Merge the latest main-v2 deletion and Remote Web architecture, drop every obsolete Workbench/rpcwire change, add an in-process real SSH drop integration test that proves reconnect, forward continuity, and window reload, and document transient versus terminal recovery behavior in both guides.

Verification:
- cd desktop && go test -run '^TestRemoteWindowRecoversAcrossRealSSHDrop$' -count=10 .
- cd desktop && go test -race -run '^TestRemoteWindowRecoversAcrossRealSSHDrop$' -count=3 .
- go test ./internal/remote/... -count=1
- go test -race ./internal/remote/... -count=1
- cd desktop && go test -run 'Remote|SSH|Serve' -count=1 .
- cd desktop && go test -race -run 'Remote|SSH|Serve' -count=1 .
- cd desktop/frontend && pnpm test:typecheck && pnpm test:remote && pnpm build
- ./scripts/cache-guard.sh
- git diff --check
2026-08-05 17:32:39 +08:00
SivanCola eaffb28e8a Merge pull request #7528 from SivanCola/feature/remove-remote-workbench
Remove Remote Workbench and simplify SSH remote access / 移除 Remote Workbench 并简化 SSH 远程访问
2026-08-05 17:22:11 +08:00
SivanCola 248c78c702 test: restore supported remote CLI coverage
Problem:
Removing the Remote Workbench protocol test file also removed coverage for the still-supported remote add, import, remove, forward, and connect commands.

Root cause:
The obsolete Workbench build-ID assertion shared a file with unrelated remote CLI contract tests, and the whole file was deleted with the protocol package.

Fix:
Restore the supported remote CLI tests and replace the build-ID assertion with migration-stub coverage for all three removed Workbench commands.

Verification:
- go test ./... -count=1
- go test -race ./... -count=1 -timeout 15m
- go test ./... -count=1 -timeout 10m (desktop)
- go test -race ./... -count=1 -timeout 15m (desktop)
- pnpm test:all && pnpm build
- ./scripts/cache-guard.sh
2026-08-05 16:52:07 +08:00
SivanCola ea3f7b5121 Merge branch 'main-v2' into feature/compact-decision-surfaces
Resolve the protocol drift between this branch and main-v2: both sides
regenerated the remote protocol artifacts, so all three conflicts
(remoteProtocol.generated.ts, schema.generated.json,
schema_hash.generated.go) were resolved by regenerating from the merged
protocol definitions with cmd/remote-protocol-gen. Textual auto-merges in
useController.ts, locales, controller/provider/event wiring were kept and
verified semantically: the branch's LocalOnly decision receipts coexist
with main-v2's subagent progress events.

Verification: remote-protocol-gen -check up to date; go build + go test
./... (root and desktop); frontend tsc, eslint, CSS checks; focused tests
from both sides: ask-card-layout 71/71, decision-surface 149/149,
remote-store 28/28, subagent-progress 46/46, subagent-progress-card 18/18.
2026-08-05 16:43:33 +08:00
SivanCola e9a3d38dc1 fix(desktop): size decision option labels by content, not by their own track
Problem: Option labels on every decision surface (Ask, approvals, runtime
blockers, context clearing) rendered crushed into a ~28px column at desktop
widths, breaking words mid-label ("Maintenance window" became
"Maintena/nce/window") in all locales.

Root cause: The 44% width cap was expressed as max-width on the label grid
item. A percentage max-width on an item inside a content-sized (max-content)
grid track resolves against that track (~64px), not against the row, so
labels were capped at 44% of ~64px and overflow-wrap:anywhere shattered the
text. The existing jsdom layout tests assert CSS declarations, so only a
real-browser render exposes this cyclic percentage resolution.

Fix: Move the cap into the track itself: grid-template-columns:
fit-content(44%) minmax(0, 1fr). fit-content() resolves its percentage
against the grid container, giving labels their natural width up to 44% of
the row. Drop the label max-width; the 560px single-column fallback is
unaffected. Add string-level regression assertions so the label rule cannot
re-grow a numeric width cap.

Verification: check-css-syntax and check-z-index-tokens pass;
ask-card-layout 71/71; decision-surface 149/149; tsc -p tsconfig.test.json
clean. Real-browser screenshots (Playwright on Edge; dark/light; en/zh;
1280px and 520px) show single-line full-word labels on Ask, runtime
decision, and clear-context cards.
2026-08-05 16:33:53 +08:00
SivanCola d935da2d21 merge: integrate current main-v2 into PR #7373
Problem: PR #7373 had fallen 48 commits behind main-v2, and the merge introduced protocol conflicts plus cache and provider-metadata compatibility hazards.

Root cause: extension interception and generated protocol artifacts evolved alongside new structured-response and reasoning metadata contracts on main-v2.

Fix: preserve both extension interception and main-v2 response-format semantics, clear provider-bound reasoning metadata after replacement, keep nil response formats byte-stable, and regenerate both protocols.

Verification: go test ./...; go vet ./...; targeted go test -race; SDK race tests; Desktop Go tests; frontend typecheck, remote tests, and build; protocol generator checks; cache guard; extension latency benchmarks.
2026-08-05 16:32:03 +08:00
SivanCola 2bf016213c Merge pull request #7589 from SivanCola/fix/account-community-integrity
fix(web): harden account verification and community flows / 修复账号验证与社区流程
2026-08-05 16:19:46 +08:00
SivanCola fd49f2e000 fix(extension): harden latency race and cache contracts
Problem:
Extension catalog cold reads could issue duplicate concurrent RPCs, observation events could block the Agent behind a wedged sidecar, SDK callback concurrency was implicit, and stable enabled-extension cache behavior had no release guard.

Root cause:
Catalog caching used an unlocked check-then-fetch sequence, event notifications waited for physical writes, and equal host/SDK intercept deadlines could race into different error reasons.

Fix:
Coalesce per-plugin catalog fetches, add a bounded non-blocking notification path that preserves request capacity, freeze SDK timeout classification, document concurrent callbacks, and add real-sidecar p50/p95 benchmarks plus a stable-extension cache guard.

Verification:
go test ./...
go test -race ./internal/rpcwire ./internal/extension/providerext ./internal/extension/dispatch ./internal/extension/sidecar ./internal/boot ./internal/agent ./internal/control
(cd sdk/go && go test -race ./...)
./scripts/cache-guard.sh
go vet ./...
go run ./cmd/extension-protocol-gen -check
2026-08-05 16:19:04 +08:00
SivanColaanderphenheimer 8223cfc0c7 fix(desktop): keep composer overflow stacks readable
Problem:
Repeated pasted-text blocks and attachment rows can grow the composer footer without bound. Simply capping the pasted list also lets its column flex items shrink until their contents are clipped.

Root cause:
The footer children had no bounded overflow area, and pasted blocks retained the default flex-shrink behavior inside a capped column container.

Fix:
Bound the pasted and attachment lists, keep pasted blocks non-shrinking, and add CSS contract coverage for both overflow paths.

Verification:
- footer-decision-overflow.test.ts (19/19)
- composer-resized-input-css.test.ts (7/7)
- message-pasted-blocks.test.ts (9/9)
- composer-session-draft.test.tsx (130/130)
- composer-goal-toggle.test.tsx (228/228)
- pnpm build
- browser: 20 blocks remain readable and scroll inside the capped list

Co-authored-by: erphenheimer <[email protected]>
2026-08-05 16:13:40 +08:00
SivanCola 771eeb1a2f fix(web): harden account and community flows
Problem:
- Email verification links landed on the static site instead of the accounts worker.
- Community responses exposed stored author emails, unverified users could affect moderation, and several account/community controls were incomplete.
- Related multi-statement writes and handle updates could leave partial or racy state.

Root cause:
- The frontend origin was reused as the verification endpoint origin.
- Public forum queries serialized internal identity columns and interaction routes did not share the verified-member gate.
- Forum writes used separate auto-commit statements and profile handles used a check-then-update sequence.

Fix:
- Add a canonical account worker origin, public profile and resend-verification UI.
- Return public handles, gate and rate-limit interactions, implement idempotent likes, and map validation failures to client errors.
- Use D1 transactional batches for related forum writes and atomically claim profile handles.
- Run focused tests and type checks before worker deployments.

Verification:
- accounts: 3 tests, TypeScript check, Wrangler dry-run
- forum: 4 tests, TypeScript check, Wrangler dry-run
- site: 55 tests, Astro build with 42 generated pages
2026-08-05 16:09:08 +08:00
SivanCola c1c3d490b8 Merge latest main-v2 into remote web simplification 2026-08-05 16:00:49 +08:00
SivanCola 2ae3fbfc26 Merge pull request #7566 from clearnature/fix7234and7168
feat(responses): unified MiMo + DashScope Responses wire alignment (#7234 + #7168)
2026-08-05 15:56:54 +08:00
SivanCola e17611b263 fix(extension): address PR #7373 review findings
Merge current main-v2, defensively redact extension provider errors at the host boundary, and normalize the actual Windows cwd in boot goldens.
2026-08-05 15:52:33 +08:00
SivanCola a8f0467406 fix(responses): preserve provider-bound reasoning across hooks
Problem:
Responses reasoning metadata could be persisted beside PostLLMCall-transformed text, and the response-format regression test did not prove per-turn isolation.

Root cause:
Agent storage pinned raw reasoning for signatures and provider policies but ignored reasoning IDs and statuses. The controller test submitted requests without observing the format on either turn.

Fix:
Preserve raw reasoning whenever provider-bound metadata is present. Add a channel-driven finishing-window interleaving test that proves distinct HTTP formats remain attached to their accepted turns, and correct the reference-turn documentation.

Verification:
- go test ./...
- go test -race ./internal/agent -run PostLLMCall -count=1
- go test -race ./internal/control -run HTTPFormat\|TurnFormat\|RefTurnFormat -count=1
- go vet on the affected runtime packages
- bash scripts/cache-guard.sh

Co-authored-by: SivanCola <[email protected]>
2026-08-05 15:45:58 +08:00
SivanCola 28aa0fd342 Merge main-v2 into fix7234and7168
Bring the integration branch onto the latest upstream base before applying the final approval blockers. This incorporates PR #7372 without changing the Responses feature behavior.
2026-08-05 15:39:06 +08:00
SivanCola 3c6d0f8cd5 Merge pull request #7372 from Akashmdshetty/feature/sub-agent-progress
Bounded sub-agent progress forwarding / 有界转发子 Agent 进度
2026-08-05 15:25:58 +08:00
Yan Li f51ffe5517 style(responses): group stdlib vs internal imports in vendor.go (Copilot) 2026-08-05 15:19:46 +08:00
SivanCola d4a8376e5e fix(agent): remove unused progress phase helper
Problem:
- The CI lint job rejected the PR because subagentProgressPhase.terminal was never called.

Root cause:
- The helper became obsolete when group lifecycle handling moved to explicit terminal events, but it remained in the progress implementation.

Fix:
- Remove the dead helper without changing progress state transitions or event routing.

Verification:
- golangci-lint run --timeout=5m
- go test ./...
- go test -race ./internal/agent -count=1
- git diff --check
2026-08-05 15:16:36 +08:00
SivanCola 039590e93a fix: keep remote web window alive after navigation 2026-08-05 15:13:41 +08:00
Yan Li 156c2854a2 style(responses): gofmt-align sseItem.Raw field (CI gofmt check) 2026-08-05 15:08:17 +08:00
SivanCola 6c28d5df36 feat(desktop): compact decision surfaces and record local receipts
Problem: Footer decision cards were visually oversized and mixed primary choices with secondary escape paths, making Ask and runtime decisions harder to scan.

Root cause: Each surface encoded its own layout density and cancellation affordance, while user decisions had no durable local receipt that could be replayed without entering provider history.

Fix: Unify decision-card sizing, scrolling, action rows, and secondary footer actions across Ask, approvals, recovery, runtime blockers, and context clearing. Persist bounded Ask/approval/recovery receipts as LocalOnly transcript metadata, wire them through history and remote events, and localize the rendered receipts.

Verification: go test ./...; (cd desktop && go test ./...); frontend typecheck; CSS checks; Ask 69/69; decision surfaces 149/149; remote protocol generator check.
2026-08-05 14:35:49 +08:00
SivanCola c03040585d fix(cli): ignore future subagent progress channels
Problem:
- The CLI recognized only the four current reserved progress names, so a future reasonix.subagent.* channel could fall through to ordinary tool output.

Root cause:
- Known-channel decoding and reserved-namespace routing shared one exact-match predicate.

Fix:
- Add a prefix predicate for the reserved namespace while retaining exact matching for supported channels.
- Suppress unknown reserved channels before ordinary tool streaming.
- Add a regression test proving no transcript, tool-stream, or known-channel state mutation.

Verification:
- go test ./...
- go test -race ./internal/event ./internal/cli -count=1
- ./scripts/cache-guard.sh
- git diff --check
2026-08-05 14:10:15 +08:00
SivanCola 43882024be fix: preserve Provider setup lock ordering
Problem: Provider setup refreshed configuration while holding the credential edit lock, opposite to the documented config-then-credential ordering. Concurrent configuration and credential edits could therefore block each other.

Root cause: refreshProviderSetup used the migration-capable config.Load path even though setup only needs a read-only snapshot.

Fix: Use LoadForRootReadOnly while the credential snapshot is locked, and add a regression that holds the config edit lock while refresh completes.

Verification: focused Provider/config race tests, root go test ./..., go vet ./..., cache-guard, and git diff --check all pass.
2026-08-05 14:08:25 +08:00
Yan Li 8f2f30bbdb style(agent): gofmt indent req := provider.Request block (Copilot suppressed) 2026-08-05 13:55:42 +08:00
Yan Li cad5493533 fix(config): host-based vendor matching for effort + cache TTL (PR #7566 Copilot)
Replace full-URL strings.Contains matching with host-level exact/suffix
matching (via officialProviderHost) in isMimoEntry and detectCacheVendor, so
unrelated or attacker-controlled URLs (e.g. api.xiaomimimo.com.attacker.example,
example.com/?u=api.anthropic.com) can't be misdetected as a known vendor.

- isMimoEntry: MiMo effort only enabled on real api.xiaomimimo.com hosts
- detectCacheVendor: cache TTL only shortened to 5m on real
  dashscope/anthropic hosts (prevents premature cold-resume pruning)

Adds attack-URL regression cases to both tests.
2026-08-05 13:42:42 +08:00
SivanCola b0218ea667 Fix task monitor positional flag parsing
Problem:
Documented task monitor commands put the task ID before options, but status, events, and control commands rejected that ordering with exit code 2.

Root cause:
Go's flag parser stops at the first positional argument, while only the tmux path normalized task IDs and its helper hard-coded a subset of flags.

Fix:
Normalize registered task flags before positional IDs for query and control commands, preserving flags-first compatibility and covering the documented order with focused tests.

Verification:
- go test ./internal/cli -count=1
- go test ./...
- documented-order status/events CLI smoke

Co-authored-by: SivanCola <[email protected]>
2026-08-05 13:20:44 +08:00
Yan Li 14b06e3978 merge: upstream main-v2 (2206ee654) into fix7234and7168
# Conflicts:
#	desktop/frontend/src/generated/remoteProtocol.generated.ts
#	internal/agent/agent.go
#	internal/remote/protocol/schema.generated.json
#	internal/remote/protocol/schema_hash.generated.go
2026-08-05 12:52:28 +08:00
SivanCola 5bd1a606bf Merge latest main-v2 into task monitor fixes
Integrate the current target branch, including the spam-comment workflow update, before publishing the reviewed task monitor fixes.
2026-08-05 12:27:43 +08:00
SivanCola 2b8f2e02c2 Fix task monitor review blockers
Problem:
- tmux mapping I/O could follow repository-controlled symlinks, while valid cleanable project scopes were rejected.
- persisted failure and control summaries could expose paths or commands through the content-free monitor interface.
- stop and cancel cleared runtime ownership before exit, leaving alive tasks without a reconciliation deadline.
- the navigation contract test still assumed the pre-helper queue wiring.

Root cause:
- tmux mapping storage bypassed the FileStore path boundary, free-form summaries crossed the machine-output boundary, and control admission was treated as runtime completion.

Fix:
- reuse FileStore symlink checks and private storage modes for tmux mappings, including the default project scope.
- persist stable failure codes only and scrub legacy summaries at every monitor output boundary.
- preserve live runtime leases after admitted control and bound legacy lease-less snapshots.
- validate the current navigation helper forwarding chain.

Verification:
- go test ./...
- go vet ./...
- go test -race ./internal/taskmonitor
- pnpm test
- pnpm build

Co-authored-by: SivanCola <[email protected]>
2026-08-05 12:27:32 +08:00
YHH 2206ee654f ci: auto-minimize activity-farming spam comments (#7560)
A 26-account network posted 84 emoji-padded template comments across 20
issues over ten hours, burying real reports. The accounts are structurally
distinct from real reporters: recently registered, zero followers, a pile of
forked repos, and sub-50 character comments with no code, link, or log.

Replayed against all 7604 issue comments in the repo, requiring every signal
to fire catches all 84 spam posts with no false positive. Any single signal
relaxed pulls in real users.
2026-08-05 12:24:55 +08:00
SivanCola 0920d8da17 Merge latest main-v2 into task monitor fixes
Integrate the current target branch before applying the task monitor review fixes so the final validation covers the latest base.
2026-08-05 12:17:45 +08:00
SivanCola 89cdd023ea Fix remote window identity test lint 2026-08-05 12:09:21 +08:00
SivanColaandaakashshetty 56fcc3c490 merge: update PR #7372 with bounded subagent progress
Problem:
- The original optional forwarding patch was behind main-v2 and did not define bounded, isolated rendering or group lifecycle semantics.

Root cause:
- Raw child events crossed the parent sink without a stable progress contract, pacing budget, or explicit group ownership.

Fix:
- Integrate bounded and UTF-8-safe progress forwarding across agent, Desktop, CLI, and headless consumers.
- Preserve explicit group lifecycle, fair backpressure, isolated in-memory rendering, and terminal-state convergence.
- Keep the original contributor commit in the PR history while resolving the implementation onto current main-v2.

Verification:
- go test ./...
- go test -race ./internal/agent ./internal/event ./internal/cli -count=1
- go test ./... (desktop)
- pnpm test:typecheck && pnpm test && pnpm build (desktop/frontend)
- ./scripts/cache-guard.sh
- Remote zero-diff gate

Co-authored-by: aakashshetty <[email protected]>
2026-08-05 12:05:36 +08:00
SivanCola 6b1c5ea13a Fix task monitor tab-bound routing
Problem:
- Task Monitor actions could resolve the active workspace after the user switched tabs.
- Delayed open-session lookups could override a newer navigation intent.
- Valid parent-relative project directories were rejected as traversal.

Root cause:
- Wails task methods derived scope from process-wide active-tab state, open-session claimed navigation only after asynchronous lookups, and project paths were screened with a substring check instead of treated as caller-selected scopes.

Fix:
- Add tab-bound task/session Wails methods while preserving the legacy bindings.
- Claim and reuse the navigation intent before the first await, discarding stale successes and failures.
- Clean valid project scopes while retaining task-ID and task-store symlink defenses.

Verification:
- go test -count=1 ./...
- cd desktop && go test -count=1 ./...
- go test -race -count=1 ./internal/taskmonitor
- cd desktop && go test -race -count=1 -run TestStopTaskForTab .
- cd desktop/frontend && pnpm test:task-monitor
- cd desktop/frontend && pnpm build
- go vet ./...
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-05 12:01:49 +08:00
SivanCola 4938e8379b Fix remote setup and window ownership recovery
Problem:
- Provider activation failures saved the key but made the advertised retry path unusable.
- A remote web child could outlive a crashed Desktop and be adopted without a process registration.
- One global frontend generation let launches for different SSH hosts cancel each other.

Root cause:
- The setup state retained the pre-save credential revision after a failed controller rebuild.
- Remote child single-instance identity was host-only and children did not observe owner death.
- Remote launch freshness was tracked globally instead of per host.

Fix:
- Track activation-pending setup state and retry controller activation without rewriting the secret.
- Scope child identities to a random Desktop owner and close children when the owner process exits.
- Track launch generations independently for each remote host.

Verification:
- go test -count=1 ./...
- go test -race -count=1 ./internal/config ./internal/serve
- desktop go test -count=1 -timeout 300s ./...
- desktop focused race tests and Windows amd64 cross-compile
- frontend test:all, test:remote, and build
- cache-guard.sh and git diff --check
2026-08-05 12:01:23 +08:00
SivanCola aec665d686 Merge remote-tracking branch 'origin/main-v2' into feature/subagent-progress 2026-08-05 11:59:12 +08:00
SivanCola 911d88457f fix(agent): preserve background fleet progress lifecycle
Problem:
- Background fleet group IDs were namespaced twice and child progress could stop after the job-ID handoff.

Root cause:
- The group merger emitted through a nested sub-sink and Execute closed it even after handing lifecycle ownership to the background job.

Fix:
- Emit already-namespaced group and child progress through the raw call sink.
- Transfer merger close ownership only for background jobs while preserving foreground cleanup.
- Add deterministic background lifecycle and stable-ID coverage.

Verification:
- go test ./...
- go test -race ./internal/agent ./internal/event ./internal/cli -count=1
- go test ./... (desktop)
- pnpm test:typecheck && pnpm test && pnpm build (desktop/frontend)
- ./scripts/cache-guard.sh
- Remote zero-diff gate
2026-08-05 11:58:46 +08:00
SivanCola e6d34bbe93 Merge remote-tracking branch 'origin/main-v2' into feature/subagent-progress 2026-08-05 11:53:12 +08:00
SivanCola cb4a21306a Merge pull request #7503 from HaoyueQin/feat/usage-stats-chart-colors
feat(desktop): Primer data-viz two-set palette for model usage charts / 使用统计图表改用 Primer 深浅两套配色
2026-08-05 11:52:34 +08:00
SivanCola 0ed4c065ec Merge main-v2 into task monitor fixes
Integrate the latest main-v2 changes before validating and publishing the task monitor remediation. This preserves the contributor branch history and avoids a force-push.
2026-08-05 11:34:42 +08:00
SivanCola ee6484480b Fix task monitor runtime ownership
Problem:
- Desktop stop/cancel could target a same-named session in another workspace.
- A heartbeat resumed after lease expiry observed its own task as stale and stopped renewing.
- Open session only displayed the session ID without navigating.

Root cause:
- Runtime routing omitted workspace identity, lease renewal used reconciled observer reads without an owner generation, and the panel was not connected to the desktop navigation queue.

Fix:
- Scope runtime cancellation by the captured workspace.
- Add owner-bound raw lease renewal with CAS and deterministic regression coverage.
- Route task session opens through the existing serialized resume-session navigation flow.

Verification:
- go test -race -count=1 ./internal/taskmonitor
- cd desktop && go test -race -count=1 -run 'TestDesktopTaskJobKiller|TestTaskMonitor|TestStopTaskRoutes' .
- cd desktop/frontend && pnpm test:task-monitor
- cd desktop/frontend && pnpm typecheck
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-05 11:34:21 +08:00
YHH 380a913a95 feat(ablation): switch subsystems off behind one shared vocabulary (#7550)
* feat(ablation): switch subsystems off behind one shared vocabulary

Nothing could actually be ablated. The only variable axis was the runtime
profile, a coarse bundle, so attributing a change in solve rate to evidence
gating, the planner, subagents, retrieval or cache-preserving compaction was
impossible and every claim about those subsystems rested on assertion.

Add internal/ablation as the single vocabulary: one Set flows from --ablate
through boot into the agent, the controller and spawned sub-agents, and names
the published arm. Each subsystem gets exactly one switch point instead of
scattered conditionals — the planner at effectivePlannerModel, subagents at
the task-tool registration, retrieval at the two BM25-backed tools plus
auto-recall, evidence at finalReadinessCheckFor, and compaction at a new
compactThresholds that collapses the cache-preserving deferral.

boot.Options.DisablePlanner folds into the same Set; ACP's --planner=off maps
onto it rather than carrying a second switch for one subsystem. The arm is
recorded in --metrics and heads the e2e report, and the control arm's command
line stays byte-identical to the pre-ablation one so old numbers remain
comparable.

* docs(cli): document the --ablate benchmark arm
2026-08-05 11:22:52 +08:00
SivanCola 2786c31179 fix(desktop): harden usage statistics interactions
Problem:
- The Other row nested interactive controls, long-tail tooltips could grow without bound, and repeated palette navigation to usage statistics was ignored.

Root cause:
- Expansion semantics were split across a role button and child button, tooltip detail rendered the full tail, and model focus was deduplicated by target instead of request identity.

Fix:
- Use one native row button, cap tooltip breakdowns at five with an omitted count, and derive a monotonic focus request ID from shared overlay state.
- Add regression coverage for accessibility, bounded breakdowns, and repeated focus requests.

Verification:
- pnpm --dir desktop/frontend pretest
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend exec eslint src/App.tsx src/components/SettingsPanel.tsx src/components/UsageStatsPanel.tsx src/__tests__/usage-stats-panel.test.tsx src/__tests__/startup-settings-contract.test.ts
- pnpm --dir desktop/frontend exec tsx src/__tests__/startup-settings-contract.test.ts
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-05 11:20:13 +08:00
SivanCola 8a6a4dedd0 Merge remote-tracking branch 'origin/main-v2' into feature/subagent-progress 2026-08-05 11:10:03 +08:00
SivanCola c17a09c671 fix: explicit group lifecycle terminals for parallel_tasks/fleet
The backend now emits an explicit lifecycle for group cards: running when
children start and exactly one terminal (completed/cancelled/failed,
including validation failures and zero-child cancellation) after the group
settles, keyed by the group call ID. Frontends no longer infer group
completion from the children observed so far — a background group's children
dispatch asynchronously, so a job-id result arriving first or a fast first
child finishing before later children dispatch could previously settle the
group permanently while children were still running. The reducer now settles
group cards only from their own terminal event; the child-inference helpers
are removed. Adds reverse-order, zero-child cancellation, group failure, and
group lifecycle integration tests.
2026-08-05 11:10:03 +08:00
SivanCola 550c84ada4 fix(boot): compose the sink before extension preflight closes over it
Race caught by TestBootExtensionUIHubWired on the latest base: a sidecar
host/ui/publish arriving during preflight lands on the hub's sink fallback
from its wire-handler goroutine, while the build reassigned the same sink
variable afterwards with the stats recorder and goalUsageTee wraps
(boot.go:409 vs the closure at boot.go:279).

Fix: perform both wraps immediately after event.Sync, before the hub is
constructed, so the closure's captured sink is never reassigned.

Verification: TestBootExtensionUIHubWired x5 and the full internal/boot
package under -race pass.
2026-08-05 11:08:10 +08:00
SivanCola e6489aa07e test(boot): regen golden for upstream update_goal builtin (#7519)
The goal-runtime PR adds the provider-visible update_goal builtin; record
the new tool contract in the golden baseline (upstream cache-impacting
addition, not extension-kernel drift).
2026-08-05 10:54:56 +08:00
SivanCola 4f16aef460 Merge remote-tracking branch 'origin/main-v2' into feature/extension-kernel-protocol-v1
# Conflicts:
#	desktop/frontend/src/generated/remoteProtocol.generated.ts
#	desktop/frontend/src/lib/useController.ts
#	internal/agent/agent.go
#	internal/boot/boot.go
#	internal/control/controller.go
#	internal/i18n/i18n.go
#	internal/i18n/messages_en.go
#	internal/i18n/messages_zh.go
#	internal/remote/protocol/schema.generated.json
#	internal/remote/protocol/schema_hash.generated.go
2026-08-05 10:51:19 +08:00
YHH 1b19148d35 feat(bench): report cost per solved task, not cost per run (#7546)
The e2e report could say what a suite cost but not what one solved task
cost, and every failure rendered as a bare cross — a wrong patch and a
guard that deliberately stopped the run were indistinguishable. Neither
number survives a comparison against another harness.

Record wall time, tool-call accounting and the stopping guard in
--metrics, name the guards through agent.PauseClass so hosts stop
reaching into unexported pause types, and lead the report with
cost-per-solved plus a failure-class breakdown. Wall time is the
harness's own clock so the same suite stays comparable when it runs
against a harness that cannot emit our metrics file.
2026-08-05 10:50:22 +08:00
SivanCola 696494fd8c test(extension): align slash fallback and golden baselines with latest main-v2
Post-merge test alignment for PR #7373 against the latest base:

- The CLI unknown-slash path now sends the input as a regular message
  (#5756); the resolution-order test keeps proving that extension actions
  resolve BEFORE that fallback and asserts the new send-as-message
  behavior.
- Regenerate the golden baselines: main-v2 added two provider-visible
  builtin tools (docs, read_subagent_result), growing the tool contract
  and cache prefix. This is an upstream cache-impacting change, not
  extension-kernel drift — recorded here so the golden gate keeps its
  byte-exact meaning.

Verification: go test ./internal/cli ./internal/boot, race on boot/agent/
rpcwire, TestGoldenBaselineNoExtensions, frontend capabilities-panel test,
pnpm test + build all pass on the merged tree.
2026-08-05 10:41:57 +08:00
SivanCola 20734d8d51 Merge origin/main-v2 into feature/remove-remote-workbench
Problem:
- PR #7528 became conflicting after main-v2 added Goal and Delivery hardening changes that also modified the legacy Remote Workbench stack.

Root cause:
- The base branch added a remote Pause Goal RPC path and regenerated protocol artifacts after this branch had removed that entire stack.

Fix:
- Merge the latest main-v2 without rewriting branch history.
- Preserve deletion of the obsolete Workbench protocol, runtime, projection, and generated frontend bindings.
- Remove the auto-merged PauseGoal remote branch while retaining the new local Goal and Delivery behavior.

Verification:
- go test -count=1 ./... (root and desktop)
- focused race tests for remote windows, Serve lifecycle, and provider credential CAS
- frontend typecheck, remote tests, full tests, production build, and bundle budgets
- go vet and golangci-lint in both Go modules
- Windows amd64 desktop test cross-compile
- cache guard, diff checks, and Workbench static residual scan
2026-08-05 10:41:03 +08:00
SivanCola 4e30f5c3da fix(remote): harden web window and provider setup lifecycle
Problem:
Concurrent disconnects could miss a window process registered after teardown, the Serve port file became visible before a slow balance diagnostic completed, a stale Provider setup page could overwrite a newer credential, and the removed OpenSSH transport left an unused resolver that failed Desktop lint.

Root cause:
Remote window open/close entry points had no shared per-host ordering boundary; runServe performed balance synchronously before starting the HTTP accept loop; Provider setup cached missing-key state without a credential-store compare-and-apply token; and the transport deletion retained its private path helper and tests.

Fix:
- Serialize and generation-fence window lifecycle operations per host, including reconnect and terminal status callbacks, with a deterministic late-registration regression.
- Run balance diagnostics asynchronously and prove the setup page responds while a balance endpoint is blocked.
- Add credential revision compare-and-apply under the existing cross-process lock and reject stale setup submissions without exposing the revision.
- Delete the orphaned OpenSSH resolver and its obsolete tests.

Verification:
- go test -count=1 ./...
- cd desktop && go test -count=1 -timeout 300s ./...
- focused root/Desktop race tests
- go vet and golangci-lint in both Go modules
- Windows amd64 Desktop test-binary cross-compile
- pnpm test:typecheck
- scripts/cache-guard.sh
- git diff --check
2026-08-05 10:33:25 +08:00
SivanCola 58da0db47e Merge remote-tracking branch 'origin/main-v2' into feature/extension-kernel-protocol-v1
# Conflicts:
#	desktop/frontend/package.json
#	desktop/frontend/src/__tests__/capabilities-panel-actions.test.ts
#	desktop/frontend/src/generated/remoteProtocol.generated.ts
#	desktop/settings_app.go
#	internal/agent/agent.go
#	internal/boot/boot.go
#	internal/cli/chat_tui.go
#	internal/event/event.go
#	internal/i18n/messages_en.go
#	internal/i18n/messages_zh.go
#	internal/i18n/messages_zh_tw.go
#	internal/remote/protocol/schema.generated.json
#	internal/remote/protocol/schema_hash.generated.go
2026-08-05 10:32:53 +08:00
SivanCola 95788da69a Merge pull request #7519 from SivanCola/fix/goal-runtime-fail-closed
Make Goal completion fail closed / 让 Goal 完成判定安全停止
2026-08-05 10:21:20 +08:00
SivanCola d9b920272b fix: honor subagent progress contract on review findings
- desktop: a background parallel_tasks/fleet whose job-id result arrives
  before any child dispatched now stays running instead of settling as
  completed on an empty progress tree; it settles when children appear and
  finish, and a cancelled/failed aggregate settles it regardless.
- serve: reserved reasonix.subagent.* ToolProgress channels are recognized
  and ignored, so sub-agent status/reasoning/text never land in ordinary
  tool output.
- agent: ordinary phase transitions now share the 32 events/s group budget
  with content previews (only initial queued/running and the terminal event
  bypass it), so a fleet of phase-flapping children cannot exceed the
  advertised queue protection.
- agent: a budget trim that drops buffered content propagates the truncation
  on the next actually-emitted channel, or surfaces a truncated notice at
  the terminal flush, so frontends always learn that preview content was
  discarded.
- tests: reverse-order background group lifecycle, budget sharing across
  statuses and previews, and trim-truncation propagation; docs updated to
  match the enforced contract.
2026-08-05 10:07:23 +08:00
SivanCola 69cdc532e8 Fix non-Goal evaluation and Delivery negation
Problem:
Ordinary turns still invoked the Goal evaluator, and Delivery treated negated inspection or persistence language as affirmative work.

Root cause:
The shared turn wrapper entered Goal advancement without a Goal turn recorder, while the Delivery classifier matched observable and durable keywords without applying its clause-aware negation rules.

Fix:
Require a current epoch-scoped Goal recorder before evaluator/FSM work, reuse negation-aware intent matching for observable and persistent actions, add focused regressions, and format the two files blocking CI.

Verification:
- go test ./...
- go test -race ./internal/agent ./internal/control ./internal/evidence ./internal/provider ./internal/permission ./internal/shellsafe ./internal/goaleval ./internal/tool/...
- cd desktop && go test ./...
- cd desktop/frontend && pnpm typecheck && pnpm test
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- go run ./cmd/remote-protocol-gen -check -root .
- git diff --check
2026-08-05 09:58:08 +08:00
SivanCola 9b7667e52c Merge remote-tracking branch 'origin/main-v2' into feature/subagent-progress 2026-08-05 09:31:54 +08:00
SivanCola d377c34459 docs: document local subagent progress behavior
Describe the reserved ToolProgress channel contract, the phase state
machine, pacing and memory bounds, what is deliberately not done (no
parent-body leakage, no persistence, no Remote/ACP/bot changes), and the
desktop and CLI behaviors in English and Simplified Chinese.
2026-08-05 09:31:34 +08:00
SivanCola 7d7d3d6aa9 test: cover subagent progress isolation and backpressure
Agent: fake-clock merger tests (250ms merge window, 32/sec group budget
round-robin, 8 KiB pending bound, UTF-8-safe tails), wrap conversion and
forwarding isolation (Message/Reasoning/Text never leak), two-child
deterministic interleave, queued→running→terminal state machines for
foreground and background runs, single terminal on cancel/deadline/error/
panic, late events ignored, and close without leaked goroutines. CLI:
per-child blocks, verbose tails, terminal folding, native scrollback phase
lines, and no cross-streaming. ACP and bot: regression tests locking that
reserved ToolProgress bodies stay silently ignored.
2026-08-05 09:31:28 +08:00
SivanCola ee36c07157 cli: render isolated subagent progress
Give every sub-agent child its own progress state and a fixed transcript
slot keyed by its namespaced call ID, independent of the single live
toolStreamID, so concurrent children never cross-stream. By default the
block shows phase, running elapsed, and recent activity; /verbose (Ctrl+O)
adds bounded 4 KiB reasoning/text tails with a truncation marker. Terminal
children fold to a one-line summary; Termux native scrollback queues a
status line on phase changes and terminal only (verbose previews at most
once per 2s per child). Adds localized phase labels and progress formats.
2026-08-05 09:31:21 +08:00
SivanCola ce967d2ac6 desktop: render nested subagent progress
Add an in-memory-only subagentProgress field to tool items: the reducer
routes reserved ToolProgress channels into the target card's preview (never
tool.output or the parent LiveStream), initializes task/read_only_task/
parallel_tasks/fleet dispatches, keeps background cards running while child
progress is non-terminal, and settles group cards when their whole child
progress tree is terminal. The card header shows a phase chip with running
elapsed and recent activity (ticking once a second while live); expanding
the card shows isolated reasoning / response preview / notices. Preview
retention is capped per channel (8 KiB reasoning/text, 2 KiB notice) and
never hydrated from history. Adds en/zh/zh-TW copy and compact styles.
2026-08-05 09:31:16 +08:00
SivanCola 9ef543ee05 agent: add bounded subagent progress forwarding
Forward each sub-agent run's Reasoning/Text/Notice/Retrying as reserved
ToolProgress channel events (reasonix.subagent.status/reasoning/text/notice)
so local frontends can show live per-child progress without the child's
bodies entering the parent transcript or provider context. A shared merger
per parent task group paces previews: one pending slot per (child, channel),
a 250ms merge window, a 32 event/sec group budget round-robined across
children, and an 8 KiB per-child pending buffer with UTF-8-safe tails.
RunProfileSpec emits exactly one terminal status (completed/cancelled/failed)
after synchronously flushing pending previews; late events are ignored.
Background runs emit queued at registration and running on slot acquisition;
parallel_tasks and fleet share one merger per call via context. Real
sub-agent tool progress is namespaced and forwarded like ToolDispatch.
2026-08-05 09:31:12 +08:00
SivanCola 6aa634db60 Merge remote-tracking branch 'origin/main-v2' into fix/goal-runtime-fail-closed 2026-08-05 09:26:45 +08:00
SivanCola f9cc0ff63b feat(serve): add remote provider setup page 2026-08-05 09:18:50 +08:00
SivanCola 295b3f71ca Merge pull request #7536 from esengine/release-notes/v1.19.7
docs(release): prepare v1.19.7 notes / 准备 v1.19.7 中英更新日志
2026-08-05 09:12:14 +08:00
SivanCola 264071dc9d docs(release): refresh v1.19.7 after late merges
Problem:
The first reviewed release candidate was superseded by two merges on main-v2, and the regenerated draft again narrowed the release surface to Desktop while omitting the DeepSeek web-search privacy and billing risk.

Root cause:
The generated draft only reflected selected user-facing categories and did not preserve the reviewed metadata corrections from the prior release-notes PR.

Fix:
Record PRs #7531 and #7532, restore the full product surface inventory, and document the default-on DeepSeek server-search opt-out and billing implications.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- bilingual render for v1.19.7
- git diff --check
- jq empty release-notes/releases.json
2026-08-05 09:11:26 +08:00
github-actions[bot] 685694dcdd docs(release): prepare v1.19.7 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-08-05 01:09:54 +00:00
YHH 93243ab14b feat(agent): store-P2 write-authority probe for unleased session saves (#7532)
Observe-first increment toward lease-holder-is-single-writer: one deduped structured warning per unleased save path; no behavior change. Store-P2 groundwork for the snapshot-conflict cluster.
2026-08-05 01:03:56 +00:00
SivanCola 315b710c3c Harden Goal budgets and Delivery readiness
Problem:
- Goal token limits were checked only after complete turns, allowing tool loops to overshoot.
- Delivery treated safe command substitutions as mutations and over-required structured reviews or conversation evidence.
- Non-Goal update_goal calls could waste repeated model rounds.

Root cause:
- Usage accounting was turn-final instead of provider-request scoped.
- Readiness conflated evidence with its production mechanism and used a binary task classifier.
- Workflow-only tool misuse had no host-side short circuit.

Fix:
- Add request admission, concurrent reservations, exact usage accounting, and fail-closed Goal budget transitions.
- Resolve narrow read-only shell substitutions and propagate trusted read-only receipts.
- Accept host-proven Medium reviews, distinguish conversational and persistent intents, and bound non-Goal update_goal recovery.
- Remove two stale symbols that blocked the repository lint gate.

Verification:
- go test ./...
- go vet ./...
- go test -race ./internal/agent ./internal/evidence ./internal/control
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- git diff --check
2026-08-05 09:00:04 +08:00
LI Xinyu 13d0b7932e fix(updater): quarantine stale self-describing transactions at reconcile
A self-describing pending transaction whose launcher and target
directories no longer match (install layout moved to
versions\<version>\ or the old install directory is gone)
previously made reconciliation fail permanently: startup/install
recovery errors before preparation can quarantine the marker, and the
marker lives in the state directory so a reinstall does not clear it.
Users were stuck with every future update failing (#7391, #7416,
#7407).

Reconcile now quarantines that disposition exactly as preparation does
("blocked a new update") — a marker rename, never a delete; the target
and rollback material are untouched, so a genuine transaction observed
from the wrong install loses nothing.
2026-08-05 08:57:29 +08:00
YHH 467a46f80b fix(bot): honor persisted session_mappings when resolving chat sessions (#7531)
sessionProfileForMessage now consumes the persisted chat→session binding (soft, degradable) instead of only the in-memory /attach override; degraded states stay stable per turn. Fixes #6917, #6934
2026-08-05 00:56:45 +00:00
SivanCola dfd8f7774b Merge pull request #7530 from esengine/release-notes/v1.19.7
docs(release): prepare v1.19.7 notes / 准备 v1.19.7 中英更新日志
2026-08-05 08:50:15 +08:00
SivanCola 26a7ba89db docs(release): correct v1.19.7 scope and risks
Problem:
The generated catalog listed Desktop as the only affected surface and omitted the default-on DeepSeek web-search privacy and billing caveat.

Root cause:
The release-note generator retained its example surface and did not promote the supported opt-out guidance into the risk section.

Fix:
Record every affected product surface and add bilingual, PR-grounded web-search privacy, billing, and opt-out guidance.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- bilingual render review
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-05 08:49:12 +08:00
github-actions[bot] e7248c7aa9 docs(release): prepare v1.19.7 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-08-05 00:46:49 +00:00
SivanCola eaa78fe290 fix(remote): default fresh Serve workspaces to SSH home
Problem:
The Remote Server workspace input displayed ~/project as a placeholder while its real value was empty. Fresh hosts therefore had no enterable zero-configuration workspace, and status-bar opens were redirected to manual setup.

Root cause:
Frontend workspace resolution stopped after the last-opened and configured values even though the bootstrap already resolves ~ to the authenticated SSH user home.

Fix:
Centralize remote workspace resolution and fall back to ~ for fresh hosts. Use the same resolver in the Server tab and status-bar launch path, show ~ in the input, and remove the now-dead missing-workspace prompt.

Verification:
- pnpm test:typecheck
- pnpm test:remote (101 assertions)
- pnpm build
- git diff --check
2026-08-05 08:42:52 +08:00
Yan Li 7ce6dc3ac0 merge: upstream main-v2 (2c54501b5) — reconcile with #7466 web_search replay + #7375/#7386
冲突解决(9 文件,双方正交功能共存):
- provider.go/dto_broker.go: Chunk/BrokerChunk 同时保留 ReasoningID/
  ReasoningStatus(我们 reasoning 贯通)+ ResponsesItem(上游 #7466
  web_search 回放)
- agent.go: stream 签名 12 返回值(reasoningID/reasoningStatus +
  responsesItems),8 个 return 点全部合并
- responses.go: messagesToInput 四参签名(vision, replayDeepSeekItems,
  summary)——vision/summary(我们 vendor 隔离)与 replayDeepSeekItems
  (上游 #7466)共存;conversationDigest 保持方法形态(c.caps 驱动);
  readStream 同时捕获 web_search_call(上游)与 reasoning status(我们)
- sseItem: Status(我们)+ Raw(上游)双字段
- 3 个 generated 文件用 remote-protocol-gen 重新生成

验证:gofmt + go vet + go build + 关键包测试全绿
2026-08-05 06:50:33 +08:00
SivanCola 9e226b16b3 fix(remote): keep previous ready Serve ownership when a switch fails
Problem:
EnsureServer published the new workspace's "starting" state and cleared the
token before launching the Serve; when the launch or the tunnel Replace
failed, it then published the new workspace's error view over the still-
running previous Serve. Stop/Logs afterwards targeted the failed workspace,
the previous token was lost, and the frontend error state allowed stopping
the wrong workspace.

Root cause:
Only previousServer was captured; the failure paths unconditionally published
the failed view (and an empty token), transferring ownership of the host's
server state away from the Serve that actually keeps running.

Fix:
- Capture previousToken alongside previousServer.
- On ensureServe or tunnel Replace failure, publishFailedServeStart restores
  the previous ready view and token when one exists (forward Set.Replace is
  atomic, so the previous tunnel stays live on failure); the error still
  surfaces through the EnsureServer return value and the caller's actionErr.
  First-start failures keep publishing the informative error view.
- Add manager-level tests for the ensureServe-error and Replace-error paths
  asserting Stop and Logs keep operating on the previous workspace, plus the
  first-start error view; the Replace-failure test also asserts the just-
  started Serve is cleaned up.
- Align openRemoteWindowForHost's comment with the committed-switch contract.

Verification:
- cd desktop && go test -run 'TestEnsureServer' green; targeted -race green
- Full desktop suite green modulo a pre-existing macOS TempDir/EBADF
  environment flake (reproduced identically on pristine origin/main-v2,
  2/6 runs, test TestWorkspaceChangeDetailBoundsTrackedPatch cleanup only)

Co-authored-by: SivanCola <[email protected]>
2026-08-05 03:34:29 +08:00
SivanCola b4dcde3835 fix(remote): drop the stale workbench gate from OpenLocalPath
Problem:
Upstream #7465 added desktop/open_local_path.go with a Remote Workbench
capability gate that referenced the removed activeRemoteWorkbench().

Root cause:
The file landed on main-v2 after the workbench removal branch diverged, so the
rebase applied it without the deleted projection API.

Fix:
Remove the gate and the stale comment: every session is local now, so remote
workspace paths never reach this binding.

Verification:
- cd desktop && go build . and go test ./... green on the rebased base

Co-authored-by: SivanCola <[email protected]>
2026-08-05 03:34:29 +08:00
SivanCola a9c99b4e0f fix(remote): keep the live window registered across handoff and enforce ticket TTL
Problem:
Re-opening a host spawned a short-lived handoff process whose Wait deleted the
host's window registration, so the still-running window became invisible to
disconnect/stop/quit and to reconnect re-pointing. Tickets were also only
cleaned by a parent-process AfterFunc, so a crashed parent could leave an
expired token file on disk.

Root cause:
The registry stored a single child per host and replaced it on every spawn,
and consumeRemoteWindowLaunch never checked the ticket's age.

Fix:
- The registry now tracks a per-host process set with a per-entry generation:
  each spawn appends its own entry, and Wait clears only that entry. A handoff
  process exiting at the single-instance gate no longer clears the live
  window, so explicit disconnect/stop, real quit, and reconnect refresh still
  reach the window. Add the handoff regression test (live window + exited
  handoff) at both the registry and App level.
- consumeRemoteWindowLaunch rejects tickets older than the TTL (mtime check)
  and deletes them, bounding leftovers even when the spawning process died.
- OpenRemoteWorkspace documents the two-phase contract precisely and gains a
  regression test for the window-open-failure path (serve stays ready for the
  new workspace; the recorded last workspace matches the running serve).
- Drop the stale compactRatioRemote field from the frontend SettingsView.

Verification:
- go test ./desktop: registry handoff/generation, TTL expiry, disconnect-after-
  handoff, and window-open-failure suites green
- cd desktop && go test ./... green

Co-authored-by: SivanCola <[email protected]>
2026-08-05 03:34:29 +08:00
SivanCola 8aed504f4f feat(remote): legacy workbench data cleanup card and doc convergence
Problem:
The removed Remote Workbench left remote-mirrors/ and
remote-provider-trust.json behind, its dedicated docs stayed in the tree, and
shared code comments still described the Broker/projection model.

Root cause:
No cleanup surface was designed when the protocol stack was deleted, and the
docs/comments were not part of the removal.

Fix:
- Settings -> Remote SSH now scans for legacy artifacts (path-free: mirror
  count and bytes, trust-file presence) and shows a "Legacy Remote Workbench
  data" card only when data exists. Mirrors and the trust file are cleaned
  separately behind a confirmation dialog; the backend accepts only the two
  whitelisted targets inside the Reasonix private directory and rejects
  symlinks and path escapes. Historical source=remote statistics are
  deliberately not deletable.
- Remove docs/REMOTE_WORKBENCH(.zh-CN).md; GUIDE/CLI remote sections now
  describe the restored web window and state that remote pages use the remote
  host's provider configuration and API keys.
- CHANGELOG Unreleased documents the removal, the remote-provider semantics,
  and the one-release command stubs; stale Remote Workbench/Broker comments in
  shared boot/provider/jobs/control/stats code are rewritten.

Verification:
- go test ./desktop (legacy scan/cleanup/symlink/escape/idempotency suites)
- Frontend remote-hosts-page suite extended with card visibility, separate
  cleanup, and cancelled-cleanup cases (16 assertions green)
- reasonix remote attach-workspace/runtime-workbench/workbench-build-id all
  exit 1 with the connect pointer

Co-authored-by: SivanCola <[email protected]>
2026-08-05 03:34:29 +08:00
SivanCola e8fc99404d feat(remote): converge remote UI on Open Remote Web
Problem:
The frontend still exposed the Remote Workbench projection: a workbench target
store and events, the Provider Trust modal, target tokens threaded through
Goal submission, and a dead compactRatioRemote banner — while the backend
stack had been removed.

Root cause:
The App.tsx target state, goalSubmit target tokens, bridge Workbench* surface,
and the settings banner were all built for the same-window projection.

Fix:
- Delete workbenchTarget.ts, ProviderTrustDialog.tsx, the generated
  remoteProtocol.generated.ts, and their tests. App.tsx keeps only the local
  layout_style "workbench" surface (sidebar, dock, chrome) untouched.
- Remove every Workbench* bridge method, the remote:workbench-target and
  remote:provider-trust subscriptions, and the target token from
  activateGoalAndSubmitOnTab / SubmitInitialGoalToTab. Remote panel and
  status bar converge on the single "Open Remote Web" entry, which still
  connects over SSH, waits, resolves the last workspace, and calls
  OpenRemoteWorkspace; a missing workspace still routes to the Server tab.
- Remote Server tab: one "Open Remote Web" action (start serves and opens the
  web window idempotently), plus the fixed hint that models, API keys, and
  sessions are managed by the remote Reasonix configuration. Remote serve
  progress continues to flow through remote:server events.
- Delete the unused EnsureRemoteServer binding and the compactRatioRemote
  settings field/banner.
- test:remote drops the workbench/provider-trust suites and adds a Server-tab
  suite (entry label, provider hint, Serve progress states, workspace-missing
  guard, open call) via a new svg-stub loader for component tests.

Verification:
- pnpm test:typecheck, pnpm test:remote (92 assertions), pnpm test (4008
  assertions), and pnpm build all green
- cd desktop && go test ./... green after the binding/field removal

Co-authored-by: SivanCola <[email protected]>
2026-08-05 03:34:29 +08:00
SivanCola bb2606375c feat(remote): remove Remote Workbench protocol and projection stack
Problem:
Remote Workbench (rpcwire JSON-RPC, schema codegen, Provider Broker,
TargetManager, workbench projection in the main window, and system OpenSSH
transport) was superseded by the restored SSH Serve web window. The full
stack, its build ldflags, and its hidden CLI commands had to disappear.

Root cause:
The Workbench was added in #6728 and its same-window projection made every
controller path conditional on an active remote target.

Fix:
- Delete internal/remote/{workbench,protocol,protocolgen,broker},
  internal/rpcwire, internal/remote/bootstrap/workbench.go, and
  cmd/remote-protocol-gen. eventwire stays (shared with CLI/serve/desktop).
- Delete the desktop workbench layer (workbench_adapter, projection,
  runtime_adapter, ssh) and the system OpenSSH/AskPass/Job Object code
  (remote_askpass, remote_ssh_transport, remote_ssh_process). Normal remote
  SSH keeps using the shared internal/remote.Client.
- Remove every activeRemoteWorkbench()/activeWorkbenchTargetIsRemote() gate
  from app.go, tabs.go, workspace_changes.go, terminal.go,
  memory_suggestions.go, settings_app.go, and stats_app.go, restoring pure
  local controller semantics. The local layout_style "workbench" and the
  shared Wails remote bindings (hosts, SFTP, forwards, serve) are preserved.
- remote attach-workspace / runtime-workbench / workbench-build-id stay
  routable for one release as exit-1 stubs pointing at
  `reasonix remote connect <host> --open`.
- Drop the protocol source-revision ldflag from scripts/desktop-build.sh and
  update the packaging test; refresh releaseasset comments to describe remote
  Serve CLI provisioning.

Verification:
- go test ./... (root) and cd desktop && go test ./... all green
- go vet and gofmt clean on both modules; go mod tidy both
- Static closure: no workbench/protocol/rpcwire references outside the
  intentional CLI stubs (frontend convergence lands in the next commit)

Co-authored-by: SivanCola <[email protected]>
2026-08-05 03:34:29 +08:00
SivanCola d7f988da13 feat(remote): restore SSH Serve web windows via per-host tickets
Problem:
Opening a remote SSH workspace projected the session into the main window
through the Remote Workbench RPC stack, and the pre-Workbench ticket-based
Serve HTML child window had been deleted, leaving no web-mode entry.

Root cause:
b9e036b84 removed the remote-window tickets, spawning, middleware, and
lifecycle branches when the App bindings migrated to TargetManager.

Fix:
- OpenRemoteWorkspace is the idempotent web launcher again: EnsureServer
  (install/reuse), atomic 127.0.0.1:0 tunnel Replace, token appended to the
  first-visit URL, last workspace saved, then open/re-point the host window.
  A failed switch leaves the previous window and tunnel untouched.
- Restore the mode-0600 one-shot ticket (size bound, 2-minute TTL, consume
  deletes, loopback-only URL validation, JSON-escaped navigation JS). argv
  carries only the ticket name and a non-secret per-host digest.
- Child windows derive a per-host Wails SingleInstance identity from the host
  digest: reopening a host routes the new ticket to the existing window,
  which consumes it, updates the title, navigates, restores, and focuses.
  Different hosts run independent windows in parallel.
- The main process keeps a generation-based child registry so a stale Wait
  can never clear a newer registration; real quit terminates survivors, while
  closing a window only releases its registration (Serve and SSH stay up).
- Terminal SSH failure or explicit disconnect/stop-server closes the host
  window (the reason already surfaces via remote:status); transient
  reconnects keep it and re-point it at the rebound tunnel URL.
- Child processes expose no Wails bindings, menu, or file drops and skip
  local tabs/tray/heartbeat/providers.

Verification:
- go test ./... in desktop, including new ticket, registry, host-identity,
  lifecycle, middleware, atomic-switch, and reconnect tests
- go test -race on the remote window and open-workspace suites
- gofmt/go vet clean

Co-authored-by: SivanCola <[email protected]>
2026-08-05 03:34:29 +08:00
SivanCola 2c54501b5e fix(agent): preserve parallel subagent results (#7375)
Problem: Long parallel_tasks and fleet answers could exceed the single-tool output limit, combining and truncating independent child reports.

Root cause: Both aggregators concatenated every full child answer before the global output guard applied head/tail truncation.

Fix: Persist each child transcript, return bounded fair previews with stable references, and add a conversation-scoped read_subagent_result pager with lineage and workspace checks.

Verification: go test ./... -count=1; go test -race ./internal/agent -count=1; ./scripts/cache-guard.sh; git diff --check.
2026-08-05 02:43:45 +08:00
SivanCola 5e4f0eb708 fix(goal): make autonomous completion fail closed
Problem:
Goal mode relied on assistant footer markers and treated missing or malformed markers as a reason to keep opening turns. Delivery readiness also owned a separate retry loop, allowing simple answers or stalled work to consume tokens until a hard turn ceiling.

Root cause:
Completion state was encoded in free-form text, continuation ownership was split between Goal and Delivery, and the host lacked a unified evaluator, progress signal, and token budget.

Fix:
Add the stable update_goal tool, epoch-scoped turn reports, a bounded independent evaluator, centralized Goal readiness decisions, progress-aware budgets, usage accounting, and compatible CLI/Desktop/remote runtime state. Delivery now reports structured readiness without scheduling hidden retries.

Verification:
- go test ./...
- cd desktop && go test ./...
- cd desktop/frontend && pnpm typecheck && pnpm test:all && pnpm build
- go test -race ./internal/control ./internal/agent ./internal/recovery ./internal/goaleval ./internal/tool/... ./internal/evidence
- ./scripts/cache-guard.sh
- go run ./cmd/remote-protocol-gen -check -root .
- git diff --check
2026-08-05 02:29:35 +08:00
SivanCola 8f6015118e Move desktop style to the top of general settings (#7515)
Problem: The desktop style selector is buried below language and currency even though it changes the application's primary layout.

Root cause: General settings render the selector as the third field.

Fix: Render the existing desktop style field first without changing its behavior, and add a DOM ordering regression assertion.

Verification:
- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm exec eslint src/components/SettingsPanel.tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm typecheck
- git diff --check
2026-08-05 02:09:30 +08:00
SivanCola d643b37b9b feat(deepseek): add server-side web search controls / 添加服务端网页搜索控制 (#7466)
* feat(deepseek): add server-side web search controls

* Harden DeepSeek web search defaults and replay

Problem:
- Existing official DeepSeek configurations did not inherit web search defaults.
- Responses API search output was discarded between stateless turns.
- Endpoint gating and billing copy were broader than the documented contracts.

Root cause:
- web_search used a two-state boolean, endpoint checks matched hosts loosely, and the shared stream/session model had no slot for provider-issued Responses items.

Fix:
- Resolve web_search as a persisted tri-state on exact official endpoints with explicit opt-out.
- Persist validated completed web_search_call items and replay them only on official DeepSeek Responses.
- Carry search replay items across the remote Broker protocol with bounded JSON validation.
- Align endpoint gating and cost copy with the official contract and add regression coverage.

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/provider/... ./internal/config ./internal/agent ./internal/remote/protocol ./internal/remote/broker
- pnpm --dir desktop/frontend test:all
- pnpm --dir desktop/frontend lint:hooks
- ./scripts/cache-guard.sh
- go vet ./...

* fix(desktop): account for Traditional Chinese search copy

Problem:
- The Desktop and desktop-windows CI jobs failed because the Traditional Chinese locale compressed to 52.1 KiB against a 52 KiB budget.

Root cause:
- The new server-side web search controls intentionally add localized capability, privacy, billing, protocol, and model guidance, while the zh-TW chunk had no remaining budget headroom.

Fix:
- Give only the zh-TW on-demand locale a 53 KiB budget while retaining the 52 KiB Simplified Chinese limit and every existing initial bundle limit.

Verification:
- pnpm build
- pnpm test:all
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/provider/... ./internal/config ./internal/agent ./internal/remote/protocol ./internal/remote/broker
- ./scripts/cache-guard.sh

* fix(desktop): simplify local file URL normalization

Problem:
- The Desktop CI job failed staticcheck S1017 on the latest main-v2 integration tree.

Root cause:
- normalizeLocalOpenPath checked for the file URL prefix before calling strings.TrimPrefix, even though TrimPrefix is already a safe no-op when the prefix is absent.

Fix:
- Normalize the optional file URL prefix unconditionally while preserving plain absolute path behavior.

Verification:
- go test . -run 'TestNormalizeLocalOpenPath|TestOpenLocalPath' -count=20
- go vet ./...
- golangci-lint run --timeout=5m

* test(cli): make unknown slash routing deterministic

Problem:
- Coverage and Ubuntu CI intermittently failed TestUnknownSlashCommandDoesNotStartTurn.

Root cause:
- Since #5756, unknown slash input intentionally starts an ordinary model turn with a typo notice, but the stale test asserted that no turn starts. Because controller submission is asynchronous, the assertion raced the runner and passed or failed depending on scheduling.

Fix:
- Assert the current routing contract, wait for TurnDone, and verify exactly one ordinary turn plus the visible unknown-command notice.

Verification:
- go test ./internal/cli -run TestUnknownSlashCommandStartsOrdinaryTurnWithNotice -count=100
- go test -race ./internal/cli -run TestUnknownSlashCommandStartsOrdinaryTurnWithNotice -count=20
- go test ./internal/cli -count=1
2026-08-05 02:00:37 +08:00
dependabot[bot] 283cd58fea chore(deps): bump the actions group across 1 directory with 6 updates (#7483)
Bumps the actions group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-go](https://github.com/actions/setup-go) | `6` | `7` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `6` | `6.0.9` |
| [actions/setup-node](https://github.com/actions/setup-node) | `6` | `7` |
| [github/codeql-action](https://github.com/github/codeql-action) | `4` | `4.37.4` |
| [actions/setup-python](https://github.com/actions/setup-python) | `6` | `7` |
| [actions/labeler](https://github.com/actions/labeler) | `6` | `7` |



Updates `actions/setup-go` from 6 to 7
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

Updates `pnpm/action-setup` from 6 to 6.0.9
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6...v6.0.9)

Updates `actions/setup-node` from 6 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

Updates `github/codeql-action` from 4 to 4.37.4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.4)

Updates `actions/setup-python` from 6 to 7
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

Updates `actions/labeler` from 6 to 7
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: github/codeql-action
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-05 00:53:31 +08:00
Qiaochu Hu f06bd1da11 fix(openai): recover half-open stream stalls and tolerate empty data: lines (#7374)
Two stream-robustness fixes in the OpenAI-compatible provider (DeepSeek,
MiMo, MiniMax-M3, and any OpenAI-compatible gateway).

1. A watchdog stall bypassed stream recovery. The idle watchdog exists to
   turn a half-open TCP drop — a local proxy (v2rayN/sing-box) that silently
   drops the long-lived SSE connection during a reasoner's first-token gap,
   with no FIN/RST — into a recoverable error. It detected the stall but
   returned a plain "stream stalled" error with no sentinel, while the
   clean-FIN idle-close path wraps io.ErrUnexpectedEOF. streamWithReconnect
   gates reconnect/StreamInterruptedError on provider.IsConnReset, which the
   plain error failed, so the stall hard-failed the turn instead of replaying
   — even though a clean idle-close on the same connection recovered. Wrap
   the stall error with io.ErrUnexpectedEOF so the half-open stall is treated
   identically to the clean-FIN drop.

2. An empty or heartbeat `data:` line fatally aborted the whole stream.
   readStream unmarshaled every data: payload with no empty guard, so
   json.Unmarshal("") returned "unexpected end of JSON input" and ended the
   turn with a confusing decode error. The Anthropic and Responses transports
   both tolerate empty data: lines; the OpenAI transport was the outlier. Skip
   empty payloads like Anthropic does.

Cancellation and deadline errors still bypass reconnect (IsConnReset excludes
context.Canceled/DeadlineExceeded), verified by TestStreamCancelDoesNotReconnect.
2026-08-04 16:48:42 +00:00
Willow Lopez 23ebe8302f fix(serve): keep ask interactive after model switches (#7450) 2026-08-04 16:41:17 +00:00
YHH 1f5f879de0 fix(agent,desktop): sanitize stored display titles at the read boundary (#7501)
Apply agent.UserPreviewText at the three ingestion seams (LoadBranchMeta, desktop title sidecars, auto-title derivation) so polluted legacy titles self-heal and wrappers can't seed new titles. Fixes the remaining half of #5666
2026-08-04 16:40:49 +00:00
siwenyu666-beep e3ec757beb fix(desktop): make local file paths in chat clickable (#7426) (#7465)
Plain Windows drive paths, UNC paths and file:/// URLs in AI replies are now rendered as clickable links that open via the native OpenLocalPath binding instead of the system browser.

- remark plugin rewrites path text nodes into file:/// links (CJK punctuation, escaped spaces, trailing closers, %xx round-trip)
- RichMarkdownLink routes file:/// clicks to OpenLocalPath; http links keep using the system browser
- new OpenLocalPath backend binding: absolute-path validation, remote-workspace rejection, executable-target refusal
- 48 unit cases + 10 headless click e2e cases; Go tests included
2026-08-04 16:32:41 +00:00
ryukeilee 5abf294509 feat(evidence): recognize swift test as a delivery verification command (#7480)
* feat(evidence): recognize swift test as a delivery verification command

Why: Swift projects using SwiftPM had no host-recognized verifier for
delivery finalization, so every sign-off fell back to git diff --check.
Add the classifier branch so swift test (like cargo/dotnet test) counts
as a non-mutating verification, while swift build/run/package stay
fail-closed mutations since they emit binaries or mutate the package.

* fix(evidence): reject swift test --xunit-output as a verifier

Why: review found swift test --xunit-output report.xml writes a JUnit
report to an arbitrary path but was still classified as a non-mutating
verification. Add xunit-output to writeOutputFlags (same treatment as
pytest --junitxml / gotestsum --junitfile) and cover it in the Swift
negative cases; document the --enable-code-coverage boundary.

* fix(evidence): reject swift test scratch-dir redirects as verifiers

Why: review found --scratch-path (legacy alias --build-path) redirects
the SwiftPM build dir outside the package, and --generate-linuxmain was
flagged as a source-tree writer. Verified against Swift 6.0.3: only
--scratch-path exists, so add it to writeOutputFlags and document the
boundary; cover the = form of --xunit-output and back the
--enable-code-coverage acceptance with a test.

* fix(evidence): actually reject swift test --build-path

Why: the previous commit's comment claimed --build-path is a legacy
alias of --scratch-path but only scratch-path was added to
writeOutputFlags, leaving swift test --build-path fail-open. Add both
flags and cover the legacy form in the negative test cases.

* test(evidence): cover --build-path equals form in swift negatives

Why: review nit noted --xunit-output tests both space and equals forms
but --build-path only the space form; the equals form exercises the
same TrimLeft/equals-trim path as other flags, so pin it explicitly.

* fix(evidence): reject Swift 6.x event-stream and attachments flags

Why: security review found swift test (Swift 6.x) gained four write
flags not covered by writeOutputFlags: --event-stream-output-path /
--experimental-event-stream-output (swift-testing JSON output) and
--attachments-path / --experimental-attachments-path (Swift Testing
attachments dir). All four write to caller-specified paths and would
bypass the mutation gate. Add them with negative test cases and note
the Package.swift build-plugin trust boundary in the swift comment.

* fix(evidence): align gofmt, reject cache-path and swift control modes

Why: security review flagged gofmt misalignment (CI would fail), the
--cache-path shared-cache redirect was not rejected, and control modes
(--help/-h/--version/--list-tests/-l) that do not run the test suite
could masquerade as verification receipts. Mirror the tsc treatment of
--help in the swift branch and add negative cases.
2026-08-04 16:32:13 +00:00
YB ed038ddd59 fix(config): stop custom providers inheriting DeepSeek's defaults (#7386)
* fix(config): stop custom providers inheriting DeepSeek's defaults

A user-defined provider silently acquired DeepSeek's balance_url, USD price
table and context_window = 1000000 the first time its config was loaded, and
those values were then persisted into the user's own file on the next rewrite.

TOML array-of-tables decoding is positional: BurntSushi/toml unifies
[[providers]][i] onto whatever the destination slice already holds at index i
rather than onto a zero value. Every config load seeds from Default(), which
ships two official DeepSeek entries, so the first two [[providers]] a user
declares were overlaid onto them and inherited every field left unset.

Consequences were not limited to cosmetics. A self-hosted endpoint was pointed
at api.deepseek.com/user/balance, cost readouts applied a foreign price table,
and compaction was sized against a 1M window instead of the real one, so it
never fired before the endpoint began rejecting requests.

Re-decode the providers array onto an empty slice so a declared entry carries
only what the file declares. Endpoint-keyed backfills still apply the defaults
that are genuinely correct for an entry, so official DeepSeek providers keep
their pricing.

Fixes #7357
Fixes #7358

* fix(config): keep official DeepSeek endpoint defaults after provider isolation

Isolating the decoded provider list stopped custom providers inheriting
DeepSeek's fields, but it also dropped context_window and balance_url for
configs that declare the official endpoint and legitimately omit them. Runtime
then saw ContextWindow == 0, which disables compaction, and an empty
balance_url removes the wallet readout.

Reapply both from backfillDeepSeekOfficialPrices, which already runs on the
runtime and edit loaders and already matches on api.deepseek.com. Keying on the
endpoint is what makes the values safe to infer, and is why this cannot leak
onto a custom provider the way the positional decode overlay did.

Declared values are never overwritten, so narrowing the window for the official
endpoint still works.

This also fixes a pre-existing gap: on main-v2 the runtime loader already
returned ContextWindow == 0 for an official provider that omitted the field,
because only the edit loader benefited from the accidental overlay.
2026-08-04 16:26:54 +00:00
Ahmed Al Busaidy 88ac28dac1 fix(provider): raise model-list response cap with truncation detection (#7383)
The 256 KB LimitReader on model-list responses truncates OpenRouter's
~531 KB response, causing a JSON parse failure. Raised to 2 MiB with
overflow detection (the +1 pattern used throughout the codebase), and
extracted into a named constant matching the project's conventions.

Fixes #6746
2026-08-04 16:23:45 +00:00
LI Xinyu 85d2a01758 fix(cli): resolve global hooks from platform Reasonix home in hook machine (#7441)
runHookCommand defaulted options.homeDir to config.ReasonixHomeDir(),
but hook.Inspect treats HomeDir as an OS user home and appends .reasonix
inside the hook package. The double append resolved global hooks from
<home>/.reasonix/.reasonix/settings.json instead of the real
<Reasonix home>/settings.json, so 'reasonix hook status|list --json'
reported global hooks as missing everywhere, and on Windows even the
single .reasonix append was wrong (%AppData%\Roaming\reasonix).

Leave HomeDir empty so the hook package resolves the platform Reasonix
home itself — correct on every OS and with --home-dir overrides
unchanged.

Fixes #7420
2026-08-04 16:19:58 +00:00
SivanCola 007b7cc54b fix(updater): recover superseded pending transactions (#7376)
Problem:
Versioned installations can inherit a valid pending update transaction that still targets the former flat install layout. Recovery rejects that transaction as outside the current Guard installation, so every later update remains blocked even after reinstalling.

Root cause:
The superseded-transaction recovery path called the ordinary transaction reader. That reader correctly requires rollback targets to belong to the current launcher directory, but the requirement makes the recovery path reject the exact flat-to-versioned transition it was designed to heal.

Fix:
Validate superseded transactions through a dedicated fail-closed path that proves the current executable is the active versioned desktop, reuses the ordinary transaction and backup validation, and requires every old release member to belong directly to the same flat install root. Re-read the full transaction identity under the pending lock, archive with no replacement, verify the moved transaction, and preserve rollback backups. Run recovery before download and again before install so users do not pay bandwidth or retry costs for a recoverable state.

Verification:
Root and Desktop Go tests and vet passed. Focused repair and updater tests, deterministic concurrency coverage, race tests, repeated recovery runs, and Windows amd64 cross-compilation passed.
2026-08-05 00:16:09 +08:00
YHH b98aff3201 fix(agent): let the planner conclude answer-only turns without an executor round (#7504)
Extend the [no_changes] contract to answer-only turns so the relay path handles pure Q&A (one answer, no executor round); strip the contract lines from display like [goal:*]. Fixes #6789
2026-08-04 16:04:23 +00:00
YHH d3f9f52b8d fix(acp): replay the user-authored view, not the persisted wire form (#7502)
Reuse UserMessageText / DisplayAssistantText at ACP replay so injected wrappers and protocol markers never reach the client; history untouched. Fixes #6882
2026-08-04 15:56:14 +00:00
HaoyueQin 9bc7063d16 feat(desktop): Primer data-viz two-set palette for model usage charts
The model usage charts (per-model donut and stacked daily trend) drew every
model in one monochrome accent ramp, which review feedback found too uniform
("all one colour"). Replace the ramp with GitHub Primer's official data-viz
tokens as a fixed two-set palette — --chart-1..5 plus the gray --chart-other,
light and dark variants defined in styles.css — so the top five models each
keep a distinct rank colour and everything beyond collapses into the gray
"Other" step, with no JS theme detection.

Each series colour is mixed 72% toward --bg-elev like the heatmap levels, so
the pure hexes sit softly on the card in every theme style and theme pack.
The dark orange uses the palette's brighter #c46212 step because the official
#984b10 emphasis reads brownish on the dark background. Palette values were
verified against primer.style and the published @primer/primitives npm CSS.

Also folds in review fixes: the command-palette usage-stats entry now consumes
its focus target once (one-shot subtab switch, mirroring BotsSection), and the
expandable "Other" row is keyboard-accessible (role=button + Enter/Space)
instead of click-only. Chart colour mapping is pinned by the usage-stats panel
assertions (18/18). No data or storage format changes — stats stay append-only
daily JSONL, so existing users' data renders unchanged.
2026-08-04 23:47:16 +08:00
YHH cf763955f4 feat(control): send unknown slash input as a regular message (#7496)
Unknown slash input falls through to a normal model turn at the controller fallthrough and the TUI's local dispatch, with the typo notice kept. MCP prompt namespace misses keep their hard error. Fixes #5756
2026-08-04 15:42:56 +00:00
YHH 7f8d700828 fix(desktop): stop empty sessions hijacking topic resolution (#7494)
Content-bearing sessions outrank content-free ones in topic resolution; newest-wins only among equals. Lazy newest-first content probe keeps the common case at one file read. Fixes #7305
2026-08-04 23:38:03 +08:00
YHH 0c2893af06 fix(agent): stop autoresearch evidence blocks leaking into displayed answers (#7493)
Unify assistant display filtering into agent.DisplayAssistantText (goal markers + evidence blocks), applied at both Message emission points; controller reuses the shared strip; TUI adopts the canonical Message text. History stays raw for parsing. Fixes #6665
2026-08-04 15:33:58 +00:00
YHH 55f238f9c7 fix(tui): snapshot the session on every quit gesture (#7489)
Double Ctrl+C, Ctrl+C after cancel, Ctrl+D, typed exit/quit/:q, and /quit //exit returned tea.Quit directly, skipping the Snapshot + lease-follow finalization. Route all quit gestures through tuiShutdownMsg so every exit persists the session. Fixes #5879
2026-08-04 23:03:51 +08:00
liaoyl830 e8be55eef3 fix(tui): preserve queue cursor when editing queued feedback (#4916)
Typing while browsing a queued item no longer resets queueEditCursor, so Enter saves the edit in place instead of appending a duplicate. Fixes #4877
2026-08-04 22:27:55 +08:00
SivanCola 21c0ae388b fix(release): load recovery control in npm job (#7482) 2026-08-04 13:53:48 +00:00
SivanCola 2a9fa2e5d8 fix(release): load npm recovery control plane (#7478) 2026-08-04 21:42:40 +08:00
SivanCola 1474c5204a fix(release): tolerate forbidden npm staging cleanup (#7477) 2026-08-04 21:18:50 +08:00
Yan Li 0cba036b97 merge: main-v2 (f24b095fc v1.19.6) into fix7234and7168
统一分支先行合并上游(单一冲突决策点):3 个 remote-protocol
生成产物冲突取上游版 + remote-protocol-gen 重新生成。
两 PR 分支将从本分支同步,避免各自解决冲突产生语义差别。
2026-08-04 21:02:44 +08:00
YHH 86b28aaf84 docs(desktop): correct the Windows signing state (#7475)
The section claimed no Authenticode certificate exists. Stable Windows
builds have been signed through SignPath since desktop-v1.14.1, and the
release fails if any payload binary is unsigned — the stale text sent at
least one reporter chasing signing as the cause of an unrelated network
problem. macOS is still unsigned; say so separately.
2026-08-04 21:00:04 +08:00
YHH e9e6589b3a fix(sysproxy): record why Windows proxy detection fell through (#7474)
WinHttpGetIEProxyConfigForCurrentUser fails on a service account or an
RDP session with no per-user IE config, and the failure was returned as
a plain nil — indistinguishable from "the system is set to direct". Log
the errno, and log which of the three sources were present when nothing
matched, so a corporate machine that can't reach the API is diagnosable
from the debug log instead of by guessing.
2026-08-04 20:59:25 +08:00
YHH 78bc2f8266 fix(desktop): zoom on title-bar double click on macOS (#7473)
The Wails drag runtime drops any mousedown with detail != 1, so a double
click on a --wails-draggable region is swallowed rather than reaching the
OS. Windows already handled it here; macOS hides its title bar the same
way and had nothing, so double-clicking the header did nothing at all.
2026-08-04 20:59:16 +08:00
Yan Li f968a61604 fix(responses): zero-usage suppression preserves completion semantics (#7168 full)
P2c 合入统一分支(responses 主题内,职责清单确认):
- 全零+stop 也发送 ChunkUsage(旧条件吞掉 stop → reasoningOnly 完成
  失效 → 空回复误判重试)
- 计费层 Pricing.Cost 对全零天然 0 成本,不污染统计
- 测试更新:TestAllZeroUsageCompletedEmitsCompletionSemantics

验证:responses+agent 全绿 + lint 0。
2026-08-04 20:20:16 +08:00
SivanCola f24b095fcb Merge pull request #7471 from esengine/release-notes/v1.19.6
docs(release): 准备 v1.19.6 中英更新日志
2026-08-04 20:15:49 +08:00
SivanCola 0c86e03a67 docs(release): include readable git status labels 2026-08-04 20:15:00 +08:00
YHHandGTC2080 786dc6d33e fix(desktop): label git status in the changes panel (#7469)
The panel printed the raw porcelain code, so an untracked file showed as
"??" and staged/unstaged pairs as "MM". Map the codes to reader-facing
labels in all three locales, passing unknown codes through unchanged.

The unmerged set is matched as whole codes: AA and DD carry no U and
would otherwise read as a plain add or delete.

Co-authored-by: GTC2080 <[email protected]>
2026-08-04 20:12:34 +08:00
SivanCola 7c95b1c9dd Merge pull request #7470 from esengine/release-notes/v1.19.6
docs(release): 准备 v1.19.6 中英更新日志
2026-08-04 20:03:26 +08:00
SivanCola 1415df287e docs(release): include final v1.19.6 fixes 2026-08-04 20:02:41 +08:00
SivanCola 90082f993d Merge remote-tracking branch 'origin/main-v2' into release-notes/v1.19.6 2026-08-04 20:02:07 +08:00
SivanCola 6d8ec2220a Revert "docs(release): prepare v1.19.6 notes"
This reverts commit 17a5244606.
2026-08-04 20:02:07 +08:00
github-actions[bot] 17a5244606 docs(release): prepare v1.19.6 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-08-04 12:01:13 +00:00
YHH 36d697587c fix(desktop): move the workspace toggle to the end of the topic bar (#7468)
Workbench rendered the collapse/expand control first in the action row,
so it sat left of copy, export, changed, terminal and the palette —
visually mid-bar rather than against the edge of the panel it controls.
Creation already rendered it last; render one block at the end for both.
2026-08-04 20:00:09 +08:00
YHH 0f5a900c45 fix(session): drop leading @file refs from the session preview (#7464)
Prompts that open with @path references produced a preview — and so a
sidebar title — made entirely of paths, with the actual request pushed
past the 80-rune clamp. Skip the leading reference run; a prompt that is
nothing but references keeps them.
2026-08-04 19:59:01 +08:00
czy 8e84e1df36 merge: sync latest upstream main-v2 and resolve package conflict 2026-08-04 19:43:36 +08:00
SivanCola 35e6cffead Merge pull request #7467 from esengine/release-notes/v1.19.6
docs(release): 准备 v1.19.6 中英更新日志
2026-08-04 19:42:42 +08:00
SivanCola 61fee7e38e docs(release): review v1.19.6 late merges 2026-08-04 19:41:56 +08:00
SivanCola a5aa171f23 Revert "docs(release): prepare v1.19.6 notes"
This reverts commit 26765dcdf6.
2026-08-04 19:41:01 +08:00
github-actions[bot] 26765dcdf6 docs(release): prepare v1.19.6 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-08-04 11:40:16 +00:00
YHH 80228bf4a7 fix(provider): name the payload that failed to decode in a stream (#7461)
A gateway putting non-JSON on a data: line produced only "invalid
character 'D' looking for beginning of value", which identifies neither
the sender nor what arrived — the reports it generates are unactionable.
Quote a bounded excerpt of the offending payload instead.
2026-08-04 19:35:10 +08:00
YHH 9271090815 fix(provider): honor a Retry-After longer than the backoff cap (#7459)
A rate-limit window is routinely longer than the 15s exponential-backoff
ceiling, and clamping the server's Retry-After to it spent attempts
re-hitting the same closed window until MaxRetries ran out. Honor the
header up to 60s, and accept its HTTP-date form as well as delta-seconds
— gateways in front of rate-limited backends send both.
2026-08-04 19:35:02 +08:00
YHH 99cf4ae5f3 fix(desktop): scale transcript text with the text-size setting (#7457)
Thinking headers, tool card labels, attachment and pasted-block chrome,
memory citations and the inline edit box carried literal pixel sizes, so
Ctrl+= and the appearance setting left them fixed while the surrounding
message text grew. Point them at the proportional tokens the transcript
region already redeclares.
2026-08-04 19:34:55 +08:00
YHH 4bc7f43478 fix(subagent): treat a non-directory store path as an error on Windows (#7456)
os.ReadDir on a regular file reports ENOTDIR on unix but ENOENT on
Windows, so the IsNotExist branch accepted a corrupt store as "no
subagents yet" and startup continued past it. Confirm the path is
really absent before taking that branch.

TestNewSubagentStoreNeverCachesCleanupError failed on every Windows
checkout for this reason.
2026-08-04 19:34:46 +08:00
YHH c303e077eb feat(glob): bound the recursive walk with a timeout (#7455)
A deep or network-backed tree could keep glob walking for tens of
minutes with no way out short of interrupting the turn. Mirror grep:
default the walk to 30s, accept timeout_seconds up to 300, and return
the matches found so far labelled as incomplete rather than an error.
Cancellation still surfaces as an error so an interrupt is not mistaken
for a finished search.
2026-08-04 19:34:39 +08:00
SivanCola 72be016952 Merge pull request #7462 from esengine/release-notes/v1.19.6
docs(release): 准备 v1.19.6 中英更新日志
2026-08-04 19:25:26 +08:00
SivanCola ada5bdf8fd docs(release): bind v1.19.6 notes to final runtime 2026-08-04 19:24:47 +08:00
SivanCola 7aabfaeb87 Revert "docs(release): prepare v1.19.6 notes"
This reverts commit 4cb097d619.
2026-08-04 19:24:19 +08:00
github-actions[bot] 4cb097d619 docs(release): prepare v1.19.6 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-08-04 11:23:11 +00:00
SivanCola e37e035bd5 Merge pull request #7458 from esengine/codex/fix-release-v1.19.6-windows-gate
fix(release): make Windows full gate portable
2026-08-04 19:21:49 +08:00
SivanCola 238d9a673b fix windows release gate portability 2026-08-04 19:08:18 +08:00
SivanCola b4e4168947 Merge pull request #7454 from esengine/release-notes/v1.19.6
docs(release): 准备 v1.19.6 中英更新日志
2026-08-04 18:42:37 +08:00
SivanCola 288447c8f2 docs(release): refine v1.19.6 product notes
Keep the generated bilingual notes focused on user outcomes, replace the duplicate version title, and declare every affected product surface.

Verification: release catalog validation; release note rendering tests; public PR citation review.
2026-08-04 18:41:35 +08:00
github-actions[bot] 19b78f7ead docs(release): prepare v1.19.6 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-08-04 10:39:01 +00:00
SivanCola ad763bce81 Merge pull request #7452 from SivanCola/fix/release-v1.19.6-notes-generator
Keep the release-notes JSON budget / 保证发布说明 JSON 输出预算
2026-08-04 18:37:52 +08:00
SivanCola 209cde2eff fix(release): preserve the notes JSON budget
Problem: Prepare release exhausted or truncated the structured response because DeepSeek v4-pro spent the shared generation budget on hidden reasoning.

Fix: disable thinking for deterministic release-note generation, retain the 8K JSON budget, surface the final finish reason, and lock the request contract into the release workflow test.

Verification: node --check scripts/generate-release-notes.mjs; bash scripts/release-workflows.test.sh; git diff --check.
2026-08-04 18:22:01 +08:00
SivanCola 9b3a452f1f Merge pull request #7449 from SivanCola/fix/release-v1.19.6-frontend-test
Restore the complete frontend release gate / 恢复完整前端发布门禁
2026-08-04 18:04:10 +08:00
SivanCola d4a416d47d test(frontend): restore the complete release gate
Problem:
The formal frontend test gate could not run because the token-format test imported an undeclared Vitest dependency. After that was exposed, a subagent layout contract still asserted the pre-responsive CSS value.

Root cause:
The token-format test was verified outside the repository test runner and never added to the package scripts. The responsive subagent layout fix changed minmax(200px, 1fr) to minmax(0, 1fr) without updating its source contract test.

Fix:
Use the repository's Node test APIs, execute the token-format coverage from pretest, and align the subagent contract with the shrinkable equal-column layout.

Verification:
pnpm --dir desktop/frontend exec tsx src/__tests__/format-tokens.test.ts
pnpm --dir desktop/frontend exec tsx src/__tests__/capabilities-panel-actions.test.ts
pnpm --dir desktop/frontend test:all
pnpm --dir desktop/frontend build
2026-08-04 17:51:19 +08:00
YHH fd04316dfa fix(tui): show the whole command on a long approval (#7447)
The approval banner clipped the subject to one line, so a long command
hid the part that decides the answer — the path being written or the
flag that makes it destructive. Clipped subjects now expand below the
banner, wrapped and bounded so a heredoc cannot push the composer away.
2026-08-04 17:48:59 +08:00
YHH b42f0f6cd6 fix(config): migrate global hooks to the new home (#7445)
The legacy-path migration copied sessions, projects, skills, archive and
hooks.json, but global hooks live in settings.json — so upgrading to the
unified home silently emptied them. Adds settings.json, and stops any
file item from overwriting one already present at the destination.
2026-08-04 17:32:07 +08:00
SivanCola b47a757460 Merge pull request #7446 from SivanCola/fix/consolidate-npm-security-alerts
fix(deps): resolve npm security advisories / 修复 npm 依赖安全告警
2026-08-04 17:30:48 +08:00
SivanCola bf14218723 fix(deps): resolve npm security advisories
Problem:
Dependabot reports 24 open alerts across the frontend and Worker lockfiles for vulnerable Hono, Undici, and PostCSS versions.

Root cause:
The Hono manifests allowed an affected release, while Miniflare and jsdom still pin Undici 7.28.0 transitively. The crash-report Vite tree also retained an affected PostCSS release.

Fix:
Raise the Hono security floor, pin Undici 7.29.0 through package-manager-native overrides, pin PostCSS 8.5.23 for crash-report, and regenerate all affected lockfiles.

Verification:
- pnpm/npm audit in all four projects
- Worker TypeScript checks
- crash-report Vitest suite (83 tests)
- desktop frontend production build and bundle budgets
- Wrangler dry-run builds for all three Workers
2026-08-04 17:27:19 +08:00
SivanCola 71533a6a4a Merge pull request #7405 from zdjmrq/fix/issue-7399
fix(desktop): lifecycle-safe subagent cleanup / 修复子代理清理竞态
2026-08-04 17:26:13 +08:00
YHH 2c0e6c9437 fix(permission): treat destructive git commands as destructive (#7437)
git tag counted as read-only regardless of arguments, so `git tag -d` and
tag creation passed the read-only boundary that plan mode and capability
runners rely on. Restore/checkout-discard/stash-drop carried no danger
label either, which also let them earn a broad session prefix grant.
2026-08-04 17:17:21 +08:00
czy 3c4d8a3d37 fix(desktop): keep task summary locale bundle within budget 2026-08-04 17:15:05 +08:00
YHH 9fdca5e163 fix(boot): keep the executor when planner_model is unresolvable (#7434)
A planner_model naming a provider that no longer resolves failed the
whole build, so the desktop opened into a startup error with no usable
chat — a stale ref from recovered config was enough. The planner is
optional; it now degrades to executor-only with a notice, matching how
the guardian model already behaves.
2026-08-04 17:04:37 +08:00
czy cbdadd9111 merge: include latest task monitor fixes 2026-08-04 16:58:50 +08:00
czy de281b5287 merge: sync latest upstream main-v2 2026-08-04 16:58:32 +08:00
czy dd2823a18f feat(desktop): add session-scoped task summary 2026-08-04 16:56:36 +08:00
SivanCola aae762db12 Merge pull request #7430 from Li-Charles-One/fix/creation-hero-stack-center
fix(creation): 空会话 hero 标题+输入框一体居中
2026-08-04 16:52:30 +08:00
SivanCola beb2946d4d chore: merge main-v2 into fix/issue-7399
Problem:
The new documentation-impact workflow checks out the PR head, but this branch predates the guard script and fails before evaluating the PR declaration.

Root cause:
The branch was behind main-v2 after the documentation guard and later desktop fixes landed.

Fix:
Merge current main-v2 so the PR is evaluated with the same guard scripts and integration baseline used by the target branch.

Verification:
- merge completed without conflicts
- focused cleanup and desktop tests passed on the merged tree
- focused race tests passed on the merged tree
- root full suite and vet checks passed on the preceding latest-base integration tree

Co-authored-by: SivanCola <[email protected]>
2026-08-04 16:38:55 +08:00
Li-Charles-One 01658c03d0 fix(creation): skip empty hero when IM/Bot detail is open
creationEmptyHero collapsed .main for empty sessions, which also hosts SidebarImConnectionDetail. Exclude sidebarImDetailConnection and assert in app-chrome-tabs.
2026-08-04 16:34:33 +08:00
SivanColaandsiwenyu666-beep ec2cd41f58 fix(desktop): make stale subagent sweeps lifecycle-safe
Problem:
Concurrent desktop controller builds could bypass an in-flight sweep, while the permanent process marker prevented later crash leftovers from being repaired.

Root cause:
A boolean sync.Map conflated in-progress and completed cleanup and never invalidated successful entries.

Fix:
Run cleanup on every build, protect other live desktop parents with a process-local liveness probe, and let an initial build repair its own crash leftovers before binding the session lease. Keep the bounded startup lease retry as defense in depth.

Verification:
- go test ./internal/agent ./internal/boot -run Test(SubagentStoreCleanupStaleRunning|NewSubagentStore) -count=20
- go test -race ./internal/agent ./internal/boot -run Test(SubagentStoreCleanupStaleRunning|NewSubagentStore) -count=5
- cd desktop && go test . -run Test(SessionParentLive|CleanupStaleRunningWithDesktopParentProbe|WithSessionLeaseContentionRetry) -count=20
- cd desktop && go test -race . -run Test(SessionParentLive|CleanupStaleRunningWithDesktopParentProbe|WithSessionLeaseContentionRetry) -count=5
- go test ./...
- go vet ./internal/agent ./internal/boot
- cd desktop && go vet .

Co-authored-by: SivanCola <[email protected]>
Co-authored-by: siwenyu666-beep <[email protected]>
2026-08-04 16:33:40 +08:00
YHH 5635adf7b7 fix(desktop): stop recreating a deleted workspace folder (#7432)
Topic-state writers ran MkdirAll on <root>/.reasonix, so a workspace
folder deleted outside Reasonix came back on the next launch — the tab
still pointed at it and the first title write rebuilt the whole path.
Project-scoped writes now require the root to still exist; the global
store is unaffected.
2026-08-04 16:28:56 +08:00
Li-Charles-One 4ce7029526 fix(creation): center empty hero title+composer as one stack
Keep the headline above the slim composer in the same footer stack so the pair centers together. Also force-remove footer border-top under Creation (theme-style specificity).
2026-08-04 16:20:36 +08:00
SivanCola fce1223d19 Merge pull request #7402 from SivanCola/feature/embedded-versioned-docs
feat(docs): Embed version-matched product docs / 内置版本匹配产品文档
2026-08-04 16:00:29 +08:00
SivanCola 7e68366c9e Merge pull request #7421 from SivanCola/fix/bot-notice-audience
Keep recovery notices out of IM chats / 避免向 IM 聊天转发恢复通知
2026-08-04 15:59:20 +08:00
SivanCola c993eaad24 fix(desktop): suppress operator notices in IM forwarding
Problem:
The Desktop bot bridge remained a second IM delivery sink that forwarded every warning, including recovery notices explicitly scoped to local operators.

Root cause:
The new notice-audience policy was enforced only by the CLI bot renderer. The Desktop event forwarder still treated warning severity as end-user delivery policy.

Fix:
Skip operator-scoped notices before the Desktop forwarder queues IM output, while preserving ordinary user warnings. Add a deterministic queue-level regression that proves the two behaviors together.

Verification:
- cd desktop && go test . -run TestBotEventForwarder -count=1
- cd desktop && go test -race . -run TestBotEventForwarder -count=1
- cd desktop && go test ./...
- git diff --check
2026-08-04 15:53:34 +08:00
SivanCola 671808a9cb fix(docs): bound search and frame corpus digests
Problem:
- documentation search accepted unbounded queries before tokenization and BM25 scoring
- delimiter-only corpus hashing did not encode file boundaries unambiguously

Root cause:
- the shared search path had no provider-visible or runtime query limit
- digest input concatenated names and bytes with a delimiter that valid UTF-8 content could contain

Fix:
- cap search queries at 4096 Unicode characters in both schema and runtime
- hash a versioned stream of length-prefixed file records
- add regression coverage for both entry paths and the legacy framing collision

Verification:
- go test ./...
- go test -race ./internal/productdocs ./internal/boot ./internal/control ./internal/cli ./internal/remote/workbench/runtime
- ./scripts/cache-guard.sh
- pnpm --dir desktop/frontend build
- npm test and npm run build in site/
2026-08-04 15:46:10 +08:00
SivanCola 0ac5bb9bb6 docs(site): explain embedded docs search
Documentation:
- add bilingual guidance for the embedded documentation command
- explain qualified-name fallback when another skill owns /docs

Verification:
- npm test in site/
- npm run build in site/
2026-08-04 15:40:55 +08:00
YHH 33902e96f3 fix(cli): authenticate release lookups with GITHUB_TOKEN (#7418)
The GitHub API fallback ran anonymously, and that quota is 60/hour per
IP — shared by everyone behind the same NAT, so `reasonix upgrade` could
fail with 403 on a network where nobody had upgraded that hour. Uses
GITHUB_TOKEN/GH_TOKEN when set, and says so when a refusal is the quota.
2026-08-04 15:39:05 +08:00
SivanCola 6acd67f254 Merge pull request #7225 from Lxiny-zy/fix/subagent-corrupt-meta-startup
fix(agent): skip corrupt subagent metadata during startup cleanup
2026-08-04 15:37:27 +08:00
SivanCola 29bea9ff06 fix(bot): keep recovery notices out of IM chats
Problem:
Successful local session recovery emitted Warn notices that the CLI bot gateway forwarded to WeChat, QQ, and Feishu as if they were actionable user warnings.

Root cause:
Notice severity doubled as delivery policy. Recovery events had neither an explicit audience nor stable identities, so every Warn notice was treated as an end-user chat message.

Fix:
Add a backward-compatible operator notice audience and stable recovery codes, mark every controller recovery outcome as operator-only, and suppress only that audience in the bot renderer. Preserve ordinary user warnings and local recovery handling. Use the stable codes for Desktop localization and quiet-notice routing, with text fallback for older records.

Verification:
- go test ./...
- go test -race ./internal/event ./internal/control ./internal/bot
- go vet ./...
- cd desktop && go test ./...
- pnpm build
- focused frontend recovery notice tests
2026-08-04 15:34:23 +08:00
SivanCola 3ff2573dc3 fix(agent): keep cleanup lease across corrupt rereads
Problem: CleanupStaleRunning released a parent session lease before skipping a corrupt metadata reread, so later refs for the same parent could be rewritten without ownership protection.

Root cause: The lease release ran before the code classified the reread failure as a skippable decode error or a fatal storage error.

Fix: Retain the parent lease for skippable decode failures, release it only before fatal returns, and add a deterministic interleaving test that corrupts the first reread and probes the lease before the second ref.

Verification: go test ./internal/agent -count=1; go test -race ./internal/agent -count=1; go vet ./internal/agent; go test ./internal/boot -count=1; go test ./... -count=1; focused cleanup tests passed 20 times normally and 5 times under race.

Co-authored-by: SivanCola <[email protected]>
2026-08-04 15:18:13 +08:00
SivanCola 0c44effe8d fix(docs): unify slash command ownership
Problem:
Hidden plugin command and skill aliases could own /docs at runtime while Desktop and remote menus still advertised the built-in documentation command.

Root cause:
Dispatch, CLI discovery, Desktop discovery, and remote session catalogs derived slash ownership independently, and the remote protocol did not advertise host-owned built-in routes.

Fix:
Centralize slash ownership resolution, preserve every existing command, allocate a deterministic conflict-free Reasonix-qualified fallback for built-in docs, and publish the host-resolved route through an additive optional catalog field. Update local help and bilingual documentation to show the effective invocation.

Verification:
- go test ./...
- cd desktop && go test ./...
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend test:composer-menu-viewport
- go test -race ./internal/productdocs ./internal/control ./internal/cli ./internal/remote/workbench/runtime
- ./scripts/cache-guard.sh
- go run ./cmd/remote-protocol-gen --check
- bash scripts/check-docs-impact.test.sh
2026-08-04 15:14:52 +08:00
SivanCola f9e10c091f fix(extension): closed-connection write errors, lazy writer, Windows test, redaction helper
Third review round fixes for PR #7373.

Problem: a graceful shutdown stored a nil terminal error, so a Notify after
EOF reported success for a frame that was never written; queued jobs were
completed with nil while being discarded; and the enqueue path could admit
frames after the writer exited. Separately, NewConn started the writer
goroutine eagerly with no lifecycle entry, the session-save test searched
raw JSON for an unescaped path (fails on Windows), and the redaction
regression test exercised a helper the production path did not use.

Fix:
- rpcwire: terminalError() never returns nil (a clean EOF synthesizes a
  connection-closed error; Serve keeps the raw closeReason so graceful
  ends still report nil). After close, the writer loop switches to a
  permanent drain mode: frames enqueued in the close race are answered
  with the terminal error instead of silently dropped. The writer loop
  and stall watchdog now start lazily on the first write or Serve, so a
  Conn constructed but never served leaks no goroutine. Regression:
  TestNotifyAfterGracefulCloseFails (post-EOF Notify/Request must fail,
  20x).
- control test: decode the session.save payload into
  dispatch.SessionPayload and compare typed fields instead of searching
  raw JSON for a path (backslashes are escaped on Windows).
- desktop: deferredReloadFailedText is now the single production
  formatter for both the log and the tab warning, so the tested helper IS
  the production path.

Verification: go test ./internal/rpcwire incl. -race; go test
./internal/extension/...; GOOS=windows vet; desktop redaction test;
gofmt clean.

Follow-up: close the completed-write vs teardown race deterministically.
writerBusy now spans a whole job including the result send, and shutdown
waits for the writer to go idle (bounded at 100ms; the stall watchdog owns
the wedged-write case) before closing the connection, so a finished write's
result is always visible before the terminal error. Also align the
capabilities-panel test with main-v2's narrowed subagent picker columns
(#7362 changed minmax(200px,1fr) to minmax(0,1fr) without updating the
expectation).
2026-08-04 15:09:21 +08:00
SivanCola 510abd2b69 Merge pull request #7344 from SivanCola/fix/cli-recovery-session-hygiene
Fix CLI recovery session proliferation and resume clarity / 修复 CLI 恢复会话膨胀与识别
2026-08-04 15:07:24 +08:00
SivanCola 5396dc4076 merge: sync main-v2 into task monitor fixes
Integration:
Bring the contributor branch up to main-v2@80ae90e8 while preserving both the Task Monitor and responsive settings frontend test gates.

Conflict resolution:
Keep test:task-monitor and test:settings-responsive, and run both from pretest.

Verification:
- go test -count=1 ./internal/taskmonitor ./internal/cli ./internal/jobs
- go test -race -count=1 ./internal/taskmonitor ./internal/cli ./internal/jobs
- focused desktop task monitor routing tests
- pnpm test:task-monitor
- pnpm test:settings-responsive
- pnpm typecheck
- pnpm check:css
- pnpm build

Co-authored-by: SivanCola <[email protected]>
2026-08-04 14:58:52 +08:00
SivanCola 41cfc15fe2 Harden CLI recovery trash publication
Problem:
CLI recovery GC exposed a Desktop trash entry before its authoritative event log and sidecars had moved. A crash, restore, or purge in that window could strand a split artifact set. Numeric /resume also ran GC after completion had assigned an index, allowing the index to shift before execution.

Root cause:
The cleanup protocol moved the primary transcript before publishing its durable operation marker, and the numbered resume path mutated the same ordered list it was about to resolve.

Fix:
Stage the transcript and all owned artifacts in a hidden, durably marked directory, reconcile interrupted stages on startup, and publish the complete trash directory with one same-filesystem rename. Retain compatibility for the earlier recovery-trash marker protocol. Keep numbered resume resolution read-only and update its ordering documentation.

Verification:
- go test ./internal/agent ./internal/cli
- go test -race ./internal/agent ./internal/cli
- go vet ./...
- go test ./...
- cd desktop && go test ./...
2026-08-04 14:55:18 +08:00
SivanCola 90eee927de fix(taskmonitor): route controls to runtime jobs
Problem:
Namespaced monitor IDs were passed directly to jobs.Manager, so Stop and Cancel could not reach their live runtime. A recorder completion or heartbeat could also win the post-kill snapshot CAS and make an admitted cancellation look like a version conflict.

Root cause:
Task snapshots did not retain the manager-local job ID, and the control path treated every CAS advance after Kill as an unrelated write.

Fix:
Persist an optional backward-compatible job_id, recover the local ID for snapshots written before that field, route cancellation through the session-scoped controller API, and reconcile expected recorder or heartbeat version advances after runtime admission. Add deterministic package and Desktop assembly regressions.

Verification:
- go test -count=1 ./internal/taskmonitor ./internal/cli ./internal/jobs
- go test -race -count=1 ./internal/taskmonitor ./internal/cli ./internal/jobs
- focused desktop task monitor routing tests
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-04 14:53:48 +08:00
SivanCola 19d5707fce fix(agent): classify corrupt subagent metadata
Problem: CleanupStaleRunning still aborted startup for custom decoder failures such as invalid time values, and its lease-protected second read had no corruption tolerance.

Root cause: LoadMeta wrapped read and decode failures with only textual context, so callers had to enumerate concrete JSON error types.

Fix: Return a private typed decode error, skip that category at both cleanup reads, preserve fatal I/O behavior, and cover invalid timestamps alongside truncated and empty metadata.

Verification: go test ./internal/agent -count=1; go test -race ./internal/agent -count=1; go vet ./internal/agent; go test ./internal/boot -count=1; go test ./... -count=1; focused cleanup tests repeated under normal and race builds.

Co-authored-by: SivanCola <[email protected]>
2026-08-04 14:52:31 +08:00
YHH 80ae90e856 fix(desktop): warn when a workspace config overrides settings (#7413)
Settings writes the user config, but a workspace reasonix.toml outranks
it, so toggles like sandbox and permission mode could read one way and
behave another with nothing on screen to explain it. The panel now names
the file actually in effect when it isn't the one being written.
2026-08-04 14:48:54 +08:00
SivanCola 2077a071ad Merge remote-tracking branch 'origin/main-v2' into feature/extension-kernel-protocol-v1 2026-08-04 14:45:58 +08:00
SivanCola c72b0c54dc Merge pull request #7369 from SivanCola/fix/macos-orphan-update-backup-recovery
fix(updater): recover orphaned macOS app backups / 恢复 macOS 更新残留备份
2026-08-04 14:38:14 +08:00
SivanCola 82518d1bcc Merge pull request #7309 from skyzhao1223/fix/subagent-created-at-meta
fix(agent): keep subagent list CreatedAt as start time
2026-08-04 14:26:52 +08:00
SivanCola 39eea11f70 merge(main-v2): resolve frontend test script overlap
Integration: Merge main-v2 at 8c8f9410e5 so PR #7402 remains mergeable without rewriting existing history.

Resolution: Preserve the base settings-responsive test and pretest hook while retaining the PR virtual-menu identity regression in test:composer-menu-viewport.

Verification: go test for productdocs, control, CLI, fileref, and Desktop; focused race tests; frontend typecheck; composer menu, settings responsive, app chrome, and token-format matrices; go vet and diff checks.
2026-08-04 14:25:22 +08:00
SivanCola 7fc5c669eb fix(docs): preserve exact release and slash resolution
Problem: Stable release queries could score preview notes as exact matches, while CLI and Desktop discovery could show duplicate /docs entries when custom overrides existed.

Root cause: Version boosting matched virtual paths by substring, and discovery surfaces appended builtin, skill, and custom entries independently of runtime dispatch precedence.

Fix: Track the structured release version for exact score boosting and resolve /docs discovery to the runtime-winning custom command, skill, or builtin across CLI completion, CLI help, and Desktop.

Verification: go test ./internal/productdocs ./internal/control ./internal/cli; cd desktop && go test ./...; focused go test -race for productdocs, CLI, and Desktop; go vet for touched modules; pnpm --dir desktop/frontend test:composer-menu-viewport.
2026-08-04 14:21:04 +08:00
SivanCola 8c8f9410e5 Merge pull request #7362 from HaoyueQin/fix/settings-page-narrow-window-layout
fix(desktop): Fix settings page responsive layout on narrow windows / 修复设置页窄窗口下的响应式布局
2026-08-04 14:19:16 +08:00
SivanCola 0d50826eef fix(agent): persist failed status after sidecar errors
Problem:
The CreatedAt sidecar pre-seed could fail before SaveFailed reached its terminal metadata write, leaving a failed subagent durably marked as running.

Root cause:
SaveFailed returned immediately from ensureBranchCreatedAt even though its existing failure-atomic contract deliberately saved subagent metadata after transcript errors.

Fix:
Accumulate the branch sidecar error, continue transcript persistence, always record the failed terminal status, and join all storage errors. Add a corrupt-sidecar regression that proves the error is reported while failed status still reaches disk.

Verification:
- go test ./internal/agent -run "TestSubagentStoreSaveFailedPersistsTerminalMetaWhenBranchMetaIsCorrupt|TestSubagentStoreTerminalSaveKeepsBranchStartAndActivityTimes" -count=20
- go test ./internal/agent -count=1
- go test -race ./internal/agent -count=1
- go vet ./internal/agent
- go test ./... -count=1
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-04 14:16:19 +08:00
HBLADEH f6d6f79ad7 feat(desktop): close tabs with middle click (#4330) 2026-08-04 14:13:12 +08:00
SivanCola bf15e46dd5 fix(extension): serialize wire writes through one writer loop; second review round
Second review round fixes for PR #7373.

Problem: the first write-bound attempt spawned a goroutine per write and
returned on caller-context expiry while the underlying Write kept running —
frames could interleave on one io.Writer, cancelled frames could still
land, blocked goroutines leaked, a 5s intercept ctx preempted the 10s
stall bound, and wmu acquisition itself ignored context.

Fix: exactly one writer goroutine per rpcwire.Conn, fed by a 256-slot
bounded queue. Frames are atomic and ordered by construction: the loop
skips a frame only when its caller gave up before the physical write
began, and finishes every frame it started. Callers enqueue and await
results with their own context (an abort fails only that call). An
independent stall watchdog (MaxWriteStall, default 10s on the sidecar)
fails the connection with WriteStallError when a physical write makes no
progress, and cannot be preempted by any per-call timeout; the sidecar
supervisor kills the process tree on any unexpected connection end.
Deterministic coverage: gated-writer proof that a queued-cancelled frame
never lands and no torn/interleaved bytes ever appear, no-reader pipe
stall, primer-based two-phase test where the caller cancels at 200ms and
the watchdog still fails the connection and reaps the wedged sidecar.

- Windows PID probe: switch to golang.org/x/sys/windows (Go's syscall
  package has no PROCESS_QUERY_LIMITED_INFORMATION); only
  ERROR_ACCESS_DENIED counts as alive — other OpenProcess failures mean
  gone. GOOS=windows go vet passes.
- desktop deferred reload: the failure text reaching logs and the tab is
  now secrets.RedactCredentials-wrapped (CodeQL #280); credential-shaped
  regression test.
- Mechanical gates: gofmt the fake sidecar helper, and SA1012 — pass
  context.Background() explicitly instead of nil.

Verification: go test -race on rpcwire (50x on the response-close and
queued-cancel tests), sidecar full package under -race, extension 9/9
packages, desktop package incl. the redaction test, golden baselines
unmodified, both protocol generators -check, cache-guard 8/8, GOOS=windows
cross-build.
2026-08-04 14:11:36 +08:00
SivanCola a5f500fa15 fix(updater): retire owned orphan backup quarantines
Problem:
Recovered macOS app backups were preserved indefinitely, and the updater had no real protected-directory smoke coverage for the quarantine lifecycle.

Root cause:
The pending transaction did not retain ownership evidence for the quarantined tree, so terminal cleanup could not distinguish it from an unrelated sibling safely.

Fix:
Bind the exact quarantine path and digest to optional transaction fields, include that path in the mutation lock set, and remove it only after an exact terminal transition and digest verification. Preserve changed or concurrently recreated paths. Add an opt-in Darwin smoke test that exercises the real /Applications boundary with a uniquely owned synthetic app.

Verification:
- go test ./...
- go vet ./...
- (cd desktop && go test ./...)
- (cd desktop && go vet ./...)
- go test -race ./internal/repair -run 'AppBundle|PendingUpdate|Handoff|Quarantine|Orphan' -count=1
- REASONIX_MACOS_APPLICATIONS_SMOKE=1 go test ./internal/repair -run TestDarwinApplicationsOrphanBackupLifecycleSmoke -count=1 -v
- pnpm --dir desktop/frontend test:updater
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend build
2026-08-04 14:11:10 +08:00
SivanCola 5ff3021663 fix(desktop): keep memory controls inside wide settings rows
Problem:
The memory tail wrapper stayed a nested flex item above 900px, causing the Suggestions button to overflow the settings row near the breakpoint.

Root cause:
The grouping styles applied at every width, so the outer row shrank the wrapper without preserving the original wide-screen flex relationship.

Fix:
Flatten the wrapper on wide screens and enable its flex grouping only at the narrow breakpoint. Add a focused CSS contract test for both modes.

Verification:
- pnpm --dir desktop/frontend test:settings-responsive
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend build
- Browser measurements at 900, 901, 910, and 920px

Co-authored-by: SivanCola <[email protected]>
2026-08-04 14:09:21 +08:00
SivanCola 4dca2b0b24 Merge pull request #7317 from Li-Charles-One/feat/creation-empty-composer-hero
feat(creation): 精简空会话欢迎页与中间 hero 输入框
2026-08-04 14:07:34 +08:00
SivanCola 2c127bed35 Preserve chronological CLI continue selection
Problem:
Recovery-family presentation ordering made --continue prefer an older recovery leaf over a more recently active parent session.

Root cause:
The non-interactive continue path reused the interactive picker ordering even though --continue promises to resume the most recent session.

Fix:
Select --continue targets directly from the chronological session listing while keeping recovery-family grouping in interactive resume surfaces. Add a regression test that proves the two ordering contracts remain independent.

Verification:
- go test ./internal/cli
- go test -race ./internal/cli
- git diff --check
2026-08-04 14:07:20 +08:00
SivanCola 10e1719096 fix(agent): preserve terminal subagent activity time
Problem:
Seeding branch metadata with the subagent start time also assigned that value to UpdatedAt, so newly completed or failed runs appeared stale in recency-based session ordering.

Root cause:
Session.Save preserves an existing branch UpdatedAt. The pre-seeded start timestamp therefore survived the terminal transcript save and became ListSessionOrder.LastActivityAt.

Fix:
Seed only BranchMeta.CreatedAt and let the terminal metadata write assign UpdatedAt. Replace the timing sleep with deterministic completed/failed coverage that verifies creation time, terminal activity time, and list ordering.

Verification:
- go test ./internal/agent -run TestSubagentStoreTerminalSaveKeepsBranchStartAndActivityTimes -count=20
- go test ./internal/agent -count=1
- go test -race ./internal/agent -count=1
- go vet ./internal/agent
- go test ./... -count=1
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-04 14:04:38 +08:00
SivanCola 2a53cca3e6 Merge remote-tracking branch 'origin/main-v2' into feature/extension-kernel-protocol-v1 2026-08-04 13:56:49 +08:00
SivanCola 5c89fea841 Merge pull request #7406 from SivanCola/docs/publish-v1.19.5-changelog
Publish v1.19.5 changelog / 发布 v1.19.5 更新日志
2026-08-04 13:55:35 +08:00
SivanCola a953a57731 docs(changelog): publish v1.19.5 notes
Context:
The complete bilingual v1.19.5 release record was present but remained hidden from the public changelog.

Change:
Mark the v1.19.5 catalog record as published so both the changelog index and exact-version route render it.

Verification:
- node scripts/release-notes.mjs validate --version 1.19.5
- npm test --prefix site
- npm run build --prefix site
2026-08-04 13:54:21 +08:00
SivanCola 67264bc66a Merge pull request #7359 from tic-top/docs/acp-set-config-option-params
docs(acp): document session/set_config_option's parameters
2026-08-04 13:53:57 +08:00
字多就没人取 1d42053b08 fix(desktop): stop spurious "session already open" startup errors from subagent sweep races
CleanupStaleRunning runs inside every controller build (boot.Build) and
transiently acquires the session lease of each running sub-agent's parent
session while rewriting its metadata. Desktop tabs share one global session
dir, so concurrent tab builds raced that probe against their own startup
bind: a tab whose bind lost the race surfaced a spurious "this session is
already open in another Reasonix window" error for a lease that was
genuinely free milliseconds later.

Two minimal fixes:

- desktop/app.go: startup lease binds (ensureTabSessionLeaseForRebuild,
  acquireCandidateSessionLease) now retry briefly on ErrSessionLeaseHeld,
  absorbing sub-millisecond in-process contention windows. A lease held by
  another window or process stays held for its whole lifetime and still
  fails fast after the bounded retries.
- internal/boot/boot.go: the stale-running sub-agent sweep runs once per
  session dir per process instead of once per controller build, eliminating
  the repeated global re-scan that created the contention. CLI/serve runs
  are fresh processes, so their sweep-once semantics are unchanged. A failed
  sweep drops its marker so a later build retries.

Adds regression tests for both layers. The desktop test reproduces the exact
reported error message before the fix.

Fixes #7399
2026-08-04 13:51:54 +08:00
YHHandHUQIANTAO 34cfca95d0 feat(desktop): unify token display format with K/M abbreviations (#7404)
Four independent token formatters produced four shapes for the same
number (142000 / 142,000 / 142.0k / 142.0K), and the long forms overflow
the sidebar's token column at its default width.

Co-authored-by: HUQIANTAO <[email protected]>
2026-08-04 13:50:38 +08:00
SivanCola d21b67abc4 fix(docs): avoid deprecated Goldmark heading API
Problem: The CI lint gate rejected the embedded documentation parser because Heading.Text is deprecated in Goldmark v1.8.2.

Root cause: Heading extraction relied on the legacy aggregate Text method instead of reading supported inline AST node values.

Fix: Collect heading text from Text, String, AutoLink, and RawHTML nodes while preserving soft line breaks. Extend the parser regression test to cover emphasis, inline code, auto-links, C#, and Setext headings.

Verification: golangci-lint run --timeout=5m; go test ./...; go test -race ./internal/productdocs.
2026-08-04 13:31:06 +08:00
SivanCola 0c542ceb7c feat(docs): embed version-matched product documentation
Problem:
Reasonix installations could not reliably search the exact documentation and release notes shipped with their build, and dynamic slash-menu filtering could leave stale virtual row gaps.

Root cause:
Documentation lived only as source files without a compiled retrieval identity or deterministic slash-command path. The shared virtual menu also let TanStack Virtual cache rows by numeric index while React used different keys.

Fix:
Embed docs and reviewed release notes, add the read-only docs tool and /docs command across CLI and Desktop, stamp and verify corpus identity in release builds, enforce documentation-impact declarations, and align virtual-list semantic keys across slash and file menus.

Verification:
- go test ./...
- go test -race ./internal/productdocs ./internal/boot ./internal/control ./internal/cli
- cd desktop && go test ./...
- pnpm --dir desktop/frontend build
- ./scripts/cache-guard.sh
- bash scripts/release-workflows.test.sh
- bash scripts/verify-embedded-docs.sh v0.0.0 e67b2aa824
2026-08-04 13:20:08 +08:00
YHH 260dc77c1e fix(fileref): keep build outputs out of @ completion (#7401)
The skip list only knew build/ and dist/ plus this repo's own paths, so a
Maven target/, a Python __pycache__ or a venv flooded the "@" menu with
generated files. Extends it to the common ecosystem outputs and gives the
desktop the same list instead of its own drifting copy.
2026-08-04 13:18:36 +08:00
YHHandttmouse e73a78d685 fix(config): keep project provider_access from hiding user providers (#7395)
An undeclared user-level provider_access means "allow all". Merging it as
a union with a project reasonix.toml that does declare one narrowed the
desktop model switcher to the project's list, so account-level providers
disappeared in that workspace. A project-only list is now ignored; the
union still applies once the user config declares its own.

Co-authored-by: ttmouse <[email protected]>
2026-08-04 12:13:52 +08:00
Yan Li 1e8d25ca95 feat(responses): vendor-table-driven default output budget (fable5 step 6)
review minor:responses.go New() 硬编码 vendor=="deepseek" 默认预算
与 caps.defaultMaxOutputTokens 职责重叠——统一:

1. vendor 表 deepseek 条目加 defaultMaxOutputTokens=32K(DefaultReasoning
   OutputTokens)——值与表合一,无散落常量
2. New() 构造期默认从表取(cap.defaultMaxOutputTokens),保留
   thinking-disabled 豁免条件(与 openai.go 一致)
3. buildRequestBody 运行时兜底同条件——否则表兜底会破坏
   "thinking-disabled 不设"语义(现有测试断言,Step 5 沙盒验证发现)
4. 测试:TestVendorTableMaxOutputTokens(mimo 65536 无条件/思考禁用也设、
   deepseek 值来自表);现有 TestRequestUsesOnlySafeProviderOutputDefaults
   5 场景保持全过

场景覆盖:deepseek 思考启用 32K / 禁用不设 / 显式优先 / 负数省略;
mimo 65536 无条件;unknown 不设。

验证:provider/control/remote 全绿 + lint 0。
2026-08-04 12:02:58 +08:00
Yan Li 3a4da243a0 fix(remote): round-trip reasoning id/status across Host<->Desktop broker
诚实盘点(用户追问规避点):管理员 #7234 明确提及"远端 broker 路径,
如适用"——被我当"不适用"跳过。实际适用:BrokerProviderChunk 转换
(FromProvider/ProviderChunk)只传 Text/Signature,ReasoningID/Status
在 broker 往返时丢失 → remote(Host↔Desktop)的 Responses 轮次
reasoning 元数据无法回传。

修复:
1. BrokerProviderChunk 加 ReasoningID/ReasoningStatus(wire DTO)
2. BrokerProviderChunkFromProvider / ProviderChunk 双向转换补字段
3. remote-protocol-gen 重新生成 3 产物
4. TestBrokerChunkRoundTripsReasoningMeta(往返不丢失)

验证:remote/control/responses 全绿 + lint 0。
2026-08-04 11:52:49 +08:00
YHH 71fba1d551 feat(cli): show the active config file in /status (#7393)
A ./reasonix.toml outranks the user-global config, so a session started in
a directory holding one ignores edits to the global file with nothing on
screen to explain it. /status now names the file actually in effect.
2026-08-04 11:50:07 +08:00
Yan Li 5b13be2aa4 fix(control): drop ref-turn methods superseded by *WithFormat wrappers
format 统一绑定到所有 turn 后,runEditedRefTurn/runScopedRefTurn/
runEditedRefTurnWithRefs/runScopedRefTurnWithRefs/runEditedRefTurnWithResolver
无调用者(闭包全走 *WithFormat wrapper)——删除(lint unused)。

control 全绿 + lint 0。
2026-08-04 11:49:30 +08:00
Yan Li 2dbc4ad4f5 fix(control): drop unused isRefTurnInput after unified format binding
ref-turn 统一绑定 format 后 isRefTurnInput 无生产调用(SubmitHTTPFormat
不再丢弃)——删除;测试改用 SlashCodeCommentLine 直接断言。
2026-08-04 11:48:24 +08:00
Yan Li 288beb06e3 fix(control): bind format to ALL accepted turns incl. ref-turn
管理员要求"绑定到被接纳的 turn"——之前只对 runGoalLoop(普通 turn)
绑定,@reference turn 用 notice 丢弃(功能裁剪的合理化,非管理员
意图)。统一架构修正:

runRefTurn/runScopedRefTurn/runRefTurnWithRefs/edited 族加 *WithFormat
wrapper(闭包捕获 format → runGuarded 闭包 withTurnFormat 注入 ctx →
runRefTurnWithResolverSync)——与 runGoalLoop 完全对称,零签名改动。

SubmitHTTPFormat 不再丢弃 ref-turn format(isRefTurnInput 保留用于
测试识别)。

测试 TestRefTurnFormatBound(ref-turn format 注入 + isRefTurnInput
识别)。control 全绿 + lint 0。
2026-08-04 11:47:58 +08:00
Yan Li 36f59c7603 fix(control/responses): strict-review findings (fable5 step 9)
review 严格审核 fix7234and7168:无 blocking,修 2 should-fix + 4 minor:

should-fix:
1. TTL 注释矛盾(config.go/controller.go 声称 5m/60m/10m,实现 24h)
   → 改 24h(DeepSeek/unknown 24h,DashScope/Anthropic 5m)
2. SubmitHTTPFormat 对 @reference turn 静默丢弃 format 却返回 202
   → isRefTurnInput 检测(FileRefLine/SlashPathLineRef 等)+ notice
   提示 + 注释修正(用户不再误以为 JSON 输出生效)

minor:
3. DetectVendor 注释 substring→exact-host
4. 多段推理 reasoningID 末段覆盖 → 注释说明
5. 旧 takePendingResponseFormat 注释引用 → 修正
6. testCacheColdAfter >0||==-1 → !=0 简化

验证:provider 6 + agent + control + config 全绿 + lint 0 + diff-check 0。
2026-08-04 11:43:54 +08:00
SivanCola e473e4f7b5 fix(extension): bound sidecar writes, pin golden search engine, CI and review gates
Review round fixes for PR #7373.

Problem: rpcwire.Conn.Request wrote synchronously before observing the
caller context, so a sidecar that stayed alive but stopped reading stdin
filled the pipe and blocked the write forever — intercept timeouts,
provider/UI calls, bounded shutdown, and /reload could all hang the host.

Root cause: the write path had no cancellation or stall detection, and the
sidecar crash supervisor only fired on process death, not on a live but
unreadable child.

Fix:
- rpcwire: writes now honor the caller's context (an abort fails only that
  request, never the connection) and a new opt-in MaxWriteStall bound —
  any write with no progress past the bound fails the connection with
  WriteStallError. The sidecar client sets a 10s default
  (ClientOptions.WriteStallBound), and its supervisor now kills the
  process tree on any unexpected connection end, covering the
  alive-but-wedged case. Deterministic tests: a pipe that is never drained
  (WriteStallError in ~100ms, connection terminal, later calls fail fast)
  and a fake sidecar wedged after initialize (stall error, crash marked,
  process reaped).
- Golden baseline: pin [tools.search] engine="native" in the fixture — the
  grep tool description (and thus tool schemas, provider request, and
  cache prefix) varies with rg on PATH, which made the golden
  machine-dependent and failed rg-less CI hosts. Regenerated artifacts
  differ only in that description; verified passing with rg excluded from
  PATH.
- providerext: out-of-order chunk buffering now has a 256-entry sequence
  window; a sidecar emitting ever-higher seqs without the missing chunk is
  failed interrupted instead of growing host memory (regression test).
- CI: the sdk job's bash steps declare shell: bash (Windows runners
  defaulted to PowerShell); the PR race sweep gains internal/extension/...,
  boot, control, and tool, and the desktop job gains go test -race ./....
- Windows vet: the first-boot test's syscall.Kill PID probe moves behind
  build-tagged helpers (signal-0 on Unix, OpenProcess query on Windows).
- Lint gates: SA1012 nil-context in the ACP test, QF1007 conditional merge
  in theme_plugin.go.

Verification: go test ./internal/rpcwire (x5 plus -race x3, incl. the
pre-existing response-close race test at -count=200), go test
./internal/extension/... (sidecar also -race), ./internal/remote,
./internal/boot (golden unmodified from the pinned fixture), desktop
focused theme tests, and go build ./... all pass.
2026-08-04 11:43:32 +08:00
YHH 48a78a09f2 fix(cli): accept Ctrl+arrows for composer word motion (#7390)
bubbles binds word-wise cursor motion to Alt+arrows only, which is the
macOS convention; on Windows and Linux terminals Ctrl+Left/Right is what
users press, and it previously did nothing in the composer.
2026-08-04 11:38:24 +08:00
YHH a9c7947a8f fix(cli): enable Windows VT processing before emitting ANSI (#7387)
colorprofile picks a Windows profile from the NT build number alone and
never inspects the console mode, so on a legacy conhost (Win10 PowerShell
outside Windows Terminal) it reports TrueColor while the console prints
the SGR escapes literally. Bubbletea turns VT on for the TUI, but
`reasonix setup`, `config` and `doctor` print outside it.

Enable VT for stdout/stderr at package init and fall back to plain output
when the console refuses it.
2026-08-04 11:25:42 +08:00
Yan Li 59cd4aaf13 fix(responses/control): close remaining 5 review items on fix7234and7168
评审 11 条全部闭环(本分支实现,参考 dev 等价修复,只依赖本分支
已有结构——不引入 dev 检索系统特性):

1. reasoning id/status 贯通 session(#7234 第 1 点):
   provider.Chunk 加 ReasoningID/ReasoningStatus + readStream terminal
   后发 meta chunk + Agent.stream 收集(返回值扩展)+ run_loop message
   持久化 + TestReasoningMetaChunkEndToEnd 端到端(SSE→meta→第二轮)
2. format 绑定 turn(#7234 第 2 点):删 pendingResponseFormat 全局槽,
   SubmitHTTPFormat 参数链(submitHTTPWithFormat→submitCommandOrTurn→
   runGoalLoop 闭包 + withTurnFormat 注入 ctx)+ 双请求/注入测试
3. vision factory 透传(#7234 第 3 点):newFromConfig 原样透传 Extra
   + TestFactoryPassesExtraThrough
4. TTL 失败回退 24h + LoadForRootReadOnly(#7168 第 4 点)+
   TestCacheColdAfterFailureFallsBackTo24h
5. singleSegment wire(Copilot):WarnOnMissingToolCallReasoning
   capability 驱动 + TestSingleSegmentReasoningWiredIntoWarningPolicy

验证:provider 6 + agent + control + config 全绿 + lint 0。
2026-08-04 10:57:40 +08:00
Yan Li 8daf6cfea0 merge: #7234 (MiMo wire alignment) into fix7234and7168
fix7234and7168 = main-v2 + #7168 + #7234(两 PR 统一合并)。

冲突解决(手动逐个,3 文件):
1. controller.go:并集(testCacheColdAfter + pendingResponseFormat)
2. responses.go:
   - summary 序列化:删 #7168 无条件残留行(mimo 漏发 summary 的根因),
     保留 #7234 条件版(vendor 隔离,DashScope 才发)
   - zero-usage 抑制:保留 #7168 版(评审 #7168 第 3 点)
3. responses_test.go:
   - summary 测试改用 DashScope BaseURL(#7234 版,修复 Copilot 批评的
     DeepSeek BaseURL 断言)
   - 保留 stop/zero-usage 测试(#7168)+ vendor 隔离测试(#7234)

验证:provider 6 包 + control + agent 全绿 + lint 0。

评审 11 条状态:summary 隔离/zero-usage/reasoning id/status 已闭环;
剩余(后续提交):reasoning 贯通 session、format 绑定 turn、
TTL 24h、singleSegment wire、vision 透传。
2026-08-04 10:49:44 +08:00
Yan Li f419c1c826 merge: #7168 (DashScope protocol compatibility + completion semantics)
fix7234and7168 = main-v2 + #7168 全部能力(DashScope summary
vendor 隔离、finish reason/zero-usage 完成语义、reasoning 回传等)。
零冲突合并。

后续:并入 #7234(MiMo wire alignment)统一实现后,评审 11 条
修复将在本分支闭环(vendor 表单点 + reasoning 贯通 + format 绑定
turn + TTL 24h + singleSegment wire + vision 透传)。
2026-08-04 10:42:59 +08:00
YHH e67b2aa824 docs(readme): swap the daily Trendshift badge for the ranked pair (#7378)
The daily badge reads '#2 Repository Of The Day' and goes stale the moment that day passes; the monthly-Go and repository badges stay accurate on their own.
2026-08-04 10:23:53 +08:00
Li-Charles-One 7592c2391e fix(creation): harden empty-session hero layout and hydration
Address PR review: flow-based title/composer stack for small windows, multi-line hero textarea growth, hero only after history hydration, and CSS trim to stay under the frontend bundle budget.
2026-08-04 10:21:24 +08:00
Li-Charles-One 51f75e2418 feat(creation): refine empty-session hero copy and layout
Use a single gradient headline, align it with the slim composer column,
and lift the input closer under the title without clipping descenders.
2026-08-04 10:18:05 +08:00
Li-Charles-One 2512cf7e17 feat(creation): slim empty-session hero composer
Drop welcome prompt cards and lift a short model-only composer under the
headline. On first message, ease back to the full bottom composer with
lighter control weights.
2026-08-04 10:18:05 +08:00
SivanCola bd0818e60b Merge remote-tracking branch 'origin/main-v2' into feature/extension-kernel-protocol-v1
# Conflicts:
#	desktop/frontend/package.json
#	desktop/frontend/scripts/check-bundle-budget.mjs
#	desktop/frontend/src/App.tsx
#	desktop/frontend/src/generated/remoteProtocol.generated.ts
#	desktop/settings_app.go
#	internal/agent/agent.go
#	internal/boot/boot.go
#	internal/i18n/i18n.go
#	internal/i18n/messages_en.go
#	internal/i18n/messages_zh.go
#	internal/i18n/messages_zh_tw.go
#	internal/remote/protocol/schema.generated.json
#	internal/remote/protocol/schema_hash.generated.go
2026-08-04 09:39:41 +08:00
czy aeee0e192c Merge upstream/main-v2 into task monitor PR 2026-08-04 09:27:52 +08:00
SivanCola 62aeb57293 Merge main-v2 and preserve remote reconnect routing
Problem:
PR #7368 conflicted with the latest transactional rewind work and its CI was blocked by formatting and lint failures. A mechanical merge would also let the new rewind and file-revert bindings fall through to Local while a Remote projection was reconnecting.

Root cause:
Both branches extended the Desktop workbench surface independently, while the Remote scheduler changes retained one unformatted composite literal, an ineffectual generation assignment, and an empty test branch.

Fix:
Merge main-v2, keep both lazy-loaded workbench components, route every new rewind and file-revert entry point through the Remote fail-closed boundary, add reconnect regressions, and fix the formatting and lint findings.

Verification:
- go vet ./... && go build ./... && go test ./...
- cd desktop && go vet ./... && go build ./... && go test ./...
- go test -race ./internal/rpcwire ./internal/remote/protocol ./internal/remote/broker ./internal/remote/workbench/...
- cd desktop && go test -race -run 'Remote|Workbench|Rewind|Workspace' ./...
- pnpm test:all && pnpm build
- golangci-lint run --timeout=5m (root and desktop)
- go run ./cmd/remote-protocol-gen -check
- ./scripts/cache-guard.sh
2026-08-04 09:23:34 +08:00
aakashshetty e9bc524fd0 feat: add optional sub-agent progress forwarding 2026-08-04 06:44:39 +05:30
SivanCola b8bc976d10 fix(extension): ACP extension model discovery, startup UI request gating, deadlock regression
Reviewer follow-ups to the extension provider wiring:

- ACP clients now discover installed extension models even when the current
  model is a builtin: the session config state is enriched from the live
  controller's ProviderCatalog on session/new, on openExistingSession, and
  on every later config-state read, filling both the legacy model list and
  the category:"model" select (creating them when the config backend has
  none). Previously only an already-current plugin/... ref appeared.
- A sidecar may legally issue host/ui/request right after
  extension/initialized, while the build is still assembling the
  controller; preflight made that window real. Such requests now wait on a
  readiness gate (controllerReady / buildFailed / request context) instead
  of failing outright, so an extension asking at startup can neither error
  nor hang a dying build. Deterministic tests cover wait-then-serve,
  build-failure unblock, and cancellation.
- Deterministic regression for the ContractEntries AB-BA fix: a
  channel-controlled test parks ContractEntries inside a tool's ReadOnly
  callback and proves a concurrent registry Add still completes — the
  pre-fix interleaving deadlocked. Stress/race runs alone could not pin
  this invariant.

Verification: go test ./internal/acp ./internal/cli ./internal/boot
./internal/tool pass; go test -race ./internal/tool ./internal/acp pass;
TestGoldenBaselineNoExtensions green unmodified; cache-guard 8/8 pass;
gofmt/go vet clean.
2026-08-04 09:12:41 +08:00
SivanCola c060a2e060 Merge pull request #7356 from SivanCola/feature/local-rewind-reliability
Make local rewind transactional and conflict-safe / 提升本地回滚的事务性与冲突安全
2026-08-04 08:37:02 +08:00
SivanCola 6ffea769e8 fix(updater): quarantine orphaned app backups
Problem:
A macOS update can remain blocked forever when the fixed app-bundle backup directory survives but no pending update transaction exists.

Root cause:
Preparation treated every existing handoff backup as owned rollback material, even when no transaction could identify or recover it.

Fix:
After validating the staged and installed bundles, atomically quarantine the exact orphaned sibling backup under the update locks. Preserve its contents, verify the move, and fail closed if the path or contents change concurrently. Classify failures as recovery errors and provide actionable non-destructive guidance.

Verification:
Focused handoff and reconciliation tests, the internal repair race suite, root and Desktop Go tests and vet, frontend updater tests, typecheck, CSS checks, production build, and diff checks passed.
2026-08-04 08:36:51 +08:00
YHH 5d4df5da70 Merge pull request #7367 from esengine/fix/finalize-npm-idempotent
fix(release): only write the npm aliases that are actually behind
2026-08-04 08:31:31 +08:00
esengine fe5a8f12b8 fix(release): only write the npm aliases that are actually behind
The alias alignment step rewrote latest/canary/next unconditionally, so a
recovery rerun performed three registry mutations per package even when
all seven packages already pointed at the release. npm treats dist-tag
writes as sensitive operations — an automation token is refused where an
interactive session is not — so a rerun with nothing left to do still
failed on the first write it did not need to make.

That is the shape of the v1.19.5 recovery: the publish itself succeeded
and the aliases have to be realigned by hand, after which rerunning the
workflow should be a no-op. It was not.

The step now reads the current aliases first and writes only what is
missing, keeping the candidate-SHA validation ahead of any mutation.

Also fixes the staging cleanup, which named "official-staging" — a tag
nothing creates. The publisher stages under "<dist-tag>-staging"
(npm/publish.mjs), so cleanup never removed anything and a staging alias
left behind by a failed publish stayed on the registry, which is exactly
what happened to latest-staging in 1.19.5.

Tests record every dist-tag invocation, so they assert how many registry
writes each path takes rather than only its exit status: none when the
aliases are already correct, only the stale ones otherwise, none at all
when provenance validation fails, and one staging removal per package
under the name the publisher really uses.
2026-08-03 17:15:16 -07:00
YHH 0dab33c3d0 Merge pull request #7366 from esengine/fix/updater-unusable-transaction
fix(updater): agree on what a pending update is, and recover from debris
2026-08-04 08:07:59 +08:00
esengine 0bad556717 fix(updater): agree on what a pending update is, and recover from debris
Preparation and reconciliation each had their own notion of "a pending
update exists". Preparation checked only that the marker file was
present and refused. Reconciliation read and validated it, and bailed
out without clearing anything when validation failed.

A marker that fails validation therefore satisfied neither: nothing could
resume it, roll it back, or remove it, while its mere presence kept
refusing every future update. Users hit a permanent dead end — retrying
did nothing, and reinstalling did nothing either, because the marker
lives in the state directory rather than the install directory. Startup
then reported the failed recovery and dropped into safe mode, so it
presented as "the whole app is broken" rather than "an update failed".

Both paths now classify through one function. The line between debris
and a recoverable transaction is drawn at self-description: bytes that
do not parse, or that do not say which release they target, for which
platform, and when they were opened, name nothing to roll back to, so
discarding them loses nothing. Every other validation failure is
environment-relative — the launcher path, whether the target sits inside
this installation, where the backup lives — and can fail for a perfectly
good transaction observed from the wrong install, so those keep the old
refusal rather than risking real rollback material. IO failures stay
errors: an unreadable marker is not an absent one.

Debris is quarantined rather than deleted. It is the only evidence of
what went wrong, and someone reporting a stuck updater should still have
it; repeated recovery never overwrites an earlier copy.

Fixes #7342.
2026-08-03 16:52:18 -07:00
SivanCola 4a2655185e feat(extension): resolve extension providers on first boot and in all model pickers
Follow-up to stage 7 closing the reviewer-flagged gap between the streaming
provider adapter and any production path.

Problem: sidecar providers merged into a resolver only after the controller
was built, and the merged resolver had no consumer — an extension provider
could not be the initial model, `default_model = "plugin/..."` failed with
unknown model, the CLI/Desktop model pickers enumerated only config
providers, and the stage-7 E2E bypassed the real resolution path.

Fix:
- Preflight extension runtimes before model resolution: sidecars start once
  via a single Manager (a build calls StartPackages exactly once), every
  pre-ownership error path closes it, and the provider merge (with
  provider:<ref> claims from the same manifest data the kernel uses) happens
  up front so conflicts fail before anything is built.
- The merged resolver feeds every resolution site: the model entry
  (plugin/-namespaced refs only; config models keep their full config entry,
  broker stays authoritative), the executor, the dual-model planner, the
  guardian, the recovery reviewer, and the subagent provider. Unknown-model
  errors list available plugin refs; RequireKey skips plugin refs (no config
  credential exists by design).
- Discovery and switching: Controller.ProviderCatalog() on the Capabilities
  port; the CLI picker, /model persistence (config.SetDefaultModel accepts
  plugin refs), Desktop ModelsForTab/SetModelForTab, and ACP
  session/new + SessionConfigState all enumerate and resolve plugin/... refs.
- Tests prove the real paths: first boot with default_model="plugin/..."
  streams the fake completion through Controller.RunTurn; unknown plugin ref
  wraps ErrUnknownModel and lists plugin refs; builtin->plugin switch via
  Rebuild streams from the sidecar with the old runtime alive until closed;
  required-preflight failure and unclaimed provider conflicts fail with the
  fake PID reaped; a failing new plugin on Rebuild leaves the old controller
  and sidecars untouched; CLI/Desktop/ACP discovery and switch coverage.
- Also fixes a merge-blocking AB-BA deadlock the branch exposed:
  tool.Registry.ContractEntries held the registry read lock across
  lazyTool.ReadOnly() (which takes the lazy-spawn mutex) while a lazy spawn
  swap held the mutex and wanted the registry write lock. Tool methods now
  run after the lock is released; output unchanged.

Verification: go test ./internal/boot ./internal/cli ./internal/acp
./internal/extension/... and cd desktop && go test . pass;
TestGoldenBaselineNoExtensions green unmodified; both protocol generators
-check green; ./scripts/cache-guard.sh 8/8 pass; go test -race on
internal/boot, internal/tool, and internal/extension/providerext pass;
gofmt/go vet clean; no new dependencies.
2026-08-04 05:49:39 +08:00
SivanCola ba329c5363 feat(remote): recover workbench sessions after SSH drops
Problem:
Unexpected Remote Workbench transport loss discarded the visible Remote projection, allowed some bindings to fall through to Local, and let data-heavy frames delay control traffic.

Root cause:
The target manager coupled logical session identity to one physical connection, rpcwire serialized every outbound frame behind one mutex, and reconnect lifecycle transitions were not generation-fenced across cancellable I/O.

Fix:
Keep a logical Remote slot with cached read-only projection, restore only the exact RuntimeTarget through a jittered supervisor, fail closed for unavailable Remote mutations, and add bounded control/data outbound scheduling. Make supervisor startup atomic, keep SSH bootstrap outside the projection lock, preserve Local navigation tokens during background recovery, and expose reconnect UX with failed-state manual retry.

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/rpcwire ./internal/remote/protocol ./internal/remote/workbench/reconnect ./internal/remote/workbench/target ./internal/remote/broker
- cd desktop && go test -race -run 'Remote|Workbench' .
- go run ./cmd/remote-protocol-gen -check
- ./scripts/cache-guard.sh
- cd desktop/frontend && pnpm test:typecheck && pnpm test:remote && pnpm build
2026-08-04 04:26:31 +08:00
SivanCola fc53cef919 Satisfy rewind picker lint
Problem:
CI lint rejected two stage-dependent if/else chains in the rewind picker with staticcheck QF1003.

Root cause:
The new confirmation state handling used repeated comparisons against the same stage value.

Fix:
Use tagged switches for Escape and Enter stage transitions without changing behavior.

Verification:
go test ./internal/cli
golangci-lint run --timeout=5m ./internal/cli
git diff --check
2026-08-04 04:22:29 +08:00
SivanCola ac91369c31 Harden checkpoint coverage in headless and CLI rewind
Problem:
Headless turns did not open checkpoints, partial-coverage CLI rewinds could commit without an explicit warning, and an empty hook runner incorrectly downgraded every file checkpoint to partial coverage.

Root cause:
Controller.Run skipped the checkpoint lifecycle used by interactive turns, the TUI called the compatibility Rewind wrapper directly, and hook coverage was inferred from a non-nil runner instead of configured tool-hook events.

Fix:
Open checkpoints for headless turns, require preview and explicit confirmation before partial file rewinds, make the compatibility wrapper fail closed, and report hook-write gaps only for actual tool hooks while keeping unknown custom hooks conservative.

Verification:
go test ./internal/control ./internal/cli ./internal/agent ./internal/hook ./internal/i18n
go test -race ./internal/control ./internal/cli ./internal/agent ./internal/hook
go test ./...
go vet ./...
(cd desktop && go test ./...)
./scripts/cache-guard.sh
2026-08-04 04:19:14 +08:00
SivanCola cf9ee2823f fix(rewind): close transaction recovery gaps
Problem:
Local rewind could leave split state around crash and compensation windows, expose unsafe legacy file restores, and lose undo metadata in Desktop paths.

Root cause:
Checkpoint persistence and compensation did not model every intermediate state, queued writers were registered too late, and UI capability propagation trusted incomplete ownership metadata.

Fix:
Make checkpoint expiration and truncation failure-aware, verify blob identities, recover rename intermediates, retain pending failed compensation, normalize paths, require final ownership fingerprints, reserve queued background writers, and preserve Desktop transaction IDs for undo.

Verification:
go test ./...
go test -race ./internal/checkpoint ./internal/control ./internal/agent
cd desktop && go test focused rewind/workspace suites
cd desktop/frontend && pnpm build
./scripts/cache-guard.sh
go vet and golangci-lint for root and desktop modules
2026-08-04 03:17:52 +08:00
SivanCola 8ed68b3162 docs(extension): bilingual extension docs, SDK CI job, and final compatibility audit
Stage 10 of the unified extension kernel / Extension Protocol v1 plan.

- New docs: EXTENSIONS.md/.zh-CN.md (capability map, install/manage,
  fail-atomic reload semantics, development guide, compatibility,
  full-trust security model) and EXTENSION_PROTOCOL.md/.zh-CN.md (prose
  companion to the generated index: transport, lifecycle, content refs,
  interception, streaming providers, structured UI, errors, v1 stability
  contract, security model).
- Updated docs: PLUGIN_PACKAGES (Manifest v1 chapter: strict parsing,
  merge rules, prompts/themes, runtime declaration, FULL TRUST disclosure
  and --link trust inheritance), CAPABILITY_DIAGNOSTICS (additive v1
  prompts/themes/runtime fields, schema_version stays 1), CLI (/reload),
  ACP (_reasonix.io/session/reloadExtensions, extensionSurface
  negotiation, /<plugin>:<action> discovery), RELEASING (sdk/go/vX.Y.Z
  tag convention, three-product tag contract unchanged), THEME_PACK
  (plugin theme kind and fallback-preserve, from stage 4).
- CI: new sdk job on the three-OS matrix (gofmt, vet, stdlib-only guard,
  test, race on pushes) triggered by sdk/ or internal/extension/ changes;
  root conformance tests run in the existing root matrix.
- Makefile: sdk-test / sdk-test-race targets.
- Bundle budget: initial raw JS+CSS 2250 -> 2265 KiB with a comment
  recording the extension UI surface as the cause; every gzip budget
  (the user-visible transfer cost) is unchanged and passing.

Compatibility audit (kept green by tests added in stages 1-9): legacy
manifests parse byte-identically, plugin-packages.json schema unchanged,
session JSONL untouched with extension state isolated to
<session>.extensions.json, desktop theme state preserves plugin: IDs
across uninstall while legacy files load unchanged, install-plan JSON is
omitempty-additive, plugin/... refs degrade to model-unavailable on old
versions, and the two new event kinds append at the enum end with
text fallbacks for old clients.
2026-08-04 02:42:27 +08:00
SivanCola 0b51315538 feat(extension): Go SDK, generated DTOs, example plugin, and host conformance
Stage 9 of the unified extension kernel / Extension Protocol v1 plan.

Problem: Extension Protocol v1 had a host implementation but no supported
way for third parties to write sidecars, no proof the contract works from
both ends, and no generator-produced SDK artifacts.

Fix:
- sdk/go: a new nested module (github.com/esengine/DeepSeek-Reasonix/sdk/go,
  Go 1.23, standard library only, zero requires). Serve(ctx, Handler,
  Options) drives the full lifecycle: initialize barrier with
  manifest-subset enforcement, interceptor routing with wildcard fallback
  and Continue/Block/Replace/Allow/Deny helpers, observation hooks, a
  streaming Provider pump (1-based contiguous seqs, exactly one stream/end,
  no chunks after cancel), structured HostUI publish/request helpers
  (structured-only by construction), transparent content-ref rehydration
  with paging and SHA-256 verification, panic isolation, bounded queues,
  and bounded shutdown. 44 unit tests, race-clean, against a scriptable
  fake host.
- The protocol generator now emits a fourth artifact,
  sdk/go/types_generated.go (wire DTOs, enums, method names, frozen limits
  and error specs), replacing the handwritten mirror; handwritten
  validators/helpers moved to types_ext.go and the public API is unchanged
  (the 44 tests compile and pass unmodified). A new exported
  protocol.EnumValues() keeps enum sets single-sourced; the three
  pre-existing artifacts regenerate byte-identical.
- sdk/go/examples/fullsidecar: the reference extension — input.receive
  rewrite, tool.before block and argument rewrite, system_prompt.build
  strategy, a fake streaming provider (text, tool call, usage, cancel
  handling), UI status/card publish plus an action-driven form request,
  and clean shutdown.
- internal/extension/conformance: 17 hermetic host<->SDK round-trip tests
  (root module, so the CI three-OS matrix covers them) building the
  example and driving it over the real wire: handshake accept and
  capability_not_declared, intercept replace/block, strategy replacement,
  provider stream/cancel/usage with seq discipline, >64 KiB content-ref
  rehydration, UI action/form round-trips, intercept timeout, mid-intercept
  crash fail-fast, unknown method, oversized frame, and bounded shutdown
  with exit 0.

Verification: cd sdk/go && go test ./... and -race pass with a zero-require
go.mod; go test ./internal/extension/... passes (conformance also -race);
both protocol generators -check green; boot golden baselines unregenerated;
root go build ./... clean; no new dependencies anywhere.
2026-08-04 02:42:06 +08:00
SivanCola 34af76be31 Merge pull request #7364 from esengine/release-notes/v1.19.5
docs(release): 准备 v1.19.5 中英更新日志
2026-08-04 02:10:28 +08:00
SivanCola 48d5519d70 test: honor platform modes in desktop rewind
Problem: the authoritative Desktop rewind test reported a file mode conflict on Windows.\n\nRoot cause: its seeded checkpoint used Unix 0644 permissions instead of the effective platform mode.\n\nFix: record the target file's actual permission bits in the checkpoint fixture.
2026-08-04 02:09:39 +08:00
SivanCola ee6c8e6c29 docs(release): prepare v1.19.5 notes
Summary:
Generate a bilingual, product-focused release record for the v1.19.5 Stable candidate from merged pull request metadata.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-04 02:09:37 +08:00
SivanCola ae8a7fc0f7 feat(extension): structured extension UI across CLI, Desktop, and ACP
Stage 8 of the unified extension kernel / Extension Protocol v1 plan.

Problem: sidecar extensions could declare UI capabilities at handshake, but
nothing rendered them — no host hub, no events, no frontend surfaces.

Fix:
- internal/extension/uihub: the host UI hub behind every sidecar client.
  host/ui/publish is strict-decoded, credential-redacted, and emitted as
  the new extension_surface / extension_status events (appended at the end
  of the event Kind enum, wire payloads in eventwire); host/ui/request
  (confirm/input/select/multiselect) rides the existing Ask machinery.
  Every surface carries plugin ID, surface ID, session ID, and generation:
  stale-generation or foreign-session publishes are dropped, stale requests
  cancelled, crashed clients rejected. Actions register as /<plugin>:<action>
  with InvokeAction/Submit RPCs, and surfaces are structured-only — no
  HTML/CSS/JS/URLs anywhere.
- CLI: status/notification render as severity notice lines, cards and forms
  as transcript cards (existing markdown renderer), actions resolve after
  CustomCommand/RunSkill and appear in slash completion.
- Desktop: bound ExtensionActions/InvokeExtensionAction/SubmitExtensionForm;
  native transcript ExtensionCard (markdown, key-values, progress, action
  buttons), ExtensionFormDialog decision surface, StatusBar severity chips,
  toast notifications, and a command-palette Extensions group — with the
  per-tab runtime epoch fence plus a per-surface generation guard dropping
  reordered publications, and full en/zh/zh-TW strings.
- ACP: initialize advertises extensionSurface (schemaVersion 1) and parses
  the client's support; supporting clients get a vendor session/update
  carrying the structured DTO plus flattened text, others get text-only
  fallbacks (cards/statuses flatten to agent_message_chunk, forms ride the
  permission-request fallback). Extension actions merge into
  available_commands_update and resolve last in resolveSlashPrompt.
- Remote protocol artifacts regenerated: the two new event kinds flow
  through eventwire.KindNames into the remote schema (append-only change).

Verification: go test ./internal/cli ./internal/acp ./internal/control
./internal/event ./internal/eventwire ./internal/extension/... ./internal/boot
pass incl. golden baselines; uihub race-clean; cd desktop && go test .
passes; desktop frontend typecheck, full tsx suite (43 extension tests),
and check:css pass; both protocol generators -check green; no new
dependencies.
2026-08-04 02:06:47 +08:00
SivanCola bf0be67b7c Merge pull request #7363 from SivanCola/docs/add-trendshift-badge
docs: add Trendshift achievement badge / 文档:添加 Trendshift 成就徽章
2026-08-04 01:59:03 +08:00
SivanCola 5928fe8344 Merge pull request #7361 from SivanCola/fix/settings-lazy-css-test-loader
Fix lazy Settings CSS test loading / 修复设置懒加载 CSS 测试
2026-08-04 01:58:07 +08:00
SivanCola 59e6f7e4bf docs: add Trendshift achievement badge
Summary:
- Add the official Trendshift daily achievement badge to the English and Chinese README pages.
- Link the badge to Reasonix's Trendshift repository profile.

Verification:
- git diff --check
- Verified the badge endpoint returns SVG and renders in the local README preview.
2026-08-04 01:57:39 +08:00
SivanCola d9b17274ba test: use platform file mode in rewind recovery
Problem: the Windows smoke test treated the seeded recovery fixture as an external file modification.\n\nRoot cause: the fixture hard-coded Unix 0644 permissions even when the platform reports a different effective mode.\n\nFix: derive restore and forward identity modes from the file's actual stat result.
2026-08-04 01:50:25 +08:00
SivanCola 4d0eaf082c fix: remove obsolete rewind helper
Problem: CI lint rejected the checkpoint manager helper after compatibility rewind moved to the transaction engine.

Root cause: The old restoreCode wrapper no longer had callers.

Fix: Remove the dead wrapper and its fmt import.

Verification: golangci-lint run --timeout=5m; go test ./internal/control.
2026-08-04 01:46:48 +08:00
SivanCola 416eae24ad fix(frontend): isolate lazy settings stylesheet
Problem:
The direct TSX frontend tests failed when SettingsPanel imported its code-split CSS because Node does not load CSS modules.

Root cause:
The production-only stylesheet side effect lived in a reusable component module that unit tests and SubagentsPanel import directly.

Fix:
Add a lazy production entry that owns the stylesheet import while keeping SettingsPanel reusable, and lock the split with the bundle contract test.

Verification:
- pnpm --dir desktop/frontend test:all
- pnpm --dir desktop/frontend build
- focused SettingsPanel import tests
- git diff --check
2026-08-04 01:44:24 +08:00
SivanCola d25f7833c3 fix: make local rewind recovery atomic
Problem: Combined rewind recovery could leave conversation and files split after a crash, background writers could cross the preview/commit window, and ancestor symlinks could redirect checkpoint I/O outside the workspace.

Root cause: Startup recovery had no conversation applier, writer registration was not coupled to the mutation barrier, compatibility rewind bypassed the transaction engine, and filesystem operations relied on lexical containment checks.

Fix: Defer combined recovery until resume, restore conversation and checkpoints before file compensation, route legacy rewind through transactions, hold writer barriers across direct and fleet background agents, and use no-follow workspace-relative filesystem operations.

Verification: go test ./...; go test -race ./internal/checkpoint ./internal/control ./internal/agent; desktop go test ./...; Windows checkpoint cross-compile; frontend rewind contract test; cache guard; go vet focused packages.
2026-08-04 01:43:25 +08:00
HaoyueQin 2f5d44956b fix(desktop): stop subagent override cards overflowing the settings column
The overrides grid used repeat(2, minmax(200px, 1fr)) with a 568px
minimum, but the settings content column is only ~500-568px wide at
window sizes 760-930px, clipping the right edge of the cards. Switch the
two columns to minmax(0, 1fr) so they shrink with the column; the
existing <=760px single-column fallback stays in place.
2026-08-04 01:41:55 +08:00
HaoyueQin 7bbacfde21 fix(desktop): keep memory workspace selector and suggestions button on the same row
At <=900px the memory tab row stacked vertically, leaving the project-name
selector top-left and the Suggestions button bottom-right; with a wrapping
flex row the short name could also end up beside the memory tabs while the
button dropped to its own line. Group the workspace selector and the
Suggestions button in a dedicated .memory-tabs-row__tail container whose
100% flex-basis always takes the second line, so the two always share a
row regardless of name length. The name prefers one line and wraps
naturally (overflow-wrap:anywhere, no truncation) when space runs out.
2026-08-04 01:41:55 +08:00
HaoyueQin 8f5eef019c fix(desktop): make settings subtab pills shrink-wrap on narrow windows
The narrow-window media query forced .settings-subtabs to display:flex;
width:100% with horizontal scrolling, so the Usage/Access/Stats pills
stretched across the whole row. Switch to inline-flex with wrap so the
pills shrink-wrap their content and wrap onto further lines instead of
stretching or scrolling. This also gives the memory page tab pills the
same compact behavior.
2026-08-04 01:41:54 +08:00
SivanCola bc7a8b68b4 Merge pull request #7353 from SivanCola/fix/system-prompt-file-resilience-secure
fix(config): secure system prompt file fallback / 安全修复系统提示词文件降级
2026-08-04 01:36:01 +08:00
SivanCola ef870e951a Merge pull request #7360 from SivanCola/fix/docs-codeblock-layout
Fix documentation code block layout / 修复文档代码块布局
2026-08-04 01:32:54 +08:00
SivanCola a5ddf58c12 fix(site): restore documentation code block layout
Problem:
Astro 7 collapsed source newlines in documentation code blocks, concatenating commands and comments and allowing copy controls to obscure code.

Root cause:
The site relied on preformatted source whitespace while JSX-style HTML compression removed line breaks. Copy buttons also shared the code text area.

Fix:
Preserve intentional documentation whitespace, reserve a dedicated top area for every copyable code block, and add regression contracts for both behaviors.

Verification:
- cd site && npm test
- cd site && npm run build
- Browser geometry at 1280x720 and 390x844 across all 15 documentation code blocks
2026-08-04 01:29:20 +08:00
tic-top 79e4d1a7a8 docs(acp): document session/set_config_option's parameters
The Session controls table names the method and the option ids, but never the
request shape, so a client has to guess the field name. The natural guess is
optionId (it is setting an option, and the ids come from configOptions), and
that fails in a way that points at the wrong thing: the handler answers

    -32602 session/set_config_option: unknown config option

with an empty name interpolated at the end, which reads as "that option does not
exist" rather than "you named the parameter wrong". The option is in fact
advertised correctly in session/new's configOptions.

Cost when a client gets this wrong is not cosmetic: tool_approval silently stays
at 'ask', so an unattended host blocks on session/request_permission for every
gated call instead of running.

Documents the params in both language versions, with a worked example, an
explicit note that the field is configId, and the result/error shapes.
2026-08-03 17:26:36 +00:00
SivanCola faa634b769 Merge pull request #7351 from SivanCola/fix/bot-recovery-lease-handoff
Keep bot recovery session ownership aligned / 保持 Bot 恢复会话所有权一致
2026-08-04 01:21:19 +08:00
SivanCola aae3851a5c Merge pull request #7328 from Nath-Vikky/codex/fix-custom-supported-efforts
fix(openai): honor custom efforts for generic and DeepSeek endpoints
2026-08-04 01:16:43 +08:00
SivanCola ee278d86db Merge pull request #7210 from jackie-cqz/fix/terminal-theme
fix: add configurable terminal theme
2026-08-04 01:11:28 +08:00
SivanCola aeb28447ff fix(config): remove obsolete runtime merge wrapper
Problem:
The snapshot refactor left an unreferenced compatibility wrapper, which failed the repository unused-code lint gate.

Fix:
Remove the dead wrapper and update its documentation reference to the snapshot function.

Verification:
- golangci-lint v2.12.2 run --timeout=5m
- go test -race ./internal/config ./internal/boot -count=1
- ./scripts/cache-guard.sh
2026-08-04 01:04:37 +08:00
SivanCola 4721c321aa fix(config): bind prompt provenance to TOML snapshot
Problem:
Project prompt provenance was detected by rereading reasonix.toml separately from the merge, so concurrent replacement could pair a project value with trusted-user provenance.

Root cause:
Metadata and decoded values came from different filesystem snapshots.

Fix:
Read each TOML source once, validate and merge those bytes, and derive provenance metadata from the same immutable snapshot. Add a deterministic replacement regression.

Verification:
- go test ./internal/config ./internal/boot -count=1
- go test -race ./internal/config ./internal/boot -count=1
- ./scripts/cache-guard.sh
- go vet ./...
- go test ./... -count=1
2026-08-04 01:00:18 +08:00
OpenAI CodexandSivanCola d231c86934 Merge latest main-v2 session title updates
Integration:
- Synchronize PR #7328 with the latest main-v2 session title changes.
- Preserve the custom supported_efforts provider contract unchanged.

Verification:
- go test -count=1 ./internal/provider/openai ./internal/config ./internal/boot ./internal/agent ./internal/serve
- go test -race -count=1 ./internal/provider/openai
- ./scripts/cache-guard.sh
- go vet ./...
- go build ./...

Co-authored-by: SivanCola <[email protected]>
2026-08-04 00:58:30 +08:00
SivanCola 0fdcc8a63a fix(checkpoint): avoid capacity hint overflow
Problem:
CodeQL reported a potential integer overflow in the checkpoint bounds map allocation.

Root cause:
The capacity hint added one to a slice length before allocating the map.

Fix:
Use the completed-checkpoint count as the hint and let the map grow for the optional current checkpoint.

Verification:
golangci-lint and checkpoint/control tests pass.
2026-08-04 00:55:53 +08:00
SivanCola ee41d1b88d fix(bot): serialize recovery handoff with teardown
Problem:
A recovery callback captured before gateway shutdown could run after closeSessionState released its lease and reacquire a recovery-path lease on an already retired session state.

Root cause:
Recovery lease transfer and session teardown had no shared lifecycle gate. The handler moved the SessionLeaseKeeper before checking whether the state was still live.

Fix:
Serialize recovery ownership handoffs with state retirement, reject callbacks that arrive after retirement, and cover the late-callback ordering with a deterministic regression test.

Verification:
- go test ./internal/bot ./internal/control ./internal/agent
- go test -race ./internal/bot
- go vet ./...
- go test ./...
2026-08-04 00:52:02 +08:00
SivanCola 3de328c1bf fix(rewind): satisfy static analysis checks
Problem:
The CI lint job rejected empty branches, a manual slice copy, and obsolete rollback helpers.

Root cause:
The transactional rewind replacement left legacy helpers and no-op error branches behind.

Fix:
Remove dead legacy paths, use copy for checkpoint snapshots, and surface undo fingerprint errors explicitly.

Verification:
golangci-lint v2.12.2 reports 0 issues; focused checkpoint, control, agent, and Windows cross-compile checks pass.
2026-08-04 00:51:38 +08:00
SivanCola f24249f4f1 Keep terminal chrome aligned with its theme
Problem:
- Auto mode referenced undefined app tokens, and explicit terminal themes left header controls and drawer borders on the app palette.

Root cause:
- Terminal colors were applied to xterm and part of the drawer, but higher-specificity layout rules and toolbar controls still bypassed the terminal token set.

Fix:
- Map auto mode to defined app tokens and route all terminal chrome through terminal-owned surface, border, foreground, and accent values.
- Set the native control color scheme for explicit light and dark terminal modes.
- Add CSS ownership regressions for the component and real drawer selectors.
- Raise the raw initial asset budget by the measured 1 KiB needed for the completed chrome rules.

Verification:
- pnpm --dir desktop/frontend test:terminal
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend build
- Edge computed-style matrix for auto light/dark and opposite explicit terminal themes
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-04 00:46:48 +08:00
SivanCola 6a2a84e039 feat(rewind): make local rollback transactional and conflict-safe
Problem:
Local rewind could partially restore files, silently fall back to legacy behavior, or apply a stale conflict decision.

Root cause:
Checkpoint payloads lacked a durable transaction protocol, mutation writers were not fenced consistently, and preview tokens were not revalidated at commit time.

Fix:
Add compatible v2 checkpoints, blob-backed atomic transactions with crash recovery, writer observation and barriers, controller prepare/commit/undo APIs, and immediate Desktop rewind with explicit conflict handling.

Verification:
Run root and Desktop Go suites, race tests, cache guard, Windows checkpoint cross-compile, frontend production build and targeted rewind/workspace contracts.
2026-08-04 00:43:48 +08:00
SivanCola 58de476bc8 Merge pull request #7349 from SivanCola/fix/serve-session-title-resilience
Fix resilient Serve session titles / 修复 Serve 会话标题生成与缓存
2026-08-04 00:41:55 +08:00
OpenAI CodexandSivanCola 9ec81f7099 Merge latest main-v2 effort contract
Integration:
- Incorporate PR #7350 declared DeepSeek effort vocabulary baseline.
- Retain PR #7328 generic OpenAI-compatible custom effort handling and authoritative explicit-list validation.

Verification:
- go test -count=1 ./internal/provider/openai ./internal/config ./internal/boot
- go test -race -count=1 ./internal/provider/openai
- ./scripts/cache-guard.sh
- go vet ./...
- go build ./...

Co-authored-by: SivanCola <[email protected]>
2026-08-04 00:41:11 +08:00
YHH 2b5b709b91 Merge pull request #7350 from esengine/fix/explicit-supported-efforts
fix(provider): let a declared effort vocabulary outrank the built-in one
2026-08-04 00:34:20 +08:00
YHH 778a443e19 Merge pull request #7343 from esengine/refactor/unify-git-invocation
refactor(git): build every internal git invocation in one place
2026-08-04 00:24:08 +08:00
SivanColaandSprayHank 2f16450e39 fix(config): secure system prompt file fallback
Problem:
A stale system_prompt_file could abort startup, but the initial fallback design allowed project config to probe Reasonix home and downgraded every read failure. That could expose credential files to provider prompts or run without explicitly configured policy.

Root cause:
Merged configuration did not retain whether system_prompt_file came from user or project scope, and boot could not distinguish a genuinely missing file from containment, permission, or other I/O failures.

Fix:
Preserve prompt-file provenance, confine project-selected paths to the workspace with os.Root, keep the trusted user-home fallback, and fall back only when every allowed location is absent. Add config and boot regressions for source precedence, credential isolation, path and symlink escapes, mixed read errors, and startup behavior.

Verification:
- go test ./internal/config ./internal/boot -count=1
- go test -race ./internal/config ./internal/boot -count=1
- go vet ./...
- ./scripts/cache-guard.sh
- go test ./... -count=1

Co-authored-by: SprayHank <[email protected]>
2026-08-04 00:23:30 +08:00
SivanCola 3c86a352c5 fix(bot): keep recovery session ownership aligned
Problem:
Bot controllers could switch to an automatic recovery branch while their session lease and remote mapping still pointed at the original transcript. A later turn could then continue with inconsistent ownership or route an attached chat back to the stale path.

Root cause:
The bot created its SessionLeaseKeeper after boot.Build and never installed an OnSessionRecovered ownership handoff. Recovery therefore updated only the controller path.

Fix:
Create the lease keeper before controller construction, wire the failure-atomic recovery handoff, move attached runtime mappings only for the active session generation, and remember the recovered path for the host. Late callbacks from retired controllers cannot overwrite their replacements.

Verification:
- go test ./internal/bot ./internal/control ./internal/agent
- go test -race ./internal/bot
- go test ./...
2026-08-04 00:20:09 +08:00
SivanCola 0c5e4fdb65 Merge pull request #7334 from GOGV/fix/serve-delete-session-feedback
fix(serve): surface session-delete failures in the web UI
2026-08-04 00:19:32 +08:00
esengine 12fdaa830b fix(provider): let a declared effort vocabulary outrank the built-in one
A third-party endpoint can serve a DeepSeek model under its own effort
scale. Declaring supported_efforts is how the config states that, and it
is the only way to describe such an endpoint — but the DeepSeek thinking
branch validated the selected level against our built-in vocabulary and
rejected anything outside low/high/max/disabled, ignoring the
declaration entirely.

The rule already existed one case up: "low" is accepted on a non-flash
model precisely when supported_efforts declares it. This extends the
same deference to the rest of the vocabulary instead of applying it to
one level.

Undeclared levels are still rejected, and a declaration that does not
list the requested level is still a rejection — honoring the config
means honoring what it actually says.

Fixes #7273.
2026-08-03 09:18:32 -07:00
SivanCola 1c3c93752d Merge pull request #7348 from SivanCola/test/credit-pr-7308-cli-continue
test(cli): preserve bare -c routing regression / 保留裸 -c 路由回归覆盖
2026-08-04 00:14:27 +08:00
YHH 68d72e6e5f Merge pull request #7346 from esengine/fix/rewind-prompt-strip
fix(rewind): show the typed prompt, not the composed turn
2026-08-04 00:14:08 +08:00
SivanCola e235647a39 Harden terminal theme readability and saves
Problem:
- Light ANSI white and bright colours were unreadable on the light terminal surface.
- Concurrent Wails settings calls could persist an older click after newer user intent.

Root cause:
- The light palette retained colours intended for a dark terminal.
- Terminal-theme writes were fired independently without ordering or stale-refresh protection.

Fix:
- Use a WCAG-readable light ANSI palette and cover all 16 colours.
- Serialize terminal-theme persistence, protect optimistic state from stale reloads, and recover failed saves.
- Keep missing settings payloads backward-compatible.
- Raise only the raw initial-asset budget by the measured 4 KiB feature cost; gzip and per-chunk limits remain unchanged.

Verification:
- Node 24 frontend production build
- terminal and usage-stats frontend suites
- root and desktop Go suites
- focused race tests for config and desktop settings
- in-app browser terminal-theme switching check
2026-08-04 00:11:53 +08:00
a3358ef847 fix(serve): make session titles fast and stable
Problem:
Serve session titles can expose pasted-text or injected-language markers, return empty output when reasoning consumes a tiny token budget, and regenerate whenever an active transcript changes mtime.

Root cause:
The title provider still used the retired effort=off value, title inputs retained UI-only labels, and cache identity tracked the whole file timestamp instead of the first user message that determines the title.

Fix:
Disable reasoning for the dedicated title provider, use a clearer bounded title prompt, strip leading pasted-text labels, skip synthetic reasoning-language turns, and persist a backward-compatible SHA-256 fingerprint of the normalized first message.

Verification:
go test ./...
go test -race ./internal/serve ./internal/agent
go vet ./internal/serve ./internal/agent
./scripts/cache-guard.sh
git diff --check

Co-authored-by: SprayHank <[email protected]>
Co-authored-by: Q1hangL <[email protected]>
2026-08-04 00:10:52 +08:00
SivanCola ad972af1f9 Merge latest main-v2 into terminal theme fix
Integration:
- Preserve the latest transient-tag normalization changes.
- Preserve Workbench listener cancellation and its regression coverage.

Verification:
- The merged upstream files match origin/main-v2 exactly.
- git diff --cached --check passed.
2026-08-04 00:10:18 +08:00
YHH 35db95a66e Merge pull request #7345 from esengine/fix/transient-tag-single-source
fix(agent): derive transient-block handling from one tag list
2026-08-04 00:09:10 +08:00
SivanColaandSKY ZHAO 61f6e2cad0 test(cli): cover bare continue shorthand routing
Preserve the top-level routing regression coverage contributed in PR #7308 after the underlying flag registration fix landed through PR #7223.

Verification:
- go test ./internal/cli -run TestRunRoutesBareInteractiveFlagsToSession -count=1
- go test ./internal/cli -count=1
- git diff --check

Co-authored-by: SKY ZHAO <[email protected]>
2026-08-04 00:07:49 +08:00
esengine 7d9e0d35af test(desktop): assert probe properties, not the whole argument list
The workspace probe test pinned git's full argument list byte-for-byte,
so it restated the shared invocation baseline instead of the two
settings it actually cares about — and broke as soon as the baseline
gained the Windows long-path entry. That brittleness is part of why the
baseline drifted between call sites: touching it meant touching exact-
match tests that had nothing to do with the change.

It now asserts what the test is about — the daemon-spawn settings are
present, the caller's arguments are preserved, the console is hidden on
Windows — and leaves the baseline itself pinned by internal/gitcmd.
2026-08-03 09:05:59 -07:00
OpenAI CodexandSivanCola 952e4ac350 Merge main-v2 into custom effort fix
Integration:
- Synchronize PR #7328 with the latest main-v2 provider and CI changes.
- Preserve the custom supported_efforts validation contract across the merged provider updates.

Verification:
- go test -count=1 ./internal/provider/openai ./internal/config ./internal/boot
- go test -race -count=1 ./internal/provider/openai
- ./scripts/cache-guard.sh
- go vet ./...
- go build ./...

Co-authored-by: SivanCola <[email protected]>
2026-08-04 00:01:45 +08:00
SivanCola 8b9674f460 Merge pull request #7321 from Nath-Vikky/codex/fix-workbench-context-cancel
fix(remote): stop Workbench listener on context cancellation
2026-08-04 00:00:27 +08:00
esengine 89e8c3d543 fix(rewind): show the typed prompt, not the composed turn
A checkpoint opens with the composed turn, so its stored prompt carries
whatever the controller prepended — the plan-mode marker and the
transient blocks. Nothing stripped that back out on the way to a
consumer, and every consumer treats Prompt as a human label: the TUI
rewind picker, the desktop change list, the workbench projection. The
rewind picker additionally restores the prompt into the composer, so
selecting an entry pasted controller markup into the user's input.

Users saw a rewind list whose entries all read "必须使用简体中文…" —
the reasoning-language block's first line, repeated for every turn,
with the actual prompts invisible behind it.

Checkpoints() now reduces each Prompt through StripComposePrefixes, the
existing helper that already knows about both the marker and the
transient blocks. Stripping on read rather than only on write also fixes
sessions whose checkpoints are already on disk, recorded composed.

Fixes #6903.
2026-08-03 09:00:12 -07:00
SivanCola 71ac1a4c9d feat(extension): streaming provider adapter for sidecar extensions
Stage 7 of the unified extension kernel / Extension Protocol v1 plan.

Problem: extension sidecars could declare providers at handshake, but the
host had no way to resolve or stream from them.

Fix:
- internal/extension/providerext: a provider.Resolver that merges each
  sidecar's catalog into the host resolver under
  plugin/<plugin>/<provider>/<model> refs, plus a provider.Provider whose
  Stream drives extension/provider/stream/open|cancel with broker-grade
  client semantics: 1-based contiguous sequence buffering (duplicates and
  stale chunks dropped, 256-slot bounded delivery queue, drain goroutine),
  stream/end.lastSeq as the frozen terminal boundary with a 1s gap timer
  that fails interrupted naming the missing sequence, context cancel that
  sends stream/cancel, and crash handling that drains buffered chunks then
  fails the stream interrupted — never falling back to another provider.
  Reasoning-policy interfaces (tool-call reasoning, reasoning round-trip,
  missing-reasoning warning) are answered from the descriptor, and effort
  is baked into the provider at resolve time like built-in providers.
- Ref conflicts are explicit: a sidecar descriptor colliding with an
  existing host ref fails BuildRuntime unless the plugin claimed
  provider:<ref> in runtime.replaces, in which case it replaces the base
  catalog entry (ParseSlot now accepts plugin-namespaced provider refs;
  the kernel's single-owner slot rules apply).
- internal/extension/providerconv: shared host-side provider<->protocol
  DTO conversions, extracted verbatim from the agent interceptor wiring
  so both consumers share one conversion path (agent behavior unchanged).
- sidecar.Client gains typed provider RPCs and a SetStreamRouter seam
  with a Disconnected signal used for crash propagation; boot wraps the
  effective resolver (local or remote broker — precedence preserved) and
  exposes it as BuildResult.ProviderResolver. Credentials never cross the
  wire: stream/open carries only ref, model, effort, and the request.

Verification: go test ./internal/extension/... ./internal/agent
./internal/boot pass (providerext and sidecar also under -race); boot E2E
with a re-exec fake sidecar resolves plugin/providerdemo/fake/x and
streams text+usage over the real wire; out-of-order/duplicate/missing/
late chunks, cancel, crash, catalog conflicts (unclaimed fails, claimed
wins), and concurrent-stream coverage included; protocol schema untouched
(generator -check green); golden baselines unregenerated; no new
dependencies.
2026-08-03 23:59:05 +08:00
SivanCola 953b98c23a Merge main-v2 into terminal theme fix
Integration:
- Preserve the terminal theme startup field and terminal regression suite.
- Keep the latest config-warning startup contract, usage-stat tests, and hook linting from main-v2.

Verification:
- Conflict markers removed.
- git diff --cached --check passed.
2026-08-03 23:57:16 +08:00
SivanColaandSivanCola 6b31b35671 test(serve): guard session delete failure feedback
Contract: The embedded web UI must keep localized active-session feedback, surface non-2xx response text, and report rejected delete requests.

Coverage: Add a focused index contract test for both locales and all three delete failure branches.

Verification: go test ./internal/serve; go test -race ./internal/serve; git diff --check.

Co-authored-by: SivanCola <[email protected]>
2026-08-03 23:54:49 +08:00
SivanColaandSivanCola 8ddaad8b9a fix(serve): report session delete network failures
Problem: Session deletion still failed silently when the browser request rejected before an HTTP response was available.

Root cause: The UI handled non-2xx responses but did not handle Promise rejection from fetch or response-body reads.

Fix: Add localized fallback feedback for rejected delete requests while preserving the existing active-session and HTTP-error paths.

Verification: Focused serve tests and browser checks cover the active-session guard, HTTP errors, and dropped connections.

Co-authored-by: SivanCola <[email protected]>
2026-08-03 23:54:40 +08:00
esengine 865adc6f1d fix(agent): derive transient-block handling from one tag list
The set of blocks the host prepends to a user turn was written out three
times: twice inside the strip regex (once for the opening alternation,
once for the closing one) and again as a switch in hasLeadingInjectedBlock.
The copies had already diverged — <autoresearch-runtime> was in none of
them, and the switch was missing active-goal, capability-route, and
interrupted-turn-recovery as well.

Two user-visible consequences. Markup for a missing tag survived into
previews, session titles, and the rewind picker, since the strip pass
never matched it. And hasLeadingInjectedBlock stopped its walk at the
first block it did not recognize, so an already-present response-language
or reasoning-language block behind one went undetected and a second copy
was prepended.

TransientUserBlockTags is now the one list: the regex is generated from
it and the walk iterates it. Attribute-carrying open tags (hook-context,
capability-route) are matched through a shared helper instead of ad-hoc
prefix checks. Tests iterate the list, so a tag added later is covered
without touching them.

Fixes #6534.
2026-08-03 08:52:45 -07:00
esengine 674be729bb refactor(git): drop computed allocation hints in argument building
Capacity hints derived from input lengths tripped the allocation-overflow
analyzer for no benefit — these lists hold a handful of entries. Uses
slices.Contains for the flag check while here.
2026-08-03 08:51:43 -07:00
SivanCola 0793472e30 Harden recovery trash across versions
Problem:
An older Reasonix runtime does not recognize the new recovery-trash cleanup operation and could route an interrupted marker through its ordinary delete reconciler before the transcript reached the trash.

Root cause:
The cleanup marker was durable before the primary transcript was recoverable, so cross-version reconciliation could observe an unsafe intermediate state.

Fix:
Write Desktop-compatible trash metadata and atomically move the transcript first, then publish the cleanup marker used to finish moving sidecars. Older runtimes can now delete only redundant leftovers after the conversation is already recoverable.

Verification:
- focused internal/agent recovery trash tests
- focused internal/agent race tests
- explicit cross-version publication-order regression test
2026-08-03 23:49:33 +08:00
SivanCola 0d41d616b3 Fix CLI recovery session hygiene
Problem:
CLI conflict-recovery copies can accumulate in resume lists, where they are indistinguishable from the original conversation and may be selected arbitrarily.

Root cause:
The CLI flattened session metadata, ignored recovery lineage, and never invoked the conservative recovery cleanup used by Desktop.

Fix:
Group recovery families in CLI resume surfaces, label copies with their parent, prefer the newest visible leaf, and move only covered, untouched, idle, unleased copies into the compatible session trash. Hold parent and branch removal guards across final revalidation and make interrupted trash moves durable.

Verification:
- go test ./...
- go vet ./...
- focused go test -race for agent recovery and CLI resume ordering
- ./scripts/cache-guard.sh
- Desktop recovery/session focused tests
2026-08-03 23:46:52 +08:00
esengine 129caed9e8 refactor(git): build every internal git invocation in one place
Reasonix shells out to git from five places: the CLI status readout, the
desktop workspace probe, the remote workbench, worktree management, and
plugin source checkouts. Each one assembled its own argument list, so the
invocation baseline drifted — three sites disabled fsmonitor and auto
maintenance, two did not, and the environment differed at every site.

internal/gitcmd now builds them all. Call sites keep their own
preferences (core.quotepath for the workbench, core.autocrlf for plugin
checkouts) by layering config on top of the shared baseline rather than
replacing it, and the environment is uniform: credential variables
filtered out of git subprocesses, optional locks off, no interactive
credential prompts on a terminal the TUI owns.

Also folds in the settings that keep repository-supplied configuration
from selecting a program for git to run during inspection — diff-side
external and textconv drivers alongside the existing fsmonitor and
maintenance settings — since a repository's config is authored by
whoever produced the repository, not chosen by the user. Covered by a
test that inspects a repository configured that way and asserts nothing
ran; the package comment records which drivers this cannot reach.

Argument-construction tests move from internal/worktree to the new
package with the code they cover.
2026-08-03 08:45:38 -07:00
SivanCola 5a5b50a985 Merge pull request #7335 from SivanCola/fix/session-event-replay-budget
Bound session event-log replay / 限制会话事件日志重放
2026-08-03 23:35:36 +08:00
YHH 3062ab652f Merge pull request #7341 from esengine/fix/security-hardening-5550-5551
fix(security): harden verification links and session lineage paths
2026-08-03 23:35:26 +08:00
esengine f7c693788c fix(security): harden verification links and session lineage paths
Two hardening fixes from the security triage:

- accounts worker: build email verification links from the configured
  APP_ORIGIN instead of the request origin — the Host header is
  caller-controlled, so a request-derived origin could point emailed links
  at an attacker-chosen host. Tokens are now URL-encoded too. (#5550)

- subagent store: session lineage walks (sessionAncestors,
  isAncestorSession) now route every session id — caller-provided and
  read back from branch metadata — through parentSessionPath's
  bare-filename validation instead of joining paths directly, so a
  "../"-shaped id can no longer escape the session directory. (#5551)
2026-08-03 08:17:53 -07:00
YHH 11d5462705 Merge pull request #7340 from esengine/feat/anthropic-web-search
feat(provider): server-side web_search passthrough for Anthropic-kind providers
2026-08-03 23:14:42 +08:00
SivanCola 7547d38c59 Harden nested session replay budgets
Problem:
A single encoded message could still expand into very large nested slices while staying below the byte and top-level message limits. User-message history loading also swallowed event replay refusals and returned an older checkpoint.

Root cause:
Replay preflighted only the messages array boundary before constructing provider.Message values, and LoadSessionUserMessages treated every probe or replay failure like a foreign event log.

Fix:
Token-walk each message before typed decoding, cap all nested JSON collection elements across the live transcript, and reject over-limit elements before reading them. Propagate probe, schema, and replay errors from user-message loading while retaining checkpoint fallback for safely ignorable logs.

Verification:
go test ./... -count=1
go test ./internal/agent -count=1
go test -race ./internal/agent -count=1
go vet ./...
cd desktop && go test ./... -count=1
cd desktop && go vet ./...
REASONIX_RELEASE_CACHE_GUARD=1 go test ./internal/agent -run ^TestReleaseCacheHitGuard$ -v -count=1
2026-08-03 23:11:43 +08:00
SivanCola 5e3712f60e Merge pull request #7319 from Li-Charles-One/feat/creation-windows-titlebar-height
fix(creation): Windows 标题条压至 40px 并对齐控件
2026-08-03 23:06:29 +08:00
SivanCola 4bd353eb31 Merge pull request #7238 from HaoyueQin/feat/usage-statistics
feat(stats): add usage statistics panel with heatmap, trends and model usage / 新增含热力图、趋势与模型用量的使用统计面板
2026-08-03 23:05:52 +08:00
SivanCola e125de6f07 Merge pull request #7320 from Li-Charles-One/fix/settings-permission-rule-overflow
fix(desktop): 细粒度权限规则长命令不再溢出(Fixes #7315)
2026-08-03 23:01:43 +08:00
esengine 0e59991860 test(anthropic): pin web_search wire shape and result surfacing
Follow-up hardening for the web_search passthrough:

- formatWebSearchResults: drop the stray leading newline when every result
  entry is blank, and use Fprintf per lint.
- Pin the anthTool wire shape both ways: the typed server tool must omit
  input_schema, and omitempty must not strip the schema from named tools.
- Cover buildRequest tool ordering with web_search on/off.
- Full SSE round-trip test: web_search_tool_result surfaces as readable
  text and server_tool_use is never mistaken for a client tool call.

Capability verified live against api.deepseek.com/anthropic: the endpoint
executes searches server-side and meters them in usage as
server_tool_use.web_search_requests.
2026-08-03 07:57:01 -07:00
Sean Liao 1f9e09c736 feat: support for anthropic web_search
Adds support for calling web_search via the provider.

Example provider config, using DeepSeek's support for the web_search
tool:

```toml
[[providers]]
name        = "anthropic-api-deepseek-com"
kind        = "anthropic"
base_url    = "https://api.deepseek.com/anthropic"
models      = ["deepseek-v4-flash", "deepseek-v4-pro"]
api_key_env = "DEEPSEEK_API_KEY"
context_window = 128000   # tokens; compaction triggers near this limit
prices      = { "deepseek-v4-flash" = { cache_hit = 0.02, input = 1, output = 2, currency = "¥" }, "deepseek-v4-pro" = { cache_hit = 0.025, input = 3, output = 6, currency = "¥" } }   # per-model prices, per 1M tokens
web_search  = true   # enable server-side web_search tool (Anthropic/DeepSeek API)
```

Example use, note that it doesn't try to perform the search via a call
to a search engine:

```
◆ reasonix  · deepseek-v4-pro
  Context is kept across turns. Type 'exit' or Ctrl-D to quit.

  › search the web for hello

  ▎ thought for 1s
• Hello Definition & Meaning | Britannica Dictionary
  https://www.britannica.com/dictionary/hello?srch_tag=e2cdp6pptoimak7ydbp6kjxddhwqysz3
• hello https://baike.baidu.com/item/hello/38467
• hello _ 百科 https://baike.sogou.com/v64731937.htm?addressbar=hide&jump_from=undefined&vrid=81001007
• hello
  https://baike.sogou.com/v64731937.htm?ch=frombaikevr&fromTitle=hello&vrid=30010097&entryScene=baike_VR_ruo&j
  ump_from=sogou-mobb-e447193f2b83d789&addressbar=hide#paragraph_3
• hello http://dict.youdao.com/w/hell_o/
• hello是什么意思 https://www.hanzidaquan.com/yingyu/hello.html
• Hello! https://www.ldoceonline.com/dictionary/hello
• hello是什么意思? https://wenwen.sogou.com/z/q961078741.htm
• hello是什么意思,hello怎么读,hello的意思是:“喂”的招呼声或问候 https://fanyi.kkabc.com/ci/Hello
• hello https://www.wordreference.com/enzh/hello Here are the top results from the web search for "hello":

1. Britannica Dictionary — Defines "hello" as a greeting, a telephone answer, a way to get attention, and an
   expression of surprise. Pronunciation: /həˈloʊ/.
2. Baidu Baike (百度百科) — Covers the history: derived from Old High German "halâ", popularized by Edison
   with the telephone. Notes its use in Chinese as "你好".
3. Sogou Baike (搜狗百科) — Similar to the Baidu entry, covering meanings, usage, and etymology including the
   Edison telephone story.
4. Youdao Dictionary — Lists: interjection (greeting, telephone, getting attention), noun (a greeting), and
   verb (to say hello). Also notes "Hello" as a song by Karmin.
5. Longman Dictionary — Comprehensive entry with usage examples: greeting, telephone, attracting attention,
   expressing surprise/confusion. Also covers informal alternatives (hi, hey, hiya).
6. WordReference — Bilingual English-Chinese entry with translations, compound forms ("golden hello", "say
   hello"), and pronunciation guides.

If you had something more specific in mind, let me know and I can refine the search!

  ▎ thought for 0s
  · 33233 tok · in 32870 (12800 cached / 20070 new) · out 363 · ¥0.0627
```

For #4217
For #5166
2026-08-03 07:52:24 -07:00
SivanCola f3490cd370 Merge latest main-v2 updater fix
Integration:
- Sync the usage-statistics branch with the latest Desktop updater recovery fix.
- Preserve the statistics subtab and updater settings behavior in the shared SettingsPanel and locale files.

Verification:
- (cd desktop/frontend && pnpm test:updater)
- (cd desktop/frontend && pnpm test:usage-stats)
- (cd desktop/frontend && pnpm typecheck && pnpm build)
2026-08-03 22:49:35 +08:00
SivanCola 8a57881a20 feat(extension): interceptor dispatch and replacement strategies across the runtime
Stage 6 of the unified extension kernel / Extension Protocol v1 plan.

Problem: the sidecar protocol and lifecycle existed but nothing in the
host consulted extensions — there was no dispatcher, no decision
semantics, and no replacement-strategy application.

Fix:
- internal/extension/dispatch: a host dispatcher that walks the frozen
  snapshot interceptor chain sequentially (priority, plugin ID,
  registration order), with per-point payload DTOs and strict
  re-validation of every replacement (DisallowUnknownFields + payload
  invariants + tool-args JSON-object shape). Decisions: continue, block
  (user-visible, secrets-redacted reason), replace (substituted payload
  flows to later interceptors and the host), allow/deny (legal only at
  permission.decision; anything else is a contract violation). Error
  policy: extensions owning a replacement slot or declared required:true
  fail the operation on timeout/error; optional observation-only
  extensions warn once and are skipped. Strategy slots (system_prompt,
  context, provider_request, provider_response, compaction,
  session_policy, permission, frontend_events) are single-owner: after
  the chain walk, the slot owner gets the final replacement say via
  RunStrategy, and its failure is always fatal.
- Wire the dispatcher through boot, control, and agent at all 17 points:
  system_prompt.build (strategy output lands in the frozen snapshot hash
  AND the live agent session via a pre-turn session swap),
  input.receive (composed text, both orchestrated and headless paths),
  agent.before_start, context.prepare and provider.request (ephemeral —
  request copy only, session messages never mutated), provider.response
  (replacement persists as the visible transcript turn), tool.before
  (re-parse replaced calls so policy/permission see what executes),
  permission.decision (full-trust allow overrides host deny, audited and
  surfaced), tool.after, compaction.prepare/complete, session
  start/end/load/save/rotate, and frontend.event observation (strategy
  may rewrite text, never the kind; malfunction never hangs the frontend
  state machine). Every point is nil-safe: no extensions means
  byte-identical behavior, golden baselines unregenerated.
- Close the externalization gap: intercept/event payloads over 64 KiB are
  offloaded to the host content store with an ExternalizedField envelope,
  and inbound replacements carrying content refs are paged back through
  host/content/read with byte-count and SHA-256 verification (protocol
  schema regenerated — additive optional fields only, hash
  821c11cc... -> 41d3978a...).

Verification: go test ./internal/extension/... ./internal/agent
./internal/boot ./internal/control pass; full go test -race on
internal/agent and the extension packages pass; every point has
continue/block/replace/timeout coverage incl. permission override
triples, two-phase chain-then-strategy ordering, cache ephemerality
(run-2 request byte-equals the no-extension baseline), and snapshot
immutability across dispatch; gofmt/go vet clean; no new dependencies.
2026-08-03 22:48:01 +08:00
SivanCola ac493a7a52 fix(session): enforce replay limits before allocation
Problem:
Session event replay decoded an entire messages array before enforcing its count budget, and the ownership probe treated JSON field order as a persistence contract.

Root cause:
The replay decoder materialized []provider.Message in the outer event record, while the bounded probe required schema_version and type to be the first two keys.

Fix:
Keep message arrays as raw JSON until each element passes the aggregate message budget, and preserve order-independent header recognition for valid logs within the byte limit while retaining the bounded native fast path.

Verification:
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- focused agent and Desktop go test -race
- go vet ./internal/agent
- cd desktop && go vet .
- git diff --check
2026-08-03 22:38:04 +08:00
SivanCola 1d8c7a4223 fix(creation): scope compact titlebar to Windows
Problem: The Windows titlebar adjustment changed shared Creation topicbar geometry on macOS and Linux.

Root cause: The compact height, padding, and title offset were applied through platform-neutral .app--creation selectors.

Fix: Restore the shared Creation geometry and move the 40px alignment overrides behind Windows-specific selectors for both default and themed styles.

Verification: app-chrome-tabs.test.ts, CSS checks, TypeScript typecheck, and the production frontend build passed.
2026-08-03 22:36:40 +08:00
SivanCola 1e5c0674ea Merge pull request #7336 from SivanCola/fix/updater-recovery-fallback
fix(desktop): improve updater recovery UX and feedback / 改进更新恢复与反馈体验
2026-08-03 22:34:02 +08:00
SivanCola 1985115723 Merge latest main-v2 settings updates
Integration:
- Sync usage statistics with the newly merged compaction-settings and diagnostics changes.
- Preserve both SettingsPanel subfeatures and their Wails/localization contracts.

Verification:
- go test -timeout=10m ./internal/stats ./internal/cli ./internal/config
- (cd desktop && go test -timeout=10m .)
- (cd desktop/frontend && pnpm typecheck && pnpm test:usage-stats && pnpm build)
2026-08-03 22:29:21 +08:00
SivanCola 51b388d4cb fix(session): bound event log replay
Problem:
Native session event logs could be fully buffered and decoded during startup without byte, record, or message limits. Desktop also treated pinned-session load failures like a missing session and could open a blank replacement.

Root cause:
The ownership probe decoded the entire first event, replay trusted the complete log shape, and the pinned-session helper collapsed non-ENOENT load errors into a false result.

Fix:
Probe only the bounded native header, enforce replay budgets with a typed path-free error, and fail closed in Desktop while preserving session files and releasing partial runtime resources.

Verification:
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- focused agent and Desktop go test -race
- go vet ./internal/agent
- cd desktop && go vet .
2026-08-03 22:21:29 +08:00
SivanCola 91bba93103 fix(desktop): improve updater recovery fallback
Problem:
Desktop update failures could leave users with only a retry action, while the update settings mixed recovery actions with low-frequency preferences and lacked a direct feedback path.

Root cause:
The frontend reduced updater failures to a manual-fallback boolean and did not distinguish recoverable pending transactions from transient failures or manual-only installation environments.

Fix:
Classify updater errors as retryable, recovery, or manual; prioritize the official signed installer for blocked flows; add official download, changelog, and GitHub feedback actions; and move privacy/update preferences into a collapsed footer section.

Verification:
- pnpm --dir desktop/frontend test:updater (33 passed)
- pnpm --dir desktop/frontend build
- in-app browser DOM and computed-style acceptance
2026-08-03 22:19:35 +08:00
SprayHank 9549af0817 fix(serve): surface session-delete failures in the web UI
Deleting a session silently swallowed non-2xx responses (the 409
'cannot delete active session' guard), so the click appeared to do
nothing. The delete button on the active session now shows a localized
notice ('无法删除当前会话' / 'Cannot delete the active session') without
calling the API, and any other failure response shows the server's
error text in a notice.
2026-08-03 22:18:36 +08:00
SivanCola 32c5bff6f2 Merge pull request #7233 from SivanCola/feature/context-compaction-threshold
Expose configurable context compaction threshold in Desktop and CLI / 在桌面端和 CLI 配置上下文压缩阈值
2026-08-03 22:13:45 +08:00
SivanCola 5cb1a78394 Merge pull request #7327 from SivanCola/fix/diagnostics-hang-signal-quality
fix(diagnostics): improve hang signal quality / 提升卡顿诊断信号质量
2026-08-03 22:12:34 +08:00
SivanCola 1861115cd2 Merge main-v2 into usage statistics
Integration:
- Sync the contributor branch with the latest main-v2 before final review.
- Preserve the reviewed usage-statistics changes while incorporating current Desktop, Remote, CLI, and provider fixes.

Verification:
- go test -timeout=20m ./...
- (cd desktop && go test -timeout=20m ./...)
- targeted Go race tests for provider, stats, agent, guardian, Remote, and Desktop
- (cd desktop/frontend && pnpm build && pnpm test:all)
2026-08-03 22:10:36 +08:00
SivanCola 35cdd64dad perf(settings): keep compaction controls off initial bundle
Problem:
The Linux desktop CI build exceeded the initial raw JavaScript and CSS budget by 0.3 KiB after adding the compaction controls.

Solution:
Code-split the control-specific CSS with the lazy Settings panel, deduplicate preset copy, and simplify equivalent state handling and guidance without changing the interaction contract.

Verification:
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/typography-overflow-contract.test.ts
2026-08-03 21:55:39 +08:00
Vikky e36b6aa803 fix(openai): honor custom supported efforts 2026-08-03 21:48:58 +08:00
SivanCola 39c6820689 feat(cli): configure compaction threshold
Problem:
Desktop exposes the automatic compaction threshold, but CLI users and scripts cannot inspect its effective source or update it safely.

Solution:
Add compact-ratio query, user-global writes, and project-local overrides with the same 65-85% guard rails and transactional config locking. Document the behavior in English and Chinese CLI/spec docs, localized help, and the website.

Verification:
- go test ./...
- go test ./internal/cli ./internal/config
- ./scripts/cache-guard.sh
- npm --prefix site test
- npm --prefix site run build
2026-08-03 21:46:05 +08:00
SivanCola 691624d8da fix(desktop): remove obsolete WebView2 report wrapper
Problem:
The Linux desktop CI job failed its unused-code lint after the contextual WebView2 report builder replaced the old compatibility wrapper.

Root cause:
The wrapper no longer had production or test callers, but remained in wails_logger.go.

Fix:
Remove the dead wrapper and keep all call sites on the contextual report builder.

Verification:
golangci-lint v2.12.2 reports zero issues; go vet, all desktop Go tests, and Windows amd64 test-binary cross-compilation passed.
2026-08-03 21:44:30 +08:00
SivanCola a04460e738 fix(desktop): track WebView2 as a direct dependency
Problem:
The desktop CI go.mod tidy gate failed because the new Windows Runtime version helper imports go-webview2 directly while the module still classified it as indirect.

Root cause:
The implementation added the direct import without committing the dependency classification produced by go mod tidy.

Fix:
Move the existing go-webview2 requirement into the direct dependency block without changing its version, checksum, or local replacement.

Verification:
go mod tidy is stable; desktop Go tests, race tests, vet, and Windows amd64 test-binary cross-compilation passed.
2026-08-03 21:36:43 +08:00
SivanCola 33c3145665 fix(stats): count failed provider requests
Problem:
Usage statistics omitted HTTP attempts whenever a logical provider stream failed or ended without terminal token usage, so retries could appear as zero requests.

Root cause:
Providers owned the request counter, callers only emitted successful Usage records, and the statistics recorder rejected zero-token records.

Fix:
Reuse caller-owned attempt counters across built-in providers, synthesize request-only usage on failed or missing-usage streams, cover every direct model-call path plus the Remote broker, and persist those records without forwarding zero-token receipts to the UI. Aggregate request-only rows independently from model token distributions and preserve exact Desktop request totals.

Verification:
- go test -timeout=20m ./...
- go test -timeout=20m ./... in desktop
- go test -race on the affected root and Desktop packages
- go vet ./... in root and desktop
- pnpm typecheck
- pnpm test:usage-stats
2026-08-03 21:33:57 +08:00
SivanCola 3bdacd8d70 fix(settings): separate custom compaction editing
Problem:
- The four-option segmented control truncated every threshold label at the settings width.
- Opening custom editing visually replaced the saved preset selection before any value was applied.

Root cause:
- Preset values and the custom-editor disclosure shared one selected-state control.

Fix:
- Render the three presets as equal-width two-line choices and keep their full accessible labels.
- Move custom editing to a separate disclosure beside an explicit saved-value summary.
- Preserve the saved preset selection while the editor is open and cover the state contract with regression tests.

Verification:
- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm exec tsx src/__tests__/typography-overflow-contract.test.ts
- pnpm typecheck
- pnpm check:css
- pnpm lint:hooks
- ./scripts/cache-guard.sh
- In-app browser DOM, computed-size, overflow, and disclosure-state checks
2026-08-03 21:33:48 +08:00
SivanCola b0d74fdf09 fix(diagnostics): improve hang signal quality
Problem:
Recent hang telemetry mixed lifetime diagnostic counts with selected time windows, allowed synthetic versions to become the release baseline, and lacked enough context to distinguish WebView2 failures from UI measurement or sleep/resume artifacts.

Root cause:
Diagnostic group queries ignored the dashboard window, long code viewers fed virtual-row measurements back through React, the watchdog retained a pre-sleep heartbeat, and WebView2 failures were queued without per-session throttling or runtime attribution.

Fix:
Apply diagnostic window filters and stable release quorum rules, show deduplicated affected installs, use direct DOM virtualizer updates, reset the watchdog after sleep, tier heap reports, and add throttled WebView2 reports with runtime and occurrence context.

Verification:
Desktop Go tests, frontend focused tests and typecheck, crash-report worker tests and typecheck, and Windows amd64 cross-compilation passed.
2026-08-03 21:14:15 +08:00
SivanCola 4c1b0aeb50 fix(settings): explain compaction threshold guard rails
Problem:
The custom compaction editor exposed a 65%–85% range without explaining why values outside it are unavailable.

Root cause:
The hint described only input mechanics and omitted the adjacent 60% tool-output cleanup and 90% forced-compaction watermarks.

Fix:
Explain both guard rails and their safety purpose in every supported locale, with regression coverage for the English contract.

Verification:
- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm test:typecheck
- In-app browser DOM and wrapping checks
2026-08-03 20:59:31 +08:00
LI Xinyu c36ca274c3 fix(agent): skip corrupt subagent metadata during startup cleanup
CleanupStaleRunning aborted startup on any undecodable .meta.json
(truncated write or killed process), reporting "cleanup stale
subagents: decode subagent metadata ...: unexpected end of JSON
input" and blocking the whole app. Skip JSON decode failures the same
way ListSubagentsByParent already does, while keeping genuine I/O
errors fatal.

Fixes #7191
2026-08-03 20:45:11 +08:00
SivanColaandSivanCola 449c98f82e fix(stats): remove accounting stalls and align metrics
Problem:
Usage accounting could block turn completion on file locks, undercount retried or continued provider calls, label turns as sessions, and render oversized custom ranges.

Root cause:
Recording performed disk I/O inline; request attempts were not carried through provider retry, reconnect, continuation, and recovery merges; the UI reused session wording; range work scaled with absent days and the full trend.

Fix:
Queue records through one bounded per-state-dir worker with settings and shutdown flush boundaries, attach HTTP attempt counts through provider contexts, preserve counts across recovery, rename completed turns, scan only existing stats files, and cap trend DOM to the latest 180 days with a notice.

Verification:
- go test -race ./internal/stats ./internal/provider ./internal/provider/openai ./internal/provider/anthropic ./internal/provider/responses
- go test ./...
- cd desktop && go test ./... && go vet ./...
- cd desktop/frontend && pnpm test:usage-stats && pnpm typecheck && pnpm build

Co-authored-by: SivanCola <[email protected]>
2026-08-03 20:37:56 +08:00
SivanCola e6fa7e8314 fix(settings): keep custom compaction option visible
Problem:
The custom compaction threshold option rendered outside the fixed 320px settings control and was clipped by the segmented control overflow.

Root cause:
Segment buttons kept their intrinsic minimum width, so the fourth localized option could not shrink into the available column.

Fix:
Allow compaction options to share and shrink within the full control width, preserve truncated labels with titles, and add CSS contract coverage.

Verification:
- pnpm test
- pnpm test:typecheck
- pnpm build
- In-app browser boundary and interaction checks
2026-08-03 20:37:33 +08:00
SivanCola f8ea772bfa Merge pull request #7311 from SivanCola/fix/cli-flag-parse-errors
fix(cli): unify flag errors and remote connect ordering / 统一参数错误与远程连接顺序
2026-08-03 20:11:21 +08:00
Yan Li 2037065685 fix(control): treat '!' inputs as non-turn for format slot
submitHTTP rejects '!' shell commands (403) before the turn loop, so a
structured-output format attached to such input was parked in the
one-shot slot and leaked into the next real turn. isNonTurnHTTPInput now
returns true for '!' prefixes; the test assertion mirrors the actual
submitHTTP behavior.
2026-08-03 20:09:14 +08:00
Yan Li cae805b1e1 fix(serve): validate format param on /submit (Copilot review)
The format field was forwarded to the controller without normalization:
arbitrary or whitespace-padded values would propagate into provider
requests and fail later with a vendor 400. Trim and validate up front —
only empty (default text) or json_object are accepted, anything else
returns 400 before the turn starts. Test covers rejection (xml -> 400,
runner never runs) and normalization (padded json_object -> 202).
2026-08-03 20:09:14 +08:00
Yan Li d6a5ea7819 fix(control): discard structured-output format on non-turn HTTP input
SubmitHTTPFormat parked the requested format in a one-shot slot consumed
only by runGoalLoopWithRawDisplay. Slash commands (/compact /new /model
...), memory quick-adds, and /remember bypass the turn loop, so the slot
leaked into the next real turn — the next ordinary message would
unexpectedly request json_object output.

isNonTurnHTTPInput() now gates the slot: commands and memory shortcuts
never set it. Leak regression test covers both paths.
2026-08-03 20:09:14 +08:00
Yan Li 3d6dd915a6 fix(control): drop unused testCacheColdAfter field (golangci-lint unused)
The field was carried over during the structured-output cherry-pick but
never referenced — cacheColdAfter() uses a package-level var
(controller.go:3397) with test override via reassignment. golangci-lint
v2 flags it as unused; removing it also aligns the struct with upstream
main-v2, which never had the field.
2026-08-03 20:09:14 +08:00
Yan Li 442f66b4ad test(responses): drop non-MiMo upstream-scope tests from PR branch
TestCompletedResponseDefaultsFinishReasonToStop and
TestAllZeroUsageCompletedIsSuppressed belong to a different response
behaviors change (FinishReason=stop synthesis, zero-usage suppression)
not included in this MiMo PR; their implementations live in the dev
branch's separate workstream. Removing them keeps the PR scoped to the
MiMo wire fixes while the remaining 25 tests (14 upstream + 11 MiMo)
all pass.
2026-08-03 20:09:14 +08:00
Yan Li 36285361f7 fix(responses): conversationDigest must mirror wire knobs
The stateful fast path compares conversationDigest(history) against the
digest recorded when the previous response completed; a mismatch forces a
full history replay instead of previous_response_id (server cache-hit
loss). The digest was built with hardcoded (vision=false, summary=false)
while the wire uses the client's real knobs, so vision-capable endpoints
and DashScope (summaryRequired) never matched.

Digest now uses the same vision/summary arguments as buildRequestBody.
Test asserts vision and dashscope-summary both change the digest.
2026-08-03 20:09:14 +08:00
Yan Li 829e4c517c feat(remote): carry responseFormat across the Host<->Desktop broker
BrokerProviderRequest gains ResponseFormat so a structured-output request
survives the Host->Desktop hop (schema regenerated: schema.generated.json,
schema_hash.generated.go, remoteProtocol.generated.ts). provider.ResponseFormat
Type gets its json tag ("type") so the DTO validates against the generated
schema.

Round-trip test extended to assert json_object survives encode/decode.
2026-08-03 20:09:14 +08:00
Yan Li d1ee719be8 feat(provider): structured JSON output (text.format=json_object)
Phase 1 (wire): provider.Request gains an optional ResponseFormat; the
Responses provider serializes it as text.format (json_object) — the one
shape MiMo/DashScope/OpenAI all define. Nil omits the key entirely,
keeping the common path byte-stable for prompt caching.

Phase 2 (call path): serve /v1/chat accepts an optional "format"
field; Controller.SubmitHTTPFormat carries it one-shot into the turn
context (agent.WithResponseFormat, same pattern as runStepLimit) so the
agent's provider.Request picks it up without persistent state.

Tests: wire serialization + omission, context round-trip, and a live
MiMo check returning {"name":"Zhang San","age":28}.
2026-08-03 20:08:48 +08:00
SivanCola f5cfa5ca64 refactor(desktop): streamline compaction threshold controls
Product: Keep presets on the default path and reveal exact threshold input only on request.

Interaction: Add focus-managed editing, explicit apply and cancel actions, keyboard recovery, and failure-atomic saves.

Budget: Raise the raw initial asset ceiling by 2 KiB for the measured feature cost while leaving gzip and per-chunk limits unchanged.

Verification: pnpm build; pnpm test; pnpm test:typecheck; focused settings snapshot coverage; in-app browser interaction check.
2026-08-03 20:08:30 +08:00
Yan Li 3d7d1528ae fix(responses): round-trip reasoning item status alongside id
The wire schema's Reasoning item also carries an optional status
(in_progress | completed). Capture the final status from the streamed
output_item.done event and echo it back on the next turn's reasoning
item, completing the id/status/content round-trip.

Live MiMo check: turn 1 emits id rs_3d141808c8874636b3c8c308163c6c21
status completed; echoing {id,status,content} on turn 2 is accepted
(completed, 1.6s).
2026-08-03 20:05:53 +08:00
Yan Li aeb5b70f4a fix(responses): round-trip reasoning item id (schema-required field)
The OpenAI Responses schema marks Reasoning.id required on input items.
The streamed output_item.added event carries the provider-issued id
(rs_...); capture it into Message.ReasoningID and echo it back on the
next turn's reasoning item. Omitted when no id was captured, keeping
wire bytes stable for vendors that ignore it.

Live MiMo check: output items carry id rs_28d480b72c06448e8e2e67efbdf8025a
with type reasoning; round-tripping it matches the documented schema.
2026-08-03 20:05:53 +08:00
Yan Li d287298c1a fix(responses): send reasoning summary only where the schema requires it
messagesToInput unconditionally serialized the DashScope-only `summary`
list on reasoning items for every vendor. MiMo's schema has no summary
field; its server folds the extra field back into the model context, so
each turn's chain-of-thought echoes both summary and content — doubling
reasoning output every tool round until max_output_tokens truncates the
turn mid-tool-call ("reply then stop" / stuck agent loops).

Vendor capability table gains summaryRequired (DashScope only). MiMo and
unknown endpoints now send content-only reasoning items, matching the
OpenAI base format. Live 6-round tool loop: reasoning stays flat
(236→230→140→144→144→151) vs the previous doubling; every round
completed.
2026-08-03 20:05:53 +08:00
Yan Li b5384decdd fix(responses): raise MiMo default max_output_tokens to 65536
MiMo's 32768 server default covers reasoning + visible output; its
thinking mode (low/medium/high all enable reasoning) can spend a large
chunk of that budget on reasoning before the visible answer, truncating
tool calls mid-JSON on long turns and breaking the agent loop. Add a
vendor capability defaultMaxOutputTokens (65536, within the documented
[1,131072] range) used only when the caller did not set MaxTokens.

Verified live: mimo-v2.5 high, max_output_tokens=65536 accepted,
status=completed.
2026-08-03 20:05:53 +08:00
Yan Li 96330b5659 feat(responses): declare MiMo single-segment reasoning capability
MiMo's thinking is one uninterruptible segment per turn: the server
emits reasoning and the final answer atomically, and a new reasoning
segment only starts on a brand-new turn — never mid-turn after a tool
call (official docs: low/medium/high all enable reasoning with no
strength differentiation). DeepSeek, by contrast, can emit several
reasoning segments across a turn's tool loop.

Add vendorCapabilities.singleSegmentReasoning (mimo=true, others
false) so callers know not to expect a multi-segment chain-of-thought
from single-segment vendors. Verified live: mimo tool-call rounds pair
one reasoning segment with the call; no mid-turn re-thinking occurs.
2026-08-03 20:05:04 +08:00
Yan Li 9dcfca8e4a feat(responses): support multimodal input (input_image) on vision-enabled user turns
Messages with attached Images on a vision-enabled provider serialize as
the documented InputItemList array form: [{type:input_text,text}] +
[{type:input_image,image_url}] per image, matching the MiMo/DashScope
multimodal example. Text-only turns keep the TextInput string shape —
images are the only trigger for the array form, so pure-text sessions
stay byte-stable.

- client.vision from Config.Extra["vision"]
- messagesToInput(messages, vision): user turns with Images -> array
  parts; system/assistant and text-only user turns unchanged
- conversationDigest passes vision=false (images don't affect the
  stateful prefix digest; image URLs are not stable across replays)

Tests: text-only stays string even with vision on; images embed as
input_image parts in order after input_text; vision-off ignores Images.
Verified live: mimo-v2.5 describes the official example image and
continues multi-turn context after the image round.
2026-08-03 20:05:04 +08:00
Yan Li 4076a68e96 fix(responses): scope missing-tool-call-reasoning warning to DeepSeek
The responses provider did not implement WarnOnMissingToolCallReasoning,
so provider.WarnOnMissingToolCallReasoning fell back to
RequiresToolCallReasoning, which is true for MiMo — surfacing a
per-session warning when a MiMo tool-call turn arrived with empty
reasoning.

MiMo documents reasoning alongside tool calls but does not guarantee it
every round (observed: mimo-v2.5-pro tool-call turn with no reasoning),
so a missing chain-of-thought there is endpoint-conditional, not a
degradation worth warning. Gate the warning on deepseek non-flash
models, mirroring openai.go's expectsDeepSeekToolCallReasoning.
2026-08-03 20:04:24 +08:00
Yan Li 373b5fd5d3 fix(openai): always serialize tool-message name key (#4711)
MiMo and other strict OpenAI-compatible backends reject a role=tool
message whose `name` key is absent (HTTP 400 "Param Incorrect, name is
not set"). A legacy empty-name tool result (#4727 shape, where backfill
has no source) previously vanished via omitempty, 400ing the whole
request.

Name becomes a *string: ordinary messages keep omitting the key
(byte-stable prompt prefix), while role=tool messages always serialize
it — empty string included.

Test: TestToolResultEmptyNameStillSerialized covers the both-names-empty
legacy shape; non-tool messages are asserted to stay name-free.
2026-08-03 20:04:24 +08:00
Yan Li 79ec3effdc feat(provider): vendor capability table + MiMo wire/knowledge alignment
Single source of truth for Responses wire-level vendor differences,
replacing scattered string comparisons in responses.go.

- new internal/provider/responses/vendor.go: vendorCapabilities table
  (stateless / sessionCacheHeader / toolCallReasoning / ignoresTemperature)
  covering dashscope, deepseek, mimo, and the default OpenAI behavior
- responses.go: mode(), sessionCache, RequiresToolCallReasoning, and the
  x-dashscope-session-cache header now consult the table
- mimo: skip temperature in the wire request (vendor forces 1.0/0.95 in
  thinking mode per official docs)
- effort: MiMo exposes a binary thinking knob — /effort none|low|medium|
  high accepted (none disables reasoning), plain OpenAI endpoints still
  reject none
- cache_policy: detect api.xiaomimimo.com explicitly; TTL stays at the
  24h unknown-vendor default pending a long-prefix (>8K tokens) TTL
  measurement (short-prefix measured ~3.5-7.5min, vendor targets hours)

Tests: vendor capability table, temperature omission, MiMo effort none,
cache vendor detection. All pass; pre-existing migrate env test failures
(TestMigrateImportsLegacy*) are unrelated to this change.
2026-08-03 20:04:24 +08:00
Yan Li 70858eda28 feat(provider): add MiMo (api.xiaomimimo.com) Responses support
MiMo's Responses API is stateless like DeepSeek's: previous_response_id
is not supported (planned phase two) and multi-turn tool calls must
retain historical reasoning in the input.

- DetectVendor: recognize api.xiaomimimo.com as vendor "mimo"
- mode(): default stateless for mimo (was stateful -> HTTP 400 on
  previous_response_id)
- RequiresToolCallReasoning: preserve reasoning on tool-call turns for
  mimo, matching its documented multi-turn requirement
- config: mimo-api preset users add responses_mode = "stateless"
2026-08-03 20:01:44 +08:00
YHH f76aad817d feat(telemetry): separate the 400s that need different fixes (#7316)
Every provider 400 lands in one bucket, so the dashboard says 46 of them
happened last month and nothing about which. Triaging four such reports by hand
this week turned up four unrelated causes: a replayed image_url, a tool schema
missing `required`, a thinking parameter shaped as an object where a boolean was
expected, and reasoning content that had to be echoed back. Each needs a
different fix, and none is distinguishable from the bucket.

errorClass deliberately never uploads the message itself, because a provider
body can echo request content back. That constraint stays: every arm matches a
fixed substring and returns a fixed token, so nothing from the body reaches the
label. Unrecognized shapes stay plain http_400 rather than guessing.

The user-facing message is unchanged — control.apiErrorReason already appends
the provider's verbatim reason, which is why those four were diagnosable at all.
This closes the gap on the telemetry side, where the aggregate lives.

Cache-impact: none. desktop/metrics_app.go is not on the cache-sensitive path
list in scripts/check-cache-impact.sh; no prompt or tool bytes change.
Cache-guard: not applicable for the same reason.

Verified: go test ./desktop (full module), plus a test asserting that bodies
carrying paths, filenames and an API key produce labels containing none of them.
2026-08-03 20:00:30 +08:00
Vikky 9847087ec5 fix(remote): stop Workbench listener on context cancellation 2026-08-03 20:00:13 +08:00
Li-Charles-One d3b10d04b4 fix(desktop): keep long permission rules inside chips (Fixes #7315)
Wrap long allow/deny command rules so the delete control stays visible
instead of overflowing the settings card.
2026-08-03 19:57:44 +08:00
SivanCola 9d403e91a7 Merge latest main-v2 into compaction settings
Integration: Bring the compaction threshold PR onto the current main-v2 baseline.

Resolution: Preserve the compaction presets while retaining the upstream GLM reasoning protocol option.

Verification: Conflict markers removed; focused feature verification follows in the next commit.
2026-08-03 19:55:52 +08:00
SivanCola 0e90e2d1cf feat(extension): Extension Protocol v1 wire schema, generator, and sidecar lifecycle
Stage 5 of the unified extension kernel / Extension Protocol v1 plan.

Problem: Reasonix had no process model for code-carrying extensions — no
stable sidecar protocol, no schema generator to keep it honest, and no
lifecycle for spawning, supervising, and retiring extension processes.

Fix:
- internal/extension/protocol: a new public JSON-RPC 2.0 protocol
  (reasonix.extension.v1) with a frozen 16-method registry (initialize,
  initialized, shutdown, intercept, event, resources/changed,
  provider/catalog, provider/stream open|cancel|chunk|end, ui/action,
  ui/submit, host/ui/publish, host/ui/request, host/content/read), strict
  DTO decoders, frozen limits (8 MiB frames, 64 KiB externalization
  threshold, 256 KiB content chunks, 8 MiB content objects), a frozen
  error table, and a v1 stability contract (only optional fields, enum
  values, and methods may be added within the major). Provider DTOs are
  public copies of the provider shapes — internal types never cross the
  wire, and credentials never appear in any DTO.
- internal/extension/protocolgen + cmd/extension-protocol-gen: generates
  the canonical JSON Schema (draft 2020-12, sha256:821c11cc…), the schema
  hash constant, and docs/EXTENSION_PROTOCOL.generated.md; -check enforces
  drift-free artifacts and a determinism test double-generates byte-equal
  output.
- internal/extension/sidecar: the only sidecar launch path. Authorization
  invariant: runtimes start only from installed+enabled plugin state —
  project configuration can never declare a sidecar. Exec-form spawn (no
  shell, ${REASONIX_PLUGIN_ROOT} expanded, absolute command required) with
  unfiltered inherited environment (the full-trust contract), 16 KiB
  redacted stderr diagnostics, initialize-first handshake with capability
  subset validation (subscriptions/replaces/providers/UI beyond the
  manifest fail with capability_not_declared, version mismatch with
  unsupported_version), bounded shutdown (shutdown RPC → stdin close →
  750ms grace → process-tree kill → 5s reap), crash supervision that
  cancels pending RPCs and fails later calls fast, a host content store
  with externalization and chunked host/content/read, and per-point
  intercept timeouts (5s input/tool/permission, 30s slow family, manifest
  override clamped to 60s).
- internal/extension SessionExtensions: per-session private plugin state
  at <session>.extensions.json, keyed by plugin ID, 1 MiB per-plugin cap,
  atomic writes, corrupt-file tolerant, never touches the session JSONL.
- boot: BuildResult.Extensions carries the sidecar Manager; the RuntimeSet
  is chained into controller cleanup (like LSP) so sidecars live exactly
  as long as their controller generation and Rebuild retires only the old
  set; required-runtime start failures and replacement-slot conflicts fail
  the build, optional failures degrade to warnings. No runtime packages
  installed means zero sidecars and a byte-identical golden surface.

Verification: go test ./internal/extension/... (sidecar also -race) and
go test ./internal/boot pass incl. golden baselines; extension protocol
generator -check green; re-exec fake-sidecar tests cover handshake
variants, capability violations, early-traffic poisoning, bounded
shutdown, mid-intercept crash, content-ref round trips, stderr redaction,
and boot end-to-end start/stop; gofmt/go vet clean; no new dependencies.
2026-08-03 19:54:56 +08:00
SivanCola af71a90ff7 fix(cli): finish successful help handling
Problem:
Explicit help still returned status 2 for nested commands whose positional arity was checked before FlagSet parsing, while successful FlagSet help was written to stderr.

Root cause:
The shared reporter reused the FlagSet error output and could only see help after Parse; remote add, remote serve, and named subagent commands short-circuited before that boundary.

Fix:
Route successful help to stdout, recognize help within required positional prefixes before arity validation, and expand end-to-end coverage for help and error streams.

Verification:
- go test ./... -count=1
- go test -race ./internal/cli -count=1
- go vet ./...
- scripts/check-cache-impact.sh on all touched CLI files
- git diff --check
2026-08-03 19:16:27 +08:00
SivanColaandSivanCola 90a8362bb4 fix(stats): complete usage accounting paths
Problem:
Remote Workbench usage was written on the SSH target, Guardian reviews kept only the last model call, external child models could be misattributed, and stale frontend requests could restore obsolete data.

Root cause:
Accounting followed the remote controller filesystem instead of the local broker, review usage used a last-value slot, identity lookup ignored the authoritative resolver catalog, and incomplete or failed filters did not invalidate main-panel generations.

Fix:
Persist brokered remote usage and turn completions in Desktop state, aggregate every Guardian call including compaction, resolve child and capability-router identities through the resolver, and clear/invalidate stats on every filter transition.

Verification:
- go test ./internal/guardian ./internal/boot ./internal/remote/workbench/runtime ./internal/stats
- go test ./... -run focused remote statistics tests (desktop)
- pnpm test:usage-stats
- pnpm typecheck

Co-authored-by: SivanCola <[email protected]>
2026-08-03 19:14:56 +08:00
SivanColaandskyzhao1223 fb9af7d3df fix(cli): accept remote connect flags around host name
Problem:
Go's flag parser stops at the first positional argument, so the documented remote connect <name> [flags] form rejected flags placed after the host name.

Root cause:
remoteConnectCLI parsed the complete argument tail as one standard-library FlagSet and required exactly one leftover positional value.

Fix:
Extract a focused remote connect syntax parser, accept both name-first and flags-first forms, preserve the -open and remote open aliases, and carry deferred parse errors through the shared command flag reporting contract.

Verification:
- go test ./...
- go test -race ./internal/cli -run 'Test(ParseCommandFlags|ParseRemoteConnectSyntaxFlagOrder|RemoteConnectSyntaxUsesSharedFlagContract)' -count=1
- go vet ./...
- git diff --check

Co-authored-by: skyzhao1223 <[email protected]>
2026-08-03 18:56:03 +08:00
SivanCola db3b4ccbcb fix(cli): report flag parsing errors consistently
Problem:
Malformed pflag arguments could exit with status 2 without explaining the error, while subcommand help was treated as a failure.

Root cause:
Command entry points handled ContinueOnError results independently. pflag does not emit parse errors in this mode, and neither flag implementation's help sentinel was distinguished.

Fix:
Add a shared parser wrapper for standard flag and pflag, route all direct CLI FlagSet parsing through it, and preserve upgrade parser purity while returning help successfully.

Verification:
- go test ./...
- go test ./internal/cli ./cmd/reasonix -count=1
- go vet ./...
2026-08-03 18:56:03 +08:00
SivanCola b36a7e3d54 Merge pull request #7313 from esengine/docs/review-v1.19.4-final
docs(release): refresh v1.19.4 notes / 补充 1.19.4 发布说明
2026-08-03 18:54:19 +08:00
SivanCola 4727e045d8 docs(release): refresh v1.19.4 notes
Cover the decision disclosure and conditional React Hook crash fixes that landed after the initial release-note review. Keep the stable record bilingual and bound to the newly reviewed Desktop behavior.
2026-08-03 18:53:17 +08:00
SivanCola 10a6d8c23a Merge pull request #7300 from SivanCola/fix/decision-long-text-disclosure
fix(desktop): disclose long decision descriptions / 完善长文本决策说明
2026-08-03 18:51:42 +08:00
SivanCola b74121b60f Merge pull request #7310 from SivanCola/fix/react-hook-order
fix(desktop): prevent conditional hook-order crashes / 防止条件 Hook 顺序崩溃
2026-08-03 18:48:58 +08:00
SivanCola fb8a8afec1 Merge pull request #7294 from esengine/release-notes/v1.19.4
docs(release): prepare v1.19.4 bilingual notes / 准备 v1.19.4 中英更新日志
2026-08-03 18:42:04 +08:00
SivanCola 0e2b986f11 fix(desktop): prevent conditional hook-order crashes
Problem:
ProviderEditor and WorkspacePanel could render fewer hooks behind early-return branches, leaving later state transitions vulnerable to React error #310.

Root cause:
Three provider memo hooks followed the built-in editor return, while workspace code-search hooks followed the closed-panel return. The frontend also had no automated rules-of-hooks gate.

Fix:
Keep every hook above the relevant early return, add built-in-to-custom and closed-to-open regression coverage, and run eslint-plugin-react-hooks during production builds.

Verification:
- pnpm lint:hooks
- pnpm exec tsx src/__tests__/provider-editor-model-picker.test.tsx
- pnpm exec tsx src/__tests__/workspace-changes-errors.test.tsx
- pnpm test:typecheck
- pnpm test
- pnpm build
- git diff --check
2026-08-03 18:39:06 +08:00
SivanCola be830422cd Merge pull request #7307 from SivanCola/fix/e2e-fixed-suite-budget
fix(e2e): budget the complete fixed suite / 为完整固定套件校准预算
2026-08-03 18:38:05 +08:00
Li-Charles-One 78f955923a fix(creation): slim Windows title strip to 40px
Match window controls, topicbar, and dock tools height so caption buttons
are not taller than the title strip. Drop leftover vertical offsets.
2026-08-03 18:37:09 +08:00
SivanCola 2b16d4242b fix(stats): harden attribution and persistence
Problem:
Auxiliary model usage could be unattributed, empty stats responses exposed null arrays, torn JSONL tails consumed the next record, and top-provider ranking followed the top model.

Root cause:
The router and reviewer events omitted canonical model refs, the disabled query path returned nil slices, appends did not repair record boundaries, and provider ranking reused model ordering.

Fix:
Propagate canonical model refs through both auxiliary paths, centralize empty-array query responses, serialize appends under the existing cross-process file lock with torn-tail repair, and rank providers from aggregate totals. Add focused regression coverage for each contract.

Verification:
- go test -race ./internal/stats ./internal/capability ./internal/recovery
- go test ./...
- cd desktop && go test ./...
- go vet ./...
- cd desktop && go vet ./...
- cd desktop/frontend && npm test -- --run
- cd desktop/frontend && npm run build
2026-08-03 18:36:43 +08:00
SivanCola d22d871238 docs(release): sync v1.19.4 notes with main
Scope:
Bring the reviewed release-notes branch onto the latest main-v2 after the E2E runner repair, issue-label workflow fix, Token Rhythm provider integration, and CLI continuation shorthand repair.

Verification:
Release note validation and the exact-candidate CI will run again on the updated branch.
2026-08-03 18:28:02 +08:00
SivanCola d4fffb5cdf docs(release): include latest v1.19.4 changes
Scope:
Add the Token Rhythm provider and model-aware reasoning work from PR #7301, restore the CLI continuation shorthand fix from PR #7223, and credit the new contributor.

Review:
Refresh the bilingual title, summary, highlights, New section, and Fixed section without changing the reviewed Stable identity or migration guidance.

Verification:
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
node scripts/release-notes.mjs render --version 1.19.4 --output /tmp/reasonix-1.19.4-zh.md
git diff --check
2026-08-03 18:27:52 +08:00
SivanCola c6ddaab47a Merge pull request #7223 from Lxiny-zy/fix/cli-c-shorthand-flag
fix(cli): make -c shorthand resolve for --continue
2026-08-03 18:25:36 +08:00
SivanCola 2fec84022f merge: integrate latest main-v2 into decision disclosure fix
Integration:
Merge main-v2 at f63bee144 after the Token Rhythm, unattended E2E, desktop icon repair, and remote runtime fixes landed.

Conflict resolution:
Keep the 2256 KiB raw initial-asset ceiling from the decision-disclosure branch; the combined production build measures 2255.3 KiB. Preserve both the decision disclosure strings and the new Token Rhythm and GLM locale entries.

Verification:
Passed the full frontend test suite and the production build with all bundle budgets on the integrated tree.
2026-08-03 18:23:40 +08:00
SivanCola 727ac27608 feat(extension): Plugin Manifest v1 with prompts, plugin themes, and full-trust install display
Stage 4 of the unified extension kernel / Extension Protocol v1 plan.

Problem: reasonix-plugin.json had no way to declare prompts, themes, or a
code runtime, no strictness against typos, and no path-safety beyond
lexical checks; install previews gave no signal that a code extension runs
with full trust.

Fix:
- Manifest v1 (apiVersion reasonix.io/plugin/v1): strict decoding with
  DisallowUnknownFields at every level so typos fail with the field path;
  unknown major versions rejected. v1 may mix legacy top-level fields with
  contributes (skills/agents/commands/prompts/hooks/mcpServers/themes):
  identical paths dedupe, same-key-different-definition is a manifest
  error naming the key. Prompts are a new path-list resource exposed as
  /<plugin>:<name> via the existing plugin-command semantics; themes are
  root-confined globs matched against .reasonix-theme files. v1 path
  safety rejects traversal, absolute paths, symlink escapes, and
  non-regular theme files; missing paths are warnings. Manifests without
  apiVersion parse byte-for-byte as before (legacy compat gates green).
- runtime declaration: exec-form command/args/env (never shell-interpreted)
  with required/priority/intercepts/replaces/capabilities validated against
  the extension kernel's interceptor points, replacement slots, priority
  bounds, and {interceptors,strategies,providers,ui}. pluginpkg cannot
  import internal/extension (extension->hook->pluginpkg cycle), so the
  validator lists are duplicated with a sync test pinning them equal.
- Full-trust disclosure: install plans carry RiskHigh + a FULL TRUST
  reason plus omitempty RuntimePlanInfo (command, intercepts, replaces,
  capabilities) that old consumers ignore; CLI plugin show/doctor and
  capdiag render prompts/themes counts and the FULL TRUST block; the
  Desktop install preview renders a danger-styled FULL TRUST section with
  the command line, interceptors, replacement slots, capabilities, and the
  session/environment/permission risk statement. Install/update/replace/
  --link continue to imply full trust with no second confirmation.
- Desktop plugin themes: plugin themes are discovered from enabled v1
  packages, validated by the existing v2 theme validator, and shown
  read-only (never copied into the user theme dir) with IDs
  plugin:<plugin>:<theme>; the internal pack ID regex is unchanged.
  Activating a plugin theme persists the full ID; when the plugin is
  disabled or uninstalled, rendering falls back to the base style while
  the ID is preserved byte-for-byte in desktop-theme-state.json so a
  reinstall of the same plugin restores the theme (non-plugin unknown IDs
  keep the old clear-and-save behavior; legacy state files load
  unchanged). Theme assets serve through the existing content-addressed
  route with digest re-verification.

Verification: go test ./internal/pluginpkg ./internal/installsource
./internal/capdiag ./internal/cli ./internal/extension pass (legacy
compat gates unmodified); cd desktop && go test . passes (plugin theme
discovery/fallback-preserve/restore/read-only/asset-route tests);
desktop frontend pnpm typecheck and pnpm test pass; docs/THEME_PACK.md
updated in both languages; gofmt/go vet clean; no new dependencies.
2026-08-03 18:22:10 +08:00
SivanCola 5e18ab64ba fix(desktop): harden decision disclosure controls
Problem:
Keyboard activation of long-description disclosures bubbled into global decision shortcuts, which could submit the current Ask answer, approve a tool call, or run the clear-context default. Decision cards also capped body and actions independently, so their combined height could still exceed the viewport.

Root cause:
The shared disclosure button did not own its activation key events, and PromptShelf had no single viewport-bounded scrolling owner for the complete decision content.

Fix:
Handle Enter and Space inside the shared disclosure control, wrap prompt content in one bounded internal scroller, keep confirmation footers outside that scroller, and remove nested body/action scroll regions.

Verification:
Passed frontend typecheck, CSS checks, focused Ask and decision-surface tests, the full frontend test suite, production build and bundle budgets, plus an in-app browser long-text and keyboard interaction check.
2026-08-03 18:20:55 +08:00
zhaotian1 d7addef4d8 fix(agent): keep subagent list CreatedAt as start time
Subagent transcripts are first saved on completion, so BranchMeta.CreatedAt
was backfilled with the save moment. Seed the sidecar from run.Meta.CreatedAt
before Session.Save so history shows when the subagent started.
2026-08-03 18:17:57 +08:00
SivanCola f63bee1444 Merge pull request #7301 from SivanCola/codex/token-rhythm-provider-glm-effort
feat: add Token Rhythm provider with model-aware reasoning
2026-08-03 18:15:20 +08:00
SivanCola dfa11ead16 fix(e2e): budget the complete fixed suite
Problem:
The calibrated 400k-token cap was exhausted after three successful baseline tasks, so the final two scenarios were skipped and the release gate could never complete.

Root cause:
Current model and cache behavior uses roughly 425k tokens for the first three tasks while keeping provider cost low, but both the harness default and workflow override retained the older 400k limit.

Fix:
Raise the bounded fixed-suite budget to 800k in the harness and workflow, and add a regression contract for the five-task baseline. Failure or skipping still fails the workflow.

Verification:
go test ./cmd/e2ebench -count=5
go vet ./cmd/e2ebench
go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/e2e-bot.yml
bash scripts/release-workflows.test.sh
2026-08-03 18:14:05 +08:00
YHH c5c5557cf8 fix(ci): label issue version from the reported version, not the dropdown (#7292)
Closes #2549.

The dropdown is a choice and "v2 — Go rewrite (1.x)" sits first, so reporters
running 0.x pick it. `Exact version` is pasted from `reasonix --version`, so
when it parses it is the better evidence: 0.x is the TypeScript line, 1.x the
Go rewrite. A value that does not parse — a commit sha, "main-v2" — leaves the
dropdown to decide, since a wrong guess is worse than the status quo.

Replaying both rules over all 1161 open issues: 11 are labelled differently,
every one of them v2 -> v1, and every one currently carries the wrong label.
Reported versions on those range from 0.3.0 to 0.53.2. Nothing moves the other
way, so the cross-check only fires where the evidence is unambiguous.

This matters beyond tidiness: filtering the tracker by `v2` is how the backlog
gets triaged, and those 11 are legacy reports sitting in that filter.

The workflow only fires on opened/edited, so existing mislabels are corrected
separately.
2026-08-03 18:10:25 +08:00
SivanCola 15ed6bc8db Merge pull request #7303 from SivanCola/fix/e2e-unattended-writes
fix(e2e): allow unattended benchmark writes / 允许无人值守基准写入
2026-08-03 18:09:41 +08:00
SivanCola 4cfe58884d fix(desktop): adapt usage charts to active theme
Problem:
Model bars and donut segments used a fixed 20-color palette, so theme and base-style changes did not update them and several colors had insufficient light-theme contrast. The PR's browser mock also pushed the initial bundle just beyond its raw-size budget.

Root cause:
The palette was stored as literal final hex colors in React while the rest of the statistics visuals used semantic CSS variables. The empty browser mock included provider aggregates the panel does not consume.

Fix:
Blend stable model seed hues with the active accent and foreground, including overflow colors beyond the primary palette. Add regression coverage for primary and overflow theme adaptation, and keep the browser mock limited to fields the panel reads.

Verification:
pnpm test:usage-stats
pnpm typecheck
pnpm check:css
pnpm build
Browser: Graphite dark/light and Aurora light; no console errors
2026-08-03 18:08:59 +08:00
SivanCola b0f57300a1 fix: preserve GLM reasoning protocol in desktop settings
Problem: Desktop settings normalized an explicit GLM reasoning protocol to auto and could erase it when saving a provider.\n\nRoot cause: The frontend protocol whitelist and localized selector labels were not extended when the backend GLM enum was introduced.\n\nFix: Add GLM to the settings whitelist, selector labels, locale contracts, and provider-view type documentation. Cover normalization and locale availability with focused regressions.\n\nVerification:\n- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx\n- pnpm exec tsx src/__tests__/startup-settings-contract.test.ts\n- pnpm typecheck\n- pnpm build
2026-08-03 18:05:36 +08:00
SivanCola f18c137ff5 fix(e2e): allow unattended benchmark writes
Problem:
The real-provider benchmark ran headlessly without an automatic writer posture and without a usable Linux sandbox backend, so valid file edits were denied and the suite exhausted its token budget.

Root cause:
The harness launched reasonix run in its default Ask mode, while the workflow did not install bubblewrap on the runner.

Fix:
Launch fixed-suite tasks with --auto and install plus verify bubblewrap before running the benchmark. Explicit ask/deny rules and the sandbox boundary remain enforced.

Verification:
go test ./cmd/e2ebench -count=5
go vet ./cmd/e2ebench
go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/e2e-bot.yml
bash scripts/release-workflows.test.sh
2026-08-03 17:55:50 +08:00
SivanCola 689351a497 docs(release): complete v1.19.4 notes
Summary:
Add the merged native icon repair and Remote profile resync fixes to the reviewed bilingual release record. Restore the complete categorized change list and remove generated version-title duplication and bot attribution.

Testing:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- node scripts/release-notes.mjs render --version 1.19.4
- git diff --check

Privacy:
- Scanned source and rendered notes for credentials and private paths.
2026-08-03 17:49:57 +08:00
czy 6c6ae6f642 fix(task-monitor): complete claim and symlink safeguards 2026-08-03 17:49:13 +08:00
SivanCola 8fd5c1f6e2 fix(desktop): clarify usage statistics tab label
Problem:
The usage statistics subtab reused the generic model usage heading and appeared as 使用模型, which described model selection instead of statistics.

Root cause:
The subtab and the model configuration section shared settings.modelUsage even though they represent different concepts.

Fix:
Add a dedicated settings.modelTab.stats locale key for English, Simplified Chinese, and Traditional Chinese, and use it only for the statistics subtab.

Verification:
pnpm test:usage-stats
pnpm typecheck
Browser DOM verification confirmed the 用量统计 tab renders and activates with charts.
2026-08-03 17:49:10 +08:00
github-actions[bot] 23aadfb6e0 docs(release): prepare v1.19.4 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-08-03 09:47:51 +00:00
SivanCola 26e004f4ba Merge remote-tracking branch 'origin/main-v2' into docs/review-v1.19.4-r2 2026-08-03 17:44:34 +08:00
SivanCola b41fc3cb72 Merge pull request #7299 from SivanCola/fix/e2e-provider-credential-path
fix(e2e): use isolated Reasonix home / 使用隔离的 Reasonix Home
2026-08-03 17:44:09 +08:00
SivanCola 642f937ab4 feat: add Token Rhythm provider with model-aware reasoning 2026-08-03 17:41:01 +08:00
SivanCola 3d35be9099 Merge pull request #7296 from SivanCola/fix/desktop-icon-self-repair
Fix desktop shortcut icons after updates / 修复更新后的桌面快捷方式图标
2026-08-03 17:40:15 +08:00
SivanCola a77ba2e509 Merge pull request #7277 from SivanCola/fix/remote-profile-resync-loop
fix(remote): Stop profile resync loops / 修复远程 Profile 重同步死循环
2026-08-03 17:38:29 +08:00
czy e1e084efc1 fix(ci): resolve task monitor lint and bundle budget 2026-08-03 17:32:42 +08:00
SivanCola 1b0dbe093e fix(desktop): disclose long decision descriptions
Problem:
Long decision descriptions could overlap adjacent options or grow approval surfaces beyond the viewport, while only Ask exposed a complete-text path.

Root cause:
Decision rows mixed fixed-density styling with surface-specific overflow behavior, and recovery approval removed the shared height limits.

Fix:
Move description overflow detection and disclosure into a shared PromptAction component, apply three-line summaries and full-text controls by decision risk, preserve dangerous runtime consequences, and bound recovery content with internal scrolling. Raise only the raw initial-asset budget by 6 KiB for the accessible disclosure behavior; gzip, CSS, and chunk budgets remain unchanged.

Verification:
pnpm typecheck
pnpm check:css
pnpm exec tsx src/__tests__/ask-card-layout.test.ts
pnpm exec tsx src/__tests__/decision-surface.test.tsx
pnpm test
pnpm build
Browser checks at 320, 768, 1024, and 1440px
2026-08-03 17:30:11 +08:00
SivanColaandHaoyueQin 9c74e799c3 fix(stats): make usage accounting accurate and bounded
Problem:
Usage stats accepted unbounded ranges, missed synchronous turns and hidden provider retries, and attributed several auxiliary model calls incorrectly. Source changes could also leave stale heatmap data visible.

Root cause:
Accounting was tied to UI TurnDone/Usage event shape, child runtimes propagated raw overrides instead of canonical identities, and stats rows had no request multiplicity. The lazy panel also kept locale resources in the eager bundle.

Fix:
Merge main-v2, add completion accounting capabilities, canonical model attribution, request multiplicity, bounded custom ranges, protocol-audit forwarding, and stale-result clearing. Keep usage panel locale/theme resources lazy and cover the current main sanitizer contract.

Verification:
go test -p 4 -timeout=5m ./...
go test -race -timeout=5m ./internal/stats ./internal/event ./internal/control ./internal/agent ./internal/guardian ./internal/serve
(cd desktop && go test ./...)
(cd desktop/frontend && pnpm test:usage-stats && pnpm typecheck && pnpm build)

Co-authored-by: HaoyueQin <[email protected]>
2026-08-03 17:29:18 +08:00
SivanCola bd27491c11 fix(e2e): use isolated Reasonix home
Summary:
Place both the e2e provider config and credential file under an explicit ephemeral REASONIX_HOME. Keep the checkout-level config only for diff mode.

Why:
The first credential-gate fix used the legacy XDG config directory. Current Linux Reasonix uses ~/.reasonix by default and strictly resolves provider secrets from <Reasonix home>/.env, so the fixed suite still reported missing credentials.

Testing:
- bash scripts/release-workflows.test.sh
- actionlint .github/workflows/e2e-bot.yml
- focused Reasonix home and credential path tests
- git diff --check

Privacy:
- Scanned the public diff for credential and private-path patterns.
2026-08-03 17:28:09 +08:00
SivanCola 42147e9ac0 Merge remote-tracking branch 'origin/main-v2' into docs/review-v1.19.4-r2 2026-08-03 17:24:35 +08:00
SivanCola 5cbf8f60f6 Merge pull request #7297 from SivanCola/fix/e2e-provider-credential-gate
fix(e2e): enforce provider credential gate / 强制真实 Provider 凭据门禁
2026-08-03 17:24:15 +08:00
czy fb7b7f90b0 fix(task-monitor): claim idempotency before control side effects 2026-08-03 17:21:40 +08:00
SivanCola 75e84414ac fix(desktop): avoid rewriting healthy icon integrations
Problem:
The startup repair could rewrite healthy Windows shortcuts and macOS aliases on every launch, overriding a custom Windows icon and causing unnecessary filesystem churn.

Root cause:
The repair validated ownership but did not distinguish stale version-scoped icon references or old alias targets from already healthy integrations.

Fix:
Repair Windows only when IconLocation points at the old versioned desktop executable, and leave healthy macOS aliases untouched when they already resolve to the running bundle.

Verification:
- go test ./...
- go test -race . -run TestRepairMacDesktopAliases|TestDesktopPackagesPreserveNativePlatformLaunchers
- go vet ./...
- Windows amd64 and arm64 cross-compilation
- git diff --check
2026-08-03 17:20:42 +08:00
SivanCola 1bdf903ee8 fix(windows): preserve customized Reasonix shortcuts
Problem:
The startup repair rewrote every existing Reasonix shortcut and could discard user-customized targets, arguments, working directories, or descriptions.

Root cause:
Shortcut ownership was inferred from the filename alone, and the repair replaced all shortcut properties.

Fix:
Verify that the existing target belongs to the current Reasonix installation and update only IconLocation. Preserve unrelated shortcuts and every user-customizable property.

Verification:
- go test ./...
- go test -race . -run TestRepairMacDesktopAliases|TestDesktopPackagesPreserveNativePlatformLaunchers
- go vet ./...
- Windows amd64 and arm64 cross-compilation
- git diff --check
2026-08-03 17:15:27 +08:00
czy e65d344a1d fix(task-monitor): harden file store boundaries 2026-08-03 17:14:17 +08:00
SivanCola 41e0c542ca fix(desktop): repair native shortcut icons after updates
Problem:
Desktop shortcuts and Finder aliases could lose their icons after an update.

Root cause:
Windows shortcut icons referenced version-scoped executables removed by retention, macOS bundle replacement could leave Finder aliases pointing at an old bundle, and Linux desktop caches could remain stale after package upgrades.

Fix:
Repair existing Windows and macOS desktop integrations on startup, keep Windows shortcut icons on the stable launcher, and refresh Linux desktop caches from the package post-install script.

Verification:
- go test ./...
- go test -race . -run TestRepairMacDesktopAliases|TestDesktopPackagesPreserveNativePlatformLaunchers
- go vet ./...
- Windows amd64 and arm64 cross-compilation
- Wails macOS arm64 production build
- Linux nFPM post-install package smoke test
2026-08-03 17:10:06 +08:00
SivanCola a14b28d1f7 fix(e2e): enforce provider credential gate
Summary:
Write the repository provider secret to the ephemeral global Reasonix credential file used by the candidate process. Fail fixed-suite e2e runs when any task fails or is skipped while still posting the report.

Why:
The e2e workflow injected the key only as a process variable, which the credential boundary intentionally ignores. The harness then reported 0/5 but returned success, creating a false-green release signal.

Testing:
- bash scripts/release-workflows.test.sh
- actionlint .github/workflows/e2e-bot.yml
- git diff --check

Privacy:
- Scanned the public diff for credentials, local user paths, and private temporary paths.
2026-08-03 17:08:11 +08:00
YHH 8675031b19 fix(config): stop tests reading the developer's real credentials (#7290)
Closes #2419.

RunWithIsolatedUserState redirects HOME, USERPROFILE, XDG_* and AppData, which
covers every location addressed by a path. The OS keyring is not one of those:
it is per-user and has no environment variable in front of it, so legacy
credential migration walked straight past the sandbox into the real store.

On a machine with credentials saved, TestMigrateImportsLegacyStateHomeDotEnv-
Credentials fails by printing the developer's actual API key where its own
fixture was expected. On a clean CI runner the keyring is empty, so it passes —
the suite only breaks for people who use the tool they are working on, which is
why the failing package in #2419 stayed unexplained for two months.

Substituting the lookup once in TestMain makes isolation the default for the
package; the three cases that exercise the lookup already install their own.

Two things verify it, because they fail in different places:

  scripts/credential-leak-check.mjs runs the suite and flags any credential
  printed that appears nowhere in the repo's sources — a value the tests did not
  write can only have come from the machine. It reported one leak before this
  change and none after, and it is the only check that can observe the bug at
  all, since it needs a populated keyring.

  TestKeyringLookupStaysOutOfTheRealStore asserts the substitution itself, so it
  holds on an empty CI keyring where the leak is invisible. Removing the
  substitution turns it red.

Neither prints a credential; the tool reports a SHA-256 prefix and the guard
reports a byte count.
2026-08-03 17:03:43 +08:00
czy 31020f5e98 fix(desktop): scope task monitor to active workspace 2026-08-03 17:01:37 +08:00
SivanCola 56c7f4ad74 docs(release): review updated v1.19.4 notes
Summary:
Refine the regenerated bilingual release notes after the Creation UI merge. Keep the dashboard comparison tied to its live-query evidence and remove automated accounts from contributor credits.

Testing:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- node scripts/release-notes.mjs render --version 1.19.4
- git diff --check

Privacy:
- Scanned rendered and source release notes for credentials, local paths, and private identifiers.
2026-08-03 17:00:43 +08:00
github-actions[bot] 5482b42666 docs(release): prepare v1.19.4 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-08-03 08:59:10 +00:00
SivanCola 46feeb71af fix(desktop): synchronize workbench runtime epochs
Problem:
Remote Workbench events carried the Host runtime epoch while the shared frontend surface could still retain its Local epoch, causing valid Remote events to be discarded.

Root cause:
Remote Meta and tab projections did not publish the Remote runtime identity, and initial pending-prompt replay could overtake the runtime:rebuilt transition after subscription.

Fix:
Project the authoritative runtime epoch through Meta and ListTabs, publish the initial Remote epoch inside the committed subscription fence, and restore the Local epoch on every switch or disconnect path. Add frontend and Desktop regressions for Local-to-Remote event admission and Local restoration.

Verification:
- cd desktop && go test ./...
- cd desktop && go vet ./...
- cd desktop && go test -race . -run Workbench -count=1
- go test -race ./internal/remote/workbench/client ./internal/remote/workbench/runtime -count=1
- cd desktop/frontend && pnpm typecheck && pnpm test:typecheck
- cd desktop/frontend && pnpm test:remote
- cd desktop/frontend && pnpm exec tsx src/__tests__/pending-prompt-stale-status.test.tsx
2026-08-03 16:52:14 +08:00
czy a7108d00f1 fix(task-monitor): reconcile expired runtime leases 2026-08-03 16:49:49 +08:00
czy aeebc5100e fix(task-monitor): fail closed without runtime owner 2026-08-03 16:44:57 +08:00
SivanCola c9da184528 Merge pull request #7276 from Li-Charles-One/feat/creation-composer-mode-pills
feat(creation): 精简输入模式 pill 并将标题栏文件夹前置
2026-08-03 16:39:58 +08:00
SivanCola 4eeeb0a8eb Merge pull request #7289 from esengine/release-notes/v1.19.4
docs(release): prepare v1.19.4 bilingual notes / 准备 v1.19.4 中英更新日志
2026-08-03 16:30:33 +08:00
SivanCola 4ea9ceee10 docs(release): refine v1.19.4 notes
Summary:
Polish the reviewed bilingual release notes around the user-facing 1.19.4 outcomes.

Corrections:
Remove the duplicated version title, clarify the production dashboard comparison, describe Scoop directory junctions precisely, and keep automated accounts out of contributor credits.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- node scripts/release-notes.mjs render --version 1.19.4
- git diff --check
2026-08-03 16:27:45 +08:00
github-actions[bot] 0d5b471f8c docs(release): prepare v1.19.4 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-08-03 08:25:09 +00:00
czy 7a2fc9afdf fix(task-monitor): avoid sessionless task ID collisions 2026-08-03 16:21:20 +08:00
czy 3b8e289cf5 fix(task-monitor): namespace recorder IDs by session 2026-08-03 16:20:01 +08:00
SivanCola df05ddd1bb Merge pull request #7287 from SivanCola/test/scoop-desktop-launch-gate
test(windows): gate Scoop desktop launch path / 增加 Scoop 桌面启动门禁
2026-08-03 16:19:17 +08:00
SivanCola ec8e8efba3 fix(creation): simplify hover timer state
Problem:
The PR merge exceeded the desktop initial raw bundle budget by 0.2 KiB.

Root cause:
Each Creation hover menu kept separate enter and leave timer refs even though those timers are mutually exclusive.

Fix:
Reuse one timer ref per menu while preserving the existing hover delays and close guards.

Verification:
- frontend test suite
- frontend typecheck and test:typecheck
- CSS checks
- frontend production build

Co-authored-by: SivanCola <[email protected]>
2026-08-03 16:12:20 +08:00
czy f2b83409ac fix(cli): preserve task machine interface 2026-08-03 16:11:19 +08:00
SivanCola a2073c6553 fix(remote): fence late control-plane responses
Problem:
Late no-op profile responses and epoch-less Desktop event projections could cross a runtime replacement boundary and restore or mutate stale client state.

Root cause:
The Desktop client allowed multiple mutations in flight and copied unchanged profile results back into authority, while the Remote adapter discarded SessionEvent.RuntimeEpoch.

Fix:
Serialize client mutations and session selection, keep unchanged profile results side-effect free, preserve event runtime epochs, and add deterministic regression coverage.

Verification:
- go test ./...
- go test -race ./internal/remote/workbench/client ./internal/remote/workbench/runtime -count=1
- (cd desktop && go test ./...)
- (cd desktop && go test -race . -run RemoteWorkbench -count=1)
- (cd desktop/frontend && pnpm typecheck)
- Windows/ARM64 integration-tag test binary cross-compile
2026-08-03 16:05:45 +08:00
HaoyueQin 9116a3b304 feat(stats): add per-day usage statistics panel with cache hit-rate
Adds a "Usage Statistics" subtab to the desktop Settings → Models page:
time-range presets (7/14/30/90 days or custom), token/session/request
cards, active-days + average cache hit-rate + top-model cards, a fixed
40-week activity heatmap with per-day cache hit rate, a stacked per-day
token trend overlaid with a smooth cache hit-rate curve, and a per-model
donut + list. All charts are hand-drawn SVG; component CSS loads on demand
to stay inside the initial bundle budget.

All frontends (desktop, cli, serve, bot, remote workbench) record usage
through one passthrough stats.Recorder mounted at the boot layer after
event.Sync. event.Usage carries a new ModelRef field so tokens attribute to
the exact model; event.TurnDone rows count completed turns. Records are
appended as daily JSONL under the user state root (<state root>/stats/), so
usage survives app upgrades and accumulates from the day the feature ships.
The desktop App.UsageStats binding serves range aggregates, and the panel
can split totals by entry point.

Why a dedicated recorder: usage accounting is observational — the stats
sink must never alter the event stream, and a bad disk state must never
interrupt a turn, so writes are best-effort (matching the telemetry append
idiom).
2026-08-03 16:05:13 +08:00
SivanCola fe319846a1 test(windows): gate Scoop desktop launch path
Problem:
The Windows release checks only exercised launcher path resolution in a unit test, so packaging regressions across Scoop installation, its current junction, and Start Menu shortcuts could still ship.

Root cause:
CI did not install a Reasonix fixture through the real Scoop CLI or launch the generated shortcut.

Fix:
Add an isolated Windows integration test that builds a versioned fixture, installs it with Scoop v0.5.3, verifies the current junction and shortcut contract, and launches the desktop marker through that shortcut. Run it on every Windows CI leg.

Verification:
- go test ./internal/desktoplauncher/... ./internal/installlayout/...
- go vet ./internal/desktoplauncher/... ./cmd/reasonix-launcher
- GOOS=windows GOARCH=amd64 go build ./cmd/reasonix-launcher
- actionlint .github/workflows/ci.yml
- git diff --check
2026-08-03 16:04:05 +08:00
SivanCola 9fc382af24 fix(creation): avoid closing unopened hover menus
Problem:
Brief pointer passes over the Creation task/profile pills could enter a closing-only state even though their menus never opened.

Root cause:
The hover-leave handlers cancelled the pending open timer but always scheduled a close timer, which later rendered the popover's closing phase and open trigger styling.

Fix:
After cancelling pending hover timers, schedule a close only when the corresponding menu is actually open or already closing. Add deterministic timer-driven coverage for both pills and preserve sustained-hover opening.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/composer-run-strip.test.tsx
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend test
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-03 16:01:51 +08:00
SivanCola 455592d9ef feat(extension): fail-atomic runtime reload across CLI, Desktop, and ACP
Stage 3 of the unified extension kernel / Extension Protocol v1 plan.

Problem: there was no shared runtime rebuild entry. Desktop settings
changes hand-rolled a fail-atomic controller rebuild; the CLI could only
hot-swap slash commands; ACP had no reload surface at all. The extension
protocol needs one build entry that returns Controller + Snapshot +
RuntimeSet, and one reload semantic every frontend shares.

Fix:
- boot.BuildRuntime returns BuildResult{Controller, Snapshot, RuntimeSet};
  boot.Build keeps its exact signature as a compat wrapper. The snapshot is
  frozen from the same in-hand objects boot assembled (never re-derived),
  with a process-wide monotonic generation; assembly degrades to a warning
  rather than failing a boot that would have succeeded before.
- extension.Builder gains ConflictCollect: legacy resources keep today's
  tier-winner shadow behavior with conflicts recorded on
  Snapshot.Diagnostics(); strict ConflictFail remains the default and is
  what v1 extensions will use.
- boot.Rebuild builds a replacement runtime fail-atomically: on any build
  or migration failure the new controller is released and the old one keeps
  working untouched. It migrates history (same session file, fresh system
  prompt spliced in), approval grants, tool-approval/plan mode, goal and
  recovery state (new public control.CarryRecoveryFrom), and lifecycle; it
  never closes the old controller — frontends own the swap.
- One reload semantic in all three frontends: busy (turn/rebuild in
  flight) coalesces to exactly one queued reload drained when idle; idle
  rebuilds, swaps atomically, and only then retires the old controller.
  CLI gains /reload (TUI reuses its model-switch swap path); ACP gains the
  vendor method _reasonix.io/session/reloadExtensions (the ACP v1
  _<vendor>/ namespace is wire-required, so not the bare reasonix/... form)
  advertised in initialize _meta; Desktop gains the bound ReloadRuntime
  action reusing the existing mutation lock, deferred-rebuild queue, and
  runtime:rebuilt fence, plus a command-palette entry (en/zh/zh-TW).

Verification: go test ./internal/boot ./internal/cli ./internal/acp
./internal/extension ./internal/control pass (extension also under -race);
TestGoldenBaselineNoExtensions green unmodified; cd desktop && go test .
passes; desktop frontend pnpm typecheck and pnpm test pass; gofmt/go vet
clean; no new dependencies.
2026-08-03 15:42:37 +08:00
YHH a91c6c2eef fix(crash-dashboard): put preferences back on the 30-day window (#7284)
With the rollup from #7279 populated, the 30-day view is the fast one: reading
it off metric_user_rollup measures 68ms against production, versus 5,667ms for
the live 7-day query it was defaulted to in #7271. Leaving the default at 7 days
now means defaulting everyone to the slower path and the less representative
window.

That also retires windowExplicit. It existed to stop a module default from
following the reader into other modules; with no module default left it is
always equivalent to windowDays === 7, so it is dead weight.

A precomputed window can go stale silently if the rollup cron stops, so the
panel heading now carries the oldest computed_at across the rollup — the cursor
refreshes a slice at a time, so that is how far behind the least recently
recomputed signal is.
2026-08-03 15:41:32 +08:00
SivanCola fcb12ed886 Merge pull request #7259 from SivanCola/fix/silent-missing-reasoning-recovery
fix(agent): silently recover missing tool-call reasoning / 静默恢复工具调用推理缺失
2026-08-03 15:38:29 +08:00
SivanCola b528d5f416 Merge pull request #7280 from esengine/release-notes/v1.19.4
docs(release): 准备 v1.19.4 中英更新日志
2026-08-03 15:31:32 +08:00
SivanCola 88dbccb552 docs(release): clarify Windows launcher validation
Summary:
Describe the Scoop junction failure as install-root validation so the 1.19.4 notes match the launcher error boundary in #7275.

Verification:
Validated the release catalog, ran the release-note tests, rendered the reviewed bilingual notes, and scanned the public text for private data.
2026-08-03 15:28:42 +08:00
YHH 4f2fb94998 feat(crash-worker): precompute the 30-day preferences deduplication (#7279)
Closes #7272.

The preferences module's 30-day COUNT(DISTINCT install_id) spans ~28M rows of
metric_users and D1 abandons it mid-query, so #7271 pinned the module to the
7-day window it can still finish. This computes the 30-day answer ahead of time
instead.

It has to be computed, not derived: a cross-day COUNT(DISTINCT) cannot be summed
from per-day totals, because an install active on twelve days would count twelve
times. Benchmarks ruled out the alternatives — storing one JSON row per install
per day and expanding it with json_each took 103s at production shape, worse
than what we have, because parsing JSON per row costs more than the I/O it
saves.

What does work is the query split by signal: measured against production, one
signal over the 30-day window is ~1s. An hourly cron walks the signal list eight
at a time behind a cursor, so a run stays well inside its CPU budget and a full
pass takes about seven hours. Each signal's rows are replaced in a single batch,
so a reader never observes one half-written, and a signal whose query is
abandoned still advances the cursor rather than blocking every signal behind it.

The dashboard reads the rollup for the 30-day desktop window and keeps querying
live everywhere else. Until the first pass completes the panel says the
precompute has not reached every signal yet, rather than showing an empty one.

Both tables are created at runtime, matching how ingest_sentinel_state and the
cli_* tables bootstrap, so no manual migration is needed.
2026-08-03 15:27:06 +08:00
github-actions[bot] 82341f8bf5 docs(release): prepare v1.19.4 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-08-03 07:26:18 +00:00
SivanCola 88feabab21 Merge pull request #7275 from SivanCola/fix/windows-launcher-junction
fix(desktop): resolve Windows launcher junctions / 修复 Windows 启动器目录联接解析
2026-08-03 15:24:04 +08:00
SivanCola c7fc96cda2 merge: integrate main-v2 usage accounting
Problem: The latest main-v2 added failed-stream usage accounting in the same run-loop site wrapped by missing-reasoning recovery, leaving the PR conflicting.

Root cause: The recovery branch returned only an error when a replay was canceled, while the new base expects every failed stream to retain usage for truthful accounting.

Fix: Merge current main-v2 without rewriting branch history, preserve the DeepSeek recovery wrapper and base usage emitter, carry merged first-attempt and retry usage through cancellation, and add a deterministic cancellation regression.

Verification: focused recovery and cancellation tests passed 20 times; go test ./... passed; go test -race ./internal/agent -count=1 passed; two post-merge real DeepSeek transient and persistent recovery rounds passed.
2026-08-03 15:13:02 +08:00
SivanCola 87633b8a17 merge(main-v2): sync remote profile fix with latest base
Context:
Bring the published fix branch onto the current main-v2 without rewriting its public history.

Integration:
Preserve the Remote Workbench profile idempotence, runtime replacement, frontend deduplication, and Windows physical acceptance coverage while incorporating the latest agent and desktop fixes.

Verification:
The merged head will be regenerated and revalidated with protocol, desktop, frontend, race, and physical Windows Remote Workbench tests before opening the pull request.

# Conflicts:
#	desktop/frontend/src/generated/remoteProtocol.generated.ts
#	internal/remote/protocol/schema.generated.json
#	internal/remote/protocol/schema_hash.generated.go
2026-08-03 15:09:32 +08:00
SivanCola 8266d6c8d1 test(agent): accept safe no-retry live fallback
Problem: The credential-gated DeepSeek probe required every stripped reasoning response to produce an identical retry, so a valid visible-text fallback could fail the test even though the runtime stayed safe.

Root cause: The live assertion did not distinguish a retryable tool-only response from a response whose visible text intentionally suppresses retry to avoid duplicate output.

Fix: Validate single tool execution, zero user warnings, and the fallback outcome before treating no-retry responses as alternate provider shapes and continuing the bounded probe.

Verification: focused recovery regressions passed 20 times; the internal agent suite passed; the live-tag race build passed; three real DeepSeek transient and persistent recovery rounds passed.
2026-08-03 15:06:03 +08:00
SivanCola 3ba9825b60 test(desktop): compare junction target identities
Problem:
Windows CI may expose a temporary directory through an 8.3 short path while GetFinalPathNameByHandle returns the equivalent long path.

Root cause:
The regression test compared path strings, so two names for the same Windows directory produced a false failure.

Fix:
Compare the resolved and expected directories with os.SameFile so the assertion verifies filesystem identity across long and short path aliases.

Verification:
Ran the local desktoplauncher tests and cross-compiled the Windows test binary for amd64 and arm64.
2026-08-03 15:05:16 +08:00
SivanCola c50f51b29b Merge pull request #7186 from paradoxSCH/fix/agent-runaway-usage-accounting
fix(agent): guard runaway reasoning usage
2026-08-03 15:02:30 +08:00
SivanCola 3f972b9b8d test(remote): accept wrapped physical user prompts
Problem:
The Windows physical Remote Workbench fixture required the Broker user message to equal the bare prompt, but current prompt assembly prepends the response-language contract.

Fix:
Keep the semantic protection by requiring the final message to have the user role and contain the original acceptance prompt, while allowing stable runtime-owned wrappers.

Verification:
- GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go test -c -tags reasonix_remote_integration ./desktop
2026-08-03 15:01:01 +08:00
SivanCola 66991e1983 fix(desktop): resolve Windows launcher junctions
Problem:
The Desktop launcher fails when Scoop invokes it through the stable current directory junction because the install-root guard receives the unresolved alias.

Root cause:
filepath.EvalSymlinks does not reliably canonicalize Windows directory junctions, and the previous fallback silently retained the junction path.

Fix:
Resolve the running launcher through a Windows file handle with GetFinalPathNameByHandle, normalize DOS and UNC extended paths, preserve the existing non-Windows fallback, and cover the junction path in Windows CI.

Verification:
Ran root and Desktop Go test and vet suites, focused race tests, repeated the unrelated timing-sensitive agent test, cross-compiled Windows launcher tests and binaries for amd64 and arm64, and validated the CI workflow.
2026-08-03 14:59:39 +08:00
SivanCola 6fde1e899b test(remote): exercise idempotent profile over Windows SSH
Problem:
The physical Windows Remote Workbench acceptance covered SSH, tools, history, and reconnects but did not exercise the profile feedback-loop regression from issue #7268.

Fix:
Apply the current collaboration, approval, and empty Goal profile twice over the live Windows OpenSSH connection. Require both calls to return unchanged, preserve the runtime epoch, and keep the subscribed snapshot authoritative before continuing the tool loop.

Verification:
- GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go test -c -tags reasonix_remote_integration ./desktop
- cd desktop && go test . -run profile/runtime focused suite
2026-08-03 14:59:16 +08:00
Li-Charles-One f2aaa68ccd feat(creation): lead topicbar with slim external opener 2026-08-03 14:56:59 +08:00
SivanCola 42e8dfdc48 Merge pull request #7195 from paradoxSCH/fix/provider-model-picker-hooks
fix(desktop): stabilize provider model picker hooks
2026-08-03 14:50:57 +08:00
SivanCola 5f236a5def test(remote): restore Windows physical acceptance build
Problem:
The opt-in Windows Remote Workbench acceptance test no longer compiled after the Windows SSH factory constructor began returning the shared transport.Factory interface.

Root cause:
The fixture still called Windows-specific peer identity and process lifecycle helpers directly on the interface result.

Fix:
Assert the expected concrete Windows factory immediately after construction so the physical fixture can inspect authenticated peer identity and verify SSH process cleanup.

Verification:
- GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go test -c -tags reasonix_remote_integration ./desktop
- cd desktop && go test . -run profile/runtime focused suite
2026-08-03 14:49:53 +08:00
Li-Charles-One 7d114dc2fe feat(creation): slim task/profile pills and open menus on hover
Hide icons/chevrons on 常规/均衡 triggers, tighten text-only pills, and open their menus on hover in Creation only.
2026-08-03 14:40:56 +08:00
SivanCola a1eca07990 fix(remote): stop profile resync feedback loops
Problem:
Remote Workbench could enter an unbounded session/profile/set and session/resync_required loop, making the Windows UI unresponsive.

Root cause:
No-op profile writes still invalidated subscriptions, snapshot refreshes emitted synthetic runtime rebuilds, and frontend profile deduplication followed prompt churn instead of actual runtime identity.

Fix:
Return an explicit unchanged disposition for idempotent profile writes, emit runtime replacement events only for real controller identity changes, serialize profile and session rotations, and deduplicate frontend profile application by runtime identity and profile content. Apply the same no-op discipline to Goal mutations and session rotations.

Verification:
- go test ./internal/remote/protocol ./internal/remote/protocolgen ./internal/remote/workbench/client ./internal/remote/workbench/runtime
- go test ./...
- cd desktop && go test ./...
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm typecheck
- go test -race ./internal/remote/workbench/client ./internal/remote/workbench/runtime
- cd desktop && go test -race . -run profile/runtime focused suite
2026-08-03 14:40:48 +08:00
SivanCola 794c6be461 Merge pull request #7270 from SivanCola/fix/unify-windows-dock-tabs
fix(desktop): align Windows workspace dock tabs / 统一 Windows 工作区标签栏
2026-08-03 14:39:39 +08:00
YHH 11e4c54104 fix(crash-dashboard): default preferences to the window D1 can finish (#7271)
metric_users takes ~1M rows a day, so its 30-day COUNT(DISTINCT install_id)
now spans ~28M rows and D1 gives up on it — measured against production, the
query comes back as a CPU-limit reset. Dropping the window-scan index in
#7258 fixed the tables around it but could not fix this one: the 7-day window
completes in 5.7s over 6.9M rows, the 30-day one does not complete at all.

So the module now defaults to 7 days. An explicitly chosen 30d still runs,
and when it fails it says so: metricUserRows returns null for did-not-complete
rather than folding it into the empty array, which the dashboard was rendering
as its no-metrics-yet state — a timeout reading as nobody using these settings.

A module default must not leak, so windowExplicit tracks whether the reader
picked the window; only a chosen 7d is carried into the other modules' links.

This is a stopgap. The real fix is pre-aggregation, which needs its own
design: a cross-day COUNT(DISTINCT) cannot be summed from daily rollups.
2026-08-03 14:26:19 +08:00
SivanCola cba788f8ce merge: sync main-v2 into task monitor PR
Resolution:
Merge the latest main-v2 without rewriting the contributor branch. Resolve the frontend package conflict by retaining upstream test scripts, migrating Task Monitor tests to the repository's tsx/jsdom harness, and removing stale Vitest configuration.

Integration:
Lazy-load the Task Monitor panel so the updated base remains within production bundle budgets. Keep the Task Monitor component suite in the desktop CI gate.

Verification:
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- cd desktop && go vet ./...
- go test -race ./internal/taskmonitor ./internal/cli ./internal/jobs
- desktop Task Monitor routing tests under race
- pnpm test:terminal
- pnpm test:task-monitor
- pnpm typecheck
- pnpm check:css
- pnpm build
2026-08-03 14:23:14 +08:00
SivanCola a3e2e6f62f fix(agent): cancel guarded reasoning streams
Problem:
The client reasoning guard could return without canceling the provider stream, while automatic DeepSeek output limits also capped thinking-disabled requests and Responses vendor detection accepted substring matches.

Root cause:
Agent stream consumption did not own a child cancellation context, and provider defaults were selected before effective reasoning mode and strict hostname classification were applied.

Fix:
Cancel every provider stream when Agent.stream exits, scope the automatic 32K DeepSeek budget to reasoning-enabled requests while preserving explicit limits, and detect Responses vendors from parsed hostnames. Add focused lifecycle, serialization, and spoofed-host regressions.

Verification:
- go test ./internal/agent ./internal/provider/openai ./internal/provider/responses ./internal/provider/anthropic -count=1
- go test -race ./internal/agent ./internal/provider/openai ./internal/provider/responses -count=1
- go vet ./...
- REASONIX_RELEASE_CACHE_GUARD=1 go test ./internal/agent -run TestReleaseCacheHitGuard -count=1 -v

Co-authored-by: SivanCola <[email protected]>
2026-08-03 14:20:02 +08:00
SivanColaandArdian Nurcahya 4a05f32d85 fix(desktop): normalize provider model metadata
Problem:
Malformed or legacy provider catalog payloads can contain null display fields, which makes model search, provider grouping, and settings rendering unsafe.

Root cause:
The frontend trusted non-null TypeScript bridge contracts at runtime and passed provider/model/name/baseUrl values directly to string operations.

Fix:
Normalize model and provider display metadata at their component ingestion boundaries, preserving the hook-order fix from #7195 while adapting the defensive null handling contributed in #7239.

Verification:
- pnpm test
- pnpm run typecheck
- pnpm exec tsx src/__tests__/provider-editor-model-picker.test.tsx
- pnpm exec tsx src/__tests__/model-switcher-refresh.test.tsx
- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- git diff --check

Co-authored-by: Ardian Nurcahya <[email protected]>
2026-08-03 14:15:41 +08:00
2a2d0e674a fix(agent): require stable reasoning before rearming
Problem: one healthy tool-call turn immediately re-armed silent recovery, so an intermittently broken endpoint could flap into repeated extra requests.

Root cause: the v2 incident state had only missing and resolved watermarks, with no compatible way to retain consecutive healthy progress.

Fix: keep the v2 document version, add optional streak and healthy-watermark fields, require three consecutive healthy turns, and protect in-memory, cross-process, stale-event, duplicate-event, and I/O-failure boundaries.

Verification: focused regressions (20x), go test ./..., go test -race ./internal/agent, go vet ./..., golangci-lint, and the cache guard.

Co-authored-by: orz0219 <[email protected]>

Co-authored-by: Lxiny-zy <[email protected]>
2026-08-03 14:12:36 +08:00
SivanCola 38a226ab6c fix(desktop): align Windows workspace dock tabs
Problem: Classic and Workbench rendered different right-dock tab geometry on Windows, while the independent terminal drawer also appeared as a fifth workspace tab.

Root cause: Workbench compressed its tab spacing separately, Classic reserved native window-control space on an already separated row, and the terminal drawer action was duplicated in the dock tab list.

Fix: Keep four shared workspace tabs, retain terminal access in the topic bar, separate Workbench window controls from the tab row, and share dividers and narrow-width spacing across Classic and Workbench.

Verification: app-chrome-tabs 120/120 passed; workspace-layout 40/40 passed; CSS syntax and z-index checks passed; production frontend build and bundle budgets passed; Windows browser preview showed matching complete tab geometry with no console warnings or errors.
2026-08-03 14:12:26 +08:00
SivanCola ae4e738b84 fix(taskmonitor): separate runtime liveness from task state
Problem:
Task lifecycle state conflated persisted intent with live execution, while Resume only moved failed or stale tasks to queued without restarting work. Desktop control also reused mutable killer routing across sessions.

Root cause:
Snapshots had no runtime-liveness dimension, job IDs were session-local, and ControlService stored a shared killer. Recorder completion could also lose its terminal update after a concurrent CAS write.

Fix:
Add backward-compatible runtime_state evidence, rename Resume to Requeue, route killers by session per call, retry completion CAS conflicts, and expose both state dimensions in Desktop. Add Workbench access and run Task Monitor component tests in CI.

Verification:
- go test ./...
- go vet ./...
- go test -race ./internal/taskmonitor ./internal/cli ./internal/jobs
- cd desktop && go test ./...
- cd desktop && go test -race ./...
- pnpm test:task-monitor
- pnpm typecheck
- pnpm check:css
- pnpm build
2026-08-03 14:10:08 +08:00
paradoxSCHandSivanCola 5dfbbe00ef fix(desktop): stabilize provider model picker hooks
Problem:
The custom provider model picker crashed with React error #310 after an asynchronous model fetch changed the candidate list from empty to populated.

Root cause:
ProviderEditorModelPicker returned before useDeferredValue for an empty candidate list, so consecutive renders called a different number of React hooks.

Fix:
Keep the hook order stable by calling useDeferredValue before the empty-list return, and add a focused regression test for the empty-to-loaded transition.

Verification:
- pnpm exec tsx src/__tests__/provider-editor-model-picker.test.tsx
- pnpm exec tsx src/__tests__/provider-model-refresh.test.ts
- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm run typecheck
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-03 13:49:12 +08:00
SivanCola b297e9616c test(agent): lock non-DeepSeek recovery boundary
Contract: Providers without the DeepSeek tool-call reasoning policy must keep the ordinary tool loop when reasoning content is absent.

Coverage: Assert two provider calls, one tool dispatch, and zero protocol recovery audits.

Verification: go test ./...; focused agent and race tests.
2026-08-03 13:23:15 +08:00
SivanCola e024cbff54 feat(extension): add unified contribution catalog, builder, and immutable runtime snapshot
Stage 2 of the unified extension kernel / Extension Protocol v1 plan.

Problem: runtime assembly (tools, skills, commands, hooks, MCP, providers)
is spread across boot with emergent last-writer-wins shadowing and no
single frozen view of what a turn actually uses. The extension protocol
needs one immutable snapshot per generation that turns, tool batches, and
compaction can pin, with explicit conflict and replacement rules.

Fix: introduce internal/extension, purely additive:
- ContributionSource/Contribution/Catalog: every resource (tool, skill,
  command, prompt, hook, MCP server, provider, theme, UI action,
  interceptor, strategy) is a contribution with a canonical ID, scope tier,
  priority, and registration order; catalog iteration is fully sorted so
  discovery order can never leak into outputs.
- Builder pipeline Discover -> Parse -> Validate -> Resolve -> Assemble ->
  Freeze -> Activate with a documented Activator seam (sidecars arrive in a
  later stage). Resolve keeps the existing tier-winner shadow behavior
  across scopes but fails the build with a ConflictError naming both
  sources when same-tier contributions from different sources collide on
  one canonical ID; hooks and interceptors stay additive.
- Replacement slots (system_prompt, context, provider_request,
  provider_response, compaction, session_policy, permission,
  frontend_events, tool:<name>, provider:<ref>) with single-owner claims;
  a second claimant fails with SlotConflictError.
- Interceptor points (17 events) with priority -1000..1000 and stable
  ordering: priority asc, plugin ID, registration order.
- RuntimeSnapshot frozen with copy-returning accessors, canonical tool
  schemas, interceptor chains, replacement owners, and a sha256 CacheHash
  over {systemPrompt, toolSchemas}; RuntimeSet owns per-generation closable
  resources with idempotent Close and a generation guard so stale cleanup
  cannot close a newer runtime.
- Adapters wrap existing discovery (builtin tools, skill.Store,
  command.LoadRoots, hook.Load, plugin.Spec, provider.Descriptor) without
  changing any existing package.

Verification: go test ./internal/extension/ -count=1 and -race pass
(~35 tests incl. 100-seed permutation determinism over schemas, chains,
and hash; conflict/slot/ordering/immutability cases); go vet and gofmt
clean. Existing packages untouched; golden baselines still green.
2026-08-03 13:19:44 +08:00
YHH 69d0944e63 fix(crash-worker): stop recreating the cli_* window-scan indexes (#7266)
#7258 dropped the three desktop indexes that cost the dashboard its window
prune, but missed their cli_ mirrors — and those are worse than a stale
migration: ensureCLITelemetrySchema issues the CREATE INDEX statements on
every cold start, so dropping them in D1 only lasts until the next CLI
request. Production still had all six.

The cli_ tables carry the same primary keys as their desktop counterparts,
so they lose the same way. Remove the CREATE INDEX statements from both the
runtime schema and migrate-client-surface.sql, and extend the drop migration
to cover all six.

The migration is now ordered against a deploy: it must run after a worker
that no longer recreates them.
2026-08-03 12:52:41 +08:00
SivanCola 41a5c82b38 test(extension): record pre-refactor golden baselines and legacy compat gates
Stage 1 of the unified extension kernel / Extension Protocol v1 plan.

Problem: the extension kernel rebuilds runtime assembly (tools, skills,
commands, hooks, MCP, providers) behind a Builder that freezes an immutable
RuntimeSnapshot. Before touching that assembly chain we need executable
proof of the current provider-visible surface, because any byte of drift in
the system prompt, tool schemas, or request serialization cold-starts the
provider prompt cache for every session.

Fix: add regression gates only; no production code changes.
- internal/boot TestGoldenBaselineNoExtensions: committed goldens for the
  composed system prompt (machine-independent: environment probes disabled
  in the fixture config, workspace root normalized to <ROOT>), the
  provider-visible tool contract via the canonical schema path, a synthetic
  provider request serialization, and the agent.PrefixShape cache hash.
  Regenerable via REASONIX_UPDATE_GOLDEN=1 for reviewed, deliberate drift.
- internal/pluginpkg legacy manifest + plugin-packages.json round-trip
  gates: manifests without apiVersion parse exactly as before (unknown
  fields ignored, paths sorted, autoStart:false normalized to nil, hook
  args presence bit preserved), and state v1 saves keep every non-empty
  field while empty strings stay omitted (omitempty status quo).
- internal/agent legacy session JSONL round-trip: pre-event-log sessions
  load and re-save with all seven messages intact.
- desktop theme state: v1 state migrates to v2 on save, user theme IDs
  round-trip, base-style IDs are never persisted, garbage files fall back
  to the default state.

Verification: go test ./internal/boot ./internal/pluginpkg ./internal/agent
and cd desktop && go test . -run TestLegacyThemeState pass;
./scripts/cache-guard.sh passes (all cases tail_avg >= 92, threshold 90).
2026-08-03 12:46:13 +08:00
SivanCola 56b5fca57c fix(agent): classify replaced reasoning retries
Problem:
A byte-identical missing-reasoning retry can return a normal final answer instead of another tool call, but the recovery audit classified every non-missing retry as recovered reasoning. The live DeepSeek behavior also lacked a reusable credential-gated regression probe.

Root cause:
The recovery outcome only distinguished another missing tool-call response from all other retry shapes, so a replaced response inflated the recovered counter even though the discarded tool call correctly stayed unexecuted.

Fix:
Record retry-replaced responses separately, preserve whole-response adoption before tool execution, cover the shape change and usage merge deterministically, and add bounded transient/persistent live probes against the official API without logging credentials or content.

Verification:
- go test ./...
- go test -race ./internal/agent
- go test -race ./internal/event ./internal/cli ./internal/telemetry ./internal/notify
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- env -u DEEPSEEK_API_KEY go test -tags live ./internal/agent -run TestLiveDeepSeekFlashMissingReasoningRecovery -count=1
2026-08-03 12:41:02 +08:00
SivanColaandSivanCola 12c4dbb624 fix(agent): scope output budgets by provider protocol
Problem:
The reasoning byte guard was converted into one 32K provider token limit and sent to every backend, coupling a local safety fallback to unrelated provider wire contracts.

Root cause:
The agent owned both controls, so OpenAI-compatible, Responses, Anthropic, DeepSeek, and unknown gateways could not choose model-appropriate fields or defaults.

Fix:
Keep the client byte guard independent, add provider and per-model max_output_tokens configuration, default only official DeepSeek endpoints to 32K, and serialize each supported protocol's native field while leaving unknown gateways unchanged unless configured. Merge current main-v2 and retain both status-bar test suites.

Verification:
go test focused provider/agent/config/boot suites; go test -race focused suites; desktop go test; frontend test/build/typecheck; cache guard; go vet; diff/gofmt checks.

Co-authored-by: SivanCola <[email protected]>
2026-08-03 12:40:40 +08:00
SivanCola e4bbe48890 fix(agent): silently recover missing tool-call reasoning
Problem:
DeepSeek V4 Flash can intermittently return tool calls without replayable reasoning_content, exposing a provider-protocol warning and degrading later tool rounds.

Root cause:
The agent treated the malformed turn as a user-visible diagnostic and immediately committed it, while the persisted cooldown only rate-limited the notice instead of attempting recovery.

Fix:
Retry one byte-identical request before tool execution, keep speculative stream events invisible, reuse the configuration-scoped cooldown as a recovery circuit breaker, preserve the empty-key fallback, and record content-free recovery metrics.

Verification:
- go test ./...
- go test -race ./internal/agent
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
2026-08-03 12:04:28 +08:00
YHH 88b9fa0869 perf: drop the dashboard's window-scan indexes, bake avatars as webp (#7258)
* perf(crash-worker): drop the window-scan indexes the dashboard loses to

Every dashboard aggregate is `WHERE date >= ... GROUP BY <other columns>`,
and each table's primary key already starts with `date`. Offering SQLite a
second index on the GROUP BY columns makes it prefer an ordered full-index
scan, which skips the window prune and reads every retained row back through
the table. The same query shape with a `date <` upper bound already picks the
primary key, which is why the previous-window delta was never slow.

Measured on seeded copies, 65 days retained against the 30-day window:

  metric_users  5.3M rows   31.6s -> 3.3s
  metrics       180k rows    807ms -> 88ms
  pings         568k rows    672ms -> 459ms

No query filters these tables by the dropped columns, so this also takes
their maintenance cost off the ingest path.

* perf(site): bake contributor avatars as 128px webp

The prebuild wrote whatever GitHub handed back straight to `<login>.png`.
Of the 98 baked files, 58 were actually JPEG behind a .png name, 19 came
back at 420x420 despite the ?s= hint, and the heaviest 120x120 avatar was
76 KB. The homepage renders all 98 at 60 CSS px.

Re-encode through sharp at twice the largest rendered size instead of
trusting the response. Avatars 823 KB -> 252 KB; the homepage drops from
842 KB to 371 KB, still 103 requests but a little over half the bytes.

sharp was already resolved in the lockfile as an astro dependency; it is
promoted to a direct devDependency so the prebuild does not rely on hoisting.
2026-08-03 11:57:50 +08:00
SivanCola 22e18cafc0 Merge pull request #7242 from SivanCola/codex/fix-v1193-windows-authenticode
fix(release): verify versioned Windows portable archives
2026-08-03 06:00:46 +08:00
SivanCola b75f2f0dbb fix(release): verify versioned Windows portable archives
Summary:
- validate both legacy flat and versioned-v1 portable layouts
- use the protected control-plane verifier during immutable tag recovery
- keep exact Authenticode and payload hash checks for all six executables

Verification:
- bash scripts/release-workflows.test.sh
- go -C desktop test . -run TestWindowsReleaseSignsPayloadBeforeRepackaging -count=1
- actionlint
- ./scripts/cache-guard.sh
- git diff --check
2026-08-03 05:45:38 +08:00
SivanCola 1eb59c3cd5 refactor(desktop): surface compaction threshold directly
Product: Remove the redundant advanced context disclosure around the sole compaction setting.

Change: Render the threshold controls directly in Agent runtime, preserve the low-touch guidance in localized helper text, and cover the visible-without-expansion contract.

Verification: pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx; pnpm build; in-app browser visual and console checks.
2026-08-03 05:36:40 +08:00
SivanCola 15d335b86b Merge pull request #7237 from esengine/release-notes/v1.19.3
docs(release): prepare v1.19.3 notes
2026-08-03 02:05:59 +08:00
github-actions[bot] 9e9f24a77c docs(release): prepare v1.19.3 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-08-02 18:03:23 +00:00
SivanCola 427fba0f27 Merge pull request #7198 from esengine/fix/background-job-visibility
fix(desktop): make blocked Delivery runtimes recoverable
2026-08-03 01:52:13 +08:00
SivanCola ae309c967a fix(desktop): preserve job ownership boundaries
Problem:
Network probes bypassed permission prompts, and local background jobs could not be listed or stopped while Remote Workbench was active.

Root cause:
The shared read-only classifier conflated workspace mutation with permission safety, while job APIs selected the active remote target before honoring an explicit tab owner.

Fix:
Separate workspace-nonmutating commands from permission-read-only commands, keep Test-NetConnection outside read-only subagents, and route job listing and cancellation by explicit local or remote tab ownership.

Verification:
Ran focused permission, evidence, agent, and Desktop tests, both Go module suites, race tests, vet, and the cache guard.
2026-08-03 01:27:16 +08:00
SivanCola df04b11b06 Merge pull request #7227 from SivanCola/feature/stable-only-release-flow
feat(release): keep one official Stable publication path / 保留单一正式版发布路径
2026-08-03 01:14:37 +08:00
SivanCola ab24371d98 fix(release): bind stable tags to reviewed candidate
Problem:
The stable helper could consume immutable version tags if main-v2 advanced while exact-SHA CI or the protected relay was starting. Manual CLI and Desktop recovery also still accepted RC tags despite the official-only release contract.

Root cause:
The helper sampled main-v2 once, pushed only tag refs, and the orchestrator required the tags to equal the later branch tip. The candidate was not proven to be the commit that changed the target reviewed Notes record.

Fix:
Bind normal candidates to their reviewed Notes change, revalidate exact push CI from the protected control plane, include main-v2 as a guarded ref in the atomic tag transaction, and accept the immutable candidate after later protected branch advances. Restrict manual CLI and Desktop recovery to official versions while retaining historical reusable-workflow parsing.

Verification:
- bash scripts/release-stable.test.sh
- bash scripts/release-workflows.test.sh
- node --test scripts/release-notes.test.mjs
- actionlint on all active release workflows
- site contract tests, SignPath contract tests, Desktop control-plane test, cache guard, and git diff --check
2026-08-03 01:05:09 +08:00
SivanCola 7bb2054edc Merge latest main-v2 into context threshold settings
Integrate the v1.20 update and recovery changes while preserving the context compaction threshold UI, validation, compatibility defaults, and tests.
2026-08-03 00:58:38 +08:00
SivanCola d9b90207be feat(desktop): allow custom compaction thresholds
Motivation:
- Let advanced users choose a threshold between the preset values without editing TOML.

Implementation:
- Add a 65% to 85% percentage draft with 0.1% steps and explicit save semantics.
- Keep preset buttons, validation, cache-risk guidance, and localized accessibility labels.
- Revert the draft to the persisted value when a guarded settings update fails.

Verification:
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- ./scripts/cache-guard.sh
- git diff --check
2026-08-03 00:58:34 +08:00
SivanCola eee6334921 Merge pull request #7199 from SivanCola/feature/v1.20-remove-guard-update-v2
Remove Guard and make Desktop updates atomic / 移除 Guard 并实现桌面端原子更新
2026-08-03 00:55:38 +08:00
SivanCola 72a2804b6a feat(desktop): expose context compaction threshold
Motivation:
- Let advanced users trade context headroom against prompt-cache reuse without editing TOML.

Implementation:
- Add guarded 70%, 80%, and 85% presets with effective token and scope guidance.
- Persist the validated global ratio through the existing failure-atomic settings rebuild path.
- Preserve older config and Wails compatibility with additive optional fields and frontend defaults.

Verification:
- go test ./...
- (cd desktop && go test ./...)
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- ./scripts/cache-guard.sh
2026-08-03 00:51:43 +08:00
SivanCola 6bd942a090 test(release): retire the Desktop prerelease relay contract
The Desktop CI still opened release-desktop-trigger.yml after the public prerelease relay was intentionally removed, causing the first PR head to fail.

Keep the protected Stable relay assertions and replace the deleted relay expectation with explicit absence checks for all three retired public prerelease workflows.

Verification:
go -C desktop test . -run TestProductionSigningRunsOnlyFromProtectedControlPlane -count=1
bash scripts/release-workflows.test.sh
git diff --check
2026-08-02 23:50:39 +08:00
SivanCola 97b42efe4a Merge latest main-v2 site navigation
Problem: main-v2 advanced after the lint repair was prepared, so the PR branch would otherwise become stale again before push.

Root cause: PR #7226 merged the shared site GitHub navigation contract during final verification.

Fix: Merge the latest base without changing the Desktop update implementation.

Verification: pnpm test in site passes all 48 contract tests; git diff --cached --check passes.
2026-08-02 23:49:25 +08:00
SivanCola 94f627c535 Remove obsolete repair state locks
Problem: The PR lint job failed because lockRepairStateFile was unused after the StartupTracker write path was removed.

Root cause: The Unix, Windows, and fallback platform helpers were left behind when startup recovery state became read-only diagnostics.

Fix: Delete the three dead platform implementations so no Guard-era repair-state lock surface remains.

Verification: golangci-lint v2.12.2 reports 0 issues; go test and go vet pass for internal/repair; the package cross-compiles for Windows amd64.
2026-08-02 23:48:53 +08:00
SivanCola 087aaa3a9b chore(release): retire the npm alias migration action
The one-time migration completed successfully in Actions run 30754751693. All seven packages now resolve latest, canary, and next to 1.19.2 with candidate provenance c46e3af1c2.

Remove the temporary manual workflow and its actionlint entry so the normal developer surface contains only ongoing release operations.

Verification:
Re-ran release workflow contracts, site tests, SignPath contract validation, actionlint, cache guard, and diff checks after removal.
2026-08-02 23:47:34 +08:00
SivanCola 93b47a036a Merge pull request #7226 from SivanCola/feature/site-github-header
Add GitHub header link and responsive navigation / 添加 GitHub 入口并优化响应式导航
2026-08-02 23:47:28 +08:00
SivanCola 08e8e79bcf Merge latest main-v2 release fixes
Problem: main-v2 advanced again while the updater conflict resolution was under verification, leaving the branch behind the current base.

Root cause: PR #7222 merged an npm alias propagation fix after the first integration merge was prepared.

Fix: Merge the new base commit without altering the resolved Desktop updater semantics.

Verification: node --test scripts/finalize-npm-official-release.test.mjs; bash scripts/release-workflows.test.sh; git diff --cached --check.
2026-08-02 23:43:29 +08:00
SivanCola 8847d5b9b8 feat(release): keep one official Stable publication path
Problem:
The dual-channel release controls and the proposed GitHub App activation add operator choices, owner-only setup, and failure modes that are unnecessary now that users consume only official releases.

Design:
Retain the proven three-tag Stable orchestrator, retire public Preview relays, and add one exact-SHA helper that validates reviewed notes and push CI before atomically creating the CLI, npm, and Desktop tags. Keep historical Preview inputs readable only for compatibility.

Experience:
Prepare release accepts one version. After the notes PR merges, a maintainer runs one command and approves the release environment once. The website and public documentation expose only the official line and reasonix upgrade.

Safety:
Preserve immutable tags, protected control-plane checks, SignPath validation, idempotent recovery, npm provenance checks, and bounded alias convergence. No persisted runtime format changes are introduced.

Verification:
Release workflow contracts, atomic tag failure cases, SignPath contract tests, website public-surface tests, actionlint, cache guard, and diff checks pass locally.
2026-08-02 23:43:21 +08:00
SivanCola 99cf86bdf1 Merge main-v2 and preserve atomic official updates
Problem: PR #7199 conflicted with the official single-channel updater changes on main-v2, which prevented GitHub from producing a merge ref and running the full CI matrix.

Root cause: Both branches changed the Desktop updater settings, Wails contracts, configuration compatibility fields, and updater tests from the same older baseline.

Fix: Merge main-v2 while retaining the single ApplyUpdateRequest flow, force every new UI operation onto the official stable-compatible channel, remove the retired channel switch helper and Safe Mode payload, and preserve request/version fences for stale async completions.

Verification: GOWORK=off go test ./... and go vet ./... in both Go modules; focused go test -race for config, install layout, window state, settings, and updater paths; pnpm test:all and pnpm build; Windows amd64 Desktop and update-helper cross-builds; cache guard, cache-impact, and release workflow contract tests.
2026-08-02 23:43:07 +08:00
SivanCola da051f9448 feat(site): add GitHub header navigation
Problem: The shared marketing header did not expose the source repository, duplicated the install destination with a Get started link, and allowed navigation text to overflow into right-side controls at intermediate widths.

Root cause: The responsive rules kept a non-shrinking navigation row visible until 900px even when its flex container was already narrower than its contents.

Fix: Add an accessible GitHub link, collapse the primary navigation before it can overlap, preserve the compact mobile control budget, remove the duplicate Get started item, and cover the contracts with focused tests.

Verification: npm test in site (48 tests passed); npx astro build; browser checks at 1101px, 1100px, 952px, and 900px in English and Chinese.
2026-08-02 23:41:43 +08:00
SivanCola 42a21500f9 Merge pull request #7222 from esengine/fix/npm-alias-propagation
fix(release): tolerate npm alias propagation delay
2026-08-02 23:35:45 +08:00
LI Xinyu 3a06203b1c fix(cli): make -c shorthand resolve for --continue
BoolVar(cont, "c", ...) registers "c" as a long flag name, so -c failed
with "unknown shorthand flag: 'c' in -c" — silently swallowed by the
return-2 error path — while --c accidentally worked. Route both flag
sets (runAgent and chatREPL) through a shared BoolP registration so
-c/--continue behave as documented, and add regression tests.

Fixes #7156, #7171
2026-08-02 23:15:58 +08:00
SivanCola 75c4308810 fix(release): wait for npm alias propagation 2026-08-02 23:13:57 +08:00
SivanCola 37360241df Merge pull request #7219 from SivanCola/chore/migrate-npm-release-aliases
chore(release): align legacy npm aliases
2026-08-02 23:09:08 +08:00
SivanCola dfc9f19563 chore(release): align legacy npm aliases
Problem:
The bridge release advanced npm latest to 1.19.2 while the compatibility canary and next aliases still selected public prereleases.

Root cause:
The legacy Stable publisher owns latest only, and the single-channel finalizer is not active yet.

Fix:
Add a zero-input, release-approved one-time workflow that verifies all seven package records against the exact 1.19.2 candidate SHA before atomically aligning latest, canary, and next. Fail closed on provenance drift and verify every final alias.

Verification:
node --test scripts/finalize-npm-official-release.test.mjs
bash scripts/release-workflows.test.sh
go run github.com/rhysd/actionlint/cmd/[email protected] .github/workflows/migrate-npm-release-aliases.yml .github/workflows/ci.yml
git diff --check
2026-08-02 23:00:41 +08:00
SivanCola cea216a57a Make Desktop updates failure-atomic
Problem:
- current.json could degrade to an in-place copy, legacy helpers could reject valid pending updates without relaunching, overlapping window saves could reorder, and manual NSIS installs wrote live layout state directly.

Root cause:
- Commit-pointer writes shared a compatibility fallback, the detached helper resolved pending state from its cache location, frontend saves were concurrent, and the installer bypassed the layout activation transaction.

Fix:
- Add strict atomic pointer writes, claim legacy transactions against the explicit install root with recovery relaunch, serialize window capture/persistence, and route normal installers through signed staging activation.
- Add failure-injection, ordering, handoff, and packaging contract tests.

Verification:
- GOWORK=off go test ./... && GOWORK=off go vet ./... (root and desktop modules)
- GOWORK=off go test -race on affected root and desktop packages
- pnpm test:all && pnpm build
- Windows amd64 cross-compile for migrator, update helper, and desktop
- ./scripts/cache-guard.sh and cache impact check
2026-08-02 21:57:19 +08:00
SivanCola 876c3dff56 Remove remaining Safe Mode code paths
Problem:
- Product Safe Mode had been disabled semantically, but boot, theme, telemetry, settings, and startup tracking still carried always-false branches and compatibility fields.

Root cause:
- The initial Guard removal preserved obsolete behavior APIs instead of reducing them to the normal startup and appearance paths.

Fix:
- Flatten boot onto the normal integration path, remove Safe Mode fields and telemetry gates, and keep legacy startup state read-only for crash diagnostics.
- Remove obsolete frontend/theme contracts and update recovery documentation.

Verification:
- GOWORK=off go test ./... && GOWORK=off go vet ./... (root and desktop modules)
- GOWORK=off go test -race ./internal/boot ./internal/telemetry ./internal/repair ./internal/fileutil ./internal/installlayout
- pnpm test:all && pnpm build
- ./scripts/cache-guard.sh and cache impact check
2026-08-02 21:57:02 +08:00
SivanCola c46e3af1c2 Merge pull request #7214 from esengine/release-notes/v1.19.2
docs(release): 准备 v1.19.2 中英更新日志
2026-08-02 21:53:25 +08:00
SivanCola c2f4976815 docs(release): refine v1.19.2 release notes
Summary:
List all affected user-facing subsystems and correct two English descriptions in the generated stable release notes.

Verification:
Validate the release catalog, run its tests, render the exact public notes, scan them for private data, and check patch formatting.
2026-08-02 21:52:30 +08:00
github-actions[bot] 2a6064c75b docs(release): prepare v1.19.2 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-08-02 13:51:21 +00:00
jackie-cqz 9e9c818324 fix: add configurable terminal theme 2026-08-02 21:00:44 +08:00
SivanCola d49358fe8a Merge main-v2 Windows instruction CI guard
Integration: Incorporate the Windows instruction-home isolation and CI environment coverage without overlapping the reasoning guard or usage telemetry paths.

Verification: Root suite, instruction tests at count 10, focused Agent race/provider/Remote suites, cache guard, formatting, and diff checks.
2026-08-02 20:44:38 +08:00
SivanCola f72874d49b Merge pull request #7209 from SivanCola/fix/windows-instruction-home-test
test(instruction): isolate Windows home imports / 隔离 Windows Home 测试
2026-08-02 20:39:03 +08:00
SivanCola 7bcea588d6 Merge main-v2 into reasoning guard
Integration: Incorporate the official release-channel migration and preview release notes while preserving the additive estimated-usage fields in the shared frontend types.

Verification: Root tests, frontend tests and production build, focused Agent race/provider/cache suites, Desktop telemetry/workbench tests, and isolated repetitions of the unrelated flaky Desktop file and git probes.
2026-08-02 20:29:44 +08:00
SivanCola 6849308cef ci(windows): cover instruction home resolution
Summary:
Add the instruction package to the bounded Windows pull-request smoke suite so platform-specific home expansion is checked before merging.

Verification:
Run the instruction package, lint the CI workflow with actionlint v1.7.7, and check patch formatting.
2026-08-02 20:19:25 +08:00
SivanCola 6076a35731 test(instruction): isolate Windows home imports
Summary:
Set both Unix and Windows home environment variables in instruction resolver tests so tilde expansion remains inside each temporary test home.

Verification:
Run the instruction package once, repeat the three home-import cases 20 times, and check patch formatting.
2026-08-02 20:18:28 +08:00
SivanCola a7acd8a3f4 fix(desktop): keep runtime recovery actions reachable
Problem:
Detached runtimes without child jobs disappeared from the status bar, Remote Workbench jobs could be replaced by local groups, and displayed runtime decision shortcuts did not work.

Root cause:
The status chip treated jobs.length as the only activity signal and selected one data source. RuntimeDecisionCard only listened for Escape.

Fix:
Keep running and prompt-blocked runtimes visible, merge active remote jobs with local runtime groups, expose actionable runtime state copy, and route enabled number keys to their matching actions.

Verification:
Ran the full frontend suite and production build, both Go module suites, focused and Desktop race suites, cache guards, targeted DOM interaction tests, and a local browser smoke check.
2026-08-02 20:10:20 +08:00
SivanCola 2afbe59efc Merge pull request #7207 from esengine/release-notes/v1.19.2-preview.1
docs(release): 准备 v1.19.2-preview.1 中英更新日志
2026-08-02 20:09:59 +08:00
SivanCola 564c5a710b docs(release): correct v1.19.2 preview surfaces
Summary:
List every user-facing subsystem covered by the bridge release notes instead of classifying the release as Desktop-only.

Verification:
Validate the release catalog, render the exact public notes, scan the rendered text for private data, and check the patch formatting.
2026-08-02 20:07:41 +08:00
github-actions[bot] dc2fdebe97 docs(release): prepare v1.19.2-preview.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.
2026-08-02 12:06:15 +00:00
SivanCola d7229fe211 fix(agent): harden reasoning output budget telemetry
Problem: Runaway reasoning streams relied only on a client byte guard, reused the provider length reason, and exposed reconstructed usage as exact across public surfaces.

Root cause: The agent did not send a stable provider output budget, Usage had no estimate provenance, and the guard was named as a character limit even though strings.Builder.Len reports bytes.

Fix: Send a stable derived MaxTokens budget through every built-in protocol, use client_reasoning_limit for the local guard, rename the option to ReasoningByteLimit, and propagate Estimated through CLI, Desktop, ACP, event wire, doctor, and Remote Workbench contracts.

Verification: go test ./... in the root and Desktop modules; focused race tests; provider serialization tests; cache guard; Remote protocol generation tests; frontend tests and production build.
2026-08-02 20:04:37 +08:00
SivanCola ede7ceef00 Merge pull request #7201 from SivanCola/codex/single-channel-runtime
feat(update): migrate clients to the official release channel
2026-08-02 20:00:46 +08:00
SivanCola d8a4288862 fix(desktop): remove obsolete safe mode paths
Problem: v1.20 no longer enters product Safe Mode, but the desktop still carried unreachable startup recovery, theme gating, renderer fallback, and platform helper paths that kept lint red.

Root cause: the initial Guard removal disconnected the entry points without deleting their dormant implementations or moving Linux-only helpers behind the Linux build tag.

Fix: delete the obsolete recovery and renderer policy, simplify theme operations to the normal path, retain only ignored legacy launch tokens and always-false wire fields for upgrade compatibility, and clear the remaining Desktop lint findings.

Verification: go test ./...; go vet ./...; golangci-lint run ./...; pnpm typecheck; pnpm exec tsx src/__tests__/theme-pack.test.ts; Windows amd64 cross-compile.
2026-08-02 19:45:37 +08:00
SivanCola 9546b1f314 ci: refresh cache impact metadata 2026-08-02 19:39:20 +08:00
SivanCola cc38074026 feat(update): migrate clients to official release channel 2026-08-02 19:34:27 +08:00
SivanCola 5664dc8ba7 fix(desktop): clear Guard removal lint blockers
Problem:
Guard removal left two unused Safe Mode internals and a no-op legacy argument check, causing golangci-lint to fail.

Root cause:
The compatibility APIs were made constant-false but their obsolete storage/helper remained, while the migrator prefix branch only continued at the natural end of the loop.

Fix:
- Remove the unused Config.safeMode field and loadSafeModeForRoot alias.
- Remove the side-effect-free legacy prefix check; unknown compatibility arguments remain ignored.

Verification:
- go test ./internal/config ./cmd/reasonix-legacy-migrator
- go vet ./internal/config ./cmd/reasonix-legacy-migrator
- golangci-lint run ./internal/config ./cmd/reasonix-legacy-migrator
- CACHE_IMPACT_PR_BODY_FILE=/tmp/reasonix-v120-guard-removal-pr.md ./scripts/check-cache-impact.sh
- ./scripts/cache-guard.sh
- git diff --check
2026-08-02 19:22:16 +08:00
SivanCola d8c319d144 fix(desktop): harden Guard-free update migration
Problem:
The initial Guard removal still left old 1.18-1.19.1 clients without a reliable migration handoff, allowed corrupt current.json files to fall back to stale flat binaries, and kept legacy pending transactions in the versioned update path.

Root cause:
Launcher and migrator ownership was split, version activation did not publish stable root entries transactionally or serialize competing processes, and Windows/Linux versioned updates still inherited parts of the flat-layout recovery protocol.

Fix:
- Share permanent launcher behavior and run the one-shot compatibility migrator before resolving the active desktop.
- Finalize exact legacy pending transactions during flat-to-versioned migration and archive only strictly older install-local transactions after a healthy recovery install.
- Publish version directories and root launcher/CLI entries under a cross-process activation lock, with current.json as the final commit point and rollback on failure.
- Remove legacy pending identities from Windows versioned handoff and activate Linux versioned updates without persisting Guard.
- Make config file merging transactional, hold one update request owner across all phases, and align Linux deb/frontend contracts with the Guard-free product behavior.
- Add migration, rollback, packaging, concurrency, compatibility, and legacy environment regression coverage.

Verification:
- go test ./...
- cd desktop && go test ./...
- targeted go test -race for config, installlayout, repair, CLI, launcher, migrator, and updater paths
- go vet ./... in both Go modules
- pnpm test:updater and pnpm build in desktop/frontend
- cache guard and cache-impact checks
- Windows amd64 desktop/update-helper and Linux amd64 launcher/migrator/update-helper cross-compilation
2026-08-02 18:46:03 +08:00
SivanCola cf17cad099 fix(desktop): recover blocked delivery runtimes 2026-08-02 18:45:21 +08:00
SivanCola 51a7b83e28 fix(desktop): restore background job controls 2026-08-02 18:32:18 +08:00
SivanCola 9fede8b281 feat(desktop): finish v1.20 remaining fault tolerance, API and packaging
Problem:
v1.20 still lacked local config recovery, kept StartupTracker/old split update
bindings in the product surface, and packaged flat Windows/Linux layouts with
Guard assumptions.

Root cause:
LoadForRoot failed closed on corrupt TOML; desktop still bound download/install
as separate Wails methods; portable and Linux packaging still assumed a flat
Guard release unit; helper still allowed a flat publish fallback.

Fix:
- Config load: corrupt user config uses last-known-good in memory (else
  built-in defaults) without rewriting the original file; project config and
  MCP JSON failures are isolated with LoadWarnings.
- Desktop surfaces configWarnings with Open config / Reload / doctor repair
  banner actions; records LKG after UI ready and on clean shutdown.
- Remove StartupTracker from launch control (observe previous run for crash
  diagnostics only).
- Wails update surface is ApplyUpdateRequest only; download/install are private.
- Helper versioned-v1 activation is mandatory for staged Windows payloads.
- Windows portable packaging/verification and Linux tarball use versioned/thin
  launcher contracts; macOS bundle rejects Guard.
- Fault-injection and legacy flat migration fixture tests for installlayout.

Verification:
- go test ./internal/config ./internal/installlayout ./cmd/reasonix-launcher
  ./cmd/reasonix-legacy-migrator ./internal/repair
- cd desktop && go test ./cmd/update-helper/ and focused package tests
- pnpm --dir desktop/frontend test:updater (31 passed)

Out of scope for local automation: Windows DPI 100x soak, Preview 72h gate,
production migration success rate, and live signed-installer UI coverage.
2026-08-02 18:05:26 +08:00
SivanCola 8d7764c61a Merge main-v2 into agent reasoning guard
Integration: preserve the latest persisted missing-reasoning warning state while retaining the PR reasoning stream guard.

Verification: focused runaway, interrupted-usage, and missing-reasoning warning tests passed.
2026-08-02 17:54:31 +08:00
SivanCola 79449ef801 feat(desktop): versioned helper activation, drop Guard, NSIS dual layout
Problem:
The Windows update helper still published a flat six-member release unit with
Guard health recovery. The Guard recovery product remained as a first-class
binary and packaging still installed Guard as the permanent entry point.

Root cause:
Post-stage publish was hard-wired to flat InstallRoot members and pending
receipt recording. Packaging and docs still treated reasonix-guard as the
launcher/recovery surface.

Fix:
- Prefer versioned-v1 activation from staged NSIS payloads: write
  versions/<ver>/{desktop,cli,helper}, swap current.json, place thin launcher
  and portable alias at InstallRoot, remove flat desktop/guard leftovers.
- On versioned success, clear the pending transaction without health probation
  and relaunch via reasonix-launcher.
- Resolve InstallRoot and update-helper source for versioned trees; claim
  pending targets from the prepared transaction rather than hard-coded flat
  paths.
- NSIS dual mode: /REASONIXSTAGE=1 still extracts the six-member flat payload
  for 1.18-1.19.1 helpers; normal install writes versioned layout and does not
  persist Guard.
- Delete cmd/reasonix-guard. Compatibility payloads still build
  reasonix-guard from reasonix-legacy-migrator only. Point recovery docs at
  reasonix doctor.
- Linux .desktop Exec uses reasonix-launcher.

Verification:
- go test ./internal/installlayout ./cmd/reasonix-launcher
  ./cmd/reasonix-legacy-migrator ./internal/repair
- cd desktop && go test ./cmd/update-helper/
- focused desktop packaging/window/updater tests
- GOOS=windows go test -c ./cmd/update-helper and desktop package
2026-08-02 17:54:11 +08:00
SivanCola 0e78ef425e feat(desktop): start v1.20 Guard removal and versioned update layout
Problem:
Desktop shutdown can panic on Windows when Wails queries window geometry after DPI reports 0. Product Safe Mode and Guard health policy force global capability loss and leave users stuck on pending-update loops. The flat install layout cannot support atomic version activation.

Root cause:
beforeClose/shutdown called WindowGetSize/Position/IsMaximised on a tearing-down WebView2 surface. StartupTracker and REASONIX_SAFE_MODE drove a process-wide degraded mode. Updates relied on a single pending transaction with health probation and Guard rollback.

Fix:
- Persist window state only from frontend reports; Go rewrites last-known state without native queries during hide/shutdown. Accept Windows border origin x=-8,y=-8 and add regression coverage.
- Introduce installlayout (current.json schema 1, staging activation, version GC) plus a permanent thin reasonix-launcher and a one-shot legacy migrator (compat binary name reasonix-guard).
- Add ApplyUpdateRequest single-action update path, install_layout field validation, and frontend update-and-restart UX.
- Make SafeModeRequested always false; strip desktop startup recovery that forced Safe Mode; keep doctor/crash diagnostics non-gating.
- Point desktop-build Windows launcher/migrator builds at the new cmds.

Verification:
- go test ./internal/installlayout ./cmd/reasonix-launcher ./cmd/reasonix-legacy-migrator ./internal/config ./internal/boot
- focused desktop window-state/updater/safe-mode tests
- pnpm --dir desktop/frontend test:updater (31 passed)

Remaining for full v1.20.0 gate: NSIS dual-mode packaging, versioned payload activation in update-helper, delete cmd/reasonix-guard source, full config last-known-good resilience, macOS/Linux packaging and release docs.
2026-08-02 17:43:03 +08:00
SivanCola 0d42ffbbdc Merge pull request #7109 from sudoviber/fix/7059-suppress-tool-call-reasoning-warning
fix(agent): rate-limit missing tool-call reasoning warnings / 按配置限频工具调用思考告警
2026-08-02 16:57:35 +08:00
Yan Li 30409a49f6 style(control): gofmt-align disableColdResumePrune field 2026-08-02 16:47:52 +08:00
paradoxSCH 70097ea087 fix(agent): guard runaway reasoning usage 2026-08-02 15:00:59 +08:00
Yan Li a8ee0fd106 fix(config): preserve legacy 24h cache TTL for DeepSeek/unknown
The legacy main-v2 threshold of 24h is correct for DeepSeek: its Context
Caching on Disk retains prefixes for 'several hours to days'. Shrinking
it would prune still-warm caches and burn the user's live cache (~4x
miss cost measured).

Only override vendors with a documented, much shorter TTL:
- DashScope Session cache: 5 minutes (GPU cache)
- Anthropic ephemeral cache: 5 minutes
- DeepSeek + unknown: keep 24h legacy default

This keeps the PR minimal and approval-friendly: no behavior change for
DeepSeek users; the fix targets only the DashScope 5-minute GPU cache
that the legacy 24h threshold previously missed.
2026-08-02 11:31:23 +08:00
Yan Li 874f5ae522 fix(responses): suppress all-zero usage records from DashScope
DashScope occasionally emits a response.completed event whose usage
object exists but is all zeros (server-side reporting gap; tokens are
still billed). Emitting it as ChunkUsage corrupted cache-ratio and cost
accounting with a spurious zero-token turn.

Gate emission on TotalTokens > 0 or a non-stop abnormal finish reason,
so length/content_filter truncations still surface while zero records
are dropped. Add TestAllZeroUsageCompletedIsSuppressed.
2026-08-02 10:19:33 +08:00
Yan Li fbf86b8d1a fix(config): vendor-aware cache TTL for cold-resume prune
Replace the hardcoded 24h cacheColdAfter constant with a vendor-aware
dynamic resolution: DashScope 5m, DeepSeek 60m, Anthropic 5m, unknown
10m. Users can override per-provider with cache_ttl_minutes in config.

DashScope Session cache TTL is documented at 5 minutes; the previous 24h
assumption meant cold-resume prune never triggered for DashScope users,
leaving stale tool results in the transcript after cache expiry.

Changes:
- Add CacheTTLMinutes field to ProviderEntry (config.toml)
- Add internal/config/cache_policy.go with DefaultCacheTTL() and
  EffectiveCacheTTL() vendor detection
- Replace var cacheColdAfter with Controller.cacheColdAfter() method
- Adapt resume_prune_test.go to use testCacheColdAfter override
- Add cache_policy_test.go (4 tests)

Verified: go test ./internal/config/ ./internal/control/ ./internal/provider/...
go vet clean.
2026-08-02 09:55:03 +08:00
SivanCola 1c62489dac Merge pull request #7159 from SivanCola/fix/theme-safe-code-highlighting
Keep syntax highlighting readable across themes / 保持不同主题下的代码高亮清晰
2026-08-02 09:22:09 +08:00
SivanCola cb74987bd9 Merge pull request #7146 from Q1hangL/fix/delivery-readiness-verifier-summary
fix(agent): guide delivery readiness verification recovery / 引导 Delivery 就绪验证恢复
2026-08-02 09:20:57 +08:00
SivanCola 3add2995d8 fix(agent): preserve warning resolution ordering
Problem:
Delayed missing-reasoning transactions could revive an incident after a newer healthy turn. Future last-missing timestamps and transient healthy-state write failures could then suppress a later regression despite the warning contract.

Root cause:
The persisted record kept only missing timestamps and deleted healthy incidents, so it had no resolution watermark to reject stale claims. State loading validated only the warning timestamp, and resolve writes discarded persistence errors.

Fix:
Keep a bounded nanosecond resolution watermark in the additive v2 state, reject inconsistent or future event timestamps, and keep non-missing read failures visible. Preserve failed healthy resolutions in the agent and retry them before the next missing claim, falling back to a visible warning if persistence is still unavailable. Add ordering, recovery, migration, and partial-read regression coverage.

Verification:
- go test -count=1 ./...
- go test -race -count=1 ./internal/agent
- go test -count=1 ./... (desktop module)
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- ./scripts/check-cache-impact.sh origin/main-v2
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-02 09:05:12 +08:00
SivanCola 1fd5d7bb99 fix(evidence): reject non-checking tsc modes
Problem:
TypeScript commands such as tsc --noEmit --init could write tsconfig.json while being treated as non-mutating verification. Metadata, watch, build, and --noCheck modes could also satisfy or stall readiness without completing a bounded type check.

Root cause:
The TypeScript verifier only required --noEmit and rejected a small set of independent output destinations. It did not distinguish ordinary compiler checks from control and early-exit modes.

Fix:
Fail closed on init, help/version/config display, list-only, no-check, watch, build/clean, trace/profile, and explicit build-info destinations for both direct tsc and npx tsc. Preserve ordinary one-shot no-emit checks and add positive and negative classifier regressions.

Verification:
- go test ./internal/evidence -run "VerificationCommandSummary|GoBuild|TSCVerification|PythonCompileall|NpxVerification|RunnerWriteOutput" -count=1
- go test ./internal/evidence ./internal/agent -count=1
- go test -race ./internal/evidence ./internal/agent -count=1
- go vet ./...
- go test ./...
- bash scripts/cache-guard.sh
- git diff --check
2026-08-02 09:03:00 +08:00
SivanCola 85de763b42 fix(desktop): guarantee contrast across diff surfaces
Problem:
Valid midtone custom themes could make semantic or syntax colors fail WCAG AA on either the plain code surface or a tinted diff row.

Root cause:
A fixed diff tint could cross the black/white contrast boundary, leaving no shared foreground that reached 4.5:1 across every rendered surface.

Fix:
Reduce semantic row tint only when required, validate every code role against plain, added, and deleted backgrounds, and add adversarial plus generated-palette regressions.

Verification:
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm build
- go test ./...
- go test -race ./internal/cli -count=1
- cd desktop && go test ./...
2026-08-02 09:00:58 +08:00
SivanCola 2e9680edd0 Merge pull request #7164 from SivanCola/fix/mcp-startup-recovery
fix(mcp): keep slow startup alive in background / 修复 MCP 慢启动反复重启
2026-08-02 08:42:37 +08:00
SivanCola 4a989f4d6a fix(mcp): flush startup stderr before reporting
Problem:
The Linux race job intermittently observed an initialize timeout before the stdio stderr copy goroutine had flushed, so structured startup diagnostics omitted the expected redacted stderr tail.

Root cause:
Startup failure paths sampled the bounded stderr buffer before closing and reaping the transport, even though transport close is the synchronization point for the copy goroutine.

Fix:
Close the failed transport first, then read and redact startup stderr for initialize and both tools/list paths.

Verification:
- go test -race ./internal/plugin -run ^TestStartupFailureReportsStageElapsedAndRedactedStderr$ -count=20
- go test -race ./internal/plugin ./internal/control ./internal/agent -count=1
- go test -p 4 ./... -count=1
- go vet ./internal/plugin
- ./scripts/cache-guard.sh
- git diff --check
2026-08-02 08:35:39 +08:00
Yan Li ced0432605 test(responses): reasoning item omitted when ReasoningContent is empty 2026-08-02 06:05:12 +08:00
Yan Li af88b256ee test(responses): cover FinishReason=stop and reasoning summary serialization 2026-08-02 06:04:33 +08:00
Yan Li 6b851ad58a fix(responses): include summary list on reasoning items for DashScope
DashScope's Responses API (token-plan endpoint) rejects reasoning items
without a summary list: "Invalid 'summary': summary is required and must
be a list for reasoning." OpenAI's format only needs content, so this is
a vendor divergence. Include both summary (summary_text) and content
(reasoning_text) so multi-turn conversations with assistant reasoning
history work on both DashScope and OpenAI-compatible endpoints.
2026-08-02 06:03:58 +08:00
Yan Li f76b81d961 fix(responses): set FinishReason=stop on response.completed
A completed response finished normally; without this the FinishReason
stayed empty and consumers (e.g. tools/baseline) reported 'unknown'.
response.incomplete keeps its length/content_filter/incomplete mapping.
2026-08-02 06:03:58 +08:00
SivanCola 1f3a67b57e fix(agent): serialize warning state transactions
Problem:
After same-incident coalescing fixed duplicate Windows warnings, concurrent claims for different configurations could still lose persisted incidents.

Root cause:
Each fingerprint had its own leader, so distinct leaders still competed in the bounded in-process portion of the file lock. Slow Windows atomic writes let queued leaders time out before their read-modify-write transaction.

Fix:
Serialize all transactions for one warning-state file with a process-local mutex before entering the bounded cross-process file lock. Same-fingerprint flights still coalesce duplicate notices, while distinct configurations now complete lossless read-modify-write transactions.

Verification:
- go test ./internal/agent -run TestMissingReasoningWarnStateConcurrent -count=100
- go test -race ./internal/agent
- go test ./...
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go test -c ./internal/agent

Co-authored-by: SivanCola <[email protected]>
2026-08-02 03:23:28 +08:00
SivanCola be1d9c359c fix(evidence): reject npx runner aliases
Require npx verifier identities to use a bare known package name with only an ordinary version or dist-tag suffix. Reject filesystem paths and package protocols so aliases such as eslint@npm:other-package cannot inherit the trusted runner classification.
2026-08-02 03:20:19 +08:00
SivanCola e94e549799 fix(mcp): fence background startup removal
Problem:
A server removal could race the narrow gap between a background handshake finishing tools/list and publishing its client, allowing the removed server to reappear.

Root cause:
EnsureConnectedInBackground registered a deferred generation but did not pass that generation into the final Host publication check.

Fix:
Carry the generation through the shared connection path, retain configured server names in connection status, and cover removal during an in-flight background startup.

Verification:
- go test ./internal/plugin -count=1
- go test -race ./internal/plugin ./internal/control ./internal/agent -count=1
- go test -p 4 ./... -count=1
- git diff --check
2026-08-02 03:15:34 +08:00
SivanColaandipanta 1522bac58d fix(evidence): harden verifier output boundaries
Problem:
Delivery recovery still accepted bytecode compilation and TypeScript diagnostic output as non-mutating verification. The npx policy also recognized only two test runners, leaving safe test and lint commands from #6901 behind the Auto Guard boundary.

Root cause:
The verifier allowlist treated compileall as a check, only gated tsc on noEmit, and did not distinguish safe npx test/check modes from output-producing runners and flags.

Fix:
Classify compileall and explicit TypeScript build-info, trace, and CPU-profile destinations as mutations. Adapt the known-runner direction from #6901 for Mocha, AVA, ESLint, Prettier check mode, and no-emit npx tsc while keeping Playwright, Cypress, source executors, control flags, and output modes fail closed.

Verification:
- go test ./internal/evidence -run 'VerificationCommandSummary|GoBuild|TSCVerification|PythonCompileall|NpxVerification|RunnerWriteOutput' -count=1
- go test ./internal/evidence ./internal/agent -count=1
- go test -race ./internal/evidence ./internal/agent -count=1
- go vet ./...
- go test ./...
- bash scripts/cache-guard.sh
- git diff --check

Co-authored-by: ipanta <[email protected]>
2026-08-02 03:14:11 +08:00
SivanCola 558fb5d474 Merge latest main-v2 into PR #7109
Refresh the Windows concurrency repair after main-v2 advanced with v1.19.1 release notes. The base-only documentation update does not overlap executable code.
2026-08-02 03:11:30 +08:00
SivanCola 969f7cd436 fix(agent): coalesce concurrent reasoning incidents
Problem:
Windows CI could emit several first warnings for one concurrent incident even though persistent state used a file lock.

Root cause:
The bounded file-lock deadline also covered the in-process queue. On slower Windows atomic writes, followers timed out locally and each took the fail-visible path.

Fix:
Coalesce in-process claims by state path and configuration fingerprint. Followers suppress duplicate warnings while forwarding their latest observation timestamp to the leader, which preserves stale-health ordering and retains the bounded cross-process lock behavior.

Verification:
- go test ./internal/agent -run TestMissingReasoningWarnStateConcurrent -count=50
- go test -race ./internal/agent
- go test ./...
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go test -c ./internal/agent

Co-authored-by: SivanCola <[email protected]>
2026-08-02 03:11:06 +08:00
SivanCola 5c16cc1408 fix(mcp): keep slow startup alive in background
Problem:
Cold MCP servers that exceeded the short interactive wait were canceled and restarted on every retry, obscuring the real startup stage and source.

Root cause:
The caller wait context also owned the shared initialize and tools/list lifecycle, so a five-second timeout terminated otherwise healthy cold starts.

Fix:
Separate the session-owned startup lifecycle from the caller wait, add bounded global and per-server startup timeouts, preserve pinned provider-visible tool schemas, and expose redacted structured diagnostics.

Verification:
- go test -p 4 ./... -count=1
- go test -race ./internal/plugin ./internal/control ./internal/agent -count=1
- ./scripts/cache-guard.sh
- go vet ./internal/plugin ./internal/config ./internal/boot ./internal/capdiag ./internal/control ./internal/agent ./internal/installsource
- git diff --check
2026-08-02 03:07:24 +08:00
SivanCola 3ad7439f92 Merge latest main-v2 into PR #7109
Refresh the repaired warning-state branch after the base advanced during verification. The intervening billing, remote protocol, and release-note changes do not overlap the incident-state implementation.
2026-08-02 03:00:33 +08:00
SivanCola 86a4958057 fix(agent): re-arm missing reasoning diagnostics
Problem:
Persisting a provider-name-only acknowledgment hid endpoint/model changes and future regressions, while OpenAI-format V4 Flash was inconsistent with the current DeepSeek thinking contract.

Root cause:
Warning state had no configuration identity, expiry, or healthy recovery transition, and the OpenAI warning matcher excluded the whole Flash family.

Fix:
Key incidents by an opaque adapter configuration fingerprint, expire them after 24 hours, clear them after healthy tool-call reasoning, and preserve ordering with millisecond event timestamps under the existing cross-process lock. Align known DeepSeek thinking models across OpenAI, Anthropic, and Responses adapters and use protocol-neutral diagnostics.

Verification:
- go test -race ./internal/agent
- go test ./...
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh

Co-authored-by: SivanCola <[email protected]>
2026-08-02 03:00:14 +08:00
SivanCola 3c5e51a214 Merge origin/main-v2 into fix/theme-safe-code-highlighting
Synchronize the latest release, desktop packaging, billing, and remote protocol changes before publishing the theme readability integration. The upstream paths do not overlap the theme or syntax-highlighting changes.
2026-08-02 02:54:51 +08:00
SivanColaandHaoyueQin 10c79d6e8b fix(desktop): preserve code contrast across pane themes
Problem:
Theme packs, Creation overrides, and diff tint layers could leave syntax or semantic diff text below WCAG AA, while the extended pane transparency rules could make protected code surfaces translucent again.

Root cause:
Base styles did not install complete code palettes, Creation replaced only the code background, diff contrast was measured against the untinted surface, and the pane-tier integration treated block code as an ordinary child panel.

Fix:
Generate complete opaque palettes for every base style and Creation, derive foregrounds against final diff row backgrounds, share the derivation with previews, exempt block code and diffs from pane transparency, and clear every extended pane variable.

Verification:
- pnpm test
- pnpm build
- go test ./...
- go test -race ./...
- in-app browser light, dark, and background-theme contrast checks

Co-authored-by: HaoyueQin <[email protected]>
2026-08-02 02:54:28 +08:00
SivanCola 49fad9d6f8 Merge main-v2 into PR #7109
Bring the warning-state fix onto the current provider and agent baseline before refining its persistence semantics. This keeps the contributor branch mergeable without rewriting its public history.
2026-08-02 02:47:28 +08:00
SivanCola bf8490df5b fix(evidence): fail closed on output-capable verifiers
Problem:
Go build package patterns can still write a workspace binary when they expand to one main package. The recovery summary also rendered only family labels, hiding safe examples such as tsc --noEmit while the classifier accepted output-capable bare tsc commands.

Root cause:
The static classifier assumed ./... always implied discarded build outputs, and the summary discarded the same concrete examples used by its consistency tests. TypeScript verification was not gated on no-emit mode.

Fix:
Classify every go build form as a mutation, render each tested safe command example in the recovery summary, and require explicit TypeScript no-emit mode. Update the serial sign-off scenario and add regressions for Go build, TypeScript output flags, and model-visible examples.

Verification:
- go test ./internal/evidence ./internal/agent -count=1
- go test -race ./internal/evidence ./internal/agent -count=1
- go vet ./...
- go test ./...
- bash scripts/cache-guard.sh
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:46:51 +08:00
HaoyueQin 9df2ec56d9 feat(desktop): complete theme pane transparency, hover consistency, and dock-tab compression
- Five-tier pane transparency system (Shell / Card / Session-hover /
  Child / Interact) with CSS variables driven by paneOpacity slider
- Sidebar session items (project-tree__topic) and folders now share
  the same session-hover tier, responding to panel opacity uniformly
- Welcome page example buttons (welcome__ex) placed on child tier,
  one level above the welcome container background
- Topicbar buttons + external-opener unified on card tier
- Update banner (banner--update) raised to child tier
- Active dock-tab underline moved inside button (bottom:-1px→1px)
  so it is no longer clipped by content below
- Windows frameless dock-tab compression: scoped to workbench layout
  only, compresses spacing (padding / gap) via clamp() while never
  shrinking tabs or truncating labels

(cherry picked from commit fad7908b0f)
2026-08-02 02:43:24 +08:00
SivanCola b0d7805b02 fix(desktop): protect syntax contrast across themes
Problem:
Code and diff surfaces inherited translucent theme backgrounds while syntax colors followed only the global light/dark mode. Custom backgrounds could therefore bleed through and make highlighted text unreadable.

Root cause:
Theme-pack overlays forwarded bgSoft alpha directly and had no derived code surface or per-token contrast guard. The theme preview also omitted realistic code and diff content.

Fix:
Derive an opaque code background and border from each theme mode, choose the syntax direction from the final surface luminance, and minimally adjust every code and diff text role to WCAG AA contrast. Route code, full diff, and inline diff rendering through the protected surface, and add live code/diff samples to theme previews.

Verification:
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm build
- cd desktop && go test ./...
- go test ./...
- go test -race ./internal/cli -count=1
- in-app browser: light/dark, home/task, official background, and #RRGGBBAA custom bgSoft
2026-08-02 02:29:43 +08:00
SivanCola 484263db3f fix(cli): follow theme mode for diff syntax colors
Problem:
CLI diffs kept the GitHub dark syntax palette on light terminal themes, leaving pale tokens difficult to read on light diff backgrounds.

Root cause:
The Chroma style was resolved once as github-dark instead of following the active CLI light or dark mode during rendering.

Fix:
Resolve the registered GitHub light/dark counterpart on every render so runtime theme switches and theme-sweep preview frames use matching syntax colors. Add regression coverage for style selection, live output changes, and source-text preservation.

Verification:
- go test ./internal/cli -count=1
- go test -race ./internal/cli -count=1
- go test ./...
2026-08-02 02:29:43 +08:00
SivanCola fa8b24caa5 fix(evidence): parse go build verification flags
Problem:
The verification classifier searched raw go build arguments for ./..., allowing flag values such as -pkgdir ./... to masquerade as package operands.

Root cause:
Build flags and their values were not separated from package operands.

Fix:
Parse documented Go build flags, accept only safe forms, require ./... among actual package operands, and fail closed for output-affecting or unknown flags. Add regressions for safe -tags forms and flag-value bypasses.

Verification:
- go test ./...
- go vet ./...
- go test -race ./internal/evidence ./internal/agent
- bash scripts/cache-guard.sh
- runtime go build flag checks with unchanged workspace status

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:24:00 +08:00
SivanCola 6afdb6805b test(agent): satisfy warning-state static analysis
Split the repeated in-memory fallback claim assertion so staticcheck does not
treat two stateful calls as an identical boolean expression.

Verification:
- golangci-lint run --timeout=5m
- go test -race ./internal/agent
- git diff --check
2026-08-02 02:09:51 +08:00
SivanCola 643916d687 fix(agent): harden readiness verifier guidance
Problem:
The readiness recovery summary advertised direct go build commands that can create workspace binaries, appeared on unrelated readiness failures, and duplicated classifier examples in a test-only list.

Root cause:
The summary treated every accepted verifier as equally safe to recommend and was appended without consulting the structured readiness failure. Its drift test did not share the data used to render the summary.

Fix:
Recommend a structured safe subset, classify only all-package go build patterns as non-mutating verification, and inject the summary only when verification evidence is actually missing. Add regression coverage for workspace-output builds, recommendation consistency, and conditional injection.

Verification:
go test ./...
go vet ./...
go test -race ./internal/evidence ./internal/agent
bash scripts/cache-guard.sh
git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:06:28 +08:00
SivanCola 165c43fc5b fix(agent): serialize persisted reasoning warning claims
Problem:
Separate agent instances cached the warning state independently and used a fixed temporary file, so concurrent claims could duplicate a warning or overwrite another provider.

Root cause:
The read-check-write sequence was not one cross-process transaction.

Fix:
Serialize each claim with a bounded cross-process file lock, reread the state under that lock, and publish the merged provider set with the existing atomic writer. Persistence failures preserve the historical once-per-session warning behavior.

Verification:
- go vet ./...
- go test -race ./internal/agent
- go test ./...
- ./scripts/cache-guard.sh

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:01:50 +08:00
Q1hangL 1d0305478a fix(agent): break delivery readiness deadlock by exposing verifier whitelist to the model
Delivery mode blocks final answers until a host-recognized verification
command is cited in complete_step, but the whitelist classifier
(bashSegmentIsVerification) is invisible to the model. The two most
intuitive guesses both fail deterministically: inline interpreters
(node -e / python -c) are blocked as opaque mutations, and read-only
inspection pipelines (grep|wc, find) never count as verification.
The model loops until maxFinalReadinessBlocksWithProgress=6 or the turn
is force-interrupted - observed 7 readiness injections in one CLI
session and reproduced locally.

Add evidence.VerificationCommandSummary() and embed it in both the
final-readiness retry message and the opaque-interpreter block message,
so the model can pick a valid verifier (node --check, project test
runners, read-only pipelines into a verifier) on the first retry.

Tests: summary/classifier consistency (anti-drift), deadlock-trap
warnings, read-only pipeline escape hatch, retry-message content.
Verified locally with go1.26.5: gofmt clean, go test ok.
2026-08-01 21:23:41 +08:00
sudoviber 5447d1c5a6 fix(agent): show missing tool-call reasoning warning once per provider
DeepSeek (and some gateways) never attach reasoning_content to tool_calls
turns, so the once-per-session notice re-appeared on every new session and
--resume even though its text promised a single showing (#7059).

Persist the warned-provider set to <reasonix home>/state via a new
missingReasoningWarnState store (atomic temp-file+rename, best-effort:
any IO failure degrades to the historical once-per-session behavior) and
check it in warnMissingToolCallReasoning before emitting. Boot supplies
the shared state dir to both executor and planner options; direct Agent
construction without the option keeps the old scope, so nothing changes
for embedders that do not opt in.

Update the notice text to "shown once" and add unit tests (persistence,
sorting, corrupt-file self-heal, empty-dir no-op) plus an e2e test
covering SetSession and fresh-agent runs over a shared state dir.
2026-08-01 05:56:36 +08:00
czy f725d08601 Merge remote-tracking branch 'origin/main-v2' into fix/task-monitor-cas-recorder
# Conflicts:
#	desktop/frontend/src/App.tsx
2026-07-31 16:10:19 +08:00
czy 79679bf2e2 fix(taskmonitor): cross-process CAS lock + job lifecycle recording
Addresses the two blocking issues from PR #6998 review:

1. FileStore.SaveTask ran its version CAS without a file lock (TOCTOU):
   two processes could both pass the check and clobber each other.
   SaveTask now holds the per-task task.lock (shared with
   AppendAuditEvent) across read-CAS-write-rename, and a corrupt
   snapshot fails loudly instead of bypassing the CAS gate. Desktop
   task control actions reuse one process-wide ControlService.

2. Nothing wrote task lifecycle data into the task store, so
   .reasonix/tasks stayed empty. jobs.Manager exposes a TaskRecorder
   hook (WithTaskRecorder / SetTaskRecorder) invoked on Start and
   terminal completion; controller.New installs a taskmonitor.TaskRecorder
   backed by FileStore so CLI, Desktop, and scripts observe the same
   task state/events. SessionID is now optional (jobs parentSession is
   commonly empty before the first turn).

Tests: concurrent cross-instance CAS, recorder lifecycle/restart/
truncation, jobs hook coverage; go test -race ./internal/taskmonitor
./internal/jobs, go vet, desktop go test all pass.
2026-07-31 16:09:52 +08:00
czy 0f6589c72b Merge latest main-v2 into task monitor PR 2026-07-29 15:25:16 +08:00
czy 9baa789f62 ci: retrigger pull request checks 2026-07-29 09:37:57 +08:00
czy 3544843dc4 fix: make task monitor validation cross-platform 2026-07-28 15:47:46 +08:00
czy b224ed8c77 fix: complete task monitor frontend CI compatibility 2026-07-28 15:28:02 +08:00
czy e445918504 fix task monitor cross-platform locking and frontend lockfile 2026-07-28 15:05:56 +08:00
czy 1ad26b104a Merge upstream main-v2 into task monitor PR 2026-07-28 14:48:25 +08:00
czy 264f50075e fix: allow resume for failed tasks 2026-07-28 13:47:23 +08:00
czy 2e51ecc57f fix: show explicit desktop task confirmations 2026-07-28 13:43:36 +08:00
czy 6d6f54a651 feat: add desktop task control actions 2026-07-28 13:39:53 +08:00
czy ae923a8567 fix: resolve desktop task store relative to workspace 2026-07-28 13:34:16 +08:00
czy 081f739c51 fix: position task monitor panel inside desktop layout 2026-07-28 11:42:58 +08:00
czy 73ecfd5ed0 fix: wire desktop task controls to writable store 2026-07-28 11:39:50 +08:00
czy dc54801dc4 fix: accept tmux task flags after task id 2026-07-28 10:56:07 +08:00
czy f0c7fb54aa feat: add optional tmux task monitor adapter 2026-07-28 10:49:42 +08:00
czy 497fb68aa9 TM-04: CLI JobKiller wiring + 跨进程文件锁 + e2e 测试
1. CLI JobKiller wiring:
   - ctrlKillerAdapter 适配 Controller → taskmonitor.JobKiller
   - cli.go runAgent/chatREPL 成功后注入 SetTaskJobKiller
   - e2e 测试 TestCLI_StopCallsKill / TestCLI_CancelCallsKill

2. 跨进程原子序列:
   - FileStore.AppendAuditEvent 使用 syscall.Flock 文件锁
   - 在锁内读取事件、分配序列、追加写入

3. 修复 InMemoryStore.SaveTask 空 projectDir 的 nil map panic
4. 修正测试参数顺序(--flags before positional args)

测试: 85 PASS(taskmonitor 58 + cli 27), -race clean
2026-07-28 10:36:01 +08:00
czy 9e7ce3e68d TM-04: Desktop JobKiller 注入 — Controller.KillJob + App.StopTask/CancelTask
1. control.Controller 新增 KillJob(id) 方法(委托 jobs.Manager.Kill)
2. desktop App 新增 StopTask/CancelTask bridge 方法
3. ctrlJobKiller 适配器连接 Controller → taskmonitor.JobKiller
4. 仅 controller 存在时注入(独立 CLI 无 active session 时 nil)

83 PASS, -race clean, go vet 零告警
2026-07-28 10:15:15 +08:00
czy e2c71a43d0 TM-04: 原子幂等/事务顺序/AppendAuditEvent/Kill 注入
1. FileStore.RecordIdempotency: O_CREATE|O_EXCL 原子 claim + 冲突参数比对
2. 事务顺序: SaveTask → AppendAuditEvent → RecordIdempotency
3. AppendAuditEvent 替代 NextSequence+SaveEvent(原子序列分配+追加)
4. Validate 在序列分配之后调用(修复 Sequence=0 错误)
5. CLI 注入 taskJobKiller(SetTaskJobKiller)
6. stop/cancel/resume 命令自动注入 JobKiller

测试: 58 PASS + 25 PASS = 83 PASS, -race clean, go vet 零告警
2026-07-28 10:06:35 +08:00
czy 1f195fd6ed TM-04: 移除残留误导注释 2026-07-28 09:44:24 +08:00
czy 8bbcd03577 TM-04 security fix: 幂等键路径穿越 + SaveEvent 校验 + 原子写入
1. safeID 统一校验 taskID/idempotency key(拒绝../和路径分隔符)
2. FileStore.SaveEvent 增加 ev.Validate() 调用
3. RecordIdempotency 注释更新为非 O_EXCL 语义说明

测试:62 PASS,-race clean,go vet 零告警
2026-07-28 09:41:19 +08:00
czy 721b3a4ffe TM-04: 幂等持久化 / 并发 / 审计序列 / Kill 集成 / race 测试
1. WriteStore 增加 CheckIdempotency/RecordIdempotency/NextSequence
2. ControlService 添加 sync.Mutex 并发保护
3. 幂等键绑定 op+taskID+version,不匹配返回冲突
4. 审计事件通过 NextSequence 获取单调递增序列
5. SaveEvent 失败返回 ErrTaskAuditFailed + error
6. JobKiller 接口接入 jobs.Manager.Kill()
7. InMemoryStore 内存幂等追踪 + FileStore JSON 文件持久化
8. control_test.go 新增 16 测试:
   - 幂等冲突(不同 op/不同 version)
   - 审计序列单调性
   - Kill 调用/终态不调用
   - 并发安全(10 goroutines → 1 winner)
   - go test -race 零告警

测试:taskmonitor 62 PASS(含 -race),go vet 零告警
2026-07-28 09:35:35 +08:00
czy 61eb6d4b73 TM-04 fix: tmp.Close() 返回值检查 + tmp.Sync() 补全 2026-07-27 20:49:00 +08:00
czy 059459c22e TM-04 fix: FileStore.SaveTask 原子写入(temp file + rename) 2026-07-27 20:46:31 +08:00
czy 2830e5c297 TM-04 fix: CLI 控制命令使用传入的 store 而非空 InMemoryStore
阻塞修复:
- taskStopCmd/CancelCmd/ResumeCmd: store.(WriteStore) 类型断言
- taskOpenSessionCmd: 直接使用 Store.GetTask(只读操作)
- 删除残留的不可达代码

79 PASS,go vet 零告警
2026-07-27 20:46:00 +08:00
czy 549089b2e4 TM-04: ControlService 测试 + CLI 控制命令
internal/taskmonitor/control_test.go (10 tests):
- Stop/Cancel 成功 + 版本冲突 + 不存在 + 终态守卫 + 无效转换
- 幂等键 + 幂等冲突 + OpenSession + 审计事件

internal/cli/task.go:
- task stop/cancel/resume <id> --expected-version N --json [--dir/--reason/--idempotency-key]
- task open-session <id> --json [--dir]
- outputControlResult 统一 JSON 输出

测试:taskmonitor 54 + cli 25 = 79 PASS
2026-07-27 20:42:37 +08:00
czy bfd79841da TM-04: 领域模型 — Version/CAS + ControlService + WriteStore
internal/taskmonitor/:
- model.go: TaskSnapshot 增加 Version uint64 字段
- store.go: 新增 WriteStore 接口(SaveTask/SaveEvent)
- control.go: ControlService(Stop/Cancel/Resume/OpenSession)
  + ControlResult/CtrlError 稳定错误码
  + 乐观锁 CAS(expected_version)
  + 幂等键支持
  + 终态守卫 + ValidTransition 校验
  + 审计事件自动记录
- memory.go: SaveTask(version CAS)/ SaveEvent 实现
- jsonstore.go: SaveTask/SaveEvent 文件持久化

测试:taskmonitor 44 + cli 25 = 69 PASS(零回归)
2026-07-27 20:40:17 +08:00
czy 63f50392f7 TM-03 fix: package.json 添加 test 脚本 2026-07-27 20:11:45 +08:00
czy 852518a60d TM-03 fix: 删除 test.tsx 未使用的 act 导入 2026-07-27 19:52:17 +08:00
czy 0d53bf2dcf TM-03: React 组件测试 — TaskMonitorPanel
新增 src/components/TaskMonitorPanel.test.tsx (11 tests):
- 面板头部渲染
- 空态展示
- fetch 失败错误态
- 任务列表 + 状态徽章
- 展开/收起详情
- 事件加载
- 事件错误态
- onClose 回调
- 刷新按钮
- 任务计数徽章
- 终态图标

配置: vite.config.ts 添加 vitest/jsdom, test-setup.ts

Go: 69 PASS, go vet 零告警
前端: 11/11 vitest PASS, tsc 零错误
2026-07-27 18:02:46 +08:00
czy 3ea586c2ec TM-03 fix: 事件加载 + 嵌套 button 修复 + 前端门禁
1. 展开任务时调用 ListTaskEvents 加载事件
   - afterSequence 增量游标,防重复 merge
   - 事件加载/错误/空态处理
   - 轮询时自动拉取已展开任务的新事件
   - eventSummary 格式化显示

2. 消除嵌套 button:
   - 外层 head 改为 div,toggle 独立 button
   - 任务行 head 改为 div,expand 独立 button
   - 刷新/关闭按钮保持独立 button

3. CSS 更新:新增 .taskmonitor__toggle/.expand/.events/.event-* 规则

4. 辅助修复:elapsed() NaN 防护、safeStateClass 净化

5. 前端门禁:tsc --noEmit 零错误
2026-07-27 17:42:43 +08:00
czy 69c5100313 TM-03 fix: projectDir 改用 os.Getwd() + CSS 样式
阻塞修复:
1. projectDir() 从 SessionDir() 改为 os.Getwd(),正确指向项目根目录
2. styles.css 添加 .taskmonitor 全部面板样式(布局/颜色/动画)

测试:69 PASS,go vet 零告警
2026-07-27 17:30:28 +08:00
czy 9e043ef51b TM-03: Desktop 只读 Task Monitor 面板
Go backend:
- desktop/app.go: ListTasks/GetTask/ListTaskEvents bridge 方法
  + taskStore() 初始化 FileStore('.reasonix/tasks')
  + projectDir() 获取当前 session 目录

Frontend:
- types.ts: TaskState/TaskSnapshot/TaskEvent 类型定义
- bridge.ts: AppBindings 接口 + mock 实现
- TaskMonitorPanel.tsx: 任务列表+详情组件
  - 7 种状态颜色/无障碍标签
  - 空态/加载态/错误态
  - 5s 自动刷新 + 手动刷新
  - 展开/收起任务详情
  - 终态标记/耗时显示
- App.tsx: 侧边栏 Tasks 按钮 + 面板集成

Go 测试:taskmonitor 44 + cli 25 = 69 PASS
前端:TypeScript 待 npm install 后验证
2026-07-27 17:23:59 +08:00
czy 00d80e68c4 TM-02 fix: CLI 默认 FileStore + 集成测试
1. taskCommand 默认 Store 从 InMemoryStore 改为 FileStore('.reasonix/tasks')
2. 新增 7 个 FileStore 集成测试:写入真实文件后执行 CLI 命令断言输出
   - list/status/events 读取、not found、JSONL、游标、空项目
3. 测试注入路径保留(SetTaskStore)
4. --follow JSON/JSONL 输出格式已通过集成测试验证

测试:taskmonitor 44 + cli 25 = 69 PASS
2026-07-27 17:08:38 +08:00
czy 856f9c4375 TM-02 fix: 补充 '..' taskID 拒绝测试
Review 阻塞项:TestFileStore_RejectsDotDotTaskID。
测试:taskmonitor 44 + cli 18 = 62 PASS
2026-07-27 16:57:54 +08:00
czy ab1d4b852c TM-02 fix: 路径穿越防护、死代码清理、FileStore 测试
修复:
1. FileStore 路径穿越:safeTaskID(拒绝空/./../路径分隔符)
   + taskRoot(拒绝含 '..' 的 projectDir)
2. 移除 task.go 死代码 'first' 变量
3. 新增 jsonstore_test.go(8 个测试):读路径、JSONL 游标、
   路径穿越拒绝、空 ID 拒绝

测试:taskmonitor 43 + cli 18 = 61 PASS
2026-07-27 16:53:41 +08:00
czy dcb3e4067c TM-02: CLI/机器接口 — task list/status/events --json
新增:
- internal/cli/task.go: taskCommand 含 list/status/events 子命令
  - --json(缩进 JSON)/ --jsonl(JSONL 流)
  - --dir 项目范围隔离
  - --after 事件游标 / --follow 轮询(含 SIGINT 退出)
  - schema_version=1,含 task_id/session_id/state/timestamps/error_code
- internal/taskmonitor/jsonstore.go: FileStore 生产级 Store 实现
- internal/cli/task_test.go: 18 个 CLI 集成测试
  - JSON Schema、空列表、不存在、项目隔离、游标、JSONL 格式、
    脱敏、缺少参数错误码
- cli.go: 注册 'task' 命令

验收:53 测试全通过(taskmonitor 35 + cli 18),go vet 零告警。
2026-07-27 16:47:59 +08:00
czy 2411978208 TM-01 fix: 项目隔离、事件校验、ValidTransition 终态 bug
修复两个阻断问题:
1. GetTask/ListEvents 增加 projectDir 参数,InMemoryStore 项目作用域校验
2. AppendEvent 增加单调递增/重复拒绝/终态拒绝/SessionID 一致性校验

额外修复:
3. ValidTransition 终态→未知状态泄漏 bug(terminal 守卫优先于 forward-compat 门)
4. Store 方法入口增加 ctx.Err() 检查

测试:35 PASS(含项目隔离、事件校验、context 取消等 7 个新增测试)
2026-07-27 16:33:30 +08:00
czy 7a576895eb TM-01: Task Monitor 领域模型与只读查询
新增 internal/taskmonitor/ 包:
- TaskState 枚举(7 状态 + 前向兼容未知状态)
- TaskSnapshot / TaskEvent 脱敏领域模型(含字段长度上限防御)
- Store 只读接口(ListTasks/GetTask/ListEvents + 游标 + 项目隔离)
- InMemoryStore 线程安全参考实现
- 28 个单元测试覆盖状态转换、事件顺序、脱敏、项目隔离等

验收:go test/vet/fmt 全通过,零修改现有代码。
2026-07-27 16:25:29 +08:00
1494 changed files with 157001 additions and 51155 deletions

No files matched your search

+1
View File
@@ -3,3 +3,4 @@
# Full tests run in CI; vet is the fast, cross-platform local check.
set -e
go vet ./...
go run ./tools/repolint
-3
View File
@@ -7,10 +7,7 @@
/.signpath/ @SivanCola @esengine
/.github/workflows/release-stable.yml @SivanCola @esengine
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
/.github/workflows/release-preview.yml @SivanCola @esengine
/.github/workflows/release-cli-trigger.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/.github/workflows/release-desktop-trigger.yml @SivanCola @esengine
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
/scripts/package-windows-desktop.sh @SivanCola @esengine
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
+10
View File
@@ -15,6 +15,16 @@ release workflow then asks that reporter to verify once the fix ships.
-
## Documentation impact
Documentation-impact: TODO
For changes to user-visible CLI, Desktop, configuration, provider, permission,
or tool behavior, use one of:
- `Documentation-impact: updated - <what changed>` and update `docs/*.md`.
- `Documentation-impact: none - <why the embedded documentation remains correct>`.
## Cache impact
Cache-impact: TODO
+159 -43
View File
@@ -29,13 +29,14 @@ jobs:
code: ${{ steps.filter.outputs.code }}
desktop: ${{ steps.filter.outputs.desktop }}
site: ${{ steps.filter.outputs.site }}
sdk: ${{ steps.filter.outputs.sdk }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: filter
run: |
code=true; desktop=true; site=true
code=true; desktop=true; site=true; sdk=true
base=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
@@ -45,7 +46,7 @@ jobs:
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
files=$(git diff --name-only "$base" HEAD)
if [ -n "$files" ]; then
code=false; desktop=false; site=false
code=false; desktop=false; site=false; sdk=false
# Root-module CI: desktop/ is a separate module, so only the
# clearly unrelated paths below can skip it.
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|desktop/|workers/|[^/]+\.md$)'; then code=true; fi
@@ -53,9 +54,13 @@ jobs:
# so only this clearly-unrelated set is safe to skip.
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|workers/|benchmarks/|npm/|[^/]+\.md$)'; then desktop=true; fi
if echo "$files" | grep -q '^site/'; then site=true; fi
# sdk/go is a nested module invisible to root `go test ./...`;
# its DTOs are generated from internal/extension/protocol, so
# both paths must trigger the sdk job.
if echo "$files" | grep -qE '^(sdk/|internal/extension/)'; then sdk=true; fi
fi
fi
{ echo "code=$code"; echo "desktop=$desktop"; echo "site=$site"; } >> "$GITHUB_OUTPUT"
{ echo "code=$code"; echo "desktop=$desktop"; echo "site=$site"; echo "sdk=$sdk"; } >> "$GITHUB_OUTPUT"
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
@@ -79,7 +84,7 @@ jobs:
uses: actions/checkout@v7
- if: env.RUN_STEPS == 'true'
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -129,15 +134,35 @@ jobs:
run: go test ./...
# Pull requests run a Windows smoke suite: the packages that actually
# carry *_windows.go code, plus cmd/. The full ./... sweep stays on
# pushes to main-v2; the Unix legs always run the full suite.
# carry *_windows.go code, the startup/checkpoint packages with portable
# filesystem contracts, plus cmd/. The full ./... sweep stays on pushes
# to main-v2; the Unix legs always run the full suite. Keep the job-level
# budget above normal 7-9 minute runner variance; each package test binary
# remains independently bounded by Go's 3 minute timeout below.
- name: test (Windows smoke)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 10
timeout-minutes: 15
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./internal/agent/... ./internal/cli/... ./internal/control/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
run: go test -p 4 -timeout=3m ./internal/agent/... ./internal/boot/... ./internal/checkpoint/... ./internal/cli/... ./internal/control/... ./internal/desktoplauncher/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
# The general Windows PR smoke list intentionally omits internal/tool.
# Keep the session-temp portability contract covered without widening the
# leg to every tool test: two real PowerShell launches must share the
# injected TMPDIR/TMP/TEMP directory.
- name: test (Windows session temp)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -timeout=2m -run '^TestBashSharesSessionTempAcrossCalls$' ./internal/tool/builtin
# Shell execution contract: PowerShell identity, Chinese workspace paths,
# UTF-8 output, and ExitCode retention must gate PRs on native Windows.
# Keep this focused (not full ./internal/tool) so the smoke budget holds.
- name: test (Windows shell execution contract)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
run: go test -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tool/builtin
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
@@ -152,6 +177,12 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./...
- name: test (Scoop desktop launch)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
timeout-minutes: 10
shell: powershell
run: .\scripts\test-scoop-desktop-launch.ps1
race:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
@@ -159,7 +190,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -186,7 +217,7 @@ jobs:
if: github.event_name == 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./internal/agent/... ./internal/plugin/... ./internal/jobs/... ./internal/proc/... ./internal/sandbox/... ./internal/filelock/... ./internal/eventwire/... ./internal/remote/...
run: go test -race ./internal/agent/... ./internal/plugin/... ./internal/jobs/... ./internal/proc/... ./internal/sandbox/... ./internal/filelock/... ./internal/eventwire/... ./internal/remote/... ./internal/extension/... ./internal/boot/... ./internal/control/... ./internal/tool/...
- name: test -race (full)
if: github.event_name != 'pull_request'
@@ -194,6 +225,65 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./...
# sdk/go is a nested stdlib-only module invisible to root `go test ./...`.
# Its DTOs are generated from internal/extension/protocol, and the
# host-side conformance tests spawn the SDK example, so the job runs the
# same three-OS matrix as the root tests.
sdk:
needs: changes
if: always()
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.sdk != 'false' }}
defaults:
run:
working-directory: sdk/go
steps:
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@v7
- if: env.RUN_STEPS == 'true'
uses: actions/setup-go@v6
with:
go-version-file: sdk/go/go.mod
- name: gofmt
if: env.RUN_STEPS == 'true'
shell: bash
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: vet
if: env.RUN_STEPS == 'true'
run: go vet ./...
- name: stdlib-only guard
if: env.RUN_STEPS == 'true'
shell: bash
run: |
# The SDK is a public module with a hard stdlib-only contract.
if go list -m all | grep -v '^github.com/esengine/DeepSeek-Reasonix/sdk/go$'; then
echo "sdk/go must not depend on anything outside the standard library"
exit 1
fi
- name: test
if: env.RUN_STEPS == 'true'
run: go test ./...
- name: test -race
if: github.event_name != 'pull_request' && env.RUN_STEPS == 'true'
run: go test -race ./...
desktop:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
@@ -204,18 +294,18 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
@@ -256,8 +346,13 @@ jobs:
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
- name: Test integrated terminal frontend
run: pnpm --dir frontend test:terminal
- name: Test desktop frontend
run: |
pnpm --dir frontend test:terminal
pnpm --dir frontend test:task-monitor
- name: Test usage statistics frontend
run: pnpm --dir frontend test:usage-stats
- name: vet
run: go vet ./...
@@ -275,6 +370,12 @@ jobs:
- name: test
run: go test ./...
# The extension work added new shared-state paths to the desktop
# runtime (tab/rebuild fences, extension UI). Race-sweep the module so
# regressions are CI-blocked, not just author-verified locally.
- name: test -race
run: go test -race ./...
# desktop/ is a separate module, so the root macOS matrix above does not
# compile or exercise the native PTY implementation.
desktop-macos:
@@ -287,7 +388,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
@@ -296,6 +397,16 @@ jobs:
- name: Test integrated terminal and PTY lifecycle
run: go test -race -run 'Test(ResolveTerminal|Terminal|EmptyTerminal|UnixTerminalProcess)' .
# desktop/*_darwin.go is the one build-tag set the lint job cannot reach:
# the main-thread watchdog is cgo, so it only type-checks with a real
# macOS toolchain.
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
working-directory: desktop
args: --timeout=5m
# Desktop project-root matching is case-insensitive only on Windows
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
# regression tests for that contract are named *OnWindows and can never
@@ -311,18 +422,18 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
@@ -357,53 +468,58 @@ jobs:
timeout-minutes: 20
run: ../scripts/desktop-build.sh windows/amd64 v0.0.0-ci canary
# repolint scans desktop/ and sdk/ too, so this job also runs for diffs the
# `code` filter would otherwise skip.
lint:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' || needs.changes.outputs.desktop != 'false' || needs.changes.outputs.sdk != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: repo standards
run: go run ./tools/repolint
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
args: --timeout=5m
# Report-only complexity monitor for internal/agent. Does not duplicate the
# main lint/test/race gates; funlen+cyclop run with issues-exit-code=0 so
# the baseline is visible as a CI summary/artifact without blocking merges.
- name: agent complexity report
if: always()
# The step above only type-checks the linux/amd64 build, so every
# //go:build windows and //go:build darwin file in the tree went unlinted.
# Both modules cross-check without a toolchain; desktop under darwin does
# not, because its watchdog is cgo, so that leg lives in desktop-macos.
- name: golangci-lint (cross-platform build tags)
run: |
# Reuse the golangci-lint binary installed by the previous step.
bash scripts/agent-complexity-report.sh
- name: upload agent complexity report
if: always()
uses: actions/upload-artifact@v7
with:
name: agent-complexity-report
path: agent-complexity-report.txt
if-no-files-found: ignore
retention-days: 14
set -euo pipefail
command -v golangci-lint
for target in "darwin ." "windows ." "windows desktop"; do
set -- $target
echo "::group::golangci-lint GOOS=$1 ($2)"
(cd "$2" && GOOS="$1" golangci-lint run --timeout=5m ./...)
echo "::endgroup::"
done
- name: release workflow contracts
run: |
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/release-preview.yml \
.github/workflows/release-cli-trigger.yml \
.github/workflows/prepare-release-notes.yml \
.github/workflows/release-stable-trigger.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml
.github/workflows/release-desktop.yml \
.github/workflows/release-verify-issues.yml \
.github/workflows/docs-impact.yml
bash scripts/release-workflows.test.sh
node scripts/check-single-release-public-contract.mjs
# The site/ auth client has security-sensitive redirect-validation logic
# (safeNext) covered by node:test unit tests. Those tests use only Node
@@ -419,7 +535,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
@@ -434,7 +550,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -452,7 +568,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
+2 -2
View File
@@ -40,11 +40,11 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v4.37.4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v4.37.4
with:
category: "/language:${{ matrix.language }}"
+4 -2
View File
@@ -20,11 +20,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
@@ -35,6 +35,8 @@ jobs:
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
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
+4 -2
View File
@@ -20,11 +20,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
@@ -35,4 +35,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler deploy
+39
View File
@@ -0,0 +1,39 @@
name: Documentation impact guard
on:
pull_request_target:
branches: [main-v2]
types: [opened, synchronize, reopened, edited, ready_for_review]
permissions:
contents: read
pull-requests: read
concurrency:
group: docs-impact-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
docs-impact:
runs-on: ubuntu-latest
steps:
# Execute only the trusted base-branch checker; never run contributor code
# from a pull_request_target workflow.
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Collect PR changed files
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \
--jq '.[].filename' > "$RUNNER_TEMP/docs-impact-files.txt"
- name: Check documentation impact declaration
env:
PR_BODY: ${{ github.event.pull_request.body }}
DOCS_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/docs-impact-files.txt
run: bash scripts/check-docs-impact.sh
+37 -6
View File
@@ -46,15 +46,27 @@ jobs:
with:
fetch-depth: 0
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Install and verify Linux sandbox backend
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --dev /dev --proc /proc -- true
- name: Build harness + fallback agent from the default branch
# Harness (e2ebench) and suite always come from main-v2 so a PR can't weaken
# its own grader or tests. The agent is rebuilt from the PR head below; this
@@ -93,10 +105,12 @@ jobs:
echo "agent under test: $src"
- name: Write provider config
env:
REASONIX_HOME: ${{ runner.temp }}/reasonix-e2e-home
# User config covers suite tasks (they run in temp dirs); the repo-root copy
# covers diff mode (the agent runs in the repo root, where project config wins).
run: |
mkdir -p ~/.config/reasonix
mkdir -p "$REASONIX_HOME"
cat > /tmp/reasonix-e2e.toml <<EOF
default_model = "e2e"
@@ -120,19 +134,25 @@ jobs:
[codegraph]
enabled = false
EOF
cp /tmp/reasonix-e2e.toml ~/.config/reasonix/config.toml
cp /tmp/reasonix-e2e.toml "$REASONIX_HOME/config.toml"
cp /tmp/reasonix-e2e.toml ./reasonix.toml
- name: Run e2e
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
REASONIX_HOME: ${{ runner.temp }}/reasonix-e2e-home
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
if [ -z "$DEEPSEEK_API_KEY" ]; then
printf 'missing DEEPSEEK_API_KEY secret\n\nAdd a repository secret named DEEPSEEK_API_KEY to enable the e2e bot.\n' > report.md
exit 0
exit 1
fi
# Provider credentials are deliberately loaded only from Reasonix's
# global credential file, not inherited process variables. Keep this
# ephemeral runner copy private and outside the checked-out project.
umask 077
printf 'DEEPSEEK_API_KEY=%s\n' "$DEEPSEEK_API_KEY" > "$REASONIX_HOME/.env"
if printf '%s' "$COMMENT_BODY" | grep -q '/e2e[[:space:]]\+diff'; then
ATTEMPTS=$(printf '%s' "$COMMENT_BODY" | sed -nE 's@.*/e2e[[:space:]]+diff[[:space:]]+x([0-9]+).*@\1@p' | head -1)
[ -z "$ATTEMPTS" ] && ATTEMPTS=1
@@ -142,10 +162,21 @@ jobs:
BASE=$(git merge-base "origin/$BASE_REF" HEAD)
"$RUNNER_TEMP/e2ebench" -mode diff -bin "${{ steps.agent.outputs.bin }}" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md
else
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000
# The current five-task baseline can exceed 400k after only three
# successful tasks. Keep bounded headroom so every scenario is graded.
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 800000
node -e '
const results = require("./report.json");
const unsuccessful = results.filter((result) => !result.Passed || result.Skipped);
if (results.length === 0 || unsuccessful.length > 0) {
console.error(`e2e suite incomplete: ${unsuccessful.length}/${results.length} unsuccessful`);
process.exit(1);
}
'
fi
- name: Post report
if: always() && hashFiles('report.md') != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TRIGGER_USER: ${{ github.event.comment.user.login }}
+19 -4
View File
@@ -22,14 +22,29 @@ jobs:
with:
script: |
const body = context.payload.issue.body || '';
// Pull the "Version line" section out of the rendered issue form.
const section = body.split(/^###\s+/m).find(s => /^Version line/i.test(s)) || '';
const m = section.match(/\bv([12])\b/i);
const sectionOf = (name) =>
body.split(/^###\s+/m).find((s) => new RegExp(`^${name}`, 'i').test(s)) || '';
const m = sectionOf('Version line').match(/\bv([12])\b/i);
if (!m) {
core.info('No version line found; nothing to label.');
return;
}
const choice = 'v' + m[1];
let choice = 'v' + m[1];
// The dropdown is a choice, and v2 sits first, so it gets picked by
// reporters who are actually on 0.x. `Exact version` is pasted from
// `reasonix --version`, so when it parses it is the better evidence:
// 0.x is the TypeScript line, 1.x the Go rewrite. Anything that does
// not parse (a commit sha, "main-v2") leaves the dropdown to decide.
const exact = sectionOf('Exact version').match(/\b(\d+)\.(\d+)\.(\d+)/);
if (exact) {
const fromVersion = Number(exact[1]) === 0 ? 'v1' : 'v2';
if (fromVersion !== choice) {
core.info(`Version line says ${choice} but ${exact[0]} says ${fromVersion}; trusting the version.`);
choice = fromVersion;
}
}
const other = choice === 'v2' ? 'v1' : 'v2';
await github.rest.issues.addLabels({
...context.repo,
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
+1 -1
View File
@@ -23,6 +23,6 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
- uses: actions/labeler@v7
with:
sync-labels: false
+24 -55
View File
@@ -1,20 +1,12 @@
name: Prepare release notes
name: Prepare release
on:
workflow_dispatch:
inputs:
version:
description: "Exact release version without a tag prefix (for example 1.18.0 or 1.19.0-preview.3)"
description: "Official version without a tag prefix (for example 1.20.0)"
required: true
type: string
from_tag:
description: "Previous release tag (defaults to the newest catalog entry)"
required: false
type: string
target_pr:
description: "Optional open same-repository PR into main-v2 to reuse instead of opening a release-only PR"
required: false
type: string
permissions:
contents: write
@@ -29,58 +21,31 @@ jobs:
with:
fetch-depth: 0
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
VERSION: ${{ inputs.version }}
TARGET_PR: ${{ inputs.target_pr }}
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 [ -n "$TARGET_PR" ]; then
if [[ ! "$TARGET_PR" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::target_pr must be a positive pull request number"
exit 1
fi
mapfile -t pr_state < <(
gh pr view "$TARGET_PR" --repo "$REPOSITORY" \
--json state,baseRefName,headRefName,isCrossRepository \
--jq '[.state, .baseRefName, .headRefName, (.isCrossRepository | tostring)][]'
)
if [ "${pr_state[0]}" != "OPEN" ] || [ "${pr_state[1]}" != "main-v2" ]; then
echo "::error::target_pr must be an open pull request into main-v2"
exit 1
fi
if [ "${pr_state[3]}" != "false" ]; then
echo "::error::target_pr comes from a fork; the repository token cannot safely update that branch"
exit 1
fi
branch="${pr_state[2]}"
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"
if ! git merge-base --is-ancestor origin/main-v2 HEAD; then
echo "::error::target_pr must include the latest main-v2 before release notes are generated"
exit 1
fi
echo "RELEASE_NOTES_PR=$TARGET_PR" >> "$GITHUB_ENV"
git merge --no-edit origin/main-v2
else
branch="release-notes/v${VERSION#v}"
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_PR=" >> "$GITHUB_ENV"
git checkout -b "$branch"
fi
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
@@ -90,12 +55,9 @@ jobs:
DEEPSEEK_MODEL: deepseek-v4-pro
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
FROM_TAG: ${{ inputs.from_tag }}
run: |
set -euo pipefail
args=(--version "$VERSION")
if [ -n "$FROM_TAG" ]; then args+=(--from "$FROM_TAG"); fi
node scripts/generate-release-notes.mjs "${args[@]}"
node scripts/generate-release-notes.mjs --version "$VERSION"
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
@@ -121,12 +83,19 @@ jobs:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
fi
git push -u origin "$RELEASE_NOTES_BRANCH"
if [ -n "$RELEASE_NOTES_PR" ]; then
echo "Updated existing release-bound PR #$RELEASE_NOTES_PR"
elif ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
gh pr create \
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
--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
-39
View File
@@ -1,39 +0,0 @@
name: Relay CLI prerelease
run-name: Relay CLI prerelease ${{ github.ref_name }}
# Keep native CLI publication on protected main-v2. The tag-triggered workflow
# carries only the immutable candidate tag and its classified channel into the
# protected release control plane.
on:
push:
tags: ["v*-*"]
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected CLI control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release.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;
}
const tag = process.env.RELEASE_TAG;
const preview = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(tag);
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: preview ? 'release-preview.yml' : 'release.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: preview ? { tag } : { channel: 'stable', tag },
});
@@ -1,43 +0,0 @@
name: Relay desktop prerelease
run-name: Relay desktop prerelease ${{ github.ref_name }}
# Keep the production signing build on protected main-v2. The tag-triggered
# relay carries only the immutable candidate tag into the protected workflow.
on:
push:
tags: ["desktop-v*-*"]
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected desktop control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release-desktop.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;
}
if (/^desktop-v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(process.env.RELEASE_TAG)) {
core.setFailed(`Desktop Preview must be dispatched without a Git tag: ${process.env.RELEASE_TAG}`);
return;
}
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release-desktop.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: {
channel: 'stable',
tag: process.env.RELEASE_TAG,
},
});
+44 -27
View File
@@ -1,10 +1,8 @@
name: Release desktop
# Desktop (Wails) release line. Stable releases are called by
# release-stable.yml after its single GitHub environment approval. Desktop
# prerelease tags are relayed by release-desktop-trigger.yml to this workflow on
# protected main-v2. Production SignPath requests therefore always have the
# exact main-v2 origin; both public channels use the production policy.
# Desktop (Wails) release line. Official releases are called by
# release-stable.yml after its single GitHub environment approval. Production
# SignPath requests therefore have the exact protected main-v2 origin.
#
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
@@ -12,27 +10,21 @@ name: Release desktop
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
#
# The same build/sign/manifest pipeline serves two channels. Approved
# orchestrators publish Stable or Preview, while manual dispatch supports
# Stable/RC recovery and non-publishing Preview signing checks. Preview advances
# only R2's preview/ pointer and never appears on the GitHub releases page. The
# legacy canary/ pointer is mirrored for clients released before the rename.
# Historical workflow-call inputs still understand Preview artifacts so old
# runs remain diagnosable. They are not exposed by manual dispatch and cannot
# create a new public Preview release.
on:
workflow_dispatch:
inputs:
channel:
description: "Release channel"
description: "Recovery channel"
type: choice
options: [stable, preview]
options: [stable]
default: stable
tag:
description: "stable: tag to publish (e.g. desktop-v1.1.0)"
required: false
type: string
base_version:
description: "Preview signing checks: intended next stable version, e.g. 1.5.0"
required: false
type: string
production_signing_smoke:
description: "Wait for external SignPath approval, verify trust, and do not publish"
required: false
@@ -73,12 +65,12 @@ on:
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
preview_number:
description: "Canonical Preview ordinal supplied by release-preview.yml"
description: "Legacy Preview ordinal for old workflow-call compatibility"
required: false
default: ""
type: string
@@ -213,7 +205,7 @@ jobs:
# execute, including during recovery of an older candidate.
ref: ${{ github.sha }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -261,7 +253,7 @@ jobs:
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -269,6 +261,17 @@ jobs:
- name: Cache hit guard
run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
env:
DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.version }}
DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }}
run: |
if [ ! -f scripts/verify-embedded-docs.sh ]; then
echo "Legacy candidate predates the embedded docs contract; skipping."
exit 0
fi
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
build:
name: build (${{ matrix.name }})
needs: [resolve, cache-guard]
@@ -299,16 +302,16 @@ jobs:
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
@@ -501,12 +504,26 @@ jobs:
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
run: cp signed-installer/*installer*.exe dist/
# Recovery builds intentionally check out the immutable product SHA,
# which can predate a verifier repair. Fetch only the verifier from the
# protected workflow commit so recovery can validate old immutable
# artifacts without rebuilding them from a moving product branch.
- name: Checkout protected release verifier
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
path: release-control
sparse-checkout: scripts/verify-windows-authenticode.ps1
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Verify Windows Authenticode release contract
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: pwsh
run: |
$arch = if ("${{ matrix.platform }}" -eq "windows/arm64") { "arm64" } else { "amd64" }
./scripts/verify-windows-authenticode.ps1 `
./release-control/scripts/verify-windows-authenticode.ps1 `
-PayloadDirectory signed-payload `
-InstallerPath "dist/Reasonix-windows-$arch-installer.exe" `
-PortableArchivePath "dist/Reasonix-windows-$arch.zip" `
@@ -569,13 +586,13 @@ jobs:
VERIFY_RELEASE_CHECKOUT: true
run: bash release-control/scripts/resolve-desktop-candidate.sh
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
@@ -712,7 +729,7 @@ jobs:
path: release-control
ref: ${{ github.workflow_sha }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: release-control/desktop/go.mod
cache: true
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- run: node scripts/release-notes.mjs validate
+47 -26
View File
@@ -1,23 +1,11 @@
name: Release npm
# npm line. Stable releases are called by release-stable.yml after its single
# GitHub environment approval. Direct tag pushes are retained only for
# prereleases so npm `next` can still ship independently without touching the
# stable Homebrew cask. Bump a prerelease with:
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
# git push origin <tag>
#
# Manual workflow_dispatch recovers only an approved Stable version from an
# existing tag. Public canary publication is owned by release-preview.yml so its
# version remains aligned with CLI, Desktop, and the immutable release marker.
# build.mjs decides the dist-tag: a `-canary.` build is `canary`, any other
# prerelease is `next`, and a stable version is `latest` (#5822 — the old
# "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
# `npm update -g` users). The publisher verifies every immutable package and
# advances each public dist-tag only when the candidate is newer.
# GitHub environment approval. Direct prerelease tag publication is disabled;
# manual workflow_dispatch recovers only an approved Stable version from an
# existing tag. Historical canary and next package identities remain readable,
# but normal publication advances only the official release.
on:
push:
tags: ['npm-v*-*']
workflow_dispatch:
inputs:
channel:
@@ -64,12 +52,12 @@ on:
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
preview_number:
description: "Canonical Preview ordinal supplied by release-preview.yml"
description: "Legacy Preview ordinal for old workflow-call compatibility"
required: false
default: ""
type: string
@@ -78,8 +66,8 @@ permissions:
contents: read
concurrency:
# Serialize every publisher for one npm dist-tag. In particular, canary
# remains ordered even if another approved orchestrator is added later.
# Serialize every publisher for one npm dist-tag. Historical canary calls
# remain ordered even though normal publication now uses Stable only.
group: release-npm-${{ inputs.channel || 'next' }}
cancel-in-progress: false
@@ -132,11 +120,23 @@ jobs:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
env:
DOCS_BUILD_VERSION: v${{ inputs.base_version }}
DOCS_SOURCE_REVISION: ${{ inputs.approved_sha }}
run: |
if [ ! -f scripts/verify-embedded-docs.sh ]; then
echo "Legacy candidate predates the embedded docs contract; skipping."
exit 0
fi
revision="$DOCS_SOURCE_REVISION"
if [ -z "$revision" ]; then revision="$(git rev-parse HEAD)"; fi
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$revision"
npm:
name: publish npm packages
@@ -149,17 +149,27 @@ jobs:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
- name: Load approved standalone recovery control plane
if: ${{ !inputs.orchestrated }}
env:
RECOVERY_CONTROL_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "$RECOVERY_CONTROL_SHA"
git restore --source="$RECOVERY_CONTROL_SHA" -- \
npm/publish.mjs \
scripts/finalize-npm-official-release.mjs
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# Tag push uses the npm-v* tag. The Preview orchestrator synthesizes a
# <base>-canary.<preview_number> version; a manual Stable dispatch publishes
# the requested semver exactly. build.mjs strips the leading `npm-`/`v`.
# 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.
- name: Resolve version
id: ver
env:
@@ -183,3 +193,14 @@ jobs:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/build.mjs "$VERSION_ARG" --publish
- name: Align legacy aliases with the official release
if: ${{ inputs.channel == 'stable' }}
env:
VERSION: ${{ inputs.base_version }}
APPROVED_SHA: ${{ inputs.approved_sha }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
candidate="$APPROVED_SHA"
if [ -z "$candidate" ]; then candidate="$(git rev-parse HEAD)"; fi
EXPECTED_SHA="$candidate" node scripts/finalize-npm-official-release.mjs "$VERSION"
-219
View File
@@ -1,219 +0,0 @@
name: Release preview
run-name: Release preview ${{ inputs.tag }}
# One protected Preview event owns the public CLI, Desktop, npm, and changelog
# identities. The CLI tag supplies the canonical preview ordinal; child
# publishers retain their native version syntax but use that same ordinal.
on:
workflow_dispatch:
inputs:
tag:
description: "Existing protected CLI Preview tag, e.g. v1.19.0-preview.3"
required: true
type: string
recovery:
description: "Recover an existing partially published Preview from main-v2 history"
required: false
default: false
type: boolean
concurrency:
# Every Preview version advances the same public CLI, Desktop, and npm
# channels. Keep the whole all-surface event serialized across versions.
group: preview-release
cancel-in-progress: false
permissions:
actions: write
contents: write
jobs:
preflight:
name: validate Preview release event
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.release.outputs.version }}
base_version: ${{ steps.release.outputs.base_version }}
preview_number: ${{ steps.release.outputs.preview_number }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Resolve Preview release
id: release
env:
RELEASE_TAG: ${{ inputs.tag }}
ALLOW_PREVIEW_RECOVERY: ${{ inputs.recovery }}
run: bash scripts/resolve-preview-release.sh
- name: Validate exact reviewed Preview notes
env:
VERSION: ${{ steps.release.outputs.version }}
RELEASE_SHA: ${{ steps.release.outputs.sha }}
run: |
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
node scripts/release-event.mjs generate \
--version "$VERSION" \
--sha "$RELEASE_SHA" \
--published-at 2000-01-01T00:00:00.000Z \
--output /tmp/release-event.json
- name: Upload reviewed release notes
uses: actions/upload-artifact@v7
with:
name: orchestrator-reviewed-release-notes
path: /tmp/release-notes.md
if-no-files-found: error
retention-days: 1
authorize:
name: approve Preview release
needs: preflight
runs-on: ubuntu-latest
environment: canary
outputs:
version: ${{ steps.approved.outputs.version }}
base_version: ${{ steps.approved.outputs.base_version }}
preview_number: ${{ steps.approved.outputs.preview_number }}
cli_tag: ${{ steps.approved.outputs.cli_tag }}
sha: ${{ steps.approved.outputs.sha }}
steps:
- name: Record approved Preview event
id: approved
env:
VERSION: ${{ needs.preflight.outputs.version }}
BASE_VERSION: ${{ needs.preflight.outputs.base_version }}
PREVIEW_NUMBER: ${{ needs.preflight.outputs.preview_number }}
CLI_TAG: ${{ needs.preflight.outputs.cli_tag }}
RELEASE_SHA: ${{ needs.preflight.outputs.sha }}
run: |
{
echo "version=$VERSION"
echo "base_version=$BASE_VERSION"
echo "preview_number=$PREVIEW_NUMBER"
echo "cli_tag=$CLI_TAG"
echo "sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
echo "Approved Preview release $VERSION at $RELEASE_SHA"
signpath-preflight:
name: verify Preview SignPath control plane
needs: authorize
uses: ./.github/workflows/release-desktop.yml
with:
channel: preview
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
signing_preflight: true
secrets: inherit
cli:
name: publish CLI Preview
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release.yml
with:
channel: preview
tag: ${{ needs.authorize.outputs.cli_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
allow_preview_recovery: ${{ inputs.recovery }}
secrets: inherit
npm:
name: publish npm Canary
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release-npm.yml
with:
channel: canary
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
secrets: inherit
desktop:
name: publish Desktop Preview
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release-desktop.yml
with:
channel: preview
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
signing_preflight_verified: true
secrets: inherit
postflight:
name: publish Preview release record
needs: [authorize, cli, npm, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every Preview publisher to succeed
env:
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
set -euo pipefail
for surface in CLI NPM DESKTOP; do
result_var="${surface}_RESULT"
if [ "${!result_var}" != "success" ]; then
echo "::error::$surface Preview publisher result is ${!result_var}, expected success"
exit 1
fi
done
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Upload immutable public release marker
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.authorize.outputs.version }}
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
RELEASE_SHA: ${{ needs.authorize.outputs.sha }}
run: |
set -euo pipefail
node scripts/release-event.mjs generate \
--version "$VERSION" --sha "$RELEASE_SHA" \
--published-at "$(gh api "repos/${{ github.repository }}/releases/tags/$CLI_TAG" --jq .published_at)" \
--output /tmp/release-event.json
existing="$(mktemp -d)"
if gh release download "$CLI_TAG" --pattern release-event.json --dir "$existing" 2>/dev/null; then
cmp -s /tmp/release-event.json "$existing/release-event.json" || {
echo "::error::published release-event.json differs from the approved Preview event"
exit 1
}
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
+18 -6
View File
@@ -4,9 +4,9 @@ run-name: Release stable ${{ 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 current main-v2 commit, and one GitHub approval releases
# every channel. After approval, a zero-publication Windows preflight verifies
# both architectures and signing stages before any public channel starts.
# 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.
# 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
@@ -54,6 +54,7 @@ jobs:
name: validate stable release set
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
outputs:
version: ${{ steps.release.outputs.version }}
@@ -68,11 +69,11 @@ jobs:
# 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@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Resolve stable release
@@ -81,6 +82,17 @@ jobs:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }}
run: bash scripts/resolve-stable-release.sh
- name: Revalidate normal release candidate and exact push CI
if: ${{ !inputs.allow_recovery }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_CI_WAIT_SECONDS: 60
RELEASE_VERSION: ${{ steps.release.outputs.version }}
RELEASE_SHA: ${{ steps.release.outputs.sha }}
run: |
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
- name: Validate reviewed release notes
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,
@@ -220,7 +232,7 @@ jobs:
# Postflight belongs to the trusted control plane, not the old build
# candidate, which may predate this verifier.
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Verify public artifacts and npm latest
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Test notifier
+23 -15
View File
@@ -1,22 +1,20 @@
name: Release
# Native CLI binary line. Stable and Preview releases are called by their
# approved all-surface orchestrators. Manual dispatch remains available only for
# Stable/RC recovery; standalone prereleases remain internal stable-channel
# checkpoints behind the `release` gate.
# Native CLI binary line. Official releases are called by the protected Stable
# orchestrator. Manual dispatch remains available only for official recovery;
# historical Preview inputs below are workflow-call compatibility, not a public
# publication entrypoint.
on:
workflow_dispatch:
inputs:
channel:
description: "Standalone CLI recovery channel"
required: true
# Public Preview recovery reruns release-preview.yml so every surface and
# its immutable release-event marker stay aligned.
default: stable
type: choice
options: [stable]
tag:
description: "Existing protected Stable/RC CLI tag (for example v1.18.0 or v1.18.0-rc.1)"
description: "Existing official CLI tag (for example v1.18.0)"
required: true
type: string
workflow_call:
@@ -44,12 +42,12 @@ on:
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
allow_preview_recovery:
description: "Allow an approved Preview tag from main-v2 history"
description: "Legacy compatibility for already-created Preview runs"
required: false
default: false
type: boolean
@@ -176,20 +174,30 @@ jobs:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
env:
DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.tag }}
DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }}
run: |
if [ ! -f scripts/verify-embedded-docs.sh ]; then
echo "Legacy candidate predates the embedded docs contract; skipping."
exit 0
fi
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
goreleaser:
name: archives + checksums + homebrew tap
needs: [resolve, cache-guard]
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
# Standalone prereleases pass release-gate above. This job therefore must not
# add a second GitHub environment approval.
# 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
with:
@@ -217,11 +225,11 @@ jobs:
printf 'product checkout is dirty before release:\n%s\n' "$dirty" >&2
exit 1
fi
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Download orchestrator-reviewed release notes
+64
View File
@@ -0,0 +1,64 @@
name: Spam comment guard
# Activity-farming bots post short emoji-padded template comments on whatever
# issue is currently active. They are cheap to spot structurally: a recently
# registered account with no history here, no followers, and a pile of forked
# repos, posting a sub-50 character comment with emoji and no code/link/log.
# Every signal must fire — on 7604 replayed comments that caught all 83 known
# spam posts with no false positive. Loosening any one of them reintroduces
# real users ("点赞", "同样的问题", a 2020 account posting "感谢各位❤").
on:
issue_comment:
types: [created]
permissions:
issues: write
pull-requests: write
concurrency:
group: spam-guard-${{ github.event.comment.id }}
jobs:
screen:
if: github.event.comment.user.type != 'Bot' && github.event.comment.author_association == 'NONE'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
with:
script: |
const EMOJI = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/gu;
const comment = context.payload.comment;
const body = comment.body || '';
const hasSubstance = /```|https?:\/\/|!\[|^>/m.test(body);
const emojiCount = (body.match(EMOJI) || []).length;
const bare = body.replace(EMOJI, '').trim();
const { data: author } = await github.rest.users.getByUsername({
username: comment.user.login,
});
const ageDays = (Date.now() - Date.parse(author.created_at)) / 86400000;
const signals = {
newAccount: ageDays < 365,
noFollowers: author.followers === 0,
forkPadded: author.public_repos >= 15,
tooShort: bare.length <= 45,
emojiPadded: emojiCount >= 1,
noSubstance: !hasSubstance,
};
const failed = Object.entries(signals).filter(([, v]) => !v).map(([k]) => k);
core.info(`${comment.user.login}: ${failed.length ? `kept (${failed.join(',')})` : 'spam'}`);
if (failed.length) return;
await github.graphql(
`mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: SPAM }) {
minimizedComment { isMinimized }
}
}`,
{ id: comment.node_id },
);
core.notice(`Minimized spam comment by ${comment.user.login}`);
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Test sweeper
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Update README contributor tables
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Test chart generator
+3 -1
View File
@@ -71,5 +71,7 @@ temp/
# Official theme art intermediate outputs / caches
scripts/official-theme-art/out/
scripts/official-theme-art/__pycache__/
agent-complexity-report.txt
site/.generated/
# Committed pointer at REASONIX.md; overrides a global CLAUDE.md ignore rule.
!/CLAUDE.md
-40
View File
@@ -1,40 +0,0 @@
# Report-only complexity monitor for internal/agent production code.
# Uses funlen + cyclop with generous thresholds so existing code does not
# generate a flood of noise. issues-exit-code is forced to 0 by the CI step so
# this never blocks merges while we establish a baseline and track trend.
version: "2"
run:
tests: false
linters:
default: none
enable:
- funlen
- cyclop
settings:
funlen:
# Deliberately above a hard 50-line rule: length alone is not the
# acceptance gate. This surfaces only the remaining large hotspots after
# the Run/executeOne extraction so trend can be tracked.
lines: 120
statements: 80
ignore-comments: true
cyclop:
max-complexity: 30
package-average: 0
skip-tests: true
issues:
max-issues-per-linter: 0
max-same-issues: 0
# Report mode: never fail the job. Baseline/trend is the gate, not a hard cut.
# The CI step also passes --issues-exit-code=0 for older golangci-lint builds.
uniq-by-line: false
output:
formats:
text:
path: stdout
print-linter-name: true
print-issued-lines: true
+16
View File
@@ -2,12 +2,23 @@ version: "2"
linters:
enable:
- copyloopvar
- errcheck
- errorlint
- govet
- ineffassign
- intrange
- modernize
- staticcheck
- unused
- usestdlibvars
settings:
modernize:
# omitempty -> omitzero changes which fields are written for nested
# structs. That is a wire-format change to session files and the
# extension protocol, not a refactor, so it lands on its own.
disable:
- omitzero
errcheck:
check-type-assertions: false
exclude-functions:
@@ -15,6 +26,11 @@ linters:
- (io.Closer).Close
- (*net/http.Response).Body.Close
- golang.org/x/term.Restore
# Win32: CloseHandle in a defer mirrors the Close exclusions above, and
# LazyProc.Call always returns a non-nil errno — the caller reads r1.
- golang.org/x/sys/windows.CloseHandle
- (*syscall.LazyProc).Call
- (*golang.org/x/sys/windows.LazyProc).Call
staticcheck:
checks:
- all
+3 -1
View File
@@ -14,7 +14,9 @@ builds:
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{ .Tag }}
# Inject version identity for `reasonix version --verbose/--json` (Integration D/E).
# buildTimeUTC is the goreleaser run time (UTC), not vcs.time.
- -s -w -X main.version={{ .Tag }} -X main.gitCommit={{ .ShortCommit }} -X main.buildTimeUTC={{ .Date }} -X reasonix/internal/productdocs.linkedVersion={{ .Tag }} -X reasonix/internal/productdocs.linkedRevision={{ .Commit }}
goos: [darwin, linux, windows]
goarch: [amd64, arm64]
-4
View File
@@ -6,15 +6,11 @@ allowed_branch_names:
- main-v2
allowed_build_definitions:
- .github/workflows/release-stable.yml
- .github/workflows/release-preview.yml
- .github/workflows/release-desktop.yml
fingerprint_files:
- .github/workflows/release-stable.yml
- .github/workflows/release-stable-trigger.yml
- .github/workflows/release-preview.yml
- .github/workflows/release-cli-trigger.yml
- .github/workflows/release-desktop.yml
- .github/workflows/release-desktop-trigger.yml
- .signpath/artifact-configurations/windows-payload.xml
- .signpath/artifact-configurations/windows-installer-v2.xml
- scripts/complete-signpath-request.ps1
+145 -3
View File
@@ -6,8 +6,114 @@ branch.
## Unreleased
### Fixed
- **Issue #7575:** Linux Bash under bubblewrap no longer mounts a fresh empty
`--tmpfs /tmp` on every call. Consecutive commands in the same logical session
now share a private temporary directory (bound at `/tmp` on Linux, exported via
`TMPDIR`/`TMP`/`TEMP` on all platforms) without exposing the host public
temporary root. `/new`, `/clear`, resume of another session, and branch
switches rotate the directory; model/settings hot rebuilds keep it. Sub-agent
runs get independent directories. Temporary files are not durable across process
restarts.
### Added
- Added `[ui].show_turn_usage` so CLI/TUI users can hide per-request token and
cost receipts from transcript scrollback without disabling usage accounting.
## [1.20.0] — 2026-08-05
Extension kernel, Task Monitor, and safer Goal completion.
Compact decision surfaces, local decision receipts, unified extension kernel,
native Task Monitor, bounded sub-agent progress, Goal fail-closed completion,
MiMo and DashScope Responses fixes, SSH remote access simplification, and
multiple Desktop stability improvements.
### Highlights
- **Unified Extension Kernel and Extension Protocol v1**: Immutable runtime
snapshots, fail-atomic reload, Plugin Manifest v1 (prompts, themes, full-trust
code runtimes), stable JSON-RPC sidecar protocol, interceptor dispatch,
streaming provider adapter, structured UI, and Go SDK.
- **Native Task Monitor**: Monitor agent tasks natively in CLI and Desktop with
lifecycle semantics and session-scoped summary view.
- **Bounded Sub-agent Progress Forwarding**: Forward structured progress for
`task`, `parallel_tasks`, and `fleet` without flooding the parent stream.
Renders nested lifecycle cards in Desktop and stable per-child transcript
slots in CLI.
- **Goal Completion Fail-Closed**: Replace free-form Goal footer markers with a
stable `update_goal` tool and epoch-scoped per-turn reports. Centralized
completion logic with bounded evaluator, progress-aware budgets, and
pause/resume controls.
- **Ablation Subsystem Switches**: Switch subsystems off behind one shared
vocabulary for controlled experiments. Includes planner, subagent, retrieval,
evidence, and compaction.
- **Benchmark Cost per Solved Task**: Report cost per solved task, tokens per
solved, median wall time, and failure-class breakdown in e2e reports.
- **Compact Decision Surfaces and Local Receipts**: Compact footer decision-card
layout with bounded scroll, dense action rows, and overflow disclosure.
Record bounded Ask, approval, and recovery decisions as local transcript
receipts.
- **Simplified SSH Remote Access**: Remove Remote Workbench protocol and
stacks; reuse CLI/Serve remote model. Desktop opens per-host native web child
windows via SSH. Keyless remote Serve setup with loopback-only page.
- **Model Usage Charts with Primer Palette**: Replace monochrome accent ramp
with GitHub Primer data-viz two-set categorical palette. Fix donut overflow
on hover and keyboard accessibility.
- **Cross-platform Extension and Task Monitor Reliability**: Make
content-reference eviction deterministic, reject Unix and Windows absolute
plugin paths consistently, stabilize parallel-task cancellation, and restore
reliable Windows validation for Task Monitor and remote provider setup.
- **MiMo and DashScope Responses Wire Alignment**: Fix multi-turn tool loops,
reasoning round-trip, JSON output for MiMo; fix DashScope second-turn 400
error, all-zero usage suppression, and vendor-aware cache TTL.
- **Desktop Stability Fixes**: Recover stuck updates and legacy WebKit, contain
macOS alias repair startup crashes, keep composer overflow stacks readable,
and harden account verification and community flows.
- **Remote Web Recovery After SSH Drops**: Add integration regression test for
SSH drop, forward recovery, and window reload. Document transient outage
behavior.
- **CI: Auto-minimize Activity-Farming Spam Comments**: Detect and minimize
template spam comments from non-contributor accounts based on structural
signals.
### Added
- Added Extension Protocol v1 and the unified extension kernel: installed or
linked sidecars can contribute tools, skills, commands, hooks, MCP servers,
providers, interceptors, and structured UI surfaces through a versioned
NDJSON contract and the public Go SDK. CLI, Desktop, ACP, and Serve support
fail-atomic runtime reloads; Serve also renders extension surfaces and lists
extension-hosted providers without exposing credentials.
- Added the structured Goal completion protocol: the always-registered
`update_goal` tool (continue/complete/blocked with reason and next_action)
replaces the `[goal:*]` footer markers. The Goal FSM is now the exclusive
cross-turn decision point and validates every complete claim against Delivery
readiness; when the model submits no report, an independent bounded evaluator
(recovery_model → guardian_model → main model, no tools/history, usage
attributed to `goal-evaluator`) judges the turn once, and any evaluator
failure pauses the goal instead of continuing silently.
- Added Goal budget classes with safe pauses: simple 10 turns / 200k tokens,
write 20 turns / 400k tokens, AutoResearch 40 turns / 800k tokens, and a
4-turn no-host-verifiable-progress gate. Pauses keep all Goal state; `/goal
resume` continues and adds one slice of the current class when the pause was
budget-related. `/goal status` shows the full turn/token/no-progress runtime,
and `/goal pause` manually suspends a running Goal.
- Added the `goalRuntime` nested view to the desktop Meta, the remote protocol
(`session/goal/pause` operation, `goalRuntime` DTO on session meta), and the
ACP status payload; the desktop Composer goal menu shows the runtime summary
with distinct pause/end/resume actions.
### Changed
- Delivery no longer retries final-answer readiness with hidden model messages:
a plain Delivery run ends on the first unsatisfied final answer and surfaces
the recovery card, while a Goal + Delivery run has the Goal FSM absorb the
failure and continue under budget with the missing requirements as the next
turn's prompt. Historical `[goal:*]` footers are stripped from old transcripts
for display only and never participate in state decisions.
- Added a **Remote SSH** module (VS Code Remote-SSH style): a user-global
`[remote]` host config, `reasonix remote` CLI (add/list/remove/import/test/
connect/status/forward/serve/fs) and `/remote` slash command, an SSH transport
@@ -20,6 +126,10 @@ branch.
- Added `reasonix serve --port-file/--token-file/--pid-file` so a supervised
headless serve can bind an ephemeral port and read its auth token from a file
(keeping it out of `ps`).
- Added an authenticated, loopback-only Provider setup page for `reasonix
serve`. A Serve whose selected Provider is missing its API key now remains
reachable, stores the submitted key in that host's Reasonix credential file,
and rebuilds the active controller in place without restarting Serve.
- Added Claude Code-style searchable CLI pickers for models, providers, and
sessions, with arrow, Vim, and `Ctrl+P` / `Ctrl+N` navigation.
- Added `-p` / `--print`, `text`, `json`, and `stream-json` output modes for
@@ -29,9 +139,20 @@ branch.
hatch.
- Added `/status` details for the active model, effort, cache, Git state,
background jobs, work profile, and provider balance where available.
### Changed
- Remote SSH workspaces now open as a standalone remote web window again.
Opening a workspace from the status bar or the Remote Server tab starts or
reuses the remote `reasonix serve`, tunnels its loopback port, and opens the
Serve web client in a dedicated per-host window. The remote web page uses
the provider configuration and API keys on the **remote** host; the desktop
no longer exposes its local providers to remote hosts. If the selected remote
Provider is missing its API key, the window opens a setup page that saves the
key only on that host and then opens the normal Serve UI. The Remote Workbench
protocol, its Provider Broker, and the same-window remote projection were
removed. Legacy mirror and provider-trust files are not deleted
automatically; Settings -> Remote SSH shows a cleanup card when they exist.
The hidden `remote attach-workspace`, `remote runtime-workbench`, and
`remote workbench-build-id` commands now fail with a pointer to
`reasonix remote connect <host> --open`.
- Automatic Plan Mode has been retired. Plan Mode is now always entered through
an explicit user choice, and the one-time config v5 upgrade removes legacy
`agent.auto_plan` and `agent.auto_plan_classifier` values so upgraded users
@@ -69,6 +190,11 @@ branch.
### Fixed
- Fixed long parallel sub-agent research being silently lost when combined
`parallel_tasks` or `fleet` answers exceeded the single-tool output limit.
Persisted sessions now keep each child transcript independently, return a
bounded fair preview plus stable reference for every result, and page full
answers through the conversation-scoped `read_subagent_result` tool.
- Fixed Remote Workbench failing with only `initialize: workbench-desktop:
connection closed` on fresh or cross-platform SSH hosts. Desktop now proves
the exact Host CLI Build ID, provisions the matching verified release without
@@ -122,6 +248,21 @@ branch.
private (`0600`, with private job directories), and the retired
`redact_tool_output` setting is removed with a one-time upgrade notice.
### Notes
- Full bilingual release notes:
<https://reasonix.io/changelog/v1.20.0/> ·
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases/tag/desktop-v1.20.0).
- The detailed entries below accumulated on `main-v2` after 1.0.0 and shipped
across 1.1.0–1.20.0; per-version attribution lives in the per-version release
notes linked above.
## 1.1.0 – 1.19.7
Per-version entries for the intermediate releases are published in the
[bilingual release notes](https://reasonix.io/changelog/) and on the
[GitHub releases page](https://github.com/esengine/DeepSeek-Reasonix/releases).
## [1.0.0] — 2026-06-03
First stable release — a **ground-up rewrite in Go**. Not an upgrade of the `0.x`
@@ -166,4 +307,5 @@ TypeScript line; a new codebase that becomes the default (`main-v2`).
support for the fetched runtime is unverified — install `codegraph` on PATH if
the auto-fetch doesn't resolve there.
[1.20.0]: https://github.com/esengine/DeepSeek-Reasonix/releases/tag/desktop-v1.20.0
[1.0.0]: https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v1.0.0
+6
View File
@@ -0,0 +1,6 @@
# Reasonix
The project's standing instructions live in `REASONIX.md` — one file, read by
both Reasonix and Claude Code. Add rules there, never here.
@REASONIX.md
+27 -2
View File
@@ -1,8 +1,13 @@
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
BUILD_TIME_UTC := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
GIT_COMMIT := $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)
LDFLAGS := -s -w \
-X main.version=$(VERSION) \
-X main.gitCommit=$(GIT_COMMIT) \
-X main.buildTimeUTC=$(BUILD_TIME_UTC)
GOEXE := $(shell go env GOEXE)
.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean
.PHONY: build vet fmt lint lint-cross lint-update test desktop-test desktop-test-short desktop-test-times sdk-test sdk-test-race hooks cross clean
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -14,6 +19,20 @@ vet:
fmt:
gofmt -w .
lint:
go run ./tools/repolint
lint-update:
go run ./tools/repolint -update
# Linting one GOOS leaves every //go:build windows and darwin file unchecked.
lint-cross:
@for t in "linux ." "darwin ." "windows ." "linux desktop" "windows desktop"; do \
set -- $$t; \
echo "== golangci-lint GOOS=$$1 ($$2)"; \
(cd $$2 && GOOS=$$1 golangci-lint run --timeout=5m ./...) || exit 1; \
done
test:
go test ./...
@@ -26,6 +45,12 @@ desktop-test-short:
desktop-test-times:
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
sdk-test:
cd sdk/go && go test ./...
sdk-test-race:
cd sdk/go && go test -race ./...
hooks:
@git config core.hooksPath .githooks
@echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)"
+17 -4
View File
@@ -11,6 +11,8 @@
&nbsp;·&nbsp;
<a href="./docs/ACP.md">ACP</a>
&nbsp;·&nbsp;
<a href="./docs/EXTENSIONS.md">Extensions</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.md">Spec</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
@@ -30,6 +32,11 @@
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/27020?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/27020/monthly?language=Go" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/27020?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/27020" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
</p>
<br/>
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
@@ -49,13 +56,15 @@
- **Multi-model & composable.** DeepSeek ships as a preset; any
OpenAI-compatible endpoint is a config entry, not new code. Optionally run
two models together (executor + planner) in separate, cache-stable sessions.
- **Plugin-driven.** External tools run as subprocesses over stdio JSON-RPC
(MCP-compatible). Built-in tools self-register at compile time.
- **Plugin-driven.** MCP servers contribute tools, prompts, and resources;
Extension Protocol v1 sidecars can also intercept runtime events, contribute
Providers and structured UI, and ship versioned plugin packages.
- **Cache-aware context maintenance.** Startup injects a small stable environment
summary, stale tool output is snipped/pruned before summary compaction, and the
built-in tool schema contract is documented for regression review.
- **Zero-friction distribution.** `CGO_ENABLED=0` single binary; cross-compile
to six targets with one command. The only dependency is a TOML parser.
to six targets with one command. The result is a fully self-contained static
binary — nothing to install on the target machine beyond the binary itself.
## Install
@@ -141,11 +150,15 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[Recovery and Safe Mode](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Recovery and updates](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
[Tool contract](./docs/TOOL_CONTRACT.md) · [Migrating from 0.x](./docs/MIGRATING.md)
- **Extension development:** [Extensions](./docs/EXTENSIONS.md) ·
[Plugin packages and Manifest v1](./docs/PLUGIN_PACKAGES.md) ·
[Extension Protocol](./docs/EXTENSION_PROTOCOL.md) ·
[Go SDK and starter](./sdk/go/README.md)
## Star History
+14 -3
View File
@@ -11,6 +11,8 @@
&nbsp;·&nbsp;
<a href="./docs/ACP.zh-CN.md">ACP</a>
&nbsp;·&nbsp;
<a href="./docs/EXTENSIONS.zh-CN.md">扩展开发</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.zh-CN.md">规格</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
@@ -30,6 +32,11 @@
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/27020?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/27020/monthly?language=Go" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/27020?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/27020" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
</p>
<br/>
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
@@ -46,12 +53,12 @@
内核无硬编码模型。
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。
- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容);
内置工具在编译期自注册。
- **插件驱动**:MCP server 提供工具、提示词和资源;Extension Protocol v1
Sidecar 还可以拦截运行时事件、提供 Provider 与结构化 UI,并通过版本化插件包分发。
- **缓存友好的上下文维护**:启动时注入稳定的环境摘要;旧工具输出会先 snip/prune,
再进入摘要 compaction;内置工具 schema 合约有文档和回归测试保护。
- **零摩擦分发**:`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。
唯一依赖是一个 TOML 解析库。
产物是完全自包含的静态二进制——目标机器上除二进制本身外无需安装任何东西。
## 安装
@@ -140,6 +147,10 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md) ·
[工具合约](./docs/TOOL_CONTRACT.zh-CN.md) ·
[从 0.x 迁移](./docs/MIGRATING.zh-CN.md)
- **扩展开发:** [扩展概览](./docs/EXTENSIONS.zh-CN.md) ·
[插件包与 Manifest v1](./docs/PLUGIN_PACKAGES.zh-CN.md) ·
[Extension Protocol](./docs/EXTENSION_PROTOCOL.zh-CN.md) ·
[Go SDK 与 starter](./sdk/go/README.md)
## Star 趋势
+45 -9
View File
@@ -6,15 +6,37 @@ agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
## Conventions
- Go kernel under `internal/`; each package owns one concern and documents it in a
package comment. Match the surrounding comment density and idiom when editing.
- Go kernel under `internal/`; each package owns one concern. A package's long
explanation belongs in its `doc.go`, not spread across implementation files.
- One transport-agnostic `control.Controller` sits behind every frontend (chat
TUI, HTTP/SSE serve, Wails desktop). Add behavior to the controller, not a
frontend, so all three inherit it.
- Layering (enforced): utility packages import nothing under `reasonix/`; only
the frontends `cli`, `serve`, `acp`, `bot`, `botruntime`, `boot` and the hosts
`cmd/`, `desktop/` may import `control`; nothing below a frontend may import
one. The declared sets live in `tools/repolint/layers.go`.
- Cache-first: the system-prompt prefix (base prompt + tools + memory) must stay
byte-stable across turns so DeepSeek's automatic prefix cache stays warm. Never
mutate it mid-session — ride the turn tail instead (see `control.Compose`).
## Comments
Default is none — the code is the truth. Write one only when the **why** is
non-obvious: a hidden constraint, a workaround anchored to something verifiable,
an invariant the type system cannot express, or an external-protocol quirk.
- Declaration doc: ≤5 lines. Package comment: ≤8 lines, or ≤40 in a `doc.go`.
- Every other comment: ≤3 lines. Struct-field and trailing `//`: 1 line.
- Never: restatements of the code, phase/stage narrative, incident or
conversation history, section banners, commented-out code, `@param` lists.
- `TODO(#nnn):` and `HACK(#nnn):` need the issue anchor. `FIXME` is banned.
- One responsibility per file; 800 lines is the ceiling.
`go run ./tools/repolint` enforces all of it against a ratchet baseline: recorded
debt is tolerated, anything new fails CI. Never widen the baseline to land a
change — fix the code. `-update` exists for carrying debt through a rename or an
extraction, and that diff must be justified in the PR.
## Memory
- Standing instructions are hierarchical: committed/shared `REASONIX.md`,
@@ -40,6 +62,7 @@ Run these **before every commit** to catch the fastest CI failures locally:
```bash
gofmt -w . # catches gofmt (saves ~13s CI)
go vet ./... # catches vet warnings (saves ~52s CI/lint)
go run ./tools/repolint # catches comment/size/layering regressions
go test ./internal/tool/builtin/ ./internal/boot/ # catches tool/boot test breaks
```
@@ -61,19 +84,32 @@ Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup f
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
## Cache-impact PR metadata
## PR metadata gates
When PR changes touch files under `internal/boot/`, `internal/tool/`, `internal/provider/`, or other cache-sensitive paths (listed in `scripts/check-cache-impact.sh`), the PR body MUST include these lines at the end:
Two CI guards read the PR body. The scripts are the source of truth and both
run locally: `scripts/check-cache-impact.sh`, `scripts/check-docs-impact.sh`.
Separators must be an ASCII `-` or `:` — an em dash fails the docs guard.
Cache-sensitive diffs (`internal/tool/`, `internal/provider/`,
`internal/boot/`, `internal/agent/agent.go`, and the rest of the list in the
script) require:
```
Cache-impact: <none|low|medium|high> — <reason>
Cache-impact: <none|low|medium|high> - <reason>
Cache-guard: <focused guard test/command or existing guard rationale>
```
If the PR also touches files under `internal/config/`, `internal/memory/`, `internal/outputstyle/`, `internal/skill/`, or `internal/boot/`, add:
`none` is a legitimate impact when the provider-visible prefix stays
byte-identical; only an empty value, `todo`, or `tbd` is rejected. If the diff
also touches `internal/config/`, `internal/memory/`, `internal/outputstyle/`,
`internal/skill/`, or `internal/boot/`, add `System-prompt-review: <note>` —
that field additionally rejects `none` and `n/a`, so it must name a reviewer.
User-visible diffs (`cmd/reasonix/`, `desktop/`, `npm/`, and most of
`internal/`; tests and lockfiles are exempt) require one of these, chosen by
whether the same PR edited `docs/*.md`:
```
System-prompt-review: <reviewer/approval note>
Documentation-impact: updated - <what changed> # docs/*.md edited
Documentation-impact: none - <why the docs stay correct> # not edited
```
Values `n/a`, `none`, `todo`, `tbd` are rejected — use a descriptive reason instead.
+205
View File
@@ -0,0 +1,205 @@
# Reasonix Benchmarks
Two primary harnesses live under `benchmarks/`; `cmd/e2ebench` also exposes a
SWE-bench Verified mode:
- `e2e/` — the committed end-to-end task suite, driven by
[`cmd/e2ebench`](../cmd/e2ebench/main.go). It runs each task against a real
provider and emits a markdown + JSON report (accuracy, cache-hit rate, token
use, cost) suitable for pasting into a PR.
- `context-maintenance-e2e/` — a standalone seed → resume → comprehension
harness that A/B-compares cold-restart cache behavior with and without
context pruning.
## Directory layout
```text
benchmarks/
├── e2e/
│ └── tasks/
│ ├── compaction/ # task.toml + verify.sh + workdir/ seed
│ ├── fix-add-bug/
│ ├── fizzbuzz/
│ ├── palindrome/
│ └── subagent-delegation/
├── swebench/
│ ├── select_subset.py # helper for choosing evaluation instances
│ └── subset.json # committed SWE-bench Verified subset
└── context-maintenance-e2e/
├── main.go
└── run/ # state dir written by seed/resume (default)
```
Each task under `e2e/tasks/<id>/` contains:
| File | Purpose |
| --- | --- |
| `task.toml` | The task definition (prompt, step/timeout limits). |
| `verify.sh` | The grader: exits 0 iff the agent's artifacts are correct. |
| `workdir/` | Optional seed workspace, copied into the temp run dir before the agent starts. |
## task.toml schema
`e2ebench` reads `benchmarks/e2e/tasks/<id>/task.toml` with the BurntSushi TOML
decoder. The task ID is the directory name; tasks run in sorted ID order.
| Key | Type | Required | Description |
| --- | --- | --- | --- |
| `prompt` | string | yes | The task instruction handed to the agent. |
| `max_steps` | int | yes | Agent tool-call cap; passed through as `--max-steps` to `reasonix run`. |
| `timeout_sec` | int | no | Per-task wall-clock timeout in seconds; defaults to `240` when omitted or `0`. |
Example (`tasks/fizzbuzz/task.toml`):
```toml
prompt = "Create a file named fizzbuzz.py containing a function fizzbuzz(n) that returns the string 'Fizz' when n is divisible by 3, 'Buzz' when divisible by 5, 'FizzBuzz' when divisible by both 3 and 5, and otherwise the number as a string. Do not print anything at import time."
max_steps = 12
timeout_sec = 180
```
## verify.sh contract
`verify.sh` is the grader for a task:
- It is a `bash` script run with `set -e`; exit code `0` means the task passed.
- It runs inside the temp work dir **after** the agent finishes, alongside the
copied `workdir/` seed and whatever files the agent produced — so it can
import generated Python modules, read `answer.txt`/`result.txt`, etc.
- The harness copies `verify.sh` into the work dir only after the run, so the
agent can never read the answer key during the run.
- Its stdout/stderr is streamed to the job log (stderr), not the report.
Examples: `compaction/verify.sh` normalizes `answer.txt` (strip whitespace,
lowercase) and compares it to the expected `aldermoor-verrin`;
`fizzbuzz/verify.sh` imports the generated module and asserts on
`fizzbuzz(3)`, `fizzbuzz(5)`, `fizzbuzz(15)`, `fizzbuzz(7)`.
## Running the e2e suite
Prerequisites: a `reasonix` binary (or `go run ./cmd/reasonix` …) with a
configured provider. The harness invokes the agent as
`reasonix run --auto --metrics <path> [--model NAME] [--max-steps N] [--profile delivery] [--ablate ARM] <prompt>`
inside a temp copy of the task's `workdir/`; the `--auto` flag is deliberate so
unattended fixture writes are allowed.
```sh
# Run the committed suite, report to stdout
go run ./cmd/e2ebench
# Same suite with the delivery prompt profile
go run ./cmd/e2ebench -profile delivery
# Write the markdown report to a file and the raw results to JSON
go run ./cmd/e2ebench -out report.md -json report.json
# Grade a PR's diff (generates tests for the diff, grades with the repo's tests)
go run ./cmd/e2ebench -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800
```
The markdown report contains the solved count, cost/tokens per solved task,
median wall time, cache-hit rate, and a per-task table with failure class
(`solved`, `timeout`, `wrong_patch`, `no_metrics`, `skipped`, or the agent's
own outcome).
### Flags
| Flag | Default | Purpose |
| --- | --- | --- |
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation) |
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
| `-bin` | `reasonix` | Path to the reasonix binary. |
| `-model` | *(config default)* | Provider/model name. |
| `-profile` | `baseline` | Prompt profile: `baseline` \| `delivery`. `delivery` appends `--profile delivery` to the agent invocation. |
| `-ablate` | *(none)* | Ablation arm: comma-separated subsystems to switch off — `evidence`, `planner`, `subagent`, `retrieval`, `compaction`; `none` \| `all`. |
| `-out` | *(stdout)* | Write the markdown report here. |
| `-json` | *(none)* | Write the JSON report here (optional). |
| `-trajectories` | *(none)* | Suite mode: write one `<task-id>.trajectory.jsonl` per task into this directory (the agent's full event stream with timestamps — see `reasonix run --trajectory`). The report gains a time-attribution line (tools vs. model) and each JSON result a `trajectory` digest. |
| `-budget` | `800000` | Abort once total tokens cross this (`0` = no cap). Remaining tasks are reported as skipped. |
Diff-mode flags:
| Flag | Default | Purpose |
| --- | --- | --- |
| `-repo` | `.` | Repo root (diff mode). |
| `-base` | *(none)* | Base ref to diff the PR head against (diff mode). |
| `-test-cmd` | `go test` | Grader command run on the affected packages (diff mode). |
| `-max-steps` | `80` | Agent tool-call cap for the diff task. |
| `-timeout` | `1200` | Agent timeout in seconds (diff mode). |
| `-attempts` | `1` | Diff mode: retry up to N times until a run passes (stochastic agent). |
## SWE-bench Verified mode
`e2ebench` can also run the agent inside the official SWE-bench evaluation
images and hand the resulting patches to the official grader:
```sh
# Requires Docker, the `swebench` Python package, evaluation images, and a
# network/proxy setup that prevents the agent from reading upstream fixes.
go run ./cmd/e2ebench -mode swebench \
-subset benchmarks/swebench/subset.json \
-network reasonix-eval -proxy http://127.0.0.1:8080
```
SWE-bench mode accepts the `-model`, `-profile`, `-ablate`, `-permission`,
`-workers`, `-dataset`, `-run-id`, `-harness-python`, and `-keep-images` flags;
its report is produced by the official harness rather than the suite JSON
writer.
## Adding a new task
1. Create `benchmarks/e2e/tasks/<task-id>/`.
2. Write `task.toml` with `prompt`, `max_steps`, and `timeout_sec` (see
[schema](#tasktoml-schema)).
3. If the task needs seed files, add them under `workdir/` (they are copied
into the temp run dir; symlinks are skipped).
4. Write `verify.sh`: `set -e`, exit 0 iff the agent's artifacts are correct.
Keep the expected answer out of the prompt and seed; the script runs in the
work dir and may validate anything the agent produced.
5. Run the suite. Since `e2ebench` has no single-task filter, iterate against a
scratch suite root:
```sh
mkdir -p scratch/tasks/<task-id>
cp -r benchmarks/e2e/tasks/<task-id>/* scratch/tasks/<task-id>/
go run ./cmd/e2ebench -suite scratch
```
When it passes, remove the scratch dir and commit the task.
## context-maintenance-e2e
This harness measures what happens when a long session goes idle past the
provider's cache TTL and then resumes: it A/B-compares cold-restart miss tokens
with and without pruning, and checks that the agent re-reads a file behind a
prune placeholder instead of hallucinating.
It is hardcoded to the `deepseek-v4-flash` model at `https://api.deepseek.com`
and requires the `DEEPSEEK_API_KEY` environment variable.
```sh
export DEEPSEEK_API_KEY=...
# Seed both arms (pruned + control) with a large session and warm the cache
go run ./benchmarks/context-maintenance-e2e seed
# Wait past the provider's cache TTL, then resume: prune the "pruned" arm and
# compare cold-restart miss tokens
go run ./benchmarks/context-maintenance-e2e resume
# Run the comprehension trials (agent must re-read a pruned file and answer
# from it); exits non-zero unless every trial passes
go run ./benchmarks/context-maintenance-e2e comprehension
```
| Flag | Default | Purpose |
| --- | --- | --- |
| `-dir` | `benchmarks/context-maintenance-e2e/run` | State directory for `seed`/`resume` (sessions + `meta.json`, `resume-<ts>.json`). |
| `-trials` | `5` | Number of comprehension trials. |
## See also
- [`docs/CLI.md`](../docs/CLI.md) — the `reasonix run` flags the e2e harness
passes through (`--auto`, `--metrics`, `--model`, `--max-steps`,
`--profile`, `--ablate`).
- [`cmd/e2ebench/main.go`](../cmd/e2ebench/main.go) — suite runner and report
renderer.
+3 -3
View File
@@ -55,7 +55,7 @@ func fakeGoFile(nonce string, i, size int) string {
func seedSession(nonce string) *agent.Session {
s := agent.NewSession("You are a terse coding agent reviewing a Go codebase.")
s.Add(provider.Message{Role: provider.RoleUser, Content: "Review every file in module " + nonce + " one by one. Keep notes short."})
for i := 0; i < fatResults; i++ {
for i := range fatResults {
id := fmt.Sprintf("c%02d", i)
name := fmt.Sprintf("src/file%02d.go", i)
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":%q}`, name)}}})
@@ -180,7 +180,7 @@ func resume(dir string) {
func comprehension(trials int) {
p := prov()
pass := 0
for t := 0; t < trials; t++ {
for t := range trials {
dir, err := os.MkdirTemp("", "cm-e2e-")
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -198,7 +198,7 @@ func comprehension(trials int) {
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"config.go"}`}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "r1", Name: "read_file", Content: content})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Noted the constants in config.go."})
for i := 0; i < 4; i++ {
for i := range 4 {
s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("ack %d", i)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Select the published SWE-bench Verified subset.
Deterministic by construction: no random seed, no hand-picking. Re-running this
against the same dataset revision reproduces subset.json byte for byte, so the
sample cannot be quietly tuned after seeing results.
Rules:
1. Repos get slots in proportion to their share of the full 500, largest
remainder first, so the sample keeps the benchmark's real composition
(django is ~46% of SWE-bench Verified and stays ~46% here).
2. Within a repo, instances are ordered by (difficulty, instance_id) and
picked at evenly spaced indices, which spreads the sample across the
repo's own difficulty mix instead of clustering on easy ones.
Nothing is excluded. psf/requests instances exercise a test suite that makes
live network calls and can exhaust the grader timeout; such a run is reported
as eval_timeout rather than dropped, because silently removing the instances a
harness handles badly is how a benchmark stops meaning anything.
"""
import json
import sys
from datasets import load_dataset
TOTAL = 50
DIFFICULTY_ORDER = {"<15 min fix": 0, "15 min - 1 hour": 1, "1-4 hours": 2, ">4 hours": 3}
def allocate(counts, total):
"""Largest-remainder apportionment of `total` slots across repo counts."""
population = sum(counts.values())
exact = {repo: n * total / population for repo, n in counts.items()}
floors = {repo: int(v) for repo, v in exact.items()}
remaining = total - sum(floors.values())
order = sorted(exact, key=lambda r: (-(exact[r] - floors[r]), r))
for repo in order[:remaining]:
floors[repo] += 1
return floors
def evenly_spaced(items, k):
if k <= 0:
return []
if k >= len(items):
return items
return [items[(2 * i + 1) * len(items) // (2 * k)] for i in range(k)]
def main():
ds = load_dataset("princeton-nlp/SWE-bench_Verified", split="test")
by_repo = {}
for row in ds:
by_repo.setdefault(row["repo"], []).append(row)
slots = allocate({repo: len(rows) for repo, rows in by_repo.items()}, TOTAL)
selected = []
for repo in sorted(by_repo):
rows = sorted(
by_repo[repo],
key=lambda r: (DIFFICULTY_ORDER.get(r["difficulty"], 9), r["instance_id"]),
)
selected.extend(evenly_spaced(rows, slots[repo]))
selected.sort(key=lambda r: r["instance_id"])
out = [
{
"instance_id": r["instance_id"],
"repo": r["repo"],
"base_commit": r["base_commit"],
"problem_statement": r["problem_statement"],
"difficulty": r["difficulty"],
}
for r in selected
]
with open("benchmarks/swebench/subset.json", "w", encoding="utf-8") as f:
json.dump(out, f, indent=2, ensure_ascii=False)
f.write("\n")
print(f"selected {len(out)} of {len(ds)}", file=sys.stderr)
for repo in sorted(by_repo):
share = 100 * len(by_repo[repo]) / len(ds)
print(f" {slots[repo]:2d} {repo:32s} (full set {share:4.1f}%)", file=sys.stderr)
if __name__ == "__main__":
main()
+352
View File
@@ -0,0 +1,352 @@
[
{
"instance_id": "astropy__astropy-13033",
"repo": "astropy/astropy",
"base_commit": "298ccb478e6bf092953bca67a3d29dc6c35f6752",
"problem_statement": "TimeSeries: misleading exception when required column check fails.\n<!-- This comments are hidden when you submit the issue,\r\nso you do not need to remove them! -->\r\n\r\n<!-- Please be sure to check out our contributing guidelines,\r\nhttps://github.com/astropy/astropy/blob/main/CONTRIBUTING.md .\r\nPlease be sure to check out our code of conduct,\r\nhttps://github.com/astropy/astropy/blob/main/CODE_OF_CONDUCT.md . -->\r\n\r\n<!-- Please have a search on our GitHub repository to see if a similar\r\nissue has already been posted.\r\nIf a similar issue is closed, have a quick look to see if you are satisfied\r\nby the resolution.\r\nIf not please go ahead and open an issue! -->\r\n\r\n<!-- Please check that the development version still produces the same bug.\r\nYou can install development version with\r\npip install git+https://github.com/astropy/astropy\r\ncommand. -->\r\n\r\n### Description\r\n<!-- Provide a general description of the bug. -->\r\n\r\nFor a `TimeSeries` object that has additional required columns (in addition to `time`), when codes mistakenly try to remove a required column, the exception it produces is misleading.\r\n\r\n### Expected behavior\r\n<!-- What did you expect to happen. -->\r\nAn exception that informs the users required columns are missing.\r\n\r\n### Actual behavior\r\nThe actual exception message is confusing:\r\n`ValueError: TimeSeries object is invalid - expected 'time' as the first columns but found 'time'`\r\n\r\n### Steps to Reproduce\r\n<!-- Ideally a code example could be provided so we can run it ourselves. -->\r\n<!-- If you are pasting code, use triple backticks (```) around\r\nyour code snippet. -->\r\n<!-- If necessary, sanitize your screen output to be pasted so you do not\r\nreveal secrets like tokens and passwords. -->\r\n\r\n```python\r\nfrom astropy.time import Time\r\nfrom astropy.timeseries import TimeSeries\r\n\r\ntime=Time(np.arange(100000, 100003), format='jd')\r\nts = TimeSeries(time=time, data = {\"flux\": [99.9, 99.8, 99.7]})\r\nts._required_columns = [\"time\", \"flux\"] \r\nts.remove_column(\"flux\")\r\n\r\n```\r\n\r\n### System Details\r\n<!-- Even if you do not think this is necessary, it is useful information for the maintainers.\r\nPlease run the following snippet and paste the output below:\r\nimport platform; print(platform.platform())\r\nimport sys; print(\"Python\", sys.version)\r\nimport numpy; print(\"Numpy\", numpy.__version__)\r\nimport erfa; print(\"pyerfa\", erfa.__version__)\r\nimport astropy; print(\"astropy\", astropy.__version__)\r\nimport scipy; print(\"Scipy\", scipy.__version__)\r\nimport matplotlib; print(\"Matplotlib\", matplotlib.__version__)\r\n-->\r\n```\r\nWindows-10-10.0.22000-SP0\r\nPython 3.9.10 | packaged by conda-forge | (main, Feb 1 2022, 21:21:54) [MSC v.1929 64 bit (AMD64)]\r\nNumpy 1.22.3\r\npyerfa 2.0.0.1\r\nastropy 5.0.3\r\nScipy 1.8.0\r\nMatplotlib 3.5.1\r\n```\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "astropy__astropy-7671",
"repo": "astropy/astropy",
"base_commit": "a7141cd90019b62688d507ae056298507678c058",
"problem_statement": "minversion failures\nThe change in PR #7647 causes `minversion` to fail in certain cases, e.g.:\r\n```\r\n>>> from astropy.utils import minversion\r\n>>> minversion('numpy', '1.14dev')\r\nTypeError Traceback (most recent call last)\r\n<ipython-input-1-760e6b1c375e> in <module>()\r\n 1 from astropy.utils import minversion\r\n----> 2 minversion('numpy', '1.14dev')\r\n\r\n~/dev/astropy/astropy/utils/introspection.py in minversion(module, version, inclusive, version_path)\r\n 144\r\n 145 if inclusive:\r\n--> 146 return LooseVersion(have_version) >= LooseVersion(version)\r\n 147 else:\r\n 148 return LooseVersion(have_version) > LooseVersion(version)\r\n\r\n~/local/conda/envs/photutils-dev/lib/python3.6/distutils/version.py in __ge__(self, other)\r\n 68\r\n 69 def __ge__(self, other):\r\n---> 70 c = self._cmp(other)\r\n 71 if c is NotImplemented:\r\n 72 return c\r\n\r\n~/local/conda/envs/photutils-dev/lib/python3.6/distutils/version.py in _cmp(self, other)\r\n 335 if self.version == other.version:\r\n 336 return 0\r\n--> 337 if self.version < other.version:\r\n 338 return -1\r\n 339 if self.version > other.version:\r\n\r\nTypeError: '<' not supported between instances of 'int' and 'str'\r\n```\r\napparently because of a bug in LooseVersion (https://bugs.python.org/issue30272):\r\n\r\n```\r\n>>> from distutils.version import LooseVersion\r\n>>> LooseVersion('1.14.3') >= LooseVersion('1.14dev')\r\n...\r\nTypeError: '<' not supported between instances of 'int' and 'str'\r\n```\r\n\r\nNote that without the \".3\" it doesn't fail:\r\n\r\n```\r\n>>> LooseVersion('1.14') >= LooseVersion('1.14dev')\r\nFalse\r\n```\r\n\r\nand using pkg_resources.parse_version (which was removed) works:\r\n```\r\n>>> from pkg_resources import parse_version\r\n>>> parse_version('1.14.3') >= parse_version('1.14dev')\r\nTrue\r\n```\r\n\r\nCC: @mhvk \n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-11099",
"repo": "django/django",
"base_commit": "d26b2424437dabeeca94d7900b37d2df4410da0c",
"problem_statement": "UsernameValidator allows trailing newline in usernames\nDescription\n\t\nASCIIUsernameValidator and UnicodeUsernameValidator use the regex \nr'^[\\w.@+-]+$'\nThe intent is to only allow alphanumeric characters as well as ., @, +, and -. However, a little known quirk of Python regexes is that $ will also match a trailing newline. Therefore, the user name validators will accept usernames which end with a newline. You can avoid this behavior by instead using \\A and \\Z to terminate regexes. For example, the validator regex could be changed to\nr'\\A[\\w.@+-]+\\Z'\nin order to reject usernames that end with a newline.\nI am not sure how to officially post a patch, but the required change is trivial - using the regex above in the two validators in contrib.auth.validators.\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "django__django-11141",
"repo": "django/django",
"base_commit": "5d9cf79baf07fc4aed7ad1b06990532a65378155",
"problem_statement": "Allow migrations directories without __init__.py files\nDescription\n\t \n\t\t(last modified by Tim Graham)\n\t \nBackground: In python 3 a package with no __init__.py is implicitly a namespace package, so it has no __file__ attribute. \nThe migrate command currently checks for existence of a __file__ attribute on the migrations package. This check was introduced in #21015, because the __file__ attribute was used in migration file discovery. \nHowever, in #23406 migration file discovery was changed to use pkgutil.iter_modules (), instead of direct filesystem access. pkgutil. iter_modules() uses the package's __path__ list, which exists on implicit namespace packages.\nAs a result, the __file__ check is no longer needed, and in fact prevents migrate from working on namespace packages (implicit or otherwise). \nRelated work: #29091\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-11551",
"repo": "django/django",
"base_commit": "7991111af12056ec9a856f35935d273526338c1f",
"problem_statement": "admin.E108 is raised on fields accessible only via instance.\nDescription\n\t \n\t\t(last modified by ajcsimons)\n\t \nAs part of startup django validates the ModelAdmin's list_display list/tuple for correctness (django.admin.contrib.checks._check_list_display). Having upgraded django from 2.07 to 2.2.1 I found that a ModelAdmin with a list display that used to pass the checks and work fine in admin now fails validation, preventing django from starting. A PositionField from the django-positions library triggers this bug, explanation why follows.\nfrom django.db import models\nfrom position.Fields import PositionField\nclass Thing(models.Model)\n number = models.IntegerField(default=0)\n order = PositionField()\nfrom django.contrib import admin\nfrom .models import Thing\[email protected](Thing)\nclass ThingAdmin(admin.ModelAdmin)\n list_display = ['number', 'order']\nUnder 2.2.1 this raises an incorrect admin.E108 message saying \"The value of list_display[1] refers to 'order' which is not a callable...\".\nUnder 2.0.7 django starts up successfully.\nIf you change 'number' to 'no_number' or 'order' to 'no_order' then the validation correctly complains about those.\nThe reason for this bug is commit ​https://github.com/django/django/commit/47016adbf54b54143d4cf052eeb29fc72d27e6b1 which was proposed and accepted as a fix for bug https://code.djangoproject.com/ticket/28490. The problem is while it fixed that bug it broke the functionality of _check_list_display_item in other cases. The rationale for that change was that after field=getattr(model, item) field could be None if item was a descriptor returning None, but subsequent code incorrectly interpreted field being None as meaning getattr raised an AttributeError. As this was done after trying field = model._meta.get_field(item) and that failing that meant the validation error should be returned. However, after the above change if hasattr(model, item) is false then we no longer even try field = model._meta.get_field(item) before returning an error. The reason hasattr(model, item) is false in the case of a PositionField is its get method throws an exception if called on an instance of the PositionField class on the Thing model class, rather than a Thing instance.\nFor clarity, here are the various logical tests that _check_list_display_item needs to deal with and the behaviour before the above change, after it, and the correct behaviour (which my suggested patch exhibits). Note this is assuming the first 2 tests callable(item) and hasattr(obj, item) are both false (corresponding to item is an actual function/lambda rather than string or an attribute of ThingAdmin).\nhasattr(model, item) returns True or False (which is the same as seeing if getattr(model, item) raises AttributeError)\nmodel._meta.get_field(item) returns a field or raises FieldDoesNotExist\nGet a field from somewhere, could either be from getattr(model,item) if hasattr was True or from get_field.\nIs that field an instance of ManyToManyField?\nIs that field None? (True in case of bug 28490)\n hasattr get_field field is None? field ManyToMany? 2.0 returns 2.2 returns Correct behaviour Comments \n True ok False False [] [] [] - \n True ok False True E109 E109 E109 - \n True ok True False E108 [] [] good bit of 28490 fix, 2.0 was wrong \n True raises False False [] [] [] - \n True raises False True E109 [] E109 Another bug introduced by 28490 fix, fails to check if ManyToMany in get_field raise case \n True raises True False E108 [] [] good bit of 28490 fix, 2.0 was wrong \n False ok False False [] E108 [] bad bit of 28490 fix, bug hit with PositionField \n False ok False True [] E108 E109 both 2.0 and 2.2 wrong \n False ok True False [] E108 [] bad 28490 fix \n False raises False False E108 E108 E108 - \n False raises False True E108 E108 E108 impossible condition, we got no field assigned to be a ManyToMany \n False raises True False E108 E108 E108 impossible condition, we got no field assigned to be None \nThe following code exhibits the correct behaviour in all cases. The key changes are there is no longer a check for hasattr(model, item), as that being false should not prevent us form attempting to get the field via get_field, and only return an E108 in the case both of them fail. If either of those means or procuring it are successful then we need to check if it's a ManyToMany. Whether or not the field is None is irrelevant, and behaviour is contained within the exception catching blocks that should cause it instead of signalled through a variable being set to None which is a source of conflation of different cases.\ndef _check_list_display_item(self, obj, item, label):\n\tif callable(item):\n\t\treturn []\n\telif hasattr(obj, item):\n\t\treturn []\n\telse:\n\t\ttry:\n\t\t\tfield = obj.model._meta.get_field(item)\n\t\texcept FieldDoesNotExist:\n\t\t\ttry:\n\t\t\t\tfield = getattr(obj.model, item)\n\t\t\texcept AttributeError:\n\t\t\t\treturn [\n\t\t\t\t\tchecks.ErLine truncated
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-11555",
"repo": "django/django",
"base_commit": "8dd5877f58f84f2b11126afbd0813e24545919ed",
"problem_statement": "order_by() a parent model crash when Meta.ordering contains expressions.\nDescription\n\t \n\t\t(last modified by Jonny Fuller)\n\t \nHi friends,\nDuring testing I discovered a strange bug when using a query expression for ordering during multi-table inheritance. You can find the full write up as well as reproducible test repository ​https://github.com/JonnyWaffles/djangoordermetabug. The bug occurs because the field is an OrderBy object, not a string, during get_order_dir. The linked stacktrace should make the issue obvious, but what I don't understand is why it only fails during test db setup, not during repl or script use. I wish I could help more and come up with a real solution. Hopefully, this is enough for someone wiser to find the culprit.\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "django__django-12050",
"repo": "django/django",
"base_commit": "b93a0e34d9b9b99d41103782b7e7aeabf47517e3",
"problem_statement": "Query.resolve_lookup_value coerces value of type list to tuple\nDescription\n\t\nChanges introduced in #30687 cause an input value list to be coerced to tuple breaking exact value queries. This affects ORM field types that are dependent on matching input types such as PickledField.\nThe expected iterable return type should match input iterable type.\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-12304",
"repo": "django/django",
"base_commit": "4c1b401e8250f9f520b3c7dc369554477ce8b15a",
"problem_statement": "Enumeration Types are not usable in templates.\nDescription\n\t \n\t\t(last modified by Mariusz Felisiak)\n\t \nThe new ​enumeration types are great but can't be used in Django templates due to their being callable. For example this doesn't work:\n{% if student.year_in_school == YearInSchool.FRESHMAN %}\nThis is because YearInSchool, being a class, is callable, and Django Templates always call callables with no arguments. The call fails because the required value argument is missing.\nThe easy solution would be to declare do_not_call_in_templates = True on the various Choices classes.\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "django__django-12858",
"repo": "django/django",
"base_commit": "f2051eb8a7febdaaa43bd33bf5a6108c5f428e59",
"problem_statement": "models.E015 is raised when ordering uses lookups that are not transforms.\nDescription\n\t\n./manage.py check\nSystemCheckError: System check identified some issues:\nERRORS:\napp.Stock: (models.E015) 'ordering' refers to the nonexistent field, related field, or lookup 'supply__product__parent__isnull'.\nHowever this ordering works fine:\n>>> list(Stock.objects.order_by('supply__product__parent__isnull').values_list('pk', flat=True)[:5])\n[1292, 1293, 1300, 1295, 1294]\n>>> list(Stock.objects.order_by('-supply__product__parent__isnull').values_list('pk', flat=True)[:5])\n[108, 109, 110, 23, 107]\nI believe it was fine until #29408 was implemented.\nStock.supply is a foreign key to Supply, Supply.product is a foreign key to Product, Product.parent is a ForeignKey('self', models.CASCADE, null=True)\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-13128",
"repo": "django/django",
"base_commit": "2d67222472f80f251607ae1b720527afceba06ad",
"problem_statement": "make temporal subtraction work without ExpressionWrapper\nDescription\n\t\nclass Experiment(models.Model):\n\tstart = models.DateTimeField()\n\tend = models.DateTimeField()\nExperiment.objects.annotate(\n\tdelta=F('end') - F('start') + Value(datetime.timedelta(), output_field=DurationField())\n)\nThis gives:\ndjango.core.exceptions.FieldError: Expression contains mixed types: DateTimeField, DurationField. You must set output_field.\n",
"difficulty": "1-4 hours"
},
{
"instance_id": "django__django-13343",
"repo": "django/django",
"base_commit": "ece18207cbb64dd89014e279ac636a6c9829828e",
"problem_statement": "FileField with a callable storage does not deconstruct properly\nDescription\n\t\nA FileField with a callable storage parameter should not actually evaluate the callable when it is being deconstructed.\nThe documentation for a FileField with a callable storage parameter, states:\nYou can use a callable as the storage parameter for django.db.models.FileField or django.db.models.ImageField. This allows you to modify the used storage at runtime, selecting different storages for different environments, for example.\nHowever, by evaluating the callable during deconstuction, the assumption that the Storage may vary at runtime is broken. Instead, when the FileField is deconstructed (which happens during makemigrations), the actual evaluated Storage is inlined into the deconstucted FileField.\nThe correct behavior should be to return a reference to the original callable during deconstruction. Note that a FileField with a callable upload_to parameter already behaves this way: the deconstructed value is simply a reference to the callable.\n---\nThis bug was introduced in the initial implementation which allowed the storage parameter to be callable: ​https://github.com/django/django/pull/8477 , which fixed the ticket https://code.djangoproject.com/ticket/28184\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-13406",
"repo": "django/django",
"base_commit": "84609b3205905097d7d3038d32e6101f012c0619",
"problem_statement": "Queryset with values()/values_list() crashes when recreated from a pickled query.\nDescription\n\t\nI am pickling query objects (queryset.query) for later re-evaluation as per ​https://docs.djangoproject.com/en/2.2/ref/models/querysets/#pickling-querysets. However, when I tried to rerun a query that combines values and annotate for a GROUP BY functionality, the result is broken.\nNormally, the result of the query is and should be a list of dicts, but in this case instances of the model are returned, but their internal state is broken and it is impossible to even access their .id because of a AttributeError: 'NoneType' object has no attribute 'attname' error.\nI created a minimum reproducible example.\nmodels.py\nfrom django.db import models\nclass Toy(models.Model):\n\tname = models.CharField(max_length=16)\n\tmaterial = models.CharField(max_length=16)\n\tprice = models.PositiveIntegerField()\ncrashing code\nimport pickle\nfrom django.db.models import Sum\nfrom django_error2.models import Toy\nToy.objects.create(name='foo', price=10, material='wood')\nToy.objects.create(name='bar', price=20, material='plastic')\nToy.objects.create(name='baz', price=100, material='wood')\nprices = Toy.objects.values('material').annotate(total_price=Sum('price'))\nprint(prices)\nprint(type(prices[0]))\nprices2 = Toy.objects.all()\nprices2.query = pickle.loads(pickle.dumps(prices.query))\nprint(type(prices2[0]))\nprint(prices2)\nThe type of prices[0] is reported as 'dict', which is ok, the type of prices2[0] is reported as '<class \"models.Toy\">', which is wrong. The code then crashes when trying to print the evaluated queryset with the following:\nTraceback (most recent call last):\n File \"/home/beda/.config/JetBrains/PyCharm2020.2/scratches/scratch_20.py\", line 19, in <module>\n\tprint(prices2)\n File \"/home/beda/virtualenvs/celus/lib/python3.6/site-packages/django/db/models/query.py\", line 253, in __repr__\n\treturn '<%s %r>' % (self.__class__.__name__, data)\n File \"/home/beda/virtualenvs/celus/lib/python3.6/site-packages/django/db/models/base.py\", line 519, in __repr__\n\treturn '<%s: %s>' % (self.__class__.__name__, self)\n File \"/home/beda/virtualenvs/celus/lib/python3.6/site-packages/django/db/models/base.py\", line 522, in __str__\n\treturn '%s object (%s)' % (self.__class__.__name__, self.pk)\n File \"/home/beda/virtualenvs/celus/lib/python3.6/site-packages/django/db/models/base.py\", line 569, in _get_pk_val\n\treturn getattr(self, meta.pk.attname)\n File \"/home/beda/virtualenvs/celus/lib/python3.6/site-packages/django/db/models/query_utils.py\", line 133, in __get__\n\tval = self._check_parent_chain(instance, self.field_name)\n File \"/home/beda/virtualenvs/celus/lib/python3.6/site-packages/django/db/models/query_utils.py\", line 150, in _check_parent_chain\n\treturn getattr(instance, link_field.attname)\nAttributeError: 'NoneType' object has no attribute 'attname'\nFrom my point of view it seems as though Django retrieves the correct data from the database, but instead of returning them as a dict, it tries to create model instances from them, which does not work as the data has wrong structure.\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "django__django-13786",
"repo": "django/django",
"base_commit": "bb64b99b78a579cb2f6178011a4cf9366e634438",
"problem_statement": "squashmigrations does not unset model options when optimizing CreateModel and AlterModelOptions\nDescription\n\t\nWhen an operation resembling AlterModelOptions(name=\"test_model\", options={}) is squashed into the corresponding CreateModel operation, model options are not cleared on the resulting new CreateModel operation object.\nCreateModel.reduce() sets the new options as options={**self.options, **operation.options} in this case (django/db/migrations/operations/models.py line 144 on commit 991dce4f), with no logic to remove options not found in operation.options as is found in AlterModelOptions.state_forwards().\nI believe this issue still exists on the master branch based on my reading of the code, but I've only tested against 2.2.\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-14089",
"repo": "django/django",
"base_commit": "d01709aae21de9cd2565b9c52f32732ea28a2d98",
"problem_statement": "Allow calling reversed() on an OrderedSet\nDescription\n\t\nCurrently, ​OrderedSet isn't reversible (i.e. allowed to be passed as an argument to Python's ​reversed()). This would be natural to support given that OrderedSet is ordered. This should be straightforward to add by adding a __reversed__() method to OrderedSet.\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "django__django-14140",
"repo": "django/django",
"base_commit": "45814af6197cfd8f4dc72ee43b90ecde305a1d5a",
"problem_statement": "Combining Q() objects with boolean expressions crashes.\nDescription\n\t \n\t\t(last modified by jonathan-golorry)\n\t \nCurrently Q objects with 1 child are treated differently during deconstruct.\n>>> from django.db.models import Q\n>>> Q(x=1).deconstruct()\n('django.db.models.Q', (), {'x': 1})\n>>> Q(x=1, y=2).deconstruct()\n('django.db.models.Q', (('x', 1), ('y', 2)), {})\nThis causes issues when deconstructing Q objects with a non-subscriptable child.\n>>> from django.contrib.auth import get_user_model\n>>> from django.db.models import Exists\n>>> Q(Exists(get_user_model().objects.filter(username='jim'))).deconstruct()\nTraceback (most recent call last):\n File \"<console>\", line 1, in <module>\n File \"...\", line 90, in deconstruct\n\tkwargs = {child[0]: child[1]}\nTypeError: 'Exists' object is not subscriptable\nPatch ​https://github.com/django/django/pull/14126 removes the special case, meaning single-child Q objects deconstruct into args instead of kwargs. A more backward-compatible approach would be to keep the special case and explicitly check that the child is a length-2 tuple, but it's unlikely that anyone is relying on this undocumented behavior.\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-14672",
"repo": "django/django",
"base_commit": "00ea883ef56fb5e092cbe4a6f7ff2e7470886ac4",
"problem_statement": "Missing call `make_hashable` on `through_fields` in `ManyToManyRel`\nDescription\n\t\nIn 3.2 identity property has been added to all ForeignObjectRel to make it possible to compare them. A hash is derived from said identity and it's possible because identity is a tuple. To make limit_choices_to hashable (one of this tuple elements), ​there's a call to make_hashable.\nIt happens that through_fields can be a list. In such case, this make_hashable call is missing in ​ManyToManyRel.\nFor some reason it only fails on checking proxy model. I think proxy models have 29 checks and normal ones 24, hence the issue, but that's just a guess.\nMinimal repro:\nclass Parent(models.Model):\n\tname = models.CharField(max_length=256)\nclass ProxyParent(Parent):\n\tclass Meta:\n\t\tproxy = True\nclass Child(models.Model):\n\tparent = models.ForeignKey(Parent, on_delete=models.CASCADE)\n\tmany_to_many_field = models.ManyToManyField(\n\t\tto=Parent,\n\t\tthrough=\"ManyToManyModel\",\n\t\tthrough_fields=['child', 'parent'],\n\t\trelated_name=\"something\"\n\t)\nclass ManyToManyModel(models.Model):\n\tparent = models.ForeignKey(Parent, on_delete=models.CASCADE, related_name='+')\n\tchild = models.ForeignKey(Child, on_delete=models.CASCADE, related_name='+')\n\tsecond_child = models.ForeignKey(Child, on_delete=models.CASCADE, null=True, default=None)\nWhich will result in \n File \"manage.py\", line 23, in <module>\n\tmain()\n File \"manage.py\", line 19, in main\n\texecute_from_command_line(sys.argv)\n File \"/home/tom/PycharmProjects/broken_m2m_project/venv/lib/python3.8/site-packages/django/core/management/__init__.py\", line 419, in execute_from_command_line\n\tutility.execute()\n File \"/home/tom/PycharmProjects/broken_m2m_project/venv/lib/python3.8/site-packages/django/core/management/__init__.py\", line 413, in execute\n\tself.fetch_command(subcommand).run_from_argv(self.argv)\n File \"/home/tom/PycharmProjects/broken_m2m_project/venv/lib/python3.8/site-packages/django/core/management/base.py\", line 354, in run_from_argv\n\tself.execute(*args, **cmd_options)\n File \"/home/tom/PycharmProjects/broken_m2m_project/venv/lib/python3.8/site-packages/django/core/management/base.py\", line 393, in execute\n\tself.check()\n File \"/home/tom/PycharmProjects/broken_m2m_project/venv/lib/python3.8/site-packages/django/core/management/base.py\", line 419, in check\n\tall_issues = checks.run_checks(\n File \"/home/tom/PycharmProjects/broken_m2m_project/venv/lib/python3.8/site-packages/django/core/checks/registry.py\", line 76, in run_checks\n\tnew_errors = check(app_configs=app_configs, databases=databases)\n File \"/home/tom/PycharmProjects/broken_m2m_project/venv/lib/python3.8/site-packages/django/core/checks/model_checks.py\", line 34, in check_all_models\n\terrors.extend(model.check(**kwargs))\n File \"/home/tom/PycharmProjects/broken_m2m_project/venv/lib/python3.8/site-packages/django/db/models/base.py\", line 1277, in check\n\t*cls._check_field_name_clashes(),\n File \"/home/tom/PycharmProjects/djangbroken_m2m_projectProject/venv/lib/python3.8/site-packages/django/db/models/base.py\", line 1465, in _check_field_name_clashes\n\tif f not in used_fields:\n File \"/home/tom/PycharmProjects/broken_m2m_project/venv/lib/python3.8/site-packages/django/db/models/fields/reverse_related.py\", line 140, in __hash__\n\treturn hash(self.identity)\nTypeError: unhashable type: 'list'\nSolution: Add missing make_hashable call on self.through_fields in ManyToManyRel.\nMissing call `make_hashable` on `through_fields` in `ManyToManyRel`\nDescription\n\t\nIn 3.2 identity property has been added to all ForeignObjectRel to make it possible to compare them. A hash is derived from said identity and it's possible because identity is a tuple. To make limit_choices_to hashable (one of this tuple elements), ​there's a call to make_hashable.\nIt happens that through_fields can be a list. In such case, this make_hashable call is missing in ​ManyToManyRel.\nFor some reason it only fails on checking proxy model. I think proxy models have 29 checks and normal ones 24, hence the issue, but that's just a guess.\nMinimal repro:\nclass Parent(models.Model):\n\tname = models.CharField(max_length=256)\nclass ProxyParent(Parent):\n\tclass Meta:\n\t\tproxy = True\nclass Child(models.Model):\n\tparent = models.ForeignKey(Parent, on_delete=models.CASCADE)\n\tmany_to_many_field = models.ManyToManyField(\n\t\tto=Parent,\n\t\tthrough=\"ManyToManyModel\",\n\t\tthrough_fields=['child', 'parent'],\n\t\trelated_name=\"something\"\n\t)\nclass ManyToManyModel(models.Model):\n\tparent = models.ForeignKey(Parent, on_delete=models.CASCADE, related_name='+')\n\tchild = models.ForeignKey(Child, on_delete=models.CASCADE, related_name='+')\n\tsecond_child = models.ForeignKey(Child, on_delete=models.CASCADE, null=True, default=None)\nWhich will result in \n File \"manage.py\", line 23, in <module>\n\tmain()\n File \"manage.py\", line 19, in main\n\texecute_Line truncated
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-14752",
"repo": "django/django",
"base_commit": "b64db05b9cedd96905d637a2d824cbbf428e40e7",
"problem_statement": "Refactor AutocompleteJsonView to support extra fields in autocomplete response\nDescription\n\t \n\t\t(last modified by mrts)\n\t \nAdding data attributes to items in ordinary non-autocomplete foreign key fields that use forms.widgets.Select-based widgets is relatively easy. This enables powerful and dynamic admin site customizations where fields from related models are updated immediately when users change the selected item.\nHowever, adding new attributes to autocomplete field results currently requires extending contrib.admin.views.autocomplete.AutocompleteJsonView and fully overriding the AutocompleteJsonView.get() method. Here's an example:\nclass MyModelAdmin(admin.ModelAdmin):\n\tdef get_urls(self):\n\t\treturn [\n\t\t\tpath('autocomplete/', CustomAutocompleteJsonView.as_view(admin_site=self.admin_site))\n\t\t\tif url.pattern.match('autocomplete/')\n\t\t\telse url for url in super().get_urls()\n\t\t]\nclass CustomAutocompleteJsonView(AutocompleteJsonView):\n\tdef get(self, request, *args, **kwargs):\n\t\tself.term, self.model_admin, self.source_field, to_field_name = self.process_request(request)\n\t\tif not self.has_perm(request):\n\t\t\traise PermissionDenied\n\t\tself.object_list = self.get_queryset()\n\t\tcontext = self.get_context_data()\n\t\treturn JsonResponse({\n\t\t\t'results': [\n\t\t\t\t{'id': str(getattr(obj, to_field_name)), 'text': str(obj), 'notes': obj.notes} # <-- customization here\n\t\t\t\tfor obj in context['object_list']\n\t\t\t],\n\t\t\t'pagination': {'more': context['page_obj'].has_next()},\n\t\t})\nThe problem with this is that as AutocompleteJsonView.get() keeps evolving, there's quite a lot of maintenance overhead required to catch up.\nThe solutions is simple, side-effect- and risk-free: adding a result customization extension point to get() by moving the lines that construct the results inside JsonResponse constructor to a separate method. So instead of\n\t\treturn JsonResponse({\n\t\t\t'results': [\n\t\t\t\t{'id': str(getattr(obj, to_field_name)), 'text': str(obj)}\n\t\t\t\tfor obj in context['object_list']\n\t\t\t],\n\t\t\t'pagination': {'more': context['page_obj'].has_next()},\n\t\t})\nthere would be\n\t\treturn JsonResponse({\n\t\t\t'results': [\n\t\t\t\tself.serialize_result(obj, to_field_name) for obj in context['object_list']\n\t\t\t],\n\t\t\t'pagination': {'more': context['page_obj'].has_next()},\n\t\t})\nwhere serialize_result() contains the original object to dictionary conversion code that would be now easy to override:\ndef serialize_result(self, obj, to_field_name):\n\treturn {'id': str(getattr(obj, to_field_name)), 'text': str(obj)}\nThe example CustomAutocompleteJsonView from above would now become succinct and maintainable:\nclass CustomAutocompleteJsonView(AutocompleteJsonView):\n\tdef serialize_result(self, obj, to_field_name):\n\t\treturn super.serialize_result(obj, to_field_name) | {'notes': obj.notes}\nWhat do you think, is this acceptable? I'm more than happy to provide the patch.\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "django__django-15280",
"repo": "django/django",
"base_commit": "973fa566521037ac140dcece73fceae50ee522f1",
"problem_statement": "Deferred fields incorrect when following prefetches back to the \"parent\" object\nDescription\n\t\nGiven the following models:\nclass User(models.Model):\n\temail = models.EmailField()\n\tkind = models.CharField(\n\t\tmax_length=10, choices=[(\"ADMIN\", \"Admin\"), (\"REGULAR\", \"Regular\")]\n\t)\nclass Profile(models.Model):\n\tfull_name = models.CharField(max_length=255)\n\tuser = models.OneToOneField(User, on_delete=models.CASCADE)\nI'd expect the following test case to pass:\ndef test_only_related_queryset(self):\n\tuser = User.objects.create(\n\t\temail=\"[email protected]\",\n\t\tkind=\"ADMIN\",\n\t)\n\tProfile.objects.create(user=user, full_name=\"Test Tester\")\n\tqueryset = User.objects.only(\"email\").prefetch_related(\n\t\tPrefetch(\n\t\t\t\"profile\",\n\t\t\tqueryset=Profile.objects.prefetch_related(\n\t\t\t\tPrefetch(\"user\", queryset=User.objects.only(\"kind\"))\n\t\t\t),\n\t\t)\n\t)\n\twith self.assertNumQueries(3):\n\t\tuser = queryset.first()\n\twith self.assertNumQueries(0):\n\t\tself.assertEqual(user.profile.user.kind, \"ADMIN\")\nThe second assertNumQueries actually fails with:\nAssertionError: 1 != 0 : 1 queries executed, 0 expected\nCaptured queries were:\n1. SELECT \"tests_user\".\"id\", \"tests_user\".\"kind\" FROM \"tests_user\" WHERE \"tests_user\".\"id\" = 1\nThis is exactly the query I'd expect to see if kind on the inner User queryset had been deferred, which it hasn't.\nThe three queries executed when iterating the main queryset (ie when executing user = queryset.first()) look correct:\n1. SELECT \"tests_user\".\"id\", \"tests_user\".\"email\" FROM \"tests_user\" ORDER BY \"tests_user\".\"id\" ASC LIMIT 1\n2. SELECT \"tests_profile\".\"id\", \"tests_profile\".\"full_name\", \"tests_profile\".\"user_id\" FROM \"tests_profile\" WHERE \"tests_profile\".\"user_id\" IN (1)\n3. SELECT \"tests_user\".\"id\", \"tests_user\".\"kind\" FROM \"tests_user\" WHERE \"tests_user\".\"id\" IN (1)\nPrinting user.profile.user.get_deferred_fields() returns {'kind'}.\nIt looks to me like Django is correctly evaluating the set of deferred fields when executing the \"inner\" User queryset, but somehow the instances are inheriting the set of fields they \"think\" have been deferred from the outer User queryset, so when the attribute is accessed it causes a database query to be executed.\nIt appears that this also happens if the relationship between Profile and User is a ForeignKey rather than a OneToOneField (in that case, a query is executed when accessing user.profile_set.all()[0].user.kind).\nI'm happy to attempt to tackle this if someone can (a) confirm it's actually a bug and (b) point me in the right direction!\nThanks :)\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-15315",
"repo": "django/django",
"base_commit": "652c68ffeebd510a6f59e1b56b3e007d07683ad8",
"problem_statement": "Model Field.__hash__() should be immutable.\nDescription\n\t\nField.__hash__ changes value when a field is assigned to a model class.\nThis code crashes with an AssertionError:\nfrom django.db import models\nf = models.CharField(max_length=200)\nd = {f: 1}\nclass Book(models.Model):\n\ttitle = f\nassert f in d\nThe bug was introduced in #31750.\nIt's unlikely to have been encountered because there are few use cases to put a field in a dict *before* it's assigned to a model class. But I found a reason to do so whilst implementing #26472 and the behaviour had me stumped for a little.\nIMO we can revert the __hash__ change from #31750. Objects with the same hash are still checked for equality, which was fixed in that ticket. But it's bad if an object's hash changes, since it breaks its use in dicts.\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "django__django-15503",
"repo": "django/django",
"base_commit": "859a87d873ce7152af73ab851653b4e1c3ffea4c",
"problem_statement": "has_key, has_keys, and has_any_keys JSONField() lookups don't handle numeric keys on SQLite, MySQL, and Oracle.\nDescription\n\t \n\t\t(last modified by TheTerrasque)\n\t \nProblem\nWhen using models.​JSONField() ​has_key lookup with numerical keys on SQLite database it fails to find the keys.\nVersions:\nDjango: 4.0.3\nPython: 3.9.6 (tags/v3.9.6:db3ff76, Jun 28 2021, 15:26:21) [MSC v.1929 64 bit (AMD64)] on win32\nsqlite3.version: '2.6.0'\nsqlite3.sqlite_version: '3.35.5'\nExample:\nDatabase\nDATABASES = {\n\t'default': {\n\t\t'ENGINE': 'django.db.backends.sqlite3',\n\t\t'NAME': 'db.sqlite3',\n\t}\n}\nModel\nclass JsonFieldHasKeyTest(models.Model):\n\tdata = models.JSONField()\nTest\nfrom django.test import TestCase\nfrom .models import JsonFieldHasKeyTest\nclass JsonFieldHasKeyTestCase(TestCase):\n\tdef setUp(self) -> None:\n\t\ttest = JsonFieldHasKeyTest(data={'foo': 'bar'})\n\t\ttest2 = JsonFieldHasKeyTest(data={'1111': 'bar'})\n\t\ttest.save()\n\t\ttest2.save()\n\tdef test_json_field_has_key(self):\n\t\tc1 = JsonFieldHasKeyTest.objects.filter(data__has_key='foo').count()\n\t\tc2 = JsonFieldHasKeyTest.objects.filter(data__has_key='1111').count()\n\t\tself.assertEqual(c1, 1, \"Should have found 1 entry with key 'foo'\")\n\t\tself.assertEqual(c2, 1, \"Should have found 1 entry with key '1111'\")\nResult\nFAIL: test_json_field_has_key (markers.tests.JsonFieldHasKeyTestCase)\n----------------------------------------------------------------------\nTraceback (most recent call last):\n File \"H:\\Files\\Projects\\Electaco\\Webservice\\elecserve\\markers\\tests.py\", line 16, in test_json_field_has_key\t \n\tself.assertEqual(c2, 1, \"Should have found 1 entry with key '1111'\")\nAssertionError: 0 != 1 : Should have found 1 entry with key '1111'\nAdditional info\nThis has been tested on SQLite and Postgresql backend, it works on postgresql but fails on sqlite.\n",
"difficulty": "1-4 hours"
},
{
"instance_id": "django__django-15814",
"repo": "django/django",
"base_commit": "5eb6a2b33d70b9889e1cafa12594ad6f80773d3a",
"problem_statement": "QuerySet.only() after select_related() crash on proxy models.\nDescription\n\t\nWhen I optimize a query using select_related() and only() methods from the proxy model I encounter an error:\nWindows 10; Python 3.10; Django 4.0.5\nTraceback (most recent call last):\n File \"D:\\study\\django_college\\manage.py\", line 22, in <module>\n\tmain()\n File \"D:\\study\\django_college\\manage.py\", line 18, in main\n\texecute_from_command_line(sys.argv)\n File \"D:\\Anaconda3\\envs\\django\\lib\\site-packages\\django\\core\\management\\__init__.py\", line 446, in execute_from_command_line\n\tutility.execute()\n File \"D:\\Anaconda3\\envs\\django\\lib\\site-packages\\django\\core\\management\\__init__.py\", line 440, in execute\n\tself.fetch_command(subcommand).run_from_argv(self.argv)\n File \"D:\\Anaconda3\\envs\\django\\lib\\site-packages\\django\\core\\management\\base.py\", line 414, in run_from_argv\n\tself.execute(*args, **cmd_options)\n File \"D:\\Anaconda3\\envs\\django\\lib\\site-packages\\django\\core\\management\\base.py\", line 460, in execute\n\toutput = self.handle(*args, **options)\n File \"D:\\study\\django_college\\project\\users\\management\\commands\\test_proxy.py\", line 9, in handle\n\tobjs = list(AnotherModel.objects.select_related(\"custom\").only(\"custom__name\").all())\n File \"D:\\Anaconda3\\envs\\django\\lib\\site-packages\\django\\db\\models\\query.py\", line 302, in __len__\n\tself._fetch_all()\n File \"D:\\Anaconda3\\envs\\django\\lib\\site-packages\\django\\db\\models\\query.py\", line 1507, in _fetch_all\n\tself._result_cache = list(self._iterable_class(self))\n File \"D:\\Anaconda3\\envs\\django\\lib\\site-packages\\django\\db\\models\\query.py\", line 71, in __iter__\n\trelated_populators = get_related_populators(klass_info, select, db)\n File \"D:\\Anaconda3\\envs\\django\\lib\\site-packages\\django\\db\\models\\query.py\", line 2268, in get_related_populators\n\trel_cls = RelatedPopulator(rel_klass_info, select, db)\n File \"D:\\Anaconda3\\envs\\django\\lib\\site-packages\\django\\db\\models\\query.py\", line 2243, in __init__\n\tself.pk_idx = self.init_list.index(self.model_cls._meta.pk.attname)\nValueError: 'id' is not in list\nModels:\nclass CustomModel(models.Model):\n\tname = models.CharField(max_length=16)\nclass ProxyCustomModel(CustomModel):\n\tclass Meta:\n\t\tproxy = True\nclass AnotherModel(models.Model):\n\tcustom = models.ForeignKey(\n\t\tProxyCustomModel,\n\t\ton_delete=models.SET_NULL,\n\t\tnull=True,\n\t\tblank=True,\n\t)\nCommand:\nclass Command(BaseCommand):\n\tdef handle(self, *args, **options):\n\t\tlist(AnotherModel.objects.select_related(\"custom\").only(\"custom__name\").all())\nAt django/db/models/sql/query.py in 745 line there is snippet:\nopts = cur_model._meta\nIf I replace it by \nopts = cur_model._meta.concrete_model._meta\nall works as expected.\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-15930",
"repo": "django/django",
"base_commit": "63884829acd207404f2a5c3cc1d6b4cd0a822b70",
"problem_statement": "Case() crashes with ~Q(pk__in=[]).\nDescription\n\t\nThe following code generates a syntax error. \nUser.objects.annotate(\n\t_a=Case(\n\t\tWhen(~Q(pk__in=[]), then=Value(True)),\n\t\tdefault=Value(False),\n\t\toutput_field=BooleanField(),\n\t)\n).order_by(\"-a\").values(\"pk\")\nThe error is: \nProgrammingError: syntax error at or near \"THEN\"\nLINE 1: ..._user\".\"id\" FROM \"users_user\" ORDER BY CASE WHEN THEN true ...\nThe generated SQL is: \nSELECT \"users_user\".\"id\" FROM \"users_user\" ORDER BY CASE WHEN THEN True ELSE False END ASC\nI expected behavior to annotate all rows with the value True since they all match.\nRelevant because ~Q(pkin=[]) is a sentinel value that is sometimes returned by application code.\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "django__django-16493",
"repo": "django/django",
"base_commit": "e3a4cee081cf60650b8824f0646383b79cb110e7",
"problem_statement": "Callable storage on FileField fails to deconstruct when it returns default_storage\nDescription\n\t\nIf the storage argument on a FileField is set to a callable that returns default_storage, it is omitted from the deconstructed form of the field, rather than being included as a reference to the callable as expected.\nFor example, given a model definition:\nfrom django.core.files.storage import FileSystemStorage, default_storage\nfrom django.db import models\nimport random\nother_storage = FileSystemStorage(location='/media/other')\ndef get_storage():\n\treturn random.choice([default_storage, other_storage])\nclass MyModel(models.Model):\n\tmy_file = models.FileField(storage=get_storage)\nrepeatedly running makemigrations will randomly generate a migration that alternately includes or omits storage=myapp.models.get_storage on the FileField definition.\nThis case was overlooked in the fix for #31941 - the deconstruct method tests if self.storage is not default_storage to determine whether to add the storage kwarg, but at this point self.storage is the evaluated version, so it wrongly returns false for a callable that returns default_storage.\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "django__django-16595",
"repo": "django/django",
"base_commit": "f9fe062de5fc0896d6bbbf3f260b5c44473b3c77",
"problem_statement": "Migration optimizer does not reduce multiple AlterField\nDescription\n\t\nLet's consider the following operations: \noperations = [\n\tmigrations.AddField(\n\t\tmodel_name=\"book\",\n\t\tname=\"title\",\n\t\tfield=models.CharField(max_length=256, null=True),\n\t),\n\tmigrations.AlterField(\n\t\tmodel_name=\"book\",\n\t\tname=\"title\",\n\t\tfield=models.CharField(max_length=128, null=True),\n\t),\n\tmigrations.AlterField(\n\t\tmodel_name=\"book\",\n\t\tname=\"title\",\n\t\tfield=models.CharField(max_length=128, null=True, help_text=\"help\"),\n\t),\n\tmigrations.AlterField(\n\t\tmodel_name=\"book\",\n\t\tname=\"title\",\n\t\tfield=models.CharField(max_length=128, null=True, help_text=\"help\", default=None),\n\t),\n]\nIf I run the optimizer, I get only the AddField, as we could expect. However, if the AddField model is separated from the AlterField (e.g. because of a non-elidable migration, or inside a non-squashed migration), none of the AlterField are reduced:\noptimizer.optimize(operations[1:], \"books\") \n[<AlterField model_name='book', name='title', field=<django.db.models.fields.CharField>>,\n <AlterField model_name='book', name='title', field=<django.db.models.fields.CharField>>,\n <AlterField model_name='book', name='title', field=<django.db.models.fields.CharField>>]\nIndeed, the AlterField.reduce does not consider the the case where operation is also an AlterField. \nIs this behaviour intended? If so, could it be documented? \nOtherwise, would it make sense to add something like\n\t\tif isinstance(operation, AlterField) and self.is_same_field_operation(\n\t\t\toperation\n\t\t):\n\t\t\treturn [operation]\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "django__django-16938",
"repo": "django/django",
"base_commit": "1136aa5005f0ae70fea12796b7e37d6f027b9263",
"problem_statement": "Serialization of m2m relation fails with custom manager using select_related\nDescription\n\t\nSerialization of many to many relation with custom manager using select_related cause FieldError: Field cannot be both deferred and traversed using select_related at the same time. Exception is raised because performance optimalization #33937.\nWorkaround is to set simple default manager. However I not sure if this is bug or expected behaviour.\nclass TestTagManager(Manager):\n\tdef get_queryset(self):\n\t\tqs = super().get_queryset()\n\t\tqs = qs.select_related(\"master\") # follow master when retrieving object by default\n\t\treturn qs\nclass TestTagMaster(models.Model):\n\tname = models.CharField(max_length=120)\nclass TestTag(models.Model):\n\t# default = Manager() # solution is to define custom default manager, which is used by RelatedManager\n\tobjects = TestTagManager()\n\tname = models.CharField(max_length=120)\n\tmaster = models.ForeignKey(TestTagMaster, on_delete=models.SET_NULL, null=True)\nclass Test(models.Model):\n\tname = models.CharField(max_length=120)\n\ttags = models.ManyToManyField(TestTag, blank=True)\nNow when serializing object\nfrom django.core import serializers\nfrom test.models import TestTag, Test, TestTagMaster\ntag_master = TestTagMaster.objects.create(name=\"master\")\ntag = TestTag.objects.create(name=\"tag\", master=tag_master)\ntest = Test.objects.create(name=\"test\")\ntest.tags.add(tag)\ntest.save()\nserializers.serialize(\"json\", [test])\nSerialize raise exception because is not possible to combine select_related and only.\n File \"/opt/venv/lib/python3.11/site-packages/django/core/serializers/__init__.py\", line 134, in serialize\n\ts.serialize(queryset, **options)\n File \"/opt/venv/lib/python3.11/site-packages/django/core/serializers/base.py\", line 167, in serialize\n\tself.handle_m2m_field(obj, field)\n File \"/opt/venv/lib/python3.11/site-packages/django/core/serializers/python.py\", line 88, in handle_m2m_field\n\tself._current[field.name] = [m2m_value(related) for related in m2m_iter]\n\t\t\t\t\t\t\t\t^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/opt/venv/lib/python3.11/site-packages/django/core/serializers/python.py\", line 88, in <listcomp>\n\tself._current[field.name] = [m2m_value(related) for related in m2m_iter]\n\t\t\t\t\t\t\t\t^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/opt/venv/lib/python3.11/site-packages/django/db/models/query.py\", line 516, in _iterator\n\tyield from iterable\n File \"/opt/venv/lib/python3.11/site-packages/django/db/models/query.py\", line 91, in __iter__\n\tresults = compiler.execute_sql(\n\t\t\t ^^^^^^^^^^^^^^^^^^^^^\n File \"/opt/venv/lib/python3.11/site-packages/django/db/models/sql/compiler.py\", line 1547, in execute_sql\n\tsql, params = self.as_sql()\n\t\t\t\t ^^^^^^^^^^^^^\n File \"/opt/venv/lib/python3.11/site-packages/django/db/models/sql/compiler.py\", line 734, in as_sql\n\textra_select, order_by, group_by = self.pre_sql_setup(\n\t\t\t\t\t\t\t\t\t ^^^^^^^^^^^^^^^^^^^\n File \"/opt/venv/lib/python3.11/site-packages/django/db/models/sql/compiler.py\", line 84, in pre_sql_setup\n\tself.setup_query(with_col_aliases=with_col_aliases)\n File \"/opt/venv/lib/python3.11/site-packages/django/db/models/sql/compiler.py\", line 73, in setup_query\n\tself.select, self.klass_info, self.annotation_col_map = self.get_select(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t^^^^^^^^^^^^^^^^\n File \"/opt/venv/lib/python3.11/site-packages/django/db/models/sql/compiler.py\", line 279, in get_select\n\trelated_klass_infos = self.get_related_selections(select, select_mask)\n\t\t\t\t\t\t ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/opt/venv/lib/python3.11/site-packages/django/db/models/sql/compiler.py\", line 1209, in get_related_selections\n\tif not select_related_descend(f, restricted, requested, select_mask):\n\t\t ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/opt/venv/lib/python3.11/site-packages/django/db/models/query_utils.py\", line 347, in select_related_descend\n\traise FieldError(\ndjango.core.exceptions.FieldError: Field TestTag.master cannot be both deferred and traversed using select_related at the same time.\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "matplotlib__matplotlib-20826",
"repo": "matplotlib/matplotlib",
"base_commit": "a0d2e399729d36499a1924e5ca5bc067c8396810",
"problem_statement": "ax.clear() adds extra ticks, un-hides shared-axis tick labels\n### Bug report\r\n\r\n**Bug summary**\r\n\r\nWhen using shared axes (e.g. from `plt.subplots(2, 2, sharex=True, sharey=True)`), calling `ax.clear()` causes ticks and tick labels to be shown that should be hidden. The axes are still linked, though (e.g. adjusting the plotting range on one subplot adjusts the others as well). This is a behavior change between matplotlib 3.4.1 and 3.4.2.\r\n\r\n**Code for reproduction**\r\n\r\nThis code produces different results with matplotlib 3.4.1 and 3.4.2:\r\n\r\n```python\r\nimport numpy as np\r\nimport matplotlib.pyplot as plt\r\n\r\nfig, axes = plt.subplots(2, 2, sharex=True, sharey=True)\r\n\r\nx = np.arange(0.0, 2*np.pi, 0.01)\r\ny = np.sin(x)\r\n\r\nfor ax in axes.flatten():\r\n ax.clear()\r\n ax.plot(x, y)\r\n```\r\n\r\nThis example is of course silly, but I use the general pattern when making animations with FuncAnimation, where my plotting function is a complex module which doesn't facilitate blitting, so I clear and re-use the axes for each frame of the animation.\r\n\r\n**Actual outcome**\r\n\r\nThis is the plot produced with matplotlib 3.4.2:\r\n\r\n![matplotlib-3 4 2](https://user-images.githubusercontent.com/23462789/126717195-a974fcf6-52d6-465b-841e-4f8172964dcd.png)\r\n\r\nThe presence of tick labels that should be hidden by virtue of the shared axes is the clearest problem in this plot, but there are also ticks that appear along the top and right side of each subplot which are not present in the example below (and not part of the default plotting style, IIRC).\r\n\r\nThe top and right-side ticks also appear when not using multiple subplots, so I think the shared-axis aspect reveals another symptom but is not a core part of this bug.\r\n\r\nIf the `ax.clear()` call is removed, the plot produced with matplotlib 3.4.2 appears identical to the 3.4.1 plot below.\r\n\r\n**Expected outcome**\r\n\r\nThis is the plot produced with matplotlib 3.4.1:\r\n\r\n![matplotlib-3 4 1](https://user-images.githubusercontent.com/23462789/126717203-e755c628-0e32-4a7d-80a0-90c1a3ca6eb7.png)\r\n\r\n**Matplotlib version**\r\n * Operating system: Ubuntu 20.04\r\n * Matplotlib version (`import matplotlib; print(matplotlib.__version__)`): 3.4.2\r\n * Matplotlib backend (`print(matplotlib.get_backend())`): module://matplotlib_inline.backend_inline\r\n * Python version: 3.8.10\r\n * Jupyter version (if applicable): jupyter core 4.7.1, jupyter lab 3.0.16\r\n * Other libraries: \r\n\r\nI've installed matplotlib (3.4.2-py38h578d9bd_0) via conda from conda-forge\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "matplotlib__matplotlib-24149",
"repo": "matplotlib/matplotlib",
"base_commit": "af39f1edffcd828f05cfdd04f2e59506bb4a27bc",
"problem_statement": "[Bug]: ax.bar raises for all-nan data on matplotlib 3.6.1 \n### Bug summary\n\n`ax.bar` raises an exception in 3.6.1 when passed only nan data. This irrevocably breaks seaborn's histogram function (which draws and then removes a \"phantom\" bar to trip the color cycle).\n\n### Code for reproduction\n\n```python\nimport numpy as np\r\nimport matplotlib.pyplot as plt\r\nf, ax = plt.subplots()\r\nax.bar([np.nan], [np.nan])\n```\n\n\n### Actual outcome\n\n```python-traceback\r\n---------------------------------------------------------------------------\r\nStopIteration Traceback (most recent call last)\r\nCell In [1], line 4\r\n 2 import matplotlib.pyplot as plt\r\n 3 f, ax = plt.subplots()\r\n----> 4 ax.bar([np.nan], [np.nan])[0].get_x()\r\n\r\nFile ~/miniconda/envs/py310/lib/python3.10/site-packages/matplotlib/__init__.py:1423, in _preprocess_data.<locals>.inner(ax, data, *args, **kwargs)\r\n 1420 @functools.wraps(func)\r\n 1421 def inner(ax, *args, data=None, **kwargs):\r\n 1422 if data is None:\r\n-> 1423 return func(ax, *map(sanitize_sequence, args), **kwargs)\r\n 1425 bound = new_sig.bind(ax, *args, **kwargs)\r\n 1426 auto_label = (bound.arguments.get(label_namer)\r\n 1427 or bound.kwargs.get(label_namer))\r\n\r\nFile ~/miniconda/envs/py310/lib/python3.10/site-packages/matplotlib/axes/_axes.py:2373, in Axes.bar(self, x, height, width, bottom, align, **kwargs)\r\n 2371 x0 = x\r\n 2372 x = np.asarray(self.convert_xunits(x))\r\n-> 2373 width = self._convert_dx(width, x0, x, self.convert_xunits)\r\n 2374 if xerr is not None:\r\n 2375 xerr = self._convert_dx(xerr, x0, x, self.convert_xunits)\r\n\r\nFile ~/miniconda/envs/py310/lib/python3.10/site-packages/matplotlib/axes/_axes.py:2182, in Axes._convert_dx(dx, x0, xconv, convert)\r\n 2170 try:\r\n 2171 # attempt to add the width to x0; this works for\r\n 2172 # datetime+timedelta, for instance\r\n (...)\r\n 2179 # removes the units from unit packages like `pint` that\r\n 2180 # wrap numpy arrays.\r\n 2181 try:\r\n-> 2182 x0 = cbook._safe_first_finite(x0)\r\n 2183 except (TypeError, IndexError, KeyError):\r\n 2184 pass\r\n\r\nFile ~/miniconda/envs/py310/lib/python3.10/site-packages/matplotlib/cbook/__init__.py:1749, in _safe_first_finite(obj, skip_nonfinite)\r\n 1746 raise RuntimeError(\"matplotlib does not \"\r\n 1747 \"support generators as input\")\r\n 1748 else:\r\n-> 1749 return next(val for val in obj if safe_isfinite(val))\r\n\r\nStopIteration: \r\n```\n\n### Expected outcome\n\nOn 3.6.0 this returns a `BarCollection` with one Rectangle, having `nan` for `x` and `height`.\n\n### Additional information\n\nI assume it's related to this bullet in the release notes:\r\n\r\n- Fix barplot being empty when first element is NaN\r\n\r\nBut I don't know the context for it to investigate further (could these link to PRs?)\r\n\r\nFurther debugging:\r\n\r\n```python\r\nax.bar([np.nan], [0]) # Raises\r\nax.bar([0], [np.nan]) # Works\r\n```\r\n\r\nSo it's about the x position specifically.\n\n### Operating system\n\nMacos\n\n### Matplotlib Version\n\n3.6.1\n\n### Matplotlib Backend\n\n_No response_\n\n### Python version\n\n_No response_\n\n### Jupyter version\n\n_No response_\n\n### Installation\n\npip\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "matplotlib__matplotlib-24970",
"repo": "matplotlib/matplotlib",
"base_commit": "a3011dfd1aaa2487cce8aa7369475533133ef777",
"problem_statement": "[Bug]: NumPy 1.24 deprecation warnings\n### Bug summary\r\n\r\nStarting NumPy 1.24 I observe several deprecation warnings.\r\n\r\n\r\n### Code for reproduction\r\n\r\n```python\r\nimport matplotlib.pyplot as plt\r\nimport numpy as np\r\n\r\nplt.get_cmap()(np.empty((0, ), dtype=np.uint8))\r\n```\r\n\r\n\r\n### Actual outcome\r\n\r\n```\r\n/usr/lib/python3.10/site-packages/matplotlib/colors.py:730: DeprecationWarning: NumPy will stop allowing conversion of out-of-bound Python integers to integer arrays. The conversion of 257 to uint8 will fail in the future.\r\nFor the old behavior, usually:\r\n np.array(value).astype(dtype)`\r\nwill give the desired result (the cast overflows).\r\n xa[xa > self.N - 1] = self._i_over\r\n/usr/lib/python3.10/site-packages/matplotlib/colors.py:731: DeprecationWarning: NumPy will stop allowing conversion of out-of-bound Python integers to integer arrays. The conversion of 256 to uint8 will fail in the future.\r\nFor the old behavior, usually:\r\n np.array(value).astype(dtype)`\r\nwill give the desired result (the cast overflows).\r\n xa[xa < 0] = self._i_under\r\n/usr/lib/python3.10/site-packages/matplotlib/colors.py:732: DeprecationWarning: NumPy will stop allowing conversion of out-of-bound Python integers to integer arrays. The conversion of 258 to uint8 will fail in the future.\r\nFor the old behavior, usually:\r\n np.array(value).astype(dtype)`\r\nwill give the desired result (the cast overflows).\r\n xa[mask_bad] = self._i_bad\r\n```\r\n\r\n### Expected outcome\r\n\r\nNo warnings.\r\n\r\n### Additional information\r\n\r\n_No response_\r\n\r\n### Operating system\r\n\r\nArchLinux\r\n\r\n### Matplotlib Version\r\n\r\n3.6.2\r\n\r\n### Matplotlib Backend\r\n\r\nQtAgg\r\n\r\n### Python version\r\n\r\nPython 3.10.9\r\n\r\n### Jupyter version\r\n\r\n_No response_\r\n\r\n### Installation\r\n\r\nLinux package manager\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "psf__requests-2317",
"repo": "psf/requests",
"base_commit": "091991be0da19de9108dbe5e3752917fea3d7fdc",
"problem_statement": "method = builtin_str(method) problem\nIn requests/sessions.py is a command:\n\nmethod = builtin_str(method)\nConverts method from\nb’GET’\nto\n\"b'GET’\"\n\nWhich is the literal string, no longer a binary string. When requests tries to use the method \"b'GET’”, it gets a 404 Not Found response.\n\nI am using python3.4 and python-neutronclient (2.3.9) with requests (2.4.3). neutronclient is broken because it uses this \"args = utils.safe_encode_list(args)\" command which converts all the values to binary string, including method.\n\nI'm not sure if this is a bug with neutronclient or a bug with requests, but I'm starting here. Seems if requests handled the method value being a binary string, we wouldn't have any problem.\n\nAlso, I tried in python2.6 and this bug doesn't exist there. Some difference between 2.6 and 3.4 makes this not work right.\n\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "pydata__xarray-2905",
"repo": "pydata/xarray",
"base_commit": "7c4e2ac83f7b4306296ff9b7b51aaf016e5ad614",
"problem_statement": "Variable.__setitem__ coercing types on objects with a values property\n#### Minimal example\r\n```python\r\nimport xarray as xr\r\n\r\ngood_indexed, bad_indexed = xr.DataArray([None]), xr.DataArray([None])\r\n\r\nclass HasValues(object):\r\n values = 5\r\n \r\ngood_indexed.loc[{'dim_0': 0}] = set()\r\nbad_indexed.loc[{'dim_0': 0}] = HasValues()\r\n\r\n# correct\r\n# good_indexed.values => array([set()], dtype=object)\r\n\r\n# incorrect\r\n# bad_indexed.values => array([array(5)], dtype=object)\r\n```\r\n#### Problem description\r\n\r\nThe current behavior prevents storing objects inside arrays of `dtype==object` even when only performing non-broadcasted assignments if the RHS has a `values` property. Many libraries produce objects with a `.values` property that gets coerced as a result.\r\n\r\nThe use case I had in prior versions was to store `ModelResult` instances from the curve fitting library `lmfit`, when fitting had be performed over an axis of a `Dataset` or `DataArray`.\r\n\r\n#### Expected Output\r\n\r\nIdeally:\r\n```\r\n...\r\n# bad_indexed.values => array([< __main__.HasValues instance>], dtype=object)\r\n```\r\n\r\n#### Output of ``xr.show_versions()``\r\n\r\nBreaking changed introduced going from `v0.10.0` -> `v0.10.1` as a result of https://github.com/pydata/xarray/pull/1746, namely the change on line https://github.com/fujiisoup/xarray/blob/6906eebfc7645d06ee807773f5df9215634addef/xarray/core/variable.py#L641.\r\n\r\n<details>\r\nINSTALLED VERSIONS\r\n------------------\r\ncommit: None\r\npython: 3.5.4.final.0\r\npython-bits: 64\r\nOS: Darwin\r\nOS-release: 16.7.0\r\nmachine: x86_64\r\nprocessor: i386\r\nbyteorder: little\r\nLC_ALL: None\r\nLANG: en_US.UTF-8\r\nLOCALE: en_US.UTF-8\r\n\r\nxarray: 0.10.1\r\npandas: 0.20.3\r\nnumpy: 1.13.1\r\nscipy: 0.19.1\r\nnetCDF4: 1.3.0\r\nh5netcdf: None\r\nh5py: 2.7.0\r\nNio: None\r\nzarr: None\r\nbottleneck: None\r\ncyordereddict: None\r\ndask: 0.15.2\r\ndistributed: None\r\nmatplotlib: 2.0.2\r\ncartopy: None\r\nseaborn: 0.8.1\r\nsetuptools: 38.4.0\r\npip: 9.0.1\r\nconda: None\r\npytest: 3.3.2\r\nIPython: 6.1.0\r\nsphinx: None\r\n</details>\r\n\r\nThank you for your help! If I can be brought to better understand any constraints to adjacent issues, I can consider drafting a fix for this. \n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "pydata__xarray-6938",
"repo": "pydata/xarray",
"base_commit": "c4e40d991c28be51de9ac560ce895ac7f9b14924",
"problem_statement": "`.swap_dims()` can modify original object\n### What happened?\r\n\r\nThis is kind of a convoluted example, but something I ran into. It appears that in certain cases `.swap_dims()` can modify the original object, here the `.dims` of a data variable that was swapped into being a dimension coordinate variable.\r\n\r\n### What did you expect to happen?\r\n\r\nI expected it not to modify the original object.\r\n\r\n### Minimal Complete Verifiable Example\r\n\r\n```Python\r\nimport numpy as np\r\nimport xarray as xr\r\n\r\nnz = 11\r\nds = xr.Dataset(\r\n data_vars={\r\n \"y\": (\"z\", np.random.rand(nz)),\r\n \"lev\": (\"z\", np.arange(nz) * 10),\r\n # ^ We want this to be a dimension coordinate\r\n },\r\n)\r\nprint(f\"ds\\n{ds}\")\r\nprint(f\"\\nds, 'lev' -> dim coord\\n{ds.swap_dims(z='lev')}\")\r\n\r\nds2 = (\r\n ds.swap_dims(z=\"lev\")\r\n .rename_dims(lev=\"z\")\r\n .reset_index(\"lev\")\r\n .reset_coords()\r\n)\r\nprint(f\"\\nds2\\n{ds2}\")\r\n# ^ This Dataset appears same as the original\r\n\r\nprint(f\"\\nds2, 'lev' -> dim coord\\n{ds2.swap_dims(z='lev')}\")\r\n# ^ Produces a Dataset with dimension coordinate 'lev'\r\nprint(f\"\\nds2 after .swap_dims() applied\\n{ds2}\")\r\n# ^ `ds2['lev']` now has dimension 'lev' although otherwise same\r\n```\r\n\r\n\r\n### MVCE confirmation\r\n\r\n- [X] Minimal example — the example is as focused as reasonably possible to demonstrate the underlying issue in xarray.\r\n- [X] Complete example — the example is self-contained, including all data and the text of any traceback.\r\n- [X] Verifiable example — the example copy & pastes into an IPython prompt or [Binder notebook](https://mybinder.org/v2/gh/pydata/xarray/main?urlpath=lab/tree/doc/examples/blank_template.ipynb), returning the result.\r\n- [X] New issue — a search of GitHub Issues suggests this is not a duplicate.\r\n\r\n### Relevant log output\r\n\r\n_No response_\r\n\r\n### Anything else we need to know?\r\n\r\nMore experiments in [this Gist](https://gist.github.com/zmoon/372d08fae8f38791b95281e951884148#file-moving-data-var-to-dim-ipynb).\r\n\r\n### Environment\r\n\r\n<details>\r\n\r\n```\r\nINSTALLED VERSIONS\r\n------------------\r\ncommit: None\r\npython: 3.8.13 | packaged by conda-forge | (default, Mar 25 2022, 05:59:00) [MSC v.1929 64 bit (AMD64)]\r\npython-bits: 64\r\nOS: Windows\r\nOS-release: 10\r\nmachine: AMD64\r\nprocessor: AMD64 Family 23 Model 113 Stepping 0, AuthenticAMD\r\nbyteorder: little\r\nLC_ALL: None\r\nLANG: None\r\nLOCALE: ('English_United States', '1252')\r\nlibhdf5: 1.12.1\r\nlibnetcdf: 4.8.1\r\n\r\nxarray: 2022.6.0\r\npandas: 1.4.0\r\nnumpy: 1.22.1\r\nscipy: 1.7.3\r\nnetCDF4: 1.5.8\r\npydap: None\r\nh5netcdf: None\r\nh5py: None\r\nNio: None\r\nzarr: None\r\ncftime: 1.6.1\r\nnc_time_axis: None\r\nPseudoNetCDF: None\r\nrasterio: None\r\ncfgrib: None\r\niris: None\r\nbottleneck: None\r\ndask: 2022.01.1\r\ndistributed: 2022.01.1\r\nmatplotlib: None\r\ncartopy: None\r\nseaborn: None\r\nnumbagg: None\r\nfsspec: 2022.01.0\r\ncupy: None\r\npint: None\r\nsparse: None\r\nflox: None\r\nnumpy_groupies: None\r\nsetuptools: 59.8.0\r\npip: 22.0.2\r\nconda: None\r\npytest: None\r\nIPython: 8.0.1\r\nsphinx: 4.4.0\r\n```\r\n</details>\r\n\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "pylint-dev__pylint-6386",
"repo": "pylint-dev/pylint",
"base_commit": "754b487f4d892e3d4872b6fc7468a71db4e31c13",
"problem_statement": "Argument expected for short verbose option\n### Bug description\r\n\r\nThe short option of the `verbose` option expects an argument.\r\nAlso, the help message for the `verbose` option suggests a value `VERBOSE` should be provided.\r\n\r\nThe long option works ok & doesn't expect an argument:\r\n`pylint mytest.py --verbose`\r\n\r\n\r\n### Command used\r\n\r\n```shell\r\npylint mytest.py -v\r\n```\r\n\r\n\r\n### Pylint output\r\n\r\n```shell\r\nusage: pylint [options]\r\npylint: error: argument --verbose/-v: expected one argument\r\n```\r\n\r\n### Expected behavior\r\n\r\nSimilar behaviour to the long option.\r\n\r\n### Pylint version\r\n\r\n```shell\r\npylint 2.14.0-dev0\r\nastroid 2.11.2\r\nPython 3.10.0b2 (v3.10.0b2:317314165a, May 31 2021, 10:02:22) [Clang 12.0.5 (clang-1205.0.22.9)]\r\n```\r\n\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "pytest-dev__pytest-7205",
"repo": "pytest-dev/pytest",
"base_commit": "5e7f1ab4bf58e473e5d7f878eb2b499d7deabd29",
"problem_statement": "BytesWarning when using --setup-show with bytes parameter\nWith Python 3.8.2, pytest 5.4.1 (or latest master; stacktraces are from there) and this file:\r\n\r\n```python\r\nimport pytest\r\n\r\[email protected]('data', [b'Hello World'])\r\ndef test_data(data):\r\n pass\r\n```\r\n\r\nwhen running `python3 -bb -m pytest --setup-show` (note the `-bb` to turn on ByteWarning and treat it as error), I get:\r\n\r\n```\r\n___________________ ERROR at setup of test_data[Hello World] ___________________\r\n\r\ncls = <class '_pytest.runner.CallInfo'>\r\nfunc = <function call_runtest_hook.<locals>.<lambda> at 0x7fb1f3e29d30>\r\nwhen = 'setup'\r\nreraise = (<class '_pytest.outcomes.Exit'>, <class 'KeyboardInterrupt'>)\r\n\r\n @classmethod\r\n def from_call(cls, func, when, reraise=None) -> \"CallInfo\":\r\n #: context of invocation: one of \"setup\", \"call\",\r\n #: \"teardown\", \"memocollect\"\r\n start = time()\r\n excinfo = None\r\n try:\r\n> result = func()\r\n\r\nsrc/_pytest/runner.py:244: \r\n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \r\nsrc/_pytest/runner.py:217: in <lambda>\r\n lambda: ihook(item=item, **kwds), when=when, reraise=reraise\r\n.venv/lib/python3.8/site-packages/pluggy/hooks.py:286: in __call__\r\n return self._hookexec(self, self.get_hookimpls(), kwargs)\r\n.venv/lib/python3.8/site-packages/pluggy/manager.py:93: in _hookexec\r\n return self._inner_hookexec(hook, methods, kwargs)\r\n.venv/lib/python3.8/site-packages/pluggy/manager.py:84: in <lambda>\r\n self._inner_hookexec = lambda hook, methods, kwargs: hook.multicall(\r\nsrc/_pytest/runner.py:123: in pytest_runtest_setup\r\n item.session._setupstate.prepare(item)\r\nsrc/_pytest/runner.py:376: in prepare\r\n raise e\r\nsrc/_pytest/runner.py:373: in prepare\r\n col.setup()\r\nsrc/_pytest/python.py:1485: in setup\r\n fixtures.fillfixtures(self)\r\nsrc/_pytest/fixtures.py:297: in fillfixtures\r\n request._fillfixtures()\r\nsrc/_pytest/fixtures.py:477: in _fillfixtures\r\n item.funcargs[argname] = self.getfixturevalue(argname)\r\nsrc/_pytest/fixtures.py:487: in getfixturevalue\r\n return self._get_active_fixturedef(argname).cached_result[0]\r\nsrc/_pytest/fixtures.py:503: in _get_active_fixturedef\r\n self._compute_fixture_value(fixturedef)\r\nsrc/_pytest/fixtures.py:584: in _compute_fixture_value\r\n fixturedef.execute(request=subrequest)\r\nsrc/_pytest/fixtures.py:914: in execute\r\n return hook.pytest_fixture_setup(fixturedef=self, request=request)\r\n.venv/lib/python3.8/site-packages/pluggy/hooks.py:286: in __call__\r\n return self._hookexec(self, self.get_hookimpls(), kwargs)\r\n.venv/lib/python3.8/site-packages/pluggy/manager.py:93: in _hookexec\r\n return self._inner_hookexec(hook, methods, kwargs)\r\n.venv/lib/python3.8/site-packages/pluggy/manager.py:84: in <lambda>\r\n self._inner_hookexec = lambda hook, methods, kwargs: hook.multicall(\r\nsrc/_pytest/setuponly.py:34: in pytest_fixture_setup\r\n _show_fixture_action(fixturedef, \"SETUP\")\r\n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \r\n\r\nfixturedef = <FixtureDef argname='data' scope='function' baseid=''>\r\nmsg = 'SETUP'\r\n\r\n def _show_fixture_action(fixturedef, msg):\r\n config = fixturedef._fixturemanager.config\r\n capman = config.pluginmanager.getplugin(\"capturemanager\")\r\n if capman:\r\n capman.suspend_global_capture()\r\n \r\n tw = config.get_terminal_writer()\r\n tw.line()\r\n tw.write(\" \" * 2 * fixturedef.scopenum)\r\n tw.write(\r\n \"{step} {scope} {fixture}\".format(\r\n step=msg.ljust(8), # align the output to TEARDOWN\r\n scope=fixturedef.scope[0].upper(),\r\n fixture=fixturedef.argname,\r\n )\r\n )\r\n \r\n if msg == \"SETUP\":\r\n deps = sorted(arg for arg in fixturedef.argnames if arg != \"request\")\r\n if deps:\r\n tw.write(\" (fixtures used: {})\".format(\", \".join(deps)))\r\n \r\n if hasattr(fixturedef, \"cached_param\"):\r\n> tw.write(\"[{}]\".format(fixturedef.cached_param))\r\nE BytesWarning: str() on a bytes instance\r\n\r\nsrc/_pytest/setuponly.py:69: BytesWarning\r\n```\r\n\r\nShouldn't that be using `saferepr` or something rather than (implicitly) `str()`?\r\n\r\n\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "pytest-dev__pytest-7571",
"repo": "pytest-dev/pytest",
"base_commit": "422685d0bdc110547535036c1ff398b5e1c44145",
"problem_statement": "caplog fixture doesn't restore log level after test\nFrom the documentation at https://docs.pytest.org/en/6.0.0/logging.html#caplog-fixture, \"The log levels set are restored automatically at the end of the test\".\r\nIt used to work, but looks broken in new 6.0 release. Minimal example to reproduce:\r\n\r\n```\r\ndef test_foo(caplog):\r\n caplog.set_level(42)\r\n\r\ndef test_bar(caplog):\r\n print(caplog.handler.level)\r\n```\r\n\r\nIt prints \"0\" for pytest<6, \"42\" after.\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "scikit-learn__scikit-learn-11578",
"repo": "scikit-learn/scikit-learn",
"base_commit": "dd69361a0d9c6ccde0d2353b00b86e0e7541a3e3",
"problem_statement": "For probabilistic scorers, LogisticRegressionCV(multi_class='multinomial') uses OvR to calculate scores\nDescription:\r\n\r\nFor scorers such as `neg_log_loss` that use `.predict_proba()` to get probability estimates out of a classifier, the predictions used to generate the scores for `LogisticRegression(multi_class='multinomial')` do not seem to be the same predictions as those generated by the `.predict_proba()` method of `LogisticRegressionCV(multi_class='multinomial')`. The former uses a single logistic function and normalises (one-v-rest approach), whereas the latter uses the softmax function (multinomial approach).\r\n\r\nThis appears to be because the `LogisticRegression()` instance supplied to the scoring function at line 955 of logistic.py within the helper function `_log_reg_scoring_path()`,\r\n(https://github.com/scikit-learn/scikit-learn/blob/master/sklearn/linear_model/logistic.py#L955)\r\n`scores.append(scoring(log_reg, X_test, y_test))`,\r\nis initialised,\r\n(https://github.com/scikit-learn/scikit-learn/blob/master/sklearn/linear_model/logistic.py#L922)\r\n`log_reg = LogisticRegression(fit_intercept=fit_intercept)`,\r\nwithout a multi_class argument, and so takes the default, which is `multi_class='ovr'`.\r\n\r\nIt seems like altering L922 to read\r\n`log_reg = LogisticRegression(fit_intercept=fit_intercept, multi_class=multi_class)`\r\nso that the `LogisticRegression()` instance supplied to the scoring function at line 955 inherits the `multi_class` option specified in `LogisticRegressionCV()` would be a fix, but I am not a coder and would appreciate some expert insight! Likewise, I do not know whether this issue exists for other classifiers/regressors, as I have only worked with Logistic Regression.\r\n\r\n\r\n\r\nMinimal example:\r\n\r\n```py\r\nimport numpy as np\r\nfrom sklearn import preprocessing, linear_model, utils\r\n\r\ndef ovr_approach(decision_function):\r\n \r\n probs = 1. / (1. + np.exp(-decision_function))\r\n probs = probs / probs.sum(axis=1).reshape((probs.shape[0], -1))\r\n \r\n return probs\r\n\r\ndef score_from_probs(probs, y_bin):\r\n \r\n return (y_bin*np.log(probs)).sum(axis=1).mean()\r\n \r\n \r\nnp.random.seed(seed=1234)\r\n\r\nsamples = 200\r\nfeatures = 5\r\nfolds = 10\r\n\r\n# Use a \"probabilistic\" scorer\r\nscorer = 'neg_log_loss'\r\n\r\nx = np.random.random(size=(samples, features))\r\ny = np.random.choice(['a', 'b', 'c'], size=samples)\r\n\r\ntest = np.random.choice(range(samples), size=int(samples/float(folds)), replace=False)\r\ntrain = [idx for idx in range(samples) if idx not in test]\r\n\r\n# Binarize the labels for y[test]\r\nlb = preprocessing.label.LabelBinarizer()\r\nlb.fit(y[test])\r\ny_bin = lb.transform(y[test])\r\n\r\n# What does _log_reg_scoring_path give us for the score?\r\ncoefs, _, scores, _ = linear_model.logistic._log_reg_scoring_path(x, y, train, test, fit_intercept=True, scoring=scorer, multi_class='multinomial')\r\n\r\n# Choose a single C to look at, for simplicity\r\nc_index = 0\r\ncoefs = coefs[c_index]\r\nscores = scores[c_index]\r\n\r\n# Initialise a LogisticRegression() instance, as in \r\n# https://github.com/scikit-learn/scikit-learn/blob/master/sklearn/linear_model/logistic.py#L922\r\nexisting_log_reg = linear_model.LogisticRegression(fit_intercept=True)\r\nexisting_log_reg.coef_ = coefs[:, :-1]\r\nexisting_log_reg.intercept_ = coefs[:, -1]\r\n\r\nexisting_dec_fn = existing_log_reg.decision_function(x[test])\r\n\r\nexisting_probs_builtin = existing_log_reg.predict_proba(x[test])\r\n\r\n# OvR approach\r\nexisting_probs_ovr = ovr_approach(existing_dec_fn)\r\n\r\n# multinomial approach\r\nexisting_probs_multi = utils.extmath.softmax(existing_dec_fn)\r\n\r\n# If we initialise our LogisticRegression() instance, with multi_class='multinomial'\r\nnew_log_reg = linear_model.LogisticRegression(fit_intercept=True, multi_class='multinomial')\r\nnew_log_reg.coef_ = coefs[:, :-1]\r\nnew_log_reg.intercept_ = coefs[:, -1]\r\n\r\nnew_dec_fn = new_log_reg.decision_function(x[test])\r\n\r\nnew_probs_builtin = new_log_reg.predict_proba(x[test])\r\n\r\n# OvR approach\r\nnew_probs_ovr = ovr_approach(new_dec_fn)\r\n\r\n# multinomial approach\r\nnew_probs_multi = utils.extmath.softmax(new_dec_fn)\r\n\r\nprint 'score returned by _log_reg_scoring_path'\r\nprint scores\r\n# -1.10566998\r\n\r\nprint 'OvR LR decision function == multinomial LR decision function?'\r\nprint (existing_dec_fn == new_dec_fn).all()\r\n# True\r\n\r\nprint 'score calculated via OvR method (either decision function)'\r\nprint score_from_probs(existing_probs_ovr, y_bin)\r\n# -1.10566997908\r\n\r\nprint 'score calculated via multinomial method (either decision function)'\r\nprint score_from_probs(existing_probs_multi, y_bin)\r\n# -1.11426297223\r\n\r\nprint 'probs predicted by existing_log_reg.predict_proba() == probs generated via the OvR approach?'\r\nprint (existing_probs_builtin == existing_probs_ovr).all()\r\nLine truncated
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "scikit-learn__scikit-learn-13439",
"repo": "scikit-learn/scikit-learn",
"base_commit": "a62775e99f2a5ea3d51db7160fad783f6cd8a4c5",
"problem_statement": "Pipeline should implement __len__\n#### Description\r\n\r\nWith the new indexing support `pipe[:len(pipe)]` raises an error.\r\n\r\n#### Steps/Code to Reproduce\r\n\r\n```python\r\nfrom sklearn import svm\r\nfrom sklearn.datasets import samples_generator\r\nfrom sklearn.feature_selection import SelectKBest\r\nfrom sklearn.feature_selection import f_regression\r\nfrom sklearn.pipeline import Pipeline\r\n\r\n# generate some data to play with\r\nX, y = samples_generator.make_classification(\r\n n_informative=5, n_redundant=0, random_state=42)\r\n\r\nanova_filter = SelectKBest(f_regression, k=5)\r\nclf = svm.SVC(kernel='linear')\r\npipe = Pipeline([('anova', anova_filter), ('svc', clf)])\r\n\r\nlen(pipe)\r\n```\r\n\r\n#### Versions\r\n\r\n```\r\nSystem:\r\n python: 3.6.7 | packaged by conda-forge | (default, Feb 19 2019, 18:37:23) [GCC 4.2.1 Compatible Clang 4.0.1 (tags/RELEASE_401/final)]\r\nexecutable: /Users/krisz/.conda/envs/arrow36/bin/python\r\n machine: Darwin-18.2.0-x86_64-i386-64bit\r\n\r\nBLAS:\r\n macros: HAVE_CBLAS=None\r\n lib_dirs: /Users/krisz/.conda/envs/arrow36/lib\r\ncblas_libs: openblas, openblas\r\n\r\nPython deps:\r\n pip: 19.0.3\r\nsetuptools: 40.8.0\r\n sklearn: 0.21.dev0\r\n numpy: 1.16.2\r\n scipy: 1.2.1\r\n Cython: 0.29.6\r\n pandas: 0.24.1\r\n```\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "scikit-learn__scikit-learn-25747",
"repo": "scikit-learn/scikit-learn",
"base_commit": "2c867b8f822eb7a684f0d5c4359e4426e1c9cfe0",
"problem_statement": "FeatureUnion not working when aggregating data and pandas transform output selected\n### Describe the bug\n\nI would like to use `pandas` transform output and use a custom transformer in a feature union which aggregates data. When I'm using this combination I got an error. When I use default `numpy` output it works fine.\n\n### Steps/Code to Reproduce\n\n```python\r\nimport pandas as pd\r\nfrom sklearn.base import BaseEstimator, TransformerMixin\r\nfrom sklearn import set_config\r\nfrom sklearn.pipeline import make_union\r\n\r\nindex = pd.date_range(start=\"2020-01-01\", end=\"2020-01-05\", inclusive=\"left\", freq=\"H\")\r\ndata = pd.DataFrame(index=index, data=[10] * len(index), columns=[\"value\"])\r\ndata[\"date\"] = index.date\r\n\r\n\r\nclass MyTransformer(BaseEstimator, TransformerMixin):\r\n def fit(self, X: pd.DataFrame, y: pd.Series | None = None, **kwargs):\r\n return self\r\n\r\n def transform(self, X: pd.DataFrame, y: pd.Series | None = None) -> pd.DataFrame:\r\n return X[\"value\"].groupby(X[\"date\"]).sum()\r\n\r\n\r\n# This works.\r\nset_config(transform_output=\"default\")\r\nprint(make_union(MyTransformer()).fit_transform(data))\r\n\r\n# This does not work.\r\nset_config(transform_output=\"pandas\")\r\nprint(make_union(MyTransformer()).fit_transform(data))\r\n```\n\n### Expected Results\n\nNo error is thrown when using `pandas` transform output.\n\n### Actual Results\n\n```python\r\n---------------------------------------------------------------------------\r\nValueError Traceback (most recent call last)\r\nCell In[5], line 25\r\n 23 # This does not work.\r\n 24 set_config(transform_output=\"pandas\")\r\n---> 25 print(make_union(MyTransformer()).fit_transform(data))\r\n\r\nFile ~/.local/share/virtualenvs/3e_VBrf2/lib/python3.10/site-packages/sklearn/utils/_set_output.py:150, in _wrap_method_output.<locals>.wrapped(self, X, *args, **kwargs)\r\n 143 if isinstance(data_to_wrap, tuple):\r\n 144 # only wrap the first output for cross decomposition\r\n 145 return (\r\n 146 _wrap_data_with_container(method, data_to_wrap[0], X, self),\r\n 147 *data_to_wrap[1:],\r\n 148 )\r\n--> 150 return _wrap_data_with_container(method, data_to_wrap, X, self)\r\n\r\nFile ~/.local/share/virtualenvs/3e_VBrf2/lib/python3.10/site-packages/sklearn/utils/_set_output.py:130, in _wrap_data_with_container(method, data_to_wrap, original_input, estimator)\r\n 127 return data_to_wrap\r\n 129 # dense_config == \"pandas\"\r\n--> 130 return _wrap_in_pandas_container(\r\n 131 data_to_wrap=data_to_wrap,\r\n 132 index=getattr(original_input, \"index\", None),\r\n 133 columns=estimator.get_feature_names_out,\r\n 134 )\r\n\r\nFile ~/.local/share/virtualenvs/3e_VBrf2/lib/python3.10/site-packages/sklearn/utils/_set_output.py:59, in _wrap_in_pandas_container(data_to_wrap, columns, index)\r\n 57 data_to_wrap.columns = columns\r\n 58 if index is not None:\r\n---> 59 data_to_wrap.index = index\r\n 60 return data_to_wrap\r\n 62 return pd.DataFrame(data_to_wrap, index=index, columns=columns)\r\n\r\nFile ~/.local/share/virtualenvs/3e_VBrf2/lib/python3.10/site-packages/pandas/core/generic.py:5588, in NDFrame.__setattr__(self, name, value)\r\n 5586 try:\r\n 5587 object.__getattribute__(self, name)\r\n-> 5588 return object.__setattr__(self, name, value)\r\n 5589 except AttributeError:\r\n 5590 pass\r\n\r\nFile ~/.local/share/virtualenvs/3e_VBrf2/lib/python3.10/site-packages/pandas/_libs/properties.pyx:70, in pandas._libs.properties.AxisProperty.__set__()\r\n\r\nFile ~/.local/share/virtualenvs/3e_VBrf2/lib/python3.10/site-packages/pandas/core/generic.py:769, in NDFrame._set_axis(self, axis, labels)\r\n 767 def _set_axis(self, axis: int, labels: Index) -> None:\r\n 768 labels = ensure_index(labels)\r\n--> 769 self._mgr.set_axis(axis, labels)\r\n 770 self._clear_item_cache()\r\n\r\nFile ~/.local/share/virtualenvs/3e_VBrf2/lib/python3.10/site-packages/pandas/core/internals/managers.py:214, in BaseBlockManager.set_axis(self, axis, new_labels)\r\n 212 def set_axis(self, axis: int, new_labels: Index) -> None:\r\n 213 # Caller is responsible for ensuring we have an Index object.\r\n--> 214 self._validate_set_axis(axis, new_labels)\r\n 215 self.axes[axis] = new_labels\r\n\r\nFile ~/.local/share/virtualenvs/3e_VBrf2/lib/python3.10/site-packages/pandas/core/internals/base.py:69, in DataManager._validate_set_axis(self, axis, new_labels)\r\n 66 pass\r\n 68 elif new_len != old_len:\r\n---> 69 raise ValueError(\r\n 70 f\"Length mismatch: Expected axis has {old_len} elements, new \"\r\n 71 f\"values have {new_len} elements\"\r\n 72 )\r\n\r\nValueError: Length mismatch: Expected axis has 4 elements, new values have 96 elements\r\n```\n\n### Versions\Line truncated
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "sphinx-doc__sphinx-10466",
"repo": "sphinx-doc/sphinx",
"base_commit": "cab2d93076d0cca7c53fac885f927dde3e2a5fec",
"problem_statement": "Message.locations duplicate unnecessary\n### Describe the bug\r\n\r\nWhen running \r\n\r\n`make clean; make gettext`\r\n\r\nthere are times the list of locations is duplicated unnecessarily, example:\r\n\r\n```\r\n#: ../../manual/render/shader_nodes/vector/vector_rotate.rst:38\r\n#: ../../manual/modeling/hair.rst:0\r\n#: ../../manual/modeling/hair.rst:0\r\n#: ../../manual/modeling/hair.rst:0\r\n#: ../../manual/modeling/metas/properties.rst:92\r\n```\r\n\r\nor \r\n\r\n```\r\n#: ../../manual/movie_clip/tracking/clip/toolbar/solve.rst:96\r\n#: ../../manual/physics/dynamic_paint/brush.rst:0\r\n#: ../../manual/physics/dynamic_paint/brush.rst:0\r\n#: ../../manual/physics/dynamic_paint/brush.rst:0\r\n#: ../../manual/physics/dynamic_paint/brush.rst:0\r\n#: ../../manual/physics/dynamic_paint/canvas.rst:0\r\n#: ../../manual/physics/dynamic_paint/canvas.rst:0\r\n#: ../../manual/physics/dynamic_paint/canvas.rst:0\r\n#: ../../manual/physics/dynamic_paint/canvas.rst:0\r\n#: ../../manual/physics/dynamic_paint/canvas.rst:0\r\n#: ../../manual/physics/dynamic_paint/canvas.rst:0\r\n#: ../../manual/physics/fluid/type/domain/cache.rst:0\r\n```\r\nas shown in this screen viewing of the 'pot' file result:\r\n \r\n<img width=\"1552\" alt=\"Screenshot 2022-01-15 at 20 41 41\" src=\"https://user-images.githubusercontent.com/16614157/149637271-1797a215-ffbe-410d-9b66-402b75896377.png\">\r\n\r\nAfter debugging a little, the problem appeared to be in the file:\r\n\r\n[sphinx/builders/gettext.py](https://www.sphinx-doc.org/en/master/_modules/sphinx/builders/gettext.html)\r\n\r\nin the '__init__' method.\r\n\r\nMy simple solution is this:\r\n\r\n```\r\n def __init__(self, text: str, locations: List[Tuple[str, int]], uuids: List[str]):\r\n self.text = text\r\n # self.locations = locations\r\n self.locations = self.uniqueLocation(locations)\r\n self.uuids = uuids\r\n\r\n def uniqueLocation(self, locations: List[Tuple[str, int]]):\r\n loc_set = set(locations)\r\n return list(loc_set)\r\n```\r\n**Note,** _this solution will probably needed to be in the_\r\n\r\n`babel.messages.pofile.PoFileParser._process_comment()`\r\n\r\n_and in the_ \r\n\r\n`babel.messages.catalog.Message.__init__()`\r\n\r\n_as well._\r\n\r\n### How to Reproduce\r\n\r\nFollow instructions on this page\r\n\r\n[Contribute Documentation](https://docs.blender.org/manual/en/3.1/about/index.html)\r\n\r\nwhich comprises of sections for installing dependencies, download sources.\r\n\r\n```\r\ncd <path to blender_docs>\r\nmake clean; make gettext\r\n```\r\n\r\nthen load the file:\r\n\r\n`build/gettext/blender_manual.pot`\r\n\r\ninto an editor and search for\r\n\r\n`#: ../../manual/modeling/hair.rst:0`\r\n\r\nand you will see repeated locations appear there. The message id is:\r\n\r\n```\r\nmsgid \"Type\"\r\nmsgstr \"\"\r\n```\r\n\r\n### Expected behavior\r\n\r\nThere should only be ONE instance of \r\n\r\n`build/gettext/blender_manual.pot`\r\n\r\nand there are NO duplications of other locations.\r\n\r\n\r\n\r\n### Your project\r\n\r\nhttps://github.com/hoangduytran/blender_ui\r\n\r\n### Screenshots\r\n\r\n_No response_\r\n\r\n### OS\r\n\r\nMacOS Catalina 10.15.7\r\n\r\n### Python version\r\n\r\n3.9\r\n\r\n### Sphinx version\r\n\r\n4.1.1\r\n\r\n### Sphinx extensions\r\n\r\n_No response_\r\n\r\n### Extra tools\r\n\r\n_No response_\r\n\r\n### Additional context\r\n\r\n_No response_\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "sphinx-doc__sphinx-11510",
"repo": "sphinx-doc/sphinx",
"base_commit": "6cb783c0024a873722952a67ebb9f41771c8eb6d",
"problem_statement": "source-read event does not modify include'd files source\n### Describe the bug\n\nIn [Yocto documentation](https://git.yoctoproject.org/yocto-docs), we use a custom extension to do some search and replace in literal blocks, see https://git.yoctoproject.org/yocto-docs/tree/documentation/sphinx/yocto-vars.py.\r\n\r\nWe discovered (https://git.yoctoproject.org/yocto-docs/commit/?id=b7375ea4380e716a02c736e4231aaf7c1d868c6b and https://lore.kernel.org/yocto-docs/CAP71WjwG2PCT=ceuZpBmeF-Xzn9yVQi1PG2+d6+wRjouoAZ0Aw@mail.gmail.com/#r) that this does not work on all files and some are left out of this mechanism. Such is the case for include'd files.\r\n\r\nI could reproduce on Sphinx 5.0.2.\n\n### How to Reproduce\n\nconf.py:\r\n```python\r\nimport sys\r\nimport os\r\n\r\nsys.path.insert(0, os.path.abspath('.'))\r\n\r\nextensions = [\r\n 'my-extension'\r\n]\r\n```\r\nindex.rst:\r\n```reStructuredText\r\nThis is a test\r\n==============\r\n\r\n.. include:: something-to-include.rst\r\n\r\n&REPLACE_ME;\r\n```\r\nsomething-to-include.rst:\r\n```reStructuredText\r\nTesting\r\n=======\r\n\r\n&REPLACE_ME;\r\n```\r\nmy-extension.py:\r\n```python\r\n#!/usr/bin/env python3\r\n\r\nfrom sphinx.application import Sphinx\r\n\r\n\r\n__version__ = '1.0'\r\n\r\n\r\ndef subst_vars_replace(app: Sphinx, docname, source):\r\n result = source[0]\r\n result = result.replace(\"&REPLACE_ME;\", \"REPLACED\")\r\n source[0] = result\r\n\r\n\r\ndef setup(app: Sphinx):\r\n\r\n app.connect('source-read', subst_vars_replace)\r\n\r\n return dict(\r\n version=__version__,\r\n parallel_read_safe=True,\r\n parallel_write_safe=True\r\n )\r\n```\r\n```sh\r\nsphinx-build . build\r\nif grep -Rq REPLACE_ME build/*.html; then echo BAD; fi\r\n```\r\n`build/index.html` will contain:\r\n```html\r\n[...]\r\n<div class=\"section\" id=\"testing\">\r\n<h1>Testing<a class=\"headerlink\" href=\"#testing\" title=\"Permalink to this heading\">¶</a></h1>\r\n<p>&amp;REPLACE_ME;</p>\r\n<p>REPLACED</p>\r\n</div>\r\n[...]\r\n```\r\n\r\nNote that the dumping docname and source[0] shows that the function actually gets called for something-to-include.rst file and its content is correctly replaced in source[0], it just does not make it to the final HTML file for some reason.\n\n### Expected behavior\n\n`build/index.html` should contain:\r\n```html\r\n[...]\r\n<div class=\"section\" id=\"testing\">\r\n<h1>Testing<a class=\"headerlink\" href=\"#testing\" title=\"Permalink to this heading\">¶</a></h1>\r\n<p>REPLACED</p>\r\n<p>REPLACED</p>\r\n</div>\r\n[...]\r\n```\n\n### Your project\n\nhttps://git.yoctoproject.org/yocto-docs\n\n### Screenshots\n\n_No response_\n\n### OS\n\nLinux\n\n### Python version\n\n3.10\n\n### Sphinx version\n\n5.0.2\n\n### Sphinx extensions\n\nCustom extension using source-read event\n\n### Extra tools\n\n_No response_\n\n### Additional context\n\n_No response_\nsource-read event does not modify include'd files source\n### Describe the bug\n\nIn [Yocto documentation](https://git.yoctoproject.org/yocto-docs), we use a custom extension to do some search and replace in literal blocks, see https://git.yoctoproject.org/yocto-docs/tree/documentation/sphinx/yocto-vars.py.\r\n\r\nWe discovered (https://git.yoctoproject.org/yocto-docs/commit/?id=b7375ea4380e716a02c736e4231aaf7c1d868c6b and https://lore.kernel.org/yocto-docs/CAP71WjwG2PCT=ceuZpBmeF-Xzn9yVQi1PG2+d6+wRjouoAZ0Aw@mail.gmail.com/#r) that this does not work on all files and some are left out of this mechanism. Such is the case for include'd files.\r\n\r\nI could reproduce on Sphinx 5.0.2.\n\n### How to Reproduce\n\nconf.py:\r\n```python\r\nimport sys\r\nimport os\r\n\r\nsys.path.insert(0, os.path.abspath('.'))\r\n\r\nextensions = [\r\n 'my-extension'\r\n]\r\n```\r\nindex.rst:\r\n```reStructuredText\r\nThis is a test\r\n==============\r\n\r\n.. include:: something-to-include.rst\r\n\r\n&REPLACE_ME;\r\n```\r\nsomething-to-include.rst:\r\n```reStructuredText\r\nTesting\r\n=======\r\n\r\n&REPLACE_ME;\r\n```\r\nmy-extension.py:\r\n```python\r\n#!/usr/bin/env python3\r\n\r\nfrom sphinx.application import Sphinx\r\n\r\n\r\n__version__ = '1.0'\r\n\r\n\r\ndef subst_vars_replace(app: Sphinx, docname, source):\r\n result = source[0]\r\n result = result.replace(\"&REPLACE_ME;\", \"REPLACED\")\r\n source[0] = result\r\n\r\n\r\ndef setup(app: Sphinx):\r\n\r\n app.connect('source-read', subst_vars_replace)\r\n\r\n return dict(\r\n version=__version__,\r\n parallel_read_safe=True,\r\n parallel_write_safe=True\r\n )\r\n```\r\n```sh\r\nsphinx-build . build\r\nif grep -Rq REPLACE_ME build/*.html; then echo BAD; fi\r\n```\r\n`build/index.html` will contain:\r\n```html\r\n[...]\r\n<div class=\"section\" id=\"testing\">\r\n<h1>Testing<a class=\"headerlink\" href=\"#testing\" title=\"Permalink to this heading\"></a></h1>\r\n<p>&amp;REPLACE_ME;</p>\r\n<p>REPLACED</p>\r\n</div>\r\n[...]\r\n```\r\n\r\nNote thaLine truncated
"difficulty": "1-4 hours"
},
{
"instance_id": "sphinx-doc__sphinx-7454",
"repo": "sphinx-doc/sphinx",
"base_commit": "aca3f825f2e4a8817190f3c885a242a285aa0dba",
"problem_statement": "Inconsistent handling of None by `autodoc_typehints`\n**Describe the bug**\r\nWith `autodoc_typehints='description'`, a function that returns `None` generates a clickable link to [None's documentation](https://docs.python.org/3/library/constants.html#None).\r\n\r\nWith `autodoc_typehints='signature'`, the `None` in the signature is not clickable.\r\n\r\n**To Reproduce**\r\nSteps to reproduce the behavior:\r\n```sh\r\nmkdir -p sphinx_type_hint_links\r\ncd sphinx_type_hint_links\r\n\r\ncat <<'EOF' >type_hint_test.py\r\ndef f1() -> None: return None\r\ndef f2() -> int: return 42\r\nEOF\r\n\r\nmkdir -p docs\r\n\r\ncat <<'EOF' >docs/conf.py\r\nextensions = [\"sphinx.ext.autodoc\", \"sphinx.ext.intersphinx\"]\r\nintersphinx_mapping = {\"python\": (\"https://docs.python.org/3\", None)}\r\n#autodoc_typehints = 'description'\r\nEOF\r\n\r\ncat <<'EOF' >docs/index.rst\r\n.. automodule:: type_hint_test\r\n.. autofunction:: f1\r\n.. autofunction:: f2\r\nEOF\r\n\r\nmkdir -p html\r\npython3.8 -m sphinx -nW -b html --keep-going docs html\r\n\r\necho\r\necho \"Searching for links:\"\r\ngrep 'docs.python.org' html/index.html\r\n```\r\n\r\nOn running the above reproducer, note that the last two lines are:\r\n```html\r\nSearching for links:\r\n<code class=\"sig-prename descclassname\">type_hint_test.</code><code class=\"sig-name descname\">f2</code><span class=\"sig-paren\">(</span><span class=\"sig-paren\">)</span> &#x2192; <a class=\"reference external\" href=\"https://docs.python.org/3/library/functions.html#int\" title=\"(in Python v3.8)\">int</a><a class=\"headerlink\" href=\"#type_hint_test.f2\" title=\"Permalink to this definition\">¶</a></dt>\r\n```\r\n\r\nThis contains a link from `f2` to the `int` docs, but not one from `f1` to the `None` docs.\r\n\r\nIf you uncomment the `autodoc_typehints = 'description'` line in the reproducer script and rerun it, you'll instead see:\r\n\r\n```html\r\nSearching for links:\r\n<dd class=\"field-odd\"><p><a class=\"reference external\" href=\"https://docs.python.org/3/library/constants.html#None\" title=\"(in Python v3.8)\">None</a></p>\r\n<dd class=\"field-odd\"><p><a class=\"reference external\" href=\"https://docs.python.org/3/library/functions.html#int\" title=\"(in Python v3.8)\">int</a></p>\r\n```\r\n\r\n**Expected behavior**\r\n\r\nThat `None` in a type hint links to the documentation for the `None` singleton regardless of whether 'description' or 'signature' mode is used.\r\n\r\n**Environment info**\r\n- OS: Linux 4.4.0\r\n- Python version: 3.8.1\r\n- Sphinx version: 3.1.0.dev20200408\r\n- Sphinx extensions: sphinx.ext.autodoc, sphinx.ext.intersphinx\r\n\r\n**Additional context**\r\n\r\nI installed a version of Sphinx that contains the fix for #7428 using:\r\n\r\n```sh\r\npython3.8 -m pip install --user --upgrade 'git+git://github.com/sphinx-doc/[email protected]#egg=sphinx'\r\n```\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "sphinx-doc__sphinx-8056",
"repo": "sphinx-doc/sphinx",
"base_commit": "e188d56ed1248dead58f3f8018c0e9a3f99193f7",
"problem_statement": "Render error when combining multiple input parameters in docstring\n**Describe the bug & Reproduce**\r\n\r\nMy team is writing a function in Python, which contains 3 inputs that are similar, so we want to put them in the same line in the docstring. \r\n\r\nAs described in 4. Parameters in [numpydoc docstring guide](https://numpydoc.readthedocs.io/en/latest/format.html#sections), this is possible if you write something like this:\r\n\r\n```\r\nx1, x2 : array_like\r\n Input arrays, description of `x1`, `x2`.\r\n```\r\n\r\nHowever, this produces:\r\n\r\n<img width=\"406\" alt=\"图片\" src=\"https://user-images.githubusercontent.com/20618587/83668496-566d3680-a5d0-11ea-8a15-5596f77b6c20.png\">\r\n\r\nEven worse, when added \"optional\", the rendered HTML stays the same as the screenshot above, so there is no way to tell whether it is optional:\r\n\r\n```\r\nx1, x2 : array_like, optional\r\n Input arrays, description of `x1`, `x2`.\r\n```\r\n\r\n**Expected behavior**\r\nSomething like \r\n\r\n- x1, x2 (_array_like, optional_) - Input arrays, description of x1, x2.\r\n\r\n**Environment info**\r\n- OS: macOS 10.15.5 (19F101)\r\n- Python version: 3.7.7\r\n- Sphinx version: 3.0.3.\r\n- Extra tools: browser: Firefox 79.0a1 or Safari 13.1.1\r\n- Sphinx extensions: \r\n\r\n```\r\nextensions = [\r\n \"sphinx.ext.autodoc\",\r\n \"sphinx.ext.todo\",\r\n \"sphinx.ext.coverage\",\r\n \"sphinx.ext.extlinks\",\r\n \"sphinx.ext.intersphinx\",\r\n \"sphinx.ext.mathjax\",\r\n \"sphinx.ext.viewcode\",\r\n \"sphinx.ext.napoleon\",\r\n \"nbsphinx\",\r\n \"sphinx.ext.mathjax\",\r\n \"sphinxcontrib.bibtex\",\r\n \"sphinx.ext.doctest\",\r\n]\r\n```\r\n\r\n\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "sphinx-doc__sphinx-8721",
"repo": "sphinx-doc/sphinx",
"base_commit": "82ef497a8c88f0f6e50d84520e7276bfbf65025d",
"problem_statement": "viewcode creates pages for epub even if `viewcode_enable_epub=False` on `make html epub`\n**Describe the bug**\r\nviewcode creates pages for epub even if `viewcode_enable_epub=False` on `make html epub`\r\n\r\n**To Reproduce**\r\n```\r\n$ make html epub\r\n```\r\n\r\n**Expected behavior**\r\nmodule pages should not be created for epub by default.\r\n\r\n**Your project**\r\nNo\r\n\r\n**Screenshots**\r\nNo\r\n\r\n**Environment info**\r\n- OS: Mac\r\n- Python version: 3.9.1\r\n- Sphinx version: HEAD of 3.x\r\n- Sphinx extensions: sphinx.ext.viewcode\r\n- Extra tools: No\r\n\r\n**Additional context**\r\nNo\r\n\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "sympy__sympy-13757",
"repo": "sympy/sympy",
"base_commit": "a5e6a101869e027e7930e694f8b1cfb082603453",
"problem_statement": "Multiplying an expression by a Poly does not evaluate when the expression is on the left side of the multiplication\nTested in Python 3.4 64-bit and 3.6 64-bit\r\nVersion: 1.1.2.dev0\r\n```\r\n>>> Poly(x)*x\r\nPoly(x**2, x, domain='ZZ')\r\n\r\n>>> x*Poly(x)\r\nx*Poly(x, x, domain='ZZ')\r\n\r\n>>> -2*Poly(x)\r\nPoly(-2*x, x, domain='ZZ')\r\n\r\n>>> S(-2)*Poly(x)\r\n-2*Poly(x, x, domain='ZZ')\r\n\r\n>>> Poly(x)*S(-2)\r\nPoly(-2*x, x, domain='ZZ')\r\n```\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "sympy__sympy-14248",
"repo": "sympy/sympy",
"base_commit": "9986b38181cdd556a3f3411e553864f11912244e",
"problem_statement": "The difference of MatrixSymbols prints as a sum with (-1) coefficient\nInternally, differences like a-b are represented as the sum of a with `(-1)*b`, but they are supposed to print like a-b. This does not happen with MatrixSymbols. I tried three printers: str, pretty, and latex: \r\n```\r\nfrom sympy import *\r\nA = MatrixSymbol('A', 2, 2)\r\nB = MatrixSymbol('B', 2, 2)\r\nprint(A - A*B - B)\r\npprint(A - A*B - B)\r\nlatex(A - A*B - B)\r\n```\r\nOutput:\r\n```\r\n(-1)*B + (-1)*A*B + A\r\n-B + -A⋅B + A\r\n'-1 B + -1 A B + A'\r\n```\r\n\r\nBased on a [Stack Overflow post](https://stackoverflow.com/q/48826611)\n",
"difficulty": "1-4 hours"
},
{
"instance_id": "sympy__sympy-14711",
"repo": "sympy/sympy",
"base_commit": "c6753448b5c34f95e250105d76709fe4d349ca1f",
"problem_statement": "vector add 0 error\n```python\r\nfrom sympy.physics.vector import ReferenceFrame, Vector\r\nfrom sympy import symbols\r\nsum([N.x, (0 * N.x)])\r\n```\r\ngives\r\n```\r\n---------------------------------------------------------------------------\r\nTypeError Traceback (most recent call last)\r\n<ipython-input-1-0b9155eecc0e> in <module>()\r\n 2 from sympy import symbols\r\n 3 N = ReferenceFrame('N')\r\n----> 4 sum([N.x, (0 * N.x)])\r\n\r\n/usr/local/lib/python3.6/site-packages/sympy/physics/vector/vector.py in __add__(self, other)\r\n 59 \"\"\"The add operator for Vector. \"\"\"\r\n 60 #if other == 0: return self\r\n---> 61 other = _check_vector(other)\r\n 62 return Vector(self.args + other.args)\r\n 63 \r\n\r\n/usr/local/lib/python3.6/site-packages/sympy/physics/vector/vector.py in _check_vector(other)\r\n 708 def _check_vector(other):\r\n 709 if not isinstance(other, Vector):\r\n--> 710 raise TypeError('A Vector must be supplied')\r\n 711 return other\r\n\r\nTypeError: A Vector must be supplied\r\n```\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "sympy__sympy-17318",
"repo": "sympy/sympy",
"base_commit": "d4e0231b08147337745dcf601e62de7eefe2fb2d",
"problem_statement": "sqrtdenest raises IndexError\n```\r\n>>> sqrtdenest((3 - sqrt(2)*sqrt(4 + 3*I) + 3*I)/2)\r\nTraceback (most recent call last):\r\n File \"<stdin>\", line 1, in <module>\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 132, in sqrtdenest\r\n z = _sqrtdenest0(expr)\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 242, in _sqrtdenest0\r\n return expr.func(*[_sqrtdenest0(a) for a in args])\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 242, in _sqrtdenest0\r\n return expr.func(*[_sqrtdenest0(a) for a in args])\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 235, in _sqrtdenest0\r\n return _sqrtdenest1(expr)\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 319, in _sqrtdenest1\r\n val = _sqrt_match(a)\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 159, in _sqrt_match\r\n r, b, a = split_surds(p)\r\n File \"sympy\\simplify\\radsimp.py\", line 1032, in split_surds\r\n g, b1, b2 = _split_gcd(*surds)\r\n File \"sympy\\simplify\\radsimp.py\", line 1068, in _split_gcd\r\n g = a[0]\r\nIndexError: tuple index out of range\r\n```\r\n\r\nIf an expression cannot be denested it should be returned unchanged.\nIndexError fixed for sqrtdenest.\nFixes #12420 \r\nNow if the expression can't be **denested**, it will be returned unchanged.\r\nOld Result:\r\n```\r\n>>> sqrtdenest((3 - sqrt(2)*sqrt(4 + 3*I) + 3*I)/2)\r\nTraceback (most recent call last):\r\n File \"<stdin>\", line 1, in <module>\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 132, in sqrtdenest\r\n z = _sqrtdenest0(expr)\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 242, in _sqrtdenest0\r\n return expr.func(*[_sqrtdenest0(a) for a in args])\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 242, in _sqrtdenest0\r\n return expr.func(*[_sqrtdenest0(a) for a in args])\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 235, in _sqrtdenest0\r\n return _sqrtdenest1(expr)\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 319, in _sqrtdenest1\r\n val = _sqrt_match(a)\r\n File \"sympy\\simplify\\sqrtdenest.py\", line 159, in _sqrt_match\r\n r, b, a = split_surds(p)\r\n File \"sympy\\simplify\\radsimp.py\", line 1032, in split_surds\r\n g, b1, b2 = _split_gcd(*surds)\r\n File \"sympy\\simplify\\radsimp.py\", line 1068, in _split_gcd\r\n g = a[0]\r\nIndexError: tuple index out of range\r\n\r\n```\r\nNew Result:\r\n\r\n```\r\nIn [9]: sqrtdenest((3 - sqrt(2)*sqrt(4 + 3*I) + 3*I)/2)\r\nOut[9]: 3/2 - sqrt(2)*sqrt(4 + 3*I)/2 + 3*I/2\r\n```\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "sympy__sympy-18189",
"repo": "sympy/sympy",
"base_commit": "1923822ddf8265199dbd9ef9ce09641d3fd042b9",
"problem_statement": "diophantine: incomplete results depending on syms order with permute=True\n```\r\nIn [10]: diophantine(n**4 + m**4 - 2**4 - 3**4, syms=(m,n), permute=True)\r\nOut[10]: {(-3, -2), (-3, 2), (-2, -3), (-2, 3), (2, -3), (2, 3), (3, -2), (3, 2)}\r\n\r\nIn [11]: diophantine(n**4 + m**4 - 2**4 - 3**4, syms=(n,m), permute=True)\r\nOut[11]: {(3, 2)}\r\n```\r\n\ndiophantine: incomplete results depending on syms order with permute=True\n```\r\nIn [10]: diophantine(n**4 + m**4 - 2**4 - 3**4, syms=(m,n), permute=True)\r\nOut[10]: {(-3, -2), (-3, 2), (-2, -3), (-2, 3), (2, -3), (2, 3), (3, -2), (3, 2)}\r\n\r\nIn [11]: diophantine(n**4 + m**4 - 2**4 - 3**4, syms=(n,m), permute=True)\r\nOut[11]: {(3, 2)}\r\n```\r\n\n",
"difficulty": "<15 min fix"
},
{
"instance_id": "sympy__sympy-20590",
"repo": "sympy/sympy",
"base_commit": "cffd4e0f86fefd4802349a9f9b19ed70934ea354",
"problem_statement": "Symbol instances have __dict__ since 1.7?\nIn version 1.6.2 Symbol instances had no `__dict__` attribute\r\n```python\r\n>>> sympy.Symbol('s').__dict__\r\n---------------------------------------------------------------------------\r\nAttributeError Traceback (most recent call last)\r\n<ipython-input-3-e2060d5eec73> in <module>\r\n----> 1 sympy.Symbol('s').__dict__\r\n\r\nAttributeError: 'Symbol' object has no attribute '__dict__'\r\n>>> sympy.Symbol('s').__slots__\r\n('name',)\r\n```\r\n\r\nThis changes in 1.7 where `sympy.Symbol('s').__dict__` now exists (and returns an empty dict)\r\nI may misinterpret this, but given the purpose of `__slots__`, I assume this is a bug, introduced because some parent class accidentally stopped defining `__slots__`.\n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "sympy__sympy-23262",
"repo": "sympy/sympy",
"base_commit": "fdc707f73a65a429935c01532cd3970d3355eab6",
"problem_statement": "Python code printer not respecting tuple with one element\nHi,\r\n\r\nThanks for the recent updates in SymPy! I'm trying to update my code to use SymPy 1.10 but ran into an issue with the Python code printer. MWE:\r\n\r\n\r\n```python\r\nimport inspect\r\nfrom sympy import lambdify\r\n\r\ninspect.getsource(lambdify([], tuple([1])))\r\n```\r\nSymPy 1.9 and under outputs:\r\n```\r\n'def _lambdifygenerated():\\n return (1,)\\n'\r\n```\r\n\r\nBut SymPy 1.10 gives\r\n\r\n```\r\n'def _lambdifygenerated():\\n return (1)\\n'\r\n```\r\nNote the missing comma after `1` that causes an integer to be returned instead of a tuple. \r\n\r\nFor tuples with two or more elements, the generated code is correct:\r\n```python\r\ninspect.getsource(lambdify([], tuple([1, 2])))\r\n```\r\nIn SymPy 1.10 and under, outputs:\r\n\r\n```\r\n'def _lambdifygenerated():\\n return (1, 2)\\n'\r\n```\r\nThis result is expected.\r\n\r\nNot sure if this is a regression. As this breaks my program which assumes the return type to always be a tuple, could you suggest a workaround from the code generation side? Thank you. \n",
"difficulty": "15 min - 1 hour"
},
{
"instance_id": "sympy__sympy-24539",
"repo": "sympy/sympy",
"base_commit": "193e3825645d93c73e31cdceb6d742cc6919624d",
"problem_statement": "`PolyElement.as_expr()` not accepting symbols\nThe method `PolyElement.as_expr()`\r\n\r\nhttps://github.com/sympy/sympy/blob/193e3825645d93c73e31cdceb6d742cc6919624d/sympy/polys/rings.py#L618-L624\r\n\r\nis supposed to let you set the symbols you want to use, but, as it stands, either you pass the wrong number of symbols, and get an error message, or you pass the right number of symbols, and it ignores them, using `self.ring.symbols` instead:\r\n\r\n```python\r\n>>> from sympy import ring, ZZ, symbols\r\n>>> R, x, y, z = ring(\"x,y,z\", ZZ)\r\n>>> f = 3*x**2*y - x*y*z + 7*z**3 + 1\r\n>>> U, V, W = symbols(\"u,v,w\")\r\n>>> f.as_expr(U, V, W)\r\n3*x**2*y - x*y*z + 7*z**3 + 1\r\n```\n",
"difficulty": "<15 min fix"
}
]
+22 -20
View File
@@ -11,11 +11,13 @@ import (
"time"
"unicode/utf8"
"reasonix/internal/ablation"
"reasonix/internal/shellparse"
)
type diffOpts struct {
bin, model, repo, base, testCmd, profile string
ablate ablation.Set
maxSteps, timeoutSec, attempts int
}
@@ -46,10 +48,7 @@ func runDiff(o diffOpts) string {
pkgs := packagesOf(srcFiles)
prompt := buildDiffPrompt(srcFiles, pkgs, truncate(gitOut(o.repo, "diff", o.base+"...HEAD", "--")))
attempts := o.attempts
if attempts < 1 {
attempts = 1
}
attempts := max(o.attempts, 1)
var best diffReport
made := 0
for i := 1; i <= attempts; i++ {
@@ -85,6 +84,9 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
args = append(args, "--model", o.model)
}
args = appendBenchmarkProfileArgs(args, o.profile)
if !o.ablate.Empty() {
args = append(args, "--ablate", o.ablate.String())
}
args = append(args, prompt)
cmd := exec.CommandContext(ctx, o.bin, args...)
cmd.Dir = o.repo
@@ -401,7 +403,7 @@ func parseCoverProfile(repo, path string) map[string][]coverBlock {
return nil
}
out := map[string][]coverBlock{}
for _, ln := range strings.Split(string(data), "\n") {
for ln := range strings.SplitSeq(string(data), "\n") {
if ln == "" || strings.HasPrefix(ln, "mode:") {
continue
}
@@ -428,8 +430,8 @@ func repoRelFromModulePath(p string) string {
if strings.HasPrefix(p, prefix) {
return p[len(prefix):]
}
if i := strings.IndexByte(p, '/'); i >= 0 {
return p[i+1:]
if _, after, ok := strings.Cut(p, "/"); ok {
return after
}
return p
}
@@ -442,7 +444,7 @@ func changedLineSet(repo, base string, srcFiles []string) map[string]map[int]boo
out := map[string]map[int]bool{}
file := ""
newLine := 0
for _, ln := range strings.Split(diff, "\n") {
for ln := range strings.SplitSeq(diff, "\n") {
// '-' (deletion) lines are intentionally unhandled: they don't advance the
// new-side line counter, so they fall through with no case.
switch {
@@ -452,10 +454,10 @@ func changedLineSet(repo, base string, srcFiles []string) map[string]map[int]boo
case strings.HasPrefix(ln, "@@"):
// @@ -a,b +c,d @@ — start collecting at new-side line c.
// Digit-only cut: malformed headers (e.g. `@@ +abc @@`) fail closed.
if plus := strings.Index(ln, "+"); plus >= 0 {
num := ln[plus+1:]
if _, after, ok := strings.Cut(ln, "+"); ok {
num := after
end := len(num)
for i := 0; i < len(num); i++ {
for i := range len(num) {
if num[i] < '0' || num[i] > '9' {
end = i
break
@@ -498,9 +500,9 @@ func countAssertionPins(ps []pinResult) int {
func parseNewTests(diff string) []testRef {
var refs []testRef
pkg := ""
for _, ln := range strings.Split(diff, "\n") {
if strings.HasPrefix(ln, "+++ b/") {
pkg = "./" + filepath.ToSlash(filepath.Dir(strings.TrimPrefix(ln, "+++ b/")))
for ln := range strings.SplitSeq(diff, "\n") {
if after, ok := strings.CutPrefix(ln, "+++ b/"); ok {
pkg = "./" + filepath.ToSlash(filepath.Dir(after))
continue
}
if !strings.HasPrefix(ln, "+") || strings.HasPrefix(ln, "+++") {
@@ -514,11 +516,11 @@ func parseNewTests(diff string) []testRef {
// Method form `(r T) Name(...)` starts with '('; parse the receiver out before the name.
var name string
if sig[0] == '(' {
close := strings.IndexByte(sig, ')')
if close < 0 {
_, after, ok := strings.Cut(sig, ")")
if !ok {
continue
}
rest := strings.TrimSpace(sig[close+1:])
rest := strings.TrimSpace(after)
methodParen := strings.IndexByte(rest, '(')
if methodParen <= 0 {
continue
@@ -540,7 +542,7 @@ func parseNewTests(diff string) []testRef {
func countAdded(diff string) int {
n := 0
for _, ln := range strings.Split(diff, "\n") {
for ln := range strings.SplitSeq(diff, "\n") {
if strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++") {
n++
}
@@ -552,7 +554,7 @@ func countAdded(diff string) int {
func failingTestNames(out string) []string {
var names []string
seen := map[string]bool{}
for _, ln := range strings.Split(out, "\n") {
for ln := range strings.SplitSeq(out, "\n") {
ln = strings.TrimSpace(ln)
if !strings.HasPrefix(ln, "--- FAIL:") {
continue
@@ -598,7 +600,7 @@ func changedGoFilesWorktree(repo string, includeTests bool) []string {
func filterGo(out string, includeTests bool) []string {
var keep []string
for _, f := range strings.Fields(strings.ReplaceAll(out, "\n", " ")) {
for f := range strings.FieldsSeq(strings.ReplaceAll(out, "\n", " ")) {
if strings.HasSuffix(f, "_test.go") && !includeTests {
continue
}
+311 -48
View File
@@ -11,12 +11,14 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
"reasonix/internal/ablation"
fileencoding "reasonix/internal/fileutil/encoding"
)
@@ -29,14 +31,18 @@ type task struct {
}
type runMetrics struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CacheHitTokens int `json:"cache_hit_tokens"`
CacheMissTokens int `json:"cache_miss_tokens"`
Steps int `json:"steps"`
Cost float64 `json:"cost"`
Currency string `json:"currency"`
Compactions int `json:"compactions"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CacheHitTokens int `json:"cache_hit_tokens"`
CacheMissTokens int `json:"cache_miss_tokens"`
// PrefixChangeReasonCounts mirrors internal/cli.RunMetrics's field of the
// same name: per-run tallies of why the cache prefix changed (e.g.
// "compact_auto", "snip", "tools"), omitempty for older metrics files.
PrefixChangeReasonCounts map[string]int `json:"prefix_change_reason_counts,omitempty"`
Steps int `json:"steps"`
Cost float64 `json:"cost"`
Currency string `json:"currency"`
Compactions int `json:"compactions"`
// Optional Delivery capability counters (omitempty for baseline/old metrics).
ReadinessChecks int `json:"readiness_checks,omitempty"`
@@ -53,17 +59,64 @@ type runMetrics struct {
CapabilityReviewBlocks int `json:"capability_review_blocks,omitempty"`
CapabilityRouterCost float64 `json:"capability_router_cost,omitempty"`
CapabilityRouterLatencyMs int64 `json:"capability_router_latency_ms,omitempty"`
Complete bool `json:"complete"`
Outcome string `json:"outcome,omitempty"`
ToolCalls int `json:"tool_calls,omitempty"`
ToolFailures int `json:"tool_failures,omitempty"`
SubagentToolCalls int `json:"subagent_tool_calls,omitempty"`
Retries int `json:"retries,omitempty"`
ToolCallsByName map[string]int `json:"tool_calls_by_name,omitempty"`
ToolFailuresByName map[string]int `json:"tool_failures_by_name,omitempty"`
}
type result struct {
task
runMetrics
Profile string `json:"profile"`
// Arm is the ablation arm the harness requested, not the arm the child
// reported, so a run that died before writing metrics is still attributable.
Arm string `json:"arm"`
Passed bool
Skipped bool
Note string
// WallMs is the harness's own clock, not the agent's self-report, so the
// number stays comparable when the same suite runs against another harness.
WallMs int64 `json:"wall_ms"`
// Unaccounted marks a run whose metrics file never landed — a killed agent
// writes nothing. Its real cost is unknown, so it is kept out of the cost
// and token aggregates instead of being averaged in as zero, which would
// quietly understate every published per-task figure.
Unaccounted bool `json:"unaccounted"`
// Partial marks accounting recovered from an in-flight snapshot after the
// agent was killed. The numbers are real but stop at the last snapshot, so
// they are counted as lower bounds rather than dropped.
Partial bool `json:"partial"`
// Trajectory is the digest of the run's recorded event trajectory; nil
// unless the harness ran with -trajectories.
Trajectory *trajectorySummary `json:"trajectory,omitempty"`
}
// class is the published failure taxonomy: solved, the guard that stopped the
// run, or wrong_patch when the agent finished cleanly and the grader still
// failed. outcome carries the agent's own classification when it wrote metrics.
func (r result) class() string {
switch {
case r.Skipped:
return "skipped"
case r.Passed:
return "solved"
case r.Outcome != "" && r.Outcome != "success":
return r.Outcome
case r.Outcome == "":
return "no_metrics"
default:
return "wrong_patch"
}
}
const defaultSuiteTokenBudget = 800_000
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
@@ -73,19 +126,31 @@ func main() {
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
}
mode := flag.String("mode", "suite", "suite | diff (diff = generate tests for the PR diff and grade with the repo's tests)")
mode := flag.String("mode", "suite", "suite | diff | swebench")
subset := flag.String("subset", "benchmarks/swebench/subset.json", "swebench mode: instance subset file")
namespace := flag.String("namespace", "swebench", "swebench mode: registry namespace holding the evaluation images")
runID := flag.String("run-id", "reasonix", "swebench mode: run id passed to the official harness")
harnessPy := flag.String("harness-python", "python3", "swebench mode: interpreter with the swebench package installed")
dataset := flag.String("dataset", "princeton-nlp/SWE-bench_Verified", "swebench mode: dataset name")
permission := flag.String("permission", "auto", "swebench mode: agent permission posture (auto | yolo)")
network := flag.String("network", "", "swebench mode: docker network for agent containers; must have no off-box route")
proxyURL := flag.String("proxy", "", "swebench mode: the only egress the agent gets, expected to allowlist just the model API")
workers := flag.Int("workers", 4, "swebench mode: parallel grader workers")
keepImages := flag.Bool("keep-images", false, "swebench mode: keep instance images instead of removing them after each run")
suite := flag.String("suite", "benchmarks/e2e", "suite root (contains tasks/<id>/)")
bin := flag.String("bin", "reasonix", "path to the reasonix binary")
model := flag.String("model", "", "provider/model name (default: config default)")
profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
ablateFlag := flag.String("ablate", "", "ablation arm: subsystems to switch off (evidence, planner, subagent, retrieval, compaction; none|all)")
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
trajDir := flag.String("trajectories", "", "suite mode: write one <task-id>.trajectory.jsonl per task into this directory")
outJSON := flag.String("json", "", "write the JSON report here (optional)")
budget := flag.Int("budget", 400_000, "abort once total tokens cross this (0 = no cap)")
budget := flag.Int("budget", defaultSuiteTokenBudget, "abort once total tokens cross this (0 = no cap)")
// diff-mode flags
repo := flag.String("repo", ".", "repo root (diff mode)")
base := flag.String("base", "", "base ref to diff the PR head against (diff mode)")
@@ -99,11 +164,36 @@ func main() {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
arm, err := ablation.Parse(*ablateFlag)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if *mode == "swebench" {
if _, err := permissionFlag(*permission); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
cwd, _ := os.Getwd()
report := runSwebench(swebenchOpts{
bin: *bin, subset: *subset, namespace: *namespace, model: *model,
profile: profile, permission: *permission, arm: arm, runID: *runID, workDir: cwd,
harness: *harnessPy, dataset: *dataset, maxSteps: *maxSteps,
timeoutSec: *timeoutSec, workers: *workers, keepImages: *keepImages,
network: *network, proxyURL: *proxyURL,
})
emit(report, *outMD, "")
if *outJSON != "" {
fmt.Fprintln(os.Stderr, "note: -json is not written in swebench mode; the harness report is authoritative")
}
return
}
if *mode == "diff" {
report := runDiff(diffOpts{
bin: *bin, model: *model, repo: *repo, base: *base,
testCmd: *testCmd, profile: profile, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
testCmd: *testCmd, profile: profile, ablate: arm, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
})
emit(report, *outMD, "")
return
@@ -124,17 +214,7 @@ func main() {
os.Exit(1)
}
var results []result
total := 0
for _, t := range tasks {
if *budget > 0 && total >= *budget {
results = append(results, result{task: t, Profile: profile, Skipped: true, Note: "skipped: token budget reached"})
continue
}
r := runTask(*bin, *model, profile, t)
total += r.PromptTokens + r.CompletionTokens
results = append(results, r)
}
results := runSuite(*bin, *model, profile, arm, tasks, *budget, *trajDir)
report := render(results)
if *outMD != "" {
@@ -200,11 +280,38 @@ func loadTasks(suite string) ([]task, error) {
return tasks, nil
}
// runSuite runs each task in order until the token budget is exhausted;
// remaining tasks are reported as skipped rather than silently dropped.
func runSuite(bin, model, profile string, arm ablation.Set, tasks []task, budget int, trajDir string) []result {
var results []result
total := 0
for _, t := range tasks {
if budget > 0 && total >= budget {
results = append(results, result{task: t, Profile: profile, Skipped: true, Note: "skipped: token budget reached"})
continue
}
r := runTask(bin, model, profile, arm, t, trajDir)
total += r.PromptTokens + r.CompletionTokens
results = append(results, r)
}
return results
}
// runTask copies the task's seed workdir into a temp dir, runs the agent there,
// then drops in verify.sh and runs it as the grader. The grader is added only
// after the run so the agent can't read the answer key.
func runTask(bin, model, profile string, t task) result {
func runTask(bin, model, profile string, arm ablation.Set, t task, trajDir string) result {
r := result{task: t, Profile: profile}
r.Arm = arm.Arm()
trajPath := ""
if trajDir != "" {
if err := os.MkdirAll(trajDir, 0o755); err != nil {
r.Note = "trajectory dir: " + err.Error()
return r
}
trajPath = filepath.Join(trajDir, t.ID+".trajectory.jsonl")
}
work, err := os.MkdirTemp("", "e2ebench-"+t.ID+"-")
if err != nil {
@@ -224,26 +331,30 @@ func runTask(bin, model, profile string, t task) result {
defer cancel()
metricsPath := filepath.Join(work, ".run-metrics.json")
args := []string{"run", "--metrics", metricsPath}
if model != "" {
args = append(args, "--model", model)
}
if t.MaxSteps > 0 {
args = append(args, "--max-steps", fmt.Sprint(t.MaxSteps))
}
args = appendBenchmarkProfileArgs(args, profile)
args = append(args, t.Prompt)
args := buildRunTaskArgs(metricsPath, trajPath, model, profile, arm, t.MaxSteps, t.Prompt)
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Dir = work
cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout
startedAt := time.Now()
runErr := cmd.Run()
r.WallMs = time.Since(startedAt).Milliseconds()
if m, err := readMetrics(metricsPath); err == nil {
r.runMetrics = m
}
if trajPath != "" {
if summary, err := summarizeTrajectory(trajPath); err == nil {
r.Trajectory = summary
}
}
// A killed child never writes metrics, so the deadline is the only place
// this failure mode is still observable.
if ctx.Err() == context.DeadlineExceeded {
r.Outcome = "timeout"
}
if runErr != nil {
r.Note = "run: " + runErr.Error()
// still grade — a non-zero exit may just be a max-steps notice
@@ -253,6 +364,28 @@ func runTask(bin, model, profile string, t task) result {
return r
}
func buildRunTaskArgs(metricsPath, trajectoryPath, model, profile string, arm ablation.Set, maxSteps int, prompt string) []string {
// Benchmarks are unattended and their fixtures require ordinary workspace
// writes. Auto still honors explicit ask/deny rules and the sandbox boundary.
args := []string{"run", "--auto", "--metrics", metricsPath}
if trajectoryPath != "" {
args = append(args, "--trajectory", trajectoryPath)
}
if model != "" {
args = append(args, "--model", model)
}
if maxSteps > 0 {
args = append(args, "--max-steps", fmt.Sprint(maxSteps))
}
args = appendBenchmarkProfileArgs(args, profile)
// The control arm must produce a byte-identical command line to the one the
// suite ran before ablation existed, so its numbers stay comparable.
if !arm.Empty() {
args = append(args, "--ablate", arm.String())
}
return append(args, prompt)
}
func grade(work, taskDir string) bool {
verify := filepath.Join(taskDir, "verify.sh")
if !fileExists(verify) {
@@ -270,11 +403,31 @@ func grade(work, taskDir string) bool {
}
func render(results []result) string {
profile := benchmarkProfileBaseline
arm := "full"
if len(results) > 0 {
if results[0].Profile != "" {
profile = results[0].Profile
}
if results[0].Arm != "" {
arm = results[0].Arm
}
}
return fmt.Sprintf("## 🤖 Reasonix e2e benchmark (%s · arm `%s`)\n\n", profile, arm) + renderBody(results)
}
// renderBody is the report without a heading, so a caller that supplies its own
// (SWE-bench mode) does not stack two titles.
func renderBody(results []result) string {
var b strings.Builder
passed, ran := 0, 0
var pTok, cTok, hit, miss, compacts int
accounted, accountedSolved, unaccounted, unaccountedSolved, partial := 0, 0, 0, 0, 0
var pTok, cTok, hit, miss, compacts, tools, toolFails int
var cost float64
var walls []int64
currency := ""
classes := map[string]int{}
prefixChangeReasons := map[string]int{}
for _, r := range results {
if r.Skipped {
continue
@@ -283,44 +436,83 @@ func render(results []result) string {
if r.Passed {
passed++
}
classes[r.class()]++
walls = append(walls, r.WallMs)
if r.Unaccounted {
unaccounted++
if r.Passed {
unaccountedSolved++
}
continue
}
accounted++
if r.Passed {
accountedSolved++
}
if r.Partial {
partial++
}
pTok += r.PromptTokens
cTok += r.CompletionTokens
hit += r.CacheHitTokens
miss += r.CacheMissTokens
compacts += r.Compactions
tools += r.ToolCalls
toolFails += r.ToolFailures
cost += r.Cost
if r.Currency != "" {
currency = r.Currency
}
for reason, n := range r.PrefixChangeReasonCounts {
prefixChangeReasons[reason] += n
}
}
profile := benchmarkProfileBaseline
if len(results) > 0 && results[0].Profile != "" {
profile = results[0].Profile
// Cost and tokens are divided by the solved instances we actually have
// accounting for. Dividing by every solve would treat a lost metrics file as
// a free solve and understate the published figure.
fmt.Fprintf(&b, "**Solved:** %d/%d (%s) · **Cost per solved:** %s · **Tokens per solved:** %s · **Median wall time:** %s\n\n",
passed, ran, pct(passed, ran),
costPerSolved(cost, accountedSolved, currency), tokensPerSolved(pTok+cTok, accountedSolved), dur(median(walls)))
fmt.Fprintf(&b, "**Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Tool calls:** %s (%s failed) · **Compactions:** %d · **Cost:** %s%.4f\n\n",
pct(hit, hit+miss), comma(pTok+cTok), comma(pTok), comma(cTok),
comma(tools), comma(toolFails), compacts, currencySym(currency), cost)
b.WriteString(renderTimeAttribution(results))
if unaccounted > 0 {
fmt.Fprintf(&b, "> **Accounting incomplete for %d of %d instances** (%d of them solved): the agent was killed before it wrote any metrics, so their cost and tokens are unknown. Totals above cover the %d accounted instances only, and per-solved figures divide by the %d accounted solves — the true totals are higher.\n\n",
unaccounted, ran, unaccountedSolved, accounted, accountedSolved)
}
if partial > 0 {
fmt.Fprintf(&b, "> **%d of %d instances contributed partial accounting**: the agent was killed mid-run and its numbers were recovered from the last in-flight snapshot. What is counted is real but stops at that snapshot, so every total above is a lower bound.\n\n",
partial, ran)
}
fmt.Fprintf(&b, "## 🤖 Reasonix e2e benchmark (%s)\n\n", profile)
fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Compactions:** %d · **Cost:** %s%.4f\n\n",
passed, ran, pct(passed, ran), pct(hit, hit+miss),
comma(pTok+cTok), comma(pTok), comma(cTok), compacts, currencySym(currency), cost)
fmt.Fprintf(&b, "| Task | Result | Steps | Prompt | Completion | Cache hit | Compact | Cost |\n")
fmt.Fprintf(&b, "|------|--------|------:|-------:|-----------:|----------:|--------:|-----:|\n")
fmt.Fprintf(&b, "| Task | Result | Class | Steps | Tools | Time | Prompt | Completion | Cache hit | Cost |\n")
fmt.Fprintf(&b, "|------|--------|-------|------:|------:|-----:|-------:|-----------:|----------:|-----:|\n")
for _, r := range results {
switch {
case r.Skipped:
fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — | — |\n", r.ID)
fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — | — | — | — |\n", r.ID)
default:
res := "❌ fail"
if r.Passed {
res = "✅ pass"
}
fmt.Fprintf(&b, "| `%s` | %s | %d | %s | %s | %s | %d | %s%.4f |\n",
r.ID, res, r.Steps, comma(r.PromptTokens), comma(r.CompletionTokens),
fmt.Fprintf(&b, "| `%s` | %s | %s | %d | %d | %s | %s | %s | %s | %s%.4f |\n",
r.ID, res, r.class(), r.Steps, r.ToolCalls, dur(r.WallMs),
comma(r.PromptTokens), comma(r.CompletionTokens),
pct(r.CacheHitTokens, r.CacheHitTokens+r.CacheMissTokens),
r.Compactions, currencySym(r.Currency), r.Cost)
currencySym(r.Currency), r.Cost)
}
}
fmt.Fprintf(&b, "\n<sub>Real provider run. Cache-hit %% is cached prompt tokens / total prompt tokens.</sub>\n")
fmt.Fprintf(&b, "\n<sub>Real provider run. Cache-hit %% is cached prompt tokens / total prompt tokens. Wall time is measured by the harness and includes process startup.</sub>\n")
if breakdown := failureBreakdown(classes); breakdown != "" {
fmt.Fprintf(&b, "\n**Failures by class:** %s\n", breakdown)
}
if breakdown := reasonBreakdown(prefixChangeReasons); breakdown != "" {
fmt.Fprintf(&b, "\n**Cache resets by cause:** %s\n", breakdown)
}
notes := false
for _, r := range results {
@@ -345,6 +537,77 @@ func pct(n, d int) string {
return fmt.Sprintf("%.0f%%", 100*float64(n)/float64(d))
}
func costPerSolved(cost float64, solved int, currency string) string {
if solved == 0 {
return "n/a"
}
return fmt.Sprintf("%s%.4f", currencySym(currency), cost/float64(solved))
}
func tokensPerSolved(tokens, solved int) string {
if solved == 0 {
return "n/a"
}
return comma(tokens / solved)
}
func median(ms []int64) int64 {
if len(ms) == 0 {
return 0
}
sorted := append([]int64(nil), ms...)
slices.Sort(sorted)
return sorted[len(sorted)/2]
}
func dur(ms int64) string {
if ms <= 0 {
return "—"
}
d := time.Duration(ms) * time.Millisecond
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
}
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
}
func failureBreakdown(classes map[string]int) string {
names := make([]string, 0, len(classes))
for name := range classes {
if name != "solved" {
names = append(names, name)
}
}
if len(names) == 0 {
return ""
}
sort.Strings(names)
parts := make([]string, 0, len(names))
for _, name := range names {
parts = append(parts, fmt.Sprintf("%s ×%d", name, classes[name]))
}
return strings.Join(parts, " · ")
}
// reasonBreakdown renders cache-prefix-change reason counts (compact_auto,
// snip, prune, tools, ...) the same way failureBreakdown renders failure
// classes, so a hit-rate regression in a PR shows which operation caused it.
func reasonBreakdown(reasons map[string]int) string {
names := make([]string, 0, len(reasons))
for name := range reasons {
names = append(names, name)
}
if len(names) == 0 {
return ""
}
sort.Strings(names)
parts := make([]string, 0, len(names))
for _, name := range names {
parts = append(parts, fmt.Sprintf("%s ×%d", name, reasons[name]))
}
return strings.Join(parts, " · ")
}
func comma(n int) string {
s := fmt.Sprint(n)
if len(s) <= 3 {
+1 -1
View File
@@ -129,7 +129,7 @@ func mutantBody(fset *token.FileSet, results *ast.FieldList) string {
if n == 0 {
n = 1
}
for i := 0; i < n; i++ {
for range n {
rets = append(rets, t)
}
}
+32
View File
@@ -3,6 +3,8 @@ package main
import (
"reflect"
"testing"
"reasonix/internal/ablation"
)
func TestAppendBenchmarkProfileArgsBaselineIsByteIdentical(t *testing.T) {
@@ -21,6 +23,36 @@ func TestAppendBenchmarkProfileArgsDeliveryUsesRealRuntimeProfile(t *testing.T)
}
}
func TestBuildRunTaskArgsEnablesUnattendedWorkspaceWrites(t *testing.T) {
got := buildRunTaskArgs("metrics.json", "run.trajectory.jsonl", "e2e", benchmarkProfileDelivery, ablation.Set{}, 12, "fix it")
want := []string{
"run", "--auto", "--metrics", "metrics.json",
"--trajectory", "run.trajectory.jsonl",
"--model", "e2e", "--max-steps", "12",
"--profile", "delivery", "fix it",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("run task args = %v, want %v", got, want)
}
}
func TestBuildRunTaskArgsPassesTheAblationArmThrough(t *testing.T) {
got := buildRunTaskArgs("m.json", "", "", benchmarkProfileBaseline, ablation.New(ablation.Evidence, ablation.Planner), 0, "fix it")
want := []string{"run", "--auto", "--metrics", "m.json", "--ablate", "evidence,planner", "fix it"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ablated args = %v, want %v", got, want)
}
}
func TestDefaultSuiteBudgetCoversCurrentFiveTaskBaseline(t *testing.T) {
// The real-provider baseline exceeded 400k after only three successful
// tasks. Keep enough headroom to grade all five instead of silently skipping
// the final scenarios as normal model and cache usage varies.
if defaultSuiteTokenBudget < 800_000 {
t.Fatalf("default suite token budget = %d, want at least 800000", defaultSuiteTokenBudget)
}
}
func TestNormalizeBenchmarkProfile(t *testing.T) {
for _, input := range []string{"", "baseline", " BASELINE "} {
if got, err := normalizeBenchmarkProfile(input); err != nil || got != benchmarkProfileBaseline {
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"strings"
"testing"
)
func TestResultClassSeparatesGuardStopsFromWrongPatches(t *testing.T) {
for _, tc := range []struct {
name string
r result
want string
}{
{"solved", result{Passed: true, runMetrics: runMetrics{Outcome: "success"}}, "solved"},
{"grader failed after a clean run", result{runMetrics: runMetrics{Outcome: "success"}}, "wrong_patch"},
{"guard stop", result{runMetrics: runMetrics{Outcome: "max_steps"}}, "max_steps"},
{"killed before writing metrics", result{}, "no_metrics"},
{"skipped", result{Skipped: true}, "skipped"},
} {
if got := tc.r.class(); got != tc.want {
t.Errorf("%s: class = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestCostPerSolvedIsNotAnAverageOverAttempts(t *testing.T) {
if got := costPerSolved(0.4, 2, "CNY"); got != "CNY 0.2000" {
t.Fatalf("cost per solved = %q, want CNY 0.2000", got)
}
if got := costPerSolved(0.4, 0, "CNY"); got != "n/a" {
t.Fatalf("zero solved = %q, want n/a", got)
}
if got := tokensPerSolved(1000, 0); got != "n/a" {
t.Fatalf("zero solved tokens = %q, want n/a", got)
}
}
func TestRenderLeadsWithCostPerSolvedAndFailureClasses(t *testing.T) {
out := render([]result{
{task: task{ID: "solved-one"}, Passed: true, WallMs: 4000,
runMetrics: runMetrics{Outcome: "success", Cost: 0.02, Currency: "CNY", PromptTokens: 1000, CacheHitTokens: 900, CacheMissTokens: 100, ToolCalls: 6}},
{task: task{ID: "stopped"}, WallMs: 9000,
runMetrics: runMetrics{Outcome: "max_steps", Cost: 0.02, Currency: "CNY", PromptTokens: 1000, ToolCalls: 40, ToolFailures: 3}},
})
for _, want := range []string{
"**Cost per solved:** CNY 0.0400",
"**Failures by class:** max_steps ×1",
"| Task | Result | Class |",
"| `stopped` | ❌ fail | max_steps |",
} {
if !strings.Contains(out, want) {
t.Errorf("report missing %q:\n%s", want, out)
}
}
}
// A killed agent writes no metrics. Counting that run as a zero-cost solve
// would make every published per-task figure cheaper than the truth.
func TestUnaccountedSolvesDoNotDeflateCostPerSolved(t *testing.T) {
results := []result{
{task: task{ID: "accounted"}, Passed: true, WallMs: 1000,
runMetrics: runMetrics{Outcome: "success", Cost: 0.02, Currency: "$", PromptTokens: 1000}},
{task: task{ID: "killed"}, Passed: true, WallMs: 1800000, Unaccounted: true,
runMetrics: runMetrics{Outcome: "timeout"}},
}
out := renderBody(results)
if !strings.Contains(out, "**Solved:** 2/2") {
t.Error("both solves must still count toward the solve rate")
}
if !strings.Contains(out, "**Cost per solved:** $ 0.0200") {
t.Errorf("cost per solved must divide by accounted solves only:\n%s", out)
}
if !strings.Contains(out, "Accounting incomplete for 1 of 2 instances") {
t.Errorf("the gap must be disclosed, not hidden:\n%s", out)
}
}
func TestRenderShowsCacheResetsByCause(t *testing.T) {
out := render([]result{
{task: task{ID: "a"}, Passed: true, WallMs: 1000,
runMetrics: runMetrics{Outcome: "success", PrefixChangeReasonCounts: map[string]int{"compact_auto": 1, "tools": 2}}},
{task: task{ID: "b"}, Passed: true, WallMs: 1000,
runMetrics: runMetrics{Outcome: "success", PrefixChangeReasonCounts: map[string]int{"snip": 3}}},
})
if want := "**Cache resets by cause:** compact_auto ×1 · snip ×3 · tools ×2"; !strings.Contains(out, want) {
t.Errorf("report missing %q:\n%s", want, out)
}
}
func TestRenderOmitsCacheResetsLineWhenNoReasonsReported(t *testing.T) {
out := render([]result{
{task: task{ID: "a"}, Passed: true, WallMs: 1000, runMetrics: runMetrics{Outcome: "success"}},
})
if strings.Contains(out, "Cache resets by cause") {
t.Errorf("report should omit the cache-resets line when no run reported any reasons:\n%s", out)
}
}
func TestDurFormatsSubMinuteAndMinuteScale(t *testing.T) {
if got := dur(4500); got != "4.5s" {
t.Errorf("dur(4500) = %q, want 4.5s", got)
}
if got := dur(125000); got != "2m05s" {
t.Errorf("dur(125000) = %q, want 2m05s", got)
}
if got := dur(0); got != "—" {
t.Errorf("dur(0) = %q, want an em dash", got)
}
}
func TestMedianPicksTheMiddleWallTime(t *testing.T) {
if got := median([]int64{9000, 1000, 4000}); got != 4000 {
t.Fatalf("median = %d, want 4000", got)
}
if got := median(nil); got != 0 {
t.Fatalf("empty median = %d, want 0", got)
}
}
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"encoding/json"
"fmt"
"slices"
"strings"
"reasonix/internal/ablation"
)
// SWE-bench mode runs the agent inside the official per-instance evaluation
// container, so it can execute the repo's tests exactly like the harnesses it
// is compared against, then hands the resulting patch to the official grader.
type swebenchInstance struct {
InstanceID string `json:"instance_id"`
Repo string `json:"repo"`
BaseCommit string `json:"base_commit"`
Problem string `json:"problem_statement"`
Difficulty string `json:"difficulty"`
}
// swebenchImage builds the registry name of an instance's evaluation image.
// The harness mangles "__" to "_1776_" only when a namespace is set, so the
// local image key it prints is not the name you can pull.
func swebenchImage(namespace, instanceID string) string {
return fmt.Sprintf("%s/sweb.eval.x86_64.%s:latest",
namespace, strings.ReplaceAll(instanceID, "__", "_1776_"))
}
// swebenchContainer is the throwaway container name for one attempt. It is
// distinct from the grader's own container so a stale agent container can never
// be mistaken for an evaluation in progress.
func swebenchContainer(instanceID string) string {
return "rxagent." + strings.ReplaceAll(instanceID, "__", ".")
}
// testbedShell wraps a command so it runs against the instance's conda
// environment. The images ship miniconda with the repo's dependencies in an env
// named "testbed"; a bare `docker exec` misses it and every import fails.
func testbedShell(command string) []string {
return []string{"bash", "-lc",
"source /opt/miniconda3/bin/activate && conda activate testbed && cd /testbed && " + command}
}
// permissionFlag maps a benchmark permission posture onto the CLI flag. auto is
// the unattended default. The alternative posture exists because comparable
// harnesses run without the dynamic-shell gate, so measuring against them under
// the gate measures our permission policy rather than the agent.
func permissionFlag(mode string) (string, error) {
switch mode {
case "", "auto":
return "--permission-mode=auto", nil
case "yolo":
return "--permission-mode=bypassPermissions", nil
default:
return "", fmt.Errorf("unknown permission mode %q (want auto or yolo)", mode)
}
}
func swebenchAgentArgs(metricsPath, model, profile, permission string, arm ablation.Set, maxSteps int, prompt string) []string {
posture, err := permissionFlag(permission)
if err != nil {
panic(err) // validated at flag-parse time; reaching here is a wiring bug
}
args := []string{"run", posture, "--metrics", metricsPath}
if model != "" {
args = append(args, "--model", model)
}
if maxSteps > 0 {
args = append(args, "--max-steps", fmt.Sprint(maxSteps))
}
args = appendBenchmarkProfileArgs(args, profile)
if !arm.Empty() {
args = append(args, "--ablate", arm.String())
}
return append(args, prompt)
}
// swebenchPrompt is the task text the agent sees. It carries the issue and the
// working rules, and deliberately withholds the test patch and the
// FAIL_TO_PASS list — those are the answer key.
func swebenchPrompt(inst swebenchInstance) string {
var b strings.Builder
b.WriteString("Resolve the following issue in the repository at /testbed.\n\n")
b.WriteString("<issue>\n")
b.WriteString(strings.TrimSpace(inst.Problem))
b.WriteString("\n</issue>\n\n")
b.WriteString("The repository is a git checkout at the commit where the issue reproduces. ")
b.WriteString("Edit the source to fix it, and run the project's own tests to check your work. ")
b.WriteString("Do not commit, and do not modify any test file — the fix is graded by tests you cannot see.\n")
return b.String()
}
// swebenchPrediction is one line of the predictions file the official grader
// reads. Field names are the harness's, not ours.
type swebenchPrediction struct {
InstanceID string `json:"instance_id"`
Model string `json:"model_name_or_path"`
Patch string `json:"model_patch"`
}
func encodePredictions(model string, patches map[string]string, order []string) (string, error) {
var b strings.Builder
for _, id := range order {
patch, ok := patches[id]
if !ok {
continue
}
line, err := json.Marshal(swebenchPrediction{InstanceID: id, Model: model, Patch: patch})
if err != nil {
return "", err
}
b.Write(line)
b.WriteByte('\n')
}
return b.String(), nil
}
// swebenchReport is the subset of the grader's JSON summary we consume. Unknown
// fields are ignored so a harness upgrade that adds counters does not break us.
type swebenchReport struct {
ResolvedIDs []string `json:"resolved_ids"`
UnresolvedIDs []string `json:"unresolved_ids"`
ErrorIDs []string `json:"error_ids"`
EmptyPatchIDs []string `json:"empty_patch_ids"`
IncompleteIDs []string `json:"incomplete_ids"`
}
// swebenchReportPath is where run_evaluation writes its summary: the model name
// from the predictions file joined with the run id, in the working directory.
func swebenchReportPath(model, runID string) string {
return model + "." + runID + ".json"
}
// gradedClass maps one instance's grader outcome onto our published failure
// taxonomy. An id the grader never mentions is reported as unknown rather than
// silently counted as unsolved.
func (r swebenchReport) gradedClass(instanceID string) string {
if slices.Contains(r.ResolvedIDs, instanceID) {
return "solved"
}
if slices.Contains(r.EmptyPatchIDs, instanceID) {
return "no_patch"
}
if slices.Contains(r.ErrorIDs, instanceID) {
return "grader_error"
}
if slices.Contains(r.IncompleteIDs, instanceID) {
return "eval_timeout"
}
if slices.Contains(r.UnresolvedIDs, instanceID) {
return "wrong_patch"
}
return "ungraded"
}
+420
View File
@@ -0,0 +1,420 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"reasonix/internal/ablation"
fileencoding "reasonix/internal/fileutil/encoding"
)
type swebenchOpts struct {
bin string
subset string
namespace string
model string
profile string
permission string
arm ablation.Set
runID string
workDir string
harness string
dataset string
maxSteps int
timeoutSec int
workers int
keepImages bool
// network is the isolated Docker network; proxyURL is the allowlisted API path.
network string
proxyURL string
}
// The only benchmark config is sandbox.bash=off, required because official
// images do not ship bubblewrap. Network facts are intentionally not injected.
const swebenchAgentConfig = "[sandbox]\nbash = \"off\"\n"
func loadSwebenchSubset(path string) ([]swebenchInstance, error) {
data, err := fileencoding.ReadFileUTF8(path)
if err != nil {
return nil, err
}
var out []swebenchInstance
if err := json.Unmarshal(data, &out); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if len(out) == 0 {
return nil, fmt.Errorf("%s: no instances", path)
}
return out, nil
}
// runSwebenchInstance drives one instance: start its evaluation container, run
// the agent inside it against /testbed, and take whatever the working tree
// became as the candidate patch. Grading happens later, in one batch.
func runSwebenchInstance(o swebenchOpts, inst swebenchInstance) (result, string) {
r := result{task: task{ID: inst.InstanceID}, Profile: o.profile}
r.Arm = o.arm.Arm()
image := swebenchImage(o.namespace, inst.InstanceID)
container := swebenchContainer(inst.InstanceID)
_ = dockerRun("rm", "-f", container)
runArgs := []string{"run", "-d", "--name", container}
if o.network != "" {
runArgs = append(runArgs, "--network", o.network)
}
for _, kv := range proxyEnv(o.proxyURL) {
runArgs = append(runArgs, "-e", kv)
}
runArgs = append(runArgs, image, "sleep", "infinity")
if out, err := dockerOutput(runArgs...); err != nil {
r.Note = "start container: " + firstLine(out)
r.Outcome = "container_error"
return r, ""
}
defer func() {
_ = dockerRun("rm", "-f", container)
if !o.keepImages {
_ = dockerRun("rmi", "-f", image)
}
}()
if err := provisionAgent(o, container); err != nil {
r.Note = "provision agent: " + err.Error()
r.Outcome = "container_error"
return r, ""
}
metricsPath := "/tmp/reasonix-metrics.json"
args := swebenchAgentArgs(metricsPath, o.model, o.profile, o.permission, o.arm, o.maxSteps, swebenchPrompt(inst))
agentCmd := append([]string{"exec", "-e", "REASONIX_HOME=/opt/rxhome", container},
testbedShell("/usr/local/bin/reasonix "+shellQuoteAll(args))...)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.timeoutSec)*time.Second)
defer cancel()
startedAt := time.Now()
runErr := dockerRunCtx(ctx, agentCmd...)
r.WallMs = time.Since(startedAt).Milliseconds()
// Prefer the final record; fall back to the snapshot a killed agent left.
// The final file is authoritative and the sidecar is only ever read when it
// is absent, so the two can never be counted together.
r.Unaccounted = true
for _, src := range []struct {
path string
partial bool
}{{metricsPath, false}, {metricsPath + ".partial", true}} {
raw, err := dockerOutput("exec", container, "cat", src.path)
if err != nil {
continue
}
var m runMetrics
if json.Unmarshal([]byte(raw), &m) != nil {
continue
}
arm := r.Arm
r.runMetrics = m
r.Arm = arm
r.Unaccounted = false
r.Partial = src.partial || !m.Complete
break
}
if ctx.Err() != nil {
r.Outcome = "timeout"
}
if runErr != nil && r.Outcome == "" {
r.Note = "agent: " + runErr.Error()
}
patch, err := extractTestbedPatch(container)
if err != nil {
r.Note = strings.TrimSpace(r.Note + " | diff: " + err.Error())
return r, ""
}
return r, patch
}
const patchArgBudget = 16 << 10
// extractTestbedPatch drops only binary entries, which git apply cannot carry;
// all text files are retained and paths are passed as argv.
func extractTestbedPatch(container string) (string, error) {
if out, err := dockerOutput("exec", container, "git", "-C", "/testbed", "add", "-A"); err != nil {
return "", fmt.Errorf("add: %s", firstLine(out))
}
numstat, err := dockerOutput("exec", container, "git", "-C", "/testbed",
"diff", "--cached", "--no-renames", "--numstat", "-z")
if err != nil {
return "", fmt.Errorf("numstat: %s", firstLine(numstat))
}
files := patchFileList(numstat)
if len(files) == 0 {
return "", nil
}
batches, err := patchFileBatches(container, files)
if err != nil {
return "", fmt.Errorf("diff args: %w", err)
}
var patch strings.Builder
for _, batch := range batches {
out, err := dockerOutput(testbedPatchDiffArgs(container, batch)...)
if err != nil {
return "", fmt.Errorf("diff: %s", firstLine(out))
}
patch.WriteString(out)
}
return patch.String(), nil
}
func testbedPatchDiffArgs(container string, files []string) []string {
return append([]string{"exec", container, "git", "--literal-pathspecs", "-C", "/testbed",
"diff", "--cached", "--no-renames", "--"}, files...)
}
func patchFileBatches(container string, files []string) ([][]string, error) {
baseBytes := argvBytes(testbedPatchDiffArgs(container, nil))
var batches [][]string
current := make([]string, 0)
currentBytes := baseBytes
for _, path := range files {
pathBytes := len(path) + 1
if baseBytes+pathBytes > patchArgBudget {
return nil, fmt.Errorf("path %q exceeds the %d-byte argv budget", path, patchArgBudget)
}
if len(current) > 0 && currentBytes+pathBytes > patchArgBudget {
batches = append(batches, current)
current = make([]string, 0)
currentBytes = baseBytes
}
current = append(current, path)
currentBytes += pathBytes
}
if len(current) > 0 {
batches = append(batches, current)
}
return batches, nil
}
func argvBytes(args []string) int {
total := 0
for _, arg := range args {
total += len(arg) + 1
}
return total
}
// patchFileList returns every text path from numstat, excluding binary entries.
func patchFileList(numstat string) []string {
var files []string
for entry := range strings.SplitSeq(numstat, "\x00") {
if entry == "" {
continue
}
fields := strings.SplitN(entry, "\t", 3)
if len(fields) != 3 {
continue
}
added, deleted, path := fields[0], fields[1], fields[2]
if added == "-" || deleted == "-" || path == "" {
continue // binary: not representable in an appliable text diff
}
files = append(files, path)
}
return files
}
// provisionAgent copies the binary and a credential-free home into the
// container. The API key is streamed in rather than baked into an image layer
// or an argv, so it never lands anywhere a later docker inspect can read it.
func provisionAgent(o swebenchOpts, container string) error {
if err := dockerRun("cp", o.bin, container+":/usr/local/bin/reasonix"); err != nil {
return err
}
if err := dockerRun("exec", container, "mkdir", "-p", "/opt/rxhome"); err != nil {
return err
}
if err := dockerPipe(swebenchAgentConfig, "exec", "-i", container,
"bash", "-c", "umask 077 && cat > /opt/rxhome/config.toml"); err != nil {
return err
}
env, err := os.ReadFile(filepath.Join(os.Getenv("REASONIX_HOME"), ".env"))
if err != nil {
return fmt.Errorf("read credentials from $REASONIX_HOME/.env: %w", err)
}
return dockerPipe(string(env), "exec", "-i", container,
"bash", "-c", "umask 077 && cat > /opt/rxhome/.env")
}
// gradeSwebench writes the predictions file and hands it to the official
// harness, then reads back its report. We never decide resolution ourselves.
func gradeSwebench(o swebenchOpts, patches map[string]string, order []string) (swebenchReport, error) {
var report swebenchReport
predictions, err := encodePredictions("reasonix", patches, order)
if err != nil {
return report, err
}
path := filepath.Join(o.workDir, "predictions.jsonl")
if err := os.WriteFile(path, []byte(predictions), 0o600); err != nil {
return report, err
}
args := []string{"-m", "swebench.harness.run_evaluation",
"--dataset_name", o.dataset,
"--predictions_path", path,
"--run_id", o.runID,
"--max_workers", fmt.Sprint(o.workers),
"--instance_ids"}
args = append(args, order...)
cmd := exec.Command(o.harness, args...)
cmd.Dir = o.workDir
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return report, fmt.Errorf("run_evaluation: %w", err)
}
raw, err := fileencoding.ReadFileUTF8(filepath.Join(o.workDir, swebenchReportPath("reasonix", o.runID)))
if err != nil {
return report, err
}
return report, json.Unmarshal(raw, &report)
}
// preflight fails before the first container instead of after fifty. A unit
// test can only check the argv we build; it cannot know whether this binary
// accepts it. An earlier run lost a full arm to a flag that existed on the
// interactive command but not on `run`.
func preflight(o swebenchOpts) error {
posture, err := permissionFlag(o.permission)
if err != nil {
return err
}
help, err := exec.Command(o.bin, "run", "--help").CombinedOutput()
if err != nil {
return fmt.Errorf("%s run --help: %w", o.bin, err)
}
name, _, _ := strings.Cut(strings.TrimPrefix(posture, "--"), "=")
if !strings.Contains(string(help), "--"+name) {
return fmt.Errorf("%s run does not accept --%s; the %q posture would fail on every instance", o.bin, name, o.permission)
}
if o.network == "" || o.proxyURL == "" {
return fmt.Errorf("-network and -proxy are required: with off-box egress the agent reads the upstream fix and every solve is unearned")
}
return nil
}
func runSwebench(o swebenchOpts) string {
if err := preflight(o); err != nil {
fmt.Fprintln(os.Stderr, "preflight:", err)
os.Exit(2)
}
instances, err := loadSwebenchSubset(o.subset)
if err != nil {
fmt.Fprintln(os.Stderr, "load subset:", err)
os.Exit(1)
}
patches := map[string]string{}
order := make([]string, 0, len(instances))
results := make([]result, 0, len(instances))
for i, inst := range instances {
fmt.Fprintf(os.Stderr, "\n=== [%d/%d] %s ===\n", i+1, len(instances), inst.InstanceID)
r, patch := runSwebenchInstance(o, inst)
order = append(order, inst.InstanceID)
if strings.TrimSpace(patch) != "" {
patches[inst.InstanceID] = patch
}
results = append(results, r)
}
report, err := gradeSwebench(o, patches, order)
if err != nil {
fmt.Fprintln(os.Stderr, "grade:", err)
}
for i := range results {
class := report.gradedClass(results[i].ID)
results[i].Passed = class == "solved"
// A guard that stopped the agent explains the failure better than the
// grader's generic "unresolved", so an agent-side outcome wins.
if results[i].Outcome == "" || results[i].Outcome == "success" {
results[i].Outcome = class
}
}
return renderSwebench(results, o)
}
func renderSwebench(results []result, o swebenchOpts) string {
model := o.model
if strings.TrimSpace(model) == "" {
model = "config default"
}
var b strings.Builder
fmt.Fprintf(&b, "## SWE-bench Verified — Reasonix (arm `%s`)\n\n", o.arm.Arm())
posture := o.permission
if posture == "" {
posture = "auto"
}
fmt.Fprintf(&b, "<sub>model `%s` · permissions `%s` · subset `%s` · run `%s` · agent runs inside the official instance image · graded by the official harness</sub>\n\n",
model, posture, filepath.Base(o.subset), o.runID)
b.WriteString(renderBody(results))
return b.String()
}
// proxyEnv sets both cases because the Go client reads the lowercase names via
// httpproxy.FromEnvironment while curl and pip inside the image read either.
func proxyEnv(url string) []string {
if strings.TrimSpace(url) == "" {
return nil
}
return []string{
"http_proxy=" + url, "https_proxy=" + url,
"HTTP_PROXY=" + url, "HTTPS_PROXY=" + url,
}
}
func firstLine(s string) string {
if before, _, ok := strings.Cut(s, "\n"); ok {
return strings.TrimSpace(before)
}
return strings.TrimSpace(s)
}
// shellQuoteAll renders argv for a `bash -lc` string. Prompts carry newlines,
// quotes and backticks straight from a GitHub issue, so nothing may be passed
// unquoted.
func shellQuoteAll(args []string) string {
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'"
}
return strings.Join(quoted, " ")
}
func dockerRun(args ...string) error {
return exec.Command("docker", args...).Run()
}
func dockerRunCtx(ctx context.Context, args ...string) error {
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second
return cmd.Run()
}
func dockerOutput(args ...string) (string, error) {
out, err := exec.Command("docker", args...).CombinedOutput()
return string(out), err
}
func dockerPipe(stdin string, args ...string) error {
cmd := exec.Command("docker", args...)
cmd.Stdin = strings.NewReader(stdin)
return cmd.Run()
}
+272
View File
@@ -0,0 +1,272 @@
package main
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/BurntSushi/toml"
"reasonix/internal/ablation"
"reasonix/internal/config"
)
// The harness prints an unmangled local image key but pulls a mangled one; the
// two differ and only the mangled form exists in the registry.
func TestSwebenchImageManglesDoubleUnderscore(t *testing.T) {
got := swebenchImage("swebench", "psf__requests-2317")
want := "swebench/sweb.eval.x86_64.psf_1776_requests-2317:latest"
if got != want {
t.Fatalf("image = %q, want %q", got, want)
}
if got := swebenchImage("swebench", "pylint-dev__pylint-7080"); !strings.Contains(got, "pylint-dev_1776_pylint-7080") {
t.Fatalf("hyphenated org mangled wrong: %q", got)
}
}
func TestTestbedShellActivatesTheInstanceCondaEnv(t *testing.T) {
got := testbedShell("git diff")
if len(got) != 3 || got[0] != "bash" || got[1] != "-lc" {
t.Fatalf("shell wrapper = %v", got)
}
for _, want := range []string{"/opt/miniconda3/bin/activate", "conda activate testbed", "cd /testbed", "git diff"} {
if !strings.Contains(got[2], want) {
t.Errorf("command missing %q: %s", want, got[2])
}
}
}
func TestSwebenchPromptWithholdsTheAnswerKey(t *testing.T) {
prompt := swebenchPrompt(swebenchInstance{
InstanceID: "psf__requests-2317",
Problem: "method = builtin_str(method) breaks binary strings",
})
if !strings.Contains(prompt, "builtin_str(method)") {
t.Fatal("the issue text must reach the agent")
}
for _, leak := range []string{"FAIL_TO_PASS", "PASS_TO_PASS", "test_patch"} {
if strings.Contains(prompt, leak) {
t.Errorf("prompt leaks the answer key: %s", leak)
}
}
if !strings.Contains(prompt, "do not modify any test file") {
t.Error("the no-test-edit rule must be stated; the grader replaces test files anyway")
}
}
func TestEncodePredictionsWritesOneHarnessRecordPerLine(t *testing.T) {
out, err := encodePredictions("reasonix", map[string]string{
"a__a-1": "diff --git a/x b/x\n",
"b__b-2": "diff --git a/y b/y\n",
}, []string{"a__a-1", "missing__missing-9", "b__b-2"})
if err != nil {
t.Fatalf("encode: %v", err)
}
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) != 2 {
t.Fatalf("lines = %d, want 2 (an instance with no patch is skipped, not emitted empty)", len(lines))
}
for _, want := range []string{`"instance_id":"a__a-1"`, `"model_name_or_path":"reasonix"`, `"model_patch":"diff --git a/x b/x\n"`} {
if !strings.Contains(lines[0], want) {
t.Errorf("first record missing %q: %s", want, lines[0])
}
}
}
func TestGradedClassNeverGuessesForAnUnmentionedInstance(t *testing.T) {
report := swebenchReport{
ResolvedIDs: []string{"a__a-1"},
UnresolvedIDs: []string{"b__b-2"},
ErrorIDs: []string{"c__c-3"},
EmptyPatchIDs: []string{"d__d-4"},
IncompleteIDs: []string{"f__f-6"},
}
for id, want := range map[string]string{
"a__a-1": "solved",
"b__b-2": "wrong_patch",
"c__c-3": "grader_error",
"d__d-4": "no_patch",
"f__f-6": "eval_timeout",
"e__e-5": "ungraded",
} {
if got := report.gradedClass(id); got != want {
t.Errorf("class(%s) = %q, want %q", id, got, want)
}
}
}
// Captured from a real run on 2026-08-04: swebench 4.1.0, gold predictions,
// run_id goldpylint. Pins the field names and the report path we depend on.
func TestSwebenchReportParsesTheHarnessSummary(t *testing.T) {
const captured = `{"total_instances":1,"submitted_instances":500,"completed_instances":1,
"resolved_instances":1,"unresolved_instances":0,"empty_patch_instances":0,"error_instances":0,
"completed_ids":["pylint-dev__pylint-7080"],"incomplete_ids":[],"empty_patch_ids":[],
"resolved_ids":["pylint-dev__pylint-7080"],"unresolved_ids":[],"error_ids":[],"schema_version":2}`
var report swebenchReport
if err := json.Unmarshal([]byte(captured), &report); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := report.gradedClass("pylint-dev__pylint-7080"); got != "solved" {
t.Fatalf("gold patch classified as %q, want solved", got)
}
if got := swebenchReportPath("gold", "goldpylint"); got != "gold.goldpylint.json" {
t.Fatalf("report path = %q, want gold.goldpylint.json", got)
}
}
// Issue text arrives verbatim from GitHub and routinely contains quotes,
// backticks, $ and newlines. It becomes one argv element inside a `bash -lc`
// string, so a quoting slip would let a problem statement run commands.
func TestShellQuoteAllContainsHostileIssueText(t *testing.T) {
hostile := "it's broken; `rm -rf /`; $(whoami)\n\"quoted\" && echo pwned"
got := shellQuoteAll([]string{"run", hostile})
if !strings.HasPrefix(got, "'run' '") || !strings.HasSuffix(got, "'") {
t.Fatalf("every element must be single-quoted: %s", got)
}
// Inside single quotes the shell expands nothing, so the only way out is an
// unescaped apostrophe: every one in the payload must have been rewritten.
if strings.Count(got, `'\''`) != strings.Count(hostile, "'") {
t.Fatalf("apostrophes not all escaped: %s", got)
}
}
func TestSwebenchAgentArgsKeepTheControlArmClean(t *testing.T) {
got := swebenchAgentArgs("/tmp/m.json", "e2e", benchmarkProfileBaseline, "auto", ablation.Set{}, 60, "fix it")
want := []string{"run", "--permission-mode=auto", "--metrics", "/tmp/m.json", "--model", "e2e", "--max-steps", "60", "fix it"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("control args = %v, want %v", got, want)
}
ablated := swebenchAgentArgs("/tmp/m.json", "", benchmarkProfileBaseline, "auto", ablation.New(ablation.Evidence), 0, "fix it")
if !reflect.DeepEqual(ablated, []string{"run", "--permission-mode=auto", "--metrics", "/tmp/m.json", "--ablate", "evidence", "fix it"}) {
t.Fatalf("ablated args = %v", ablated)
}
}
// The two postures must differ in exactly one argument. If anything else moved
// between arms, the published delta would not isolate the permission gate.
func TestPermissionPostureIsTheOnlyDifferenceBetweenArms(t *testing.T) {
a := swebenchAgentArgs("/m.json", "e2e", benchmarkProfileBaseline, "auto", ablation.Set{}, 60, "fix it")
b := swebenchAgentArgs("/m.json", "e2e", benchmarkProfileBaseline, "yolo", ablation.Set{}, 60, "fix it")
if len(a) != len(b) {
t.Fatalf("arms differ in argument count: %v vs %v", a, b)
}
diffs := 0
for i := range a {
if a[i] != b[i] {
diffs++
}
}
if diffs != 1 || a[1] != "--permission-mode=auto" || b[1] != "--permission-mode=bypassPermissions" {
t.Fatalf("arms must differ only in the posture flag: %v vs %v", a, b)
}
if _, err := permissionFlag("bypass"); err == nil {
t.Fatal("an unknown posture must fail loudly rather than silently running unattended")
}
}
// The benchmark config must remain a default install, except for the required
// bash sandbox override; decode it with the real config type to pin the keys.
func TestSwebenchAgentConfigTunesNothingInTheAgentsFavor(t *testing.T) {
var cfg config.Config
meta, err := toml.Decode(swebenchAgentConfig, &cfg)
if err != nil {
t.Fatalf("container config must be valid TOML: %v", err)
}
if cfg.Sandbox.Bash != "off" {
t.Errorf("sandbox.bash = %q, want \"off\": bubblewrap is absent from the official images", cfg.Sandbox.Bash)
}
// Do not configure the benchmark's blocked network as an agent fact.
if cfg.Environment.Offline {
t.Error("the benchmark must not declare the environment offline: that configures the agent better than a default install")
}
for _, key := range meta.Keys() {
if meta.Type(key...) == "Hash" {
continue // table header, not a setting
}
if got := key.String(); got != "sandbox.bash" {
t.Errorf("unexpected benchmark-only setting %q: the container must run a default install", got)
}
}
}
// Binary entries are omitted because their placeholder is not applicable;
// every text entry, including generated files, must remain in the patch.
func TestPatchFileListDropsBinariesAndNothingElse(t *testing.T) {
numstat := strings.Join([]string{
"12\t4\tsphinx/directives/other.py", // the actual fix
"0\t9\tsphinx/old_helper.py", // text deletion
"-\t-\t_repro/_build/.doctrees/environment.pickle", // binary: drop
"-\t-\timg/probe.png", // new binary: drop
"3\t0\t_repro/_build/_static/basic.css", // build output the agent left: keep
"2\t0\tsklearn.egg-info/PKG-INFO", // packaging metadata: keep
"1\t0\tpkg/__pycache__/note.txt", // cache tree: keep
"5\t1\trepro.py", // agent scratch: keep
}, "\x00") + "\x00"
got := patchFileList(numstat)
want := []string{
"sphinx/directives/other.py",
"sphinx/old_helper.py",
"_repro/_build/_static/basic.css",
"sklearn.egg-info/PKG-INFO",
"pkg/__pycache__/note.txt",
"repro.py",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("patchFileList = %v, want %v", got, want)
}
}
func TestPatchFileListToleratesEmptyAndMalformedInput(t *testing.T) {
if got := patchFileList(""); got != nil {
t.Fatalf("empty numstat = %v, want nil", got)
}
if got := patchFileList("garbage-without-tabs\x00\x00"); got != nil {
t.Fatalf("malformed numstat = %v, want nil", got)
}
}
func TestTestbedPatchDiffArgsTreatPathsLiterally(t *testing.T) {
got := testbedPatchDiffArgs("agent-123", []string{"source.py", ":(exclude)*"})
want := []string{
"exec", "agent-123", "git", "--literal-pathspecs", "-C", "/testbed",
"diff", "--cached", "--no-renames", "--", "source.py", ":(exclude)*",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("patch diff args = %v, want %v", got, want)
}
}
func TestPatchFileBatchesBoundArgBytesAndPreserveOrder(t *testing.T) {
files := make([]string, 2000)
for i := range files {
files[i] = "generated.txt"
}
batches, err := patchFileBatches("agent-123", files)
if err != nil {
t.Fatalf("patchFileBatches: %v", err)
}
if len(batches) < 2 {
t.Fatalf("batches = %d, want multiple batches", len(batches))
}
var got []string
for _, batch := range batches {
if bytes := argvBytes(testbedPatchDiffArgs("agent-123", batch)); bytes > patchArgBudget {
t.Fatalf("batch argv bytes = %d, budget = %d", bytes, patchArgBudget)
}
got = append(got, batch...)
}
if !reflect.DeepEqual(got, files) {
t.Fatalf("batched paths changed: got %d paths, want %d", len(got), len(files))
}
}
func TestPatchFileBatchesRejectOversizedPath(t *testing.T) {
path := strings.Repeat("x", patchArgBudget)
if _, err := patchFileBatches("agent-123", []string{path}); err == nil {
t.Fatal("oversized path must be rejected")
}
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
// trajectorySummary is the harness-side digest of one run's trajectory file:
// where the wall clock went, split between tool execution and everything the
// model spent between calls (thinking, streaming, provider latency).
type trajectorySummary struct {
Path string `json:"path"`
Records int `json:"records"`
SpanMs int64 `json:"span_ms"`
ToolMs int64 `json:"tool_ms"`
ModelMs int64 `json:"model_ms"` // SpanMs − ToolMs, floored at zero
}
// trajectoryRecord is the subset of trajectory.Record the summary needs.
type trajectoryRecord struct {
TS int64 `json:"ts"`
Event *struct {
Kind string `json:"kind"`
Tool *struct {
DurationMs int64 `json:"durationMs"`
ParentID string `json:"parentId"`
} `json:"tool"`
} `json:"event"`
}
// renderTimeAttribution aggregates recorded runs into one report line; empty
// when no run in the suite carried a trajectory.
func renderTimeAttribution(results []result) string {
var toolMs, modelMs int64
runs := 0
for _, r := range results {
if r.Trajectory != nil {
runs++
toolMs += r.Trajectory.ToolMs
modelMs += r.Trajectory.ModelMs
}
}
if runs == 0 {
return ""
}
return fmt.Sprintf("**Time attribution** (%d recorded runs): **tools** %s (%s) · **model** %s (%s)\n\n",
runs, dur(toolMs), pct(int(toolMs), int(toolMs+modelMs)),
dur(modelMs), pct(int(modelMs), int(toolMs+modelMs)))
}
// summarizeTrajectory reads a run's JSONL trajectory. A truncated final line
// (killed run) is skipped, matching the recorder's durability contract.
func summarizeTrajectory(path string) (*trajectorySummary, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
s := &trajectorySummary{Path: path}
var firstTS, lastTS int64
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1<<20), 16<<20)
for sc.Scan() {
var rec trajectoryRecord
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
continue
}
s.Records++
if firstTS == 0 {
firstTS = rec.TS
}
lastTS = rec.TS
if rec.Event == nil || rec.Event.Tool == nil {
continue
}
// Subagent calls overlap the parent's wall clock; counting them would
// double-book the span.
if rec.Event.Kind == "tool_result" && rec.Event.Tool.ParentID == "" {
s.ToolMs += rec.Event.Tool.DurationMs
}
}
if err := sc.Err(); err != nil {
return nil, err
}
s.SpanMs = lastTS - firstTS
if s.ModelMs = s.SpanMs - s.ToolMs; s.ModelMs < 0 {
s.ModelMs = 0
}
return s, nil
}
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSummarizeTrajectoryAttributesTimeAndSkipsTruncatedTail(t *testing.T) {
path := filepath.Join(t.TempDir(), "task.trajectory.jsonl")
lines := []string{
`{"schema_version":1,"seq":1,"ts":1000,"event":{"kind":"turn_started"}}`,
`{"schema_version":1,"seq":2,"ts":1500,"event":{"kind":"tool_dispatch","tool":{"name":"bash"}}}`,
`{"schema_version":1,"seq":3,"ts":2000,"event":{"kind":"tool_result","tool":{"name":"bash","durationMs":400}}}`,
`{"schema_version":1,"seq":4,"ts":2500,"event":{"kind":"tool_result","tool":{"name":"grep","durationMs":300,"parentId":"task-1"}}}`,
`{"schema_version":1,"seq":5,"ts":4000,"event":{"kind":"turn_done"}}`,
`{"schema_version":1,"seq":6,"ts":4100,"event":{"kind":"tool_res`, // killed mid-write
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
s, err := summarizeTrajectory(path)
if err != nil {
t.Fatalf("summarizeTrajectory: %v", err)
}
if s.Records != 5 {
t.Errorf("records = %d, want 5 (truncated tail skipped)", s.Records)
}
if s.SpanMs != 3000 {
t.Errorf("span = %d, want 3000", s.SpanMs)
}
if s.ToolMs != 400 {
t.Errorf("tool ms = %d, want 400 (subagent call must not double-book)", s.ToolMs)
}
if s.ModelMs != 2600 {
t.Errorf("model ms = %d, want 2600", s.ModelMs)
}
}
func TestRenderBodyIncludesTimeAttributionOnlyForRecordedRuns(t *testing.T) {
plain := result{task: task{ID: "a"}, Passed: true}
if got := renderBody([]result{plain}); strings.Contains(got, "Time attribution") {
t.Fatalf("unrecorded run must not report time attribution:\n%s", got)
}
recorded := plain
recorded.Trajectory = &trajectorySummary{Records: 5, SpanMs: 3000, ToolMs: 1000, ModelMs: 2000}
got := renderBody([]result{recorded})
if !strings.Contains(got, "Time attribution") || !strings.Contains(got, "(1 recorded runs)") {
t.Fatalf("recorded run missing time attribution:\n%s", got)
}
}
@@ -5,7 +5,7 @@ import (
"fmt"
"log"
"reasonix/internal/remote/protocolgen"
"reasonix/internal/extension/protocolgen"
)
func main() {
@@ -22,7 +22,7 @@ func main() {
if err := protocolgen.Check(*root, artifacts); err != nil {
log.Fatal(err)
}
fmt.Println("Remote protocol artifacts are up to date.")
fmt.Println("Extension protocol artifacts are up to date.")
return
}
if err := protocolgen.Write(*root, artifacts); err != nil {
-381
View File
@@ -1,381 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/provider"
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/provider/responses"
"reasonix/internal/repair"
)
func runAssist(args []string) int {
fs := flag.NewFlagSet("reasonix-guard assist", flag.ContinueOnError)
root := fs.String("root", ".", "project root to diagnose")
model := fs.String("model", "", "provider or provider/model used for the one-shot plan")
network := fs.Bool("network", true, "include provider connectivity diagnostics")
apply := fs.Bool("apply", false, "apply the validated plan after preview and confirmation")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow a plan to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print the plan and preview as JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
report, err := repair.Diagnose(ctx, repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := requestRepairPlan(ctx, *root, *model, report)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(struct {
Plan repair.RepairPlan `json:"plan"`
PlanID string `json:"planId"`
Preview []repair.RepairPlanPreview `json:"preview"`
PreviewID string `json:"previewId"`
}{plan, repair.RepairPlanID(plan), preview, repair.RepairPlanPreviewID(plan, preview)}); code != 0 {
return code
}
} else {
printPlanPreview(plan, preview)
}
if !*apply {
return 0
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
opts.ExpectedPreviewID = repair.RepairPlanPreviewID(plan, preview)
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func runApplyPlan(args []string) int {
fs := flag.NewFlagSet("reasonix-guard apply-plan", flag.ContinueOnError)
root := fs.String("root", ".", "project root")
file := fs.String("file", "", "RepairPlan JSON file")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow project reasonix.toml repair")
previewID := fs.String("preview-id", "", "expected preview ID from a prior dry-run")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*file) == "" {
return 2
}
b, err := os.ReadFile(*file)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := repair.DecodeRepairPlan(b)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if !*jsonOut {
printPlanPreview(plan, preview)
}
actualPreviewID := repair.RepairPlanPreviewID(plan, preview)
expectedPreviewID := strings.TrimSpace(*previewID)
if *yes && expectedPreviewID == "" {
fmt.Fprintln(os.Stderr, "error: --preview-id is required with --yes; preview the plan and confirm its current previewId first")
return 2
}
if expectedPreviewID != "" && expectedPreviewID != actualPreviewID {
fmt.Fprintf(os.Stderr, "error: repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)\n", expectedPreviewID, actualPreviewID)
return 1
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
opts.ExpectedPreviewID = actualPreviewID
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func requestRepairPlan(ctx context.Context, root, modelRef string, report repair.DiagnosticReport) (repair.RepairPlan, error) {
cfg, err := loadAIProviderConfig(root)
if err != nil {
return repair.RepairPlan{}, err
}
if strings.TrimSpace(modelRef) == "" {
modelRef = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(modelRef)
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI assistance model %q is not configured", modelRef)
}
if entry.RequiresAPIKey() && entry.APIKey() == "" {
return repair.RepairPlan{}, fmt.Errorf("AI assistance provider %q has no configured API key", entry.Name)
}
p, err := provider.New(entry.Kind, repairPlanProviderConfig(entry, cfg.NetworkProxySpec()))
if err != nil {
return repair.RepairPlan{}, err
}
safeReport, snapshotAliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
return repair.RepairPlan{}, err
}
stream, err := p.Stream(ctx, provider.Request{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: repairPlanSystemPrompt},
{Role: provider.RoleUser, Content: string(payload)},
},
Temperature: provider.TemperaturePtr(0),
MaxTokens: 2000,
})
if err != nil {
return repair.RepairPlan{}, err
}
var out strings.Builder
for chunk := range stream {
switch chunk.Type {
case provider.ChunkText:
out.WriteString(chunk.Text)
case provider.ChunkError:
if chunk.Err != nil {
return repair.RepairPlan{}, chunk.Err
}
}
}
plan, err := repair.DecodeRepairPlan([]byte(out.String()))
if err != nil {
return repair.RepairPlan{}, err
}
return resolveProviderSnapshotAliases(plan, snapshotAliases)
}
func repairPlanProviderConfig(entry *config.ProviderEntry, proxy netclient.ProxySpec) provider.Config {
return provider.Config{
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
Extra: map[string]any{
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
"thinking": entry.Thinking, "effort": config.EffectiveEffort(entry), "supported_efforts": entry.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(entry),
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
"auth_header": entry.AuthHeader, "proxy_spec": proxy,
"vision": config.EffectiveVision(entry), "vision_detail": entry.VisionDetail,
},
}
}
func loadAIProviderConfig(root string) (*config.Config, error) {
// Recovery tooling must never rewrite user configuration, so skip the
// on-disk legacy MCP tier migration that LoadForRoot performs.
if cfg, err := config.LoadForRootReadOnly(root); err == nil {
return cfg, nil
}
if snapshots, err := repair.ListConfigSnapshots(); err == nil {
for _, snapshot := range snapshots {
cfg, loadErr := config.LoadForEditReadOnlyStrict(snapshot.Path)
if loadErr == nil {
return cfg, nil
}
}
}
// Built-in recovery defaults resolve the global credential file without
// reading, migrating, or rewriting malformed user/project TOML.
return config.LoadRecoveryDefaultsForRoot(root), nil
}
const repairPlanSystemPrompt = `You are a Reasonix recovery planner. Return JSON only, matching exactly:
{"schemaVersion":1,"summary":"...","actions":[{"type":"...","scope":"","snapshotId":"","target":"","reason":"..."}]}
The user message is a diagnostic report whose findings carry only a severity, a machine code (e.g. config.invalid_toml, derived.invalid_json, update related codes), and a generic scope; pick actions from those codes.
Snapshot IDs are host-issued aliases ordered newest first; pendingUpdate=true means rollback_update is available.
Allowed actions only (return an empty actions array when no safe action applies):
- repair_config with scope global or project
- restore_snapshot with snapshotId
- rebuild_derived_state with target tabs, projects, window, zoom, or all
- rollback_update with no parameters
Never request shell commands, credential changes, session-content edits, arbitrary paths, plugin execution, or source-code changes. Prefer the smallest reversible plan supported by the diagnostics. Do not invent snapshot IDs.`
// providerSafeReport is the strict allowlist DTO sent to the AI provider.
// It deliberately carries no free-form diagnostic strings or state metadata:
// finding messages can quote config lines, URLs, commands, and permission
// rules, while snapshot and update metadata can be edited on disk. Only fixed
// diagnostic vocabulary, booleans, and host-issued snapshot aliases leave the
// machine.
type providerSafeReport struct {
Root string `json:"root"`
Network bool `json:"network"`
Snapshots []providerSafeSnapshot `json:"snapshots"`
PendingUpdate bool `json:"pendingUpdate"`
Findings []providerSafeFinding `json:"findings"`
}
type providerSafeSnapshot struct {
ID string `json:"id"`
}
type providerSafeFinding struct {
Severity string `json:"severity"`
Code string `json:"code"`
Scope string `json:"scope,omitempty"`
}
func providerSafeReportFrom(report repair.DiagnosticReport) (providerSafeReport, map[string]string) {
safe := providerSafeReport{
Root: "<project>",
Network: report.Network,
Snapshots: make([]providerSafeSnapshot, 0, len(report.Snapshots)),
PendingUpdate: report.PendingUpdate != nil,
Findings: make([]providerSafeFinding, 0, len(report.Findings)),
}
snapshotAliases := make(map[string]string, len(report.Snapshots))
for _, snapshot := range report.Snapshots {
if strings.TrimSpace(snapshot.ID) != "" {
alias := fmt.Sprintf("snapshot-%d", len(safe.Snapshots)+1)
safe.Snapshots = append(safe.Snapshots, providerSafeSnapshot{ID: alias})
snapshotAliases[alias] = snapshot.ID
}
}
for _, finding := range report.Findings {
safe.Findings = append(safe.Findings, providerSafeFinding{
Severity: providerSafeSeverity(finding.Severity),
Code: providerSafeDiagnosticCode(finding.Code),
Scope: providerSafeScope(finding.Scope),
})
}
return safe, snapshotAliases
}
func resolveProviderSnapshotAliases(plan repair.RepairPlan, aliases map[string]string) (repair.RepairPlan, error) {
resolved := plan
resolved.Actions = append([]repair.RepairPlanAction(nil), plan.Actions...)
for i := range resolved.Actions {
action := &resolved.Actions[i]
if action.Type != "restore_snapshot" {
continue
}
id, ok := aliases[action.SnapshotID]
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI repair plan referenced unknown snapshot alias %q", action.SnapshotID)
}
action.SnapshotID = id
}
return resolved, nil
}
func providerSafeSeverity(severity string) string {
switch severity {
case "error", "warning", "info":
return severity
default:
return "unknown"
}
}
func providerSafeDiagnosticCode(code string) string {
switch code {
case "config.invalid_toml", "config.load_failed",
"provider.missing_name", "provider.duplicate_name", "provider.unsupported_kind",
"provider.no_models", "provider.invalid_url", "provider.invalid_models_url",
"provider.invalid_key_name", "provider.missing_key",
"model.no_default", "model.invalid_default",
"plugin.missing_name", "plugin.duplicate_name", "plugin.missing_command",
"plugin.command_missing", "plugin.invalid_url", "plugin.invalid_type",
"permissions.conflict", "file.permissions",
"directory.unavailable", "directory.unreadable", "directory.not_directory", "directory.not_writable",
"derived.invalid_json",
"network.invalid_proxy", "network.client_failed", "network.unreachable",
"network.authentication_failed", "network.ok", "network.unexpected_status":
return code
default:
return "unknown"
}
}
// providerSafeScope generalizes a finding scope to a closed vocabulary.
// derived:* names survive because rebuild_derived_state needs the target and
// the suffixes are a fixed enum; provider:*/plugin:* carry user-chosen names
// and collapse to their category; anything unrecognized becomes "other".
func providerSafeScope(scope string) string {
scope = strings.TrimSpace(scope)
switch {
case scope == "":
return ""
case strings.HasPrefix(scope, "derived:"):
switch scope[len("derived:"):] {
case "tabs", "projects", "window", "zoom":
return scope
}
return "derived:other"
case strings.HasPrefix(scope, "provider:"):
return "provider"
case strings.HasPrefix(scope, "plugin:"):
return "plugin"
}
switch scope {
case "global", "project", "runtime", "network", "model", "provider", "plugin", "permissions",
"global config", "credential file", "Reasonix home", "Reasonix state directory", "project root":
return scope
}
return "other"
}
func printPlanPreview(plan repair.RepairPlan, previews []repair.RepairPlanPreview) {
fmt.Println("AI repair plan:", plan.Summary)
for _, preview := range previews {
fmt.Printf(" %d. %s\n", preview.Index, preview.Description)
if preview.Diff != "" {
fmt.Println(preview.Diff)
}
}
}
func confirmPlan() bool {
fmt.Print("Apply this repair plan? [y/N] ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
line = strings.ToLower(strings.TrimSpace(line))
return line == "y" || line == "yes"
}
-246
View File
@@ -1,246 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/repair"
)
func TestRepairPlanProviderConfigPreservesKimiK3Efforts(t *testing.T) {
entry := &config.ProviderEntry{
Name: "opencode-go",
Kind: "openai",
BaseURL: "https://opencode.ai/zen/go/v1",
Model: "kimi-k3",
ReasoningProtocol: config.ReasoningProtocolOpenAI,
SupportedEfforts: []string{"high", "max"},
DefaultEffort: "max",
}
got := repairPlanProviderConfig(entry, netclient.ProxySpec{})
if effort := got.Extra["effort"]; effort != "max" {
t.Fatalf("effort = %#v, want max", effort)
}
if efforts, ok := got.Extra["supported_efforts"].([]string); !ok || !reflect.DeepEqual(efforts, []string{"high", "max"}) {
t.Fatalf("supported_efforts = %#v, want [high max]", got.Extra["supported_efforts"])
}
if protocol := got.Extra["reasoning_protocol"]; protocol != config.ReasoningProtocolOpenAI {
t.Fatalf("reasoning_protocol = %#v, want openai", protocol)
}
}
// TestProviderSafeReportDropsUserControlledContent pins the outbound-privacy
// contract: the payload sent to the AI provider must carry only allowlisted
// codes, severities, and generic scopes — never diagnostic prose, which can
// quote config lines, MCP command lines, permission rules, URLs with
// credentials, emails, tokens, or absolute paths.
func TestProviderSafeReportDropsUserControlledContent(t *testing.T) {
const metadataSecret = "snapshot-metadata-secret"
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
secrets := []string{
"/Users/someone/dotfiles",
"[email protected]",
"sk-live-Abc123Secret",
"npx --api-key=sk-live-Abc123Secret mcp-server",
"bash(rm -rf *)",
"corp-internal-llm",
"https://user:[email protected]:8080",
metadataSecret,
"secret.finding.code",
"secret-severity",
snapshotID,
}
report := repair.DiagnosticReport{
GeneratedAt: metadataSecret,
Root: "/Users/someone/dotfiles",
Network: true,
Snapshots: []repair.DiagnosticSnapshot{
{ID: snapshotID, RecordedAt: metadataSecret, Version: metadataSecret},
{ID: metadataSecret, RecordedAt: metadataSecret, Version: metadataSecret},
},
PendingUpdate: &repair.DiagnosticUpdate{FromVersion: metadataSecret, ToVersion: metadataSecret},
Findings: []repair.DiagnosticFinding{
{Severity: "error", Code: "config.invalid_toml", Scope: "global",
Message: "Configuration cannot be parsed: line 3: api_key = \"sk-live-Abc123Secret\" contact [email protected]"},
{Severity: "warning", Code: "plugin.command_missing", Scope: "plugin:corp-internal-llm",
Message: `MCP command "npx --api-key=sk-live-Abc123Secret mcp-server" was not found.`},
{Severity: "warning", Code: "permissions.conflict", Scope: "permissions",
Message: `Permission rule "bash(rm -rf *)" appears in allow, deny; deny takes precedence.`},
{Severity: "error", Code: "network.invalid_proxy", Scope: "network",
Message: "Proxy configuration is invalid: parse \"https://user:[email protected]:8080\": bad"},
{Severity: "warning", Code: "provider.missing_key", Scope: "provider:corp-internal-llm",
Message: "The configured API key is missing."},
{Severity: "warning", Code: "derived.invalid_json", Scope: "derived:tabs",
Message: "Derived desktop state desktop-tabs.json is malformed."},
{Severity: "secret-severity", Code: "secret.finding.code", Scope: metadataSecret,
Message: metadataSecret, Remediation: metadataSecret},
},
}
safeReport, aliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
t.Fatal(err)
}
body := string(payload)
for _, secret := range secrets {
if strings.Contains(body, secret) {
t.Fatalf("outbound payload leaked %q:\n%s", secret, body)
}
}
for _, want := range []string{
`"root":"\u003cproject\u003e"`, // json.Marshal HTML-escapes <project>
`"code":"config.invalid_toml"`,
`"scope":"provider"`,
`"scope":"plugin"`,
`"scope":"derived:tabs"`,
`"id":"snapshot-1"`,
`"id":"snapshot-2"`,
`"pendingUpdate":true`,
`"severity":"unknown"`,
`"code":"unknown"`,
`"scope":"other"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("outbound payload missing %s:\n%s", want, body)
}
}
if aliases["snapshot-1"] != snapshotID || aliases["snapshot-2"] != metadataSecret {
t.Fatalf("snapshot alias map = %v", aliases)
}
for _, forbiddenField := range []string{"generatedAt", "recordedAt", "version", "fromVersion", "toVersion"} {
if strings.Contains(body, `"`+forbiddenField+`"`) {
t.Fatalf("outbound payload retained free-form field %q:\n%s", forbiddenField, body)
}
}
}
func TestResolveProviderSnapshotAliases(t *testing.T) {
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
plan, err := repair.DecodeRepairPlan([]byte(`{
"schemaVersion": 1,
"summary": "restore newest snapshot",
"actions": [
{"type":"repair_config","scope":"global","reason":"invalid config"},
{"type":"restore_snapshot","snapshotId":"snapshot-1","reason":"known-good config"}
]
}`))
if err != nil {
t.Fatal(err)
}
resolved, err := resolveProviderSnapshotAliases(plan, map[string]string{"snapshot-1": snapshotID})
if err != nil {
t.Fatal(err)
}
if got := resolved.Actions[1].SnapshotID; got != snapshotID {
t.Fatalf("resolved snapshot ID = %q, want %q", got, snapshotID)
}
if got := plan.Actions[1].SnapshotID; got != "snapshot-1" {
t.Fatalf("alias resolution mutated source plan: %q", got)
}
if _, err := resolveProviderSnapshotAliases(plan, nil); err == nil {
t.Fatal("unknown snapshot alias was accepted")
}
}
func writeDerivedStatePlan(t *testing.T) (string, repair.RepairPlan) {
t.Helper()
file := filepath.Join(t.TempDir(), "plan.json")
raw := []byte(`{"schemaVersion":1,"summary":"rebuild tabs","actions":[{"type":"rebuild_derived_state","target":"tabs","reason":"malformed"}]}`)
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
plan, err := repair.DecodeRepairPlan(raw)
if err != nil {
t.Fatal(err)
}
return file, plan
}
func TestApplyPlanYesRequiresConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, _ := writeDerivedStatePlan(t)
if got := runApplyPlan([]string{"--file", file, "--yes"}); got != 2 {
t.Fatalf("exit code = %d, want missing preview ID usage refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "bad-tabs" {
t.Fatalf("unbound --yes touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanRejectsStateChangedAfterPreview(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("first-state"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if err := os.WriteFile(tabs, []byte("changed-after-preview"), 0o600); err != nil {
t.Fatal(err)
}
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 1 {
t.Fatalf("exit code = %d, want stale preview refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "changed-after-preview" {
t.Fatalf("stale preview touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanAcceptsCurrentConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 0 {
t.Fatalf("exit code = %d, want confirmed preview application", got)
}
if _, err := os.Stat(tabs); !os.IsNotExist(err) {
t.Fatalf("confirmed repair did not quarantine derived state: %v", err)
}
}
func TestProviderSafeScopeClosedVocabulary(t *testing.T) {
cases := map[string]string{
"": "",
"global": "global",
"project": "project",
"derived:tabs": "derived:tabs",
"derived:zoom": "derived:zoom",
"derived:/etc/passwd": "derived:other",
"provider:corp-llm": "provider",
"plugin:internal-mcp": "plugin",
"credential file": "credential file",
"[email protected]": "other",
"/Users/someone/project": "other",
}
for in, want := range cases {
if got := providerSafeScope(in); got != want {
t.Errorf("providerSafeScope(%q) = %q, want %q", in, got, want)
}
}
}
-415
View File
@@ -1,415 +0,0 @@
// Command reasonix-guard diagnoses and repairs Reasonix without loading the
// desktop shell. It is packaged beside the desktop application so recovery
// remains available when Wails, WebView, or user configuration cannot start.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/repair"
)
var version = "dev"
func main() { os.Exit(run(os.Args[1:])) }
func run(args []string) int {
if len(args) == 0 {
return runLaunch(nil)
}
switch args[0] {
case "check":
return runCheck(args[1:], false)
case "repair":
return runCheck(args[1:], true)
case "launch":
return runLaunch(args[1:])
case "recover":
return runRecover(args[1:])
case "snapshots":
return runSnapshots(args[1:])
case "restore":
return runRestore(args[1:])
case "undo":
return runUndo(args[1:])
case "diagnose":
return runDiagnose(args[1:])
case "rebuild":
return runRebuild(args[1:])
case "assist":
return runAssist(args[1:])
case "apply-plan":
return runApplyPlan(args[1:])
case "version", "--version", "-v":
fmt.Println("reasonix-guard", version)
return 0
case "help", "--help", "-h":
usage()
return 0
default:
if isMacBundleLauncher() {
return runLaunch(append([]string{"--"}, args...))
}
fmt.Fprintln(os.Stderr, "unknown command:", args[0])
usage()
return 2
}
}
func runDiagnose(args []string) int {
fs := flag.NewFlagSet("reasonix-guard diagnose", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
network := fs.Bool("network", false, "probe provider endpoints and credentials")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.Diagnose(context.Background(), repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(report); code != 0 {
return code
}
} else {
fmt.Println("Reasonix Guard diagnostics")
if len(report.Findings) == 0 {
fmt.Println(" ok: no issues found")
}
for _, finding := range report.Findings {
fmt.Printf(" %-7s %-32s %s\n", finding.Severity, finding.Code, finding.Message)
}
}
if report.HasErrors() {
return 1
}
return 0
}
func runRebuild(args []string) int {
fs := flag.NewFlagSet("reasonix-guard rebuild", flag.ContinueOnError)
target := fs.String("target", "", "tabs|projects|window|zoom|all")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*target) == "" {
return 2
}
applied, err := repair.RebuildDerivedState(*target)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(applied)
}
for _, path := range applied {
fmt.Println("quarantined:", path)
}
return 0
}
func runSnapshots(args []string) int {
fs := flag.NewFlagSet("reasonix-guard snapshots", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
snapshots, err := repair.ListConfigSnapshots()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(snapshots)
}
for _, snap := range snapshots {
fmt.Printf("%s %s %s\n", snap.ID, snap.Version, snap.RecordedAt)
}
return 0
}
func runRestore(args []string) int {
fs := flag.NewFlagSet("reasonix-guard restore", flag.ContinueOnError)
snapshot := fs.String("snapshot", "", "snapshot id to restore")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*snapshot) == "" {
return 2
}
tx, err := repair.RestoreConfigSnapshot(*snapshot)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
fmt.Println("restored config snapshot; undo transaction:", tx.ID)
return 0
}
func runUndo(args []string) int {
fs := flag.NewFlagSet("reasonix-guard undo", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
tx, err := repair.UndoLastRepair()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(tx)
}
fmt.Println("undid repair:", tx.ID)
return 0
}
func printJSON(v any) int {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func runCheck(args []string, apply bool) int {
name := "reasonix-guard check"
if apply {
name = "reasonix-guard repair"
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: apply, IncludeProject: *includeProject})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
return 1
}
} else {
fmt.Println("Reasonix Guard")
for _, check := range report.Checks {
status := "ok"
if !check.Exists {
status = "missing"
} else if !check.Valid {
status = "invalid"
}
fmt.Printf(" %-8s %-8s %s\n", check.Scope, status, check.Path)
if check.Error != "" {
fmt.Println(" ", check.Error)
}
}
for _, action := range report.Applied {
fmt.Println(" applied:", action)
}
}
for _, check := range report.Checks {
if check.Exists && !check.Valid {
return 1
}
}
return 0
}
// failedInstallBlocksLaunch decides whether startup must stop after a failed
// RecoverFailedInstall. The failure marker means the installer may have left
// the release unit partially replaced, so the binaries cannot be presumed
// coherent until a rollback completes: any error with the restore unfinished
// fails closed. MixedInstall alone is not the signal — a rollback that failed
// before touching anything (e.g. while staging) reports MixedInstall=false yet
// leaves the installer's half-written unit in place. Only a completed restore
// (RolledBack, with just the marker cleanup failing) is safe to launch.
func failedInstallBlocksLaunch(result repair.UpdateRollbackResult, err error) bool {
return err != nil && !result.RolledBack
}
func runLaunch(args []string) int {
fs := flag.NewFlagSet("reasonix-guard launch", flag.ContinueOnError)
app := fs.String("app", "", "desktop executable path")
safeMode := fs.Bool("safe-mode", false, "force Safe Mode")
detach := fs.Bool("detach", packagedDetachedLauncher(), "start the desktop and exit the launcher")
if err := fs.Parse(args); err != nil {
return 2
}
// An update helper that watched the installer fail records a marker (it
// cannot roll back itself from outside the install directory). Restore the
// previous release unit immediately instead of waiting for a crash loop.
if result, failure, err := repair.RecoverFailedInstall(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback after failed install failed:", err)
if failedInstallBlocksLaunch(result, err) {
fmt.Fprintln(os.Stderr, "error: the failed installer may have left a mix of two releases and the rollback did not complete; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
} else if failure != nil && result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after the %s installer failed.\n", result.ToVersion, result.FromVersion)
}
forceSafeMode := false
if result, err := repair.ReconcilePendingUpdate(version); err != nil {
if !errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
fmt.Fprintln(os.Stderr, "previous update recovery failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation may mix two releases; refusing to start. Re-run reasonix-guard to retry recovery, or reinstall Reasonix.")
return 1
}
forceSafeMode = true
}
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s before launch after an unfinished %s update.\n", result.FromVersion, result.ToVersion)
} else if result.Cleared {
fmt.Fprintf(os.Stderr, "Reasonix Guard cleared an abandoned %s update before launch.\n", result.ToVersion)
}
tracker := repair.NewStartupTracker("")
useSafeMode := *safeMode || forceSafeMode
if !useSafeMode && tracker.SafeModeRecommended() {
if result, err := repair.RollbackPendingUpdate(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation now mixes two releases; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
// The compensated install is still a coherent (new-version) release
// unit, so a Safe Mode boot is a sound fallback while the pending
// transaction stays on disk for the next rollback attempt.
useSafeMode = true
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after %s failed to start.\n", result.ToVersion, result.FromVersion)
_ = tracker.MarkClean()
} else {
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: ".", Apply: true}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
}
useSafeMode = true
case recoverySafeMode:
useSafeMode = true
case recoveryQuit:
return 0
}
}
}
path := strings.TrimSpace(*app)
if path == "" {
path = siblingDesktopExecutable()
}
if path == "" {
fmt.Fprintln(os.Stderr, "error: cannot locate reasonix-desktop; pass --app PATH")
return 1
}
childArgs := append([]string(nil), fs.Args()...)
if useSafeMode {
childArgs = append([]string{"--safe-mode"}, childArgs...)
}
cmd := exec.Command(path, childArgs...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if *detach {
if err := cmd.Start(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
if err := cmd.Run(); err != nil {
if exit, ok := err.(*exec.ExitError); ok {
return exit.ExitCode()
}
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func packagedDetachedLauncher() bool {
if runtime.GOOS != "windows" {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
name := strings.ToLower(filepath.Base(exe))
return name == "reasonix-launcher.exe" || name == "reasonix.exe"
}
func runRecover(args []string) int {
fs := flag.NewFlagSet("reasonix-guard recover", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
app := fs.String("app", "", "desktop executable path")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: true, IncludeProject: *includeProject}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
return 1
}
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
case recoverySafeMode:
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
default:
return 0
}
}
func siblingDesktopExecutable() string {
exe, err := os.Executable()
if err != nil {
return ""
}
name := "reasonix-desktop"
if runtime.GOOS == "windows" {
name += ".exe"
}
candidates := []string{filepath.Join(filepath.Dir(exe), name)}
if runtime.GOOS == "darwin" {
candidates = append(candidates, filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "MacOS", name)))
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func isMacBundleLauncher() bool {
if runtime.GOOS != "darwin" {
return false
}
exe, err := os.Executable()
return err == nil && strings.Contains(filepath.ToSlash(exe), ".app/Contents/MacOS/")
}
func usage() {
fmt.Println("usage: reasonix-guard <check|repair|diagnose|rebuild|assist|apply-plan|launch|recover|snapshots|restore|undo> [options]")
}
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"reasonix/internal/repair"
)
func TestCheckReportsInvalidProjectConfig(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[broken\n"), 0o600); err != nil {
t.Fatal(err)
}
if code := run([]string{"check", "--root", root, "--json"}); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
}
func TestFailedInstallBlocksLaunch(t *testing.T) {
cases := []struct {
name string
result repair.UpdateRollbackResult
err error
want bool
}{
{name: "no error never blocks", result: repair.UpdateRollbackResult{}, err: nil, want: false},
{name: "incomplete rollback fails closed", result: repair.UpdateRollbackResult{}, err: errors.New("stage failed"), want: true},
{name: "uncompensated rollback fails closed", result: repair.UpdateRollbackResult{MixedInstall: true}, err: errors.New("restore failed"), want: true},
{name: "completed restore with marker cleanup error launches", result: repair.UpdateRollbackResult{RolledBack: true}, err: errors.New("remove marker: permission denied"), want: false},
}
for _, tc := range cases {
if got := failedInstallBlocksLaunch(tc.result, tc.err); got != tc.want {
t.Errorf("%s: failedInstallBlocksLaunch = %v, want %v", tc.name, got, tc.want)
}
}
}
-9
View File
@@ -1,9 +0,0 @@
package main
type recoveryChoice int
const (
recoveryQuit recoveryChoice = iota
recoverySafeMode
recoveryRepair
)
-24
View File
@@ -1,24 +0,0 @@
//go:build darwin
package main
import (
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
script := `button returned of (display dialog "Reasonix failed to start repeatedly. You can repair damaged configuration or start with external integrations disabled." with title "Reasonix Recovery" buttons {"Quit", "Safe Mode", "Repair and Start"} default button "Repair and Start" with icon caution)`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Repair and Start":
return recoveryRepair
case "Safe Mode":
return recoverySafeMode
default:
return recoveryQuit
}
}
-36
View File
@@ -1,36 +0,0 @@
//go:build linux
package main
import (
"errors"
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
if path, err := exec.LookPath("zenity"); err == nil {
cmd := exec.Command(path, "--question", "--title=Reasonix Recovery",
"--text=Reasonix failed to start repeatedly.",
"--ok-label=Repair and Start", "--cancel-label=Safe Mode", "--extra-button=Quit")
out, err := cmd.Output()
if strings.TrimSpace(string(out)) == "Quit" {
return recoveryQuit
}
if err == nil {
return recoveryRepair
}
return recoverySafeMode
}
if path, err := exec.LookPath("kdialog"); err == nil {
err := exec.Command(path, "--warningyesnocancel", "Reasonix failed to start repeatedly. Repair configuration and start?", "--title", "Reasonix Recovery").Run()
if err == nil {
return recoveryRepair
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 2 {
return recoveryQuit
}
}
return recoverySafeMode
}
-5
View File
@@ -1,5 +0,0 @@
//go:build !darwin && !windows && !linux
package main
func nativeRecoveryChoice() recoveryChoice { return recoverySafeMode }
-27
View File
@@ -1,27 +0,0 @@
//go:build windows
package main
import (
"os/exec"
"strings"
"syscall"
)
func nativeRecoveryChoice() recoveryChoice {
script := `Add-Type -AssemblyName PresentationFramework; $r=[System.Windows.MessageBox]::Show('Reasonix failed to start repeatedly. Yes: repair configuration and start. No: start in Safe Mode. Cancel: quit.','Reasonix Recovery','YesNoCancel','Warning'); Write-Output $r`
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
out, err := cmd.Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Yes":
return recoveryRepair
case "No":
return recoverySafeMode
default:
return recoveryQuit
}
}
+32
View File
@@ -0,0 +1,32 @@
// Command reasonix-launcher is the permanent thin desktop entry point for the
// versioned install layout (v1.20+). It reads InstallRoot/current.json, starts
// the active reasonix-desktop binary, and exits. It never counts crashes,
// chooses previous versions, or enters a product safe mode.
//
// Migration compatibility: old shortcuts may still pass "launch", "--detach",
// or "--safe-mode". Those tokens are stripped and ignored.
package main
import (
"os"
"reasonix/internal/desktoplauncher"
)
var version = "dev"
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
return desktoplauncher.Run(args, version)
}
func resolveDesktopPath(installRoot string) (string, error) {
return desktoplauncher.ResolveDesktopPath(installRoot)
}
func stripLegacyLaunchArgs(args []string) []string {
return desktoplauncher.StripLegacyLaunchArgs(args)
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/installlayout"
)
func TestStripLegacyLaunchArgs(t *testing.T) {
got := stripLegacyLaunchArgs([]string{
"launch", "--detach", "--safe-mode", "--app", `C:\Old\reasonix-desktop.exe`, "--foo", "bar",
})
want := []string{"--foo", "bar"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("got %#v want %#v", got, want)
}
got = stripLegacyLaunchArgs([]string{"--app=ignored", "keep"})
if len(got) != 1 || got[0] != "keep" {
t.Fatalf("got %#v", got)
}
got = stripLegacyLaunchArgs([]string{"--", "--safe-mode", "x"})
if len(got) != 3 || got[0] != "--" {
t.Fatalf("separator not preserved: %#v", got)
}
}
func TestResolveDesktopPathUsesCurrentJSON(t *testing.T) {
root := t.TempDir()
src := t.TempDir()
version := "v1.20.0"
members := make([]installlayout.Member, 0, 3)
for _, name := range installlayout.AllowedVersionMembers() {
path := filepath.Join(src, name)
if err := os.WriteFile(path, []byte("bin-"+name), 0o755); err != nil {
t.Fatal(err)
}
members = append(members, installlayout.Member{Name: name, Path: path})
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: root,
Version: version,
RequestID: "launcher-test",
Members: members,
}); err != nil {
t.Fatal(err)
}
path, err := resolveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(root, "versions", version, installlayout.DesktopBinaryName())
if path != want {
t.Fatalf("path=%s want=%s", path, want)
}
}
func TestResolveDesktopPathFallsBackToFlatSibling(t *testing.T) {
root := t.TempDir()
flat := filepath.Join(root, installlayout.DesktopBinaryName())
if err := os.WriteFile(flat, []byte("flat"), 0o755); err != nil {
t.Fatal(err)
}
path, err := resolveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
if path != flat {
t.Fatalf("path=%s want=%s", path, flat)
}
}
func TestResolveDesktopPathRejectsCorruptPointer(t *testing.T) {
root := t.TempDir()
// Write a current.json that fails schema validation.
if err := os.WriteFile(filepath.Join(root, installlayout.CurrentFileName), []byte(`{"schemaVersion":99}`), 0o644); err != nil {
t.Fatal(err)
}
// Even with a flat sibling, a corrupt pointer must fail closed. Falling back
// would silently run a stale executable after a partially observed update.
if err := os.WriteFile(filepath.Join(root, installlayout.DesktopBinaryName()), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if path, err := resolveDesktopPath(root); err == nil {
t.Fatalf("corrupt current.json resolved stale flat desktop %q", path)
}
}
+525
View File
@@ -0,0 +1,525 @@
// Command reasonix-legacy-migrator is the one-shot flat→versioned install
// migrator used by v1.20+ packaging. In compatibility payloads it is still
// named reasonix-guard(.exe) so 1.18–1.19.1 updaters can hand off; the source
// and behavior are intentionally separate from the old Guard recovery product.
//
// It only: acquires a migration lock, validates the flat release unit, creates
// versions/<version>, writes current.json, rewrites entry points, starts the
// thin launcher, and self-deletes when possible. It never chooses safe mode,
// counts crashes, or auto-rolls back.
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"reasonix/internal/desktoplauncher"
"reasonix/internal/fileutil"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
var version = "dev"
const migrationLockName = ".reasonix-layout-migrate.lock"
func main() {
if runningAsLauncher() {
os.Exit(desktoplauncher.Run(os.Args[1:], version))
}
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
if len(args) == 1 {
switch args[0] {
case "version", "--version", "-v":
fmt.Println("reasonix-legacy-migrator", version)
return 0
case "help", "--help", "-h":
fmt.Println("usage: reasonix-legacy-migrator [--install-root PATH] [--version VERSION] [--activate-staging PATH]")
return 0
}
}
installRoot := ""
activeVersion := strings.TrimSpace(version)
activateStaging := ""
relaunch := true
for i := 0; i < len(args); i++ {
switch args[i] {
case "--install-root":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --install-root requires a path")
return 2
}
i++
installRoot = args[i]
case "--version":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --version requires a value")
return 2
}
i++
activeVersion = args[i]
case "--activate-staging":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --activate-staging requires a path")
return 2
}
i++
activateStaging = args[i]
case "launch", "--detach", "--safe-mode":
// Accept legacy argv from old shortcuts; no product behavior.
continue
case "--no-relaunch":
relaunch = false
continue
case "--app":
if i+1 < len(args) {
i++
}
continue
}
}
if installRoot == "" {
exe, err := os.Executable()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
installRoot = filepath.Dir(exe)
}
installRoot = filepath.Clean(installRoot)
if activateStaging != "" {
activeVersion, err := normalizeActiveVersion(activeVersion)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if err := activateInstallerStaging(installRoot, activeVersion, activateStaging); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if relaunch {
_ = startLauncher(installRoot)
}
return 0
}
if err := migrateWithRelaunch(installRoot, activeVersion, relaunch); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func migrate(installRoot, activeVersion string) error {
return migrateWithRelaunch(installRoot, activeVersion, true)
}
func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error {
var err error
activeVersion, err = normalizeActiveVersion(activeVersion)
if err != nil {
return err
}
unlock, err := acquireMigrationLock(installRoot)
if err != nil {
return err
}
defer unlock()
// Pointer already committed: only cleanup, never overwrite the active version.
// A present but corrupt pointer is fatal; treating it like an absent pointer
// could overwrite evidence of a partial activation and migrate stale files.
currentPath := filepath.Join(installRoot, installlayout.CurrentFileName)
if _, statErr := os.Lstat(currentPath); statErr == nil {
ptr, err := installlayout.ReadCurrent(installRoot)
if err != nil {
return err
}
if err := finalizeLegacyPendingUpdate(installRoot, ptr.ActiveVersion); err != nil {
return err
}
_ = cleanupLegacyFlatFiles(installRoot)
_ = archiveInstallRootLegacyMarkers(installRoot)
if relaunch {
_ = startLauncher(installRoot) // best-effort; layout already committed
selfDelete()
}
return nil
} else if !os.IsNotExist(statErr) {
return fmt.Errorf("migrate: inspect current.json: %w", statErr)
}
desktopName := installlayout.DesktopBinaryName()
cliName := installlayout.CLIBinaryName()
helperName := installlayout.UpdateHelperBinaryName()
flatDesktop := filepath.Join(installRoot, desktopName)
if _, err := os.Lstat(flatDesktop); err != nil {
return fmt.Errorf("migrate: flat desktop binary missing: %w", err)
}
members := []installlayout.Member{
{Name: desktopName, Path: flatDesktop},
}
if p := optionalRegular(filepath.Join(installRoot, cliName)); p != "" {
members = append(members, installlayout.Member{Name: cliName, Path: p})
} else {
// CLI may be absent on some portable trees; synthesize from desktop only
// is not allowed — require the whitelist. Prefer copying desktop as a
// last-resort placeholder is forbidden; fail closed.
return fmt.Errorf("migrate: flat CLI binary %s is required", cliName)
}
requiredNames := []string{desktopName, cliName}
if runtime.GOOS == "windows" {
requiredNames = append(requiredNames, helperName)
if p := optionalRegular(filepath.Join(installRoot, helperName)); p != "" {
members = append(members, installlayout.Member{Name: helperName, Path: p})
} else {
return fmt.Errorf("migrate: flat update helper %s is required", helperName)
}
}
// Old Linux updaters only publish desktop, CLI, and the compatibility
// migrator. On Unix the migrator is also a thin-launcher multicall binary,
// so it can create the permanent entry point without another payload member.
if err := ensureLauncherEntry(installRoot); err != nil {
return err
}
// v1.18-v1.19 helpers leave an installed transaction awaiting Guard health.
// The flat release unit is still intact here, so commit that exact verified
// transaction before moving its files into the version directory.
if err := finalizeLegacyPendingUpdate(installRoot, activeVersion); err != nil {
return err
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: activeVersion,
RequestID: "legacy-migrate",
Members: members,
RequiredNames: requiredNames,
}); err != nil {
return fmt.Errorf("migrate: activate versioned layout: %w", err)
}
_ = cleanupLegacyFlatFiles(installRoot)
_ = archiveInstallRootLegacyMarkers(installRoot)
if relaunch {
// Launcher start is best-effort: layout activation is the commit point.
if err := startLauncher(installRoot); err != nil {
fmt.Fprintln(os.Stderr, "warning: start launcher after migrate:", err)
}
// Unix can unlink the running migrator. On Windows the parent thin launcher
// removes it after this process exits.
selfDelete()
}
return nil
}
func normalizeActiveVersion(activeVersion string) (string, error) {
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
// Accept bare "dev" builds in development only.
if activeVersion == "" || activeVersion == "dev" {
activeVersion = "v0.0.0-dev"
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
return "", err
}
} else if !strings.HasPrefix(activeVersion, "v") {
activeVersion = "v" + activeVersion
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
return "", err
}
} else {
return "", err
}
}
return activeVersion, nil
}
func activateInstallerStaging(installRoot, activeVersion, stagingRoot string) error {
installRoot = filepath.Clean(strings.TrimSpace(installRoot))
stagingRoot = filepath.Clean(strings.TrimSpace(stagingRoot))
if !pathWithinInstallRoot(installRoot, stagingRoot) || stagingRoot == installRoot {
return fmt.Errorf("installer staging must be inside the install root")
}
info, err := os.Lstat(stagingRoot)
if err != nil {
return fmt.Errorf("inspect installer staging: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("installer staging must be a real directory")
}
desktopName := installlayout.DesktopBinaryName()
cliName := installlayout.CLIBinaryName()
requiredNames := []string{desktopName, cliName}
if runtime.GOOS == "windows" {
requiredNames = append(requiredNames, installlayout.UpdateHelperBinaryName())
}
members := make([]installlayout.Member, 0, len(requiredNames))
for _, name := range requiredNames {
members = append(members, installlayout.Member{Name: name, Path: filepath.Join(stagingRoot, name)})
}
launcherName := installlayout.LauncherBinaryName()
launcherSource := filepath.Join(stagingRoot, launcherName)
rootMembers := []installlayout.Member{
{Name: launcherName, Path: launcherSource},
{Name: cliName, Path: filepath.Join(stagingRoot, cliName)},
}
requiredRootNames := []string{launcherName, cliName}
if alias := installlayout.PortableAliasName(); alias != "" {
rootMembers = append(rootMembers, installlayout.Member{Name: alias, Path: launcherSource})
requiredRootNames = append(requiredRootNames, alias)
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: activeVersion,
RequestID: "signed-installer-" + activeVersion,
Members: members,
RequiredNames: requiredNames,
RootMembers: rootMembers,
RequiredRootNames: requiredRootNames,
}); err != nil {
return fmt.Errorf("activate signed installer staging: %w", err)
}
return nil
}
func runningAsLauncher() bool {
exe, err := os.Executable()
if err != nil {
return false
}
return strings.EqualFold(filepath.Base(exe), installlayout.LauncherBinaryName())
}
func optionalRegular(path string) string {
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return ""
}
return path
}
func acquireMigrationLock(installRoot string) (func(), error) {
if err := os.MkdirAll(installRoot, 0o755); err != nil {
return nil, err
}
path := filepath.Join(installRoot, migrationLockName)
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
if os.IsExist(err) {
// Stale lock from a dead migrator: if older than 10 minutes, steal it.
info, statErr := os.Stat(path)
if statErr == nil && time.Since(info.ModTime()) > 10*time.Minute {
_ = os.Remove(path)
f, err = os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
}
}
if err != nil {
return nil, fmt.Errorf("migrate: acquire lock: %w", err)
}
}
_, _ = fmt.Fprintf(f, "pid=%d\nversion=%s\n", os.Getpid(), version)
_ = f.Close()
return func() { _ = os.Remove(path) }, nil
}
func ensureLauncherEntry(installRoot string) error {
launcher := installlayout.LauncherBinaryName()
path := filepath.Join(installRoot, launcher)
if info, err := os.Lstat(path); err == nil {
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("migrate: thin launcher %s is not a regular file", launcher)
}
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()
if err != nil {
return fmt.Errorf("migrate: resolve migrator executable: %w", err)
}
info, err := os.Lstat(exe)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("migrate: migrator executable is not a regular file")
}
body, err := os.ReadFile(exe)
if err != nil {
return fmt.Errorf("migrate: read launcher source: %w", err)
}
if err := fileutil.AtomicWriteFile(path, body, 0o755); err != nil {
return fmt.Errorf("migrate: install thin launcher: %w", err)
}
return nil
}
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
tx, err := repair.ReadPendingUpdate()
if os.IsNotExist(err) {
return nil
}
if err != nil {
return 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)
}
if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
return 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")
}
}
id := repair.UpdateTransactionID(tx)
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
}
if current, err := repair.ReadPendingUpdate(); err == nil {
if repair.UpdateTransactionID(current) == id {
return fmt.Errorf("migrate: legacy pending update remained after commit")
}
return fmt.Errorf("migrate: pending update changed during commit")
} else if !os.IsNotExist(err) {
return fmt.Errorf("migrate: verify legacy pending update commit: %w", err)
}
return nil
}
func pathWithinInstallRoot(installRoot, target string) bool {
root := filepath.Clean(strings.TrimSpace(installRoot))
target = filepath.Clean(strings.TrimSpace(target))
if root == "" || target == "" || !filepath.IsAbs(root) || !filepath.IsAbs(target) {
return false
}
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
return false
}
return true
}
func cleanupLegacyFlatFiles(installRoot string) error {
// Remove flat desktop/CLI/helper/guard sidecars after the version tree is
// active. Never delete the thin launcher or current.json.
names := []string{
installlayout.DesktopBinaryName(),
installlayout.CLIBinaryName(),
installlayout.UpdateHelperBinaryName(),
}
if runtime.GOOS == "windows" {
names = append(names, "reasonix-guard.exe")
} else {
names = append(names, "reasonix-guard")
}
for _, name := range names {
path := filepath.Join(installRoot, name)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
_ = os.Remove(path)
}
return nil
}
func archiveInstallRootLegacyMarkers(installRoot string) error {
// Very old portable builds wrote markers beside the binary. Current repair
// state under Reasonix home is finalized through repair transaction APIs.
markers := []string{
"pending-update.json",
"startup-state.json",
}
ts := time.Now().UTC().Format("20060102T150405Z")
destRoot := filepath.Join(installRoot, "repair", "legacy-v1", ts)
moved := false
for _, name := range markers {
src := filepath.Join(installRoot, name)
if _, err := os.Lstat(src); err != nil {
continue
}
if err := os.MkdirAll(destRoot, 0o755); err != nil {
return err
}
dest := filepath.Join(destRoot, name)
if err := os.Rename(src, dest); err != nil {
// Cross-device: copy+remove best effort.
data, readErr := os.ReadFile(src)
if readErr != nil {
continue
}
if writeErr := fileutil.AtomicWriteFile(dest, data, 0o600); writeErr == nil {
_ = os.Remove(src)
}
}
moved = true
}
if moved {
meta, _ := json.MarshalIndent(map[string]any{
"migratedAt": time.Now().UTC().Format(time.RFC3339),
"from": "flat-v1",
"to": installlayout.InstallLayoutVersionedV1,
}, "", " ")
_ = fileutil.AtomicWriteFile(filepath.Join(destRoot, "migration.json"), append(meta, '\n'), 0o644)
}
return nil
}
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")
}
}
if _, err := os.Lstat(path); err != nil {
// Migration succeeded; user can start manually.
return nil
}
cmd := exec.Command(path)
cmd.Dir = installRoot
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if runtime.GOOS == "windows" {
return cmd.Start()
}
return cmd.Start()
}
func selfDelete() {
exe, err := os.Executable()
if err != nil {
return
}
// Do not delete if we are not named like a compatibility guard payload.
base := strings.ToLower(filepath.Base(exe))
if base != "reasonix-guard" && base != "reasonix-guard.exe" {
return
}
_ = os.Remove(exe)
}
+162
View File
@@ -0,0 +1,162 @@
package main
import (
"os"
"path/filepath"
"runtime"
"testing"
"reasonix/internal/installlayout"
)
func installerVersionNames() []string {
names := []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()}
if runtime.GOOS == "windows" {
names = append(names, installlayout.UpdateHelperBinaryName())
}
return names
}
func writeInstallerStaging(t *testing.T, root, label string, includeLauncher bool) string {
t.Helper()
staging := filepath.Join(root, "versions", ".installer-"+label)
if err := os.MkdirAll(staging, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range installerVersionNames() {
if err := os.WriteFile(filepath.Join(staging, name), []byte(label+"-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
if includeLauncher {
name := installlayout.LauncherBinaryName()
if err := os.WriteFile(filepath.Join(staging, name), []byte(label+"-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
return staging
}
func TestMigrateFlatInstallToVersioned(t *testing.T) {
root := t.TempDir()
// Flat release unit.
for _, name := range installlayout.AllowedVersionMembers() {
if err := os.WriteFile(filepath.Join(root, name), []byte("flat-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
// 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)
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher.exe"), []byte("launcher"), 0o644)
_ = os.WriteFile(filepath.Join(root, "Reasonix.exe"), []byte("launcher"), 0o644)
// Marker files that must be archived, not deleted silently without record.
_ = os.WriteFile(filepath.Join(root, "pending-update.json"), []byte(`{"pending":true}`), 0o644)
_ = os.WriteFile(filepath.Join(root, "startup-state.json"), []byte(`{}`), 0o644)
if err := migrate(root, "v1.20.0"); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(root) {
t.Fatal("current.json missing after migration")
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
// Flat desktop must be cleaned up after successful activation.
if _, err := os.Stat(filepath.Join(root, installlayout.DesktopBinaryName())); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed after migration")
}
// Active desktop lives under versions/.
if _, err := installlayout.ActiveDesktopPath(root); err != nil {
t.Fatal(err)
}
// Second run is idempotent cleanup-only.
if err := migrate(root, "v1.20.0"); err != nil {
t.Fatalf("idempotent re-run: %v", err)
}
ptr2, err := installlayout.ReadCurrent(root)
if err != nil || ptr2.ActiveVersion != "v1.20.0" {
t.Fatalf("re-run overwrote active version: %+v", ptr2)
}
}
func TestMigrateRefusesWithoutFlatUnit(t *testing.T) {
root := t.TempDir()
if err := migrate(root, "v1.20.0"); err == nil {
t.Fatal("expected failure without flat desktop")
}
}
func TestMigrateRefusesCorruptCurrentPointerWithoutOverwritingIt(t *testing.T) {
root := t.TempDir()
corrupt := []byte(`{"schemaVersion":99}`)
current := filepath.Join(root, installlayout.CurrentFileName)
if err := os.WriteFile(current, corrupt, 0o644); err != nil {
t.Fatal(err)
}
for _, name := range installlayout.AllowedVersionMembers() {
if err := os.WriteFile(filepath.Join(root, name), []byte("stale-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
if err := migrate(root, "v1.20.0"); err == nil {
t.Fatal("corrupt current.json was treated as an absent pointer")
}
got, err := os.ReadFile(current)
if err != nil {
t.Fatal(err)
}
if string(got) != string(corrupt) {
t.Fatalf("corrupt pointer was overwritten: %q", got)
}
}
func TestActivateInstallerStagingPublishesVersionAndRootEntries(t *testing.T) {
root := t.TempDir()
staging := writeInstallerStaging(t, root, "new", true)
if err := activateInstallerStaging(root, "v1.20.0", staging); err != nil {
t.Fatal(err)
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
activeDesktop, err := installlayout.ActiveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
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()} {
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)
}
}
if alias := installlayout.PortableAliasName(); alias != "" {
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)
}
}
}
func TestActivateInstallerStagingFailureKeepsPreviousPointer(t *testing.T) {
root := t.TempDir()
oldStaging := writeInstallerStaging(t, root, "old", true)
if err := activateInstallerStaging(root, "v1.19.1", oldStaging); err != nil {
t.Fatal(err)
}
broken := writeInstallerStaging(t, root, "broken", false)
if err := activateInstallerStaging(root, "v1.20.0", broken); err == nil {
t.Fatal("staging without a launcher was activated")
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.19.1" {
t.Fatalf("previous pointer changed after failed activation: %+v err=%v", ptr, err)
}
if err := activateInstallerStaging(root, "v1.20.0", t.TempDir()); err == nil {
t.Fatal("staging outside the install root was accepted")
}
}
+4 -4
View File
@@ -49,7 +49,7 @@ func main() {
}
}
// --- JSON-RPC framing ---
// JSON-RPC framing
type request struct {
JSONRPC string `json:"jsonrpc"`
@@ -151,7 +151,7 @@ func handleLine(line []byte, w *bufio.Writer) error {
return nil
}
// --- tools ---
// tools
// toolDef is one exposed tool: its advertised metadata plus the handler. run
// returns the text result, or an error which becomes an isError tool result the
@@ -257,7 +257,7 @@ func textResult(text string, isError bool) map[string]any {
}
}
// --- prompts ---
// prompts
// promptList advertises the server's prompts. They surface in reasonix as
// /mcp__example__<name> slash commands.
@@ -297,7 +297,7 @@ func getPrompt(params json.RawMessage) (any, *rpcError) {
}, nil
}
// --- resources ---
// resources
// resourceContents is the demo resource store, keyed by uri.
var resourceContents = map[string]string{
+17 -3
View File
@@ -16,10 +16,24 @@ import (
_ "reasonix/internal/tool/builtin"
)
// version is injected at build time via -ldflags "-X main.version=...".
var version = "dev"
// Build identity injected via -ldflags (see Makefile). version remains the
// single-line contract for `reasonix --version`; gitCommit/buildTimeUTC feed
// `reasonix version --verbose` / `--json` without embedding config paths.
var (
version = "dev"
gitCommit = ""
buildTimeUTC = ""
)
var runCLI = cli.Run
// runCLI is the CLI entry; tests may stub it. Production routes through
// RunWithBuildInfo so ldflags metadata is available to version --verbose/--json.
var runCLI = func(args []string, buildVersion string) int {
return cli.RunWithBuildInfo(args, cli.BuildInfo{
Version: buildVersion,
GitCommit: gitCommit,
BuildTimeUTC: buildTimeUTC,
})
}
func main() {
os.Exit(runWithCrashCapture(os.Args[1:], version))
+5 -2
View File
@@ -29,8 +29,11 @@ func TestRunWithCrashCaptureRecordsAndReraises(t *testing.T) {
if err != nil || len(reports) != 1 {
t.Fatalf("captured reports=%d err=%v", len(reports), err)
}
if got := reports[0].Report.TopFrame; !strings.Contains(got, "main_test.go:") {
t.Fatalf("top frame = %q, want panic call site in main_test.go", got)
// Token-like function and file basenames may be redacted by the current
// privacy filter. Keep asserting that the top frame is the test call site
// without requiring its pre-redaction basename.
if got := reports[0].Report.TopFrame; !strings.Contains(got, "_test.go:") {
t.Fatalf("top frame = %q, want sanitized panic call site in a test file", got)
}
preview, err := crashreport.Preview(reports[0].Report)
if err != nil {
-1
View File
@@ -28,7 +28,6 @@ func TestTopLevelSignPathWorkflowCallGraph(t *testing.T) {
}
want := []string{
".github/workflows/release-desktop.yml",
".github/workflows/release-preview.yml",
".github/workflows/release-stable.yml",
}
if !reflect.DeepEqual(got, want) {
+1
View File
@@ -30,6 +30,7 @@
!/build/linux/nfpm.yaml
!/build/linux/reasonix.desktop
!/build/linux/io.reasonix.desktop.update.policy
!/build/linux/postinstall.sh
!/build/linux/icons
!/build/linux/icons/**
+13 -12
View File
@@ -154,22 +154,23 @@ not depend on homepage badge semantics. Self-update behavior by platform:
in-place swap would be blocked by Gatekeeper; the banner links to the download
page for a manual update instead.
### Unsigned builds — first launch
### Code signing — first launch
There are no Apple/Windows code-signing certificates yet, so a downloaded build
trips the OS gatekeepers on first run:
- **macOS** — open `Reasonix-darwin-universal.dmg` and drag Reasonix into
Applications. Gatekeeper may then report the app "is damaged" or is from an
unidentified developer; clear the quarantine attribute and open it:
- **Windows** — stable builds carry an Authenticode signature (SignPath, approved
per release; `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*.
- **macOS** — still unsigned and un-notarized. Open
`Reasonix-darwin-universal.dmg`, drag Reasonix into Applications, then clear the
quarantine attribute when Gatekeeper reports the app "is damaged" or is from an
unidentified developer:
```sh
xattr -dr com.apple.quarantine /Applications/Reasonix.app
```
- **Windows** — SmartScreen shows "Windows protected your PC". Click *More info →
Run anyway*.
When Developer ID / Authenticode certificates are added, the release workflow's
`HAS_APPLE_CERT` gate flips to the signed path and these steps go away.
This is also why macOS has no in-place self-update: the swap would be blocked.
Adding a Developer ID certificate flips the release workflow's `HAS_APPLE_CERT`
gate to the signed path and removes both.
### Verifying a download
+1213 -819
View File
File diff suppressed because it is too large. Load diff
+5 -7
View File
@@ -124,12 +124,10 @@ func TestScheduleSnapshotCoalesces(t *testing.T) {
_ = a
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for range 64 {
wg.Go(func() {
tab.sink.Emit(event.Event{Kind: event.TurnDone})
}()
})
}
wg.Wait()
@@ -335,11 +333,11 @@ func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...any) {
runtimeEvents <- runtimeEventEnvelope{
ctx: ctx,
name: name,
payload: append([]interface{}(nil), payload...),
payload: append([]any(nil), payload...),
}
}
tab := &WorkspaceTab{
+1 -1
View File
@@ -35,7 +35,7 @@ func TestCarriedRebuildsKeepOneSession(t *testing.T) {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
for range 5 {
prevPath := ctrl.SessionPath()
carried := ctrl.History()
ctrl.Close()
+205 -55
View File
@@ -13,6 +13,7 @@ import (
"os/exec"
"path/filepath"
"reflect"
"slices"
"strconv"
"strings"
"sync"
@@ -24,6 +25,7 @@ import (
"reasonix/internal/billing"
"reasonix/internal/boot"
"reasonix/internal/bot"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
@@ -372,9 +374,12 @@ func (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {
return r.ask(ctx)
}
func TestCommandsIncludesEffortNotThinking(t *testing.T) {
func TestCommandsIncludesDocsAndEffortNotThinking(t *testing.T) {
app := NewApp()
cmds := app.Commands()
if !hasCommand(cmds, "docs") {
t.Fatalf("Commands() should include docs: %+v", cmds)
}
if !hasCommand(cmds, "effort") {
t.Fatalf("Commands() should include effort: %+v", cmds)
}
@@ -383,6 +388,128 @@ func TestCommandsIncludesEffortNotThinking(t *testing.T) {
}
}
func TestCommandsDocsShowsOnlyRuntimeWinner(t *testing.T) {
tests := []struct {
name string
commands []command.Command
skills []skill.Skill
wantKind string
}{
{
name: "custom command shadows builtin",
commands: []command.Command{{Name: "docs", Description: "custom docs"}},
wantKind: "custom",
},
{
name: "skill shadows builtin",
skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
wantKind: "skill",
},
{
name: "custom command shadows skill and builtin",
commands: []command.Command{{Name: "docs", Description: "custom docs"}},
skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
wantKind: "custom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := control.New(control.Options{Commands: tt.commands, Skills: tt.skills})
defer ctrl.Close()
app := NewApp()
app.setTestCtrl(ctrl, "")
var docs []CommandInfo
for _, cmd := range app.Commands() {
if cmd.Name == "docs" {
docs = append(docs, cmd)
}
}
if len(docs) != 1 || docs[0].Kind != tt.wantKind {
t.Fatalf("docs commands = %+v, want one %s entry", docs, tt.wantKind)
}
if fallback, ok := commandInfoByName(app.Commands(), control.ReasonixDocsSlashName); !ok || fallback.Kind != "builtin" {
t.Fatalf("qualified docs fallback = %+v, %v; want built-in", fallback, ok)
}
})
}
}
func commandInfoByName(commands []CommandInfo, name string) (CommandInfo, bool) {
for _, command := range commands {
if command.Name == name {
return command, true
}
}
return CommandInfo{}, false
}
func TestCommandsDocsAccountsForHiddenCompatibilityAliases(t *testing.T) {
tests := []struct {
name string
commands []command.Command
skills []skill.Skill
wantCanonical string
}{
{
name: "hidden plugin command alias",
commands: []command.Command{
{Name: "docs", Plugin: "manuals", Hidden: true},
{Name: "manuals:docs", Plugin: "manuals"},
},
wantCanonical: "manuals:docs",
},
{
name: "compatible plugin skill alias",
skills: []skill.Skill{{Name: "docs", Plugin: "manuals"}},
wantCanonical: "manuals:docs",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := control.New(control.Options{Commands: tt.commands, Skills: tt.skills})
defer ctrl.Close()
app := NewApp()
app.setTestCtrl(ctrl, "")
commands := app.Commands()
if _, ok := commandInfoByName(commands, "docs"); ok {
t.Fatalf("hidden runtime owner left a misleading docs entry: %+v", commands)
}
for _, want := range []string{control.ReasonixDocsSlashName, tt.wantCanonical} {
if _, ok := commandInfoByName(commands, want); !ok {
t.Fatalf("commands missing %q: %+v", want, commands)
}
}
})
}
}
func TestCommandsDocsDoesNotDisplaceQualifiedCustomCommands(t *testing.T) {
ctrl := control.New(control.Options{Commands: []command.Command{
{Name: "docs", Description: "custom docs"},
{Name: "reasonix:docs", Description: "qualified custom docs"},
{Name: "reasonix:builtin:docs", Description: "second qualified custom docs"},
}})
defer ctrl.Close()
app := NewApp()
app.setTestCtrl(ctrl, "")
commands := app.Commands()
for _, want := range []struct {
name string
kind string
}{
{name: "docs", kind: "custom"},
{name: "reasonix:docs", kind: "custom"},
{name: "reasonix:builtin:docs", kind: "custom"},
{name: "reasonix:builtin:docs:2", kind: "builtin"},
} {
if command, ok := commandInfoByName(commands, want.name); !ok || command.Kind != want.kind {
t.Fatalf("command %q = %+v, %v; want kind %q", want.name, command, ok, want.kind)
}
}
}
func TestCommandsClassifiesSubagentSkills(t *testing.T) {
ctrl := control.New(control.Options{Skills: []skill.Skill{
{Name: "init", Description: "inline skill", RunAs: skill.RunInline},
@@ -932,14 +1059,14 @@ func TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {
func TestEmitReadyInvokesReadyHook(t *testing.T) {
app := NewApp()
var calls int32
var calls atomic.Int32
app.readyHook = func() {
atomic.AddInt32(&calls, 1)
calls.Add(1)
}
app.emitReady(context.TODO())
if got := atomic.LoadInt32(&calls); got != 1 {
if got := calls.Load(); got != 1 {
t.Fatalf("ready hook calls = %d, want 1", got)
}
}
@@ -996,6 +1123,9 @@ status_bar_items = ["cost", "balance"]
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
t.Fatalf("set desktop terminal theme: %v", err)
}
if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
t.Fatalf("set desktop close behavior: %v", err)
}
@@ -1016,7 +1146,7 @@ status_bar_items = ["cost", "balance"]
}
got := NewApp().Settings()
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" {
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DesktopTerminalTheme != "light" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" {
t.Fatalf("desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)
}
if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.StatusBarItems, want) {
@@ -1037,6 +1167,9 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
t.Fatalf("set desktop terminal theme: %v", err)
}
if err := userCfg.SetDesktopStatusBarStyle("icon"); err != nil {
t.Fatalf("set desktop status bar style: %v", err)
}
@@ -1057,7 +1190,7 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
}
got := NewApp().DesktopStartupSettings()
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates || got.UpdateChannel != "preview" {
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DesktopTerminalTheme != "light" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates || got.UpdateChannel != "stable" {
t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got)
}
if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) {
@@ -1095,7 +1228,7 @@ func BenchmarkDesktopSettingsPayloads(b *testing.B) {
b.Setenv("SHARED_PROVIDER_KEY", "sk-test")
cfg := config.LoadForEdit(config.UserConfigPath())
for i := 0; i < 40; i++ {
for i := range 40 {
cfg.Providers = append(cfg.Providers, config.ProviderEntry{
Name: fmt.Sprintf("custom-%02d", i),
Kind: "openai",
@@ -1111,12 +1244,12 @@ func BenchmarkDesktopSettingsPayloads(b *testing.B) {
app := NewApp()
b.Run("Settings", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for range b.N {
_ = app.Settings()
}
})
b.Run("DesktopStartupSettings", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for range b.N {
_ = app.DesktopStartupSettings()
}
})
@@ -1941,7 +2074,6 @@ func TestResetProviderPresetAccessRejectsMissingSameNameProvider(t *testing.T) {
func TestAddEveryProviderPresetAccessInstallsTemplate(t *testing.T) {
for _, preset := range config.CuratedProviderPresets() {
preset := preset
t.Run(preset.ID, func(t *testing.T) {
isolateDesktopUserDirs(t)
@@ -2758,7 +2890,7 @@ func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) {
app := NewApp()
app.ctx = context.Background()
app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
app.runtimeEvents.emit = func(context.Context, string, ...any) {}
tab := &WorkspaceTab{
ID: "tab_depth_cap_effort",
Scope: "global",
@@ -3434,7 +3566,7 @@ func TestSetModelForTabContinuesRecoveryPathAfterSnapshotConflict(t *testing.T)
app := NewApp()
app.ctx = context.Background()
app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
app.runtimeEvents.emit = func(context.Context, string, ...any) {}
tab := &WorkspaceTab{
ID: "tab_recovery_model",
Scope: "global",
@@ -4207,16 +4339,13 @@ func runQuickClickWorkspaceReconcileTest(t *testing.T, layoutStyle string) {
errs := make(chan error, len(actions))
var wg sync.WaitGroup
for _, action := range actions {
action := action
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
ready <- struct{}{}
<-start
if err := action.run(); err != nil {
errs <- fmt.Errorf("%s: %w", action.name, err)
}
}()
})
}
for range actions {
<-ready
@@ -5622,6 +5751,17 @@ func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old"}
cfg.Providers = []config.ProviderEntry{
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -5630,12 +5770,17 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
path := filepath.Join(dir, "jobs.jsonl")
jm := jobs.NewManager(event.Discard)
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
defer ctrl.Close()
app := NewApp()
app.setTestCtrl(ctrl, "")
app.ctx = context.Background()
app.setTestCtrl(ctrl, "old/old-model")
t.Cleanup(func() {
if current := app.activeCtrl(); current != nil {
current.Close()
}
})
release := make(chan struct{})
jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
job := jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
@@ -5643,11 +5788,21 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
return "", nil
}
})
defer close(release)
t.Cleanup(func() { close(release) })
err := app.SetTokenMode("economy")
if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
t.Fatalf("SetTokenMode with background job error = %v, want background-job guard", err)
if err == nil || !strings.Contains(err.Error(), "background_jobs=1") {
t.Fatalf("SetTokenMode with background job error = %v, want exact background-job guard", err)
}
cancelled, err := app.CancelJobForTab("", job.ID)
if err != nil || !cancelled {
t.Fatalf("CancelJobForTab = %v, %v, want true, nil", cancelled, err)
}
if result := jm.WaitForSession(context.Background(), agent.BranchID(path), []string{job.ID}, 5); len(result) != 1 || result[0].Status != jobs.Killed {
t.Fatalf("stopped background job = %+v, want one killed result", result)
}
if err := app.SetTokenMode("economy"); err != nil {
t.Fatalf("SetTokenMode after stopping background job: %v", err)
}
}
@@ -6635,7 +6790,7 @@ func assertSingleTeardownTimeoutNotice(t *testing.T, notices <-chan event.Event,
t.Fatal("missing background-job teardown timeout notice")
}
var waited time.Duration
for _, field := range strings.Fields(notice.Detail) {
for field := range strings.FieldsSeq(notice.Detail) {
if !strings.HasPrefix(field, "waited=") {
continue
}
@@ -7000,7 +7155,7 @@ func TestSubmitEntryPointsRejectEmptyProviderInput(t *testing.T) {
return app.SubmitEditedDisplayToTab("missing", "visible prompt", "\n", "original prompt")
}},
{name: "initial goal", call: func() error {
_, err := app.SubmitInitialGoalToTab("missing", "goal", "visible prompt", "", nil, "normal", "auto", "local", 0, 0)
_, err := app.SubmitInitialGoalToTab("missing", "goal", "visible prompt", "", nil, "normal", "auto")
return err
}},
} {
@@ -7139,7 +7294,7 @@ func BenchmarkDesktopListSessionsScoped(b *testing.B) {
if err := os.MkdirAll(dir, 0o755); err != nil {
b.Fatalf("mkdir %s: %v", dir, err)
}
for i := 0; i < 120; i++ {
for i := range 120 {
path := filepath.Join(dir, fmt.Sprintf("session-%03d.jsonl", i))
body := fmt.Sprintf(`{"role":"user","content":"session %03d"}`+"\n", i)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
@@ -7154,7 +7309,7 @@ func BenchmarkDesktopListSessionsScoped(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for range b.N {
sessions := app.ListSessions()
if len(sessions) != 120 {
b.Fatalf("ListSessions len = %d, want 120", len(sessions))
@@ -7405,12 +7560,12 @@ func TestDesktopSharedHostProjectMCPConnectsWithoutLaunchApproval(t *testing.T)
srv := desktopMCPHTTPServer(t)
defer srv.Close()
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
[[plugins]]
name = "h"
type = "http"
url = %q
`, srv.URL)), 0o644); err != nil {
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
@@ -7601,12 +7756,12 @@ func TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {
srv := desktopMCPHTTPServer(t)
defer srv.Close()
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
[[plugins]]
name = "h"
type = "http"
url = %q
`, srv.URL)), 0o644); err != nil {
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
@@ -7751,12 +7906,12 @@ func TestReconnectMCPServerUsesEffectiveProjectConfigWhenUserNameIsShadowed(t *t
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
[[plugins]]
name = "h"
type = "http"
url = %q
`, projectServer.URL)), 0o644); err != nil {
`, projectServer.URL), 0o644); err != nil {
t.Fatal(err)
}
@@ -7897,7 +8052,7 @@ func newGatedDesktopMCPLaunchFixture(t *testing.T, startGateAddr string) gatedDe
gateConfig = fmt.Sprintf("DESKTOP_MCP_START_GATE_ADDR = %q\n", startGateAddr)
}
helperArgs := []string{"-test.run=TestDesktopMCPHelperProcess", "--"}
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
[[plugins]]
name = "h"
command = %q
@@ -7909,7 +8064,7 @@ DESKTOP_MCP_SINGLE_INSTANCE_ADDR = %q
%s
[sandbox]
network = true
`, exe, singleInstanceAddr, gateConfig)), 0o644); err != nil {
`, exe, singleInstanceAddr, gateConfig), 0o644); err != nil {
t.Fatal(err)
}
@@ -8147,13 +8302,13 @@ func installGatedTestPluginPackage(t *testing.T, mcpServerName string) string {
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(fmt.Sprintf(`{
if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), fmt.Appendf(nil, `{
"name": "review-helper",
"version": "1.0.0",
"mcpServers": {
%q: { "type": "stdio", "command": "helper" }
}
}`, mcpServerName)), 0o644); err != nil {
}`, mcpServerName), 0o644); err != nil {
t.Fatal(err)
}
if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
@@ -8924,13 +9079,13 @@ func TestRemoveMCPServerRejectsPluginManagedServerWithoutDisconnecting(t *testin
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(fmt.Sprintf(`{
if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), fmt.Appendf(nil, `{
"name": "superpowers",
"version": "1.0.0",
"mcpServers": {
"helper": { "type": "http", "url": %q }
}
}`, srv.URL)), 0o644); err != nil {
}`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
@@ -9969,7 +10124,7 @@ func TestRunShellForTabStaysBoundDuringRapidProjectTabSwitching(t *testing.T) {
waitForShellDispatch(t, shellEvents, marker)
waitForFile(t, filepath.Join(projectA, marker), "shell")
for i := 0; i < 8; i++ {
for range 8 {
if err := app.SetActiveTab("project-b"); err != nil {
t.Fatalf("SetActiveTab(project-b): %v", err)
}
@@ -10123,12 +10278,7 @@ func newBackgroundJobController(t *testing.T, label string) *control.Controller
}
func hasLevel(levels []string, want string) bool {
for _, level := range levels {
if level == want {
return true
}
}
return false
return slices.Contains(levels, want)
}
func hasCommand(cmds []CommandInfo, name string) bool {
@@ -10168,7 +10318,7 @@ func TestSessionActionsWithoutControllerReturnError(t *testing.T) {
}
}
// --- Prompt history scanning tests ------------------------------------------
// Prompt history scanning tests
func identityPromptDisplay(text string) string { return text }
@@ -10458,14 +10608,14 @@ func TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *t
early := filepath.Join(dir, "early.jsonl")
late := filepath.Join(dir, "late.jsonl")
if err := os.WriteFile(early, []byte(fmt.Sprintf(`{"role":"user","content":"early first","time":%d}
if err := os.WriteFile(early, fmt.Appendf(nil, `{"role":"user","content":"early first","time":%d}
{"role":"assistant","content":"ok"}
{"role":"user","content":"early second","time":%d}
`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {
`, base.UnixMilli(), base.Add(time.Minute).UnixMilli()), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(late, []byte(fmt.Sprintf(`{"role":"user","content":"late newest","time":%d}
`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {
if err := os.WriteFile(late, fmt.Appendf(nil, `{"role":"user","content":"late newest","time":%d}
`, base.Add(2*time.Minute).UnixMilli()), 0o644); err != nil {
t.Fatal(err)
}
// Invert file mtimes: session activity should keep each session grouped
@@ -10634,8 +10784,8 @@ func TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T
other := filepath.Join(dir, "other.jsonl")
var lines []byte
for i := range 55 {
lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"current %d"}
`, i))...)
lines = append(lines, fmt.Appendf(nil, `{"role":"user","content":"current %d"}
`, i)...)
}
if err := os.WriteFile(current, lines, 0o644); err != nil {
t.Fatal(err)
@@ -10709,8 +10859,8 @@ func TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T)
dir := t.TempDir()
var lines []byte
for i := range 250 {
lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"prompt %d"}
`, i))...)
lines = append(lines, fmt.Appendf(nil, `{"role":"user","content":"prompt %d"}
`, i)...)
}
if err := os.WriteFile(filepath.Join(dir, "many.jsonl"), lines, 0o644); err != nil {
t.Fatal(err)
+332
View File
@@ -0,0 +1,332 @@
package main
import (
"fmt"
"strings"
"time"
"reasonix/internal/control"
"reasonix/internal/workspacelease"
)
// ActiveWorkView is the structured Desktop contract for work that prevents a
// controller rebuild or a destructive tab action. Jobs is always a JSON array.
type ActiveWorkView struct {
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt"`
Cancellable bool `json:"cancellable"`
Jobs []JobView `json:"jobs"`
}
// JobCancelBatchView reports which requested jobs accepted cancellation. Both
// slices are initialized so Wails never sends null to React.
type JobCancelBatchView struct {
Cancelled []string `json:"cancelled"`
NotRunning []string `json:"notRunning"`
}
// BackgroundRuntimeView is one visible or detached runtime with active work.
// TabID is an opaque process-local handle; paths and session writer ids are
// deliberately omitted from this user-facing contract.
type BackgroundRuntimeView struct {
TabID string `json:"tabId"`
Title string `json:"title"`
Detached bool `json:"detached"`
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt"`
Jobs []JobView `json:"jobs"`
}
// WorkspaceConflictView describes a currently-waiting Delivery writer without
// exposing the lock path, process id, or session path.
type WorkspaceConflictView struct {
State string `json:"state"`
OwnerTabID string `json:"ownerTabId,omitempty"`
OwnerTitle string `json:"ownerTitle,omitempty"`
OwnerWork ActiveWorkView `json:"ownerWork"`
CanReveal bool `json:"canReveal"`
CanCreateWorktree bool `json:"canCreateWorktree"`
}
func activeWorkForController(ctrl control.SessionAPI) ActiveWorkView {
view := ActiveWorkView{Jobs: []JobView{}}
if ctrl == nil {
return view
}
status := ctrl.RuntimeStatus()
view.Running = status.Running
view.PendingPrompt = status.PendingPrompt
view.Cancellable = status.Cancellable
for _, job := range ctrl.Jobs() {
view.Jobs = append(view.Jobs, JobView{
ID: job.ID, Kind: job.Kind, Label: job.Label,
Status: job.Status, StartedAt: job.StartedAt,
})
}
return view
}
func (v ActiveWorkView) active() bool {
return v.Running || v.PendingPrompt || len(v.Jobs) > 0
}
// ActiveWorkForTab returns the precise blocker state for one tab. It is a
// preflight aid only; rebuild paths still re-check active work atomically.
func (a *App) ActiveWorkForTab(tabID string) ActiveWorkView {
return activeWorkForController(a.ctrlForRuntimeTabID(tabID))
}
// CancelJobsForTab requests cancellation for a stable tab id. The job manager
// keeps cancelled-but-unwinding jobs visible until their done channels close.
func (a *App) CancelJobsForTab(tabID string, jobIDs []string) (JobCancelBatchView, error) {
result := JobCancelBatchView{Cancelled: []string{}, NotRunning: []string{}}
seen := map[string]bool{}
for _, raw := range jobIDs {
id := strings.TrimSpace(raw)
if id == "" || seen[id] {
continue
}
seen[id] = true
cancelled, err := a.CancelJobForTab(tabID, id)
if err != nil {
return result, err
}
if cancelled {
result.Cancelled = append(result.Cancelled, id)
} else {
result.NotRunning = append(result.NotRunning, id)
}
}
return result, nil
}
type backgroundRuntimeSnapshot struct {
id string
ctrl control.SessionAPI
detached bool
title string
}
// BackgroundRuntimes returns every process-local runtime that still needs a
// visible recovery path, including runtimes detached by an explicit tab close.
func (a *App) BackgroundRuntimes() []BackgroundRuntimeView {
a.mu.RLock()
snapshots := make([]backgroundRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions))
seen := map[*WorkspaceTab]bool{}
for _, tab := range a.tabs {
if tab == nil || seen[tab] {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, title: title})
}
for _, tab := range a.detachedSessions {
if tab == nil || seen[tab] {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, detached: true, title: title})
}
a.mu.RUnlock()
out := make([]BackgroundRuntimeView, 0, len(snapshots))
for _, snapshot := range snapshots {
work := activeWorkForController(snapshot.ctrl)
if !work.active() {
continue
}
out = append(out, BackgroundRuntimeView{
TabID: snapshot.id, Title: snapshot.title, Detached: snapshot.detached,
Running: work.Running, PendingPrompt: work.PendingPrompt, Jobs: work.Jobs,
})
}
return out
}
func (a *App) ctrlForRuntimeTabID(tabID string) control.SessionAPI {
a.mu.RLock()
defer a.mu.RUnlock()
if strings.TrimSpace(tabID) == "" {
return a.activeCtrlLocked()
}
tab := a.tabByEventSinkIDLocked(tabID)
if tab == nil {
return nil
}
return tab.Ctrl
}
// RevealBackgroundRuntime activates a visible owner or reopens the exact
// detached session. It never reattaches by workspace alone.
func (a *App) RevealBackgroundRuntime(tabID string) (TabMeta, error) {
a.mu.RLock()
if tab := a.tabs[tabID]; tab != nil {
a.mu.RUnlock()
if err := a.SetActiveTab(tabID); err != nil {
return TabMeta{}, err
}
a.mu.RLock()
current := a.tabs[tabID]
if current == nil {
a.mu.RUnlock()
return TabMeta{}, fmt.Errorf("background task is no longer available")
}
meta := a.tabMeta(current, true)
a.mu.RUnlock()
return enrichTabMeta(meta), nil
}
tab := a.tabByEventSinkIDLocked(tabID)
if tab == nil || tab.Ctrl == nil {
a.mu.RUnlock()
return TabMeta{}, fmt.Errorf("background task is no longer available")
}
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
topicID := tab.TopicID
sessionPath := tab.currentSessionPath()
a.mu.RUnlock()
if strings.TrimSpace(sessionPath) == "" {
return TabMeta{}, fmt.Errorf("background task session is unavailable")
}
return a.OpenTopicSession(scope, workspaceRoot, topicID, sessionPath)
}
type workspaceLeaseReporter interface {
WorkspaceLeaseState() workspacelease.State
}
func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State {
if reporter, ok := ctrl.(workspaceLeaseReporter); ok {
return reporter.WorkspaceLeaseState()
}
return workspacelease.State{}
}
// WorkspaceConflictForTab classifies the owner that a Delivery writer is
// currently waiting for. An acquired process-local owner is actionable; when no
// local owner matches, the OS lock is treated as external.
func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
empty := WorkspaceConflictView{State: "none", OwnerWork: ActiveWorkView{Jobs: []JobView{}}}
a.mu.RLock()
var target *WorkspaceTab
if strings.TrimSpace(tabID) == "" {
target = a.activeTabLocked()
} else {
target = a.tabByEventSinkIDLocked(tabID)
}
if target == nil {
a.mu.RUnlock()
return empty
}
targetCtrl := target.Ctrl
targetWorkspaceRoot := target.WorkspaceRoot
a.mu.RUnlock()
if targetCtrl == nil {
return empty
}
targetState := controllerWorkspaceLeaseState(targetCtrl)
if !targetState.Waiting || targetState.Acquired {
return empty
}
targetRoot, err := workspacelease.CanonicalWorkspace(targetWorkspaceRoot)
if err != nil {
return empty
}
availability := a.DeliveryWorktreeAvailability(targetWorkspaceRoot)
a.mu.RLock()
type candidate struct {
id string
ctrl control.SessionAPI
root string
title string
}
candidates := make([]candidate, 0, len(a.tabs)+len(a.detachedSessions))
seen := map[*WorkspaceTab]bool{}
for _, tab := range a.runtimeTabsLocked() {
if tab == nil || tab == target || seen[tab] || tab.Ctrl == nil {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
candidates = append(candidates, candidate{id: tab.ID, ctrl: tab.Ctrl, root: tab.WorkspaceRoot, title: title})
}
a.mu.RUnlock()
for _, candidate := range candidates {
root, err := workspacelease.CanonicalWorkspace(candidate.root)
if err != nil || root != targetRoot || !controllerWorkspaceLeaseState(candidate.ctrl).Acquired {
continue
}
return WorkspaceConflictView{
State: "local", OwnerTabID: candidate.id, OwnerTitle: candidate.title,
OwnerWork: activeWorkForController(candidate.ctrl), CanReveal: true,
CanCreateWorktree: availability.Available,
}
}
empty.State = "external"
empty.CanCreateWorktree = availability.Available
return empty
}
// RevealWorkspaceWriterForTab opens the exact process-local runtime identified
// by WorkspaceConflictForTab. External writers remain non-actionable.
func (a *App) RevealWorkspaceWriterForTab(tabID string) (TabMeta, error) {
conflict := a.WorkspaceConflictForTab(tabID)
if conflict.State != "local" || conflict.OwnerTabID == "" {
return TabMeta{}, fmt.Errorf("the workspace writer is not available in this Reasonix window")
}
return a.RevealBackgroundRuntime(conflict.OwnerTabID)
}
const stopAndCloseGrace = 15 * time.Second
// CloseTabWithPolicy makes the old implicit detach behavior an explicit user
// choice. stop_and_close never removes the tab until all owned work is idle.
func (a *App) CloseTabWithPolicy(tabID, policy string) error {
switch strings.TrimSpace(policy) {
case "keep_running":
return a.closeTab(tabID, true)
case "stop_and_close":
ctrl := a.ctrlForRuntimeTabID(tabID)
if ctrl == nil {
return a.closeTab(tabID, false)
}
ctrl.Cancel()
jobs := ctrl.Jobs()
ids := make([]string, 0, len(jobs))
for _, job := range jobs {
ids = append(ids, job.ID)
}
if _, err := a.CancelJobsForTab(tabID, ids); err != nil {
return err
}
deadline := time.NewTimer(stopAndCloseGrace)
defer deadline.Stop()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
for {
if !activeWorkForController(ctrl).active() {
return a.closeTab(tabID, false)
}
select {
case <-deadline.C:
return fmt.Errorf("background work did not stop within %s; the task was kept open", stopAndCloseGrace)
case <-ticker.C:
}
}
default:
return fmt.Errorf("unknown close policy %q", policy)
}
}
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"testing"
"reasonix/internal/control"
"reasonix/internal/jobs"
"reasonix/internal/workspacelease"
)
type backgroundRuntimeController struct {
control.SessionAPI
status control.RuntimeStatus
jobs []jobs.View
lease workspacelease.State
cancelled []string
turnCancel int
}
func (c *backgroundRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
func (c *backgroundRuntimeController) Jobs() []jobs.View {
return append([]jobs.View(nil), c.jobs...)
}
func (c *backgroundRuntimeController) CancelJob(id string) bool {
for _, job := range c.jobs {
if job.ID == id {
c.cancelled = append(c.cancelled, id)
return true
}
}
return false
}
func (c *backgroundRuntimeController) Cancel() { c.turnCancel++ }
func (c *backgroundRuntimeController) WorkspaceLeaseState() workspacelease.State {
return c.lease
}
func TestBackgroundRuntimeAPIsKeepDetachedJobsActionable(t *testing.T) {
targetCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-target", Kind: "bash", Label: "target tests", Status: "running", StartedAt: 1}},
}
focusedCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-focused", Kind: "bash", Label: "focused tests", Status: "running", StartedAt: 2}},
}
target := &WorkspaceTab{ID: "target", TopicTitle: "Detached task", Ctrl: targetCtrl}
focused := &WorkspaceTab{ID: "focused", TopicTitle: "Focused task", Ctrl: focusedCtrl}
app := &App{
tabs: map[string]*WorkspaceTab{"focused": focused},
tabOrder: []string{"focused"},
activeTabID: "focused",
detachedSessions: map[string]*WorkspaceTab{"session-target": target},
}
work := app.ActiveWorkForTab("target")
if len(work.Jobs) != 1 || work.Jobs[0].ID != "job-target" {
t.Fatalf("ActiveWorkForTab(target) = %+v, want target job", work)
}
result, err := app.CancelJobsForTab("target", []string{"job-target", "job-target", "missing"})
if err != nil {
t.Fatalf("CancelJobsForTab: %v", err)
}
if len(result.Cancelled) != 1 || result.Cancelled[0] != "job-target" || len(result.NotRunning) != 1 || result.NotRunning[0] != "missing" {
t.Fatalf("CancelJobsForTab result = %+v", result)
}
if len(targetCtrl.cancelled) != 1 || len(focusedCtrl.cancelled) != 0 {
t.Fatalf("cancellation routing target=%v focused=%v", targetCtrl.cancelled, focusedCtrl.cancelled)
}
runtimes := app.BackgroundRuntimes()
if len(runtimes) != 2 {
t.Fatalf("BackgroundRuntimes = %+v, want visible and detached", runtimes)
}
var foundDetached bool
for _, runtime := range runtimes {
if runtime.TabID == "target" {
foundDetached = runtime.Detached && runtime.Title == "Detached task" && len(runtime.Jobs) == 1
}
}
if !foundDetached {
t.Fatalf("detached runtime missing from %+v", runtimes)
}
}
func TestCancelJobForLocalRuntimeByExplicitTab(t *testing.T) {
localCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-local", Kind: "bash", Status: "running"}},
}
app := &App{
tabs: map[string]*WorkspaceTab{"local": {ID: "local", Ctrl: localCtrl}},
tabOrder: []string{"local"},
activeTabID: "local",
}
listed := app.JobsForTab("local")
if len(listed) != 1 || listed[0].ID != "job-local" {
t.Fatalf("JobsForTab(local) = %+v, want local job", listed)
}
cancelled, err := app.CancelJobForTab("local", "job-local")
if err != nil {
t.Fatalf("CancelJobForTab(local): %v", err)
}
if !cancelled || len(localCtrl.cancelled) != 1 || localCtrl.cancelled[0] != "job-local" {
t.Fatalf("local cancellation = %t, calls=%v; want local job cancelled", cancelled, localCtrl.cancelled)
}
}
func TestWorkspaceConflictIdentifiesExactLocalWriterWithoutIdentity(t *testing.T) {
root := t.TempDir()
waiterCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{Running: true},
lease: workspacelease.State{Waiting: true},
}
ownerCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "owner-job", Kind: "bash", Status: "running"}},
lease: workspacelease.State{Acquired: true},
}
app := &App{
tabs: map[string]*WorkspaceTab{
"waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl},
"owner": {ID: "owner", TopicTitle: "Writing task", WorkspaceRoot: root, Ctrl: ownerCtrl},
},
tabOrder: []string{"waiter", "owner"},
activeTabID: "waiter",
}
conflict := app.WorkspaceConflictForTab("waiter")
if conflict.State != "local" || conflict.OwnerTabID != "owner" || conflict.OwnerTitle != "Writing task" || !conflict.CanReveal {
t.Fatalf("WorkspaceConflictForTab = %+v, want exact local owner", conflict)
}
if len(conflict.OwnerWork.Jobs) != 1 || conflict.OwnerWork.Jobs[0].ID != "owner-job" {
t.Fatalf("owner work = %+v, want sanitized active work", conflict.OwnerWork)
}
}
+3 -3
View File
@@ -400,7 +400,7 @@ func truncateForBridge(s string, limit int) string {
return string(runes[:limit]) + "…"
}
// ---- bot.DesktopBridge 实现 ----
// bot.DesktopBridge 实现
func (h *botBridgeHub) Sessions() []bot.DesktopSessionInfo {
if h.sessions == nil {
@@ -555,7 +555,7 @@ func (h *botBridgeHub) Answer(askID string, answers []event.AskAnswer) (string,
return fmt.Sprintf("已提交「%s」的回答。桌面端若已先处理,以先到者为准。", h.tabLabel(p.tabID)), nil
}
// ---- 显式接管 ----
// 显式接管
func (h *botBridgeHub) Takeover(route bot.DesktopWatchRoute, tabID string) (string, error) {
tabID = strings.TrimSpace(tabID)
@@ -660,7 +660,7 @@ func (h *botBridgeHub) DriveInput(route bot.DesktopWatchRoute, text string) (str
if errors.Is(err, errDriveBusy) {
return "", h.busyError(tabID)
}
return "", fmt.Errorf("驱动失败: %v", err)
return "", fmt.Errorf("驱动失败: %w", err)
}
return "", nil
}
+2 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"errors"
"fmt"
"log/slog"
"strings"
@@ -114,7 +115,7 @@ func (a *App) bridgeAnnounce(tabID, text string) {
func (a *App) bridgeDrive(tabID, text string, route bot.DesktopWatchRoute) error {
admission, ctrl, err := a.beginTabTurn(tabID, false)
if err != nil {
if err == control.ErrTurnRunning {
if errors.Is(err, control.ErrTurnRunning) {
return errDriveBusy
}
return err
+1 -1
View File
@@ -155,7 +155,7 @@ func TestBridgeTakeoverSwitchAnnouncesReleaseToOldTab(t *testing.T) {
t.Fatalf("switch to B: %v", err)
}
seen := map[string]bool{}
for i := 0; i < 2; i++ {
for range 2 {
select {
case got := <-env.announced:
seen[got[0]] = true
+1 -1
View File
@@ -693,7 +693,7 @@ func postFeishuInstallFormResult(base string, body map[string]string) (map[strin
for k, v := range body {
reqBody.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(base, "/")+"/oauth/v1/app/registration", strings.NewReader(reqBody.Encode()))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+"/oauth/v1/app/registration", strings.NewReader(reqBody.Encode()))
if err != nil {
return nil, 0, err
}
+4
View File
@@ -105,6 +105,10 @@ func (f *botEventForwarder) Emit(e event.Event) {
f.sendToAll(qb.String())
case event.Notice:
if e.Audience == event.NoticeAudienceOperator {
// Local runtime maintenance is not actionable in the remote IM chat.
break
}
if e.Level == event.LevelWarn {
f.sendToAll("⚠️ " + e.Text)
}
+33
View File
@@ -131,3 +131,36 @@ func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
t.Fatal("approval notice was not sent")
}
}
func TestBotEventForwarderSuppressesOperatorNoticesWithoutHidingUserWarnings(t *testing.T) {
forwarder := &botEventForwarder{
runtime: &desktopBotRuntime{},
targets: []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}},
queue: make(chan string, 2),
}
forwarder.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Audience: event.NoticeAudienceOperator,
Code: event.NoticeCodeSessionRecoveryForked,
Text: "local session maintenance",
})
forwarder.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Text: "please retry the user action",
})
if got := len(forwarder.queue); got != 1 {
t.Fatalf("queued messages = %d, want only the actionable user warning", got)
}
if got := <-forwarder.queue; got != "⚠️ please retry the user action" {
t.Fatalf("queued message = %q, want the ordinary user warning", got)
}
}
+2 -2
View File
@@ -114,12 +114,12 @@ func assertRequiredJSONSlicesNonNil(t *testing.T, path string, value reflect.Val
if value.Kind() == reflect.Slice && value.IsNil() {
t.Fatalf("%s is a nil slice; JSON contract requires []", path)
}
for i := 0; i < value.Len(); i++ {
for i := range value.Len() {
assertRequiredJSONSlicesNonNil(t, path, value.Index(i))
}
case reflect.Struct:
typ := value.Type()
for i := 0; i < value.NumField(); i++ {
for i := range value.NumField() {
fieldType := typ.Field(i)
if fieldType.PkgPath != "" {
continue
+4 -2
View File
@@ -20,6 +20,8 @@ description: |
vendor: "Reasonix Contributors"
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
license: "MIT"
scripts:
postinstall: ./build/linux/postinstall.sh
# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41); apt
# pulls them in on install. These package names ship from Ubuntu 22.04 / Debian 12 on.
# pkexec is required for the in-app Polkit authorization update path.
@@ -32,8 +34,8 @@ contents:
dst: /usr/bin/reasonix-desktop
file_info:
mode: 0755
- src: ./build/bin/reasonix-guard
dst: /usr/bin/reasonix-guard
- src: ./build/bin/reasonix-launcher
dst: /usr/bin/reasonix-launcher
file_info:
mode: 0755
- src: ./build/bin/reasonix
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Refresh desktop integration after both fresh installs and upgrades. Debian's
# package triggers usually do this too, but an explicit best-effort refresh
# repairs stale blank icons on desktops that kept an older cache.
if [ -x /usr/bin/gtk-update-icon-cache ]; then
/usr/bin/gtk-update-icon-cache --force --quiet /usr/share/icons/hicolor || true
fi
if [ -x /usr/bin/update-desktop-database ]; then
/usr/bin/update-desktop-database --quiet /usr/share/applications || true
fi
exit 0
+1 -1
View File
@@ -2,7 +2,7 @@
Type=Application
Name=Reasonix
Comment=Reasonix desktop — a Wails shell around the Go kernel
Exec=reasonix-guard launch --detach
Exec=reasonix-launcher
Icon=reasonix-desktop
Categories=Development;Utility;
Terminal=false
+171 -39
View File
@@ -1,4 +1,4 @@
Unicode true
Unicode true
####
## Reasonix per-user NSIS installer.
@@ -100,8 +100,11 @@ OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the inst
!define REASONIX_LAUNCHER "reasonix-launcher.exe"
!define REASONIX_CLI "reasonix-cli.exe"
!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"
!define REASONIX_LAYOUT_INSTALLER "reasonix-layout-installer.exe"
!define REASONIX_PAYLOAD_MANIFEST "reasonix-payload.json"
!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"
!define REASONIX_LEGACY_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Reasonix"
!define REASONIX_LEGACY_PRODUCT_KEY "Software\reasonix\Reasonix"
!define REASONIX_UNLOCK_RETRIES 60
Var ReasonixUpdateMode
Var ReasonixStageMode
@@ -123,7 +126,11 @@ ShowInstDetails show # This will always show the installation details.
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
!if /FileExists "${REASONIX_LAUNCHER}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${REASONIX_LAUNCHER}"
!else
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!endif
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
# Persist the resolved install path so a subsequent update picks it up
@@ -139,6 +146,48 @@ ShowInstDetails show # This will always show the installation details.
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
; Tauri 0.53 separately persisted $INSTDIR under its manufacturer/product key
; and restores that value before every later install. Clear only a same-root
; value so re-running 0.53 cannot overwrite the current uninstaller; preserve a
; genuinely separate legacy installation. If this cleanup fails, retain the old
; uninstall alias so a later update can retry the migration.
!macro reasonix.deleteLegacyInstallerStateIfOwned
StrCpy $1 "1"
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
${If} $0 == "$INSTDIR"
ClearErrors
DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
${If} ${Errors}
StrCpy $1 "0"
${EndIf}
${ElseIf} $0 == "$\"$INSTDIR$\""
ClearErrors
DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
${If} ${Errors}
StrCpy $1 "0"
${EndIf}
${EndIf}
${If} $1 == "1"
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "InstallLocation"
${If} $0 == "$INSTDIR"
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${ElseIf} $0 == "$\"$INSTDIR$\""
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${Else}
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "UninstallString"
${If} $0 == "$INSTDIR\uninstall.exe"
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${ElseIf} $0 == "$\"$INSTDIR\uninstall.exe$\""
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${EndIf}
${EndIf}
${EndIf}
!macroend
!macro reasonix.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
DeleteRegKey HKCU "${UNINST_KEY}"
@@ -175,8 +224,44 @@ reasonix_stage_mode_done:
StrCmp $INSTDIR "" 0 done
ClearErrors
ReadRegStr $0 HKCU "${UNINST_KEY}" "DisplayIcon"
IfErrors legacy_location
StrCmp $0 "" legacy_location
${GetParent} "$0" $INSTDIR
StrCmp $INSTDIR "" legacy_location done
legacy_location:
; Tauri 0.53 used a different uninstall key and may have stored the selected
; directory with surrounding quotes (for example "D:\Reasonix"). Reuse it
; only while its uninstaller still exists so a stale registry value cannot
; redirect the repair installer into an unrelated directory.
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "InstallLocation"
IfErrors legacy_uninstaller
StrCmp $0 "" legacy_uninstaller
StrCpy $1 $0 1
StrCmp $1 "$\"" 0 legacy_location_ready
StrCpy $1 $0 1 -1
StrCmp $1 "$\"" 0 legacy_location_ready
StrCpy $0 $0 -1 1
legacy_location_ready:
IfFileExists "$0\uninstall.exe" 0 legacy_uninstaller
StrCpy $INSTDIR $0
Goto done
legacy_uninstaller:
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "UninstallString"
IfErrors fallback
StrCmp $0 "" fallback
StrCpy $1 $0 1
StrCmp $1 "$\"" 0 legacy_uninstaller_ready
StrCpy $1 $0 1 -1
StrCmp $1 "$\"" 0 legacy_uninstaller_ready
StrCpy $0 $0 -1 1
legacy_uninstaller_ready:
IfFileExists "$0" 0 fallback
${GetParent} "$0" $INSTDIR
StrCmp $INSTDIR "" fallback done
@@ -213,12 +298,21 @@ Function reasonix.waitForExecutableUnlock
StrCpy $0 0
retry:
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_guard
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_versioned_target
ClearErrors
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_versioned_target:
; A same-version recovery install replaces this directory transactionally.
; Detect the running active binary before asking the Go activator to rename it.
IfFileExists "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0 check_guard
ClearErrors
FileOpen $1 "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_guard:
IfFileExists "$INSTDIR\${REASONIX_GUARD}" 0 check_launcher
ClearErrors
@@ -273,72 +367,107 @@ FunctionEnd
Section
!insertmacro wails.setShellContext
; Current auto-update helpers extract the signed installer payload into a
; unique temporary directory, then compare-and-publish the exact claimed
; release unit. Legacy helpers omit /REASONIXSTAGE and retain the historical
; in-place update behavior required to upgrade into the hardened release.
StrCmp $ReasonixStageMode "1" reasonix_copy_payload
; /REASONIXSTAGE=1: flat six-member payload for 1.18–1.19.1 helpers (and
; the new helper's staging extract). Do not write shortcuts/uninstaller.
; Normal install: versioned-v1 layout under versions/v${INFO_PRODUCTVERSION}/
; with a permanent thin launcher at InstallRoot. Guard is only present in
; STAGE payloads (as the one-shot legacy migrator) and is not persisted on
; a normal install.
StrCmp $ReasonixStageMode "1" reasonix_stage_payload
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
Goto reasonix_normal_install
reasonix_copy_payload:
reasonix_stage_payload:
SetOutPath $INSTDIR
; The release workflow embeds a minisign-authenticated manifest after
; SignPath has signed the five executable members. Only the extraction
; handoff needs these files; a normal installation does not persist them.
StrCmp $ReasonixStageMode "1" 0 reasonix_payload_metadata_done
!if /FileExists "${REASONIX_PAYLOAD_MANIFEST}"
File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"
!endif
!if /FileExists "${REASONIX_PAYLOAD_SIGNATURE}"
File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"
!endif
reasonix_payload_metadata_done:
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
!endif
!if /FileExists "${REASONIX_GUARD}"
File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done
; Portable archives expose Reasonix.exe as a second copy of the Guard
; launcher. Preserve that layout only when upgrading an existing portable
; directory, and keep the alias in lockstep with the canonical launcher.
IfFileExists "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0 reasonix_no_portable_entry
File "/oname=${REASONIX_PORTABLE_ENTRY}" "${REASONIX_LAUNCHER}"
reasonix_no_portable_entry:
; Keep the Guard launcher as the target while taking the visible shortcut
; icon from the Wails application. The launcher embeds the same icon too,
; but an explicit icon source prevents stale/generic taskbar pins after an
; in-place upgrade from a launcher build that had no Windows resources.
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
reasonix_launcher_payload_done:
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!endif
Goto reasonix_section_done
reasonix_normal_install:
; Extract into an install-local temporary directory, then let the signed Go
; activator validate the complete release unit, transactionally publish the
; version/root entries, and strictly atomically replace current.json last.
; The normal/recovery installer therefore shares the same commit protocol as
; automatic updates instead of writing live files or current.json in place.
System::Call 'kernel32::GetCurrentProcessId() i .R8'
CreateDirectory "$INSTDIR\versions"
StrCpy $R9 "$INSTDIR\versions\.installer-v${INFO_PRODUCTVERSION}-$R8"
RMDir /r "$R9"
CreateDirectory "$R9"
SetOutPath "$R9"
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done_no_launcher
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
reasonix_launcher_payload_done_no_launcher:
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!else
!warning "${REASONIX_CLI} was not found; remote upload installation will be unavailable."
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
!endif
SetOutPath "$PLUGINSDIR"
!if /FileExists "${REASONIX_GUARD}"
File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"
!else
!error "${REASONIX_GUARD} was not found; normal installs require the signed layout activator."
!endif
DetailPrint "Reasonix layout activator output:"
nsExec::ExecToLog /OEM '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}" --install-root "$INSTDIR" --version "v${INFO_PRODUCTVERSION}" --activate-staging "$R9" --no-relaunch'
Pop $0
StrCmp $0 "0" reasonix_layout_activated
DetailPrint "Reasonix layout activation failed with exit code $0; the previous version remains active."
RMDir /r "$R9"
SetErrorLevel 1
Abort "Reasonix could not activate the verified release. The previous version was left unchanged."
reasonix_layout_activated:
RMDir /r "$R9"
SetOutPath "$INSTDIR"
; Remove flat leftovers from prior 1.18–1.19 installs when overwriting.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
!if /FileExists "${REASONIX_LAUNCHER}"
; Keep both target and icon on the stable launcher. Pointing IconLocation at
; versions\vX\reasonix-desktop.exe leaves a blank shortcut as soon as version
; retention removes that directory after a later update.
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0
!else
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
!endif
StrCmp $ReasonixStageMode "1" reasonix_section_done
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro reasonix.writeUninstaller
!insertmacro reasonix.deleteLegacyInstallerStateIfOwned
reasonix_section_done:
SectionEnd
@@ -347,13 +476,15 @@ Section "uninstall"
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
; Precision uninstall: delete main application files
; Precision uninstall: flat leftovers, thin entry points, and version trees.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_LAUNCHER}"
Delete "$INSTDIR\${REASONIX_CLI}"
Delete "$INSTDIR\${REASONIX_PORTABLE_ENTRY}"
Delete "$INSTDIR\current.json"
RMDir /r "$INSTDIR\versions"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
@@ -362,6 +493,7 @@ Section "uninstall"
!insertmacro wails.unassociateCustomProtocols
!insertmacro reasonix.deleteUninstaller
!insertmacro reasonix.deleteLegacyInstallerStateIfOwned
; Only remove the installation directory if it is empty to prevent data loss
RMDir $INSTDIR
+7 -3
View File
@@ -7,6 +7,9 @@ import (
)
func TestDesktopBuildLinksSharedSourceRevision(t *testing.T) {
// The remote protocol source-revision ldflag was removed with the Remote
// Workbench protocol stack; the test now asserts the shared product docs
// identity and the packaging-order invariant below.
data, err := os.ReadFile("../scripts/desktop-build.sh")
if err != nil {
t.Fatal(err)
@@ -15,9 +18,10 @@ func TestDesktopBuildLinksSharedSourceRevision(t *testing.T) {
for _, want := range []string{
`SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)"`,
`SOURCE_REVISION="$SOURCE_REVISION+dirty"`,
`source_revision_ldflag="-X reasonix/internal/remote/protocol.linkedSourceRevision=$SOURCE_REVISION"`,
`ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $source_revision_ldflag"`,
`-X main.version=$VERSION $source_revision_ldflag`,
`product_docs_ldflags="-X reasonix/internal/productdocs.linkedVersion=$VERSION -X reasonix/internal/productdocs.linkedRevision=$SOURCE_REVISION"`,
`cli_identity_ldflags="-X main.version=$VERSION -X main.gitCommit=$GIT_COMMIT -X main.buildTimeUTC=$BUILD_TIME_UTC $product_docs_ldflags"`,
`ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $product_docs_ldflags"`,
`cli_identity_ldflags`,
} {
if !strings.Contains(script, want) {
t.Fatalf("desktop-build.sh does not preserve the shared build identity %q", want)
+118 -5
View File
@@ -12,8 +12,86 @@ import (
"reasonix/internal/checkpoint"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
func TestDesktopRewindCommitAndUndoUseAuthoritativeControllerState(t *testing.T) {
dir := t.TempDir()
root := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
filePath := filepath.Join(root, "a.txt")
if err := os.WriteFile(filePath, []byte("after"), 0o644); err != nil {
t.Fatal(err)
}
fileInfo, err := os.Stat(filePath)
if err != nil {
t.Fatal(err)
}
diskMode := uint32(fileInfo.Mode().Perm())
before := "before"
afterExists := true
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{
SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: time.Now(), Prompt: "edit", MsgIndex: 3,
Coverage: checkpoint.CoverageComplete,
Files: []checkpoint.FileSnap{{
Path: "a.txt", Content: &before, SHA256: checkpoint.Digest([]byte(before)), Mode: diskMode,
AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("after")), AfterMode: diskMode,
CaptureSource: checkpoint.CaptureBeforeMutation,
}},
})
session := agent.NewSession("")
session.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "first"},
{Role: provider.RoleAssistant, Content: "answer"},
{Role: provider.RoleUser, Content: "edit"},
{Role: provider.RoleAssistant, Content: "done"},
})
if err := session.Save(sessionPath); err != nil {
t.Fatal(err)
}
ag := agent.New(nil, nil, session, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, Runner: ag, SessionDir: dir, SessionPath: sessionPath, WorkspaceRoot: root, Label: "test"})
app := &App{}
app.setTestCtrl(ctrl, "test")
plan := app.PreviewRewindForTab("test", 1, "both")
if !plan.OK || !plan.CanFiles || !plan.CanConversation {
t.Fatalf("preview = %+v", plan)
}
result := app.CommitRewindForTab("test", plan.PlanID, 1, "both")
if !result.OK || !result.UndoAvailable || result.TransactionID == "" {
t.Fatalf("commit = %+v", result)
}
if got, err := os.ReadFile(filePath); err != nil || string(got) != before {
t.Fatalf("file after commit = %q err=%v", got, err)
}
if got := ctrl.History(); len(got) != 3 {
t.Fatalf("controller history after commit = %d, want 3", len(got))
}
if got := app.HistoryForTab("test"); len(got) != 3 {
t.Fatalf("desktop history after commit = %d, want 3", len(got))
}
undo := app.UndoRewindForTab("test", result.TransactionID)
if !undo.OK {
t.Fatalf("undo = %+v", undo)
}
if got, err := os.ReadFile(filePath); err != nil || string(got) != "after" {
t.Fatalf("file after undo = %q err=%v", got, err)
}
if got := ctrl.History(); len(got) != 5 {
t.Fatalf("controller history after undo = %d, want 5", len(got))
}
if got := app.HistoryForTab("test"); len(got) != 5 {
t.Fatalf("desktop history after undo = %d, want 5", len(got))
}
}
func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
t.Helper()
b, err := json.Marshal(c)
@@ -60,11 +138,16 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
t.Fatal(err)
}
content := "old"
afterExists := true
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2,
Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2,
Coverage: checkpoint.CoverageComplete,
Files: []checkpoint.FileSnap{{
Path: "a.txt", Content: &content, SHA256: checkpoint.Digest([]byte(content)),
AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("new")), CaptureSource: checkpoint.CaptureBeforeMutation,
}}})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
@@ -108,6 +191,36 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
assertCheckpointFilesEncodeAsArray(t, metas)
}
func TestCheckpointsCanCodeDoesNotReenableLegacySuffix(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
content := "old"
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "before", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "legacy edit", MsgIndex: 2,
Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
ctrl.SetSessionPath(sessionPath)
app := &App{}
app.setTestCtrl(ctrl, "test")
metas := app.CheckpointsForTab("test")
if len(metas) != 2 {
t.Fatalf("checkpoints = %d, want 2", len(metas))
}
for _, meta := range metas {
if meta.CanCode {
t.Fatalf("legacy suffix re-enabled code rewind at turn %d: %+v", meta.Turn, meta)
}
}
}
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
@@ -117,7 +230,7 @@ func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
}
content := "old"
files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
for i := 0; i < checkpointFilePreviewLimit+5; i++ {
for i := range checkpointFilePreviewLimit + 5 {
files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
}
now := time.Now()
@@ -83,7 +83,7 @@ func readVerifiedWindowsStagedPayload(path string) ([]byte, error) {
)
if verifyErr != nil {
if closeErr != nil {
return nil, fmt.Errorf("Authenticode verification failed: %v (release trust state: %w)", verifyErr, closeErr)
return nil, fmt.Errorf("Authenticode verification failed: %w (release trust state: %w)", verifyErr, closeErr)
}
return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr)
}
+1 -1
View File
@@ -227,7 +227,7 @@ func TestCopyOwnedRegularFileCopiesMatchingOwner(t *testing.T) {
}
}
// --- runInstall table-driven coverage via injectable deps ---
// runInstall table-driven coverage via injectable deps
type installProbe struct {
phases []string
+163 -43
View File
@@ -5,6 +5,7 @@ package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
@@ -19,21 +20,24 @@ import (
"golang.org/x/sys/windows"
"reasonix/desktop/internal/winuninstall"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
const parentExitTimeout = 2 * time.Minute
var (
waitForProcessExitFn = waitForProcessExit
runInstallerFn = runInstaller
startRelaunchFn = startRelaunch
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
stageInstallerFn = stageVerifiedInstaller
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
lstatUpdateStagingFn = os.Lstat
waitForProcessExitFn = waitForProcessExit
runInstallerFn = runInstaller
startRelaunchFn = startRelaunch
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
stageInstallerFn = stageVerifiedInstaller
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
lstatUpdateStagingFn = os.Lstat
reconcileWindowsUninstallRegistrationFn = winuninstall.Reconcile
)
func main() {
@@ -42,7 +46,7 @@ func main() {
func run(args []string) int {
var parentPID uint
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID string
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
@@ -53,6 +57,7 @@ func run(args []string) int {
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
if err := fs.Parse(args); err != nil {
return 2
}
@@ -69,11 +74,15 @@ func run(args []string) int {
logger.Print("missing --to-version")
return 2
}
if createdAt == "" {
if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
logger.Printf("unsupported --install-layout %q", installLayout)
return 2
}
if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
logger.Print("missing --created-at")
return 2
}
if transactionID == "" {
if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
logger.Print("missing --transaction-id")
return 2
}
@@ -87,6 +96,25 @@ func run(args []string) int {
return 1
}
}
if installLayout == installlayout.InstallLayoutVersionedV1 {
return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
}
recoverExisting := func(reason string) int {
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after %s: %v", reason, relaunchErr)
}
}
return 1
}
// The detached helper runs from the update cache, so a plain
// repair.ReadPendingUpdate would validate the transaction against the cache
// directory and reject every legitimate flat install. Claim through the
// explicit install-local launcher path instead; the repair package validates
// the complete transaction identity and exact release-unit path set while it
// acquires the pending + target locks.
claimTargets := windowsReleaseUnitPaths(installDir)
claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
// The old desktop may acquire the pending lock during its normal shutdown.
// Wait for that exact process first, then claim the transaction and hold both
// pending and target locks across the installer replacement window.
@@ -94,13 +122,13 @@ func run(args []string) int {
toVersion,
createdAt,
transactionID,
filepath.Join(installDir, "reasonix-desktop.exe"),
windowsReleaseUnitPaths(installDir),
claimLauncher,
claimTargets,
parentExitTimeout,
)
if err != nil {
logger.Printf("claim pending update: %v", err)
return 1
return recoverExisting("pending update claim failure")
}
defer releaseClaim()
recoverUnstarted := func() int {
@@ -161,29 +189,41 @@ func run(args []string) int {
}
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after publish failure: %v", relaunchErr)
}
}
return 1
}
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
if receipts == nil {
// Versioned-v1 activation: no flat publish receipts and no health
// probation. Clear the pending transaction and relaunch the launcher.
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("clear pending after versioned activate: %v", cancelErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
}
} else {
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
}
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
if relaunch != "" {
if err := startRelaunchFn(relaunch, installDir); err != nil {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
@@ -191,6 +231,92 @@ func run(args []string) int {
return 0
}
func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
recoverExisting := func() int {
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch after versioned update failure: %v", err)
}
}
return 1
}
if _, err := installlayout.ReadCurrent(installDir); err != nil {
logger.Printf("read versioned install pointer: %v", err)
return recoverExisting()
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind versioned update installer: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create versioned update staging: %v", err)
return recoverExisting()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind versioned update staging: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged versioned installer: %v", err)
return recoverExisting()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract versioned installer payload: %v", installerErr)
return recoverExisting()
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: toVersion,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
}
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
logger.Printf("activate versioned release: %v", err)
return recoverExisting()
}
if _, err := reconcileWindowsUninstallRegistrationFn(installDir, toVersion); err != nil {
// The release is already active and must not be rolled back for stale
// Add/Remove Programs metadata. A later update or full installer retries
// this idempotent reconciliation.
logger.Printf("reconcile Windows uninstall registration: %v", err)
}
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch versioned release: %v", err)
return 1
}
}
return 0
}
// preferRelaunchPath chooses the thin launcher when present so post-update
// restarts use the permanent entry point, not a flat desktop binary.
func preferRelaunchPath(relaunch, installDir string) string {
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
path := filepath.Join(installDir, name)
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
return path
}
}
return relaunch
}
func validSHA256(value string) bool {
value = strings.TrimSpace(value)
if len(value) != sha256.Size*2 {
@@ -328,21 +454,15 @@ func installStagedWindowsReleaseUnit(
claimed *repair.UpdateTransaction,
stagingDir string,
) (bool, []repair.FileUpdateInstallReceipt, error) {
members, err := loadWindowsStagedReleaseUnit(claimed, stagingDir)
if err != nil {
return false, nil, err
// v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
// fail closed without mutating the live install or leaving flat pending state.
if !preferVersionedWindowsActivation(stagingDir) {
return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
}
// Persist recovery intent before the first live rename. If the helper exits
// between release-unit members, Guard rolls the exact pending transaction
// back instead of launching a mixed install.
if err := repair.MarkUpdateApplyFailedExact(claimed, "Windows update publish did not complete"); err != nil {
return false, nil, fmt.Errorf("record update recovery intent: %w", err)
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
return false, nil, fmt.Errorf("versioned activate: %w", err)
}
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, repair.PublishClaimedFileUpdateMemberExact)
if err != nil {
return true, receipts, err
}
return true, receipts, nil
return true, nil, nil
}
func windowsReleaseUnitPaths(installDir string) []string {
@@ -380,7 +500,7 @@ func newLogger() *log.Logger {
func waitForProcessExit(pid uint32, timeout time.Duration) error {
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
if err != nil {
if err == windows.ERROR_INVALID_PARAMETER {
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
return nil
}
return err
@@ -414,7 +534,7 @@ func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
if path == "" || owner == nil || !owner.IsDir() {
return fmt.Errorf("Windows update cleanup identity is incomplete")
}
for attempt := 0; attempt < 16; attempt++ {
for attempt := range 16 {
cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
from, err := windows.UTF16PtrFromString(path)
if err != nil {
+163 -30
View File
@@ -12,11 +12,27 @@ import (
"testing"
"time"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
t.Helper()
paths := windowsReleaseUnitPaths(installDir)
for _, path := range paths {
if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
t.Fatal(err)
}
}
tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
if err != nil {
t.Fatal(err)
}
return tx
}
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
source := filepath.Join(t.TempDir(), "installer.exe")
content := []byte("verified-installer")
@@ -72,8 +88,99 @@ func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
installDir := t.TempDir()
seed := t.TempDir()
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installDir,
Version: "v1.20.0",
RequestID: "seed-windows-helper",
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
{Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
{Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
},
RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
}); err != nil {
t.Fatal(err)
}
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn
t.Cleanup(func() {
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall
})
legacyClaimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
legacyClaimed = true
return nil, func() {}, errors.New("legacy claim must not run")
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
reconciledVersion := ""
reconcileWindowsUninstallRegistrationFn = func(root, targetVersion string) (bool, error) {
if root != installDir {
t.Fatalf("reconcile root = %q, want %q", root, installDir)
}
reconciledVersion = targetVersion
return true, nil
}
runInstallerFn = func(_ string, staging string) error {
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
return err
}
}
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error { relaunched = true; return nil }
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
"--to-version", "v1.20.1",
"--install-layout", "versioned-v1",
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
if legacyClaimed {
t.Fatal("versioned update claimed legacy pending transaction")
}
if !relaunched {
t.Fatal("versioned update did not relaunch")
}
if reconciledVersion != "v1.20.1" {
t.Fatalf("reconciled version = %q, want v1.20.1", reconciledVersion)
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.1" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
var events []string
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
@@ -107,11 +214,11 @@ func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
}
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
return &repair.UpdateTransaction{}, func() { events = append(events, "release") }, nil
return pending, func() { events = append(events, "release") }, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
events = append(events, "publish")
return true, nil, nil
return true, []repair.FileUpdateInstallReceipt{}, nil
}
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
events = append(events, "record-installed")
@@ -132,16 +239,16 @@ func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
"--to-version", pending.ToVersion,
"--created-at", pending.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(pending),
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
wantClaim := "claim:" + strings.Join([]string{
"v2",
"2026-07-29T00:00:00Z",
"transaction-1",
pending.ToVersion,
pending.CreatedAt,
repair.UpdateTransactionID(pending),
filepath.Join(installDir, "reasonix-desktop.exe"),
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
}, "\x01")
@@ -197,6 +304,44 @@ func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
}
}
func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
relaunched := false
startRelaunchFn = func(path, dir string) error {
relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
return nil
}
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return nil, nil, errors.New("pending lock unavailable")
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", "2026-08-02T00:00:00Z",
"--transaction-id", strings.Repeat("a", 64),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch the existing desktop")
}
}
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
@@ -221,12 +366,7 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
startRelaunchFn = func(string, string) error { return nil }
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
@@ -236,7 +376,6 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
const createdAt = "2026-07-29T00:00:00Z"
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
@@ -244,7 +383,7 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", createdAt,
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
@@ -280,12 +419,7 @@ func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.
relaunched = true
return nil
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
@@ -296,7 +430,11 @@ func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.
return func() {}, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
return true, nil, nil
return true, []repair.FileUpdateInstallReceipt{{
UpdateTransactionID: repair.UpdateTransactionID(claimed),
TargetPath: claimed.TargetPath,
InstalledStateID: "installed",
}}, nil
}
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
return nil, errors.New("release unit drifted")
@@ -351,12 +489,7 @@ func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
relaunched = true
return nil
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
releases := 0
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() { releases++ }, nil
@@ -0,0 +1,118 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
// staged NSIS payload:
//
// InstallRoot/
// reasonix-launcher.exe
// Reasonix.exe (launcher alias when present or portable)
// reasonix-cli.exe (CLI entry; full binary for now)
// current.json
// versions/<version>/
// reasonix-desktop.exe
// reasonix-cli.exe
// reasonix-update-helper.exe
//
// Any failure before the current.json pointer swap leaves the previous active
// version unchanged. The helper never counts crashes or selects prior versions.
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
if claimed == nil {
return fmt.Errorf("versioned activate: transaction is nil")
}
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
// When the claimed primary is already under versions/<ver>/, climb to root.
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
installRoot = root
}
version := strings.TrimSpace(claimed.ToVersion)
if err := installlayout.ValidateVersionName(version); err != nil {
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if err := installlayout.ValidateVersionName(version); err != nil {
return fmt.Errorf("versioned activate: %w", err)
}
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
}
}
requestID := repair.UpdateTransactionID(claimed)
if requestID == "" {
requestID = "helper-" + version
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: version,
RequestID: requestID,
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
},
RootMembers: []installlayout.Member{
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
},
RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
}); err != nil {
return err
}
// Remove flat release-unit leftovers so the install root is the thin layout.
// Do not remove the launcher/CLI/alias we just wrote.
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-update-helper.exe", // helper lives only under versions/
} {
_ = os.Remove(filepath.Join(installRoot, name))
}
// Best-effort retention GC of older version trees.
_ = installlayout.RetainPreviousVersions(installRoot, 0)
_ = installlayout.CleanupStaleStaging(installRoot, 0)
return nil
}
// preferVersionedWindowsActivation reports whether the staged payload is
// complete enough for versioned-v1 activation.
func preferVersionedWindowsActivation(stagingDir string) bool {
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
info, err := os.Lstat(filepath.Join(stagingDir, name))
if err != nil || !info.Mode().IsRegular() {
return false
}
}
return true
}
@@ -0,0 +1,129 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
func TestPreferVersionedWindowsActivation(t *testing.T) {
staging := t.TempDir()
if preferVersionedWindowsActivation(staging) {
t.Fatal("empty staging must not prefer versioned")
}
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
if !preferVersionedWindowsActivation(staging) {
t.Fatal("complete staging must prefer versioned")
}
}
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
installDir := t.TempDir()
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Seed a flat desktop so cleanup is observable.
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
t.Fatal(err)
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("current.json missing")
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
desktop, err := installlayout.ActiveDesktopPath(installDir)
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(desktop)
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
t.Fatalf("desktop payload = %q err=%v", raw, err)
}
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
t.Fatalf("root entry %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed")
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
t.Fatal("flat guard should be removed")
}
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
t.Fatalf("prefer relaunch = %s", prefer)
}
}
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
installDir := t.TempDir()
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Minimal claimed unit (versioned path does not need full flat file list).
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if !started || receipts != nil {
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("versioned activation did not write current.json")
}
}
+1 -1
View File
@@ -155,7 +155,7 @@ func baseCrashReport(kind string) crashReport {
}
func topFrameFromStack(stack string) string {
for _, line := range strings.Split(stack, "\n") {
for line := range strings.SplitSeq(stack, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
+23 -1
View File
@@ -22,13 +22,35 @@ func TestParseWebView2ProcessFailure(t *testing.T) {
}
func TestWebView2ProcessFailureReportIsStructured(t *testing.T) {
report := webView2ProcessFailureReport(2)
report := webView2ProcessFailureReportWithContext(2, 3, "132.0.2957.140")
if report.Source != "webview2.process" || report.Label != "windows.webview2.process_failed" {
t.Fatalf("report = %+v", report)
}
if report.FingerprintHint != "windows.webview2.render_process_unresponsive" {
t.Fatalf("fingerprint hint = %q", report.FingerprintHint)
}
for _, want := range []string{"runtime version: 132.0.2957.140", "same-process occurrence: 3", "exit code: unavailable"} {
if !strings.Contains(report.Message, want) {
t.Fatalf("report message missing %q: %s", want, report.Message)
}
}
}
func TestWebView2FailureTrackerDeduplicatesSessionBursts(t *testing.T) {
var tracker webView2FailureTracker
base := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
if occurrence, report := tracker.observe(2, base); occurrence != 1 || !report {
t.Fatalf("first observation = (%d, %v)", occurrence, report)
}
if occurrence, report := tracker.observe(2, base.Add(time.Minute)); occurrence != 2 || report {
t.Fatalf("burst observation = (%d, %v)", occurrence, report)
}
if occurrence, report := tracker.observe(2, base.Add(webView2FailureReportCooldown)); occurrence != 3 || !report {
t.Fatalf("post-cooldown observation = (%d, %v)", occurrence, report)
}
if occurrence, report := tracker.observe(6, base.Add(time.Minute)); occurrence != 1 || !report {
t.Fatalf("different kind observation = (%d, %v)", occurrence, report)
}
}
func TestPreviousRunReportUsesOnlyBoundedLifecycleContext(t *testing.T) {
-6
View File
@@ -146,12 +146,6 @@ func (a *App) flushPendingCrash() {
if version == "dev" {
return
}
// Safe Mode boots from built-in defaults and cannot read the user's real
// telemetry preference, so it must neither send the pending report nor
// consume it: leave the file for the next normal boot to decide.
if config.SafeModeRequested() {
return
}
paths := pendingCrashPaths()
if len(paths) == 0 {
return
Loaded 100 of 1494 files, more files were not shown because too many files have changed in this diff. Show more