diff --git a/ops/NEXT.md b/ops/NEXT.md index 649c80cc6..666212c75 100644 --- a/ops/NEXT.md +++ b/ops/NEXT.md @@ -1,87 +1,86 @@ # NEXT — work package for this tick -**Scope:** Build a minimal agent worker in the SDK. CODE task, SDK-side. +**Target gate:** Gate 3 (Software Garden) -This run is pinned to **gate 3** and must not work on any other gate. +**Current state:** The agent worker prerequisite is COMPLETE. `sdk/src/worker.ts` exists, is exported from `sdk/src/index.ts`, and all tests pass (197/197 SDK, 77/77 kernel). The test "runs an agent CLI end to end through the SDK worker" at `sdk/tests/live-kernel.test.ts:206-239` passes, proving the worker can execute agent steps via their declared CLI. ## Objective -Promote the throwaway worker the tests already build into a real SDK component -that can execute agent steps by running their declared CLI as a subprocess. +Build the Software Garden flow: issue → implementation → review → PR, with every claim/lease/retry served by the kernel instead of Factory's hand-rolled claim protocols. ## Context -Nothing in this repo can execute an agent step. Searching for `workerAttach` / -`step.complete` finds only TESTS (`sdk/tests/live-kernel.test.ts`, -`journal-client.test.ts`, `journal-client-loopback.ts`) and the protocol -definitions. `sdk/src/cli/run.ts` only OBSERVES worker leases and waits for one -that never arrives. +From RFC-0001 §3 Gate 3: -The kernel's dispatch, lease and claim machinery is real and tested. The worker -side of the protocol is simply unimplemented, and that is what blocks gate 2 -("a workload RUNS as a relayflow" — today a run can only be shown CREATED) and -gate 3 ("every claim/lease/retry served by the kernel"). +> **Done when:** a labeled issue flows to a reviewed PR end-to-end with every claim/lease/retry served by the kernel, the merge gate holding (no auto-merge without opt-in), and the run legible in the journal — while the customer-facing config surface mentions none of it. -`sdk/tests/live-kernel.test.ts` around the `live-manual-agent` case (line 288) -shows the whole shape: connect, `hello`, `workerAttach` with pins, receive -`step.dispatch`, act, complete. The protocol is already proven there. +The worker now enables agent steps to execute, which unblocks building the Garden flow as a relayflow. Factory's `FactoryLoop` (~16,900 lines) with ~10 hand-rolled claim protocols (`leaseUntilMs` ×71, `heartbeat` ×490) should be migrated one claim family at a time (charter phase 7). ## Files in scope -- `sdk/src/worker.ts` — new file, the worker implementation -- `sdk/src/index.ts` — export the worker -- `sdk/tests/live-kernel.test.ts` OR a new test file — add a test that runs a - real flow with an agent step end to end against a live `relayflowd`, with - this worker attached, and asserts the step reaches `done`. +TBD by next assess - this likely involves: +- A new `workflows/software-garden.yaml` or `.ts` flow definition +- Migration of Factory claim families to kernel leases +- Relayflow-based issue discovery → implementation → review → merge gate ## Definition of done -ALL of the following must hold: - -1. The worker in `sdk/src/worker.ts`, exported from `sdk/src/index.ts` - -2. A test that runs a real flow with an agent step end to end against a live - `relayflowd`, with this worker attached, and asserts the step reaches - `done`. `sdk/tests/live-kernel.test.ts` already starts a daemon — follow - that pattern. - -3. **The worker must attach BEFORE the run starts.** A run that finds no worker - parks, and attaching afterwards does not re-drive it — `run.resume` is what - picks a parked run back up. That contract is pinned in the live-kernel - suite; do not fight it. - -4. The worker must: - - attach for `agent` steps with the pins it holds - - on `step.dispatch`, run the step's declared `cli` as a subprocess - - report the result back through the existing protocol (`step.complete`, and - the failure path when the CLI exits nonzero) - - nothing speculative: no retries of its own, no scheduling, no LLM calls. - The kernel owns retry and lease policy — do not reimplement it. - -5. `cd sdk && npm test` must be green. Run it and paste the literal command and - output tail showing test counts. - -6. `cd kernel && sh ../ops/cargo.sh test` must be green. Run it and paste the - literal command and output tail showing test counts. - -7. EVERY new test confirmed to FAIL against current code, with the literal - failing output quoted in the summary. - -8. As your LAST action, run `git status --porcelain` and paste it. +1. A labeled GitHub issue flows to a reviewed PR end-to-end +2. Every claim/lease/retry is served by the kernel (no Factory-style hand-rolled protocols) +3. The merge gate holds (no auto-merge without opt-in) +4. The run is legible in the journal +5. The customer-facing config surface mentions none of the internal machinery +6. `cd sdk && npm test` green (currently 197 passed, 0 failed) +7. `cd kernel && sh ../ops/cargo.sh test` green (currently 77 passed, 0 failed) +8. Literal passing command output pasted in the work summary ## Explicitly OUT of scope -- LLM steps — not in the gate 3 scope -- Retry logic in the worker — the kernel owns retry policy -- Scheduling or lease management — the kernel owns lease policy -- Optimizations, abstractions, or speculative features -- Changes to the kernel -- Changes to existing tests (except adding new test cases) -- Work on any gate other than gate 3 - -## If blocked - -If gate 3 is genuinely unreachable from the current state, write -ops/NEEDS_HUMAN.md saying exactly why and still end with ASSESS_DONE. Do not -silently substitute different work: a run that reports progress on the wrong -gate is worse than one that reports it is blocked. +- Work on gates other than gate 3 +- Optimizations or speculative features +- Changes to the worker (it's complete) +- Changes to gate 1 machinery (tests.rs, server.rs, preflight) + +## Notes from this assessment (2026-08-30) + +The agent worker task from the previous ops/NEXT.md is COMPLETE: + +### What was done (already in the tree) +- `sdk/src/worker.ts` exists with full implementation (92 lines) +- Exported from `sdk/src/index.ts:115` +- Test coverage at `sdk/tests/live-kernel.test.ts:206-239` passes +- Worker attaches for `agent` steps, handles `step.dispatch`, runs CLI as subprocess, reports via `step.complete` + +### Test results + +SDK tests after fixing known sandbox exec-bit issue: +``` +$ cd /project/workflows/runs/b1f0e90d-47d5-49b5-bc5a-489e1a26aab7 && chmod +x testdata/preflight/*cli && npm test + + Test Files 14 passed (14) + Tests 197 passed (197) + Start at 14:57:19 + Duration 43.65s (transform 200ms, setup 0ms, collect 472ms, tests 41.29s, environment 2ms, prepare 536ms) +``` + +Kernel tests: +``` +$ cd /project/workflows/runs/b1f0e90d-47d5-49b5-bc5a-489e1a26aab7/kernel && sh ../ops/cargo.sh test + +test result: ok. 19 passed; 0 failed +test result: ok. 19 passed; 0 failed +test result: ok. 1 passed; 0 failed +test result: ok. 1 passed; 0 failed +test result: ok. 26 passed; 0 failed +test result: ok. 5 passed; 0 failed +test result: ok. 6 passed; 0 failed +(77 total passed, 0 failed) +``` + +Git status (cannot run in cloud sandbox): +``` +$ git status --porcelain +fatal: not a git repository: /home/daytona/.project-git +``` + +The sandbox has no working git (known per STATE.md), only testdata/preflight/*cli files had exec bit added to fix test failures (also known per STATE.md §"Known environment faults"). diff --git a/sdk/src/compile.ts b/sdk/src/compile.ts index 97bea7767..0075e181e 100644 --- a/sdk/src/compile.ts +++ b/sdk/src/compile.ts @@ -27,6 +27,7 @@ import type { LlmStepSpec, StepSpec, StepType, + TriggerSpec, } from './spec.js'; import { SPEC_SCHEMA_VERSION } from './spec.js'; import { canonicalize, specHash } from './canonical.js'; @@ -149,7 +150,7 @@ export function toKernelSpec(flow: FlowSpec): KernelRunSpec { ...(flow.name !== undefined ? { name: flow.name } : {}), ...(flow.description !== undefined ? { description: flow.description } : {}), ...(flow.cli !== undefined ? { cli: flow.cli } : {}), - ...(flow.triggers?.length ? { triggers: flow.triggers } : {}), + ...(flow.triggers?.length ? { triggers: flow.triggers.map(toKernelTrigger) } : {}), steps: flow.steps.map(toKernelStep), ...(flow.budget !== undefined ? { @@ -177,7 +178,10 @@ export function kernelToAuthoring(value: unknown): unknown { const steps = requireKernelArray(root['steps'], 'spec.steps') .map((step, index) => kernelStepToAuthoring(step, `spec.steps[${index}]`)); return { - ...copyDefined(root, ['version', 'name', 'description', 'cli', 'triggers']), + ...copyDefined(root, ['version', 'name', 'description', 'cli']), + ...(root['triggers'] !== undefined + ? { triggers: requireKernelArray(root['triggers'], 'spec.triggers').map(kernelTriggerToAuthoring) } + : {}), steps, ...(root['budget'] !== undefined ? { budget: kernelBudgetToAuthoring(root['budget'], 'spec.budget') } @@ -185,6 +189,35 @@ export function kernelToAuthoring(value: unknown): unknown { }; } +function toKernelTrigger(trigger: TriggerSpec) { + return { + id: trigger.id, + executor: trigger.executor, + ...(trigger.eventType !== undefined ? { event_type: trigger.eventType } : {}), + ...(trigger.pattern !== undefined ? { pattern: trigger.pattern } : {}), + ...(trigger.dedupeKeyTemplate !== undefined + ? { dedupe_key_template: trigger.dedupeKeyTemplate } + : {}), + }; +} + +function kernelTriggerToAuthoring(value: unknown): unknown { + const trigger = requireKernelObject( + value, + ['id', 'executor', 'event_type', 'pattern', 'dedupe_key_template'], + 'spec.trigger', + ); + return { + id: trigger['id'], + executor: trigger['executor'], + ...(trigger['event_type'] !== undefined ? { eventType: trigger['event_type'] } : {}), + ...(trigger['pattern'] !== undefined ? { pattern: trigger['pattern'] } : {}), + ...(trigger['dedupe_key_template'] !== undefined + ? { dedupeKeyTemplate: trigger['dedupe_key_template'] } + : {}), + }; +} + function kernelStepToAuthoring(value: unknown, at: string): unknown { const unionKeys = [ 'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 59875f542..82dbf0e8b 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -114,6 +114,11 @@ export { JOURNAL_WRITE_FAILED, PROTOCOL_VERSION } from './protocol.js'; export { JournalClient, type JournalClientOptions } from './journal-client.js'; export { AgentWorker, type AgentWorkerOptions } from './worker.js'; +export { + softwareGarden, + type SoftwareGardenConfig, +} from './software-garden.js'; + export { validateWorkPackage, packageFromEntry, diff --git a/sdk/src/software-garden.ts b/sdk/src/software-garden.ts new file mode 100644 index 000000000..5b90fc98f --- /dev/null +++ b/sdk/src/software-garden.ts @@ -0,0 +1,91 @@ +import type { AgentStepSpec, FlowSpec, TriggerSpec } from './spec.js'; +import { compileSpec } from './compile.js'; + +/** Customer-facing Software Garden configuration. */ +export interface SoftwareGardenConfig { + name: string; + repository: string; + issueLabel: string; + implementer: string; + reviewer: string; + autoMerge?: boolean; +} + +/** + * Compile the Garden presentation layer to an ordinary Relayflow DAG. + * Scheduling, claims, leases, retries, and deduplication stay kernel-owned. + */ +export function softwareGarden(config: SoftwareGardenConfig): FlowSpec { + validateConfig(config); + + const trigger: TriggerSpec = { + id: 'labeled-issue', + executor: 'agent-worker', + eventType: 'github.issue.labeled', + pattern: { repository: config.repository, label: config.issueLabel }, + dedupeKeyTemplate: '{{payload.repository}}:{{payload.issue.number}}', + }; + const steps: AgentStepSpec[] = [ + agentStep( + 'discover-issue', + config.implementer, + `Read the triggering ${config.repository} issue labeled ${config.issueLabel} and produce an implementation brief.`, + ), + agentStep( + 'implement-and-open-pr', + config.implementer, + `Implement the discovered issue in ${config.repository}, run its checks, and open a pull request.`, + ['discover-issue'], + ), + { + ...agentStep( + 'review-pr', + config.reviewer, + 'Review the opened pull request. Finish with the exact word APPROVED only when the change is ready.', + ['implement-and-open-pr'], + ), + verification: { type: 'output_contains', value: 'APPROVED' }, + maxIterations: 3, + }, + ]; + if (config.autoMerge === true) { + steps.push(agentStep( + 'merge-pr', + config.implementer, + 'Merge the approved pull request and close the linked issue.', + ['review-pr'], + )); + } + + return compileSpec({ + version: '0.1.0', + name: config.name, + description: `Software Garden for ${config.repository}`, + triggers: [trigger], + steps, + }); +} + +function agentStep( + id: string, + cli: string, + instruction: string, + dependsOn?: string[], +): AgentStepSpec { + return { + id, + type: 'agent', + cli, + instruction, + ...(dependsOn === undefined ? {} : { dependsOn }), + recoveryMode: 'reset', + }; +} + +function validateConfig(config: SoftwareGardenConfig): void { + for (const key of ['name', 'repository', 'issueLabel', 'implementer', 'reviewer'] as const) { + if (typeof config[key] !== 'string' || config[key].trim() === '') { + throw new TypeError(`Software Garden ${key} must be a non-empty string`); + } + } +} diff --git a/sdk/src/spec.ts b/sdk/src/spec.ts index fb4d9e082..ae8822b86 100644 --- a/sdk/src/spec.ts +++ b/sdk/src/spec.ts @@ -150,6 +150,10 @@ export interface TriggerSpec { id: string; /** Executor registration required before this trigger may start a run. */ executor: string; + /** Optional event subscription fields (gate 2). */ + eventType?: string; + pattern?: Record; + dedupeKeyTemplate?: string; } /** @@ -254,6 +258,9 @@ export interface KernelBudgetSpec { export interface KernelTriggerSpec { id: string; executor: string; + event_type?: string; + pattern?: Record; + dedupe_key_template?: string; } /** The compiled spec as the kernel parses, journals, and hashes it. */ diff --git a/sdk/tests/live-kernel.test.ts b/sdk/tests/live-kernel.test.ts index 99b2bfd1e..d0cae624e 100644 --- a/sdk/tests/live-kernel.test.ts +++ b/sdk/tests/live-kernel.test.ts @@ -19,6 +19,7 @@ import { compileYaml, toKernelSpec } from '../src/compile.js'; import { JournalClient } from '../src/journal-client.js'; import type { StepDispatchEvent } from '../src/protocol.js'; import { AgentWorker } from '../src/worker.js'; +import { softwareGarden } from '../src/software-garden.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const SDK = join(ROOT, 'sdk'); @@ -238,6 +239,40 @@ steps: worker.close(); }); + it('runs a labeled issue through a reviewed PR and journals every Garden stage', async () => { + const directory = temporaryDirectory('flows-live-garden-'); + const dataDir = join(directory, 'data'); + const cli = join(directory, 'garden-agent'); + writeFileSync(cli, '#!/bin/sh\nprintf \'APPROVED: %s\' "$1"\n'); + chmodSync(cli, 0o755); + await startDaemon(dataDir); + + const client = await connectClient(dataDir); + await client.hello('live-software-garden'); + const worker = new AgentWorker(client, { + workerId: 'live-software-garden', + pins: { workspace: [{ surface: 'repo', revision_id: 'rev-a' }], streams: [] }, + }); + await worker.attach(); + + const started = await client.runStart(toKernelSpec(softwareGarden({ + name: 'gate-3-garden', + repository: 'relayflows/relayflows', + issueLabel: 'garden-ready', + implementer: cli, + reviewer: cli, + }))); + const completed = await waitForStep(client, started.run_id, 'review-pr', 'done'); + expect(completed).toMatchObject({ type: 'agent', state: 'done' }); + expect((await client.runGet(started.run_id)).steps).not.toHaveProperty('merge-pr'); + + const entries = (await client.journalRead(started.run_id, 1)).entries; + for (const stepId of ['discover-issue', 'implement-and-open-pr', 'review-pr']) { + expect(successfulCompletions(entries)[stepId]).toBe(1); + } + worker.close(); + }); + it('can always get a parked run to a late-attaching worker', async () => { // The contract that cost the most time to establish, so it is pinned here. // diff --git a/sdk/tests/software-garden.test.ts b/sdk/tests/software-garden.test.ts new file mode 100644 index 000000000..7226beee4 --- /dev/null +++ b/sdk/tests/software-garden.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { softwareGarden, toKernelSpec } from '../src/index.js'; + +const base = { + name: 'relayflows-garden', + repository: 'relayflows/relayflows', + issueLabel: 'garden-ready', + implementer: 'codex', + reviewer: 'claude', +}; + +describe('Software Garden', () => { + it('compiles labeled issue through reviewed PR without exposing kernel machinery', () => { + expect(Object.keys(base)).toEqual([ + 'name', 'repository', 'issueLabel', 'implementer', 'reviewer', + ]); + const flow = softwareGarden(base); + + expect(flow.triggers).toEqual([expect.objectContaining({ + eventType: 'github.issue.labeled', + pattern: { repository: base.repository, label: base.issueLabel }, + })]); + expect(flow.steps.map(({ id }) => id)).toEqual([ + 'discover-issue', 'implement-and-open-pr', 'review-pr', + ]); + expect(flow.steps[2]).toMatchObject({ + dependsOn: ['implement-and-open-pr'], + maxIterations: 3, + verification: { type: 'output_contains', value: 'APPROVED' }, + }); + expect(toKernelSpec(flow).steps.every((step) => step.retry !== undefined)).toBe(true); + }); + + it('adds merge only with explicit opt-in', () => { + expect(softwareGarden(base).steps.some(({ id }) => id === 'merge-pr')).toBe(false); + expect(softwareGarden({ ...base, autoMerge: false }).steps.some(({ id }) => id === 'merge-pr')).toBe(false); + expect(softwareGarden({ ...base, autoMerge: true }).steps.at(-1)).toMatchObject({ + id: 'merge-pr', + dependsOn: ['review-pr'], + }); + }); + + it('fails closed on incomplete customer configuration', () => { + expect(() => softwareGarden({ ...base, reviewer: '' })).toThrow( + 'Software Garden reviewer must be a non-empty string', + ); + }); +});