@sgtbeatdown/pi-tasks 0.1.15 → 0.1.16

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/src/task-store.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { TASK_WORK_LIMITS } from "./task-work-scope";
2
- import { TERMINAL_INTENT_OPERATION, TaskProtocolError } from "./task-protocol";
2
+ import { TASK_EVENT_TYPES, TERMINAL_INTENT_OPERATION, TaskProtocolError } from "./task-protocol";
3
3
  import type { RelayTransportBinding, RelayEnvelope, TaskEndpoint, TaskEvent, TaskRecord, TaskSnapshot, TerminalDeliveryState, TerminalTaskIntentType } from "./task-protocol";
4
4
 
5
5
  export interface TaskStoreOptions {
@@ -17,29 +17,53 @@ export interface TaskOperationRecord {
17
17
  readonly taskId: string; readonly operation: string; readonly logicalId: string;
18
18
  readonly logicalType: string; readonly envelopeIds: readonly string[];
19
19
  }
20
+ export interface PresentationRecord { readonly taskId: string; readonly eventId: string; }
21
+ export interface PendingPresentation extends PresentationRecord { readonly order: number; }
20
22
  export interface TaskStore {
21
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;
22
26
  putTask(task: Omit<TaskRecord, "events">): void;
23
27
  getTask(taskId: string): TaskSnapshot | undefined;
24
28
  listTasks(): readonly TaskSnapshot[];
29
+ /** Whether any task of this lifetime is active; indexed, no snapshot is built. */
30
+ hasActiveTasks(): boolean;
25
31
  setStatus(taskId: string, status: TaskRecord["status"]): void;
32
+ /** Follows a reassignment; existing envelopes keep their addresses. */
33
+ setTarget(taskId: string, target: TaskEndpoint): void;
26
34
  appendEvent(event: TaskEvent): boolean;
27
35
  putOutbox(envelope: RelayEnvelope): void;
28
36
  outbox(state: OutboxRecord["state"]): readonly OutboxRecord[];
29
37
  /** Indexed lookup, fair rotation and cooldown are required store capabilities. */
30
38
  getOutboxRecord(envelopeId: string): OutboxRecord | undefined;
31
39
  hasPendingOutbox(): boolean;
40
+ /** Rotates fairly but yields only each target's oldest pending envelope (per-target FIFO). */
32
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[];
33
44
  outboxRetryAt(envelopeId: string): number;
34
45
  postponeOutbox(envelopeId: string, until: number): void;
35
46
  markOutboxAccepted(envelopeId: string): void;
36
47
  quarantineOutbox(envelopeId: string, input: OutboxQuarantineInput): void;
37
48
  quarantinedOutbox(): readonly OutboxQuarantineRecord[];
49
+ /** Indexed lookup of one quarantined envelope. */
50
+ getQuarantine(envelopeId: string): OutboxQuarantineRecord | undefined;
38
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;
39
59
  getReceiveCursor(): string;
40
60
  setReceiveCursor(cursor: string): void;
41
61
  trackRelayDeliveries(endpoint: TaskEndpoint, deliveries: readonly { readonly cursor: string; readonly envelopeId: string }[]): void;
42
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;
43
67
  requestRelayAcknowledgement(endpoint: TaskEndpoint, cursor: string, retryAt?: number): void;
44
68
  requestedRelayAcknowledgements(endpoint: TaskEndpoint, limit?: number, now?: number): readonly string[];
45
69
  relayAcknowledgementRetryAt(endpoint: TaskEndpoint, cursor: string): number;
@@ -49,6 +73,8 @@ export interface TaskStore {
49
73
  getIntentEnvelope(intentId: string): RelayEnvelope | undefined;
50
74
  putInsertionReceipt(taskId: string, eventId: string): boolean;
51
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;
52
78
  getEndpointGeneration(): string | undefined;
53
79
  setEndpointGeneration(generation: string): void;
54
80
  getEndpointBinding(): TaskEndpoint | undefined;
@@ -60,9 +86,15 @@ export interface TaskStore {
60
86
  }
61
87
 
62
88
  const LIMITS = { entries: 16_384, bytes: 32 * 1024 * 1024 };
63
- type Cell = { readonly text: string; readonly bytes: number; readonly order: number };
64
- type Undo = { readonly table: Map<string, Cell>; readonly key: string; readonly prior: Cell | undefined };
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 };
65
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;
66
98
 
67
99
  /** One endpoint lifetime. No SQLite, files, migrations, startup reads or replay. */
68
100
  export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
@@ -73,35 +105,72 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
73
105
  const frames: Map<string, Undo>[] = [];
74
106
  // ID-only retry rotation, bounded by the existing pending outbox; no payload copy.
75
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>();
76
110
  const syncPendingOutbox = (id: string) => {
77
111
  const row = tables.get("outbox")?.get(id);
78
- if (row && (JSON.parse(row.text) as OutboxRecord).state === "pending") {
112
+ const record = row && read<OutboxRecord>(row);
113
+ if (record?.state === "pending") {
79
114
  if (!pendingOutbox.has(id)) pendingOutbox.set(id, 0);
80
- } else pendingOutbox.delete(id);
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
+ }
81
141
  };
82
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(); };
83
148
  const remember = (name: string, key: string, target: Map<string, Cell>, prior: Cell | undefined) => {
84
149
  const frame = frames.at(-1), identity = JSON.stringify([name, key]);
85
- if (frame && !frame.has(identity)) frame.set(identity, { table: target, key, prior });
150
+ if (frame && !frame.has(identity)) frame.set(identity, { name, table: target, key, prior });
86
151
  };
87
152
  const check = () => { if (closed) throw new TaskProtocolError("RELAY_CLOSED", "task state is closed", { retryable: false }); };
88
153
  const table = (name: string) => { check(); let value = tables.get(name); if (!value) { value = new Map(); tables.set(name, value); } return value; };
89
- const get = <T>(name: string, key: string): T | undefined => { const row = table(name).get(key); return row ? JSON.parse(row.text) as T : undefined; };
90
- const rows = <T>(name: string): T[] => [...table(name).values()].sort((a, b) => a.order - b.order).map(row => JSON.parse(row.text) as T);
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)!)); };
91
157
  const put = (name: string, key: string, value: unknown, onlyNew = false): boolean => {
92
158
  const target = table(name), prior = target.get(key);
93
159
  if (onlyNew && prior) return false;
94
160
  const text = JSON.stringify(value), size = Buffer.byteLength(text) + Buffer.byteLength(key) + 64;
95
161
  if (bytes - (prior?.bytes ?? 0) + size > maxBytes || (!prior && entries >= maxEntries)) throw new TaskProtocolError("TASK_CAPACITY", "active task state is full", { retryable: false });
96
162
  remember(name, key, target, prior);
97
- target.set(key, { text, bytes: size, order: prior?.order ?? sequence++ });
163
+ const cell = { text, bytes: size, order: prior?.order ?? sequence++ };
164
+ target.set(key, cell); reindex(name, key, prior, cell);
98
165
  bytes += size - (prior?.bytes ?? 0); if (!prior) entries++;
166
+ committed(name, key);
99
167
  return true;
100
168
  };
