Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 80 additions & 61 deletions ops/NEXT.md
Original file line number Diff line number Diff line change
@@ -1,87 +1,106 @@
# NEXT — work package for this tick
# NEXT — Gate 3 work package for this tick

**Scope:** Build a minimal agent worker in the SDK. CODE task, SDK-side.
**Gate:** 3 (per ops/TARGET.md)

This run is pinned to **gate 3** and must not work on any other gate.
**Scope:** Build sub-PR A of the Gate 2 push: a real `hn-monitor` polling runner in the SDK. CODE task, `sdk/src/`-side. This is a scaffolding PR — proof that the workload EXECUTES end-to-end is deliberately deferred to sub-PR B (integration test). Do not conflate the two.

## 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 a continuous `hn-monitor` polling runner that composes existing pieces: the HN poller, the journal client, and the agent worker. The runner proves the pieces ASSEMBLE and its unit tests hold. Proof that the workload actually EXECUTES end-to-end (dispatch → step complete) is sub-PR B.

## 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.
RFC-0001 §3 gate 2 is done when "hn-monitor runs as a relayflow in production, triggered by its real events, with zero bespoke persistence." Every primitive already exists in this repo — event triggers (PR #14, `kernel/relayflowd/tests/event_wake.rs`), the flow spec (`testdata/hn-monitor.flow.yaml`), the poller (`sdk/src/hn-poller.ts`), the agent worker (`sdk/src/worker.ts` from PR #53), a one-shot demo (`sdk/src/demo-hn-monitor.ts`) — but nothing has ever run them together as a continuous workload. This PR fixes that.

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").
## Prior attempt: PR #83 (closed)

`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.
Produced a functional runner but was rejected by the swarm on five real findings. Address them in this attempt:

## Files in scope
1. **Fail-closed on journal errors.** #83's `catch (err) { onPollError(err) }` swallowed EVERY error including `eventSubmit` journal failures — violates covenant 2 (fail-closed) and RFC-0001 §1. Only fetch-level errors (network flakiness, HN API rate limits) may be swallowed; a journal write failure MUST throw and terminate the runner. Split: `try { fetch } catch { onFetchError }` around the network call, `try { eventSubmit } catch { rethrow }` around the journal call.

- `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`.
2. **AgentWorker.close() must release the worker (or explicitly document it does not).** #83 added `await worker.close()` to shutdown but the current `close()` only drains local promises — it does NOT tell the kernel to release the worker registration. Either:
- Add a `workerRelease` verb to `sdk/src/protocol.ts` and call it from `close()` (preferred — completes the shutdown contract), OR
- Add a one-line comment on `close()` naming exactly what shutdown intentionally does NOT do

## Definition of done
3. **Class field declaration order.** #83 declared `private readonly fetcher` AFTER the constructor. Works today because of ES2022 hoisting semantics but breaks silently if someone adds `= someDefault` to a declaration. Declare ALL fields at the top of the class body, before the constructor.

4. **Signal handlers must be opt-in via AbortSignal.** #83 registered `SIGTERM`/`SIGINT` handlers on the process directly with no opt-out. A library user embedding this can't cancel one runner without affecting others. Accept `signal?: AbortSignal` in options; the CLI wrapper (sub-PR C) can create + wire a process-signal-driven AbortController.

5. **Test coverage for pollError branch.** #83's tests never asserted the loop survives a fetcher throw AND the loop TERMINATES on a journal throw. Add both cases; without them, someone regresses `onPollError` to a no-op and every test still passes.

## Do not re-do these

Merged and closed; a PR redoing any will be closed:
- picker actionability (#42), unterminated backticks (#45)
- deterministic-command preflight refusal (#47) — do not touch preflight
- gate-1 race regression test (#48) — do not touch `kernel/relayflowd/src/server/tests.rs` or `server.rs`
- ops/NEXT.md validation (#50) — do not touch `sdk/src/work-package-validator.ts`
- SDK agent worker (#53) — `sdk/src/worker.ts` is done; you MAY modify `close()` per finding #2 above, but do NOT rewrite the attach/dispatch/complete flow

## The task

ALL of the following must hold:
Add `sdk/src/hn-monitor-runner.ts`. It composes the existing pieces into a continuous runner:

1. The worker in `sdk/src/worker.ts`, exported from `sdk/src/index.ts`
- constructs a `JournalClient` connected to the running `relayflowd` socket
- constructs an `AgentWorker` (from `sdk/src/worker.ts`) and calls `workerAttach()` for `agent` steps — attach BEFORE first poll (a run parked because no worker attached is only revived by `run.resume`; the live-kernel suite pins this)
- loops: `pollHackerNewsOnce(spec, sink)` → sleep `POLL_INTERVAL_MS` (env-configurable, default 60000 = 60s) → repeat
- exit cleanly on `AbortSignal.abort` (drain in-flight steps, close client, release worker per finding #2)
- 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.
Keep it small and honest:
- the worker must attach BEFORE the first poll
- the poller layer handles single-fetch failures with a typed error; the loop just moves to the next tick — but journal errors MUST fail the runner (finding #1)
- no scheduling logic beyond the sleep (the kernel owns retry and dedupe policy)
- no LLM calls; the runner is glue, not a reviewer

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.
## Explicit non-goals for THIS PR (belongs to later sub-PRs)

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.
- Proving the workload actually executes end-to-end (dispatch → step complete). That is sub-PR B (integration test with real relayflowd + fake HN fetch + assert step reaches `done`). This PR ONLY proves the runner assembles and its unit tests hold.
- CLI wrapper (`flows hn-monitor start`). That is sub-PR C.
- Ops/STATE.md gate-2 GREEN declaration. That is sub-PR D.

Say all three explicitly in the PR body so the history lens doesn't reject on "runner doesn't prove workload runs."

## Files in scope

- `sdk/src/hn-monitor-runner.ts` (new)
- `sdk/src/worker.ts` — either `close()` calls `workerRelease` (add to protocol.ts if missing), OR a one-line comment names what close() intentionally does NOT do
- `sdk/src/protocol.ts` — if you added `workerRelease`, matching request/response definitions
- `sdk/src/index.ts` — export `HnMonitorRunner`
- `sdk/tests/hn-monitor-runner.test.ts` (new)

## Definition of done

5. `cd sdk && npm test` must be green. Run it and paste the literal command and
output tail showing test counts.
ALL of the following must be verified with literal command output pasted:

6. `cd kernel && sh ../ops/cargo.sh test` must be green. Run it and paste the
literal command and output tail showing test counts.
- `sdk/src/hn-monitor-runner.ts` exists, exports `HnMonitorRunner` from `sdk/src/index.ts`
- `sdk/src/worker.ts` — either `close()` calls `workerRelease` (add to protocol.ts if missing), OR a one-line comment names what close() intentionally does NOT do
- `sdk/src/protocol.ts` — if you added `workerRelease`, matching request/response definitions
- `sdk/tests/hn-monitor-runner.test.ts` covers ALL of these:
- fake fetch + mock journal client → runner submits an event on each tick
- abort signal triggers clean shutdown within one tick (worker released or documented)
- worker attach happens before first poll
- **fetch throw → loop survives** (onPollError called, next tick still runs)
- **journal throw → loop TERMINATES** (runner.run() rejects with the error)
- `cd sdk && npm test` green (pretest hook builds the kernel automatically)
- EVERY new test confirmed to FAIL against current code (comment out the source; the test fails), with the literal failing output pasted in your summary
- PR body explicitly names the non-goals (test-actually-runs is sub-PR B; CLI is sub-PR C; gate-2 declaration is sub-PR D)
- as your LAST action, run `git status --porcelain` and paste it

7. EVERY new test confirmed to FAIL against current code, with the literal
failing output quoted in the summary.
## Out of scope for THIS tick — DO NOT TOUCH

8. As your LAST action, run `git status --porcelain` and paste it.
- `.github/workflows/*` — no GHA changes
- `kernel/*` — the kernel side of gate 2 already works via PR #14
- `workflows/*.yaml` — those are for later sub-PRs
- `ops/AUTODRIVE_BRIEF.md` — chief owns this file, not the drive loop
- CLI wrapper — sub-PR C, separate PR
- end-to-end integration test with real relayflowd — sub-PR B, separate PR
- ops/STATE.md gate-2 declaration — sub-PR D, separate PR

## Explicitly OUT of scope
## If you cannot finish

- 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
Say so and file what you learned. A minimal runner with an honest gap description beats a complete-looking one that doesn't shut down cleanly or leaks journal errors.

## If blocked
Several drive runs execute in parallel, each pinned to a different gate. Work outside this target collides with a sibling run, so staying inside it is not a preference — it is what makes parallel execution safe.

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.
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.
99 changes: 99 additions & 0 deletions sdk/src/hn-monitor-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { join, resolve } from 'node:path';
import { HnFetchError, pollHackerNewsOnce, type Fetcher } from './hn-poller.js';
import { JournalClient } from './journal-client.js';
import type { Pins } from './protocol.js';
import { AgentWorker } from './worker.js';

const DEFAULT_POLL_INTERVAL_MS = 60_000;

interface RunnerClient {
connect(): Promise<void>;
hello(name: string): Promise<unknown>;
eventSubmit(spec: unknown, event: { type: string; payload?: unknown; key?: string }): Promise<unknown>;
close(): void;
}

interface RunnerWorker {
attach(): Promise<void>;
close(): Promise<void> | void;
}

export interface HnMonitorRunnerOptions {
spec: unknown;
workerId: string;
pins: Pins;
socketPath?: string;
pollIntervalMs?: number;
fetcher?: Fetcher;
signal?: AbortSignal;
onPollError?: (error: HnFetchError) => void;
}

export interface HnMonitorRunnerDependencies {
client?: RunnerClient;
worker?: RunnerWorker;
}

/** Continuously polls Hacker News and submits events through the journal. */
export class HnMonitorRunner {
private readonly client: RunnerClient;
private readonly fetcher?: Fetcher;
private readonly onPollError: (error: HnFetchError) => void;
private readonly pollIntervalMs: number;
private readonly signal?: AbortSignal;
private readonly spec: unknown;
private readonly worker: RunnerWorker;

constructor(options: HnMonitorRunnerOptions, dependencies: HnMonitorRunnerDependencies = {}) {
const socketPath = options.socketPath ?? defaultSocketPath();
const client = dependencies.client ?? new JournalClient(socketPath);
this.client = client;
this.fetcher = options.fetcher;
this.onPollError = options.onPollError ?? (() => undefined);
this.pollIntervalMs = (
options.pollIntervalMs ?? Number.parseInt(process.env.POLL_INTERVAL_MS ?? '', 10)
) || DEFAULT_POLL_INTERVAL_MS;
this.signal = options.signal;
this.spec = options.spec;
this.worker = dependencies.worker
?? new AgentWorker(client as JournalClient, { workerId: options.workerId, pins: options.pins });
}

async run(): Promise<void> {
await this.client.connect();
try {
await this.client.hello('hn-monitor-runner');
await this.worker.attach();
while (!this.signal?.aborted) {
try {
await pollHackerNewsOnce(this.spec, this.client, { fetcher: this.fetcher });
} catch (error) {
if (!(error instanceof HnFetchError)) throw error;
this.onPollError(error);
}
await abortableDelay(this.pollIntervalMs, this.signal);
}
} finally {
await this.worker.close();
this.client.close();
}
}
}

function defaultSocketPath(): string {
const dataDir = resolve(process.env.RELAYFLOW_DATA_DIR ?? join(process.cwd(), '..', '.relayflowd'));
return join(dataDir, 'relayflowd.sock');
}

function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.resolve();
return new Promise((resolveDelay) => {
const timer = setTimeout(done, ms);
signal?.addEventListener('abort', done, { once: true });
function done(): void {
clearTimeout(timer);
signal?.removeEventListener('abort', done);
resolveDelay();
}
});
}
15 changes: 14 additions & 1 deletion sdk/src/hn-poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export interface EventSink {
/** Injected so parsing and submission stay deterministic in tests. */
export type Fetcher = (url: string) => Promise<string>;

/** A fetch-layer failure that a continuous poll loop may retry next tick. */
export class HnFetchError extends Error {
constructor(cause: unknown) {
super(`HN fetch failed: ${String(cause)}`, { cause });
this.name = 'HnFetchError';
}
}

const defaultFetcher: Fetcher = async (url) => {
const response = await fetch(url);
if (!response.ok) {
Expand Down Expand Up @@ -54,7 +62,12 @@ export async function pollHackerNewsOnce(
const storyLimit = options.storyLimit ?? DEFAULT_STORY_LIMIT;
const fetcher = options.fetcher ?? defaultFetcher;

const body = await fetcher(TOP_STORIES_URL);
let body: string;
try {
body = await fetcher(TOP_STORIES_URL);
} catch (cause) {
throw new HnFetchError(cause);
}

let storyIds: unknown;
try {
Expand Down
6 changes: 6 additions & 0 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,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 {
HnMonitorRunner,
type HnMonitorRunnerDependencies,
type HnMonitorRunnerOptions,
} from './hn-monitor-runner.js';

export {
validateWorkPackage,
Expand Down Expand Up @@ -140,6 +145,7 @@ export {
export {
pollHackerNewsOnce,
HN_TOP_STORIES_URL,
HnFetchError,
type EventSink,
type Fetcher,
type PollOptions,
Expand Down
11 changes: 9 additions & 2 deletions sdk/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface CliResult {
/** Executes dispatched agent steps using their declared CLI. */
export class AgentWorker extends EventEmitter {
private attached = false;
private readonly inFlight = new Set<Promise<void>>();

constructor(
private readonly client: JournalClient,
Expand All @@ -38,14 +39,20 @@ export class AgentWorker extends EventEmitter {
}
}

close(): void {
async close(): Promise<void> {
this.client.off('step.dispatch', this.onDispatch);
this.attached = false;
await Promise.allSettled(this.inFlight);
// This does not release the server registration; closing its client connection does.
}

private readonly onDispatch = (dispatch: StepDispatchEvent): void => {
if (dispatch.step_type !== 'agent') return;
void this.execute(dispatch).catch((error: unknown) => this.emit('error', error));
const execution = this.execute(dispatch);
this.inFlight.add(execution);
void execution
.catch((error: unknown) => this.emit('error', error))
.finally(() => this.inFlight.delete(execution));
};

private async execute(dispatch: StepDispatchEvent): Promise<void> {
Expand Down
Loading