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

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 +130 -19
  2. package/dist/authority/contracts.d.ts +7 -0
  3. package/dist/authority/contracts.js +20 -4
  4. package/dist/authority/delivery.d.ts +25 -20
  5. package/dist/authority/delivery.js +97 -49
  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 +7 -6
  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 +61 -3
  23. package/dist/authority/runtime.d.ts +42 -2
  24. package/dist/authority/runtime.js +13 -14
  25. package/dist/authority/state.d.ts +21 -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 +48 -4
  32. package/dist/authority/tools.js +79 -91
  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,16 @@ 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. 0.1.0-rc.3 also upgrades stored `ptb.deliver` tasks to task version 2.
30
+ Older versions then park those deliveries as `task_too_old`. This upgrade is
31
+ one-way too.
26
32
 
27
33
  Known issues:
28
34
 
@@ -30,8 +36,11 @@ Known issues:
30
36
  Team Bright retries it; this package does not.
31
37
  2. `team_sync` can replay pages that an earlier call released. Act only on
32
38
  `task_read`, and never call `team_sync` from a nested tool call.
33
- 3. Interrupt: a Worker can interrupt the lead, the run to stop is chosen when the
34
- Alert is accepted, and the abort has no settle bound.
39
+ 3. Interrupt fallback can leave an Alert queued. When a provider ignores the abort
40
+ for the full 10-second wait, the Alert is admitted while the run is busy. When
41
+ that run then ends by abort, the Alert waits until the next input starts a run.
42
+ An ordinary Alert queued before a run-only abort behaves the same. The cause is
43
+ pi-durable 1.4.0: an aborted run does not drain its inbox.
35
44
  4. An Alert can be admitted after its recipient is closed or fenced. After an owner
36
45
  Close, a busy Worker can still run one tool call.
37
46
 
@@ -44,17 +53,31 @@ npm install @hypercarrier/pi-team-durable@next
44
53
  The package ships compiled JavaScript and type declarations under `dist`.
45
54
  Node 22.19 or newer. Install the three pinned peers beside it.
46
55
 
56
+ `./testing` exports named internals for a host's own tests. It is unstable test support:
57
+ its names can change in any release. Production code imports `./authority` only.
58
+
47
59
  ## API
48
60
 
49
61
  `./authority` exports the runtime factory, authority documents, observation
50
62
  observer, and structural stop ports. A host adapter registers this runtime.
51
63
 
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.
64
+ - `ptb.team` owns membership and lifecycle. `ptb.head` holds current graph
65
+ coordinates, readiness, occupancy, attention, and bounded recent references.
66
+ Task family members hold current facts, retained Attempts, cards, and operator
67
+ projections. Immutable graph indexes hold topology. Worker indexes hold
68
+ nonterminal Task IDs. Each changed document is stored as a base.
69
+ - A controller-only conversation holds graph revision diffs and controller
70
+ events. Commands restore the incremental engine from current Task records.
71
+ They commit changed facts, indexes, receipts, obligations, and history together.
72
+ - Operation receipts retain exact result strings per epoch. Native tool-call
73
+ receipts use task scope and retire with the tool. Direct callers use permanent
74
+ family members. Obligations retain their identity after settlement; bounded
75
+ hash partitions index pending and unscheduled keys.
55
76
  - Nine lead tools and three Worker tools use the rc.7 contract. Exact conversation
56
77
  and generation bindings guard authority. A fork does not acquire an existing
57
- Team's lead authority.
78
+ Team's lead authority. Each role acts only on its schema's declared fields,
79
+ including nested fields. Undeclared fields do not enter replay identity.
80
+ Only the lead can request an Alert interrupt.
58
81
  - `ptb.worker-anchor` owns a Worker conversation and renews a durable sleep.
59
82
  Lead Esc does not end it. Stops use stored target IDs through the node's single
60
83
  mark-before-resume fence. Failed confirmation retains the intent.
@@ -62,22 +85,77 @@ observer, and structural stop ports. A host adapter registers this runtime.
62
85
  admission, and waits for settlement. Notices use `team.notice` entries whose
