Skip to content

Commit 38814c6

Browse files
committed
fix(runtime): make the retired connection an actual tombstone
Review reproduced two writes that still committed against a retained retired row: `credential.vault.set` with a `request_headers` locator, and `connection.request-headers.replace`. Guarding the catalog update alone had not established the invariant it claimed, because those are sibling writes to the same connection — and guarding entry points one at a time is what left them open. Every connection-owned write now passes one assertion. The credential vault and the request-header replacement share it with the catalog update, so a path added later inherits the refusal instead of needing to remember it. Reading, querying and deleting stay the exceptions, and the regression proves the row survives its refusals: after all three writes are rejected it is still readable and still deletable, which is the only reason it is retained. That refusal point moved earlier than an existing expectation: a client-supplied OAuth token for the retired provider used to be rejected for its credential kind and is now rejected for its connection, so that test asserts the connection-level code with the reason stated. The Desktop detail no longer offers what the storage layer refuses. The advanced-request, model-management and capability sections are hidden for a retired connection, leaving the retirement notice and deletion — the request-header editor was the worst of them, since before the vault refused it a user could save a header that could never reach a request. Sessions bound to a retired provider now project as stale in the task rail. `provider_retired` joins `connection_missing` and `fake_backend` there: the connection still exists and is still enabled, so nothing else about the row looks wrong and the task read as healthy until opened. Also in this push: current main is merged, and since main independently took epoch 30 for access-credential pairing, this wire removal takes 31. The Astryx inventory is regenerated and `git diff --check` is clean. Reported by @hqhq1025; the detail-page half was also observed by @M4n5ter. Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
2 parents 46d9eff + 92da51d commit 38814c6

76 files changed

Lines changed: 5297 additions & 424 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/RELEASE_CHECKLIST.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Windows needs no secrets while the build is unsigned: electron-builder skips sig
1616

1717
## Create the draft
1818

19-
1. Confirm the intended commit is on `main`, CI is green, and `apps/desktop/package.json` contains a version that has never been released.
19+
1. Confirm the intended commit is on `main`, CI is green, `apps/desktop/package.json` contains a version that has never been released, and the exact `maka-agent` version in `packages/cli/package.json` is public on npm.
2020
2. In GitHub Actions, run `Release desktop` against `main`.
2121
3. Confirm every workflow step passes on both platforms and a draft release named `v<version>` exists.
2222
4. Confirm the draft records the intended commit SHA and contains the macOS DMG, ZIP, `latest-mac.yml`, the Windows `.exe`, ZIP, `latest.yml`, the bundled Git source-materials archive, and matching `.sha256` files.

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
fi
4343
4444
- name: Test CI planner
45-
run: node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs
45+
run: node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs scripts/verify-windows-harness.test.mjs
4646

4747
# Same shape and the same needs: a regenerate-and-diff contract that runs
4848
# on Node alone, so it belongs beside the planner test rather than behind

.github/workflows/release-desktop.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ jobs:
4949
- name: Install dependencies
5050
run: npm ci
5151

52+
- name: Verify Runtime Host setup package
53+
run: |
54+
requested_version="$(node -p "require('./packages/cli/package.json').version")"
55+
setup_package="maka-agent@${requested_version}"
56+
published_version="$(npm view "$setup_package" version)"
57+
if [ "$published_version" != "$requested_version" ]; then
58+
echo "Published package resolved to ${published_version}, expected ${requested_version}." >&2
59+
exit 1
60+
fi
61+
echo "MAKA_RUNTIME_HOST_SETUP_PACKAGE=$setup_package" >> "$GITHUB_ENV"
62+
5263
- name: Audit production dependencies
5364
run: npm audit --omit=dev --audit-level=moderate
5465

CONTRIBUTING.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ npm run cli:dev # TUI with the Maka Dev profile
100100
npm run cli:dev -- run "" # one non-interactive turn
101101
```
102102

103+
To exercise Desktop's remote Runtime Host setup with the current worktree, build the same
104+
self-contained package shape used for releases and opt the development app into that archive:
105+
106+
```sh
107+
npm run release:cli:pack -- --allow-dirty
108+
MAKA_RUNTIME_HOST_SETUP_ARCHIVE="$PWD/packages/cli/release/<archive>.tgz" npm run dev
109+
```
110+
111+
The development app uploads the temporary archive over SSH. Packaged apps ignore this override.
112+
103113
Evaluation commands and contracts live in [`packages/eval`](./packages/eval).
104114

105115
### Building

CONTRIBUTING.zh-CN.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,16 @@ npm --workspace maka-agent exec -- maka # TUI
9898
npm --workspace maka-agent exec -- maka run "" # 非交互地跑一个 Turn
9999
```
100100

101+
如需用当前工作区真实验证 Desktop 的远程 Runtime Host setup,可先构建与正式发布相同形态的
102+
自包含 package,再让开发版应用显式使用该 archive:
103+
104+
```sh
105+
npm run release:cli:pack -- --allow-dirty
106+
MAKA_RUNTIME_HOST_SETUP_ARCHIVE="$PWD/packages/cli/release/<archive>.tgz" npm run dev
107+
```
108+
109+
开发版应用会通过 SSH 上传这个临时 archive;正式打包应用会忽略该覆盖项。
110+
101111
Eval 的命令与 contract 见 [`packages/eval`](./packages/eval)
102112

