@sgtbeatdown/pi-tasks 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,12 +26,21 @@ Wolfpack's relay owns durable mailbox delivery and peer forwarding; Pi owns task
26
26
 
27
27
  ### v2 delegation workflow
28
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.
29
+ Configure Pi role models with `WOLFPACK_IMPLEMENTER_MODEL` and `WOLFPACK_REVIEWER_MODEL`; they default to `openai-codex/gpt-5.6-terra` and `openai-codex/gpt-5.6-sol`, respectively. Explicit user or project choices override those defaults.
30
+
31
+ ```bash
32
+ IMPLEMENTER_MODEL="${WOLFPACK_IMPLEMENTER_MODEL:-openai-codex/gpt-5.6-terra}"
33
+ REVIEWER_MODEL="${WOLFPACK_REVIEWER_MODEL:-openai-codex/gpt-5.6-sol}"
34
+ ```
35
+
36
+ 1. create or select a role session. For a disposable worker, omit an initial assignment prompt and pass the resolved role model: `wolfpack agent spawn <project> --name <task-role> --model "$IMPLEMENTER_MODEL" --json` (or `"$REVIEWER_MODEL"` for review). Put all worker instructions in `agent_task_send.task` so the new Pi process can become idle before assignment.
30
37
  2. verify structured session readiness, wait for extension registration, and read `taskEndpoint` from `wolfpack session status <session> --json`.
31
38
  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
39
  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
40
  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
41
 
42
+ Coordinator-capable agents may delegate further when justified, and the spawning coordinator owns each child's lifecycle. After terminal completion and acknowledgment, deliberately retain the child or run `wolfpack kill <stable-session-id> --json`, then verify that exact ID is absent from `wolfpack list --json`. Never use `wolfpack session send`, `/exit`, or `/quit` for cleanup; terminal input is not Wolfpack teardown.
43
+
35
44
  ### v2 worker-only execution gate
36
45
 
37
46
  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.
@@ -140,7 +149,7 @@ On start or resume it rebuilds incorporated IDs from the full durable session en
140
149
 
141
150
  ## parent workflow
142
151
 
143
- 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`.
152
+ 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 assignment prompt using `wolfpack agent spawn <project> --name <task-role> --model "$IMPLEMENTER_MODEL" --json` (or `"$REVIEWER_MODEL"` for review) so it reaches idle before assignment; put worker instructions in `agent_task_send.task`.
144
153
  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.
145
154
  3. keep working; use `agent_task_status` or `agent_task_inbox` for structured follow-up, and `agent_task_message` for questions, answers, and decisions.
146
155
  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.5",
3
+ "version": "0.1.6",
4
4
  "description": "Durable Pi agent delegation through the Wolfpack task gateway.",
5
5
  "module": "src/index.ts",
6
6
  "exports": {
@@ -7,27 +7,49 @@ description: Use when opening, selecting, delegating to, checking, or cleaning u
7
7
 
8
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
+ ## choose one assignment mode
11
+
12
+ choose exactly one mode for the initial assignment:
13
+
14
+ - **full-startup mode:** spawn with the complete `--plan`, `--prompt-file`, or concise `--prompt`. do not call `agent_task_send` for that same work.
15
+ - **endpoint mode:** spawn without a startup plan or prompt, obtain the structured `taskEndpoint`, then put the complete assignment in one `agent_task_send.task`.
16
+
17
+ never combine a plan-driven spawn with a narrower endpoint task. prefer one cohesive implementation handoff per approved PR or phase; split only at a real approval, design, isolation, or blocker boundary—not per issue, commit, finding, or verification checkpoint. the remaining v2 workflow below describes endpoint mode.
18
+
19
+ ## role model selection
20
+
21
+ Pi role models are explicit and configurable at spawn time:
22
+
23
+ ```bash
24
+ IMPLEMENTER_MODEL="${WOLFPACK_IMPLEMENTER_MODEL:-openai-codex/gpt-5.6-terra}"
25
+ REVIEWER_MODEL="${WOLFPACK_REVIEWER_MODEL:-openai-codex/gpt-5.6-sol}"
26
+ ```
27
+
28
+ Pass `--model "$IMPLEMENTER_MODEL"` when spawning the editing implementer and `--model "$REVIEWER_MODEL"` when spawning the read-only reviewer. an explicit user or project model choice overrides the environment/default. never infer role or model from the session name, and never omit the resolved model because the current parent default may differ.
29
+
10
30
  ## v2 requirements and addressing
11
31
 
12
32
  - 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
33
  - 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`.
34
+ - create a disposable worker without an initial assignment prompt: `wolfpack agent spawn <project> --name <task-role> --model "$IMPLEMENTER_MODEL" --json` (or `"$REVIEWER_MODEL"` for review). Do not start a disposable worker with a blocking “wait for assignments” prompt. Put the complete instructions in `agent_task_send.task`.
15
35
  - 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
36
  - pass the returned endpoint without translation:
17
37
 
18
38
  ```json
19
39
  {
20
40
  "to": { "relay": "wolfpack-pi-tasks-v2", "id": "target-opaque-endpoint-id" },
21
- "task": "implement the narrow change and run focused tests",
22
- "timeoutMs": 1800000
41
+ "task": "implement the approved phase and run focused tests",
42
+ "timeoutMs": 3600000
23
43
  }
24
44
  ```
25
45
 
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.
46
+ 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. use at least `3600000` milliseconds for coding assignments; timeout is a failure deadline, not a progress-poll interval.
27
47
 
28
48
  ## phase roles and handoffs
29
49
 
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.
50
+ For a multi-step project phase, retain one persistent implementer and one persistent read-only reviewer. Give the implementer the whole approved phase, including required commit ordering and focused verification, rather than opening one task per issue or checkpoint. 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.
51
+
52
+ A coordinator-capable agent may delegate further when the quality or throughput gain justifies it. The spawning coordinator owns each child's complete lifecycle: record the stable session ID, acknowledge its terminal task once, then deliberately retain the child or close it with `wolfpack kill <stable-session-id> --json` and verify that exact ID is absent from `wolfpack list --json`.
31
53
 
32
54
  `PI_TASK_WORKER=1` sessions are leaf roles. Workers cannot call `agent_task_send`; workers cannot call `agent_task_cancel`; workers cannot call `agent_task_ack`. These coordinator-tool attempts are blocked with the stable reason `PI_TASK_WORKER_COORDINATION_FORBIDDEN`, not the pre-assignment reason. They may use `agent_task_message` only for the eligible incorporated assignment named by its input `taskId`. Generic role-orchestration guidance applies only to non-worker coordinators. Task workers must not create, rotate, or close Wolfpack sessions through shell or session-control tools.
33
55
 
@@ -40,17 +62,17 @@ After every verified and acknowledged terminal task, explicitly retain a parent-
40
62
  | assignment completion | worker | `agent_task_done` | unchanged |
41
63
  | task acknowledgment | parent | `agent_task_ack` | unchanged |
42
64
  | role session retention | parent | `none` | retained for reuse |
43
- | role session teardown | parent | `wolfpack kill <session-or-id> --json` | exact stable session id terminated |
44
- | teardown verification | parent | `wolfpack list --json` | exact stable session id absent |
45
- | Pi exit | none | `/quit` | not Wolfpack teardown |
65
+ | role session teardown | spawning coordinator | `wolfpack kill <stable-session-id> --json` | exact stable session id terminated |
66
+ | teardown verification | spawning coordinator | `wolfpack list --json` | exact stable session id absent |
67
+ | Pi exit | none | `/exit` or `/quit` | not Wolfpack teardown |
46
68
 
47
- 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.
69
+ Assignment completion ends the assigned task, not the reusable role session. When no further reuse is likely, the coordinator that spawned the role—not the worker itself—must pass the exact stable session ID to `wolfpack kill <stable-session-id> --json`, then inspect `wolfpack list --json` and verify that ID is absent. Never use `wolfpack session send`, `/exit`, or `/quit` for cleanup: they are terminal interaction, not Wolfpack teardown. Do not guess lifecycle commands. Workers never close themselves.
48
70
 
49
71
  ## v2 workflow
50
72
 
51
73
  1. create or select the role session, verify its structured project and terminal readiness, then obtain its registered `taskEndpoint`.
52
74
  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.
53
- 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.
75
+ 3. keep working. Use `agent_task_status` or `agent_task_inbox` only at meaningful checkpoints or when progress evidence is needed; do not turn status checks into lifecycle chatter. Call `agent_task_wait` only when the user explicitly asks to block.
54
76
  4. use `agent_task_message` for durable `question`, `answer`, or `information` flow. Do not use rendered text, terminal output, or logs as lifecycle evidence.
55
77
  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.
56
78
  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.
package/src/extension.ts CHANGED
@@ -5,9 +5,9 @@ import { Type } from "typebox";
5
5
 
6
6
  import { abortableSleep } from "./abortable-sleep";
7
7
  import { deliverTaskInbox, incorporatedTaskEvents } from "./task-inbox";
8
- import { TaskOutboxDeliveryError, TaskProtocolError } from "./task-protocol";
8
+ import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskOutboxDeliveryError, TaskProtocolError } from "./task-protocol";
9
9
  import { createWolfpackTaskCore } from "./wolfpack-task-relay";
10
- import type { TaskCore } from "./task-core";
10
+ import type { SubmitIntentInput, SubmitIntentOutcome, TaskCore } from "./task-core";
11
11
  import type { TaskEndpoint, TaskSnapshot } from "./task-protocol";
12
12
 
13
13
  const DEFAULT_TASK_TIMEOUT_MS = 30 * 60 * 1_000;
@@ -64,7 +64,6 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
64
64
  let inboxContext: ExtensionContext | undefined;
65
65
  let backgroundTimer: ReturnType<typeof setInterval> | undefined;
66
66
  let lifecycleEpoch = 0;
67
- const pendingInsertions = new Set<string>();
68
67
  const closingTaskIds = new Set<string>();
69
68
  const workerGateEnabled = process.env.PI_TASK_WORKER === "1";
70
69
  const configuredCore: ConfiguredCoreFactory = core === undefined ? createConfiguredCoreLoader(createCore) : async (): Promise<TaskCore> => core;
@@ -104,9 +103,10 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
104
103
  sendMessage(message, options) { if (isCurrent()) pi.sendMessage(message, options); },
105
104
  appendEntry(customType, data) { if (isCurrent()) pi.appendEntry(customType, data); },
106
105
  }, guardedCore, {
106
+ isIdle: (): boolean => isCurrent() && context.isIdle(),
107
107
  hasPendingMessages: (): boolean => !isCurrent() || context.hasPendingMessages(),
108
108
  sessionManager: context.sessionManager,
109
- }, pendingInsertions, signal);
109
+ }, signal);
110
110
  return outboxError;
111
111
  });
112
112
  const refreshLifecycle = async (context: ExtensionContext, epoch: number): Promise<void> => {
@@ -157,7 +157,6 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
157
157
  });
158
158
 
159
159
  pi.on("session_start", async (_event, context) => {
160
- pendingInsertions.clear();
161
160
  closingTaskIds.clear();
162
161
  const epoch = ++lifecycleEpoch;
163
162
  inboxContext = context;
@@ -173,8 +172,12 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
173
172
  inboxContext = context;
174
173
  await refreshLifecycle(context, epoch);
175
174
  });
175
+ pi.on("agent_settled", async (_event, context) => {
176
+ const epoch = lifecycleEpoch;
177
+ inboxContext = context;
178
+ await refreshLifecycle(context, epoch);
179
+ });
176
180
  pi.on("session_shutdown", () => {
177
- pendingInsertions.clear();
178
181
  closingTaskIds.clear();
179
182
  lifecycleEpoch += 1;
180
183
  if (backgroundTimer) clearInterval(backgroundTimer);
@@ -194,10 +197,13 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
194
197
  });
