diff --git a/docs/execution-boundaries.md b/docs/execution-boundaries.md new file mode 100644 index 0000000..2a72d1e --- /dev/null +++ b/docs/execution-boundaries.md @@ -0,0 +1,26 @@ +# Execution boundaries + +Factorize separates an incoming event from the system that executes its task: + +```text +provider webhook -> SourceAdapter -> WorkItem -> prompt renderer + | + v +Durable Object orchestrator -> ExecutionBackend -> external execution system +``` + +`SourceAdapter` owns provider payload normalization. It must not know about VMs, +Herdr, or a coding harness. `ExecutionBackend` owns launch, prompt delivery, +inspection, output, and stopping. The orchestrator owns durable queue, claim, +concurrency, and delivery state, but does not construct backend commands. + +The current `ExeHerdrBackend` is one implementation. exe.dev supplies command +transport and VM wake-up, Herdr supplies workspace and agent supervision, and +the configured harness supplies Codex, Claude, Pi, or another Herdr-supported +agent. A future Amp implementation should implement `ExecutionBackend` without +adding Amp-specific state or commands to the orchestrator. + +Launch and prompt delivery are deliberately separate operations. An existing +harness proves only that launch reconciliation succeeded. It never proves that +the run's prompt was accepted. Prompt delivery therefore has its own persisted +state and request/response receipt. diff --git a/src/exe-herdr-backend.ts b/src/exe-herdr-backend.ts new file mode 100644 index 0000000..de8a01c --- /dev/null +++ b/src/exe-herdr-backend.ts @@ -0,0 +1,26 @@ +import type { ExecutionBackend, LaunchRequest, PromptDeliveryReceipt, RunHandle } from "./execution"; +import { agentOutputCommand, agentStatusCommand, exec, launchAgentCommand, promptAgentCommand, stopAgentCommand, type ExeConnection } from "./exe"; + +/** Execution adapter for the current exe.dev transport, Herdr supervisor, and configured harness. */ +export class ExeHerdrBackend implements ExecutionBackend { + readonly kind = "exe-herdr"; + + constructor(private readonly connection: ExeConnection) {} + + async launch(request: LaunchRequest) { + const command = await exec(this.connection, launchAgentCommand(request.agentName, this.connection, request.workspaceName, request.runPath, request.lease)); + return { handle: { backend: this.kind, agentName: request.agentName }, command }; + } + + async deliverPrompt(handle: RunHandle, prompt: string): Promise { + const command = await exec(this.connection, promptAgentCommand(this.connection, handle.agentName, prompt)); + const state = command.ok && (command.exitCode === null || command.exitCode === 0) + ? "accepted" + : command.status >= 500 || command.exitCode === null ? "ambiguous" : "failed"; + return { state, command }; + } + + inspect(handle: RunHandle) { return exec(this.connection, agentStatusCommand(this.connection, handle.agentName)); } + readOutput(handle: RunHandle) { return exec(this.connection, agentOutputCommand(this.connection, handle.agentName)); } + stop(handle: RunHandle) { return exec(this.connection, stopAgentCommand(this.connection, handle.agentName)); } +} diff --git a/src/exe.ts b/src/exe.ts index 0d40b54..ca44986 100644 --- a/src/exe.ts +++ b/src/exe.ts @@ -41,8 +41,7 @@ export function shellAtom(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } -export function startAgentCommand(agentName: string, connection: ExeConnection, prompt: string, workspaceName: string, runPath: string, lease: string): string { - const encodedPrompt = base64(prompt); +export function launchAgentCommand(agentName: string, connection: ExeConnection, workspaceName: string, runPath: string, lease: string): string { const name = agentName; const herdr = herdrBinary(connection); return [ @@ -56,10 +55,20 @@ export function startAgentCommand(agentName: string, connection: ExeConnection, `workspace_id=$(printf '%s' "$workspaces" | jq -r --arg label ${shellAtom(workspaceName)} '.result.workspaces[]? | select(.label == $label) | .workspace_id' | head -n1)`, `if [ -z "$workspace_id" ]; then created=$(${herdr} workspace create --cwd ${shellAtom(runPath)} --label ${shellAtom(workspaceName)} --no-focus) && workspace_id=$(printf '%s' "$created" | jq -er '.result.workspace.workspace_id') && tab_id=$(printf '%s' "$created" | jq -er '.result.tab.tab_id') && pane=$(printf '%s' "$created" | jq -er '.result.root_pane.pane_id') && ${herdr} tab rename "$tab_id" ${shellAtom(name)} >/dev/null; else tabs=$(${herdr} tab list --workspace "$workspace_id") && tab_id=$(printf '%s' "$tabs" | jq -r --arg label ${shellAtom(name)} '.result.tabs[]? | select(.label == $label) | .tab_id' | head -n1); if [ -z "$tab_id" ]; then created=$(${herdr} tab create --workspace "$workspace_id" --cwd ${shellAtom(runPath)} --label ${shellAtom(name)} --no-focus) && tab_id=$(printf '%s' "$created" | jq -er '.result.tab.tab_id') && pane=$(printf '%s' "$created" | jq -er '.result.root_pane.pane_id'); else panes=$(${herdr} pane list --workspace "$workspace_id") && pane=$(printf '%s' "$panes" | jq -r --arg tab "$tab_id" '.result.panes[]? | select(.tab_id == $tab) | .pane_id' | head -n1); test -n "$pane"; extras=$(printf '%s' "$panes" | jq -r --arg tab "$tab_id" --arg keep "$pane" '.result.panes[]? | select(.tab_id == $tab and .pane_id != $keep) | .pane_id'); for extra in $extras; do ${herdr} pane close "$extra" >/dev/null; done; fi; fi`, `existing=$(${herdr} agent get ${shellAtom(name)} 2>/dev/null || true)`, - `if [ -n "$existing" ]; then printf '%s\\n' "$existing"; else ${herdr} agent start ${shellAtom(name)} --kind ${shellAtom(connection.agentKind)} --pane "$pane"${agentCommand(connection)} && prompt=$(printf '%s' ${shellAtom(encodedPrompt)} | base64 -d) && ${herdr} agent prompt ${shellAtom(name)} "$prompt" && ${herdr} agent get ${shellAtom(name)}; fi`, + `if [ -n "$existing" ]; then printf '%s\\n' "$existing"; else ${herdr} agent start ${shellAtom(name)} --kind ${shellAtom(connection.agentKind)} --pane "$pane"${agentCommand(connection)} && ${herdr} agent get ${shellAtom(name)}; fi`, ].join(" && "); } +export function promptAgentCommand(connection: ExeConnection, agentName: string, prompt: string): string { + const encodedPrompt = base64(prompt), herdr = herdrBinary(connection); + return `${herdrPrefix(connection)} && prompt=$(printf '%s' ${shellAtom(encodedPrompt)} | base64 -d) && ${herdr} agent prompt ${shellAtom(agentName)} "$prompt"`; +} + +/** Compatibility helper used by recovery paths; prompt delivery is never skipped. */ +export function startAgentCommand(agentName: string, connection: ExeConnection, prompt: string, workspaceName: string, runPath: string, lease: string): string { + return `${launchAgentCommand(agentName, connection, workspaceName, runPath, lease)} && ${promptAgentCommand(connection, agentName, prompt)}`; +} + export function agentListCommand(connection: ExeConnection): string { return `${herdrPrefix(connection)} && ${herdrBinary(connection)} agent list`; } export function paneGetCommand(connection: ExeConnection, paneId: string): string { return `${herdrPrefix(connection)} && ${herdrBinary(connection)} pane get ${shellAtom(paneId)}`; } export function paneProcessInfoCommand(connection: ExeConnection, paneId: string): string { return `${herdrPrefix(connection)} && ${herdrBinary(connection)} pane process-info --pane ${shellAtom(paneId)}`; } diff --git a/src/execution.ts b/src/execution.ts new file mode 100644 index 0000000..f1e9829 --- /dev/null +++ b/src/execution.ts @@ -0,0 +1,42 @@ +export type PromptDeliveryState = "pending" | "submitting" | "accepted" | "ambiguous" | "failed"; + +export interface BackendCommandResult { + ok: boolean; + status: number; + exitCode: number | null; + body: string; + requestBody: string; +} + +export interface LaunchRequest { + runId: string; + agentName: string; + workspaceName: string; + runPath: string; + lease: string; +} + +export interface RunHandle { + backend: string; + agentName: string; +} + +export interface LaunchReceipt { + handle: RunHandle; + command: BackendCommandResult; +} + +export interface PromptDeliveryReceipt { + state: Exclude; + command: BackendCommandResult; +} + +/** Boundary implemented by exe.dev + Herdr today and by alternative runners later. */ +export interface ExecutionBackend { + readonly kind: string; + launch(request: LaunchRequest): Promise; + deliverPrompt(handle: RunHandle, prompt: string): Promise; + inspect(handle: RunHandle): Promise; + readOutput(handle: RunHandle): Promise; + stop(handle: RunHandle): Promise; +} diff --git a/src/linear-source.ts b/src/linear-source.ts new file mode 100644 index 0000000..dbe0833 --- /dev/null +++ b/src/linear-source.ts @@ -0,0 +1,60 @@ +import Mustache from "mustache"; +import type { WorkItem } from "./types"; + +const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const text = (value: unknown): string => typeof value === "string" ? value : ""; + +export const DEFAULT_CONTEXT_TEMPLATE = `--- +pipe: "{{{flow.name}}}" +issue: "{{{ticket.identifier}}}" +url: "{{{ticket.url}}}" +{{#ticket.project.name}}project: "{{{ticket.project.name}}}" +{{/ticket.project.name}}{{#ticket.labels.length}}labels: [{{#ticket.labels}}"{{{name}}}"{{^last}}, {{/last}}{{/ticket.labels}}] +{{/ticket.labels.length}}{{#ticket.assignee.name}}owner: "{{{ticket.assignee.name}}}" +{{/ticket.assignee.name}}{{#ticket.state.name}}status: "{{{ticket.state.name}}}" +{{/ticket.state.name}}--- + +# {{{ticket.title}}} + +{{{ticket.description}}}`; + +export interface SourceAdapter { + toWorkItem(payload: TPayload, claimKey: string, event: Record): WorkItem; + renderPrompt(template: string, payload: TPayload, flowName: string): string; +} + +export class LinearSourceAdapter implements SourceAdapter> { + toWorkItem(payload: Record, claimKey: string, event: Record): WorkItem { + return { + provider: "linear", claimKey, identifier: claimKey, + title: String(payload.title ?? payload.issue?.title ?? ""), + description: String(payload.description ?? payload.issue?.description ?? ""), + url: linearIssueUrl(payload, claimKey), event, + }; + } + + renderPrompt(template: string, payload: Record, flowName: string): string { + return renderContextTemplate(template, payload, flowName); + } +} + +export function linearIssueUrl(data: Record, issueId: string): string { + const candidate = typeof data.url === "string" ? data.url : typeof data.issue?.url === "string" ? data.issue.url : ""; + return candidate.startsWith("https://linear.app/") ? candidate : `https://linear.app/issue/${encodeURIComponent(issueId)}`; +} + +export function renderContextTemplate(template: string, payload: Record, flowName: string): string { + const issue = object(payload.issue); + const ticket = text(issue.title) || text(issue.description) ? issue : payload; + const project = object(ticket.project), assignee = object(ticket.assignee), state = object(ticket.state), labelsValue = object(ticket.labels); + const rawLabels = Array.isArray(ticket.labels) ? ticket.labels : Array.isArray(labelsValue.nodes) ? labelsValue.nodes : []; + const labels = rawLabels.map((label) => text(object(label).name)).filter(Boolean); + const normalizedTicket = { + ...ticket, id: text(ticket.id), identifier: text(ticket.identifier) || text(ticket.id), url: text(ticket.url), + title: text(ticket.title) || "Untitled Linear issue", description: text(ticket.description).trim() || "No description provided.", + project, assignee, state, labels: labels.map((name, index) => ({ name, last: index === labels.length - 1 })), + }; + return Mustache.render(template || DEFAULT_CONTEXT_TEMPLATE, { ...payload, ticket: normalizedTicket, flow: { name: flowName } }); +} + +export const linearTicketPrompt = (payload: Record, flowName: string) => renderContextTemplate(DEFAULT_CONTEXT_TEMPLATE, payload, flowName); diff --git a/src/tenant.ts b/src/tenant.ts index 5e457bc..5128136 100644 --- a/src/tenant.ts +++ b/src/tenant.ts @@ -11,6 +11,10 @@ import { githubClaimKey, githubHeaders, installationToken, normalizeRepository, import type { FlowSource, WorkItem } from "./types"; import { invokeCustomHandler, validateCustomHandler } from "./custom-handler"; import { sanitizeTailEvent, suppressTailEvent, tailFingerprint, verifyTailDelivery } from "./cloudflare-tail"; +import { ExeHerdrBackend } from "./exe-herdr-backend"; +import { DEFAULT_CONTEXT_TEMPLATE, LinearSourceAdapter, linearTicketPrompt, renderContextTemplate } from "./linear-source"; + +export { DEFAULT_CONTEXT_TEMPLATE, linearTicketPrompt, renderContextTemplate } from "./linear-source"; type Row = Record; const json = (value: unknown) => Response.json(value); @@ -20,57 +24,7 @@ const HERDR_FLOW_NAME = /^[a-z][a-z0-9_-]{0,29}$/; const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; const text = (value: unknown): string => typeof value === "string" ? value : ""; -/** The initial per-flow template. It intentionally mirrors Factorize's former prompt. */ -export const DEFAULT_CONTEXT_TEMPLATE = `--- -pipe: "{{{flow.name}}}" -issue: "{{{ticket.identifier}}}" -url: "{{{ticket.url}}}" -{{#ticket.project.name}}project: "{{{ticket.project.name}}}" -{{/ticket.project.name}}{{#ticket.labels.length}}labels: [{{#ticket.labels}}"{{{name}}}"{{^last}}, {{/last}}{{/ticket.labels}}] -{{/ticket.labels.length}}{{#ticket.assignee.name}}owner: "{{{ticket.assignee.name}}}" -{{/ticket.assignee.name}}{{#ticket.state.name}}status: "{{{ticket.state.name}}}" -{{/ticket.state.name}}--- - -# {{{ticket.title}}} - -{{{ticket.description}}}`; - -/** - * Render an agent prompt from the complete Linear webhook payload. `ticket` is - * a normalized convenience alias; the original payload keys remain available. - */ -export function renderContextTemplate(template: string, payload: Record, pipeName: string): string { - const issue = object(payload.issue); - // Issue webhooks put fields directly on data; IssueLabel events use the fetched - // linked issue under `issue`. - const ticket = text(issue.title) || text(issue.description) ? issue : payload; - const project = object(ticket.project); - const assignee = object(ticket.assignee); - const state = object(ticket.state); - const labelsValue = object(ticket.labels); - const rawLabels = Array.isArray(ticket.labels) ? ticket.labels : Array.isArray(labelsValue.nodes) ? labelsValue.nodes : []; - const labels = rawLabels.map((label) => text(object(label).name)).filter(Boolean); - const normalizedTicket = { - ...ticket, - id: text(ticket.id), - identifier: text(ticket.identifier) || text(ticket.id), - url: text(ticket.url), - title: text(ticket.title) || "Untitled Linear issue", - description: text(ticket.description).trim() || "No description provided.", - project, - assignee, - state, - labels: labels.map((name, index) => ({ name, last: index === labels.length - 1 })), - }; - return Mustache.render(template || DEFAULT_CONTEXT_TEMPLATE, { - ...payload, - ticket: normalizedTicket, - flow: { name: pipeName }, - }); -} - -/** @deprecated Use renderContextTemplate with a flow's saved template. */ -export const linearTicketPrompt = (payload: Record, pipeName: string) => renderContextTemplate(DEFAULT_CONTEXT_TEMPLATE, payload, pipeName); +const linearSource = new LinearSourceAdapter(); export class Tenant extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { @@ -109,6 +63,11 @@ export class Tenant extends DurableObject { this.ensureColumn("runs", "exec_response", "TEXT"); this.ensureColumn("runs", "exec_status", "INTEGER"); this.ensureColumn("runs", "exec_exit_code", "INTEGER"); + this.ensureColumn("runs", "prompt_delivery_state", "TEXT NOT NULL DEFAULT 'legacy'"); + this.ensureColumn("runs", "prompt_delivery_request", "TEXT"); + this.ensureColumn("runs", "prompt_delivery_response", "TEXT"); + this.ensureColumn("runs", "prompt_delivery_status", "INTEGER"); + this.ensureColumn("runs", "prompt_delivery_exit_code", "INTEGER"); this.ensureColumn("pipes", "source_kind", "TEXT NOT NULL DEFAULT 'linear'"); this.ensureColumn("pipes", "source_config", "TEXT NOT NULL DEFAULT '{}'"); this.ensureColumn("pipes", "trigger_kind", "TEXT NOT NULL DEFAULT 'linear_match'"); @@ -295,7 +254,7 @@ export class Tenant extends DurableObject { const page = Math.max(1, Math.min(10000, Number.parseInt(url.searchParams.get("page") ?? "1", 10) || 1)); const offset = (page - 1) * 10; const events = view === "events" ? this.rows("SELECT id, delivery_id, issue_id, issue_url, event_type, event_action, outcome, detail, provider, received_at FROM flow_events WHERE flow_id = ? ORDER BY received_at DESC, id DESC LIMIT 11 OFFSET ?", flowId, offset) : []; - const runs = view === "runs" ? this.rows("SELECT id, issue_id, issue_title, issue_url, claim_key, agent_name, workspace_name, agent_kind, state, provider, prompt, result, created_at, updated_at, exec_request, exec_response, exec_status, exec_exit_code, recovery_reason, recovery_attempt, recovery_last_action, recovery_started_at FROM runs WHERE pipe_id = ? AND state != 'ignored' ORDER BY created_at DESC, id DESC LIMIT 11 OFFSET ?", flowId, offset) : []; + const runs = view === "runs" ? this.rows("SELECT id, issue_id, issue_title, issue_url, claim_key, agent_name, workspace_name, agent_kind, state, provider, prompt, prompt_delivery_state, prompt_delivery_request, prompt_delivery_response, prompt_delivery_status, prompt_delivery_exit_code, result, created_at, updated_at, exec_request, exec_response, exec_status, exec_exit_code, recovery_reason, recovery_attempt, recovery_last_action, recovery_started_at FROM runs WHERE pipe_id = ? AND state != 'ignored' ORDER BY created_at DESC, id DESC LIMIT 11 OFFSET ?", flowId, offset) : []; await this.backfillRunIssueDetails(runs); const hasNext = (view === "events" ? events : runs).length > 10; if (events.length > 10) events.pop(); @@ -304,6 +263,8 @@ export class Tenant extends DurableObject { if (run.state !== "ignored" && typeof run.prompt === "string" && run.prompt) run.prompt = await decrypt(run.prompt, this.env.CREDENTIAL_ENCRYPTION_KEY); if (typeof run.exec_request === "string" && run.exec_request) run.exec_request = await decrypt(run.exec_request, this.env.CREDENTIAL_ENCRYPTION_KEY); if (typeof run.exec_response === "string" && run.exec_response) run.exec_response = await decrypt(run.exec_response, this.env.CREDENTIAL_ENCRYPTION_KEY); + if (typeof run.prompt_delivery_request === "string" && run.prompt_delivery_request) run.prompt_delivery_request = await decrypt(run.prompt_delivery_request, this.env.CREDENTIAL_ENCRYPTION_KEY); + if (typeof run.prompt_delivery_response === "string" && run.prompt_delivery_response) run.prompt_delivery_response = await decrypt(run.prompt_delivery_response, this.env.CREDENTIAL_ENCRYPTION_KEY); // Duplicate-webhook records predate encrypted results and deliberately retain // a plain-text explanation. Only completed/failed agent output is encrypted. if (run.state !== "ignored" && typeof run.result === "string" && run.result) run.result = await decrypt(run.result, this.env.CREDENTIAL_ENCRYPTION_KEY); @@ -336,7 +297,7 @@ export class Tenant extends DurableObject { const run = this.one("SELECT runs.*, pipes.name AS flow_name FROM runs JOIN pipes ON pipes.id = runs.pipe_id WHERE runs.id = ?", runId); if (!run) return new Response("Not found", { status: 404 }); await this.backfillRunIssueDetails([run]); - for (const field of ["prompt", "exec_request", "exec_response", "result"] as const) { + for (const field of ["prompt", "exec_request", "exec_response", "prompt_delivery_request", "prompt_delivery_response", "result"] as const) { if (run.state !== "ignored" && typeof run[field] === "string" && run[field]) run[field] = await decrypt(String(run[field]), this.env.CREDENTIAL_ENCRYPTION_KEY); } return json(run); @@ -405,7 +366,8 @@ export class Tenant extends DurableObject { if (pipe) { const connection = await this.exeConnection(String(pipe.exe_connection_id || "default")); if (connection && run.agent_name) { - const stopped = await exec(connection, stopAgentCommand(connection, String(run.agent_name))); + const backend = new ExeHerdrBackend(connection); + const stopped = await backend.stop({ backend: backend.kind, agentName: String(run.agent_name) }); if (!stopped.ok) return Response.json({ error: "Could not stop the active agent" }, { status: 502 }); } } @@ -464,7 +426,8 @@ export class Tenant extends DurableObject { this.recordFlowEvent(pipe, deliveryId, null, null, matchingEvent, "ignored", "Webhook did not match this flow's trigger."); continue; } - await this.queueWorkItem(pipe, deliveryId, { provider: "linear", claimKey: matchingIssueId, identifier: matchingIssueId, title: String(data.title ?? data.issue?.title ?? ""), description: String(data.description ?? data.issue?.description ?? ""), url: this.issueUrl(data, matchingIssueId), event: matchingEvent }, renderContextTemplate(String(pipe.context_template || DEFAULT_CONTEXT_TEMPLATE), data, String(pipe.name))); + const workItem = linearSource.toWorkItem(data, matchingIssueId, matchingEvent); + await this.queueWorkItem(pipe, deliveryId, workItem, linearSource.renderPrompt(String(pipe.context_template || DEFAULT_CONTEXT_TEMPLATE), data, String(pipe.name))); } await Promise.all(custom.map((pipe) => this.evaluateCustom(pipe, "linear", deliveryId, event))); await this.ctx.storage.setAlarm(Date.now()); @@ -538,7 +501,7 @@ export class Tenant extends DurableObject { try { this.ctx.storage.sql.exec("INSERT INTO active_claims (pipe_id,issue_id,run_id) VALUES (?,?,?)", pipe.id, workItem.claimKey, runId); } catch { this.recordFlowEvent(pipe, deliveryId, workItem.identifier, workItem.url, workItem.event as any, "duplicate", "An active or queued run already owns this work item.", workItem.provider); return; } const prompt = await encrypt(plaintextPrompt, this.env.CREDENTIAL_ENCRYPTION_KEY); - this.ctx.storage.sql.exec("INSERT INTO runs (id,pipe_id,issue_id,issue_title,issue_url,claim_key,agent_name,workspace_name,agent_kind,state,prompt,provider,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", runId, pipe.id, workItem.identifier, workItem.title, workItem.url, workItem.claimKey, `factorize-${runId}`, String(pipe.workspace_name ?? ""), String(pipe.agent_kind ?? ""), "queued", prompt, workItem.provider, now(), now()); + this.ctx.storage.sql.exec("INSERT INTO runs (id,pipe_id,issue_id,issue_title,issue_url,claim_key,agent_name,workspace_name,agent_kind,state,prompt,prompt_delivery_state,provider,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", runId, pipe.id, workItem.identifier, workItem.title, workItem.url, workItem.claimKey, `factorize-${runId}`, String(pipe.workspace_name ?? ""), String(pipe.agent_kind ?? ""), "queued", prompt, "pending", workItem.provider, now(), now()); const active = this.one("SELECT count(*) AS count FROM runs WHERE pipe_id=? AND state IN ('starting','running','blocked','recovering')", pipe.id) as Row; this.recordFlowEvent(pipe, deliveryId, workItem.identifier, workItem.url, workItem.event as any, Number(active.count) < Number(pipe.max_concurrency) ? "accepted" : "queued_capacity", Number(active.count) < Number(pipe.max_concurrency) ? "Handler accepted delivery; agent queued to start." : "Handler accepted delivery; queued for capacity.", workItem.provider); } @@ -627,23 +590,31 @@ export class Tenant extends DurableObject { const worktreePath = `${connection.cwd.replace(/\/$/, "")}/.factorize-runs/${String(run.id)}`; this.ctx.storage.sql.exec("UPDATE runs SET herdr_server_namespace='default',worktree_path=?,ownership_lease=?,ownership_generation=ownership_generation+1,agent_session_generation=1,updated_at=? WHERE id=?", worktreePath, lease, now(), run.id); if (String(run.provider || "linear") === "linear") await this.safeLinearComment(String(run.issue_id), `Factorize started **${connection.agentKind}** on ${connection.vmName} in Herdr workspace \`${workspaceName}\` for this issue.`); - const prompt = await decrypt(String(run.prompt), this.env.CREDENTIAL_ENCRYPTION_KEY); - const result = await exec(connection, startAgentCommand(String(run.agent_name), connection, prompt, workspaceName, worktreePath, lease)); + const backend = new ExeHerdrBackend(connection); + const launched = await backend.launch({ runId: String(run.id), agentName: String(run.agent_name), workspaceName, runPath: worktreePath, lease }); + const result = launched.command; const execRequest = await encrypt(result.requestBody, this.env.CREDENTIAL_ENCRYPTION_KEY); const execResponse = await encrypt(result.body, this.env.CREDENTIAL_ENCRYPTION_KEY); this.ctx.storage.sql.exec("UPDATE runs SET exec_request = ?, exec_response = ?, exec_status = ?, exec_exit_code = ?, updated_at = ? WHERE id = ?", execRequest, execResponse, result.status, result.exitCode, now(), run.id); this.commandActivity(run.id, "initial agent start", result); if (!result.ok || (result.exitCode !== null && result.exitCode !== 0)) return this.finishRun(run, "failed", `Unable to start Herdr agent (exe.dev HTTP ${result.status}, VM exit ${result.exitCode ?? "not reported"}): ${result.body.slice(0, 800)}`); - this.ctx.storage.sql.exec("UPDATE runs SET prompt_accepted=1,updated_at=? WHERE id=?", now(), run.id); - const verification = await exec(connection, agentStatusCommand(connection, String(run.agent_name))); + const verification = await backend.inspect(launched.handle); this.commandActivity(run.id, "initial agent verification", verification); if (!verification.ok || (verification.exitCode !== null && verification.exitCode !== 0) || !herdrAgentStatus(verification.body)) { return this.beginRecovery(run, pipe, connection, `agent start succeeded but verification was ambiguous (exe.dev HTTP ${verification.status}, VM exit ${verification.exitCode ?? "not reported"})`); } const identity = parseAgent(verification.body); if (!identity) return this.beginRecovery(run, pipe, connection, "agent start succeeded but its structured identity was inconsistent"); + this.persistIdentity(run.id, identity, "starting"); + const prompt = await decrypt(String(run.prompt), this.env.CREDENTIAL_ENCRYPTION_KEY); + this.ctx.storage.sql.exec("UPDATE runs SET prompt_delivery_state='submitting',updated_at=? WHERE id=?", now(), run.id); + const delivery = await backend.deliverPrompt(launched.handle, prompt); + const deliveryRequest = await encrypt(delivery.command.requestBody, this.env.CREDENTIAL_ENCRYPTION_KEY); + const deliveryResponse = await encrypt(delivery.command.body, this.env.CREDENTIAL_ENCRYPTION_KEY); + this.ctx.storage.sql.exec("UPDATE runs SET prompt_delivery_state=?,prompt_delivery_request=?,prompt_delivery_response=?,prompt_delivery_status=?,prompt_delivery_exit_code=?,prompt_accepted=?,updated_at=? WHERE id=?", delivery.state, deliveryRequest, deliveryResponse, delivery.command.status, delivery.command.exitCode, delivery.state === "accepted" ? 1 : 0, now(), run.id); + this.commandActivity(run.id, "initial prompt delivery", delivery.command); + if (delivery.state !== "accepted") return this.finishRun(run, "failed", `Harness launched, but prompt delivery was ${delivery.state} (exe.dev HTTP ${delivery.command.status}, VM exit ${delivery.command.exitCode ?? "not reported"}).`); this.persistIdentity(run.id, identity, "running"); - this.ctx.storage.sql.exec("UPDATE runs SET prompt_accepted=1, updated_at=? WHERE id=?", now(), run.id); } private async pollRun(run: Row): Promise { @@ -652,7 +623,8 @@ export class Tenant extends DurableObject { const connection = await this.connectionForPipe(pipe); if (!connection) return this.finishRun(run, "failed", "exe.dev connection is unavailable."); if (String(run.state) === "recovering") return this.recoverRun(run, pipe, connection); - const status = await exec(connection, agentStatusCommand(connection, String(run.agent_name))); + const backend = new ExeHerdrBackend(connection), handle = { backend: "exe-herdr", agentName: String(run.agent_name) }; + const status = await backend.inspect(handle); if (!status.ok && (status.exitCode === null || status.exitCode === 0)) return; // transient exe.dev/API failure if (!status.ok || (status.exitCode !== null && status.exitCode !== 0)) return this.beginRecovery(run, pipe, connection, `expected Herdr agent is no longer resolvable (VM exit ${status.exitCode ?? "unknown"})`); const live = parseAgent(status.body); @@ -671,7 +643,7 @@ export class Tenant extends DurableObject { return; } if (agentStatus !== "done" && agentStatus !== "idle") return; - const output = await exec(connection, agentOutputCommand(connection, String(run.agent_name))); + const output = await backend.readOutput(handle); await this.finishRun(run, "done", output.ok ? output.body : "Agent completed; terminal output could not be read.", output.ok); } @@ -769,14 +741,14 @@ export class Tenant extends DurableObject { if (!runPath || !lease) return this.finishRecovery(run, "failed", "Recovery cannot prove the run directory lease."); const replacement = await exec(connection, startAgentCommand(String(run.agent_name), connection, recoveryPrompt, String(run.workspace_name), runPath, lease)); this.commandActivity(run.id, "replacement agent start", replacement); - if (replacement.ok && (replacement.exitCode === null || replacement.exitCode === 0)) this.ctx.storage.sql.exec("UPDATE runs SET prompt_accepted=1 WHERE id=?", run.id); + if (replacement.ok && (replacement.exitCode === null || replacement.exitCode === 0)) this.ctx.storage.sql.exec("UPDATE runs SET prompt_accepted=1,prompt_delivery_state='accepted' WHERE id=?", run.id); const live = replacement.ok ? parseAgent(replacement.body) : null; if (live) return this.finishRecovery(run, "running", "Recreated the deleted pane/workspace and started a replacement harness.", live); this.scheduleRecovery(run.id, attempt, "idempotent run-tab reconciliation was not yet verifiable"); } private scheduleRecovery(runId: unknown, attempt: number, action: string): void { const delay = Math.min(60_000, 2_000 * 2 ** Math.min(attempt - 1, 5)); this.ctx.storage.sql.exec("UPDATE runs SET recovery_next_at=?,recovery_last_action=?,updated_at=? WHERE id=?", Date.now() + delay, action, now(), runId); this.activity(runId, "recovery_retry", action); } - private async resumeRecoveredHarness(run: Row, connection: ExeConnection, live: HerdrIdentity, action: string): Promise { if (Number(run.recovery_prompt_attempted)) return this.finishRecovery(run, "running", `${action} Recovery prompt delivery was previously attempted and was not repeated.`, live); const prompt = await decrypt(String(run.prompt), this.env.CREDENTIAL_ENCRYPTION_KEY); const output = run.result ? await decrypt(String(run.result), this.env.CREDENTIAL_ENCRYPTION_KEY) : ""; const recoveryPrompt = `${prompt}\n\nRecovery context: inspect existing repository state and continue rather than repeating completed work. Last captured output/status:\n${output.slice(-4000)}\n${String(run.last_agent_status || "unknown")}`; this.ctx.storage.sql.exec("UPDATE runs SET recovery_prompt_attempted=1 WHERE id=?", run.id); const sent = await exec(connection, `${agentStatusCommand(connection, live.name)} && ${connection.herdrCommand?.trim() || "herdr"} agent prompt '${live.name.replaceAll("'", `'\"'\"'`)}' '${recoveryPrompt.replaceAll("'", `'\"'\"'`)}'`); if (!sent.ok || (sent.exitCode !== null && sent.exitCode !== 0)) return this.finishRecovery(run, "running", `${action} Recovery prompt acknowledgement was ambiguous, so it will not be submitted twice.`, live); this.ctx.storage.sql.exec("UPDATE runs SET recovery_prompt_accepted=1 WHERE id=?", run.id); await this.finishRecovery(run, "running", action, live); } + private async resumeRecoveredHarness(run: Row, connection: ExeConnection, live: HerdrIdentity, action: string): Promise { if (Number(run.recovery_prompt_attempted)) return this.finishRecovery(run, "running", `${action} Recovery prompt delivery was previously attempted and was not repeated.`, live); const prompt = await decrypt(String(run.prompt), this.env.CREDENTIAL_ENCRYPTION_KEY); const output = run.result ? await decrypt(String(run.result), this.env.CREDENTIAL_ENCRYPTION_KEY) : ""; const recoveryPrompt = `${prompt}\n\nRecovery context: inspect existing repository state and continue rather than repeating completed work. Last captured output/status:\n${output.slice(-4000)}\n${String(run.last_agent_status || "unknown")}`; this.ctx.storage.sql.exec("UPDATE runs SET recovery_prompt_attempted=1,prompt_delivery_state='submitting' WHERE id=?", run.id); const sent = await exec(connection, `${agentStatusCommand(connection, live.name)} && ${connection.herdrCommand?.trim() || "herdr"} agent prompt '${live.name.replaceAll("'", `'\"'\"'`)}' '${recoveryPrompt.replaceAll("'", `'\"'\"'`)}'`); if (!sent.ok || (sent.exitCode !== null && sent.exitCode !== 0)) { this.ctx.storage.sql.exec("UPDATE runs SET prompt_delivery_state='ambiguous' WHERE id=?", run.id); return this.finishRecovery(run, "running", `${action} Recovery prompt acknowledgement was ambiguous, so it will not be submitted twice.`, live); } this.ctx.storage.sql.exec("UPDATE runs SET recovery_prompt_accepted=1,prompt_delivery_state='accepted' WHERE id=?", run.id); await this.finishRecovery(run, "running", action, live); } private async finishRecovery(run: Row, state: "running" | "failed", action: string, live?: HerdrIdentity): Promise { this.activity(run.id, state === "running" ? "recovery_succeeded" : "recovery_failed", action); if (live) this.persistIdentity(run.id, live, state); this.ctx.storage.sql.exec("UPDATE runs SET state=?,recovery_attempt=0,recovery_reason=NULL,recovery_started_at=NULL,recovery_last_action=?,recovery_next_at=NULL,recovery_comment_finished=1,updated_at=? WHERE id=?", state, action, now(), run.id); if (!Number(run.recovery_comment_finished) && String(run.provider || "linear") === "linear") await this.safeLinearComment(String(run.issue_id), `Factorize automatic recovery ${state === "running" ? "succeeded" : "permanently failed"} for run \`${run.id}\`: ${action}`); if (state === "failed") { const encrypted = await encrypt(action, this.env.CREDENTIAL_ENCRYPTION_KEY); this.ctx.storage.sql.exec("UPDATE runs SET result=? WHERE id=?", encrypted, run.id); this.ctx.storage.sql.exec("DELETE FROM active_claims WHERE pipe_id=? AND issue_id=?", run.pipe_id, run.claim_key || run.issue_id); this.ctx.storage.sql.exec("UPDATE runs SET claim_released=1 WHERE id=?", run.id); } } private async finishRun(run: Row, state: Extract, result: string, outputCaptured = false): Promise { diff --git a/src/ui.ts b/src/ui.ts index 13575f0..498b080 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -142,10 +142,10 @@ const badge=(value,good)=>''+esc(id)+' ↗'}; async function load(){const response=await fetch('/api/pipes/'+encodeURIComponent(flowId));if(response.status===401)return location.href='/auth/linear';if(!response.ok){root.innerHTML='

