@hypercarrier/pi-team-durable 0.1.0-rc.1 → 0.1.0-rc.2

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.
Files changed (35) hide show
  1. package/README.md +100 -9
  2. package/dist/authority/contracts.d.ts +7 -0
  3. package/dist/authority/contracts.js +20 -4
  4. package/dist/authority/delivery.d.ts +4 -3
  5. package/dist/authority/delivery.js +35 -29
  6. package/dist/authority/graph-control.d.ts +1 -1
  7. package/dist/authority/incremental-graph-control.d.ts +156 -0
  8. package/dist/authority/incremental-graph-control.js +1051 -0
  9. package/dist/authority/index.d.ts +5 -1
  10. package/dist/authority/index.js +5 -1
  11. package/dist/authority/interrupt.js +3 -3
  12. package/dist/authority/notifier.d.ts +1 -1
  13. package/dist/authority/notifier.js +26 -20
  14. package/dist/authority/observation-store.d.ts +104 -0
  15. package/dist/authority/observation-store.js +226 -0
  16. package/dist/authority/observation.d.ts +3 -45
  17. package/dist/authority/observation.js +86 -68
  18. package/dist/authority/panel.d.ts +121 -0
  19. package/dist/authority/panel.js +44 -0
  20. package/dist/authority/projection.d.ts +5 -4
  21. package/dist/authority/projection.js +11 -11
  22. package/dist/authority/rc7-contract.json +57 -1
  23. package/dist/authority/runtime.d.ts +38 -2
  24. package/dist/authority/runtime.js +13 -14
  25. package/dist/authority/state.d.ts +19 -4
  26. package/dist/authority/state.js +14 -12
  27. package/dist/authority/store.d.ts +144 -0
  28. package/dist/authority/store.js +675 -0
  29. package/dist/authority/sync.d.ts +4 -3
  30. package/dist/authority/sync.js +20 -19
  31. package/dist/authority/tools.d.ts +41 -4
  32. package/dist/authority/tools.js +63 -87
  33. package/dist/testing.d.ts +11 -0
  34. package/dist/testing.js +10 -0
  35. package/package.json +7 -1
package/README.md CHANGED
@@ -19,10 +19,14 @@ notices.
19
19
  Later versions publish from CI with npm provenance.
20
20
  - **Protocol value.** The persisted notice value `sender: 'durable-team'` is a
21
21
  protocol value from the package's former name. It stays unchanged.
22
- - **Tests here.** CI runs the unit suites.
22
+ - **Tests here.** CI runs the unit suites and a 100-seed differential oracle run.
23
+ Run 500 or more seeds by hand before a release.
23
24
  The SIGKILL crash suite and `bench/e2e.ts` stay in HyperCarrier (private).
24
- - **Store format.** This version stores version-1 documents. The next version
25
- migrates them to version 2, and a downgrade cannot read a migrated store.
25
+ - **Before deploy.** The first open migrates the store to the indexed format. The
26
+ migration is one-way: rollback means restoring a backup. Back up the store, and
27
+ stop every older node process first. 0.1.0-rc.1 stores version-1 documents.
28
+ This version migrates them to version 2, and a downgrade cannot read a migrated
29
+ store.
26
30
 
27
31
  Known issues:
28
32
 
@@ -44,14 +48,26 @@ npm install @hypercarrier/pi-team-durable@next
44
48
  The package ships compiled JavaScript and type declarations under `dist`.
45
49
  Node 22.19 or newer. Install the three pinned peers beside it.
46
50
 
51
+ `./testing` exports named internals for a host's own tests. It is unstable test support:
52
+ its names can change in any release. Production code imports `./authority` only.
53
+
47
54
  ## API
48
55
 
49
56
  `./authority` exports the runtime factory, authority documents, observation
50
57
  observer, and structural stop ports. A host adapter registers this runtime.
51
58
 
