Skip to content

Commit 044fd6a

Browse files
Run session git and workflow lifecycle outside the TUI (#854)
* Commit session checkpoints through isomorphic git * Thread isogit audit methods into agent assembly * Move workflow lifecycle out of the terminal UI * Format session store and documentation * Persist session commit keys with exclusive create Write first-time commit keys with wx and reload on EEXIST. Wrap JSON.parse failures as Invalid commit signing key. Stop listIndexPaths from swallowing every error. Treat SessionStores as an interface. Drop the IMPLEMENTATION.md claim that exclusive-delta blob staging is in place. * Flash workflow status from the product host The runner now emits workflow. The product host subscribes and flashes the active step or complete via existing notices. * Read worker audit from the shared session store
1 parent 63a8582 commit 044fd6a

27 files changed

Lines changed: 876 additions & 403 deletions

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ Workflows are named, ordered recipes the agent follows step by step — a thin l
198198
- `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.
199199
- 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.
200200

201-
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`).
201+
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`).
202202

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

docs/IMPLEMENTATION.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -400,9 +400,9 @@ Session runtime state lives under the global projects tree (not in the repo):
400400
- Migration: if a session exists only under in-repo `.agent-state/<session-id>/`, it is moved into the global tree on open/list
401401
- Atomic JSON writes with schema validation on load
402402

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

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

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

486487
### Bounded audit collector retention between checkpoints
487488

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

496498
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.
497499

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"dependencies": {
8383
"@intx/agent": "workspace:*",
8484
"@intx/authz": "workspace:*",
85+
"@intx/crypto": "0.3.0",
8586
"@intx/inference": "workspace:*",
8687
"@intx/log": "workspace:*",
8788
"@intx/storage-isogit": "workspace:*",
@@ -97,7 +98,8 @@
9798
"@modelcontextprotocol/sdk": "^1.29.0",
9899
"@opentui/core": "0.5.10",
99100
"arktype": "catalog:",
100-
"highlight.js": "^11.11.1"
101+
"highlight.js": "^11.11.1",
102+
"isomorphic-git": "catalog:"
101103
},
102104
"devDependencies": {
103105
"@eslint/js": "^9.39.0",

scripts/guard-real-projects-dir.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ async function main(): Promise<void> {
8686
leaked.map((name) => ` ${name}`).join("\n") +
8787
"\n\nA test must pass an explicit `home` (mkdtemp'd) through to any " +
8888
"function that otherwise defaults to node:os homedir() — see " +
89-
"tests/unit/workflow-controller.test.ts for the pattern.\n",
89+
"tests/unit/workflow-host.test.ts for the pattern.\n",
9090
);
9191
process.exit(1);
9292
}

src/exec/runner.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js";
7575
import type { ReactorEmittedEvent } from "@intx/inference";
7676
import { setAgentSourceUnlessClosed } from "../tui/agent-source-sync.js";
7777
import { getToolApprovalBudget } from "../tui/tool-execution-watchdog.js";
78+
import { WorkflowHost } from "../workflows/host.js";
7879

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

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

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

476478
const agentToolset = await createAgentToolset({
477479
cwd: config.cwd,
@@ -494,8 +496,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
494496
}
495497
return currentAgent.blobReader;
496498
},
497-
// Exec has no workflow controller — intentional delta vs TUI.
498-
isWorkflowActive: () => false,
499+
isWorkflowActive: () => workflowHostHolder.instance?.isActive() === true,
500+
completeWorkflowStep: (stepId) =>
501+
workflowHostHolder.instance?.complete(stepId) ?? "not-current",
499502
onOperatorGate: (question, options) => promptOperator(question, options, interactive),
500503
sessionMode,
501504
toolAvailability,
@@ -615,6 +618,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
615618
},
616619
getProvider: () => config,
617620
getWorkdir: () => workdir,
621+
getSessionId: () => sessionId,
618622
authorize: createReactorAuthorize(permissionGate),
619623
inferenceDeps,
620624
getSources: () => {
@@ -637,6 +641,14 @@ export async function runExec(config: Config): Promise<ExecResult> {
637641
},
638642
});
639643

644+
const workflowHost = new WorkflowHost({
645+
cwd: config.cwd,
646+
getSessionId: () => sessionId,
647+
getToolDefinitions: () => agentToolset.dynamicRunner.currentDefinitions(),
648+
getDirector: () => directorHolder.instance,
649+
});
650+
workflowHostHolder.instance = workflowHost;
651+
640652
const emitter = new EventEmitter();
641653
const {
642654
hookManager,
@@ -681,6 +693,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
681693
});
682694
});
683695
}
696+
await workflowHost.resume();
684697

685698
const textChunks: string[] = [];
686699
// Cycles persist to the context store only on inference.done; the recorder

src/session/assemble-runtime.test.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdtemp } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import type { Agent } from "@intx/agent";
6-
import type { Compactor, ContextStore, ToolDefinition } from "@intx/types/runtime";
6+
import type { AuditStore, Compactor, ContextStore, ToolDefinition } from "@intx/types/runtime";
77

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

111111
describe("assembleChatAgent", () => {
112-
test("getWorkdir and getCompactor run at buildAgent time, not assemble time", async () => {
112+
test("getWorkdir, getSessionId, and getCompactor run at buildAgent time", async () => {
113113
const storeDirs: string[] = [];
114114
const agentWorkdirs: string[] = [];
115+
const agentSessionIds: string[] = [];
116+
const agentAudits: AuditStore[] = [];
117+
const agentStorages: ContextStore[] = [];
115118
const agentCompactors: Compactor[] = [];
116119
const fakeStorage = {
117120
readBlob: async () => new Uint8Array(),
118-
} as unknown as ContextStore;
121+
} as unknown as ContextStore & AuditStore;
119122
const fakeAgent = { close: async () => {} } as unknown as Agent;
120123

121124
await withMockedModuleDuring(
122125
import.meta.resolve("./optimized-context-store.js"),
123126
(real: typeof import("./optimized-context-store.js")) => ({
124127
...real,
125-
createOptimizedContextStore: async (dir: string) => {
128+
createSessionStores: async (dir: string) => {
126129
storeDirs.push(dir);
127-
return fakeStorage;
130+
return { storage: fakeStorage, audit: fakeStorage };
128131
},
129132
}),
130133
async () => {
@@ -134,18 +137,29 @@ describe("assembleChatAgent", () => {
134137
...real,
135138
createAgentWithLiveToolDispatch: async (
136139
_def: unknown,
137-
env: { workdir: string; compactors: { "pruning-compactor": Compactor } },
140+
env: {
141+
workdir: string;
142+
sessionId?: string;
143+
storage: ContextStore;
144+
audit: AuditStore;
145+
compactors: { "pruning-compactor": Compactor };
146+
},
138147
) => {
139148
agentWorkdirs.push(env.workdir);
149+
if (env.sessionId !== undefined) agentSessionIds.push(env.sessionId);
150+
agentStorages.push(env.storage);
151+
agentAudits.push(env.audit);
140152
agentCompactors.push(env.compactors["pruning-compactor"]);
141153
return fakeAgent;
142154
},
143155
}),
144156
async () => {
145157
const { assembleChatAgent } = await import("./assemble-runtime.js");
146158
const workdirCalls: string[] = [];
159+
const sessionIdCalls: string[] = [];
147160
const compactorCalls: string[] = [];
148161
let liveDir = "/assemble-dir";
162+
let liveSessionId = "assemble-session";
149163
let liveCompactor = stubCompactor("assemble");
150164

151165
const { buildAgent } = assembleChatAgent({
@@ -166,6 +180,10 @@ describe("assembleChatAgent", () => {
166180
workdirCalls.push(liveDir);
167181
return liveDir;
168182
},
183+
getSessionId: () => {
184+
sessionIdCalls.push(liveSessionId);
185+
return liveSessionId;
186+
},
169187
inferenceDeps: stubInferenceDeps(),
170188
getSources: () => [
171189
{
@@ -185,20 +203,27 @@ describe("assembleChatAgent", () => {
185203
});
186204

187205
expect(workdirCalls).toEqual([]);
206+
expect(sessionIdCalls).toEqual([]);
188207
expect(compactorCalls).toEqual([]);
189208
expect(storeDirs).toEqual([]);
190209
expect(agentWorkdirs).toEqual([]);
191210

192211
liveDir = "/build-dir";
212+
liveSessionId = "build-session";
193213
liveCompactor = stubCompactor("build");
194214
const builtCompactor = liveCompactor;
195215

196216
await buildAgent();
197217

198218
expect(workdirCalls).toEqual(["/build-dir"]);
219+
expect(sessionIdCalls).toEqual(["build-session"]);
199220
expect(compactorCalls).toEqual(["build"]);
200221
expect(storeDirs).toEqual(["/build-dir"]);
201222
expect(agentWorkdirs).toEqual(["/build-dir"]);
223+
expect(agentSessionIds).toEqual(["build-session"]);
224+
expect(agentStorages).toEqual([fakeStorage]);
225+
expect(agentAudits).toEqual([fakeStorage]);
226+
expect(Object.is(agentAudits[0], agentStorages[0])).toBe(true);
202227
expect(agentCompactors).toEqual([builtCompactor]);
203228
},
204229
);

src/session/assemble-runtime.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {
2222
type Agent,
2323
type AuthorizeFn,
2424
} from "@intx/agent";
25-
import { noopAuditStore } from "@intx/agent/testing";
2625
import type { Compactor, ContextStore, InferenceSource, ToolDefinition } from "@intx/types/runtime";
2726
import { type } from "arktype";
2827

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

426427
const buildAgent = async (): Promise<Agent> => {
427428
const workdir = wiring.getWorkdir();
428-
const storage = await createOptimizedContextStore(workdir);
429+
const { storage, audit } = await createSessionStores(workdir);
429430
const agent = await createAgentWithLiveToolDispatch(agentDef, {
430431
sources: wiring.getSources(),
431432
defaultSource: wiring.getDefaultSource(),
@@ -438,7 +439,8 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
438439
...wiring.inferenceDeps,
439440
contextTransforms: [createAttachmentRehydrateTransform((key) => storage.readBlob(key))],
440441
},
441-
audit: noopAuditStore(),
442+
audit,
443+
sessionId: wiring.getSessionId(),
442444
// Gate-backed reactor authorization: ask-tier calls suspend via the
443445
// vendored approval-suspend primitive instead of parking on a closure.
444446
authorize: wiring.authorize,

0 commit comments

Comments
 (0)