@sgtbeatdown/wolfpack-tasks 0.0.0-stage → 0.2.0
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 +213 -2
- package/docs/protocol-reference.md +124 -0
- package/package.json +55 -4
- package/skills/task-context-summary/SKILL.md +34 -0
- package/skills/wolfpack-task-delegation/SKILL.md +107 -0
- package/src/abortable-sleep.ts +25 -0
- package/src/agent-task-runtime.ts +709 -0
- package/src/agent-task-tool-schemas.ts +30 -0
- package/src/claude/adapter.ts +336 -0
- package/src/claude/constants.ts +40 -0
- package/src/claude/environment.ts +54 -0
- package/src/claude/json-rpc.ts +50 -0
- package/src/claude/mcp-server.ts +144 -0
- package/src/claude/server.ts +37 -0
- package/src/claude/status-file.ts +66 -0
- package/src/claude/validation.ts +28 -0
- package/src/claude/wake.ts +215 -0
- package/src/claude/worker-gate-hook.ts +26 -0
- package/src/claude/worker-gate.ts +260 -0
- package/src/configured-task-core.ts +76 -0
- package/src/extension.ts +51 -0
- package/src/in-memory-task-relay.ts +115 -0
- package/src/index.ts +35 -0
- package/src/task-core.ts +925 -0
- package/src/task-errors.ts +111 -0
- package/src/task-inbox.ts +292 -0
- package/src/task-protocol.ts +243 -0
- package/src/task-relay-conformance.ts +33 -0
- package/src/task-store.ts +342 -0
- package/src/task-work-scope.ts +98 -0
- package/src/task-worker-mode.ts +9 -0
- package/src/volatile-task-session.ts +310 -0
- package/src/wolfpack-endpoint-view.ts +46 -0
- package/src/wolfpack-local-auth.ts +20 -0
- package/src/wolfpack-task-relay.ts +39 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import type { Static, TSchema } from "typebox";
|
|
2
|
+
|
|
3
|
+
import { abortableSleep } from "./abortable-sleep";
|
|
4
|
+
import { DoneParams, InboxParams, MessageParams, SendParams, TaskIdParams, WaitParams } from "./agent-task-tool-schemas";
|
|
5
|
+
import { createTaskWorkScope } from "./task-work-scope";
|
|
6
|
+
import { taskWorkerModeEnabled, WORKER_GATE_DENIAL_CODE } from "./task-worker-mode";
|
|
7
|
+
import { deliverTaskInbox, taskEntryEvidence, type TaskEntryEvidence } from "./task-inbox";
|
|
8
|
+
import { RELAY_LOSS_CODES, taskErrorClass } from "./task-errors";
|
|
9
|
+
import { TASK_EVENT_TYPES, TaskDeliveryEvidenceState, TaskDeliveryStage, TaskOutboxDeliveryError, TaskProtocolError, isBlockedOutboxDeliveryCode } from "./task-protocol";
|
|
10
|
+
import { createConfiguredTaskCore } from "./configured-task-core";
|
|
11
|
+
import type { OwnedTaskCore } from "./configured-task-core";
|
|
12
|
+
import type { SubmitIntentInput, SubmitIntentOutcome, TaskCore } from "./task-core";
|
|
13
|
+
import type { TaskEndpoint, TaskSnapshot } from "./task-protocol";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TASK_TIMEOUT_MS = 30 * 60 * 1_000;
|
|
16
|
+
/** Background cadence while work is pending (active task, pending outbox, or a worker awaiting assignment). */
|
|
17
|
+
export const ACTIVE_POLL_MS = 1_000;
|
|
18
|
+
/** Background cadence otherwise. */
|
|
19
|
+
export const IDLE_POLL_MS = 5_000;
|
|
20
|
+
const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
|
|
21
|
+
/** Events that assign a task to their target: the original assignment and a reassignment to a lost target's successor. */
|
|
22
|
+
const ASSIGNMENT_EVENTS: ReadonlySet<string> = new Set(["task.created", "task.reassigned"]);
|
|
23
|
+
const RELAY_STATE_LOST_CODE = "RELAY_STATE_LOST";
|
|
24
|
+
const RELAY_LOSS_CUSTOM_TYPE = "pi-tasks-relay-loss";
|
|
25
|
+
const LOST_ASSIGNMENT_MAX_IDS = 8;
|
|
26
|
+
const LOST_ASSIGNMENT_ID_MAX_CHARS = 128;
|
|
27
|
+
const RELAY_LOSS_RECOVERY_GUIDANCE = "Parent/coordinator: independently verify work and side effects, obtain the worker's fresh taskEndpoint from structured Wolfpack session status, then send a new explicit assignment for the remaining scope. Do not replay the old task ID or infer a canonical outcome from local failure. Worker must wait for that assignment; old history does not authorize work.";
|
|
28
|
+
const WAIT_BACKOFF_INITIAL_MS = 250;
|
|
29
|
+
const WAIT_BACKOFF_MAX_MS = 2_000;
|
|
30
|
+
const PRE_ASSIGNMENT_TOOLS = new Set(["agent_task_inbox", "agent_task_status", "agent_task_wait"]);
|
|
31
|
+
const COORDINATOR_ONLY_TASK_TOOLS = new Set(["agent_task_send", "agent_task_cancel", "agent_task_ack"]);
|
|
32
|
+
export { WORKER_GATE_DENIAL_CODE };
|
|
33
|
+
export const WORKER_COORDINATION_FORBIDDEN_CODE = "PI_TASK_WORKER_COORDINATION_FORBIDDEN";
|
|
34
|
+
|
|
35
|
+
/** Host operations the runtime needs: insert a model-visible message (optionally waking the model) and append a custom history entry. */
|
|
36
|
+
export interface AgentTaskHost {
|
|
37
|
+
sendMessage(
|
|
38
|
+
message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: unknown },
|
|
39
|
+
options: { readonly triggerTurn: false } | { readonly triggerTurn: true; readonly deliverAs: "followUp" },
|
|
40
|
+
): void;
|
|
41
|
+
appendEntry(customType: string, data: unknown): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The subset of a host session context the runtime reads. Pi's `ExtensionContext` satisfies it structurally. */
|
|
45
|
+
export interface AgentTaskRuntimeContext {
|
|
46
|
+
isIdle(): boolean;
|
|
47
|
+
hasPendingMessages(): boolean;
|
|
48
|
+
readonly sessionManager: { readonly getEntries: () => readonly unknown[] };
|
|
49
|
+
readonly ui: {
|
|
50
|
+
setStatus(key: string, text: string | undefined): void;
|
|
51
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
52
|
+
readonly theme: { fg(color: "warning", text: string): string };
|
|
53
|
+
};
|
|
54
|
+
readonly signal?: AbortSignal | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AgentTaskToolResult {
|
|
58
|
+
readonly content: { readonly type: "text"; readonly text: string }[];
|
|
59
|
+
readonly details: unknown;
|
|
60
|
+
readonly terminate?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AgentTaskToolDefinition<TParams extends TSchema = TSchema> {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly label: string;
|
|
66
|
+
readonly description: string;
|
|
67
|
+
readonly parameters: TParams;
|
|
68
|
+
execute(toolCallId: string, params: Static<TParams>, signal: AbortSignal | undefined, onUpdate: ((update: AgentTaskToolResult) => void) | undefined, context: AgentTaskRuntimeContext): Promise<AgentTaskToolResult>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ToolCallGateEvent {
|
|
72
|
+
readonly toolName: string;
|
|
73
|
+
readonly input: unknown;
|
|
74
|
+
}
|
|
75
|
+
export interface ToolCallGateDecision {
|
|
76
|
+
readonly block: true;
|
|
77
|
+
readonly reason: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Host lifecycle hooks. Pi maps them one to one to its extension events; other hosts map their own lifecycle onto them. */
|
|
81
|
+
export interface AgentTaskLifecycle {
|
|
82
|
+
tool_call(event: ToolCallGateEvent, context: Pick<AgentTaskRuntimeContext, "sessionManager" | "signal">): Promise<ToolCallGateDecision | undefined>;
|
|
83
|
+
session_start(event: unknown, context: AgentTaskRuntimeContext): Promise<void>;
|
|
84
|
+
agent_end(event: unknown, context: AgentTaskRuntimeContext): Promise<void>;
|
|
85
|
+
agent_settled(event: unknown, context: AgentTaskRuntimeContext): Promise<void>;
|
|
86
|
+
turn_end(event?: unknown, context?: AgentTaskRuntimeContext): Promise<void>;
|
|
87
|
+
session_shutdown(event?: unknown, context?: AgentTaskRuntimeContext): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface AgentTaskRuntime {
|
|
91
|
+
readonly lifecycle: AgentTaskLifecycle;
|
|
92
|
+
/** Tools in registration order. */
|
|
93
|
+
readonly tools: readonly AgentTaskToolDefinition[];
|
|
94
|
+
/** True when `PI_TASK_WORKER=1` or `WOLFPACK_TASK_WORKER=1` activated the worker gate. */
|
|
95
|
+
readonly workerGateEnabled: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function defineTool<TParams extends TSchema>(tool: AgentTaskToolDefinition<TParams>): AgentTaskToolDefinition {
|
|
99
|
+
return tool as unknown as AgentTaskToolDefinition;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type ConfiguredCoreFactory = (signal?: AbortSignal, rebind?: boolean) => Promise<TaskCore & Partial<Pick<OwnedTaskCore, "close">>>;
|
|
103
|
+
export interface ConfiguredCoreLoader extends ConfiguredCoreFactory {
|
|
104
|
+
close(): Promise<void>;
|
|
105
|
+
start(): Promise<void>;
|
|
106
|
+
}
|
|
107
|
+
export const defaultCoreFactory: ConfiguredCoreFactory = (signal, rebind) => createConfiguredTaskCore({ rebind }, signal);
|
|
108
|
+
|
|
109
|
+
/** One lifecycle owns setup and its core. Late setup cannot resurrect a closed lifecycle. */
|
|
110
|
+
export function createConfiguredCoreLoader(factory: ConfiguredCoreFactory): ConfiguredCoreLoader {
|
|
111
|
+
let corePromise: ReturnType<ConfiguredCoreFactory> | undefined;
|
|
112
|
+
let controller = new AbortController();
|
|
113
|
+
let epoch = 0, closed = false;
|
|
114
|
+
let closing: Promise<void> = Promise.resolve();
|
|
115
|
+
const load: ConfiguredCoreLoader = async (signal, rebind) => {
|
|
116
|
+
if (closed) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle is closed", { retryable: false });
|
|
117
|
+
if (corePromise) return corePromise;
|
|
118
|
+
const current = epoch;
|
|
119
|
+
const lifetimeSignal = controller.signal;
|
|
120
|
+
const combined = signal ? AbortSignal.any([signal, lifetimeSignal]) : lifetimeSignal;
|
|
121
|
+
const candidate = Promise.resolve().then(async () => {
|
|
122
|
+
if (combined.aborted) throw new TaskProtocolError("ABORTED", "task setup was cancelled");
|
|
123
|
+
const value = await factory(combined, rebind);
|
|
124
|
+
if (current !== epoch || closed || combined.aborted) {
|
|
125
|
+
await value.close?.();
|
|
126
|
+
throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
});
|
|
130
|
+
corePromise = candidate;
|
|
131
|
+
try { return await candidate; }
|
|
132
|
+
catch (error) { if (corePromise === candidate) corePromise = undefined; throw error; }
|
|
133
|
+
};
|
|
134
|
+
load.start = async () => {
|
|
135
|
+
const current = epoch;
|
|
136
|
+
await closing;
|
|
137
|
+
if (current !== epoch) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was stopped during startup", { retryable: false });
|
|
138
|
+
if (closed) { closed = false; controller = new AbortController(); }
|
|
139
|
+
};
|
|
140
|
+
load.close = () => {
|
|
141
|
+
closed = true; epoch++; controller.abort();
|
|
142
|
+
const prior = corePromise; corePromise = undefined;
|
|
143
|
+
const dispose = async () => {
|
|
144
|
+
let value: Awaited<ReturnType<ConfiguredCoreFactory>> | undefined;
|
|
145
|
+
try { value = await prior; } catch { /* Setup failure already owns cleanup. */ }
|
|
146
|
+
await value?.close?.();
|
|
147
|
+
};
|
|
148
|
+
closing = Promise.all([closing, dispose()]).then(() => undefined);
|
|
149
|
+
return closing;
|
|
150
|
+
};
|
|
151
|
+
return load;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Creates the host-neutral endpoint-owned task runtime using the memory-owned Wolfpack transport by default. */
|
|
155
|
+
export function createAgentTaskRuntime(host: AgentTaskHost, core: TaskCore | undefined = undefined, createCore: ConfiguredCoreFactory = defaultCoreFactory): AgentTaskRuntime {
|
|
156
|
+
let inboxContext: AgentTaskRuntimeContext | undefined;
|
|
157
|
+
let backgroundTimer: ReturnType<typeof setTimeout> | undefined;
|
|
158
|
+
let lifecycleEpoch = 0;
|
|
159
|
+
const closingTaskIds = new Set<string>();
|
|
160
|
+
const workerGateEnabled = taskWorkerModeEnabled();
|
|
161
|
+
const ownedLoader = core === undefined ? createConfiguredCoreLoader(createCore) : undefined;
|
|
162
|
+
const configuredCore: ConfiguredCoreFactory = ownedLoader ?? (async (): Promise<TaskCore> => core!);
|
|
163
|
+
let relayLossRecovery: Promise<void> | undefined;
|
|
164
|
+
let pendingRelayLossCount: number | undefined;
|
|
165
|
+
// Bounded diagnostic text from confirmed loss only; never consulted as authorization evidence.
|
|
166
|
+
let lostWorkerAssignmentDiagnostic: string | undefined;
|
|
167
|
+
const workerAssignmentDenial = (): { block: true; reason: string } => ({
|
|
168
|
+
block: true,
|
|
169
|
+
reason: lostWorkerAssignmentDiagnostic === undefined ? WORKER_GATE_DENIAL_CODE : `${WORKER_GATE_DENIAL_CODE}: ${lostWorkerAssignmentDiagnostic}`,
|
|
170
|
+
});
|
|
171
|
+
const refreshInbox = createSingleFlightInboxRefresh(async (signal) => {
|
|
172
|
+
const context = inboxContext;
|
|
173
|
+
const epoch = lifecycleEpoch;
|
|
174
|
+
const isCurrent = (): boolean => inboxContext === context && lifecycleEpoch === epoch;
|
|
175
|
+
if (!context) return undefined;
|
|
176
|
+
// Phased: intake runs ahead of speculative backlog work and ACK repair.
|
|
177
|
+
const work = createTaskWorkScope(signal, "phased");
|
|
178
|
+
signal = work.signal;
|
|
179
|
+
const activeCore = await configuredCore(signal);
|
|
180
|
+
if (!isCurrent()) return undefined;
|
|
181
|
+
let outboxError: unknown;
|
|
182
|
+
try {
|
|
183
|
+
await activeCore.connect(signal, work);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (isRelayLoss(error)) return error;
|
|
186
|
+
outboxError = error;
|
|
187
|
+
}
|
|
188
|
+
if (!isCurrent()) return undefined;
|
|
189
|
+
try {
|
|
190
|
+
await activeCore.evaluateTimeouts(signal, work);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (isRelayLoss(error)) return error;
|
|
193
|
+
outboxError ??= error;
|
|
194
|
+
}
|
|
195
|
+
if (!isCurrent()) return undefined;
|
|
196
|
+
const guardedCore: TaskCore = {
|
|
197
|
+
...activeCore,
|
|
198
|
+
async receive(receiveSignal, receiveWork) {
|
|
199
|
+
if (!isCurrent()) return [];
|
|
200
|
+
const deliveries = await activeCore.receive(receiveSignal, receiveWork);
|
|
201
|
+
return isCurrent() ? deliveries : [];
|
|
202
|
+
},
|
|
203
|
+
async intakeInbox(intakeSignal, intakeWork) {
|
|
204
|
+
if (!isCurrent()) return [];
|
|
205
|
+
const items = await activeCore.intakeInbox(intakeSignal, intakeWork);
|
|
206
|
+
return isCurrent() ? items : [];
|
|
207
|
+
},
|
|
208
|
+
async shipRequired(shipSignal, shipWork) {
|
|
209
|
+
if (isCurrent()) await activeCore.shipRequired(shipSignal, shipWork);
|
|
210
|
+
},
|
|
211
|
+
async repairAcknowledgements(repairSignal, repairWork) {
|
|
212
|
+
if (isCurrent()) await activeCore.repairAcknowledgements(repairSignal, repairWork);
|
|
213
|
+
},
|
|
214
|
+
async acknowledgeRelayDelivery(cursor, acknowledgementSignal, acknowledgementWork) {
|
|
215
|
+
if (isCurrent()) await activeCore.acknowledgeRelayDelivery(cursor, acknowledgementSignal, acknowledgementWork);
|
|
216
|
+
},
|
|
217
|
+
completeLocalPresentation(eventId) {
|
|
218
|
+
if (isCurrent()) activeCore.completeLocalPresentation(eventId);
|
|
219
|
+
},
|
|
220
|
+
async recordInsertion(input, insertionSignal, insertionWork) {
|
|
221
|
+
if (isCurrent()) await activeCore.recordInsertion(input, insertionSignal, insertionWork);
|
|
222
|
+
},
|
|
223
|
+
async submitIntent(input, intentSignal, intentWork) {
|
|
224
|
+
if (isCurrent()) await activeCore.submitIntent(input, intentSignal, intentWork);
|
|
225
|
+
},
|
|
226
|
+
async flushOutbox(flushSignal, flushWork) {
|
|
227
|
+
if (isCurrent()) await activeCore.flushOutbox(flushSignal, flushWork);
|
|
228
|
+
},
|
|
229
|
+
recordWakeRequest(input, wakeWork) {
|
|
230
|
+
if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
|
|
231
|
+
const request = activeCore.recordWakeRequest(input, wakeWork);
|
|
232
|
+
return {
|
|
233
|
+
flush: async (flushSignal?: AbortSignal): Promise<void> => {
|
|
234
|
+
if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
|
|
235
|
+
await request.flush(flushSignal);
|
|
236
|
+
},
|
|
237
|
+
// A replaced lifecycle discards its own state; there is nothing left to withdraw.
|
|
238
|
+
discard() { if (isCurrent()) request.discard(); },
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
const inboxOutboxError = await deliverTaskInbox({
|
|
243
|
+
sendMessage(message, options) { if (isCurrent()) host.sendMessage(message, options); },
|
|
244
|
+
appendEntry(customType, data) { if (isCurrent()) host.appendEntry(customType, data); },
|
|
245
|
+
}, guardedCore, {
|
|
246
|
+
isIdle: (): boolean => isCurrent() && context.isIdle(),
|
|
247
|
+
hasPendingMessages: (): boolean => !isCurrent() || context.hasPendingMessages(),
|
|
248
|
+
sessionManager: context.sessionManager,
|
|
249
|
+
}, signal, work);
|
|
250
|
+
return outboxError ?? inboxOutboxError;
|
|
251
|
+
});
|
|
252
|
+
const cancelBackgroundRefresh = (): void => {
|
|
253
|
+
if (backgroundTimer) clearTimeout(backgroundTimer);
|
|
254
|
+
backgroundTimer = undefined;
|
|
255
|
+
};
|
|
256
|
+
/** Keep exactly one pending background timer; each lifecycle refresh re-arms it with the next cadence. */
|
|
257
|
+
const scheduleBackgroundRefresh = (epoch: number, delayMs: number): void => {
|
|
258
|
+
cancelBackgroundRefresh();
|
|
259
|
+
backgroundTimer = setTimeout(() => {
|
|
260
|
+
backgroundTimer = undefined;
|
|
261
|
+
if (inboxContext && lifecycleEpoch === epoch) void requestRefresh();
|
|
262
|
+
}, delayMs);
|
|
263
|
+
};
|
|
264
|
+
// Active cadence while a task is active, the outbox is pending, or a gate-enabled worker lacks an eligible assignment.
|
|
265
|
+
// An eligible assignment implies an active task, so the worker clause together with the first reduces to `workerGateEnabled`.
|
|
266
|
+
const nextPollDelay = (activeCore: TaskCore | undefined): number => activeCore !== undefined && (workerGateEnabled || activeCore.hasPendingWork()) ? ACTIVE_POLL_MS : IDLE_POLL_MS;
|
|
267
|
+
const startBackgroundRefresh = (context: AgentTaskRuntimeContext, epoch: number): void => {
|
|
268
|
+
inboxContext = context;
|
|
269
|
+
scheduleBackgroundRefresh(epoch, IDLE_POLL_MS);
|
|
270
|
+
};
|
|
271
|
+
let lifecycleRefresh: Promise<void> | undefined;
|
|
272
|
+
let trailingRefresh = false;
|
|
273
|
+
/** Immediate single-flight lifecycle refresh; requests made while one runs coalesce into one trailing run. */
|
|
274
|
+
const requestRefresh = (): Promise<void> => {
|
|
275
|
+
if (lifecycleRefresh) {
|
|
276
|
+
trailingRefresh = true;
|
|
277
|
+
return lifecycleRefresh;
|
|
278
|
+
}
|
|
279
|
+
const current = (async (): Promise<void> => {
|
|
280
|
+
do {
|
|
281
|
+
trailingRefresh = false;
|
|
282
|
+
const context = inboxContext;
|
|
283
|
+
if (!context) return;
|
|
284
|
+
try {
|
|
285
|
+
await refreshLifecycle(context, lifecycleEpoch);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
// Callers fire and forget, so a failure escaping refreshLifecycle (e.g. a throwing status UI) must not reject.
|
|
288
|
+
console.error("pi-tasks: lifecycle refresh failed", error);
|
|
289
|
+
if (inboxContext === context) {
|
|
290
|
+
try {
|
|
291
|
+
context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", `tasks: refresh failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
292
|
+
} catch {
|
|
293
|
+
// Status is best effort; the failure is already logged above.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} while (trailingRefresh);
|
|
298
|
+
})().finally(() => {
|
|
299
|
+
if (lifecycleRefresh === current) lifecycleRefresh = undefined;
|
|
300
|
+
});
|
|
301
|
+
lifecycleRefresh = current;
|
|
302
|
+
return current;
|
|
303
|
+
};
|
|
304
|
+
const announceFreshBinding = (context: AgentTaskRuntimeContext): void => {
|
|
305
|
+
if (pendingRelayLossCount === undefined) return;
|
|
306
|
+
const count = pendingRelayLossCount;
|
|
307
|
+
pendingRelayLossCount = undefined;
|
|
308
|
+
const taskSummary = count === 0 ? "No active tasks required local failure records." : `${count} active ${count === 1 ? "task was" : "tasks were"} marked locally failed with unknown remote outcome.`;
|
|
309
|
+
context.ui.notify(`Relay state was lost; automatically bound a fresh endpoint. ${taskSummary}`, "warning");
|
|
310
|
+
};
|
|
311
|
+
const recoverRelayLoss = async (context: AgentTaskRuntimeContext, epoch: number, activeCore: TaskCore | undefined): Promise<boolean> => {
|
|
312
|
+
if (!ownedLoader || lifecycleEpoch !== epoch || inboxContext !== context) return false;
|
|
313
|
+
if (relayLossRecovery) { await relayLossRecovery; return true; }
|
|
314
|
+
const pending = (async (): Promise<void> => {
|
|
315
|
+
const recoveryEpoch = ++lifecycleEpoch;
|
|
316
|
+
const activeTasks = activeCore?.listTasks().filter((task) => task.status === "active") ?? [];
|
|
317
|
+
if (workerGateEnabled && activeCore) {
|
|
318
|
+
const lostAssignments = activeTasks.filter((task) => sameEndpoint(task.target, activeCore.endpoint));
|
|
319
|
+
if (lostAssignments.length > 0) {
|
|
320
|
+
const ids = lostAssignments.slice(0, LOST_ASSIGNMENT_MAX_IDS).map(({ taskId }) => JSON.stringify(taskId.length > LOST_ASSIGNMENT_ID_MAX_CHARS ? `${taskId.slice(0, LOST_ASSIGNMENT_ID_MAX_CHARS)}…` : taskId));
|
|
321
|
+
lostWorkerAssignmentDiagnostic = `${RELAY_STATE_LOST_CODE}; lost assignment IDs (at most ${LOST_ASSIGNMENT_MAX_IDS}, IDs truncated at ${LOST_ASSIGNMENT_ID_MAX_CHARS} chars): ${ids.join(", ")}${lostAssignments.length > ids.length ? "; additional IDs omitted" : ""}. The remote outcome is unknown. ${RELAY_LOSS_RECOVERY_GUIDANCE}`;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
inboxContext = undefined;
|
|
325
|
+
cancelBackgroundRefresh();
|
|
326
|
+
await ownedLoader.close();
|
|
327
|
+
if (recoveryEpoch !== lifecycleEpoch) return;
|
|
328
|
+
for (const task of activeTasks) {
|
|
329
|
+
host.sendMessage({
|
|
330
|
+
customType: RELAY_LOSS_CUSTOM_TYPE,
|
|
331
|
+
content: `## task failed locally\ntask: \`${task.taskId}\`\n\nRelay state was lost; the remote outcome is unknown.\n\n${RELAY_LOSS_RECOVERY_GUIDANCE}`,
|
|
332
|
+
display: true,
|
|
333
|
+
details: { taskId: task.taskId, status: "failed", error: { code: RELAY_STATE_LOST_CODE, message: "relay state was lost; remote outcome is unknown", retryable: false }, remoteOutcome: "unknown" },
|
|
334
|
+
}, { triggerTurn: false });
|
|
335
|
+
}
|
|
336
|
+
pendingRelayLossCount = (pendingRelayLossCount ?? 0) + activeTasks.length;
|
|
337
|
+
closingTaskIds.clear();
|
|
338
|
+
try {
|
|
339
|
+
await ownedLoader.start();
|
|
340
|
+
if (recoveryEpoch !== lifecycleEpoch) return;
|
|
341
|
+
await ownedLoader(undefined, true);
|
|
342
|
+
if (recoveryEpoch !== lifecycleEpoch) return;
|
|
343
|
+
announceFreshBinding(context);
|
|
344
|
+
context.ui.setStatus("pi-tasks", undefined);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (recoveryEpoch !== lifecycleEpoch) return;
|
|
347
|
+
context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", outboxFailureStatus(error)));
|
|
348
|
+
}
|
|
349
|
+
if (recoveryEpoch === lifecycleEpoch) startBackgroundRefresh(context, recoveryEpoch);
|
|
350
|
+
})();
|
|
351
|
+
relayLossRecovery = pending;
|
|
352
|
+
try { await pending; } finally { if (relayLossRecovery === pending) relayLossRecovery = undefined; }
|
|
353
|
+
return true;
|
|
354
|
+
};
|
|
355
|
+
const refreshLifecycle = async (context: AgentTaskRuntimeContext, epoch: number): Promise<void> => {
|
|
356
|
+
const isCurrent = (): boolean => lifecycleEpoch === epoch && inboxContext === context;
|
|
357
|
+
let activeCore: (TaskCore & Partial<Pick<OwnedTaskCore, "close">>) | undefined;
|
|
358
|
+
try {
|
|
359
|
+
activeCore = await configuredCore();
|
|
360
|
+
if (!isCurrent()) return;
|
|
361
|
+
const outboxError = await refreshInbox();
|
|
362
|
+
if (!isCurrent()) return;
|
|
363
|
+
if (isRelayLoss(outboxError) && await recoverRelayLoss(context, epoch, activeCore)) return;
|
|
364
|
+
const lifecycleError = outboxError;
|
|
365
|
+
const status = lifecycleError === undefined ? undefined : outboxFailureStatus(lifecycleError);
|
|
366
|
+
context.ui.setStatus("pi-tasks", status === undefined ? undefined : context.ui.theme.fg("warning", status));
|
|
367
|
+
if (lifecycleError === undefined) announceFreshBinding(context);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (isCurrent() && isRelayLoss(error) && await recoverRelayLoss(context, epoch, activeCore)) return;
|
|
370
|
+
if (isCurrent()) context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", outboxFailureStatus(error)));
|
|
371
|
+
} finally {
|
|
372
|
+
if (isCurrent()) {
|
|
373
|
+
// The chain must outlive a failed cadence evaluation (e.g. an unreadable task snapshot): err toward polling and surface the fault.
|
|
374
|
+
let delay = ACTIVE_POLL_MS;
|
|
375
|
+
let delayError: unknown;
|
|
376
|
+
try {
|
|
377
|
+
delay = nextPollDelay(activeCore);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
delayError = error;
|
|
380
|
+
}
|
|
381
|
+
scheduleBackgroundRefresh(epoch, delay); // Armed before the status write, which may itself throw.
|
|
382
|
+
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)}`));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const toolCall: AgentTaskLifecycle["tool_call"] = async (event, context) => {
|
|
388
|
+
if (!workerGateEnabled || PRE_ASSIGNMENT_TOOLS.has(event.toolName)) return undefined;
|
|
389
|
+
if (COORDINATOR_ONLY_TASK_TOOLS.has(event.toolName)) return { block: true, reason: WORKER_COORDINATION_FORBIDDEN_CODE };
|
|
390
|
+
try {
|
|
391
|
+
const activeCore = await configuredCore(context.signal);
|
|
392
|
+
const assignments = assignedWorkerTasks(activeCore, taskEntryEvidence(context.sessionManager));
|
|
393
|
+
const taskId = inputTaskId(event.input);
|
|
394
|
+
if (event.toolName === "agent_task_done") {
|
|
395
|
+
const assigned = taskId === undefined ? undefined : assignments.find((task) => task.taskId === taskId);
|
|
396
|
+
if (assigned === undefined || (!eligibleWorkerTask(assigned, closingTaskIds) && assigned.terminalDelivery.state === "not_submitted" && !closingTaskIds.has(assigned.taskId))) {
|
|
397
|
+
return workerAssignmentDenial();
|
|
398
|
+
}
|
|
399
|
+
closingTaskIds.add(assigned.taskId);
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
if (event.toolName === "agent_task_message") {
|
|
403
|
+
const assigned = taskId === undefined ? undefined : assignments.find((task) => task.taskId === taskId);
|
|
404
|
+
return assigned !== undefined && eligibleWorkerTask(assigned, closingTaskIds) ? undefined : workerAssignmentDenial();
|
|
405
|
+
}
|
|
406
|
+
if (assignments.some((task) => eligibleWorkerTask(task, closingTaskIds))) return undefined;
|
|
407
|
+
} catch {
|
|
408
|
+
// Authorization evidence unavailable or malformed: fail closed with the stable public reason.
|
|
409
|
+
}
|
|
410
|
+
return workerAssignmentDenial();
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const lifecycle: AgentTaskLifecycle = { tool_call: toolCall, async session_start(_event, context) {
|
|
414
|
+
closingTaskIds.clear();
|
|
415
|
+
lostWorkerAssignmentDiagnostic = undefined;
|
|
416
|
+
const epoch = ++lifecycleEpoch;
|
|
417
|
+
await ownedLoader?.start();
|
|
418
|
+
if (epoch !== lifecycleEpoch) return;
|
|
419
|
+
startBackgroundRefresh(context, epoch);
|
|
420
|
+
await requestRefresh();
|
|
421
|
+
}, async agent_end(_event, context) {
|
|
422
|
+
if (!inboxContext) return;
|
|
423
|
+
inboxContext = context;
|
|
424
|
+
await requestRefresh();
|
|
425
|
+
}, async agent_settled(_event, context) {
|
|
426
|
+
if (!inboxContext) return;
|
|
427
|
+
inboxContext = context;
|
|
428
|
+
await requestRefresh();
|
|
429
|
+
}, async turn_end() {
|
|
430
|
+
void requestRefresh();
|
|
431
|
+
}, async session_shutdown() {
|
|
432
|
+
closingTaskIds.clear();
|
|
433
|
+
lostWorkerAssignmentDiagnostic = undefined;
|
|
434
|
+
lifecycleEpoch += 1;
|
|
435
|
+
cancelBackgroundRefresh();
|
|
436
|
+
inboxContext = undefined;
|
|
437
|
+
pendingRelayLossCount = undefined;
|
|
438
|
+
await ownedLoader?.close();
|
|
439
|
+
} };
|
|
440
|
+
|
|
441
|
+
const tools: AgentTaskToolDefinition[] = [];
|
|
442
|
+
tools.push(defineTool({
|
|
443
|
+
name: "agent_task_send", label: "Send Agent Task", description: "Create a task in this endpoint's RAM and submit its assignment; restart loses active state.", parameters: SendParams,
|
|
444
|
+
async execute(_id, params, signal) {
|
|
445
|
+
try {
|
|
446
|
+
const sent = await (await configuredCore(signal)).createTask({ target: params.to, task: params.task, timeoutMs: params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS }, signal);
|
|
447
|
+
void requestRefresh();
|
|
448
|
+
return toolResult(sent, `## task accepted\n- task: \`${sent.taskId}\`\n- target: \`${params.to.relay}/${params.to.id}\`\n- delivery: relay acceptance only`);
|
|
449
|
+
} catch (error) { return taskError(error); }
|
|
450
|
+
},
|
|
451
|
+
}));
|
|
452
|
+
tools.push(defineTool({
|
|
453
|
+
name: "agent_task_status", label: "Task Status", description: "Read local endpoint-owned task state; status is unavailable while its origin is offline.", parameters: TaskIdParams,
|
|
454
|
+
async execute(_id, params, signal, _onUpdate, context) {
|
|
455
|
+
try {
|
|
456
|
+
const activeCore = await configuredCore(signal);
|
|
457
|
+
const task = activeCore.getTask(params.taskId);
|
|
458
|
+
if (!task) return taskError(new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${params.taskId}`, { retryable: false }));
|
|
459
|
+
const deliveryEvidence = taskDeliveryEvidence(task);
|
|
460
|
+
return toolResult({ ...task, deliveryEvidence }, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}${receiverAssignment(activeCore, task, context)}${deliveryEvidenceText(deliveryEvidence)}`);
|
|
461
|
+
} catch (error) { return taskError(error); }
|
|
462
|
+
},
|
|
463
|
+
}));
|
|
464
|
+
tools.push(defineTool({
|
|
465
|
+
name: "agent_task_wait", label: "Wait Agent Task", description: "Wait for a locally-known endpoint-owned task to reach a terminal state.", parameters: WaitParams,
|
|
466
|
+
async execute(_id, params, signal, onUpdate) {
|
|
467
|
+
const deadline = Date.now() + (params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS);
|
|
468
|
+
try {
|
|
469
|
+
// Fetching belongs to the background cadence; the wait re-checks local state on store changes or backoff.
|
|
470
|
+
void requestRefresh();
|
|
471
|
+
for (let backoff = WAIT_BACKOFF_INITIAL_MS; ; backoff = Math.min(backoff * 2, WAIT_BACKOFF_MAX_MS)) {
|
|
472
|
+
// Resolve the core each round so a relay-loss replacement is re-subscribed.
|
|
473
|
+
const activeCore = await configuredCore(signal);
|
|
474
|
+
const task = activeCore.getTask(params.taskId);
|
|
475
|
+
if (!task) return taskError(new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${params.taskId}`, { retryable: false }));
|
|
476
|
+
if (terminal(task.status)) return toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`);
|
|
477
|
+
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`);
|
|
478
|
+
onUpdate?.({ content: [{ type: "text", text: `waiting for ${params.taskId}...` }], details: {} });
|
|
479
|
+
await abortableSleep(Math.max(1, Math.min(backoff, deadline - Date.now())), signal, () => new Error("task wait was cancelled"), (wake) => activeCore.onChange(wake));
|
|
480
|
+
}
|
|
481
|
+
} catch (error) { return taskError(error); }
|
|
482
|
+
},
|
|
483
|
+
}));
|
|
484
|
+
tools.push(defineTool({
|
|
485
|
+
name: "agent_task_inbox", label: "Task Inbox", description: "Read local task records after processing relay deliveries without acknowledging task lifecycle.", parameters: InboxParams,
|
|
486
|
+
async execute(_id, _params, signal, _onUpdate, context) {
|
|
487
|
+
try {
|
|
488
|
+
await refreshInbox(signal);
|
|
489
|
+
const activeCore = await configuredCore(signal);
|
|
490
|
+
const tasks = activeCore.listTasks();
|
|
491
|
+
return toolResult({ tasks }, `## task inbox\n${tasks.map((task) => `- \`${task.taskId}\`: ${task.status}${receiverAssignment(activeCore, task, context)}`).join("\n") || "- empty"}`);
|
|
492
|
+
} catch (error) { return taskError(error); }
|
|
493
|
+
},
|
|
494
|
+
}));
|
|
495
|
+
tools.push(defineTool({
|
|
496
|
+
name: "agent_task_message", label: "Message Agent Task", description: "Record a message intent in this lifetime before relay submission.", parameters: MessageParams,
|
|
497
|
+
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); } },
|
|
498
|
+
}));
|
|
499
|
+
tools.push(defineTool({
|
|
500
|
+
name: "agent_task_cancel", label: "Cancel Agent Task", description: "Record cancellation in this lifetime before relay submission.", parameters: TaskIdParams,
|
|
501
|
+
async execute(_id, params, signal) {
|
|
502
|
+
try {
|
|
503
|
+
const activeCore = await configuredCore(signal);
|
|
504
|
+
try {
|
|
505
|
+
await activeCore.submitIntent({ taskId: params.taskId, type: "task.cancelled", payload: {} }, signal);
|
|
506
|
+
void requestRefresh();
|
|
507
|
+
return toolResult({ taskId: params.taskId }, `## task cancellation requested\n- task: \`${params.taskId}\``);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return blockedCancellationResult(activeCore, params.taskId, error) ?? taskError(error);
|
|
510
|
+
}
|
|
511
|
+
} catch (error) { return taskError(error); }
|
|
512
|
+
},
|
|
513
|
+
}));
|
|
514
|
+
tools.push(defineTool({
|
|
515
|
+
name: "agent_task_ack", label: "Acknowledge Agent Task", description: "Record parent acknowledgement as an origin-owned logical event.", parameters: TaskIdParams,
|
|
516
|
+
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); } },
|
|
517
|
+
}));
|
|
518
|
+
tools.push(defineTool({
|
|
519
|
+
name: "agent_task_done", label: "Complete Agent Task", description: "Record a terminal intent in this lifetime before relay submission.", parameters: DoneParams,
|
|
520
|
+
async execute(_id, params, signal) {
|
|
521
|
+
try {
|
|
522
|
+
const activeCore = await configuredCore(signal);
|
|
523
|
+
let submission: SubmitIntentOutcome | undefined;
|
|
524
|
+
try {
|
|
525
|
+
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 }) } };
|
|
526
|
+
if (activeCore.submitIntentWithOutcome) submission = await activeCore.submitIntentWithOutcome(input, signal);
|
|
527
|
+
else await activeCore.submitIntent(input, signal);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
return blockedDoneResult(activeCore, params.taskId, params.status, error) ?? taskError(error);
|
|
530
|
+
}
|
|
531
|
+
void requestRefresh();
|
|
532
|
+
const task = activeCore.getTask(params.taskId);
|
|
533
|
+
if (!task) return taskError(new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${params.taskId}`, { retryable: false }));
|
|
534
|
+
if (submission?.authority === "origin" && submission.canonicalEvent.type === "task.late_terminal") {
|
|
535
|
+
return {
|
|
536
|
+
...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}`),
|
|
537
|
+
terminate: true,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (submission?.authority === "origin" && task.status === params.status) {
|
|
541
|
+
return {
|
|
542
|
+
...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}`),
|
|
543
|
+
terminate: true,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
const deliveryOutcome = task.terminalDelivery.state === "accepted" ? "accepted" : "recorded";
|
|
547
|
+
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}`);
|
|
548
|
+
return task.terminalDelivery.state === "accepted" || terminal(task.status) ? { ...result, terminate: true } : result;
|
|
549
|
+
} catch (error) { return taskError(error); }
|
|
550
|
+
},
|
|
551
|
+
}));
|
|
552
|
+
return { lifecycle, tools, workerGateEnabled };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function createSingleFlightInboxRefresh<TValue>(refresh: (signal?: AbortSignal) => Promise<TValue>): (signal?: AbortSignal) => Promise<TValue> {
|
|
556
|
+
let inFlight: Promise<TValue> | undefined;
|
|
557
|
+
return (signal): Promise<TValue> => {
|
|
558
|
+
if (inFlight) return inFlight;
|
|
559
|
+
const current = refresh(signal).finally(() => {
|
|
560
|
+
if (inFlight === current) inFlight = undefined;
|
|
561
|
+
});
|
|
562
|
+
inFlight = current;
|
|
563
|
+
return current;
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
interface TaskDeliveryEvidence {
|
|
568
|
+
readonly receiverReceipt: "not_confirmed" | "confirmed";
|
|
569
|
+
readonly piInsertion: "not_confirmed" | "blocked" | "confirmed";
|
|
570
|
+
readonly wakeAcceptance: "not_confirmed" | "pending" | "confirmed";
|
|
571
|
+
readonly modelExecution: "not_evidenced";
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function taskDeliveryEvidence(task: TaskSnapshot): TaskDeliveryEvidence {
|
|
575
|
+
let receiverReceipt: TaskDeliveryEvidence["receiverReceipt"] = "not_confirmed";
|
|
576
|
+
let piInsertion: TaskDeliveryEvidence["piInsertion"] = "not_confirmed";
|
|
577
|
+
let wakeAcceptance: TaskDeliveryEvidence["wakeAcceptance"] = "not_confirmed";
|
|
578
|
+
// After a reassignment, evidence describes the latest assignment.
|
|
579
|
+
const assignmentEventId = task.events.findLast((event) => ASSIGNMENT_EVENTS.has(event.type))?.eventId;
|
|
580
|
+
for (const event of task.events) {
|
|
581
|
+
if (event.type !== "task.delivery_receipt" || event.payload.eventId !== assignmentEventId) continue;
|
|
582
|
+
if (event.payload.stage === TaskDeliveryStage.receiverRecorded) receiverReceipt = "confirmed";
|
|
583
|
+
if (event.payload.stage === TaskDeliveryStage.piInsertion && event.payload.state === TaskDeliveryEvidenceState.blocked) piInsertion = "blocked";
|
|
584
|
+
if (event.payload.stage === TaskDeliveryStage.piInserted || event.payload.stage === undefined) {
|
|
585
|
+
receiverReceipt = "confirmed";
|
|
586
|
+
piInsertion = "confirmed";
|
|
587
|
+
}
|
|
588
|
+
// Legacy v0.1.15 receivers still send wake_requested; current receivers send only the highest stage.
|
|
589
|
+
if (event.payload.stage === TaskDeliveryStage.wakeRequested) wakeAcceptance = "pending";
|
|
590
|
+
if (event.payload.stage === TaskDeliveryStage.wakeAccepted) {
|
|
591
|
+
receiverReceipt = "confirmed";
|
|
592
|
+
piInsertion = "confirmed";
|
|
593
|
+
wakeAcceptance = "confirmed";
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return { receiverReceipt, piInsertion, wakeAcceptance, modelExecution: "not_evidenced" };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function deliveryEvidenceText(evidence: TaskDeliveryEvidence): string {
|
|
600
|
+
const insertion = evidence.piInsertion === "blocked" ? "blocked; retryable" : evidence.piInsertion;
|
|
601
|
+
return `\n- receiver receipt (RAM): ${evidence.receiverReceipt}\n- Pi insertion: ${insertion}\n- wake acceptance: ${evidence.wakeAcceptance}\n- model execution: ${evidence.modelExecution}`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function toolResult(details: unknown, markdown: string): AgentTaskToolResult {
|
|
605
|
+
return { content: [{ type: "text", text: markdown }], details };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function taskError(error: unknown): AgentTaskToolResult {
|
|
609
|
+
const message = error instanceof Error ? error.message : "task operation failed";
|
|
610
|
+
if (error instanceof TaskProtocolError) {
|
|
611
|
+
return toolResult({ ...error.details, error: { code: error.code, message, retryable: error.retryable } }, `## task error\n- ${message}`);
|
|
612
|
+
}
|
|
613
|
+
return toolResult({ error: { code: "TASK_ERROR", message, retryable: true } }, `## task error\n- ${message}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function blockedCancellationResult(core: TaskCore, taskId: string, error: unknown): AgentTaskToolResult | undefined {
|
|
617
|
+
if (!(error instanceof TaskOutboxDeliveryError) || error.details?.taskId !== taskId) return undefined;
|
|
618
|
+
const task = core.getTask(taskId);
|
|
619
|
+
const warning = task === undefined ? undefined : blockedDeliveryWarning(error, task.target);
|
|
620
|
+
if (task?.status !== "cancelled" || !sameEndpoint(task.origin, core.endpoint) || warning === undefined) return undefined;
|
|
621
|
+
return toolResult({ taskId, status: "cancelled", warnings: [warning] }, `## task cancelled\n- task: \`${taskId}\`\n- canonical status: cancelled\n- target delivery: blocked; receiver incorporation not confirmed`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function blockedDoneResult(core: TaskCore, taskId: string, requestedStatus: string, error: unknown): AgentTaskToolResult | undefined {
|
|
625
|
+
if (!(error instanceof TaskOutboxDeliveryError)) return undefined;
|
|
626
|
+
const task = core.getTask(taskId);
|
|
627
|
+
if (task === undefined) return undefined;
|
|
628
|
+
if (!sameEndpoint(task.origin, core.endpoint) && task.terminalDelivery.state === "delivery_blocked" && task.terminalDelivery.intentType === `task.${requestedStatus}`) {
|
|
629
|
+
return toolResult({
|
|
630
|
+
taskId,
|
|
631
|
+
status: task.status,
|
|
632
|
+
terminalDelivery: task.terminalDelivery,
|
|
633
|
+
error: { code: error.code, message: error.message, retryable: error.retryable },
|
|
634
|
+
}, `## terminal intent delivery blocked\n- task: \`${taskId}\`\n- canonical status: ${task.status}\n- canonical completion not confirmed; retry remains allowed`);
|
|
635
|
+
}
|
|
636
|
+
const warning = blockedDeliveryWarning(error, task.target);
|
|
637
|
+
if (!sameEndpoint(task.origin, core.endpoint) || task.status !== requestedStatus || warning === undefined) return undefined;
|
|
638
|
+
return {
|
|
639
|
+
...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`),
|
|
640
|
+
terminate: true,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function blockedDeliveryWarning(error: TaskOutboxDeliveryError, target: TaskEndpoint): Readonly<Record<string, unknown>> | undefined {
|
|
645
|
+
if (error.code !== TARGET_NOT_REGISTERED_CODE || error.retryable) return undefined;
|
|
646
|
+
const details = { ...error.details };
|
|
647
|
+
delete details.taskId;
|
|
648
|
+
delete details.envelopeId;
|
|
649
|
+
delete details.target;
|
|
650
|
+
delete details.blockedAt;
|
|
651
|
+
return { code: error.code, message: error.message, retryable: false, delivery: "blocked", target, details };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Status-line text for a failed refresh. Actionable relay-state labels come first; otherwise
|
|
656
|
+
* `tasks: <class> (<CODE>) on <operation>` when the failed relay operation is known.
|
|
657
|
+
*/
|
|
658
|
+
export function outboxFailureStatus(error: unknown): string {
|
|
659
|
+
if (error instanceof TaskProtocolError) {
|
|
660
|
+
if (RELAY_LOSS_CODES.has(error.code)) return "tasks: relay state lost; rebinding automatically";
|
|
661
|
+
if (error.code === "RELAY_PROFILE_REQUIRED") return "tasks: compatible memory-owned relay required";
|
|
662
|
+
if (error.code === "RELAY_AUTH_REQUIRED") return "tasks: Wolfpack authentication required";
|
|
663
|
+
const operation = error.details?.operation;
|
|
664
|
+
if (typeof operation === "string") return `tasks: ${taskErrorClass(error.code)} (${error.code}) on ${operation}`;
|
|
665
|
+
}
|
|
666
|
+
return error instanceof TaskOutboxDeliveryError && isBlockedOutboxDeliveryCode(error.code) ? "tasks: outbox degraded" : "tasks: relay unavailable";
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function isRelayLoss(error: unknown): error is TaskProtocolError {
|
|
670
|
+
return error instanceof TaskProtocolError && RELAY_LOSS_CODES.has(error.code);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function terminal(status: string): boolean {
|
|
674
|
+
return Object.values(TASK_EVENT_TYPES).some(type => type.status === status);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function receiverAssignment(core: TaskCore, task: TaskSnapshot, context: Pick<AgentTaskRuntimeContext, "sessionManager">): string {
|
|
678
|
+
if (task.status !== "active" || sameEndpoint(task.origin, core.endpoint) || !sameEndpoint(task.target, core.endpoint)) return "";
|
|
679
|
+
const incorporated = taskEntryEvidence(context.sessionManager).incorporated.get(task.taskId);
|
|
680
|
+
if (!task.events.some((event) => ASSIGNMENT_EVENTS.has(event.type) && sameEndpoint(event.target, core.endpoint) && incorporated?.has(event.eventId) === true)) return "";
|
|
681
|
+
return `\n\n## task assignment\n${task.task}`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Session evidence is memoized; tasks are read fresh on every call so the gate follows current task state. */
|
|
685
|
+
function assignedWorkerTasks(core: TaskCore, evidence: TaskEntryEvidence): readonly TaskSnapshot[] {
|
|
686
|
+
const assignments: TaskSnapshot[] = [];
|
|
687
|
+
for (const [taskId, eventIds] of evidence.incorporated) {
|
|
688
|
+
const task = core.getTask(taskId);
|
|
689
|
+
if (!task || !sameEndpoint(task.target, core.endpoint)) continue;
|
|
690
|
+
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);
|
|
691
|
+
}
|
|
692
|
+
return assignments;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function eligibleWorkerTask(task: TaskSnapshot, closingTaskIds: ReadonlySet<string>): boolean {
|
|
696
|
+
return task.status === "active" && task.terminalDelivery.state === "not_submitted" && !closingTaskIds.has(task.taskId);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function sameEndpoint(left: TaskEndpoint, right: TaskEndpoint): boolean {
|
|
700
|
+
return left.relay === right.relay && left.id === right.id;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function inputTaskId(input: unknown): string | undefined {
|
|
704
|
+
return isRecord(input) && typeof input.taskId === "string" ? input.taskId : undefined;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
708
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
709
|
+
}
|