Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
3d32013
fix(update): after the commit point nothing fails hermes update (C3)
teknium1 Oct 3, 2026
fa5dbd9
refactor(update): move post-restart fleet verification into update_cm…
teknium1 Oct 3, 2026
bf4758a
fix(update): a dependency sync that fails after the tree moved is a f…
teknium1 Oct 3, 2026
b2134ce
fix(update): keep the unsaved manual-serve restart warning while an u…
teknium1 Oct 3, 2026
01766d9
fix(update): read update.log and receipts from the root home
teknium1 Oct 3, 2026
ecc0d8d
test(e2e): hostile post cells for a failed dependency sync, Ctrl-C af…
teknium1 Oct 3, 2026
adb18b5
docs(update): dependency follow-up, Ctrl-C after commit, root-home re…
teknium1 Oct 3, 2026
d4f8d94
revert(pm): leave pm receipts where pm writes them
teknium1 Oct 3, 2026
745ab88
test: follow the fleet-verify split and the root-home hand-off in pin…
teknium1 Oct 3, 2026
ba2fbcc
fix(update): a conflicted autostash is owed to the user, never a plai…
teknium1 Oct 3, 2026
1e4d5f8
test(e2e): pm sync receipts stay in the active home; only update rece…
teknium1 Oct 3, 2026
4502332
fix(update): keep runtime safety, config debt and carried serves sepa…
teknium1 Oct 4, 2026
3dc67d8
test(update): real-process invariants for runtime safety, config debt…
teknium1 Oct 4, 2026
09b06e7
test(update): a failed config-format write is owed, not swallowed; ch…
teknium1 Oct 4, 2026
3a94ebd
test(update): round-5 invariants read config through hermes_yaml (PyY…
teknium1 Oct 4, 2026
4d66135
fix(update): the receipt's build stage fails only for a build follow-…
teknium1 Oct 4, 2026
ef2c538
fix(update): preserve receipt identity and durable terminal facts
teknium1 Oct 4, 2026
dbf710d
fix(update): no post-commit receipt-store, restart-IPC or profile-syn…
teknium1 Oct 4, 2026
4e2a0e7
docs(update): receipt-store and gateway-marker rules after the commit…
teknium1 Oct 4, 2026
6ce051e
fix(update): a refused Windows resume after the commit point never fa…
teknium1 Oct 4, 2026
1e08ef1
docs(update): profile sync is owed only when the step escapes; one po…
teknium1 Oct 4, 2026
3d4e3bf
fix(update): a stopped unmapped gateway keeps its restart owed across…
teknium1 Oct 5, 2026
967add6
fix(update): managed SSH observer reads a profile update's receipt fr…
teknium1 Oct 5, 2026
5f4ae82
fix(update): a dashboard accepts only its own action's completion fro…
teknium1 Oct 5, 2026
cc009e9
docs(update): maintenance docstring says profile sync is best-effort …
teknium1 Oct 5, 2026
cb620d8
fix(update): an unbuilt Desktop app is reported by one whole line the…
teknium1 Oct 5, 2026
7e2f440
fix(update): run the isolated completion child in UTF-8 mode (hardeni…
teknium1 Oct 5, 2026
15eb324
docs(update): post-commit contract in the compacted update-pipeline docs
teknium1 Oct 5, 2026
bc4f5cc
chore(update): post-commit catch-all boundaries carry the code-health…
teknium1 Oct 5, 2026
8cf0455
test(dashboard): update-status root-log cells move to their own file
teknium1 Oct 5, 2026
281c68a
fix(update): an unwritable gateway status never fails a settled commit
teknium1 Oct 5, 2026
2b59418
fix(update): a Desktop build that never ran is named for the hand-off
teknium1 Oct 5, 2026
e5c8e59
fix(update): a lost completion after the commit point is owed, not fa…
teknium1 Oct 5, 2026
35042af
fix(update): a raising fleet restart in the takeover child is owed, n…
teknium1 Oct 5, 2026
964e3dc
fix(update): source_build migration waivers say what actually happens
teknium1 Oct 5, 2026
506ea4b
fix(update): docs name the parked-stash exit 1 and the owed completion
teknium1 Oct 5, 2026
87fbf03
fix(update): a lost completion is owed without adopting a retired cha…
teknium1 Oct 5, 2026
f179b2f
fix(update): new post-commit tests use explicit encodings
teknium1 Oct 5, 2026
eb0d19f
fix(update): an unmapped stopped gateway's restart debt needs its own…
teknium1 Oct 5, 2026
05355f7
docs(update): hermes_cli/AGENTS.md states the post-commit exit rule a…
teknium1 Oct 5, 2026
1b51277
fix(update): every owed follow-up reaches the Desktop hand-off result
teknium1 Oct 5, 2026
9586d99
fix(update): the gateway /update status follows a committed run's rec…
teknium1 Oct 5, 2026
be62eeb
fix(update): the prepared completion child runs in UTF-8 mode too
teknium1 Oct 5, 2026
69f6f5c
test(update): the gateway-status boundary tests stub the owning-insta…
teknium1 Oct 5, 2026
b2be749
docs(update): hermes_cli/AGENTS.md keeps the post-commit rule within …
teknium1 Oct 5, 2026
3b2d4ee
fix: an active config write failure still migrates siblings and resto…
teknium1 Oct 5, 2026
78cd7d3
fix: a same-SHA catch-up adds newly stopped unmapped gateways to the …
teknium1 Oct 5, 2026
c9f47c0
fix: the dashboard update status certifies an outcome only from its o…
teknium1 Oct 5, 2026
8c4a3d1
fix: dashboard, Desktop and managed SSH clients report a committed up…
teknium1 Oct 5, 2026
ec8e0cf
fix: dashboard owed-update toast string exists in every web locale
teknium1 Oct 6, 2026
4150ed1
test(update): the utf8-children test stubs the treeless-checkout conv…
teknium1 Oct 6, 2026
8494c28
fix: a named profile's updater and pm receipts see each other again
teknium1 Oct 6, 2026
2715cff
fix: a restarted dashboard keeps its update action identity past the …
teknium1 Oct 6, 2026
01d789b
fix: managed SSH results name owed work on restore-failed and partial…
teknium1 Oct 6, 2026
d09626b
fix: a post-commit follow-up is owed once, through one owe_followup h…
teknium1 Oct 6, 2026
c8d2025
fix: the dashboard reads update.log through the one log-path resolver
teknium1 Oct 6, 2026
8fc2475
fix: Desktop backend update names owed work on partial and receipt-pr…
teknium1 Oct 6, 2026
b299cf3
fix: dashboard update toast names a partial run's owed work, only fro…
teknium1 Oct 6, 2026
ea46a3a
fix: one lookup reads a run's archived receipt, with one error contract
teknium1 Oct 6, 2026
9d385c3
fix: a lost completion closes the run from the child's record, not th…
teknium1 Oct 6, 2026
8e22df1
test(e2e): under a sticky profile pm status shows the newest receipt,…
teknium1 Oct 6, 2026
189ce63
chore: retrigger CI (zero-job dispatch failure, auto-heal)
teknium1 Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions apps/desktop/e2e/update/build-fail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
* #124040: the Desktop build failed inside the update takeover's completion
* stage and the failure was swallowed / kept looping instead of reaching the
* user. Here upstream main carries a Desktop source file that does not
* compile; the user clicks "Update now". The update must end with the app
* telling the user the update FAILED (the relaunched app's result dialog is
* logged as `[updates] detached update FAILED`), never "finished OK", and the
* updater must not be left running.
* compile; the user clicks "Update now".
*
* The build runs after the code committed, so under contract C3 the update
* succeeds with an owed follow-up: Hermes IS on the new code and only this
* app was not rebuilt. The relaunched (old-build) app must say exactly that
* and what to do — the action-required dialog, logged as `[updates] detached
* update finished with manual action` — never "finished OK" (a silent stale
* app) and never "FAILED / still on the previous version" (false: the code
* moved). The updater must not be left running.
*/

import * as path from 'node:path'
Expand All @@ -32,7 +37,7 @@ import {
waitForUpdateOffer
} from './harness'

test('a Desktop build failure during Update now is reported to the user as a failed update', async () => {
test('a Desktop build failure during Update now tells the user the app was not rebuilt and how to fix it', async () => {
test.setTimeout(20 * 60_000)
const session = await startInstallSession()
const { facts, env } = session
Expand All @@ -52,7 +57,7 @@ test('a Desktop build failure during Update now is reported to the user as a fai
await clickUpdateNowAndExpectHandoff(launched, facts)
})

await test.step('the failed build reaches the user as a failed update', async () => {
await test.step('the failed build reaches the user as an app that still needs rebuilding', async () => {
await waitFor(
'the relaunched app to report the update outcome',
() => /\[updates\] detached update (finished|FAILED)/.test(desktopLog(facts)),
Expand All @@ -64,8 +69,15 @@ test('a Desktop build failure during Update now is reported to the user as a fai
.filter(line => /\[updates\] detached update/.test(line))
.join('\n')

expect(outcome, `the update is reported as FAILED, not finished\n${explain()}`).toMatch(/detached update FAILED/)
expect(outcome, `the owed rebuild reaches the user as an action, not a silent OK\n${explain()}`).toMatch(
/detached update finished with manual action/
)
expect(outcome, 'the user is told the Desktop app could not be rebuilt and how to rebuild it').toMatch(
/Desktop app could not be rebuilt.*hermes desktop --force-build/
)
expect(outcome).not.toMatch(/detached update finished OK/)
// The code committed: "FAILED / still on the previous version" would be false.
expect(outcome).not.toMatch(/detached update FAILED|previous version/)
// The relaunched app reports the result while posix.sh is still inside launch_app's 1.5 s
// acceptance window, so the updater gets a bounded moment to exit instead of none.
await expect
Expand All @@ -77,10 +89,8 @@ test('a Desktop build failure during Update now is reported to the user as a fai
{ timeout: 30_000, message: 'no updater is left running after the failure' }
)
.toEqual([])
// What the user is left on: record it for triage (the checkout moved; the app bundle did not).
test
.info()
.annotations.push({ type: 'checkout-after-failure', description: git(facts.checkout, 'rev-parse', 'HEAD') })
// The checkout moved (that is why it is not "previous version"); the app bundle did not.
expect(git(facts.checkout, 'rev-parse', 'HEAD'), 'the code update committed').toBe(target)
})
} finally {
await session.close()
Expand Down
146 changes: 146 additions & 0 deletions apps/desktop/electron/managed-ssh-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,105 @@ test.runIf(process.platform !== 'win32')(
}
)

test.runIf(process.platform !== 'win32')(
'a committed update that still owes a step stays a success but names the owed step',
async () => {
const home = await mkdtemp(path.join(os.tmpdir(), 'hermes-managed-owed-'))

const run = async (
followups: unknown[],
{ exitCode = 0, outcome = 'success', restoreFails = false, userAction = null as unknown } = {}
) => {
const receipts = path.join(home, 'logs', 'update_receipts')
await mkdir(receipts, { recursive: true })
await writeFile(path.join(home, `.update_exit_code.${CORRELATION}`), String(exitCode))
await writeFile(
path.join(receipts, 'update_x.json'),
JSON.stringify({
correlation_id: CORRELATION,
outcome,
started_at: '2026-08-23T00:00:00Z',
finished_at: '2026-08-23T00:01:00Z',
followups,
user_action: userAction
})
)

const command = buildRemoteUpdateObservationCommand(
{ ssh: { exec: async () => '' }, platform: 'Linux', hermesPath: '/opt/hermes/hermes', hermesHome: home },
CORRELATION
)

const observed = parseRemoteUpdateObservation((await exec(command, { shell: 'sh' })).stdout, CORRELATION)

return runManagedSshUpdate({
connectionId: 'home',
correlationId: CORRELATION,
scopes: [{ key: 'conn:home::default', profile: 'default' }],
preflightRemote: async () => {},
drainScope: async () => {},
updateRemote: async () => ({ exitCode: observed.exitCode!, receipt: observed.receipt! }),
awaitRestoreClearance: async () => {},
closeTransports: async () => {},
restoreScope: async () => {
if (restoreFails) {
throw new Error('restore refused')
}
},
releaseGate: () => {}
})
}

try {
const owed = await run([{ step: 'dependencies', reason: 'sync failed', at: '2026-08-23T00:01:00Z' }])

assert.equal(owed.ok, true)
assert.equal(owed.updateOk, true)
assert.equal(owed.restoreOk, true)
assert.deepEqual(owed.owed, [{ step: 'dependencies', reason: 'sync failed' }])
assert.match(owed.message || '', /still owed: dependencies \(sync failed\)/)
assert.match(owed.message || '', /hermes update/)
assert.match(managedSshUpdateAllRow({ id: 'home' }, owed).detail || '', /dependencies/)

const clean = await run([])

assert.equal(clean.ok, true)
assert.equal(clean.owed, undefined)
assert.equal(clean.message, 'Remote Hermes updated and every managed SSH profile is ready.')

// The debt survives a failed restoration: update and restore verdicts stay independent.
const unrestored = await run([{ step: 'dependencies', reason: 'sync failed' }], { restoreFails: true })

assert.equal(unrestored.updateOk, true)
assert.equal(unrestored.restoreOk, false)
assert.deepEqual(unrestored.owed, [{ step: 'dependencies', reason: 'sync failed' }])
assert.match(
managedSshUpdateAllRow({ id: 'home' }, unrestored).detail || '',
/still owed: dependencies \(sync failed\)/
)

// record_user_action turns a success into partial/exit 1: its exact manual instruction is shown,
// and the rerun advice is never offered for a parked stash.
const instruction = 'Run `git stash apply stash@{0}` in /opt/hermes to restore your local changes.'
const partial = await run([], {
exitCode: 1,
outcome: 'partial',
userAction: { step: 'local_changes', reason: instruction }
})

assert.equal(partial.updateOk, false)
assert.equal(partial.restoreOk, true)
assert.deepEqual(partial.owed, [{ step: 'local_changes', reason: instruction }])
const detail = managedSshUpdateAllRow({ id: 'home' }, partial).detail || ''
assert.ok(detail.includes(instruction), detail)
assert.match(detail, /local_changes/)
assert.doesNotMatch(detail, /Re-run `hermes update`/)
} finally {
await rm(home, { force: true, recursive: true })
}
}
)

test.runIf(process.platform !== 'win32')(
'managed observer unwraps a named profile home for the install-wide marker',
async () => {
Expand Down Expand Up @@ -538,6 +637,53 @@ test.runIf(process.platform !== 'win32')(
}
)

test.runIf(process.platform !== 'win32')(
'managed observer reads a named profile update receipt from the root home the CLI writes',
async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'hermes-managed-profile-receipt-'))
const profileHome = path.join(root, 'profiles', 'research')

const receipt = (outcome: string) =>
JSON.stringify({
correlation_id: CORRELATION,
outcome,
started_at: '2026-08-23T00:00:00Z',
finished_at: '2026-08-23T00:01:00Z'
})

try {
// The CLI's receipt store is install-wide; a stale copy under the profile is not its source.
await mkdir(path.join(root, 'logs', 'update_receipts'), { recursive: true })
await mkdir(path.join(profileHome, 'logs', 'update_receipts'), { recursive: true })
await writeFile(path.join(profileHome, `.update_exit_code.${CORRELATION}`), '0')
await writeFile(path.join(root, 'logs', 'update_receipts', `update_${CORRELATION}.json`), receipt('success'))
await writeFile(
path.join(profileHome, 'logs', 'update_receipts', `update_${CORRELATION}.json`),
receipt('failed')
)

const command = buildRemoteUpdateObservationCommand(
{
ssh: { exec: async () => '' },
platform: 'Linux',
hermesPath: '/opt/hermes/hermes',
hermesHome: profileHome
},
CORRELATION
)

const { stdout } = await exec(command, { shell: 'sh' })
const parsed = parseRemoteUpdateObservation(stdout, CORRELATION)

assert.equal(parsed.exitCode, 0)
assert.equal(parsed.receipt?.correlationId, CORRELATION)
assert.equal(parsed.receipt?.outcome, 'success')
} finally {
await rm(root, { force: true, recursive: true })
}
}
)

test('Windows coordinator handoff is pending until its marker clears and correlated receipt is durable', async () => {
const replies = [
observation({ marker: 'live', markerPid: 44 }),
Expand Down
85 changes: 74 additions & 11 deletions apps/desktop/electron/managed-ssh-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-

type ManagedUpdateOutcome = 'updated' | 'update-failed' | 'restore-failed' | 'update-and-restore-failed' | 'refused'

interface ManagedUpdateOwedStep {
step: string
reason: string
}

interface ManagedUpdateReceiptSummary {
correlationId: string
outcome: string
Expand All @@ -39,6 +44,9 @@ interface ManagedUpdateReceiptSummary {
preVersion?: string
postVersion?: string
stopReason?: string
// Post-commit steps a successful update still owes (contract C3).
followups?: ManagedUpdateOwedStep[]
userAction?: ManagedUpdateOwedStep | null
}

interface ManagedUpdateScopeResult {
Expand All @@ -57,6 +65,8 @@ interface ManagedConnectionUpdateResult {
exitCode: null | number
receipt: ManagedUpdateReceiptSummary | null
scopes: ManagedUpdateScopeResult[]
// Every step a committed update still owes; set only when non-empty.
owed?: ManagedUpdateOwedStep[]
error?: string
message?: string
// Set when the connection was deliberately not attempted (a known safety
Expand Down Expand Up @@ -324,9 +334,10 @@ from pathlib import Path

home=Path(os.path.expanduser(sys.argv[1]))
correlation=sys.argv[2]
# The update marker is install-wide even when the launcher was invoked with a
# named profile home (<root>/profiles/<name>). Correlated status/intent/receipt
# remain under the launch home, matching the CLI writer.
# The update marker and the receipt store are install-wide even when the launcher
# was invoked with a named profile home (<root>/profiles/<name>): the CLI writes
# receipts under the root home. Correlated status/ready/intent stay under the
# launch home, matching their writers.
profile_parent=home.parent.name
is_profile_home=(profile_parent.lower()=='profiles') if os.name=='nt' else (profile_parent=='profiles')
install_root=home.parent.parent if is_profile_home else home
Expand Down Expand Up @@ -416,7 +427,7 @@ def terminal_code():
except ValueError:return 'malformed'

def receipt():
directory=home/'logs'/'update_receipts'
directory=install_root/'logs'/'update_receipts'
try:paths=sorted(directory.glob('update_*.json'),key=lambda p:p.stat().st_mtime_ns,reverse=True)
except OSError:return None
for path in paths:
Expand All @@ -426,12 +437,15 @@ def receipt():
if payload.get('outcome')=='running' or not payload.get('finished_at'):continue
pre=payload.get('pre_update') if isinstance(payload.get('pre_update'),dict) else {}
post=payload.get('post_update') if isinstance(payload.get('post_update'),dict) else {}
action=payload.get('user_action')
return {
'correlationId':correlation,'outcome':str(payload.get('outcome') or 'unknown'),
'startedAt':payload.get('started_at'),'finishedAt':payload.get('finished_at'),
'preSha':pre.get('sha'),'postSha':post.get('sha'),
'preVersion':pre.get('version'),'postVersion':post.get('version'),
'stopReason':payload.get('stop_reason'),
'followups':[{'step':str(f.get('step')),'reason':str(f.get('reason') or '')} for f in payload.get('followups') or [] if isinstance(f,dict)],
'userAction':{'step':str(action.get('step')),'reason':str(action.get('reason') or '')} if isinstance(action,dict) else None,
}
return None

Expand Down Expand Up @@ -480,6 +494,59 @@ function buildRemoteUpdateObservationCommand(target: RemoteUpdateTarget, correla
return `python3 -c ${shq(OBSERVATION_SCRIPT)} ${shq(home)} ${shq(correlation)}`
}

function owedStep(value: any): ManagedUpdateOwedStep | null {
return value && typeof value === 'object' ? { step: String(value.step), reason: String(value.reason ?? '') } : null
}

// An older remote observer omits the debt fields: read them as none.
function withReceiptDebt(receipt: any): ManagedUpdateReceiptSummary {
const followups = Array.isArray(receipt.followups) ? receipt.followups.map(owedStep).filter(Boolean) : []

return { ...receipt, followups, userAction: owedStep(receipt.userAction) }
}

// C3: a committed update is a success even when post-commit steps are owed;
// name each one and its remedy instead of claiming everything is ready. The
// debt rides on every terminal receipt, so it is composed with the update and
// restoration verdicts, never only on success: a committed run whose profile
// restore failed, and a partial run that parked local changes, still owe it.
function managedUpdateMessage(
outcome: ManagedUpdateOutcome,
restoreOk: boolean,
receipt: ManagedUpdateReceiptSummary | undefined
): { owed?: ManagedUpdateOwedStep[]; message: string } {
const followups = receipt?.followups ?? []
const userAction = receipt?.userAction
const owed = [...followups, ...(userAction ? [userAction] : [])]

const status =
outcome === 'updated'
? 'Remote Hermes updated'
: restoreOk
? 'The remote update failed, but every managed SSH profile was restored.'
: 'The remote update transaction could not restore every managed SSH profile.'

if (!owed.length) {
return { message: outcome === 'updated' ? `${status} and every managed SSH profile is ready.` : status }
}

const message =
(outcome === 'updated' ? `${status}, but these steps are still owed: ` : `${status} These steps are still owed: `) +
[
...followups.map(step => (step.reason ? `${step.step} (${step.reason})` : step.step)),
...(userAction ? [userAction.step] : [])
].join('; ') +
'.' +
// Only follow-ups are finished by a rerun; a user action (e.g. a parked
// stash) is the producer's exact manual instruction, shown verbatim.
(followups.length
? ` Re-run \`hermes update\` on the remote to finish ${followups.map(step => step.step).join(', ')}.`
: '') +
(userAction?.reason ? ` ${userAction.reason}` : '')

return { owed, message }
}

function parseRemoteUpdateObservation(raw: string, correlationId: string): RemoteUpdateObservation {
const correlation = validateCorrelationId(correlationId)
let parsed: any
Expand Down Expand Up @@ -525,7 +592,7 @@ function parseRemoteUpdateObservation(raw: string, correlationId: string): Remot
throw new Error('Remote update receipt did not match this transaction.')
}

receipt = parsed.receipt as ManagedUpdateReceiptSummary
receipt = withReceiptDebt(parsed.receipt)
}

let coordinatorReady: RemoteUpdateObservation['coordinatorReady'] = null
Expand Down Expand Up @@ -937,12 +1004,7 @@ async function runManagedSshUpdate<TScope extends ManagedSshScope>(
receipt: proof?.receipt ?? null,
scopes: restoreResults,
...(error ? { error } : {}),
message:
outcome === 'updated'
? 'Remote Hermes updated and every managed SSH profile is ready.'
: restoreOk
? 'The remote update failed, but every managed SSH profile was restored.'
: 'The remote update transaction could not restore every managed SSH profile.'
...managedUpdateMessage(outcome, restoreOk, proof?.receipt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

R7 · [P2] Carry this recovery message through both Update-all notification consumers

The formatter repair now works: this result and managedSshUpdateAllRow(...).detail retain dependencies debt on restore failure and the manual stash instruction on a partial run. However, the row converter also gives every failed managed row a nonempty error. Both Update everything and Settings → Update all prefer that error to detail.

Executed the source-extracted lifecycle → row → both actual notification callbacks with injected transport/bridge/state. A success receipt with dependencies debt plus restoreScope throwing Error("restore refused") sends only restore refused to both notification APIs. Exit 1 / partial with a verbatim local_changes recovery instruction sends only update-failed. In each case .detail still has the complete recovery text. Clean and success-with-debt controls pass; removing only .error restores the message.

Compose the detail and specific diagnostic through a shared presentation helper used by BOTH consumers; keep update/restore status independent and do not tell users an updater rerun restores a parked stash. Extend the regression past .detail to capture actual notification arguments. This anchors the unadapted downstream consumer contract at the changed producer; it does not claim managedUpdateMessage still has the repaired early return. No live SSH or mounted UI was exercised. Full matrix, RCA and limits are in R7 of the review.

}
}

Expand Down Expand Up @@ -1179,6 +1241,7 @@ export {
managedSshUpdateAllRow,
type ManagedUpdateDeps,
type ManagedUpdateOutcome,
type ManagedUpdateOwedStep,
type ManagedUpdateReceiptSummary,
type ManagedUpdateScopeResult,
markerIsClear,
Expand Down
Loading
Loading