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
42 changes: 42 additions & 0 deletions packages/contracts/src/wake-conditions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "@effect/vitest";
import * as Schema from "effect/Schema";
import { WakeCondition, wakeConditionLabel, wakeConditionWhen } from "./wake-conditions.ts";

const decode = Schema.decodeUnknownSync(WakeCondition);

describe("wake condition words", () => {
it("says what a stuck-CI wake waits for, and labels its chip", () => {
const pr = decode({ kind: "ciStuck", repo: "acme/app", pr: 7 });
expect(wakeConditionWhen(pr)).toBe(
"a CI job of acme/app#7 sits queued 15+ min, or a run awaits approval",
);
expect(wakeConditionLabel(pr)).toBe("CI job queued 15+ min");
const run = decode({ kind: "ciStuck", repo: "acme/app", runId: 55, queuedMinutes: 30 });
expect(wakeConditionWhen(run)).toBe(
"a CI job of acme/app run 55 sits queued 30+ min, or a run awaits approval",
);
expect(wakeConditionLabel(run)).toBe("CI job queued 30+ min");
});

it("says what a machine wake waits for, and labels its chip", () => {
const load = decode({ kind: "machine", metric: "load", below: 8 });
expect(wakeConditionWhen(load)).toBe("this machine's load stays below 8 for 5 min");
expect(wakeConditionLabel(load)).toBe("quieter (load < 8)");
const disk = decode({
kind: "machine",
metric: "diskFree",
belowGB: 20,
path: "/Users/me/code",
});
expect(wakeConditionWhen(disk)).toBe("free disk space at /Users/me/code drops below 20 GB");
expect(wakeConditionLabel(disk)).toBe("disk < 20 GB");
});

it("decodes neither a metric it doesn't know nor a metric's fields under the other", () => {
expect(() => decode({ kind: "machine", metric: "memory", below: 1 })).toThrow();
expect(() => decode({ kind: "machine", metric: "diskFree", below: 20 })).toThrow();
expect(() =>
decode({ kind: "machine", metric: "load", below: Number.POSITIVE_INFINITY }),
).toThrow();
});
});
76 changes: 74 additions & 2 deletions packages/contracts/src/wake-conditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,60 @@ export const ThreadFinishedCondition = Schema.Struct({
});
export type ThreadFinishedCondition = typeof ThreadFinishedCondition.Type;

/** The minutes a `ciStuck` wake lets a job sit queued when it names none. */
export const CI_STUCK_DEFAULT_MINUTES = 15;

/**
* A hosted CI job sits queued, with no runner, for `queuedMinutes` (filled in with
* `CI_STUCK_DEFAULT_MINUTES`); or a run waits for a maintainer to approve it, as a fork's does. Of a
* PR's checks, or of one Actions run; without either, the waiting thread's bound PR.
*/
export const CiStuckCondition = Schema.Struct({
kind: Schema.Literal("ciStuck"),
repo: RepoName,
// Finite rather than Int: a tool's input schema is read at every session's start, and an Int's
// bounds cost twice its words there. The source refuses a fraction.
pr: Schema.optionalKey(Schema.Finite),
runId: Schema.optionalKey(Schema.Finite),
queuedMinutes: Schema.optionalKey(Schema.Finite),
});
export type CiStuckCondition = typeof CiStuckCondition.Type;

/** The minutes a `machine` load wake wants the load to stay low when it names none. */
export const MACHINE_LOAD_DEFAULT_MINUTES = 5;

/**
* This machine's 1-minute load average, absolute as `uptime` shows it (not divided by its CPUs),
* stays below `below` for `forMinutes` (filled in with `MACHINE_LOAD_DEFAULT_MINUTES`).
*/
export const MachineLoadCondition = Schema.Struct({
kind: Schema.Literal("machine"),
metric: Schema.Literal("load"),
below: Schema.Finite,
forMinutes: Schema.optionalKey(Schema.Finite),
});
export type MachineLoadCondition = typeof MachineLoadCondition.Type;

/**
* Free space on the volume holding `path` (filled in with the waiting thread's repo) drops below
* `belowGB`, in GB of 10^9 bytes as Finder and `df -H` count them.
*/
export const DiskFreeCondition = Schema.Struct({
kind: Schema.Literal("machine"),
metric: Schema.Literal("diskFree"),
belowGB: Schema.Finite,
path: Schema.optionalKey(Schema.String),
});
export type DiskFreeCondition = typeof DiskFreeCondition.Type;