52
- - `ptb.team`, `ptb.graph`, `ptb.journal`, `ptb.outbox`, and `ptb.receipts` store
53
- authority, graph history, recent events, delivery obligations, and replay results.
54
- Each accepted command and its mechanics commit together.
59
+ - `ptb.team` owns membership and lifecycle. `ptb.head` holds current graph
60
+ coordinates, readiness, occupancy, attention, and bounded recent references.
61
+ Task family members hold current facts, retained Attempts, cards, and operator
62
+ projections. Immutable graph indexes hold topology. Worker indexes hold
63
+ nonterminal Task IDs. Each changed document is stored as a base.
64
+ - A controller-only conversation holds graph revision diffs and controller
65
+ events. Commands restore the incremental engine from current Task records.
66
+ They commit changed facts, indexes, receipts, obligations, and history together.
67
+ - Operation receipts retain exact result strings per epoch. Native tool-call
68
+ receipts use task scope and retire with the tool. Direct callers use permanent
69
+ family members. Obligations retain their identity after settlement; bounded
70
+ hash partitions index pending and unscheduled keys.
55
71
  - Nine lead tools and three Worker tools use the rc.7 contract. Exact conversation
56
72
  and generation bindings guard authority. A fork does not acquire an existing
57
73
  Team's lead authority.
@@ -75,9 +91,51 @@ observer, and structural stop ports. A host adapter registers this runtime.
75
91
  The final request hook records exact page visibility. A later observer requires
76
92
  a matching persisted native assistant entry and successful input settlement
77
93
  before it advances the cursor. Compaction and failed runs release unseen pages.
78
- - The journal keeps 100 recent events in its document and immutable `ptb.event`
79
- entries in native storage. This bound does not cover graph history, receipts,
80
- outbox, observation proofs, or storage history.
94
+ - The head keeps references to 100 recent journal events. Their immutable
95
+ `ptb.event` entries stay in the lead conversation, so sync page boundaries stay
96
+ unchanged.
97
+ - Observation indexes, page metadata, immutable page bodies, and open proofs
98
+ use separate base-checkpointed documents. Requests check active pages from
99
+ the newest message backward. They do not hash transcript history.
100
+ - The observer retires a proof after its run settles and it processes the
101
+ evidence. It retires a page body only when the page is acknowledged or
102
+ replaced and the contiguous cursor covers it. Unsettled proofs and notifier
103
+ submit/settle checkpoints keep referenced bodies. Unknown notifier ownership
104
+ keeps the body. Page metadata and the exact replay receipts remain.
105
+ - `observations(tx)` and `observationStats(tx)` are diagnostic readers. The
106
+ latter reports current-record counts and UTF-8 JSON bytes. Production hooks
107
+ and the observer read current indexes.
108
+ - `/ptb` reads one acknowledged scan anchor for the current epoch. This anchor
109
+ differs from the runtime reservation anchor in `scanByEpoch`. Cursor advance
110
+ folds eligible waiting-page anchors before removing their waiting entries.
111
+ Body retirement retains the anchor; epoch change removes it.
112
+ - `readTeamPanel` reads current Task projections and recent records.
113
+ `readTeamSummary` reads head indexes. `state.open()` is a full-history diagnostic
114
+ adapter; runtime paths do not use it.
115
+
116
+ The first authority open migrates rc.7 documents in one transaction. It checks
117
+ current facts against the reference controller and writes the new records and
118
+ log. The same transaction raises all six shared PTB document kinds to version 2:
119
+ `ptb.team`, `ptb.graph`, `ptb.journal`, `ptb.outbox`, `ptb.receipts`, and
120
+ `ptb.observation`. `ptb.team` keeps its data. The other five retain only
121
+ `{fence:"ptb-authority-v2"}`. Current commands use the new authority records.
122
+ These occupied addresses prevent old code from creating fresh version-1
123
+ documents. pi-durable rejects old typed reads before mutation. Fresh stores
124
+ receive the same fence. Native pi-durable documents keep their own versions.
125
+
126
+ A migrated operation receipt receives its original projection check on its
127
+ first replay. Downgrade is not supported.
128
+
129
+ The fence covers stopped and reopened stores, not an old process that is still
130
+ running. Before the first open with this build, stop every node process that
131
+ runs older code, and back up the whole store directory. To roll back, restore
132
+ that backup and run the older build on it. A rollback discards all Team work
133
+ accepted after the migration.
134
+
135
+ Known hazard until the next contract change: `team_sync` can replay a stale
136
+ page. This occurred with a direct snapshot call as well as nested calls. Leads
137
+ use `task_read` for any Task state they act on. A direct `team_sync` call does
138
+ not establish current Task state.
81
139
 
