Skip to content
Merged
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
69 changes: 28 additions & 41 deletions orchestrator-v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ A rebuild of the Bifrost orchestrator as a thin **get-work + dispatch** system.

## Packages

| Package | Purpose |
| -------------------------------- | ----------------------------------------------------------------- |
| `@bifrost-ai/interfaces-work` | Work item, handler, and execution contracts |
| `@bifrost-ai/protocol` | Signed WebSocket RPC between orchestrator and runners |
| `@bifrost-ai/orchestrator` | Thin orchestrator: stream work items, dispatch, record outcomes |
| `@bifrost-ai/runner` | Remote runner: script stack execution, conventions, decorators |
| `@bifrost-ai/engine` | Engine interface, types, and `TestEngine` for development/testing |
| `@bifrost-ai/engine-claude-code` | Claude Code Agent SDK engine (`ClaudeCodeEngine`) |
| `@bifrost-ai/engine-cursor` | Cursor SDK engine (`CursorEngine`) |
| `@bifrost-ai/agent-3-task` | Task Agent — single-shot LLM execution (registered as scripts) |
| Package | Purpose |
| -------------------------------- | ------------------------------------------------------------------------- |
| `@bifrost-ai/interfaces-work` | Work item, handler, and execution contracts |
| `@bifrost-ai/protocol` | Signed WebSocket RPC between orchestrator and runners |
| `@bifrost-ai/orchestrator` | Thin orchestrator: stream work items, dispatch, record outcomes |
| `@bifrost-ai/runner` | Remote runner: script stack execution, conventions, decorators |
| `@bifrost-ai/engine` | Engine interface, types, and `TestEngine` for development/testing |
| `@bifrost-ai/engine-claude-code` | Claude Code Agent SDK engine (`ClaudeCodeEngine`) |
| `@bifrost-ai/engine-cursor` | Cursor SDK engine (`CursorEngine`) |
| `@bifrost-ai/agent-3-task` | Task Agent — single-shot LLM execution (registered as scripts) |
| `@bifrost-ai/agent-4-workflow` | Workflow Agent — DAG scheduling with dependencies (registered as scripts) |

For design background and how each piece fits together, see [docs/](docs/).

Expand Down Expand Up @@ -104,7 +105,7 @@ import { Orchestrator, loadAuthorizedRunners } from "@bifrost-ai/orchestrator";
import { exportPublicKeyPem, generateKeyPair } from "@bifrost-ai/protocol";

const orchestratorIdentity = generateKeyPair("orchestrator");
const runnerIdentity = generateKeyPair("runner-1");
const runnerIdentity = generateKeyPair("runner");

const orchestrator = new Orchestrator();
orchestrator.registerWorkItemSource(workItemSource);
Expand All @@ -114,12 +115,6 @@ const handle = await orchestrator.start({
authorizedRunners: loadAuthorizedRunners([
{ keyId: runnerIdentity.keyId, publicKeyPem: exportPublicKeyPem(runnerIdentity.publicKey) },
]),
scheduler: {
async call(method, params) {
// workflow scheduling callbacks from runners
return { ok: true };
},
},
port: 9100,
});

Expand All @@ -144,7 +139,7 @@ const runner = new Runner({ data });
runner.registerEngine("claude", new ClaudeCodeEngine());
runner.registerEngine("cursor", new CursorEngine());
runner.registerTaskAgent("reviewer", await loadAgent("./agents/reviewer/AGENT.md"));
runner.registerScriptAgent("echo", echo);
runner.registerScript("echo", echo);

