pi-squad 0.16.3 → 0.16.5
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 +24 -15
- package/package.json +1 -1
- package/src/agent-pool.ts +123 -44
- package/src/index.ts +258 -223
- package/src/panel/message-view.ts +58 -50
- package/src/panel/squad-widget.ts +35 -10
- package/src/panel/task-list.ts +13 -3
- package/src/review.ts +15 -0
- package/src/router.ts +37 -35
- package/src/scheduler.ts +300 -73
- package/src/store.ts +195 -1
- package/src/types.ts +27 -1
package/src/store.ts
CHANGED
|
@@ -9,13 +9,16 @@
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import * as os from "node:os";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
12
13
|
import type {
|
|
13
14
|
AgentDef,
|
|
14
15
|
KnowledgeEntry,
|
|
15
16
|
Squad,
|
|
16
17
|
SquadContext,
|
|
17
18
|
Task,
|
|
19
|
+
TaskMailboxEntry,
|
|
18
20
|
TaskMessage,
|
|
21
|
+
TaskSession,
|
|
19
22
|
TaskUsage,
|
|
20
23
|
DEFAULT_SQUAD_CONFIG,
|
|
21
24
|
} from "./types.js";
|
|
@@ -122,6 +125,15 @@ export function getMessagesFilePath(squadId: string, taskId: string, parentPath?
|
|
|
122
125
|
return path.join(getTaskDir(squadId, taskId, parentPath), "messages.jsonl");
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
export function getTaskMailboxFilePath(squadId: string, taskId: string, parentPath?: string): string {
|
|
129
|
+
return path.join(getTaskDir(squadId, taskId, parentPath), "mailbox.json");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Task-owned directory where Pi creates the task's durable session JSONL. */
|
|
133
|
+
export function getTaskSessionDir(squadId: string, taskId: string, parentPath?: string): string {
|
|
134
|
+
return path.join(getTaskDir(squadId, taskId, parentPath), "session");
|
|
135
|
+
}
|
|
136
|
+
|
|
125
137
|
// ============================================================================
|
|
126
138
|
// Atomic File Operations
|
|
127
139
|
// ============================================================================
|
|
@@ -149,6 +161,52 @@ function readJson<T>(filePath: string): T | null {
|
|
|
149
161
|
}
|
|
150
162
|
}
|
|
151
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Serialize cross-process read/modify/write operations for one JSON file.
|
|
166
|
+
* Atomic rename prevents torn writes but does not prevent two writers from
|
|
167
|
+
* reading the same old value and overwriting each other. The adjacent lock is
|
|
168
|
+
* short-lived; an abandoned lock older than 30s is recoverable after a crash.
|
|
169
|
+
*/
|
|
170
|
+
function withJsonFileLock<T>(filePath: string, operation: () => T): T {
|
|
171
|
+
ensureDir(path.dirname(filePath));
|
|
172
|
+
const lockPath = `${filePath}.lock`;
|
|
173
|
+
const startedAt = Date.now();
|
|
174
|
+
let lockFd: number | null = null;
|
|
175
|
+
while (lockFd === null) {
|
|
176
|
+
try {
|
|
177
|
+
lockFd = fs.openSync(lockPath, "wx");
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
180
|
+
try {
|
|
181
|
+
if (Date.now() - fs.statSync(lockPath).mtimeMs > 30_000) {
|
|
182
|
+
fs.unlinkSync(lockPath);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (Date.now() - startedAt > 10_000) {
|
|
189
|
+
throw new Error(`Timed out acquiring durable store lock: ${lockPath}`);
|
|
190
|
+
}
|
|
191
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
fs.writeFileSync(lockFd, `${process.pid}\n${Date.now()}\n`, "utf-8");
|
|
196
|
+
} catch (error) {
|
|
197
|
+
try { fs.closeSync(lockFd); } catch { /* ignore */ }
|
|
198
|
+
try { fs.unlinkSync(lockPath); } catch { /* ignore */ }
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
return operation();
|
|
204
|
+
} finally {
|
|
205
|
+
try { fs.closeSync(lockFd); } catch { /* ignore */ }
|
|
206
|
+
try { fs.unlinkSync(lockPath); } catch { /* ignore */ }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
152
210
|
/** Append a JSONL line */
|
|
153
211
|
function appendJsonl(filePath: string, entry: unknown): void {
|
|
154
212
|
ensureDir(path.dirname(filePath));
|
|
@@ -385,6 +443,64 @@ export function updateTaskUsage(squadId: string, taskId: string, usage: Partial<
|
|
|
385
443
|
saveTask(squadId, task);
|
|
386
444
|
}
|
|
387
445
|
|
|
446
|
+
/** Read the durable Pi session currently bound to a task. */
|
|
447
|
+
export function loadTaskSession(squadId: string, taskId: string, parentPath?: string): TaskSession | null {
|
|
448
|
+
return loadTask(squadId, taskId, parentPath)?.session ?? null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Bind a task to its durable Pi session. The file identity is write-once: a
|
|
453
|
+
* reconstructed scheduler may repeat the same binding, but cannot replace it
|
|
454
|
+
* with a fresh session and silently discard the task's conversation context.
|
|
455
|
+
*/
|
|
456
|
+
export function bindTaskSession(
|
|
457
|
+
squadId: string,
|
|
458
|
+
taskId: string,
|
|
459
|
+
session: TaskSession,
|
|
460
|
+
parentPath?: string,
|
|
461
|
+
): TaskSession {
|
|
462
|
+
const task = loadTask(squadId, taskId, parentPath);
|
|
463
|
+
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
464
|
+
if (!path.isAbsolute(session.file)) {
|
|
465
|
+
throw new Error(`Task session file must be absolute: ${session.file}`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const normalized: TaskSession = {
|
|
469
|
+
file: path.normalize(session.file),
|
|
470
|
+
...(session.sessionId ? { sessionId: session.sessionId } : {}),
|
|
471
|
+
};
|
|
472
|
+
const existing = task.session;
|
|
473
|
+
if (existing) {
|
|
474
|
+
if (path.normalize(existing.file) !== normalized.file) {
|
|
475
|
+
throw new Error(`Task ${taskId} is already bound to Pi session ${existing.file}`);
|
|
476
|
+
}
|
|
477
|
+
if (existing.sessionId && normalized.sessionId && existing.sessionId !== normalized.sessionId) {
|
|
478
|
+
// Before Pi accepts the first prompt, get_state reserves the eventual
|
|
479
|
+
// file path but the JSONL does not exist yet. Reopening that same path
|
|
480
|
+
// produces a new provisional sessionId. Permit only that pre-materialized
|
|
481
|
+
// same-file recovery; a real JSONL header makes the ID immutable.
|
|
482
|
+
let materialized = false;
|
|
483
|
+
try { materialized = fs.statSync(normalized.file).size > 0; } catch { /* not created yet */ }
|
|
484
|
+
if (materialized) {
|
|
485
|
+
throw new Error(`Task ${taskId} is already bound to Pi session ${existing.file}`);
|
|
486
|
+
}
|
|
487
|
+
task.session = normalized;
|
|
488
|
+
saveTask(squadId, task, parentPath);
|
|
489
|
+
return normalized;
|
|
490
|
+
}
|
|
491
|
+
if (!existing.sessionId && normalized.sessionId) {
|
|
492
|
+
task.session = normalized;
|
|
493
|
+
saveTask(squadId, task, parentPath);
|
|
494
|
+
return normalized;
|
|
495
|
+
}
|
|
496
|
+
return existing;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
task.session = normalized;
|
|
500
|
+
saveTask(squadId, task, parentPath);
|
|
501
|
+
return normalized;
|
|
502
|
+
}
|
|
503
|
+
|
|
388
504
|
// ============================================================================
|
|
389
505
|
// Messages
|
|
390
506
|
// ============================================================================
|
|
@@ -394,7 +510,85 @@ export function appendMessage(squadId: string, taskId: string, message: TaskMess
|
|
|
394
510
|
}
|
|
395
511
|
|
|
396
512
|
export function loadMessages(squadId: string, taskId: string, parentPath?: string): TaskMessage[] {
|
|
397
|
-
|
|
513
|
+
const history = readJsonl<TaskMessage>(getMessagesFilePath(squadId, taskId, parentPath));
|
|
514
|
+
const seen = new Set(history.flatMap((message) => message.id ? [message.id] : []));
|
|
515
|
+
for (const entry of loadTaskMailbox(squadId, taskId, parentPath)) {
|
|
516
|
+
if (!seen.has(entry.id)) {
|
|
517
|
+
history.push(entry.message);
|
|
518
|
+
seen.add(entry.id);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return history.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Load all task-addressed mailbox entries, including delivered history. */
|
|
525
|
+
export function loadTaskMailbox(squadId: string, taskId: string, parentPath?: string): TaskMailboxEntry[] {
|
|
526
|
+
return readJson<TaskMailboxEntry[]>(getTaskMailboxFilePath(squadId, taskId, parentPath)) ?? [];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Load only mailbox entries still awaiting successful delivery to this task. */
|
|
530
|
+
export function loadPendingTaskMessages(squadId: string, taskId: string, parentPath?: string): TaskMailboxEntry[] {
|
|
531
|
+
return loadTaskMailbox(squadId, taskId, parentPath).filter((entry) => entry.deliveredAt === null);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Durably queue one inbound message for a task. The mailbox is written before
|
|
536
|
+
* the append-only history; loadMessages merges both by stable ID, so the
|
|
537
|
+
* message remains visible even if a process stops between those writes.
|
|
538
|
+
*/
|
|
539
|
+
export function queueTaskMessage(
|
|
540
|
+
squadId: string,
|
|
541
|
+
taskId: string,
|
|
542
|
+
message: TaskMessage,
|
|
543
|
+
parentPath?: string,
|
|
544
|
+
): TaskMailboxEntry {
|
|
545
|
+
if (!loadTask(squadId, taskId, parentPath)) throw new Error(`Task not found: ${taskId}`);
|
|
546
|
+
|
|
547
|
+
const mailboxPath = getTaskMailboxFilePath(squadId, taskId, parentPath);
|
|
548
|
+
return withJsonFileLock(mailboxPath, () => {
|
|
549
|
+
const mailbox = loadTaskMailbox(squadId, taskId, parentPath);
|
|
550
|
+
const id = message.id ?? randomUUID();
|
|
551
|
+
const existing = mailbox.find((entry) => entry.id === id);
|
|
552
|
+
if (existing) return existing;
|
|
553
|
+
|
|
554
|
+
const durableMessage: TaskMessage = { ...message, id };
|
|
555
|
+
const entry: TaskMailboxEntry = {
|
|
556
|
+
id,
|
|
557
|
+
taskId,
|
|
558
|
+
enqueuedAt: now(),
|
|
559
|
+
deliveredAt: null,
|
|
560
|
+
message: durableMessage,
|
|
561
|
+
};
|
|
562
|
+
mailbox.push(entry);
|
|
563
|
+
writeJsonAtomic(mailboxPath, mailbox);
|
|
564
|
+
appendJsonl(getMessagesFilePath(squadId, taskId, parentPath), durableMessage);
|
|
565
|
+
return entry;
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Mark task mailbox entries delivered while retaining their durable history. */
|
|
570
|
+
export function acknowledgeTaskMessages(
|
|
571
|
+
squadId: string,
|
|
572
|
+
taskId: string,
|
|
573
|
+
messageIds: string[],
|
|
574
|
+
parentPath?: string,
|
|
575
|
+
): number {
|
|
576
|
+
if (messageIds.length === 0) return 0;
|
|
577
|
+
const wanted = new Set(messageIds);
|
|
578
|
+
const mailboxPath = getTaskMailboxFilePath(squadId, taskId, parentPath);
|
|
579
|
+
return withJsonFileLock(mailboxPath, () => {
|
|
580
|
+
const mailbox = loadTaskMailbox(squadId, taskId, parentPath);
|
|
581
|
+
const deliveredAt = now();
|
|
582
|
+
let count = 0;
|
|
583
|
+
for (const entry of mailbox) {
|
|
584
|
+
if (wanted.has(entry.id) && entry.deliveredAt === null) {
|
|
585
|
+
entry.deliveredAt = deliveredAt;
|
|
586
|
+
count++;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (count > 0) writeJsonAtomic(mailboxPath, mailbox);
|
|
590
|
+
return count;
|
|
591
|
+
});
|
|
398
592
|
}
|
|
399
593
|
|
|
400
594
|
// ============================================================================
|
package/src/types.ts
CHANGED
|
@@ -112,8 +112,10 @@ export interface Squad {
|
|
|
112
112
|
/** Agent name → overrides. Keys must exist in .pi/squad/agents/ */
|
|
113
113
|
agents: Record<string, SquadAgentEntry>;
|
|
114
114
|
config: SquadConfig;
|
|
115
|
-
/** Mandatory independent main-session review; absent
|
|
115
|
+
/** Mandatory independent main-session review; absent while rework is running. */
|
|
116
116
|
review?: SquadReview;
|
|
117
|
+
/** Completed prior review attempts retained as same-squad audit evidence. */
|
|
118
|
+
reviewHistory?: SquadReview[];
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
// ============================================================================
|
|
@@ -129,6 +131,14 @@ export interface TaskUsage {
|
|
|
129
131
|
turns: number;
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
/** Durable Pi session owned by one task. Once bound, the file identity is immutable. */
|
|
135
|
+
export interface TaskSession {
|
|
136
|
+
/** Absolute Pi session JSONL path, suitable for `pi --session`. */
|
|
137
|
+
file: string;
|
|
138
|
+
/** Pi's session UUID from RPC get_state, when available. */
|
|
139
|
+
sessionId?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
132
142
|
export interface Task {
|
|
133
143
|
id: string;
|
|
134
144
|
title: string;
|
|
@@ -145,6 +155,8 @@ export interface Task {
|
|
|
145
155
|
output: string | null;
|
|
146
156
|
error: string | null;
|
|
147
157
|
usage: TaskUsage;
|
|
158
|
+
/** Durable Pi context for this task. Absent until its first process creates a session. */
|
|
159
|
+
session?: TaskSession;
|
|
148
160
|
/** If this is a rework task, the original task ID it's fixing */
|
|
149
161
|
retryOf?: string;
|
|
150
162
|
/** How many times this task chain has been retried */
|
|
@@ -160,6 +172,8 @@ export interface Task {
|
|
|
160
172
|
export type MessageType = "status" | "text" | "tool" | "mention" | "reply" | "message" | "done" | "error";
|
|
161
173
|
|
|
162
174
|
export interface TaskMessage {
|
|
175
|
+
/** Stable ID when the message participates in the durable task mailbox. */
|
|
176
|
+
id?: string;
|
|
163
177
|
ts: string;
|
|
164
178
|
from: string;
|
|
165
179
|
type: MessageType;
|
|
@@ -171,6 +185,18 @@ export interface TaskMessage {
|
|
|
171
185
|
args?: Record<string, unknown>;
|
|
172
186
|
}
|
|
173
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Durable inbound delivery record owned by a task ID. Entries are retained
|
|
190
|
+
* after acknowledgement so task history remains available across restarts.
|
|
191
|
+
*/
|
|
192
|
+
export interface TaskMailboxEntry {
|
|
193
|
+
id: string;
|
|
194
|
+
taskId: string;
|
|
195
|
+
enqueuedAt: string;
|
|
196
|
+
deliveredAt: string | null;
|
|
197
|
+
message: TaskMessage;
|
|
198
|
+
}
|
|
199
|
+
|
|
174
200
|
// ============================================================================
|
|
175
201
|
// Context (extension-maintained live state)
|
|
176
202
|
// ============================================================================
|