Could not load this flow.

';return}const data=await response.json(),flow=data.flow,events=data.events||[],runs=data.runs||[];const eventRows=events.map(event=>''+time(event.received_at)+''+issueLink(event.issue_id,event.issue_url)+''+esc(event.event_type)+' · '+esc(event.event_action)+''+badge(event.outcome,event.outcome==='triggered')+''+esc(event.detail)+'').join('')||'No relevant webhooks yet. Update an issue in this flow’s Linear project to see it here.';const runRows=runs.map(run=>''+esc(run.issue_title||run.issue_id)+''+issueLink(run.issue_id,run.issue_url)+''+esc(run.agent_kind||flow.agent_kind||'Herdr agent')+''+esc(run.agent_name)+''+esc(run.workspace_name||flow.workspace_name||'—')+''+badge(run.state,['queued','starting','running','recovering','done'].includes(run.state))+''+time(run.updated_at)+'').join('')||'No agent runs yet.';root.innerHTML='

'+esc(flow.name)+'

'+esc(flow.filter_type)+' trigger · '+esc(flow.max_concurrency)+' concurrent agents · '+esc(flow.agent_kind||'Herdr agent')+' · workspace '+esc(flow.workspace_name||'—')+'

Edit Flow

Webhook activity

Relevant Linear webhooks and whether they queued an agent.

