Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9bd7581
refactor(dedupe): remove module-level singleton export
FreshlyBrewedCode Sep 19, 2026
0da4192
refactor(pubsub): convert module singleton to instance-based createPu…
FreshlyBrewedCode Sep 19, 2026
a92317d
refactor(workspace): convert module-level refreshGates to instance-based
FreshlyBrewedCode Sep 19, 2026
184225d
refactor(runs): convert active registry to instance-based, remove tes…
FreshlyBrewedCode Sep 19, 2026
67ab45a
refactor(scheduler): remove registry?/now? test seams, use Effect Clock
FreshlyBrewedCode Sep 19, 2026
ed9b05d
refactor(daemon,http): wire per-daemon services through composition root
FreshlyBrewedCode Sep 19, 2026
bfd6fb0
test: update all tests to use per-daemon services
FreshlyBrewedCode Sep 19, 2026
2b0ef88
test: add two-daemon coexistence test (#38 acceptance criterion)
FreshlyBrewedCode Sep 19, 2026
ede22d7
fix: add port guard in two-daemon test for typecheck
FreshlyBrewedCode Sep 19, 2026
666d012
fix(stack): preserve usage accounting, domain-error messages and lint…
FreshlyBrewedCode Sep 20, 2026
4cc5fcc
chore: remove committed sandbox artifact
FreshlyBrewedCode Sep 23, 2026
b302a5e
test(scheduler): drive scheduler tests with Effect's real TestClock
FreshlyBrewedCode Sep 23, 2026
f80d592
test(scheduler): await the async fixture in the unknown-tag case
FreshlyBrewedCode Sep 23, 2026
77dc7ab
fix(runtime): use the constructed DedupeKeyError message in DispatchC…
FreshlyBrewedCode Oct 2, 2026
ed05d49
test(server): pass daemon services to the prepareWorkspace tests
FreshlyBrewedCode Oct 2, 2026
d4d38b8
fix(server): carry the agent-runtime fixes through the per-daemon ser…
FreshlyBrewedCode Oct 2, 2026
522ae4c
fix(server): restore the lower-layer wiring and rationale the rebase …
FreshlyBrewedCode Oct 2, 2026
8008b28
test(server): restore the invariant assertions the per-daemon rewrite…
FreshlyBrewedCode Oct 2, 2026
82f4f5d
fix(runs): decide admission before claiming the dedupe key
FreshlyBrewedCode Oct 2, 2026
2c94d22
refactor(daemon): compose per-daemon state as Effect layers
FreshlyBrewedCode Oct 2, 2026
6c44b33
feat(daemon): cancel active runs on shutdown
FreshlyBrewedCode Oct 2, 2026
a7839f6
fix(runtime): end an abandoned agent stream at once
FreshlyBrewedCode Oct 2, 2026
1e725cb
fix(daemon): let cancelled runs record their end after a timed-out sh…
FreshlyBrewedCode Oct 2, 2026
c2cde06
fix(runs): refuse a start whose run id is still reserving
FreshlyBrewedCode Oct 2, 2026
e21d37e
fix(daemon): build the daemon runtime's layers at startup
FreshlyBrewedCode Oct 2, 2026
40478a8
fix(runtime): bound how long a run waits for adapter teardown
FreshlyBrewedCode Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,12 @@ export const serveCommand = Command.make("serve", serveConfig, (config) =>
: "scheduler: none (no schedules in config)",
),
);
// The daemon's shutdown path: stop the scheduler and server and dispose
// the agent runtime before exiting, instead of dying with them live.
// The daemon's shutdown path: stop the scheduler, cancel every active run
// and wait (bounded) for each to record `RunCancelled` and kill its
// `ctx.exec` children, then stop the server and dispose the runtime —
// instead of dying with them live and leaving orphaned processes and
// "interrupted" runs behind. `once`: a second signal falls through to the
// default handler and kills the process outright.
yield* Effect.sync(() => {
const shutdown = (signal: NodeJS.Signals) => {
void handle.stop().finally(() => process.exit(signal === "SIGINT" ? 130 : 143));
Expand Down
27 changes: 22 additions & 5 deletions src/lib/dedupe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@
* again — a dead run must not hold a key forever. Release happens the moment
* the holding run reaches any terminal state (including cancelled/failed);
* see `startTrackedRun` in server/runs.ts.
*
* #38: "per-process" means per daemon. The registry is a `Context.Service`
* whose layer builds a fresh map each time a daemon runtime is built
* (`server/daemon-runtime.ts`), so two daemons in one process never share
* keys. `createDedupeRegistry` stays exported for unit tests of the plain
* map logic.
*/

import { Schema } from "effect";
import { Context, Layer, Schema } from "effect";

export class DedupeKeyError extends Schema.TaggedError<DedupeKeyError>()("DedupeKeyError", {
key: Schema.String,
Expand All @@ -44,7 +50,7 @@ export class DedupeKeyError extends Schema.TaggedError<DedupeKeyError>()("Dedupe
}
}

export interface DedupeRegistry {
export interface DedupeRegistryShape {
/** The run id currently holding `key`, or `undefined`. */
holderOf(key: string): string | undefined;
/**
Expand All @@ -57,7 +63,7 @@ export interface DedupeRegistry {
release(key: string, runId: string): void;
}

export function createDedupeRegistry(): DedupeRegistry {
export function createDedupeRegistry(): DedupeRegistryShape {
const held = new Map<string, string>();
return {
holderOf: (key) => held.get(key),
Expand All @@ -73,5 +79,16 @@ export function createDedupeRegistry(): DedupeRegistry {
};
}

/** The daemon's shared registry — every run start and dispatch claim in this process goes through it. */
export const dedupeRegistry: DedupeRegistry = createDedupeRegistry();
/**
* The dedupe registry as a daemon service (#38, ADR 0009 §5). Resolve it from
* the daemon runtime's context; provide it with `DedupeRegistryLayer`.
*/
export class DedupeRegistry extends Context.Service<DedupeRegistry, DedupeRegistryShape>()(
"DedupeRegistry",
) {}

/** A fresh, empty registry per layer build — i.e. per daemon runtime. */
export const DedupeRegistryLayer: Layer.Layer<DedupeRegistry> = Layer.sync(
DedupeRegistry,
createDedupeRegistry,
);
21 changes: 20 additions & 1 deletion src/lib/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import type { ExecResult } from "./exec";
import { allocateWorkspace, evictOldWorkspaces } from "./workspace";
import { allocateWorkspace, createRefreshGates, evictOldWorkspaces } from "./workspace";
import { writeBack } from "./writeback";
import type { GitIdentity } from "./clone";
import { hostExec } from "./exec";
Expand All @@ -29,6 +29,7 @@ describe("allocateWorkspace (D28)", () => {
await seedRepo(seed, "one");

const dir = await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-a",
workspaceRoot,
sshUrl: seed,
Expand All @@ -53,6 +54,7 @@ describe("allocateWorkspace (D28)", () => {
await seedRepo(seed, "one");

await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-one",
workspaceRoot,
sshUrl: seed,
Expand All @@ -65,6 +67,7 @@ describe("allocateWorkspace (D28)", () => {
await Bun.$`git -C ${seed} -c user.name=seed -c user.email=seed@seed.local commit -q -m second`.quiet();

const dir = await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-two",
workspaceRoot,
sshUrl: seed,
Expand All @@ -83,6 +86,7 @@ describe("allocateWorkspace (D28)", () => {

for (const runId of ["run-1", "run-2", "run-3"]) {
await allocateWorkspace({
refreshGates: createRefreshGates(),
runId,
workspaceRoot,
sshUrl: seed,
Expand All @@ -103,20 +107,23 @@ describe("allocateWorkspace (D28)", () => {
const seed = join(root, "seed-repo");
await seedRepo(seed, "one");

const sharedGates = createRefreshGates();
const [a, b] = await Promise.all([
allocateWorkspace({
runId: "run-x",
workspaceRoot,
sshUrl: seed,
identity: IDENTITY,
retainedWorkspaces: 10,
refreshGates: sharedGates,
}),
allocateWorkspace({
runId: "run-y",
workspaceRoot,
sshUrl: seed,
identity: IDENTITY,
retainedWorkspaces: 10,
refreshGates: sharedGates,
}),
]);
expect(existsSync(join(a, "seed.txt"))).toBe(true);
Expand All @@ -132,6 +139,7 @@ describe("allocateWorkspace (D28)", () => {
await seedRepo(seed, "one");

const dir = await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-a",
workspaceRoot,
sshUrl: seed,
Expand Down Expand Up @@ -159,6 +167,7 @@ describe("allocateWorkspace (D28)", () => {
await Bun.$`git -C ${seed} push -q ${remote} main`.quiet();

const dir = await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-a",
workspaceRoot,
sshUrl: remote,
Expand Down Expand Up @@ -203,6 +212,7 @@ describe("allocateWorkspace (D28)", () => {
await seedRepo(seed, "one");

await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-1",
workspaceRoot,
sshUrl: seed,
Expand All @@ -211,6 +221,7 @@ describe("allocateWorkspace (D28)", () => {
});
await Bun.sleep(5);
await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-2",
workspaceRoot,
sshUrl: seed,
Expand All @@ -220,6 +231,7 @@ describe("allocateWorkspace (D28)", () => {
await Bun.sleep(5);

await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-3",
workspaceRoot,
sshUrl: seed,
Expand All @@ -241,6 +253,7 @@ describe("allocateWorkspace (D28)", () => {
await seedRepo(seed, "one");

await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-1",
workspaceRoot,
sshUrl: seed,
Expand All @@ -249,6 +262,7 @@ describe("allocateWorkspace (D28)", () => {
});
await Bun.sleep(5);
await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-2",
workspaceRoot,
sshUrl: seed,
Expand All @@ -258,6 +272,7 @@ describe("allocateWorkspace (D28)", () => {
await Bun.sleep(5);

await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-3",
workspaceRoot,
sshUrl: seed,
Expand All @@ -280,6 +295,7 @@ describe("scratch workspaces (issue #13)", () => {
await seedRepo(seed, "one");

const dir = await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-scratch",
workspaceRoot,
sshUrl: seed,
Expand All @@ -304,6 +320,7 @@ describe("scratch workspaces (issue #13)", () => {
// Two failed-run scratch leftovers, plus one clone.
for (const runId of ["scratch-1", "scratch-2"]) {
await allocateWorkspace({
refreshGates: createRefreshGates(),
runId,
workspaceRoot,
sshUrl: seed,
Expand All @@ -313,6 +330,7 @@ describe("scratch workspaces (issue #13)", () => {
});
}
await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "clone-1",
workspaceRoot,
sshUrl: seed,
Expand Down Expand Up @@ -367,6 +385,7 @@ describe("host exec injection (issue #13)", () => {
// `hostExec` directly, `git clone` would really run and contradict it.
const exit0: ExecResult = { command: "fake", exitCode: 0, stdout: "", stderr: "" };
const dir = await allocateWorkspace({
refreshGates: createRefreshGates(),
runId: "run-fake",
workspaceRoot: workspaceRoot2,
sshUrl: seed,
Expand Down
49 changes: 45 additions & 4 deletions src/lib/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
* non-host workspace is plumbing or a rewrite.
*/

import { Context, Layer } from "effect";
import { existsSync, readdirSync, statSync } from "node:fs";
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
Expand All @@ -31,6 +32,40 @@ import type { WorkspaceKind } from "../workflow";

const MIRROR_DIR = ".mirror.git";

/**
* The mirror-refresh promise queue, keyed by mirror path (see the
* concurrency note above). #38: per daemon, not per module — it is the
* `RefreshGates` service below, and `allocateWorkspace` takes the resolved
* value as a plain argument.
*/
export interface RefreshGatesShape {
get(mirrorPath: string): Promise<void> | undefined;
set(mirrorPath: string, promise: Promise<void>): void;
}

export function createRefreshGates(): RefreshGatesShape {
const gates = new Map<string, Promise<void>>();
return {
get: (mirrorPath) => gates.get(mirrorPath),
set: (mirrorPath, promise) => {
gates.set(mirrorPath, promise);
},
};
}

/**
* The refresh gates as a daemon service (#38, ADR 0009 §5); provided by
* `RefreshGatesLayer`, one fresh queue per daemon runtime.
*/
export class RefreshGates extends Context.Service<RefreshGates, RefreshGatesShape>()(
"RefreshGates",
) {}

export const RefreshGatesLayer: Layer.Layer<RefreshGates> = Layer.sync(
RefreshGates,
createRefreshGates,
);

export interface WorkspaceAllocationInput {
readonly runId: string;
readonly workspaceRoot: string;
Expand All @@ -56,11 +91,15 @@ export interface WorkspaceAllocationInput {
readonly protectedEntries?: ReadonlyArray<string>;
/** Injectable host-exec seam; defaults to `hostExec` (no behaviour change). */
readonly exec?: ExecFn;
/** Per-daemon refresh gates for serializing mirror maintenance. */
readonly refreshGates: RefreshGatesShape;
}

const refreshGates = new Map<string, Promise<void>>();

function enqueueRefresh(mirrorPath: string, task: () => Promise<void>): Promise<void> {
function enqueueRefresh(
refreshGates: RefreshGatesShape,
mirrorPath: string,
task: () => Promise<void>,
): Promise<void> {
const prior = refreshGates.get(mirrorPath) ?? Promise.resolve();
const next = prior.then(task, task);
refreshGates.set(mirrorPath, next);
Expand Down Expand Up @@ -110,7 +149,9 @@ export async function allocateWorkspace(input: WorkspaceAllocationInput): Promis

const mirrorPath = join(workspaceRoot, MIRROR_DIR);

await enqueueRefresh(mirrorPath, () => refreshMirror(mirrorPath, sshUrl, exec));
await enqueueRefresh(input.refreshGates, mirrorPath, () =>
refreshMirror(mirrorPath, sshUrl, exec),
);

if (existsSync(dir)) {
await rm(dir, { recursive: true, force: true });
Expand Down
45 changes: 44 additions & 1 deletion src/runtime/agent-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Effect, ManagedRuntime } from "effect";
import { describe, expect, test } from "bun:test";
import type { AgentAdapterYield, AgentSignal } from "./agent-adapter";
import { AgentRuntimeLayer } from "./agent-runtime";
import { buildAgentStepEffect } from "./agent-step";
import { abortableIterable, buildAgentStepEffect } from "./agent-step";

function makeYield(chunk: unknown, signal?: AgentSignal): AgentAdapterYield {
return signal !== undefined ? { chunk, signal } : { chunk };
Expand Down Expand Up @@ -169,3 +169,46 @@ describe("buildAgentStepEffect signal extraction", () => {
expect(outcome.chunkCount).toBe(1);
});
});

describe("abortableIterable (#38: abandoning a step is immediate)", () => {
test("a stream that ends on its own is passed through and never aborted", async () => {
const controller = new AbortController();
async function* source() {
yield 1;
yield 2;
}
const { iterable, teardown } = abortableIterable(source(), controller);
const seen: Array<number> = [];
for await (const n of iterable) seen.push(n);
await teardown();
expect(seen).toEqual([1, 2]);
expect(controller.signal.aborted).toBe(false);
});

test("return() during a pending next() resolves at once, aborts, and tears down in the background", async () => {
const controller = new AbortController();
let finalized = false;
async function* source() {
try {
yield 1;
// Waiting on the model: only the abort lets this generator move on.
await new Promise<void>((resolve) =>
controller.signal.addEventListener("abort", () => setTimeout(resolve, 50)),
);
} finally {
finalized = true;
}
}
const { iterable, teardown } = abortableIterable(source(), controller);
const iterator = iterable[Symbol.asyncIterator]();
expect(await iterator.next()).toEqual({ done: false, value: 1 });
const pending = iterator.next();

expect(await iterator.return!()).toEqual({ done: true, value: undefined });
expect(controller.signal.aborted).toBe(true);
expect(await pending).toEqual({ done: true, value: undefined });
expect(finalized).toBe(false);
await teardown();
expect(finalized).toBe(true);
});
});
Loading
Loading