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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ Workflows are named, ordered recipes the agent follows step by step — a thin l
- `coordinator.ts` — bridges runtime and director: produces the `[WORKFLOW STEP i/total: label]` directive injected into each turn's system prompt, and compare-and-advances the runtime when a `submit_output` tagged `{ step }` completes. Already-complete and not-current ids are acknowledged without moving the cursor. Shared by both directors. Fresh and resumed runs share one listener path.
- The built-in recipes: the atomics `update-ticket`, `improve-docs`, `write-tests`, `triage-bug`, `code-review`, `scope-project`, and the `build-feature` composite that chains them.

Invocation: workflows are **not** top-level slash commands. Recipe definitions load into the `WORKFLOWS` registry from **enabled workflow/command plugins** at startup; command surfaces on those plugins (e.g. a workflow plugin's command prefix such as `/mywf scope`). Slash commands may also be authored as data-only markdown (`commands/*.md`, no `index.ts`); see PLUGINS.md. The model never suggests or auto-starts workflows from ordinary chat. Skills (bundled `corbits-skills`, enabled plugins, or `.agents/skills/`) load on demand via `skill_search` then `use_skill`, or as `/<skill-name>` slash commands when `user-invocable` is not `false` (see Skills below). The TUI surfaces state via `src/tui/workflow-controller.ts` (lifecycle, capability overrides, resume) — the header shows step progress (`⟳ name · step/total label`).
Invocation: workflows are **not** top-level slash commands. Recipe definitions load into the `WORKFLOWS` registry from **enabled workflow/command plugins** at startup; command surfaces on those plugins (e.g. a workflow plugin's command prefix such as `/mywf scope`). Slash commands may also be authored as data-only markdown (`commands/*.md`, no `index.ts`); see PLUGINS.md. The model never suggests or auto-starts workflows from ordinary chat. Skills (bundled `corbits-skills`, enabled plugins, or `.agents/skills/`) load on demand via `skill_search` then `use_skill`, or as `/<skill-name>` slash commands when `user-invocable` is not `false` (see Skills below). `WorkflowHost` (`src/workflows/host.ts`) owns lifecycle, capability overrides, and resume; the TUI only renders host status in the header (`⟳ name · step/total label`).

### Fleet agents (`src/subagent/`, `src/agent/agent-search.ts`)

Expand Down
32 changes: 17 additions & 15 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,9 +400,9 @@ Session runtime state lives under the global projects tree (not in the repo):
- Migration: if a session exists only under in-repo `.agent-state/<session-id>/`, it is moved into the global tree on open/list
- Atomic JSON writes with schema validation on load

**Worker audit persistence.** Workers initialize a real `@intx/storage-isogit`
`AuditStore` at `<worker-workdir>/audit-store` (`src/subagent/run.ts`), separate
from the native context store's Git index. Initialization failure prevents worker
**Worker audit persistence.** Workers get context and audit from one
`createSessionStores` call on the worker workdir: the same isomorphic-git
repo, one index. Initialization failure prevents worker
execution. The existing agent-owned audit and error collectors persist at
checkpoint and shutdown; retained worker sessions flush at checkpoint/resume and
close. The parent still supplies `noopAuditStore()`: collectors exist there too,
Expand All @@ -415,18 +415,19 @@ error collector; there is no added retry subsystem or side-effect rollback.

`createOptimizedContextStore` (`src/session/optimized-context-store.ts`) wraps the
Interchange git store to keep per-checkpoint cost independent of session length.
Checkpoint commits go through system git and use the operator's global
`user.name` / `user.email` when both are set, so commit-author hooks see a real
identity; otherwise they fall back to Interchange's harness author
(`interchange-harness`, `harness@interchange.local`).
Checkpoint commits go through isomorphic-git (`base.commit()` after staging extra
segment files and blobs). Author and committer are Interchange's harness identity
(`interchange-harness`, `harness@interchange.local`); a `CommitSigner` from
`commit-signer.ts` signs each commit. The wrapper never shells out to system git.
The append-only snapshots (`turns.jsonl`, `prompt.jsonl`) are written as rolling
segments (`turns-0001.jsonl`, ...) that seal at 256KB, so `git add` re-hashes only
the small active segment instead of the whole growing file. Segment zero keeps the
original filename, so a legacy monolithic `turns.jsonl` reads back as its own first
segment. `load` and `readAt` concatenate every segment in order; a torn final line
in the active segment (from a crash mid-write) is dropped on resume. Only tool-output
blobs new since the last commit are staged, and stale segments deleted by a
history rewrite (compaction) are removed from the tree on the next commit. The
in the active segment (from a crash mid-write) is dropped on resume. The wrapper
stages extra tool-output blobs it wrote since the last commit; vendor
`base.commit()` also stages the whole `tool-output/` tree. Stale segments deleted
by a history rewrite (compaction) are removed from the tree on the next commit. The
per-commit git tree still grows one entry per spilled tool-output blob across the
session; that tree re-write is inherent to git and left as residual cost.

Expand Down Expand Up @@ -485,13 +486,14 @@ Corbits Code v0.3 memory and stall hardening is implemented under `src/`, `tests

### Bounded audit collector retention between checkpoints

Agent-owned audit collectors buffer completed tool results until checkpoint or
shutdown flush, including when a noop store is supplied. Workers use the durable
store described under State Persistence; the parent's noop store does not make
collector retention inapplicable. Long, checkpoint-sparse runs can retain
Production chat and sub-agent assembly persist audit via the same isogit
object as context storage (`createSessionStores`), plus a stable `sessionId`.
Agent-owned audit collectors still buffer completed tool results until
checkpoint or shutdown flush. Long, checkpoint-sparse runs can retain
unbounded results. Bounded retention remains owned by the `@intx/inference`
audit collector: opportunistic flushing or capped result bodies must preserve
metadata.
metadata. If a collector is introduced in front of the isogit audit methods,
add a bounded wrapper in `src/` and re-run hardening tests.

Other wave items (read bounds, shell truncation, process-group kill, grep caps, plugin spawn mitigation, per-tool watchdog, inference retry UX) are implemented or partially mitigated in `src/` with co-located tests; the two items above remain upstream-owned.

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"dependencies": {
"@intx/agent": "workspace:*",
"@intx/authz": "workspace:*",
"@intx/crypto": "0.3.0",
"@intx/inference": "workspace:*",
"@intx/log": "workspace:*",
"@intx/storage-isogit": "workspace:*",
Expand All @@ -97,7 +98,8 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentui/core": "0.5.10",
"arktype": "catalog:",
"highlight.js": "^11.11.1"
"highlight.js": "^11.11.1",
"isomorphic-git": "catalog:"
},
"devDependencies": {
"@eslint/js": "^9.39.0",
Expand Down
2 changes: 1 addition & 1 deletion scripts/guard-real-projects-dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ async function main(): Promise<void> {
leaked.map((name) => ` ${name}`).join("\n") +
"\n\nA test must pass an explicit `home` (mkdtemp'd) through to any " +
"function that otherwise defaults to node:os homedir() — see " +
"tests/unit/workflow-controller.test.ts for the pattern.\n",
"tests/unit/workflow-host.test.ts for the pattern.\n",
);
process.exit(1);
}
Expand Down
17 changes: 15 additions & 2 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js";
import type { ReactorEmittedEvent } from "@intx/inference";
import { setAgentSourceUnlessClosed } from "../tui/agent-source-sync.js";
import { getToolApprovalBudget } from "../tui/tool-execution-watchdog.js";
import { WorkflowHost } from "../workflows/host.js";

const logger = getLogger([LOG_NAMESPACE_ROOT, "exec"]);

Expand Down Expand Up @@ -472,6 +473,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
let currentStorage: ContextStore | null = null;

const overlay = resolveExecDirectorOverlay(config.director);
const workflowHostHolder: { instance?: WorkflowHost } = {};

const agentToolset = await createAgentToolset({
cwd: config.cwd,
Expand All @@ -494,8 +496,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
}
return currentAgent.blobReader;
},
// Exec has no workflow controller — intentional delta vs TUI.
isWorkflowActive: () => false,
isWorkflowActive: () => workflowHostHolder.instance?.isActive() === true,
completeWorkflowStep: (stepId) =>
workflowHostHolder.instance?.complete(stepId) ?? "not-current",
onOperatorGate: (question, options) => promptOperator(question, options, interactive),
sessionMode,
toolAvailability,
Expand Down Expand Up @@ -615,6 +618,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
},
getProvider: () => config,
getWorkdir: () => workdir,
getSessionId: () => sessionId,
authorize: createReactorAuthorize(permissionGate),
inferenceDeps,
getSources: () => {
Expand All @@ -637,6 +641,14 @@ export async function runExec(config: Config): Promise<ExecResult> {
},
});

const workflowHost = new WorkflowHost({
cwd: config.cwd,
getSessionId: () => sessionId,
getToolDefinitions: () => agentToolset.dynamicRunner.currentDefinitions(),
getDirector: () => directorHolder.instance,
});
workflowHostHolder.instance = workflowHost;

const emitter = new EventEmitter();
const {
hookManager,
Expand Down Expand Up @@ -681,6 +693,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
});
});
}
await workflowHost.resume();

const textChunks: string[] = [];
// Cycles persist to the context store only on inference.done; the recorder
Expand Down
37 changes: 31 additions & 6 deletions src/session/assemble-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Agent } from "@intx/agent";
import type { Compactor, ContextStore, ToolDefinition } from "@intx/types/runtime";
import type { AuditStore, Compactor, ContextStore, ToolDefinition } from "@intx/types/runtime";

import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
import {
Expand Down Expand Up @@ -109,22 +109,25 @@ function stubInferenceDeps(): ChatAgentWiring["inferenceDeps"] {
}

describe("assembleChatAgent", () => {
test("getWorkdir and getCompactor run at buildAgent time, not assemble time", async () => {
test("getWorkdir, getSessionId, and getCompactor run at buildAgent time", async () => {
const storeDirs: string[] = [];
const agentWorkdirs: string[] = [];
const agentSessionIds: string[] = [];
const agentAudits: AuditStore[] = [];
const agentStorages: ContextStore[] = [];
const agentCompactors: Compactor[] = [];
const fakeStorage = {
readBlob: async () => new Uint8Array(),
} as unknown as ContextStore;
} as unknown as ContextStore & AuditStore;
const fakeAgent = { close: async () => {} } as unknown as Agent;

await withMockedModuleDuring(
import.meta.resolve("./optimized-context-store.js"),
(real: typeof import("./optimized-context-store.js")) => ({
...real,
createOptimizedContextStore: async (dir: string) => {
createSessionStores: async (dir: string) => {
storeDirs.push(dir);
return fakeStorage;
return { storage: fakeStorage, audit: fakeStorage };
},
}),
async () => {
Expand All @@ -134,18 +137,29 @@ describe("assembleChatAgent", () => {
...real,
createAgentWithLiveToolDispatch: async (
_def: unknown,
env: { workdir: string; compactors: { "pruning-compactor": Compactor } },
env: {
workdir: string;
sessionId?: string;
storage: ContextStore;
audit: AuditStore;
compactors: { "pruning-compactor": Compactor };
},
) => {
agentWorkdirs.push(env.workdir);
if (env.sessionId !== undefined) agentSessionIds.push(env.sessionId);
agentStorages.push(env.storage);
agentAudits.push(env.audit);
agentCompactors.push(env.compactors["pruning-compactor"]);
return fakeAgent;
},
}),
async () => {
const { assembleChatAgent } = await import("./assemble-runtime.js");
const workdirCalls: string[] = [];
const sessionIdCalls: string[] = [];
const compactorCalls: string[] = [];
let liveDir = "/assemble-dir";
let liveSessionId = "assemble-session";
let liveCompactor = stubCompactor("assemble");

const { buildAgent } = assembleChatAgent({
Expand All @@ -166,6 +180,10 @@ describe("assembleChatAgent", () => {
workdirCalls.push(liveDir);
return liveDir;
},
getSessionId: () => {
sessionIdCalls.push(liveSessionId);
return liveSessionId;
},
inferenceDeps: stubInferenceDeps(),
getSources: () => [
{
Expand All @@ -185,20 +203,27 @@ describe("assembleChatAgent", () => {
});

expect(workdirCalls).toEqual([]);
expect(sessionIdCalls).toEqual([]);
expect(compactorCalls).toEqual([]);
expect(storeDirs).toEqual([]);
expect(agentWorkdirs).toEqual([]);

liveDir = "/build-dir";
liveSessionId = "build-session";
liveCompactor = stubCompactor("build");
const builtCompactor = liveCompactor;

await buildAgent();

expect(workdirCalls).toEqual(["/build-dir"]);
expect(sessionIdCalls).toEqual(["build-session"]);
expect(compactorCalls).toEqual(["build"]);
expect(storeDirs).toEqual(["/build-dir"]);
expect(agentWorkdirs).toEqual(["/build-dir"]);
expect(agentSessionIds).toEqual(["build-session"]);
expect(agentStorages).toEqual([fakeStorage]);
expect(agentAudits).toEqual([fakeStorage]);
expect(Object.is(agentAudits[0], agentStorages[0])).toBe(true);
expect(agentCompactors).toEqual([builtCompactor]);
},
);
Expand Down
10 changes: 6 additions & 4 deletions src/session/assemble-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
type Agent,
type AuthorizeFn,
} from "@intx/agent";
import { noopAuditStore } from "@intx/agent/testing";
import type { Compactor, ContextStore, InferenceSource, ToolDefinition } from "@intx/types/runtime";
import { type } from "arktype";

Expand All @@ -48,7 +47,7 @@ import { createChatDirector, type ChatDirector } from "../agent/director.js";
import type { Task } from "../agent/tasks.js";
import type { AgentToolset } from "../agent/tools.js";
import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js";
import { createOptimizedContextStore } from "./optimized-context-store.js";
import { createSessionStores } from "./optimized-context-store.js";
import { createAttachmentRehydrateTransform } from "./attachment-store.js";
import {
loadProjectTrust,
Expand Down Expand Up @@ -353,6 +352,8 @@ export interface ChatAgentWiring {
authorize: AuthorizeFn;
/** Read at each build so /clear and workdir rotation use the live store path. */
getWorkdir: () => string;
/** Read at each build so /clear and session rotation stamp the live session id. */
getSessionId: () => string;
inferenceDeps: Awaited<ReturnType<typeof createInferenceDependencies>>;
getSources: () => InferenceSource[];
getDefaultSource: () => string;
Expand Down Expand Up @@ -425,7 +426,7 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {

const buildAgent = async (): Promise<Agent> => {
const workdir = wiring.getWorkdir();
const storage = await createOptimizedContextStore(workdir);
const { storage, audit } = await createSessionStores(workdir);
const agent = await createAgentWithLiveToolDispatch(agentDef, {
sources: wiring.getSources(),
defaultSource: wiring.getDefaultSource(),
Expand All @@ -438,7 +439,8 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
...wiring.inferenceDeps,
contextTransforms: [createAttachmentRehydrateTransform((key) => storage.readBlob(key))],
},
audit: noopAuditStore(),
audit,
sessionId: wiring.getSessionId(),
// Gate-backed reactor authorization: ask-tier calls suspend via the
// vendored approval-suspend primitive instead of parking on a closure.
authorize: wiring.authorize,
Expand Down
Loading
Loading