'+eventRows+'
ReceivedIssueEventResultDetails

Agent runs

'+runRows+'
IssueHerdr agentWorkspaceStateUpdated
';root.querySelectorAll('[data-run-href]').forEach(row=>{row.onclick=e=>{if(!e.target.closest('a'))location.href=row.dataset.runHref};row.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();location.href=row.dataset.runHref}}})} const renderActivity=load; -load=async()=>{await renderActivity();const response=await fetch('/api/pipes/'+encodeURIComponent(flowId));if(!response.ok)return;const data=await response.json(),runs=(data.runs||[]).filter(run=>run.state!=='ignored');if(!runs.length)return;const diagnostics=runs.map(run=>'
'+esc(run.agent_name)+''+(run.exec_status!=null?'HTTP '+esc(String(run.exec_status))+(run.exec_exit_code!=null?' · VM exit '+esc(String(run.exec_exit_code)):'' ):esc(run.state))+'
'+(run.exec_status!=null?'

Exact command submitted to exe.dev

'+esc(run.exec_request)+'

Actual exe.dev response

'+esc(run.exec_response)+'
':'

No exe.dev request or response was recorded for this run. It did not reach the VM-command step.

')+(run.result?'

Herdr agent output

'+esc(run.result)+'
':'')+'
').join('');root.insertAdjacentHTML('beforeend','