63
86
  data (`TeamNoticeData`) names the delivery kind and its sender: an Alert's
64
87
  author, or `team-lead` for Tasks and stops. An interrupt Alert's data also
65
- has `interrupt: true`. A reader names a notice from this data, never from its
66
- text.
67
- Interrupt Alerts persist the recipient run ID at acceptance. A separate phase
68
- asks the host `abortRun` port to stop that exact run, then checkpoints before
69
- normal admission. Queue identities stay native; no input is re-queued.
70
- A failed interruption appears as unproved in the notice. Retry never selects
71
- a replacement run. The node supplies a cancellable wait outside its admission lock.
88
+ has `interrupt: true`. A confirmed interruption adds `interruptedRun` to the
89
+ obligation and entry data. This field identifies the aborted native run.
90
+ A reader names a notice from this data, never from its text.
91
+ New interrupt obligations and delivery inputs carry `interruptAt: 'delivery'`.
92
+ The interrupt phase selects `LiveDoc.run` and commits its run ID and absolute
93
+ settlement deadline before asking the host `abortRun` port to stop that run.
94
+ The settlement wait has a 10-second budget. Retry keeps the selection and deadline.
95
+ An idle recipient gets a plain steer. An unproved interruption gets normal
96
+ admission with an unproved outcome. A newer run gets no second abort.
97
+ Unmarked inputs keep their acceptance-time choice and model-facing wording;
98
+ an absent run ID remains an idle-at-send choice.
99
+ Task version 2 migrates version-1 inputs and checkpoints without changing them.
100
+ After the first open and resume, older nodes park these deliveries with
101
+ `task_too_old`. Restore a backup to roll back.
102
+ Queue identities and inbox order stay native. No input is re-queued.
103
+ The node supplies a cancellable wait outside its admission lock.
104
+ Native run-only abort does not drain an input admitted before abort settlement.
105
+ That input can stay queued until another admission starts the next run. Two
106
+ bounded expected-failure cases in `test/authority/interrupt-parity.test.ts`
107
+ record it.
72
108
  - `ptb.notify` reserves an automatic page before admission. A failed lead input
73
109
  pauses notification. Explicit sync or a new owner input resumes it.
74
110
  - `team_sync` reserves successive bounded pages. It never waits for consumption.
75
111
  The final request hook records exact page visibility. A later observer requires
76
112
  a matching persisted native assistant entry and successful input settlement
77
113
  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.
114
+ - The head keeps references to 100 recent journal events. Their immutable
115
+ `ptb.event` entries stay in the lead conversation, so sync page boundaries stay
116
+ unchanged.
117
+ - Observation indexes, page metadata, immutable page bodies, and open proofs
118
+ use separate base-checkpointed documents. Requests check active pages from
119
+ the newest message backward. They do not hash transcript history.
120
+ - The observer retires a proof after its run settles and it processes the
121
+ evidence. It retires a page body only when the page is acknowledged or
122
+ replaced and the contiguous cursor covers it. Unsettled proofs and notifier
123
+ submit/settle checkpoints keep referenced bodies. Unknown notifier ownership
124
+ keeps the body. Page metadata and the exact replay receipts remain.
125
+ - `observations(tx)` and `observationStats(tx)` are diagnostic readers. The
126
+ latter reports current-record counts and UTF-8 JSON bytes. Production hooks
127
+ and the observer read current indexes.
128
+ - `/ptb` reads one acknowledged scan anchor for the current epoch. This anchor
129
+ differs from the runtime reservation anchor in `scanByEpoch`. Cursor advance
130
+ folds eligible waiting-page anchors before removing their waiting entries.
131
+ Body retirement retains the anchor; epoch change removes it.
132
+ - `readTeamPanel` reads current Task projections and recent records.
133
+ `readTeamSummary` reads head indexes. `state.open()` is a full-history diagnostic
134
+ adapter; runtime paths do not use it.
135
+
136
+ The first authority open migrates rc.7 documents in one transaction. It checks
137
+ current facts against the reference controller and writes the new records and
138
+ log. The same transaction raises all six shared PTB document kinds to version 2:
139
+ `ptb.team`, `ptb.graph`, `ptb.journal`, `ptb.outbox`, `ptb.receipts`, and
140
+ `ptb.observation`. `ptb.team` keeps its data. The other five retain only
141
+ `{fence:"ptb-authority-v2"}`. Current commands use the new authority records.
142
+ These occupied addresses prevent old code from creating fresh version-1
143
+ documents. pi-durable rejects old typed reads before mutation. Fresh stores
144
+ receive the same fence. Native pi-durable documents keep their own versions.
145
+
146
+ A migrated operation receipt receives its original projection check on its
147
+ first replay. Downgrade is not supported.
148
+
149
+ The fence covers stopped and reopened stores, not an old process that is still
150
+ running. Before the first open with this build, stop every node process that
151
+ runs older code, and back up the whole store directory. To roll back, restore
152
+ that backup and run the older build on it. A rollback discards all Team work
153
+ accepted after the migration.
154
+
155
+ Known hazard until the next contract change: `team_sync` can replay a stale
156
+ page. This occurred with a direct snapshot call as well as nested calls. Leads
157
+ use `task_read` for any Task state they act on. A direct `team_sync` call does
158
+ not establish current Task state.
81
159
 
