app/engine/workqueue.ts
zzmq.dev@gmail.com/zz / app / engine / workqueue.ts
@ b4dbd262
Download
Ladder Summary
- file The file implements the **work‑queue logic** for a Lane’s ask/answer history: it determines which asks are currently claimable, which actor (if any) holds a claim on a given ask, and aggregates those claimable asks across multiple repositories. **Exported symbols** | Symbol | Kind | Purpose | |--------|------|---------| | `isResolved` | function | Returns `true` if a given ask ID already has an `ANSWER` with action **grant** or **complete** in the lane’s history. | | `claimHolder` | function | Scans a lane’s history to find the current holder (actor ID) of a claim on a specific ask, or `null` if none. | | `claimableAsks` | function | Returns the list of unanswered asks that satisfy dependency resolution, scope permissions, and claim‑availability for a particular actor, sorted by priority. | | `WorkScope` | type (union) | `"coordinator" | "contributor" | "all"` – a client‑side filter applied on top of the normal scope checks. | | `ClaimableEntry` | interface | `{ repo, laneId, ask, alreadyClaimed }` – a structured record describing a claimable ask together with its repository and lane context. | | `claimableAcrossRepos` | async function | Given a set of repositories, an actor, and an optional `WorkScope`, fetches all lanes (via the backend) and returns the combined `ClaimableEntry[]`. | | `claimableFromSnapshots` | function | Synchronous version of `claimableAcrossRepos`; it works on an already‑fetched array of `{repo, lane}` snapshots to produce the same `ClaimableEntry[]` without extra network calls. | Together, these utilities support the “claim → work → answer” loop and allow the system to be swapped to a different backend (e.g., a shared queue) without changing the core claim‑resolution algorithm.
// The generic work-queue machinery over a Lane's ask/answer history:
// which asks are claimable right now, and the claim -> work -> answer
// loop. Built so a later backend swap (a real shared queue, multiple
// concurrent actors) changes where claimableAsks reads from, not the
// shape of the loop itself.
import { ask, answer, ASK_SCOPE } from "./lane.js";
import { readLane } from "./store.js";
import type { AnswerEntry, AskEntry, AskKind, Lane } from "./types.js";
function isAnswer(entry: Lane["history"][number]): entry is AnswerEntry {
return entry.type === "ANSWER";
}
function isAsk(entry: Lane["history"][number]): entry is AskEntry {
return entry.type === "ASK";
}
function isResolved(lane: Lane, askId: string, actions: Array<"grant" | "complete">): boolean {
return lane.history.some((e) => isAnswer(e) && e.ask === askId && actions.includes(e.action as "grant" | "complete"));
}
/**
* Every Ask with no matching Answer yet, where every id in depends_on
* already has a "grant"/"complete" Answer, and actor is permitted to
* act on it (ASK_SCOPE). Sorted by priority (descending); order among
* equal priority is unspecified on purpose -- this is the query a real
* concurrent actor runs against a shared queue, not a scheduling promise.
*/
export function claimableAsks(lane: Lane, actor: string): AskEntry[] {
const answeredAskIds = new Set(lane.history.filter(isAnswer).map((e) => e.ask));
return lane.history
.filter(isAsk)
.filter((a) => !answeredAskIds.has(a.id))
.filter((a) => (a.depends_on ?? []).every((depId) => isResolved(lane, depId, ["grant", "complete"])))
.filter((a) => ASK_SCOPE[a.kind] === "open" || lane.coordinators.includes(actor))
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
}
/**
* Claims one ask: `actor` posts a claim_request, and the lane's own
* coordinator (lane.coordinators[0] -- the same identity as `actor` for
* a self-coordinated lane, a genuinely different one for the repo Lane)
* grants it. `actor` then does the work and posts the Answer to the
* original ask with action "complete" and doWork()'s return value as
* payload -- completing work doesn't need coordinator authority, only
* granting the claim does.
*/
export async function claimAndAnswer(stateDir: string, laneId: string, theAsk: AskEntry, actor: string, doWork: (theAsk: AskEntry) => Promise<unknown>): Promise<AnswerPayloadResult> {
const lane = readLane(stateDir, laneId);
if (!lane) throw new Error(`no such lane: ${laneId}`);
const grantedBy = lane.coordinators[0] ?? actor;
const claim = ask(stateDir, laneId, { kind: "claim_request", from: actor, payload: { target: theAsk.id } });
answer(stateDir, laneId, claim, { from: grantedBy, action: "grant", payload: { grant: `grant-${claim.id}` } });
const payload = await doWork(theAsk);
answer(stateDir, laneId, theAsk, { from: actor, action: "complete", payload });
return { ask: theAsk, payload };
}
interface AnswerPayloadResult {
ask: AskEntry;
payload: unknown;
}
/**
* Repeatedly finds the next claimable ask and runs the handler
* registered for its kind, until nothing is claimable -- either the
* queue is drained, or everything left is blocked on a dependency that
* will never resolve (reported, not silently swallowed).
*/
export async function runWorkerLoop(stateDir: string, laneId: string, actor: string, handlers: Partial<Record<AskKind, (theAsk: AskEntry) => Promise<unknown>>>): Promise<void> {
for (;;) {
const lane = readLane(stateDir, laneId);
if (!lane) throw new Error(`no such lane: ${laneId}`);
const [next] = claimableAsks(lane, actor);
if (!next) {
const unanswered = lane.history.filter(isAsk).filter((a) => !lane.history.some((e) => isAnswer(e) && e.ask === a.id));
if (unanswered.length) {
console.error(`worker loop on ${laneId} stopped with ${unanswered.length} ask(s) still unanswered (blocked on an unmet dependency, or out of scope for ${actor}): ${unanswered.map((a) => a.id).join(", ")}`);
}
return;
}
const handler = handlers[next.kind];
if (!handler) throw new Error(`no handler registered for ask kind "${next.kind}" (${next.id})`);
await claimAndAnswer(stateDir, laneId, next, actor, handler);
}
}