Skip to content
Open
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 src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ export const RunEventPayload = Schema.TaggedUnion({
/** The run currently holding `key` (non-terminal — it is holding). */
holderRunId: Schema.String,
childWorkflowId: Schema.String,
/** Human-readable reason, naming the held key and its holder. */
error: Schema.optional(Schema.String),
},

// ---- ctx.agent(name, prompt, opts?) --------------------------------
Expand Down
3 changes: 0 additions & 3 deletions src/lib/dedupe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,3 @@ 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();
37 changes: 36 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,8 @@ describe("allocateWorkspace (D28)", () => {
await seedRepo(seed, "one");

const dir = await allocateWorkspace({
refreshGates: createRefreshGates(),

runId: "run-a",
workspaceRoot,
sshUrl: seed,
Expand All @@ -53,6 +55,8 @@ describe("allocateWorkspace (D28)", () => {
await seedRepo(seed, "one");

await allocateWorkspace({
refreshGates: createRefreshGates(),

runId: "run-one",
workspaceRoot,
sshUrl: seed,
Expand All @@ -65,6 +69,8 @@ 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 +89,8 @@ 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 +111,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 +143,8 @@ 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 +172,8 @@ 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 +218,8 @@ describe("allocateWorkspace (D28)", () => {
await seedRepo(seed, "one");

await allocateWorkspace({
refreshGates: createRefreshGates(),

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

runId: "run-2",
workspaceRoot,
sshUrl: seed,
Expand All @@ -220,6 +239,8 @@ describe("allocateWorkspace (D28)", () => {
await Bun.sleep(5);

await allocateWorkspace({
refreshGates: createRefreshGates(),

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

await allocateWorkspace({
refreshGates: createRefreshGates(),

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

runId: "run-2",
workspaceRoot,
sshUrl: seed,
Expand All @@ -258,6 +283,8 @@ describe("allocateWorkspace (D28)", () => {
await Bun.sleep(5);

await allocateWorkspace({
refreshGates: createRefreshGates(),

runId: "run-3",
workspaceRoot,
sshUrl: seed,
Expand All @@ -280,6 +307,8 @@ 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 +333,8 @@ 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 +344,8 @@ describe("scratch workspaces (issue #13)", () => {
});
}
await allocateWorkspace({
refreshGates: createRefreshGates(),

runId: "clone-1",
workspaceRoot,
sshUrl: seed,
Expand Down Expand Up @@ -367,6 +400,8 @@ 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
29 changes: 25 additions & 4 deletions src/lib/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ import type { WorkspaceKind } from "../workflow";

const MIRROR_DIR = ".mirror.git";

export interface RefreshGates {
get(mirrorPath: string): Promise<void> | undefined;
set(mirrorPath: string, promise: Promise<void>): void;
}

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

export interface WorkspaceAllocationInput {
readonly runId: string;
readonly workspaceRoot: string;
Expand All @@ -56,11 +71,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: RefreshGates;
}

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

function enqueueRefresh(mirrorPath: string, task: () => Promise<void>): Promise<void> {
function enqueueRefresh(
refreshGates: RefreshGates,
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 +129,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
1 change: 1 addition & 0 deletions src/runtime/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ export async function startRun<I, O>(
key: err.key,
holderRunId: err.holderRunId,
childWorkflowId: child.id,
error: domainErrorMessage(err),
});
}
throw err;
Expand Down
25 changes: 25 additions & 0 deletions src/server/concurrency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import { createSlowFakeAdapter } from "../replay/adapter";
import { makeAgentRuntime } from "../runtime/agent-runtime";
import { admitRun } from "./admission";
import { serve } from "./http";
import { createRunRegistry } from "./runs";
import { createPubSub } from "./pubsub";
import { createDedupeRegistry } from "../lib/dedupe";
import { createRefreshGates } from "../lib/workspace";

const TREE_WORKFLOW = `${import.meta.dir}/../../test/fixtures/tree-workflow.ts`;

Expand Down Expand Up @@ -71,7 +75,14 @@ describe("phase 5 P1: per-run working trees (D28) over the real server", () => {

const workspaceRoot = join(root, "workspaces");
const db = openStore(join(root, "factory.db"));
const services = {
registry: createRunRegistry(),
pubsub: createPubSub(),
dedupeRegistry: createDedupeRegistry(),
refreshGates: createRefreshGates(),
};
const server = serve({
services,
db,
runtime: makeAgentRuntime(
createSlowFakeAdapter(
Expand Down Expand Up @@ -160,7 +171,14 @@ describe("phase 5 P1: per-run working trees (D28) over the real server", () => {
await Bun.$`git -C ${seed} -c user.name=seed -c user.email=seed@seed.local commit -q --allow-empty -m seed`.quiet();

const db = openStore(join(root, "factory.db"));
const services = {
registry: createRunRegistry(),
pubsub: createPubSub(),
dedupeRegistry: createDedupeRegistry(),
refreshGates: createRefreshGates(),
};
const server = serve({
services,
db,
runtime: makeAgentRuntime(
createSlowFakeAdapter(
Expand Down Expand Up @@ -222,7 +240,14 @@ describe("phase 5 P1: per-run working trees (D28) over the real server", () => {

const workspaceRoot = join(root, "workspaces");
const db = openStore(join(root, "factory.db"));
const services = {
registry: createRunRegistry(),
pubsub: createPubSub(),
dedupeRegistry: createDedupeRegistry(),
refreshGates: createRefreshGates(),
};
const server = serve({
services,
db,
runtime: makeAgentRuntime(
createSlowFakeAdapter(
Expand Down
Loading
Loading