@sgtbeatdown/wolfpack-tasks 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -2
- package/docs/protocol-reference.md +124 -0
- package/package.json +55 -4
- package/skills/task-context-summary/SKILL.md +34 -0
- package/skills/wolfpack-task-delegation/SKILL.md +107 -0
- package/src/abortable-sleep.ts +25 -0
- package/src/agent-task-runtime.ts +709 -0
- package/src/agent-task-tool-schemas.ts +30 -0
- package/src/claude/adapter.ts +336 -0
- package/src/claude/constants.ts +40 -0
- package/src/claude/environment.ts +54 -0
- package/src/claude/json-rpc.ts +50 -0
- package/src/claude/mcp-server.ts +144 -0
- package/src/claude/server.ts +37 -0
- package/src/claude/status-file.ts +66 -0
- package/src/claude/validation.ts +28 -0
- package/src/claude/wake.ts +215 -0
- package/src/claude/worker-gate-hook.ts +26 -0
- package/src/claude/worker-gate.ts +260 -0
- package/src/configured-task-core.ts +76 -0
- package/src/extension.ts +51 -0
- package/src/in-memory-task-relay.ts +115 -0
- package/src/index.ts +35 -0
- package/src/task-core.ts +925 -0
- package/src/task-errors.ts +111 -0
- package/src/task-inbox.ts +292 -0
- package/src/task-protocol.ts +243 -0
- package/src/task-relay-conformance.ts +33 -0
- package/src/task-store.ts +342 -0
- package/src/task-work-scope.ts +98 -0
- package/src/task-worker-mode.ts +9 -0
- package/src/volatile-task-session.ts +310 -0
- package/src/wolfpack-endpoint-view.ts +46 -0
- package/src/wolfpack-local-auth.ts +20 -0
- package/src/wolfpack-task-relay.ts +39 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { TASK_PROTOCOL_VERSION } from "./task-protocol";
|
|
4
|
+
import type { TaskRelay } from "./task-protocol";
|
|
5
|
+
|
|
6
|
+
/** Shared assertions external relay adapters can run against their implementation. */
|
|
7
|
+
export function runTaskRelayConformance(createRelay: () => TaskRelay): void {
|
|
8
|
+
test("acknowledging a later delivery retains earlier unacknowledged mail", async () => {
|
|
9
|
+
const relay = createRelay();
|
|
10
|
+
const source = { relay: relay.id, id: "source" }, target = { relay: relay.id, id: "target" };
|
|
11
|
+
for (const endpoint of [source, target]) await relay.connect({ endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
|
|
12
|
+
for (const envelopeId of ["first", "second"]) await relay.send({ envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId: "task", kind: "assignment", payload: "{}", createdAt: new Date().toISOString() });
|
|
13
|
+
const page = await relay.receive({ endpoint: target, cursor: "0", limit: 10 });
|
|
14
|
+
expect(page.deliveries).toHaveLength(2);
|
|
15
|
+
await relay.acknowledgeDelivery({ endpoint: target, cursor: page.deliveries[1]!.cursor });
|
|
16
|
+
expect((await relay.receive({ endpoint: target, cursor: "0", limit: 10 })).deliveries.map(d => d.envelope.envelopeId)).toContain("first");
|
|
17
|
+
});
|
|
18
|
+
test("enforces registration, accepts duplicate envelopes once, and retains delivery until acknowledgement", async () => {
|
|
19
|
+
const relay = createRelay();
|
|
20
|
+
const source = { relay: relay.id, id: "source" };
|
|
21
|
+
const target = { relay: relay.id, id: "target" };
|
|
22
|
+
await relay.connect({ endpoint: source, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
|
|
23
|
+
await relay.connect({ endpoint: target, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
|
|
24
|
+
await expect(relay.resolve({ relay: relay.id, reference: "missing" })).rejects.toThrow("not registered");
|
|
25
|
+
const envelope = { envelopeId: "envelope-1", protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId: "task-1", kind: "assignment" as const, payload: "{}", createdAt: new Date(0).toISOString() };
|
|
26
|
+
await relay.send(envelope);
|
|
27
|
+
await relay.send(envelope);
|
|
28
|
+
const page = await relay.receive({ endpoint: target, cursor: "0", limit: 10 });
|
|
29
|
+
expect(page.deliveries).toHaveLength(1);
|
|
30
|
+
await relay.acknowledgeDelivery({ endpoint: target, cursor: page.nextCursor });
|
|
31
|
+
expect((await relay.receive({ endpoint: target, cursor: page.nextCursor, limit: 10 })).deliveries).toHaveLength(0);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { TASK_WORK_LIMITS } from "./task-work-scope";
|
|
2
|
+
import { TASK_EVENT_TYPES, TERMINAL_INTENT_OPERATION, TaskProtocolError } from "./task-protocol";
|
|
3
|
+
import type { RelayTransportBinding, RelayEnvelope, TaskEndpoint, TaskEvent, TaskRecord, TaskSnapshot, TerminalDeliveryState, TerminalTaskIntentType } from "./task-protocol";
|
|
4
|
+
|
|
5
|
+
export interface TaskStoreOptions {
|
|
6
|
+
/** Lower-only per-endpoint limits; session history, not this store, is the archive. */
|
|
7
|
+
readonly maxEntries?: number;
|
|
8
|
+
readonly maxBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface OutboxRecord { readonly envelope: RelayEnvelope; readonly state: "pending" | "accepted"; }
|
|
11
|
+
export interface OutboxQuarantineInput {
|
|
12
|
+
readonly errorCode: string; readonly reason: string;
|
|
13
|
+
readonly details: Readonly<Record<string, unknown>>; readonly quarantinedAt: number;
|
|
14
|
+
}
|
|
15
|
+
export interface OutboxQuarantineRecord extends OutboxQuarantineInput { readonly envelope: RelayEnvelope; readonly priorState: OutboxRecord["state"]; }
|
|
16
|
+
export interface TaskOperationRecord {
|
|
17
|
+
readonly taskId: string; readonly operation: string; readonly logicalId: string;
|
|
18
|
+
readonly logicalType: string; readonly envelopeIds: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
export interface PresentationRecord { readonly taskId: string; readonly eventId: string; }
|
|
21
|
+
export interface PendingPresentation extends PresentationRecord { readonly order: number; }
|
|
22
|
+
export interface TaskStore {
|
|
23
|
+
transaction<T>(operation: () => T): T;
|
|
24
|
+
/** Synchronous hook after each top-level commit or non-transactional write; a throwing listener is isolated and logged. */
|
|
25
|
+
onChange(listener: () => void): () => void;
|
|
26
|
+
putTask(task: Omit<TaskRecord, "events">): void;
|
|
27
|
+
getTask(taskId: string): TaskSnapshot | undefined;
|
|
28
|
+
listTasks(): readonly TaskSnapshot[];
|
|
29
|
+
/** Whether any task of this lifetime is active; indexed, no snapshot is built. */
|
|
30
|
+
hasActiveTasks(): boolean;
|
|
31
|
+
setStatus(taskId: string, status: TaskRecord["status"]): void;
|
|
32
|
+
/** Follows a reassignment; existing envelopes keep their addresses. */
|
|
33
|
+
setTarget(taskId: string, target: TaskEndpoint): void;
|
|
34
|
+
appendEvent(event: TaskEvent): boolean;
|
|
35
|
+
putOutbox(envelope: RelayEnvelope): void;
|
|
36
|
+
outbox(state: OutboxRecord["state"]): readonly OutboxRecord[];
|
|
37
|
+
/** Indexed lookup, fair rotation and cooldown are required store capabilities. */
|
|
38
|
+
getOutboxRecord(envelopeId: string): OutboxRecord | undefined;
|
|
39
|
+
hasPendingOutbox(): boolean;
|
|
40
|
+
/** Rotates fairly but yields only each target's oldest pending envelope (per-target FIFO). */
|
|
41
|
+
selectPendingOutbox(limit: number, now: number, maxInspections?: number): readonly OutboxRecord[];
|
|
42
|
+
/** Older pending envelopes to the same target, oldest first; the envelope may be sent only once this is empty. */
|
|
43
|
+
pendingOutboxAhead(envelopeId: string): readonly string[];
|
|
44
|
+
outboxRetryAt(envelopeId: string): number;
|
|
45
|
+
postponeOutbox(envelopeId: string, until: number): void;
|
|
46
|
+
markOutboxAccepted(envelopeId: string): void;
|
|
47
|
+
quarantineOutbox(envelopeId: string, input: OutboxQuarantineInput): void;
|
|
48
|
+
quarantinedOutbox(): readonly OutboxQuarantineRecord[];
|
|
49
|
+
/** Indexed lookup of one quarantined envelope. */
|
|
50
|
+
getQuarantine(envelopeId: string): OutboxQuarantineRecord | undefined;
|
|
51
|
+
persistInbox(envelope: RelayEnvelope, cursor: string): boolean;
|
|
52
|
+
/** Store arrival order of a persisted inbox envelope; shares the presentation order sequence. */
|
|
53
|
+
inboxOrder(envelopeId: string): number | undefined;
|
|
54
|
+
inboxEnvelope(envelopeId: string): RelayEnvelope | undefined;
|
|
55
|
+
/** Origin-local canonical events awaiting Pi presentation; ids only, events stay in the task log. */
|
|
56
|
+
enqueuePresentation(details: PresentationRecord): void;
|
|
57
|
+
pendingPresentations(): readonly PendingPresentation[];
|
|
58
|
+
completePresentation(eventId: string): void;
|
|
59
|
+
getReceiveCursor(): string;
|
|
60
|
+
setReceiveCursor(cursor: string): void;
|
|
61
|
+
trackRelayDeliveries(endpoint: TaskEndpoint, deliveries: readonly { readonly cursor: string; readonly envelopeId: string }[]): void;
|
|
62
|
+
pendingRelayEnvelopeId(endpoint: TaskEndpoint, cursor: string): string | undefined;
|
|
63
|
+
/** Tracked, unacknowledged deliveries in this endpoint scope, oldest cursor first. */
|
|
64
|
+
pendingRelayDeliveries(endpoint: TaskEndpoint): readonly { readonly cursor: string; readonly envelopeId: string }[];
|
|
65
|
+
/** Highest tracked cursor in this endpoint scope; intake fetches after it. */
|
|
66
|
+
relayIntakeCursor(endpoint: TaskEndpoint): string;
|
|
67
|
+
requestRelayAcknowledgement(endpoint: TaskEndpoint, cursor: string, retryAt?: number): void;
|
|
68
|
+
requestedRelayAcknowledgements(endpoint: TaskEndpoint, limit?: number, now?: number): readonly string[];
|
|
69
|
+
relayAcknowledgementRetryAt(endpoint: TaskEndpoint, cursor: string): number;
|
|
70
|
+
acknowledgeRelayCursor(endpoint: TaskEndpoint, cursor: string): void;
|
|
71
|
+
putIntent(intentId: string, taskId: string, envelopeId: string): void;
|
|
72
|
+
/** Locally submitted intent only; retained through acceptance or quarantine in this RAM lifetime. */
|
|
73
|
+
getIntentEnvelope(intentId: string): RelayEnvelope | undefined;
|
|
74
|
+
putInsertionReceipt(taskId: string, eventId: string): boolean;
|
|
75
|
+
reserveTaskOperation(input: TaskOperationRecord): { readonly created: boolean; readonly record: TaskOperationRecord };
|
|
76
|
+
/** Withdraws a reservation whose envelopes were never offered to the relay, with its pending envelopes and intent row. */
|
|
77
|
+
discardUnsentOperation(taskId: string, operation: string): void;
|
|
78
|
+
getEndpointGeneration(): string | undefined;
|
|
79
|
+
setEndpointGeneration(generation: string): void;
|
|
80
|
+
getEndpointBinding(): TaskEndpoint | undefined;
|
|
81
|
+
setEndpointBinding(endpoint: TaskEndpoint): void;
|
|
82
|
+
getRelayTransportBinding(): RelayTransportBinding | undefined;
|
|
83
|
+
setRelayTransportBinding(binding: RelayTransportBinding): void;
|
|
84
|
+
clear(): void;
|
|
85
|
+
close(): void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const LIMITS = { entries: 16_384, bytes: 32 * 1024 * 1024 };
|
|
89
|
+
// `text` is the immutable source of truth and of the byte charge; `value` is its lazily parsed, deep-frozen form.
|
|
90
|
+
type Cell = { readonly text: string; readonly bytes: number; readonly order: number; value?: unknown };
|
|
91
|
+
type Undo = { readonly name: string; readonly table: Map<string, Cell>; readonly key: string; readonly prior: Cell | undefined };
|
|
92
|
+
interface Checkpoint { frontier: string; highWater: string; pending: Record<string, string>; requested: Record<string, number>; }
|
|
93
|
+
const freeze = <T>(value: T): T => {
|
|
94
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) { Object.freeze(value); for (const child of Object.values(value)) freeze(child); }
|
|
95
|
+
return value;
|
|
96
|
+
};
|
|
97
|
+
const read = <T>(row: Cell): T => (row.value ??= freeze(JSON.parse(row.text))) as T;
|
|
98
|
+
|
|
99
|
+
/** One endpoint lifetime. No SQLite, files, migrations, startup reads or replay. */
|
|
100
|
+
export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
|
|
101
|
+
if (Object.keys(options).some(key => key !== "maxEntries" && key !== "maxBytes")) throw new TypeError("task state is memory-only; persistence options are unsupported");
|
|
102
|
+
const maxEntries = options.maxEntries ?? LIMITS.entries, maxBytes = options.maxBytes ?? LIMITS.bytes;
|
|
103
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > LIMITS.entries || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > LIMITS.bytes) throw new TypeError("invalid task memory limit");
|
|
104
|
+
const tables = new Map<string, Map<string, Cell>>();
|
|
105
|
+
const frames: Map<string, Undo>[] = [];
|
|
106
|
+
// ID-only retry rotation, bounded by the existing pending outbox; no payload copy.
|
|
107
|
+
const pendingOutbox = new Map<string, number>();
|
|
108
|
+
// Per-target FIFO: pending ids grouped by target; outbox cell order is creation order.
|
|
109
|
+
const pendingByTarget = new Map<string, Set<string>>(), pendingTarget = new Map<string, string>();
|
|
110
|
+
const syncPendingOutbox = (id: string) => {
|
|
111
|
+
const row = tables.get("outbox")?.get(id);
|
|
112
|
+
const record = row && read<OutboxRecord>(row);
|
|
113
|
+
if (record?.state === "pending") {
|
|
114
|
+
if (!pendingOutbox.has(id)) pendingOutbox.set(id, 0);
|
|
115
|
+
if (!pendingTarget.has(id)) {
|
|
116
|
+
const key = pair(record.envelope.target.relay, record.envelope.target.id);
|
|
117
|
+
pendingTarget.set(id, key);
|
|
118
|
+
const ids = pendingByTarget.get(key) ?? new Set<string>(); ids.add(id); pendingByTarget.set(key, ids);
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
pendingOutbox.delete(id);
|
|
122
|
+
const key = pendingTarget.get(id);
|
|
123
|
+
if (key !== undefined) { pendingTarget.delete(id); const ids = pendingByTarget.get(key)!; ids.delete(id); if (!ids.size) pendingByTarget.delete(key); }
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const outboxOrder = (id: string) => tables.get("outbox")!.get(id)!.order;
|
|
127
|
+
const pendingAhead = (id: string): string[] => {
|
|
128
|
+
const key = pendingTarget.get(id); if (key === undefined) return [];
|
|
129
|
+
const order = outboxOrder(id);
|
|
130
|
+
return [...pendingByTarget.get(key)!].filter(other => outboxOrder(other) < order).sort((a, b) => outboxOrder(a) - outboxOrder(b));
|
|
131
|
+
};
|
|
132
|
+
// Cell-derived indexes, updated with every cell change including rollback; never a second copy of the data.
|
|
133
|
+
const eventsByTask = new Map<string, Set<string>>(), activeTasks = new Set<string>();
|
|
134
|
+
const reindex = (name: string, key: string, before: Cell | undefined, after: Cell | undefined) => {
|
|
135
|
+
if (name === "tasks") {
|
|
136
|
+
if (after && read<TaskRecord>(after).status === "active") activeTasks.add(key); else activeTasks.delete(key);
|
|
137
|
+
} else if (name === "events") {
|
|
138
|
+
if (before) { const taskId = read<TaskEvent>(before).taskId, ids = eventsByTask.get(taskId); ids?.delete(key); if (ids?.size === 0) eventsByTask.delete(taskId); }
|
|
139
|
+
if (after) { const taskId = read<TaskEvent>(after).taskId, ids = eventsByTask.get(taskId) ?? new Set<string>(); ids.add(key); eventsByTask.set(taskId, ids); }
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
let bytes = 0, entries = 0, sequence = 0, closed = false;
|
|
143
|
+
const listeners = new Set<() => void>();
|
|
144
|
+
// Listeners run after state is final; a failure is logged, not rethrown, because the write already committed.
|
|
145
|
+
const notify = () => { for (const listener of [...listeners]) { try { listener(); } catch (error) { console.error("pi-tasks: task store change listener failed", error); } } };
|
|
146
|
+
// Top-level writes refresh the pending-outbox index before notifying so listeners see committed state.
|
|
147
|
+
const committed = (name: string, key: string) => { if (frames.length) return; if (name === "outbox") syncPendingOutbox(key); notify(); };
|
|
148
|
+
const remember = (name: string, key: string, target: Map<string, Cell>, prior: Cell | undefined) => {
|
|
149
|
+
const frame = frames.at(-1), identity = JSON.stringify([name, key]);
|
|
150
|
+
if (frame && !frame.has(identity)) frame.set(identity, { name, table: target, key, prior });
|
|
151
|
+
};
|
|
152
|
+
const check = () => { if (closed) throw new TaskProtocolError("RELAY_CLOSED", "task state is closed", { retryable: false }); };
|
|
153
|
+
const table = (name: string) => { check(); let value = tables.get(name); if (!value) { value = new Map(); tables.set(name, value); } return value; };
|
|
154
|
+
const get = <T>(name: string, key: string): T | undefined => { const row = table(name).get(key); return row ? read<T>(row) : undefined; };
|
|
155
|
+
const rows = <T>(name: string): T[] => [...table(name).values()].sort((a, b) => a.order - b.order).map(row => read<T>(row));
|
|
156
|
+
const taskEvents = (taskId: string): TaskEvent[] => { const events = table("events"); return [...eventsByTask.get(taskId) ?? []].map(id => read<TaskEvent>(events.get(id)!)); };
|
|
157
|
+
const put = (name: string, key: string, value: unknown, onlyNew = false): boolean => {
|
|
158
|
+
const target = table(name), prior = target.get(key);
|
|
159
|
+
if (onlyNew && prior) return false;
|
|
160
|
+
const text = JSON.stringify(value), size = Buffer.byteLength(text) + Buffer.byteLength(key) + 64;
|
|
161
|
+
if (bytes - (prior?.bytes ?? 0) + size > maxBytes || (!prior && entries >= maxEntries)) throw new TaskProtocolError("TASK_CAPACITY", "active task state is full", { retryable: false });
|
|
162
|
+
remember(name, key, target, prior);
|
|
163
|
+
const cell = { text, bytes: size, order: prior?.order ?? sequence++ };
|
|
164
|
+
target.set(key, cell); reindex(name, key, prior, cell);
|
|
165
|
+
bytes += size - (prior?.bytes ?? 0); if (!prior) entries++;
|
|
166
|
+
committed(name, key);
|
|
167
|
+
return true;
|
|
168
|
+
};
|
|
169
|
+
const remove = (name: string, key: string) => {
|
|
170
|
+
const target = table(name), prior = target.get(key); if (!prior) return;
|
|
171
|
+
remember(name, key, target, prior);
|
|
172
|
+
target.delete(key); reindex(name, key, prior, undefined); bytes -= prior.bytes; entries--;
|
|
173
|
+
committed(name, key);
|
|
174
|
+
};
|
|
175
|
+
const pair = (a: string, b: string) => JSON.stringify([a, b]);
|
|
176
|
+
const requireTask = (id: string) => { if (!get("tasks", id)) throw new TaskProtocolError("TASK_NOT_FOUND", "task is not active in this endpoint lifetime"); };
|
|
177
|
+
const sortEvents = (a: TaskEvent, b: TaskEvent) => BigInt(a.sequence) < BigInt(b.sequence) ? -1 : BigInt(a.sequence) > BigInt(b.sequence) ? 1 : a.eventId.localeCompare(b.eventId);
|
|
178
|
+
const terminal = (task: Omit<TaskRecord, "events">): TerminalDeliveryState => {
|
|
179
|
+
const record = get<TaskOperationRecord>("operations", pair(task.taskId, TERMINAL_INTENT_OPERATION));
|
|
180
|
+
if (!record) return { state: "not_submitted" };
|
|
181
|
+
const type = TASK_EVENT_TYPES[record.logicalType];
|
|
182
|
+
if (!type?.terminal || !type.intent || record.envelopeIds.length !== 1) throw new Error("terminal operation is malformed");
|
|
183
|
+
const identity = { intentId: record.logicalId, intentType: record.logicalType as TerminalTaskIntentType, envelopeId: record.envelopeIds[0]!, origin: task.origin };
|
|
184
|
+
const outbox = get<OutboxRecord>("outbox", identity.envelopeId);
|
|
185
|
+
if (outbox) return { state: outbox.state, ...identity };
|
|
186
|
+
const blocked = get<OutboxQuarantineRecord>("quarantine", identity.envelopeId);
|
|
187
|
+
if (!blocked) throw new Error("terminal operation has no delivery record");
|
|
188
|
+
return { state: "delivery_blocked", ...identity, blockedAt: blocked.quarantinedAt, error: { code: blocked.errorCode, retryable: false, details: blocked.details } };
|
|
189
|
+
};
|
|
190
|
+
const snapshot = (task: Omit<TaskRecord, "events">, events: TaskEvent[]): TaskSnapshot => ({ ...task, events: events.sort(sortEvents), terminalDelivery: terminal(task) });
|
|
191
|
+
const state = <T>(key: string) => get<T>("state", key);
|
|
192
|
+
const checkpointKey = (endpoint: TaskEndpoint) => {
|
|
193
|
+
const bound = store.getEndpointBinding();
|
|
194
|
+
if (bound && (bound.relay !== endpoint.relay || bound.id !== endpoint.id)) throw new TaskProtocolError("RELAY_RESET", "delivery checkpoint belongs to a retired endpoint", { retryable: false });
|
|
195
|
+
return JSON.stringify([endpoint.relay, endpoint.id, store.getRelayTransportBinding()?.epoch]);
|
|
196
|
+
};
|
|
197
|
+
// A mutable copy: callers edit it before writing it back, and the cached cell value is frozen.
|
|
198
|
+
const checkpoint = (endpoint: TaskEndpoint): Checkpoint => structuredClone(get<Checkpoint>("checkpoints", checkpointKey(endpoint))) ?? { frontier: store.getReceiveCursor(), highWater: store.getReceiveCursor(), pending: {}, requested: {} };
|
|
199
|
+
const writeCheckpoint = (endpoint: TaskEndpoint, value: Checkpoint) => { put("checkpoints", checkpointKey(endpoint), value); };
|
|
200
|
+
const store: TaskStore = {
|
|
201
|
+
transaction<T>(operation: () => T): T {
|
|
202
|
+
check(); const priorBytes = bytes, priorEntries = entries, priorSequence = sequence;
|
|
203
|
+
const frame = new Map<string, Undo>(); frames.push(frame);
|
|
204
|
+
let result: T, topLevelChange = false;
|
|
205
|
+
try {
|
|
206
|
+
result = operation();
|
|
207
|
+
if (result instanceof Promise) throw new TypeError("task transactions must be synchronous");
|
|
208
|
+
const parent = frames.at(-2);
|
|
209
|
+
if (parent) {
|
|
210
|
+
for (const [key, item] of frame) if (!parent.has(key)) parent.set(key, item);
|
|
211
|
+
} else {
|
|
212
|
+
for (const item of frame.values()) if (item.table === tables.get("outbox")) syncPendingOutbox(item.key);
|
|
213
|
+
topLevelChange = frame.size > 0;
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
for (const item of [...frame.values()].reverse()) {
|
|
217
|
+
const current = item.table.get(item.key);
|
|
218
|
+
if (item.prior) item.table.set(item.key, item.prior); else item.table.delete(item.key);
|
|
219
|
+
reindex(item.name, item.key, current, item.prior);
|
|
220
|
+
}
|
|
221
|
+
bytes = priorBytes; entries = priorEntries; sequence = priorSequence;
|
|
222
|
+
throw error;
|
|
223
|
+
} finally { frames.pop(); }
|
|
224
|
+
if (topLevelChange) notify();
|
|
225
|
+
return result;
|
|
226
|
+
},
|
|
227
|
+
onChange(listener) { const entry = () => listener(); listeners.add(entry); return () => { listeners.delete(entry); }; },
|
|
228
|
+
putTask(task) { put("tasks", task.taskId, task, true); },
|
|
229
|
+
getTask(id) { const task = get<Omit<TaskRecord, "events">>("tasks", id); return task ? snapshot(task, taskEvents(id)) : undefined; },
|
|
230
|
+
listTasks() {
|
|
231
|
+
return rows<Omit<TaskRecord, "events">>("tasks").sort((a, b) => a.createdAt - b.createdAt || a.taskId.localeCompare(b.taskId)).map(task => snapshot(task, taskEvents(task.taskId)));
|
|
232
|
+
},
|
|
233
|
+
hasActiveTasks() { check(); return activeTasks.size > 0; },
|
|
234
|
+
setStatus(id, status) { const task = get<Omit<TaskRecord, "events">>("tasks", id); if (task) put("tasks", id, { ...task, status }); },
|
|
235
|
+
setTarget(id, target) { const task = get<Omit<TaskRecord, "events">>("tasks", id); if (task) put("tasks", id, { ...task, target }); },
|
|
236
|
+
appendEvent(event) { requireTask(event.taskId); return put("events", event.eventId, event, true); },
|
|
237
|
+
putOutbox(envelope) { put("outbox", envelope.envelopeId, { envelope, state: "pending" }, true); if (!frames.length) syncPendingOutbox(envelope.envelopeId); },
|
|
238
|
+
outbox(status) { return rows<OutboxRecord>("outbox").filter(item => item.state === status); },
|
|
239
|
+
getOutboxRecord(id) { return get<OutboxRecord>("outbox", id); },
|
|
240
|
+
hasPendingOutbox() { check(); return pendingOutbox.size > 0; },
|
|
241
|
+
selectPendingOutbox(limit, now, maxInspections = TASK_WORK_LIMITS.retryInspections) {
|
|
242
|
+
check();
|
|
243
|
+
const selected: OutboxRecord[] = [];
|
|
244
|
+
// Also bound inspection when the queue is cooling down. Rotate even skipped IDs.
|
|
245
|
+
const inspections = Math.min(pendingOutbox.size, TASK_WORK_LIMITS.retryInspections, maxInspections);
|
|
246
|
+
for (let i = 0; i < inspections && selected.length < limit; i++) {
|
|
247
|
+
const [id, retryAt] = pendingOutbox.entries().next().value!;
|
|
248
|
+
pendingOutbox.delete(id); pendingOutbox.set(id, retryAt);
|
|
249
|
+
// Only a target's oldest pending envelope is sendable; inspecting any of its ids offers the head.
|
|
250
|
+
const head = pendingAhead(id)[0] ?? id;
|
|
251
|
+
if (pendingOutbox.get(head)! <= now && !selected.some(record => record.envelope.envelopeId === head)) {
|
|
252
|
+
const record = get<OutboxRecord>("outbox", head);
|
|
253
|
+
if (record?.state === "pending") selected.push(record);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return selected;
|
|
257
|
+
},
|
|
258
|
+
pendingOutboxAhead(id) { check(); return pendingAhead(id); },
|
|
259
|
+
outboxRetryAt(id) { check(); return pendingOutbox.get(id) ?? 0; },
|
|
260
|
+
postponeOutbox(id, until) { check(); if (pendingOutbox.has(id)) pendingOutbox.set(id, until); },
|
|
261
|
+
markOutboxAccepted(id) { const prior = get<OutboxRecord>("outbox", id); if (prior) put("outbox", id, { ...prior, state: "accepted" }); if (!frames.length) syncPendingOutbox(id); },
|
|
262
|
+
quarantineOutbox(id, input) {
|
|
263
|
+
store.transaction(() => { const prior = get<OutboxRecord>("outbox", id); if (!prior) return;
|
|
264
|
+
put("quarantine", id, { ...input, envelope: prior.envelope, priorState: prior.state }, true); remove("outbox", id);
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
quarantinedOutbox() { return rows<OutboxQuarantineRecord>("quarantine"); },
|
|
268
|
+
getQuarantine(id) { return get<OutboxQuarantineRecord>("quarantine", id); },
|
|
269
|
+
persistInbox(envelope, cursor) { return put("inbox", envelope.envelopeId, { envelope, cursor }, true); },
|
|
270
|
+
inboxOrder(id) { return table("inbox").get(id)?.order; },
|
|
271
|
+
inboxEnvelope(id) { return get<{ envelope: RelayEnvelope }>("inbox", id)?.envelope; },
|
|
272
|
+
enqueuePresentation(details) { requireTask(details.taskId); put("presentation", details.eventId, { taskId: details.taskId, eventId: details.eventId }, true); },
|
|
273
|
+
pendingPresentations() { return [...table("presentation").values()].sort((a, b) => a.order - b.order).map(row => ({ ...read<PresentationRecord>(row), order: row.order })); },
|
|
274
|
+
completePresentation(eventId) { remove("presentation", eventId); },
|
|
275
|
+
getReceiveCursor() { return state<string>("cursor") ?? "0"; },
|
|
276
|
+
setReceiveCursor(cursor) { put("state", "cursor", cursor); },
|
|
277
|
+
trackRelayDeliveries(endpoint, deliveries) {
|
|
278
|
+
const value = checkpoint(endpoint), highWater = BigInt(value.highWater);
|
|
279
|
+
for (const delivery of deliveries) {
|
|
280
|
+
const prior = value.pending[delivery.cursor];
|
|
281
|
+
if (prior !== undefined && prior !== delivery.envelopeId) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor changed envelope identity");
|
|
282
|
+
if (BigInt(delivery.cursor) > highWater) value.pending[delivery.cursor] = delivery.envelopeId;
|
|
283
|
+
if (BigInt(delivery.cursor) > BigInt(value.highWater)) value.highWater = delivery.cursor;
|
|
284
|
+
}
|
|
285
|
+
writeCheckpoint(endpoint, value);
|
|
286
|
+
},
|
|
287
|
+
pendingRelayEnvelopeId(endpoint, cursor) { return checkpoint(endpoint).pending[cursor]; },
|
|
288
|
+
pendingRelayDeliveries(endpoint) {
|
|
289
|
+
return Object.entries(checkpoint(endpoint).pending).map(([cursor, envelopeId]) => ({ cursor, envelopeId }))
|
|
290
|
+
.sort((left, right) => BigInt(left.cursor) < BigInt(right.cursor) ? -1 : 1);
|
|
291
|
+
},
|
|
292
|
+
relayIntakeCursor(endpoint) { return checkpoint(endpoint).highWater; },
|
|
293
|
+
requestRelayAcknowledgement(endpoint, cursor, retryAt = 0) {
|
|
294
|
+
const value = checkpoint(endpoint);
|
|
295
|
+
if (value.pending[cursor] === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
|
|
296
|
+
value.requested[cursor] = retryAt; writeCheckpoint(endpoint, value);
|
|
297
|
+
},
|
|
298
|
+
requestedRelayAcknowledgements(endpoint, limit = Number.MAX_SAFE_INTEGER, now = Date.now()) {
|
|
299
|
+
const value = checkpoint(endpoint);
|
|
300
|
+
return Object.keys(value.requested).filter(cursor => value.requested[cursor]! <= now).slice(0, limit);
|
|
301
|
+
},
|
|
302
|
+
relayAcknowledgementRetryAt(endpoint, cursor) { return checkpoint(endpoint).requested[cursor] ?? 0; },
|
|
303
|
+
acknowledgeRelayCursor(endpoint, cursor) {
|
|
304
|
+
store.transaction(() => {
|
|
305
|
+
const value = checkpoint(endpoint); if (value.pending[cursor] === undefined) return;
|
|
306
|
+
delete value.pending[cursor]; delete value.requested[cursor];
|
|
307
|
+
const pending = Object.keys(value.pending);
|
|
308
|
+
if (!pending.length) value.frontier = value.highWater;
|
|
309
|
+
else if (BigInt(cursor) > BigInt(value.frontier) && pending.every(other => BigInt(other) > BigInt(cursor))) value.frontier = cursor;
|
|
310
|
+
writeCheckpoint(endpoint, value); store.setReceiveCursor(value.frontier);
|
|
311
|
+
});
|
|
312
|
+
},
|
|
313
|
+
putIntent(intentId, taskId, envelopeId) { put("intents", intentId, { taskId, envelopeId }, true); },
|
|
314
|
+
getIntentEnvelope(intentId) {
|
|
315
|
+
const intent = get<{ taskId: string; envelopeId: string }>("intents", intentId);
|
|
316
|
+
if (!intent) return undefined;
|
|
317
|
+
const envelope = (get<OutboxRecord>("outbox", intent.envelopeId) ?? get<OutboxQuarantineRecord>("quarantine", intent.envelopeId))?.envelope;
|
|
318
|
+
return envelope?.taskId === intent.taskId ? envelope : undefined;
|
|
319
|
+
},
|
|
320
|
+
putInsertionReceipt(taskId, eventId) { requireTask(taskId); return put("insertions", pair(taskId, eventId), true, true); },
|
|
321
|
+
reserveTaskOperation(input) { requireTask(input.taskId); const key = pair(input.taskId, input.operation); const created = put("operations", key, input, true); return { created, record: get<TaskOperationRecord>("operations", key)! }; },
|
|
322
|
+
discardUnsentOperation(taskId, operation) {
|
|
323
|
+
store.transaction(() => {
|
|
324
|
+
const key = pair(taskId, operation), record = get<TaskOperationRecord>("operations", key); if (!record) return;
|
|
325
|
+
for (const id of record.envelopeIds) {
|
|
326
|
+
if (get<OutboxRecord>("outbox", id)?.state !== "pending") throw new Error("only a pending, never-sent operation can be discarded");
|
|
327
|
+
remove("outbox", id);
|
|
328
|
+
}
|
|
329
|
+
remove("intents", record.logicalId); remove("operations", key);
|
|
330
|
+
});
|
|
331
|
+
},
|
|
332
|
+
getEndpointGeneration() { return state<string>("generation"); },
|
|
333
|
+
setEndpointGeneration(value) { put("state", "generation", value); },
|
|
334
|
+
getEndpointBinding() { return state<TaskEndpoint>("endpoint"); },
|
|
335
|
+
setEndpointBinding(value) { put("state", "endpoint", value); },
|
|
336
|
+
getRelayTransportBinding() { return state<RelayTransportBinding>("transport"); },
|
|
337
|
+
setRelayTransportBinding(value) { put("state", "transport", value); },
|
|
338
|
+
clear() { check(); if (frames.length) throw new Error("cannot clear task state during a transaction"); tables.clear(); eventsByTask.clear(); activeTasks.clear(); pendingOutbox.clear(); pendingByTarget.clear(); pendingTarget.clear(); bytes = entries = sequence = 0; },
|
|
339
|
+
close() { if (closed) return; store.clear(); closed = true; },
|
|
340
|
+
};
|
|
341
|
+
return store;
|
|
342
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { TaskProtocolError } from "./task-protocol";
|
|
2
|
+
|
|
3
|
+
export const TASK_WORK_LIMITS = { scopedSends: 56, retries: 8, acknowledgements: 48, pageItems: 50,
|
|
4
|
+
deadlineMs: 2_000, retrySliceMs: 250, ackRepairSliceMs: 250, capacityCooldownMs: 5_000, retryInspections: 64,
|
|
5
|
+
// Fetched-but-unacknowledged relay deliveries; intake pauses at this bound until ACKs drain it.
|
|
6
|
+
pendingDeliveries: 200 } as const;
|
|
7
|
+
|
|
8
|
+
/** Time source for delivery budgets: the background deadline, admission slices and retry cooldowns. Tests inject virtual time. */
|
|
9
|
+
export interface TaskTimers {
|
|
10
|
+
/** Wall-clock milliseconds, comparable with stored retry times. */
|
|
11
|
+
now(): number;
|
|
12
|
+
/** Monotonic milliseconds for admission slices. */
|
|
13
|
+
elapsed(): number;
|
|
14
|
+
/** A signal that aborts once `ms` have passed. */
|
|
15
|
+
timeout(ms: number): AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const SYSTEM_TASK_TIMERS: TaskTimers = { now: () => Date.now(), elapsed: () => performance.now(), timeout: ms => AbortSignal.timeout(ms) };
|
|
19
|
+
|
|
20
|
+
/** Required envelopes a phased pass persisted; its ship phase sends them in persist order. */
|
|
21
|
+
export interface TaskShipment {
|
|
22
|
+
readonly envelopeIds: readonly string[];
|
|
23
|
+
/** Throws the blocked-delivery error once the envelopes were offered. */
|
|
24
|
+
readonly verify?: () => void;
|
|
25
|
+
/** Runs once every envelope is accepted, e.g. staging the raw ACK that accepted fan-out authorizes. */
|
|
26
|
+
readonly accepted?: () => void;
|
|
27
|
+
/** Set by the ship phase, with the first send or blocked-delivery failure, if any. */
|
|
28
|
+
shipped: boolean;
|
|
29
|
+
failure?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Shared counts for nested receipts/fanout; background I/O also has one finite window. */
|
|
33
|
+
export interface TaskWorkScope {
|
|
34
|
+
readonly signal: AbortSignal;
|
|
35
|
+
readonly attemptedSends: Set<string>;
|
|
36
|
+
readonly attemptedAcknowledgements: Set<string>;
|
|
37
|
+
readonly blockedRetryTargets: Set<string>;
|
|
38
|
+
retryStarted: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Fixed at creation for a refresh pass: its driver runs intake, presentation, required sends,
|
|
41
|
+
* ACKs and old-work retry as explicit phases, so core operations never start old-work retry
|
|
42
|
+
* or ACK repair on their own.
|
|
43
|
+
*/
|
|
44
|
+
readonly phased: boolean;
|
|
45
|
+
/** Deferred required sends of a phased pass, in persist order. */
|
|
46
|
+
readonly shipments: TaskShipment[];
|
|
47
|
+
claimSend(envelopeId: string, retry: boolean): boolean;
|
|
48
|
+
claimAcknowledgement(cursor: string): boolean;
|
|
49
|
+
canAcknowledge(): boolean;
|
|
50
|
+
remainingAcknowledgements(): number;
|
|
51
|
+
canRetry(): boolean;
|
|
52
|
+
canRepairAcknowledgement(): boolean;
|
|
53
|
+
backgroundSignal(): AbortSignal;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createTaskWorkScope(signal?: AbortSignal, mode: "background" | "foreground" | "phased" = "background", timers: TaskTimers = SYSTEM_TASK_TIMERS): TaskWorkScope {
|
|
57
|
+
const caller = signal ?? new AbortController().signal;
|
|
58
|
+
let background: AbortSignal | undefined;
|
|
59
|
+
const backgroundSignal = () => background ??= AbortSignal.any([caller, timers.timeout(TASK_WORK_LIMITS.deadlineMs)]);
|
|
60
|
+
// Foreground requests retain the transport/caller timeout. Their speculative
|
|
61
|
+
// retries start a bounded background window only after required sends finish.
|
|
62
|
+
const active = mode === "foreground" ? caller : backgroundSignal();
|
|
63
|
+
const attemptedSends = new Set<string>(), attemptedAcknowledgements = new Set<string>(), blockedRetryTargets = new Set<string>();
|
|
64
|
+
let scopedSends = 0, retrySends = 0, acknowledgements = 0;
|
|
65
|
+
let retryUntil: number | undefined, repairUntil: number | undefined;
|
|
66
|
+
return {
|
|
67
|
+
signal: active, attemptedSends, attemptedAcknowledgements, blockedRetryTargets, retryStarted: false, phased: mode === "phased", shipments: [],
|
|
68
|
+
backgroundSignal,
|
|
69
|
+
claimSend(id, retry) {
|
|
70
|
+
if ((retry ? backgroundSignal() : active).aborted || attemptedSends.has(id) || (retry ? retrySends >= TASK_WORK_LIMITS.retries : scopedSends >= TASK_WORK_LIMITS.scopedSends)) return false;
|
|
71
|
+
attemptedSends.add(id);
|
|
72
|
+
if (retry) retrySends++; else scopedSends++;
|
|
73
|
+
return true;
|
|
74
|
+
},
|
|
75
|
+
claimAcknowledgement(cursor) {
|
|
76
|
+
if (active.aborted || attemptedAcknowledgements.has(cursor) || acknowledgements >= TASK_WORK_LIMITS.acknowledgements) return false;
|
|
77
|
+
attemptedAcknowledgements.add(cursor); acknowledgements++;
|
|
78
|
+
return true;
|
|
79
|
+
},
|
|
80
|
+
canAcknowledge() { return !active.aborted && acknowledgements < TASK_WORK_LIMITS.acknowledgements; },
|
|
81
|
+
remainingAcknowledgements() { return active.aborted ? 0 : TASK_WORK_LIMITS.acknowledgements - acknowledgements; },
|
|
82
|
+
// Slices stop admission, not completion: an admitted request gets the
|
|
83
|
+
// remaining background window, including cancellation and late fences.
|
|
84
|
+
canRetry() { return !backgroundSignal().aborted && timers.elapsed() < (retryUntil ??= timers.elapsed() + TASK_WORK_LIMITS.retrySliceMs); },
|
|
85
|
+
canRepairAcknowledgement() { return !backgroundSignal().aborted && timers.elapsed() < (repairUntil ??= timers.elapsed() + TASK_WORK_LIMITS.ackRepairSliceMs); },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function deferredTaskDelivery(details?: Record<string, unknown>): TaskProtocolError {
|
|
90
|
+
return new TaskProtocolError("TASK_DELIVERY_DEFERRED", "task delivery deferred by the local work budget or retry cooldown", { retryable: true, details });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Known admission backpressure gets at least one normal polling interval, without another timer. */
|
|
94
|
+
export function capacityRetryAt(error: unknown, now = Date.now()): number | undefined {
|
|
95
|
+
if (!(error instanceof TaskProtocolError) || error.code !== "RELAY_CAPACITY" || !error.retryable) return undefined;
|
|
96
|
+
const hint = error.details?.retryAfterMs;
|
|
97
|
+
return now + Math.max(TASK_WORK_LIMITS.capacityCooldownMs, typeof hint === "number" && Number.isFinite(hint) && hint >= 0 ? hint : 0);
|
|
98
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Stable public reason prefix for every worker-gate denial. */
|
|
2
|
+
export const WORKER_GATE_DENIAL_CODE = "PI_TASK_WORKER_ASSIGNMENT_REQUIRED";
|
|
3
|
+
|
|
4
|
+
/** Environment variables that activate the task-worker gate. Either one set to exactly "1" selects worker mode. */
|
|
5
|
+
export const TASK_WORKER_ENV_VARS = Object.freeze(["PI_TASK_WORKER", "WOLFPACK_TASK_WORKER"] as const);
|
|
6
|
+
|
|
7
|
+
export function taskWorkerModeEnabled(env: Readonly<Record<string, string | undefined>> = process.env): boolean {
|
|
8
|
+
return TASK_WORKER_ENV_VARS.some((name) => env[name] === "1");
|
|
9
|
+
}
|