82
160
  The host injects model roles: a static `modelRoles` list, or a `catalog(tx,
83
161
  lead)` resolver that gives each lead its own roles and default. The AmPi
@@ -93,11 +171,44 @@ contract pin: it ships the same source files, and a test compares their SHA-256
93
171
  values with the recorded ones. The tests regenerate the contract from the
94
172
  installed devDependency `@hypercarrier/pi-team-bright@0.25.1`.
95
173
 
174
+ Each role's tool labels and descriptions come from Pi Team Bright's own
175
+ registration. `scripts/pi-registration.mjs` captures it per role through an
176
+ inert host. The parity test compares every durable tool with the installed
177
+ `@hypercarrier/pi-team-bright`. Pi registers the Worker tools outside its
178
+ catalog, so their parameter schemas also come from that registration.
179
+
96
180
  ```sh
97
181
  npm ci
98
182
  npm test
99
183
  ```
100
184
 
185
+ The hardening oracle freezes `src/authority` at
186
+ certified P, the 0.1.0-rc.1 port, in `test/oracle/authority`.
187
+ Do not update that copy when optimizing the runtime. The seeded differential
188
+ harness compares exact tool results or error messages, all public Task cards,
189
+ and created `ptb.deliver` inputs after every command. Each runtime has its own
190
+ MemoryStorage Harness. Both schedulers stay paused. The stop port returns fake
191
+ settlement. A synthetic persisted lead run permits snapshot and update reads.
192
+ Delivery execution and observation acknowledgement have separate tests.
193
+
194
+ ```sh
195
+ # From the repository root: N seeds over K processes (defaults: 500 and 4).
196
+ npm run test:oracle -- --seeds 500 --shards 4
197
+ # Replay one failing seed. Failures print its full command trace.
198
+ npm run test:oracle -- --seed 42 --seeds 1 --shards 1
199
+ # Run only the deterministic boundary cases.
200
+ npm run test:oracle -- --targeted-only
201
+ ```
202
+
203
+ Each seed runs about 70 commands over at most seven Tasks. It combines a fixed
204
+ lifecycle and transition prefix with 30 random commands, then starts a new Team
205
+ epoch. Use `--steps` to change the random command count. A separate deterministic
206
+ sequence covers prerequisite repair, superseded dependents, removed and
207
+ reintroduced keys, stale lineage, blocked Worker capacity, reorder-only
208
+ restatement, and stop notices after Worker reconnection. The normal Vitest suite
209
+ runs these cases and three smoke seeds. This oracle detects behavior drift from the frozen
210
+ runtime; it does not prove that the frozen behavior is correct.
211
+
101
212
  Fast tests use MemoryStorage, a fixed clock, inert timers, and faux responses.
102
213
  The SIGKILL crash-recovery suite and `bench/e2e.ts` stay in HyperCarrier (private).