await runner.start();
```
Expand All @@ -170,33 +165,25 @@ identity:

See [docs/runner.md](docs/runner.md) for config discovery, trust model, and handler enrollment.

### RPC methods exposed by the orchestrator

Runners call back into the orchestrator over the same signed WebSocket:

| Method | Params | Description |
| ------------------------- | -------------------------- | ------------------------ |
| `workItem.complete` | `{ workItemId }` | Mark work item completed |
| `workItem.fail` | `{ workItemId, message? }` | Mark work item failed |
| `workItem.pause` | `{ workItemId }` | Mark work item paused |
| `workItemSource.setState` | `{ workItemId, state }` | Persist handler state |
| `scheduler.call` | `{ method, args }` | Invoke scheduler proxy |
## RPC surface

| Method | Params | Purpose |
| ------------------------------------ | -------------------------------------------- | --------------------------------- |
| `dispatch` | `WorkItem` | Execute work on runner |
| `workItem.complete` | `{ workItemId }` | Mark work item completed |
| `workItem.fail` | `{ workItemId, message }` | Mark work item failed |
| `workItem.pause` | `{ workItemId }` | Mark work item paused |
| `workItemSource.setState` | `{ workItemId, state }` | Persist handler state |
| `workItemSource.createDraftWorkItem` | `{ input }` | Create a draft child work item |
| `workItemSource.startWorkItem` | `{ workItemId }` | Promote a draft work item to live |
| `workItemSource.setDependency` | `{ workItemId, dependsOnWorkItemId, type? }` | Link work item execution order |
| `workItemSource.getDependencies` | `{ workItemId }` | List dependencies for a work item |
| `workItemSource.getWorkItemStatus` | `{ workItemId }` | Read current work item status |

The orchestrator dispatches work with `dispatch` RPC requests containing a full `WorkItem` object.

## Documentation

- [docs/](docs/) — how the system works (architecture, design decisions)
- [packages/protocol/README.md](packages/protocol/README.md) — protocol implementation details

## Related issues

This work tracks the Orchestrator v2 rebuild:
## Roadmap

- [#32 Script task execution primitive](https://github.com/devzeebo/bifrost/issues/32)
- [#33 Runner↔orchestrator protocol](https://github.com/devzeebo/bifrost/issues/33)
- [#35 Thin orchestrator](https://github.com/devzeebo/bifrost/issues/35)
- [#36 Runner package](https://github.com/devzeebo/bifrost/issues/36)
- [#37 Task Agent (`agent-3-task`)](https://github.com/devzeebo/bifrost/issues/37) — [lifecycle docs](docs/agent-3-task.md)
- [#39 Workflow Agent (`agent-4-workflow`)](https://github.com/devzeebo/bifrost/issues/39) — [lifecycle docs](docs/agent-4-workflow.md)
- [#41 Structured output package](https://github.com/devzeebo/bifrost/issues/41) — schemas, sentinel files, JSON/YAML validation
2 changes: 1 addition & 1 deletion orchestrator-v2/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,4 @@ The runner package consumes `protocol` and `interfaces-work` to execute work ite
| Runner package | Done |
| Bifrost work item source adapter | Planned ([#40](https://github.com/devzeebo/bifrost/issues/40)) |
| Task Agent (`agent-3-task`) | Done ([#37](https://github.com/devzeebo/bifrost/issues/37)) |
| Workflow Agent (`agent-4-workflow`) | Planned ([#39](https://github.com/devzeebo/bifrost/issues/39)) |
| Workflow Agent (`agent-4-workflow`) | Done ([#39](https://github.com/devzeebo/bifrost/issues/39)) |
4 changes: 2 additions & 2 deletions orchestrator-v2/docs/agent-4-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,9 @@ The orchestrator never inspects the dependency graph. It dispatches whatever the

### Permanent child failure (die-die)

> **TO BE RESOLVED**
When a child reaches a terminal **failed** state, the work item source treats that dependency edge as satisfied (same as **completed**). The workflow's own blockers clear, it is re-dispatched for the verify pass, and returns **failed** if any child failed.

When a child exhausts all retries and permanently fails, the workflow needs a dispatch so it can fail itself. The exact trigger — whether the child's terminal failure clears the workflow's blocker immediately, or whether the task source needs explicit logic to re-yield the workflow on child failure — is not yet decided.
Work item sources must implement this semantics: a `blocks` edge is cleared when the blocking work item reaches any terminal state (`completed` or `failed`).

## Related

Expand Down
24 changes: 13 additions & 11 deletions orchestrator-v2/docs/orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The `@bifrost-ai/orchestrator` package implements a **get-work + dispatch** loop
2. Accepts runner connections authenticated by pre-shared ed25519 keys.
3. Streams work items from `workItemSource.watchWorkItems()`.
4. Dispatches each work item to an available, heartbeating runner.
5. Routes runner RPC callbacks to the task source and scheduler.
5. Routes runner RPC callbacks to the work item source.
6. Drains in-flight work when the task stream ends.

### What the orchestrator does not do
Expand Down Expand Up @@ -56,7 +56,7 @@ sequenceDiagram
| `dispatcher` | Send `dispatch` RPC to a peer |
| `DispatchAckHandler` | Handle dispatch accept/reject responses |
| `ResultHandler` | Handle `workItem.complete` / `workItem.fail` / `workItem.pause` |
| `RpcRouter` | Route runner RPC to task source and scheduler |
| `RpcRouter` | Route runner RPC to work item source |
| `config` | Load authorized runner public keys from PEM entries |

### Runner availability
Expand Down Expand Up @@ -91,7 +91,6 @@ orchestrator.addWorkItemMapper("task", (workItem) => workItem);
type OrchestratorStartOptions = {
identity: PeerIdentity;
authorizedRunners: ReadonlyMap<string, KeyObject>;
scheduler: Scheduler;
host?: string;
port?: number;
heartbeatTimeoutMs?: number; // default 30000
Expand All @@ -102,22 +101,25 @@ type OrchestratorStartOptions = {
const handle = await orchestrator.start({
identity: orchestratorIdentity,
authorizedRunners: loadAuthorizedRunners([{ keyId, publicKeyPem }]),
scheduler,
port: 9100,
});
```

