@sgtbeatdown/pi-tasks 0.1.2 → 0.1.4

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
@@ -1,21 +1,50 @@
1
1
  # pi-tasks
2
2
 
3
- `pi-tasks` is a Pi extension client for Wolfpack task transports. The retained v1 adapter uses Wolfpack's machine-global `~/.wolfpack/tasks` store; v2 keeps endpoint-owned Pi task state locally and uses Wolfpack as a content-blind relay. Neither adapter writes assignments to terminals. Read Wolfpack's [harness-neutral task adapter contract](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-adapter-contract.md) for canonical adapter behavior and its [task gateway guide](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-gateway.md) for routes and operations.
3
+ `pi-tasks` is a Pi extension client for Wolfpack task transports. The default v2 adapter keeps endpoint-owned Pi task state locally and uses Wolfpack as a content-blind relay; the retained v1 adapter uses Wolfpack's machine-global `~/.wolfpack/tasks` store. Neither adapter writes assignments to terminals. Wolfpack's [relay v2 control-api contract](https://github.com/almogdepaz/wolfpack/blob/main/docs/control-api-schema.md#pi-tasks-relay-v2-boundary) is canonical for the default transport, while its [task gateway guide](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-gateway.md) documents the explicit v1 compatibility transport.
4
4
 
5
5
  ## v2 endpoint-owned relay
6
6
 
7
7
  The default extension uses the configured local Wolfpack relay v2 adapter, never the in-memory conformance relay. It registers an opaque endpoint with `POST /api/task-relay/v2/connect`, stores endpoint-owned task state at the deterministic per-session path `~/.pi/tasks/v2/sessions/<sha256(WOLFPACK_SESSION_NAME)>/tasks.sqlite`, and exchanges opaque relay envelopes through Wolfpack. The adapter needs a Wolfpack release exposing the stable [relay v2 control-api contract](https://github.com/almogdepaz/wolfpack/blob/main/docs/control-api-schema.md#pi-tasks-relay-v2-boundary); a v1-only gateway is incompatible and does not silently fall back.
8
8
 
9
- Set `WOLFPACK_SESSION_NAME` for every Pi process. The adapter uses `WOLFPACK_PORT` when the local control port differs from `18790`; `WOLFPACK_SESSION_NAME` resolves the active Pi process to its relay endpoint. On startup, obtain a target's opaque `{ relay, id }` endpoint from its Wolfpack session-status projection after that target has registered, then send it through the endpoint-owned tool shape:
9
+ Set `WOLFPACK_SESSION_NAME` for every Pi process. The adapter uses `WOLFPACK_PORT` when the local control port differs from `18790`; `WOLFPACK_SESSION_NAME` resolves the active Pi process to its relay endpoint. After the target extension registers, run `wolfpack session status <session> --json` and read its `taskEndpoint`. Pass that opaque `{ relay, id }` value unchanged; do not derive it from a session name, broker ID, terminal label, output, or prose.
10
+
11
+ ### minimal valid v2 send envelope
10
12
 
11
13
  ```json
12
14
  {
13
- "to": { "relay": "wolfpack-pi-tasks-v2", "id": "target-opaque-endpoint-id" },
15
+ "to": {
16
+ "relay": "wolfpack-pi-tasks-v2",
17
+ "id": "target-opaque-endpoint-id"
18
+ },
14
19
  "task": "implement the narrow change and run focused tests"
15
20
  }
16
21
  ```
17
22
 
18
- Wolfpack's relay owns durable mailbox delivery and peer forwarding; Pi owns task lifecycle, logical event order, receipts, and its local SQLite state. Insertion receipts are idempotent by `{ taskId, eventId }`, so replay after Pi has structurally recorded an event cannot create an additional logical receipt. `createInMemoryTaskRelay` remains exported solely as a deterministic conformance fixture.
23
+ The default `agent_task_send` schema is exactly `to`, `task`, and optional `timeoutMs`. v1-only context, role, metadata, preflight, idempotency, and completion-prompt fields are not silently accepted or translated.
24
+
25
+ Wolfpack's relay owns durable mailbox delivery and peer forwarding; Pi owns task lifecycle, logical event order, receipts, and its local SQLite state. `agent_task_send` returns after relay acceptance only, not Pi insertion or model execution. The receiver inserts model-visible events as structured `pi-tasks-event` custom messages through Pi's safe `deliverAs: "followUp"` queue and records structured `{ taskId, eventId }` insertion evidence. Replay after Pi has structurally recorded an event cannot create an additional logical receipt. `createInMemoryTaskRelay` remains exported solely as a deterministic conformance fixture.
26
+
27
+ ### v2 delegation workflow
28
+
29
+ 1. create or select a role session. For a disposable worker, omit an initial model prompt: `wolfpack agent spawn <project> --name <task-role> --json`. Put all worker instructions in `agent_task_send.task` so the new Pi process can become idle before assignment.
30
+ 2. verify structured session readiness, wait for extension registration, and read `taskEndpoint` from `wolfpack session status <session> --json`.
31
+ 3. call `agent_task_send` with that endpoint and the complete task instructions. Keep working; use `agent_task_status` or `agent_task_inbox` for structured evidence, and call `agent_task_wait` only when the user explicitly asks to block.
32
+ 4. use `agent_task_message` for durable questions, answers, and information. The receiver calls `agent_task_done` as its final action; no completion prose follows.
33
+ 5. independently verify the result, call `agent_task_ack({ taskId })` once for that terminal task, then explicitly retain or close only the role sessions the parent spawned.
34
+
35
+ ### v2 worker-only execution gate
36
+
37
+ Set `PI_TASK_WORKER=1` only when launching a task-only worker. The exact value enables a fail-closed model `tool_call` gate; an absent value or any other value leaves ordinary interactive Pi behavior unchanged. Before assignment, only `agent_task_inbox`, `agent_task_status`, and `agent_task_wait` are allowed. Other current and future model tools are blocked with the stable reason `PI_TASK_WORKER_ASSIGNMENT_REQUIRED`. Explicit user `!`/`!!` shell commands are outside Pi's model `tool_call` event and are not intercepted.
38
+
39
+ The gate opens only when the current Pi session contains a structured `pi-tasks-event` entry for `task.created` and the same event belongs to a locally persisted active task targeted to the current endpoint. Rendered prompt text, terminal output, unknown events, foreign tasks, and stale terminal tasks do not authorize execution. On restart, durable session evidence reopens the gate only while the matching local task remains eligible.
40
+
41
+ Preflighting `agent_task_done` marks that task as closing before sibling calls are preflighted. Ordinary tools remain blocked for a closing, pending-terminal, accepted-terminal, or `delivery_blocked` task; an idempotent `agent_task_done` retry for that same assigned task remains allowed. Another independently active assignment can still authorize work.
42
+
43
+ ### v2 terminal delivery and acknowledgment
44
+
45
+ A receiver task snapshot reports terminal transport separately as `terminalDelivery`: `not_submitted`, `pending`, `accepted`, or `delivery_blocked`. The blocked variant includes the stable intent/envelope identities, origin endpoint, timestamp, and structured non-retryable relay error. Canonical task `status` remains origin-owned and is never changed to `delivery_blocked`. Retryable failures keep the same pending terminal intent and envelope; permanent failures remain inspectable and are not rebound to a successor endpoint without a separate authenticated Wolfpack contract.
46
+
47
+ `agent_task_ack` is valid only for a terminal origin-owned task. Sequential, concurrent, and restart retries reuse one durable `task.parent_acknowledged` event and the same destination envelope identities while still retrying any pending physical delivery.
19
48
 
20
49
  ## v1 gateway adapter (retained)
21
50
 
@@ -48,7 +77,7 @@ A target is always `{ machine, sessionId }`, where `sessionId` is the stable opa
48
77
  }
49
78
  ```
50
79
 
51
- ### minimal valid send envelope
80
+ ### v1 minimal valid send envelope
52
81
 
53
82
  ```json
54
83
  {
@@ -109,7 +138,7 @@ On start or resume it rebuilds incorporated IDs from the full durable session en
109
138
 
110
139
  ## parent workflow
111
140
 
112
- 1. create or select a Wolfpack Pi session with the canonical session-control workflow and retain its stable broker ID. Spawn a disposable worker without an initial model prompt so it reaches idle before assignment; put worker instructions in `agent_task_send.task`.
141
+ 1. create or select a Wolfpack Pi session with the canonical session-control workflow and retain its stable broker ID. Spawn a disposable worker without an initial model prompt using `wolfpack agent spawn <project> --name <task-role> --json` so it reaches idle before assignment; put worker instructions in `agent_task_send.task`.
113
142
  2. before remote dispatch, complete Wolfpack's [Live-peer readiness checklist](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-gateway.md#live-peer-readiness-checklist) and retain operator-recorded package/reload evidence; only then check the target's local requirements and send compact instructions with curated context and selected refs when they save receiver investigation.
114
143
  3. keep working; use `agent_task_status` or `agent_task_inbox` for structured follow-up, and `agent_task_message` for questions, answers, and decisions.
115
144
  4. verify cited files, diffs, tests, and paths-only artifact metadata independently before reporting success.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sgtbeatdown/pi-tasks",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Durable Pi agent delegation through the Wolfpack task gateway.",
5
5
  "module": "src/index.ts",
6
6
  "exports": {
@@ -1,53 +1,81 @@
1
1
  ---
2
2
  name: wolfpack-pi-task-delegation
3
- description: Use when opening, selecting, delegating to, checking, or cleaning up a Wolfpack Pi subagent through the gateway-backed agent task tools.
3
+ description: Use when opening, selecting, delegating to, checking, or cleaning up a Wolfpack Pi subagent through the endpoint-owned agent task tools.
4
4
  ---
5
5
 
6
6
  # wolfpack pi task delegation
7
7
 
8
- use `wolfpack-tailnet-control` for session control. this skill covers gateway task lifecycle, curated context, and parent verification. Pi is one conforming adapter. Wolfpack's [harness-neutral task adapter contract](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-adapter-contract.md) is the canonical adapter reference; its [task gateway guide](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-gateway.md) covers routes, trust, retention, and federation.
8
+ use `wolfpack-tailnet-control` for session control. this skill covers endpoint-owned v2 task lifecycle, structured handoffs, and parent verification. The package default is the v2 extension described by Wolfpack's [relay v2 control-api contract](https://github.com/almogdepaz/wolfpack/blob/main/docs/control-api-schema.md#pi-tasks-relay-v2-boundary).
9
9
 
10
- ## gateway requirements and addressing
10
+ ## v2 requirements and addressing
11
11
 
12
- - every participating Pi process needs this package and a reachable local Wolfpack v1 gateway.
13
- - `WOLFPACK_SESSION_NAME` is the caller selector; the local gateway resolves it to a stable broker ID. `WOLFPACK_PORT` selects the local port and defaults to `18790`.
14
- - address a target as `to: { machine, sessionId }`, retaining the stable broker `sessionId` from structured Wolfpack session control rather than a terminal label.
15
- - use `machine: "local"` for same-machine work. For remote work, use only the receiver's canonical HTTPS Tailnet origin, such as `https://worker.example.ts.net`; never a terminal name, arbitrary URL, path, query, fragment, credentials, or unexpected port.
16
- - v1 trusts local processes and trusted Tailnet machines. Pi calls only its local gateway; gateways use direct fetch federation. JWT federation is unsupported, so do not promise remote delivery where Wolfpack global JWT requires credentials.
12
+ - load this package's default extension in every participating Pi process and set `WOLFPACK_SESSION_NAME`. Set `WOLFPACK_PORT` only when the local Wolfpack control port differs from `18790`.
13
+ - address `agent_task_send` targets only as `to: { relay, id }`. For the default Wolfpack adapter, the relay is `wolfpack-pi-tasks-v2` and the ID is opaque.
14
+ - create a disposable worker without an initial model prompt: `wolfpack agent spawn <project> --name <task-role> --json`. Do not start a disposable worker with a blocking “wait for assignments” prompt. Put the complete instructions in `agent_task_send.task`.
15
+ - after the target extension registers, run structured session control (`wolfpack session status <session> --json`) and read its `taskEndpoint`. Do not derive endpoint IDs from session names, broker IDs, terminal labels, output, or prose.
16
+ - pass the returned endpoint without translation:
17
+
18
+ ```json
19
+ {
20
+ "to": { "relay": "wolfpack-pi-tasks-v2", "id": "target-opaque-endpoint-id" },
21
+ "task": "implement the narrow change and run focused tests",
22
+ "timeoutMs": 1800000
23
+ }
24
+ ```
25
+
26
+ The default `agent_task_send` schema is exactly `to`, `task`, and optional `timeoutMs`. v1-only fields such as context, role, metadata, preflight, idempotency keys, and completion prompts are not silently accepted or translated.
17
27
 
18
28
  ## phase roles and handoffs
19
29
 
20
- For a multi-step project phase, retain one persistent implementer and one persistent read-only reviewer. Reuse a healthy role session for corrections and follow-up review; a completed task is an assignment result, not a reason to replace its session. Do not rotate a healthy role session for routine corrections. Rotate only for explicit phase completion, material context saturation, harness failure, or a required independent specialist. Keep at most one active assignment per role unless the user explicitly asks for parallel work.
30
+ For a multi-step project phase, retain one persistent implementer and one persistent read-only reviewer. Reuse a healthy role session for corrections and follow-up review; a completed task finishes one assignment, not the underlying session. Do not rotate a healthy role session for routine corrections. Rotate only for phase completion, material context degradation, harness failure, or required specialist independence. Keep at most one active assignment per role unless the user explicitly approves parallel work.
31
+
32
+ After every verified and acknowledged terminal task, explicitly retain a parent-spawned session only when concrete follow-up is likely; otherwise close it through canonical Wolfpack session control. Workers never close their own sessions. terminal `send` is only for explicit human steering; it is not task state, completion evidence, or a substitute for `agent_task_message`.
33
+
34
+ ## delegation lifecycle
35
+
36
+ | event | owner | command | session outcome |
37
+ | --- | --- | --- | --- |
38
+ | assignment completion | worker | `agent_task_done` | unchanged |
39
+ | task acknowledgment | parent | `agent_task_ack` | unchanged |
40
+ | role session retention | parent | `none` | retained for reuse |
41
+ | role session teardown | parent | `wolfpack kill <session-or-id> --json` | exact stable session id terminated |
42
+ | teardown verification | parent | `wolfpack list --json` | exact stable session id absent |
43
+ | Pi exit | none | `/quit` | not Wolfpack teardown |
44
+
45
+ Assignment completion ends the assigned task, not the reusable role session. When no further reuse is likely, the parent—not the worker—must pass the exact stable session ID to `wolfpack kill <session-or-id> --json`, then inspect `wolfpack list --json` and verify that ID is absent. `/quit` exits Pi; it is not Wolfpack teardown. Workers never close themselves.
46
+
47
+ ## v2 workflow
48
+
49
+ 1. create or select the role session, verify its structured project and terminal readiness, then obtain its registered `taskEndpoint`.
50
+ 2. call `agent_task_send` with the opaque endpoint and concise instructions in `task`. The tool returns after relay acceptance only, not Pi insertion or model execution. If submission reports a retryable delivery error after local persistence, use the structured task ID from the error and inspect it rather than creating an unrelated replacement.
51
+ 3. keep working. Use `agent_task_status` or `agent_task_inbox` for structured evidence. Call `agent_task_wait` only when the user explicitly asks to block.
52
+ 4. use `agent_task_message` for durable `question`, `answer`, or `information` flow. Do not use rendered text, terminal output, or logs as lifecycle evidence.
53
+ 5. assignees call `agent_task_done` as their final action with the assigned task ID, terminal status, concise summary, and optional structured result, error, and artifact declarations. Report source modifications in `result.changedFiles`; artifacts are receiver-project-relative regular files for a parent to inspect, not changed-file lists: `{ "result": { "changedFiles": ["src/extension.ts"] }, "artifacts": [{ "path": "verification/task-2.md" }] }`. No prose completion afterward.
54
+ 6. the parent independently verifies files, diff, tests, and artifacts. Then call `agent_task_ack({ taskId })` for that one terminal task and explicitly retain or close the spawned role session.
55
+
56
+ ## v2 delivery and recovery
21
57
 
22
- Persistence is a parent decision, not a default. After every verified and acknowledged terminal task, the parent must explicitly choose whether to retain or close a session it spawned. Retain it only when the parent expects concrete follow-up work; close it when the parent does not expect to use it again. Do not leave the cleanup decision implicit.
58
+ Wolfpack v2 is a content-blind relay; each Pi endpoint owns task lifecycle, canonical event order, receipts, and SQLite state. Relay acceptance does not prove model execution. The receiver inserts model-visible events as structured `pi-tasks-event` custom messages with Pi's safe `deliverAs: "followUp"` queue, then records insertion through structured `{ taskId, eventId }` evidence. Restart recovery reads durable session entries and local task state; it never parses rendered prompts or terminal prose.
23
59
 
24
- Use gateway task tools for assignments, questions, decisions, completion, and acknowledgment. terminal `send` is only for explicit human steering; it is not task state, a completion channel, or a substitute for `agent_task_message`.
60
+ Receiver terminal submission has one task-wide logical identity. Same-status retries reuse it; a conflicting terminal status fails closed. Canonical status remains origin-owned, while `terminalDelivery` separately exposes `not_submitted`, `pending`, `accepted`, or `delivery_blocked`. Parent acknowledgment is terminal-only and retries reuse one logical event and stable envelope identities.
25
61
 
26
- ## workflow
62
+ Set `PI_TASK_WORKER=1` only for task-only workers. Before a valid structured assignment matches a locally owned active task, the worker gate allows only inbox/status/wait inspection and blocks other model tools with `PI_TASK_WORKER_ASSIGNMENT_REQUIRED`.
27
63
 
28
- 1. create or select an existing Pi session with canonical Wolfpack session control. Reuse the phase role session when it is healthy. For a disposable worker, omit `--prompt`: `wolfpack agent spawn <project> --name <task-role> --json`. Put all worker instructions in `agent_task_send.task` so the fresh Pi process can reach idle before assignment. Do not start a disposable worker with a blocking “wait for assignments” model prompt. Read the structured spawn response and retain the stable broker `sessionId`. Before remote dispatch, complete Wolfpack's [Live-peer readiness checklist](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-gateway.md#live-peer-readiness-checklist), including operator-recorded package/reload evidence; if it cannot pass, stop before task creation and report fixture-only verification.
29
- 2. send concise opaque task instructions with `agent_task_send`. Use `context.summary` only for constraints, decisions, and recovery state, and deliberately select `context.refs` with `path`, optional `selector`, and `purpose`; refs are metadata, not copied files or transcript. Use `task-context-summary` only for recovery/reuse.
30
- 3. include `role`, `metadata`, enforceable `preflight.requiredProject`, bounded timeout, idempotency key, and `onCompletePrompt` only when they apply. Timeout is 1000ms through 24h; default is 30 minutes. Task/context limits are 16 KiB each, assignment envelope 48 KiB, and request body 64 KiB.
31
- 4. after durable receipt, keep working. Do not call `agent_task_wait` unless the user explicitly asks to block. Use `agent_task_status` or `agent_task_inbox` for structured follow-up.
32
- 5. use `agent_task_message` for durable `question`, `answer`, or `information` flow. One question may be unresolved globally; answers link to it. A receiver question accepted by the sender ends the receiver turn; a parent question does not end the parent turn.
33
- 6. assignees call `agent_task_done` exactly once as their final action with `completed`, `failed`, or `cancelled`, concise summary, and optional structured result/error/artifact metadata. Report source modifications in `result.changedFiles`; artifacts are receiver-project-relative regular files for a parent to inspect, not changed-file lists: `{ "result": { "changedFiles": ["src/extension.ts"] }, "artifacts": [{ "path": "verification/task-2.md" }] }`. Follow the canonical Wolfpack artifact contract for provenance, containment, and warning behavior. No prose completion afterward.
34
- 7. parent independently verifies files, diff, tests, and artifacts before reporting success. Then use `agent_task_ack({ taskId })` for that one verified terminal task; remote acknowledgment is two-phase and failed propagation leaves the task visible for explicit repair. After acknowledgment, explicitly decide whether the spawned session has a likely next assignment. Retain it while review, correction, or another concrete assignment is expected; otherwise close it through canonical Wolfpack session control. Cleanup applies only to sessions this parent spawned and only after result verification and acknowledgment.
64
+ ## v1 compatibility
35
65
 
36
- ## delivery and recovery
66
+ The retained v1 workflow is available only through the explicit `@sgtbeatdown/pi-tasks/v1-compat-extension` entrypoint. It does not load by default, and v2 never falls back or translates its targets.
37
67
 
38
- `agent_task_send` returns after durable receiver-gateway acceptance, not adapter insertion or model execution; delivery remains pending until `task.delivered`. A remote initial send gets one initial attempt. The receiver stores a provisional receipt, and Pi sees the assignment only after sender receipt confirmation. Later peer events get four total attempts: initial plus retries around 1, 2, and 4 seconds with jitter. Retry exhaustion is a surfaced local delivery failure; v1 has no gateway queue, scheduler, or offline dispatch.
68
+ v1 uses a reachable local Wolfpack task gateway and addresses targets as `to: { machine, sessionId }`. Use `machine: "local"` for same-machine work; remote work uses only the receiver's canonical HTTPS Tailnet origin. v1 trusts local processes and trusted Tailnet machines, has no JWT federation, and follows Wolfpack's [task gateway guide](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-gateway.md).
39
69
 
40
- The sender gateway owns canonical order, expiry, and terminal choice; the first accepted terminal event wins. Sender timeout causes best-effort remote cancellation. The local extension polls every five seconds. With no pending message, it submits visible events through Pi's safe `deliverAs: "followUp"` queue: idle sessions start immediately, active turns finish first, and task delivery never steers or interrupts them. It stores `{ taskId, eventId }` in structured custom messages, replays missing events after restart, and records delivery only after structural insertion. Do not call that exactly-once execution.
70
+ Before v1 remote dispatch, complete the [Live-peer readiness checklist](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-gateway.md#live-peer-readiness-checklist), including operator-recorded package/reload evidence. If it cannot pass, stop before task creation and report fixture-only verification. Isolated coverage is the deterministic acceptance gate, but it does not prove a specific live peer.
41
71
 
42
- Isolated coverage is the deterministic acceptance gate, but a specific live peer still requires current readiness at the time of use. Do not claim that a particular remote deployment is reachable without the checklist evidence.
72
+ Only v1 supports curated `context.summary`/refs, role, metadata, preflight, idempotency keys, and completion prompts. Use `context.summary` only for constraints, decisions, and recovery state. Refs are metadata, not copied files or transcript. A v1 remote initial send gets one initial attempt; later peer events get four total attempts. Retry exhaustion is surfaced; v1 has no offline queue or background dispatch promise.
43
73
 
44
74
  ## do not
45
75
 
46
- - do not use terminal output as task state or completion evidence.
47
- - do not ask workers to complete in prose.
76
+ - do not use terminal output, rendered prompts, logs, or error prose as task state.
77
+ - do not ask workers to complete in prose or close their own sessions.
48
78
  - do not steer or interrupt an active Pi turn with task context; use the adapter's follow-up queue.
49
79
  - Do not copy plans, source contents, or transcripts into task context.
50
- - do not use filesystem task storage or retain old `mustReturn`, `rejected`, or semantic completion contracts.
51
- - do not promise JWT federation, artifact byte transfer, or background retry/offline dispatch.
52
- - do not make workers close their own sessions.
53
- - do not leave a parent-spawned session open after acknowledgment when the parent does not expect further work from it.
80
+ - do not mix v1 and v2 target shapes or silently translate between them.
81
+ - do not promise JWT federation, artifact byte transfer, exactly-once model execution, or successor endpoint rebinding.
@@ -0,0 +1,16 @@
1
+ export async function abortableSleep(milliseconds: number, signal: AbortSignal | undefined, cancellationError: () => Error): Promise<void> {
2
+ if (signal?.aborted) throw cancellationError();
3
+ await new Promise<void>((resolve, reject) => {
4
+ const removeAbortListener = (): void => signal?.removeEventListener("abort", abort);
5
+ const timeout = setTimeout(() => {
6
+ removeAbortListener();
7
+ resolve();
8
+ }, milliseconds);
9
+ const abort = (): void => {
10
+ clearTimeout(timeout);
11
+ removeAbortListener();
12
+ reject(cancellationError());
13
+ };
14
+ signal?.addEventListener("abort", abort, { once: true });
15
+ });
16
+ }
package/src/extension.ts CHANGED
@@ -3,10 +3,12 @@ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
 
6
- import { deliverTaskInbox } from "./task-inbox";
6
+ import { abortableSleep } from "./abortable-sleep";
7
+ import { deliverTaskInbox, incorporatedTaskEvents } from "./task-inbox";
7
8
  import { TaskOutboxDeliveryError, TaskProtocolError } from "./task-protocol";
8
9
  import { createWolfpackTaskCore } from "./wolfpack-task-relay";
9
10
  import type { TaskCore } from "./task-core";
11
+ import type { TaskEndpoint, TaskSnapshot } from "./task-protocol";
10
12
 
11
13
  const DEFAULT_TASK_TIMEOUT_MS = 30 * 60 * 1_000;
12
14
  const MIN_TASK_TIMEOUT_MS = 1_000;
@@ -15,6 +17,8 @@ const BACKGROUND_POLL_MS = 5_000;
15
17
  const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
16
18
  const WAIT_POLL_MS = 250;
17
19
  const SUMMARY_MAX_CHARS = 1_200;
20
+ const PRE_ASSIGNMENT_TOOLS = new Set(["agent_task_inbox", "agent_task_status", "agent_task_wait"]);
21
+ export const WORKER_GATE_DENIAL_CODE = "PI_TASK_WORKER_ASSIGNMENT_REQUIRED";
18
22
 
19
23
  const EndpointParams = Type.Object({
20
24
  relay: Type.String({ minLength: 1, description: "relay identifier" }),
@@ -58,6 +62,9 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
58
62
  let inboxContext: ExtensionContext | undefined;
59
63
  let backgroundTimer: ReturnType<typeof setInterval> | undefined;
60
64
  let lifecycleEpoch = 0;
65
+ const pendingInsertions = new Set<string>();
66
+ const closingTaskIds = new Set<string>();
67
+ const workerGateEnabled = process.env.PI_TASK_WORKER === "1";
61
68
  const configuredCore: ConfiguredCoreFactory = core === undefined ? createConfiguredCoreLoader(createCore) : async (): Promise<TaskCore> => core;
62
69
  const refreshInbox = createSingleFlightInboxRefresh(async (signal) => {
63
70
  const context = inboxContext;
@@ -97,7 +104,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
97
104
  }, guardedCore, {
98
105
  hasPendingMessages: (): boolean => !isCurrent() || context.hasPendingMessages(),
99
106
  sessionManager: context.sessionManager,
100
- }, signal);
107
+ }, pendingInsertions, signal);
101
108
  return outboxError;
102
109
  });
103
110
  const refreshLifecycle = async (context: ExtensionContext, epoch: number): Promise<void> => {
@@ -121,7 +128,30 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
121
128
  }
122
129
  };
123
130
 
131
+ pi.on("tool_call", async (event, context) => {
132
+ if (!workerGateEnabled || PRE_ASSIGNMENT_TOOLS.has(event.toolName)) return undefined;
133
+ try {
134
+ const activeCore = await configuredCore(context.signal);
135
+ const assignments = assignedWorkerTasks(activeCore, context.sessionManager.getEntries());
136
+ if (event.toolName === "agent_task_done") {
137
+ const taskId = isRecord(event.input) && typeof event.input.taskId === "string" ? event.input.taskId : undefined;
138
+ const assigned = taskId === undefined ? undefined : assignments.find((task) => task.taskId === taskId);
139
+ if (assigned === undefined || (!eligibleWorkerTask(assigned, closingTaskIds) && assigned.terminalDelivery.state === "not_submitted" && !closingTaskIds.has(assigned.taskId))) {
140
+ return { block: true, reason: WORKER_GATE_DENIAL_CODE };
141
+ }
142
+ closingTaskIds.add(assigned.taskId);
143
+ return undefined;
144
+ }
145
+ if (assignments.some((task) => eligibleWorkerTask(task, closingTaskIds))) return undefined;
146
+ } catch {
147
+ // Authorization evidence unavailable or malformed: fail closed with the stable public reason.
148
+ }
149
+ return { block: true, reason: WORKER_GATE_DENIAL_CODE };
150
+ });
151
+
124
152
  pi.on("session_start", async (_event, context) => {
153
+ pendingInsertions.clear();
154
+ closingTaskIds.clear();
125
155
  const epoch = ++lifecycleEpoch;
126
156
  inboxContext = context;
127
157
  if (backgroundTimer) clearInterval(backgroundTimer);
@@ -137,6 +167,8 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
137
167
  await refreshLifecycle(context, epoch);
138
168
  });
139
169
  pi.on("session_shutdown", () => {
170
+ pendingInsertions.clear();
171
+ closingTaskIds.clear();
140
172
  lifecycleEpoch += 1;
141
173
  if (backgroundTimer) clearInterval(backgroundTimer);
142
174
  backgroundTimer = undefined;
@@ -174,7 +206,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
174
206
  if (terminal(task.status)) return toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`);
175
207
  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`);
176
208
  onUpdate?.({ content: [{ type: "text", text: `waiting for ${params.taskId}...` }], details: {} });
177
- await sleep(Math.min(WAIT_POLL_MS, deadline - Date.now()), signal);
209
+ await abortableSleep(Math.max(1, Math.min(WAIT_POLL_MS, deadline - Date.now())), signal, () => new Error("task wait was cancelled"));
178
210
  }
179
211
  } catch (error) { return taskError(error); }
