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
239 lines (218 loc) • 8.32 kB
JavaScript
// MC bridge dispatch client — POST wrapper for aiwg serve's
// /api/v1/sessions/:id/dispatch endpoint with retry/backoff and typed errors.
//
// Cycle 2 of #1182. Cycle-1 had a stub that logged "would-dispatch"; this
// module fires real requests and surfaces structured failure modes the
// queue-tailer can act on (mark mission failed vs. retry).
import { backoffMs } from './queue-tailer.mjs';
/**
* @typedef {object} DispatchClientOptions
* @property {string} aiwgServeUrl Base URL of aiwg serve.
* @property {number} [retryBaseMs=500] Initial backoff for retryable failures.
* @property {number} [maxAttempts=5] Total attempts (1 initial + N-1 retries) before giving up.
* @property {typeof fetch} [fetchImpl] Inject for tests. Defaults to globalThis.fetch.
* @property {(msg: string, meta?: object) => void} [logger]
*/
/**
* @typedef {object} DispatchAccepted
* @property {'accepted'} outcome
* @property {string} missionId
* @property {string} executorId
* @property {string} [estimatedStart]
* @property {'v2'|'v1-fallback'} [dispatchPath]
* @property {boolean} [idempotentReplayed]
* @property {number} attempts How many attempts it took to succeed.
*/
/**
* @typedef {object} DispatchFailedTerminal
* @property {'failed'} outcome
* @property {string} missionId
* @property {string} reason Short code: invalid_request | unauthorized | no_executor_available | dispatch_error | client_error
* @property {string} message Human-readable detail (RFC 7807 detail when available).
* @property {number} status HTTP status (-1 for never-connected).
* @property {number} attempts
*/
/**
* @typedef {DispatchAccepted | DispatchFailedTerminal} DispatchOutcome
*/
const DEFAULT_RETRY_BASE_MS = 500;
const DEFAULT_MAX_ATTEMPTS = 5;
/**
* Classify an HTTP response into one of three buckets:
* - 'accepted' → 2xx, payload describes the mission state
* - 'retryable' → 5xx, 503, transient — retry with backoff
* - 'terminal' → 4xx (except 503-as-retryable), no point retrying
*
* @param {number} status
* @returns {'accepted'|'retryable'|'terminal'}
*/
export function classifyStatus(status) {
if (status >= 200 && status < 300) return 'accepted';
// 503 with "no_executor_available" is retryable — operator may be
// bringing up an executor. 503 in general is treated as transient.
if (status === 503) return 'retryable';
// 502, 504 (gateway/proxy) — transient
if (status === 502 || status === 504) return 'retryable';
// Any other 5xx → transient as well
if (status >= 500 && status < 600) return 'retryable';
// 4xx → terminal (caller's payload won't suddenly become valid)
return 'terminal';
}
/**
* Map a 4xx status to a short reason code the status-writer surfaces.
*
* @param {number} status
* @param {string} fallback
* @returns {string}
*/
export function reasonForStatus(status, fallback = 'client_error') {
switch (status) {
case 400: return 'invalid_request';
case 401:
case 403: return 'unauthorized';
case 404: return 'executor_not_found';
case 409: return 'idempotency_conflict';
case 422: return 'idempotency_key_reused';
case 503: return 'no_executor_available';
default: return fallback;
}
}
/**
* Sleep promise that honours an AbortSignal so a SIGINT during backoff
* resolves promptly instead of waiting out the full delay.
*
* @param {number} ms
* @param {AbortSignal} [signal]
* @returns {Promise<void>}
*/
export function sleep(ms, signal) {
return new Promise(resolve => {
if (signal?.aborted) { resolve(); return; }
const t = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
});
}
/**
* Fire one dispatch attempt against aiwg serve.
*
* @param {DispatchClientOptions} opts
* @param {string} mcSessionId
* @param {object} payload
* @param {AbortSignal} [signal]
* @returns {Promise<{status: number, body: any, networkError?: Error}>}
*/
async function oneAttempt(opts, mcSessionId, payload, signal) {
const fetchImpl = opts.fetchImpl || globalThis.fetch;
if (!fetchImpl) {
throw new Error('No fetch implementation available — pass opts.fetchImpl or run on Node 18+.');
}
const url = `${opts.aiwgServeUrl.replace(/\/+$/, '')}/api/v1/sessions/${encodeURIComponent(mcSessionId)}/dispatch`;
try {
const resp = await fetchImpl(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
});
let body = null;
const text = await resp.text();
if (text) {
try { body = JSON.parse(text); }
catch { body = { error: text }; }
}
return { status: resp.status, body };
} catch (err) {
// ECONNREFUSED, DNS failure, timeout → network error, treat as retryable
return { status: -1, body: null, networkError: err };
}
}
/**
* Dispatch a mission to aiwg serve. Handles retry/backoff. Returns a typed
* outcome that the caller (queue-tailer) uses to update mission status.
*
* Retry policy:
* - 2xx → accepted, return immediately
* - 5xx / network → retry with exponential backoff up to maxAttempts
* - 4xx → terminal failure, no retry
*
* @param {DispatchClientOptions} opts
* @param {string} mcSessionId
* @param {object} payload The mission dispatch payload (V1DispatchPayload shape).
* @param {AbortSignal} [signal]
* @returns {Promise<DispatchOutcome>}
*/
export async function dispatchMission(opts, mcSessionId, payload, signal) {
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
const retryBaseMs = opts.retryBaseMs ?? DEFAULT_RETRY_BASE_MS;
const log = opts.logger || (() => {});
const missionId = payload.mission_id;
let lastStatus = -1;
let lastMessage = 'no attempt made';
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (signal?.aborted) {
return {
outcome: 'failed',
missionId,
reason: 'aborted',
message: 'dispatch aborted before completion',
status: lastStatus,
attempts: attempt - 1,
};
}
const { status, body, networkError } = await oneAttempt(opts, mcSessionId, payload, signal);
lastStatus = status;
if (networkError) {
lastMessage = `network: ${networkError.message || String(networkError)}`;
log('dispatch-client:network-error', { missionId, attempt, error: lastMessage });
} else if (status === -1) {
lastMessage = 'unknown network error';
} else {
lastMessage = (body && (body.detail || body.error)) || `HTTP ${status}`;
}
const bucket = networkError ? 'retryable' : classifyStatus(status);
if (bucket === 'accepted') {
const accepted = {
outcome: 'accepted',
missionId,
executorId: (body && body.executor_id) || 'unknown',
estimatedStart: body && body.estimated_start,
dispatchPath: body && body.dispatch_path,
idempotentReplayed: Boolean(body && body.idempotent_replayed),
attempts: attempt,
};
log('dispatch-client:accepted', {
missionId,
attempts: attempt,
executorId: accepted.executorId,
});
return accepted;
}
if (bucket === 'terminal') {
const reason = reasonForStatus(status);
log('dispatch-client:terminal', { missionId, status, reason, message: lastMessage });
return {
outcome: 'failed',
missionId,
reason,
message: lastMessage,
status,
attempts: attempt,
};
}
// Retryable: back off (unless this was the last attempt)
if (attempt < maxAttempts) {
const delay = backoffMs(attempt, retryBaseMs);
log('dispatch-client:retrying', { missionId, attempt, status, delay });
await sleep(delay, signal);
}
}
log('dispatch-client:exhausted', { missionId, attempts: maxAttempts, lastStatus });
return {
outcome: 'failed',
missionId,
reason: lastStatus === -1 ? 'unreachable' : reasonForStatus(lastStatus, 'dispatch_error'),
message: `dispatch exhausted ${maxAttempts} attempts: ${lastMessage}`,
status: lastStatus,
attempts: maxAttempts,
};
}