82
140
  The host injects model roles: a static `modelRoles` list, or a `catalog(tx,
83
141
  lead)` resolver that gives each lead its own roles and default. The AmPi
@@ -93,11 +151,44 @@ contract pin: it ships the same source files, and a test compares their SHA-256
93
151
  values with the recorded ones. The tests regenerate the contract from the
94
152
  installed devDependency `@hypercarrier/pi-team-bright@0.25.1`.
95
153
 
154
+ Each role's tool labels and descriptions come from Pi Team Bright's own
155
+ registration. `scripts/pi-registration.mjs` captures it per role through an
156
+ inert host. The parity test compares every durable tool with the installed
157
+ `@hypercarrier/pi-team-bright`. Known gap: Pi's Worker `alert_send` requires
158
+ non-empty `text` and `task_id`; the durable schema does not yet.
159
+
96
160
  ```sh
97
161
  npm ci
98
162
  npm test
99
163
  ```
100
164
 
165
+ The hardening oracle freezes `src/authority` at
166
+ certified P, the 0.1.0-rc.1 port, in `test/oracle/authority`.
167
+ Do not update that copy when optimizing the runtime. The seeded differential
168
+ harness compares exact tool results or error messages, all public Task cards,
169
+ and created `ptb.deliver` inputs after every command. Each runtime has its own
170
+ MemoryStorage Harness. Both schedulers stay paused. The stop port returns fake
171
+ settlement. A synthetic persisted lead run permits snapshot and update reads.
172
+ Delivery execution and observation acknowledgement have separate tests.
173
+
174
+ ```sh
175
+ # From the repository root: N seeds over K processes (defaults: 500 and 4).
176
+ npm run test:oracle -- --seeds 500 --shards 4
177
+ # Replay one failing seed. Failures print its full command trace.
178
+ npm run test:oracle -- --seed 42 --seeds 1 --shards 1
179
+ # Run only the deterministic boundary cases.
180
+ npm run test:oracle -- --targeted-only
181
+ ```
182
+
183
+ Each seed runs about 70 commands over at most seven Tasks. It combines a fixed
184
+ lifecycle and transition prefix with 30 random commands, then starts a new Team
185
+ epoch. Use `--steps` to change the random command count. A separate deterministic
186
+ sequence covers prerequisite repair, superseded dependents, removed and
187
+ reintroduced keys, stale lineage, blocked Worker capacity, reorder-only
188
+ restatement, and stop notices after Worker reconnection. The normal Vitest suite
189
+ runs these cases and three smoke seeds. This oracle detects behavior drift from the frozen
190
+ runtime; it does not prove that the frozen behavior is correct.
191
+
101
192
  Fast tests use MemoryStorage, a fixed clock, inert timers, and faux responses.
102
193
  The SIGKILL crash-recovery suite and `bench/e2e.ts` stay in HyperCarrier (private).
103
194
  They use the AmPi node's subprocess carrier. This repository's tests do not
@@ -3,10 +3,17 @@ import type { Result } from './state.ts';
3
3
  export declare const TOOL_NAMES: readonly ["team_create", "team_sync", "ensure_worker", "task_graph_apply", "task_read", "task_update", "worker_stop", "team_shutdown", "alert_send"];
4
4
  export type ToolName = typeof TOOL_NAMES[number];
5
5
  export type Role = 'lead' | 'worker';
6
+ /** The label and description Pi Team Bright registers for one role's tool. */
7
+ export interface ToolPresentation {
8
+ label: string;
9
+ description: string;
10
+ }
6
11
  export interface Contract {
7
12
  lead: Record<ToolName, TSchema>;
8
13
  worker: Partial<Record<ToolName, TSchema>>;
9
14
  results: Record<ToolName, TSchema>;
15
+ presentation: Record<Role, Partial<Record<ToolName, ToolPresentation>>>;
10
16
  }
17
+ export declare function checkSchema(schema: TSchema, value: unknown): boolean;
11
18
  export declare function contracts(goalMaxLength?: number): Contract;