180
212
  }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
@@ -252,15 +284,31 @@ function terminal(status: string): boolean {
252
284
  return ["completed", "failed", "cancelled", "timed_out"].includes(status);
253
285
  }
254
286
 
287
+ function assignedWorkerTasks(core: TaskCore, entries: readonly unknown[]): readonly TaskSnapshot[] {
288
+ const assignments = new Map<string, TaskSnapshot>();
289
+ for (const evidence of incorporatedTaskEvents(entries)) {
290
+ const task = core.getTask(evidence.taskId);
291
+ if (!task || !sameEndpoint(task.target, core.endpoint)) continue;
292
+ const created = task.events.find((event) => event.eventId === evidence.eventId);
293
+ if (!created || created.type !== "task.created" || !sameEndpoint(created.target, core.endpoint) || !sameEndpoint(created.source, task.origin)) continue;
294
+ assignments.set(task.taskId, task);
295
+ }
296
+ return [...assignments.values()];
297
+ }
298
+
299
+ function eligibleWorkerTask(task: TaskSnapshot, closingTaskIds: ReadonlySet<string>): boolean {
300
+ return task.status === "active" && task.terminalDelivery.state === "not_submitted" && !closingTaskIds.has(task.taskId);
301
+ }
302
+
303
+ function sameEndpoint(left: TaskEndpoint, right: TaskEndpoint): boolean {
304
+ return left.relay === right.relay && left.id === right.id;
305
+ }
306
+
307
+ function isRecord(value: unknown): value is Record<string, unknown> {
308
+ return value !== null && typeof value === "object" && !Array.isArray(value);
309
+ }
310
+
255
311
  function text(result: { readonly content?: readonly unknown[] }): string {
256
312
  const first = result.content?.[0];
257
313
  return first && typeof first === "object" && "type" in first && first.type === "text" && "text" in first && typeof first.text === "string" ? first.text : "";
258
314
  }