Actual exe.dev command results

Only real agent runs appear here. The bearer token is never shown.

'+diagnostics+'
')}; +load=async()=>{await renderActivity();const response=await fetch('/api/pipes/'+encodeURIComponent(flowId));if(!response.ok)return;const data=await response.json(),runs=(data.runs||[]).filter(run=>run.state!=='ignored');if(!runs.length)return;const diagnostics=runs.map(run=>'
'+esc(run.agent_name)+''+(run.exec_status!=null?'HTTP '+esc(String(run.exec_status))+(run.exec_exit_code!=null?' · VM exit '+esc(String(run.exec_exit_code)):'' ):esc(run.state))+'
'+(run.exec_status!=null?'

Harness launch request

'+esc(run.exec_request)+'

Harness launch response

'+esc(run.exec_response)+'
':'

No exe.dev request or response was recorded for this run. It did not reach the VM-command step.

')+(run.result?'

Herdr agent output

'+esc(run.result)+'
':'')+'
').join('');root.insertAdjacentHTML('beforeend','

Actual exe.dev command results

Only real agent runs appear here. The bearer token is never shown.

'+diagnostics+'
')}; // Keep run details with the run they belong to. The native disclosure control // makes the command and response available without expanding every run. -load=async()=>{await renderActivity();const response=await fetch('/api/pipes/'+encodeURIComponent(flowId));if(!response.ok)return;const data=await response.json(),runs=(data.runs||[]).filter(run=>run.state!=='ignored'),runSection=Array.from(root.querySelectorAll('section')).find(section=>section.querySelector('h2')?.textContent==='Agent runs'),webhookSection=Array.from(root.querySelectorAll('section')).find(section=>section.querySelector('h2')?.textContent==='Webhook activity');if(!runSection)return;const panels=runs.map(run=>{const recovery=run.state==='recovering'?'