195
198
  pi.registerTool({
196
199
  name: "agent_task_status", label: "Task Status", description: "Read local endpoint-owned task state; status is unavailable while its origin is offline.", parameters: TaskIdParams,
197
- async execute(_id, params, signal) {
200
+ async execute(_id, params, signal, _onUpdate, context) {
198
201
  try {
199
- const task = (await configuredCore(signal)).getTask(params.taskId);
200
- return task ? toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`) : taskError(new Error("unknown local task"));
202
+ const activeCore = await configuredCore(signal);
203
+ const task = activeCore.getTask(params.taskId);
204
+ if (!task) return taskError(new Error("unknown local task"));
205
+ const deliveryEvidence = taskDeliveryEvidence(task);
206
+ return toolResult({ ...task, deliveryEvidence }, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}${receiverAssignment(activeCore, task, context)}${deliveryEvidenceText(deliveryEvidence)}`);
201
207
  } catch (error) { return taskError(error); }
202
208
  }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
203
209
  });
@@ -220,11 +226,12 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
220
226
  });
221
227
  pi.registerTool({
222
228
  name: "agent_task_inbox", label: "Task Inbox", description: "Read local task records after processing relay deliveries without acknowledging task lifecycle.", parameters: Type.Object({}),
223
- async execute(_id, _params, signal) {
229
+ async execute(_id, _params, signal, _onUpdate, context) {
224
230
  try {
225
231
  await refreshInbox(signal);
226
- const tasks = (await configuredCore(signal)).listTasks();
227
- return toolResult({ tasks }, `## task inbox\n${tasks.map((task) => `- \`${task.taskId}\`: ${task.status}`).join("\n") || "- empty"}`);
232
+ const activeCore = await configuredCore(signal);
233
+ const tasks = activeCore.listTasks();
234
+ return toolResult({ tasks }, `## task inbox\n${tasks.map((task) => `- \`${task.taskId}\`: ${task.status}${receiverAssignment(activeCore, task, context)}`).join("\n") || "- empty"}`);
228
235
  } catch (error) { return taskError(error); }
229
236
  },
230
237
  renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
@@ -258,8 +265,32 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
258
265
  name: "agent_task_done", label: "Complete Agent Task", description: "Persist a terminal task intent before relay submission.", parameters: DoneParams,
259
266
  async execute(_id, params, signal) {
260
267
  try {
261
- await (await configuredCore(signal)).submitIntent({ taskId: params.taskId, type: `task.${params.status}`, payload: { summary: params.summary, ...(params.result === undefined ? {} : { result: params.result }), ...(params.error === undefined ? {} : { error: params.error }), ...(params.artifacts === undefined ? {} : { artifacts: params.artifacts }) } }, signal);
262
- return { ...toolResult({ taskId: params.taskId }, `## task ${params.status}\n- task: \`${params.taskId}\`\n- ${params.summary}`), terminate: true };
268
+ const activeCore = await configuredCore(signal);
269
+ let submission: SubmitIntentOutcome | undefined;
270
+ try {
271
+ const input: SubmitIntentInput = { taskId: params.taskId, type: `task.${params.status}`, payload: { summary: params.summary, ...(params.result === undefined ? {} : { result: params.result }), ...(params.error === undefined ? {} : { error: params.error }), ...(params.artifacts === undefined ? {} : { artifacts: params.artifacts }) } };
272
+ if (activeCore.submitIntentWithOutcome) submission = await activeCore.submitIntentWithOutcome(input, signal);
273
+ else await activeCore.submitIntent(input, signal);
274
+ } catch (error) {
275
+ return blockedDoneResult(activeCore, params.taskId, params.status, error) ?? taskError(error);
276
+ }
277
+ const task = activeCore.getTask(params.taskId);
278
+ if (!task) return taskError(new Error("unknown local task"));
279
+ if (submission?.authority === "origin" && submission.canonicalEvent.type === "task.late_terminal") {
280
+ return {
281
+ ...toolResult({ taskId: params.taskId, requestedStatus: params.status, observedCanonicalStatus: task.status, canonicalEvent: submission.canonicalEvent }, `## canonical late terminal recorded\n- task: \`${params.taskId}\`\n- requested status: ${params.status}\n- canonical status remains: ${task.status}\n- ${params.summary}`),
282
+ terminate: true,
283
+ };
284
+ }
285
+ if (submission?.authority === "origin" && task.status === params.status) {
286
+ return {
287
+ ...toolResult({ taskId: params.taskId, requestedStatus: params.status, observedCanonicalStatus: task.status, canonicalCompletion: { state: "confirmed", status: task.status } }, `## task ${task.status}\n- task: \`${params.taskId}\`\n- canonical status: ${task.status}\n- ${params.summary}`),
288
+ terminate: true,
289
+ };
290
+ }
291
+ const deliveryOutcome = task.terminalDelivery.state === "accepted" ? "accepted" : "recorded";
292
+ const result = toolResult({ taskId: params.taskId, requestedStatus: params.status, observedCanonicalStatus: task.status, terminalDelivery: task.terminalDelivery }, `## terminal intent ${deliveryOutcome}\n- task: \`${params.taskId}\`\n- requested status: ${params.status}\n- observed canonical status: ${task.status}\n- ${params.summary}`);
293
+ return task.terminalDelivery.state === "accepted" || terminal(task.status) ? { ...result, terminate: true } : result;
263
294
  } catch (error) { return taskError(error); }
264
295
  }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
265
296
  });
@@ -281,6 +312,37 @@ export default function piTasks(pi: ExtensionAPI): void {
281
312
  registerAgentTaskTools(pi);
282
313
  }
283
314
 
315
+ interface TaskDeliveryEvidence {
316
+ readonly receiverPersistence: "not_confirmed" | "confirmed";
317
+ readonly piInsertion: "not_confirmed" | "blocked" | "confirmed";
318
+ readonly wakeAcceptance: "not_confirmed" | "pending" | "confirmed";
319
+ readonly modelExecution: "not_evidenced";
320
+ }
321
+
322
+ function taskDeliveryEvidence(task: TaskSnapshot): TaskDeliveryEvidence {
323
+ let receiverPersistence: TaskDeliveryEvidence["receiverPersistence"] = "not_confirmed";
324
+ let piInsertion: TaskDeliveryEvidence["piInsertion"] = "not_confirmed";
325
+ let wakeAcceptance: TaskDeliveryEvidence["wakeAcceptance"] = "not_confirmed";
326
+ const assignmentEventId = task.events.find((event) => event.type === "task.created")?.eventId;
327
+ for (const event of task.events) {
328
+ if (event.type !== "task.delivery_receipt" || event.payload.eventId !== assignmentEventId) continue;
329
+ if (event.payload.stage === TaskDeliveryStage.receiverPersisted) receiverPersistence = "confirmed";
330
+ if (event.payload.stage === TaskDeliveryStage.piInsertion && event.payload.state === TaskDeliveryEvidenceState.blocked) piInsertion = "blocked";
331
+ if (event.payload.stage === TaskDeliveryStage.piInserted || event.payload.stage === undefined) {
332
+ receiverPersistence = "confirmed";
333
+ piInsertion = "confirmed";
334
+ }
335
+ if (event.payload.stage === TaskDeliveryStage.wakeRequested) wakeAcceptance = "pending";
336
+ if (event.payload.stage === TaskDeliveryStage.wakeAccepted) wakeAcceptance = "confirmed";
337
+ }
338
+ return { receiverPersistence, piInsertion, wakeAcceptance, modelExecution: "not_evidenced" };
339
+ }
340
+
341
+ function deliveryEvidenceText(evidence: TaskDeliveryEvidence): string {
342
+ const insertion = evidence.piInsertion === "blocked" ? "blocked; retryable" : evidence.piInsertion;
343
+ return `\n- receiver persistence: ${evidence.receiverPersistence}\n- Pi insertion: ${insertion}\n- wake acceptance: ${evidence.wakeAcceptance}\n- model execution: ${evidence.modelExecution}`;
344
+ }
345
+
284
346
  function toolResult(details: unknown, markdown: string): AgentToolResult<unknown> {
285
347
  return { content: [{ type: "text", text: markdown }], details };
286
348
  }
@@ -294,26 +356,41 @@ function taskError(error: unknown): AgentToolResult<unknown> {
294
356
  }
295
357
 
296
358
  function blockedCancellationResult(core: TaskCore, taskId: string, error: unknown): AgentToolResult<unknown> | undefined {
297
- if (!(error instanceof TaskOutboxDeliveryError) || error.code !== TARGET_NOT_REGISTERED_CODE || error.retryable || error.details?.taskId !== taskId) return undefined;
359
+ if (!(error instanceof TaskOutboxDeliveryError) || error.details?.taskId !== taskId) return undefined;
298
360
  const task = core.getTask(taskId);
299
- if (task?.status !== "cancelled" || !sameEndpoint(task.origin, core.endpoint)) return undefined;
361
+ const warning = task === undefined ? undefined : blockedDeliveryWarning(error, task.target);
362
+ if (task?.status !== "cancelled" || !sameEndpoint(task.origin, core.endpoint) || warning === undefined) return undefined;
363
+ return toolResult({ taskId, status: "cancelled", warnings: [warning] }, `## task cancelled\n- task: \`${taskId}\`\n- canonical status: cancelled\n- target delivery: blocked; receiver incorporation not confirmed`);
364
+ }
365
+
366
+ function blockedDoneResult(core: TaskCore, taskId: string, requestedStatus: string, error: unknown): AgentToolResult<unknown> | undefined {
367
+ if (!(error instanceof TaskOutboxDeliveryError)) return undefined;
368
+ const task = core.getTask(taskId);
369
+ if (task === undefined) return undefined;
370
+ if (!sameEndpoint(task.origin, core.endpoint) && task.terminalDelivery.state === "delivery_blocked" && task.terminalDelivery.intentType === `task.${requestedStatus}`) {
371
+ return toolResult({
372
+ taskId,
373
+ status: task.status,
374
+ terminalDelivery: task.terminalDelivery,
375
+ error: { code: error.code, message: error.message, retryable: error.retryable },
376
+ }, `## terminal intent delivery blocked\n- task: \`${taskId}\`\n- canonical status: ${task.status}\n- canonical completion not confirmed; retry remains allowed`);
377
+ }
378
+ const warning = blockedDeliveryWarning(error, task.target);
379
+ if (!sameEndpoint(task.origin, core.endpoint) || task.status !== requestedStatus || warning === undefined) return undefined;
380
+ return {
381
+ ...toolResult({ taskId, status: task.status, warnings: [warning] }, `## task ${task.status}\n- task: \`${taskId}\`\n- canonical status: ${task.status}\n- target delivery: blocked; receiver incorporation not confirmed`),
382
+ terminate: true,
383
+ };
384
+ }
385
+
386
+ function blockedDeliveryWarning(error: TaskOutboxDeliveryError, target: TaskEndpoint): Readonly<Record<string, unknown>> | undefined {
387
+ if (error.code !== TARGET_NOT_REGISTERED_CODE || error.retryable) return undefined;
300
388
  const details = { ...error.details };
301
389
  delete details.taskId;
302
390
  delete details.envelopeId;
303
391
  delete details.target;
304
392
  delete details.blockedAt;
305
- return toolResult({
306
- taskId,
307
- status: "cancelled",
308
- warnings: [{
309
- code: error.code,
310
- message: error.message,
311
- retryable: false,
312
- delivery: "blocked",
313
- target: task.target,
314
- details,
315
- }],
316
- }, `## task cancelled\n- task: \`${taskId}\`\n- canonical status: cancelled\n- target delivery: blocked; receiver incorporation not confirmed`);
393
+ return { code: error.code, message: error.message, retryable: false, delivery: "blocked", target, details };
317
394
  }
318
395
 
