@sgtbeatdown/pi-tasks 0.1.0 → 0.1.2
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 +69 -447
- package/package.json +9 -4
- package/skills/task-context-summary/SKILL.md +34 -0
- package/skills/wolfpack-pi-task-delegation/SKILL.md +34 -56
- package/src/extension.ts +217 -259
- package/src/gateway-client.ts +409 -0
- package/src/in-memory-task-relay.ts +114 -0
- package/src/index.ts +35 -0
- package/src/legacy-extension.ts +248 -0
- package/src/legacy-task-inbox.ts +148 -0
- package/src/metrics-cli.ts +20 -0
- package/src/metrics.ts +288 -0
- package/src/sqlite-database.ts +68 -0
- package/src/task-artifacts.ts +153 -0
- package/src/task-board.ts +365 -0
- package/src/task-core.ts +383 -0
- package/src/task-inbox.ts +109 -0
- package/src/task-protocol.ts +129 -0
- package/src/task-relay-conformance.ts +23 -0
- package/src/task-store.ts +238 -0
- package/src/v1-compat-extension.ts +1 -0
- package/src/wolfpack-task-relay.ts +319 -0
- package/src/auto-notify.ts +0 -24
- package/src/protocol.ts +0 -73
- package/src/store.ts +0 -494
- package/src/stores/filesystem.ts +0 -40
- package/src/task-communication.ts +0 -68
- package/src/transports/wolfpack.ts +0 -29
- package/src/types.ts +0 -114
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_TASK_TIMEOUT_MS,
|
|
8
|
+
GatewayClientError,
|
|
9
|
+
MAX_TASK_TIMEOUT_MS,
|
|
10
|
+
MIN_TASK_TIMEOUT_MS,
|
|
11
|
+
createWolfpackGatewayClient,
|
|
12
|
+
type TaskCompletionInput,
|
|
13
|
+
type TaskStatus,
|
|
14
|
+
type WolfpackGatewayClient,
|
|
15
|
+
} from "./gateway-client";
|
|
16
|
+
import { deliverTaskInbox, restoredInboxCursor } from "./legacy-task-inbox";
|
|
17
|
+
|
|
18
|
+
const BACKGROUND_POLL_MS = 5_000;
|
|
19
|
+
const WAIT_POLL_MS = 250;
|
|
20
|
+
const SUMMARY_MAX_CHARS = 1_200;
|
|
21
|
+
|
|
22
|
+
const AddressParams = Type.Object({
|
|
23
|
+
machine: Type.String({ minLength: 1, description: "local or a canonical Wolfpack machine identity" }),
|
|
24
|
+
sessionId: Type.String({ minLength: 1, description: "Stable opaque Wolfpack broker session id" }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const ContextRefParams = Type.Object({
|
|
28
|
+
path: Type.String({ minLength: 1 }),
|
|
29
|
+
selector: Type.Optional(Type.String()),
|
|
30
|
+
purpose: Type.Optional(Type.String()),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const SendParams = Type.Object({
|
|
34
|
+
to: AddressParams,
|
|
35
|
+
task: Type.String({ minLength: 1, maxLength: 16 * 1024 }),
|
|
36
|
+
context: Type.Optional(Type.Object({
|
|
37
|
+
summary: Type.Optional(Type.String({ minLength: 1, maxLength: 16 * 1024 })),
|
|
38
|
+
refs: Type.Optional(Type.Array(ContextRefParams)),
|
|
39
|
+
})),
|
|
40
|
+
role: Type.Optional(Type.String()),
|
|
41
|
+
preflight: Type.Optional(Type.Object({ requiredProject: Type.Optional(Type.String()) })),
|
|
42
|
+
metadata: Type.Optional(Type.Object({
|
|
43
|
+
phaseId: Type.Optional(Type.String()),
|
|
44
|
+
issueId: Type.Optional(Type.String()),
|
|
45
|
+
verificationTier: Type.Optional(Type.String()),
|
|
46
|
+
rootCause: Type.Optional(Type.String()),
|
|
47
|
+
})),
|
|
48
|
+
onCompletePrompt: Type.Optional(Type.String({ maxLength: 4_000 })),
|
|
49
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: MIN_TASK_TIMEOUT_MS, maximum: MAX_TASK_TIMEOUT_MS })),
|
|
50
|
+
idempotencyKey: Type.Optional(Type.String({ minLength: 1 })),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const TaskIdParams = Type.Object({ taskId: Type.String({ minLength: 1 }) });
|
|
54
|
+
const WaitParams = Type.Object({ taskId: Type.String({ minLength: 1 }), timeoutMs: Type.Optional(Type.Integer({ minimum: MIN_TASK_TIMEOUT_MS, maximum: MAX_TASK_TIMEOUT_MS })) });
|
|
55
|
+
const InboxParams = Type.Object({ includeAcknowledged: Type.Optional(Type.Boolean({ default: false })) });
|
|
56
|
+
const MessageParams = Type.Object({
|
|
57
|
+
taskId: Type.String({ minLength: 1 }),
|
|
58
|
+
type: StringEnum(["question", "answer", "information"] as const),
|
|
59
|
+
message: Type.String({ minLength: 1, maxLength: 16 * 1024 }),
|
|
60
|
+
replyToMessageId: Type.Optional(Type.String({ minLength: 1 })),
|
|
61
|
+
});
|
|
62
|
+
const DoneParams = Type.Object({
|
|
63
|
+
taskId: Type.String({ minLength: 1 }),
|
|
64
|
+
status: StringEnum(["completed", "failed", "cancelled"] as const),
|
|
65
|
+
summary: Type.String({ minLength: 1, maxLength: SUMMARY_MAX_CHARS }),
|
|
66
|
+
result: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
67
|
+
error: Type.Optional(Type.Object({ code: Type.String(), message: Type.String(), retryable: Type.Boolean() })),
|
|
68
|
+
artifacts: Type.Optional(Type.Array(Type.Object({ path: Type.String({ minLength: 1, description: "artifact paths must name receiver-project-relative regular files to inspect; artifacts are not a changed-file list." }), mimeType: Type.Optional(Type.String()), description: Type.Optional(Type.String()) }))),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export function registerAgentTaskTools(pi: ExtensionAPI, client: WolfpackGatewayClient = createWolfpackGatewayClient()): void {
|
|
72
|
+
let backgroundTimer: ReturnType<typeof setInterval> | undefined;
|
|
73
|
+
let inboxContext: ExtensionContext | undefined;
|
|
74
|
+
const refreshInbox = createSingleFlightInboxRefresh(async () => {
|
|
75
|
+
if (!inboxContext) return;
|
|
76
|
+
const cursor = restoredInboxCursor(inboxContext.sessionManager.getEntries());
|
|
77
|
+
await deliverTaskInbox(pi, client, inboxContext, cursor);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
81
|
+
inboxContext = ctx;
|
|
82
|
+
ctx.ui.setStatus("wolfpack-tasks", ctx.ui.theme.fg("dim", `tasks: ${client.callerSession}`));
|
|
83
|
+
backgroundTimer = setInterval(() => {
|
|
84
|
+
void refreshInbox().catch(() => ctx.ui.setStatus("wolfpack-tasks", ctx.ui.theme.fg("warning", "tasks: unavailable")));
|
|
85
|
+
}, BACKGROUND_POLL_MS);
|
|
86
|
+
await refreshInbox().catch(() => undefined);
|
|
87
|
+
});
|
|
88
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
89
|
+
inboxContext = ctx;
|
|
90
|
+
setTimeout(() => { void refreshInbox().catch(() => undefined); }, 0);
|
|
91
|
+
});
|
|
92
|
+
pi.on("session_shutdown", () => {
|
|
93
|
+
if (backgroundTimer) clearInterval(backgroundTimer);
|
|
94
|
+
backgroundTimer = undefined;
|
|
95
|
+
inboxContext = undefined;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.registerTool({
|
|
99
|
+
name: "agent_task_send", label: "Send Agent Task", description: "Durably send a bounded task to an existing Wolfpack Pi session.",
|
|
100
|
+
promptSnippet: "Send nonblocking structured tasks through the local Wolfpack gateway",
|
|
101
|
+
promptGuidelines: ["Use agent_task_send for normal nonblocking delegation; do not wait unless the user explicitly asks."],
|
|
102
|
+
parameters: SendParams,
|
|
103
|
+
async execute(_toolCallId, params, signal) {
|
|
104
|
+
try {
|
|
105
|
+
const receipt = await client.send(params, signal);
|
|
106
|
+
return toolResult(receipt, `## task accepted\n- task: \`${receipt.taskId}\`\n- target: \`${params.to.machine}/${params.to.sessionId}\`\n- delivery: pending adapter insertion (confirmed by \`task.delivered\`)\n${warnings(receipt.warnings)}`);
|
|
107
|
+
} catch (error) { return clientError(error); }
|
|
108
|
+
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
pi.registerTool({
|
|
112
|
+
name: "agent_task_status", label: "Task Status", description: "Read compact structured task status from Wolfpack.", parameters: TaskIdParams,
|
|
113
|
+
async execute(_toolCallId, params, signal) {
|
|
114
|
+
try { const status = await client.status(params.taskId, signal); return statusResult(status); } catch (error) { return clientError(error); }
|
|
115
|
+
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.registerTool({
|
|
119
|
+
name: "agent_task_wait", label: "Wait Agent Task", description: "Block only when explicitly invoked until a task reaches a terminal state or this wait times out.", parameters: WaitParams,
|
|
120
|
+
async execute(_toolCallId, params, signal, onUpdate) {
|
|
121
|
+
const deadline = Date.now() + (params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS);
|
|
122
|
+
try {
|
|
123
|
+
for (;;) {
|
|
124
|
+
throwIfAborted(signal);
|
|
125
|
+
const status = await client.status(params.taskId, signal);
|
|
126
|
+
if (terminal(status.status)) return statusResult(status);
|
|
127
|
+
if (Date.now() >= deadline) return toolResult({ taskId: params.taskId, status: status.status, error: { code: "WAIT_TIMEOUT", retryable: true } }, `## task wait\n- task: \`${params.taskId}\`\n- status: ${status.status}\n- wait timed out`);
|
|
128
|
+
onUpdate?.({ content: [{ type: "text", text: `waiting for ${params.taskId}...` }], details: {} });
|
|
129
|
+
await sleep(Math.min(WAIT_POLL_MS, Math.max(1, deadline - Date.now())), signal);
|
|
130
|
+
}
|
|
131
|
+
} catch (error) { return clientError(error); }
|
|
132
|
+
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
pi.registerTool({
|
|
136
|
+
name: "agent_task_inbox", label: "Task Inbox", description: "Read inbox task events without acknowledging them.", parameters: InboxParams,
|
|
137
|
+
async execute(_toolCallId, params, signal) {
|
|
138
|
+
try {
|
|
139
|
+
let cursor = "0";
|
|
140
|
+
const taskIds = new Set<string>();
|
|
141
|
+
for (;;) {
|
|
142
|
+
const page = await client.inbox(cursor, params.includeAcknowledged ?? false, signal);
|
|
143
|
+
for (const event of page.events) taskIds.add(event.taskId);
|
|
144
|
+
if (!page.hasMore) break;
|
|
145
|
+
cursor = page.nextCursor;
|
|
146
|
+
}
|
|
147
|
+
const tasks = await Promise.all([...taskIds].map((taskId) => client.status(taskId, signal)));
|
|
148
|
+
return toolResult({ tasks }, `## task inbox\n${tasks.length === 0 ? "- empty" : tasks.map((task) => `- \`${task.task.taskId}\`: ${task.status}${task.completion ? ` — ${task.completion.summary}` : ""}`).join("\n")}`);
|
|
149
|
+
} catch (error) { return clientError(error); }
|
|
150
|
+
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
pi.registerTool({
|
|
154
|
+
name: "agent_task_ack", label: "Acknowledge Agent Task", description: "Acknowledge one independently verified terminal task.", parameters: TaskIdParams,
|
|
155
|
+
async execute(_toolCallId, params, signal) {
|
|
156
|
+
try { return toolResult(await client.ack(params.taskId, signal), `## task acknowledged\n- task: \`${params.taskId}\``); } catch (error) { return clientError(error); }
|
|
157
|
+
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
pi.registerTool({
|
|
161
|
+
name: "agent_task_message", label: "Message Agent Task", description: "Durably send a question, answer, or information event for a task.", parameters: MessageParams,
|
|
162
|
+
async execute(_toolCallId, params, signal) {
|
|
163
|
+
try {
|
|
164
|
+
const [identity, status] = await Promise.all([client.sessionStatus(signal), client.status(params.taskId, signal)]);
|
|
165
|
+
const receipt = await client.message(params, signal);
|
|
166
|
+
const receiverQuestion = params.type === "question" && identity.sessionId === status.task.target.sessionId;
|
|
167
|
+
return { ...toolResult(receipt, `## task ${params.type}\n- task: \`${params.taskId}\`\n${params.message}`), ...(receiverQuestion ? { terminate: true } : {}) };
|
|
168
|
+
} catch (error) { return clientError(error); }
|
|
169
|
+
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
pi.registerTool({
|
|
173
|
+
name: "agent_task_cancel", label: "Cancel Agent Task", description: "Request cancellation for a non-terminal task through Wolfpack.", parameters: TaskIdParams,
|
|
174
|
+
async execute(_toolCallId, params, signal) {
|
|
175
|
+
try { const receipt = await client.cancel(params.taskId, signal); return toolResult(receipt, `## task cancellation requested\n- task: \`${params.taskId}\`${warnings(receipt.warnings)}`); } catch (error) { return clientError(error); }
|
|
176
|
+
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
pi.registerTool({
|
|
180
|
+
name: "agent_task_done", label: "Complete Agent Task", description: "Finish an assigned task with a structured terminal result. Report source modifications in result.changedFiles; artifacts are receiver-project-relative regular files to inspect, not a changed-file list. Use exactly once as the final action.",
|
|
181
|
+
promptSnippet: "Complete an assigned task: report changed files in result.changedFiles and artifacts separately", promptGuidelines: ["Report source modifications under result.changedFiles. Reserve artifacts for receiver-project-relative regular files a parent should inspect, not changed-file lists.", "Use agent_task_done exactly once as the final action for an assigned task."], parameters: DoneParams,
|
|
182
|
+
async execute(_toolCallId, params, signal) {
|
|
183
|
+
try {
|
|
184
|
+
const result: TaskCompletionInput = { summary: params.summary, ...(params.result && { result: params.result }), ...(params.error && { error: params.error }), ...(params.artifacts && { artifacts: params.artifacts }) };
|
|
185
|
+
const receipt = await client.complete({ taskId: params.taskId, status: params.status, result }, signal);
|
|
186
|
+
return { ...toolResult(receipt, `## task ${params.status}\n- task: \`${params.taskId}\`\n- ${params.summary}${warnings(receipt.warnings)}`), terminate: true };
|
|
187
|
+
} catch (error) { return clientError(error); }
|
|
188
|
+
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function createSingleFlightInboxRefresh(refresh: () => Promise<void>): () => Promise<void> {
|
|
193
|
+
let inFlight: Promise<void> | undefined;
|
|
194
|
+
return (): Promise<void> => {
|
|
195
|
+
if (inFlight) return inFlight;
|
|
196
|
+
const current = refresh().finally(() => {
|
|
197
|
+
if (inFlight === current) inFlight = undefined;
|
|
198
|
+
});
|
|
199
|
+
inFlight = current;
|
|
200
|
+
return current;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export default function piTasks(pi: ExtensionAPI): void {
|
|
205
|
+
registerAgentTaskTools(pi);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function statusResult(status: TaskStatus): AgentToolResult<unknown> {
|
|
209
|
+
return toolResult(status, `## task status\n- task: \`${status.task.taskId}\`\n- status: ${status.status}${status.completion ? `\n- summary: ${status.completion.summary}` : ""}${warnings(status.warnings)}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function toolResult(details: unknown, markdown: string): AgentToolResult<unknown> {
|
|
213
|
+
return { content: [{ type: "text", text: markdown }], details };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function clientError(error: unknown): AgentToolResult<unknown> {
|
|
217
|
+
const gateway = error instanceof GatewayClientError ? error : new GatewayClientError("CLIENT_ERROR", error instanceof Error ? error.message : "task gateway request failed", true);
|
|
218
|
+
const path = gateway.path === undefined ? {} : { path: gateway.path };
|
|
219
|
+
return toolResult(
|
|
220
|
+
{ error: { code: gateway.code, message: gateway.message, retryable: gateway.retryable, ...path } },
|
|
221
|
+
`## task gateway error\n- ${gateway.code}: ${gateway.message}${gateway.path === undefined ? "" : `\n- path: \`${gateway.path}\``}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function warnings(values: readonly { readonly code: string; readonly message: string }[]): string {
|
|
226
|
+
return values.length === 0 ? "" : `\n## gateway warnings\n${values.map((value) => `- ${value.code}: ${value.message}`).join("\n")}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function terminal(status: string): boolean {
|
|
230
|
+
return status === "completed" || status === "failed" || status === "cancelled" || status === "timed_out";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function text(result: { readonly content?: readonly unknown[] }): string {
|
|
234
|
+
const first = result.content?.[0];
|
|
235
|
+
return first && typeof first === "object" && "type" in first && first.type === "text" && "text" in first && typeof first.text === "string" ? first.text : "";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
239
|
+
if (signal?.aborted) throw new GatewayClientError("ABORTED", "task wait was cancelled", true);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function sleep(milliseconds: number, signal: AbortSignal | undefined): Promise<void> {
|
|
243
|
+
await new Promise<void>((resolve, reject) => {
|
|
244
|
+
const timeout = setTimeout(resolve, milliseconds);
|
|
245
|
+
const abort = (): void => { clearTimeout(timeout); reject(new GatewayClientError("ABORTED", "task wait was cancelled", true)); };
|
|
246
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
247
|
+
});
|
|
248
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { TaskAssignment, TaskEvent, TaskStatus, WolfpackGatewayClient } from "./gateway-client";
|
|
2
|
+
|
|
3
|
+
const TASK_EVENT_CUSTOM_TYPE = "wolfpack-task-event";
|
|
4
|
+
const TASK_CURSOR_CUSTOM_TYPE = "wolfpack-task-cursor";
|
|
5
|
+
|
|
6
|
+
interface InboxContext {
|
|
7
|
+
readonly hasPendingMessages: () => boolean;
|
|
8
|
+
readonly sessionManager: {
|
|
9
|
+
readonly buildContextEntries: () => readonly unknown[];
|
|
10
|
+
readonly getEntries: () => readonly unknown[];
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface InboxPi {
|
|
15
|
+
sendMessage(message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails }, options: { readonly triggerTurn: true; readonly deliverAs: "followUp" }): void;
|
|
16
|
+
appendEntry(customType: string, data: { readonly cursor: string }): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TaskEventDetails {
|
|
20
|
+
readonly taskId: string;
|
|
21
|
+
readonly eventId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface InboxClient {
|
|
25
|
+
inbox(cursor: string, includeAcknowledged: boolean): Promise<{ readonly events: readonly TaskEvent[]; readonly nextCursor: string; readonly hasMore: boolean }>;
|
|
26
|
+
status(taskId: string): Promise<TaskStatus>;
|
|
27
|
+
delivered(taskId: string, eventId: string): Promise<unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function deliverTaskInbox(pi: InboxPi, client: InboxClient, ctx: InboxContext, initialCursor: string): Promise<string> {
|
|
31
|
+
if (ctx.hasPendingMessages()) return initialCursor;
|
|
32
|
+
const entries = ctx.sessionManager.getEntries();
|
|
33
|
+
const incorporated = incorporatedEvents(entries);
|
|
34
|
+
const includeAcknowledged = initialCursor === "0" && !hasPersistedCursor(entries);
|
|
35
|
+
let cursor = initialCursor;
|
|
36
|
+
for (;;) {
|
|
37
|
+
const page = await client.inbox(cursor, includeAcknowledged);
|
|
38
|
+
for (const event of page.events) {
|
|
39
|
+
const disposition = inboxDisposition(event.type);
|
|
40
|
+
if (disposition === "internal") continue;
|
|
41
|
+
if (disposition === "unknown") throw new Error(`unknown task inbox event type: ${event.type}`);
|
|
42
|
+
if (ctx.hasPendingMessages()) return cursor;
|
|
43
|
+
const eventKey = key(event.taskId, event.id);
|
|
44
|
+
const snapshot = await client.status(event.taskId);
|
|
45
|
+
if (ctx.hasPendingMessages()) return cursor;
|
|
46
|
+
if (incorporated.has(eventKey)) {
|
|
47
|
+
if (event.destination.sessionId === snapshot.task.target.sessionId) await client.delivered(event.taskId, event.id);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (terminalHistoryAcknowledged(snapshot)) continue;
|
|
51
|
+
pi.sendMessage({
|
|
52
|
+
customType: TASK_EVENT_CUSTOM_TYPE,
|
|
53
|
+
content: renderTaskEvent(event, snapshot),
|
|
54
|
+
display: true,
|
|
55
|
+
details: { taskId: event.taskId, eventId: event.id },
|
|
56
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
57
|
+
if (!incorporatedEvents(ctx.sessionManager.getEntries()).has(eventKey)) return cursor;
|
|
58
|
+
incorporated.add(eventKey);
|
|
59
|
+
if (event.destination.sessionId === snapshot.task.target.sessionId) await client.delivered(event.taskId, event.id);
|
|
60
|
+
}
|
|
61
|
+
// Inbox cursors are machine-local delivery sequences, never task event sequences.
|
|
62
|
+
cursor = page.nextCursor;
|
|
63
|
+
pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor });
|
|
64
|
+
if (!page.hasMore) return cursor;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasPersistedCursor(entries: readonly unknown[]): boolean {
|
|
69
|
+
return entries.some((entry) => isRecord(entry) && entry.type === "custom" && entry.customType === TASK_CURSOR_CUSTOM_TYPE && isRecord(entry.data) && decimal(entry.data.cursor));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function inboxDisposition(type: string): "model-visible" | "internal" | "unknown" {
|
|
73
|
+
if (type === "task.created" || type === "task.question" || type === "task.answer" || type === "task.information" || terminalEvent(type) || type === "task.cancel_requested") return "model-visible";
|
|
74
|
+
if (type === "task.received" || type === "task.receipt_confirmed" || type === "task.delivered" || type === "message.delivered" || type === "task.parent_ack_pending" || type === "task.parent_acknowledged" || type === "event.delivery_failed" || type === "task.late_terminal") return "internal";
|
|
75
|
+
return "unknown";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function terminalHistoryAcknowledged(snapshot: TaskStatus): boolean {
|
|
79
|
+
return ["completed", "failed", "cancelled", "timed_out"].includes(snapshot.status) && snapshot.events.some((event) => event.type === "task.parent_acknowledged");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function restoredInboxCursor(entries: readonly unknown[]): string {
|
|
83
|
+
let cursor = "0";
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== TASK_CURSOR_CUSTOM_TYPE || !isRecord(entry.data) || !decimal(entry.data.cursor)) continue;
|
|
86
|
+
cursor = entry.data.cursor;
|
|
87
|
+
}
|
|
88
|
+
return cursor;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function incorporatedEvents(entries: readonly unknown[]): Set<string> {
|
|
92
|
+
const events = new Set<string>();
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (!isRecord(entry) || entry.type !== "custom_message" || entry.customType !== TASK_EVENT_CUSTOM_TYPE || !isRecord(entry.details) || !nonEmpty(entry.details.taskId) || !nonEmpty(entry.details.eventId)) continue;
|
|
95
|
+
events.add(key(entry.details.taskId, entry.details.eventId));
|
|
96
|
+
}
|
|
97
|
+
return events;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function renderTaskEvent(event: TaskEvent, snapshot: TaskStatus): string {
|
|
101
|
+
const header = `task: \`${event.taskId}\` · event: \`${event.id}\``;
|
|
102
|
+
if (event.type === "task.created") return renderAssignment(header, snapshot.task, snapshot.warnings);
|
|
103
|
+
const message = event.message ? `\n\n${event.message}` : "";
|
|
104
|
+
const completion = event.completion ?? snapshot.completion;
|
|
105
|
+
const completionText = completion ? `\n\n**summary:** ${completion.summary}` : "";
|
|
106
|
+
const parentPrompt = terminalEvent(event.type) && event.destination.sessionId === snapshot.task.source.sessionId && snapshot.task.onCompletePrompt
|
|
107
|
+
? `\n\n## parent follow-up\n${snapshot.task.onCompletePrompt}`
|
|
108
|
+
: "";
|
|
109
|
+
const warnings = renderWarnings(snapshot.warnings);
|
|
110
|
+
return `## task ${label(event.type)}\n${header}${message}${completionText}${parentPrompt}${warnings}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderAssignment(header: string, assignment: TaskAssignment, warnings: readonly { readonly code: string; readonly message: string }[]): string {
|
|
114
|
+
const role = assignment.role ? `\n\n## role\n${assignment.role}` : "";
|
|
115
|
+
const context = assignment.context?.summary ? `\n\n## context\n${assignment.context.summary}` : "";
|
|
116
|
+
const refs = assignment.context?.refs?.length
|
|
117
|
+
? `\n\n## selected refs\n${assignment.context.refs.map((ref) => `- \`${ref.path}\`${ref.selector ? ` — ${ref.selector}` : ""}${ref.purpose ? ` — ${ref.purpose}` : ""}`).join("\n")}`
|
|
118
|
+
: "";
|
|
119
|
+
return `## task assignment\n${header}\n\n${assignment.task}${role}${context}${refs}${renderWarnings(warnings)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderWarnings(warnings: readonly { readonly code: string; readonly message: string }[]): string {
|
|
123
|
+
return warnings.length === 0 ? "" : `\n\n## gateway warnings\n${warnings.map((warning) => `- ${warning.code}: ${warning.message}`).join("\n")}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function label(type: string): string {
|
|
127
|
+
return type.replace(/^task\./, "").replaceAll("_", " ");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function terminalEvent(type: string): boolean {
|
|
131
|
+
return type === "task.completed" || type === "task.failed" || type === "task.cancelled" || type === "task.timed_out";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function key(taskId: string, eventId: string): string {
|
|
135
|
+
return `${taskId}\u0000${eventId}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
139
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function nonEmpty(value: unknown): value is string {
|
|
143
|
+
return typeof value === "string" && value.length > 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function decimal(value: unknown): value is string {
|
|
147
|
+
return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
|
|
148
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { analyzeTaskStoreMetrics } from "./metrics";
|
|
2
|
+
import { buildTaskBoard } from "./task-board";
|
|
3
|
+
|
|
4
|
+
export async function runTaskMetricsCli(args: readonly string[]): Promise<number> {
|
|
5
|
+
const tasksRoot = args[0];
|
|
6
|
+
if (!tasksRoot) {
|
|
7
|
+
console.error("usage: bun run task-metrics <tasksRoot> [--board]");
|
|
8
|
+
return 1;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const report = args.includes("--board")
|
|
12
|
+
? await buildTaskBoard(tasksRoot)
|
|
13
|
+
: await analyzeTaskStoreMetrics(tasksRoot);
|
|
14
|
+
console.log(JSON.stringify(report, null, 2));
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (import.meta.main) {
|
|
19
|
+
process.exitCode = await runTaskMetricsCli(process.argv.slice(2));
|
|
20
|
+
}
|