259
-
260
- async function sleep(milliseconds: number, signal: AbortSignal | undefined): Promise<void> {
261
- if (signal?.aborted) throw new Error("task wait was cancelled");
262
- await new Promise<void>((resolve, reject) => {
263
- const timer = setTimeout(resolve, Math.max(1, milliseconds));
264
- signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("task wait was cancelled")); }, { once: true });
265
- });
266
- }
@@ -3,6 +3,7 @@ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
 
6
+ import { abortableSleep } from "./abortable-sleep";
6
7
  import {
7
8
  DEFAULT_TASK_TIMEOUT_MS,
8
9
  GatewayClientError,
@@ -126,7 +127,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, client: WolfpackGateway
126
127
  if (terminal(status.status)) return statusResult(status);
127
128
  if (Date.now() >= deadline) return toolResult({ taskId: params.taskId, status: status.status, error: { code: "WAIT_TIMEOUT", retryable: true } }, `## task wait\n- task: \`${params.taskId}\`\n- status: ${status.status}\n- wait timed out`);
128
129
  onUpdate?.({ content: [{ type: "text", text: `waiting for ${params.taskId}...` }], details: {} });
129
- await sleep(Math.min(WAIT_POLL_MS, Math.max(1, deadline - Date.now())), signal);
130
+ await abortableSleep(Math.min(WAIT_POLL_MS, Math.max(1, deadline - Date.now())), signal, () => new GatewayClientError("ABORTED", "task wait was cancelled", true));
130
131
  }