Recovery attempt '+esc(run.recovery_attempt||0)+' · '+esc(run.recovery_reason||'Herdr state changed')+' · '+esc(run.recovery_last_action||'reconciling')+'

':'';const command=run.exec_status!=null?'

Exact command submitted to exe.dev

'+esc(run.exec_request)+'

Actual exe.dev response

'+esc(run.exec_response)+'
':'

No exe.dev request or response was recorded for this run. It did not reach the VM-command step.

';const prompt=run.prompt?'

Prompt sent to agent

'+esc(run.prompt)+'
':'';const output=run.result?'

Full agent output

'+esc(run.result)+'
':'';return '
'+issueLink(run.issue_id,run.issue_url)+''+esc(run.agent_kind||data.flow.agent_kind||'Herdr agent')+' · '+esc(run.agent_name)+''+badge(run.state,['queued','starting','running','recovering','done'].includes(run.state))+''+time(run.updated_at)+'
'+recovery+prompt+command+output+'
'}).join('')||'

No agent runs yet.

';runSection.innerHTML='

Agent runs

Open a completed run to inspect its full, scrollable output.

'+panels;if(webhookSection)webhookSection.before(runSection)}; +load=async()=>{await renderActivity();const response=await fetch('/api/pipes/'+encodeURIComponent(flowId));if(!response.ok)return;const data=await response.json(),runs=(data.runs||[]).filter(run=>run.state!=='ignored'),runSection=Array.from(root.querySelectorAll('section')).find(section=>section.querySelector('h2')?.textContent==='Agent runs'),webhookSection=Array.from(root.querySelectorAll('section')).find(section=>section.querySelector('h2')?.textContent==='Webhook activity');if(!runSection)return;const panels=runs.map(run=>{const recovery=run.state==='recovering'?'

