aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
415 lines (384 loc) • 14.5 kB
JavaScript
// MC queue-tail bridge — long-running watcher that picks up queued missions
// from .aiwg/ralph-external/mc/sessions/*/session.json and dispatches them to
// `aiwg serve`'s executor contract endpoint.
//
// Status: cycle 1 (skeleton + smoke). Implementation tracked under #1182.
//
// This module is intentionally minimal in cycle 1 — it establishes the contract
// surface so the tailer can be wired into CLI and tested before cycle-2 lands
// the full dispatch wiring + retry/backoff + WS event subscription.
import { readdir, readFile, rename, writeFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { watch } from 'node:fs';
/**
* @typedef {object} BridgeOptions
* @property {string} aiwgServeUrl Base URL of `aiwg serve`. Default http://127.0.0.1:7337.
* @property {string} watchDir MC sessions root. Default .aiwg/ralph-external/mc.
* @property {number} pollIntervalMs Fallback polling cadence when fs.watch is unavailable. Default 1000.
* @property {number} retryBaseMs Initial backoff for failed dispatch. Default 500.
* @property {number} maxAttempts Consecutive dispatch failures before marking `failed`. Default 5.
* @property {AbortSignal} [signal] Abort signal for cooperative shutdown.
* @property {(msg: string, meta?: object) => void} [logger] Structured logger.
*/
const DEFAULTS = Object.freeze({
aiwgServeUrl: 'http://127.0.0.1:7337',
watchDir: '.aiwg/ralph-external/mc',
pollIntervalMs: 1000,
retryBaseMs: 500,
maxAttempts: 5,
});
/**
* Discover all `<id>/session.json` files under the MC sessions root.
*
* @param {string} root
* @returns {Promise<string[]>} Absolute paths.
*/
export async function discoverSessions(root) {
const sessionsDir = join(root, 'sessions');
let entries;
try {
entries = await readdir(sessionsDir, { withFileTypes: true });
} catch (err) {
if (err && err.code === 'ENOENT') return [];
throw err;
}
const paths = [];
for (const e of entries) {
if (e.isDirectory()) paths.push(join(sessionsDir, e.name, 'session.json'));
}
return paths;
}
/**
* Read and parse a session.json. Returns null on missing/unparseable file
* (the tailer should keep going on transient parse errors during writes).
*
* @param {string} path
* @returns {Promise<object|null>}
*/
export async function readSession(path) {
try {
const raw = await readFile(path, 'utf-8');
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Atomic write — temp file + rename — so a crash never leaves a half-written
* session.json. The tailer relies on this when writing back mission lifecycle
* status changes.
*
* @param {string} path
* @param {object} session
*/
export async function writeSessionAtomic(path, session) {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
session.updatedAt = new Date().toISOString();
await writeFile(tmp, JSON.stringify(session, null, 2));
await rename(tmp, path);
}
/**
* Extract missions in `queued` state from a session.
*
* @param {object} session
* @returns {Array<{sessionId: string, mission: object}>}
*/
export function queuedMissions(session) {
if (!session || !Array.isArray(session.missions)) return [];
return session.missions
.filter(m => m && m.status === 'queued')
.map(mission => ({ sessionId: session.id, mission }));
}
/**
* Build the v1 dispatch payload from an MC mission. Mirrors the wire shape in
* `test/fixtures/sandbox-api/executor-v1/dispatch/dispatch-request.json`.
*
* @param {object} mission
* @param {object} sessionMeta { sessionId }
* @returns {object}
*/
export function buildDispatchPayload(mission, sessionMeta) {
return {
mission_id: mission.id,
objective: mission.objective,
completion: mission.completion || null,
long_running: Boolean(mission.longRunning),
executor_filter: {
executor_id: mission.targetExecutorId || null,
capabilities: mission.requiredCapabilities || [],
agent_id: mission.targetAgentId || null,
},
metadata: {
mc_session_id: sessionMeta.sessionId,
mode: mission.mode || 'direct',
priority: mission.priority || 'normal',
...(mission.metadata || {}),
},
};
}
/**
* Compute exponential backoff: base * 2^(attempt-1), capped at 30 000 ms.
*
* @param {number} attempt 1-indexed.
* @param {number} baseMs
* @returns {number}
*/
export function backoffMs(attempt, baseMs) {
return Math.min(30_000, baseMs * Math.pow(2, attempt - 1));
}
/**
* Long-running watcher. Tails session.json files under watchDir, dispatches
* queued missions to aiwg serve via dispatch-client, and writes the result
* (assigned or failed) back through status-writer.
*
* Cycle 1: would-dispatch stub.
* Cycle 2 (THIS): real POST + retry/backoff + atomic status writeback.
* Cycle 3 (next): WS event subscription for runtime lifecycle transitions
* and the aiwg mc bridge CLI subcommand.
*
* @param {BridgeOptions} userOpts
* @returns {Promise<{stop: () => Promise<void>, options: BridgeOptions}>}
*/
export async function startQueueTailer(userOpts = {}) {
const options = { ...DEFAULTS, ...userOpts };
const log = options.logger || (() => {});
// Lazy-import to keep the public surface minimal and avoid pulling in
// dispatch-client during pure unit tests that only exercise pure helpers.
// dryRun=true skips the import + side effects (cycle-1 behaviour preserved).
const dryRun = Boolean(options.dryRun);
const dispatchClientP = dryRun ? null : import('./dispatch-client.mjs');
const statusWriterP = dryRun ? null : import('./status-writer.mjs');
const wsClientP = dryRun || options.disableWSSubscription
? null
: import('./executor-ws-client.mjs');
log('queue-tailer:start', {
aiwgServeUrl: options.aiwgServeUrl,
watchDir: options.watchDir,
dryRun,
});
let stopped = false;
let watcher = null;
// Track which missions are currently being dispatched so a re-tick during
// backoff doesn't spawn a parallel attempt for the same mission.
const inflight = new Set();
// Track which executors we're subscribed to (executorId → ws handle).
const wsSubscriptions = new Map();
// missionId → sessionId mapping so we know where to write back when an
// event arrives. Populated when dispatch succeeds.
const missionToSession = new Map();
/** Resolve the session.json path for a session by reading from disk. */
const sessionPathFor = (sessionId) =>
join(options.watchDir, 'sessions', sessionId, 'session.json');
/**
* Dispatch one queued mission. Marks it `assigned` on success, `failed`
* on terminal error (with reason + message), or leaves it queued on abort.
*/
const dispatchOne = async (sessionId, mission) => {
const key = `${sessionId}:${mission.id}`;
if (inflight.has(key)) return;
inflight.add(key);
const payload = buildDispatchPayload(mission, { sessionId });
const path = sessionPathFor(sessionId);
try {
const { dispatchMission } = await dispatchClientP;
const { applyStatusUpdate } = await statusWriterP;
// Race guard: between the tick's readSession and this point, another
// dispatchOne may have already taken the mission to `assigned` (e.g.,
// fs.watch fired on the writeback's temp-file and queued a duplicate
// tick). Re-read and bail if the mission is no longer queued.
const fresh = await readSession(path);
const current = fresh?.missions?.find(m => m && m.id === mission.id);
if (!current || current.status !== 'queued') {
log('queue-tailer:skip-no-longer-queued', {
missionId: mission.id,
sessionId,
observedStatus: current?.status ?? 'missing',
});
return;
}
const outcome = await dispatchMission(
{
aiwgServeUrl: options.aiwgServeUrl,
retryBaseMs: options.retryBaseMs,
maxAttempts: options.maxAttempts,
fetchImpl: options.fetchImpl,
logger: log,
},
sessionId,
payload,
options.signal,
);
if (outcome.outcome === 'accepted') {
await applyStatusUpdate(path, {
missionId: mission.id,
status: 'assigned',
transitionFrom: 'queued',
patch: {
executorId: outcome.executorId,
dispatchedAt: new Date().toISOString(),
dispatchAttempts: outcome.attempts,
estimatedStart: outcome.estimatedStart || null,
},
});
log('queue-tailer:dispatched', {
missionId: mission.id,
sessionId,
executorId: outcome.executorId,
attempts: outcome.attempts,
});
missionToSession.set(mission.id, sessionId);
// Subscribe to this executor's WS stream (no-op if already subscribed)
await ensureWSSubscription(outcome.executorId);
} else {
await applyStatusUpdate(path, {
missionId: mission.id,
status: 'failed',
transitionFrom: 'queued',
patch: {
failedAt: new Date().toISOString(),
failureReason: outcome.reason,
failureMessage: outcome.message,
dispatchAttempts: outcome.attempts,
},
});
log('queue-tailer:dispatch-failed', {
missionId: mission.id,
sessionId,
reason: outcome.reason,
attempts: outcome.attempts,
});
}
} catch (err) {
log('queue-tailer:dispatch-error', {
missionId: mission.id,
sessionId,
error: String(err && err.message || err),
});
} finally {
inflight.delete(key);
}
};
const tick = async () => {
if (stopped) return;
const paths = await discoverSessions(options.watchDir);
for (const path of paths) {
const session = await readSession(path);
if (!session) continue;
const queued = queuedMissions(session);
for (const { mission } of queued) {
if (dryRun) {
const payload = buildDispatchPayload(mission, { sessionId: session.id });
log('queue-tailer:would-dispatch', {
missionId: mission.id,
sessionId: session.id,
endpoint: `${options.aiwgServeUrl}/api/v1/sessions/${session.id}/dispatch`,
payloadDigest: digestPayload(payload),
});
} else {
// Fire-and-track; dispatchOne is async and gates re-entry via inflight.
void dispatchOne(session.id, mission);
}
}
}
};
/**
* Ensure we have a live WS subscription to the given executor. Idempotent —
* subsequent calls are no-ops once the subscription is established. When the
* subscription is disabled (dryRun or disableWSSubscription), this is a no-op.
*/
async function ensureWSSubscription(executorId) {
if (!wsClientP) return;
if (!executorId || executorId === 'unknown') return;
if (wsSubscriptions.has(executorId)) return;
const { startExecutorWS, eventToStatusUpdate } = await wsClientP;
const { applyStatusUpdate } = await statusWriterP;
const handle = startExecutorWS({
aiwgServeUrl: options.aiwgServeUrl,
executorId,
token: options.executorTokens?.[executorId],
WebSocketImpl: options.WebSocketImpl,
logger: log,
onEvent: async (event) => {
const update = eventToStatusUpdate(event);
if (!update) return;
const sessionId = missionToSession.get(update.missionId);
if (!sessionId) {
log('queue-tailer:event-unmapped-mission', {
missionId: update.missionId,
event: event.event,
});
return;
}
const path = sessionPathFor(sessionId);
const res = await applyStatusUpdate(path, update);
log('queue-tailer:event-applied', {
missionId: update.missionId,
event: event.event,
newStatus: update.status,
outcome: res.outcome,
});
// Forget the mission once it reaches a terminal state.
if (['done', 'failed', 'aborted'].includes(update.status)) {
missionToSession.delete(update.missionId);
}
},
onState: (s) => log('queue-tailer:ws-state', { executorId, state: s }),
});
wsSubscriptions.set(executorId, handle);
}
try {
watcher = watch(options.watchDir, { recursive: true }, () => {
tick().catch(err => log('queue-tailer:tick-error', { error: String(err) }));
});
} catch {
// fs.watch unavailable on some filesystems; fall back to polling.
const id = setInterval(() => {
if (stopped) return;
tick().catch(err => log('queue-tailer:tick-error', { error: String(err) }));
}, options.pollIntervalMs);
watcher = { close: () => clearInterval(id) };
}
// Initial sweep so existing queued missions get seen on startup.
await tick();
// If the operator pre-declared executors to subscribe to, hook them up now.
// (Useful when missions are dispatched out-of-band and we want immediate
// event coverage rather than waiting for the first bridge-driven dispatch.)
if (!dryRun && options.subscribeExecutors) {
for (const id of options.subscribeExecutors) {
await ensureWSSubscription(id);
}
}
if (options.signal) {
options.signal.addEventListener('abort', () => { void stop(); }, { once: true });
}
async function stop() {
if (stopped) return;
stopped = true;
try { watcher?.close?.(); } catch {}
// Tear down WS subscriptions.
for (const [, handle] of wsSubscriptions) {
try { await handle.stop(); } catch {}
}
wsSubscriptions.clear();
// Wait briefly for any inflight dispatches to settle so we don't leave
// half-finished writes.
const drainStart = Date.now();
while (inflight.size > 0 && Date.now() - drainStart < 5000) {
await new Promise(r => setTimeout(r, 50));
}
log('queue-tailer:stop', { drainedInflight: inflight.size === 0 });
}
return { stop, options };
}
/**
* Short payload digest for log lines — keeps long objectives out of the log
* without losing identity.
*
* @param {object} payload
* @returns {string}
*/
function digestPayload(payload) {
const o = payload.objective || '';
return `${payload.mission_id}:${o.slice(0, 32)}${o.length > 32 ? '…' : ''}`;
}