@parall/agent-core 1.44.0 → 1.45.0

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.
@@ -13,8 +13,15 @@ import type { ParallEvent } from './types.js';
13
13
  export interface LaneFlowHost {
14
14
  laneLedger?: LaneLedger;
15
15
  ledgerDisabled: boolean;
16
+ /**
17
+ * Sticky: the server answered a by-id complete with 400 (predates the
18
+ * form, v1.44). Typed resolution falls back to the legacy ack for the
19
+ * rest of the process; claims stay on the ledger (that face is older).
20
+ */
21
+ typedByIdCompleteUnsupported: boolean;
16
22
  shuttingDown: boolean;
17
23
  dispatchedMessages: Set<string>;
24
+ dispatchedTasks: Set<string>;
18
25
  /**
19
26
  * Per-WorkItem failure backoff for typed dispatch consumption. A consume
20
27
  * that ends without an ack re-arms the entry; the next attempt for the
@@ -60,14 +67,77 @@ export declare function dispatchLaneGroup(host: LaneFlowHost, opts: {
60
67
  captureText?: string[];
61
68
  hasMoreLocal: () => boolean;
62
69
  }): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'>;
70
+ /**
71
+ * The WorkItem ids of a typed event group whose lifecycle the ledger owns
72
+ * (claimed into dsp lanes by consumeTypedDispatch), or null when the group
73
+ * must stay on the legacy received/ack surface — ledger unavailable, an id
74
+ * missing, or a member that rides a message lane. Non-null means: skip
75
+ * legacy received+ack, resolve via the by-id complete, and fold the
76
+ * turn-error signal into the outcome.
77
+ */
78
+ export declare function typedLedgerEventIds(host: LaneFlowHost, events: ParallEvent[]): string[] | null;
79
+ /** Outcome of a by-id complete attempt. */
80
+ export type TypedResolveOutcome = 'ok' | 'stale' | 'unsupported' | 'failed';
81
+ /**
82
+ * By-id complete (turn_outcome ok): terminal resolution of ONE WorkItem —
83
+ * exact where the source pair is ambiguous across task siblings. `lane`
84
+ * fences the call while the row is claim-owned (a dethroned caller gets
85
+ * 'stale' and must leave the row to its successor); omit it for the
86
+ * wrapper-less pending close (buffered drain, administrative drops).
87
+ */
88
+ export declare function resolveDispatchByID(host: LaneFlowHost, dispatchEventId: string, lane?: string): Promise<TypedResolveOutcome>;
89
+ /**
90
+ * Free a typed event's in-memory hot-path dedupe claim by its source pair —
91
+ * the ParallEvent-keyed mirror of the gateway's clearTypedDispatchDedupe
92
+ * (which keys on the wire DispatchNewData). Used when a buffered typed
93
+ * dispatch ran but its resolution failed: the member re-drives, and a stale
94
+ * local claim would make this pod reject the retry forever.
95
+ *
96
+ * PARITY: the switch below and clearTypedDispatchDedupe's must handle the
97
+ * same typed source families — when adding a new typed event type, extend
98
+ * BOTH (they key the same dedupe entries from different event shapes).
99
+ */
100
+ export declare function clearTypedDedupeForEvent(host: LaneFlowHost, event: ParallEvent): void;
101
+ /**
102
+ * Settle a drained typed group — the one wrapper-less dispatch path: these
103
+ * events buffered behind a busy main (fork-less adapter), their consume
104
+ * guards returned false and released the claims, so the drain site owns the
105
+ * terminal resolution. Rows are pending — the by-id close resolves them
106
+ * without a fence; a row a successor lane meanwhile claimed answers stale and
107
+ * is that owner's to finish. `turnErrored` (consumed at the call site before
108
+ * the drain can start another turn) leaves the rows for the renotify pacing
109
+ * instead. Either way a row left live gets its local dedupe claim freed, or
110
+ * this pod would reject the retry forever.
111
+ */
112
+ export declare function settleDrainedTypedGroup(host: LaneFlowHost, events: ParallEvent[], ids: string[], turnErrored: boolean): Promise<void>;
113
+ /** Consumption hooks for one typed dispatch. */
114
+ export interface TypedConsumeHooks {
115
+ /**
116
+ * Legacy administrative ack — invoked ONLY on the ledger-disabled fallback
117
+ * path (old server). On the ledger path the WorkItem resolves through the
118
+ * sources-form complete instead; this callback never fires there.
119
+ */
120
+ legacyAck: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>;
121
+ /**
122
+ * Free the item's in-memory hot-path dedupe claim. Invoked when the handler
123
+ * ran but the resolution could not commit (sources-form complete failed) —
124
+ * the member is released for re-drive, and a stale local claim would make
125
+ * this pod reject its own retry forever.
126
+ */
127
+ clearDedupe?: () => void;
128
+ }
63
129
  /**
64
130
  * Consume one typed dispatch (task/comment/schedule/trigger/approval) under
65
131
  * its typed-lane occupancy guard: claim the dsp:<id> lane (skip when another
66
- * pod holds it or the WorkItem is already resolved), run the handler, ack on
67
- * success (the doc's option (b): notification delivered, tracked elsewhere),
68
- * then release the lane. Legacy run+ack flow when the ledger is unavailable.
132
+ * pod holds it or the WorkItem is already resolved), run the handler, then
133
+ * settle. A successful run resolves the WorkItem terminally via the
134
+ * sources-form complete (turn_outcome ok the server sweeps it, or lets a
135
+ * typed Effect committed during the turn stand, and drops the vacated lane);
136
+ * a failed / errored / unresolved run releases the member back to pending on
137
+ * the redrive budget via the lane complete. Legacy run+ack flow when the
138
+ * ledger is unavailable.
69
139
  *
70
- * Repeated failures back off: a consume that ends un-acked re-arms the
140
+ * Repeated failures back off: a consume that ends unresolved re-arms the
71
141
  * WorkItem's backoff entry, and the next attempt sleeps out the remaining
72
142
  * window before claiming. Without this, complete's release re-drives the
73
143
  * item instantly and a persistently-failing consume (e.g. buffered behind a
@@ -78,7 +148,7 @@ export declare function consumeTypedDispatch(host: LaneFlowHost, ref: {
78
148
  dispatchEventId?: string;
79
149
  sourceType?: string;
80
150
  sourceId?: string;
81
- }, run: (dispatchEventId?: string) => Promise<boolean>, ack: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>): Promise<void>;
151
+ }, run: (dispatchEventId?: string) => Promise<boolean>, hooks: TypedConsumeHooks): Promise<void>;
82
152
  /**
83
153
  * Shared consumption of a message WorkItem referenced by id — the ONE
84
154
  * protocol for dispatch catch-up pages and post-complete re-drive hints
@@ -1 +1 @@
1
- {"version":3,"file":"gateway-lane-flow.d.ts","sourceRoot":"","sources":["../src/gateway-lane-flow.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"gateway-lane-flow.d.ts","sourceRoot":"","sources":["../src/gateway-lane-flow.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC5E,OAAO,KAAK,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAEtF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;GAKG;AAEH,0EAA0E;AAC1E,MAAM,WAAW,YAAY;IAC3B,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,cAAc,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,4BAA4B,EAAE,OAAO,CAAC;IACtC,YAAY,EAAE,OAAO,CAAC;IACtB,kBAAkB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B;;;;;;;OAOG;IACH,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtE,IAAI,EAAE;QACJ,MAAM,EAAE,YAAY,CAAC;QACrB,GAAG,CAAC,EAAE,aAAa,CAAC;QACpB,MAAM,EAAE;YAAE,MAAM,EAAE,MAAM,CAAA;SAAE,CAAC;QAC3B,eAAe,EAAE,eAAe,CAAC;QACjC,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,cAAc,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;IAC5C,oBAAoB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC;IAC9C,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC;IAClE,WAAW,CACT,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,aAAa,CAAC,EAAE,WAAW,EAAE,EAC7B,WAAW,CAAC,EAAE,MAAM,EAAE,GACrB,OAAO,CAAC,OAAO,CAAC,CAAC;IACpB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;IACrC,4BAA4B,CAC1B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,uBAAuB,CAAC,CAAC;IACpC,kBAAkB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1D;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,YAAY,EAClB,IAAI,EAAE;IACJ,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,OAAO,CAAC;CAC7B,GACA,OAAO,CAAC,YAAY,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAC,CAqG3D;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,MAAM,EAAE,GAAG,IAAI,CAQ9F;AAED,2CAA2C;AAC3C,MAAM,MAAM,mBAAmB,GAAG,IAAI,GAAG,OAAO,GAAG,aAAa,GAAG,QAAQ,CAAC;AAa5E;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,YAAY,EAClB,eAAe,EAAE,MAAM,EACvB,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,mBAAmB,CAAC,CAgB9B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,CA6BrF;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,YAAY,EAClB,MAAM,EAAE,WAAW,EAAE,EACrB,GAAG,EAAE,MAAM,EAAE,EACb,WAAW,EAAE,OAAO,GACnB,OAAO,CAAC,IAAI,CAAC,CA0Cf;AAOD,gDAAgD;AAChD,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,SAAS,EAAE,CAAC,eAAe,CAAC,EAAE,MAAM,KAAK,OAAO,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IAClF;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,oBAAoB,CACxC,IAAI,EAAE,YAAY,EAClB,GAAG,EAAE;IAAE,eAAe,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,EACzE,GAAG,EAAE,CAAC,eAAe,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,EACnD,KAAK,EAAE,iBAAiB,GACvB,OAAO,CAAC,IAAI,CAAC,CA8If;AAED;;;;;;;GAOG;AACH,wBAAsB,sBAAsB,CAC1C,IAAI,EAAE,YAAY,EAClB,IAAI,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACvD,OAAO,CAAC,IAAI,CAAC,CAsEf"}
@@ -1,3 +1,4 @@
1
+ import { ApiError } from '@parall/sdk';
1
2
  import { LedgerUnsupportedError } from './lane-ledger.js';
2
3
  /**
3
4
  * Dispatch one group of same-lane message events under the ledger contract:
@@ -101,6 +102,155 @@ export async function dispatchLaneGroup(host, opts) {
101
102
  await ledger.completeIfIdle(lane.laneKey, pendingInjections || opts.hasMoreLocal());
102
103
  return 'dispatched';
103
104
  }
105
+ /**
106
+ * The WorkItem ids of a typed event group whose lifecycle the ledger owns
107
+ * (claimed into dsp lanes by consumeTypedDispatch), or null when the group
108
+ * must stay on the legacy received/ack surface — ledger unavailable, an id
109
+ * missing, or a member that rides a message lane. Non-null means: skip
110
+ * legacy received+ack, resolve via the by-id complete, and fold the
111
+ * turn-error signal into the outcome.
112
+ */
113
+ export function typedLedgerEventIds(host, events) {
114
+ if (!host.laneLedger || host.ledgerDisabled)
115
+ return null;
116
+ const ids = [];
117
+ for (const ev of events) {
118
+ if (!ev.dispatchEventId || host.usesLaneLedger(ev))
119
+ return null;
120
+ ids.push(ev.dispatchEventId);
121
+ }
122
+ return ids.length > 0 ? ids : null;
123
+ }
124
+ /**
125
+ * A pre-by-id server (v1.44) routes a by-id body into its lane-form
126
+ * validation and answers 400 INVALID_REQUEST. Any 400 here means the server
127
+ * did not understand the form — a well-formed by-id call never 400s on a
128
+ * current server — so the caller falls back to the legacy typed ack for the
129
+ * rest of the process (sticky; the bridge restarts on the next update).
130
+ */
131
+ function isByIDCompleteUnsupported(err) {
132
+ return err instanceof ApiError && err.status === 400;
133
+ }
134
+ /**
135
+ * By-id complete (turn_outcome ok): terminal resolution of ONE WorkItem —
136
+ * exact where the source pair is ambiguous across task siblings. `lane`
137
+ * fences the call while the row is claim-owned (a dethroned caller gets
138
+ * 'stale' and must leave the row to its successor); omit it for the
139
+ * wrapper-less pending close (buffered drain, administrative drops).
140
+ */
141
+ export async function resolveDispatchByID(host, dispatchEventId, lane) {
142
+ try {
143
+ await host.opts.client.completeDispatch(host.opts.config.org_id, {
144
+ dispatch_event_id: dispatchEventId,
145
+ ...(lane ? { lane } : {}),
146
+ turn_outcome: 'ok',
147
+ });
148
+ return 'ok';
149
+ }
150
+ catch (err) {
151
+ if (err instanceof ApiError && err.status === 409)
152
+ return 'stale';
153
+ if (isByIDCompleteUnsupported(err))
154
+ return 'unsupported';
155
+ host.opts.log?.warn(`by-id complete failed for ${dispatchEventId} — leaving for re-drive: ${String(err)}`);
156
+ return 'failed';
157
+ }
158
+ }
159
+ /**
160
+ * Free a typed event's in-memory hot-path dedupe claim by its source pair —
161
+ * the ParallEvent-keyed mirror of the gateway's clearTypedDispatchDedupe
162
+ * (which keys on the wire DispatchNewData). Used when a buffered typed
163
+ * dispatch ran but its resolution failed: the member re-drives, and a stale
164
+ * local claim would make this pod reject the retry forever.
165
+ *
166
+ * PARITY: the switch below and clearTypedDispatchDedupe's must handle the
167
+ * same typed source families — when adding a new typed event type, extend
168
+ * BOTH (they key the same dedupe entries from different event shapes).
169
+ */
170
+ export function clearTypedDedupeForEvent(host, event) {
171
+ const sourceId = event.ackSourceId;
172
+ if (!sourceId)
173
+ return;
174
+ switch (event.ackSourceType) {
175
+ case 'task_activity': {
176
+ // Task dedupe keys are `${task_id}:${updated_at}`; the task id is the
177
+ // event target. Prefix-clear mirrors clearTypedDispatchDedupe.
178
+ const prefix = `${event.targetId}:`;
179
+ for (const key of host.dispatchedTasks) {
180
+ if (key.startsWith(prefix))
181
+ host.dispatchedTasks.delete(key);
182
+ }
183
+ break;
184
+ }
185
+ case 'comment':
186
+ host.dispatchedTasks.delete(`comment:${sourceId}`);
187
+ break;
188
+ case 'schedule_run':
189
+ host.dispatchedTasks.delete(`schedule_run:${sourceId}`);
190
+ break;
191
+ case 'external_trigger_run':
192
+ host.dispatchedTasks.delete(`external_trigger_run:${sourceId}`);
193
+ break;
194
+ case 'channel_message':
195
+ host.dispatchedMessages.delete(`channel_message:${sourceId}`);
196
+ break;
197
+ case 'approval':
198
+ host.dispatchedTasks.delete(`approval:${sourceId}`);
199
+ break;
200
+ }
201
+ }
202
+ /**
203
+ * Settle a drained typed group — the one wrapper-less dispatch path: these
204
+ * events buffered behind a busy main (fork-less adapter), their consume
205
+ * guards returned false and released the claims, so the drain site owns the
206
+ * terminal resolution. Rows are pending — the by-id close resolves them
207
+ * without a fence; a row a successor lane meanwhile claimed answers stale and
208
+ * is that owner's to finish. `turnErrored` (consumed at the call site before
209
+ * the drain can start another turn) leaves the rows for the renotify pacing
210
+ * instead. Either way a row left live gets its local dedupe claim freed, or
211
+ * this pod would reject the retry forever.
212
+ */
213
+ export async function settleDrainedTypedGroup(host, events, ids, turnErrored) {
214
+ if (turnErrored) {
215
+ host.opts.log?.info(`buffered typed turn for ${events[events.length - 1]?.messageId} surfaced a runtime error — leaving for re-drive`);
216
+ for (const event of events)
217
+ clearTypedDedupeForEvent(host, event);
218
+ return;
219
+ }
220
+ // The legacy ack BY ID, never by source — task siblings share a source
221
+ // pair, and a by-source ack would sweep the undispatched one. Awaited:
222
+ // these rows are already pending (released at buffer time), so a failed
223
+ // ack must free the local dedupe claim or the pending retry would be
224
+ // self-rejected by this pod forever.
225
+ const legacyAckFrom = async (start) => {
226
+ for (const [j, id] of ids.slice(start).entries()) {
227
+ try {
228
+ await host.opts.client.ackDispatchByID(host.opts.config.org_id, id);
229
+ }
230
+ catch {
231
+ clearTypedDedupeForEvent(host, events[start + j]);
232
+ }
233
+ }
234
+ };
235
+ if (host.typedByIdCompleteUnsupported) {
236
+ // Sticky: the server already answered one by-id probe with 400 — go
237
+ // straight to the legacy acks instead of re-probing per group.
238
+ await legacyAckFrom(0);
239
+ return;
240
+ }
241
+ for (const [i, id] of ids.entries()) {
242
+ const outcome = await resolveDispatchByID(host, id);
243
+ if (outcome === 'unsupported') {
244
+ host.typedByIdCompleteUnsupported = true;
245
+ host.opts.log?.warn('server predates the by-id dispatch complete — falling back to legacy typed acks');
246
+ await legacyAckFrom(i);
247
+ return;
248
+ }
249
+ if (outcome !== 'ok') {
250
+ clearTypedDedupeForEvent(host, events[i]);
251
+ }
252
+ }
253
+ }
104
254
  /** Failure backoff pacing for typed dispatch retries (base 2s, cap 5min). */
105
255
  const TYPED_BACKOFF_BASE_MS = 2_000;
106
256
  const TYPED_BACKOFF_CAP_MS = 5 * 60_000;
@@ -108,18 +258,22 @@ const TYPED_BACKOFF_MAP_CAP = 512;
108
258
  /**
109
259
  * Consume one typed dispatch (task/comment/schedule/trigger/approval) under
110
260
  * its typed-lane occupancy guard: claim the dsp:<id> lane (skip when another
111
- * pod holds it or the WorkItem is already resolved), run the handler, ack on
112
- * success (the doc's option (b): notification delivered, tracked elsewhere),
113
- * then release the lane. Legacy run+ack flow when the ledger is unavailable.
261
+ * pod holds it or the WorkItem is already resolved), run the handler, then
262
+ * settle. A successful run resolves the WorkItem terminally via the
263
+ * sources-form complete (turn_outcome ok the server sweeps it, or lets a
264
+ * typed Effect committed during the turn stand, and drops the vacated lane);
265
+ * a failed / errored / unresolved run releases the member back to pending on
266
+ * the redrive budget via the lane complete. Legacy run+ack flow when the
267
+ * ledger is unavailable.
114
268
  *
115
- * Repeated failures back off: a consume that ends un-acked re-arms the
269
+ * Repeated failures back off: a consume that ends unresolved re-arms the
116
270
  * WorkItem's backoff entry, and the next attempt sleeps out the remaining
117
271
  * window before claiming. Without this, complete's release re-drives the
118
272
  * item instantly and a persistently-failing consume (e.g. buffered behind a
119
273
  * saturated fork pool) spins at wire speed — the client half of the
120
274
  * 2026-07-11 poison loop (the server half is the redrive budget).
121
275
  */
122
- export async function consumeTypedDispatch(host, ref, run, ack) {
276
+ export async function consumeTypedDispatch(host, ref, run, hooks) {
123
277
  const backoffKey = ref.dispatchEventId ?? `${ref.sourceType}:${ref.sourceId}`;
124
278
  const armed = host.typedRedriveBackoff.get(backoffKey);
125
279
  if (armed) {
@@ -142,8 +296,8 @@ export async function consumeTypedDispatch(host, ref, run, ack) {
142
296
  // consume — clearing backoff there would let an ack outage re-create the
143
297
  // wire-speed release/re-drive loop against a pre-budget server.
144
298
  const settleAck = (ackResult) => ackResult !== false;
145
- const settle = (acked) => {
146
- if (acked) {
299
+ const settle = (resolved) => {
300
+ if (resolved) {
147
301
  host.typedRedriveBackoff.delete(backoffKey);
148
302
  return;
149
303
  }
@@ -168,7 +322,7 @@ export async function consumeTypedDispatch(host, ref, run, ack) {
168
322
  let acked = false;
169
323
  try {
170
324
  if (await run(ref.dispatchEventId)) {
171
- acked = settleAck(await ack(ref.dispatchEventId));
325
+ acked = settleAck(await hooks.legacyAck(ref.dispatchEventId));
172
326
  }
173
327
  }
174
328
  finally {
@@ -197,21 +351,68 @@ export async function consumeTypedDispatch(host, ref, run, ack) {
197
351
  host.opts.log?.info(`typed dispatch ${ref.dispatchEventId ?? `${ref.sourceType}:${ref.sourceId}`} not claimable (held elsewhere or already resolved) — skipping`);
198
352
  return;
199
353
  }
200
- let acked = false;
354
+ let resolved = false;
355
+ let viaLegacyAck = false;
201
356
  try {
202
- // Ack must settle before Complete. A fire-and-forget ack races the lane
203
- // release: Complete can return the still-received typed item to pending
204
- // and publish a re-drive while its successful ack is still in flight.
357
+ // A successful run resolves the member terminally through the by-id
358
+ // complete one fenced call that sweeps exactly this WorkItem (a typed
359
+ // Effect committed during the turn already made it terminal idempotent
360
+ // no-op) AND drops the vacated lane. Resolution must settle inside this
361
+ // guard: a fire-and-forget resolve would race the finally's release and
362
+ // spuriously re-drive handled work.
205
363
  if (await run(lane.typedDispatchEventId)) {
206
- acked = settleAck(await ack(lane.typedDispatchEventId));
364
+ if (host.typedByIdCompleteUnsupported || !lane.typedDispatchEventId) {
365
+ // Pre-by-id server (or a defensive claim shape without the id):
366
+ // the legacy administrative ack is still live there.
367
+ viaLegacyAck = true;
368
+ resolved = settleAck(await hooks.legacyAck(lane.typedDispatchEventId));
369
+ }
370
+ else {
371
+ const outcome = await resolveDispatchByID(host, lane.typedDispatchEventId, lane.lane);
372
+ if (outcome === 'unsupported') {
373
+ host.typedByIdCompleteUnsupported = true;
374
+ host.opts.log?.warn('server predates the by-id dispatch complete — falling back to the legacy typed ack');
375
+ viaLegacyAck = true;
376
+ resolved = settleAck(await hooks.legacyAck(lane.typedDispatchEventId));
377
+ }
378
+ else if (outcome === 'stale') {
379
+ // A successor lane owns the row — its turn resolves it. Nothing to
380
+ // spin on locally; our claim record is already dethroned. Free the
381
+ // local dedupe claim though: if the successor later FAILS and
382
+ // releases the row, the re-drive may land back on this pod, and a
383
+ // stale local claim would self-reject the retry into the budget.
384
+ hooks.clearDedupe?.();
385
+ resolved = true;
386
+ }
387
+ else {
388
+ resolved = outcome === 'ok';
389
+ if (!resolved) {
390
+ // The member is about to be released for re-drive — free its
391
+ // hot-path dedupe claim first, or this pod rejects its own retry
392
+ // forever (same contract as the legacy ack-failure path).
393
+ hooks.clearDedupe?.();
394
+ }
395
+ }
396
+ }
207
397
  }
208
398
  }
209
399
  finally {
210
- settle(acked);
211
- // Release the occupancy row. A buffered dispatch may outlive this guard
212
- // (lane TTL) acceptable at-least-once; the ledger's resolution paths
213
- // still dedupe the persistent side effects.
214
- await host.laneLedger.completeIfIdle(lane.laneKey, false).catch(() => { });
400
+ settle(resolved);
401
+ if (resolved && !viaLegacyAck) {
402
+ // The by-id complete already dropped (or a takeover already owns) the
403
+ // server-side lane row; only the local record and context file remain.
404
+ host.laneLedger.dropLocal(lane.laneKey);
405
+ }
406
+ else {
407
+ // Two jobs, one call: an unresolved turn (unconsumed / failed /
408
+ // errored / resolution-failed) releases the member back to pending on
409
+ // the redrive budget; a legacy-acked turn still needs its server-side
410
+ // lane row dropped (the ack resolves the row but not the occupancy).
411
+ // A buffered dispatch may outlive this guard (lane TTL) — acceptable
412
+ // at-least-once; the ledger's resolution paths still dedupe the
413
+ // persistent side effects.
414
+ await host.laneLedger.completeIfIdle(lane.laneKey, false).catch(() => { });
415
+ }
215
416
  }
216
417
  }
217
418
  /**
@@ -227,7 +428,22 @@ export async function consumeMessageWorkItem(host, item) {
227
428
  return;
228
429
  if (!host.tryClaimMessage(item.source_id))
229
430
  return;
431
+ // Administrative drop (deleted source / self-sender / skip decision): on
432
+ // the ledger path the by-id complete closes the pending row terminally and
433
+ // refuses (409) a row a live lane owns — the owner's turn resolves it. The
434
+ // legacy ack remains the ledger-disabled / pre-by-id fallback.
435
+ // Fire-and-forget either way: a failed drop leaves the row live and the
436
+ // next re-drive converges on the same drop.
230
437
  const ackItem = () => {
438
+ if (host.laneLedger && !host.ledgerDisabled && !host.typedByIdCompleteUnsupported) {
439
+ void resolveDispatchByID(host, item.id).then((outcome) => {
440
+ if (outcome === 'unsupported') {
441
+ host.typedByIdCompleteUnsupported = true;
442
+ host.opts.client.ackDispatchByID(host.opts.config.org_id, item.id).catch(() => { });
443
+ }
444
+ });
445
+ return;
446
+ }
231
447
  host.opts.client.ackDispatchByID(host.opts.config.org_id, item.id).catch(() => { });
232
448
  };
233
449
  let msg = null;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export * from './provider-config.js';
2
2
  export * from './types.js';
3
3
  export * from './lane-key.js';
4
4
  export type { LaneFlowHost } from './gateway-lane-flow.js';
5
- export { consumeTypedDispatch } from './gateway-lane-flow.js';
5
+ export { consumeTypedDispatch, settleDrainedTypedGroup } from './gateway-lane-flow.js';
6
6
  export * from './session-state.js';
7
7
  export * from './routing.js';
8
8
  export * from './event-format.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAC9D,cAAc,oBAAoB,CAAC;AACnC,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACxD,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAClF,YAAY,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,EACf,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,oBAAoB,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACvF,cAAc,oBAAoB,CAAC;AACnC,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACxD,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAClF,YAAY,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,EACf,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC"}
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export * from './provider-config.js';
2
2
  export * from './types.js';
3
3
  export * from './lane-key.js';
4
- export { consumeTypedDispatch } from './gateway-lane-flow.js';
4
+ export { consumeTypedDispatch, settleDrainedTypedGroup } from './gateway-lane-flow.js';
5
5
  export * from './session-state.js';
6
6
  export * from './routing.js';
7
7
  export * from './event-format.js';
@@ -123,6 +123,14 @@ export declare class LaneLedger {
123
123
  sourceType?: string;
124
124
  sourceId?: string;
125
125
  }): Promise<ActiveLane | null>;
126
+ /**
127
+ * Drop a lane's local record without any server call — for a typed lane
128
+ * whose member the by-id complete just resolved (the server dropped the
129
+ * vacated lane row in the same transaction). Calling completeIfIdle
130
+ * instead would fire a lane-form Complete at a lane that no longer exists
131
+ * and burn an RPC on the guaranteed STALE_LANE answer.
132
+ */
133
+ dropLocal(laneKey: string): void;
126
134
  /**
127
135
  * Remove the per-lane context file (and its CLI sidecar) when the lane
128
136
  * ends. A leftover file would make a later cross-context send to the same
@@ -1 +1 @@
1
- {"version":3,"file":"lane-ledger.d.ts","sourceRoot":"","sources":["../src/lane-ledger.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,sDAAsD;AACtD,MAAM,MAAM,UAAU,GAAG;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,6EAA6E;IAC7E,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF;;;;GAIG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;CAAG;AAEpD,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,CAAC;AAqBrD;;;;;;;;;;;GAWG;AACH,qBAAa,UAAU;IAInB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiC;gBAGpC,IAAI,EAAE;QACrB,MAAM,EAAE,YAAY,CAAC;QACrB,KAAK,EAAE,MAAM,CAAC;QACd,UAAU,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,aAAa,CAAC;KACrB;IAGH,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,+FAA+F;IAC/F,OAAO,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO;IAIpC,UAAU,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM;IAOtC,WAAW,CAAC,KAAK,EAAE,WAAW,GAAG,UAAU,GAAG,SAAS;IAIvD,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM;IAIzC;;;;;;OAMG;IACG,UAAU,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAsFnE;;;;;OAKG;IACG,SAAS,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC;IA0BrD;;;;;OAKG;IACH;;;;;;OAMG;IACH,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAK9B,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IA6B3E;;;;;OAKG;IACH,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAKjC;;;;;;;OAOG;IACH,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI;IA6BlC;;;;OAIG;IACG,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYvC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAOjC,yEAAyE;IACzE,IAAI,WAAW,IAAI,MAAM,CAExB;IAED;;;;;OAKG;IACG,UAAU,CAAC,GAAG,EAAE;QACpB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IA8B9B;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;CAW1B"}
1
+ {"version":3,"file":"lane-ledger.d.ts","sourceRoot":"","sources":["../src/lane-ledger.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,sDAAsD;AACtD,MAAM,MAAM,UAAU,GAAG;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,6EAA6E;IAC7E,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF;;;;GAIG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;CAAG;AAEpD,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,CAAC;AAqBrD;;;;;;;;;;;GAWG;AACH,qBAAa,UAAU;IAInB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiC;gBAGpC,IAAI,EAAE;QACrB,MAAM,EAAE,YAAY,CAAC;QACrB,KAAK,EAAE,MAAM,CAAC;QACd,UAAU,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,aAAa,CAAC;KACrB;IAGH,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,+FAA+F;IAC/F,OAAO,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO;IAIpC,UAAU,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM;IAOtC,WAAW,CAAC,KAAK,EAAE,WAAW,GAAG,UAAU,GAAG,SAAS;IAIvD,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM;IAIzC;;;;;;OAMG;IACG,UAAU,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAsFnE;;;;;OAKG;IACG,SAAS,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC;IA0BrD;;;;;OAKG;IACH;;;;;;OAMG;IACH,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAK9B,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IA6B3E;;;;;OAKG;IACH,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAKjC;;;;;;;OAOG;IACH,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI;IA6BlC;;;;OAIG;IACG,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYvC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAOjC,yEAAyE;IACzE,IAAI,WAAW,IAAI,MAAM,CAExB;IAED;;;;;OAKG;IACG,UAAU,CAAC,GAAG,EAAE;QACpB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IA8B9B;;;;;;OAMG;IACH,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAOhC;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;CAW1B"}
@@ -347,6 +347,20 @@ export class LaneLedger {
347
347
  this.lanes.set(lane.laneKey, lane);
348
348
  return lane;
349
349
  }
350
+ /**
351
+ * Drop a lane's local record without any server call — for a typed lane
352
+ * whose member the by-id complete just resolved (the server dropped the
353
+ * vacated lane row in the same transaction). Calling completeIfIdle
354
+ * instead would fire a lane-form Complete at a lane that no longer exists
355
+ * and burn an RPC on the guaranteed STALE_LANE answer.
356
+ */
357
+ dropLocal(laneKey) {
358
+ const lane = this.lanes.get(laneKey);
359
+ if (!lane)
360
+ return;
361
+ this.lanes.delete(laneKey);
362
+ this.removeLaneContext(lane);
363
+ }
350
364
  /**
351
365
  * Remove the per-lane context file (and its CLI sidecar) when the lane
352
366
  * ends. A leftover file would make a later cross-context send to the same
@@ -1,2 +1,2 @@
1
- export declare const PARALL_SCHEDULES_SKILL = "# Parall Schedules\n\nA **Schedule** is a platform time trigger. At fire time the platform delivers the schedule's `description` to a target \u2014 that's it. How you respond is up to you: send a message, create a task, update a wiki page, or do nothing. Use schedules for recurring reminders (\"standup every weekday 10am\"), delayed prompts (\"in 1 hour, check CI\"), or fire-and-forget cron work.\n\nThree spec types \u2014 pick exactly one:\n\n- `cron` \u2014 5-field expression (min granularity: 1 minute)\n- `interval` \u2014 every N seconds (minimum 60)\n- `one_shot` \u2014 fire once at a specific time\n\n## Creating schedules\n\n```bash\n# Recurring cron (weekdays 10am New York)\nparall schedules create \\\n --name \"Daily standup\" \\\n --description \"Ask the team for their plan today; see prll://wik_xxx for the standup template\" \\\n --target-ids prll://usr_xxx \\\n --cron-expr \"0 10 * * 1-5\" \\\n --timezone America/New_York \\\n --attached-to-uri prll://cht_xxx\n\n# Every 30 minutes\nparall schedules create \\\n --name \"CI watch\" \\\n --description \"Check the deploy status and flag failures\" \\\n --target-ids prll://usr_xxx \\\n --interval-seconds 1800\n\n# One-shot at a future RFC3339 time\nparall schedules create \\\n --name \"Followup\" \\\n --description \"Remind the user about the PR review if still pending\" \\\n --target-ids prll://usr_xxx \\\n --run-at <FUTURE_RFC3339_TIME>\n```\n\n`--target-ids` is who receives the fire (usually yourself when you're self-scheduling; another agent or human when delegating). `--attached-to-uri` optionally anchors the schedule to a task / chat / project / wiki page \u2014 when that resource is archived or deleted, the schedule auto-cancels (`cancel_reason=attached_gone`).\n\n### Reminders for someone else\n\nWhen someone asks you to remind them (or a third person), put that person in\n`--target-ids` \u2014 the fire is delivered to its targets, so a reminder\ntargeting only yourself never reaches them. The schedule record stays yours as\ncreator (there is no owner transfer); add yourself as an additional target\nonly if you also need to act at fire time.\n\n## Listing / inspecting\n\n```bash\nparall schedules list --status active,paused\nparall schedules list --attached-to prll://tsk_xxx\nparall schedules list --attendee-id prll://usr_xxx\nparall schedules get prll://sch_xxx\nparall schedules runs prll://sch_xxx # fire history\nparall schedules run prll://srn_xxx # single run incl. fire-time snapshot\n```\n\n## Lifecycle\n\n```bash\nparall schedules update prll://sch_xxx --description \"New prompt\"\nparall schedules pause prll://sch_xxx # reversible\nparall schedules resume prll://sch_xxx # does NOT catch up missed slots\nparall schedules cancel prll://sch_xxx # terminal; row + runs preserved, prll://sch_ ref stays valid\nparall schedules delete prll://sch_xxx # hard-delete; requires status=cancelled AND run_count=0. Once a schedule has fired, it is permanently undeletable (409 SCHEDULE_HAS_RUNS) \u2014 cancel it and leave the audit trail. Delete is for never-fired test/accidental schedules only.\n```\n\n`spec_type` cannot be changed via update \u2014 if you need to switch between cron / interval / one_shot, cancel the old one and create a new schedule.\n\n## Responding to schedule fires\n\nWhen you receive `[Event: schedule.fired]`, the platform has fired a schedule targeting you.\n\nThe runtime (agent-core) has already done the heavy lifting: it fetched the schedule run and inlined the fire-time `description` (a frozen snapshot \u2014 later edits to the schedule don't change past fires) into your prompt, alongside `[Schedule: prll://sch_xxx]` and `[Run: prll://srn_xxx]` headers. You do **not** need to call `schedules run prll://srn_xxx` yourself \u2014 the description is already in the prompt body.\n\nYour job is to interpret the description and act:\n\n1. Read the description and any `prll://` refs it contains\n2. Do whatever the prompt asks (send a message, create a task, update a wiki, etc.) \u2014 there is no canonical response format\n3. Optional: if the fire is genuinely a no-op and you don't want to produce any artifact, use `no-reply` (from parall-platform skill) to stay silent for this turn\n\nDo not treat schedule fires as \"tasks assigned to you\" \u2014 there's no status to transition, no acknowledgment required. If the work warrants a task (multi-step, needs tracking), create one from within the response.\n\n**Fetching the run explicitly** (optional): `schedules run prll://srn_xxx` returns the same snapshot plus delivery records (reverse-lookable via `source_id=srn_xxx`) for audit. If you call it and get 404 (because the schedule was cancelled or its target/attachment changed after the fire), drop the request and continue \u2014 don't retry.\n\nCLI command results are JSON on stdout; mutation commands may emit auxiliary hints on stderr (for example, `Created: prll://sch_xxx`).\n";
1
+ export declare const PARALL_SCHEDULES_SKILL = "# Parall Schedules\n\nA **Schedule** is a platform time trigger. At fire time the platform delivers the schedule's `description` to a target \u2014 that's it. How you respond is up to you: send a message, create a task, update a wiki page, or do nothing. Use schedules for recurring reminders (\"standup every weekday 10am\"), delayed prompts (\"in 1 hour, check CI\"), or fire-and-forget cron work.\n\nThree spec types \u2014 pick exactly one:\n\n- `cron` \u2014 5-field expression (min granularity: 1 minute)\n- `interval` \u2014 every N seconds (minimum 60)\n- `one_shot` \u2014 fire once at a specific time\n\n## Creating schedules\n\n```bash\n# Recurring cron (weekdays 10am New York)\nparall schedules create \\\n --name \"Daily standup\" \\\n --description \"Ask the team for their plan today; see prll://wik_xxx for the standup template\" \\\n --target-ids prll://usr_xxx \\\n --cron-expr \"0 10 * * 1-5\" \\\n --timezone America/New_York \\\n --attached-to-uri prll://cht_xxx\n\n# Every 30 minutes\nparall schedules create \\\n --name \"CI watch\" \\\n --description \"Check the deploy status and flag failures\" \\\n --target-ids prll://usr_xxx \\\n --interval-seconds 1800\n\n# One-shot at a future RFC3339 time\nparall schedules create \\\n --name \"Followup\" \\\n --description \"Remind the user about the PR review if still pending\" \\\n --target-ids prll://usr_xxx \\\n --run-at <FUTURE_RFC3339_TIME>\n```\n\n`--target-ids` is who receives the fire (usually yourself when you're self-scheduling; another agent or human when delegating). `--attached-to-uri` optionally anchors the schedule to a task / chat / project / wiki page \u2014 when that resource is archived or deleted, the schedule auto-cancels (`status_reason=attached_gone`). A schedule whose agent targets all sit on a terminated machine is auto-paused by the platform (`status_reason=attendee_unreachable`) instead of firing into a void; resuming a recurring schedule while the machine is still terminated just pauses it again on the next slot (a one-shot resumed past its catch-up window instead follows the normal missed semantics and completes).\n\n### Reminders for someone else\n\nWhen someone asks you to remind them (or a third person), put that person in\n`--target-ids` \u2014 the fire is delivered to its targets, so a reminder\ntargeting only yourself never reaches them. The schedule record stays yours as\ncreator (there is no owner transfer); add yourself as an additional target\nonly if you also need to act at fire time.\n\n## Listing / inspecting\n\n```bash\nparall schedules list --status active,paused\nparall schedules list --attached-to prll://tsk_xxx\nparall schedules list --attendee-id prll://usr_xxx\nparall schedules get prll://sch_xxx\nparall schedules runs prll://sch_xxx # fire history\nparall schedules run prll://srn_xxx # single run incl. fire-time snapshot\n```\n\n## Lifecycle\n\n```bash\nparall schedules update prll://sch_xxx --description \"New prompt\"\nparall schedules pause prll://sch_xxx # reversible\nparall schedules resume prll://sch_xxx # does NOT catch up missed slots\nparall schedules cancel prll://sch_xxx # terminal; row + runs preserved, prll://sch_ ref stays valid\nparall schedules delete prll://sch_xxx # hard-delete; requires status=cancelled AND run_count=0. Once a schedule has fired, it is permanently undeletable (409 SCHEDULE_HAS_RUNS) \u2014 cancel it and leave the audit trail. Delete is for never-fired test/accidental schedules only.\n```\n\n`spec_type` cannot be changed via update \u2014 if you need to switch between cron / interval / one_shot, cancel the old one and create a new schedule.\n\n## Responding to schedule fires\n\nWhen you receive `[Event: schedule.fired]`, the platform has fired a schedule targeting you.\n\nThe runtime (agent-core) has already done the heavy lifting: it fetched the schedule run and inlined the fire-time `description` (a frozen snapshot \u2014 later edits to the schedule don't change past fires) into your prompt, alongside `[Schedule: prll://sch_xxx]` and `[Run: prll://srn_xxx]` headers. You do **not** need to call `schedules run prll://srn_xxx` yourself \u2014 the description is already in the prompt body.\n\nYour job is to interpret the description and act:\n\n1. Read the description and any `prll://` refs it contains\n2. Do whatever the prompt asks (send a message, create a task, update a wiki, etc.) \u2014 there is no canonical response format\n3. Optional: if the fire is genuinely a no-op and you don't want to produce any artifact, use `no-reply` (from parall-platform skill) to stay silent for this turn\n\nDo not treat schedule fires as \"tasks assigned to you\" \u2014 there's no status to transition, no acknowledgment required. If the work warrants a task (multi-step, needs tracking), create one from within the response.\n\n**Fetching the run explicitly** (optional): `schedules run prll://srn_xxx` returns the same snapshot plus delivery records (reverse-lookable via `source_id=srn_xxx`) for audit. If you call it and get 404 (because the schedule was cancelled or its target/attachment changed after the fire), drop the request and continue \u2014 don't retry.\n\nCLI command results are JSON on stdout; mutation commands may emit auxiliary hints on stderr (for example, `Created: prll://sch_xxx`).\n";
2
2
  //# sourceMappingURL=parall-schedules.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"parall-schedules.d.ts","sourceRoot":"","sources":["../../src/skills/parall-schedules.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB,44JAuFlC,CAAC"}
1
+ {"version":3,"file":"parall-schedules.d.ts","sourceRoot":"","sources":["../../src/skills/parall-schedules.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB,mwKAuFlC,CAAC"}
@@ -35,7 +35,7 @@ parall schedules create \\
35
35
  --run-at <FUTURE_RFC3339_TIME>
36
36
  \`\`\`
37
37
 
38
- \`--target-ids\` is who receives the fire (usually yourself when you're self-scheduling; another agent or human when delegating). \`--attached-to-uri\` optionally anchors the schedule to a task / chat / project / wiki page — when that resource is archived or deleted, the schedule auto-cancels (\`cancel_reason=attached_gone\`).
38
+ \`--target-ids\` is who receives the fire (usually yourself when you're self-scheduling; another agent or human when delegating). \`--attached-to-uri\` optionally anchors the schedule to a task / chat / project / wiki page — when that resource is archived or deleted, the schedule auto-cancels (\`status_reason=attached_gone\`). A schedule whose agent targets all sit on a terminated machine is auto-paused by the platform (\`status_reason=attendee_unreachable\`) instead of firing into a void; resuming a recurring schedule while the machine is still terminated just pauses it again on the next slot (a one-shot resumed past its catch-up window instead follows the normal missed semantics and completes).
39
39
 
40
40
  ### Reminders for someone else
41
41
 
@@ -1,2 +1,2 @@
1
- export declare const PARALL_TASKS_SKILL = "# Parall Tasks\n\nManage tasks and projects via the Parall CLI. Auth and runtime context are pre-configured.\n\n## Finding What's on Someone's Plate (incl. subtasks)\n\nTo answer \"what do I still have to do\", \"what's <person> working on\", or any\n\"open work assigned to X\" question, use `tasks assigned`:\n\n```bash\n# Pending tasks (todo + in_progress) assigned to a member \u2014 INCLUDES subtasks.\nparall tasks assigned prll://usr_xxx # a specific person (e.g. the human who asked)\nparall tasks assigned # yourself (defaults to the authenticated user)\n```\n\nThis is the authoritative \"open work for a person\" query. It returns every\npending task assigned to that member **including subtasks** \u2014 even when the\nsubtask's parent task belongs to someone else. Decomposed work usually lives in\nsubtasks, so do NOT answer this kind of question from `tasks list` alone:\nthat is org-wide, page-capped, and not scoped to a person, so a person's\nsubtasks are easily missed.\n\nResolve a person's `prll://usr_` id from the message context, the members\nlist, or ref search; your own id comes from `parall whoami`.\n\n## Task Commands\n\n```bash\n# List tasks (org-wide; filter by status, assignee, or parent)\nparall tasks list\nparall tasks list --status todo\nparall tasks list --status in_progress\nparall tasks list --assignee-id prll://usr_xxx # first page only (default 20) \u2014 for a person's FULL backlog use 'tasks assigned' above\nparall tasks subtasks prll://tsk_xxx # children of a single parent task\n\n# Create a task (add --parent-id to make it a SUBTASK of another task)\nparall tasks create --title \"Task title\" [--assignee-id prll://usr_xxx] [--parent-id prll://tsk_xxx] [--project-id prll://prj_xxx]\n\n# Update task status\nparall tasks update prll://tsk_xxx --status in_progress\nparall tasks update prll://tsk_xxx --status done\n\n# Add a comment\nparall tasks comments add prll://tsk_xxx --body \"Progress update...\"\n```\n\nSubtasks are just tasks with a parent: create one with `tasks create --parent-id`,\nre-parent with `tasks update --parent-id`, list a parent's children with\n`tasks subtasks`. `tasks list` without `--parent-id` already returns both\ntop-level tasks and subtasks; per-person open work is best fetched with\n`tasks assigned` (above).\n\n## Project Commands\n\n```bash\nparall projects list\n```\n\n## Watching Tasks\n\nWatchers receive dispatch events for a task's new comments. Acting on a task\nauto-subscribes you \u2014 creating it, being assigned, commenting, being\n@mentioned, or substantively editing it (description / assignee). Handle or\ndismiss those comment events deliberately.\n\n```bash\nparall tasks watch prll://tsk_xxx # follow a task without acting on it\nparall tasks unwatch prll://tsk_xxx # opt out of a task's comment events\nparall tasks watchers prll://tsk_xxx # list who is watching\n```\n\nCreators and assignees are locked subscribers \u2014 `unwatch` returns 409 for\nthem until the role changes (e.g. reassignment); it works for every other\nwatcher.\n\n## Responding to Task Assignments\n\nWhen you receive `[Event: task.assigned]`:\n\n1. Acknowledge with a comment: `tasks comments add prll://tsk_xxx --body \"On it\"`\n2. Update status: `tasks update prll://tsk_xxx --status in_progress`\n3. Do the work\n4. Report results in a comment. If a gate remains \u2014 review, merge, deploy,\n requester acceptance \u2014 set `in_review` and name the gate; set `done`\n only once the work has actually landed\n\n## Responding to Task Comments\n\nWhen you receive `[Event: task.comment.created]`, someone commented on a task you are watching. Read the comment body and respond if action is needed:\n\n1. Review the comment content and task context\n2. Reply via comment: `tasks comments add prll://tsk_xxx --body \"Response...\"`\n3. If the comment requests status changes, update accordingly\n\nCLI success output is JSON; errors print a JSON line plus, on a `PERMISSION_DENIED`, an optional plain-text `Request approval:` line \u2014 read both.\n";
1
+ export declare const PARALL_TASKS_SKILL = "# Parall Tasks\n\nManage tasks and projects via the Parall CLI. Auth and runtime context are pre-configured.\n\n## Finding What's on Someone's Plate (incl. subtasks)\n\nTo answer \"what do I still have to do\", \"what's <person> working on\", or any\n\"open work assigned to X\" question, use `tasks assigned`:\n\n```bash\n# Pending tasks (todo + in_progress) assigned to a member \u2014 INCLUDES subtasks.\nparall tasks assigned prll://usr_xxx # a specific person (e.g. the human who asked)\nparall tasks assigned # yourself (defaults to the authenticated user)\n```\n\nThis is the authoritative \"open work for a person\" query. It returns every\npending task assigned to that member **including subtasks** \u2014 even when the\nsubtask's parent task belongs to someone else. Decomposed work usually lives in\nsubtasks, so do NOT answer this kind of question from `tasks list` alone:\nthat is org-wide, page-capped, and not scoped to a person, so a person's\nsubtasks are easily missed.\n\nResolve a person's `prll://usr_` id from the message context, the members\nlist, or ref search; your own id comes from `parall whoami`.\n\n## Task Commands\n\n```bash\n# List tasks (org-wide; filter by status, assignee, or parent)\nparall tasks list\nparall tasks list --status todo\nparall tasks list --status in_progress\nparall tasks list --assignee-id prll://usr_xxx # first page only (default 20) \u2014 for a person's FULL backlog use 'tasks assigned' above\nparall tasks subtasks prll://tsk_xxx # children of a single parent task\n\n# Create a task (add --parent-id to make it a SUBTASK of another task)\nparall tasks create --title \"Task title\" [--assignee-id prll://usr_xxx] [--parent-id prll://tsk_xxx] [--project-id prll://prj_xxx] [--due-date 2026-08-01]\n\n# Update task status \u2014 add --placement end so the task lands at the end of\n# its NEW status column (a bare --status keeps the old column's sort_order)\nparall tasks update prll://tsk_xxx --status in_progress --placement end\nparall tasks update prll://tsk_xxx --status done --placement end\n\n# Due date \u2014 a plain YYYY-MM-DD date (no timestamps); \"none\" clears it\nparall tasks update prll://tsk_xxx --due-date 2026-08-01\nparall tasks update prll://tsk_xxx --due-date none\n\n# Move a task to the end of its status column\nparall tasks update prll://tsk_xxx --placement end\n\n# Add a comment\nparall tasks comments add prll://tsk_xxx --body \"Progress update...\"\n```\n\nOrdering: to append a task to the end of a status column, always use\n`--placement end` \u2014 the server resolves the position atomically. This\nincludes status changes: a bare `--status` keeps the task's old\n`sort_order`, which may collide inside the new column. Do NOT compute a\n`sort_order` value yourself from listed tasks (your view may be stale or\npartial). `--sort-order` is only for pinpoint insertion between two cards\nyou just listed, and it cannot be combined with `--placement`.\n\nSubtasks are just tasks with a parent: create one with `tasks create --parent-id`,\nre-parent with `tasks update --parent-id`, list a parent's children with\n`tasks subtasks`. `tasks list` without `--parent-id` already returns both\ntop-level tasks and subtasks; per-person open work is best fetched with\n`tasks assigned` (above).\n\n## Project Commands\n\n```bash\nparall projects list\n```\n\n## Watching Tasks\n\nWatchers receive dispatch events for a task's new comments. Acting on a task\nauto-subscribes you \u2014 creating it, being assigned, commenting, being\n@mentioned, or substantively editing it (description / assignee). Handle or\ndismiss those comment events deliberately.\n\n```bash\nparall tasks watch prll://tsk_xxx # follow a task without acting on it\nparall tasks unwatch prll://tsk_xxx # opt out of a task's comment events\nparall tasks watchers prll://tsk_xxx # list who is watching\n```\n\nCreators and assignees are locked subscribers \u2014 `unwatch` returns 409 for\nthem until the role changes (e.g. reassignment); it works for every other\nwatcher.\n\n## Responding to Task Assignments\n\nWhen you receive `[Event: task.assigned]`:\n\n1. Acknowledge with a comment: `tasks comments add prll://tsk_xxx --body \"On it\"`\n2. Update status: `tasks update prll://tsk_xxx --status in_progress --placement end`\n3. Do the work\n4. Report results in a comment. If a gate remains \u2014 review, merge, deploy,\n requester acceptance \u2014 set `in_review` and name the gate; set `done`\n only once the work has actually landed\n\n## Responding to Task Comments\n\nWhen you receive `[Event: task.comment.created]`, someone commented on a task you are watching. Read the comment body and respond if action is needed:\n\n1. Review the comment content and task context\n2. Reply via comment: `tasks comments add prll://tsk_xxx --body \"Response...\"`\n3. If the comment requests status changes, update accordingly\n\nCLI success output is JSON; errors print a JSON line plus, on a `PERMISSION_DENIED`, an optional plain-text `Request approval:` line \u2014 read both.\n";
2
2
  //# sourceMappingURL=parall-tasks.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"parall-tasks.d.ts","sourceRoot":"","sources":["../../src/skills/parall-tasks.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB,2/HA+F9B,CAAC"}
1
+ {"version":3,"file":"parall-tasks.d.ts","sourceRoot":"","sources":["../../src/skills/parall-tasks.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB,k+JA+G9B,CAAC"}
@@ -34,16 +34,32 @@ parall tasks list --assignee-id prll://usr_xxx # first page only (default 20)
34
34
  parall tasks subtasks prll://tsk_xxx # children of a single parent task
35
35
 
36
36
  # Create a task (add --parent-id to make it a SUBTASK of another task)
37
- parall tasks create --title "Task title" [--assignee-id prll://usr_xxx] [--parent-id prll://tsk_xxx] [--project-id prll://prj_xxx]
37
+ parall tasks create --title "Task title" [--assignee-id prll://usr_xxx] [--parent-id prll://tsk_xxx] [--project-id prll://prj_xxx] [--due-date 2026-08-01]
38
38
 
39
- # Update task status
40
- parall tasks update prll://tsk_xxx --status in_progress
41
- parall tasks update prll://tsk_xxx --status done
39
+ # Update task status — add --placement end so the task lands at the end of
40
+ # its NEW status column (a bare --status keeps the old column's sort_order)
41
+ parall tasks update prll://tsk_xxx --status in_progress --placement end
42
+ parall tasks update prll://tsk_xxx --status done --placement end
43
+
44
+ # Due date — a plain YYYY-MM-DD date (no timestamps); "none" clears it
45
+ parall tasks update prll://tsk_xxx --due-date 2026-08-01
46
+ parall tasks update prll://tsk_xxx --due-date none
47
+
48
+ # Move a task to the end of its status column
49
+ parall tasks update prll://tsk_xxx --placement end
42
50
 
43
51
  # Add a comment
44
52
  parall tasks comments add prll://tsk_xxx --body "Progress update..."
45
53
  \`\`\`
46
54
 
55
+ Ordering: to append a task to the end of a status column, always use
56
+ \`--placement end\` — the server resolves the position atomically. This
57
+ includes status changes: a bare \`--status\` keeps the task's old
58
+ \`sort_order\`, which may collide inside the new column. Do NOT compute a
59
+ \`sort_order\` value yourself from listed tasks (your view may be stale or
60
+ partial). \`--sort-order\` is only for pinpoint insertion between two cards
61
+ you just listed, and it cannot be combined with \`--placement\`.
62
+
47
63
  Subtasks are just tasks with a parent: create one with \`tasks create --parent-id\`,
48
64
  re-parent with \`tasks update --parent-id\`, list a parent's children with
49
65
  \`tasks subtasks\`. \`tasks list\` without \`--parent-id\` already returns both
@@ -78,7 +94,7 @@ watcher.
78
94
  When you receive \`[Event: task.assigned]\`:
79
95
 
80
96
  1. Acknowledge with a comment: \`tasks comments add prll://tsk_xxx --body "On it"\`
81
- 2. Update status: \`tasks update prll://tsk_xxx --status in_progress\`
97
+ 2. Update status: \`tasks update prll://tsk_xxx --status in_progress --placement end\`
82
98
  3. Do the work
83
99
  4. Report results in a comment. If a gate remains — review, merge, deploy,
84
100
  requester acceptance — set \`in_review\` and name the gate; set \`done\`