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
124 changes: 58 additions & 66 deletions ops/NEXT.md
Original file line number Diff line number Diff line change
@@ -1,87 +1,79 @@
# NEXT — work package for this tick

**Scope:** Build a minimal agent worker in the SDK. CODE task, SDK-side.
**Gate:** Gate 2 (proactive agent workload — hn-monitor runs as a relayflow)

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).

## 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.
Add `sdk/src/hn-monitor-runner.ts` that composes existing pieces (JournalClient from `sdk/src/journal-client.ts`, AgentWorker from `sdk/src/worker.ts`, pollHackerNewsOnce from `sdk/src/hn-poller.ts`) into a continuous runner for the hn-monitor workload.

## Context
This addresses five findings from closed PR #83:

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.
1. **Fail-closed on journal errors** — only fetch errors may be swallowed; journal write failures MUST throw
2. **AgentWorker.close() must release the worker** — either add workerRelease verb or document what close() does NOT do
3. **Class field declaration order** — all fields before constructor
4. **Signal handlers opt-in via AbortSignal** — no process-wide signal handlers
5. **Test coverage for pollError branch** — loop survives fetch throw AND terminates on journal throw

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").
## Context

`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.
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: event triggers (PR #14), 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), and a one-shot demo (`sdk/src/demo-hn-monitor.ts`). Nothing has run them together as a continuous workload. This PR fixes that.

## 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`.
- `sdk/src/hn-monitor-runner.ts` — NEW, the continuous runner
- `sdk/src/worker.ts` — MAY modify close() per finding #2 (add workerRelease or document)
- `sdk/src/protocol.ts` — IF workerRelease verb needs to be added
- `sdk/tests/hn-monitor-runner.test.ts` — NEW, test coverage
- `sdk/src/index.ts` — export HnMonitorRunner

## 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. `sdk/src/hn-monitor-runner.ts` exists and exports `HnMonitorRunner`
2. Exported from `sdk/src/index.ts`
3. Runner constructs JournalClient and AgentWorker
4. **Worker attach happens BEFORE first poll** (a parked run needs run.resume)
5. Loop: pollHackerNewsOnce → sleep POLL_INTERVAL_MS (env-configurable, default 60000) → repeat
6. **Fetch errors caught and handled** — loop continues to next tick
7. **Journal errors MUST throw and terminate the runner** (fail-closed)
8. Clean shutdown on AbortSignal.abort (drain in-flight, close client, release/document worker)
9. `sdk/src/worker.ts` — either close() calls workerRelease, OR one-line comment naming what close() does NOT do
10. `sdk/src/protocol.ts` — IF workerRelease added, include request/response definitions
11. `sdk/tests/hn-monitor-runner.test.ts` covers ALL of:
- fake fetch + mock journal → runner submits event on each tick
- abort signal triggers clean shutdown within one tick (worker released/documented)
- worker attach before first poll
- **fetch throw → loop survives** (onPollError called, next tick runs)
- **journal throw → loop TERMINATES** (runner.run() rejects)
12. EVERY new test confirmed to FAIL against current code — comment out source, capture failure output
13. PR body explicitly names non-goals: integration test (sub-PR B), CLI wrapper (sub-PR C), gate-2 declaration (sub-PR D)

Commands that MUST pass with literal output captured:

```bash
cd sdk && npm test
```

Exit 0, all tests passing.

```bash
git status --porcelain
```

Shows only intended modifications. Run this as LAST action.

## 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.
- Proving the workload actually executes end-to-end (sub-PR B: integration test with real relayflowd)
- CLI wrapper `flows hn-monitor start` (sub-PR C)
- ops/STATE.md gate-2 GREEN declaration (sub-PR D)
- `.github/workflows/*` — no GHA changes
- `kernel/*` — kernel side already works via PR #14
- `workflows/*.yaml` — for later sub-PRs
- `ops/AUTODRIVE_BRIEF.md` — chief owns this file
- Scheduling logic beyond the sleep — kernel owns retry/dedupe policy
- LLM calls — runner is glue, not a reviewer
- Rewriting worker.ts attach/dispatch/complete flow (PR #53 closed this)
105 changes: 105 additions & 0 deletions sdk/src/hn-monitor-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { JournalClient } from './journal-client.js';
import { pollHackerNewsOnce, type Fetcher } from './hn-poller.js';
import type { Pins } from './protocol.js';
import { AgentWorker } from './worker.js';

const DEFAULT_POLL_INTERVAL_MS = 60_000;

const defaultFetcher: Fetcher = async (url) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`HN fetch failed: HTTP ${response.status}`);
return response.text();
};

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

class FetchError {
constructor(readonly cause: unknown) {}
}

/** Continuously submits Hacker News events and serves their agent steps. */
export class HnMonitorRunner {
private readonly client: JournalClient;
private readonly worker: AgentWorker;
private readonly pollIntervalMs: number;

constructor(private readonly options: HnMonitorRunnerOptions) {
this.client = new JournalClient(options.socketPath);
this.worker = new AgentWorker(this.client, {
workerId: options.workerId,
pins: options.pins,
});
this.pollIntervalMs = options.pollIntervalMs ?? pollIntervalFromEnv();
}

async run(): Promise<void> {
try {
await this.client.connect();
await this.worker.attach();
while (!this.options.signal?.aborted) {
await this.pollOnce();
await delay(this.pollIntervalMs, this.options.signal);
}
} finally {
this.worker.close();
this.client.close();
}
}

private async pollOnce(): Promise<void> {
const fetcher = wrapFetcher(this.options.fetcher ?? defaultFetcher);
try {
await pollHackerNewsOnce(this.options.spec, this.client, {
fetcher,
storyLimit: this.options.storyLimit,
});
} catch (error) {
if (!(error instanceof FetchError)) throw error;
this.options.onPollError?.(error.cause);
}
}
}

function wrapFetcher(fetcher: Fetcher): Fetcher {
return async (url) => {
try {
return await fetcher(url);
} catch (error) {
throw new FetchError(error);
}
};
}

function pollIntervalFromEnv(): number {
const value = process.env.POLL_INTERVAL_MS;
if (value === undefined) return DEFAULT_POLL_INTERVAL_MS;
const interval = Number(value);
if (!Number.isFinite(interval) || interval < 0) {
throw new Error('POLL_INTERVAL_MS must be a non-negative number');
}
return interval;
}

function delay(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(done, ms);
signal?.addEventListener('abort', done, { once: true });

function done(): void {
clearTimeout(timer);
signal?.removeEventListener('abort', done);
resolve();
}
});
}
1 change: 1 addition & 0 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ 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 HnMonitorRunnerOptions } from './hn-monitor-runner.js';

export {
validateWorkPackage,
Expand Down
1 change: 1 addition & 0 deletions sdk/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export class AgentWorker extends EventEmitter {
}

close(): void {
// Protocol v0 has no worker release verb; close only detaches dispatch handling.
this.client.off('step.dispatch', this.onDispatch);
this.attached = false;
}
Expand Down
Loading