131
132
  } catch (error) { return clientError(error); }
132
133
  }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
@@ -238,11 +239,3 @@ function text(result: { readonly content?: readonly unknown[] }): string {
238
239
  function throwIfAborted(signal: AbortSignal | undefined): void {
239
240
  if (signal?.aborted) throw new GatewayClientError("ABORTED", "task wait was cancelled", true);
240
241
  }
241
-
242
- async function sleep(milliseconds: number, signal: AbortSignal | undefined): Promise<void> {
243
- await new Promise<void>((resolve, reject) => {
244
- const timeout = setTimeout(resolve, milliseconds);
245
- const abort = (): void => { clearTimeout(timeout); reject(new GatewayClientError("ABORTED", "task wait was cancelled", true)); };
246
- signal?.addEventListener("abort", abort, { once: true });
247
- });
248
- }
package/src/task-core.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  MAX_RELAY_PAYLOAD_BYTES,
3
+ PARENT_ACKNOWLEDGMENT_OPERATION,
3
4
  TASK_PROTOCOL_VERSION,
5
+ TERMINAL_INTENT_OPERATION,
4
6
  TaskEnvelopeKind,
5
7
  TaskOutboxDeliveryError,
6
8
  TaskProtocolError,
@@ -13,10 +15,12 @@ import type {
13
15
  TaskIntent,
14
16
  TaskRecord,
15
17
  TaskRelay,
18
+ TaskSnapshot,
16
19
  } from "./task-protocol";
17
20
  import type { TaskStore } from "./task-store";
18
21
 
19
22
  const RECEIVE_PAGE_SIZE = 100;
23
+ const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
20
24
  const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
21
25
  const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
22
26
  const CANONICAL_EVENTS = new Set([
@@ -49,10 +53,11 @@ export interface SubmitIntentInput {
49
53
  }
50
54
 
51
55
  export interface TaskCore {
56
+ readonly endpoint: TaskEndpoint;
52
57
  connect(signal?: AbortSignal): Promise<void>;
53
58
  createTask(input: CreateTaskInput, signal?: AbortSignal): Promise<{ readonly taskId: string }>;
54
- getTask(taskId: string): TaskRecord | undefined;
55
- listTasks(): readonly TaskRecord[];
59
+ getTask(taskId: string): TaskSnapshot | undefined;
60
+ listTasks(): readonly TaskSnapshot[];
56
61
  flushOutbox(signal?: AbortSignal): Promise<void>;
57
62
  receive(signal?: AbortSignal): Promise<readonly RelayDelivery[]>;
58
63
  acknowledgeRelayDelivery(cursor: string, signal?: AbortSignal): Promise<void>;
@@ -72,6 +77,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
72
77
  const ids = options.ids ?? (() => crypto.randomUUID());
73
78
 
74
79
  return {
80
+ endpoint: options.endpoint,
75
81
  async connect(signal) {
76
82
  const connection = await options.relay.connect({ endpoint: options.endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: options.store.getReceiveCursor() }, signal);
77
83
  if (!sameEndpoint(connection.endpoint, options.endpoint)) throw new TaskProtocolError("INVALID_CONNECTION", "relay connected a different endpoint");
@@ -133,12 +139,15 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
133
139
  },
134
140
  async submitIntent(input, signal) {
135
141
  if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
136
- const task = requiredTask(options.store, input.taskId);
137
142
  let persistedEnvelopeIds: readonly string[] = [];
143
+ let receiverTerminal = false;
138
144
  options.store.transaction(() => {
145
+ const task = requiredTask(options.store, input.taskId);
146
+ receiverTerminal = !sameEndpoint(options.endpoint, task.origin) && TERMINAL_EVENTS.has(input.type);
139
147
  persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, input);
140
148
  });
141
149
  await flush(options, signal, new Set(persistedEnvelopeIds));
150
+ if (receiverTerminal) throwIfTerminalDeliveryBlocked(options.store, input.taskId);
142
151
  },
143
152
  async recordInsertion(input, signal) {
144
153
  const task = requiredTask(options.store, input.taskId);
@@ -163,11 +172,12 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
163
172
  if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
164
173
  },
165
174
  async acknowledgeParent(taskId, signal) {
166
- const task = requiredTask(options.store, taskId);
167
- if (!sameEndpoint(task.origin, options.endpoint)) throw new TaskProtocolError("NOT_ORIGIN", "only origin may acknowledge a task");
168
175
  let persistedEnvelopeIds: readonly string[] = [];
169
176
  options.store.transaction(() => {
170
- persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged");
177
+ const task = requiredTask(options.store, taskId);
178
+ if (!sameEndpoint(task.origin, options.endpoint)) throw new TaskProtocolError("NOT_ORIGIN", "only origin may acknowledge a task");
179
+ if (!TERMINAL_STATUSES.has(task.status)) throw new TaskProtocolError("TASK_NOT_TERMINAL", "only a terminal task may be acknowledged", { retryable: false });
180
+ persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged", PARENT_ACKNOWLEDGMENT_OPERATION);
171
181
  });
172
182
  await flush(options, signal, new Set(persistedEnvelopeIds));
173
183
  },
@@ -236,27 +246,45 @@ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => s
236
246
  }
237
247
  const envelopeId = ids();
238
248
  const intent: TaskIntent = { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload };
249
+ if (TERMINAL_EVENTS.has(input.type)) {
250
+ const reservation = options.store.reserveTaskOperation({
251
+ taskId: input.taskId,
252
+ operation: TERMINAL_INTENT_OPERATION,
253
+ logicalId: intent.intentId,
254
+ logicalType: intent.type,
255
+ envelopeIds: [envelopeId],
256
+ });
257
+ if (!reservation.created) {
258
+ if (reservation.record.logicalType !== intent.type) {
259
+ throw new TaskProtocolError("TERMINAL_INTENT_CONFLICT", "terminal task intent conflicts with the existing terminal action", {
260
+ retryable: false,
261
+ details: { taskId: input.taskId, existingType: reservation.record.logicalType, requestedType: intent.type },
262
+ });
263
+ }
264
+ return reservation.record.envelopeIds;
265
+ }
266
+ }
239
267
  options.store.putIntent(intent.intentId, input.taskId, envelopeId);
240
268
  options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent));
241
269
  return [envelopeId];
242
270
  }
243
271
 
244
- function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string): readonly string[] {
272
+ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string): readonly string[] {
245
273
  const terminal = TERMINAL_EVENTS.has(requestedType);
246
274
  const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
247
275
  const sequence = String(task.events.length + 1);
248
276
  const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { intentId: intent.intentId, ...intent.payload });
249
- const persistedEnvelopeIds: string[] = [];
277
+ const targetEnvelope = envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
278
+ const originEnvelope = sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
279
+ const persistedEnvelopeIds = originEnvelope === undefined ? [targetEnvelope.envelopeId] : [targetEnvelope.envelopeId, originEnvelope.envelopeId];
280
+ if (operation !== undefined) {
281
+ const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
282
+ if (!reservation.created) return reservation.record.envelopeIds;
283
+ }
250
284
  options.store.appendEvent(canonical);
251
285
  if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
252
- const targetEnvelope = envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
253
286
  options.store.putOutbox(targetEnvelope);
254
- persistedEnvelopeIds.push(targetEnvelope.envelopeId);
255
- if (!sameEndpoint(task.origin, task.target)) {
256
- const originEnvelope = envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
257
- options.store.putOutbox(originEnvelope);
258
- persistedEnvelopeIds.push(originEnvelope.envelopeId);
259
- }
287
+ if (originEnvelope !== undefined) options.store.putOutbox(originEnvelope);
260
288
  return persistedEnvelopeIds;
261
289
  }
262
290
 
@@ -268,6 +296,16 @@ async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined,
268
296
  options.store.transaction(() => { options.store.markOutboxAccepted(record.envelope.envelopeId); });
269
297
  } catch (error) {
270
298
  if (signal?.aborted || (error instanceof TaskProtocolError && error.code === "ABORTED")) throw error;
299
+ if (error instanceof TaskProtocolError && error.code === TARGET_NOT_REGISTERED_CODE && !error.retryable) {
300
+ options.store.transaction(() => {
301
+ options.store.quarantineOutbox(record.envelope.envelopeId, {
302
+ errorCode: error.code,
303
+ reason: error.message,
304
+ details: error.details ?? {},
305
+ quarantinedAt: Date.now(),
306
+ });
307
+ });
308
+ }
271
309
  failures.push({ envelopeId: record.envelope.envelopeId, error });
272
310
  }
273
311
  }
@@ -301,7 +339,16 @@ function parsePayload(envelope: RelayEnvelope): unknown {
301
339
  try { return JSON.parse(envelope.payload) as unknown; } catch { throw new TaskProtocolError("INVALID_PAYLOAD", "relay envelope payload is not valid task protocol JSON"); }
302
340
  }
303
341
 