103
214
  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,9 +1,11 @@
1
1
  import type { AbortRun, InterruptTarget } from './interrupt.ts';
2
- import { type SubmissionId, type Tx } from '@earendil-works/pi-durable';
3
- import { type State, type Obligation } from './state.ts';
2
+ import { type TaskId, type SubmissionId, type Tx } from '@earendil-works/pi-durable';
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;
8
+ interruptAt?: 'delivery';
7
9
  epoch: string;
8
10
  content: string;
9
11
  whenBusy: 'steer' | 'followUp';
@@ -13,7 +15,8 @@ export interface DeliveryInput extends InterruptTarget {
13
15
  /**
14
16
  * The structured data of a Team input's `team.notice` entry. A reader names the input from these fields, never from
15
17
  * its text: `delivery` is the kind (`update` for a notifier page), `sender` the author, at most SENDER_LIMIT characters.
16
- * `interrupt` marks an Alert sent to interrupt the recipient's run; the interruption outcome stays in the model-facing content.
18
+ * `interrupt` marks an Alert sent to interrupt the recipient's run. `interruptedRun` identifies a confirmed aborted run.
19
+ * The interruption outcome stays in the model-facing content.
17
20
  * Entries written before `delivery` existed carry only `source` and `deliveryId` or `pageId`.
18
21
  */
19
22
  export type TeamNoticeData = {
@@ -24,12 +27,13 @@ export type TeamNoticeData = {
24
27
  delivery?: Obligation['kind'] | 'update';
25
28
  sender?: string;
26
29
  interrupt?: true;
30
+ interruptedRun?: TaskId;
27
31
  };
28
32
  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;
33
+ export declare function eligible(s: State, o: Obligation): Promise<boolean>;
34
+ export declare function deliveryInput(s: State, o: Obligation): Promise<DeliveryInput>;
31
35
  /** The input a delivery submits. The request ID names the Obligation, so a retry reacquires the same admission. */
32
- export declare function deliveryDraft(input: DeliveryInput, interruption?: Obligation['interruption']): {
36
+ export declare function deliveryDraft(input: DeliveryInput, interruption?: Obligation['interruption'], interruptedRun?: TaskId): {
33
37
  type: "input";
34
38
  content: string;
35
39
  whenBusy: "steer" | "followUp";
@@ -39,21 +43,22 @@ export declare function deliveryDraft(input: DeliveryInput, interruption?: Oblig
39
43
  data: TeamNoticeData;
40
44
  };
41
45
  };
42
- export declare const Deliver: import("@earendil-works/pi-durable").Task<DeliveryInput, {
43
- phase: "interrupt";
44
- } | {
45
- phase: "submit";
46
+ export declare const INTERRUPT_SETTLE_TIMEOUT_MS = 10000;
47
+ type InterruptCheckpoint = {
48
+ phase: 'interrupt';
49
+ selection?: {
50
+ run?: TaskId;
51
+ deadline: number;
52
+ };
53
+ };
54
+ type DeliveryCheckpoint = InterruptCheckpoint | {
55
+ phase: 'submit';
46
56
  } | {
47
- phase: "settle";
57
+ phase: 'settle';
48
58
  submissionId: SubmissionId;
49
- }, null, object>;
59
+ };
60
+ export declare const Deliver: import("@earendil-works/pi-durable").Task<DeliveryInput, DeliveryCheckpoint, null, object>;
50
61
  /** Keep the shared submit/settle mechanics; only the host-owned interruption needs a port. */
51
- export declare function createDeliver(abortRun: AbortRun): import("@earendil-works/pi-durable").Task<DeliveryInput, {
52
- phase: "interrupt";
53
- } | {
54
- phase: "submit";
55
- } | {
56
- phase: "settle";
57
- submissionId: SubmissionId;
58
- }, null, object>;
62
+ export declare function createDeliver(abortRun: AbortRun): import("@earendil-works/pi-durable").Task<DeliveryInput, DeliveryCheckpoint, null, object>;
59
63
  export declare function scheduleDeliveries(tx: Tx, s: State, delivery: typeof Deliver): Promise<void>;
64
+ export {};
@@ -1,10 +1,9 @@
1
- import { withAbortSignal } from '@earendil-works/chord/context';
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";
1
+ import { withAbortSignal, withCancel, awaitWithContext } from '@earendil-works/chord/context';
2
+ import { defineTask, LiveDoc } from '@earendil-works/pi-durable';
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,45 +14,52 @@ 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);
35
- 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 } : {}) };
32
+ const sender = (o.kind === 'alert' ? (await alertRecord(s, o.alertId))?.from : 'team-lead')?.slice(0, SENDER_LIMIT);
33
+ return { key: o.key, epoch: o.epoch, conversationId: o.conversationId, ...(o.interrupt ? { interrupt: true, ...(o.interruptAt ? { interruptAt: o.interruptAt } : {}), ...(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. */
38
- export function deliveryDraft(input, interruption) {
39
- const data = { source: 'team', deliveryId: input.key, epoch: input.epoch, ...(input.kind ? { delivery: input.kind } : {}), ...(input.sender ? { sender: input.sender } : {}), ...(input.interrupt && input.kind === 'alert' ? { interrupt: true } : {}) };
40
- const content = interruption ? JSON.stringify({ ...JSON.parse(input.content), interruption: { outcome: interruption, message: interruption === 'interrupted' ? 'The captured run was interrupted.' : interruption === 'ended' ? 'The captured run already ended or changed.' : 'Interruption was not proved; this Alert uses normal delivery.' } }) : input.content;
36
+ export function deliveryDraft(input, interruption, interruptedRun) {
37
+ const data = { source: 'team', deliveryId: input.key, epoch: input.epoch, ...(input.kind ? { delivery: input.kind } : {}), ...(input.sender ? { sender: input.sender } : {}), ...(input.interrupt && input.kind === 'alert' ? { interrupt: true } : {}), ...(interruptedRun !== undefined ? { interruptedRun } : {}) };
38
+ const content = interruption ? JSON.stringify({ ...JSON.parse(input.content), interruption: { outcome: interruption, message: interruption === 'interrupted' ? (input.interruptAt === 'delivery' ? 'A run in progress was interrupted to deliver this Alert. Its last input may be unanswered; read it before continuing.' : 'The captured run was interrupted.') : interruption === 'ended' ? (input.interruptAt === 'delivery' ? 'The run selected for this Alert already ended or changed.' : 'The captured run already ended or changed.') : 'Interruption was not proved; this Alert uses normal delivery.' } }) : input.content;
41
39
  return { type: 'input', content, whenBusy: input.whenBusy, requestId: `ptb:${input.key}`, entry: { kind: 'team.notice', data } };
42
40
  }
43
41
  const draft = deliveryDraft;
42
+ export const INTERRUPT_SETTLE_TIMEOUT_MS = 10_000;
44
43
  export const Deliver = defineTask({
45
- name: 'ptb.deliver', version: 1, initial: input => ({ phase: input.interrupt ? 'interrupt' : 'submit' }),
44
+ name: 'ptb.deliver', version: 2, initial: input => ({ phase: input.interrupt ? 'interrupt' : 'submit' }),
45
+ migrate: (input, checkpoint, fromVersion) => {
46
+ if (fromVersion !== 1)
47
+ throw new Error('PTB delivery migration supports only version 1');
48
+ return { input: input, checkpoint: checkpoint };
49
+ },
46
50
  phases: {
47
51
  interrupt: async () => { throw new Error('The host must supply the run-only interrupt port'); },
48
52
  submit: async (task, runtime, context) => {
49
- let valid = false, interruption;
53
+ let valid = false, interruption, interruptedRun;
50
54
  await runtime.commit(async (tx) => {
51
- const s = await open(tx), o = s.outbox.obligations[task.input.key];
52
- valid = !!o && eligible(s, o);
55
+ const s = await open(tx), o = await obligation(s, task.input.key);
56
+ valid = !!o && await eligible(s, o);
53
57
  interruption = o?.interruption;
58
+ interruptedRun = o?.interruptedRun;
54
59
  if (!valid) {
55
60
  if (o)
56
- o.state = 'retired';
61
+ await setObligationState(s, o, 'retired');
62
+ flushAuthority(s);
57
63
  return { status: 'terminal', outcome: { status: 'completed', result: null } };
58
64
  }
59
65
  return undefined;
@@ -63,9 +69,9 @@ export const Deliver = defineTask({
63
69
  const target = await runtime.conversation(task.input.conversationId, context);
64
70
  if (!target)
65
71
  throw new Error('PTB delivery recipient is missing');
66
- const submission = await target.submit(draft(task.input, interruption), context);
72
+ const submission = await target.submit(draft(task.input, interruption, interruptedRun), context);
67
73
  await runtime.commit(async (tx) => {
68
- const s = await open(tx), o = s.outbox.obligations[task.input.key];
74
+ const s = await open(tx), o = await obligation(s, task.input.key);
69
75
  if (!o)
70
76
  throw new Error('PTB delivery obligation is missing');
71
77
  o.submissionId = submission.id;
@@ -79,23 +85,23 @@ export const Deliver = defineTask({
79
85
  if (!target)
80
86
  throw new Error('PTB admitted recipient is missing');
81
87
  // Same request ID reacquires the durable admission after a process interruption.
82
- let interruption;
83
- await runtime.commit(async (tx) => { interruption = (await open(tx)).outbox.obligations[task.input.key]?.interruption; return undefined; }, context);
84
- const submission = await target.submit(draft(task.input, interruption), context);
88
+ let interruption, interruptedRun;
89
+ await runtime.commit(async (tx) => { const o = await obligation(await open(tx), task.input.key); interruption = o?.interruption; interruptedRun = o?.interruptedRun; return undefined; }, context);
90
+ const submission = await target.submit(draft(task.input, interruption, interruptedRun), context);
85
91
  if (submission.id !== task.state.checkpoint.submissionId)
86
92
  throw new Error('PTB admission identity changed');
87
93
  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);
94
+ 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
95
  if (obsolete)
90
96
  await submission.abort(context);
91
97
  const settled = await submission.wait(context);
92
98
  await runtime.commit(async (tx) => {
93
- const s = await open(tx), o = s.outbox.obligations[task.input.key];
99
+ const s = await open(tx), o = await obligation(s, task.input.key);
94
100
  if (o) {
95
101
  o.settledAt = runtime.now();
96
102
  o.outcome = settled.status === 'done' ? 'answered' : `unanswered:${settled.reason}`;
97
103
  if (o.state === 'pending')
98
- o.state = 'settled';
104
+ await setObligationState(s, o, 'settled');
99
105
  }
100
106
  if (s.team.current?.epoch === task.input.epoch)
101
107
  await event(tx, s, 'authority', 'delivery_settled', { at: new Date(runtime.now()).toISOString() });
@@ -104,10 +110,10 @@ export const Deliver = defineTask({
104
110
  },
105
111
  },
106
112
  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';
113
+ await runtime.commit(async (tx) => { const s = await open(tx), o = await obligation(s, task.input.key); if (o) {
114
+ await setObligationState(s, o, 'retired');
109
115
  o.outcome = 'stopped';
110
- } return { status: 'terminal', outcome: { status: 'aborted' } }; }, context);
116
+ } flushAuthority(s); return { status: 'terminal', outcome: { status: 'aborted' } }; }, context);
111
117
  },
112
118
  });
113
119
  /** Keep the shared submit/settle mechanics; only the host-owned interruption needs a port. */
@@ -117,24 +123,60 @@ export function createDeliver(abortRun) {
117
123
  submit: (task, runtime, context) => Deliver.definition.phases.submit(task, runtime, context),
118
124
  interrupt: async (task, runtime, context) => {
119
125
  const bound = withAbortSignal(runtime.signal, context);
126
+ const selection = task.state.checkpoint.selection;
127
+ if (!selection)
128
+ return runtime.commit(async (tx) => {
129
+ const s = await open(tx), o = await obligation(s, task.input.key);
130
+ if (!o || !await eligible(s, o)) {
131
+ if (o)
132
+ await setObligationState(s, o, 'retired');
133
+ flushAuthority(s);
134
+ return { status: 'terminal', outcome: { status: 'completed', result: null } };
135
+ }
136
+ // Absence of the marker is a recorded P choice, including an idle recipient. Never reinterpret it.
137
+ const run = task.input.interruptAt === 'delivery' ? (await tx.doc(LiveDoc, task.input.conversationId)).run?.taskId : task.input.expectedRunTaskId;
138
+ if (task.input.interruptAt === 'delivery') {
139
+ if (run === undefined)
140
+ delete o.interruptRun;
141
+ else
142
+ o.interruptRun = run;
143
+ }
144
+ return { status: 'running', checkpoint: { phase: 'interrupt', selection: { ...(run !== undefined ? { run } : {}), deadline: runtime.now() + INTERRUPT_SETTLE_TIMEOUT_MS } } };
145
+ }, bound);
120
146
  let outcome;
121
- try {
122
- outcome = await abortRun(runtime, task.input, bound);
123
- }
124
- catch (error) {
125
- if (runtime.signal.aborted)
126
- throw error;
127
- runtime.report(error);
128
- outcome = 'failed';
147
+ const remaining = selection.deadline - runtime.now();
148
+ if (remaining <= 0)
149
+ outcome = selection.run === undefined ? 'ended' : 'failed';
150
+ else {
151
+ const wait = withCancel(bound), stop = runtime.setTimer(() => wait.cancel(new Error('Interrupt settlement deadline reached')), remaining);
152
+ try {
153
+ outcome = await awaitWithContext(abortRun(runtime, { ...task.input, expectedRunTaskId: selection.run }, wait.context), wait.context);
154
+ }
155
+ catch (error) {
156
+ if (runtime.signal.aborted)
157
+ throw error;
158
+ runtime.report(error);
159
+ outcome = 'failed';
160
+ }
161
+ finally {
162
+ stop();
163
+ wait.cancel();
164
+ }
129
165
  }
130
166
  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') {
167
+ const s = await open(tx), o = await obligation(s, task.input.key);
168
+ if (!o || !await eligible(s, o) || outcome === 'retired') {
133
169
  if (o)
134
- o.state = 'retired';
170
+ await setObligationState(s, o, 'retired');
171
+ flushAuthority(s);
135
172
  return { status: 'terminal', outcome: { status: 'completed', result: null } };
136
173
  }
137
- o.interruption = outcome;
174
+ if (task.input.interruptAt === 'delivery' && selection.run === undefined && outcome === 'ended')
175
+ delete o.interruption;
176
+ else
177
+ o.interruption = outcome;
178
+ if (task.input.interruptAt === 'delivery' && selection.run !== undefined && outcome === 'interrupted')
179
+ o.interruptedRun = selection.run;
138
180
  return { status: 'running', checkpoint: { phase: 'submit' } };
139
181
  }, bound);
140
182
  },
@@ -144,9 +186,15 @@ export async function scheduleDeliveries(tx, s, delivery) {
144
186
  const team = s.team.current;
145
187
  if (!team || team.lifecycle !== 'active')
146
188
  return;
147
- for (const o of Object.values(s.outbox.obligations)) {
148
- if (o.deliveryTaskId || !eligible(s, o))
189
+ for (const o of await unscheduledObligations(s)) {
190
+ const key = o.key;
191
+ if (o.deliveryTaskId) {
192
+ await markScheduled(s, key);
193
+ continue;
194
+ }
195
+ if (!await eligible(s, o))
149
196
  continue;
150
- o.deliveryTaskId = await tx.createTask(delivery, deliveryInput(s, o), { conversationId: team.lead, ownership: { kind: 'conversation' }, background: true, abandonOnRestart: false });
197
+ o.deliveryTaskId = await tx.createTask(delivery, await deliveryInput(s, o), { conversationId: team.lead, ownership: { kind: 'conversation' }, background: true, abandonOnRestart: false });
198
+ await markScheduled(s, key);
151
199
  }
152
200
  }
@@ -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;