Recovery attempt '+esc(run.recovery_attempt||0)+' · '+esc(run.recovery_reason||'Herdr state changed')+' · '+esc(run.recovery_last_action||'reconciling')+'

':'';const command=run.exec_status!=null?'

Harness launch request

'+esc(run.exec_request)+'

Harness launch response

'+esc(run.exec_response)+'
':'

No exe.dev request or response was recorded for this run. It did not reach the VM-command step.

';const prompt=run.prompt?'

Rendered prompt

'+esc(run.prompt)+'
':'';const output=run.result?'

Full agent output

'+esc(run.result)+'
':'';return '
'+issueLink(run.issue_id,run.issue_url)+''+esc(run.agent_kind||data.flow.agent_kind||'Herdr agent')+' · '+esc(run.agent_name)+''+badge(run.state,['queued','starting','running','recovering','done'].includes(run.state))+''+time(run.updated_at)+'
'+recovery+prompt+command+output+'
'}).join('')||'

No agent runs yet.

';runSection.innerHTML='

Agent runs

Open a completed run to inspect its full, scrollable output.

'+panels;if(webhookSection)webhookSection.before(runSection)}; load(); `); @@ -172,7 +172,7 @@ const root=document.querySelector('#run-detail'),runId=root.dataset.runId; const esc=v=>{const d=document.createElement('div');d.textContent=v??'';return d.innerHTML}; const time=v=>new Date(v).toLocaleString(); const section=(title,value)=>value?'

