@hypercarrier/pi-team-durable 0.1.0-rc.2 → 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 +33 -13
- package/dist/authority/delivery.d.ts +21 -17
- package/dist/authority/delivery.js +63 -21
- package/dist/authority/interrupt.js +4 -3
- package/dist/authority/rc7-contract.json +4 -2
- package/dist/authority/runtime.d.ts +4 -0
- package/dist/authority/state.d.ts +2 -0
- package/dist/authority/tools.d.ts +11 -4
- package/dist/authority/tools.js +18 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,9 @@ notices.
|
|
|
26
26
|
migration is one-way: rollback means restoring a backup. Back up the store, and
|
|
27
27
|
stop every older node process first. 0.1.0-rc.1 stores version-1 documents.
|
|
28
28
|
This version migrates them to version 2, and a downgrade cannot read a migrated
|
|
29
|
-
store.
|
|
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.
|
|
30
32
|
|
|
31
33
|
Known issues:
|
|
32
34
|
|
|
@@ -34,8 +36,11 @@ Known issues:
|
|
|
34
36
|
Team Bright retries it; this package does not.
|
|
35
37
|
2. `team_sync` can replay pages that an earlier call released. Act only on
|
|
36
38
|
`task_read`, and never call `team_sync` from a nested tool call.
|
|
37
|
-
3. Interrupt
|
|
38
|
-
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.
|
|
39
44
|
4. An Alert can be admitted after its recipient is closed or fenced. After an owner
|
|
40
45
|
Close, a busy Worker can still run one tool call.
|
|
41
46
|
|
|
@@ -70,7 +75,9 @@ observer, and structural stop ports. A host adapter registers this runtime.
|
|
|
70
75
|
hash partitions index pending and unscheduled keys.
|
|
71
76
|
- Nine lead tools and three Worker tools use the rc.7 contract. Exact conversation
|
|
72
77
|
and generation bindings guard authority. A fork does not acquire an existing
|
|
73
|
-
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.
|
|
74
81
|
- `ptb.worker-anchor` owns a Worker conversation and renews a durable sleep.
|
|
75
82
|
Lead Esc does not end it. Stops use stored target IDs through the node's single
|
|
76
83
|
mark-before-resume fence. Failed confirmation retains the intent.
|
|
@@ -78,13 +85,26 @@ observer, and structural stop ports. A host adapter registers this runtime.
|
|
|
78
85
|
admission, and waits for settlement. Notices use `team.notice` entries whose
|
|
79
86
|
data (`TeamNoticeData`) names the delivery kind and its sender: an Alert's
|
|
80
87
|
author, or `team-lead` for Tasks and stops. An interrupt Alert's data also
|
|
81
|
-
has `interrupt: true`. A
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
88
108
|
- `ptb.notify` reserves an automatic page before admission. A failed lead input
|
|
89
109
|
pauses notification. Explicit sync or a new owner input resumes it.
|
|
90
110
|
- `team_sync` reserves successive bounded pages. It never waits for consumption.
|
|
@@ -154,8 +174,8 @@ installed devDependency `@hypercarrier/pi-team-bright@0.25.1`.
|
|
|
154
174
|
Each role's tool labels and descriptions come from Pi Team Bright's own
|
|
155
175
|
registration. `scripts/pi-registration.mjs` captures it per role through an
|
|
156
176
|
inert host. The parity test compares every durable tool with the installed
|
|
157
|
-
`@hypercarrier/pi-team-bright`.
|
|
158
|
-
|
|
177
|
+
`@hypercarrier/pi-team-bright`. Pi registers the Worker tools outside its
|
|
178
|
+
catalog, so their parameter schemas also come from that registration.
|
|
159
179
|
|
|
160
180
|
```sh
|
|
161
181
|
npm ci
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { AbortRun, InterruptTarget } from './interrupt.ts';
|
|
2
|
-
import { type SubmissionId, type Tx } from '@earendil-works/pi-durable';
|
|
2
|
+
import { type TaskId, type SubmissionId, type Tx } from '@earendil-works/pi-durable';
|
|
3
3
|
import { type Obligation } from './state.ts';
|
|
4
4
|
import { type Authority as State } from './store.ts';
|
|
5
5
|
/** `kind` and `sender` name the delivery in its queue entry; inputs persisted before they existed lack them. */
|
|
6
6
|
export interface DeliveryInput extends InterruptTarget {
|
|
7
7
|
interrupt?: true;
|
|
8
|
+
interruptAt?: 'delivery';
|
|
8
9
|
epoch: string;
|
|
9
10
|
content: string;
|
|
10
11
|
whenBusy: 'steer' | 'followUp';
|
|
@@ -14,7 +15,8 @@ export interface DeliveryInput extends InterruptTarget {
|
|
|
14
15
|
/**
|
|
15
16
|
* The structured data of a Team input's `team.notice` entry. A reader names the input from these fields, never from
|
|
16
17
|
* its text: `delivery` is the kind (`update` for a notifier page), `sender` the author, at most SENDER_LIMIT characters.
|
|
17
|
-
* `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.
|
|
18
20
|
* Entries written before `delivery` existed carry only `source` and `deliveryId` or `pageId`.
|
|
19
21
|
*/
|
|
20
22
|
export type TeamNoticeData = {
|
|
@@ -25,12 +27,13 @@ export type TeamNoticeData = {
|
|
|
25
27
|
delivery?: Obligation['kind'] | 'update';
|
|
26
28
|
sender?: string;
|
|
27
29
|
interrupt?: true;
|
|
30
|
+
interruptedRun?: TaskId;
|
|
28
31
|
};
|
|
29
32
|
export declare const SENDER_LIMIT = 64;
|
|
30
33
|
export declare function eligible(s: State, o: Obligation): Promise<boolean>;
|
|
31
34
|
export declare function deliveryInput(s: State, o: Obligation): Promise<DeliveryInput>;
|
|
32
35
|
/** The input a delivery submits. The request ID names the Obligation, so a retry reacquires the same admission. */
|
|
33
|
-
export declare function deliveryDraft(input: DeliveryInput, interruption?: Obligation['interruption']): {
|
|
36
|
+
export declare function deliveryDraft(input: DeliveryInput, interruption?: Obligation['interruption'], interruptedRun?: TaskId): {
|
|
34
37
|
type: "input";
|
|
35
38
|
content: string;
|
|
36
39
|
whenBusy: "steer" | "followUp";
|
|
@@ -40,21 +43,22 @@ export declare function deliveryDraft(input: DeliveryInput, interruption?: Oblig
|
|
|
40
43
|
data: TeamNoticeData;
|
|
41
44
|
};
|
|
42
45
|
};
|
|
43
|
-
export declare const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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';
|
|
47
56
|
} | {
|
|
48
|
-
phase:
|
|
57
|
+
phase: 'settle';
|
|
49
58
|
submissionId: SubmissionId;
|
|
50
|
-
}
|
|
59
|
+
};
|
|
60
|
+
export declare const Deliver: import("@earendil-works/pi-durable").Task<DeliveryInput, DeliveryCheckpoint, null, object>;
|
|
51
61
|
/** Keep the shared submit/settle mechanics; only the host-owned interruption needs a port. */
|
|
52
|
-
export declare function createDeliver(abortRun: AbortRun): import("@earendil-works/pi-durable").Task<DeliveryInput,
|
|
53
|
-
phase: "interrupt";
|
|
54
|
-
} | {
|
|
55
|
-
phase: "submit";
|
|
56
|
-
} | {
|
|
57
|
-
phase: "settle";
|
|
58
|
-
submissionId: SubmissionId;
|
|
59
|
-
}, null, object>;
|
|
62
|
+
export declare function createDeliver(abortRun: AbortRun): import("@earendil-works/pi-durable").Task<DeliveryInput, DeliveryCheckpoint, null, object>;
|
|
60
63
|
export declare function scheduleDeliveries(tx: Tx, s: State, delivery: typeof Deliver): Promise<void>;
|
|
64
|
+
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { withAbortSignal } from '@earendil-works/chord/context';
|
|
2
|
-
import { defineTask } from '@earendil-works/pi-durable';
|
|
1
|
+
import { withAbortSignal, withCancel, awaitWithContext } from '@earendil-works/chord/context';
|
|
2
|
+
import { defineTask, LiveDoc } from '@earendil-works/pi-durable';
|
|
3
3
|
import { plain, event } from "./state.js";
|
|
4
4
|
import { openAuthority as open, flushAuthority, markScheduled, unscheduledObligations, taskRecord, alertRecord, obligation, setObligationState } from "./store.js";
|
|
5
5
|
export const SENDER_LIMIT = 64;
|
|
@@ -30,25 +30,32 @@ export async function deliveryInput(s, o) {
|
|
|
30
30
|
}
|
|
31
31
|
// An Alert names its author; durable-team sends Tasks and stops on the Team authority's behalf, as team-lead.
|
|
32
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.interruptRun !== undefined ? { expectedRunTaskId: o.interruptRun } : {}) } : {}), content: JSON.stringify(payload), whenBusy: o.kind === 'task' ? 'followUp' : 'steer', kind: o.kind, ...(sender ? { sender } : {}) };
|
|
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 } : {}) };
|
|
34
34
|
}
|
|
35
35
|
/** The input a delivery submits. The request ID names the Obligation, so a retry reacquires the same admission. */
|
|
36
|
-
export function deliveryDraft(input, interruption) {
|
|
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 } : {}) };
|
|
38
|
-
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;
|
|
39
39
|
return { type: 'input', content, whenBusy: input.whenBusy, requestId: `ptb:${input.key}`, entry: { kind: 'team.notice', data } };
|
|
40
40
|
}
|
|
41
41
|
const draft = deliveryDraft;
|
|
42
|
+
export const INTERRUPT_SETTLE_TIMEOUT_MS = 10_000;
|
|
42
43
|
export const Deliver = defineTask({
|
|
43
|
-
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
|
+
},
|
|
44
50
|
phases: {
|
|
45
51
|
interrupt: async () => { throw new Error('The host must supply the run-only interrupt port'); },
|
|
46
52
|
submit: async (task, runtime, context) => {
|
|
47
|
-
let valid = false, interruption;
|
|
53
|
+
let valid = false, interruption, interruptedRun;
|
|
48
54
|
await runtime.commit(async (tx) => {
|
|
49
55
|
const s = await open(tx), o = await obligation(s, task.input.key);
|
|
50
56
|
valid = !!o && await eligible(s, o);
|
|
51
57
|
interruption = o?.interruption;
|
|
58
|
+
interruptedRun = o?.interruptedRun;
|
|
52
59
|
if (!valid) {
|
|
53
60
|
if (o)
|
|
54
61
|
await setObligationState(s, o, 'retired');
|
|
@@ -62,7 +69,7 @@ export const Deliver = defineTask({
|
|
|
62
69
|
const target = await runtime.conversation(task.input.conversationId, context);
|
|
63
70
|
if (!target)
|
|
64
71
|
throw new Error('PTB delivery recipient is missing');
|
|
65
|
-
const submission = await target.submit(draft(task.input, interruption), context);
|
|
72
|
+
const submission = await target.submit(draft(task.input, interruption, interruptedRun), context);
|
|
66
73
|
await runtime.commit(async (tx) => {
|
|
67
74
|
const s = await open(tx), o = await obligation(s, task.input.key);
|
|
68
75
|
if (!o)
|
|
@@ -78,9 +85,9 @@ export const Deliver = defineTask({
|
|
|
78
85
|
if (!target)
|
|
79
86
|
throw new Error('PTB admitted recipient is missing');
|
|
80
87
|
// Same request ID reacquires the durable admission after a process interruption.
|
|
81
|
-
let interruption;
|
|
82
|
-
await runtime.commit(async (tx) => {
|
|
83
|
-
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);
|
|
84
91
|
if (submission.id !== task.state.checkpoint.submissionId)
|
|
85
92
|
throw new Error('PTB admission identity changed');
|
|
86
93
|
let obsolete = false;
|
|
@@ -116,15 +123,45 @@ export function createDeliver(abortRun) {
|
|
|
116
123
|
submit: (task, runtime, context) => Deliver.definition.phases.submit(task, runtime, context),
|
|
117
124
|
interrupt: async (task, runtime, context) => {
|
|
118
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);
|
|
119
146
|
let outcome;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
}
|
|
128
165
|
}
|
|
129
166
|
await runtime.commit(async (tx) => {
|
|
130
167
|
const s = await open(tx), o = await obligation(s, task.input.key);
|
|
@@ -134,7 +171,12 @@ export function createDeliver(abortRun) {
|
|
|
134
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
|
},
|
|
@@ -16,11 +16,12 @@ export async function abortExpectedRun(harness, target, context) {
|
|
|
16
16
|
return task.state.outcome.status === 'aborted' ? 'interrupted' : 'ended';
|
|
17
17
|
if (live.run?.taskId !== target.expectedRunTaskId)
|
|
18
18
|
return 'ended';
|
|
19
|
-
return 'abort';
|
|
19
|
+
return task.abortRequested ? 'wait' : 'abort';
|
|
20
20
|
}, context);
|
|
21
|
-
if (check !== 'abort')
|
|
21
|
+
if (check !== 'abort' && check !== 'wait')
|
|
22
22
|
return check;
|
|
23
|
-
|
|
23
|
+
if (check === 'abort')
|
|
24
|
+
await harness.abortTask(target.expectedRunTaskId, context);
|
|
24
25
|
const task = await harness.waitForTask(target.expectedRunTaskId, context);
|
|
25
26
|
return task.state.outcome.status === 'aborted' ? 'interrupted' : 'failed';
|
|
26
27
|
}
|
|
@@ -81,6 +81,10 @@ export declare function createTeamRuntime(options: RuntimeOptions): {
|
|
|
81
81
|
}, null, object>;
|
|
82
82
|
Deliver: import("@earendil-works/pi-durable").Task<import("./delivery.ts").DeliveryInput, {
|
|
83
83
|
phase: "interrupt";
|
|
84
|
+
selection?: {
|
|
85
|
+
run?: import("@earendil-works/pi-durable").TaskId;
|
|
86
|
+
deadline: number;
|
|
87
|
+
};
|
|
84
88
|
} | {
|
|
85
89
|
phase: "submit";
|
|
86
90
|
} | {
|
|
@@ -74,7 +74,9 @@ export interface Alert {
|
|
|
74
74
|
}
|
|
75
75
|
export interface Obligation {
|
|
76
76
|
interrupt?: true;
|
|
77
|
+
interruptAt?: 'delivery';
|
|
77
78
|
interruptRun?: TaskId;
|
|
79
|
+
interruptedRun?: TaskId;
|
|
78
80
|
interruption?: 'interrupted' | 'ended' | 'failed';
|
|
79
81
|
acceptedAt?: number;
|
|
80
82
|
submittedAt?: number;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TSchema } from 'typebox';
|
|
1
2
|
import type { Context } from '@earendil-works/chord/context';
|
|
2
3
|
import { type ToolExecutionApi, type Tx } from '@earendil-works/pi-durable';
|
|
3
4
|
import { type Role, type ToolName } from './contracts.ts';
|
|
@@ -57,6 +58,12 @@ interface Args {
|
|
|
57
58
|
task_version?: string;
|
|
58
59
|
interrupt?: boolean;
|
|
59
60
|
}
|
|
61
|
+
interface ParameterSchema extends TSchema {
|
|
62
|
+
properties?: Record<string, ParameterSchema>;
|
|
63
|
+
items?: ParameterSchema;
|
|
64
|
+
}
|
|
65
|
+
/** The input a role tool acts on. Pi's role adapters forward declared fields, including nested Task definitions. */
|
|
66
|
+
export declare function declaredParameters(schema: ParameterSchema, value: unknown): unknown;
|
|
60
67
|
/** The catalog resolver that options select: the host's per-lead port, else the static list. */
|
|
61
68
|
export declare function catalogOf(options: Pick<CoreOptions, 'modelRoles' | 'defaultModelRole' | 'catalog'>): (tx: Tx, lead: ConversationId) => Promise<ModelRoleCatalog>;
|
|
62
69
|
/** Authority commands commit intent and replay receipts; injected ports own effects. */
|
|
@@ -65,12 +72,12 @@ export declare function createAuthorityTools(options: CoreOptions): {
|
|
|
65
72
|
label: string;
|
|
66
73
|
name: string;
|
|
67
74
|
description: string;
|
|
68
|
-
parameters:
|
|
75
|
+
parameters: TSchema;
|
|
69
76
|
constrainedSampling?: false | import("@earendil-works/pi-ai").ConstrainedSamplingConfig;
|
|
70
77
|
replay?: "safe" | "unsafe";
|
|
71
78
|
executionMode?: import("@earendil-works/pi-durable").ToolExecutionMode;
|
|
72
79
|
prepareArguments?(args: unknown): unknown;
|
|
73
|
-
structuredOutputSchema?:
|
|
80
|
+
structuredOutputSchema?: TSchema;
|
|
74
81
|
callers?: readonly ("model" | "tools")[];
|
|
75
82
|
deferred?: true;
|
|
76
83
|
outputLimits?: {
|
|
@@ -84,12 +91,12 @@ export declare function createAuthorityTools(options: CoreOptions): {
|
|
|
84
91
|
label: string;
|
|
85
92
|
name: string;
|
|
86
93
|
description: string;
|
|
87
|
-
parameters:
|
|
94
|
+
parameters: TSchema;
|
|
88
95
|
constrainedSampling?: false | import("@earendil-works/pi-ai").ConstrainedSamplingConfig;
|
|
89
96
|
replay?: "safe" | "unsafe";
|
|
90
97
|
executionMode?: import("@earendil-works/pi-durable").ToolExecutionMode;
|
|
91
98
|
prepareArguments?(args: unknown): unknown;
|
|
92
|
-
structuredOutputSchema?:
|
|
99
|
+
structuredOutputSchema?: TSchema;
|
|
93
100
|
callers?: readonly ("model" | "tools")[];
|
|
94
101
|
deferred?: true;
|
|
95
102
|
outputLimits?: {
|
package/dist/authority/tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineTool
|
|
1
|
+
import { defineTool } from '@earendil-works/pi-durable';
|
|
2
2
|
import { contracts, checkSchema, checkResult, TOOL_NAMES } from "./contracts.js";
|
|
3
3
|
import { GraphControlRefusal } from "./graph-control.js";
|
|
4
4
|
import { projectGraphTaskCards } from "./graph-card.js";
|
|
@@ -6,6 +6,15 @@ import { snapshotResult } from "./projection.js";
|
|
|
6
6
|
import { plain, event } from "./state.js";
|
|
7
7
|
import { openAuthority as open, startEpoch, taskCards, unfinished, graphCommand, hasOperation, addAlert, addObligation, callReceipt, saveCall, allObligations, setObligationState } from "./store.js";
|
|
8
8
|
const LEAD = 'team-lead';
|
|
9
|
+
/** The input a role tool acts on. Pi's role adapters forward declared fields, including nested Task definitions. */
|
|
10
|
+
export function declaredParameters(schema, value) {
|
|
11
|
+
const { items, properties } = schema;
|
|
12
|
+
if (Array.isArray(value) && items)
|
|
13
|
+
return value.map(item => declaredParameters(items, item));
|
|
14
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value) && properties)
|
|
15
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => Object.hasOwn(properties, key)).map(([key, item]) => [key, declaredParameters(properties[key], item)]));
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
9
18
|
class Declined extends Error {
|
|
10
19
|
result;
|
|
11
20
|
constructor(result) { super(String(result.message ?? result.reason)); this.result = result; }
|
|
@@ -136,18 +145,17 @@ export function createAuthorityTools(options) {
|
|
|
136
145
|
return { kind: 'updated', operation_id: r.operationId, replayed: r.replayed, transition: r.transition, task: { id: r.task.id, status: r.task.state.kind, assignee: r.task.assignee, version: r.task.version }, ready_task_ids: r.readyTaskIds, ...(r.failureTraversal ? { failure_traversal: { source_task_id: r.failureTraversal.sourceTaskId, target_task_id: r.failureTraversal.targetTaskId, traversal: r.failureTraversal.traversal } } : {}) };
|
|
137
146
|
}
|
|
138
147
|
if (name === 'alert_send') {
|
|
139
|
-
const to = role === 'worker' ? LEAD : a.to;
|
|
148
|
+
const to = role === 'worker' ? LEAD : a.to, interrupt = role === 'lead' && a.interrupt === true;
|
|
140
149
|
if (!a.text?.trim() || a.task_version && !a.task_id || (to === '*') !== (a.kind === 'announcement'))
|
|
141
150
|
return decline({ kind: 'refused', reason: 'invalid_fanout', message: 'Invalid Alert text, Task reference or announcement target.' });
|
|
142
151
|
const recipients = to === LEAD ? [{ name: LEAD, conversationId: team.lead, generation: 0 }] : Object.values(team.workers).filter(w => w.state === 'active' && (to === '*' || w.name === to)).map(w => ({ name: w.name, conversationId: w.conversationId, generation: w.generation }));
|
|
143
152
|
if (!recipients.length)
|
|
144
153
|
return decline({ kind: 'refused', reason: to === '*' ? 'no_eligible_recipients' : 'recipient_not_current', message: 'No current recipient.' });
|
|
145
154
|
const id = `${team.epoch}:alert:${api.taskId}`;
|
|
146
|
-
await addAlert(s, plain({ id, from: actor, kind: a.kind, text: a.text.trim(), ...(
|
|
155
|
+
await addAlert(s, plain({ id, from: actor, kind: a.kind, text: a.text.trim(), ...(interrupt ? { interrupt: true } : {}), task_id: a.task_id, task_version: a.task_version, receivedAt: new Date(api.now()).toISOString(), recipients }));
|
|
147
156
|
for (const recipient of recipients) {
|
|
148
157
|
const key = `${id}:${recipient.name}:g${recipient.generation}`;
|
|
149
|
-
|
|
150
|
-
await addObligation(s, { key, epoch: team.epoch, kind: 'alert', recipient: recipient.name, conversationId: recipient.conversationId, generation: recipient.generation, alertId: id, state: 'pending', acceptedAt: api.now(), ...(a.interrupt ? { interrupt: true, ...(run !== undefined ? { interruptRun: run } : {}) } : {}) });
|
|
158
|
+
await addObligation(s, { key, epoch: team.epoch, kind: 'alert', recipient: recipient.name, conversationId: recipient.conversationId, generation: recipient.generation, alertId: id, state: 'pending', acceptedAt: api.now(), ...(interrupt ? { interrupt: true, interruptAt: 'delivery' } : {}) });
|
|
151
159
|
}
|
|
152
160
|
await event(tx, s, actor, 'alert_accepted', { at: new Date(api.now()).toISOString() });
|
|
153
161
|
return { kind: 'alert_sent', accepted_recipients: recipients.map(r => r.name), failed_recipients: [], ...(a.task_id ? { task_id: a.task_id } : {}), ...(a.task_version ? { task_version: a.task_version } : {}) };
|
|
@@ -190,6 +198,10 @@ export function createAuthorityTools(options) {
|
|
|
190
198
|
}
|
|
191
199
|
async function execute(name, role, input, api, context) {
|
|
192
200
|
const schema = contract[role][name];
|
|
201
|
+
// Earlier receipts hashed raw arguments. Their exact input must still replay without executing again.
|
|
202
|
+
const rawFingerprint = JSON.stringify({ name, role, input, conversation: api.conversationId });
|
|
203
|
+
if (schema)
|
|
204
|
+
input = declaredParameters(schema, input);
|
|
193
205
|
if (!schema || !checkSchema(schema, input))
|
|
194
206
|
throw new Error(`Invalid ${role} ${name} parameters`);
|
|
195
207
|
const a = input;
|
|
@@ -200,7 +212,7 @@ export function createAuthorityTools(options) {
|
|
|
200
212
|
const s = await open(tx), receipt = await callReceipt(s, api.taskId, native), saved = receipt.fingerprint ? receipt : undefined;
|
|
201
213
|
s.deferFlush = true;
|
|
202
214
|
if (saved) {
|
|
203
|
-
if (saved.fingerprint !== fingerprint)
|
|
215
|
+
if (saved.fingerprint !== fingerprint && saved.fingerprint !== rawFingerprint)
|
|
204
216
|
throw new Error('Durable tool identity reused with changed input');
|
|
205
217
|
return saved.result ? JSON.parse(saved.result) : plain({ stops: saved.stops, epoch: saved.epoch });
|
|
206
218
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypercarrier/pi-team-durable",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.3",
|
|
4
4
|
"description": "Pi Team Bright semantics on pi-durable: one Task authority per Team, Workers as durable subthreads, accepted-then-delivered notices.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|