103113
### 构建

apps/desktop/electron-builder.config.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1+
const runtimeHostSetupPackage = process.env.MAKA_RUNTIME_HOST_SETUP_PACKAGE?.trim();
2+
if (
3+
runtimeHostSetupPackage !== undefined &&
4+
!/^maka-agent@[0-9][0-9A-Za-z.+-]*$/u.test(runtimeHostSetupPackage)
5+
) {
6+
throw new Error('MAKA_RUNTIME_HOST_SETUP_PACKAGE must name an exact Maka CLI version');
7+
}
8+
19
export default {
210
appId: 'com.maka.desktop',
311
productName: 'Maka',
412
artifactName: 'Maka-${version}-mac-${arch}.${ext}',
513
asar: true,
14+
...(runtimeHostSetupPackage
15+
? { extraMetadata: { runtimeHostSetupPackage } }
16+
: {}),
617
directories: {
718
output: 'release',
819
},

apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import assert from 'node:assert/strict';
22
import test from 'node:test';
33
import type { BotIncomingMessage } from '@maka/runtime/bots';
4+
import {
5+
RuntimeHostOperationError,
6+
RuntimeHostRequestInterruptedError,
7+
} from '@maka/runtime-host/client';
48
import {
59
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
610
RUNTIME_HOST_COMPATIBILITY_EPOCH,
@@ -12,6 +16,7 @@ import type {
1216
DesktopRuntimeHostCandidateStartResult,
1317
} from '../runtime-host-desktop-candidate.js';
1418
import {
19+
RuntimeHostPairingFinalizationInterruptedError,
1520
RuntimeHostUpgradeCancelledError,
1621
startRuntimeHostDesktopManager,
1722
} from '../runtime-host-desktop-manager.js';
@@ -186,6 +191,167 @@ test('keeps Local and remote Hosts active and routes work by owning Host', async
186191
await manager.close();
187192
});
188193

194+
test('replays pairing finalization after an unknown commit and reconnect', async () => {
195+
const local = candidateHarness({ hostId: 'host-a' });
196+
const remoteHostId = 'a'.repeat(64);
197+
const first = candidateHarness({
198+
hostId: remoteHostId,
199+
finalizeFailures: [
200+
new RuntimeHostOperationError(
201+
'access.credential.finalize',
202+
'commit_outcome_unknown',
203+
'finalization outcome is unknown',
204+
),
205+
],
206+
disconnectOnFinalizeFailure: true,
207+
});
208+
const replacement = candidateHarness({ hostId: remoteHostId });
209+
const queue = [local.candidate, first.candidate, replacement.candidate];
210+
const manager = await startRuntimeHostDesktopManager(
211+
{} as DesktopRuntimeHostCandidateStartInput,
212+
{
213+
startCandidate: async () => ready(queue.shift()!),
214+
reconnectBackoff: { minMs: 0, maxMs: 0 },
215+
},
216+
);
217+
await manager.enable(remoteTarget('office'));
218+
219+
await manager.finalizePairing('office');
220+
221+
assert.equal(first.finalizeCalls, 1);
222+
assert.equal(replacement.finalizeCalls, 1);
223+
await manager.close();
224+
});
225+
226+
for (const dispatch of ['not_dispatched', 'dispatched'] as const) {
227+
test(`replays ${dispatch} pairing finalization after connection loss`, async () => {
228+
const local = candidateHarness({ hostId: 'host-a' });
229+
const remoteHostId = 'a'.repeat(64);
230+
const first = candidateHarness({
231+
hostId: remoteHostId,
232+
finalizeFailures: [
233+
new RuntimeHostRequestInterruptedError(
234+
'access.credential.finalize',
235+
'command',
236+
dispatch,
237+
'connection_lost',
238+
),
239+
],
240+
disconnectOnFinalizeFailure: true,
241+
});
242+
const replacement = candidateHarness({ hostId: remoteHostId });
243+
const queue = [local.candidate, first.candidate, replacement.candidate];
244+
const manager = await startRuntimeHostDesktopManager(
245+
{} as DesktopRuntimeHostCandidateStartInput,
246+
{
247+
startCandidate: async () => ready(queue.shift()!),
248+
reconnectBackoff: { minMs: 0, maxMs: 0 },
249+
},
250+
);
251+
await manager.enable(remoteTarget('office'));
252+
253+
await manager.finalizePairing('office');
254+
255+
assert.equal(first.finalizeCalls, 1);
256+
assert.equal(replacement.finalizeCalls, 1);
257+
await manager.close();
258+
});
259+
}
260+
261+
test('defers reconnecting pairing finalization when the manager closes', async () => {
262+
const local = candidateHarness({ hostId: 'host-a' });
263+
const remoteHostId = 'a'.repeat(64);
264+
const remote = candidateHarness({
265+
hostId: remoteHostId,
266+
finalizeFailures: [
267+
new RuntimeHostOperationError(
268+
'access.credential.finalize',
269+
'commit_outcome_unknown',
270+
'finalization outcome is unknown',
271+
),
272+
],
273+
disconnectOnFinalizeFailure: true,
274+
});
275+
let reconnectStarted!: () => void;
276+
const reconnecting = new Promise<void>((resolve) => {
277+
reconnectStarted = resolve;
278+
});
279+
let starts = 0;
280+
const manager = await startRuntimeHostDesktopManager(
281+
{} as DesktopRuntimeHostCandidateStartInput,
282+
{
283+
startCandidate: async (input) => {
284+
starts += 1;
285+
if (starts === 1) return ready(local.candidate);
286+
if (starts === 2) return ready(remote.candidate);
287+
reconnectStarted();
288+
const signal = input.signal;
289+
assert.ok(signal);
290+
return await new Promise<DesktopRuntimeHostCandidateStartResult>((_resolve, reject) => {
291+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
292+
});
293+
},
294+
reconnectBackoff: { minMs: 0, maxMs: 0 },
295+
},
296+
);
297+
await manager.enable(remoteTarget('office'));
298+
299+
const finalization = assert.rejects(
300+
() => manager.finalizePairing('office'),
301+
RuntimeHostPairingFinalizationInterruptedError,
302+
);
303+
await reconnecting;
304+
await manager.close();
305+
await finalization;
306+
307+
assert.equal(remote.finalizeCalls, 1);
308+
assert.equal(starts, 3);
309+
});
310+
311+
test('defers pairing finalization when reconnect does not complete in time', async () => {
312+
const local = candidateHarness({ hostId: 'host-a' });
313+
const remoteHostId = 'a'.repeat(64);
314+
const remote = candidateHarness({
315+
hostId: remoteHostId,
316+
finalizeFailures: [
317+
new RuntimeHostOperationError(
318+
'access.credential.finalize',
319+
'commit_outcome_unknown',
320+
'finalization outcome is unknown',
321+
),
322+
],
323+
disconnectOnFinalizeFailure: true,
324+
});
325+
let starts = 0;
326+
const manager = await startRuntimeHostDesktopManager(
327+
{} as DesktopRuntimeHostCandidateStartInput,
328+
{
329+
startCandidate: async (input) => {
330+
starts += 1;
331+
if (starts === 1) return ready(local.candidate);
332+
if (starts === 2) return ready(remote.candidate);
333+
const signal = input.signal;
334+
assert.ok(signal);
335+
return await new Promise<DesktopRuntimeHostCandidateStartResult>((_resolve, reject) => {
336+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
337+
});
338+
},
339+
reconnectBackoff: { minMs: 0, maxMs: 0 },
340+
pairingFinalizationTimeoutMs: 10,
341+
},
342+
);
343+
await manager.enable(remoteTarget('office'));
344+
345+
await assert.rejects(
346+
() => manager.finalizePairing('office'),
347+
RuntimeHostPairingFinalizationInterruptedError,
348+
);
349+
350+
assert.equal(remote.finalizeCalls, 1);
351+
assert.equal(starts, 3);
352+
await manager.close();
353+
});
354+
189355
test('coalesces concurrent enable requests for one remote profile', async () => {
190356
const local = candidateHarness({ hostId: 'host-a' });
191357
const remote = candidateHarness({ hostId: 'host-b', lifecycleMode: 'remote' });
@@ -504,6 +670,8 @@ function candidateHarness(
504670
activeTasks?: boolean;
505671
lifecycleMode?: 'ephemeral' | 'service' | 'remote';
506672
hostId?: string;
673+
finalizeFailures?: Error[];
674+
disconnectOnFinalizeFailure?: boolean;
507675
} = {},
508676
) {
509677
let resolveClosed: (() => void) | undefined;
@@ -515,6 +683,7 @@ function candidateHarness(
515683
const stoppedSessions: string[] = [];
516684
let lifecycleState: 'ready' | 'unavailable' = 'ready';
517685
let prepareUpgradeCalls = 0;
686+
let finalizeCalls = 0;
518687
const prepareUpgradeAuthorities: boolean[] = [];
519688
const candidate = {
520689
closed,
@@ -536,6 +705,18 @@ function candidateHarness(
536705
}
537706
return { kind: 'prepared' as const, pid: 42 };
538707
},
708+
async finalizeAccessCredential() {
709+
finalizeCalls += 1;
710+
const failure = options.finalizeFailures?.shift();
711+
if (failure) {
712+
if (options.disconnectOnFinalizeFailure) {
713+
lifecycleState = 'unavailable';
714+
resolveClosed?.();
715+
}
716+
throw failure;
717+
}
718+
return {};
719+
},
539720
},
540721
botIncoming: {
541722
async handleBotIncomingMessage() {
@@ -573,6 +754,9 @@ function candidateHarness(
573754
get prepareUpgradeAuthorities() {
574755
return prepareUpgradeAuthorities;
575756
},
757+
get finalizeCalls() {
758+
return finalizeCalls;
759+
},
576760
};
577761
}
578762

0 commit comments

Comments
 (0)