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