pi-agent-squad 0.8.4 → 0.9.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 +47 -1
- package/cypher-status.ts +401 -0
- package/index.ts +614 -103
- package/package.json +18 -1
- package/session.ts +2 -0
- package/spawn.ts +86 -39
- package/task-delivery.ts +201 -0
- package/task-recovery.ts +41 -0
- package/task-state.ts +286 -0
- package/task-status.ts +107 -0
package/task-state.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
export const TASK_STATE_ENTRY = "agent-squad-task-state";
|
|
2
|
+
export const TASK_STATE_VERSION = 1 as const;
|
|
3
|
+
export const TASK_RESULT_DELIVERY_PREFIX = "agent-squad-result:";
|
|
4
|
+
|
|
5
|
+
export const TASK_STATE_LIMITS = {
|
|
6
|
+
runId: 128,
|
|
7
|
+
address: 256,
|
|
8
|
+
agent: 128,
|
|
9
|
+
task: 64 * 1024,
|
|
10
|
+
cwd: 4096,
|
|
11
|
+
ownerRuntimeId: 128,
|
|
12
|
+
childSessionFile: 4096,
|
|
13
|
+
resultSummary: 32 * 1024,
|
|
14
|
+
error: 8 * 1024,
|
|
15
|
+
retryOf: 128,
|
|
16
|
+
deliveryId: 256,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export type PersistedTaskMode = "sync" | "async" | "message";
|
|
20
|
+
export type PersistedTaskStatus =
|
|
21
|
+
| "starting"
|
|
22
|
+
| "running"
|
|
23
|
+
| "completed"
|
|
24
|
+
| "failed"
|
|
25
|
+
| "cancelled"
|
|
26
|
+
| "interrupted";
|
|
27
|
+
|
|
28
|
+
export interface PersistedTaskState {
|
|
29
|
+
version: 1;
|
|
30
|
+
runId: string;
|
|
31
|
+
address: string;
|
|
32
|
+
agent: string;
|
|
33
|
+
task: string;
|
|
34
|
+
cwd: string;
|
|
35
|
+
readOnly: boolean;
|
|
36
|
+
mode: PersistedTaskMode;
|
|
37
|
+
status: PersistedTaskStatus;
|
|
38
|
+
startedAt: number;
|
|
39
|
+
updatedAt: number;
|
|
40
|
+
endedAt?: number;
|
|
41
|
+
timeoutAt?: number;
|
|
42
|
+
childSessionFile?: string;
|
|
43
|
+
resultSummary?: string;
|
|
44
|
+
error?: string;
|
|
45
|
+
retryOf?: string;
|
|
46
|
+
deliveryId?: string;
|
|
47
|
+
resultInjected?: boolean;
|
|
48
|
+
ownerRuntimeId: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TaskStateEntryLike {
|
|
52
|
+
type?: unknown;
|
|
53
|
+
customType?: unknown;
|
|
54
|
+
data?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const MODES = new Set<PersistedTaskMode>(["sync", "async", "message"]);
|
|
58
|
+
const STATUSES = new Set<PersistedTaskStatus>([
|
|
59
|
+
"starting",
|
|
60
|
+
"running",
|
|
61
|
+
"completed",
|
|
62
|
+
"failed",
|
|
63
|
+
"cancelled",
|
|
64
|
+
"interrupted",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
68
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
69
|
+
try {
|
|
70
|
+
const prototype = Object.getPrototypeOf(value);
|
|
71
|
+
return prototype === Object.prototype || prototype === null;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function requiredString(
|
|
78
|
+
value: unknown,
|
|
79
|
+
maxLength: number,
|
|
80
|
+
options: { allowEmpty?: boolean } = {},
|
|
81
|
+
): value is string {
|
|
82
|
+
return (
|
|
83
|
+
typeof value === "string" &&
|
|
84
|
+
(options.allowEmpty === true || value.length > 0) &&
|
|
85
|
+
value.length <= maxLength
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function optionalString(value: unknown, maxLength: number): value is string | undefined {
|
|
90
|
+
return value === undefined || requiredString(value, maxLength);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function safeTimestamp(value: unknown): value is number {
|
|
94
|
+
return (
|
|
95
|
+
typeof value === "number" &&
|
|
96
|
+
Number.isSafeInteger(value) &&
|
|
97
|
+
Number.isFinite(value) &&
|
|
98
|
+
value >= 0
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function optionalTimestamp(value: unknown): value is number | undefined {
|
|
103
|
+
return value === undefined || safeTimestamp(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hasNull(value: string): boolean {
|
|
107
|
+
return value.includes("\0");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isSingleLineIdentity(value: string): boolean {
|
|
111
|
+
return !/[\u0000-\u0020\u007f]/.test(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isSafeRunIdentity(value: string): boolean {
|
|
115
|
+
return isSingleLineIdentity(value) && /^[A-Za-z0-9_.-]+$/.test(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isSafeAgentIdentity(value: string): boolean {
|
|
119
|
+
return (
|
|
120
|
+
isSafeRunIdentity(value) &&
|
|
121
|
+
value !== "." &&
|
|
122
|
+
value !== ".." &&
|
|
123
|
+
value !== "main"
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isSafeAddress(value: string): boolean {
|
|
128
|
+
return (
|
|
129
|
+
isSingleLineIdentity(value) &&
|
|
130
|
+
/^[A-Za-z0-9_.#-]+$/.test(value) &&
|
|
131
|
+
value !== "." &&
|
|
132
|
+
value !== ".." &&
|
|
133
|
+
value !== "main"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Session custom-entry data is untrusted. This validator intentionally rejects
|
|
139
|
+
* unknown versions, non-plain objects, oversized strings, unknown enums, and
|
|
140
|
+
* non-integer timestamps. A rejected snapshot is ignored without affecting
|
|
141
|
+
* other snapshots for the same or another run.
|
|
142
|
+
*/
|
|
143
|
+
export function parsePersistedTaskState(data: unknown): PersistedTaskState | undefined {
|
|
144
|
+
if (!isPlainObject(data)) return undefined;
|
|
145
|
+
if (data.version !== TASK_STATE_VERSION || !Number.isInteger(data.version)) return undefined;
|
|
146
|
+
if (!requiredString(data.runId, TASK_STATE_LIMITS.runId)) return undefined;
|
|
147
|
+
if (!requiredString(data.address, TASK_STATE_LIMITS.address)) return undefined;
|
|
148
|
+
if (!requiredString(data.agent, TASK_STATE_LIMITS.agent)) return undefined;
|
|
149
|
+
if (!requiredString(data.task, TASK_STATE_LIMITS.task, { allowEmpty: true })) return undefined;
|
|
150
|
+
if (!requiredString(data.cwd, TASK_STATE_LIMITS.cwd)) return undefined;
|
|
151
|
+
if (typeof data.readOnly !== "boolean") return undefined;
|
|
152
|
+
if (typeof data.mode !== "string" || !MODES.has(data.mode as PersistedTaskMode)) return undefined;
|
|
153
|
+
if (typeof data.status !== "string" || !STATUSES.has(data.status as PersistedTaskStatus)) return undefined;
|
|
154
|
+
if (!safeTimestamp(data.startedAt) || !safeTimestamp(data.updatedAt)) return undefined;
|
|
155
|
+
if (!optionalTimestamp(data.endedAt) || !optionalTimestamp(data.timeoutAt)) return undefined;
|
|
156
|
+
if (!optionalString(data.childSessionFile, TASK_STATE_LIMITS.childSessionFile)) return undefined;
|
|
157
|
+
if (!optionalString(data.resultSummary, TASK_STATE_LIMITS.resultSummary)) return undefined;
|
|
158
|
+
if (!optionalString(data.error, TASK_STATE_LIMITS.error)) return undefined;
|
|
159
|
+
if (!optionalString(data.retryOf, TASK_STATE_LIMITS.retryOf)) return undefined;
|
|
160
|
+
if (!optionalString(data.deliveryId, TASK_STATE_LIMITS.deliveryId)) return undefined;
|
|
161
|
+
if (data.resultInjected !== undefined && typeof data.resultInjected !== "boolean") return undefined;
|
|
162
|
+
if (!requiredString(data.ownerRuntimeId, TASK_STATE_LIMITS.ownerRuntimeId)) return undefined;
|
|
163
|
+
if (
|
|
164
|
+
!isSafeRunIdentity(data.runId) ||
|
|
165
|
+
!isSafeAddress(data.address) ||
|
|
166
|
+
!isSafeAgentIdentity(data.agent) ||
|
|
167
|
+
!isSafeRunIdentity(data.ownerRuntimeId) ||
|
|
168
|
+
hasNull(data.task) ||
|
|
169
|
+
hasNull(data.cwd) ||
|
|
170
|
+
(data.childSessionFile !== undefined && hasNull(data.childSessionFile)) ||
|
|
171
|
+
(data.resultSummary !== undefined && hasNull(data.resultSummary)) ||
|
|
172
|
+
(data.error !== undefined && hasNull(data.error)) ||
|
|
173
|
+
(data.retryOf !== undefined && !isSafeRunIdentity(data.retryOf)) ||
|
|
174
|
+
(data.deliveryId !== undefined && !isSingleLineIdentity(data.deliveryId))
|
|
175
|
+
) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
if (
|
|
179
|
+
data.updatedAt < data.startedAt ||
|
|
180
|
+
(data.endedAt !== undefined && data.endedAt < data.startedAt) ||
|
|
181
|
+
(data.timeoutAt !== undefined && data.timeoutAt < data.startedAt)
|
|
182
|
+
) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
(data.status === "completed" ||
|
|
187
|
+
data.status === "failed" ||
|
|
188
|
+
data.status === "cancelled" ||
|
|
189
|
+
data.status === "interrupted") &&
|
|
190
|
+
data.endedAt === undefined
|
|
191
|
+
) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
if (
|
|
195
|
+
data.status === "completed" &&
|
|
196
|
+
(!requiredString(data.deliveryId, TASK_STATE_LIMITS.deliveryId) ||
|
|
197
|
+
typeof data.resultInjected !== "boolean" ||
|
|
198
|
+
data.deliveryId !== `${TASK_RESULT_DELIVERY_PREFIX}${data.runId}`)
|
|
199
|
+
) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
version: TASK_STATE_VERSION,
|
|
205
|
+
runId: data.runId,
|
|
206
|
+
address: data.address,
|
|
207
|
+
agent: data.agent,
|
|
208
|
+
task: data.task,
|
|
209
|
+
cwd: data.cwd,
|
|
210
|
+
readOnly: data.readOnly,
|
|
211
|
+
mode: data.mode as PersistedTaskMode,
|
|
212
|
+
status: data.status as PersistedTaskStatus,
|
|
213
|
+
startedAt: data.startedAt,
|
|
214
|
+
updatedAt: data.updatedAt,
|
|
215
|
+
...(data.endedAt !== undefined ? { endedAt: data.endedAt } : {}),
|
|
216
|
+
...(data.timeoutAt !== undefined ? { timeoutAt: data.timeoutAt } : {}),
|
|
217
|
+
...(data.childSessionFile !== undefined ? { childSessionFile: data.childSessionFile } : {}),
|
|
218
|
+
...(data.resultSummary !== undefined ? { resultSummary: data.resultSummary } : {}),
|
|
219
|
+
...(data.error !== undefined ? { error: data.error } : {}),
|
|
220
|
+
...(data.retryOf !== undefined ? { retryOf: data.retryOf } : {}),
|
|
221
|
+
...(data.deliveryId !== undefined ? { deliveryId: data.deliveryId } : {}),
|
|
222
|
+
...(data.resultInjected !== undefined ? { resultInjected: data.resultInjected } : {}),
|
|
223
|
+
ownerRuntimeId: data.ownerRuntimeId,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Reconstruct the authoritative latest valid snapshot for every run. */
|
|
228
|
+
export function reconstructTaskStates(entries: readonly unknown[]): Map<string, PersistedTaskState> {
|
|
229
|
+
const latest = new Map<string, PersistedTaskState>();
|
|
230
|
+
for (const rawEntry of entries) {
|
|
231
|
+
try {
|
|
232
|
+
if (!isPlainObject(rawEntry)) continue;
|
|
233
|
+
const entry = rawEntry as TaskStateEntryLike;
|
|
234
|
+
if (entry.type !== "custom" || entry.customType !== TASK_STATE_ENTRY) continue;
|
|
235
|
+
const state = parsePersistedTaskState(entry.data);
|
|
236
|
+
if (state) latest.set(state.runId, state);
|
|
237
|
+
} catch {
|
|
238
|
+
// One hostile/malformed entry must not prevent recovery of the rest.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return latest;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function isTerminalTaskStatus(status: PersistedTaskStatus): boolean {
|
|
245
|
+
return (
|
|
246
|
+
status === "completed" ||
|
|
247
|
+
status === "failed" ||
|
|
248
|
+
status === "cancelled" ||
|
|
249
|
+
status === "interrupted"
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function isRetryableTaskStatus(status: PersistedTaskStatus): boolean {
|
|
254
|
+
return status === "failed" || status === "cancelled" || status === "interrupted";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Bound diagnostics written by trusted runtime code before persistence. */
|
|
258
|
+
export function boundTaskText(value: unknown, maxLength: number): string {
|
|
259
|
+
const text = String(value ?? "");
|
|
260
|
+
if (text.length <= maxLength) return text;
|
|
261
|
+
if (maxLength <= 1) return text.slice(0, maxLength);
|
|
262
|
+
return `${text.slice(0, maxLength - 1)}…`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function validateLaunchText(
|
|
266
|
+
field: keyof Pick<typeof TASK_STATE_LIMITS, "agent" | "task" | "cwd" | "address">,
|
|
267
|
+
value: string,
|
|
268
|
+
options: { allowEmpty?: boolean } = {},
|
|
269
|
+
): void {
|
|
270
|
+
const maxLength = TASK_STATE_LIMITS[field];
|
|
271
|
+
if (value.length > maxLength || (value.length === 0 && options.allowEmpty !== true)) {
|
|
272
|
+
const empty = value.length === 0 && options.allowEmpty !== true;
|
|
273
|
+
throw new Error(
|
|
274
|
+
empty
|
|
275
|
+
? `${field} must not be empty.`
|
|
276
|
+
: `${field} exceeds the ${maxLength}-character persistence limit.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (
|
|
280
|
+
(field === "agent" && !isSafeAgentIdentity(value)) ||
|
|
281
|
+
(field === "address" && !isSafeAddress(value)) ||
|
|
282
|
+
((field === "task" || field === "cwd") && hasNull(value))
|
|
283
|
+
) {
|
|
284
|
+
throw new Error(`${field} contains characters that cannot be persisted safely.`);
|
|
285
|
+
}
|
|
286
|
+
}
|
package/task-status.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { PersistedTaskState, PersistedTaskStatus } from "./task-state.ts";
|
|
2
|
+
|
|
3
|
+
export interface LiveTaskView {
|
|
4
|
+
runId: string;
|
|
5
|
+
address: string;
|
|
6
|
+
agent: string;
|
|
7
|
+
task: string;
|
|
8
|
+
status: "starting" | "running";
|
|
9
|
+
startedAt: number;
|
|
10
|
+
updatedAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const GROUP_ORDER: PersistedTaskStatus[] = [
|
|
14
|
+
"running",
|
|
15
|
+
"starting",
|
|
16
|
+
"interrupted",
|
|
17
|
+
"failed",
|
|
18
|
+
"cancelled",
|
|
19
|
+
"completed",
|
|
20
|
+
];
|
|
21
|
+
const GROUP_LABEL: Record<PersistedTaskStatus, string> = {
|
|
22
|
+
starting: "Active",
|
|
23
|
+
running: "Active",
|
|
24
|
+
interrupted: "Interrupted",
|
|
25
|
+
failed: "Failed",
|
|
26
|
+
cancelled: "Cancelled",
|
|
27
|
+
completed: "Completed",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function oneLine(value: string, maxLength: number): string {
|
|
31
|
+
const text = value.replace(/\s+/g, " ").trim();
|
|
32
|
+
if (text.length <= maxLength) return text;
|
|
33
|
+
return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function activeLine(state: PersistedTaskState | LiveTaskView, now: number): string {
|
|
37
|
+
const elapsed = Math.max(0, Math.floor((now - state.startedAt) / 1000));
|
|
38
|
+
return `- ${state.address} · ${state.status} · ${elapsed}s · run ${state.runId.slice(0, 8)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function historyLine(state: PersistedTaskState): string {
|
|
42
|
+
const reason = oneLine(state.error ?? "", 240);
|
|
43
|
+
if (state.status === "interrupted") {
|
|
44
|
+
return `- ${state.address} · runtime ended before completion${reason ? ` · ${reason}` : ""}\n Retry: /subagent-retry ${state.runId}`;
|
|
45
|
+
}
|
|
46
|
+
if (state.status === "failed") {
|
|
47
|
+
return `- ${state.address} · failed${reason ? ` · ${reason}` : ""} · run ${state.runId.slice(0, 8)}`;
|
|
48
|
+
}
|
|
49
|
+
if (state.status === "cancelled") {
|
|
50
|
+
return `- ${state.address} · cancelled${reason ? ` · ${reason}` : ""} · run ${state.runId.slice(0, 8)}`;
|
|
51
|
+
}
|
|
52
|
+
if (state.status === "completed") {
|
|
53
|
+
return `- ${state.address} · ${state.resultInjected ? "result delivered" : "result pending delivery"} · run ${state.runId.slice(0, 8)}`;
|
|
54
|
+
}
|
|
55
|
+
return `- ${state.address} · ${state.status} · run ${state.runId.slice(0, 8)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Merge current runtime tasks with current-branch snapshots. Live data wins for
|
|
60
|
+
* the same runId. Output is deliberately bounded.
|
|
61
|
+
*/
|
|
62
|
+
export function formatTaskStatus(
|
|
63
|
+
liveTasks: readonly LiveTaskView[],
|
|
64
|
+
persistedStates: ReadonlyMap<string, PersistedTaskState>,
|
|
65
|
+
options: { now?: number; maxPerGroup?: number; maxTotal?: number } = {},
|
|
66
|
+
): string {
|
|
67
|
+
const now = options.now ?? Date.now();
|
|
68
|
+
const maxPerGroup = Math.max(1, options.maxPerGroup ?? 10);
|
|
69
|
+
const maxTotal = Math.max(1, options.maxTotal ?? 40);
|
|
70
|
+
const merged = new Map<string, PersistedTaskState | LiveTaskView>();
|
|
71
|
+
for (const state of persistedStates.values()) merged.set(state.runId, state);
|
|
72
|
+
for (const live of liveTasks) merged.set(live.runId, live);
|
|
73
|
+
|
|
74
|
+
const orderedStatuses = [...new Set(GROUP_ORDER)];
|
|
75
|
+
const sections: string[] = [];
|
|
76
|
+
let shownTotal = 0;
|
|
77
|
+
let omittedTotal = 0;
|
|
78
|
+
for (const status of orderedStatuses) {
|
|
79
|
+
const label = GROUP_LABEL[status];
|
|
80
|
+
const statuses =
|
|
81
|
+
label === "Active" ? new Set<PersistedTaskStatus>(["starting", "running"]) : new Set([status]);
|
|
82
|
+
if (status === "starting") continue;
|
|
83
|
+
const items = [...merged.values()]
|
|
84
|
+
.filter((state) => statuses.has(state.status))
|
|
85
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
86
|
+
if (items.length === 0) continue;
|
|
87
|
+
const capacity = Math.max(0, Math.min(maxPerGroup, maxTotal - shownTotal));
|
|
88
|
+
const visible = items.slice(0, capacity);
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
for (const item of visible) {
|
|
91
|
+
lines.push(
|
|
92
|
+
item.status === "starting" || item.status === "running"
|
|
93
|
+
? activeLine(item, now)
|
|
94
|
+
: historyLine(item as PersistedTaskState),
|
|
95
|
+
);
|
|
96
|
+
lines.push(` ${oneLine(item.task, 160) || "(empty task)"}`);
|
|
97
|
+
}
|
|
98
|
+
const omitted = items.length - visible.length;
|
|
99
|
+
if (omitted > 0) lines.push(`- … ${omitted} more ${label.toLowerCase()} task(s) omitted`);
|
|
100
|
+
sections.push(`${label}:\n${lines.join("\n")}`);
|
|
101
|
+
shownTotal += visible.length;
|
|
102
|
+
omittedTotal += omitted;
|
|
103
|
+
}
|
|
104
|
+
if (sections.length === 0) return "Subagent tasks: none";
|
|
105
|
+
if (omittedTotal > 0) sections.push(`History truncated: ${omittedTotal} task(s) omitted.`);
|
|
106
|
+
return sections.join("\n\n");
|
|
107
|
+
}
|