Skip to content

feat(runtime-compat): the engine side of the desktop repair card (#5672 slice 2, E1) - #5700

Merged
qingyun-wu merged 5 commits into
feat/runtime-compat-repairfrom
feat/runtime-compat-app-surface
Nov 5, 2026
Merged

qingyun-wu merged 5 commits into
feat/runtime-compat-repairfrom
feat/runtime-compat-app-surface

Conversation

@qingyun-wu

@qingyun-wu qingyun-wu commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #5672 (base feat/runtime-compat-repair, head 5e19931). Merge order: #5672 first, then this PR, then the desktop D0 pin bump.

Slice 2 of #5672, engine part "E1", from the plan page "#5672 slice 2 — desktop repair plan" (§2 E1-a..d, as amended by §7 D-1..D-3, R-2, R-3).

Status

  • 134376c (draft checkpoint): the regression tests only, red on feat(runtime-compat): detect and repair an unusable Claude Code or Codex CLI before a launch #5672's head.

  • a165967: the implementation in src/runtime_compat.py, the review follow-ups below, and the docs. All green locally.

  • 4d01b67: one declared phase set (REPAIR_PHASES). An undeclared phase raises. done is a stdout progress line only: it is never written to a record, and a swap journal carrying it is rolled back, not finished (RepairPhases, 5 tests).

  • 5a94103: fix, from review. The retry budget was checked only after a failure, so a backoff clipped to the remaining budget plus scheduler delay could start attempt 2 past the deadline (at 30.01 s). The budget is now checked again before each later attempt is dispatched. Fake-clock tests cover an exact-deadline case (fail at 29 s, 1 s backoff) and overslept backoffs (29 s + 5 s, 1 s + 40 s).

    python3 tests/runtime-compat-app-surface.test.py AppRepairBudget with the new tests, before the fix (4d01b67's src/):

    FAIL: test_an_overslept_backoff_does_not_start_another_attempt (__main__.AppRepairBudget.test_an_overslept_backoff_does_not_start_another_attempt)
    AssertionError: Lists differ: [0.0, 30.01] != [0.0]
    FAIL: test_no_attempt_starts_at_the_exact_deadline (__main__.AppRepairBudget.test_no_attempt_starts_at_the_exact_deadline)
    AssertionError: Lists differ: [0.0, 30.0] != [0.0]
    Ran 5 tests in 0.691s
    FAILED (failures=2)
    

    after (5a94103):

    Ran 5 tests in 0.602s
    OK
    

What it adds (contract the desktop parses)

Item Surface Test class
E1-a repair progress the app can parse repair --json: {"type":"progress","phase":"checking-disk"|"downloading"|"verifying"|"switching"|"done", "attempt", "attempts"} lines, then one {"type":"result", ok, reason, detail, user_action, attempts, path, version} RepairJson
E1-b in-app consent repair --consent-source app --consent-by <mxid> [--plan-hash H]: records consent{source,by,at,plan_hash,runtime} in state/runtime-compat-<cli>.json before acting; refuses a changed plan (plan-changed, exit 2); success closes the incident (resolved_by: app), tells the owner in the DM thread, and answers pending or late "install compatible …" replies with "already fixed from the app" AppConsent
E1-c no wait / no DM for app-launched cores SUTANDO_RUNTIME_CONSENT_SURFACE=app: ensure never waits and never queues the DM, whatever --wait / --notify-owner say ConsentSurfaceApp, gate AppLaunchedCoreNeverWaitsOrDms (real launcher)
E1-d stable doctor --json each entry gains schema: 1, selected_reason, plan_hash, auth{login,remedy,detail}; key sets pinned DoctorJsonSchema
D-2 narrow prefer-managed + logged reason selection is unchanged (first usable); selected_reason e.g. chose <runtimes>/bin/claude (managed) 2.1.0 because ~/.npm-global/bin/claude (PATH) is broken: --version exited 1, in doctor --json and one runtime-compat: chose … line on every ensure / which PreferManaged
D-3 notify-owner notify-owner --runtime R [--json] → {queued, reason}; once per incident via Incident.notify; nothing when the CLI is usable now NotifyOwner
R-2 check timeouts app surface: 10 s per binary, 60 s per discovery; candidates past the cap are unknown: not probed in time and unusable Timeouts
R-3 download retries app surface: no retry starts after 30 s, backoff clipped to the budget, npm's own fetch retries off, attempt counter in the progress lines AppRepairBudget

Contract notes (from Mini's review of 134376c):

  • R-3 is stricter than "30 s total backoff": the 30 s budget is measured from the start of the first download attempt and covers the attempts themselves, not only the sleeps. No new attempt starts once 30 s have passed (checked right before each attempt is dispatched), so a slow blocked download can end with fewer than 3 attempts (test_app_surface_blocked_download_gives_up_within_budget pins attempts < 3 for 20 s attempts). That is the contract the card's attempt counter shows. A single attempt still runs to completion, so a slow but working download is never cut off.
  • done phase: chose to emit it in code, as §2 E1-a lists it. {"type":"progress","phase":"done"} comes right before the result line, on success and failure. The plan page is not changed.
  • Codex: the real Codex launcher is covered too: test_app_surface_broken_codex_stops_at_once_without_a_dm in tests/codex-core-launcher.test.py.
  • D-2: prefer-managed cases now cover broken, too-old, incompatible, known-bad and absent. The golden test pins each candidates[] entry's keys, and auth.login is one of ok, logged-out or unknown (AUTH_LOGIN_VALUES; expired is item 7/E3, not this PR).
  • The no-wait test also asserts that no installer ran and that no managed link exists.

Two tests already pass at the base and stay as pins: test_repair_without_json_keeps_stdout_empty (prose stays on stderr) and test_missing_cli_plans_npm_install_via_bundled_node (R-3 fresh machine).

Evidence: red at #5672's head 5e19931 (tests from this branch)

python3 tests/runtime-compat-app-surface.test.py

ERROR: test_app_consent_closes_the_incident_and_answers_the_open_dm
ERROR: test_app_consent_is_recorded_before_the_repair_runs
ERROR: test_changed_plan_refuses_the_consent
ERROR: test_dm_reply_after_app_repair_reads_as_already_fixed
ERROR: test_app_surface_blocked_download_gives_up_within_budget
ERROR: test_codex_auth_block
ERROR: test_nothing_selected_has_no_auth
ERROR: test_plan_hash_is_stable_and_follows_the_plan
ERROR: test_a_new_incident_can_dm_again
ERROR: test_notify_owner_after_resolution_sends_nothing
ERROR: test_notify_owner_queues_once_per_incident
ERROR: test_managed_preferred_when_path_binary_is_absent
ERROR: test_managed_preferred_when_path_binary_is_broken
ERROR: test_managed_preferred_when_path_binary_is_known_bad
ERROR: test_newer_untested_path_binary_is_kept
ERROR: test_nothing_usable_says_so
ERROR: test_older_path_binary_that_passes_doctor_is_kept_over_a_newer_managed
ERROR: test_repair_json_failure_counts_every_attempt
ERROR: test_repair_json_streams_phases_then_one_result_line
ERROR: test_a_hanging_cli_is_unusable_within_the_cap_for_real
ERROR: test_app_surface_probe_timeout_is_10s_per_binary
ERROR: test_discovery_deadline_reports_unprobed_candidates_as_unusable
ERROR: test_without_the_app_surface_probes_keep_the_30s_timeout
FAIL: test_app_surface_turns_off_npm_own_fetch_retries
FAIL: test_app_surface_logged_out_returns_4_at_once
FAIL: test_app_surface_never_waits_or_dms
FAIL: test_doctor_json_schema_1_golden
FAIL: test_every_launch_logs_the_chosen_binary_and_reason
Ran 30 tests in 55.824s
FAILED (failures=5, errors=23)

tests/runtime-compat-gate.test.py AppLaunchedCoreNeverWaitsOrDms (real start-cli.sh, broken stub claude, SUTANDO_RUNTIME_WAIT_S unset):

AssertionError: the launch was still waiting for a fix after 60s
Ran 1 test in 60.306s
FAILED (failures=1)

CI at 134376c was red only for those two new files: python standalone tests (6/9) failed on tests/runtime-compat-app-surface.test.py and (7/9) on tests/runtime-compat-gate.test.py. tsc + tests (clean install) is the aggregate job that requires them.

Evidence: green at a165967 (and 4d01b67)

Run locally with the host's SUTANDO_* and TMUX* variables unset:

== runtime-compat-app-surface
Ran 33 tests (38 at 4d01b67, OK) in 33.994s
OK
== runtime-compat
Ran 139 tests in 50.698s
OK
== runtime-compat-gate
Ran 10 tests in 10.717s
OK
== startup-managed-cli
Ran 4 tests in 9.012s
OK
== codex-core-launcher
ERROR: test_managed_notifier_waits_for_each_result_before_next_task (__main__.CodexCoreLauncherTests.test_managed_notifier_waits_for_each_result_before_next_task)
FAIL: test_launches_codex_and_managed_task_notifier (__main__.CodexCoreLauncherTests.test_launches_codex_and_managed_task_notifier)
FAIL: test_managed_notifier_does_not_recover_stale_running_from_PROSE_containing_the_placeholder (__main__.CodexCoreLauncherTests.test_managed_notifier_does_not_recover_stale_running_from_PROSE_containing_the_placeholder)
FAIL: test_managed_notifier_does_not_treat_stale_running_no_affordance_as_idle (__main__.CodexCoreLauncherTests.test_managed_notifier_does_not_treat_stale_running_no_affordance_as_idle)
FAIL: test_notifier_supervisor_survives_child_process_group_cleanup (__main__.CodexCoreLauncherTests.test_notifier_supervisor_survives_child_process_group_cleanup)
Ran 88 tests in 323.125s
FAILED (failures=4, errors=1)
== start-cli-claude-config-dir.test.sh
PASSED: 5
FAILED: 0
== startup-core-auth-routing.test.sh
Results: 15 passed, 0 failed

codex-core-launcher: the same two tests (test_managed_notifier_waits_for_each_result_before_next_task, test_launches_codex_and_managed_task_notifier: "managed core-input monitor did not start") also fail at the base 5e19931 on this host. They are host-specific and not touched by this diff. The three other notifier tests that failed in the full run pass in isolation, both here and at the base. CI is the arbiter.

At 5a94103:

== runtime-compat-app-surface
Ran 40 tests in 41.845s
OK
== runtime-compat
Ran 139 tests in 73.075s
OK
== runtime-compat-gate
Ran 10 tests in 14.724s
OK

Also: ruff clean, scripts/review-checks.sh PASS on the diff against the base, and gen-src-map --check up to date.

Stub CLIs and temp dirs only; no real credentials.

🤖 Generated with Claude Code

qingyun-ag2 and others added 2 commits October 18, 2026 07:04
… needs (red)

Regression tests for slice 2's engine additions (E1), stacked on #5672.
They fail at #5672's head 5e19931 and pass once the implementation lands:

- repair --json: one progress line per phase, then one result line
- consent recorded from the app before the repair acts; success closes the
  incident, tells the owner in the DM thread, and answers late replies
- SUTANDO_RUNTIME_CONSENT_SURFACE=app: no wait, no DM (module and real launcher)
- doctor --json schema 1 with selected_reason, plan_hash and auth
- the chosen binary and the reason, in doctor --json and every launch log
- notify-owner: at most one DM per incident, none once usable
- app-surface time bounds: 10 s per binary, 60 s per discovery, 30 s retries

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When the desktop app launches the core it is the consent surface, so the
engine must answer fast, never DM on its own, and give the app a stable
contract to render and act on:

- repair --json streams checking-disk, downloading (attempt n of N),
  verifying, switching and done, then one result line with the version.
- repair --consent-source app --consent-by MXID [--plan-hash H] records the
  consent on the incident before acting and refuses a changed plan. Success
  closes the incident, sends one "fixed from the app" follow-up when a DM
  went out, and answers pending or late install replies instead of
  repairing twice.
- SUTANDO_RUNTIME_CONSENT_SURFACE=app: ensure never waits and never queues
  the DM; probes get 10 s per binary and 60 s per discovery (later
  candidates are "unknown", never a pass); no download attempt starts 30 s
  after the first, and npm's own fetch retries are off.
- doctor --json entries carry schema 1, selected_reason, plan_hash and auth.
- Every ensure and which logs the chosen binary and why; selection itself
  is unchanged (the first usable one wins).
- notify-owner queues the incident's one DM for an unanswered card, and
  nothing once the CLI works.

Review follow-ups from the contract review: the no-wait test asserts no
installer ran, too-old and incompatible prefer-managed cases, candidate keys
and auth.login values pinned, a done phase, and the Codex launcher case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 92%.

Diff Coverage

Diff: origin/feat/runtime-compat-repair...HEAD, staged and unstaged changes

  • src/runtime_compat.py (98.7%): Missing lines 1254,1261

Summary

  • Total: 157 lines
  • Missing: 2 lines
  • Coverage: 98%

src/runtime_compat.py

Lines 1250-1258

  1250     def out(queued: bool, reason: str) -> int:
  1251         if a.json:
  1252             print(json.dumps({"queued": queued, "reason": reason}))
  1253         else:
! 1254             _say(f"owner DM {'queued' if queued else 'not queued'}: {reason}")
  1255         return 0
  1256     report = discover(a.runtime, env=env)
  1257     if report.selected is not None:
  1258         return out(False, "usable")

Lines 1257-1265

  1257     if report.selected is not None:
  1258         return out(False, "usable")
  1259     ws = _workspace()
  1260     if ws is None:
! 1261         return out(False, "no-workspace")
  1262     inc = Incident(ws, a.runtime)
  1263     if inc.notify(notice_body(a.runtime, plan(report, env=env))):
  1264         return out(True, "queued")
  1265     return out(False, "already-sent" if inc.rec.get("notice_queued") else "not-queued")

qingyun-ag2 and others added 2 commits October 18, 2026 07:39
…r resumable

REPAIR_PHASES is the only phase set repair --json may emit; an undeclared
phase raises. Tests pin the set, the order on success and failure runs,
done exactly once and last, that done is never written to an on-disk
record, and that a swap journal carrying it is rolled back, not finished.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…budget is spent

The retry loop checked the budget only after a failure, so a backoff clipped
to the remaining budget, plus any scheduler delay, could still start another
attempt past the deadline (attempt 2 at 30.01 s). The budget is now checked
again before each later attempt is dispatched; when it is spent, the repair
stops with the last attempt's failure.

Tests (fake clock): an attempt failing at 29 s whose 1 s backoff lands exactly
on the deadline, and overslept backoffs (29 s + 5 s, 1 s + 40 s). Each starts
only the first attempt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@qingyun-wu
qingyun-wu marked this pull request as ready for review October 18, 2026 14:57

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review at 5a94103a0: no change requested. Not approving yet because there is no expert approval at this head.

What I checked

  • Read the whole diff against the base feat/runtime-compat-repair (5e19931; the branch is up to date with it): src/runtime_compat.py, the three test files, and docs/runtime-compat-repair-matrix.md.
  • tests/runtime-compat-app-surface.test.py at this head: Ran 40 tests ... OK. With src/runtime_compat.py swapped back to the base version, the same file gives FAILED (failures=5, errors=32). So the tests exercise the real module, not a copy.
  • The budget fix in 5a94103: with src/ from 4d01b67, AppRepairBudget fails exactly the two new cases (test_no_attempt_starts_at_the_exact_deadline, test_an_overslept_backoff_does_not_start_another_attempt). At this head they pass. _run_with_retry now checks the budget again before each later attempt is dispatched, and clips the backoff to what is left.
  • tests/runtime-compat.test.py (the existing suite) is still OK at this head.
  • None of the new tests assert on module source. Their read_text() calls read only output files: records, notices, results.
  • No added line has a hardcoded ~/.sutando path or a live SUTANDO_WORKSPACE default. CI is all green, and the commit status has license/cla: success (1 context). There are no unresolved review threads.
  • E1-c is applied where the launchers enter. runtime-compat-gate.sh calls ensure, and main() forces wait_s=0 and notify_owner=False under SUTANDO_RUNTIME_CONSENT_SURFACE=app. The real-launcher gate test AppLaunchedCoreNeverWaitsOrDms pins this.

Non-blocking notes

  • _repair_cmd: when --consent-source app is given but _workspace() returns None, the repair still runs. In that case no consent is recorded and resolve_from_app is skipped. E1-b says consent is recorded before acting. Either refuse there, or say in the docs that this is the degraded path.
  • ensure() applies the app-surface rules only through main(). A future Python caller of ensure() would not get them. Moving the check into _ensure would keep the contract in one place.
  • The early refusals (no-consent, plan-changed) emit a result line with no done progress line before it. That is fine, but the desktop parser should not wait for done.

What's missing before this can be approved

  • At least one substantive APPROVED review at this head from a maintainer with write access.
  • It is stacked on #5672, which is still open, so the merge order in the body applies.
  • Owed, not blocking: one real round trip through the desktop repair card (doctor, then consent, then repair --json progress, then the incident closed in the DM). This is a startup path, and every test here uses stub CLIs.

— Sutando (susans-MacBook-Pro)

An app --consent-source with no --plan-hash skipped the changed-plan
check entirely, so a stale app card could trigger whatever repair plan
is current. A workspace that failed to resolve also let the install
run without ever recording the consent that authorized it.

Both paths now refuse before any install: a missing or mismatched
--plan-hash under --consent-source app ("plan-hash-required" /
"plan-changed"), and an unresolvable workspace under any consent
source ("workspace-unavailable").

Red-first in tests/runtime-compat-app-surface.test.py:
test_app_consent_without_a_plan_hash_is_refused and
test_missing_workspace_refuses_before_installing fail on the prior
head and pass after the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Fixed the app-consent plan-hash gap in src/runtime_compat.py (_repair_cmd):

  • --consent-source app now requires a non-empty --plan-hash; missing it refuses before any install (plan-hash-required, exit 2). A mismatched hash was already refused (plan-changed) — that path is unchanged.
  • Also closed a related gap: if the workspace can't be resolved, consent recording was silently skipped while the install still ran. Now any --consent-source with no resolvable workspace refuses before installing (workspace-unavailable, exit 2), since consent can't be recorded.

Red-first tests added in tests/runtime-compat-app-surface.test.py (AppConsent), both confirmed failing on the prior head and passing after the fix:

  • test_app_consent_without_a_plan_hash_is_refused
  • test_missing_workspace_refuses_before_installing

Updated three existing tests that relied on the old (buggy) no-plan-hash shortcut to now pass a real plan hash: test_app_consent_closes_the_incident_and_answers_the_open_dm, test_dm_reply_after_app_repair_reads_as_already_fixed, test_notify_owner_after_resolution_sends_nothing.

Full runtime-compat-app-surface.test.py (42 tests) and runtime-compat.test.py (139 tests) pass. Updated docs/runtime-compat-repair-matrix.md to document the two new refusal reasons.

New head: 67e4eae

@marklysze marklysze left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 67e4eae (stacked on #5672; CI: 33 pass, nothing failing or pending). Scope: the two new refusals in _repair_cmd and their tests, as asked. Approving; two low findings.

What I checked

  • Both refusals sit before assisted_install, so nothing installs on either path: plan-hash-required (src/runtime_compat.py:1214) is ahead of the plan computation, and workspace-unavailable (:1228) is ahead of Incident(...) and record_consent. The mismatched-hash refusal that was already there still comes first when a hash is supplied, and each refusal is a RepairResult with exit 2, --json result line and a user_action, the same shape the other refusals use.
  • Tests: runtime-compat-app-surface 42/42 and runtime-compat-gate 10/10 pass here. codex-core-launcher has 8 failures (the notifier_* tests); the identical 8 fail on the base branch on this host (run side by side), so the PR adds none, and its new test_app_surface_broken_codex_stops_at_once_without_a_dm passes.
  • Mutants (each alone, AppConsent/RepairJson/AppRepairBudget): removing the plan-hash refusal, moving it to not consent_source and not plan_hash, removing the workspace refusal, and either refusal exiting 0 are all caught.

Findings

  1. Low: "any consent source" is not what the workspace test pins. The commit and PR say an unresolvable workspace refuses under any consent source, but test_missing_workspace_refuses_before_installing uses --consent-source app only. A mutant that narrows the check to a.consent_source == "app" and ws is None passes the whole suite, so a future non-app source could go back to installing with no consent recorded. A second case with another source (for example --consent-source test) pins it.
  2. Low, design note: a non-app source with no --plan-hash is still accepted and records the consent against the current plan hash (consent_hash = current), i.e. the same stale-card gap this PR closes for app. Nothing sends such a source today (the desktop uses app), and a mutant requiring the hash for every source also survives, so the contract is unspecified either way. Worth deciding which one is intended and pinning it, or limiting --consent-source to the values the engine knows.

Not blockers. The fix does what the description says on both paths.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve at 67e4eae6. The bar is met: @marklysze's substantive approval at this head, CI green (33 success), license/cla: success (1 context), and no unresolved review threads.

What changed since my comment at 5a94103a (one commit, src/runtime_compat.py +12/−1):

  • src/runtime_compat.py:1214-1217: --consent-source app without --plan-hash now refuses with plan-hash-required (exit 2). The check runs before the plan is computed, so a stale card can no longer consent to whatever the plan has become.
  • src/runtime_compat.py:1228-1232: a consent source with no resolvable workspace refuses with workspace-unavailable, ahead of Incident(...) and record_consent. This closes the gap I noted last round, where the repair ran with no consent recorded.

What I ran (scratch worktree, SUTANDO_WORKSPACE* unset):

  • At head: runtime-compat-app-surface 42/42, runtime-compat-gate 10/10, runtime-compat 139/139, all OK.
  • Revert-to-fail: with src/runtime_compat.py from 5a94103a and the tests from head, exactly the two new cases fail, test_app_consent_without_a_plan_hash_is_refused and test_missing_workspace_refuses_before_installing (tests/runtime-compat-app-surface.test.py:220, :227). So the tests exercise the real refusals.
  • scripts/review-checks.sh on the PR diff: PASS. The new tests' read_text() calls read output records and notices, never module source.

No change-request condition fires. The base feat/runtime-compat-repair is the intended stack on #5672, not a wrong base.

Non-blocking

  • I agree with Mark's first finding: the workspace refusal is tested only with --consent-source app, though the code refuses for any source. A second case with another source would pin that.

Still owed, not blocking: one real round trip through the desktop repair card (doctor, consent, repair --json progress, incident closed in the DM). Merge order stays #5672 first, then this PR.

— Sutando (susans-MacBook-Pro)

@qingyun-wu
qingyun-wu merged commit 2ebbdd8 into feat/runtime-compat-repair Nov 5, 2026
34 checks passed
@qingyun-wu
qingyun-wu deleted the feat/runtime-compat-app-surface branch November 5, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants