mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
1544 lines
55 KiB
JavaScript
1544 lines
55 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { isAbsolute } from 'node:path';
|
|
|
|
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
|
|
import {
|
|
appendInboundFilesToPrompt,
|
|
InboundFileError,
|
|
} from './inbound-file.mjs';
|
|
import { outboundArtifactRegistry } from './semantic/artifact.mjs';
|
|
|
|
// Every channel plugin runs in the same Host process. Sharing ownership by
|
|
// Harness origin prevents two channel-specific clients bound to one Session
|
|
// from claiming or cancelling each other's interactions.
|
|
const interactionRegistries = new Map();
|
|
const MAX_ERROR_CLASSIFICATION_BYTES = 64;
|
|
|
|
async function smallResponseText(response) {
|
|
const stream = response?.body;
|
|
if (!stream || typeof stream.getReader !== 'function') return null;
|
|
|
|
const reader = stream.getReader();
|
|
const chunks = [];
|
|
let length = 0;
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (!(value instanceof Uint8Array)
|
|
|| length + value.byteLength > MAX_ERROR_CLASSIFICATION_BYTES) return null;
|
|
chunks.push(value);
|
|
length += value.byteLength;
|
|
}
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
try {
|
|
await reader.cancel();
|
|
} catch {
|
|
// The response body is diagnostic-only; cancellation failures do not
|
|
// replace the HTTP status that caused the transport error.
|
|
}
|
|
}
|
|
|
|
const bytes = new Uint8Array(length);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
bytes.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
return new TextDecoder().decode(bytes);
|
|
}
|
|
|
|
function isLoopbackHarnessHostname(hostname) {
|
|
if (hostname === 'localhost' || hostname === '[::1]') return true;
|
|
const parts = hostname.split('.');
|
|
return parts.length === 4
|
|
&& parts[0] === '127'
|
|
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
}
|
|
|
|
async function harnessHttpErrorCode(response, hostname) {
|
|
if (response.status === 401) return 'harness-auth-required';
|
|
if (response.status === 407) return 'harness-proxy-auth-required';
|
|
if (response.status === 403) {
|
|
const body = await smallResponseText(response);
|
|
if (body?.trim() !== 'forbidden') return 'harness-request-forbidden';
|
|
return isLoopbackHarnessHostname(hostname)
|
|
? 'harness-loopback-forbidden'
|
|
: 'harness-host-untrusted';
|
|
}
|
|
if (response.status === 404) return 'harness-api-not-found';
|
|
return 'harness-http-failed';
|
|
}
|
|
|
|
function interactionRegistry(origin) {
|
|
let registry = interactionRegistries.get(origin);
|
|
if (!registry) {
|
|
registry = {
|
|
ownerships: new Map(),
|
|
claims: new Map(),
|
|
controls: new WeakMap(),
|
|
nextOrder: 0,
|
|
};
|
|
interactionRegistries.set(origin, registry);
|
|
}
|
|
return registry;
|
|
}
|
|
|
|
function normalizeControl(control) {
|
|
const ownerType = typeof control?.owner;
|
|
if ((ownerType !== 'object' && ownerType !== 'function')
|
|
|| control.owner === null
|
|
|| typeof control.key !== 'string'
|
|
|| !control.key) return null;
|
|
return { owner: control.owner, key: control.key };
|
|
}
|
|
|
|
function validModelSelection(value) {
|
|
return value !== null
|
|
&& typeof value === 'object'
|
|
&& typeof value.provider === 'string'
|
|
&& Boolean(value.provider)
|
|
&& typeof value.model === 'string'
|
|
&& Boolean(value.model)
|
|
&& (value.reasoningEffort === undefined || typeof value.reasoningEffort === 'string');
|
|
}
|
|
|
|
function validateModelCatalog(value, method, { session = false } = {}) {
|
|
if (!value || typeof value !== 'object'
|
|
|| !Array.isArray(value.groups)
|
|
|| !Array.isArray(value.failures)) {
|
|
throw new Error(`Harness returned an invalid response for ${method}`);
|
|
}
|
|
for (const group of value.groups) {
|
|
if (!group || typeof group !== 'object'
|
|
|| typeof group.id !== 'string' || !group.id
|
|
|| typeof group.name !== 'string' || !group.name
|
|
|| !Array.isArray(group.models)) {
|
|
throw new Error(`Harness returned an invalid response for ${method}`);
|
|
}
|
|
for (const model of group.models) {
|
|
if (!model || typeof model !== 'object'
|
|
|| typeof model.id !== 'string' || !model.id
|
|
|| typeof model.name !== 'string' || !model.name) {
|
|
throw new Error(`Harness returned an invalid response for ${method}`);
|
|
}
|
|
}
|
|
}
|
|
for (const failure of value.failures) {
|
|
if (!failure || typeof failure !== 'object'
|
|
|| typeof failure.id !== 'string' || !failure.id
|
|
|| typeof failure.name !== 'string' || !failure.name
|
|
|| (failure.message !== undefined && typeof failure.message !== 'string')) {
|
|
throw new Error(`Harness returned an invalid response for ${method}`);
|
|
}
|
|
}
|
|
if (session && (!validModelSelection(value.current) || typeof value.routable !== 'boolean')) {
|
|
throw new Error(`Harness returned an invalid response for ${method}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function turnStoppedError() {
|
|
const error = new Error('Harness turn was stopped before producing a text reply');
|
|
error.code = 'turn-stopped';
|
|
return error;
|
|
}
|
|
|
|
function workspacePaths(value) {
|
|
if (!Array.isArray(value?.items)) return [];
|
|
return value.items.flatMap((item) => (
|
|
typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
|
|
));
|
|
}
|
|
|
|
function workspaceFromList(workspacePath, workspaceList) {
|
|
if (!Array.isArray(workspaceList?.items)
|
|
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
|
|
throw new Error('Harness returned an invalid response for workspace.list');
|
|
}
|
|
|
|
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
|
|
if (!workspace) return null;
|
|
if (!Array.isArray(workspace.sessionIds)
|
|
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
|
|
throw new Error('Harness returned invalid session IDs for workspace.list');
|
|
}
|
|
return workspace;
|
|
}
|
|
|
|
function toEpochMs(value) {
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value < 1e12 ? value * 1000 : value;
|
|
}
|
|
if (typeof value === 'string' && value) {
|
|
const parsed = Date.parse(value);
|
|
if (!Number.isNaN(parsed)) return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sessionTimeMs(summary) {
|
|
if (!summary || typeof summary !== 'object') return null;
|
|
const candidates = [
|
|
summary?.header?.lastActivityAt,
|
|
summary?.header?.updatedAt,
|
|
summary?.header?.createdAt,
|
|
summary?.projections?.values?.lastActivityAt,
|
|
summary?.projections?.values?.updatedAt,
|
|
summary?.projections?.values?.createdAt,
|
|
summary?.lastActivityAt,
|
|
summary?.updatedAt,
|
|
summary?.createdAt,
|
|
];
|
|
for (const value of candidates) {
|
|
const ms = toEpochMs(value);
|
|
if (ms !== null) return ms;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function workspaceSessions(workspace, archivedSessionIds, sessionList) {
|
|
if (!Array.isArray(sessionList?.items)) {
|
|
throw new Error('Harness returned an invalid response for session.list');
|
|
}
|
|
|
|
const archived = new Set(archivedSessionIds);
|
|
const summaries = new Map(sessionList.items.flatMap((item) => (
|
|
typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
|
|
)));
|
|
return {
|
|
workspace: workspace.path,
|
|
sessions: workspace.sessionIds.map((sessionId) => {
|
|
const summary = summaries.get(sessionId);
|
|
const title = summary?.projections?.values?.title;
|
|
const session = {
|
|
sessionId,
|
|
title: typeof title === 'string' ? title : null,
|
|
archived: archived.has(sessionId),
|
|
blank: summary?.blank === true,
|
|
origin: summary?.origin === 'subagent' ? 'subagent' : null,
|
|
summaryAvailable: summary !== undefined,
|
|
};
|
|
const time = sessionTimeMs(summary);
|
|
if (time !== null) session.time = time;
|
|
return session;
|
|
}),
|
|
};
|
|
}
|
|
|
|
function sleep(ms, signal) {
|
|
return new Promise((resolve, reject) => {
|
|
if (signal?.aborted) {
|
|
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => {
|
|
signal?.removeEventListener('abort', onAbort);
|
|
resolve();
|
|
}, ms);
|
|
const onAbort = () => {
|
|
clearTimeout(timer);
|
|
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
};
|
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
}
|
|
|
|
function assistantMessageText(event) {
|
|
return (event?.data?.message?.content ?? [])
|
|
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
|
.map((part) => part.text)
|
|
.join('\n')
|
|
.trim();
|
|
}
|
|
|
|
function consumeInteractionOwnership(ownership, entries) {
|
|
const ordered = [...entries]
|
|
.map((entry) => entry?.event ?? entry)
|
|
.filter(Boolean)
|
|
.sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1));
|
|
|
|
for (const event of ordered) {
|
|
const seq = event.seq ?? -1;
|
|
if (seq <= ownership.lastSeq) continue;
|
|
ownership.lastSeq = seq;
|
|
|
|
if (event.type === 'turn/start') {
|
|
const turn = event.data?.turn ?? null;
|
|
if (ownership.active && turn !== ownership.turn) ownership.active = false;
|
|
if (ownership.turn !== null && turn !== ownership.turn) ownership.completed = true;
|
|
ownership.openTurn = turn;
|
|
continue;
|
|
}
|
|
if (event.type === 'user/message' && event.data?.source?.rpcId === ownership.promptRpcId) {
|
|
ownership.active = true;
|
|
ownership.started = true;
|
|
ownership.completed = false;
|
|
ownership.turn = event.data?.turn ?? ownership.openTurn;
|
|
continue;
|
|
}
|
|
if (event.type === 'turn/end' && event.data?.turn === ownership.turn) {
|
|
ownership.active = false;
|
|
ownership.completed = true;
|
|
continue;
|
|
}
|
|
let toolCall = null;
|
|
if (event.type === 'tool/call'
|
|
&& ownership.active
|
|
&& event.data?.turn === ownership.turn
|
|
&& typeof event.data?.callId === 'string'
|
|
&& event.data.callId) {
|
|
toolCall = {
|
|
callId: event.data.callId,
|
|
name: event.data?.name,
|
|
arguments: event.data?.arguments,
|
|
};
|
|
} else if (event.type === 'tool/code-dispatch-start'
|
|
&& ownership.active
|
|
&& typeof event.data?.subCallId === 'string'
|
|
&& event.data.subCallId) {
|
|
let argumentsText;
|
|
try {
|
|
argumentsText = JSON.stringify(event.data?.arguments);
|
|
} catch {
|
|
argumentsText = undefined;
|
|
}
|
|
toolCall = {
|
|
callId: event.data.subCallId,
|
|
name: event.data?.name,
|
|
arguments: argumentsText,
|
|
};
|
|
}
|
|
if (toolCall) ownership.toolCalls.set(toolCall.callId, Object.freeze(toolCall));
|
|
}
|
|
}
|
|
|
|
export class HarnessReplyTracker {
|
|
#promptRpcId;
|
|
#lastSeq;
|
|
#openTurn = null;
|
|
#targetTurn = null;
|
|
#stepText = new Map();
|
|
#latestText = '';
|
|
#finished = false;
|
|
#reason = null;
|
|
|
|
constructor({ promptRpcId, afterSeq = -1 }) {
|
|
this.#promptRpcId = promptRpcId;
|
|
this.#lastSeq = afterSeq;
|
|
}
|
|
|
|
get finished() {
|
|
return this.#finished;
|
|
}
|
|
|
|
get answer() {
|
|
return this.#latestText.trim();
|
|
}
|
|
|
|
get reason() {
|
|
return this.#reason;
|
|
}
|
|
|
|
get tracking() {
|
|
return this.#targetTurn !== null && !this.#finished;
|
|
}
|
|
|
|
get turn() {
|
|
return this.#targetTurn;
|
|
}
|
|
|
|
consume(entries) {
|
|
let update = null;
|
|
const ordered = [...entries]
|
|
.map((entry) => entry?.event ?? entry)
|
|
.filter(Boolean)
|
|
.sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1));
|
|
|
|
for (const event of ordered) {
|
|
const seq = event.seq ?? -1;
|
|
if (seq <= this.#lastSeq) continue;
|
|
this.#lastSeq = seq;
|
|
|
|
if (event.type === 'turn/start') this.#openTurn = event.data?.turn ?? null;
|
|
|
|
if (event.type === 'user/message' && event.data?.source?.rpcId === this.#promptRpcId) {
|
|
this.#targetTurn = this.#openTurn;
|
|
continue;
|
|
}
|
|
if (this.#targetTurn === null) continue;
|
|
|
|
if (event.type === 'turn/end') {
|
|
if (event.data?.turn !== this.#targetTurn) continue;
|
|
this.#finished = true;
|
|
this.#reason = event.data?.reason ?? null;
|
|
this.#openTurn = null;
|
|
continue;
|
|
}
|
|
if (event.data?.turn !== this.#targetTurn) continue;
|
|
|
|
if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
|
|
const step = event.data?.step ?? 0;
|
|
const index = event.data.chunk.index ?? 0;
|
|
const key = `${step}:${index}`;
|
|
this.#stepText.set(key, (this.#stepText.get(key) ?? '') + event.data.chunk.text);
|
|
const prefix = `${step}:`;
|
|
const text = [...this.#stepText.entries()]
|
|
.filter(([partKey]) => partKey.startsWith(prefix))
|
|
.sort(([left], [right]) => Number(left.split(':')[1]) - Number(right.split(':')[1]))
|
|
.map(([, part]) => part)
|
|
.join('\n')
|
|
.trim();
|
|
if (text && text !== this.#latestText) {
|
|
this.#latestText = text;
|
|
update = { type: 'text', text };
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (event.type === 'assistant/message') {
|
|
const text = assistantMessageText(event);
|
|
if (text && text !== this.#latestText) {
|
|
this.#latestText = text;
|
|
update = { type: 'text', text };
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (event.type === 'tool/call') {
|
|
update = { type: 'tool', name: event.data?.name ?? '工具' };
|
|
} else if (event.type === 'tool/result') {
|
|
update = { type: 'status', text: '正在整理结果…' };
|
|
}
|
|
}
|
|
return update;
|
|
}
|
|
}
|
|
|
|
export class HarnessRpcError extends Error {
|
|
constructor(method, error) {
|
|
super(`${method}: ${error?.message ?? 'unknown Harness RPC error'}`);
|
|
this.name = 'HarnessRpcError';
|
|
this.method = method;
|
|
this.code = error?.code ?? 'internal';
|
|
this.details = error?.details ?? {};
|
|
}
|
|
}
|
|
|
|
export class HarnessTransportError extends Error {
|
|
constructor(code, method, { cause, status } = {}) {
|
|
const statusDetail = Number.isInteger(status) ? `, HTTP ${status}` : '';
|
|
super(`Harness ${method} transport failed (${code}${statusDetail})`, { cause });
|
|
this.name = 'HarnessTransportError';
|
|
this.code = code;
|
|
this.method = method;
|
|
if (Number.isInteger(status)) this.status = status;
|
|
}
|
|
}
|
|
|
|
export class HarnessHealthError extends Error {
|
|
constructor(cause) {
|
|
super('Harness health RPC was rejected', { cause });
|
|
this.name = 'HarnessHealthError';
|
|
this.code = 'harness-rpc-rejected';
|
|
this.method = 'host.describe';
|
|
}
|
|
}
|
|
|
|
export class HarnessInteractionError extends Error {
|
|
constructor(code, message) {
|
|
super(message);
|
|
this.name = 'HarnessInteractionError';
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
export class HarnessClient {
|
|
#baseUrl;
|
|
#workspace;
|
|
#agentPreset;
|
|
#autostart;
|
|
#dshBin;
|
|
#fetch;
|
|
#createWebSocket;
|
|
#interactionReconnectDelayMs;
|
|
#rpcIdPrefix;
|
|
#logPrefix;
|
|
#commandExecutor;
|
|
#controlExecutor;
|
|
#sessionMaintenanceExecutor;
|
|
#fileIngressExecutor;
|
|
#managedProcess = null;
|
|
#interactionRegistry;
|
|
#interactionOwnerships;
|
|
#interactionClaims;
|
|
#controlOwnerships;
|
|
|
|
constructor({
|
|
baseUrl,
|
|
workspace,
|
|
agentPreset,
|
|
autostart = false,
|
|
dshBin = 'dsh',
|
|
fetchImpl = fetch,
|
|
createWebSocket = (url) => new WebSocket(url),
|
|
interactionReconnectDelayMs = 500,
|
|
rpcIdPrefix = 'im',
|
|
logPrefix = 'dsh-im',
|
|
commandExecutor,
|
|
controlExecutor,
|
|
sessionMaintenanceExecutor,
|
|
fileIngressExecutor,
|
|
}) {
|
|
if (typeof createWebSocket !== 'function') {
|
|
throw new TypeError('createWebSocket must be a function');
|
|
}
|
|
if (!Number.isFinite(interactionReconnectDelayMs) || interactionReconnectDelayMs < 0) {
|
|
throw new TypeError('interactionReconnectDelayMs must be a non-negative number');
|
|
}
|
|
if (typeof rpcIdPrefix !== 'string' || !rpcIdPrefix.trim()) {
|
|
throw new TypeError('rpcIdPrefix must be a non-empty string');
|
|
}
|
|
if (typeof logPrefix !== 'string' || !logPrefix.trim()) {
|
|
throw new TypeError('logPrefix must be a non-empty string');
|
|
}
|
|
if (commandExecutor !== undefined && typeof commandExecutor !== 'function') {
|
|
throw new TypeError('commandExecutor must be a function');
|
|
}
|
|
if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
|
|
throw new TypeError('controlExecutor must be a function');
|
|
}
|
|
if (sessionMaintenanceExecutor !== undefined
|
|
&& typeof sessionMaintenanceExecutor !== 'function') {
|
|
throw new TypeError('sessionMaintenanceExecutor must be a function');
|
|
}
|
|
if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
|
|
throw new TypeError('fileIngressExecutor must be a function');
|
|
}
|
|
this.#baseUrl = new URL(baseUrl);
|
|
this.#workspace = workspace;
|
|
// Keep an omitted preset absent so session.create resolves the Host's current default.
|
|
this.#agentPreset = agentPreset ?? undefined;
|
|
this.#autostart = autostart;
|
|
this.#dshBin = dshBin;
|
|
this.#fetch = fetchImpl;
|
|
this.#createWebSocket = createWebSocket;
|
|
this.#interactionReconnectDelayMs = interactionReconnectDelayMs;
|
|
this.#rpcIdPrefix = rpcIdPrefix.trim();
|
|
this.#logPrefix = logPrefix.trim();
|
|
this.#commandExecutor = commandExecutor;
|
|
this.#controlExecutor = controlExecutor;
|
|
this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
|
|
this.#fileIngressExecutor = fileIngressExecutor;
|
|
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
|
|
this.#interactionOwnerships = this.#interactionRegistry.ownerships;
|
|
this.#interactionClaims = this.#interactionRegistry.claims;
|
|
this.#controlOwnerships = this.#interactionRegistry.controls;
|
|
}
|
|
|
|
async rpc(method, payload = {}, timeoutMs = 30_000, options = {}) {
|
|
const rpcId = options.rpcId ?? `${this.#rpcIdPrefix}-${randomUUID()}`;
|
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
const signal = options.signal
|
|
? AbortSignal.any([options.signal, timeoutSignal])
|
|
: timeoutSignal;
|
|
let response;
|
|
try {
|
|
response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
|
|
signal,
|
|
});
|
|
} catch (error) {
|
|
// Preserve an explicit caller cancellation; it is control flow, not a
|
|
// Harness availability diagnosis.
|
|
if (options.signal?.aborted) throw error;
|
|
throw new HarnessTransportError(
|
|
timeoutSignal.aborted ? 'harness-timeout' : 'harness-connect-failed',
|
|
method,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
if (!response.ok) {
|
|
const code = await harnessHttpErrorCode(response, this.#baseUrl.hostname);
|
|
throw new HarnessTransportError(code, method, { status: response.status });
|
|
}
|
|
let body;
|
|
try {
|
|
body = await response.json();
|
|
} catch (error) {
|
|
throw new HarnessTransportError('harness-response-invalid', method, { cause: error });
|
|
}
|
|
if (body?.type !== 'server-response' || body?.rpcId !== rpcId) {
|
|
throw new HarnessTransportError('harness-response-invalid', method, {
|
|
cause: new Error(`Harness returned an invalid response for ${method}`),
|
|
});
|
|
}
|
|
if (!body.result || typeof body.result !== 'object' || typeof body.result.ok !== 'boolean') {
|
|
throw new HarnessTransportError('harness-response-invalid', method, {
|
|
cause: new Error(`Harness returned an invalid result for ${method}`),
|
|
});
|
|
}
|
|
if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error);
|
|
return body.result.value;
|
|
}
|
|
|
|
async health(options = {}) {
|
|
try {
|
|
await this.rpc('host.describe', {}, 5_000, options);
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof HarnessRpcError) throw new HarnessHealthError(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async ensureRunning(options = {}) {
|
|
try {
|
|
return await this.health(options);
|
|
} catch (firstError) {
|
|
if (!this.#autostart) throw firstError;
|
|
}
|
|
|
|
if (!this.#managedProcess || this.#managedProcess.exitCode !== null) {
|
|
const port = this.#baseUrl.port || (this.#baseUrl.protocol === 'https:' ? '443' : '80');
|
|
this.#managedProcess = spawn(this.#dshBin, [
|
|
'web', '--host', this.#baseUrl.hostname, '--port', port,
|
|
], {
|
|
cwd: this.#workspace,
|
|
env: process.env,
|
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
});
|
|
this.#managedProcess.on('error', (error) => {
|
|
console.error(`[${this.#logPrefix}] failed to start Harness:`, error.message);
|
|
});
|
|
}
|
|
|
|
const deadline = Date.now() + 60_000;
|
|
let lastError;
|
|
while (Date.now() < deadline) {
|
|
await sleep(1_000, options.signal);
|
|
try {
|
|
return await this.health(options);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
if (lastError) throw lastError;
|
|
throw new HarnessTransportError('harness-timeout', 'host.describe');
|
|
}
|
|
|
|
async listWorkspaces(options = {}) {
|
|
await this.ensureRunning(options);
|
|
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
|
|
}
|
|
|
|
async listWorkspaceSessions(workspacePath, options = {}) {
|
|
await this.ensureRunning(options);
|
|
const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
|
|
const workspace = workspaceFromList(workspacePath, workspaceList);
|
|
if (!workspace) return { workspace: workspacePath, sessions: [] };
|
|
const sessionList = await this.rpc('session.list', {}, 30_000, options);
|
|
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
|
}
|
|
|
|
async listModels(options = {}) {
|
|
await this.ensureRunning(options);
|
|
const value = await this.rpc('llm.models', {}, 30_000, options);
|
|
return validateModelCatalog(value, 'llm.models');
|
|
}
|
|
|
|
async getSessionModels(sessionId, options = {}) {
|
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
await this.ensureRunning(options);
|
|
const value = await this.rpc('session.models', { sessionId }, 30_000, options);
|
|
return validateModelCatalog(value, 'session.models', { session: true });
|
|
}
|
|
|
|
async selectSessionModel(sessionId, selection, options = {}) {
|
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
if (!validModelSelection(selection)) {
|
|
throw new TypeError('A provider and model are required');
|
|
}
|
|
await this.ensureRunning(options);
|
|
const operation = (maintenanceSignal) => {
|
|
const signal = maintenanceSignal && options.signal
|
|
? AbortSignal.any([maintenanceSignal, options.signal])
|
|
: (maintenanceSignal ?? options.signal);
|
|
return this.rpc('session.selectModel', {
|
|
sessionId,
|
|
provider: selection.provider,
|
|
model: selection.model,
|
|
}, 30_000, signal ? { ...options, signal } : options);
|
|
};
|
|
const value = this.#sessionMaintenanceExecutor
|
|
? await this.#sessionMaintenanceExecutor({ sessionId, operation })
|
|
: await operation();
|
|
if (!value || typeof value !== 'object' || !validModelSelection(value.selected)) {
|
|
throw new Error('Harness returned an invalid response for session.selectModel');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async isSessionRunning(sessionId, options = {}) {
|
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
await this.ensureRunning(options);
|
|
const value = await this.rpc('session.list', {}, 30_000, options);
|
|
if (!value || typeof value !== 'object' || !Array.isArray(value.items)) {
|
|
throw new Error('Harness returned an invalid response for session.list');
|
|
}
|
|
for (const item of value.items) {
|
|
if (!item || typeof item !== 'object'
|
|
|| typeof item.sessionId !== 'string' || !item.sessionId
|
|
|| typeof item.running !== 'boolean') {
|
|
throw new Error('Harness returned an invalid response for session.list');
|
|
}
|
|
}
|
|
return value.items.find((item) => item.sessionId === sessionId)?.running ?? false;
|
|
}
|
|
|
|
async adoptWorkspaceSession(value, options = {}) {
|
|
return adoptRegisteredWorkspaceSession(this, value, options);
|
|
}
|
|
|
|
async workspaceId(options = {}) {
|
|
const { workspace = this.#workspace, ...rpcOptions } = options;
|
|
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
|
|
const existing = items.find((item) => item.path === workspace);
|
|
if (existing) return existing.workspaceId;
|
|
const created = await this.rpc('workspace.create', { path: workspace }, 30_000, rpcOptions);
|
|
return created.workspace.workspaceId;
|
|
}
|
|
|
|
async createSession(options = {}) {
|
|
const { agentPreset: requestedPreset, ...rpcOptions } = options;
|
|
await this.ensureRunning(rpcOptions);
|
|
const workspaceId = await this.workspaceId(rpcOptions);
|
|
const payload = { workspaceId };
|
|
const agentPreset = requestedPreset !== undefined ? requestedPreset : this.#agentPreset;
|
|
if (agentPreset != null) payload.agentPreset = agentPreset;
|
|
const created = await this.rpc('session.create', payload, 30_000, rpcOptions);
|
|
return created.sessionId;
|
|
}
|
|
|
|
async executeCommand(sessionId, line, options = {}) {
|
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
if (typeof line !== 'string' || !line) throw new TypeError('command line is required');
|
|
if (!this.#commandExecutor) {
|
|
const error = new Error('Harness command execution is unavailable');
|
|
error.code = 'commands-unavailable';
|
|
throw error;
|
|
}
|
|
try {
|
|
return await this.#commandExecutor(sessionId, line, options);
|
|
} catch (error) {
|
|
if (error?.failure && typeof error.failure === 'object') {
|
|
throw new HarnessRpcError('commands.execute', error.failure);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async sessionExists(sessionId, options = {}) {
|
|
try {
|
|
await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async respondInteraction(rpcId, result, options = {}) {
|
|
if (typeof rpcId !== 'string' || !rpcId) throw new TypeError('rpcId is required');
|
|
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
|
|
throw new TypeError('A Harness RPC result is required');
|
|
}
|
|
const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? 30_000);
|
|
const signal = options.signal
|
|
? AbortSignal.any([options.signal, timeoutSignal])
|
|
: timeoutSignal;
|
|
const response = await this.#fetch(new URL('/api/respond', this.#baseUrl), {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ type: 'client-response', rpcId, result }),
|
|
signal,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Harness transport respond failed: HTTP ${response.status}`);
|
|
}
|
|
const receipt = await response.json();
|
|
if (receipt?.accepted === true) return receipt;
|
|
if (receipt?.accepted !== false
|
|
|| (receipt.reason !== 'bad-response' && receipt.reason !== 'not-pending')) {
|
|
throw new Error('Harness returned an invalid interaction response receipt');
|
|
}
|
|
const reason = receipt.reason;
|
|
throw new HarnessInteractionError(
|
|
`interaction-${reason}`,
|
|
`Harness interaction response was rejected (${reason})`,
|
|
);
|
|
}
|
|
|
|
async watchInteractions(sessionId, {
|
|
signal,
|
|
onInteraction,
|
|
onResolved,
|
|
onOpen,
|
|
ownership,
|
|
} = {}) {
|
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
if (!signal || typeof signal.addEventListener !== 'function') {
|
|
throw new TypeError('watchInteractions requires an AbortSignal');
|
|
}
|
|
if (onInteraction !== undefined && typeof onInteraction !== 'function') {
|
|
throw new TypeError('onInteraction must be a function');
|
|
}
|
|
if (onResolved !== undefined && typeof onResolved !== 'function') {
|
|
throw new TypeError('onResolved must be a function');
|
|
}
|
|
if (onOpen !== undefined && typeof onOpen !== 'function') {
|
|
throw new TypeError('onOpen must be a function');
|
|
}
|
|
|
|
while (!signal.aborted) {
|
|
try {
|
|
await this.#watchInteractionSocket(sessionId, {
|
|
signal,
|
|
onInteraction,
|
|
onResolved,
|
|
onOpen,
|
|
ownership,
|
|
});
|
|
} catch (error) {
|
|
if (signal.aborted) return;
|
|
console.warn(`[${this.#logPrefix}] Harness interaction stream disconnected:`, error.message);
|
|
}
|
|
if (signal.aborted) return;
|
|
try {
|
|
await sleep(this.#interactionReconnectDelayMs, signal);
|
|
} catch {
|
|
if (signal.aborted) return;
|
|
throw new Error('Harness interaction reconnect wait failed');
|
|
}
|
|
}
|
|
}
|
|
|
|
#registerInteractionOwnership(sessionId, ownership) {
|
|
const owners = this.#interactionOwnerships.get(sessionId) ?? new Set();
|
|
ownership.order = this.#interactionRegistry.nextOrder;
|
|
this.#interactionRegistry.nextOrder += 1;
|
|
owners.add(ownership);
|
|
this.#interactionOwnerships.set(sessionId, owners);
|
|
}
|
|
|
|
#unregisterInteractionOwnership(sessionId, ownership) {
|
|
const owners = this.#interactionOwnerships.get(sessionId);
|
|
owners?.delete(ownership);
|
|
if (owners?.size === 0) this.#interactionOwnerships.delete(sessionId);
|
|
for (const [key, claim] of this.#interactionClaims) {
|
|
if (claim.ownership === ownership) this.#interactionClaims.delete(key);
|
|
}
|
|
}
|
|
|
|
#registerControlOwnership(ownership) {
|
|
if (!ownership.control) return;
|
|
let routes = this.#controlOwnerships.get(ownership.control.owner);
|
|
if (!routes) {
|
|
routes = new Map();
|
|
this.#controlOwnerships.set(ownership.control.owner, routes);
|
|
}
|
|
const owners = routes.get(ownership.control.key) ?? new Set();
|
|
owners.add(ownership);
|
|
routes.set(ownership.control.key, owners);
|
|
}
|
|
|
|
#unregisterControlOwnership(ownership) {
|
|
if (!ownership.control) return;
|
|
const routes = this.#controlOwnerships.get(ownership.control.owner);
|
|
const owners = routes?.get(ownership.control.key);
|
|
owners?.delete(ownership);
|
|
if (owners?.size === 0) routes.delete(ownership.control.key);
|
|
if (routes?.size === 0) this.#controlOwnerships.delete(ownership.control.owner);
|
|
}
|
|
|
|
#controlCandidates(sessionId, control) {
|
|
const normalized = normalizeControl(control);
|
|
if (!normalized) return [];
|
|
const owners = this.#controlOwnerships.get(normalized.owner)?.get(normalized.key);
|
|
return [...(owners ?? [])]
|
|
.filter((ownership) => ownership.sessionId === sessionId)
|
|
.sort((left, right) => left.order - right.order);
|
|
}
|
|
|
|
#activeControlOwnership(sessionId, control) {
|
|
return this.#controlCandidates(sessionId, control).find((ownership) => (
|
|
ownership.started
|
|
&& ownership.active
|
|
&& !ownership.completed
|
|
&& ownership.turn !== null
|
|
)) ?? null;
|
|
}
|
|
|
|
async #refreshControlOwnership(sessionId, control, options) {
|
|
const candidates = this.#controlCandidates(sessionId, control);
|
|
// An exact local owner is mandatory before even observing the Session.
|
|
// This prevents an unrelated chat bound to the same Session from using
|
|
// run state as authority to cancel or steer somebody else's turn.
|
|
if (candidates.length === 0) return null;
|
|
try {
|
|
const history = await this.rpc(
|
|
'session.history',
|
|
{ sessionId, maxMessages: 50 },
|
|
30_000,
|
|
options,
|
|
);
|
|
this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
|
|
} catch (error) {
|
|
if (!(error instanceof HarnessRpcError) || error.code !== 'session-not-found') throw error;
|
|
for (const ownership of candidates) {
|
|
ownership.active = false;
|
|
ownership.completed = true;
|
|
}
|
|
return null;
|
|
}
|
|
return this.#activeControlOwnership(sessionId, control);
|
|
}
|
|
|
|
async hasActiveTurn(sessionId, control, options = {}) {
|
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
return Boolean(await this.#refreshControlOwnership(sessionId, control, options));
|
|
}
|
|
|
|
async stopActiveTurn(sessionId, control, options = {}) {
|
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
const ownership = await this.#refreshControlOwnership(sessionId, control, options);
|
|
if (!ownership) return false;
|
|
if (ownership.stopRequested) return true;
|
|
// Re-check after the refresh await. The owning ask may have completed and
|
|
// unregistered while session.history was in flight.
|
|
if (this.#activeControlOwnership(sessionId, control) !== ownership) return false;
|
|
ownership.stopRequested = true;
|
|
try {
|
|
if (this.#controlExecutor) {
|
|
const accepted = this.#controlExecutor({
|
|
sessionId,
|
|
expectedTurn: ownership.turn,
|
|
promptRpcId: ownership.promptRpcId,
|
|
action: 'stop',
|
|
});
|
|
if (accepted && typeof accepted.then === 'function') {
|
|
throw new TypeError('controlExecutor must return synchronously');
|
|
}
|
|
if (accepted !== undefined) {
|
|
if (typeof accepted !== 'boolean') {
|
|
throw new TypeError('controlExecutor must return a boolean or undefined');
|
|
}
|
|
if (!accepted) ownership.stopRequested = false;
|
|
return accepted;
|
|
}
|
|
}
|
|
await this.rpc(
|
|
'session.cancel',
|
|
{ sessionId, keepInbox: true },
|
|
30_000,
|
|
options,
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
if (this.#activeControlOwnership(sessionId, control) === ownership) {
|
|
ownership.stopRequested = false;
|
|
}
|
|
if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async steerActiveTurn(sessionId, text, control, options = {}) {
|
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
if (typeof text !== 'string' || !text.trim()) {
|
|
throw new TypeError('Steering text is required');
|
|
}
|
|
const ownership = await this.#refreshControlOwnership(sessionId, control, options);
|
|
if (!ownership || ownership.stopRequested) return false;
|
|
if (this.#activeControlOwnership(sessionId, control) !== ownership) return false;
|
|
if (this.#controlExecutor) {
|
|
const accepted = this.#controlExecutor({
|
|
sessionId,
|
|
expectedTurn: ownership.turn,
|
|
promptRpcId: ownership.promptRpcId,
|
|
action: 'steer',
|
|
text,
|
|
});
|
|
if (accepted && typeof accepted.then === 'function') {
|
|
throw new TypeError('controlExecutor must return synchronously');
|
|
}
|
|
if (accepted !== undefined) {
|
|
if (typeof accepted !== 'boolean') {
|
|
throw new TypeError('controlExecutor must return a boolean or undefined');
|
|
}
|
|
return accepted;
|
|
}
|
|
}
|
|
await this.rpc('session.prompt', {
|
|
sessionId,
|
|
mode: 'steer',
|
|
content: [{ type: 'text', text }],
|
|
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
}, 30_000, options);
|
|
return true;
|
|
}
|
|
|
|
#consumeInteractionOwnerships(sessionId, entries) {
|
|
for (const ownership of this.#interactionOwnerships.get(sessionId) ?? []) {
|
|
consumeInteractionOwnership(ownership, entries);
|
|
}
|
|
}
|
|
|
|
async #refreshInteractionOwnerships(sessionId, signal) {
|
|
const history = await this.rpc(
|
|
'session.history',
|
|
{ sessionId, maxMessages: 50 },
|
|
30_000,
|
|
{ signal },
|
|
);
|
|
this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
|
|
}
|
|
|
|
#interactionOwner(sessionId, claimKey, kind) {
|
|
const claim = this.#interactionClaims.get(claimKey);
|
|
if (claim && this.#interactionOwnerships.get(sessionId)?.has(claim.ownership)) return claim;
|
|
|
|
const owners = [...(this.#interactionOwnerships.get(sessionId) ?? [])];
|
|
const active = owners
|
|
.filter((ownership) => ownership.active)
|
|
.sort((left, right) => left.order - right.order);
|
|
if (active.length > 0) return { ownership: active[0], recovered: false };
|
|
|
|
// A newly attached IM conversation may encounter a question left by
|
|
// an earlier runtime before its queued prompt starts. Let the oldest such
|
|
// ask adopt that replay so the Session can recover instead of deadlocking.
|
|
// Approval adopters receive recovered=true and must reject it without ever
|
|
// presenting it as approvable; the original actor/route cannot be proven
|
|
// after a runtime restart.
|
|
const ownership = owners
|
|
.filter((ownership) => !ownership.started && !ownership.completed)
|
|
.sort((left, right) => left.order - right.order)[0] ?? null;
|
|
return ownership ? { ownership, recovered: true } : null;
|
|
}
|
|
|
|
async ask(sessionId, prompt, options = {}) {
|
|
if (typeof options === 'number') options = { timeoutMs: options };
|
|
const timeoutMs = options.timeoutMs ?? 600_000;
|
|
const signal = options.signal;
|
|
const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
|
|
const onArtifact = typeof options.onArtifact === 'function' ? options.onArtifact : null;
|
|
const onInteraction = typeof options.onInteraction === 'function'
|
|
? options.onInteraction
|
|
: undefined;
|
|
const onInteractionResolved = typeof options.onInteractionResolved === 'function'
|
|
? options.onInteractionResolved
|
|
: undefined;
|
|
const control = normalizeControl(options.control);
|
|
const inboundFiles = Array.isArray(options.files) ? options.files.filter(Boolean) : [];
|
|
await this.ensureRunning({ signal });
|
|
const before = await this.rpc(
|
|
'session.history',
|
|
{ sessionId, maxMessages: 1 },
|
|
30_000,
|
|
{ signal },
|
|
);
|
|
const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1));
|
|
const promptRpcId = `${this.#rpcIdPrefix}-${randomUUID()}`;
|
|
const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq });
|
|
const interactionController = onInteraction || onInteractionResolved
|
|
? new AbortController()
|
|
: null;
|
|
const interactionSignal = interactionController
|
|
? (signal
|
|
? AbortSignal.any([signal, interactionController.signal])
|
|
: interactionController.signal)
|
|
: null;
|
|
// The mux is host-global. A prompt RPC becomes the owner only when its
|
|
// durable user/message starts a turn, so two chats bound to one Session
|
|
// cannot answer each other's questions or approvals.
|
|
const ownership = interactionController || control
|
|
? {
|
|
sessionId,
|
|
promptRpcId,
|
|
active: false,
|
|
started: false,
|
|
completed: false,
|
|
stopRequested: false,
|
|
turn: null,
|
|
openTurn: null,
|
|
lastSeq: baselineSeq,
|
|
reconnect: null,
|
|
order: -1,
|
|
toolCalls: new Map(),
|
|
control,
|
|
}
|
|
: null;
|
|
let interactionTask = null;
|
|
let artifactsDelivered = false;
|
|
let deliveredArtifactCount = 0;
|
|
let stagedInboundFiles = null;
|
|
let promptAccepted = false;
|
|
let turnFinished = false;
|
|
|
|
const deliverArtifacts = async () => {
|
|
if (!onArtifact || artifactsDelivered || tracker.turn === null) {
|
|
return deliveredArtifactCount;
|
|
}
|
|
artifactsDelivered = true;
|
|
const artifacts = outboundArtifactRegistry.take(sessionId, tracker.turn, { signal });
|
|
for (const artifact of artifacts) {
|
|
try {
|
|
await onArtifact(artifact);
|
|
deliveredArtifactCount += 1;
|
|
} catch (error) {
|
|
outboundArtifactRegistry.release(artifact);
|
|
console.warn(`[${this.#logPrefix}] ignored an artifact handoff failure:`, error.message);
|
|
}
|
|
}
|
|
return deliveredArtifactCount;
|
|
};
|
|
|
|
if (ownership) {
|
|
this.#registerInteractionOwnership(sessionId, ownership);
|
|
this.#registerControlOwnership(ownership);
|
|
}
|
|
// This is resource ownership, not a feature Gate: it lets the Host retain
|
|
// this Turn's snapshots until the channel has polled and claimed them.
|
|
const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
|
|
|
|
try {
|
|
if (inboundFiles.length > 0) {
|
|
if (!this.#fileIngressExecutor) {
|
|
throw new InboundFileError(
|
|
'inbound-file-ingress-unavailable',
|
|
'Harness file ingress is unavailable in this Host process.',
|
|
);
|
|
}
|
|
const sessionList = await this.rpc(
|
|
'session.list',
|
|
{},
|
|
30_000,
|
|
{ signal },
|
|
);
|
|
const sessionWorkspace = sessionList?.items?.find(
|
|
(item) => item?.sessionId === sessionId,
|
|
)?.cwd;
|
|
stagedInboundFiles = await this.#fileIngressExecutor({
|
|
sessionId,
|
|
workspace: sessionWorkspace,
|
|
files: inboundFiles,
|
|
signal,
|
|
});
|
|
prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
|
|
}
|
|
if (interactionSignal) {
|
|
let markOpen;
|
|
const opened = new Promise((resolve) => { markOpen = resolve; });
|
|
interactionTask = this.watchInteractions(sessionId, {
|
|
signal: interactionSignal,
|
|
onInteraction,
|
|
onResolved: onInteractionResolved,
|
|
onOpen: markOpen,
|
|
ownership,
|
|
});
|
|
void interactionTask.catch(() => undefined);
|
|
await Promise.race([
|
|
opened,
|
|
sleep(30_000, interactionSignal).then(() => {
|
|
throw new Error('Harness interaction stream did not open within 30 seconds');
|
|
}),
|
|
]);
|
|
}
|
|
|
|
const content = typeof prompt === 'string'
|
|
? [{ type: 'text', text: prompt }]
|
|
: prompt;
|
|
if (!Array.isArray(content) || content.length === 0) {
|
|
throw new TypeError('Harness prompt content is required');
|
|
}
|
|
await this.rpc('session.prompt', {
|
|
sessionId,
|
|
mode: 'queue',
|
|
content,
|
|
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
}, 30_000, { rpcId: promptRpcId, signal });
|
|
promptAccepted = true;
|
|
|
|
try {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
await sleep(300, signal);
|
|
const history = await this.rpc(
|
|
'session.history',
|
|
{ sessionId, maxMessages: 50 },
|
|
30_000,
|
|
{ signal },
|
|
);
|
|
const wasActive = ownership?.active === true;
|
|
if (ownership) {
|
|
this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
|
|
if (!wasActive && ownership.active) ownership.reconnect?.();
|
|
}
|
|
const update = tracker.consume(history.events ?? []);
|
|
if (update && onUpdate) {
|
|
try {
|
|
await onUpdate(update);
|
|
} catch (error) {
|
|
console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
|
|
}
|
|
}
|
|
if (!tracker.finished) continue;
|
|
turnFinished = true;
|
|
// An accepted /stop revokes attachment delivery even when Harness
|
|
// preserved a useful partial text answer for the existing UX.
|
|
const artifactCount = ownership?.stopRequested
|
|
? 0
|
|
: await deliverArtifacts();
|
|
if (tracker.answer) {
|
|
return tracker.answer;
|
|
}
|
|
if (artifactCount > 0) return '';
|
|
if (ownership?.stopRequested) throw turnStoppedError();
|
|
throw new Error(
|
|
`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
|
|
);
|
|
}
|
|
throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`);
|
|
} catch (error) {
|
|
// Once cancellation was accepted, transport/poll failures and timeouts
|
|
// describe the convergence of that stop, not an unrelated ask failure.
|
|
if (!ownership?.stopRequested) throw error;
|
|
if (tracker.answer) {
|
|
return tracker.answer;
|
|
}
|
|
if (error?.code === 'turn-stopped') throw error;
|
|
throw turnStoppedError();
|
|
}
|
|
} finally {
|
|
if (stagedInboundFiles && (!promptAccepted || turnFinished)) {
|
|
await stagedInboundFiles.cleanup().catch((error) => {
|
|
console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
|
|
});
|
|
}
|
|
closeArtifactConsumer();
|
|
if (ownership) {
|
|
this.#unregisterControlOwnership(ownership);
|
|
this.#unregisterInteractionOwnership(sessionId, ownership);
|
|
}
|
|
if (tracker.turn !== null) outboundArtifactRegistry.discard(sessionId, tracker.turn);
|
|
interactionController?.abort(new DOMException('Harness turn finished', 'AbortError'));
|
|
if (interactionTask) await interactionTask.catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
#watchInteractionSocket(sessionId, {
|
|
signal,
|
|
onInteraction,
|
|
onResolved,
|
|
onOpen,
|
|
ownership,
|
|
}) {
|
|
const url = new URL('/api/events.mux', this.#baseUrl);
|
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let socket;
|
|
try {
|
|
socket = this.#createWebSocket(url.toString());
|
|
} catch (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
let opened = false;
|
|
let settled = false;
|
|
let callbackFailure = null;
|
|
let callbackTail = Promise.resolve();
|
|
let ownershipReady = ownership === undefined || ownership === null;
|
|
const bufferedEnvelopes = [];
|
|
const finish = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
socket.removeEventListener('open', handleOpen);
|
|
socket.removeEventListener('message', handleMessage);
|
|
socket.removeEventListener('close', handleClose);
|
|
socket.removeEventListener('error', handleError);
|
|
signal.removeEventListener('abort', handleAbort);
|
|
if (ownership?.reconnect === close) ownership.reconnect = null;
|
|
if (signal.aborted) {
|
|
resolve();
|
|
return;
|
|
}
|
|
void callbackTail.then(() => {
|
|
const failure = error ?? callbackFailure;
|
|
if (failure) reject(failure);
|
|
else resolve();
|
|
}, reject);
|
|
};
|
|
const close = () => {
|
|
try {
|
|
if (socket.readyState === 0 || socket.readyState === 1) socket.close();
|
|
} catch {
|
|
// Cleanup must still settle the watcher if a WebSocket rejects close while connecting.
|
|
}
|
|
};
|
|
const handleOpen = () => {
|
|
opened = true;
|
|
if (ownership) ownership.reconnect = close;
|
|
try {
|
|
onOpen?.();
|
|
} catch (error) {
|
|
console.warn(`[${this.#logPrefix}] ignored an interaction open callback failure:`, error.message);
|
|
}
|
|
if (ownership) {
|
|
void this.#refreshInteractionOwnerships(sessionId, signal).then(() => {
|
|
if (settled) return;
|
|
ownershipReady = true;
|
|
for (const envelope of bufferedEnvelopes.splice(0)) processEnvelope(envelope);
|
|
}).catch((error) => {
|
|
callbackFailure ??= error;
|
|
close();
|
|
finish(error);
|
|
});
|
|
}
|
|
};
|
|
const dispatch = (callback, value) => {
|
|
if (!callback) return;
|
|
callbackTail = callbackTail
|
|
.then(() => callback(value))
|
|
.catch((error) => {
|
|
callbackFailure ??= error;
|
|
close();
|
|
finish(callbackFailure);
|
|
});
|
|
};
|
|
const processEnvelope = (envelope) => {
|
|
const payload = envelope.payload;
|
|
if (ownership && payload.type === 'session/event') {
|
|
this.#consumeInteractionOwnerships(sessionId, [payload.event]);
|
|
return;
|
|
}
|
|
if (payload.type === 'question/requested' || payload.type === 'approval/requested') {
|
|
const kind = payload.type === 'question/requested' ? 'question' : 'approval';
|
|
const interactionId = kind === 'question' ? envelope.rpcId : payload.approvalId;
|
|
const claimKey = `${kind}:${interactionId}`;
|
|
if (ownership) {
|
|
const claim = this.#interactionOwner(sessionId, claimKey, kind);
|
|
if (claim?.ownership !== ownership) return;
|
|
this.#interactionClaims.set(claimKey, claim);
|
|
}
|
|
const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
|
|
? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
|
|
: undefined;
|
|
dispatch(onInteraction, Object.freeze({
|
|
kind,
|
|
interactionId,
|
|
rpcId: envelope.rpcId,
|
|
sessionId,
|
|
payload,
|
|
recovered: ownership
|
|
? this.#interactionClaims.get(claimKey)?.recovered === true
|
|
: false,
|
|
...(toolCall ? { toolCall } : {}),
|
|
reconnect: close,
|
|
respond: (result, options = {}) => this.respondInteraction(
|
|
envelope.rpcId,
|
|
result,
|
|
{ ...options, signal: options.signal ?? signal },
|
|
),
|
|
}));
|
|
return;
|
|
}
|
|
if (payload.type === 'question/resolved' || payload.type === 'approval/resolved') {
|
|
const kind = payload.type === 'question/resolved' ? 'question' : 'approval';
|
|
const interactionId = kind === 'question'
|
|
? payload.questionRpcId
|
|
: payload.approvalId;
|
|
const claimKey = `${kind}:${interactionId}`;
|
|
if (ownership) {
|
|
const claim = this.#interactionClaims.get(claimKey);
|
|
if (claim?.ownership !== ownership) return;
|
|
this.#interactionClaims.delete(claimKey);
|
|
}
|
|
dispatch(onResolved, Object.freeze({
|
|
kind,
|
|
interactionId,
|
|
sessionId,
|
|
outcome: payload.outcome,
|
|
payload,
|
|
}));
|
|
}
|
|
};
|
|
const handleMessage = (event) => {
|
|
try {
|
|
if (typeof event.data !== 'string') throw new Error('binary WebSocket frame');
|
|
const envelope = JSON.parse(event.data);
|
|
const payload = envelope?.payload;
|
|
if (envelope?.type !== 'server-request'
|
|
|| typeof envelope.rpcId !== 'string'
|
|
|| !payload || typeof payload !== 'object'
|
|
|| envelope.method !== payload.type) {
|
|
throw new Error('invalid server-request envelope');
|
|
}
|
|
if (payload.sessionId !== sessionId) return;
|
|
if (!ownershipReady) bufferedEnvelopes.push(envelope);
|
|
else processEnvelope(envelope);
|
|
} catch (error) {
|
|
console.warn(`[${this.#logPrefix}] ignored a malformed Harness interaction frame:`, error.message);
|
|
}
|
|
};
|
|
const handleClose = () => finish(opened ? null : new Error(
|
|
'Harness interaction WebSocket closed before opening',
|
|
));
|
|
const handleError = () => {
|
|
finish(new Error(opened
|
|
? 'Harness interaction WebSocket failed'
|
|
: 'Harness interaction WebSocket failed before opening'));
|
|
close();
|
|
};
|
|
const handleAbort = () => {
|
|
close();
|
|
finish();
|
|
};
|
|
|
|
socket.addEventListener('open', handleOpen);
|
|
socket.addEventListener('message', handleMessage);
|
|
socket.addEventListener('close', handleClose, { once: true });
|
|
socket.addEventListener('error', handleError, { once: true });
|
|
signal.addEventListener('abort', handleAbort, { once: true });
|
|
if (signal.aborted) handleAbort();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Watch the global Harness event mux (all sessions) until `signal`
|
|
* aborts, reconnecting on drop. The Desktop host serves the mux as a
|
|
* WebSocket downlink; frames are `server-request` envelopes whose payload
|
|
* is a `session/event` — only those are forwarded. `onReconnect` (when
|
|
* provided) fires after every (re)connection so callers can compensate
|
|
* for events missed while offline.
|
|
*/
|
|
async watchHarnessEvents({ signal, onSessionEvent, onReconnect } = {}) {
|
|
if (typeof onSessionEvent !== 'function') {
|
|
throw new TypeError('watchHarnessEvents requires onSessionEvent');
|
|
}
|
|
if (!signal || typeof signal.addEventListener !== 'function') {
|
|
throw new TypeError('watchHarnessEvents requires an AbortSignal');
|
|
}
|
|
if (onReconnect !== undefined && typeof onReconnect !== 'function') {
|
|
throw new TypeError('onReconnect must be a function');
|
|
}
|
|
const url = new URL('/api/events.mux', this.#baseUrl);
|
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
while (!signal.aborted) {
|
|
try {
|
|
await this.#watchHarnessEventSocket(url.toString(), {
|
|
signal,
|
|
onSessionEvent,
|
|
onReconnect,
|
|
});
|
|
} catch (error) {
|
|
if (signal.aborted) return;
|
|
console.warn(`[${this.#logPrefix}] Harness event mux disconnected:`, error.message);
|
|
}
|
|
if (signal.aborted) return;
|
|
try {
|
|
await sleep(this.#interactionReconnectDelayMs, signal);
|
|
} catch {
|
|
if (signal.aborted) return;
|
|
throw new Error('Harness event mux reconnect wait failed');
|
|
}
|
|
}
|
|
}
|
|
|
|
#watchHarnessEventSocket(url, { signal, onSessionEvent, onReconnect }) {
|
|
return new Promise((resolve, reject) => {
|
|
let socket;
|
|
try {
|
|
socket = this.#createWebSocket(url);
|
|
} catch (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
let opened = false;
|
|
let finished = false;
|
|
const close = () => {
|
|
try {
|
|
socket.close();
|
|
} catch {
|
|
// Already closed.
|
|
}
|
|
};
|
|
const finish = (error) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
socket.removeEventListener('open', handleOpen);
|
|
socket.removeEventListener('message', handleMessage);
|
|
socket.removeEventListener('close', handleClose);
|
|
socket.removeEventListener('error', handleError);
|
|
signal.removeEventListener('abort', handleAbort);
|
|
if (error) reject(error);
|
|
else resolve();
|
|
};
|
|
const handleOpen = () => {
|
|
opened = true;
|
|
try {
|
|
onReconnect?.();
|
|
} catch (error) {
|
|
console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
|
|
}
|
|
};
|
|
const handleMessage = (event) => {
|
|
try {
|
|
if (typeof event.data !== 'string') return;
|
|
const envelope = JSON.parse(event.data);
|
|
const payload = envelope?.payload;
|
|
if (envelope?.type !== 'server-request'
|
|
|| !payload
|
|
|| typeof payload !== 'object'
|
|
|| envelope.method !== payload.type
|
|
|| payload.type !== 'session/event'
|
|
|| typeof payload.sessionId !== 'string'
|
|
|| !payload.event
|
|
|| typeof payload.event !== 'object') return;
|
|
onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
|
|
} catch (error) {
|
|
console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
|
|
}
|
|
};
|
|
const handleClose = () => finish(opened ? null : new Error(
|
|
'Harness event mux WebSocket closed before opening',
|
|
));
|
|
const handleError = () => {
|
|
finish(new Error(opened
|
|
? 'Harness event mux WebSocket failed'
|
|
: 'Harness event mux WebSocket failed before opening'));
|
|
close();
|
|
};
|
|
const handleAbort = () => {
|
|
close();
|
|
finish();
|
|
};
|
|
|
|
socket.addEventListener('open', handleOpen);
|
|
socket.addEventListener('message', handleMessage);
|
|
socket.addEventListener('close', handleClose, { once: true });
|
|
socket.addEventListener('error', handleError, { once: true });
|
|
signal.addEventListener('abort', handleAbort, { once: true });
|
|
if (signal.aborted) handleAbort();
|
|
});
|
|
}
|
|
|
|
stopManagedProcess() {
|
|
if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM');
|
|
}
|
|
}
|