@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 +35 -6
- package/package.json +1 -1
- package/skills/wolfpack-pi-task-delegation/SKILL.md +57 -29
- package/src/abortable-sleep.ts +16 -0
- package/src/extension.ts +59 -11
- package/src/legacy-extension.ts +2 -9
- package/src/task-core.ts +63 -16
- package/src/task-inbox.ts +22 -25
- package/src/task-protocol.ts +36 -1
- package/src/task-store.ts +260 -7
- package/src/wolfpack-task-relay.ts +25 -1
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
|
|
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.
|
|
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": {
|
|
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
|
-
|
|
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,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
|
|
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
|
|
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
|
-
##
|
|
10
|
+
## v2 requirements and addressing
|
|
11
11
|
|
|
12
|
-
- every participating Pi process
|
|
13
|
-
- `
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
51
|
-
- do not promise JWT federation, artifact byte transfer, or
|
|
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 {
|
|
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
|
|
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
|
-
}
|
package/src/legacy-extension.ts
CHANGED
|
@@ -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
|
|
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):
|
|
55
|
-
listTasks(): readonly
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
87
|
-
const events =
|
|
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.
|
|
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
|
-
}
|
package/src/task-protocol.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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 =
|
|
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):
|
|
27
|
-
listTasks(): readonly
|
|
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[]):
|
|
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 {
|