From 4bc84e529677f694687ffb1c8224382aa4f2da75 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 1 May 2026 21:01:54 +0200 Subject: [PATCH 1/2] Add runnable persona bridge --- .../completed/2026-05/traj_7m1k73hrw2k8.json | 61 +++ .../completed/2026-05/traj_7m1k73hrw2k8.md | 38 ++ .../2026-05/traj_7m1k73hrw2k8.trace.json | 59 +++ .trajectories/index.json | 27 ++ packages/cli/package.json | 2 +- packages/harness-kit/CHANGELOG.md | 5 +- packages/harness-kit/README.md | 67 ++- packages/harness-kit/src/index.ts | 14 + packages/harness-kit/src/runner.test.ts | 183 ++++++++ packages/harness-kit/src/runner.ts | 401 ++++++++++++++++++ pnpm-lock.yaml | 10 +- 11 files changed, 851 insertions(+), 16 deletions(-) create mode 100644 .trajectories/completed/2026-05/traj_7m1k73hrw2k8.json create mode 100644 .trajectories/completed/2026-05/traj_7m1k73hrw2k8.md create mode 100644 .trajectories/completed/2026-05/traj_7m1k73hrw2k8.trace.json create mode 100644 .trajectories/index.json create mode 100644 packages/harness-kit/src/runner.test.ts create mode 100644 packages/harness-kit/src/runner.ts diff --git a/.trajectories/completed/2026-05/traj_7m1k73hrw2k8.json b/.trajectories/completed/2026-05/traj_7m1k73hrw2k8.json new file mode 100644 index 00000000..335baea6 --- /dev/null +++ b/.trajectories/completed/2026-05/traj_7m1k73hrw2k8.json @@ -0,0 +1,61 @@ +{ + "id": "traj_7m1k73hrw2k8", + "version": 1, + "task": { + "title": "Add Workforce runnable persona sendMessage bridge" + }, + "status": "completed", + "startedAt": "2026-05-01T18:52:31.913Z", + "completedAt": "2026-05-01T19:00:30.407Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-05-01T18:58:51.766Z" + } + ], + "chapters": [ + { + "id": "chap_otmwo2ibx2w1", + "title": "Work", + "agentName": "default", + "startedAt": "2026-05-01T18:58:51.766Z", + "endedAt": "2026-05-01T19:00:30.407Z", + "events": [ + { + "ts": 1777661931767, + "type": "decision", + "content": "Added runnable persona bridge in harness-kit instead of workload-router: Added runnable persona bridge in harness-kit instead of workload-router", + "raw": { + "question": "Added runnable persona bridge in harness-kit instead of workload-router", + "chosen": "Added runnable persona bridge in harness-kit instead of workload-router", + "alternatives": [], + "reasoning": "workload-router remains the pure selection/install metadata layer; harness-kit already owns per-harness command knowledge and can safely expose the side-effecting sendMessage bridge Ricky needs." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Added a harness-kit runnable persona bridge with non-interactive sendMessage execution, fake-harness tests, README docs, and updated @relayfile/local-mount to 0.6.1 so the pulled main branch typechecks with includeGit.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [ + "e746d8a", + "8051d7b" + ], + "filesChanged": [ + "packages/agentworkforce/package.json", + "packages/harness-kit/package.json", + "packages/workload-router/package.json" + ], + "projectId": "/Users/khaliqgant/Projects/AgentWorkforce/workforce", + "tags": [], + "_trace": { + "startRef": "1eab74d214aa7bd00de842bce7e35f380e45cd69", + "endRef": "e746d8a20e2ea947c7edf9d32544e3017ca68a30", + "traceId": "03dae7ba-f43e-4402-ac18-3cb804848bdc" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-05/traj_7m1k73hrw2k8.md b/.trajectories/completed/2026-05/traj_7m1k73hrw2k8.md new file mode 100644 index 00000000..6edf89de --- /dev/null +++ b/.trajectories/completed/2026-05/traj_7m1k73hrw2k8.md @@ -0,0 +1,38 @@ +# Trajectory: Add Workforce runnable persona sendMessage bridge + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** May 1, 2026 at 08:52 PM +> **Completed:** May 1, 2026 at 09:00 PM + +--- + +## Summary + +Added a harness-kit runnable persona bridge with non-interactive sendMessage execution, fake-harness tests, README docs, and updated @relayfile/local-mount to 0.6.1 so the pulled main branch typechecks with includeGit. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Added runnable persona bridge in harness-kit instead of workload-router +- **Chose:** Added runnable persona bridge in harness-kit instead of workload-router +- **Reasoning:** workload-router remains the pure selection/install metadata layer; harness-kit already owns per-harness command knowledge and can safely expose the side-effecting sendMessage bridge Ricky needs. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Added runnable persona bridge in harness-kit instead of workload-router: Added runnable persona bridge in harness-kit instead of workload-router + +--- + +## Artifacts + +**Commits:** e746d8a, 8051d7b +**Files changed:** 3 diff --git a/.trajectories/completed/2026-05/traj_7m1k73hrw2k8.trace.json b/.trajectories/completed/2026-05/traj_7m1k73hrw2k8.trace.json new file mode 100644 index 00000000..416d02de --- /dev/null +++ b/.trajectories/completed/2026-05/traj_7m1k73hrw2k8.trace.json @@ -0,0 +1,59 @@ +{ + "version": "1.0.0", + "id": "03dae7ba-f43e-4402-ac18-3cb804848bdc", + "timestamp": "2026-05-01T19:00:30.468Z", + "trajectory": "traj_7m1k73hrw2k8", + "files": [ + { + "path": "packages/agentworkforce/package.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 6, + "revision": "e746d8a20e2ea947c7edf9d32544e3017ca68a30" + } + ] + } + ] + }, + { + "path": "packages/harness-kit/package.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 6, + "revision": "e746d8a20e2ea947c7edf9d32544e3017ca68a30" + } + ] + } + ] + }, + { + "path": "packages/workload-router/package.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 6, + "revision": "e746d8a20e2ea947c7edf9d32544e3017ca68a30" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.trajectories/index.json b/.trajectories/index.json new file mode 100644 index 00000000..49a9cbbb --- /dev/null +++ b/.trajectories/index.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "lastUpdated": "2026-05-01T19:00:30.550Z", + "trajectories": { + "traj_1775734701264_ba65c69b": { + "title": "finish-npm-provenance-persona-workflow", + "status": "completed", + "startedAt": "2026-04-09T11:38:21.264Z", + "completedAt": "2026-04-09T11:40:10.797Z", + "path": "/Users/khaliqgant/Projects/AgentWorkforce/workforce/.trajectories/completed/traj_1775734701264_ba65c69b.json" + }, + "traj_1775760549887_744167d6": { + "title": "add-opencode-workflow-specialist-persona-workflow", + "status": "abandoned", + "startedAt": "2026-04-09T18:49:09.887Z", + "completedAt": "2026-04-09T18:57:24.224Z", + "path": "/Users/khaliqgant/Projects/AgentWorkforce/workforce/.trajectories/completed/traj_1775760549887_744167d6.json" + }, + "traj_7m1k73hrw2k8": { + "title": "Add Workforce runnable persona sendMessage bridge", + "status": "completed", + "startedAt": "2026-05-01T18:52:31.913Z", + "completedAt": "2026-05-01T19:00:30.407Z", + "path": "/Users/khaliqgant/Projects/AgentWorkforce/workforce/.trajectories/completed/2026-05/traj_7m1k73hrw2k8.json" + } + } +} \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json index 14ca4208..1b5101a2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,7 +15,7 @@ "dependencies": { "@agentworkforce/harness-kit": "workspace:*", "@agentworkforce/workload-router": "workspace:*", - "@relayfile/local-mount": "^0.6.0", + "@relayfile/local-mount": "^0.6.1", "ora": "^9.4.0" }, "repository": { diff --git a/packages/harness-kit/CHANGELOG.md b/packages/harness-kit/CHANGELOG.md index d605a518..53ebaea7 100644 --- a/packages/harness-kit/CHANGELOG.md +++ b/packages/harness-kit/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add a `useRunnablePersona` / `useRunnableSelection` bridge that launches the selected harness non-interactively and returns captured execution results. + ## [0.2.1] - 2026-04-29 ### Released - v0.2.1 - diff --git a/packages/harness-kit/README.md b/packages/harness-kit/README.md index bfbe253d..d7974401 100644 --- a/packages/harness-kit/README.md +++ b/packages/harness-kit/README.md @@ -21,7 +21,7 @@ pnpm add @agentworkforce/harness-kit @agentworkforce/workload-router ## What's in the box -### `buildInteractiveSpec(input)` — translate a persona to a spawnable argv +### `buildInteractiveSpec(input)` — translate a persona to an interactive argv Takes the fields off a `PersonaSelection` (harness, model, systemPrompt, mcpServers, permissions) and returns `{bin, args, initialPrompt, warnings}`. @@ -55,6 +55,7 @@ for (const w of warnings) console.warn(w); // Build the exec spec. const spec = buildInteractiveSpec({ harness: selection.runtime.harness, + personaId: selection.personaId, model: selection.runtime.model, systemPrompt: selection.runtime.systemPrompt, mcpServers: mcpResolution.servers, @@ -70,6 +71,40 @@ spawn(spec.bin, args, { }); ``` +### `useRunnablePersona(intent)` — run a persona non-interactively + +For orchestrators that need a programmatic `sendMessage()` surface, the kit +also exposes a thin runner around the same router + harness translation path. +It resolves the persona, launches the selected harness in non-interactive +mode, captures stdout/stderr, reports progress chunks, supports cancellation +and timeouts, and returns a stable execution result. + +```ts +import { useRunnablePersona } from '@agentworkforce/harness-kit'; + +const persona = useRunnablePersona('agent-relay-workflow'); +const run = persona.sendMessage('Write a workflow artifact as structured JSON.', { + workingDirectory: process.cwd(), + name: 'workflow-writer', + timeoutSeconds: persona.selection.runtime.harnessSettings.timeoutSeconds, + inputs: { outputPath: 'workflows/generated/docs-audit.ts' }, + onProgress: (chunk) => process.stderr.write(chunk.text) +}); + +const result = await run; +if (result.status !== 'completed') { + throw new Error(result.stderr || `persona run failed: ${result.status}`); +} +console.log(result.output); +``` + +The runner maps harnesses to their non-interactive command shapes: +`claude --print`, `codex exec`, and `opencode run`. It writes generated +config files such as `opencode.json` only for the duration of the child +process and restores or removes them afterward. Skill installation is opt-in +with `installSkills: true`; callers that need stronger filesystem isolation +should keep using a mount/sandbox layer around the runner. + ### Claude harness guarantees When `harness === 'claude'`, `buildInteractiveSpec` **always** emits both: @@ -143,6 +178,7 @@ export interface DroppedMcpServer { name: string; refs: string[] } export function buildInteractiveSpec(input: BuildInteractiveSpecInput): InteractiveSpec export interface BuildInteractiveSpecInput { harness: Harness; + personaId: string; model: string; systemPrompt: string; mcpServers?: Record; @@ -154,16 +190,29 @@ export interface InteractiveSpec { initialPrompt: string | null; warnings: string[]; } + +// Runnable personas +export function useRunnablePersona(intent, options?): RunnablePersonaContext +export function useRunnableSelection(selection, options?): RunnablePersonaContext +export interface RunnablePersonaContext { + selection: PersonaSelection; + install: PersonaInstallContext; + sendMessage(task, options?): PersonaExecution; +} +export interface PersonaExecutionResult { + status: 'completed' | 'failed' | 'cancelled' | 'timeout'; + output: string; + stderr: string; + exitCode: number | null; + durationMs: number; +} ``` ## Status Small, stable surface focused on the three things a harness spawner needs: -resolve env refs, resolve MCP config, build argv. It does **not** spawn -processes, manage stdio, install skills, or talk to the filesystem — those -live in the CLI because different consumers will want different behaviors -there (CI pipelines, IDE plugins, test harnesses, etc.). - -A future `useSelection().run()` convenience in the router may wrap these -helpers into a one-liner — if that happens, it'll be built on top of this -package, not a replacement for it. +resolve env refs, resolve MCP config, and build argv. The default exports are +still pure when you use `buildInteractiveSpec` directly. The +`useRunnablePersona` convenience is intentionally the small side-effecting +layer for consumers that want the same harness knowledge plus a captured +non-interactive child process. diff --git a/packages/harness-kit/src/index.ts b/packages/harness-kit/src/index.ts index eb870343..baf12638 100644 --- a/packages/harness-kit/src/index.ts +++ b/packages/harness-kit/src/index.ts @@ -23,6 +23,20 @@ export { type InteractiveSpec } from './harness.js'; +export { + buildNonInteractiveSpec, + makeRunnablePersonaContext, + useRunnablePersona, + useRunnableSelection, + type NonInteractiveSpec, + type PersonaExecution, + type PersonaExecutionResult, + type PersonaSendOptions, + type RunnablePersonaContext, + type RunnablePersonaOptions, + type RunnableSelectionOptions +} from './runner.js'; + export { detectHarness, detectHarnesses, diff --git a/packages/harness-kit/src/runner.test.ts b/packages/harness-kit/src/runner.test.ts new file mode 100644 index 00000000..0deae7e3 --- /dev/null +++ b/packages/harness-kit/src/runner.test.ts @@ -0,0 +1,183 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { PersonaSelection } from '@agentworkforce/workload-router'; +import { + buildNonInteractiveSpec, + useRunnableSelection +} from './runner.js'; + +function fakeSelection(overrides: Partial = {}): PersonaSelection { + return { + personaId: 'test-persona', + tier: 'best', + runtime: { + harness: 'codex', + model: 'openai-codex/gpt-5.3-codex', + systemPrompt: 'You are a test persona.', + harnessSettings: { + reasoning: 'high', + timeoutSeconds: 30 + } + }, + skills: [], + rationale: 'test', + ...overrides + }; +} + +function writeHarness( + dir: string, + source: string +): string { + const path = join(dir, 'fake-harness.js'); + writeFileSync(path, source, 'utf8'); + chmodSync(path, 0o755); + return path; +} + +test('buildNonInteractiveSpec translates harnesses to non-interactive commands', () => { + const claude = buildNonInteractiveSpec({ + harness: 'claude', + personaId: 'p', + model: 'claude-sonnet-4-6', + systemPrompt: 'system', + task: 'task', + name: 'run-name' + }); + assert.equal(claude.bin, 'claude'); + assert.ok(claude.args.includes('--print')); + assert.ok(claude.args.includes('--output-format')); + assert.ok(claude.args.includes('--name')); + assert.equal(claude.args.at(-1), 'task'); + + const codex = buildNonInteractiveSpec({ + harness: 'codex', + personaId: 'p', + model: 'openai-codex/gpt-5.3-codex', + systemPrompt: 'system', + task: 'task' + }); + assert.deepEqual(codex.args.slice(0, 4), ['exec', '-m', 'gpt-5.3-codex', '--skip-git-repo-check']); + assert.match(String(codex.args.at(-1)), /system\n\nUser task:\ntask/); + + const opencode = buildNonInteractiveSpec({ + harness: 'opencode', + personaId: 'p', + model: 'opencode/gpt-5-nano', + systemPrompt: 'system', + task: 'task', + workingDirectory: '/tmp/project' + }); + assert.deepEqual(opencode.args.slice(0, 7), [ + 'run', + '--agent', + 'p', + '--model', + 'opencode/gpt-5-nano', + '--format', + 'default' + ]); + assert.ok(opencode.args.includes('--dir')); + assert.equal(opencode.configFiles.length, 1); +}); + +test('useRunnableSelection spawns the harness, captures output, and passes inputs/env', async () => { + const dir = mkdtempSync(join(tmpdir(), 'aw-runner-')); + try { + const harness = writeHarness( + dir, + `#!/usr/bin/env node +const payload = { + argv: process.argv.slice(2), + cwd: process.cwd(), + envValue: process.env.TEST_PERSONA_ENV +}; +process.stdout.write(JSON.stringify(payload)); +` + ); + const context = useRunnableSelection(fakeSelection(), { + commandOverrides: { codex: harness } + }); + const progress: string[] = []; + const result = await context.sendMessage('write a workflow', { + workingDirectory: dir, + inputs: { answer: 42 }, + env: { TEST_PERSONA_ENV: 'from-env' }, + onProgress: (chunk) => progress.push(chunk.text) + }); + + assert.equal(result.status, 'completed'); + assert.equal(result.exitCode, 0); + assert.equal(progress.join(''), result.output); + const payload = JSON.parse(result.output); + assert.equal(payload.cwd, realpathSync(dir)); + assert.equal(payload.envValue, 'from-env'); + assert.equal(payload.argv[0], 'exec'); + assert.match(payload.argv.at(-1), /write a workflow/); + assert.match(payload.argv.at(-1), /"answer": 42/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('useRunnableSelection reports non-zero harness exits as failed', async () => { + const dir = mkdtempSync(join(tmpdir(), 'aw-runner-fail-')); + try { + const harness = writeHarness( + dir, + `#!/usr/bin/env node +process.stderr.write('boom'); +process.exit(7); +` + ); + const context = useRunnableSelection(fakeSelection(), { + commandOverrides: { codex: harness } + }); + const result = await context.sendMessage('task', { workingDirectory: dir }); + + assert.equal(result.status, 'failed'); + assert.equal(result.exitCode, 7); + assert.equal(result.stderr, 'boom'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('useRunnableSelection materializes and removes opencode config files around the run', async () => { + const dir = mkdtempSync(join(tmpdir(), 'aw-runner-opencode-')); + try { + const harness = writeHarness( + dir, + `#!/usr/bin/env node +const fs = require('node:fs'); +process.stdout.write(fs.readFileSync('opencode.json', 'utf8')); +` + ); + const context = useRunnableSelection( + fakeSelection({ + runtime: { + harness: 'opencode', + model: 'opencode/gpt-5-nano', + systemPrompt: 'You are an opencode persona.', + harnessSettings: { + reasoning: 'medium', + timeoutSeconds: 30 + } + } + }), + { commandOverrides: { opencode: harness } } + ); + const result = await context.sendMessage('task', { workingDirectory: dir }); + + assert.equal(result.status, 'completed'); + const config = JSON.parse(result.output); + assert.equal(config.agent['test-persona'].prompt, 'You are an opencode persona.'); + assert.throws(() => readFileSync(join(dir, 'opencode.json'), 'utf8')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/harness-kit/src/runner.ts b/packages/harness-kit/src/runner.ts new file mode 100644 index 00000000..bd986cc1 --- /dev/null +++ b/packages/harness-kit/src/runner.ts @@ -0,0 +1,401 @@ +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, normalize, sep } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { + usePersona, + useSelection, + type Harness, + type PersonaContext, + type PersonaIntent, + type PersonaSelection, + type PersonaTier, + type RoutingProfile, + type RoutingProfileId +} from '@agentworkforce/workload-router'; + +import { resolveStringMapLenient } from './env-refs.js'; +import { buildInteractiveSpec, type BuildInteractiveSpecInput } from './harness.js'; +import { formatDropWarnings, resolveMcpServersLenient } from './mcp.js'; + +export interface PersonaSendOptions { + workingDirectory?: string; + name?: string; + timeoutSeconds?: number; + inputs?: Record; + installSkills?: boolean; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + onProgress?: (chunk: { stream: 'stdout' | 'stderr'; text: string }) => void; +} + +export interface PersonaExecutionResult { + status: 'completed' | 'failed' | 'cancelled' | 'timeout'; + output: string; + stderr: string; + exitCode: number | null; + durationMs: number; + workflowRunId?: string; + stepName?: string; +} + +export interface PersonaExecution extends Promise { + cancel(reason?: string): void; + readonly runId: Promise; +} + +export interface RunnablePersonaContext { + readonly selection: PersonaSelection; + readonly install: PersonaContext['install']; + sendMessage(task: string, options?: PersonaSendOptions): PersonaExecution; +} + +export interface RunnablePersonaOptions { + harness?: Harness; + tier?: PersonaTier; + profile?: RoutingProfile | RoutingProfileId; + installRoot?: string; + commandOverrides?: Partial>; +} + +export interface RunnableSelectionOptions { + harness?: Harness; + installRoot?: string; + commandOverrides?: Partial>; +} + +export interface NonInteractiveSpec { + bin: string; + args: readonly string[]; + configFiles: readonly { path: string; contents: string }[]; + warnings: readonly string[]; +} + +interface SpawnCaptureResult { + stdout: string; + stderr: string; + exitCode: number | null; + status: PersonaExecutionResult['status']; +} + +interface ConfigWrite { + path: string; + existed: boolean; + previous?: string; +} + +export function useRunnablePersona( + intent: PersonaIntent, + options: RunnablePersonaOptions = {} +): RunnablePersonaContext { + const context = usePersona(intent, { + harness: options.harness, + tier: options.tier, + profile: options.profile, + installRoot: options.installRoot + }); + return makeRunnablePersonaContext(context, { + commandOverrides: options.commandOverrides + }); +} + +export function useRunnableSelection( + selection: PersonaSelection, + options: RunnableSelectionOptions = {} +): RunnablePersonaContext { + const context = useSelection(selection, { + harness: options.harness, + installRoot: options.installRoot + }); + return makeRunnablePersonaContext(context, { + commandOverrides: options.commandOverrides + }); +} + +export function makeRunnablePersonaContext( + context: PersonaContext, + options: { commandOverrides?: Partial> } = {} +): RunnablePersonaContext { + const sendMessage = (task: string, sendOptions: PersonaSendOptions = {}) => { + const runId = randomUUID(); + const controller = new AbortController(); + const startedAt = Date.now(); + let cancelReason = ''; + + const cancel = (reason = 'cancelled') => { + cancelReason = reason; + controller.abort(); + }; + + const promise = (async (): Promise => { + const cwd = sendOptions.workingDirectory ?? process.cwd(); + const callerEnv = sendOptions.env ? { ...process.env, ...sendOptions.env } : process.env; + const envResolution = resolveStringMapLenient(context.selection.env, callerEnv, 'env'); + const mcpResolution = resolveMcpServersLenient(context.selection.mcpServers, callerEnv); + const dropWarnings = formatDropWarnings( + envResolution.dropped, + mcpResolution.dropped, + mcpResolution.droppedServers + ); + const spec = buildNonInteractiveSpec({ + harness: context.selection.runtime.harness, + personaId: context.selection.personaId, + model: context.selection.runtime.model, + systemPrompt: context.selection.runtime.systemPrompt, + mcpServers: mcpResolution.servers, + permissions: context.selection.permissions, + task: withInputs(task, sendOptions.inputs), + name: sendOptions.name, + workingDirectory: cwd + }); + const warnings = [...dropWarnings, ...spec.warnings]; + const warningText = warnings.length ? warnings.map((w) => `warning: ${w}\n`).join('') : ''; + if (warningText) { + sendOptions.onProgress?.({ stream: 'stderr', text: warningText }); + } + + const env = { + ...callerEnv, + ...(envResolution.value ?? {}) + }; + const bin = options.commandOverrides?.[context.selection.runtime.harness] ?? spec.bin; + const signal = anySignal([controller.signal, sendOptions.signal]); + const configWrites = materializeConfigFiles(cwd, spec.configFiles); + try { + if (sendOptions.installSkills === true && context.install.commandString !== ':') { + const install = await spawnCapture( + context.install.command[0], + context.install.command.slice(1), + { cwd, env, signal, timeoutSeconds: sendOptions.timeoutSeconds, onProgress: sendOptions.onProgress } + ); + if (install.status !== 'completed' || install.exitCode !== 0) { + return { + status: install.status === 'completed' ? 'failed' : install.status, + output: install.stdout, + stderr: warningText + install.stderr, + exitCode: install.exitCode, + durationMs: Date.now() - startedAt + }; + } + } + + const result = await spawnCapture(bin, spec.args, { + cwd, + env, + signal, + timeoutSeconds: sendOptions.timeoutSeconds, + onProgress: sendOptions.onProgress + }); + const status = + result.status === 'completed' && result.exitCode !== 0 ? 'failed' : result.status; + return { + status, + output: result.stdout, + stderr: warningText + result.stderr + (cancelReason ? `\n${cancelReason}` : ''), + exitCode: result.exitCode, + durationMs: Date.now() - startedAt + }; + } finally { + restoreConfigFiles(configWrites); + if (sendOptions.installSkills === true && context.install.cleanupCommandString !== ':') { + await spawnCapture(context.install.cleanupCommand[0], context.install.cleanupCommand.slice(1), { + cwd, + env, + signal: undefined, + timeoutSeconds: 30 + }); + } + } + })(); + + const execution = promise as PersonaExecution; + Object.defineProperties(execution, { + cancel: { value: cancel }, + runId: { value: Promise.resolve(runId) } + }); + return execution; + }; + + return Object.freeze({ + selection: context.selection, + install: context.install, + sendMessage + }); +} + +export function buildNonInteractiveSpec( + input: BuildInteractiveSpecInput & { + task: string; + name?: string; + workingDirectory?: string; + } +): NonInteractiveSpec { + const interactive = buildInteractiveSpec(input); + switch (input.harness) { + case 'claude': { + const args = [...interactive.args, '--print', '--output-format', 'text']; + if (input.name) args.push('--name', input.name); + args.push(input.task); + return { + bin: interactive.bin, + args, + configFiles: interactive.configFiles, + warnings: interactive.warnings + }; + } + case 'codex': { + const prompt = interactive.initialPrompt + ? `${interactive.initialPrompt}\n\nUser task:\n${input.task}` + : input.task; + return { + bin: interactive.bin, + args: ['exec', ...interactive.args, '--skip-git-repo-check', prompt], + configFiles: interactive.configFiles, + warnings: interactive.warnings + }; + } + case 'opencode': { + const args = ['run', ...interactive.args, '--model', input.model, '--format', 'default']; + if (input.workingDirectory) args.push('--dir', input.workingDirectory); + if (input.name) args.push('--title', input.name); + args.push(input.task); + return { + bin: interactive.bin, + args, + configFiles: interactive.configFiles, + warnings: interactive.warnings + }; + } + default: { + const _exhaustive: never = input.harness; + throw new Error(`Unhandled harness: ${String(_exhaustive)}`); + } + } +} + +function withInputs(task: string, inputs: PersonaSendOptions['inputs']): string { + if (!inputs || Object.keys(inputs).length === 0) return task; + return `${task}\n\nRun inputs:\n${JSON.stringify(inputs, null, 2)}`; +} + +function assertSafeRelativePath(path: string): void { + if (!path) throw new Error('config file path must be non-empty'); + if (isAbsolute(path)) throw new Error(`config file path must be relative: ${path}`); + const normalized = normalize(path); + if (normalized === '..' || normalized.startsWith(`..${sep}`)) { + throw new Error(`config file path must not escape the working directory: ${path}`); + } +} + +function materializeConfigFiles( + cwd: string, + files: readonly { path: string; contents: string }[] +): ConfigWrite[] { + const writes: ConfigWrite[] = []; + for (const file of files) { + assertSafeRelativePath(file.path); + const target = join(cwd, file.path); + const existed = existsSync(target); + const previous = existed ? readFileSync(target, 'utf8') : undefined; + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, file.contents, 'utf8'); + writes.push({ path: target, existed, previous }); + } + return writes; +} + +function restoreConfigFiles(writes: readonly ConfigWrite[]): void { + for (const write of [...writes].reverse()) { + if (write.existed) { + writeFileSync(write.path, write.previous ?? '', 'utf8'); + } else { + rmSync(write.path, { force: true }); + } + } +} + +async function spawnCapture( + bin: string | undefined, + args: readonly string[], + options: { + cwd: string; + env: NodeJS.ProcessEnv; + signal?: AbortSignal; + timeoutSeconds?: number; + onProgress?: PersonaSendOptions['onProgress']; + } +): Promise { + if (!bin) { + return { stdout: '', stderr: 'missing command\n', exitCode: 127, status: 'failed' }; + } + + return await new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let settled = false; + let timedOut = false; + let cancelled = false; + const child = spawn(bin, [...args], { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + const timeout = + options.timeoutSeconds && options.timeoutSeconds > 0 + ? setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + }, options.timeoutSeconds * 1000) + : undefined; + const abort = () => { + cancelled = true; + child.kill('SIGTERM'); + }; + options.signal?.addEventListener('abort', abort, { once: true }); + + const finish = (exitCode: number | null, status?: PersonaExecutionResult['status']) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + options.signal?.removeEventListener('abort', abort); + resolve({ + stdout, + stderr, + exitCode, + status: status ?? (timedOut ? 'timeout' : cancelled ? 'cancelled' : 'completed') + }); + }; + + child.stdout.on('data', (buf: Buffer) => { + const text = buf.toString(); + stdout += text; + options.onProgress?.({ stream: 'stdout', text }); + }); + child.stderr.on('data', (buf: Buffer) => { + const text = buf.toString(); + stderr += text; + options.onProgress?.({ stream: 'stderr', text }); + }); + child.on('exit', (code) => finish(code)); + child.on('error', (err: NodeJS.ErrnoException) => { + stderr += err.message; + finish(err.code === 'ENOENT' ? 127 : 1, 'failed'); + }); + }); +} + +function anySignal(signals: Array): AbortSignal | undefined { + const active = signals.filter((signal): signal is AbortSignal => signal !== undefined); + if (active.length === 0) return undefined; + if (active.length === 1) return active[0]; + const controller = new AbortController(); + for (const signal of active) { + if (signal.aborted) { + controller.abort(); + break; + } + signal.addEventListener('abort', () => controller.abort(), { once: true }); + } + return controller.signal; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb4b3346..6aacb2bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: workspace:* version: link:../workload-router '@relayfile/local-mount': - specifier: ^0.6.0 - version: 0.6.0 + specifier: ^0.6.1 + version: 0.6.1 ora: specifier: ^9.4.0 version: 9.4.0 @@ -139,8 +139,8 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} - '@relayfile/local-mount@0.6.0': - resolution: {integrity: sha512-Iz2a99ACOiA9pnS+Y8FBik/DqzyhSE9aKMKFkGkx0DQOI/qLveD/Kae9vBQudbvD2ZEcDAtrK86eE44f2yCjMA==} + '@relayfile/local-mount@0.6.1': + resolution: {integrity: sha512-EZkR1mlHHSxq8ZWXXBA8PXJ5lyb72WjTTDOYYKDmwgVNzAWo/+hO3jrLOY4yqm8VN3ukQU75jm1GysNM839N2Q==} engines: {node: '>=18'} '@types/node@22.19.15': @@ -336,7 +336,7 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 - '@relayfile/local-mount@0.6.0': + '@relayfile/local-mount@0.6.1': dependencies: '@parcel/watcher': 2.5.6 ignore: 7.0.5 From 526c16c1c7953787bfd98b9b6ea458f16edafc92 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 1 May 2026 21:08:45 +0200 Subject: [PATCH 2/2] Harden runnable persona cancellation --- .../completed/2026-05/traj_cntjweljhmft.json | 53 +++++++++++++++++ .../completed/2026-05/traj_cntjweljhmft.md | 31 ++++++++++ .trajectories/index.json | 9 ++- packages/harness-kit/src/runner.test.ts | 58 ++++++++++++++++++- packages/harness-kit/src/runner.ts | 31 +++++++++- 5 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 .trajectories/completed/2026-05/traj_cntjweljhmft.json create mode 100644 .trajectories/completed/2026-05/traj_cntjweljhmft.md diff --git a/.trajectories/completed/2026-05/traj_cntjweljhmft.json b/.trajectories/completed/2026-05/traj_cntjweljhmft.json new file mode 100644 index 00000000..b66ed78c --- /dev/null +++ b/.trajectories/completed/2026-05/traj_cntjweljhmft.json @@ -0,0 +1,53 @@ +{ + "id": "traj_cntjweljhmft", + "version": 1, + "task": { + "title": "Address PR 33 review comments" + }, + "status": "completed", + "startedAt": "2026-05-01T19:06:48.954Z", + "completedAt": "2026-05-01T19:08:35.648Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-05-01T19:08:32.212Z" + } + ], + "chapters": [ + { + "id": "chap_h0xjo7yreo25", + "title": "Work", + "agentName": "default", + "startedAt": "2026-05-01T19:08:32.212Z", + "endedAt": "2026-05-01T19:08:35.648Z", + "events": [ + { + "ts": 1777662512216, + "type": "decision", + "content": "Accepted PR 33 cancellation review findings: Accepted PR 33 cancellation review findings", + "raw": { + "question": "Accepted PR 33 cancellation review findings", + "chosen": "Accepted PR 33 cancellation review findings", + "alternatives": [], + "reasoning": "The runner must not spawn a harness after an already-aborted signal, and timeout must complete even when a harness ignores SIGTERM; both paths now have fake-harness regression tests." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Addressed both PR 33 automated review comments by preventing already-aborted sends from spawning and by adding a SIGKILL watchdog after timeout SIGTERM, with regression tests for both paths.", + "approach": "Standard approach", + "confidence": 0.92 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/khaliqgant/Projects/AgentWorkforce/workforce", + "tags": [], + "_trace": { + "startRef": "4bc84e529677f694687ffb1c8224382aa4f2da75", + "endRef": "4bc84e529677f694687ffb1c8224382aa4f2da75" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-05/traj_cntjweljhmft.md b/.trajectories/completed/2026-05/traj_cntjweljhmft.md new file mode 100644 index 00000000..c1aded0e --- /dev/null +++ b/.trajectories/completed/2026-05/traj_cntjweljhmft.md @@ -0,0 +1,31 @@ +# Trajectory: Address PR 33 review comments + +> **Status:** ✅ Completed +> **Confidence:** 92% +> **Started:** May 1, 2026 at 09:06 PM +> **Completed:** May 1, 2026 at 09:08 PM + +--- + +## Summary + +Addressed both PR 33 automated review comments by preventing already-aborted sends from spawning and by adding a SIGKILL watchdog after timeout SIGTERM, with regression tests for both paths. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Accepted PR 33 cancellation review findings +- **Chose:** Accepted PR 33 cancellation review findings +- **Reasoning:** The runner must not spawn a harness after an already-aborted signal, and timeout must complete even when a harness ignores SIGTERM; both paths now have fake-harness regression tests. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Accepted PR 33 cancellation review findings: Accepted PR 33 cancellation review findings diff --git a/.trajectories/index.json b/.trajectories/index.json index 49a9cbbb..57abb28d 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-05-01T19:00:30.550Z", + "lastUpdated": "2026-05-01T19:08:35.768Z", "trajectories": { "traj_1775734701264_ba65c69b": { "title": "finish-npm-provenance-persona-workflow", @@ -22,6 +22,13 @@ "startedAt": "2026-05-01T18:52:31.913Z", "completedAt": "2026-05-01T19:00:30.407Z", "path": "/Users/khaliqgant/Projects/AgentWorkforce/workforce/.trajectories/completed/2026-05/traj_7m1k73hrw2k8.json" + }, + "traj_cntjweljhmft": { + "title": "Address PR 33 review comments", + "status": "completed", + "startedAt": "2026-05-01T19:06:48.954Z", + "completedAt": "2026-05-01T19:08:35.648Z", + "path": "/Users/khaliqgant/Projects/AgentWorkforce/workforce/.trajectories/completed/2026-05/traj_cntjweljhmft.json" } } } \ No newline at end of file diff --git a/packages/harness-kit/src/runner.test.ts b/packages/harness-kit/src/runner.test.ts index 0deae7e3..5acece8e 100644 --- a/packages/harness-kit/src/runner.test.ts +++ b/packages/harness-kit/src/runner.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -147,6 +147,62 @@ process.exit(7); } }); +test('useRunnableSelection does not spawn when called with an already-aborted signal', async () => { + const dir = mkdtempSync(join(tmpdir(), 'aw-runner-aborted-')); + try { + const marker = join(dir, 'spawned'); + const harness = writeHarness( + dir, + `#!/usr/bin/env node +require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'spawned'); +process.stdout.write('should-not-run'); +` + ); + const controller = new AbortController(); + controller.abort('cancel-before-start'); + const context = useRunnableSelection(fakeSelection(), { + commandOverrides: { codex: harness } + }); + const result = await context.sendMessage('task', { + workingDirectory: dir, + signal: controller.signal + }); + + assert.equal(result.status, 'cancelled'); + assert.equal(result.output, ''); + assert.match(result.stderr, /cancel-before-start/); + assert.equal(existsSync(marker), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('useRunnableSelection force-kills a child that ignores timeout SIGTERM', async () => { + const dir = mkdtempSync(join(tmpdir(), 'aw-runner-timeout-')); + try { + const harness = writeHarness( + dir, + `#!/usr/bin/env node +process.on('SIGTERM', () => {}); +setInterval(() => {}, 1000); +` + ); + const context = useRunnableSelection(fakeSelection(), { + commandOverrides: { codex: harness } + }); + const result = await context.sendMessage('task', { + workingDirectory: dir, + timeoutSeconds: 0.2 + }); + + assert.equal(result.status, 'timeout'); + assert.equal(result.exitCode, null); + assert.ok(result.durationMs >= 1_000, 'expected timeout to wait for the forced kill grace period'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('useRunnableSelection materializes and removes opencode config files around the run', async () => { const dir = mkdtempSync(join(tmpdir(), 'aw-runner-opencode-')); try { diff --git a/packages/harness-kit/src/runner.ts b/packages/harness-kit/src/runner.ts index bd986cc1..59f3b7c8 100644 --- a/packages/harness-kit/src/runner.ts +++ b/packages/harness-kit/src/runner.ts @@ -85,6 +85,8 @@ interface ConfigWrite { previous?: string; } +const FORCE_KILL_GRACE_MS = 1_000; + export function useRunnablePersona( intent: PersonaIntent, options: RunnablePersonaOptions = {} @@ -161,6 +163,15 @@ export function makeRunnablePersonaContext( }; const bin = options.commandOverrides?.[context.selection.runtime.harness] ?? spec.bin; const signal = anySignal([controller.signal, sendOptions.signal]); + if (signal?.aborted) { + return { + status: 'cancelled', + output: '', + stderr: warningText + abortReason(signal, cancelReason), + exitCode: null, + durationMs: Date.now() - startedAt + }; + } const configWrites = materializeConfigFiles(cwd, spec.configFiles); try { if (sendOptions.installSkills === true && context.install.commandString !== ':') { @@ -329,6 +340,9 @@ async function spawnCapture( if (!bin) { return { stdout: '', stderr: 'missing command\n', exitCode: 127, status: 'failed' }; } + if (options.signal?.aborted) { + return { stdout: '', stderr: abortReason(options.signal), exitCode: null, status: 'cancelled' }; + } return await new Promise((resolve) => { let stdout = ''; @@ -336,6 +350,7 @@ async function spawnCapture( let settled = false; let timedOut = false; let cancelled = false; + let forceKillTimeout: NodeJS.Timeout | undefined; const child = spawn(bin, [...args], { cwd: options.cwd, env: options.env, @@ -346,6 +361,9 @@ async function spawnCapture( ? setTimeout(() => { timedOut = true; child.kill('SIGTERM'); + forceKillTimeout = setTimeout(() => { + if (!settled) child.kill('SIGKILL'); + }, FORCE_KILL_GRACE_MS); }, options.timeoutSeconds * 1000) : undefined; const abort = () => { @@ -358,6 +376,7 @@ async function spawnCapture( if (settled) return; settled = true; if (timeout) clearTimeout(timeout); + if (forceKillTimeout) clearTimeout(forceKillTimeout); options.signal?.removeEventListener('abort', abort); resolve({ stdout, @@ -385,6 +404,14 @@ async function spawnCapture( }); } +function abortReason(signal: AbortSignal, fallback = 'cancelled'): string { + return signal.reason instanceof Error + ? signal.reason.message + : typeof signal.reason === 'string' + ? signal.reason + : fallback; +} + function anySignal(signals: Array): AbortSignal | undefined { const active = signals.filter((signal): signal is AbortSignal => signal !== undefined); if (active.length === 0) return undefined; @@ -392,10 +419,10 @@ function anySignal(signals: Array): AbortSignal | undef const controller = new AbortController(); for (const signal of active) { if (signal.aborted) { - controller.abort(); + controller.abort(signal.reason); break; } - signal.addEventListener('abort', () => controller.abort(), { once: true }); + signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true }); } return controller.signal; }