@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.
- package/README.md +130 -19
- package/dist/authority/contracts.d.ts +7 -0
- package/dist/authority/contracts.js +20 -4
- package/dist/authority/delivery.d.ts +25 -20
- package/dist/authority/delivery.js +97 -49
- package/dist/authority/graph-control.d.ts +1 -1
- package/dist/authority/incremental-graph-control.d.ts +156 -0
- package/dist/authority/incremental-graph-control.js +1051 -0
- package/dist/authority/index.d.ts +5 -1
- package/dist/authority/index.js +5 -1
- package/dist/authority/interrupt.js +7 -6
- package/dist/authority/notifier.d.ts +1 -1
- package/dist/authority/notifier.js +26 -20
- package/dist/authority/observation-store.d.ts +104 -0
- package/dist/authority/observation-store.js +226 -0
- package/dist/authority/observation.d.ts +3 -45
- package/dist/authority/observation.js +86 -68
- package/dist/authority/panel.d.ts +121 -0
- package/dist/authority/panel.js +44 -0
- package/dist/authority/projection.d.ts +5 -4
- package/dist/authority/projection.js +11 -11
- package/dist/authority/rc7-contract.json +61 -3
- package/dist/authority/runtime.d.ts +42 -2
- package/dist/authority/runtime.js +13 -14
- package/dist/authority/state.d.ts +21 -4
- package/dist/authority/state.js +14 -12
- package/dist/authority/store.d.ts +144 -0
- package/dist/authority/store.js +675 -0
- package/dist/authority/sync.d.ts +4 -3
- package/dist/authority/sync.js +20 -19
- package/dist/authority/tools.d.ts +48 -4
- package/dist/authority/tools.js +79 -91
- package/dist/testing.d.ts +11 -0
- package/dist/testing.js +10 -0
- 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
|
-
- **
|
|
25
|
-
|
|
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
|
|
34
|
-
Alert is
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
79
|
-
entries in
|
|
80
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
57
|
+
phase: 'settle';
|
|
48
58
|
submissionId: SubmissionId;
|
|
49
|
-
}
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
|
17
|
+
if (!s.head.graph || s.head.epoch !== team.epoch)
|
|
19
18
|
return false;
|
|
20
|
-
const task =
|
|
21
|
-
return !!task && task.version === o.version && task.assignee === o.recipient && task.
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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) => {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
108
|
-
o
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
|
170
|
+
await setObligationState(s, o, 'retired');
|
|
171
|
+
flushAuthority(s);
|
|
135
172
|
return { status: 'terminal', outcome: { status: 'completed', result: null } };
|
|
136
173
|
}
|
|
137
|
-
|
|
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
|
|
148
|
-
|
|
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;
|