@sgtbeatdown/pi-tasks 0.1.14 → 0.1.16

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
@@ -140,16 +140,18 @@ try {
140
140
  });
141
141
 
142
142
  // a real application would consume the assignment and do the work here.
143
- for (const delivery of await worker.receive()) {
144
- await worker.acknowledgeRelayDelivery(delivery.cursor);
143
+ for (const item of await worker.receive()) {
144
+ if (item.kind === "relay") await worker.acknowledgeRelayDelivery(item.delivery.cursor);
145
145
  }
146
146
  await worker.submitIntent({
147
147
  taskId,
148
148
  type: "task.completed",
149
149
  payload: { summary: "the change adds task coordination" },
150
150
  });
151
- for (const delivery of await parent.receive()) {
152
- await parent.acknowledgeRelayDelivery(delivery.cursor);
151
+ // relay items carry peer mail; local items are the parent's own view of events it canonicalized.
152
+ for (const item of await parent.receive()) {
153
+ if (item.kind === "relay") await parent.acknowledgeRelayDelivery(item.delivery.cursor);
154
+ else parent.completeLocalPresentation(item.event.eventId);
153
155
  }
154
156
 
155
157
  console.log(parent.getTask(taskId)?.status); // completed
@@ -166,6 +168,10 @@ this demonstrates task coordination, not agent execution. both endpoints share o
166
168
 
167
169
  implement the exported [`TaskRelay` interface](src/task-protocol.ts) to connect endpoints, resolve targets, send envelopes, receive mail, and acknowledge individual deliveries. provide a separate `createTaskStore()` and `createTaskCore()` for each endpoint. `runTaskRelayConformance` is available to check a relay implementation.
168
170
 
171
+ `receive()` returns items in order: `relay` items wrap a delivery you acknowledge with `acknowledgeRelayDelivery`, and `local` items carry an event the origin itself canonicalized, which you complete with `completeLocalPresentation` once presented.
172
+
173
+ `createTaskCore({ timers })` accepts a `TaskTimers` clock for the delivery budget (deadline, retry slices, cooldowns), which lets tests run those bounds on virtual time; task records still use `clock`. an origin can move an active task off a target whose loss the relay reported with `reassignTask`; the [reference](docs/protocol-reference.md#memory-owned-lifecycle) covers its preconditions and why the default extension does not call it yet.
174
+
169
175
  your application owns polling, retries, timeout evaluation, delivery acknowledgments, and shutdown. it also owns how assignments reach an agent and how that agent runs them. the core alone does not install pi tools or wake a model.
170
176
 
171
177
  ## important limits
@@ -8,7 +8,7 @@ The [relay control API](https://github.com/almogdepaz/wolfpack/blob/main/docs/co
8
8
 
9
9
  ## endpoint-owned relay
10
10
 
11
- The default extension requires a compatible Wolfpack memory-owned server. It negotiates `volatile-v1` at `POST /api/task-relay/volatile-v1`. No transport opt-in flag is needed. The old durable relay adapter is removed, not retained as a compatibility mode.
11
+ The default extension requires a compatible Wolfpack memory-owned server. It negotiates `volatile-v1` at `POST /api/task-relay/volatile-v1`. No transport opt-in flag is needed. The old durable relay adapter is removed, not retained as a compatibility mode. The transport keeps at most 8 requests in flight, with 2 more slots reserved for ACK/connect; further requests wait for a slot in FIFO order instead of failing locally. A waiter aborted by its caller has sent nothing and fails with a retryable `ABORTED`; `RELAY_CAPACITY` comes only from the server.
12
12
 
13
13
  Set `WOLFPACK_SESSION_NAME` for each Pi process. `WOLFPACK_PORT` defaults to `18790`; `WOLFPACK_SESSION_NAME` resolves the active Pi process to its relay endpoint. Read `taskEndpoint` from structured session creation/status and pass its opaque `{ relay, id }` unchanged. Never derive it from a name, terminal output or broker ID.
14
14
 
@@ -28,22 +28,35 @@ The `agent_task_send` schema is exactly `to`, `task`, and optional `timeoutMs`.
28
28
 
29
29
  ## history versus live state
30
30
 
31
- - Pi requests task-processing turns through its safe `deliverAs: "followUp"` queue. Pi session messages and tool calls/results preserve historical context. Non-waking receipts, parent ACKs and late-terminal facts are archived as `pi-tasks-event-record` custom entries before transport ACK, without waking the model. Received model-visible task events include the complete structured event in `pi-tasks-event` details, including results—not just a rendered summary.
31
+ - Pi requests task-processing turns through its safe `deliverAs: "followUp"` queue. Pi session messages and tool calls/results preserve historical context. Parent ACKs, late-terminal facts and confirmed receiver-authored conversation echoes are archived as `pi-tasks-event-record` custom entries before transport ACK, without model insertion or wake. Received model-visible task events include the complete structured event in `pi-tasks-event` details, including results—not just a rendered summary.
32
32
  - Active tasks, outbox intentions, individual ACK checkpoints, deduplication and delivery-blocked evidence are RAM-owned. Each endpoint has lower-only limits of 16,384 records/32MiB encoded state. Admission fails before exceeding those bounds; they are not a process-RSS guarantee.
33
33
  - `agent_task_send` confirms relay acceptance, **not execution**. `receiver_recorded` confirms receiver RAM receipt, **not durable persistence**. Pi insertion and wake acceptance are separate evidence, never proof of model execution.
34
+ - Delivery receipts are origin-local status. The origin records each one in its task log without presenting, archiving or forwarding it to the worker. Stages are ordered `receiver_recorded` < `pi_inserted` < `wake_accepted`, and a later stage implies the earlier ones. Per event and refresh, a receiver sends only the highest stage it reached, plus `pi_insertion` `blocked` when Pi did not record the insertion, so an assignment received, inserted and woken in one refresh costs one receipt. The `wake_accepted` record is written before Pi emits the wake, which keeps it ahead of any intent the woken model sends; it is sent only after the wake appears in Pi's entries, and an unobserved or throwing wake withdraws the never-sent record. `wake_requested` remains in `TaskDeliveryStage` and is accepted from 0.1.15 receivers (status shows wake acceptance pending) but is no longer emitted.
34
35
  - Session history is not an operational journal. A new process/lifecycle starts empty with a fresh endpoint/generation; it does not reconstruct tasks, replay envelopes, reuse old ACKs or reopen a worker gate from old history.
35
36
  - A relay restart can lose even accepted mail. A Pi restart loses that endpoint's active task state. Relay-loss evidence marks affected work failed only in the discarded local lifetime; its remote outcome remains explicitly unknown and is never presented as canonical delivery or completion evidence.
36
37
 
37
38
  ## memory-owned lifecycle
38
39
 
39
- `createConfiguredTaskCore()` owns its transport and `createTaskStore()` RAM state. Startup registers; polling runs every five seconds and at agent settlement. Shutdown fences new work, aborts requests, drains active calls and discards state. Late callbacks cannot mutate a successor lifetime.
40
+ `createConfiguredTaskCore()` owns its transport and `createTaskStore()` RAM state. Startup registers; background polling is one `setTimeout` chain that re-arms after each refresh: every 1s while a task is active, the outbox is pending, or a gate-enabled worker awaits an eligible assignment, otherwise every 5s. Successful send/message/done/ack/cancel tools, `turn_end`, agent end and agent settlement request an immediate single-flight refresh; requests made during a running refresh coalesce into one trailing refresh. `agent_task_wait` requests one refresh, then re-checks local state when the RAM store commits a change or after a backoff of 250ms doubling to 2s; it issues no relay requests of its own. Shutdown fences new work, aborts requests, drains active calls and discards state. Late callbacks cannot mutate a successor lifetime.
40
41
 
41
- Background refresh records protocol facts in RAM even while Pi is busy or has queued messages. A valid completion within the fetched receive page can become canonical before its notification is presented. Model-visible insertion and the separate follow-up wake still wait for idle Pi with no queued messages; visible relay mail remains replayable until the required structured insertion/wake evidence is present. Intake alone does not incorporate an assignment or authorize worker tools. Parent acknowledgment before terminal presentation retains the event but suppresses its redundant wake.
42
+ Background refresh records protocol facts in RAM even while Pi is busy or has queued messages. A valid completion within the fetched receive page can become canonical before its notification is presented. Model-visible insertion and the separate follow-up wake still wait for idle Pi with no queued messages; visible relay mail remains replayable until the required structured insertion/wake evidence is present. Intake alone does not incorporate an assignment or authorize worker tools. Parent acknowledgment before terminal presentation retains the event but suppresses its redundant wake. Origin-authored conversations do not echo to the origin. The origin's own view of other canonical events it creates, such as a completion canonicalized from a worker's raw intent, is queued in a RAM-only local presentation table instead of a self-addressed relay envelope. `receive()` merges those local items with relay mail by intake order. A held model-visible item blocks later visible items; non-visible items behind it are still archived and ACKed. A local item needs the same insertion and wake evidence before it completes, but records no relay delivery receipts. Receiver-authored conversation copies remain canonical replicas, but local outbound intent identity makes their presentation archive-only; payload author claims are not provenance. Missing or unmatched intent identity leaves peer conversations actionable. This correlation is RAM-only and is never recovered from session history.
42
43
 
43
- This separation does not change polling or the 100-envelope receive-page bound: a full prefix of held visible mail can still prevent intake of a newer intent beyond that page. General backlog scanning is not provided.
44
+ The adaptive 1s/5s polling cadence does not change relay byte limits. A single-flight background refresh runs explicit phases in one work scope: lease connection, then intake (timeout canonicalization and relay receive persist and canonicalize without sending), presentation (Pi insertion/wake and receipt records, still without sending), shipping (required timeout fan-out, intake fan-out and receipts in persist order), acknowledgement (ACKs for presented items, then raw ACK repair) and finally old-work retry. The shared counts are: at most 56 scoped sends plus 8 rotating old-work sends, 48 ACK attempts and receive pages of up to 50 items while fewer than 200 relay deliveries are fetched but unacknowledged, with a 2s overall deadline. Failed attempts consume the shared counts; the same send/ACK identity is not attempted twice in that scope. Separate 250ms retry/ACK-repair slices stop admitting another attempt, not an already admitted request: each admitted request gets the remaining background window. These are scheduling bounds, not throughput guarantees. Foreground send/message/done retain the configured transport request deadline (default 12s) and caller cancellation instead of inheriting the background cutoff. Any speculative old-work retry after a foreground send uses a finite 2s background window, starting only when that share is reached.
45
+
46
+ Intake first re-drives tracked, unacknowledged relay deliveries from the local RAM inbox in cursor order, then fetches after the highest tracked cursor and follows `hasMore` while the pending set has room. At 200 fetched-but-unacknowledged deliveries fetching pauses until ACKs drain it; nothing is skipped or dropped. Each delivery is tracked and persisted on its own before fan-out, so an abort mid-page loses nothing and the next fetch resumes after the last tracked cursor. Byte limits can make a page shorter than 50 items, and shared send/ACK counts can defer work. When the ship phase accepts a raw intent's required canonical fan-out, the intent stages an authorized ACK request in the existing checkpoint; the acknowledge phase's repair pass sends it, and a staged delivery is not re-driven. Failed or pending required fan-out withholds only that intent's ACK and no longer stops intake of later deliveries. A presented relay item is ACKed only after the ship phase accepted its receipts or found them blocked for good (reported as outbox degradation); a failed receipt withholds only that item's ACK, and presentation, shipping and ACKs of later items continue. Receipts to one target still leave in creation order, so a failed receipt also defers younger receipts to the same origin. Because receipts ship before ACKs, an ACK never precedes the receipt that justifies it. The repair pass uses the same ACK budget, admission slice and overall background deadline; remaining raw ACK work reports local deferral on budget/slice/cooldown exhaustion. Presentation stops at the remaining ACK budget. The first fan-out, receipt, presentation, repair or deferral failure is collected and reported after the pass, so it no longer holds back items intake already produced. Core operations in a phased pass never start old-work retry or ACK repair on their own; only the final phase retries old work, after required sends and ACKs, even on empty, held/busy or failed passes when budget remains. Ignored-abort ACK responses cannot later advance local authority. A held visible event stops presentation of later visible events but not archive and ACK of later non-visible events; an ACK budget exhaustion stops the pass. An intent whose required fan-out is quarantined is never ACKed and keeps one pending slot. General backlog scanning beyond the pending bound is not provided.
47
+
48
+ Scoped operations attempt their own immutable IDs and cannot report acceptance for omitted or partial fan-out. Concurrent same-ID sends join existing delivery rather than retransmitting. Pending retry IDs rotate incrementally in RAM on attempts/actual skips, including failures and cooldown skips, without copying payloads or rotating an unattempted suffix past the retry deadline. A foreground packet's capacity refusal does not suppress smaller old packets to the same target; same-cycle target suppression starts only when speculative retry itself encounters capacity. Mail to one target leaves in creation order: an envelope is sent only after every older pending envelope to the same target is accepted, and a younger send waits for an older send already in flight. While the oldest envelope is cooling down or out of budget, younger mail to that target reports `TASK_DELIVERY_DEFERRED` (with `blockedBy`); a quarantined envelope leaves the queue and blocks nothing, and other targets are unaffected. Retry selection offers only each target's oldest pending envelope, and a reported send first offers older mail to its target from the bounded old-work share. Known capacity defers that envelope/ACK for at least 5s (or the validated longer `retryAfterMs`), without a retry timer. `TASK_DELIVERY_DEFERRED` is a retryable local budget/cooldown outcome, not relay acceptance or permanent quarantine. Raw intent replays retain their canonical fan-out reservation and are not ACKed while required sends remain pending; quarantined required sends report their blocked outcome instead of false acceptance. The exported `TaskStore` contract requires indexed lookup, fair rotation, cooldown, per-target FIFO selection (`pendingOutboxAhead`), withdrawal of a never-sent reservation (`discardUnsentOperation`), the RAM local presentation queue, post-commit `onChange` notification, the indexed `getQuarantine(envelopeId)` and `hasActiveTasks()` lookups and `setTarget(taskId, target)`, supplied by `createTaskStore()`; custom `TaskStore`/`TaskCore` implementations written against 0.1.15 must add these members (`TaskCore` also gains `hasPendingWork`, `onChange`, `completeLocalPresentation` and the phased-pass members `intakeInbox`, `shipRequired` and `repairAcknowledgements`, and its `WakeRequest` gains `discard()`; `createTaskWorkScope(signal, "phased")` makes a scope whose evidence and fan-out sends are queued for `shipRequired`, and `deliverTaskInbox` requires one); there is no legacy snapshot/fixed-prefix fallback. `TaskCore.receive()` returns an ordered array of `{ kind: "relay", delivery, order }` and `{ kind: "local", event, order }` items; a consumer ACKs relay items and completes local items with `completeLocalPresentation(eventId)`. The documented transport seam is unchanged, and origin-targeted canonical envelopes from older peers are still accepted.
49
+
50
+ `createTaskStore()` keeps each row as immutable encoded text and caches its parsed value on first read; events are indexed by task and active tasks are tracked as a set, and both indexes follow transaction rollback and `clear()`. Stored values are deep-frozen and shared between reads: outbox and quarantine records, task events, payloads and endpoints throw a `TypeError` on a strict-mode write. Task snapshots and pending presentations are fresh top-level objects around those frozen values, so a write to their own fields is lost. Neither reaches the store. The store's own read-modify-write of the ACK checkpoint works on a copy. `createTaskCore({ timers })` takes an optional `TaskTimers` (`now`, `elapsed`, `timeout`) for the delivery budget: the 2s deadline, the retry/ACK-repair slices and capacity cooldowns. It defaults to system time and is separate from `clock`, which stamps task records; tests use it to run deadline and cooldown cases on virtual time.
44
51
 
45
52
  The default extension automatically replaces a lifetime only after a structured `RELAY_RESET` or `RELAY_REBIND_REQUIRED`. It fences and discards the unusable RAM state, records each active task in session history as locally failed with `RELAY_STATE_LOST` and an explicitly unknown remote outcome, then binds a fresh empty endpoint. The loss record does not wake the model by itself. Transient availability, authentication, capacity, abort, profile and malformed-response failures never trigger replacement. Historical tasks are not adopted into the successor lifetime.
46
53
 
54
+ An origin can move an active task off a lost target with the optional `TaskCore.reassignTask(taskId)`, which `createTaskCore()` provides. It requires a quarantined send to the current target whose relay refusal was `TARGET_NOT_REGISTERED`; otherwise it fails with non-retryable `REASSIGNMENT_REFUSED` (`NOT_ORIGIN` for a non-origin caller). It resolves the lost endpoint's reference through the relay and refuses when it resolves to itself; the loss and the target are re-checked inside the commit, so one loss allows one reassignment even when calls race. It then sets the task's target, appends a `task.reassigned` event (payload `{ task, previousTarget }`), presents it locally at the origin and sends the successor an assignment envelope carrying `{ task, event, history }`. History that would exceed `MAX_RELAY_PAYLOAD_BYTES` keeps the newest events that fit and sets `historyTruncated: true`; a task too large even without history fails with `PAYLOAD_TOO_LARGE`. The receiver validates the carried history against the task and records it like an original assignment: it sends a `receiver_recorded` receipt, presents a `## task assignment (reassigned)` message and opens the worker gate for that task. A known task follows the reassignment, and carried events it already holds are skipped. A 0.1.15 receiver rejects `task.reassigned` as `INVALID_ASSIGNMENT`.
55
+
56
+ The default extension does not call `reassignTask` yet. A current Wolfpack server issues a fresh random endpoint id on every rebind and answers `resolve` for a retired id with `TARGET_NOT_REGISTERED`, so the origin can detect the loss but cannot discover the successor; the client also rejects a `resolve` result whose id differs from the reference. Automatic reassignment needs the server to resolve a retired id (or a stable session-scoped reference that survives restart) to its live successor, and then a client change to accept the differing id.
57
+
58
+ Error codes have one internal registry (`src/task-errors.ts`) that gives each code a class, a default message and explicit retry, rebind, relay-loss and blocked-delivery flags; a per-throw `retryable` still wins. Every failure of a `createVolatileTaskSession` relay request carries the relay operation in `details.operation`, and failures built from relay responses use the registry message for their code (server-supplied text was never shown); codes, `retryable` and other details are unchanged, and nothing new goes on the wire. The status line names a failed relay operation as `tasks: <class> (<CODE>) on <operation>`, for example `tasks: capacity (RELAY_CAPACITY) on send`; an unregistered code has class `error`. Relay loss, a missing compatible profile and required authentication keep their dedicated labels, and failures without a named operation keep `tasks: outbox degraded` or `tasks: relay unavailable`. Event handling (model-visible, terminal and its status, conversational, origin-local evidence, accepted as a receiver intent) comes from one internal `TASK_EVENT_TYPES` table in `src/task-protocol.ts`; unknown event types are still rejected.
59
+
47
60
  `createVolatileTaskSession({ url, callerSession, store })` is the lower-level API for a caller-owned RAM store. Transport reconnect within that same lifetime retains pending immutable retries; `rebind()` clears the store, while `close()` stops transport and the caller then closes its state. Persistence/path options are rejected. Old database files are neither read nor deleted.
48
61
 
49
62
  Full immutable content, including the original creation timestamp, is reused for retries. Destination-confirmed acceptance is required. Conflicting content, exhausted delivery and reset cannot become successful acceptance. Individual ACKs preserve sparse bigint cursors and cannot skip earlier pending mail. Bounds/deadlines apply through complete response bodies, including noncooperative injected transports.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sgtbeatdown/pi-tasks",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Memory-only endpoint-owned Pi agent delegation through Wolfpack relay v2.",
5
5
  "module": "src/index.ts",
6
6
  "exports": {
@@ -1,16 +1,25 @@
1
- export async function abortableSleep(milliseconds: number, signal: AbortSignal | undefined, cancellationError: () => Error): Promise<void> {
1
+ /** Sleep until the timeout, an optional early wake, or abort; every exit path releases the timer and both subscriptions. */
2
+ export async function abortableSleep(milliseconds: number, signal: AbortSignal | undefined, cancellationError: () => Error, subscribeWake?: (wake: () => void) => () => void): Promise<void> {
2
3
  if (signal?.aborted) throw cancellationError();
3
4
  await new Promise<void>((resolve, reject) => {
4
- const removeAbortListener = (): void => signal?.removeEventListener("abort", abort);
5
+ let unsubscribeWake: (() => void) | undefined;
6
+ const release = (): void => {
7
+ clearTimeout(timeout);
8
+ signal?.removeEventListener("abort", abort);
9
+ unsubscribeWake?.();
10
+ };
5
11
  const timeout = setTimeout(() => {
6
- removeAbortListener();
12
+ release();
7
13
  resolve();
8
14
  }, milliseconds);
9
15
  const abort = (): void => {
10
- clearTimeout(timeout);
11
- removeAbortListener();
16
+ release();
12
17
  reject(cancellationError());
13
18
  };
14
19
  signal?.addEventListener("abort", abort, { once: true });
20
+ unsubscribeWake = subscribeWake?.(() => {
21
+ release();
22
+ resolve();
23
+ });
15
24
  });
16
25
  }
@@ -50,10 +50,14 @@ export async function createConfiguredTaskCore(options: ConfiguredTaskCoreOption
50
50
  const result: unknown = value.apply(core, args);
51
51
  if (key === "recordWakeRequest") {
52
52
  const request = result as WakeRequest;
53
- return { flush: (signal?: AbortSignal): Promise<void> => {
54
- if (closed) throw new TaskProtocolError("RELAY_CLOSED", "task session is closed", { retryable: false });
55
- return track(request.flush(signal));
56
- } };
53
+ return {
54
+ flush: (signal?: AbortSignal): Promise<void> => {
55
+ if (closed) throw new TaskProtocolError("RELAY_CLOSED", "task session is closed", { retryable: false });
56
+ return track(request.flush(signal));
57
+ },
58
+ // A closed session drops its store; there is nothing left to withdraw.
59
+ discard: () => { if (!closed) request.discard(); },
60
+ };
57
61
  }
58
62
  return result instanceof Promise ? track(result) : result;
59
63
  } });
package/src/extension.ts CHANGED
@@ -4,8 +4,10 @@ import { Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
 
6
6
  import { abortableSleep } from "./abortable-sleep";
7
- import { deliverTaskInbox, incorporatedTaskEvents } from "./task-inbox";
8
- import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskOutboxDeliveryError, TaskProtocolError, isBlockedOutboxDeliveryCode } from "./task-protocol";
7
+ import { createTaskWorkScope } from "./task-work-scope";
8
+ import { deliverTaskInbox, taskEntryEvidence, type TaskEntryEvidence } from "./task-inbox";
9
+ import { RELAY_LOSS_CODES, taskErrorClass } from "./task-errors";
10
+ import { TASK_EVENT_TYPES, TaskDeliveryEvidenceState, TaskDeliveryStage, TaskOutboxDeliveryError, TaskProtocolError, isBlockedOutboxDeliveryCode } from "./task-protocol";
9
11
  import { createConfiguredTaskCore } from "./configured-task-core";
10
12
  import type { OwnedTaskCore } from "./configured-task-core";
11
13
  import type { SubmitIntentInput, SubmitIntentOutcome, TaskCore } from "./task-core";
@@ -14,12 +16,17 @@ import type { TaskEndpoint, TaskSnapshot } from "./task-protocol";
14
16
  const DEFAULT_TASK_TIMEOUT_MS = 30 * 60 * 1_000;
15
17
  const MIN_TASK_TIMEOUT_MS = 1_000;
16
18
  const MAX_TASK_TIMEOUT_MS = 86_400_000;
17
- const BACKGROUND_POLL_MS = 5_000;
19
+ /** Background cadence while work is pending (active task, pending outbox, or a worker awaiting assignment). */
20
+ export const ACTIVE_POLL_MS = 1_000;
21
+ /** Background cadence otherwise. */
22
+ export const IDLE_POLL_MS = 5_000;
18
23
  const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
24
+ /** Events that assign a task to their target: the original assignment and a reassignment to a lost target's successor. */
25
+ const ASSIGNMENT_EVENTS: ReadonlySet<string> = new Set(["task.created", "task.reassigned"]);
19
26
  const RELAY_STATE_LOST_CODE = "RELAY_STATE_LOST";
20
27
  const RELAY_LOSS_CUSTOM_TYPE = "pi-tasks-relay-loss";
21
- const RELAY_LOSS_CODES = new Set(["RELAY_RESET", "RELAY_REBIND_REQUIRED"]);
22
- const WAIT_POLL_MS = 250;
28
+ const WAIT_BACKOFF_INITIAL_MS = 250;
29
+ const WAIT_BACKOFF_MAX_MS = 2_000;
23
30
  const SUMMARY_MAX_CHARS = 1_200;
24
31
  const PRE_ASSIGNMENT_TOOLS = new Set(["agent_task_inbox", "agent_task_status", "agent_task_wait"]);
25
32
  const COORDINATOR_ONLY_TASK_TOOLS = new Set(["agent_task_send", "agent_task_cancel", "agent_task_ack"]);
@@ -100,7 +107,7 @@ export function createConfiguredCoreLoader(factory: ConfiguredCoreFactory): Conf
100
107
  /** Registers endpoint-owned tools using the memory-owned Wolfpack transport by default. */
101
108
  export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefined = undefined, createCore: ConfiguredCoreFactory = defaultCoreFactory): void {
102
109
  let inboxContext: ExtensionContext | undefined;
103
- let backgroundTimer: ReturnType<typeof setInterval> | undefined;
110
+ let backgroundTimer: ReturnType<typeof setTimeout> | undefined;
104
111
  let lifecycleEpoch = 0;
105
112
  const closingTaskIds = new Set<string>();
106
113
  const workerGateEnabled = process.env.PI_TASK_WORKER === "1";
@@ -113,18 +120,21 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
113
120
  const epoch = lifecycleEpoch;
114
121
  const isCurrent = (): boolean => inboxContext === context && lifecycleEpoch === epoch;
115
122
  if (!context) return undefined;
123
+ // Phased: intake runs ahead of speculative backlog work and ACK repair.
124
+ const work = createTaskWorkScope(signal, "phased");
125
+ signal = work.signal;
116
126
  const activeCore = await configuredCore(signal);
117
127
  if (!isCurrent()) return undefined;
118
128
  let outboxError: unknown;
119
129
  try {
120
- await activeCore.flushOutbox(signal);
130
+ await activeCore.connect(signal, work);
121
131
  } catch (error) {
122
132
  if (isRelayLoss(error)) return error;
123
133
  outboxError = error;
124
134
  }
125
135
  if (!isCurrent()) return undefined;
126
136
  try {
127
- await activeCore.evaluateTimeouts(signal);
137
+ await activeCore.evaluateTimeouts(signal, work);
128
138
  } catch (error) {
129
139
  if (isRelayLoss(error)) return error;
130
140
  outboxError ??= error;
@@ -132,24 +142,48 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
132
142
  if (!isCurrent()) return undefined;
133
143
  const guardedCore: TaskCore = {
134
144
  ...activeCore,
135
- async receive(receiveSignal) {
145
+ async receive(receiveSignal, receiveWork) {
136
146
  if (!isCurrent()) return [];
137
- const deliveries = await activeCore.receive(receiveSignal);
147
+ const deliveries = await activeCore.receive(receiveSignal, receiveWork);
138
148
  return isCurrent() ? deliveries : [];
139
149
  },
140
- async acknowledgeRelayDelivery(cursor, acknowledgementSignal) {
141
- if (isCurrent()) await activeCore.acknowledgeRelayDelivery(cursor, acknowledgementSignal);
150
+ async intakeInbox(intakeSignal, intakeWork) {
151
+ if (!isCurrent()) return [];
152
+ const items = await activeCore.intakeInbox(intakeSignal, intakeWork);
153
+ return isCurrent() ? items : [];
154
+ },
155
+ async shipRequired(shipSignal, shipWork) {
156
+ if (isCurrent()) await activeCore.shipRequired(shipSignal, shipWork);
157
+ },
158
+ async repairAcknowledgements(repairSignal, repairWork) {
159
+ if (isCurrent()) await activeCore.repairAcknowledgements(repairSignal, repairWork);
160
+ },
161
+ async acknowledgeRelayDelivery(cursor, acknowledgementSignal, acknowledgementWork) {
162
+ if (isCurrent()) await activeCore.acknowledgeRelayDelivery(cursor, acknowledgementSignal, acknowledgementWork);
163
+ },
164
+ completeLocalPresentation(eventId) {
165
+ if (isCurrent()) activeCore.completeLocalPresentation(eventId);
142
166
  },
143
- async recordInsertion(input, insertionSignal) {
144
- if (isCurrent()) await activeCore.recordInsertion(input, insertionSignal);
167
+ async recordInsertion(input, insertionSignal, insertionWork) {
168
+ if (isCurrent()) await activeCore.recordInsertion(input, insertionSignal, insertionWork);
145
169
  },
146
- recordWakeRequest(input) {
170
+ async submitIntent(input, intentSignal, intentWork) {
171
+ if (isCurrent()) await activeCore.submitIntent(input, intentSignal, intentWork);
172
+ },
173
+ async flushOutbox(flushSignal, flushWork) {
174
+ if (isCurrent()) await activeCore.flushOutbox(flushSignal, flushWork);
175
+ },
176
+ recordWakeRequest(input, wakeWork) {
147
177
  if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
148
- const request = activeCore.recordWakeRequest(input);
149
- return { flush: async (flushSignal?: AbortSignal): Promise<void> => {
150
- if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
151
- await request.flush(flushSignal);
152
- } };
178
+ const request = activeCore.recordWakeRequest(input, wakeWork);
179
+ return {
180
+ flush: async (flushSignal?: AbortSignal): Promise<void> => {
181
+ if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
182
+ await request.flush(flushSignal);
183
+ },
184
+ // A replaced lifecycle discards its own state; there is nothing left to withdraw.
185
+ discard() { if (isCurrent()) request.discard(); },
186
+ };
153
187
  },
154
188
  };
155
189
  const inboxOutboxError = await deliverTaskInbox({
@@ -159,16 +193,60 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
159
193
  isIdle: (): boolean => isCurrent() && context.isIdle(),
160
194
  hasPendingMessages: (): boolean => !isCurrent() || context.hasPendingMessages(),
161
195
  sessionManager: context.sessionManager,
162
- }, signal);
196
+ }, signal, work);
163
197
  return outboxError ?? inboxOutboxError;
164
198
  });
199
+ const cancelBackgroundRefresh = (): void => {
200
+ if (backgroundTimer) clearTimeout(backgroundTimer);
201
+ backgroundTimer = undefined;
202
+ };
203
+ /** Keep exactly one pending background timer; each lifecycle refresh re-arms it with the next cadence. */
204
+ const scheduleBackgroundRefresh = (epoch: number, delayMs: number): void => {
205
+ cancelBackgroundRefresh();
206
+ backgroundTimer = setTimeout(() => {
207
+ backgroundTimer = undefined;
208
+ if (inboxContext && lifecycleEpoch === epoch) void requestRefresh();
209
+ }, delayMs);
210
+ };
211
+ // Active cadence while a task is active, the outbox is pending, or a gate-enabled worker lacks an eligible assignment.
212
+ // An eligible assignment implies an active task, so the worker clause together with the first reduces to `workerGateEnabled`.
213
+ const nextPollDelay = (activeCore: TaskCore | undefined): number => activeCore !== undefined && (workerGateEnabled || activeCore.hasPendingWork()) ? ACTIVE_POLL_MS : IDLE_POLL_MS;
165
214
  const startBackgroundRefresh = (context: ExtensionContext, epoch: number): void => {
166
215
  inboxContext = context;
167
- if (backgroundTimer) clearInterval(backgroundTimer);
168
- backgroundTimer = setInterval(() => {
169
- const currentContext = inboxContext;
170
- if (currentContext) void refreshLifecycle(currentContext, epoch);
171
- }, BACKGROUND_POLL_MS);
216
+ scheduleBackgroundRefresh(epoch, IDLE_POLL_MS);
217
+ };
218
+ let lifecycleRefresh: Promise<void> | undefined;
219
+ let trailingRefresh = false;
220
+ /** Immediate single-flight lifecycle refresh; requests made while one runs coalesce into one trailing run. */
221
+ const requestRefresh = (): Promise<void> => {
222
+ if (lifecycleRefresh) {
223
+ trailingRefresh = true;
224
+ return lifecycleRefresh;
225
+ }
226
+ const current = (async (): Promise<void> => {
227
+ do {
228
+ trailingRefresh = false;
229
+ const context = inboxContext;
230
+ if (!context) return;
231
+ try {
232
+ await refreshLifecycle(context, lifecycleEpoch);
233
+ } catch (error) {
234
+ // Callers fire and forget, so a failure escaping refreshLifecycle (e.g. a throwing status UI) must not reject.
235
+ console.error("pi-tasks: lifecycle refresh failed", error);
236
+ if (inboxContext === context) {
237
+ try {
238
+ context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", `tasks: refresh failed: ${error instanceof Error ? error.message : String(error)}`));
239
+ } catch {
240
+ // Status is best effort; the failure is already logged above.
241
+ }
242
+ }
243
+ }
244
+ } while (trailingRefresh);
245
+ })().finally(() => {
246
+ if (lifecycleRefresh === current) lifecycleRefresh = undefined;
247
+ });
248
+ lifecycleRefresh = current;
249
+ return current;
172
250
  };
173
251
  const announceFreshBinding = (context: ExtensionContext): void => {
174
252
  if (pendingRelayLossCount === undefined) return;
@@ -184,8 +262,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
184
262
  const recoveryEpoch = ++lifecycleEpoch;
185
263
  const activeTasks = activeCore?.listTasks().filter((task) => task.status === "active") ?? [];
186
264
  inboxContext = undefined;
187
- if (backgroundTimer) clearInterval(backgroundTimer);
188
- backgroundTimer = undefined;
265
+ cancelBackgroundRefresh();
189
266
  await ownedLoader.close();
190
267
  if (recoveryEpoch !== lifecycleEpoch) return;
191
268
  for (const task of activeTasks) {
@@ -220,24 +297,30 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
220
297
  let activeCore: (TaskCore & Partial<Pick<OwnedTaskCore, "close">>) | undefined;
221
298
  try {
222
299
  activeCore = await configuredCore();
223
- let connectionError: unknown;
224
- try {
225
- await activeCore.connect();
226
- } catch (error) {
227
- connectionError = error;
228
- }
229
300
  if (!isCurrent()) return;
230
- if (isRelayLoss(connectionError) && await recoverRelayLoss(context, epoch, activeCore)) return;
231
301
  const outboxError = await refreshInbox();
232
302
  if (!isCurrent()) return;
233
303
  if (isRelayLoss(outboxError) && await recoverRelayLoss(context, epoch, activeCore)) return;
234
- const lifecycleError = outboxError ?? connectionError;
304
+ const lifecycleError = outboxError;
235
305
  const status = lifecycleError === undefined ? undefined : outboxFailureStatus(lifecycleError);
236
306
  context.ui.setStatus("pi-tasks", status === undefined ? undefined : context.ui.theme.fg("warning", status));
237
307
  if (lifecycleError === undefined) announceFreshBinding(context);
238
308
  } catch (error) {
239
309
  if (isCurrent() && isRelayLoss(error) && await recoverRelayLoss(context, epoch, activeCore)) return;
240
310
  if (isCurrent()) context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", outboxFailureStatus(error)));
311
+ } finally {
312
+ if (isCurrent()) {
313
+ // The chain must outlive a failed cadence evaluation (e.g. an unreadable task snapshot): err toward polling and surface the fault.
314
+ let delay = ACTIVE_POLL_MS;
315
+ let delayError: unknown;
316
+ try {
317
+ delay = nextPollDelay(activeCore);
318
+ } catch (error) {
319
+ delayError = error;
320
+ }
321
+ scheduleBackgroundRefresh(epoch, delay); // Armed before the status write, which may itself throw.
322
+ if (delayError !== undefined) context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", `tasks: local task state unreadable: ${delayError instanceof Error ? delayError.message : String(delayError)}`));
323
+ }
241
324
  }
242
325
  };
243
326
 
@@ -246,7 +329,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
246
329
  if (COORDINATOR_ONLY_TASK_TOOLS.has(event.toolName)) return { block: true, reason: WORKER_COORDINATION_FORBIDDEN_CODE };
247
330
  try {
248
331
  const activeCore = await configuredCore(context.signal);
249
- const assignments = assignedWorkerTasks(activeCore, context.sessionManager.getEntries());
332
+ const assignments = assignedWorkerTasks(activeCore, taskEntryEvidence(context.sessionManager));
250
333
  const taskId = inputTaskId(event.input);
251
334
  if (event.toolName === "agent_task_done") {
252
335
  const assigned = taskId === undefined ? undefined : assignments.find((task) => task.taskId === taskId);
@@ -273,25 +356,25 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
273
356
  await ownedLoader?.start();
274
357
  if (epoch !== lifecycleEpoch) return;
275
358
  startBackgroundRefresh(context, epoch);
276
- await refreshLifecycle(context, epoch);
359
+ await requestRefresh();
277
360
  });
278
361
  pi.on("agent_end", async (_event, context) => {
279
362
  if (!inboxContext) return;
280
- const epoch = lifecycleEpoch;
281
363
  inboxContext = context;
282
- await refreshLifecycle(context, epoch);
364
+ await requestRefresh();
283
365
  });
284
366
  pi.on("agent_settled", async (_event, context) => {
285
367
  if (!inboxContext) return;
286
- const epoch = lifecycleEpoch;
287
368
  inboxContext = context;
288
- await refreshLifecycle(context, epoch);
369
+ await requestRefresh();
370
+ });
371
+ pi.on("turn_end", async () => {
372
+ void requestRefresh();
289
373
  });
290
374
  pi.on("session_shutdown", async () => {
291
375
  closingTaskIds.clear();
292
376
  lifecycleEpoch += 1;
293
- if (backgroundTimer) clearInterval(backgroundTimer);
294
- backgroundTimer = undefined;
377
+ cancelBackgroundRefresh();
295
378
  inboxContext = undefined;
296
379
  pendingRelayLossCount = undefined;
297
380
  await ownedLoader?.close();
@@ -302,6 +385,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
302
385
  async execute(_id, params, signal) {
303
386
  try {
304
387
  const sent = await (await configuredCore(signal)).createTask({ target: params.to, task: params.task, timeoutMs: params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS }, signal);
388
+ void requestRefresh();
305
389
  return toolResult(sent, `## task accepted\n- task: \`${sent.taskId}\`\n- target: \`${params.to.relay}/${params.to.id}\`\n- delivery: relay acceptance only`);
306
390
  } catch (error) { return taskError(error); }
307
391
  },
@@ -324,14 +408,17 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
324
408
  async execute(_id, params, signal, onUpdate) {
325
409
  const deadline = Date.now() + (params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS);
326
410
  try {
327
- for (;;) {
328
- await refreshInbox(signal);
329
- const task = (await configuredCore(signal)).getTask(params.taskId);
411
+ // Fetching belongs to the background cadence; the wait re-checks local state on store changes or backoff.
412
+ void requestRefresh();
413
+ for (let backoff = WAIT_BACKOFF_INITIAL_MS; ; backoff = Math.min(backoff * 2, WAIT_BACKOFF_MAX_MS)) {
414
+ // Resolve the core each round so a relay-loss replacement is re-subscribed.
415
+ const activeCore = await configuredCore(signal);
416
+ const task = activeCore.getTask(params.taskId);
330
417
  if (!task) return taskError(new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${params.taskId}`, { retryable: false }));
331
418
  if (terminal(task.status)) return toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`);
332
419
  if (Date.now() >= deadline) return toolResult({ taskId: params.taskId, status: task.status }, `## task wait\n- task: \`${params.taskId}\`\n- status: ${task.status}\n- wait timed out`);
333
420
  onUpdate?.({ content: [{ type: "text", text: `waiting for ${params.taskId}...` }], details: {} });
334
- await abortableSleep(Math.max(1, Math.min(WAIT_POLL_MS, deadline - Date.now())), signal, () => new Error("task wait was cancelled"));
421
+ await abortableSleep(Math.max(1, Math.min(backoff, deadline - Date.now())), signal, () => new Error("task wait was cancelled"), (wake) => activeCore.onChange(wake));
335
422
  }
336
423
  } catch (error) { return taskError(error); }
337
424
  }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
@@ -350,7 +437,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
350
437
  });
351
438
  pi.registerTool({
352
439
  name: "agent_task_message", label: "Message Agent Task", description: "Record a message intent in this lifetime before relay submission.", parameters: MessageParams,
353
- async execute(_id, params, signal) { try { await (await configuredCore(signal)).submitIntent({ taskId: params.taskId, type: `task.${params.type}`, payload: { message: params.message } }, signal); return toolResult({ taskId: params.taskId }, `## task ${params.type}\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
440
+ async execute(_id, params, signal) { try { await (await configuredCore(signal)).submitIntent({ taskId: params.taskId, type: `task.${params.type}`, payload: { message: params.message } }, signal); void requestRefresh(); return toolResult({ taskId: params.taskId }, `## task ${params.type}\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
354
441
  renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
355
442
  });
356
443
  pi.registerTool({
@@ -360,6 +447,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
360
447
  const activeCore = await configuredCore(signal);
361
448
  try {
362
449
  await activeCore.submitIntent({ taskId: params.taskId, type: "task.cancelled", payload: {} }, signal);
450
+ void requestRefresh();
363
451
  return toolResult({ taskId: params.taskId }, `## task cancellation requested\n- task: \`${params.taskId}\``);
364
452
  } catch (error) {
365
453
  return blockedCancellationResult(activeCore, params.taskId, error) ?? taskError(error);
@@ -370,7 +458,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
370
458
  });
371
459
  pi.registerTool({
372
460
  name: "agent_task_ack", label: "Acknowledge Agent Task", description: "Record parent acknowledgement as an origin-owned logical event.", parameters: TaskIdParams,
373
- async execute(_id, params, signal) { try { await (await configuredCore(signal)).acknowledgeParent(params.taskId, signal); return toolResult({ taskId: params.taskId }, `## task acknowledged\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
461
+ async execute(_id, params, signal) { try { await (await configuredCore(signal)).acknowledgeParent(params.taskId, signal); void requestRefresh(); return toolResult({ taskId: params.taskId }, `## task acknowledged\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
374
462
  renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
375
463
  });
376
464
  pi.registerTool({
@@ -386,6 +474,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
386
474
  } catch (error) {
387
475
  return blockedDoneResult(activeCore, params.taskId, params.status, error) ?? taskError(error);
388
476
  }
477
+ void requestRefresh();
389
478
  const task = activeCore.getTask(params.taskId);
390
479
  if (!task) return taskError(new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${params.taskId}`, { retryable: false }));
391
480
  if (submission?.authority === "origin" && submission.canonicalEvent.type === "task.late_terminal") {
@@ -435,7 +524,8 @@ function taskDeliveryEvidence(task: TaskSnapshot): TaskDeliveryEvidence {
435
524
  let receiverReceipt: TaskDeliveryEvidence["receiverReceipt"] = "not_confirmed";
436
525
  let piInsertion: TaskDeliveryEvidence["piInsertion"] = "not_confirmed";
437
526
  let wakeAcceptance: TaskDeliveryEvidence["wakeAcceptance"] = "not_confirmed";
438
- const assignmentEventId = task.events.find((event) => event.type === "task.created")?.eventId;
527
+ // After a reassignment, evidence describes the latest assignment.
528
+ const assignmentEventId = task.events.findLast((event) => ASSIGNMENT_EVENTS.has(event.type))?.eventId;
439
529
  for (const event of task.events) {
440
530
  if (event.type !== "task.delivery_receipt" || event.payload.eventId !== assignmentEventId) continue;
441
531
  if (event.payload.stage === TaskDeliveryStage.receiverRecorded) receiverReceipt = "confirmed";
@@ -444,8 +534,13 @@ function taskDeliveryEvidence(task: TaskSnapshot): TaskDeliveryEvidence {
444
534
  receiverReceipt = "confirmed";
445
535
  piInsertion = "confirmed";
446
536
  }
537
+ // Legacy v0.1.15 receivers still send wake_requested; current receivers send only the highest stage.
447
538
  if (event.payload.stage === TaskDeliveryStage.wakeRequested) wakeAcceptance = "pending";
448
- if (event.payload.stage === TaskDeliveryStage.wakeAccepted) wakeAcceptance = "confirmed";
539
+ if (event.payload.stage === TaskDeliveryStage.wakeAccepted) {
540
+ receiverReceipt = "confirmed";
541
+ piInsertion = "confirmed";
542
+ wakeAcceptance = "confirmed";
543
+ }
449
544
  }
450
545
  return { receiverReceipt, piInsertion, wakeAcceptance, modelExecution: "not_evidenced" };
451
546
  }
@@ -505,11 +600,17 @@ function blockedDeliveryWarning(error: TaskOutboxDeliveryError, target: TaskEndp
505
600
  return { code: error.code, message: error.message, retryable: false, delivery: "blocked", target, details };
506
601
  }
507
602
 
508
- function outboxFailureStatus(error: unknown): string {
603
+ /**
604
+ * Status-line text for a failed refresh. Actionable relay-state labels come first; otherwise
605
+ * `tasks: <class> (<CODE>) on <operation>` when the failed relay operation is known.
606
+ */
607
+ export function outboxFailureStatus(error: unknown): string {
509
608
  if (error instanceof TaskProtocolError) {
510
609
  if (RELAY_LOSS_CODES.has(error.code)) return "tasks: relay state lost; rebinding automatically";
511
610
  if (error.code === "RELAY_PROFILE_REQUIRED") return "tasks: compatible memory-owned relay required";
512
611
  if (error.code === "RELAY_AUTH_REQUIRED") return "tasks: Wolfpack authentication required";
612
+ const operation = error.details?.operation;
613
+ if (typeof operation === "string") return `tasks: ${taskErrorClass(error.code)} (${error.code}) on ${operation}`;
513
614
  }
514
615
  return error instanceof TaskOutboxDeliveryError && isBlockedOutboxDeliveryCode(error.code) ? "tasks: outbox degraded" : "tasks: relay unavailable";
515
616
  }
@@ -519,26 +620,25 @@ function isRelayLoss(error: unknown): error is TaskProtocolError {
519
620
  }
520
621
 
521
622
  function terminal(status: string): boolean {
522
- return ["completed", "failed", "cancelled", "timed_out"].includes(status);
623
+ return Object.values(TASK_EVENT_TYPES).some(type => type.status === status);
523
624
  }
524
625
 
525
626
  function receiverAssignment(core: TaskCore, task: TaskSnapshot, context: Pick<ExtensionContext, "sessionManager">): string {
526
627
  if (task.status !== "active" || sameEndpoint(task.origin, core.endpoint) || !sameEndpoint(task.target, core.endpoint)) return "";
527
- const created = task.events.find((event) => event.type === "task.created");
528
- if (!created || !incorporatedTaskEvents(context.sessionManager.getEntries()).some((evidence) => evidence.taskId === task.taskId && evidence.eventId === created.eventId)) return "";
628
+ const incorporated = taskEntryEvidence(context.sessionManager).incorporated.get(task.taskId);
629
+ if (!task.events.some((event) => ASSIGNMENT_EVENTS.has(event.type) && sameEndpoint(event.target, core.endpoint) && incorporated?.has(event.eventId) === true)) return "";
529
630
  return `\n\n## task assignment\n${task.task}`;
530
631
  }
531
632
 
532
- function assignedWorkerTasks(core: TaskCore, entries: readonly unknown[]): readonly TaskSnapshot[] {
533
- const assignments = new Map<string, TaskSnapshot>();
534
- for (const evidence of incorporatedTaskEvents(entries)) {
535
- const task = core.getTask(evidence.taskId);
633
+ /** Session evidence is memoized; tasks are read fresh on every call so the gate follows current task state. */
634
+ function assignedWorkerTasks(core: TaskCore, evidence: TaskEntryEvidence): readonly TaskSnapshot[] {
635
+ const assignments: TaskSnapshot[] = [];
636
+ for (const [taskId, eventIds] of evidence.incorporated) {
637
+ const task = core.getTask(taskId);
536
638
  if (!task || !sameEndpoint(task.target, core.endpoint)) continue;
537
- const created = task.events.find((event) => event.eventId === evidence.eventId);
538
- if (!created || created.type !== "task.created" || !sameEndpoint(created.target, core.endpoint) || !sameEndpoint(created.source, task.origin)) continue;
539
- assignments.set(task.taskId, task);
639
+ if (task.events.some((event) => eventIds.has(event.eventId) && ASSIGNMENT_EVENTS.has(event.type) && sameEndpoint(event.target, core.endpoint) && sameEndpoint(event.source, task.origin))) assignments.push(task);
540
640
  }
541
- return [...assignments.values()];
641
+ return assignments;
542
642
  }
543
643
 
544
644
  function eligibleWorkerTask(task: TaskSnapshot, closingTaskIds: ReadonlySet<string>): boolean {
package/src/index.ts CHANGED
@@ -30,5 +30,6 @@ export type {
30
30
  TaskRecord,
31
31
  TaskRelay,
32
32
  } from "./task-protocol";
33
- export type { SubmitIntentOutcome, TaskCore, TaskCoreOptions } from "./task-core";
34
- export type { TaskStore, TaskStoreOptions } from "./task-store";
33
+ export type { SubmitIntentOutcome, TaskCore, TaskCoreOptions, TaskInboxItem } from "./task-core";
34
+ export type { PendingPresentation, PresentationRecord, TaskStore, TaskStoreOptions } from "./task-store";
35
+ export type { TaskTimers } from "./task-work-scope";