101
169
  const remove = (name: string, key: string) => {
102
170
  const target = table(name), prior = target.get(key); if (!prior) return;
103
171
  remember(name, key, target, prior);
104
- target.delete(key); bytes -= prior.bytes; entries--;
172
+ target.delete(key); reindex(name, key, prior, undefined); bytes -= prior.bytes; entries--;
173
+ committed(name, key);
105
174
  };
106
175
  const pair = (a: string, b: string) => JSON.stringify([a, b]);
107
176
  const requireTask = (id: string) => { if (!get("tasks", id)) throw new TaskProtocolError("TASK_NOT_FOUND", "task is not active in this endpoint lifetime"); };
@@ -109,7 +178,8 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
109
178
  const terminal = (task: Omit<TaskRecord, "events">): TerminalDeliveryState => {
110
179
  const record = get<TaskOperationRecord>("operations", pair(task.taskId, TERMINAL_INTENT_OPERATION));
111
180
  if (!record) return { state: "not_submitted" };
112
- if (!["task.completed", "task.failed", "task.cancelled"].includes(record.logicalType) || record.envelopeIds.length !== 1) throw new Error("terminal operation is malformed");
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");
113
183
  const identity = { intentId: record.logicalId, intentType: record.logicalType as TerminalTaskIntentType, envelopeId: record.envelopeIds[0]!, origin: task.origin };
114
184
  const outbox = get<OutboxRecord>("outbox", identity.envelopeId);
115
185
  if (outbox) return { state: outbox.state, ...identity };
@@ -124,36 +194,45 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
124
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 });
125
195
  return JSON.stringify([endpoint.relay, endpoint.id, store.getRelayTransportBinding()?.epoch]);