'+title+'

'+esc(value)+'
':''; -async function load(){const response=await fetch('/api/runs/'+encodeURIComponent(runId));if(response.status===401)return location.href='/auth/linear';if(!response.ok){root.innerHTML='← My Flows

This agent run could not be found.

';return}const run=await response.json(),trusted=typeof run.issue_url==='string'&&(run.issue_url.startsWith('https://linear.app/')||/^https:\\/\\/github\\.com\\/[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+\\/pull\\/\\d+$/.test(run.issue_url)),issueHref=trusted?run.issue_url:'https://linear.app/issue/'+encodeURIComponent(run.issue_id);root.innerHTML='← '+esc(run.flow_name||'Flow')+'

'+esc(run.issue_id)+' ↗

'+esc(run.issue_title||run.issue_id)+'

'+esc(run.agent_kind||'Herdr agent')+' · '+esc(run.agent_name)+'

'+esc(run.state)+'
Workspace
'+esc(run.workspace_name||'—')+'
Created
'+time(run.created_at)+'
Updated
'+time(run.updated_at)+'
Exit status
'+(run.exec_status!=null?'HTTP '+esc(String(run.exec_status))+(run.exec_exit_code!=null?' · VM '+esc(String(run.exec_exit_code)):''):'—')+'
'+section('Prompt sent to agent',run.prompt)+section('Exact command submitted to exe.dev',run.exec_request)+section('Actual exe.dev response',run.exec_response)+section('Full agent output',run.result)}load(); +async function load(){const response=await fetch('/api/runs/'+encodeURIComponent(runId));if(response.status===401)return location.href='/auth/linear';if(!response.ok){root.innerHTML='← My Flows