/** Everything a wake can wait on beside its time. Closed: an unknown kind doesn't decode. */
export const WakeCondition = Schema.Union([BranchMovedCondition, ThreadFinishedCondition]);
export const WakeCondition = Schema.Union([
BranchMovedCondition,
ThreadFinishedCondition,
CiStuckCondition,
MachineLoadCondition,
DiskFreeCondition,
]);
export type WakeCondition = typeof WakeCondition.Type;
export type WakeConditionKind = WakeCondition["kind"];

Expand Down Expand Up @@ -85,15 +137,35 @@ export const wakeConditionWhen = (condition: WakeCondition, name?: string): stri
return `${condition.repo} ${condition.branch ?? "default branch"} gets a new head`;
case "threadFinished":
return `${name === undefined ? `thread ${condition.threadId}` : `"${name}"`} finishes`;
case "ciStuck":
return `a CI job of ${ciTarget(condition)} sits queued ${ciMinutes(condition)}+ min, or a run awaits approval`;
case "machine":
return condition.metric === "load"
? `this machine's load stays below ${condition.below} for ${condition.forMinutes ?? MACHINE_LOAD_DEFAULT_MINUTES} min`
: `free disk space${condition.path === undefined ? "" : ` at ${condition.path}`} drops below ${condition.belowGB} GB`;
}
};

/** A chip's short label for a condition: the branch, or the thread. */
const ciTarget = (condition: CiStuckCondition) =>
condition.runId !== undefined
? `${condition.repo} run ${condition.runId}`
: `${condition.repo}${condition.pr === undefined ? "" : `#${condition.pr}`}`;

const ciMinutes = (condition: CiStuckCondition) =>
condition.queuedMinutes ?? CI_STUCK_DEFAULT_MINUTES;

/** A chip's short label for a condition: the branch, the thread, the CI, the load or the disk. */
export const wakeConditionLabel = (condition: WakeCondition): string => {
switch (condition.kind) {
case "branchMoved":
return condition.branch ?? condition.repo;
case "threadFinished":
return "thread";
case "ciStuck":
return `CI job queued ${ciMinutes(condition)}+ min`;
case "machine":
return condition.metric === "load"
? `quieter (load < ${condition.below})`
: `disk < ${condition.belowGB} GB`;
}
};
4 changes: 3 additions & 1 deletion packages/server/src/http/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ import { ReviewRequestsLive } from "../github/review-requests.ts";
import { PrDependenciesLive } from "../wakes/pr-dependencies.ts";
import { ThreadWakesLive } from "../wakes/thread-wakes.ts";
import { BranchHeadsLive } from "../wakes/branch-heads.ts";
import { CiJobsLive } from "../wakes/ci-stuck.ts";
import { WakeConditionsLive } from "../wakes/conditions.ts";
import { WakeConditionSourcesLive } from "../wakes/condition-sources.ts";
import { MachineStateLive } from "../wakes/machine.ts";
import { AppActivityLive } from "../git/activity.ts";
import { ThreadVisibility } from "../git/visibility.ts";
import type { GitPlatform } from "../git/platform.ts";
Expand Down Expand Up @@ -373,7 +375,7 @@ export const ServerCore = Layer.unwrap(
PlanKeeperLive,
WakeConditionsLive.pipe(
Layer.provide(WakeConditionSourcesLive),
Layer.provide(BranchHeadsLive),
Layer.provide(Layer.mergeAll(BranchHeadsLive, CiJobsLive, MachineStateLive)),
),
),
),
Expand Down
8 changes: 5 additions & 3 deletions packages/server/src/orchestration/prompt-budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ const ROLES: ReadonlyArray<Role> = ["orchestrator", "worker", "plain"];

/**
* Characters, prompt and tools together. Measured on 2026-11-02 at 59,373, 33,022 and 22,669.
* Characters rather than tokens: no tokenizer is a dependency, and the ratio moves little.
* Characters rather than tokens: no tokenizer is a dependency, and the ratio moves little. Worker
* and plain raised by 1,000 on 2026-11-03 for the `ciStuck` and `machine` wake conditions, which
* every role's `schedule_wake` lists (measured then at 61,307, 35,201 and 24,370).
*/
const BUDGET: Readonly<Record<Role, number>> = {
orchestrator: 62_400,
worker: 34_700,
plain: 23_900,
worker: 35_700,
plain: 24_900,
};

const AUTONOMIES: ReadonlyArray<Autonomy> = ["supervised", "publish", "full"];
Expand Down
Loading
Loading