126
196
  };
127
- const checkpoint = (endpoint: TaskEndpoint): Checkpoint => get<Checkpoint>("checkpoints", checkpointKey(endpoint)) ?? { frontier: store.getReceiveCursor(), highWater: store.getReceiveCursor(), pending: {}, requested: {} };
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: {} };
128
199
  const writeCheckpoint = (endpoint: TaskEndpoint, value: Checkpoint) => { put("checkpoints", checkpointKey(endpoint), value); };
129
200
  const store: TaskStore = {
130
201
  transaction<T>(operation: () => T): T {
131
202
  check(); const priorBytes = bytes, priorEntries = entries, priorSequence = sequence;
132
203
  const frame = new Map<string, Undo>(); frames.push(frame);
204
+ let result: T, topLevelChange = false;
133
205
  try {
134
- const result = operation();
206
+ result = operation();
135
207
  if (result instanceof Promise) throw new TypeError("task transactions must be synchronous");
136
208
  const parent = frames.at(-2);
137
209
  if (parent) {
138
210
  for (const [key, item] of frame) if (!parent.has(key)) parent.set(key, item);
139
211
  } else {
140
212
  for (const item of frame.values()) if (item.table === tables.get("outbox")) syncPendingOutbox(item.key);
213
+ topLevelChange = frame.size > 0;
141
214
  }
142
- return result;
143
215
  } catch (error) {
144
- for (const item of [...frame.values()].reverse()) { if (item.prior) item.table.set(item.key, item.prior); else item.table.delete(item.key); }
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
+ }
145
221
  bytes = priorBytes; entries = priorEntries; sequence = priorSequence;
146
222
  throw error;
147
223
  } finally { frames.pop(); }
224
+ if (topLevelChange) notify();
225
+ return result;
148
226
  },
227
+ onChange(listener) { const entry = () => listener(); listeners.add(entry); return () => { listeners.delete(entry); }; },
149
228
  putTask(task) { put("tasks", task.taskId, task, true); },
150
- getTask(id) { const task = get<Omit<TaskRecord, "events">>("tasks", id); return task ? snapshot(task, rows<TaskEvent>("events").filter(event => event.taskId === id)) : undefined; },
229
+ getTask(id) { const task = get<Omit<TaskRecord, "events">>("tasks", id); return task ? snapshot(task, taskEvents(id)) : undefined; },
151
230
  listTasks() {
152
- const events = new Map<string, TaskEvent[]>();
153
- for (const event of rows<TaskEvent>("events")) { const list = events.get(event.taskId) ?? []; list.push(event); events.set(event.taskId, list); }
154
- return rows<Omit<TaskRecord, "events">>("tasks").sort((a, b) => a.createdAt - b.createdAt || a.taskId.localeCompare(b.taskId)).map(task => snapshot(task, events.get(task.taskId) ?? []));
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)));
155
232
  },
233
+ hasActiveTasks() { check(); return activeTasks.size > 0; },
156
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 }); },
157
236
  appendEvent(event) { requireTask(event.taskId); return put("events", event.eventId, event, true); },
158
237
  putOutbox(envelope) { put("outbox", envelope.envelopeId, { envelope, state: "pending" }, true); if (!frames.length) syncPendingOutbox(envelope.envelopeId); },
159
238
  outbox(status) { return rows<OutboxRecord>("outbox").filter(item => item.state === status); },
@@ -167,13 +246,16 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
167
246
  for (let i = 0; i < inspections && selected.length < limit; i++) {
168
247
  const [id, retryAt] = pendingOutbox.entries().next().value!;
169
248
  pendingOutbox.delete(id); pendingOutbox.set(id, retryAt);
170
- if (retryAt <= now) {
171
- const record = get<OutboxRecord>("outbox", id);
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);
172
253
  if (record?.state === "pending") selected.push(record);
173
254
  }
174
255
  }
175
256
  return selected;
176
257
  },
258
+ pendingOutboxAhead(id) { check(); return pendingAhead(id); },
177
259
  outboxRetryAt(id) { check(); return pendingOutbox.get(id) ?? 0; },
178
260
  postponeOutbox(id, until) { check(); if (pendingOutbox.has(id)) pendingOutbox.set(id, until); },
179
261
  markOutboxAccepted(id) { const prior = get<OutboxRecord>("outbox", id); if (prior) put("outbox", id, { ...prior, state: "accepted" }); if (!frames.length) syncPendingOutbox(id); },
