fix(mosaic): bound promotion transport delivery
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { Command } from 'commander';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FleetRoster } from './fleet.js';
|
||||
import { TmuxPromotionTransport } from '../fleet/promotion-transport.js';
|
||||
import {
|
||||
promoteSeat,
|
||||
registerPromoteCommand,
|
||||
type PromotionBreadcrumbStore,
|
||||
type PromotionResult,
|
||||
type PromotionTransport,
|
||||
} from './promote.js';
|
||||
|
||||
@@ -94,6 +97,46 @@ describe('mosaic promote', () => {
|
||||
expect(result.status).toBe('VERIFIED');
|
||||
});
|
||||
|
||||
it('returns UNVERIFIED within the command bound when a tmux runner wedges', async () => {
|
||||
vi.useFakeTimers();
|
||||
const roster: FleetRoster = {
|
||||
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
|
||||
defaults: { workingDirectory: '~/src' },
|
||||
runtimes: {},
|
||||
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
|
||||
transport: 'tmux',
|
||||
version: 1,
|
||||
};
|
||||
const promotionTransport = new TmuxPromotionTransport({
|
||||
mosaicHome: '/mosaic',
|
||||
rosterLoader: async () => roster,
|
||||
runner: async () => new Promise(() => {}),
|
||||
});
|
||||
const store: PromotionBreadcrumbStore = {
|
||||
readAttemptId: vi.fn(async () => null),
|
||||
readResult: vi.fn(async () => null),
|
||||
};
|
||||
|
||||
try {
|
||||
let observedResult: PromotionResult | undefined;
|
||||
void promoteSeat('claude-seat', {
|
||||
store,
|
||||
transport: promotionTransport,
|
||||
}).then((result) => {
|
||||
observedResult = result;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(observedResult).toMatchObject({
|
||||
reason: 'RESOLVE_FAILED: Promotion transport command timed out after 5000ms.',
|
||||
seat: 'claude-seat',
|
||||
status: 'UNVERIFIED',
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('prints VERIFIED with the resolved seat, session, bundle, and wall-clock expiry', async () => {
|
||||
const promotionTransport = transport();
|
||||
const store: PromotionBreadcrumbStore = {
|
||||
@@ -123,6 +166,34 @@ describe('mosaic promote', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('prints UNVERIFIED and exits 1 when delivery fails', async () => {
|
||||
const promotionTransport: PromotionTransport = {
|
||||
resolve: vi.fn(async () => target),
|
||||
sendPromotion: vi.fn(async () => {
|
||||
throw new Error('tmux unavailable');
|
||||
}),
|
||||
};
|
||||
const store: PromotionBreadcrumbStore = {
|
||||
readAttemptId: vi.fn(async () => null),
|
||||
readResult: vi.fn(async () => null),
|
||||
};
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerPromoteCommand(program, { store, transport: promotionTransport });
|
||||
|
||||
try {
|
||||
process.exitCode = undefined;
|
||||
await program.parseAsync(['node', 'mosaic', 'promote', 'claude-seat']);
|
||||
expect(output).toHaveBeenCalledWith(
|
||||
`UNVERIFIED seat=claude-seat session=${target.sessionId} bundle=local expiry=none reason=DELIVERY_FAILED: tmux unavailable`,
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
output.mockRestore();
|
||||
process.exitCode = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a stale result even when its nonce matches', async () => {
|
||||
const promotionTransport = transport();
|
||||
let now = 1_000;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { resolveFleetPaths, type CommandRunner } from './fleet.js';
|
||||
const ATTEMPT_ID_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 250;
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const SUBPROCESS_TIMEOUT_MS = 4_500;
|
||||
const PENDING_DIRECTORY = 'mosaic-lease';
|
||||
const RESULT_FILE = 'last-result.json';
|
||||
|
||||
@@ -92,7 +93,12 @@ export async function promoteSeat(
|
||||
const sleep = options.sleep ?? defaultSleep;
|
||||
const timeoutMs = normalizeTimeout(options.timeoutMs);
|
||||
const pollIntervalMs = normalizePollInterval(options.pollIntervalMs);
|
||||
const target = await options.transport.resolve(seat);
|
||||
let target: PromotionTarget;
|
||||
try {
|
||||
target = await options.transport.resolve(seat);
|
||||
} catch (error: unknown) {
|
||||
return unverifiedUnresolvedSeat(seat, `RESOLVE_FAILED: ${errorMessage(error)}`);
|
||||
}
|
||||
const previousAttemptId = await options.store.readAttemptId(target.sessionId);
|
||||
const preSendTimestamp = clock();
|
||||
try {
|
||||
@@ -109,6 +115,9 @@ export async function promoteSeat(
|
||||
attemptId = currentAttemptId;
|
||||
}
|
||||
if (attemptId !== null || previousAttemptId === null) {
|
||||
// Completion can consume a first attempt's nonce before this poll observes it.
|
||||
// In that branch, correlation degrades to session_id + fresh timestamp, which
|
||||
// is acceptable for this 0600, same-UID local trust boundary.
|
||||
const breadcrumb = await options.store.readResult();
|
||||
if (
|
||||
breadcrumb !== null &&
|
||||
@@ -233,6 +242,17 @@ function parseOptionTimeout(value: string | undefined): number | undefined {
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function unverifiedUnresolvedSeat(seat: string, reason: string): PromotionResult {
|
||||
return {
|
||||
bundle: 'unresolved',
|
||||
expiresAtWallclock: null,
|
||||
reason,
|
||||
seat,
|
||||
sessionId: 'unresolved',
|
||||
status: 'UNVERIFIED',
|
||||
};
|
||||
}
|
||||
|
||||
function unverified(target: PromotionTarget, reason: string): PromotionResult {
|
||||
return {
|
||||
bundle: target.bundle,
|
||||
@@ -268,6 +288,21 @@ function runCommand(
|
||||
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const finish = (result: { exitCode: number; stderr: string; stdout: string }): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(result);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
finish({
|
||||
exitCode: 124,
|
||||
stderr: `Promotion transport subprocess timed out after ${SUBPROCESS_TIMEOUT_MS}ms.`,
|
||||
stdout,
|
||||
});
|
||||
}, SUBPROCESS_TIMEOUT_MS);
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
@@ -275,10 +310,10 @@ function runCommand(
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', (error: Error) => {
|
||||
resolve({ exitCode: 127, stderr: error.message, stdout });
|
||||
finish({ exitCode: 127, stderr: error.message, stdout });
|
||||
});
|
||||
child.on('close', (code: number | null) => {
|
||||
resolve({ exitCode: code ?? 1, stderr, stdout });
|
||||
finish({ exitCode: code ?? 1, stderr, stdout });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user