319
396
  function outboxFailureStatus(error: unknown): "tasks: relay unavailable" | "tasks: outbox degraded" {
@@ -324,6 +401,13 @@ function terminal(status: string): boolean {
324
401
  return ["completed", "failed", "cancelled", "timed_out"].includes(status);
325
402
  }
326
403
 
404
+ function receiverAssignment(core: TaskCore, task: TaskSnapshot, context: Pick<ExtensionContext, "sessionManager">): string {
405
+ if (task.status !== "active" || sameEndpoint(task.origin, core.endpoint) || !sameEndpoint(task.target, core.endpoint)) return "";
406
+ const created = task.events.find((event) => event.type === "task.created");
407
+ if (!created || !incorporatedTaskEvents(context.sessionManager.getEntries()).some((evidence) => evidence.taskId === task.taskId && evidence.eventId === created.eventId)) return "";
408
+ return `\n\n## task assignment\n${task.task}`;
409
+ }
410
+
327
411
  function assignedWorkerTasks(core: TaskCore, entries: readonly unknown[]): readonly TaskSnapshot[] {
328
412
  const assignments = new Map<string, TaskSnapshot>();
329
413
  for (const evidence of incorporatedTaskEvents(entries)) {
package/src/index.ts CHANGED
@@ -31,5 +31,5 @@ export type {
31
31
  TaskRecord,
32
32
  TaskRelay,
33
33
  } from "./task-protocol";
34
- export type { TaskCore, TaskCoreOptions } from "./task-core";
34
+ export type { SubmitIntentOutcome, TaskCore, TaskCoreOptions } from "./task-core";
35
35
  export type { TaskStore, TaskStoreOptions } from "./task-store";
package/src/task-core.ts CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  PARENT_ACKNOWLEDGMENT_OPERATION,
5
5
  TASK_PROTOCOL_VERSION,
6
6
  TERMINAL_INTENT_OPERATION,
7
+ TaskDeliveryEvidenceState,
8
+ TaskDeliveryStage,
7
9
  TaskEnvelopeKind,
8
10
  TaskOutboxDeliveryError,
9
11
  TaskProtocolError,
@@ -21,6 +23,7 @@ import type {
21
23
  import type { TaskStore } from "./task-store";
22
24
 
23
25
  const RECEIVE_PAGE_SIZE = 100;
26
+ const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
24
27
  const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
25
28
  const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
26
29
  const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
@@ -53,6 +56,10 @@ export interface SubmitIntentInput {
53
56
  readonly payload: Record<string, unknown>;
54
57
  }
55
58
 
59
+ export type SubmitIntentOutcome =
60
+ | { readonly authority: "origin"; readonly canonicalEvent: { readonly type: string; readonly reused: boolean } }
61
+ | { readonly authority: "receiver" };
62
+
56
63
  export interface TaskCore {
57
64
  readonly endpoint: TaskEndpoint;
58
65
  connect(signal?: AbortSignal): Promise<void>;
@@ -63,6 +70,7 @@ export interface TaskCore {
63
70
  receive(signal?: AbortSignal): Promise<readonly RelayDelivery[]>;
64
71
  acknowledgeRelayDelivery(cursor: string, signal?: AbortSignal): Promise<void>;
65
72
  submitIntent(input: SubmitIntentInput, signal?: AbortSignal): Promise<void>;
73
+ submitIntentWithOutcome?(input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome>;
66
74
  recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal): Promise<void>;
67
75
  evaluateTimeouts(signal?: AbortSignal): Promise<void>;
68
76
  acknowledgeParent(taskId: string, signal?: AbortSignal): Promise<void>;
@@ -76,6 +84,36 @@ type ReceivedEnvelope =
76
84
  export function createTaskCore(options: TaskCoreOptions): TaskCore {
77
85
  const clock = options.clock ?? { now: (): number => Date.now() };
78
86
  const ids = options.ids ?? (() => crypto.randomUUID());
87
+ const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome> => {
88
+ if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
89
+ let persistedEnvelopeIds: readonly string[] = [];
90
+ let outcome: SubmitIntentOutcome | undefined;
91
+ let receiverTerminal = false;
92
+ let originCancellation = false;
93
+ const evidence = deliveryEvidence(input);
94
+ options.store.transaction(() => {
95
+ const task = requiredTask(options.store, input.taskId);
96
+ receiverTerminal = !sameEndpoint(options.endpoint, task.origin) && TERMINAL_EVENTS.has(input.type);
97
+ originCancellation = sameEndpoint(options.endpoint, task.origin) && input.type === "task.cancelled";
98
+ const persisted = persistIntent(options, clock.now, ids, task, input, evidence === undefined ? undefined : deliveryEvidenceOperation(evidence));
99
+ persistedEnvelopeIds = persisted.envelopeIds;
100
+ outcome = persisted.outcome;
101
+ });
102
+ if (evidence !== undefined) {
103
+ await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
104
+ } else {
105
+ try {
106
+ await flush(options, signal, new Set(persistedEnvelopeIds));
107
+ } catch (error) {
108
+ if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
109
+ throw error;
110
+ }
111
+ if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
112
+ }
113
+ if (receiverTerminal) throwIfTerminalDeliveryBlocked(options.store, input.taskId);
114
+ if (outcome === undefined) throw new TaskProtocolError("INVALID_INTENT", "task intent outcome was not persisted");
115
+ return outcome;
116
+ };
79
117
 
80
118
  return {
81
119
  endpoint: options.endpoint,
@@ -138,35 +176,21 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
138
176
  await options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor }, signal);
139
177
  options.store.transaction(() => { options.store.setReceiveCursor(cursor); });
140
178
  },
141
- async submitIntent(input, signal) {
142
- if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
143
- let persistedEnvelopeIds: readonly string[] = [];
144
- let receiverTerminal = false;
145
- let originCancellation = false;
146
- options.store.transaction(() => {
147
- const task = requiredTask(options.store, input.taskId);
148
- receiverTerminal = !sameEndpoint(options.endpoint, task.origin) && TERMINAL_EVENTS.has(input.type);
149
- originCancellation = sameEndpoint(options.endpoint, task.origin) && input.type === "task.cancelled";
150
- persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, input);
151
- });
152
- try {
153
- await flush(options, signal, new Set(persistedEnvelopeIds));
154
- } catch (error) {
155
- if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
156
- throw error;
157
- }
158
- if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
159
- if (receiverTerminal) throwIfTerminalDeliveryBlocked(options.store, input.taskId);
160
- },
179
+ async submitIntent(input, signal) { await submitIntentWithOutcome(input, signal); },
180
+ submitIntentWithOutcome,
161
181
  async recordInsertion(input, signal) {
162
182
  const task = requiredTask(options.store, input.taskId);
183
+ const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.piInserted, state: TaskDeliveryEvidenceState.confirmed };
163
184
  let persistedEnvelopeIds: readonly string[] = [];
164
185
  options.store.transaction(() => {
165
- if (options.store.putInsertionReceipt(input.taskId, input.eventId)) {
166
- persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, { taskId: input.taskId, type: "task.delivery_receipt", payload: { eventId: input.eventId } });
167
- }
186
+ options.store.putInsertionReceipt(input.taskId, input.eventId);
187
+ persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, {
188
+ taskId: input.taskId,
189
+ type: "task.delivery_receipt",
190
+ payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
191
+ }, deliveryEvidenceOperation(evidence)).envelopeIds;
168
192
  });
169
- if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
193
+ await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
170
194
  },
171
195
  async evaluateTimeouts(signal) {
172
196
  const persistedEnvelopeIds: string[] = [];
@@ -175,7 +199,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
175
199
  options.store.transaction(() => {
176
200
  const task = options.store.getTask(candidate.taskId);
177
201
  if (!task || !sameEndpoint(task.origin, options.endpoint) || TERMINAL_STATUSES.has(task.status) || task.expiresAt > clock.now()) return;
178
- persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out"));
202
+ persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out").envelopeIds);
179
203
  });
180
204
  }
181
205
  if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
@@ -186,7 +210,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
186
210
  const task = requiredTask(options.store, taskId);
187
211
  if (!sameEndpoint(task.origin, options.endpoint)) throw new TaskProtocolError("NOT_ORIGIN", "only origin may acknowledge a task");
188
212
  if (!TERMINAL_STATUSES.has(task.status)) throw new TaskProtocolError("TASK_NOT_TERMINAL", "only a terminal task may be acknowledged", { retryable: false });
189
- persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged", PARENT_ACKNOWLEDGMENT_OPERATION);
213
+ persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged", PARENT_ACKNOWLEDGMENT_OPERATION).envelopeIds;
190
214
  });