This agent run could not be found.

';return}const run=await response.json(),trusted=typeof run.issue_url==='string'&&(run.issue_url.startsWith('https://linear.app/')||/^https:\\/\\/github\\.com\\/[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+\\/pull\\/\\d+$/.test(run.issue_url)),issueHref=trusted?run.issue_url:'https://linear.app/issue/'+encodeURIComponent(run.issue_id);root.innerHTML='← '+esc(run.flow_name||'Flow')+'

'+esc(run.issue_id)+' ↗

'+esc(run.issue_title||run.issue_id)+'

'+esc(run.agent_kind||'Herdr agent')+' · '+esc(run.agent_name)+'

'+esc(run.state)+'
Workspace
'+esc(run.workspace_name||'—')+'
Created
'+time(run.created_at)+'
Updated
'+time(run.updated_at)+'
Exit status
'+(run.exec_status!=null?'HTTP '+esc(String(run.exec_status))+(run.exec_exit_code!=null?' · VM '+esc(String(run.exec_exit_code)):''):'—')+'
'+section('Rendered prompt · delivery '+(run.prompt_delivery_state||'legacy'),run.prompt)+section('Harness launch request',run.exec_request)+section('Harness launch response',run.exec_response)+section('Prompt delivery request',run.prompt_delivery_request)+section('Prompt delivery response',run.prompt_delivery_response)+section('Full agent output',run.result)}load(); `); export const flowsPage = (viewer: { email: string }) => shell("My Flows — Factorize", viewer, ` diff --git a/test/execution-backend.test.ts b/test/execution-backend.test.ts new file mode 100644 index 0000000..0f5f140 --- /dev/null +++ b/test/execution-backend.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ExeHerdrBackend } from "../src/exe-herdr-backend"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("ExeHerdrBackend", () => { + it("returns independent launch and prompt delivery receipts", async () => { + const requests: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => { + const body = String(init.body), marker = body.match(/__factorize_exit_[a-f0-9]+__/)?.[0]; + requests.push(body); + return new Response(`{\"result\":{\"agent\":{\"name\":\"run-1\",\"agent_status\":\"working\"}}}\n${marker}:0\n`); + })); + const backend = new ExeHerdrBackend({ vmName: "vm", apiToken: "secret", agentKind: "codex", cwd: "/repo" }); + const launched = await backend.launch({ runId: "run-1", agentName: "run-1", workspaceName: "flow", runPath: "/repo/.factorize-runs/run-1", lease: "lease" }); + expect(requests[0]).toContain("agent start"); + expect(requests[0]).not.toContain("agent prompt"); + const delivered = await backend.deliverPrompt(launched.handle, "do the work"); + expect(delivered.state).toBe("accepted"); + expect(requests[1]).toContain("agent prompt"); + expect(requests[1]).not.toContain("agent start"); + }); +}); diff --git a/test/ui.test.ts b/test/ui.test.ts index 8023f26..f7882df 100644 --- a/test/ui.test.ts +++ b/test/ui.test.ts @@ -83,8 +83,9 @@ describe("pages", () => { it("renders an agent run page", () => { const html = runDetailPage({ email: "owner@example.com" }, "run-1"); expect(html).toContain("/api/runs/"); - expect(html).toContain("Prompt sent to agent"); - expect(html).toContain("Exact command submitted to exe.dev"); + expect(html).toContain("Rendered prompt"); + expect(html).toContain("Harness launch request"); + expect(html).toContain("Prompt delivery request"); expect(html).toContain("Full agent output"); expectInlineScriptsToParse(html); }); diff --git a/test/workspace.test.ts b/test/workspace.test.ts index 1b2ce40..9a2e27c 100644 --- a/test/workspace.test.ts +++ b/test/workspace.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { agentStatusCommand, connectionCheckCommand, defaultAgentCommand, garbageCollectPaneCommand, herdrAgentStatus, replaceForegroundCommand, shellAtom, startAgentCommand } from "../src/exe"; +import { agentStatusCommand, connectionCheckCommand, defaultAgentCommand, garbageCollectPaneCommand, herdrAgentStatus, launchAgentCommand, promptAgentCommand, replaceForegroundCommand, shellAtom, startAgentCommand } from "../src/exe"; import { workingDirectoryFor, workspaceNameFor } from "../src/workspace"; describe("flow workspaces", () => { @@ -35,6 +35,22 @@ describe("flow workspaces", () => { expect(command).toContain("-- '--dangerously-bypass-approvals-and-sandbox'"); }); + it("keeps harness launch separate from prompt delivery", () => { + const connection = { vmName: "vm", apiToken: "token", agentKind: "codex", cwd: "/repo" }; + const launch = launchAgentCommand("factorize-1", connection, "factorize", "/repo/.factorize-runs/run", "lease"); + const delivery = promptAgentCommand(connection, "factorize-1", "fix it"); + expect(launch).toContain("agent start 'factorize-1'"); + expect(launch).not.toContain("agent prompt"); + expect(delivery).toContain("agent prompt 'factorize-1'"); + expect(delivery).not.toContain("agent start"); + }); + + it("never skips prompt delivery when an idempotent launch finds an agent", () => { + const command = startAgentCommand("factorize-1", { vmName: "vm", apiToken: "token", agentKind: "codex", cwd: "/repo" }, "fix it", "factorize", "/repo/.factorize-runs/run", "lease"); + expect(command.indexOf("agent prompt 'factorize-1'")).toBeGreaterThan(command.indexOf("existing=")); + expect(command).not.toMatch(/then printf[^;]+; else[^;]+agent prompt/); + }); + it("uses no-prompt defaults for Codex and Claude, but leaves Pi unchanged", () => { expect(defaultAgentCommand("codex")).toBe("--dangerously-bypass-approvals-and-sandbox"); expect(defaultAgentCommand("claude")).toBe("--dangerously-skip-permissions");