@@ -183,7 +265,13 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
183
265
  });
184
266
  },
185
267
  quarantinedOutbox() { return rows<OutboxQuarantineRecord>("quarantine"); },
268
+ getQuarantine(id) { return get<OutboxQuarantineRecord>("quarantine", id); },
186
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); },
187
275
  getReceiveCursor() { return state<string>("cursor") ?? "0"; },
188
276
  setReceiveCursor(cursor) { put("state", "cursor", cursor); },
189
277
  trackRelayDeliveries(endpoint, deliveries) {
@@ -197,6 +285,11 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
197
285
  writeCheckpoint(endpoint, value);
198
286
  },
199
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; },
200
293
  requestRelayAcknowledgement(endpoint, cursor, retryAt = 0) {
201
294
  const value = checkpoint(endpoint);
202
295
  if (value.pending[cursor] === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
@@ -226,13 +319,23 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
226
319
  },
227
320
  putInsertionReceipt(taskId, eventId) { requireTask(taskId); return put("insertions", pair(taskId, eventId), true, true); },
228
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
+ },
229
332
  getEndpointGeneration() { return state<string>("generation"); },
230
333
  setEndpointGeneration(value) { put("state", "generation", value); },
231
334
  getEndpointBinding() { return state<TaskEndpoint>("endpoint"); },
232
335
  setEndpointBinding(value) { put("state", "endpoint", value); },
233
336
  getRelayTransportBinding() { return state<RelayTransportBinding>("transport"); },
234
337
  setRelayTransportBinding(value) { put("state", "transport", value); },
235
- clear() { check(); if (frames.length) throw new Error("cannot clear task state during a transaction"); tables.clear(); pendingOutbox.clear(); bytes = entries = sequence = 0; },
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; },
236
339
  close() { if (closed) return; store.clear(); closed = true; },
237
340
  };
238
341
  return store;
@@ -1,7 +1,33 @@
1
1
  import { TaskProtocolError } from "./task-protocol";
2
2
 
3
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 } as const;
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
+ }
5
31
 
6
32
  /** Shared counts for nested receipts/fanout; background I/O also has one finite window. */
7
33
  export interface TaskWorkScope {
@@ -10,27 +36,35 @@ export interface TaskWorkScope {
10
36
  readonly attemptedAcknowledgements: Set<string>;
11
37
  readonly blockedRetryTargets: Set<string>;
12
38
  retryStarted: boolean;
13
- retryAllowed: 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[];
14
47
  claimSend(envelopeId: string, retry: boolean): boolean;
15
48
  claimAcknowledgement(cursor: string): boolean;
16
49
  canAcknowledge(): boolean;
50
+ remainingAcknowledgements(): number;
17
51
  canRetry(): boolean;
18
52
  canRepairAcknowledgement(): boolean;
19
53
  backgroundSignal(): AbortSignal;
20
54
  }
21
55
 
22
- export function createTaskWorkScope(signal?: AbortSignal, mode: "background" | "foreground" = "background"): TaskWorkScope {
56
+ export function createTaskWorkScope(signal?: AbortSignal, mode: "background" | "foreground" | "phased" = "background", timers: TaskTimers = SYSTEM_TASK_TIMERS): TaskWorkScope {
23
57
  const caller = signal ?? new AbortController().signal;
24
58
  let background: AbortSignal | undefined;
25
- const backgroundSignal = () => background ??= AbortSignal.any([caller, AbortSignal.timeout(TASK_WORK_LIMITS.deadlineMs)]);
59
+ const backgroundSignal = () => background ??= AbortSignal.any([caller, timers.timeout(TASK_WORK_LIMITS.deadlineMs)]);
26
60
  // Foreground requests retain the transport/caller timeout. Their speculative
27
61
  // retries start a bounded background window only after required sends finish.
28
- const active = mode === "background" ? backgroundSignal() : caller;
62
+ const active = mode === "foreground" ? caller : backgroundSignal();
29
63
  const attemptedSends = new Set<string>(), attemptedAcknowledgements = new Set<string>(), blockedRetryTargets = new Set<string>();
30
64
  let scopedSends = 0, retrySends = 0, acknowledgements = 0;
31
65
  let retryUntil: number | undefined, repairUntil: number | undefined;
32
66
  return {
33
- signal: active, attemptedSends, attemptedAcknowledgements, blockedRetryTargets, retryStarted: false, retryAllowed: true,
67
+ signal: active, attemptedSends, attemptedAcknowledgements, blockedRetryTargets, retryStarted: false, phased: mode === "phased", shipments: [],
34
68
  backgroundSignal,
35
69
  claimSend(id, retry) {
36
70
  if ((retry ? backgroundSignal() : active).aborted || attemptedSends.has(id) || (retry ? retrySends >= TASK_WORK_LIMITS.retries : scopedSends >= TASK_WORK_LIMITS.scopedSends)) return false;
@@ -44,10 +78,11 @@ export function createTaskWorkScope(signal?: AbortSignal, mode: "background" | "
44
78
  return true;
45
79
  },
46
80
  canAcknowledge() { return !active.aborted && acknowledgements < TASK_WORK_LIMITS.acknowledgements; },
81
+ remainingAcknowledgements() { return active.aborted ? 0 : TASK_WORK_LIMITS.acknowledgements - acknowledgements; },
47
82
  // Slices stop admission, not completion: an admitted request gets the
48
83
  // remaining background window, including cancellation and late fences.
49
- canRetry() { return !backgroundSignal().aborted && performance.now() < (retryUntil ??= performance.now() + TASK_WORK_LIMITS.retrySliceMs); },
50
- canRepairAcknowledgement() { return !backgroundSignal().aborted && performance.now() < (repairUntil ??= performance.now() + TASK_WORK_LIMITS.ackRepairSliceMs); },
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); },
51
86
  };
52
87
  }
53
88
 
@@ -56,8 +91,8 @@ export function deferredTaskDelivery(details?: Record<string, unknown>): TaskPro
56
91
  }