191
215
  await flush(options, signal, new Set(persistedEnvelopeIds));
192
216
  },
@@ -244,42 +268,80 @@ function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, id
244
268
  options.store.appendEvent(received.event);
245
269
  return [];
246
270
  }
247
- if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type);
271
+ if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type).envelopeIds;
248
272
  if (options.store.appendEvent(received.event) && TERMINAL_EVENTS.has(received.event.type)) options.store.setStatus(received.task.taskId, statusFor(received.event.type));
249
273
  return [];
250
274
  }
251
275
 
252
- function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput): readonly string[] {
276
+ interface PersistedIntent {
277
+ readonly envelopeIds: readonly string[];
278
+ readonly outcome: SubmitIntentOutcome;
279
+ }
280
+
281
+ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput, reservedOperation?: string): PersistedIntent {
253
282
  if (sameEndpoint(options.endpoint, task.origin)) {
254
- const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : undefined;
255
- return canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation);
283
+ const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
284
+ const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation);
285
+ return { envelopeIds: canonical.envelopeIds, outcome: { authority: "origin", canonicalEvent: { type: canonical.eventType, reused: canonical.reused } } };
256
286
  }
257
287
  const envelopeId = ids();
258
288
  const intent: TaskIntent = { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload };
259
- if (TERMINAL_EVENTS.has(input.type)) {
289
+ const operation = TERMINAL_EVENTS.has(input.type) ? TERMINAL_INTENT_OPERATION : reservedOperation;
290
+ if (operation !== undefined) {
260
291
  const reservation = options.store.reserveTaskOperation({
261
292
  taskId: input.taskId,
262
- operation: TERMINAL_INTENT_OPERATION,
293
+ operation,
263
294
  logicalId: intent.intentId,
264
295
  logicalType: intent.type,
265
296
  envelopeIds: [envelopeId],
266
297
  });
267
298
  if (!reservation.created) {
268
- if (reservation.record.logicalType !== intent.type) {
299
+ if (operation === TERMINAL_INTENT_OPERATION && reservation.record.logicalType !== intent.type) {
269
300
  throw new TaskProtocolError("TERMINAL_INTENT_CONFLICT", "terminal task intent conflicts with the existing terminal action", {
270
301
  retryable: false,
271
302
  details: { taskId: input.taskId, existingType: reservation.record.logicalType, requestedType: intent.type },
272
303
  });
273
304
  }
274
- return reservation.record.envelopeIds;
305
+ return { envelopeIds: reservation.record.envelopeIds, outcome: { authority: "receiver" } };
275
306
  }
276
307
  }
277
308
  options.store.putIntent(intent.intentId, input.taskId, envelopeId);
278
309
  options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent));
279
- return [envelopeId];
310
+ return { envelopeIds: [envelopeId], outcome: { authority: "receiver" } };
311
+ }
312
+
313
+ interface DeliveryEvidence {
314
+ readonly eventId: string;
315
+ readonly stage: TaskDeliveryStage;
316
+ readonly state: TaskDeliveryEvidenceState;
317
+ readonly retryable?: true;
318
+ }
319
+
320
+ function deliveryEvidence(input: SubmitIntentInput): DeliveryEvidence | undefined {
321
+ if (input.type !== "task.delivery_receipt" || typeof input.payload.eventId !== "string") return undefined;
322
+ const stage = input.payload.stage;
323
+ const state = input.payload.state;
324
+ if (stage === TaskDeliveryStage.piInsertion && state === TaskDeliveryEvidenceState.blocked && input.payload.retryable === true) {
325
+ return { eventId: input.payload.eventId, stage, state, retryable: true };
326
+ }
327
+ if ((stage === TaskDeliveryStage.receiverPersisted || stage === TaskDeliveryStage.piInserted || stage === TaskDeliveryStage.wakeRequested || stage === TaskDeliveryStage.wakeAccepted)
328
+ && state === TaskDeliveryEvidenceState.confirmed) {
329
+ return { eventId: input.payload.eventId, stage, state };
330
+ }
331
+ return undefined;
332
+ }
333
+
334
+ function deliveryEvidenceOperation(input: DeliveryEvidence): string {
335
+ return `${DELIVERY_EVIDENCE_OPERATION}:${input.eventId}:${input.stage}:${input.state}`;
280
336
  }
281
337
 
282
- function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string): readonly string[] {
338
+ interface Canonicalization {
339
+ readonly envelopeIds: readonly string[];
340
+ readonly eventType: string;
341
+ readonly reused: boolean;
342
+ }
343
+
344
+ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string): Canonicalization {
283
345
  const terminal = TERMINAL_EVENTS.has(requestedType);
284
346
  const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
285
347
  const sequence = String(task.events.length + 1);
@@ -289,13 +351,23 @@ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => st
289
351
  const persistedEnvelopeIds = originEnvelope === undefined ? [targetEnvelope.envelopeId] : [targetEnvelope.envelopeId, originEnvelope.envelopeId];
290
352
  if (operation !== undefined) {
291
353
  const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
292
- if (!reservation.created) return reservation.record.envelopeIds;
354
+ if (!reservation.created) return { envelopeIds: reservation.record.envelopeIds, eventType: reservation.record.logicalType, reused: true };
293
355
  }
294
356
  options.store.appendEvent(canonical);
295
357
  if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
296
358
  options.store.putOutbox(targetEnvelope);
297
359
  if (originEnvelope !== undefined) options.store.putOutbox(originEnvelope);
298
- return persistedEnvelopeIds;
360
+ return { envelopeIds: persistedEnvelopeIds, eventType: type, reused: false };
361
+ }
362
+
363
+ async function flushDeliveryEvidence(options: TaskCoreOptions, signal: AbortSignal | undefined, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence): Promise<void> {
364
+ try {
365
+ if (envelopeIds.length > 0) await flush(options, signal, new Set(envelopeIds));
366
+ } catch (error) {
367
+ throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
368
+ throw error;
369
+ }
370
+ throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
299
371
  }
300
372
 
301
373
  async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined, reportedEnvelopeIds?: ReadonlySet<string>): Promise<void> {
@@ -359,6 +431,24 @@ function throwIfCancellationDeliveryBlocked(options: TaskCoreOptions, taskId: st
359
431
  });
360
432
  }
361
433
 
434
+ function throwIfDeliveryEvidenceBlocked(store: TaskStore, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence): void {
435
+ const blocked = store.quarantinedOutbox().find((record) => envelopeIds.includes(record.envelope.envelopeId));
436
+ if (blocked === undefined) return;
437
+ throw new TaskOutboxDeliveryError(blocked.errorCode, "task delivery evidence is blocked", {
438
+ retryable: false,
439
+ details: {
440
+ ...blocked.details,
441
+ taskId,
442
+ eventId: evidence.eventId,
443
+ stage: evidence.stage,
444
+ state: evidence.state,
445
+ envelopeId: blocked.envelope.envelopeId,
446
+ target: blocked.envelope.target,
447
+ blockedAt: blocked.quarantinedAt,
448
+ },
449
+ });
450
+ }
451
+
362
452
  function throwIfTerminalDeliveryBlocked(store: TaskStore, taskId: string): void {
363
453
  const delivery = requiredTask(store, taskId).terminalDelivery;
364
454
  if (delivery.state !== "delivery_blocked") return;
package/src/task-inbox.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  import type { TaskCore } from "./task-core";
2
- import { TaskEnvelopeKind, TaskProtocolError } from "./task-protocol";
2
+ import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskEnvelopeKind, TaskProtocolError } from "./task-protocol";
3
3
  import type { RelayDelivery, TaskEvent } from "./task-protocol";
4
4
 
5
5
  const TASK_EVENT_CUSTOM_TYPE = "pi-tasks-event";
6
+ const TASK_WAKE_CUSTOM_TYPE = "pi-tasks-wake";
6
7
  const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
7
8
 
8
9
  interface InboxContext {
10
+ readonly isIdle: () => boolean;
9
11
  readonly hasPendingMessages: () => boolean;
10
12
  readonly sessionManager: { readonly getEntries: () => readonly unknown[] };
11
13
  }
12
14
 
13
15
  interface InboxPi {
14
- sendMessage(message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails }, options: { readonly triggerTurn: true; readonly deliverAs: "followUp" }): void;
16
+ sendMessage(
17
+ message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails },
18
+ options: { readonly triggerTurn: false } | { readonly triggerTurn: true; readonly deliverAs: "followUp" },
19
+ ): void;
15
20
  appendEntry(customType: string, data: { readonly cursor: string }): void;
16
21
  }
17
22
 
@@ -20,8 +25,12 @@ export interface TaskEventDetails {
20
25
  readonly eventId: string;
21
26
  }
22
27
 
