Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
531753c
workflows: paginate run history by finish time, cursor, bounded read
graycyrus Aug 20, 2026
6535aac
merge upstream/main to pick up the openhuman pin/Cargo.lock CI fix
graycyrus Aug 20, 2026
7afadec
feat: real local ACP engine + per-harness model override (#1245)
sanil-23 Aug 20, 2026
3ba465c
fix(harness): baseline teammate steps aside for a company-authored ti…
sanil-23 Aug 20, 2026
6eba1fb
test(harness): assert the roster prompt marks a baseline teammate (#1…
sanil-23 Aug 20, 2026
1412c39
fix: give codex a real model lever via session/set_config_option (#1245)
sanil-23 Aug 20, 2026
1bd4f21
fix(mcp): tell a Sign in button from a token field before the click (…
oxoxDev Aug 20, 2026
46c26b6
feat(console): offer a token field where sign-in cannot work (#1260)
oxoxDev Aug 20, 2026
2939ef8
Merge remote-tracking branch 'upstream/main' into fix/1012-run-histor…
graycyrus Aug 20, 2026
ab6bd1e
fix: send ledger status needsReason as camelCase on the wire
graycyrus Aug 20, 2026
993d2ec
Merge remote-tracking branch 'upstream/main' into fix/1266-needs-reas…
graycyrus Aug 20, 2026
4fa96d2
fix: update issue #1189 tests for the runs response envelope
graycyrus Aug 20, 2026
6927810
fix(mcp): mark StaticTokenRequired dead-code-safe under non-mcp builds
oxoxDev Aug 20, 2026
b0e9186
fix(harness): a desk must not stand in for a company teammate (#1196)
sanil-23 Aug 20, 2026
450e594
fix: workflow-list-columns e2e mock serves the runs envelope shape
graycyrus Aug 20, 2026
be1970e
Merge pull request #1269 from oxoxDev/fix/1260-mcp-oauth-dcr-hint
oxoxDev Aug 20, 2026
d099578
Merge pull request #1274 from graycyrus/fix/1266-needs-reason-case-mi…
oxoxDev Aug 20, 2026
34f5787
Merge pull request #1246 from sanil-23/fix/1196-baseline-tiebreak-pre…
sanil-23 Aug 20, 2026
32fb7cb
fix: auto-approve LocalAcpAgent permission requests, copied from buzz…
sanil-23 Aug 20, 2026
bed8ba6
Merge pull request #1272 from graycyrus/fix/1012-run-history-pagination
graycyrus Aug 20, 2026
539854f
Merge pull request #1247 from sanil-23/fix/1244-acp-default-harness-k…
sanil-23 Aug 20, 2026
ad37521
Merge remote-tracking branch 'upstream/main' into feat/1245-acp-local…
sanil-23 Aug 20, 2026
f81ea96
feat: expose ACP harness readiness as a Tauri command (#1245)
sanil-23 Aug 20, 2026
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
95 changes: 77 additions & 18 deletions docs/spec/runtime/harnesses.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,43 @@ kind = "acp"
[harness.acp]
transport = "local"
agent = "claude"
model = "claude-opus-4-5" # optional — see "Model", below
```

`[harness.inference]` and `[harness.acp]` attach to the **most recently
declared** `[[harness]]`. That is ordinary TOML array-of-tables sub-table
syntax, but it is easy to misread as a company-level section, so it is worth
reading twice.

### Model

`[harness.acp].model` is a hint forwarded to the agent's own model lever —
not a credential, so it does not join `[harness.inference]`'s prohibition on
`acp` harnesses (see [Validation](#validation)). Optional; a harness with none
runs whatever the agent's own config or CLI default resolves to.

`LocalAcpAgent` reaches that lever one of two ways, confirmed live against the
real adapters (issue #1245), not guessed — whichever this build knows for that
`agent`:

| `agent` | lever |
|---|---|
| `claude` | startup env var `ANTHROPIC_MODEL` |
| `goose` | startup env var `GOOSE_MODEL` |
| `codex` | no startup env var (`OPENAI_MODEL`, `CODEX_MODEL`, `MODEL` and `OPENAI_DEFAULT_MODEL` all tried, none had any effect) — instead, `session/set_config_option` right after `session/new`, using the `configOptions` entry `codex-acp` itself advertises with `category: "model"` |

The `set_config_option` fallback is not codex-specific in the code — it fires
for any agent whose startup env var this build does not know, whenever the
fresh `session/new` response advertises a `category: "model"` option matching
the requested value. It is per-session state, confirmed live: a second,
independent session on the same subprocess starts back at the adapter's
default, not the previously-set model.

`transport = "local"` only, for now: the `runner` wire protocol does not carry
`model`, so validation rejects it there rather than accepting and silently
dropping it — the same "my model setting does nothing" failure mode
[Validation](#validation) already guards against for `[harness.inference]`.

### Binding an agent

```toml
Expand Down Expand Up @@ -106,6 +136,8 @@ engine, which is never true.
- `[harness.inference]` on an `acp` kind, or `[harness.acp]` on a `built_in` one
- `transport = "local"` with no `agent`, or naming a `runner`; and the reverse
for `transport = "runner"`
- an empty `model`, or one set on `transport = "runner"` (see
[Model](#model))

A section on the wrong kind is an **error, not an ignored key**. This is the
same rule [agents.md](agents.md) applies to a bundle carrying both roster forms,
Expand All @@ -127,16 +159,26 @@ transport = "runner" # reach one that dialed in
runner = "stevens_laptop"
```

**A remote runner is a transport, not a third kind.**
`src/runner/dispatch.rs::RunnerDispatch` already implements the same `AcpAgent`
port the local subprocess does, so the only thing that differs is how bytes
reach the agent. Modelling it as a third kind would add a resolution path that
resolves to the same place.
**A remote runner is a transport, not a third kind.** `transport = "local"` and
`transport = "runner"` resolve to the same `AcpAgent` port
(`crate::ports::acp::AcpAgent`); only how bytes reach the agent differs.
Modelling the runner as a third kind would add a resolution path that resolves
to the same place.

The transports differ in where they live, which is why `AcpAgent` is a **port**
rather than an ACP client in the host crate: a subprocess over stdio belongs to
the desktop shell, a WebSocket to the runner lane. The same inversion the
storage ports use.
storage ports use — and, concretely, why the port itself lives at
`crate::ports::acp`, ungated, rather than under `crate::harness` (behind
`openhuman`): the desktop shell that supplies the `local` implementation does
not enable that feature. See that module's own docs for the full reasoning.

`local` has a real implementation as of issue #1245 — `LocalAcpAgent`
(`src-tauri/src/acp/local_agent.rs`), wired through `AppState::with_acp_agents`
and `desktop::register`. `runner` does not yet: `src/runner/dispatch.rs`
declares `RunnerDispatch`, but it does not implement `AcpAgent`, and nothing
wires it into `lanes::build`. A `runner`-transport harness resolves
`unavailable` on every build today, `local` included.

### Readiness

Expand Down Expand Up @@ -187,14 +229,19 @@ All three methods route. A method forwarding to a fixed engine would send

### A harness with no engine fails the turn

A harness can be declared, valid, and still have no engine. Today that is every
`acp` harness on a server build: the transports live in the desktop shell (a
stdio subprocess) and the runner lane (a socket), and neither is wired into the
server. Those turns fail, naming the harness and the fix.
A harness can be declared, valid, and still have no engine. That is every `acp`
harness on a server build (no transport is wired there at all), every
`runner`-transport harness on any build (its socket transport isn't wired
yet), and a `local`-transport harness on a desktop build that was not given an
`AcpAgentFactory` (`AppState::with_acp_agents` — every embedder but the
packaged desktop app). Those turns fail, naming the harness and the fix.

They MUST NOT fall back to another harness's engine. That is the worst outcome
available: the turn would succeed, on a model and a credential nobody chose, and
the only evidence would be a billing line.
the only evidence would be a billing line. This also covers the agent itself
failing to start (not installed, not signed in, or a spawn error) — that
surfaces as the same kind of failure, naming the harness and the reason, not a
silent fallback either.

---

Expand All @@ -203,8 +250,16 @@ the only evidence would be a billing line.
- **`[brain].mode`** (`hosted` | `sidecar`) is a separate axis. It selects the
cognition seam *within* the built-in harness.
- **Tools, policy, budgets, desks.** All company- or agent-scoped, and unchanged
by which engine runs the turn. An ACP agent is still subject to the company's
approval policy.
by which engine runs the turn — **except `local`'s own permission prompts**
(`session/request_permission`), which are not routed through
`ApprovalRequestQueue` at all. `LocalAcpAgent` auto-approves whatever its CLI
still asks about, by option `kind` rather than a configured id, mirroring
`buzz-agent`'s own answer to the same protocol gap
(`crates/buzz-acp/src/acp.rs::handle_permission_request`): the CLI's own
permission mode is the trust boundary, the same as it is for a developer
running that CLI interactively themselves. This is a deliberate choice, not
a placeholder — an ACP-run teammate is not gated by the company's approval
policy the way a `built_in`-run one is.
- **Which model an agent's `tier` means.** A tier names a workload and is
resolved against whatever provider its harness turns out to use, so an agent
keeps its tier when it moves between harnesses. See
Expand All @@ -216,12 +271,16 @@ the only evidence would be a billing line.

| concern | where |
|---|---|
| manifest types, kind/transport vocabularies | `src/company/types.rs` |
| manifest types, kind/transport/model vocabulary | `src/company/types.rs` |
| validation, `effective_harnesses`, `harness_for` | `src/company/manifest.rs` |
| per-agent dispatch | `src/harness/router.rs` |
| building the lanes at boot | `src/harness/lanes.rs` |
| building the lanes at boot, resolving `acp` engines | `src/harness/lanes.rs` |
| the built-in engine | `src/harness/built_in/` |
| the ACP `RunTurn` and its port | `src/harness/acp/run_turn.rs` |
| local transport: discovery, spawn, codec | `src-tauri/src/acp/` |
| runner transport | `src/runner/dispatch.rs` |
| the `AcpAgent`/`AcpAgentFactory` ports (ungated) | `src/ports/acp.rs` |
| the ACP `RunTurn` (folds a port `AcpTurn` into `TurnStep`) | `src/harness/acp/run_turn.rs` |
| wiring an `AcpAgentFactory` onto a host | `AppState::with_acp_agents` (`src/app/types.rs`), consumed by `desktop::register` |
| local transport: discovery, spawn, codec | `src-tauri/src/acp/` (`client.rs`, `discovery.rs`, `confine.rs`) |
| the `local` `AcpAgentFactory` implementation | `src-tauri/src/acp/local_agent.rs` (`LocalAcpAgent`/`LocalAcpAgentFactory`) |
| the desktop's own wiring | `src-tauri/src/embedded.rs` |
| runner transport (declared, not yet an engine) | `src/runner/dispatch.rs` |
| per-harness roster narrowing | `HarnessDeps::serves` |
32 changes: 25 additions & 7 deletions frontend/src/api/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,23 +814,41 @@ export function cancelWorkflowRun(
}

/**
* The company's finished workflow runs, **newest first** (issue #228).
* One page of {@link listWorkflowRuns} (issue #1012).
*
* `hasMore` says whether an older page exists behind `beforeSeq` — the run
* history drawer's "Load older" affordance is gated on it, so a truncated
* history never silently reads as the whole thing.
*/
export interface WorkflowRunsPage {
runs: WorkflowRunOutcome[];
hasMore: boolean;
}

/**
* The company's finished workflow runs, **newest first** (issue #228) — now
* genuinely true of the *displayed* `seq`/`atMillis`, not just the order two
* runs started in (issue #1012).
*
* `workflow` narrows to one graph's runs; `limit` caps the page (the host
* defaults to a short recent list and clamps a large ask). A host predating this
* route answers 404 — callers should treat that as "no history yet" rather than
* an error, since the console still works without it.
* defaults to a short recent list and clamps a large ask). `beforeSeq` pages
* further back: pass the `seq` of the oldest run already held to fetch the
* page before it (issue #1012) — `hasMore` on the returned page says whether
* one exists. A host predating this route answers 404 — callers should treat
* that as "no history yet" rather than an error, since the console still works
* without it.
*/
export function listWorkflowRuns(
client: OpenCompanyClient,
company: string | null,
options?: { workflow?: string; limit?: number },
): Promise<WorkflowRunOutcome[]> {
options?: { workflow?: string; limit?: number; beforeSeq?: number },
): Promise<WorkflowRunsPage> {
const params = new URLSearchParams();
if (options?.workflow) params.set("workflow", options.workflow);
if (options?.limit) params.set("limit", String(options.limit));
if (options?.beforeSeq) params.set("before_seq", String(options.beforeSeq));
const query = params.toString();
return client.get<WorkflowRunOutcome[]>(
return client.get<WorkflowRunsPage>(
`${client.scopeFor(company)}/workflows/runs${query ? `?${query}` : ""}`,
);
}
Expand Down
56 changes: 54 additions & 2 deletions frontend/src/views/WorkflowsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,15 @@ export function WorkflowsView({
// vanished when the drawer was dismissed and a scheduled run's never reached
// the operator at all.
const [runs, setRuns] = useState<WorkflowRunOutcome[]>([]);
// Issue #1012: whether an older page of `runs` exists behind the oldest
// `seq` currently held — gates the drawer's "Load older" affordance. Reset
// to `false` whenever the effect below replaces `runs` wholesale (a fresh
// newest-page fetch has not yet learned this), and updated by both that
// effect and `loadOlderRuns` from each fetch's own `hasMore`.
const [runsHasMore, setRunsHasMore] = useState(false);
// A "Load older" fetch in flight, so the drawer can disable the control and
// avoid a second click racing the first for the same older page.
const [loadingOlderRuns, setLoadingOlderRuns] = useState(false);
// Which workflow the rows in `runs` were fetched for.
//
// `graph` and `runs` are two independent requests off the same selection, so
Expand Down Expand Up @@ -988,18 +997,25 @@ export function WorkflowsView({
// whether the host serves this route.
if (!selectedId) {
setRuns([]);
setRunsHasMore(false);
setRunsFor(null);
return;
}
let live = true;
(async () => {
try {
const rows = await listWorkflowRuns(client, company, {
const { runs: rows, hasMore } = await listWorkflowRuns(client, company, {
workflow: selectedId,
limit: 50,
});
if (!live) return;
setRuns(rows);
// Issue #1012: this effect always replaces the page wholesale (a
// company switch, a run event, an explicit refresh) — any older runs
// a "Load older" click had appended are gone with it, so `hasMore`
// starts back over from this fresh newest page's own answer rather
// than carrying forward whatever the appended state last said.
setRunsHasMore(hasMore);
setRunsFor(selectedId);
setHistorySupported(true);
// Issue #371, the no-live-stream fallback. If the run we just POSTed is
Expand All @@ -1022,6 +1038,7 @@ export function WorkflowsView({
// Degrade quietly: an older host simply has no history to show.
console.debug("[WorkflowsView] run history unavailable", e);
setRuns([]);
setRunsHasMore(false);
// Still THIS workflow's answer — "the host has no history for it" — so
// the pair agrees and the copilot may proceed, told via `runsKnown`
// that nothing is known about runs rather than that there were none.
Expand All @@ -1039,6 +1056,36 @@ export function WorkflowsView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, company, selectedId, runsTick, runEventTick]);

// Issue #1012: "Load older", the run-history drawer's pagination affordance.
// APPENDS to `runs` rather than replacing it — unlike the effect above,
// which always starts over from the newest page. Paged off the `seq` of the
// oldest run currently held, matching the host's `?before_seq=` cursor
// semantics (issue #1012's ordering fix made `seq` the field every row's
// own display agrees with, which is what makes it a stable paging key).
const loadOlderRuns = useCallback(() => {
if (!selectedId || loadingOlderRuns) return;
const oldest = runs.at(-1)?.seq;
if (oldest === undefined) return;
setLoadingOlderRuns(true);
(async () => {
try {
const { runs: older, hasMore } = await listWorkflowRuns(client, company, {
workflow: selectedId,
limit: 50,
beforeSeq: oldest,
});
setRuns((prev) => [...prev, ...older]);
setRunsHasMore(hasMore);
} catch (e) {
// Same quiet degradation as the newest-page fetch — leave what is
// already shown in place rather than losing it to a failed page.
console.debug("[WorkflowsView] loading older run history failed", e);
} finally {
setLoadingOlderRuns(false);
}
})();
}, [client, company, selectedId, runs, loadingOlderRuns]);

// Issue #303: the run page the index's health readings are folded from.
//
// Fetched only while the index is on screen — every card reads from one
Expand All @@ -1060,7 +1107,9 @@ export function WorkflowsView({
let live = true;
(async () => {
try {
const rows = await listWorkflowRuns(client, company, { limit: 200 });
// `hasMore` is ignored here: the index only needs enough of the
// company-wide page to fold per-card health, not a pagination UI.
const { runs: rows } = await listWorkflowRuns(client, company, { limit: 200 });
if (!live) return;
setIndexRuns(rows);
setIndexRunsLoaded(true);
Expand Down Expand Up @@ -2697,6 +2746,9 @@ export function WorkflowsView({
onFixWithCopilot={handleFixWithCopilot}
fixingRunSeq={fixingRunSeq}
fixReason={fixReason}
hasMore={runsHasMore}
onLoadOlder={loadOlderRuns}
loadingOlder={loadingOlderRuns}
/>
) : null
}
Expand Down
Loading
Loading