304
- function requiredTask(store: TaskStore, taskId: string): TaskRecord {
342
+ function throwIfTerminalDeliveryBlocked(store: TaskStore, taskId: string): void {
343
+ const delivery = requiredTask(store, taskId).terminalDelivery;
344
+ if (delivery.state !== "delivery_blocked") return;
345
+ throw new TaskOutboxDeliveryError(delivery.error.code, "terminal task delivery is blocked", {
346
+ retryable: false,
347
+ details: { ...delivery.error.details, taskId, envelopeId: delivery.envelopeId, origin: delivery.origin, blockedAt: delivery.blockedAt },
348
+ });
349
+ }
350
+
351
+ function requiredTask(store: TaskStore, taskId: string): TaskSnapshot {
305
352
  const task = store.getTask(taskId);
306
353
  if (!task) throw new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${taskId}`);
307
354
  return task;
package/src/task-inbox.ts CHANGED
@@ -15,13 +15,13 @@ interface InboxPi {
15
15
  appendEntry(customType: string, data: { readonly cursor: string }): void;
16
16
  }
17
17
 
18
- interface TaskEventDetails {
18
+ export interface TaskEventDetails {
19
19
  readonly taskId: string;
20
20
  readonly eventId: string;
21
21
  }
22
22
 
23
23
  /** Inserts durable Pi evidence before allowing the relay mailbox cursor to advance. */
24
- export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal): Promise<void> {
24
+ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, pendingInsertions: Set<string>, signal?: AbortSignal): Promise<void> {
25
25
  if (context.hasPendingMessages()) return;
26
26
  const deliveries = await core.receive(signal);
27
27
  for (const delivery of deliveries) {
@@ -34,14 +34,20 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
34
34
  if (!isKnownEvent(event.type)) throw new TaskProtocolError("UNKNOWN_EVENT", `unknown task inbox event type: ${event.type}`);
35
35
  if (context.hasPendingMessages()) return;
36
36
  const eventKey = key(event.taskId, event.eventId);
37
- if (!incorporatedEvents(context.sessionManager.getEntries()).has(eventKey) && isModelVisible(event.type)) {
38
- pi.sendMessage({
39
- customType: TASK_EVENT_CUSTOM_TYPE,
40
- content: renderTaskEvent(event),
41
- display: true,
42
- details: { taskId: event.taskId, eventId: event.eventId },
43
- }, { triggerTurn: true, deliverAs: "followUp" });
37
+ const incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
38
+ if (incorporated) pendingInsertions.delete(eventKey);
39
+ if (!incorporated && isModelVisible(event.type)) {
40
+ if (!pendingInsertions.has(eventKey)) {
41
+ pi.sendMessage({
42
+ customType: TASK_EVENT_CUSTOM_TYPE,
43
+ content: renderTaskEvent(event),
44
+ display: true,
45
+ details: { taskId: event.taskId, eventId: event.eventId },
46
+ }, { triggerTurn: true, deliverAs: "followUp" });
47
+ pendingInsertions.add(eventKey);
48
+ }
44
49
  if (!incorporatedEvents(context.sessionManager.getEntries()).has(eventKey)) return;
50
+ pendingInsertions.delete(eventKey);
45
51
  }
46
52
  if (isModelVisible(event.type)) await core.recordInsertion({ taskId: event.taskId, eventId: event.eventId }, signal);
47
53
  await core.acknowledgeRelayDelivery(delivery.cursor, signal);
@@ -49,15 +55,6 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
49
55
  }
50
56
  }
51
57
 
52
- export function restoredInboxCursor(entries: readonly unknown[]): string {
53
- let cursor = "0";
54
- for (const entry of entries) {
55
- if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== TASK_CURSOR_CUSTOM_TYPE || !isRecord(entry.data) || !decimal(entry.data.cursor)) continue;
56
- cursor = entry.data.cursor;
57
- }
58
- return cursor;
59
- }
60
-
61
58
  function inboxEvent(delivery: RelayDelivery): TaskEvent {
62
59
  try {
63
60
  const payload = JSON.parse(delivery.envelope.payload) as unknown;
@@ -83,15 +80,19 @@ function renderTaskEvent(event: TaskEvent): string {
83
80
  return `## task ${event.type.replace(/^task\./, "").replaceAll("_", " ")}\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`${body}${summary}`;
84
81
  }
85
82
 
86
- function incorporatedEvents(entries: readonly unknown[]): Set<string> {
87
- const events = new Set<string>();
83
+ export function incorporatedTaskEvents(entries: readonly unknown[]): readonly TaskEventDetails[] {
84
+ const events: TaskEventDetails[] = [];
88
85
  for (const entry of entries) {
89
86
  if (!isRecord(entry) || entry.type !== "custom_message" || entry.customType !== TASK_EVENT_CUSTOM_TYPE || !isRecord(entry.details) || typeof entry.details.taskId !== "string" || typeof entry.details.eventId !== "string") continue;
90
- events.add(key(entry.details.taskId, entry.details.eventId));
87
+ events.push({ taskId: entry.details.taskId, eventId: entry.details.eventId });
91
88
  }
92
89
  return events;
93
90
  }
94
91
 
92
+ function incorporatedEvents(entries: readonly unknown[]): Set<string> {
93
+ return new Set(incorporatedTaskEvents(entries).map((event) => key(event.taskId, event.eventId)));
94
+ }
95
+
95
96
  function isTaskEvent(value: unknown): value is TaskEvent {
96
97
  return isRecord(value) && typeof value.eventId === "string" && typeof value.taskId === "string" && typeof value.type === "string" && typeof value.sequence === "string" && isRecord(value.source) && isRecord(value.target) && typeof value.occurredAt === "number" && isRecord(value.payload);
97
98
  }
@@ -103,7 +104,3 @@ function key(taskId: string, eventId: string): string {
103
104
  function isRecord(value: unknown): value is Record<string, unknown> {
104
105
  return value !== null && typeof value === "object" && !Array.isArray(value);
105
106
  }
106
-
107
- function decimal(value: unknown): value is string {
108
- return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
109
- }
@@ -1,5 +1,7 @@
1
1
  export const TASK_PROTOCOL_VERSION = "pi-tasks/v2" as const;
2
2
  export const MAX_RELAY_PAYLOAD_BYTES = 48 * 1024;
3
+ export const PARENT_ACKNOWLEDGMENT_OPERATION = "parent_acknowledgment";
4
+ export const TERMINAL_INTENT_OPERATION = "terminal_intent";
3
5
 
4
6
  export interface TaskEndpoint {
5
7
  readonly relay: string;
@@ -100,13 +102,46 @@ export interface TaskRecord {
100
102
  readonly events: readonly TaskEvent[];
101
103
  }
102
104
 
105
+ export type TerminalTaskIntentType = "task.completed" | "task.failed" | "task.cancelled";
106
+
103
107
  export interface TaskIntent {
104
108
  readonly intentId: string;
105
109
  readonly taskId: string;
106
- readonly type: "task.completed" | "task.failed" | "task.cancelled" | "task.information" | "task.question" | "task.answer" | "task.delivery_receipt";
110
+ readonly type: TerminalTaskIntentType | "task.information" | "task.question" | "task.answer" | "task.delivery_receipt";
107
111
  readonly payload: Record<string, unknown>;
108
112
  }
109
113
 
114
+ interface TerminalDeliveryIdentity {
115
+ readonly intentId: string;
116
+ readonly intentType: TerminalTaskIntentType;
117
+ readonly envelopeId: string;
118
+ readonly origin: TaskEndpoint;
119
+ }
120
+
121
+ interface TerminalDeliveryPending extends TerminalDeliveryIdentity {
122
+ readonly state: "pending";
123
+ }
124
+
125
+ interface TerminalDeliveryAccepted extends TerminalDeliveryIdentity {
126
+ readonly state: "accepted";
127
+ }
128
+
129
+ interface TerminalDeliveryBlocked extends TerminalDeliveryIdentity {
130
+ readonly state: "delivery_blocked";
131
+ readonly blockedAt: number;
132
+ readonly error: { readonly code: string; readonly retryable: false; readonly details: Readonly<Record<string, unknown>> };
133
+ }
134
+
135
+ export type TerminalDeliveryState =
136
+ | { readonly state: "not_submitted" }
137
+ | TerminalDeliveryPending
138
+ | TerminalDeliveryAccepted
139
+ | TerminalDeliveryBlocked;
140
+
141
+ export interface TaskSnapshot extends TaskRecord {
142
+ readonly terminalDelivery: TerminalDeliveryState;
143
+ }
144
+
110
145
  export class TaskProtocolError extends Error {
111
146
  readonly code: string;
112
147
  readonly retryable: boolean;
package/src/task-store.ts CHANGED
@@ -4,12 +4,14 @@ import { dirname, join } from "node:path";
4
4
 
5
5
  import { openSqliteDatabase } from "./sqlite-database";
6
6
  import type { SqliteDatabase } from "./sqlite-database";
7
- import type { RelayEnvelope, TaskEvent, TaskRecord } from "./task-protocol";
7
+ import { PARENT_ACKNOWLEDGMENT_OPERATION, TERMINAL_INTENT_OPERATION } from "./task-protocol";
8
+ import type { RelayEnvelope, TaskEndpoint, TaskEvent, TaskRecord, TaskSnapshot, TerminalDeliveryState, TerminalTaskIntentType } from "./task-protocol";
8
9
 
9
- const SCHEMA_VERSION = 3;
10
+ const SCHEMA_VERSION = 5;
10
11
  const OWNER_ONLY_MODE = 0o700;
11
12
  const OWNER_ONLY_FILE_MODE = 0o600;
12
13
  const ENDPOINT_GENERATION_STATE_KEY = "endpoint_generation";
14
+ const ENDPOINT_BINDING_STATE_KEY = "endpoint_binding";
13
15
 
14
16
  export interface TaskStoreOptions {
15
17
  readonly path?: string;
@@ -20,23 +22,48 @@ export interface OutboxRecord {
20
22
  readonly state: "pending" | "accepted";
21
23
  }
22
24
 
25
+ export interface OutboxQuarantineInput {
26
+ readonly errorCode: string;
27
+ readonly reason: string;
28
+ readonly details: Readonly<Record<string, unknown>>;
29
+ readonly quarantinedAt: number;
30
+ }
31
+
32
+ export interface OutboxQuarantineRecord extends OutboxQuarantineInput {
33
+ readonly envelope: RelayEnvelope;
34
+ readonly priorState: OutboxRecord["state"];
35
+ }
36
+
37
+ export interface TaskOperationRecord {
38
+ readonly taskId: string;
39
+ readonly operation: string;
40
+ readonly logicalId: string;
41
+ readonly logicalType: string;
42
+ readonly envelopeIds: readonly string[];
43
+ }
44
+
23
45
  export interface TaskStore {
24
46
  transaction<T>(operation: () => T): T;
25
47
  putTask(task: Omit<TaskRecord, "events">): void;
26
- getTask(taskId: string): TaskRecord | undefined;
27
- listTasks(): readonly TaskRecord[];
48
+ getTask(taskId: string): TaskSnapshot | undefined;
49
+ listTasks(): readonly TaskSnapshot[];
28
50
  setStatus(taskId: string, status: TaskRecord["status"]): void;
29
51
  appendEvent(event: TaskEvent): boolean;
30
52
  putOutbox(envelope: RelayEnvelope): void;
31
53
  outbox(state: OutboxRecord["state"]): readonly OutboxRecord[];
32
54
  markOutboxAccepted(envelopeId: string): void;
55
+ quarantineOutbox(envelopeId: string, input: OutboxQuarantineInput): void;
56
+ quarantinedOutbox(): readonly OutboxQuarantineRecord[];
33
57
  persistInbox(envelope: RelayEnvelope, cursor: string): boolean;
34
58
  getReceiveCursor(): string;
35
59
  setReceiveCursor(cursor: string): void;
36
60
  putIntent(intentId: string, taskId: string, envelopeId: string): void;
37
61
  putInsertionReceipt(taskId: string, eventId: string): boolean;
62
+ reserveTaskOperation(input: TaskOperationRecord): { readonly created: boolean; readonly record: TaskOperationRecord };
38
63
  getEndpointGeneration(): string | undefined;
39
64
  setEndpointGeneration(generation: string): void;
65
+ getEndpointBinding(): TaskEndpoint | undefined;
66
+ setEndpointBinding(endpoint: TaskEndpoint): void;
40
67
  close(): void;
41
68
  }
42
69
 
@@ -60,7 +87,7 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
60
87
  const row = database.query("SELECT * FROM tasks WHERE task_id = ?").get(taskId) as TaskRow | null;
61
88
  if (!row) return undefined;
62
89
  const eventRows = database.query("SELECT * FROM events WHERE task_id = ? ORDER BY CAST(sequence AS INTEGER), event_id").all(taskId) as EventRow[];
63
- return taskFromRow(row, eventRows);
90
+ return taskFromRow(database, row, eventRows);
64
91
  },
65
92
  listTasks() {
66
93
  const rows = database.query("SELECT * FROM tasks ORDER BY created_at, task_id").all() as TaskRow[];
@@ -71,7 +98,7 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
71
98
  current.push(event);
72
99
  eventsByTask.set(event.task_id, current);
73
100
  }
74
- return rows.map((row) => taskFromRow(row, eventsByTask.get(row.task_id) ?? []));
101
+ return rows.map((row) => taskFromRow(database, row, eventsByTask.get(row.task_id) ?? []));
75
102
  },
76
103
  setStatus(taskId, status) {
77
104
  database.query("UPDATE tasks SET status = ? WHERE task_id = ?").run(status, taskId);
@@ -92,6 +119,26 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
92
119
  markOutboxAccepted(envelopeId) {
93
120
  database.query("UPDATE outbox SET state = 'accepted' WHERE envelope_id = ?").run(envelopeId);
94
121
  },
122
+ quarantineOutbox(envelopeId, input) {
123
+ const row = database.query("SELECT envelope, state FROM outbox WHERE envelope_id = ?").get(envelopeId) as { readonly envelope: string; readonly state: OutboxRecord["state"] } | null;
124
+ if (!row) return;
125
+ database.query(`INSERT INTO outbox_quarantine (envelope, error_code, reason, details, quarantined_at, prior_state, envelope_id)
126
+ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(envelope_id) DO NOTHING`).run(
127
+ row.envelope, input.errorCode, input.reason, JSON.stringify(input.details), input.quarantinedAt, row.state, envelopeId,
128
+ );
129
+ database.query("DELETE FROM outbox WHERE envelope_id = ?").run(envelopeId);
130
+ },
131
+ quarantinedOutbox() {
132
+ const rows = database.query("SELECT envelope, error_code, reason, details, quarantined_at, prior_state FROM outbox_quarantine ORDER BY rowid").all() as OutboxQuarantineRow[];
133
+ return rows.map((row) => ({
134
+ envelope: parseEnvelope(row.envelope),
135
+ errorCode: row.error_code,
136
+ reason: row.reason,
137
+ details: parseRecord(row.details),
138
+ quarantinedAt: row.quarantined_at,
139
+ priorState: row.prior_state,
140
+ }));
141
+ },
95
142
  persistInbox(envelope, cursor) {
96
143
  const result = database.query("INSERT INTO inbox (envelope_id, cursor, envelope) VALUES (?, ?, ?) ON CONFLICT(envelope_id) DO NOTHING").run(envelope.envelopeId, cursor, JSON.stringify(envelope));
97
144
  return result.changes === 1;
@@ -110,6 +157,13 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
110
157
  const result = database.query("INSERT INTO insertion_receipts (task_id, event_id) VALUES (?, ?) ON CONFLICT(task_id, event_id) DO NOTHING").run(taskId, eventId);
111
158
  return result.changes === 1;
112
159
  },
160
+ reserveTaskOperation(input) {
161
+ const result = database.query(`INSERT INTO task_operations (task_id, operation, logical_id, logical_type, envelope_ids)
162
+ VALUES (?, ?, ?, ?, ?) ON CONFLICT(task_id, operation) DO NOTHING`).run(input.taskId, input.operation, input.logicalId, input.logicalType, JSON.stringify(input.envelopeIds));
163
+ const row = database.query("SELECT task_id, operation, logical_id, logical_type, envelope_ids FROM task_operations WHERE task_id = ? AND operation = ?").get(input.taskId, input.operation) as TaskOperationRow | null;
164
+ if (!row) throw new Error("task operation reservation was not persisted");
165
+ return { created: result.changes === 1, record: taskOperationFromRow(row) };
166
+ },
113
167
  getEndpointGeneration() {
114
168
  const row = database.query("SELECT value FROM relay_state WHERE name = ?").get(ENDPOINT_GENERATION_STATE_KEY) as { readonly value: string } | null;
115
169
  return row?.value;
@@ -117,6 +171,13 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
117
171
  setEndpointGeneration(generation) {
118
172
  database.query("INSERT INTO relay_state (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value").run(ENDPOINT_GENERATION_STATE_KEY, generation);
119
173
  },
174
+ getEndpointBinding() {
175
+ const row = database.query("SELECT value FROM relay_state WHERE name = ?").get(ENDPOINT_BINDING_STATE_KEY) as { readonly value: string } | null;
176
+ return row ? parseEndpoint(row.value) : undefined;
177
+ },
178
+ setEndpointBinding(endpoint) {
179
+ database.query("INSERT INTO relay_state (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value").run(ENDPOINT_BINDING_STATE_KEY, JSON.stringify(endpoint));
180
+ },
120
181
  close() { database.close(); },
121
182
  };
122
183
  return store;
@@ -135,6 +196,34 @@ interface TaskRow {
135
196
  readonly status: string;
136
197
  }
137
198
 
199
+ interface OutboxQuarantineRow {
200
+ readonly envelope: string;
201
+ readonly error_code: string;
202
+ readonly reason: string;
203
+ readonly details: string;
204
+ readonly quarantined_at: number;
205
+ readonly prior_state: OutboxRecord["state"];
206
+ }
207
+
208
+ interface TaskOperationRow {
209
+ readonly task_id: string;
210
+ readonly operation: string;
211
+ readonly logical_id: string;
212
+ readonly logical_type: string;
213
+ readonly envelope_ids: string;
214
+ }
215
+
216
+ interface LegacyIntentRow {
217
+ readonly intent_id: string;
218
+ readonly task_id: string;
219
+ readonly envelope_id: string;
220
+ }
221
+
222
+ interface LegacyEnvelopeRow {
223
+ readonly envelope_id: string;
224
+ readonly envelope: string;
225
+ }
226
+
138
227
  interface EventRow {
139
228
  readonly event_id: string;
140
229
  readonly task_id: string;
@@ -175,6 +264,121 @@ function migrate(database: SqliteDatabase): void {
175
264
  PRAGMA user_version = 3;
176
265
  `);
177
266
  }
267
+ if (version.user_version <= 3) {
268
+ database.exec(`CREATE TABLE IF NOT EXISTS outbox_quarantine (
269
+ envelope_id TEXT PRIMARY KEY,
270
+ envelope TEXT NOT NULL,
271
+ reason TEXT NOT NULL,
272
+ quarantined_at INTEGER NOT NULL,
273
+ error_code TEXT NOT NULL DEFAULT 'UNKNOWN',
274
+ details TEXT NOT NULL DEFAULT '{}',
275
+ prior_state TEXT NOT NULL DEFAULT 'pending'
276
+ );`);
277
+ ensureColumn(database, "outbox_quarantine", "envelope", "TEXT NOT NULL DEFAULT '{}'");
278
+ ensureColumn(database, "outbox_quarantine", "reason", "TEXT NOT NULL DEFAULT 'operator quarantine'");
279
+ ensureColumn(database, "outbox_quarantine", "quarantined_at", "INTEGER NOT NULL DEFAULT 0");
280
+ ensureColumn(database, "outbox_quarantine", "error_code", "TEXT NOT NULL DEFAULT 'UNKNOWN'");
281
+ ensureColumn(database, "outbox_quarantine", "details", "TEXT NOT NULL DEFAULT '{}'");
282
+ ensureColumn(database, "outbox_quarantine", "prior_state", "TEXT NOT NULL DEFAULT 'pending'");
283
+ database.exec("PRAGMA user_version = 4;");
284
+ }
285
+ if (version.user_version <= 4) {
286
+ database.transaction(() => {
287
+ database.exec(`CREATE TABLE IF NOT EXISTS task_operations (
288
+ task_id TEXT NOT NULL REFERENCES tasks(task_id),
289
+ operation TEXT NOT NULL,
290
+ logical_id TEXT NOT NULL,
291
+ logical_type TEXT NOT NULL,
292
+ envelope_ids TEXT NOT NULL,
293
+ PRIMARY KEY (task_id, operation)
294
+ );`);
295
+ backfillTaskOperations(database);
296
+ database.exec("PRAGMA user_version = 5;");
297
+ })();
298
+ }
299
+ }
300
+
301
+ function backfillTaskOperations(database: SqliteDatabase): void {
302
+ const envelopes = legacyEnvelopes(database);
303
+ const terminalTasks = new Set<string>();
304
+ const intents = database.query("SELECT intent_id, task_id, envelope_id FROM intents ORDER BY rowid").all() as LegacyIntentRow[];
305
+ for (const intent of intents) {
306
+ if (terminalTasks.has(intent.task_id)) continue;
307
+ const serializedEnvelope = envelopes.get(intent.envelope_id);
308
+ if (!serializedEnvelope) throw new Error(`legacy intent ${intent.intent_id} has no durable envelope`);
309
+ const envelope = legacyEnvelope(intent.envelope_id, serializedEnvelope);
310
+ const payload = legacyEnvelopePayload(envelope);
311
+ if (envelope.kind !== "intent" || envelope.taskId !== intent.task_id || !isRecord(payload) || payload.intentId !== intent.intent_id || payload.taskId !== intent.task_id || typeof payload.type !== "string") {
312
+ throw new Error(`legacy intent ${intent.intent_id} has malformed structured state`);
313
+ }
314
+ if (!isTerminalIntentType(payload.type)) continue;
315
+ insertTaskOperation(database, { taskId: intent.task_id, operation: TERMINAL_INTENT_OPERATION, logicalId: intent.intent_id, logicalType: payload.type, envelopeIds: [intent.envelope_id] });
316
+ terminalTasks.add(intent.task_id);
317
+ }
318
+
319
+ const acknowledgments = database.query("SELECT * FROM events WHERE type = 'task.parent_acknowledged' ORDER BY task_id, CAST(sequence AS INTEGER), rowid").all() as EventRow[];
320
+ const acknowledgedTasks = new Set<string>();
321
+ for (const acknowledgment of acknowledgments) {
322
+ if (acknowledgedTasks.has(acknowledgment.task_id)) continue;
323
+ const envelopeIds = [...envelopes]
324
+ .flatMap(([envelopeId, serializedEnvelope]) => {
325
+ const envelope = optionalLegacyEnvelope(envelopeId, serializedEnvelope);
326
+ return envelope?.kind === "canonical_event" && envelope.taskId === acknowledgment.task_id && eventIdFromLegacyEnvelope(envelope) === acknowledgment.event_id ? [envelopeId] : [];
327
+ })
328
+ .sort();
329
+ if (envelopeIds.length === 0) throw new Error(`legacy parent acknowledgment ${acknowledgment.event_id} has no durable envelope`);
330
+ insertTaskOperation(database, { taskId: acknowledgment.task_id, operation: PARENT_ACKNOWLEDGMENT_OPERATION, logicalId: acknowledgment.event_id, logicalType: acknowledgment.type, envelopeIds });
331
+ acknowledgedTasks.add(acknowledgment.task_id);
332
+ }
333
+ }
334
+
335
+ function legacyEnvelopes(database: SqliteDatabase): Map<string, string> {
336
+ const envelopes = new Map<string, string>();
337
+ const live = database.query("SELECT envelope_id, envelope FROM outbox ORDER BY rowid").all() as LegacyEnvelopeRow[];
338
+ const quarantined = database.query("SELECT envelope_id, envelope FROM outbox_quarantine ORDER BY rowid").all() as LegacyEnvelopeRow[];
339
+ for (const row of [...live, ...quarantined]) {
340
+ if (row.envelope_id.length === 0) throw new Error("legacy durable envelope identity is malformed");
341
+ if (!envelopes.has(row.envelope_id)) envelopes.set(row.envelope_id, row.envelope);
342
+ }
343
+ return envelopes;
344
+ }
345
+
346
+ function legacyEnvelope(envelopeId: string, serializedEnvelope: string): RelayEnvelope {
347
+ const envelope = parseEnvelope(serializedEnvelope);
348
+ if (envelope.envelopeId !== envelopeId) throw new Error(`legacy envelope ${envelopeId} identity is malformed`);
349
+ return envelope;
350
+ }
351
+
352
+ function optionalLegacyEnvelope(envelopeId: string, serializedEnvelope: string): RelayEnvelope | undefined {
353
+ try {
354
+ return legacyEnvelope(envelopeId, serializedEnvelope);
355
+ } catch {
356
+ // Unrelated operator quarantine rows are audit data, not lifecycle state.
357
+ return undefined;
358
+ }
359
+ }
360
+
361
+ function legacyEnvelopePayload(envelope: RelayEnvelope): unknown {
362
+ try {
363
+ return JSON.parse(envelope.payload) as unknown;
364
+ } catch {
365
+ throw new Error(`legacy envelope ${envelope.envelopeId} payload is malformed`);
366
+ }
367
+ }
368
+
369
+ function eventIdFromLegacyEnvelope(envelope: RelayEnvelope): string | undefined {
370
+ const payload = legacyEnvelopePayload(envelope);
371
+ return isRecord(payload) && typeof payload.eventId === "string" ? payload.eventId : undefined;
372
+ }
373
+
374
+ function insertTaskOperation(database: SqliteDatabase, input: TaskOperationRecord): void {
375
+ database.query(`INSERT INTO task_operations (task_id, operation, logical_id, logical_type, envelope_ids)
376
+ VALUES (?, ?, ?, ?, ?) ON CONFLICT(task_id, operation) DO NOTHING`).run(input.taskId, input.operation, input.logicalId, input.logicalType, JSON.stringify(input.envelopeIds));
377
+ }
378
+
379
+ function ensureColumn(database: SqliteDatabase, table: string, name: string, definition: string): void {
380
+ const columns = database.query(`PRAGMA table_info(${table})`).all() as Array<{ readonly name: string }>;
381
+ if (!columns.some((column) => column.name === name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
178
382
  }
179
383
 
180
384
  function preparePath(path: string): void {
@@ -199,7 +403,7 @@ function taskRow(task: Omit<TaskRecord, "events">): Record<string, string | numb
199
403
  };
200
404
  }
201
405
 
202
- function taskFromRow(row: TaskRow, events: readonly EventRow[]): TaskRecord {
406
+ function taskFromRow(database: SqliteDatabase, row: TaskRow, events: readonly EventRow[]): TaskSnapshot {
203
407
  return {
204
408
  taskId: row.task_id,
205
409
  protocolVersion: row.protocol_version,
@@ -210,6 +414,7 @@ function taskFromRow(row: TaskRow, events: readonly EventRow[]): TaskRecord {
210
414
  expiresAt: row.expires_at,
211
415
  status: row.status as TaskRecord["status"],
212
416
  events: events.map(eventFromRow),
417
+ terminalDelivery: terminalDeliveryFromDatabase(database, row),
213
418
  };
214
419
  }
215
420
 
@@ -233,6 +438,54 @@ function parseEnvelope(value: string): RelayEnvelope {
233
438
  return JSON.parse(value) as RelayEnvelope;
234
439
  }
235
440
 
441
+ function taskOperationFromRow(row: TaskOperationRow): TaskOperationRecord {
442
+ const envelopeIds = JSON.parse(row.envelope_ids) as unknown;
443
+ if (!Array.isArray(envelopeIds) || !envelopeIds.every((value) => typeof value === "string" && value.length > 0)) throw new Error("task operation envelope identities are malformed");
444
+ return { taskId: row.task_id, operation: row.operation, logicalId: row.logical_id, logicalType: row.logical_type, envelopeIds };
445
+ }
446
+
447
+ function terminalDeliveryFromDatabase(database: SqliteDatabase, task: TaskRow): TerminalDeliveryState {
448
+ const operation = database.query("SELECT task_id, operation, logical_id, logical_type, envelope_ids FROM task_operations WHERE task_id = ? AND operation = ?").get(task.task_id, TERMINAL_INTENT_OPERATION) as TaskOperationRow | null;
449
+ if (!operation) return { state: "not_submitted" };
450
+ const record = taskOperationFromRow(operation);
451
+ if (!isTerminalIntentType(record.logicalType) || record.envelopeIds.length !== 1) throw new Error("terminal task operation is malformed");
452
+ const envelopeId = record.envelopeIds[0];
453
+ if (envelopeId === undefined) throw new Error("terminal task operation has no envelope identity");
454
+ const identity = {
455
+ intentId: record.logicalId,
456
+ intentType: record.logicalType,
457
+ envelopeId,
458
+ origin: { relay: task.origin_relay, id: task.origin_id },
459
+ };
460
+ const outbox = database.query("SELECT state FROM outbox WHERE envelope_id = ?").get(identity.envelopeId) as { readonly state: OutboxRecord["state"] } | null;
461
+ if (outbox?.state === "pending") return { state: "pending", ...identity };
462
+ if (outbox?.state === "accepted") return { state: "accepted", ...identity };
463
+ const blocked = database.query("SELECT error_code, details, quarantined_at FROM outbox_quarantine WHERE envelope_id = ?").get(identity.envelopeId) as Pick<OutboxQuarantineRow, "error_code" | "details" | "quarantined_at"> | null;
464
+ if (!blocked) throw new Error("terminal task operation has no durable delivery record");
465
+ return {
466
+ state: "delivery_blocked",
467
+ ...identity,
468
+ blockedAt: blocked.quarantined_at,
469
+ error: { code: blocked.error_code, retryable: false, details: parseRecord(blocked.details) },
470
+ };
471
+ }
472
+
473
+ function isTerminalIntentType(value: string): value is TerminalTaskIntentType {
474
+ return value === "task.completed" || value === "task.failed" || value === "task.cancelled";
475
+ }
476
+
477
+ function parseEndpoint(value: string): TaskEndpoint {
478
+ const endpoint = JSON.parse(value) as unknown;
479
+ if (!isRecord(endpoint) || typeof endpoint.relay !== "string" || endpoint.relay.length === 0 || typeof endpoint.id !== "string" || endpoint.id.length === 0) {
480
+ throw new Error("task store endpoint binding is malformed");
481
+ }
482
+ return { relay: endpoint.relay, id: endpoint.id };
483
+ }
484
+
236
485
  function parseRecord(value: string): Record<string, unknown> {
237
486
  return JSON.parse(value) as Record<string, unknown>;
238
487
  }
488
+
489
+ function isRecord(value: unknown): value is Record<string, unknown> {
490
+ return value !== null && typeof value === "object" && !Array.isArray(value);
491
+ }
@@ -18,7 +18,7 @@ import type {
18
18
  TaskRelay,
19
19
  } from "./task-protocol";
20
20
  import type { TaskCoreOptions } from "./task-core";
21
- import type { TaskStoreOptions } from "./task-store";
21
+ import type { TaskStore, TaskStoreOptions } from "./task-store";
22
22
 
23
23
  export const WOLFPACK_TASK_RELAY_ID = "wolfpack-pi-tasks-v2";
24
24
  export const WOLFPACK_TASK_RELAY_PROTOCOL_VERSION = 2;
@@ -28,6 +28,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
28
28
  const MAX_REQUEST_TIMEOUT_MS = 60_000;
29
29
  const REGISTRATION_POLL_MARGIN_MS = 10_000;
30
30
  const SESSION_STORE_DIRECTORY = "sessions";
31
+ const ENDPOINT_ROTATED_CODE = "ENDPOINT_ROTATED";
31
32
 
32
33
  export interface WolfpackTaskRelayOptions {
33
34
  readonly baseUrl?: string;
@@ -182,6 +183,7 @@ export async function createWolfpackTaskCore(options: WolfpackTaskCoreOptions =
182
183
  const relay = createWolfpackTaskRelay({ ...options, sessionName, generation });
183
184
  try {
184
185
  const endpoint = await relay.endpoint(signal);
186
+ bindRegisteredEndpoint(store, endpoint, options.clock?.now() ?? Date.now());
185
187
  return createTaskCore({ endpoint, relay, store, ...(options.clock === undefined ? {} : { clock: options.clock }), ...(options.ids === undefined ? {} : { ids: options.ids }) });
186
188
  } catch (error) {
187
189
  store.close();
@@ -189,6 +191,28 @@ export async function createWolfpackTaskCore(options: WolfpackTaskCoreOptions =
189
191
  }
190
192
  }
191
193
 
194
+ function bindRegisteredEndpoint(store: TaskStore, endpoint: TaskEndpoint, now: number): void {
195
+ const priorEndpoint = store.getEndpointBinding();
196
+ if (!priorEndpoint) {
197
+ store.transaction(() => { store.setEndpointBinding(endpoint); });
198
+ return;
199
+ }
200
+ if (sameEndpoint(priorEndpoint, endpoint)) return;
201
+ store.transaction(() => {
202
+ store.setEndpointBinding(endpoint);
203
+ store.setReceiveCursor("0");
204
+ for (const record of store.outbox("pending")) {
205
+ if (!sameEndpoint(record.envelope.source, priorEndpoint)) continue;
206
+ store.quarantineOutbox(record.envelope.envelopeId, {
207
+ errorCode: ENDPOINT_ROTATED_CODE,
208
+ reason: "source endpoint rotated before delivery",
209
+ details: { priorEndpoint, endpoint },
210
+ quarantinedAt: now,
211
+ });
212
+ }
213
+ });
214
+ }
215
+
192
216
  function toWolfpackEnvelope(envelope: RelayEnvelope): WolfpackRelayEnvelope {
193
217
  let payload: unknown;
194
218
  try {