23
- /** Inserts durable Pi evidence before allowing the relay mailbox cursor to advance. */
24
- export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, pendingInsertions: Set<string>, signal?: AbortSignal): Promise<void> {
28
+ type DeliveryEvidencePayload =
29
+ | { readonly stage: typeof TaskDeliveryStage.receiverPersisted | typeof TaskDeliveryStage.wakeRequested | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
30
+ | { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
31
+
32
+ /** Persists model-visible Pi evidence before advancing the relay cursor, then starts one separate turn. */
33
+ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal): Promise<void> {
25
34
  if (context.hasPendingMessages()) return;
26
35
  const deliveries = await core.receive(signal);
27
36
  for (const delivery of deliveries) {
@@ -33,28 +42,52 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
33
42
  const event = inboxEvent(delivery);
34
43
  if (!isKnownEvent(event.type)) throw new TaskProtocolError("UNKNOWN_EVENT", `unknown task inbox event type: ${event.type}`);
35
44
  if (context.hasPendingMessages()) return;
45
+ const eventDetails = { taskId: event.taskId, eventId: event.eventId };
36
46
  const eventKey = key(event.taskId, event.eventId);
37
- const incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
38
- if (incorporated) pendingInsertions.delete(eventKey);
47
+ if (event.type === "task.created") {
48
+ await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverPersisted, state: TaskDeliveryEvidenceState.confirmed }, signal);
49
+ }
50
+ let incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
39
51
  if (!incorporated && isModelVisible(event.type)) {
40
- if (!pendingInsertions.has(eventKey)) {
52
+ if (!context.isIdle()) return;
53
+ pi.sendMessage({
54
+ customType: TASK_EVENT_CUSTOM_TYPE,
55
+ content: renderTaskEvent(event),
56
+ display: true,
57
+ details: eventDetails,
58
+ }, { triggerTurn: false });
59
+ incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
60
+ if (!incorporated) {
61
+ await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, signal);
62
+ return;
63
+ }
64
+ }
65
+ if (isModelVisible(event.type)) {
66
+ await core.recordInsertion(eventDetails, signal);
67
+ let wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
68
+ if (!wakeAccepted) {
69
+ if (!context.isIdle()) return;
70
+ await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed }, signal);
41
71
  pi.sendMessage({
42
- customType: TASK_EVENT_CUSTOM_TYPE,
43
- content: renderTaskEvent(event),
44
- display: true,
45
- details: { taskId: event.taskId, eventId: event.eventId },
72
+ customType: TASK_WAKE_CUSTOM_TYPE,
73
+ content: "Process the pending Pi task event.",
74
+ display: false,
75
+ details: eventDetails,
46
76
  }, { triggerTurn: true, deliverAs: "followUp" });
47
- pendingInsertions.add(eventKey);
77
+ wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
78
+ if (!wakeAccepted) return;
48
79
  }
49
- if (!incorporatedEvents(context.sessionManager.getEntries()).has(eventKey)) return;
50
- pendingInsertions.delete(eventKey);
80
+ await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
51
81
  }
52
- if (isModelVisible(event.type)) await core.recordInsertion({ taskId: event.taskId, eventId: event.eventId }, signal);
53
82
  await core.acknowledgeRelayDelivery(delivery.cursor, signal);
54
83
  pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
55
84
  }
56
85
  }
57
86
 
87
+ async function recordDeliveryEvidence(core: TaskCore, event: TaskEventDetails, evidence: DeliveryEvidencePayload, signal?: AbortSignal): Promise<void> {
88
+ await core.submitIntent({ taskId: event.taskId, type: "task.delivery_receipt", payload: { eventId: event.eventId, ...evidence } }, signal);
89
+ }
90
+
58
91
  function inboxEvent(delivery: RelayDelivery): TaskEvent {
59
92
  try {
60
93
  const payload = JSON.parse(delivery.envelope.payload) as unknown;
@@ -81,16 +114,24 @@ function renderTaskEvent(event: TaskEvent): string {
81
114
  }
82
115
 
83
116
  export function incorporatedTaskEvents(entries: readonly unknown[]): readonly TaskEventDetails[] {
117
+ return taskMessageDetails(entries, TASK_EVENT_CUSTOM_TYPE);
118
+ }
119
+
120
+ function taskMessageDetails(entries: readonly unknown[], customType: string): readonly TaskEventDetails[] {
84
121
  const events: TaskEventDetails[] = [];
85
122
  for (const entry of entries) {
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;
123
+ if (!isRecord(entry) || entry.type !== "custom_message" || entry.customType !== customType || !isRecord(entry.details) || typeof entry.details.taskId !== "string" || typeof entry.details.eventId !== "string") continue;
87
124
  events.push({ taskId: entry.details.taskId, eventId: entry.details.eventId });
88
125
  }
89
126
  return events;
90
127
  }
91
128
 
129
+ function taskMessageKeys(entries: readonly unknown[], customType: string): Set<string> {
130
+ return new Set(taskMessageDetails(entries, customType).map((event) => key(event.taskId, event.eventId)));
131
+ }
132
+
92
133
  function incorporatedEvents(entries: readonly unknown[]): Set<string> {
93
- return new Set(incorporatedTaskEvents(entries).map((event) => key(event.taskId, event.eventId)));
134
+ return taskMessageKeys(entries, TASK_EVENT_CUSTOM_TYPE);
94
135
  }
95
136
 
96
137
  function isTaskEvent(value: unknown): value is TaskEvent {
@@ -4,6 +4,19 @@ export const ORIGIN_CANCELLATION_OPERATION = "origin_cancellation";
4
4
  export const PARENT_ACKNOWLEDGMENT_OPERATION = "parent_acknowledgment";
5
5
  export const TERMINAL_INTENT_OPERATION = "terminal_intent";
6
6
 
7
+ export const TaskDeliveryStage = {
8
+ receiverPersisted: "receiver_persisted",
9
+ piInsertion: "pi_insertion",
10
+ piInserted: "pi_inserted",
11
+ wakeRequested: "wake_requested",
12
+ wakeAccepted: "wake_accepted",
13
+ } as const;
14
+
15
+ export const TaskDeliveryEvidenceState = {
16
+ confirmed: "confirmed",
17
+ blocked: "blocked",
18
+ } as const;
19
+
7
20
  export interface TaskEndpoint {
8
21
  readonly relay: string;
9
22
  readonly id: string;
@@ -105,6 +118,9 @@ export interface TaskRecord {
105
118
 
106
119
  export type TerminalTaskIntentType = "task.completed" | "task.failed" | "task.cancelled";
107
120
 
121
+ export type TaskDeliveryStage = (typeof TaskDeliveryStage)[keyof typeof TaskDeliveryStage];
122
+ export type TaskDeliveryEvidenceState = (typeof TaskDeliveryEvidenceState)[keyof typeof TaskDeliveryEvidenceState];
123
+
108
124
  export interface TaskIntent {
109
125
  readonly intentId: string;
110
126
  readonly taskId: string;