Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 32 additions & 0 deletions packages/host-kit/src/internal/host-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,38 @@ export function describeUnconfirmedProcessStop(pid: number, claimedAs: string):
return `pid ${pid} could not be confirmed as ${claimedAs}, so no signal was sent to it`;
}

/** Which verdict a retained stop recorded, as it travels through untyped error `details`. */
type ConfirmedProcessStopReason = Extract<ConfirmedProcessStop, { status: 'retained' }>['reason'];

/**
* Whether `reason` names a retained stop verdict. Forwarding gates on this rather than on error text,
* so an unrelated failure's details cannot be forwarded as one.
*/
function isConfirmedProcessStopReason(value: unknown): value is ConfirmedProcessStopReason {
return value === 'signal-failed' || value === 'exit-timeout' || value === 'identity-unverified';
}

/**
* The verdict a nested layer published, or nothing when the error carries no stop verdict. A wrapper
* that flattens the inner message keeps the verdict in `details` and drops the inner surface's file
* paths and advice, which describe a file the caller of the wrapper was not asked about.
*/
export function readConfirmedProcessStopDetails(
details: Record<string, unknown> | undefined,
): ConfirmedProcessStopDetails | undefined {
const reason = details?.reason;
const pid = details?.pid;
if (!isConfirmedProcessStopReason(reason) || typeof pid !== 'number') return undefined;
const signal = details?.signal;
const errno = details?.errno;
return {
reason,
signal: signal === 'SIGTERM' || signal === 'SIGKILL' ? signal : undefined,
errno: reason === 'signal-failed' && typeof errno === 'string' ? errno : undefined,
pid,
};
}

/**
* The process to stop, and what the caller recorded about it. A stop signals only a process it can
* name: an `identity` that claims nothing is unverifiable, and so is one whose claims the host will
Expand Down
3 changes: 2 additions & 1 deletion packages/host-kit/src/process.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export {
type ConfirmedProcessStop,
type ConfirmedProcessStopDetails,
confirmedProcessStopDetails,
type ConfirmedProcessStopDetails,
describeUnconfirmedProcessStop,
expandProcessTree,
hostCurrentWorkingDirectory,
Expand All @@ -20,6 +20,7 @@ export {
type ProcessStopBudget,
type ProcessStopIdentity,
type ProcessStopTarget,
readConfirmedProcessStopDetails,
readHostCpuArch,
readHostCpuArchSync,
readHostEnvironmentVariable,
Expand Down
27 changes: 25 additions & 2 deletions src/__tests__/client-metro-auto-companion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir';
import { AppError } from '@agent-device/kernel/errors';

vi.mock('../metro/client-metro-companion.ts', () => ({
ensureMetroCompanion: vi.fn(),
Expand Down Expand Up @@ -224,7 +225,7 @@ test('prepareMetroRuntime rejects bridged descriptors without iOS bundle URLs',
}
});

test('prepareMetroRuntime preserves the initial bridge error if companion startup fails', async () => {
test('prepareMetroRuntime preserves the initial bridge error and companion stop verdict', async () => {
const tempRoot = mkdtempForTestSync('agent-device-metro-companion-error-');
const projectRoot = path.join(tempRoot, 'project');
fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true });
Expand All @@ -239,7 +240,21 @@ test('prepareMetroRuntime preserves the initial bridge error if companion startu
}),
);

vi.mocked(ensureMetroCompanion).mockRejectedValue(new Error('companion startup failed'));
vi.mocked(ensureMetroCompanion).mockRejectedValue(
// A companion stop that could not confirm the exit arrives typed, and the wrapper flattens the
// message into a `bridgeError=` fragment: the verdict has to travel in `details` to be usable.
// The companion's own artifact paths and advice travel beside it, and must not be forwarded:
// they describe a file this caller did not ask about.
new AppError('COMMAND_FAILED', 'companion startup failed', {
reason: 'signal-failed',
signal: 'SIGTERM',
errno: 'EPERM',
pid: 4101,
statePath: '/tmp/project/.agent-device/metro-companion.json',
logPath: '/tmp/project/.agent-device/metro-companion.log',
hint: 'a retry acts on that same companion',
}),
);

const fetchMock = vi.fn();
fetchMock.mockResolvedValueOnce({
Expand Down Expand Up @@ -269,6 +284,14 @@ test('prepareMetroRuntime preserves the initial bridge error if companion startu
assert.match(error.message, /bridgeError=companion startup failed/);
assert.match(error.message, /initialBridgeError=initial bridge auth failed/);
assert.doesNotMatch(error.message, /metroCompanionLog=/);
const details = (error as AppError).details;
assert.equal(details?.reason, 'signal-failed');
assert.equal(details?.signal, 'SIGTERM');
assert.equal(details?.errno, 'EPERM');
assert.equal(details?.pid, 4101);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
assert.equal(details?.statePath, undefined);
assert.equal(details?.logPath, undefined);
assert.equal(details?.hint, undefined);
return true;
},
);
Expand Down
Loading
Loading