From 531753c635dac7b34790467b17139d7a18bb71c4 Mon Sep 17 00:00:00 2001
From: cyrus
Date: Thu, 20 Aug 2026 16:30:03 +0530
Subject: [PATCH 01/14] workflows: paginate run history by finish time, cursor,
bounded read
GET .../workflows/runs now returns a WorkflowRunsPage envelope
({ runs, hasMore }) instead of a bare array, ordered newest-first by
the run's actual settle time (seq/atMillis), with beforeSeq-based
cursor pagination so a page can be fetched further back without an
unbounded read. RunHistoryPanel gains a "Load older" affordance gated
on hasMore.
Every existing call site and test that read the runs endpoint as a
bare array needed updating to the new envelope shape.
Closes #1012
---
frontend/src/api/workflows.ts | 32 +-
frontend/src/views/WorkflowsView.tsx | 56 +-
.../src/views/workflows/RunHistoryPanel.tsx | 30 +
.../e2e/workflow-selection-persists.spec.ts | 2 +-
.../unit/workflow-deeplink-reread.test.ts | 2 +-
.../test/unit/workflow-index-first.test.ts | 2 +-
.../test/unit/workflow-index-paused.test.ts | 2 +-
.../unit/workflow-live-node-state.test.ts | 2 +-
.../test/unit/workflow-run-failure.test.ts | 2 +-
frontend/test/unit/workflow-run-input.test.ts | 2 +-
.../test/unit/workflow-toolbar-layout.test.ts | 2 +-
src/server/ops/inference.rs | 2 +-
src/server/ops/workflows.rs | 823 +++++++++++-------
src/server/ops/write_test.rs | 2 +-
14 files changed, 636 insertions(+), 325 deletions(-)
diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts
index 96073f100..739ea3aa8 100644
--- a/frontend/src/api/workflows.ts
+++ b/frontend/src/api/workflows.ts
@@ -792,23 +792,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 {
+ options?: { workflow?: string; limit?: number; beforeSeq?: number },
+): Promise {
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(
+ return client.get(
`${client.scopeFor(company)}/workflows/runs${query ? `?${query}` : ""}`,
);
}
diff --git a/frontend/src/views/WorkflowsView.tsx b/frontend/src/views/WorkflowsView.tsx
index 2f154e905..ea2616e0b 100644
--- a/frontend/src/views/WorkflowsView.tsx
+++ b/frontend/src/views/WorkflowsView.tsx
@@ -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([]);
+ // 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
@@ -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
@@ -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.
@@ -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
@@ -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);
@@ -2697,6 +2746,9 @@ export function WorkflowsView({
onFixWithCopilot={handleFixWithCopilot}
fixingRunSeq={fixingRunSeq}
fixReason={fixReason}
+ hasMore={runsHasMore}
+ onLoadOlder={loadOlderRuns}
+ loadingOlder={loadingOlderRuns}
/>
) : null
}
diff --git a/frontend/src/views/workflows/RunHistoryPanel.tsx b/frontend/src/views/workflows/RunHistoryPanel.tsx
index e15f6b11d..d011b0d76 100644
--- a/frontend/src/views/workflows/RunHistoryPanel.tsx
+++ b/frontend/src/views/workflows/RunHistoryPanel.tsx
@@ -168,6 +168,9 @@ export function RunHistoryPanel({
onFixWithCopilot,
fixingRunSeq,
fixReason,
+ hasMore,
+ onLoadOlder,
+ loadingOlder,
}: {
runs: WorkflowRunOutcome[];
/**
@@ -192,6 +195,19 @@ export function RunHistoryPanel({
fixingRunSeq?: number | null;
/** A run the copilot judged un-fixable, shown inline under that run's row. */
fixReason?: { seq: number; reason: string } | null;
+ /**
+ * Whether an older page of `runs` exists behind the oldest `seq` currently
+ * held (issue #1012) — the silent-truncation half of that issue. Omitted
+ * (or `false`) hides "Load older" entirely, which is also how a host
+ * predating the pagination fields degrades: no crash, just no affordance.
+ */
+ hasMore?: boolean;
+ /** Fetch and append the next-older page. Absent hides "Load older" even if
+ * `hasMore` is true — a caller with nowhere to route the click should not
+ * offer it. */
+ onLoadOlder?: () => void;
+ /** An older-page fetch is in flight, so "Load older" shows as busy. */
+ loadingOlder?: boolean;
}) {
// Only one fix may be in flight at a time: `handleFixWithCopilot` sets a
// single `prefilledDraft`/`editOpen` slot, so a second Fix started on a
@@ -266,6 +282,20 @@ export function RunHistoryPanel({
fixReason={fixReason?.seq === run.seq ? fixReason.reason : null}
/>
))}
+ {/* Issue #1012: the honest half of the page cap — a truncated
+ history says so, with a way to see more, rather than silently
+ ending at `limit` and reading as the whole story. */}
+ {hasMore && onLoadOlder && (
+
+ )}
)}
diff --git a/frontend/test/e2e/workflow-selection-persists.spec.ts b/frontend/test/e2e/workflow-selection-persists.spec.ts
index 0e514df45..988c515a4 100644
--- a/frontend/test/e2e/workflow-selection-persists.spec.ts
+++ b/frontend/test/e2e/workflow-selection-persists.spec.ts
@@ -111,7 +111,7 @@ async function mockCompanySwitchApi(page: Page) {
if (path === `/api/v1/companies/${company}/workflows`) return json(workflows[company]);
if (path.endsWith("/workflows/tool-slugs")) return json({ slugs: [] });
if (path.endsWith("/workflows/wired-channels")) return json({ channels: [] });
- if (path.endsWith("/workflows/runs")) return json([]);
+ if (path.endsWith("/workflows/runs")) return json({ runs: [], hasMore: false });
const workflowId = path.match(/\/workflows\/([^/]+)$/)?.[1];
if (workflowId) {
diff --git a/frontend/test/unit/workflow-deeplink-reread.test.ts b/frontend/test/unit/workflow-deeplink-reread.test.ts
index 401197370..0a5a2c721 100644
--- a/frontend/test/unit/workflow-deeplink-reread.test.ts
+++ b/frontend/test/unit/workflow-deeplink-reread.test.ts
@@ -107,7 +107,7 @@ function makeClient(
if (path.endsWith("/workflows")) return listFor();
if (path.includes("/workflows/tool-slugs")) return { slugs: [], unwired: [] };
if (path.includes("/workflows/wired-channels")) return { channels: [] };
- if (path.includes("/workflows/runs")) return [];
+ if (path.includes("/workflows/runs")) return { runs: [], hasMore: false };
const m = path.match(/\/workflows\/([^/?]+)$/);
if (m) {
const id = decodeURIComponent(m[1]);
diff --git a/frontend/test/unit/workflow-index-first.test.ts b/frontend/test/unit/workflow-index-first.test.ts
index 077ce5f78..1dbccf7d0 100644
--- a/frontend/test/unit/workflow-index-first.test.ts
+++ b/frontend/test/unit/workflow-index-first.test.ts
@@ -95,7 +95,7 @@ function makeClient(rows: WorkflowSummary[] = ROWS) {
if (path.endsWith("/workflows")) return rows;
if (path.includes("/workflows/tool-slugs")) return { slugs: [], unwired: [] };
if (path.includes("/workflows/wired-channels")) return { channels: [] };
- if (path.includes("/workflows/runs")) return [];
+ if (path.includes("/workflows/runs")) return { runs: [], hasMore: false };
const m = path.match(/\/workflows\/([^/?]+)$/);
if (m) {
const id = decodeURIComponent(m[1]);
diff --git a/frontend/test/unit/workflow-index-paused.test.ts b/frontend/test/unit/workflow-index-paused.test.ts
index ebf649a30..de4202c25 100644
--- a/frontend/test/unit/workflow-index-paused.test.ts
+++ b/frontend/test/unit/workflow-index-paused.test.ts
@@ -85,7 +85,7 @@ function makeClient(rows: WorkflowSummary[] = ROWS, created?: WorkflowGraph) {
if (path.endsWith("/workflows")) return rows;
if (path.includes("/workflows/tool-slugs")) return { slugs: [], unwired: [] };
if (path.includes("/workflows/wired-channels")) return { channels: [] };
- if (path.includes("/workflows/runs")) return [];
+ if (path.includes("/workflows/runs")) return { runs: [], hasMore: false };
const m = path.match(/\/workflows\/([^/?]+)$/);
if (m) return graphFor(decodeURIComponent(m[1]));
return null;
diff --git a/frontend/test/unit/workflow-live-node-state.test.ts b/frontend/test/unit/workflow-live-node-state.test.ts
index 4995742a7..237cb2e77 100644
--- a/frontend/test/unit/workflow-live-node-state.test.ts
+++ b/frontend/test/unit/workflow-live-node-state.test.ts
@@ -200,7 +200,7 @@ function fakeClient(): OpenCompanyClient {
scopeFor: (company: string | null) => `/api/v1/${company ?? "company"}`,
get: async (path: string) => {
if (path.endsWith("/workflows")) return [{ id: GRAPH.id, name: GRAPH.name }];
- if (path.includes("/workflows/runs")) return [RUNNING_ROW];
+ if (path.includes("/workflows/runs")) return { runs: [RUNNING_ROW], hasMore: false };
return GRAPH;
},
post: async () => ({}),
diff --git a/frontend/test/unit/workflow-run-failure.test.ts b/frontend/test/unit/workflow-run-failure.test.ts
index 8b6d56e63..a0abee438 100644
--- a/frontend/test/unit/workflow-run-failure.test.ts
+++ b/frontend/test/unit/workflow-run-failure.test.ts
@@ -92,7 +92,7 @@ function fakeClient(post: () => Promise): OpenCompanyClient {
scopeFor: (company: string | null) => `/api/v1/${company ?? "company"}`,
get: async (path: string) => {
if (path.endsWith("/workflows")) return [{ id: GRAPH.id, name: GRAPH.name }];
- if (path.includes("/workflows/runs")) return [];
+ if (path.includes("/workflows/runs")) return { runs: [], hasMore: false };
return GRAPH;
},
post,
diff --git a/frontend/test/unit/workflow-run-input.test.ts b/frontend/test/unit/workflow-run-input.test.ts
index e18d574dd..8c6bf6ba2 100644
--- a/frontend/test/unit/workflow-run-input.test.ts
+++ b/frontend/test/unit/workflow-run-input.test.ts
@@ -94,7 +94,7 @@ function fakeClient(posts: Posted[]): OpenCompanyClient {
scopeFor: (company: string | null) => `/api/v1/${company ?? "company"}`,
get: async (path: string) => {
if (path.endsWith("/workflows")) return [{ id: GRAPH.id, name: GRAPH.name }];
- if (path.includes("/workflows/runs")) return [];
+ if (path.includes("/workflows/runs")) return { runs: [], hasMore: false };
return GRAPH;
},
post: async (path: string, body: unknown) => {
diff --git a/frontend/test/unit/workflow-toolbar-layout.test.ts b/frontend/test/unit/workflow-toolbar-layout.test.ts
index db30a056c..08630ccc0 100644
--- a/frontend/test/unit/workflow-toolbar-layout.test.ts
+++ b/frontend/test/unit/workflow-toolbar-layout.test.ts
@@ -101,7 +101,7 @@ function makeClient(rows: WorkflowSummary[] = ROWS) {
if (path.endsWith("/workflows")) return rows;
if (path.includes("/workflows/tool-slugs")) return { slugs: [], unwired: [] };
if (path.includes("/workflows/wired-channels")) return { channels: [] };
- if (path.includes("/workflows/runs")) return [];
+ if (path.includes("/workflows/runs")) return { runs: [], hasMore: false };
const m = path.match(/\/workflows\/([^/?]+)$/);
if (m) return graphFor(decodeURIComponent(m[1]));
return null;
diff --git a/src/server/ops/inference.rs b/src/server/ops/inference.rs
index c10bf594d..e95ad3ee8 100644
--- a/src/server/ops/inference.rs
+++ b/src/server/ops/inference.rs
@@ -1763,7 +1763,7 @@ base_url = "https://byo.example/v1"
// The dead end left nothing behind — run history is still empty.
let (status, runs, raw) = send(&state, "GET", "/api/v1/company/workflows/runs", None).await;
assert_eq!(status, StatusCode::OK, "{raw}");
- assert_eq!(runs.as_array().map(Vec::len), Some(0), "{runs}");
+ assert_eq!(runs["runs"].as_array().map(Vec::len), Some(0), "{runs}");
}
/// Issue #514 (review): a config that cannot be *read* is not evidence to
diff --git a/src/server/ops/workflows.rs b/src/server/ops/workflows.rs
index 991f042d1..f2ea18e5c 100644
--- a/src/server/ops/workflows.rs
+++ b/src/server/ops/workflows.rs
@@ -90,7 +90,7 @@ use crate::company::{
};
use crate::error::OpenCompanyError;
use crate::ports::types::{
- CompanyEvent, CompanyRecord, EventSeq, OverlayWorkflow, WorkflowNodeStatus,
+ CompanyEvent, CompanyRecord, EventSeq, OverlayWorkflow, StoredEvent, WorkflowNodeStatus,
};
use crate::ports::workflow_verdict::{RunVerdictFacts, WorkflowRunVerdict};
use crate::runtime::cron::{CivilTime, CronExpr};
@@ -2369,7 +2369,15 @@ async fn workflow_wired_channels(company: ScopedCompany) -> Json,
+ /// Opaque pagination cursor (issue #1012): only runs whose displayed
+ /// `seq` is strictly less than this are considered. Absent reads the
+ /// newest page. The console walks backward through history by passing the
+ /// `seq` of the oldest run it already holds — the same `before_seq` shape
+ /// [`chat_history`](crate::server::chat_history)'s `?before=` and
+ /// `TaskDetailQuery`'s `?discussionBefore=` already use for the same
+ /// problem.
+ before_seq: Option,
}
/// One finished run as the console's history panel renders it (camelCase).
@@ -2567,64 +2583,50 @@ impl From for WorkflowRunNode {
}
}
-/// `GET …/workflows/runs?workflow=&limit=` — the company's finished workflow
-/// runs, **newest first** (issue #228).
+/// Folds a chronologically-ordered slice of journal rows into per-run outcomes
+/// (issue #371's group-by-run fold). Extracted out of [`list_runs`] by issue
+/// #1012 so the caller can run it repeatedly over a growing, backward-paged
+/// buffer instead of once over an unbounded forward read — see the read loop
+/// there.
///
-/// This is the durable half of the issue: a manual run's delivery rows used to
-/// live only in the console drawer until it was dismissed, and a scheduled run's
-/// only on host stdout. Folding
-/// [`CompanyEvent::WorkflowRunFinished`](crate::ports::types::CompanyEvent) out
-/// of the journal makes both survive a console reload, which is the whole point.
+/// Issue #371 turned this from a filter into a **group-by-run fold**: a run
+/// now contributes up to N+2 rows (a start, one per node, a finish) instead of
+/// one, and they have to come back as a single history entry.
///
-/// The fold reads the company's whole event log
-/// (`read_from(0, MAX)`) — the same thing
-/// [`chat_history`](crate::server::chat_history) already does on every history
-/// GET. Following that precedent keeps this route from inventing an index the
-/// rest of the read plane doesn't have; if the journal scan ever becomes the
-/// bottleneck it should be fixed for both surfaces at once, not just here.
-async fn list_runs(
- company: ScopedCompany,
- Query(query): Query,
-) -> Result>, ApiError> {
- let limit = match query.limit {
- Some(0) | None => DEFAULT_RUN_LIMIT,
- Some(n) => n.min(MAX_RUN_LIMIT),
- };
-
- let stored = company
- .runtime
- .events()
- .read_from(company.id(), EventSeq::new(0), usize::MAX)
- .await
- .map_err(ApiError)?;
-
- // Issue #371 turned this from a filter into a **group-by-run fold**: a run
- // now contributes up to N+2 rows (a start, one per node, a finish) instead
- // of one, and they have to come back as a single history entry.
- //
- // The invariant that keeps it simple: the journal is append-only and
- // single-writer, so a run's rows are ordered `Started < Node… < Finished` —
- // the runner drains and joins its progress collector before returning, which
- // is what makes the last part true rather than a race. Rows of *different*
- // runs may interleave (two workflows can run at once), so the grouping is
- // keyed on run id rather than on adjacency.
- //
- // A pre-#371 finished row has no run id and no start, so it simply folds to
- // itself — one row in, one entry out, exactly as before.
+/// The invariant that keeps it simple: the journal is append-only and
+/// single-writer, so a run's rows are ordered `Started < Node… < Finished` —
+/// the runner drains and joins its progress collector before returning, which
+/// is what makes the last part true rather than a race. Rows of *different*
+/// runs may interleave (two workflows can run at once), so the grouping is
+/// keyed on run id rather than on adjacency. **`rows` must be chronologically
+/// ordered (ascending `seq`)** for this invariant to hold — a caller reading
+/// backward via [`EventLog::read_before`](crate::ports::EventLog::read_before)
+/// (which comes back newest-first) must reverse each page before it is folded
+/// in.
+///
+/// A pre-#371 finished row has no run id and no start, so it simply folds to
+/// itself — one row in, one entry out, exactly as before. The same shape
+/// results for a post-#371 row whose start fell outside `rows` — whether
+/// because the caller's window does not reach that far back yet, or because a
+/// retention pass pruned the `WorkflowRunStarted` row while keeping its
+/// `WorkflowRunFinished` (the two are independently prunable; see
+/// `CompanyEvent::retention_class`). Both are "legitimate" in the sense the
+/// original comment on this fold already drew: nothing here tells them apart,
+/// and nothing needs to — a caller paging backward for more history is exactly
+/// how the first case resolves itself into the second, or into a real match.
+///
+/// Returns the folded runs, in fold/push order (not sorted or truncated — the
+/// caller does that), and the highest `seq` seen among EVERY row, matched or
+/// not — the `read_through` high-water mark [`list_runs`]'s #1009 cross-check
+/// resumes reading from.
+fn fold_run_events(rows: Vec, wanted: Option<&str>) -> (Vec, u64) {
let mut runs: Vec = Vec::new();
let mut index: std::collections::HashMap = std::collections::HashMap::new();
- // The `?workflow=` filter is applied per event rather than after the `limit`
- // cut, so asking for one workflow returns that workflow's most recent N —
- // not "whichever of the last N happen to match".
- let wanted = query.workflow.as_deref();
let matches = |workflow_id: &str| wanted.is_none_or(|w| w == workflow_id);
- // The high-water mark of the snapshot above, kept over EVERY row rather than
- // only the matched ones. It is where the settle below resumes reading, which
- // is what tells a run that died apart from one that merely finished while
- // this request was folding.
+ // The high-water mark over EVERY row rather than only the matched ones.
let mut read_through = 0u64;
- for stored in stored {
+ for stored in rows {
let seq = stored.seq.value();
let at_millis = stored.at_millis;
read_through = read_through.max(seq);
@@ -2815,216 +2817,338 @@ async fn list_runs(
}
}
- // Issue #1009: cross-check the still-`running` rows against the live run set
- // and settle the ones nobody is running.
- //
- // The fold above marks a start with no finish `running: true`, which is only
- // ever settled by the boot sweep ([`sweep_interrupted_runs`]). Three ways a
- // finish never lands — a task that panicked, an append that failed, a host
- // that died — therefore all read as an eternal spinner *until the next host
- // restart*, with a Stop button that cannot help and a 2s console poll that
- // never stops. This closes the gap between restarts: any run the fold thinks
- // is in flight whose id is **absent** from the supervisor's live set has no
- // task behind it here and now, so it is journaled a synthetic finish (the
- // same `INTERRUPTED_BY_RESTART` the boot sweep uses) and flipped in the
- // in-memory row, so this very response is already self-consistent.
- //
- // Keyed strictly on `live()` membership. A run the current process is
- // genuinely running is registered there and is left untouched — the watchdog
- // (issue #1009, path A) is what guarantees a *panicking* run never reaches
- // this predicate, because it journals its own finish before its guard drops.
- //
- // The one accepted false positive: a run that survived a live
- // `rebuild_company` swap is registered on the *old* supervisor and so is
- // absent from the successor's `live()`, so this could settle a run that is
- // still walking its graph. Accepted because (i) the watchdog keeps panics out
- // of this path entirely, (ii) that run's real finish lands later in journal
- // order and wins the read's last-writer-wins display, and (iii) it is the
- // same class the boot sweep already accepts — which is why that sweep gates
- // on the handover being absent (see the runtime builder call site). It never
- // corrupts the journal: that run's second, truthful finish lands *after* the
- // synthetic one and so supersedes it.
- //
- // That last argument turns on ORDER, and it does not carry to a run which
- // settles inside this request — there the truthful finish lands first and
- // loses. See the window handled below; it is closed rather than accepted.
- let live_ids: HashSet = company
- .runtime
- .run_supervisor()
- .live()
- .into_iter()
- .map(|(run_id, _workflow_id)| run_id)
- .collect();
- let mut dead: Vec = Vec::new();
- for (index, entry) in runs.iter().enumerate() {
- if !entry.running {
- continue;
+ (runs, read_through)
+}
+
+/// `GET …/workflows/runs?workflow=&limit=&before_seq=` — the company's
+/// finished workflow runs, **newest first** (issue #228), a page at a time
+/// (issue #1012).
+///
+/// This is the durable half of the issue: a manual run's delivery rows used to
+/// live only in the console drawer until it was dismissed, and a scheduled run's
+/// only on host stdout. Folding
+/// [`CompanyEvent::WorkflowRunFinished`](crate::ports::types::CompanyEvent) out
+/// of the journal makes both survive a console reload, which is the whole point.
+///
+/// The fold walks the journal **backward**, in bounded
+/// [`RUN_EVENT_PAGE`]-sized pages via
+/// [`EventLog::read_before`](crate::ports::EventLog::read_before) — the same
+/// pattern [`history_for_desk`](crate::server::chat_history::history_for_desk)
+/// already uses for a desk transcript, which has the same
+/// unbounded-forever-growing-journal problem and answers it the same way.
+/// Before #1012 this read all of `read_from(0, MAX)` on every call (a company's
+/// *entire* event history, not just its workflow runs — the same call
+/// `chat_history` used to make too), which got slower as the journal grew and
+/// never stopped growing; the backward-paged walk instead reads only as much
+/// of the journal as it takes to answer this page's `limit`, plus one extra run
+/// to know whether there is more (`hasMore`).
+///
+/// A run is a *group* of events (`Started`, N × node events, `Finished`), not
+/// one — so a page of raw events is not a page of runs. See the loop below and
+/// [`fold_run_events`]'s doc for how a bracket split across a page boundary is
+/// handled.
+async fn list_runs(
+ company: ScopedCompany,
+ Query(query): Query,
+) -> Result, ApiError> {
+ let limit = match query.limit {
+ Some(0) | None => DEFAULT_RUN_LIMIT,
+ Some(n) => n.min(MAX_RUN_LIMIT),
+ };
+ // The `?workflow=` filter is applied per event rather than after the `limit`
+ // cut, so asking for one workflow returns that workflow's most recent N —
+ // not "whichever of the last N happen to match".
+ let wanted = query.workflow.as_deref();
+
+ // A run counts as ready to answer with once its `Started` row has been
+ // found (full data — `startedNodes`, `nodes`, `startedAtMillis` — is then
+ // known), or it has no run id at all (a pre-#371/gapped orphan finish,
+ // complete by definition — nothing more to wait for). A run whose
+ // `Finished`/node row has been seen but whose `Started` has not (yet) is
+ // still open: walking backward means its `Started` row, if it exists, is
+ // further back than what has been read so far.
+ let is_settled =
+ |run: &WorkflowRunOutcome| run.run_id.is_none() || run.started_at_millis.is_some();
+
+ let mut cursor = query.before_seq.map(EventSeq::new);
+ let mut buffer: Vec = Vec::new();
+ let mut runs: Vec = Vec::new();
+ let mut read_through = 0u64;
+ // Whether the walk reached the true beginning of the journal — the only
+ // condition under which an open (not-yet-settled) run can be trusted as
+ // permanently orphaned rather than merely not-yet-resolved. See the
+ // `retain` below. No placeholder initial value: every path out of the loop
+ // assigns it before breaking, so the compiler can already prove it is set
+ // by the time it is read after the loop. `mut` because the loop can
+ // reassign it once per page before the page that finally breaks out.
+ let mut exhausted;
+ loop {
+ let page = company
+ .runtime
+ .events()
+ .read_before(company.id(), cursor, RUN_EVENT_PAGE)
+ .await
+ .map_err(ApiError)?;
+ if page.is_empty() {
+ exhausted = true;
+ break;
}
- let Some(run_id) = entry.run_id.as_ref() else {
- continue;
- };
- if live_ids.contains(run_id) {
- continue;
+ // A page shorter than asked-for proves there is nothing older left to
+ // read, without waiting for one more round trip that would only
+ // confirm it empty.
+ exhausted = page.len() < RUN_EVENT_PAGE;
+ // `read_before` returns newest-first; its own last element is this
+ // page's oldest row, and the correct cursor to resume strictly before.
+ cursor = page.last().map(|event| event.seq);
+ let mut chrono_page = page;
+ chrono_page.reverse();
+ buffer.splice(0..0, chrono_page);
+
+ let (folded, through) = fold_run_events(buffer.clone(), wanted);
+ // `read_through`'s meaning — the high-water mark of a "now" snapshot,
+ // which the #1009 cross-check below resumes reading from — is fixed
+ // by the FIRST page: `fold_run_events` computes it as the buffer's max
+ // `seq`, and the buffer only grows *older* on every later page, so
+ // this value cannot change after the first assignment. Reassigning it
+ // unconditionally is simplest and gives the identical answer.
+ read_through = through;
+ let settled = folded.iter().filter(|run| is_settled(run)).count();
+ runs = folded;
+ if exhausted || settled > limit {
+ break;
}
- dead.push(index);
+ }
+ if !exhausted {
+ // Drop any run still open: walking further back might yet resolve it
+ // (find its `Started` row) or might not, and returning it now, in the
+ // fold's orphan placeholder shape, would risk showing a real run's
+ // history as gapped when the only reason it looks that way is that
+ // this page chose not to read far enough. Once the journal actually IS
+ // exhausted, every remaining open row is a genuine orphan — see
+ // `fold_run_events`'s doc — and is kept exactly as the fold shaped it.
+ runs.retain(is_settled);
}
- // ── The window between the snapshot and `live()` ────────────────────────
- //
- // `live()` is consulted AFTER the journal snapshot was taken, and a run can
- // settle in between: it appends its finish (too late for the snapshot) and
- // then drops its guard (in time to be missing from `live()`). Such a run is
- // indistinguishable, on the two facts above, from one that died — but it is
- // the opposite, and settling it is worse than the hang this repairs.
- //
- // The ordering is what makes it worse rather than merely wrong. The rebuild
- // false positive this block already accepts is self-correcting because the
- // run's real finish lands *after* the synthetic one, and the fold settles an
- // entry from the last finish it sees. Here the real finish lands *first*, so
- // the synthetic one wins for good: a successful run reads
- // `INTERRUPTED_BY_RESTART` permanently, and because the fold overwrites
- // `deliveries` from whichever finish settles last, the record of what it
- // sent is replaced by an empty list.
- //
- // So before writing anything, read the journal on from where the snapshot
- // stopped and drop any candidate whose finish turns up there. That is
- // exact rather than a heuristic: a run whose start was in the snapshot was
- // registered before it (`begin` precedes both the spawn and the runner's
- // `WorkflowRunStarted`), so a candidate missing from `live()` has already
- // been deregistered — and a deregistered run journaled its finish first, if
- // it was ever going to. Anything appended before that point is at a higher
- // sequence than the whole snapshot, so this second read cannot miss it.
- //
- // Cheap where it matters: it runs only when there are candidates at all,
- // which after the first settle is nothing, and it reads only the tail.
- if !dead.is_empty() {
- match company
+ // Issue #1012: this cross-check only makes sense against "now" — an
+ // older page (a `before_seq` cursor was given) is not the newest state,
+ // so a `running: true` row on it is out of scope here: either it was
+ // already resolved by an earlier newest-page read (whose synthetic
+ // finish will surface naturally once an older page's window reaches
+ // that seq), or it is a genuinely long-lived run outside what
+ // pagination is meant to answer.
+ if query.before_seq.is_none() {
+ // Issue #1009: cross-check the still-`running` rows against the live run set
+ // and settle the ones nobody is running.
+ //
+ // The fold above marks a start with no finish `running: true`, which is only
+ // ever settled by the boot sweep ([`sweep_interrupted_runs`]). Three ways a
+ // finish never lands — a task that panicked, an append that failed, a host
+ // that died — therefore all read as an eternal spinner *until the next host
+ // restart*, with a Stop button that cannot help and a 2s console poll that
+ // never stops. This closes the gap between restarts: any run the fold thinks
+ // is in flight whose id is **absent** from the supervisor's live set has no
+ // task behind it here and now, so it is journaled a synthetic finish (the
+ // same `INTERRUPTED_BY_RESTART` the boot sweep uses) and flipped in the
+ // in-memory row, so this very response is already self-consistent.
+ //
+ // Keyed strictly on `live()` membership. A run the current process is
+ // genuinely running is registered there and is left untouched — the watchdog
+ // (issue #1009, path A) is what guarantees a *panicking* run never reaches
+ // this predicate, because it journals its own finish before its guard drops.
+ //
+ // The one accepted false positive: a run that survived a live
+ // `rebuild_company` swap is registered on the *old* supervisor and so is
+ // absent from the successor's `live()`, so this could settle a run that is
+ // still walking its graph. Accepted because (i) the watchdog keeps panics out
+ // of this path entirely, (ii) that run's real finish lands later in journal
+ // order and wins the read's last-writer-wins display, and (iii) it is the
+ // same class the boot sweep already accepts — which is why that sweep gates
+ // on the handover being absent (see the runtime builder call site). It never
+ // corrupts the journal: that run's second, truthful finish lands *after* the
+ // synthetic one and so supersedes it.
+ //
+ // That last argument turns on ORDER, and it does not carry to a run which
+ // settles inside this request — there the truthful finish lands first and
+ // loses. See the window handled below; it is closed rather than accepted.
+ let live_ids: HashSet = company
.runtime
- .events()
- .read_from(
- company.id(),
- EventSeq::new(read_through.saturating_add(1)),
- usize::MAX,
- )
- .await
- {
- Ok(tail) => {
- let settled_since: HashSet = tail
- .into_iter()
- .filter_map(|stored| match stored.event {
- CompanyEvent::WorkflowRunFinished {
- run_id: Some(run_id),
- ..
- } => Some(run_id),
- _ => None,
- })
- .collect();
- dead.retain(|index| {
- runs[*index]
- .run_id
- .as_ref()
- .is_none_or(|run_id| !settled_since.contains(run_id))
- });
+ .run_supervisor()
+ .live()
+ .into_iter()
+ .map(|(run_id, _workflow_id)| run_id)
+ .collect();
+ let mut dead: Vec = Vec::new();
+ for (index, entry) in runs.iter().enumerate() {
+ if !entry.running {
+ continue;
}
- Err(err) => {
- // Unprovable, so nothing is settled. The row keeps reporting
- // `running` and a later poll retries — strictly better than
- // stamping "interrupted" on a run that may well be finishing.
- tracing::warn!(
- company = %company.id(),
- %err,
- "could not re-read the journal to confirm a workflow run is dead; \
- leaving it as running"
- );
- dead.clear();
+ let Some(run_id) = entry.run_id.as_ref() else {
+ continue;
+ };
+ if live_ids.contains(run_id) {
+ continue;
}
+ dead.push(index);
}
- }
- for index in &dead {
- let entry = &mut runs[*index];
- let Some(run_id) = entry.run_id.clone() else {
- continue;
- };
- // Durable half: append the finish so it survives this response, folds
- // settled on the next `GET …/workflows/runs`, and stops the boot sweep
- // from having to. Best-effort by construction — a failed append leaves
- // the row as the in-memory flip below still makes it, and the next read
- // simply retries.
- crate::runtime::record_run_finished(
- company.runtime.events(),
- company.id(),
- &entry.workflow_id,
- entry.scheduled,
- &run_id,
- Err(crate::runtime::workflow_outcome::INTERRUPTED_BY_RESTART.into()),
- )
- .await;
- // In-memory half: flip the row this response returns, so the console does
- // not have to wait for the next poll to stop the spinner.
- entry.running = false;
- entry.error = Some(crate::runtime::workflow_outcome::INTERRUPTED_BY_RESTART.to_string());
- }
+ // ── The window between the snapshot and `live()` ────────────────────────
+ //
+ // `live()` is consulted AFTER the journal snapshot was taken, and a run can
+ // settle in between: it appends its finish (too late for the snapshot) and
+ // then drops its guard (in time to be missing from `live()`). Such a run is
+ // indistinguishable, on the two facts above, from one that died — but it is
+ // the opposite, and settling it is worse than the hang this repairs.
+ //
+ // The ordering is what makes it worse rather than merely wrong. The rebuild
+ // false positive this block already accepts is self-correcting because the
+ // run's real finish lands *after* the synthetic one, and the fold settles an
+ // entry from the last finish it sees. Here the real finish lands *first*, so
+ // the synthetic one wins for good: a successful run reads
+ // `INTERRUPTED_BY_RESTART` permanently, and because the fold overwrites
+ // `deliveries` from whichever finish settles last, the record of what it
+ // sent is replaced by an empty list.
+ //
+ // So before writing anything, read the journal on from where the snapshot
+ // stopped and drop any candidate whose finish turns up there. That is
+ // exact rather than a heuristic: a run whose start was in the snapshot was
+ // registered before it (`begin` precedes both the spawn and the runner's
+ // `WorkflowRunStarted`), so a candidate missing from `live()` has already
+ // been deregistered — and a deregistered run journaled its finish first, if
+ // it was ever going to. Anything appended before that point is at a higher
+ // sequence than the whole snapshot, so this second read cannot miss it.
+ //
+ // Cheap where it matters: it runs only when there are candidates at all,
+ // which after the first settle is nothing, and it reads only the tail.
+ if !dead.is_empty() {
+ match company
+ .runtime
+ .events()
+ .read_from(
+ company.id(),
+ EventSeq::new(read_through.saturating_add(1)),
+ usize::MAX,
+ )
+ .await
+ {
+ Ok(tail) => {
+ let settled_since: HashSet = tail
+ .into_iter()
+ .filter_map(|stored| match stored.event {
+ CompanyEvent::WorkflowRunFinished {
+ run_id: Some(run_id),
+ ..
+ } => Some(run_id),
+ _ => None,
+ })
+ .collect();
+ dead.retain(|index| {
+ runs[*index]
+ .run_id
+ .as_ref()
+ .is_none_or(|run_id| !settled_since.contains(run_id))
+ });
+ }
+ Err(err) => {
+ // Unprovable, so nothing is settled. The row keeps reporting
+ // `running` and a later poll retries — strictly better than
+ // stamping "interrupted" on a run that may well be finishing.
+ tracing::warn!(
+ company = %company.id(),
+ %err,
+ "could not re-read the journal to confirm a workflow run is dead; \
+ leaving it as running"
+ );
+ dead.clear();
+ }
+ }
+ }
- // ── Serve the row the NEXT read will fold, identically ──────────────────
- //
- // The fold keys a settled entry on its **finish**, taking `seq` and
- // `at_millis` from that row. So flipping `running` while leaving the
- // start's values in place means this response and the one 2s later carry
- // *different* `seq` for the same run — and the console keys its history
- // rows on exactly that field (`RunHistoryPanel`: `key={run.seq}`, with
- // `selectedRunSeq` / `fixingRunSeq` / `fixReason.seq` compared against it).
- // The row remounts and any selection on it is dropped, in the one window
- // where an operator is most likely to be looking: the 2s recovery poll runs
- // precisely because someone is watching this run.
- //
- // So the appended rows are read back and their real `seq` / `at_millis`
- // stamped on. Read back rather than returned from `record_run_finished`,
- // which reports only whether the append happened — the values served are
- // then the durable ones rather than a second construction of them.
- if !dead.is_empty() {
- match company
- .runtime
- .events()
- .read_from(
+ for index in &dead {
+ let entry = &mut runs[*index];
+ let Some(run_id) = entry.run_id.clone() else {
+ continue;
+ };
+ // Durable half: append the finish so it survives this response, folds
+ // settled on the next `GET …/workflows/runs`, and stops the boot sweep
+ // from having to. Best-effort by construction — a failed append leaves
+ // the row as the in-memory flip below still makes it, and the next read
+ // simply retries.
+ crate::runtime::record_run_finished(
+ company.runtime.events(),
company.id(),
- EventSeq::new(read_through.saturating_add(1)),
- usize::MAX,
+ &entry.workflow_id,
+ entry.scheduled,
+ &run_id,
+ Err(crate::runtime::workflow_outcome::INTERRUPTED_BY_RESTART.into()),
)
- .await
- {
- Ok(appended) => {
- let stamped: std::collections::HashMap = appended
- .into_iter()
- .filter_map(|stored| match stored.event {
- CompanyEvent::WorkflowRunFinished {
- run_id: Some(run_id),
- ..
- } => Some((run_id, (stored.seq.value(), stored.at_millis))),
- _ => None,
- })
- .collect();
- for index in &dead {
- let entry = &mut runs[*index];
- let Some((seq, at_millis)) =
- entry.run_id.as_ref().and_then(|id| stamped.get(id))
- else {
- continue;
- };
- entry.seq = *seq;
- entry.at_millis = *at_millis;
+ .await;
+ // In-memory half: flip the row this response returns, so the console does
+ // not have to wait for the next poll to stop the spinner.
+ entry.running = false;
+ entry.error =
+ Some(crate::runtime::workflow_outcome::INTERRUPTED_BY_RESTART.to_string());
+ }
+
+ // ── Serve the row the NEXT read will fold, identically ──────────────────
+ //
+ // The fold keys a settled entry on its **finish**, taking `seq` and
+ // `at_millis` from that row. So flipping `running` while leaving the
+ // start's values in place means this response and the one 2s later carry
+ // *different* `seq` for the same run — and the console keys its history
+ // rows on exactly that field (`RunHistoryPanel`: `key={run.seq}`, with
+ // `selectedRunSeq` / `fixingRunSeq` / `fixReason.seq` compared against it).
+ // The row remounts and any selection on it is dropped, in the one window
+ // where an operator is most likely to be looking: the 2s recovery poll runs
+ // precisely because someone is watching this run.
+ //
+ // So the appended rows are read back and their real `seq` / `at_millis`
+ // stamped on. Read back rather than returned from `record_run_finished`,
+ // which reports only whether the append happened — the values served are
+ // then the durable ones rather than a second construction of them.
+ if !dead.is_empty() {
+ match company
+ .runtime
+ .events()
+ .read_from(
+ company.id(),
+ EventSeq::new(read_through.saturating_add(1)),
+ usize::MAX,
+ )
+ .await
+ {
+ Ok(appended) => {
+ let stamped: std::collections::HashMap = appended
+ .into_iter()
+ .filter_map(|stored| match stored.event {
+ CompanyEvent::WorkflowRunFinished {
+ run_id: Some(run_id),
+ ..
+ } => Some((run_id, (stored.seq.value(), stored.at_millis))),
+ _ => None,
+ })
+ .collect();
+ for index in &dead {
+ let entry = &mut runs[*index];
+ let Some((seq, at_millis)) =
+ entry.run_id.as_ref().and_then(|id| stamped.get(id))
+ else {
+ continue;
+ };
+ entry.seq = *seq;
+ entry.at_millis = *at_millis;
+ }
+ }
+ Err(err) => {
+ // The settle itself stands — it is already durable. Only the
+ // row's identity is left at the start's, which the next read
+ // corrects.
+ tracing::warn!(
+ company = %company.id(),
+ %err,
+ "settled a dead workflow run but could not read back its finish row; \
+ this response carries the start's seq and time"
+ );
}
- }
- Err(err) => {
- // The settle itself stands — it is already durable. Only the
- // row's identity is left at the start's, which the next read
- // corrects.
- tracing::warn!(
- company = %company.id(),
- %err,
- "settled a dead workflow run but could not read back its finish row; \
- this response carries the start's seq and time"
- );
}
}
}
@@ -3043,10 +3167,29 @@ async fn list_runs(
run.verdict = run.derive_verdict();
}
- // Newest first: a history panel leads with the run that just happened. The
- // `limit` now cuts *runs* rather than journal rows, which is the number the
- // caller was asking about all along.
- runs.reverse();
+ // Newest first: a history panel leads with the run that just happened.
+ //
+ // Issue #1012: sorted explicitly by `(at_millis, seq)` descending — the
+ // very pair every row *displays* — rather than `reverse()`d. `reverse()`
+ // only flips the fold's push order, which is the order runs *started* (an
+ // entry is pushed once, at its `WorkflowRunStarted` row, and only mutated
+ // in place — never re-pushed — when its `WorkflowRunFinished` row later
+ // overwrites `seq`/`at_millis` to the finish's own). Two runs that
+ // interleave (B starts after A, but finishes first) therefore used to come
+ // back in *start* order while every row read as if it were ordered by
+ // *finish* — the row for A would lead even though B's `seq`/`atMillis` say
+ // B is newer. Sorting on the same field the row displays makes the two
+ // agree by construction, for a run still in flight (which sorts on its own
+ // start) exactly as for one already settled.
+ //
+ // The `limit` now cuts *runs* rather than journal rows, which is the
+ // number the caller was asking about all along.
+ runs.sort_by_key(|r| std::cmp::Reverse((r.at_millis, r.seq)));
+ // Issue #1012: the backward-paged read above stopped once it had settled
+ // at least `limit + 1` runs (or ran out of journal), precisely so this
+ // count is known here — one more than fit on the page means there is a
+ // page after this one.
+ let has_more = runs.len() > limit;
runs.truncate(limit);
// Issue #1143. A blocked node's `approval_ids` is a receipt of what the run
@@ -3087,7 +3230,22 @@ async fn list_runs(
}
}
- Ok(Json(runs))
+ Ok(Json(WorkflowRunsResponse { runs, has_more }))
+}
+
+/// The `GET …/workflows/runs` response body (issue #1012).
+///
+/// Wrapped rather than a bare array — as this route answered before — because
+/// `hasMore` has nowhere else to ride: the console's history drawer cannot
+/// otherwise tell "this is the whole history" from "this page was truncated at
+/// `limit`", which is exactly the silent-truncation half of the issue.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct WorkflowRunsResponse {
+ runs: Vec,
+ /// Whether a further, older page exists behind `?before_seq=`.
+ has_more: bool,
}
/// Relabels a run's node rows for the nodes it blocked on a human (issue #881).
@@ -5322,7 +5480,7 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 2, "body: {body}");
// Newest first: the history panel leads with the run that just ran.
@@ -5390,7 +5548,7 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 3, "body: {body}");
// The more serious fact first: a run that broke mid-graph AND did
@@ -5449,7 +5607,7 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 1, "body: {body}");
assert_eq!(rows[0]["running"], true, "{body}");
assert_eq!(rows[0]["verdict"], "running", "{body}");
@@ -5538,10 +5696,10 @@ mod tests {
.unwrap();
let body = json_body(response).await;
assert_eq!(
- body[0]["error"],
+ body["runs"][0]["error"],
"no inference source for agent node `worker`"
);
- assert_eq!(body[0]["deliveries"].as_array().unwrap().len(), 0);
+ assert_eq!(body["runs"][0]["deliveries"].as_array().unwrap().len(), 0);
}
// ── Issue #371: the per-node progress fold ─────────────────────────
@@ -5690,7 +5848,7 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 1, "four journal rows fold to one run: {body}");
assert_eq!(rows[0]["runId"], "run-1");
@@ -5746,7 +5904,7 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 1, "one run: {body}");
assert_eq!(rows[0]["running"], true, "still in flight: {body}");
assert_eq!(rows[0]["runId"], run, "{body}");
@@ -5794,7 +5952,7 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 1, "only the asked-for workflow: {body}");
assert_eq!(rows[0]["runId"], "run-mine");
let started = rows[0]["startedNodes"].as_array().expect("startedNodes");
@@ -5828,7 +5986,7 @@ mod tests {
.unwrap();
let body = json_body(response).await;
assert!(
- body[0].get("startedNodes").is_none(),
+ body["runs"][0].get("startedNodes").is_none(),
"an empty trail is absent, not `[]`: {body}"
);
}
@@ -5868,11 +6026,13 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- assert!(body[0].get("running").is_none(), "settled: {body}");
- let started = body[0]["startedNodes"].as_array().expect("startedNodes");
+ assert!(body["runs"][0].get("running").is_none(), "settled: {body}");
+ let started = body["runs"][0]["startedNodes"]
+ .as_array()
+ .expect("startedNodes");
assert_eq!(started.len(), 2, "{body}");
assert_eq!(started[1], "draft");
- let nodes = body[0]["nodes"].as_array().expect("nodes");
+ let nodes = body["runs"][0]["nodes"].as_array().expect("nodes");
assert_eq!(nodes.len(), 1, "`draft` never finished: {body}");
}
@@ -5943,7 +6103,7 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 1, "{body}");
assert!(
rows[0].get("error").is_none(),
@@ -6007,7 +6167,7 @@ mod tests {
.unwrap();
let body = json_body(response).await;
assert_eq!(
- body[0]["blockedNodes"][0]["stranded"], 1,
+ body["runs"][0]["blockedNodes"][0]["stranded"], 1,
"an approval id the journal no longer holds must read as stranded, \
or the drawer goes on linking to an empty queue: {body}"
);
@@ -6092,7 +6252,7 @@ mod tests {
.unwrap();
let body = json_body(response).await;
assert!(
- body[0]["blockedNodes"][0].get("stranded").is_none(),
+ body["runs"][0]["blockedNodes"][0].get("stranded").is_none(),
"a parked approval is still decidable, so nothing may be marked \
stranded: {body}"
);
@@ -6131,9 +6291,9 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- assert_eq!(body[0]["running"], true, "{body}");
- assert_eq!(body[0]["nodes"].as_array().unwrap().len(), 1);
- assert!(body[0].get("error").is_none(), "{body}");
+ assert_eq!(body["runs"][0]["running"], true, "{body}");
+ assert_eq!(body["runs"][0]["nodes"].as_array().unwrap().len(), 1);
+ assert!(body["runs"][0].get("error").is_none(), "{body}");
}
/// An event log that lets exactly one run settle **inside** `list_runs`'
@@ -6318,7 +6478,7 @@ mod tests {
// still the snapshot's, `running: true`. That is honest: the run WAS
// in flight when the journal was sampled. What matters is that it is
// not stamped dead.
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 1, "{body}");
assert!(
rows[0].get("error").is_none(),
@@ -6335,9 +6495,12 @@ mod tests {
.unwrap(),
)
.await;
- assert!(next[0].get("running").is_none(), "settled: {next}");
- assert!(next[0].get("error").is_none(), "a successful run: {next}");
- assert_eq!(next[0]["deliveries"][0]["status"], "sent", "{next}");
+ assert!(next["runs"][0].get("running").is_none(), "settled: {next}");
+ assert!(
+ next["runs"][0].get("error").is_none(),
+ "a successful run: {next}"
+ );
+ assert_eq!(next["runs"][0]["deliveries"][0]["status"], "sent", "{next}");
}
/// **A settled row keeps one identity across reads.** The response that
@@ -6374,21 +6537,24 @@ mod tests {
)
.await;
- assert!(first[0].get("running").is_none(), "settled: {first}");
+ assert!(
+ first["runs"][0].get("running").is_none(),
+ "settled: {first}"
+ );
assert_eq!(
- first[0]["seq"], second[0]["seq"],
+ first["runs"][0]["seq"], second["runs"][0]["seq"],
"the settling read and the one after it must agree on the row's \
identity: {first} then {second}"
);
assert_eq!(
- first[0]["atMillis"], second[0]["atMillis"],
+ first["runs"][0]["atMillis"], second["runs"][0]["atMillis"],
"…and on when it settled: {first} then {second}"
);
// Specifically the FINISH's row, which is what the next fold uses —
// not the start's, which is the only other candidate.
assert!(
- first[0]["atMillis"].as_u64().unwrap()
- >= first[0]["startedAtMillis"].as_u64().unwrap(),
+ first["runs"][0]["atMillis"].as_u64().unwrap()
+ >= first["runs"][0]["startedAtMillis"].as_u64().unwrap(),
"the settle cannot predate the start: {first}"
);
}
@@ -6410,7 +6576,7 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["workflowId"], "digest");
assert!(rows[0].get("nodes").is_none(), "{body}");
@@ -6440,7 +6606,7 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 2, "{body}");
let by_id = |run: &str| {
rows.iter()
@@ -6454,6 +6620,44 @@ mod tests {
assert_eq!(by_id("run-b")["nodes"].as_array().unwrap().len(), 1);
}
+ /// Issue #1012. Two interleaved runs — `run-a` starts first but
+ /// `run-b` finishes last — must come back ordered by **finish**, the
+ /// field every row displays, not by the order they started. The old
+ /// `runs.reverse()` only flipped the fold's push order (start order),
+ /// so this exact fixture used to list `run-a` first despite `run-b`
+ /// carrying the newer `seq`/`atMillis`.
+ #[tokio::test]
+ async fn run_history_orders_by_finish_not_start() {
+ let home_dir = home();
+ let home = home_dir.path().to_path_buf();
+ let (state, _store, id) = hosted_state(&home).await;
+
+ // Start order: a, then b. Finish order: a, then b — so b is both
+ // the last to start AND the last to finish, which alone would not
+ // distinguish "ordered by start" from "ordered by finish". Insert
+ // a THIRD run, `c`, that starts before `b` but finishes before `a`
+ // too, so the two orderings genuinely disagree on where it lands.
+ journal_start(&state, &id, "wf", "run-a", false).await;
+ journal_start(&state, &id, "wf", "run-c", false).await;
+ journal_finish(&state, &id, "wf", "run-c", false, None).await;
+ journal_start(&state, &id, "wf", "run-b", false).await;
+ journal_finish(&state, &id, "wf", "run-a", false, None).await;
+ journal_finish(&state, &id, "wf", "run-b", false, None).await;
+
+ // Start order: a, c, b. Finish order: c, a, b.
+ // By-start reversal would read: b, c, a (wrong).
+ // By-finish descending must read: b, a, c.
+ let response = router(state)
+ .oneshot(request("GET", "/api/v1/company/workflows/runs", None))
+ .await
+ .unwrap();
+ let body = json_body(response).await;
+ let rows = body["runs"].as_array().expect("array");
+ assert_eq!(rows.len(), 3, "{body}");
+ let ids: Vec<&str> = rows.iter().map(|r| r["runId"].as_str().unwrap()).collect();
+ assert_eq!(ids, vec!["run-b", "run-a", "run-c"], "{body}");
+ }
+
/// `?limit=` now cuts **runs**, not journal rows — the number the caller
/// was asking about all along. Without the group-aware cut, a limit of 2
/// over three 4-row runs would return fragments.
@@ -6480,7 +6684,7 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 2, "{body}");
// Newest first, and each one whole.
assert_eq!(rows[0]["runId"], "run-2");
@@ -6513,7 +6717,7 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- let rows = body.as_array().expect("array");
+ let rows = body["runs"].as_array().expect("array");
assert_eq!(rows.len(), 1, "body: {body}");
assert_eq!(rows[0]["workflowId"], "digest");
}
@@ -6545,14 +6749,14 @@ mod tests {
// Explicit cap, taken from the newest end.
let capped = page("/api/v1/company/workflows/runs?limit=3").await;
- let rows = capped.as_array().expect("array");
+ let rows = capped["runs"].as_array().expect("array");
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["workflowId"], "wf-24", "newest first: {capped}");
// No `limit` → the default page, not the whole 25.
let defaulted = page("/api/v1/company/workflows/runs").await;
assert_eq!(
- defaulted.as_array().unwrap().len(),
+ defaulted["runs"].as_array().unwrap().len(),
DEFAULT_RUN_LIMIT,
"{defaulted}"
);
@@ -6560,11 +6764,15 @@ mod tests {
// `limit=0` means "I didn't really mean zero" — an empty page is
// never what a caller wants, so it falls back to the default.
let zero = page("/api/v1/company/workflows/runs?limit=0").await;
- assert_eq!(zero.as_array().unwrap().len(), DEFAULT_RUN_LIMIT, "{zero}");
+ assert_eq!(
+ zero["runs"].as_array().unwrap().len(),
+ DEFAULT_RUN_LIMIT,
+ "{zero}"
+ );
// Above the ceiling clamps; with only 25 rows that is all of them.
let huge = page("/api/v1/company/workflows/runs?limit=100000").await;
- assert_eq!(huge.as_array().unwrap().len(), 25, "{huge}");
+ assert_eq!(huge["runs"].as_array().unwrap().len(), 25, "{huge}");
}
/// A company that has never run a workflow gets an empty list, not a
@@ -6580,7 +6788,10 @@ mod tests {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
- assert_eq!(json_body(response).await.as_array().unwrap().len(), 0);
+ assert_eq!(
+ json_body(response).await["runs"].as_array().unwrap().len(),
+ 0
+ );
}
/// **Route-ordering pin.** `runs` is a syntactically valid `wid`, so
@@ -6604,9 +6815,12 @@ mod tests {
StatusCode::OK,
"the static /workflows/runs must win over /workflows/{{wid}}"
);
- // An array of outcomes, not a single graph object.
+ // A runs page — `{ runs: [...], hasMore }` — not a single graph object.
let body = json_body(response).await;
- assert!(body.is_array(), "graph read shadowed the history: {body}");
+ assert!(
+ body["runs"].is_array(),
+ "graph read shadowed the history: {body}"
+ );
}
// -------------------------------------------------------------------
@@ -6737,7 +6951,7 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
- assert_eq!(body[0]["workflowId"], "digest");
+ assert_eq!(body["runs"][0]["workflowId"], "digest");
}
// ── Issue #259: edit + delete at the HTTP boundary ──────────────────
@@ -7298,12 +7512,9 @@ mod tests {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
- let rows = json_body(response).await;
- assert_eq!(
- rows.as_array().unwrap().len(),
- 1,
- "past runs must outlive the workflow: {rows}"
- );
+ let body = json_body(response).await;
+ let rows = body["runs"].as_array().unwrap();
+ assert_eq!(rows.len(), 1, "past runs must outlive the workflow: {body}");
assert_eq!(rows[0]["workflowId"], "greeter");
}
@@ -7552,15 +7763,15 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- assert_eq!(body.as_array().unwrap().len(), 1);
+ assert_eq!(body["runs"].as_array().unwrap().len(), 1);
// `running` is skip-serialized when false, so a settled row simply
// omits it — assert it is not `true` rather than equal to `false`.
assert_ne!(
- body[0]["running"], true,
+ body["runs"][0]["running"], true,
"an absent run is settled on the read, not left spinning: {body}"
);
assert_eq!(
- body[0]["error"],
+ body["runs"][0]["error"],
crate::runtime::workflow_outcome::INTERRUPTED_BY_RESTART
);
@@ -7602,9 +7813,9 @@ mod tests {
.await
.unwrap();
let body = json_body(response).await;
- assert_eq!(body.as_array().unwrap().len(), 1);
+ assert_eq!(body["runs"].as_array().unwrap().len(), 1);
assert_eq!(
- body[0]["running"], true,
+ body["runs"][0]["running"], true,
"a run the process is running must not be settled from under it"
);
@@ -8182,7 +8393,7 @@ label = "ok"
.await
.unwrap();
let rows = json_body(response).await;
- let row = &rows.as_array().expect("array")[0];
+ let row = &rows["runs"].as_array().expect("array")[0];
assert_eq!(row["runId"], run_id.as_str(), "{rows}");
assert_eq!(row["cancelled"], true, "{rows}");
assert!(
diff --git a/src/server/ops/write_test.rs b/src/server/ops/write_test.rs
index e7a32ecbd..5810349fd 100644
--- a/src/server/ops/write_test.rs
+++ b/src/server/ops/write_test.rs
@@ -9114,7 +9114,7 @@ async fn the_run_history_carries_a_runs_board_rows() {
let (status, runs) = send(&state, "GET", "/api/v1/company/workflows/runs", None).await;
assert_eq!(status, StatusCode::OK);
- let runs = runs.as_array().expect("an array of runs");
+ let runs = runs["runs"].as_array().expect("an array of runs");
let settled = runs
.iter()
From 7afadec648e6a3e2dcc2511050b4dac9b5d6aa38 Mon Sep 17 00:00:00 2001
From: sanil-23
Date: Thu, 20 Aug 2026 16:46:55 +0530
Subject: [PATCH 02/14] feat: real local ACP engine + per-harness model
override (#1245)
Named harnesses (#993) let a teammate bind to an `acp` harness, but no
engine existed to actually run that turn -- lanes::build unconditionally
recorded every acp harness `unavailable`. This wires a real one for
`transport = "local"`, plus a `model` field so a power user can pin a
specific model on it, mirroring the pattern block/buzz already ships
(a teammate carries harness + model as independent settings, and the
host injects the model into the harness's own startup lever).
- `AcpHarness.model: Option` (src/company/types.rs) + validation
(manifest.rs): a plain string hint forwarded to the agent's own lever,
not a credential, so it does not join `[harness.inference]`'s
prohibition on acp harnesses. Rejected on `transport = "runner"` (no
wire protocol for it yet) and when empty.
- The `AcpAgent`/`AcpAgentFactory`/`AcpTurn`/`AcpUpdate` port moved from
`harness::acp::run_turn` (behind the `openhuman` feature) to
`src/ports/acp.rs`, ungated. The desktop shell -- the only implementation
this crate does not itself provide -- does not enable `openhuman` on its
`opencompany` dependency at all, so the port had to live somewhere it
could actually see without pulling in the whole embedded-engine
dependency tree. `harness::acp::run_turn` re-exports the types and keeps
`AcpRunTurn`/`fold`, which do need `openhuman`'s `TurnStep`/`RunTurn`.
- `lanes::build` resolves a real engine for `transport = "local"` when
given a factory (`Option<&dyn AcpAgentFactory>`, `#[cfg(feature = "acp")]`
with an uninhabited-type fallback for `openhuman`-without-`acp` builds);
`transport = "runner"` still resolves `unavailable` (its own, larger
piece of work).
- `LocalAcpAgent`/`LocalAcpAgentFactory` (src-tauri/src/acp/local_agent.rs):
spawns the harness's CLI via the existing `AcpClient`, demultiplexes
ACP's single global `session/update` stream by session id (one
subprocess serves every teammate on the harness), and injects the model
via a per-CLI env var confirmed live against the real adapter --
`ANTHROPIC_MODEL` for claude, `GOOSE_MODEL` for goose. `codex` has no
confirmed lever yet (validated but not injected, rather than guessed).
V1 fails closed on ACP permission requests rather than routing them
through the company's approval-policy gate -- a known, documented gap,
not the intended end state.
- `AppState::with_acp_agents` (src/app/types.rs) threads the factory to
`desktop::register`, mirroring `with_rebuilder`'s exact pattern; wired
for real in src-tauri/src/embedded.rs.
- Found and fixed a real bug via live testing: discovery.rs's catalog
still named the legacy `claude-code-acp` binary; the current package
installs `claude-agent-acp`. Would have silently failed every spawn on
a current install.
Live-tested against a real, authenticated claude-agent-acp (not just the
scripted fixture): a real prompt/response round trip, `session/new`
advertising a model config option, `ANTHROPIC_MODEL` actually steering
the reported current model, and the full `LocalAcpAgent` path through the
`AcpAgent` trait -- see src-tauri/tests/acp_live_smoke.rs (`#[ignore]`d,
costs real usage, never runs in CI).
Co-Authored-By: Claude
---
docs/spec/runtime/harnesses.md | 83 ++++++--
src-tauri/src/acp/client.rs | 7 +
src-tauri/src/acp/discovery.rs | 18 +-
src-tauri/src/acp/local_agent.rs | 320 ++++++++++++++++++++++++++++++
src-tauri/src/acp/mod.rs | 2 +
src-tauri/src/embedded.rs | 7 +-
src-tauri/tests/acp_client.rs | 1 +
src-tauri/tests/acp_live_smoke.rs | 246 +++++++++++++++++++++++
src/app/types.rs | 20 ++
src/company/manifest.rs | 51 +++++
src/company/types.rs | 11 +
src/desktop.rs | 9 +
src/harness/acp/run_turn.rs | 69 +------
src/harness/built_in/brain.rs | 1 +
src/harness/lanes.rs | 80 ++++++++
src/ports/acp.rs | 124 ++++++++++++
src/ports/mod.rs | 2 +
src/runtime/builder.rs | 38 ++++
18 files changed, 1006 insertions(+), 83 deletions(-)
create mode 100644 src-tauri/src/acp/local_agent.rs
create mode 100644 src-tauri/tests/acp_live_smoke.rs
create mode 100644 src/ports/acp.rs
diff --git a/docs/spec/runtime/harnesses.md b/docs/spec/runtime/harnesses.md
index 46e7cfdde..4e5c4a718 100644
--- a/docs/spec/runtime/harnesses.md
+++ b/docs/spec/runtime/harnesses.md
@@ -64,6 +64,7 @@ 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
@@ -71,6 +72,28 @@ 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 startup 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.
+
+Whether it actually does anything depends on whether this build knows a
+startup lever for that `agent` — confirmed live against the real adapters
+(issue #1245), not guessed:
+
+| `agent` | lever |
+|---|---|
+| `claude` | `ANTHROPIC_MODEL` |
+| `goose` | `GOOSE_MODEL` |
+| `codex` | none known yet — `model` is accepted and validated, but not injected |
+
+`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
@@ -106,6 +129,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,
@@ -127,16 +152,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
@@ -187,14 +222,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.
---
@@ -204,7 +244,12 @@ the only evidence would be a billing line.
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.
+ approval policy — **not yet true for `local`'s own permission prompts**
+ (`session/request_permission`): `LocalAcpAgent` fails closed on every one it
+ was not explicitly configured to allow, rather than routing it through
+ `ApprovalRequestQueue`. Safe (a refusal is a visible, actionable failure; a
+ silent auto-approval would not be), but a known gap, not the intended
+ end state.
- **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
@@ -216,12 +261,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` |
diff --git a/src-tauri/src/acp/client.rs b/src-tauri/src/acp/client.rs
index 46ddf268b..21eab4731 100644
--- a/src-tauri/src/acp/client.rs
+++ b/src-tauri/src/acp/client.rs
@@ -160,15 +160,22 @@ pub type UpdateSink = Arc;
impl AcpClient {
/// Spawns `command` and starts reading it.
+ ///
+ /// `env` is added on top of this process's own inherited environment —
+ /// not a replacement for it — so a harness that also needs `PATH`, `HOME`,
+ /// etc. keeps them. Callers that need no extra vars (every one before
+ /// issue #1245) pass `&[]`.
pub async fn spawn(
command: &str,
args: &[&str],
cwd: &Path,
+ env: &[(&str, &str)],
handler: Arc,
updates: UpdateSink,
) -> Result {
let mut child = Command::new(command)
.args(args)
+ .envs(env.iter().copied())
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
diff --git a/src-tauri/src/acp/discovery.rs b/src-tauri/src/acp/discovery.rs
index 098e8e91e..60961f3dc 100644
--- a/src-tauri/src/acp/discovery.rs
+++ b/src-tauri/src/acp/discovery.rs
@@ -75,7 +75,13 @@ pub const HARNESSES: &[Harness] = &[
Harness {
id: "claude",
label: "Claude Code",
- command: "claude-code-acp",
+ // Confirmed live (issue #1245): `npm install -g
+ // @agentclientprotocol/claude-agent-acp` installs a binary named
+ // `claude-agent-acp`, not `claude-code-acp` (the package's former
+ // name, before it moved under the `@agentclientprotocol` scope). A
+ // stale `claude-code-acp` here silently fails every "not found" probe
+ // and every spawn on a current install.
+ command: "claude-agent-acp",
args: &[],
credential: Some(".claude/.credentials.json"),
},
@@ -251,7 +257,7 @@ mod test {
// THE distinction this module exists for. Both are "unavailable", and
// the fixes are completely different — install it, versus sign in — so
// collapsing them tells the operator to do the wrong thing.
- let probe = Fake::new().with_installed("claude-code-acp");
+ let probe = Fake::new().with_installed("claude-agent-acp");
assert_eq!(readiness_of(&probe, "claude"), Readiness::NotSignedIn);
assert_ne!(readiness_of(&probe, "claude"), Readiness::NotInstalled);
}
@@ -259,7 +265,7 @@ mod test {
#[test]
fn a_signed_in_harness_is_ready() {
let probe = Fake::new()
- .with_installed("claude-code-acp")
+ .with_installed("claude-agent-acp")
.with_file("/home/ada/.claude/.credentials.json");
assert_eq!(readiness_of(&probe, "claude"), Readiness::Ready);
assert!(readiness_of(&probe, "claude").is_ready());
@@ -269,7 +275,7 @@ mod test {
fn each_harness_is_probed_at_its_own_paths() {
// One harness being signed in must not make another look signed in.
let probe = Fake::new()
- .with_installed("claude-code-acp")
+ .with_installed("claude-agent-acp")
.with_installed("codex-acp")
.with_file("/home/ada/.claude/.credentials.json");
assert_eq!(readiness_of(&probe, "claude"), Readiness::Ready);
@@ -280,7 +286,9 @@ mod test {
fn no_home_directory_reads_as_signed_out_rather_than_ready() {
// The safe direction of the two wrong answers: claiming ready would
// fail at first use, far from the cause.
- let probe = Fake::new().with_installed("claude-code-acp").without_home();
+ let probe = Fake::new()
+ .with_installed("claude-agent-acp")
+ .without_home();
assert_eq!(readiness_of(&probe, "claude"), Readiness::NotSignedIn);
}
diff --git a/src-tauri/src/acp/local_agent.rs b/src-tauri/src/acp/local_agent.rs
new file mode 100644
index 000000000..120b2bf87
--- /dev/null
+++ b/src-tauri/src/acp/local_agent.rs
@@ -0,0 +1,320 @@
+//! `LocalAcpAgent`: the `transport = "local"` implementation of the host
+//! crate's [`AcpAgent`] port (issue #1245) — a real coding CLI, spawned once
+//! per declared local-acp harness and driven over stdio through the existing
+//! [`AcpClient`].
+//!
+//! ## One process, many sessions
+//!
+//! A harness can serve more than one teammate, but [`AcpClient::spawn`] opens
+//! one subprocess with one global update sink — ACP's `session/update`
+//! notifications are not routed per caller, only tagged with the `sessionId`
+//! they belong to. So this buffers every notification by `sessionId` as it
+//! arrives, and a `prompt` call drains only its own session's buffer after
+//! `session/prompt` returns rather than reading whatever the sink last saw.
+//!
+//! ## Permission requests: fails closed (deliberate, and a known gap)
+//!
+//! `docs/spec/runtime/harnesses.md` says an ACP agent "is still subject to
+//! the company's approval policy" — this does not yet route ACP permission
+//! requests through that policy gate; it refuses every one it did not
+//! explicitly configure to allow, via the same [`ConfinedFiles`] the fixture
+//! tests already use. That is the safe direction to be wrong in: a refused
+//! edit is a visible failure the operator can act on, where a silently
+//! auto-approved one would not be. Wiring ACP's `session/request_permission`
+//! into `ApprovalRequestQueue` is real follow-up work, not done here.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex as StdMutex};
+
+use async_trait::async_trait;
+use opencompany::Result;
+use opencompany::error::OpenCompanyError;
+use opencompany::ports::acp::{AcpAgent, AcpAgentFactory, AcpTurn, AcpUpdate};
+use opencompany::ports::types::CompanyId;
+use serde_json::Value;
+use tokio::sync::Mutex as AsyncMutex;
+
+use crate::acp::client::{AcpClient, ClientHandler, ConfinedFiles};
+use crate::acp::confine::Confinement;
+use crate::acp::discovery::HARNESSES;
+
+/// Per-CLI startup model env var, confirmed live against the real adapter
+/// (issue #1245's live smoke test) — not guessed. `None` means this build has
+/// no known lever for that CLI: `model` is still accepted on the manifest,
+/// but nothing is injected, rather than silently spawning a process that
+/// ignores the setting.
+fn model_env_var(agent: &str) -> Option<&'static str> {
+ match agent {
+ "claude" => Some("ANTHROPIC_MODEL"),
+ "goose" => Some("GOOSE_MODEL"),
+ // codex: no confirmed startup-model env var. Buzz (block/buzz), the
+ // one other project this design is modeled on, has none either.
+ _ => None,
+ }
+}
+
+/// One spawned local-transport ACP harness, serving every teammate bound to
+/// it.
+pub struct LocalAcpAgent {
+ command: &'static str,
+ args: Vec,
+ env: Vec<(String, String)>,
+ /// The company's agent-workspace root (`HarnessDeps::workspace_root`).
+ /// Each session roots at `workspace_root///workspace`,
+ /// mirroring `harness::built_in::build::agent_workspace` exactly, so an
+ /// ACP-run teammate's files land in the same conventional place a
+ /// `built_in`-run one's would.
+ workspace_root: PathBuf,
+ client: AsyncMutex
+ )}
);
diff --git a/frontend/test/unit/mcp-credential-affordance.test.ts b/frontend/test/unit/mcp-credential-affordance.test.ts
new file mode 100644
index 000000000..2f41b57e7
--- /dev/null
+++ b/frontend/test/unit/mcp-credential-affordance.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+
+import { credentialAffordance } from "@/views/connections/McpServersSection";
+
+/**
+ * Issue #1260. The MCP row offered a **Sign in** button on a server whose OAuth
+ * could never complete: Slack's MCP endpoint answers `401` with a proper
+ * resource-metadata challenge but advertises no RFC 7591 dynamic client
+ * registration, so there is no client to mint. `POST …/oauth/start` refused
+ * with a `400` naming the real remedy — paste a static token — which the
+ * operator could only read by pressing a button that could not work.
+ *
+ * The host now distinguishes the two states. These pin the console half: which
+ * control a row offers is a function of that hint and nothing else.
+ */
+describe("credentialAffordance", () => {
+ it("offers sign-in only when the host says sign-in can complete", () => {
+ expect(credentialAffordance("oauth_required")).toBe("sign_in");
+ });
+
+ it("offers a token field when OAuth is required but undrivable", () => {
+ expect(credentialAffordance("static_token_required")).toBe("add_token");
+ });
+
+ it("never offers sign-in for a server that cannot complete one", () => {
+ // The regression itself. Both codes carry `status: needs_config` and both
+ // read as "an auth problem"; only this distinction stops the unusable
+ // button coming back.
+ expect(credentialAffordance("static_token_required")).not.toBe("sign_in");
+ });
+
+ it("routes a plain credential prompt to the same field", () => {
+ expect(credentialAffordance("credential_required")).toBe("add_token");
+ });
+
+ it("offers nothing for a healthy server or an unknown code", () => {
+ // A server that probed `ok` carries no hint at all, and must not sprout a
+ // credential control; an unrecognised future code must not either, because
+ // guessing which control it wants is how the wrong one gets offered.
+ expect(credentialAffordance(undefined)).toBe("none");
+ expect(credentialAffordance("token_rejected")).toBe("none");
+ expect(credentialAffordance("some_code_added_later")).toBe("none");
+ });
+});
From ab6bd1e57f62e2ac812fcc3ee5f7ea355057c838 Mon Sep 17 00:00:00 2001
From: cyrus
Date: Thu, 20 Aug 2026 17:32:35 +0530
Subject: [PATCH 08/14] fix: send ledger status needsReason as camelCase on the
wire
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The console's "ask for a reason before closing" guard never fired on
any declared ledger: the server sent StatusSpec::needs_reason as
snake_case while the frontend's LedgerStatus.needsReason expected
camelCase, so it always read undefined.
StatusSpec itself must stay snake_case in both directions — it's also
what a declared ledger round-trips through in every store backend, so
an asymmetric rename there would silently drop the flag back to false
on save-then-reload (verified this against
closing_without_a_reason_is_a_400_that_says_so, which broke under that
approach). Instead, add a wire-only LedgerStatusDto in
src/server/ops/ledgers.rs with #[serde(rename_all = "camelCase")], and
have LedgerSummary.statuses build through it.
Closes #1266
---
src/ledger/spec.rs | 9 +++++++++
src/ledger/spec_test.rs | 22 ++++++++++++++++++++++
src/server/ops/ledgers.rs | 33 +++++++++++++++++++++++++++++++--
src/server/ops/ledgers_test.rs | 27 +++++++++++++++++++++++++++
4 files changed, 89 insertions(+), 2 deletions(-)
diff --git a/src/ledger/spec.rs b/src/ledger/spec.rs
index 4c7b86caf..6c2ea1c25 100644
--- a/src/ledger/spec.rs
+++ b/src/ledger/spec.rs
@@ -136,6 +136,15 @@ pub struct StatusSpec {
#[serde(default)]
pub closed: bool,
/// Whether closing into this status demands a `reason`.
+ ///
+ /// Plain snake_case on purpose (issue #1266's near-miss): this type is
+ /// also what a declared ledger round-trips through in every store
+ /// backend (`store/fs_ops.rs`, `sqlite.rs`, `mongodb.rs` all persist
+ /// `LedgerSpec` via this same `Serialize`/`Deserialize`), so a rename
+ /// here would make Serialize write one key and Deserialize expect
+ /// another — silently dropping `needs_reason` back to `false` on the
+ /// very next reload. The console-facing camelCase key belongs on a
+ /// wire-only DTO instead — see `server::ops::ledgers::LedgerStatusDto`.
#[serde(default)]
pub needs_reason: bool,
}
diff --git a/src/ledger/spec_test.rs b/src/ledger/spec_test.rs
index 52a7f94ac..f687ce40a 100644
--- a/src/ledger/spec_test.rs
+++ b/src/ledger/spec_test.rs
@@ -34,6 +34,28 @@ fn a_minimal_declaration_parses() {
assert!(spec.written_by.contains("record_entry"));
}
+/// **`StatusSpec` itself stays snake_case both ways** (issue #1266's
+/// near-miss): it is also what a declared ledger round-trips through in
+/// every store backend, so `needs_reason` in must equal `needs_reason` out
+/// or a save-then-reload silently drops the flag back to `false`. The
+/// console-facing camelCase key lives on a wire-only DTO instead
+/// (`server::ops::ledgers::LedgerStatusDto`), covered by
+/// `ledgers_test.rs`'s `a_status_that_needs_a_reason_carries_camel_case_on_the_wire`.
+#[test]
+fn status_spec_round_trips_needs_reason_through_its_own_serde_unchanged() {
+ let spec = parse(&minimal(), false).expect("declaration still parses with needs_reason");
+ let status = spec.status("closed").expect("the closed status");
+ assert!(status.needs_reason);
+
+ let wire = serde_json::to_value(status).unwrap();
+ assert_eq!(wire["needs_reason"], true);
+ let restored: StatusSpec = serde_json::from_value(wire).unwrap();
+ assert!(
+ restored.needs_reason,
+ "a store round-trip through this type's own serde must not lose the flag"
+ );
+}
+
#[test]
fn a_ledger_needs_exactly_one_id_field() {
let mut document = minimal();
diff --git a/src/server/ops/ledgers.rs b/src/server/ops/ledgers.rs
index 23e49965f..4ab8326c4 100644
--- a/src/server/ops/ledgers.rs
+++ b/src/server/ops/ledgers.rs
@@ -47,6 +47,35 @@ pub fn router() -> Router {
))
}
+/// One status, wire-shaped for the console (issue #1266).
+///
+/// `crate::ledger::StatusSpec` stays plain snake_case in both directions,
+/// because it is also what a declared ledger round-trips through in every
+/// store backend — a rename there would make a save write one key and a
+/// reload expect another, silently dropping `needs_reason` back to `false`.
+/// This DTO exists so the API-output shape can be camelCase (matching every
+/// other field `LedgerSummary` sends) without that type doing double duty.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct LedgerStatusDto {
+ name: String,
+ #[serde(skip_serializing_if = "String::is_empty")]
+ label: String,
+ closed: bool,
+ needs_reason: bool,
+}
+
+impl From<&crate::ledger::StatusSpec> for LedgerStatusDto {
+ fn from(status: &crate::ledger::StatusSpec) -> Self {
+ Self {
+ name: status.name.clone(),
+ label: status.label.clone(),
+ closed: status.closed,
+ needs_reason: status.needs_reason,
+ }
+ }
+}
+
/// One ledger as the console lists it.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -65,7 +94,7 @@ struct LedgerSummary {
/// Whether the runtime ships it. A built-in cannot be retired.
builtin: bool,
fields: Vec,
- statuses: Vec,
+ statuses: Vec,
sections: Vec,
#[serde(skip_serializing_if = "Vec::is_empty")]
writers: Vec,
@@ -211,7 +240,7 @@ async fn summary(ctx: &ledgers::Ledgers, spec: &LedgerSpec) -> Result
Date: Thu, 20 Aug 2026 17:43:58 +0530
Subject: [PATCH 09/14] fix: update issue #1189 tests for the runs response
envelope
The #1189 stranded-approvals tests merged in from upstream still indexed
the run history response as a bare array (body[0][...]), a leftover from
before #1012 wrapped it in { runs, hasMore }. Point them at body["runs"][0]
so they assert against the actual response shape.
---
src/server/ops/workflows.rs | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/src/server/ops/workflows.rs b/src/server/ops/workflows.rs
index 992e53960..d95186ad2 100644
--- a/src/server/ops/workflows.rs
+++ b/src/server/ops/workflows.rs
@@ -6252,7 +6252,7 @@ mod tests {
// blocked-node list said "cannot be continued" and the run's own
// one-word reading said "go and decide it", on the same row.
assert_eq!(
- body[0]["verdict"], "stranded",
+ body["runs"][0]["verdict"], "stranded",
"the verdict must be derived after the reconciliation, not before it: {body}"
);
}
@@ -6341,7 +6341,7 @@ mod tests {
stranded: {body}"
);
assert_eq!(
- body[0]["verdict"], "blocked",
+ body["runs"][0]["verdict"], "blocked",
"a run with a live card is still blocked, not stranded: {body}"
);
}
@@ -6391,11 +6391,11 @@ mod tests {
.unwrap();
let body = json_body(response).await;
assert_eq!(
- body[0]["strandedApprovals"], 2,
+ body["runs"][0]["strandedApprovals"], 2,
"both gates lost their card, and nothing else on this row says so: {body}"
);
assert_eq!(
- body[0]["verdict"], "stranded",
+ body["runs"][0]["verdict"], "stranded",
"nothing in the queue is waiting on this run: {body}"
);
}
@@ -6475,11 +6475,11 @@ mod tests {
.unwrap();
let body = json_body(response).await;
assert!(
- body[0].get("strandedApprovals").is_none(),
+ body["runs"][0].get("strandedApprovals").is_none(),
"the gate's card is on the queue, so nothing may be marked stranded: {body}"
);
assert_eq!(
- body[0]["verdict"], "awaiting-approval",
+ body["runs"][0]["verdict"], "awaiting-approval",
"a decidable gate is still awaiting a person: {body}"
);
}
@@ -6527,11 +6527,11 @@ mod tests {
.unwrap();
let body = json_body(response).await;
assert!(
- body[0].get("strandedApprovals").is_none(),
+ body["runs"][0].get("strandedApprovals").is_none(),
"a row with no run id cannot be joined, so it must report nothing: {body}"
);
assert_eq!(
- body[0]["verdict"], "awaiting-approval",
+ body["runs"][0]["verdict"], "awaiting-approval",
"an unjoinable row keeps the reading it had before #1189: {body}"
);
}
From 6927810c33fdeb43bef82cb38a02206296bdba60 Mon Sep 17 00:00:00 2001
From: oxoxDev
Date: Thu, 20 Aug 2026 17:54:01 +0530
Subject: [PATCH 10/14] fix(mcp): mark StaticTokenRequired dead-code-safe under
non-mcp builds
The `openhuman,tinycortex` CI lane builds without the `mcp` feature, so
refine_oauth_capability's real body (the only place that constructs this
variant) compiles out in favor of its no-op stub. Match the existing
cfg_attr(not(feature = "..."), allow(dead_code)) idiom used elsewhere in
this file's siblings (paypal.rs, rpc.rs, workflows.rs) rather than
widening the feature gate.
---
src/harness/built_in/mcp_probe.rs | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/src/harness/built_in/mcp_probe.rs b/src/harness/built_in/mcp_probe.rs
index 5b1595125..8dea7495a 100644
--- a/src/harness/built_in/mcp_probe.rs
+++ b/src/harness/built_in/mcp_probe.rs
@@ -69,6 +69,12 @@ enum FailureKind {
/// Distinct from [`FailureKind::OauthRequired`] because the operator's next
/// action is the opposite one — paste a static token, rather than press a
/// Sign in button that cannot succeed.
+ ///
+ /// Only ever constructed by `refine_oauth_capability`, which is itself
+ /// gated on `feature = "mcp"` (no `mcp` feature means no `oauth/start`
+ /// route, so there is nothing to downgrade) — so this variant is
+ /// legitimately unconstructed in builds without that feature.
+ #[cfg_attr(not(feature = "mcp"), allow(dead_code))]
StaticTokenRequired,
/// Anything not otherwise recognised.
Unknown,
From b0e918629c456868ce41e031d563b69970191541 Mon Sep 17 00:00:00 2001
From: sanil-23
Date: Thu, 20 Aug 2026 18:20:06 +0530
Subject: [PATCH 11/14] fix(harness): a desk must not stand in for a company
teammate (#1196)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses CodeRabbit review: prefer_company_over_baseline classified any
non-baseline candidate id as company-side, so a desk (which resolves to
neither) could count as "company" and wrongly trigger dropping a genuine
baseline teammate from a tie the company never actually resolved. Replaced the
boolean classifier with a three-way Provenance (Baseline / Company / neither)
so a desk stays neutral on both sides — it neither triggers the drop nor is
dropped by it.
Co-Authored-By: Claude
---
src/harness/built_in/planning.rs | 53 ++++++++++++++++++---------
src/harness/built_in/planning/test.rs | 21 +++++++++--
2 files changed, 53 insertions(+), 21 deletions(-)
diff --git a/src/harness/built_in/planning.rs b/src/harness/built_in/planning.rs
index 8946ca3ba..e8640b5eb 100644
--- a/src/harness/built_in/planning.rs
+++ b/src/harness/built_in/planning.rs
@@ -1884,29 +1884,48 @@ fn resolve_assignee_candidates(
/// baseline teammates, or between two company teammates, carries no such
/// signal and is left untouched — issue #1106's park-and-ask stands for both.
///
-/// A candidate id that does not resolve to a manifest agent (a desk, or an
-/// overlay teammate — [`OverlayAgent`](crate::ports::types::OverlayAgent) has
-/// no `global` field, so it can never be one) counts as company-side: only a
-/// manifest agent explicitly marked `global` is baseline.
+/// A candidate id resolves to exactly one of three provenances: a manifest
+/// agent marked `global` is [`Baseline`](Provenance::Baseline); a manifest
+/// agent that is not, or an overlay teammate — which
+/// [`OverlayAgent`](crate::ports::types::OverlayAgent) can never be, having no
+/// `global` field at all — is [`Company`](Provenance::Company); anything else
+/// `resolve_assignee_candidates` could still have handed back (a desk) is
+/// neither. A desk is not the company's own choice of *teammate*, so its mere
+/// presence must not stand in for a real one: it neither triggers the drop nor
+/// is dropped by it, on either side of the tie.
+enum Provenance {
+ Baseline,
+ Company,
+}
+
+fn provenance_of(evidence: &Evidence, id: &str) -> Option {
+ if let Some(agent) = evidence.record.manifest.agents.iter().find(|a| a.id == id) {
+ return Some(if agent.global {
+ Provenance::Baseline
+ } else {
+ Provenance::Company
+ });
+ }
+ if evidence.record.overlay_agents.iter().any(|a| a.id == id) {
+ return Some(Provenance::Company);
+ }
+ None
+}
+
fn prefer_company_over_baseline(
evidence: &Evidence,
candidates: Vec,
) -> Vec {
- let is_baseline = |id: &str| {
- evidence
- .record
- .manifest
- .agents
- .iter()
- .find(|a| a.id == id)
- .is_some_and(|a| a.global)
- };
- let has_company_side = candidates.iter().any(|c| !is_baseline(&c.id));
- let has_baseline = candidates.iter().any(|c| is_baseline(&c.id));
- if has_company_side && has_baseline {
+ let has_company = candidates
+ .iter()
+ .any(|c| matches!(provenance_of(evidence, &c.id), Some(Provenance::Company)));
+ let has_baseline = candidates
+ .iter()
+ .any(|c| matches!(provenance_of(evidence, &c.id), Some(Provenance::Baseline)));
+ if has_company && has_baseline {
candidates
.into_iter()
- .filter(|c| !is_baseline(&c.id))
+ .filter(|c| !matches!(provenance_of(evidence, &c.id), Some(Provenance::Baseline)))
.collect()
} else {
candidates
diff --git a/src/harness/built_in/planning/test.rs b/src/harness/built_in/planning/test.rs
index cd49b9f7b..4b422d6ec 100644
--- a/src/harness/built_in/planning/test.rs
+++ b/src/harness/built_in/planning/test.rs
@@ -1857,10 +1857,8 @@ fn prefer_company_over_baseline_drops_only_a_true_mixed_tie() {
vec!["maya"]
);
- // A teammate and a desk: neither resolves to a baseline agent, so both
- // count as company-side and the tie is untouched — #1106's case, and the
- // reason an unresolved id (a desk) must default to company-side rather
- // than silently misfiring as baseline.
+ // A teammate and a desk: no baseline teammate in the tie (the desk isn't
+ // one), so nothing is dropped — #1106's case.
let teammate_and_desk =
prefer_company_over_baseline(&evidence, vec![candidate("maya"), candidate("studio")]);
assert_eq!(
@@ -1869,6 +1867,21 @@ fn prefer_company_over_baseline_drops_only_a_true_mixed_tie() {
"no baseline teammate in the tie"
);
+ // A baseline teammate and a desk: a desk is not the company's own choice
+ // of *teammate*, so its presence must not stand in for one and silently
+ // knock the real baseline candidate out of a tie nobody actually resolved
+ // in the company's favour.
+ let baseline_and_desk =
+ prefer_company_over_baseline(&evidence, vec![candidate("sam"), candidate("studio")]);
+ assert_eq!(
+ baseline_and_desk
+ .iter()
+ .map(|c| c.id.as_str())
+ .collect::>(),
+ vec!["sam", "studio"],
+ "a desk is neutral: it neither triggers the drop nor gets dropped by it"
+ );
+
// A single baseline candidate, alone: nothing to prefer it over.
let solo_baseline = prefer_company_over_baseline(&evidence, vec![candidate("sam")]);
assert_eq!(
From 450e594fe3fb13d251a61df9362db430bc9dc3e3 Mon Sep 17 00:00:00 2001
From: cyrus
Date: Thu, 20 Aug 2026 18:21:49 +0530
Subject: [PATCH 12/14] fix: workflow-list-columns e2e mock serves the runs
envelope shape
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The route stub for GET …/workflows/runs still fulfilled with a bare
JSON array. Since #1012 wrapped that response as { runs, hasMore }, the
console's fetch never found `data.runs` and the list never rendered,
timing out both geometry specs in this file (CI: Console E2E and
Console E2E (live brain)).
---
frontend/test/e2e/workflow-list-columns.spec.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/frontend/test/e2e/workflow-list-columns.spec.ts b/frontend/test/e2e/workflow-list-columns.spec.ts
index 8a4cd5288..ba38a5ff2 100644
--- a/frontend/test/e2e/workflow-list-columns.spec.ts
+++ b/frontend/test/e2e/workflow-list-columns.spec.ts
@@ -101,7 +101,11 @@ async function openList(page: Page) {
(url) => isRunPage(url),
async (route: Route) => {
if (route.request().method() !== "GET") return route.fallback();
- await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(RUNS) });
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ runs: RUNS, hasMore: false }),
+ });
},
);
await page.addInitScript(() => {
From 32fb7cbe83118fdc97d45e6acdc5647d1e3c9ec5 Mon Sep 17 00:00:00 2001
From: sanil-23
Date: Thu, 20 Aug 2026 18:46:26 +0530
Subject: [PATCH 13/14] fix: auto-approve LocalAcpAgent permission requests,
copied from buzz-agent (#1245)
LocalAcpAgent previously failed closed on every session/request_permission
call it wasn't explicitly configured to allow. Replace that with
AutoApprovingFiles, mirroring buzz-agent's handle_permission_request: pick
the allow_once-kind option the CLI offered, falling back to reject_once/
reject_always, never a hardcoded optionId. The CLI's own permission mode is
the trust boundary, same as running it interactively.
Co-Authored-By: Claude
---
docs/spec/runtime/harnesses.md | 17 +++---
src-tauri/src/acp/client.rs | 88 ++++++++++++++++++++++++++++++++
src-tauri/src/acp/local_agent.rs | 26 +++++-----
src-tauri/tests/acp_client.rs | 23 ++++++++-
4 files changed, 134 insertions(+), 20 deletions(-)
diff --git a/docs/spec/runtime/harnesses.md b/docs/spec/runtime/harnesses.md
index b1c4a6da1..e0f407755 100644
--- a/docs/spec/runtime/harnesses.md
+++ b/docs/spec/runtime/harnesses.md
@@ -250,13 +250,16 @@ silent fallback either.
- **`[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 — **not yet true for `local`'s own permission prompts**
- (`session/request_permission`): `LocalAcpAgent` fails closed on every one it
- was not explicitly configured to allow, rather than routing it through
- `ApprovalRequestQueue`. Safe (a refusal is a visible, actionable failure; a
- silent auto-approval would not be), but a known gap, not the intended
- end state.
+ 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
diff --git a/src-tauri/src/acp/client.rs b/src-tauri/src/acp/client.rs
index 21eab4731..e0ee257fa 100644
--- a/src-tauri/src/acp/client.rs
+++ b/src-tauri/src/acp/client.rs
@@ -135,6 +135,58 @@ impl ClientHandler for ConfinedFiles {
}
}
+/// Wraps another handler's file logic but answers every permission request
+/// itself, picking by option `kind` rather than a caller-configured id.
+///
+/// Ported from how `buzz-agent` handles the same protocol gap
+/// (`crates/buzz-acp/src/acp.rs::handle_permission_request`): finds the
+/// option whose `kind` is `allow_once`, falling back to `reject_once` /
+/// `reject_always` if the agent offered no allow option at all. Never a
+/// hardcoded `optionId` — adapters name their ids however they like, and only
+/// `kind` is part of the ACP spec's stable vocabulary.
+///
+/// This is `LocalAcpAgent`'s production handler: an ACP agent's own
+/// permission-mode config option (the same `session/set_config_option` lever
+/// already used for model steering) is meant to keep it from asking at all,
+/// and this is the fallback for whatever still does — never a hang, never a
+/// silent refusal that reads as the harness doing nothing.
+pub struct AutoApprovingFiles {
+ inner: H,
+}
+
+impl AutoApprovingFiles {
+ pub fn new(inner: H) -> Self {
+ Self { inner }
+ }
+}
+
+#[async_trait::async_trait]
+impl ClientHandler for AutoApprovingFiles {
+ async fn read_text_file(&self, path: &Path) -> Result {
+ self.inner.read_text_file(path).await
+ }
+
+ async fn write_text_file(&self, path: &Path, content: &str) -> Result<(), String> {
+ self.inner.write_text_file(path, content).await
+ }
+
+ async fn request_permission(&self, _tool_call: &Value, options: &Value) -> String {
+ let by_kind = |kind: &str| {
+ options.as_array().and_then(|list| {
+ list.iter()
+ .find(|o| o["kind"].as_str() == Some(kind))
+ .and_then(|o| o["optionId"].as_str())
+ })
+ };
+ by_kind("allow_once")
+ .or_else(|| by_kind("reject_once"))
+ .or_else(|| by_kind("reject_always"))
+ .map(str::to_string)
+ // Nothing offered at all: say so rather than inventing an id.
+ .unwrap_or_else(|| "reject".to_string())
+ }
+}
+
type Pending = Arc>>>>;
/// One spawned harness.
@@ -400,3 +452,39 @@ async fn serve(method: &str, params: &Value, handler: &dyn ClientHandler) -> Res
other => Err(format!("unsupported client method: {other}")),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::acp::confine::Confinement;
+
+ fn auto_approving(root: &std::path::Path) -> AutoApprovingFiles {
+ AutoApprovingFiles::new(ConfinedFiles::new(Confinement::new(root).unwrap(), None))
+ }
+
+ #[tokio::test]
+ async fn falls_back_to_reject_once_when_the_agent_offers_no_allow_option() {
+ let dir = tempfile::tempdir().unwrap();
+ let handler = auto_approving(dir.path());
+ let options = json!([
+ { "optionId": "n1", "name": "Reject", "kind": "reject_once" },
+ { "optionId": "n2", "name": "Reject always", "kind": "reject_always" },
+ ]);
+
+ assert_eq!(
+ handler.request_permission(&Value::Null, &options).await,
+ "n1"
+ );
+ }
+
+ #[tokio::test]
+ async fn falls_back_to_the_literal_reject_when_the_agent_offers_nothing_to_pick() {
+ let dir = tempfile::tempdir().unwrap();
+ let handler = auto_approving(dir.path());
+
+ assert_eq!(
+ handler.request_permission(&Value::Null, &json!([])).await,
+ "reject"
+ );
+ }
+}
diff --git a/src-tauri/src/acp/local_agent.rs b/src-tauri/src/acp/local_agent.rs
index 19760ae04..99599b3f6 100644
--- a/src-tauri/src/acp/local_agent.rs
+++ b/src-tauri/src/acp/local_agent.rs
@@ -12,16 +12,16 @@
//! arrives, and a `prompt` call drains only its own session's buffer after
//! `session/prompt` returns rather than reading whatever the sink last saw.
//!
-//! ## Permission requests: fails closed (deliberate, and a known gap)
+//! ## Permission requests: copied from `buzz-agent`, not bridged to the queue
//!
-//! `docs/spec/runtime/harnesses.md` says an ACP agent "is still subject to
-//! the company's approval policy" — this does not yet route ACP permission
-//! requests through that policy gate; it refuses every one it did not
-//! explicitly configure to allow, via the same [`ConfinedFiles`] the fixture
-//! tests already use. That is the safe direction to be wrong in: a refused
-//! edit is a visible failure the operator can act on, where a silently
-//! auto-approved one would not be. Wiring ACP's `session/request_permission`
-//! into `ApprovalRequestQueue` is real follow-up work, not done here.
+//! An earlier draft routed ACP `session/request_permission` calls through
+//! `ApprovalRequestQueue` and, until that landed, refused every request by
+//! default. `buzz-agent` (`crates/buzz-acp`) answers a much simpler question
+//! instead — trust the CLI's own permission mode, and auto-approve whatever
+//! it still asks about — and that is what this does too, via
+//! [`AutoApprovingFiles`]. There is no human-approval queue in the loop here;
+//! an ACP-run teammate's own CLI is the trust boundary, the same as it is for
+//! a developer running that CLI interactively themselves.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -35,7 +35,7 @@ use opencompany::ports::types::CompanyId;
use serde_json::Value;
use tokio::sync::Mutex as AsyncMutex;
-use crate::acp::client::{AcpClient, ClientHandler, ConfinedFiles};
+use crate::acp::client::{AcpClient, AutoApprovingFiles, ClientHandler, ConfinedFiles};
use crate::acp::confine::Confinement;
use crate::acp::discovery::HARNESSES;
@@ -120,8 +120,10 @@ impl LocalAcpAgent {
})?;
let confinement = Confinement::new(&self.workspace_root)
.map_err(|error| OpenCompanyError::Config(format!("acp workspace: {error}")))?;
- // V1 fails closed — see the module docs.
- let handler: Arc = Arc::new(ConfinedFiles::new(confinement, None));
+ // Auto-approves permission requests by kind — see the module docs.
+ let handler: Arc = Arc::new(AutoApprovingFiles::new(
+ ConfinedFiles::new(confinement, None),
+ ));
let pending = Arc::clone(&self.pending_updates);
let sink: crate::acp::client::UpdateSink = Arc::new(move |update: Value| {
diff --git a/src-tauri/tests/acp_client.rs b/src-tauri/tests/acp_client.rs
index bcc95e72c..2fa93fd45 100644
--- a/src-tauri/tests/acp_client.rs
+++ b/src-tauri/tests/acp_client.rs
@@ -10,7 +10,9 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
-use opencompany_desktop_lib::acp::client::{AcpClient, AcpError, ClientHandler, ConfinedFiles};
+use opencompany_desktop_lib::acp::client::{
+ AcpClient, AcpError, AutoApprovingFiles, ClientHandler, ConfinedFiles,
+};
use opencompany_desktop_lib::acp::confine::Confinement;
use serde_json::Value;
@@ -230,6 +232,25 @@ async fn an_option_the_agent_never_offered_is_not_echoed_back() {
assert_eq!(updates.said(), "chose:no");
}
+#[tokio::test]
+async fn auto_approving_files_picks_the_allow_once_option_unprompted() {
+ // `LocalAcpAgent`'s production handler (issue #1245) — the opposite of
+ // `permission_defaults_to_refusing_rather_than_allowing` above by design:
+ // no configured id at all, yet it still answers "yes", because it looks
+ // at `kind` rather than needing one told to it.
+ let dir = tempfile::tempdir().unwrap();
+ let root = dir.path().canonicalize().unwrap();
+ let handler: Arc = Arc::new(AutoApprovingFiles::new(ConfinedFiles::new(
+ Confinement::new(&root).unwrap(),
+ None,
+ )));
+ let (client, updates) = connect(&root, handler).await;
+ let session = client.new_session(&root).await.unwrap();
+
+ client.prompt(&session, "ask").await.unwrap();
+ assert_eq!(updates.said(), "chose:yes");
+}
+
#[tokio::test]
async fn a_harness_that_dies_mid_turn_fails_the_caller_instead_of_hanging() {
// An ordinary event — a crash, an OOM kill — and the caller has to hear
From f81ea96fd905ce1c42ca8f7baed4bc463902e98c Mon Sep 17 00:00:00 2001
From: sanil-23
Date: Thu, 20 Aug 2026 19:02:52 +0530
Subject: [PATCH 14/14] feat: expose ACP harness readiness as a Tauri command
(#1245)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Not yet wired into generate_handler! or called by the frontend — this is
the read-only probe (acp::discovery::survey) on its own IPC surface, ready
for the UI work that consumes it.
Co-Authored-By: Claude
---
src-tauri/src/commands.rs | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index d479f47b7..3b2bc4b30 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -398,6 +398,19 @@ pub async fn oc_forget_local_instance(
local.forget(&id)
}
+/// Every coding harness this shell knows how to drive over ACP, and whether
+/// each is actually usable right now.
+///
+/// Takes no state and no connection id: unlike everything else in this file,
+/// readiness is a property of *this machine*, not of a host it talks to. The
+/// probe reads `PATH` and the credential files each harness keeps under the
+/// user's home — see `acp::discovery`'s module docs for why that is checked by
+/// file rather than by starting the harness.
+#[tauri::command]
+pub fn oc_acp_harnesses() -> Vec {
+ crate::acp::discovery::survey(&crate::acp::discovery::SystemProbe)
+}
+
#[cfg(test)]
mod test {
use std::sync::Arc;