Authorized runners are loaded via `loadAuthorizedRunners([{ keyId, publicKeyPem }])`. Adding a runner requires updating this list and restarting.

### Scheduler proxy
### Work item source RPC

Runners can call `scheduler.call(method, args)` through the orchestrator. This is a generic RPC proxy for workflow scheduling (retries, DAG advancement, etc.) without the orchestrator implementing scheduling logic itself. The `Scheduler` interface is:
Runners access work item source methods through the orchestrator via typed RPC routes (same transport as `workItemSource.setState`):

```typescript
type Scheduler = {
call(method: string, params: unknown): Promise<unknown>;
};
```
| RPC method | Work item source call |
| ------------------------------------ | ------------------------------------------------------- |
| `workItemSource.createDraftWorkItem` | `createDraftWorkItem(input)` |
| `workItemSource.startWorkItem` | `startWorkItem(workItemId)` |
| `workItemSource.setDependency` | `setDependency(workItemId, dependsOnWorkItemId, type?)` |
| `workItemSource.getDependencies` | `getDependencies(workItemId)` |
| `workItemSource.getWorkItemStatus` | `getWorkItemStatus(workItemId)` |

Handlers receive these via `ctx.source` on `WorkItemExecutionContext`.

## Alternatives rejected

Expand Down
18 changes: 11 additions & 7 deletions orchestrator-v2/docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,17 @@ The protocol package defines the transport. The orchestrator package defines the

**Runner → Orchestrator:**

| Method | Params | Notes |
| ------------------------- | -------------------------- | --------------------------- |
| `workItem.complete` | `{ workItemId }` | |
| `workItem.fail` | `{ workItemId, message? }` | |
| `workItem.pause` | `{ workItemId }` | |
| `workItemSource.setState` | `{ workItemId, state }` | Proxied to work item source |
| `scheduler.call` | `{ method, args }` | Proxied to scheduler |
| Method | Params | Notes |
| ------------------------------------ | -------------------------------------------- | --------------------------- |
| `workItem.complete` | `{ workItemId }` | |
| `workItem.fail` | `{ workItemId, message? }` | |
| `workItem.pause` | `{ workItemId }` | |
| `workItemSource.setState` | `{ workItemId, state }` | Proxied to work item source |
| `workItemSource.createDraftWorkItem` | `{ input }` | Proxied to work item source |
| `workItemSource.startWorkItem` | `{ workItemId }` | Proxied to work item source |
| `workItemSource.setDependency` | `{ workItemId, dependsOnWorkItemId, type? }` | Proxied to work item source |
| `workItemSource.getDependencies` | `{ workItemId }` | Proxied to work item source |
| `workItemSource.getWorkItemStatus` | `{ workItemId }` | Proxied to work item source |

