@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 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: a Worker can interrupt the lead, the run to stop is chosen when the
38
- 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.
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 reader names a notice from this data, never from its
82
- text.
83
- Interrupt Alerts persist the recipient run ID at acceptance. A separate phase
84
- asks the host `abortRun` port to stop that exact run, then checkpoints before
85
- normal admission. Queue identities stay native; no input is re-queued.
86
- A failed interruption appears as unproved in the notice. Retry never selects
87
- 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.
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`. Known gap: Pi's Worker `alert_send` requires
158
- non-empty `text` and `task_id`; the durable schema does not yet.
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; 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.
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 Deliver: import("@earendil-works/pi-durable").Task<DeliveryInput, {
44
- phase: "interrupt";
45
- } | {
46
- 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';
47
56
  } | {
48
- phase: "settle";
57
+ phase: 'settle';
49
58
  submissionId: SubmissionId;
50
- }, null, object>;
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: 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
+ },
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) => { interruption = (await obligation(await open(tx), task.input.key))?.interruption; return undefined; }, context);
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
- try {
121
- outcome = await abortRun(runtime, task.input, bound);
122
- }
123
- catch (error) {
124
- if (runtime.signal.aborted)
125
- throw error;
126
- runtime.report(error);
127
- 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
+ }
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
- 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
  },
@@ -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
- await harness.abortTask(target.expectedRunTaskId, context);
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
  }
@@ -402,10 +402,12 @@
402
402
  ]
403
403
  },
404
404
  "text": {
405
- "type": "string"
405
+ "type": "string",
406
+ "minLength": 1
406
407
  },
407
408
  "task_id": {
408
- "type": "string"
409
+ "type": "string",
410
+ "minLength": 1
409
411
  },
410
412
  "task_version": {
411
413
  "type": "string",
@@ -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: import("typebox").TSchema;
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?: import("typebox").TSchema;
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: import("typebox").TSchema;
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?: import("typebox").TSchema;
99
+ structuredOutputSchema?: TSchema;
93
100
  callers?: readonly ("model" | "tools")[];
94
101
  deferred?: true;
95
102
  outputLimits?: {
@@ -1,4 +1,4 @@
1
- import { defineTool, LiveDoc } from '@earendil-works/pi-durable';
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(), ...(a.interrupt ? { interrupt: true } : {}), task_id: a.task_id, task_version: a.task_version, receivedAt: new Date(api.now()).toISOString(), recipients }));
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
- const run = a.interrupt ? (await tx.doc(LiveDoc, recipient.conversationId)).run?.taskId : undefined;
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.2",
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",