57
92
 
58
93
  /** Known admission backpressure gets at least one normal polling interval, without another timer. */
59
- export function capacityRetryAt(error: unknown): number | undefined {
94
+ export function capacityRetryAt(error: unknown, now = Date.now()): number | undefined {
60
95
  if (!(error instanceof TaskProtocolError) || error.code !== "RELAY_CAPACITY" || !error.retryable) return undefined;
61
96
  const hint = error.details?.retryAfterMs;
62
- return Date.now() + Math.max(TASK_WORK_LIMITS.capacityCooldownMs, typeof hint === "number" && Number.isFinite(hint) && hint >= 0 ? hint : 0);
97
+ return now + Math.max(TASK_WORK_LIMITS.capacityCooldownMs, typeof hint === "number" && Number.isFinite(hint) && hint >= 0 ? hint : 0);
63
98
  }
@@ -1,5 +1,6 @@
1
1
  import { createTaskCore } from "./task-core";
2
2
  import type { TaskCore } from "./task-core";
3
+ import { RESET_CODES, RETRY_CODES, taskErrorMessage } from "./task-errors";
3
4
  import { TASK_PROTOCOL_VERSION, TaskProtocolError } from "./task-protocol";
4
5
  import type { RelayTransportBinding, TaskEndpoint, TaskRelay } from "./task-protocol";
5
6
  import type { TaskStore } from "./task-store";
@@ -8,8 +9,6 @@ import { fromWolfpackEnvelope, toWolfpackEnvelope, WOLFPACK_TASK_RELAY_ID } from
8
9
  export const VOLATILE_PROFILE = "volatile-v1";
9
10
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
11
  const CURSOR = /^(0|[1-9][0-9]{0,31})$/;
11
- const RESET_CODES = new Set(["RELAY_RESET", "REGISTRATION_EXPIRED", "SOURCE_MISMATCH", "CALLER_DEAD", "CALLER_NOT_FOUND"]);
12
- const RETRY_CODES = new Set(["RELAY_CAPACITY", "RELAY_UNAVAILABLE", "PEER_UNREACHABLE"]);
13
12
 
14
13
  export interface VolatileTaskSessionOptions {
15
14
  /** Explicit trusted endpoint ingress URL. No default route or installed server. */
@@ -123,6 +122,7 @@ class Transport {
123
122
  private expiresAt = 0;
124
123
  private renewing: Promise<RelayTransportBinding> | undefined;
125
124
  private active = 0;
125
+ private readonly waiters: Array<{ reserved: boolean; grant(): void }> = [];
126
126
  constructor(private url: string, private caller: string, private generation: string, prior: RelayTransportBinding | undefined,
127
127
  private fetcher: typeof fetch, private timeoutMs: number, private onReset: (expected: RelayTransportBinding | undefined) => void) { this.binding = prior; }
128
128
  stop(): void { this.stopSignal.abort(); }
@@ -202,13 +202,34 @@ class Transport {
202
202
  },
203
203
  };
204
204
  }
205
+ // FIFO in-flight slots: 8 for ordinary requests, 2 more held for acknowledge/connect.
206
+ // A waiter has sent nothing, so its abort is a plain retryable ABORTED; RELAY_CAPACITY
207
+ // only ever comes from the server.
208
+ private fits(reserved: boolean): boolean { return this.active < (reserved ? 10 : 8); }
209
+ private acquire(reserved: boolean, signal?: AbortSignal): Promise<void> {
210
+ if (this.fits(reserved)) { this.active++; return Promise.resolve(); }
211
+ return new Promise((resolve, reject) => {
212
+ const cleanup = () => { signal?.removeEventListener("abort", cancel); this.stopSignal.signal.removeEventListener("abort", cancel); };
213
+ const waiter = { reserved, grant: () => { cleanup(); this.active++; resolve(); } };
214
+ const cancel = () => {
215
+ cleanup(); this.waiters.splice(this.waiters.indexOf(waiter), 1);
216
+ reject(this.stopSignal.signal.aborted ? failure("RELAY_RESET", false, { mayHaveBeenDelivered: true }) : failure("ABORTED", true));
217
+ };
218
+ this.waiters.push(waiter);
219
+ signal?.addEventListener("abort", cancel, { once: true }); this.stopSignal.signal.addEventListener("abort", cancel, { once: true });
220
+ if (signal?.aborted) cancel();
221
+ });
222
+ }
223
+ private release(): void {
224
+ this.active--;
225
+ for (const waiter of [...this.waiters]) if (this.fits(waiter.reserved)) { this.waiters.splice(this.waiters.indexOf(waiter), 1); waiter.grant(); }
226
+ }
205
227
  private async request(body: Record<string, unknown>, signal?: AbortSignal): Promise<{ epoch: string; value: unknown }> {
206
228
  this.assertActive();
207
229
  const reserved = body.operation === "acknowledge" || body.operation === "connect";
208
- if (this.active >= (reserved ? 10 : 8)) throw failure("RELAY_CAPACITY", true);
209
230
  const encoded = JSON.stringify(body);
210
231
  if (Buffer.byteLength(encoded) > 64 * 1024) throw failure("PAYLOAD_TOO_LARGE", false);
211
- this.active++;
232
+ await this.acquire(reserved, signal);
212
233
  const controller = new AbortController();
213
234
  let timedOut = false;
214
235
  const abort = () => controller.abort();
@@ -268,14 +289,15 @@ class Transport {
268
289
  if (!result.ok || data.ok !== true || !uuid(data.epoch)) throw failure("INVALID_RESPONSE", true);
269
290
  return { epoch: data.epoch, value: data.value };
270
291
  } catch (error) {
271
- if (error instanceof TaskProtocolError) throw error;
272
- throw failure("RELAY_UNAVAILABLE", true, { mayHaveBeenDelivered: true });
292
+ // Every failure of an admitted request names its relay operation for the status line.
293
+ const known = error instanceof TaskProtocolError ? error : failure("RELAY_UNAVAILABLE", true, { mayHaveBeenDelivered: true });
294
+ throw new TaskProtocolError(known.code, known.message, { retryable: known.retryable, details: { ...known.details, operation: body.operation } });
273
295
  } finally {
274
- clearTimeout(timer); this.active--; signal?.removeEventListener("abort", abort); this.stopSignal.signal.removeEventListener("abort", abort); controller.signal.removeEventListener("abort", rejectAbort);
296
+ clearTimeout(timer); this.release(); signal?.removeEventListener("abort", abort); this.stopSignal.signal.removeEventListener("abort", abort); controller.signal.removeEventListener("abort", rejectAbort);
275
297
  }
276
298
  }
277
299
  }
278
- function failure(code: string, retryable: boolean, details?: Record<string, unknown>): TaskProtocolError { return new TaskProtocolError(code, code, { retryable, details }); }
300
+ function failure(code: string, retryable: boolean, details?: Record<string, unknown>): TaskProtocolError { return new TaskProtocolError(code, taskErrorMessage(code), { retryable, details }); }
279
301
  function record(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
280
302
  function text(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value.length <= 512; }
281
303
  function uuid(value: unknown): value is string { return typeof value === "string" && UUID.test(value); }