## Alternatives rejected

Expand Down
4 changes: 2 additions & 2 deletions orchestrator-v2/docs/runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const runner = new Runner({ data });

runner.registerEngine("claude", claudeEngine);
runner.registerTaskAgent("reviewer", await loadAgent("./agents/reviewer/AGENT.md"));
runner.registerScriptAgent("doSomething", doSomething);
runner.registerScript("doSomething", doSomething);

await runner.start();
```
Expand Down Expand Up @@ -125,7 +125,7 @@ sequenceDiagram
| `ctx.handlers` | Other registered handlers — `ctx.handlers.get(kind, name)` |
| `ctx.setState(state)` | RPC `workItemSource.setState` to orchestrator |

Task source and scheduler are reached over RPC. The engine (when used by agent packages) runs locally on the runner — never proxied.
Work item source methods are reached over RPC via `ctx.workItemSource`. The engine (when used by agent packages) runs locally on the runner — never proxied.

## Alternatives rejected

Expand Down
1 change: 0 additions & 1 deletion orchestrator-v2/examples/lvl3/doSomething.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@ import type { ScriptFn } from "@bifrost-ai/runner";

export const doSomething: ScriptFn = async (workItem, ctx) => {
console.log(`the cwd is ${ctx.cwd} for task ${JSON.stringify(workItem.state)}`);
return { outcome: "completed" };
};
1 change: 1 addition & 0 deletions orchestrator-v2/examples/lvl3/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ orchestrator.addWorkItemMapper("task", (workItem) => {
return {
...workItem,
state: {
...workItem.state,
instructions: rune.description,
workingDir: workItem.state.workingDir as string,
engineName: workItem.state.engineName as string,
Expand Down
15 changes: 15 additions & 0 deletions orchestrator-v2/examples/lvl4/agents/cowsay-flow/decorators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { DecoratorFn } from "@bifrost-ai/interfaces-work";

export const LOG_STEP_DECORATOR = "logStep";

export const logStep: DecoratorFn = async (workItem, _ctx, next) => {
console.log(`[${workItem.name}] starting`);
const result = await next();
console.log(`[${workItem.name}] finished`);
return result;
};

export const logPrepare: DecoratorFn = async (workItem, _ctx, next) => {
console.log(`prepare child ${workItem.workItemId}`);
return next();
};
2 changes: 2 additions & 0 deletions orchestrator-v2/examples/lvl4/agents/cowsay-flow/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { COWSAY_FLOW, createCowsayFlow } from "./workflow.js";
export { LOG_STEP_DECORATOR, logPrepare, logStep } from "./decorators.js";
7 changes: 7 additions & 0 deletions orchestrator-v2/examples/lvl4/agents/cowsay-flow/prepare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { continueStep } from "@bifrost-ai/agent-4-workflow";
import type { WorkflowScriptFn } from "@bifrost-ai/agent-4-workflow";

export const prepare: WorkflowScriptFn = async ({ cwd }) => {
console.log(`preparing workflow in ${cwd}`);
return continueStep("prepared");
};
7 changes: 7 additions & 0 deletions orchestrator-v2/examples/lvl4/agents/cowsay-flow/summarize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { continueStep } from "@bifrost-ai/agent-4-workflow";
import type { WorkflowScriptFn } from "@bifrost-ai/agent-4-workflow";

export const summarize: WorkflowScriptFn = async ({ workItem }) => {
console.log(`workflow ${workItem.workItemId} finished the cowsay step`);
return continueStep("summarized");
};
14 changes: 14 additions & 0 deletions orchestrator-v2/examples/lvl4/agents/cowsay-flow/workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { script, task, Workflow } from "@bifrost-ai/agent-4-workflow";

import { LOG_STEP_DECORATOR, logPrepare } from "./decorators.js";
import { prepare } from "./prepare.js";
import { summarize } from "./summarize.js";

export const COWSAY_FLOW = "cowsay-flow";

export function createCowsayFlow(): Workflow {
return new Workflow({ name: COWSAY_FLOW })
.step(script(prepare, "prepare"), [{ name: "logPrepare", fn: logPrepare }])
.step(task("cowsay"), [LOG_STEP_DECORATOR])
.step(script(summarize, "summarize"), [LOG_STEP_DECORATOR]);
}
22 changes: 22 additions & 0 deletions orchestrator-v2/examples/lvl4/agents/cowsay/AGENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
name: cowsay
description: A cow that says things
tools: []
template:
parameters:
phrase: string?
user_prompt: string
---

BDD Red only implements the tests. The cow says {{phrase}}

Implement the following user feature:

{{user_prompt}}

---

{
phrase: 'moo'
user_prompt: 'Implement a pomodoro timer. 25/5, with automated success tracking'
}
5 changes: 5 additions & 0 deletions orchestrator-v2/examples/lvl4/agents/cowsay/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const moduleDir = dirname(fileURLToPath(import.meta.url));
export const agentPath = join(moduleDir, "AGENT.md");
32 changes: 32 additions & 0 deletions orchestrator-v2/examples/lvl4/mappers/map-task-work-item.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { type TaskAgentState } from "@bifrost-ai/agent-3-task";
import type { WorkItemMapper } from "@bifrost-ai/orchestrator";
import { type RuneDetail } from "@bifrost-ai/work-item-source-bifrost";

function requireStringField(state: Record<string, unknown>, field: string): string {
const value = state[field];
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Task work item state is missing required field: ${field}`);
}
return value;
}