12
19
  export declare function checkResult(contract: Contract, name: ToolName, value: Result): Result;
@@ -1,12 +1,27 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { Check } from 'typebox/value';
2
+ import { Compile } from 'typebox/compile';
3
3
  export const TOOL_NAMES = ['team_create', 'team_sync', 'ensure_worker', 'task_graph_apply', 'task_read', 'task_update', 'worker_stop', 'team_shutdown', 'alert_send'];
4
4
  // Generated from the pinned executable rc.7 contract, not the stale agent surface.
5
- const source = JSON.parse(readFileSync(new URL('./rc7-contract.json', import.meta.url), 'utf8'));
5
+ const source = readFileSync(new URL('./rc7-contract.json', import.meta.url), 'utf8');
6
+ const patchedContracts = new Map();
7
+ const validators = new Map();
8
+ export function checkSchema(schema, value) {
9
+ const key = JSON.stringify(schema, (_key, value) => value && typeof value === 'object' && !Array.isArray(value)
10
+ ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : value);
11
+ let validator = validators.get(key);
12
+ if (!validator) {
13
+ validator = Compile(JSON.parse(key));
14
+ validators.set(key, validator);
15
+ }
16
+ return validator.Check(value);
17
+ }
6
18
  export function contracts(goalMaxLength = 1600) {
7
19
  if (!Number.isSafeInteger(goalMaxLength) || goalMaxLength < 1)
8
20
  throw new Error('goalMaxLength must be a positive safe integer');
9
- const copy = structuredClone(source);
21
+ const cached = patchedContracts.get(goalMaxLength);
22
+ if (cached !== undefined)
23
+ return JSON.parse(cached);
24
+ const copy = JSON.parse(source);
10
25
  function patch(value) {
11
26
  if (!value || typeof value !== 'object')
12
27
  return;
@@ -20,10 +35,11 @@ export function contracts(goalMaxLength = 1600) {
20
35
  patch(child);
21
36
  }
22
37
  patch(copy);
38
+ patchedContracts.set(goalMaxLength, JSON.stringify(copy));
23
39
  return copy;
24
40
  }
25
41
  export function checkResult(contract, name, value) {
26
- if (!Check(contract.results[name], value))
42
+ if (!checkSchema(contract.results[name], value))
27
43
  throw new Error(`PTB ${name} produced a result outside the rc.7 contract: ${JSON.stringify(value)}`);
28
44
  return value;
29
45
  }
@@ -1,6 +1,7 @@
1
1
  import type { AbortRun, InterruptTarget } from './interrupt.ts';
2
2
  import { type SubmissionId, type Tx } from '@earendil-works/pi-durable';
3
- import { type State, type Obligation } from './state.ts';
3
+ import { type Obligation } from './state.ts';
4
+ import { type Authority as State } from './store.ts';
4
5
  /** `kind` and `sender` name the delivery in its queue entry; inputs persisted before they existed lack them. */
5
6
  export interface DeliveryInput extends InterruptTarget {
6
7
  interrupt?: true;
@@ -26,8 +27,8 @@ export type TeamNoticeData = {
26
27
  interrupt?: true;
27
28
  };
28
29
  export declare const SENDER_LIMIT = 64;
29
- export declare function eligible(s: State, o: Obligation): boolean;
30
- export declare function deliveryInput(s: State, o: Obligation): DeliveryInput;
30
+ export declare function eligible(s: State, o: Obligation): Promise<boolean>;
31
+ export declare function deliveryInput(s: State, o: Obligation): Promise<DeliveryInput>;
31
32
  /** The input a delivery submits. The request ID names the Obligation, so a retry reacquires the same admission. */
32
33
  export declare function deliveryDraft(input: DeliveryInput, interruption?: Obligation['interruption']): {
33
34
  type: "input";
@@ -1,10 +1,9 @@
1
1
  import { withAbortSignal } from '@earendil-works/chord/context';
2
2
  import { defineTask } from '@earendil-works/pi-durable';
3
- import { GraphTaskController } from "./graph-control.js";
4
- import { projectGraphTaskCards } from "./graph-card.js";
5
- import { open, plain, event } from "./state.js";
3
+ import { plain, event } from "./state.js";
4
+ import { openAuthority as open, flushAuthority, markScheduled, unscheduledObligations, taskRecord, alertRecord, obligation, setObligationState } from "./store.js";
6
5
  export const SENDER_LIMIT = 64;
7
- export function eligible(s, o) {
6
+ export async function eligible(s, o) {
8
7
  const team = s.team.current;
9
8
  if (!team || team.epoch !== o.epoch || team.lifecycle !== 'active' || team.shutdownPending || o.state !== 'pending')
10
9
  return false;
@@ -15,23 +14,22 @@ export function eligible(s, o) {
15
14
  return false;
16
15
  if (o.kind !== 'task')
17
16
  return true;
18
- if (!s.graph.snapshot || s.graph.epoch !== team.epoch)
17
+ if (!s.head.graph || s.head.epoch !== team.epoch)
19
18
  return false;
20
- const task = GraphTaskController.recover(plain(s.graph.snapshot)).readTasks().find(t => t.id === o.taskId);
21
- return !!task && task.version === o.version && task.assignee === o.recipient && task.state.kind === 'ready';
19
+ const task = (await taskRecord(s, o.taskId))?.card;
20
+ return !!task && task.version === o.version && task.assignee === o.recipient && task.status === 'ready';
22
21
  }
23
- export function deliveryInput(s, o) {
22
+ export async function deliveryInput(s, o) {
24
23
  let payload;
25
24
  if (o.kind === 'alert')
26
- payload = { kind: 'alert', alert: plain(s.outbox.alerts[o.alertId]) };
25
+ payload = { kind: 'alert', alert: plain(await alertRecord(s, o.alertId)) };
27
26
  else if (o.kind === 'stop')
28
27
  payload = { kind: 'task_changed', task_id: o.taskId, message: 'The previous Attempt was superseded or cancelled. Read current Task authority before continuing.' };
29
28
  else {
30
- const graph = GraphTaskController.recover(plain(s.graph.snapshot));
31
- payload = { kind: 'task', task: projectGraphTaskCards(graph).find(t => t.id === o.taskId) };
29
+ payload = { kind: 'task', task: plain((await taskRecord(s, o.taskId))?.card) };
32
30
  }
33
31
  // An Alert names its author; durable-team sends Tasks and stops on the Team authority's behalf, as team-lead.
34
- const sender = (o.kind === 'alert' ? s.outbox.alerts[o.alertId]?.from : 'team-lead')?.slice(0, SENDER_LIMIT);
32
+ const sender = (o.kind === 'alert' ? (await alertRecord(s, o.alertId))?.from : 'team-lead')?.slice(0, SENDER_LIMIT);
35
33
  return { key: o.key, epoch: o.epoch, conversationId: o.conversationId, ...(o.interrupt ? { interrupt: true, ...(o.interruptRun !== undefined ? { expectedRunTaskId: o.interruptRun } : {}) } : {}), content: JSON.stringify(payload), whenBusy: o.kind === 'task' ? 'followUp' : 'steer', kind: o.kind, ...(sender ? { sender } : {}) };
36
34
  }
37
35
  /** The input a delivery submits. The request ID names the Obligation, so a retry reacquires the same admission. */
@@ -48,12 +46,13 @@ export const Deliver = defineTask({
48
46
  submit: async (task, runtime, context) => {
49
47
  let valid = false, interruption;
50
48
  await runtime.commit(async (tx) => {
51
- const s = await open(tx), o = s.outbox.obligations[task.input.key];
52
- valid = !!o && eligible(s, o);
49
+ const s = await open(tx), o = await obligation(s, task.input.key);
50
+ valid = !!o && await eligible(s, o);
53
51
  interruption = o?.interruption;
54
52
  if (!valid) {
55
53
  if (o)
56
- o.state = 'retired';
54
+ await setObligationState(s, o, 'retired');
55
+ flushAuthority(s);
57
56
  return { status: 'terminal', outcome: { status: 'completed', result: null } };
58
57
  }
59
58
  return undefined;
@@ -65,7 +64,7 @@ export const Deliver = defineTask({
65
64
  throw new Error('PTB delivery recipient is missing');
66
65
  const submission = await target.submit(draft(task.input, interruption), context);
67
66
  await runtime.commit(async (tx) => {
68
- const s = await open(tx), o = s.outbox.obligations[task.input.key];
67
+ const s = await open(tx), o = await obligation(s, task.input.key);
69
68
  if (!o)
70
69
  throw new Error('PTB delivery obligation is missing');
71
70
  o.submissionId = submission.id;
@@ -80,22 +79,22 @@ export const Deliver = defineTask({
80
79
  throw new Error('PTB admitted recipient is missing');
81
80
  // Same request ID reacquires the durable admission after a process interruption.
82
81
  let interruption;
83
- await runtime.commit(async (tx) => { interruption = (await open(tx)).outbox.obligations[task.input.key]?.interruption; return undefined; }, context);
82
+ await runtime.commit(async (tx) => { interruption = (await obligation(await open(tx), task.input.key))?.interruption; return undefined; }, context);
84
83
  const submission = await target.submit(draft(task.input, interruption), context);
85
84
  if (submission.id !== task.state.checkpoint.submissionId)
86
85
  throw new Error('PTB admission identity changed');
87
86
  let obsolete = false;
88
- await runtime.commit(async (tx) => { const s = await open(tx), o = s.outbox.obligations[task.input.key]; obsolete = !o || !eligible(s, o); return undefined; }, context);
87
+ await runtime.commit(async (tx) => { const s = await open(tx), o = await obligation(s, task.input.key); obsolete = !o || !await eligible(s, o); return undefined; }, context);
89
88
  if (obsolete)
90
89
  await submission.abort(context);
91
90
  const settled = await submission.wait(context);
92
91
  await runtime.commit(async (tx) => {
93
- const s = await open(tx), o = s.outbox.obligations[task.input.key];
92
+ const s = await open(tx), o = await obligation(s, task.input.key);
94
93
  if (o) {
95
94
  o.settledAt = runtime.now();
96
95
  o.outcome = settled.status === 'done' ? 'answered' : `unanswered:${settled.reason}`;
97
96
  if (o.state === 'pending')
98
- o.state = 'settled';
97
+ await setObligationState(s, o, 'settled');
99
98
  }
100
99
  if (s.team.current?.epoch === task.input.epoch)
101
100
  await event(tx, s, 'authority', 'delivery_settled', { at: new Date(runtime.now()).toISOString() });
@@ -104,10 +103,10 @@ export const Deliver = defineTask({
104
103
  },
105
104
  },
106
105
  abort: async (task, runtime, context) => {
107
- await runtime.commit(async (tx) => { const o = (await open(tx)).outbox.obligations[task.input.key]; if (o) {
108
- o.state = 'retired';
106
+ await runtime.commit(async (tx) => { const s = await open(tx), o = await obligation(s, task.input.key); if (o) {
107
+ await setObligationState(s, o, 'retired');
109
108
  o.outcome = 'stopped';
110
- } return { status: 'terminal', outcome: { status: 'aborted' } }; }, context);
109
+ } flushAuthority(s); return { status: 'terminal', outcome: { status: 'aborted' } }; }, context);
111
110
  },
112
111
  });
113
112
  /** Keep the shared submit/settle mechanics; only the host-owned interruption needs a port. */
@@ -128,10 +127,11 @@ export function createDeliver(abortRun) {
128
127
  outcome = 'failed';
129
128
  }
130
129
  await runtime.commit(async (tx) => {
131
- const s = await open(tx), o = s.outbox.obligations[task.input.key];
132
- if (!o || !eligible(s, o) || outcome === 'retired') {
130
+ const s = await open(tx), o = await obligation(s, task.input.key);
131
+ if (!o || !await eligible(s, o) || outcome === 'retired') {
133
132
  if (o)
134
- o.state = 'retired';
133
+ await setObligationState(s, o, 'retired');
134
+ flushAuthority(s);
135
135
  return { status: 'terminal', outcome: { status: 'completed', result: null } };
136
136
  }
137
137
  o.interruption = outcome;
@@ -144,9 +144,15 @@ export async function scheduleDeliveries(tx, s, delivery) {
144
144
  const team = s.team.current;
145
145
  if (!team || team.lifecycle !== 'active')
146
146
  return;
147
- for (const o of Object.values(s.outbox.obligations)) {
148
- if (o.deliveryTaskId || !eligible(s, o))
147
+ for (const o of await unscheduledObligations(s)) {
148
+ const key = o.key;
149
+ if (o.deliveryTaskId) {
150
+ await markScheduled(s, key);
149
151
  continue;
150
- o.deliveryTaskId = await tx.createTask(delivery, deliveryInput(s, o), { conversationId: team.lead, ownership: { kind: 'conversation' }, background: true, abandonOnRestart: false });
152
+ }
153
+ if (!await eligible(s, o))
154
+ continue;
155
+ o.deliveryTaskId = await tx.createTask(delivery, await deliveryInput(s, o), { conversationId: team.lead, ownership: { kind: 'conversation' }, background: true, abandonOnRestart: false });
156
+ await markScheduled(s, key);
151
157
  }
152
158
  }
@@ -220,7 +220,7 @@ export type GraphControlResult = GraphApplyResult | GraphTransitionResult;
220
220
  export type GraphControlReceipt = GraphControlResult & {
221
221
  replayed: boolean;
222
222
  };
223
- interface StoredReceipt {
223
+ export interface StoredReceipt {
224
224
  operationId: string;
225
225
  fingerprint: string;
226
226
  result: GraphControlResult;
@@ -0,0 +1,156 @@
1
+ import { type GraphVersionRef, type GraphApplyInput, type GraphTaskTransitionInput, type GraphTaskState, type GraphTaskView, type GraphAttemptView, type GraphRevision, type GraphControlEvent, type GraphApplyResult, type GraphTransitionResult, type GraphControlSnapshot } from "./graph-control.ts";
2
+ type StoredTaskDefinition = GraphRevision["tasks"][number];
3
+ type StoredReceipt = GraphControlSnapshot["receipts"][number];
4
+ type EventOf<K extends GraphControlEvent["kind"]> = Extract<GraphControlEvent, {
5
+ kind: K;
6
+ }>;
7
+ type AttemptStartedEvent = EventOf<"attempt_started">;
8
+ type AttemptBlockedEvent = EventOf<"attempt_blocked">;
9
+ type AttemptResumedEvent = EventOf<"attempt_resumed">;
10
+ type AttemptCompletedEvent = EventOf<"attempt_completed">;
11
+ type AttemptSupersededEvent = EventOf<"attempt_superseded">;
12
+ type TaskCancelledEvent = EventOf<"task_cancelled">;
13
+ type FailureEdgeExhaustedEvent = EventOf<"failure_edge_exhausted">;
14
+ interface AttemptFold {
15
+ started: AttemptStartedEvent;
16
+ blocked?: AttemptBlockedEvent;
17
+ resumed?: AttemptResumedEvent;
18
+ completed?: AttemptCompletedEvent;
19
+ superseded?: AttemptSupersededEvent;
20
+ cancellation?: TaskCancelledEvent;
21
+ exhausted?: FailureEdgeExhaustedEvent;
22
+ endedAt?: string;
23
+ endedSequence?: number;
24
+ }
25
+ interface DerivedTask {
26
+ definition: StoredTaskDefinition;
27
+ state: GraphTaskState;
28
+ activationKey?: string;
29
+ inputAttemptIds: Record<string, string>;
30
+ acceptedAttemptId?: string;
31
+ attemptsStarted: number;
32
+ }
33
+ declare function digest(value: unknown): string;
34
+ interface TaskNode {
35
+ definition: StoredTaskDefinition;
36
+ cancellation?: TaskCancelledEvent;
37
+ currentContext?: string;
38
+ demand: number;
39
+ traversals: number;
40
+ activations: Map<string, AttemptFold>;
41
+ derived?: DerivedTask;
42
+ view?: GraphTaskView;
43
+ }
44
+ /** Current-state representation. History and replay receipts have separate owners. */
45
+ export interface GraphMaterializedHead {
46
+ sequence: number;
47
+ revision?: Omit<GraphRevision, "tasks">;
48
+ topology: Array<{
49
+ key: string;
50
+ needs: string[];
51
+ }>;
52
+ projections: Record<string, string>;
53
+ ready: Record<string, string[]>;
54
+ occupied: Record<string, string[]>;
55
+ }
56
+ export interface GraphMaterializedTask {
57
+ id: string;
58
+ attemptsStarted: number;
59
+ node?: Omit<TaskNode, "activations">;
60
+ attempts: AttemptFold[];
61
+ }
62
+ export declare class GraphTaskRequired extends Error {
63
+ readonly ids: string[];
64
+ constructor(ids: string[]);
65
+ }
66
+ export declare class IncrementalGraphTaskController {
67
+ private readonly graphRevisions;
68
+ private readonly events;
69
+ private readonly receipts;
70
+ private readonly attempts;
71
+ private readonly attemptCounts;
72
+ private readonly activeAttempts;
73
+ private nodes;
74
+ private dependents;
75
+ private ranks;
76
+ private readonly dirty;
77
+ private readonly ready;
78
+ private readonly occupied;
79
+ private sequence;
80
+ private projections;
81
+ private readonly changed;
82
+ private restoredRevisionCount;
83
+ private structuralTasks;
84
+ private commandRecordedAt?;
85
+ private readonly clock;
86
+ constructor(clock?: () => Date);
87
+ /** Migration only. The caller validates the source with the reference controller. */
88
+ static fromSnapshot(snapshot: GraphControlSnapshot, clock?: () => Date): IncrementalGraphTaskController;
89
+ static restore(head: GraphMaterializedHead, records: ReadonlyMap<string, GraphMaterializedTask>, clock?: () => Date): IncrementalGraphTaskController;
90
+ headUpdate(): {
91
+ sequence: number;
92
+ projections: {
93
+ [k: string]: string;
94
+ };
95
+ ready: {
96
+ [k: string]: string[];
97
+ };
98
+ occupied: {
99
+ [k: string]: string[];
100
+ };
101
+ };
102
+ materializedHead(): GraphMaterializedHead;
103
+ materializedTask(id: string): GraphMaterializedTask;
104
+ changes(): {
105
+ taskIds: string[];
106
+ revisions: GraphRevision[];
107
+ events: GraphControlEvent[];
108
+ receipts: import("./graph-control.ts").StoredReceipt[];
109
+ };
110
+ restoreReceipt(receipt: StoredReceipt): void;
111
+ snapshot(): GraphControlSnapshot;
112
+ currentGraphVersion(): GraphVersionRef | undefined;
113
+ hasOperationReceipt(operationId: string): boolean;
114
+ applyGraph(input: GraphApplyInput): GraphApplyResult & {
115
+ replayed: boolean;
116
+ };
117
+ transition(input: GraphTaskTransitionInput): GraphTransitionResult & {
118
+ replayed: boolean;
119
+ };
120
+ readTask(taskId: string): GraphTaskView;
121
+ readTasks(): GraphTaskView[];
122
+ attemptStartedSequence(id: string): number;
123
+ readAttempts(taskId?: string): GraphAttemptView[];
124
+ selectReadyFrontier(): GraphTaskView[];
125
+ trace(): {
126
+ graphRevisions: GraphRevision[];
127
+ tasks: GraphTaskView[];
128
+ attempts: GraphAttemptView[];
129
+ events: GraphControlEvent[];
130
+ };
131
+ private claim;
132
+ private block;
133
+ private resume;
134
+ private complete;
135
+ private cancel;
136
+ private assertWorker;
137
+ private invalid;
138
+ private command;
139
+ private append;
140
+ private nextSequence;
141
+ private currentRevision;
142
+ private definition;
143
+ private installGraph;
144
+ private fold;
145
+ private rebuild;
146
+ private flush;
147
+ private indexView;
148
+ private readyIds;
149
+ private occupiedTask;
150
+ private activeAttemptIds;
151
+ private attemptsToSupersede;
152
+ private deriveNode;
153
+ private toView;
154
+ }
155
+ export declare function prerequisiteProjection(task: DerivedTask | undefined): string;
156
+ export { digest as graphControlFingerprint };