export const mapTaskWorkItem: WorkItemMapper<RuneDetail> = (workItem) => {
const rune = workItem.metadata;
const workingDir = requireStringField(workItem.state, "workingDir");
const engineName = requireStringField(workItem.state, "engineName");
const sessionId = workItem.state.sessionId;
if (sessionId !== undefined && typeof sessionId !== "string") {
throw new Error("Task work item state has invalid sessionId");
}

return {
...workItem,
state: {
...workItem.state,
instructions: rune.description,
workingDir,
engineName,
sessionId,
} satisfies TaskAgentState,
};
};
10 changes: 10 additions & 0 deletions orchestrator-v2/examples/lvl4/orchestrator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Orchestrator } from "@bifrost-ai/orchestrator";
import { BifrostWorkItemSource } from "@bifrost-ai/work-item-source-bifrost";

import { mapTaskWorkItem } from "./mappers/map-task-work-item.js";

export const orchestrator = new Orchestrator();

orchestrator.registerWorkItemSource(new BifrostWorkItemSource());

orchestrator.addWorkItemMapper("task", mapTaskWorkItem);
17 changes: 17 additions & 0 deletions orchestrator-v2/examples/lvl4/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@bifrost-ai/example-lvl4",
"private": true,
"type": "module",
"dependencies": {
"@bifrost-ai/agent-3-task": "workspace:*",
"@bifrost-ai/agent-4-workflow": "workspace:*",
"@bifrost-ai/engine-cursor": "workspace:*",
"@bifrost-ai/interfaces-work": "workspace:*",
"@bifrost-ai/orchestrator": "workspace:*",
"@bifrost-ai/runner": "workspace:*",
"@bifrost-ai/work-item-source-bifrost": "workspace:*"
},
"devDependencies": {
"typescript": "catalog:"
}
}
Loading