@roodriigoooo/pi-docket 0.4.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/CHANGELOG.md +132 -0
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/assets/docket_logo.jpeg +0 -0
- package/docs/adr/0001-bundle-first-checkpoints.md +21 -0
- package/docs/adr/0002-rename-to-docket.md +44 -0
- package/docs/architecture.md +101 -0
- package/docs/bundle-guidelines.md +39 -0
- package/docs/configuration.md +191 -0
- package/docs/releases/0.4.0.md +93 -0
- package/extensions/artifact-catalog.ts +467 -0
- package/extensions/background-work.ts +510 -0
- package/extensions/checkpoint-commands.ts +147 -0
- package/extensions/checkpoint-lifecycle.ts +195 -0
- package/extensions/checkpoint-selector.ts +162 -0
- package/extensions/checkpoint-store.ts +230 -0
- package/extensions/checkpoint-summarizer.ts +141 -0
- package/extensions/docket-command-grammar.ts +319 -0
- package/extensions/docket-command-router.ts +626 -0
- package/extensions/docket-config.ts +88 -0
- package/extensions/docket-extension-surface.ts +43 -0
- package/extensions/docket-navigator.ts +585 -0
- package/extensions/docket.README.md +46 -0
- package/extensions/docket.ts +2965 -0
- package/extensions/event-log.ts +121 -0
- package/extensions/git-context.ts +44 -0
- package/extensions/loaded-artifact-context.ts +228 -0
- package/extensions/search-index.ts +140 -0
- package/extensions/types.ts +40 -0
- package/extensions/worker-activity.ts +402 -0
- package/extensions/worker-changes.ts +180 -0
- package/extensions/worker-commands.ts +251 -0
- package/extensions/worker-dock-cache.ts +147 -0
- package/extensions/worker-events.ts +87 -0
- package/extensions/worker-eviction.ts +55 -0
- package/extensions/worker-guardrails.md +125 -0
- package/extensions/worker-kinds/patcher.md +23 -0
- package/extensions/worker-kinds/scout.md +17 -0
- package/extensions/worker-kinds.ts +280 -0
- package/extensions/worker-result.ts +193 -0
- package/extensions/worker-store.ts +621 -0
- package/extensions/worker-summary-embed.ts +98 -0
- package/package.json +53 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { workerLaunchDetail, workerLaunchSubject, workerQuestions, workerShortLabel, workerSummaryName, type WorkerStatus } from "./background-work.js";
|
|
2
|
+
import { readGitSnapshot } from "./git-context.js";
|
|
3
|
+
import type { LoadedArtifactContext } from "./loaded-artifact-context.js";
|
|
4
|
+
import type { ArtifactKind } from "./types.js";
|
|
5
|
+
import type { WorkerKindRegistry, WorkerKind } from "./worker-kinds.js";
|
|
6
|
+
import { workerProjectKey, type WorkerStore } from "./worker-store.js";
|
|
7
|
+
|
|
8
|
+
export type WorkerCompletionCandidate = { value: string; label: string };
|
|
9
|
+
|
|
10
|
+
type NotifyLevel = "info" | "warning" | "error";
|
|
11
|
+
type DocketMessageKind = "list" | "success" | "action";
|
|
12
|
+
|
|
13
|
+
type WorkerCommandsDeps = {
|
|
14
|
+
store: WorkerStore;
|
|
15
|
+
loadedArtifacts: Pick<LoadedArtifactContext, "loadSource" | "unloadSource">;
|
|
16
|
+
cwd: string;
|
|
17
|
+
projectRoot?: string;
|
|
18
|
+
parentSession?: string;
|
|
19
|
+
kinds: WorkerKindRegistry;
|
|
20
|
+
maxActive(): number;
|
|
21
|
+
captureTerminal(): boolean;
|
|
22
|
+
notify(text: string, level: NotifyLevel): void;
|
|
23
|
+
announce(subject: string, detail?: string, kind?: DocketMessageKind, docket?: { kind: ArtifactKind; title: string; subtitle?: string }, meta?: { workerId: string }): void;
|
|
24
|
+
emitText(text: string, kind: "list", heading: string): void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type WorkerCommands = {
|
|
28
|
+
spawn(task: string, options?: { worktree?: boolean; fresh?: boolean; as?: string; parentWorkerId?: string; depth?: number; layout?: "single" | "split-events"; captureTerminal?: boolean }): Promise<WorkerStatus | undefined>;
|
|
29
|
+
tell(ref: string, text: string): Promise<void>;
|
|
30
|
+
list(options?: { allProjects?: boolean }): Promise<void>;
|
|
31
|
+
listKinds(): Promise<void>;
|
|
32
|
+
delete(ref: string | undefined): Promise<void>;
|
|
33
|
+
respawn(target: string): Promise<void>;
|
|
34
|
+
load(ref: string | undefined): Promise<void>;
|
|
35
|
+
unload(ref: string): Promise<void>;
|
|
36
|
+
completionCandidates(): Promise<WorkerCompletionCandidate[]>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function workerAge(updatedAt: string): string {
|
|
40
|
+
const ageMs = Date.now() - Date.parse(updatedAt);
|
|
41
|
+
if (!Number.isFinite(ageMs) || ageMs < 0) return updatedAt;
|
|
42
|
+
const seconds = Math.round(ageMs / 1000);
|
|
43
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
44
|
+
const minutes = Math.round(seconds / 60);
|
|
45
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
46
|
+
const hours = Math.round(minutes / 60);
|
|
47
|
+
return `${hours}h ago`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function workerCompletionCandidates(store: WorkerStore, options: { projectRoot?: string } = {}): Promise<WorkerCompletionCandidate[]> {
|
|
51
|
+
try {
|
|
52
|
+
const workers = await store.list(options);
|
|
53
|
+
return workers.slice(-10).reverse().map((w) => ({
|
|
54
|
+
value: workerShortLabel(w.index),
|
|
55
|
+
label: `${workerShortLabel(w.index)} ${w.state} ${workerSummaryName(w, 40)}`,
|
|
56
|
+
}));
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatWorkerTell(worker: WorkerStatus, text: string): string {
|
|
63
|
+
const questions = workerQuestions(worker);
|
|
64
|
+
if (questions.length === 0) return `Parent message: ${text}`;
|
|
65
|
+
const questionList = questions.map((question, index) => `${index + 1}) ${question.text}`).join(" ");
|
|
66
|
+
return `Parent message for ${questions.length} question${questions.length === 1 ? "" : "s"}: ${questionList} Message: ${text}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatWorkerList(workers: WorkerStatus[], options: { groupByProject?: boolean } = {}): string {
|
|
70
|
+
if (workers.length === 0) return "No Docket workers";
|
|
71
|
+
const lineFor = (w: WorkerStatus) => {
|
|
72
|
+
const label = workerShortLabel(w.index).padEnd(4);
|
|
73
|
+
const state = (w.state ?? "?").padEnd(8);
|
|
74
|
+
const kind = (w.kind ?? "default").padEnd(8);
|
|
75
|
+
const artifacts = `${w.artifactCount ?? "?"} artifacts`.padEnd(14);
|
|
76
|
+
const age = workerAge(w.updatedAt).padEnd(8);
|
|
77
|
+
const parentTag = w.parentWorkerId ? ` ↳w${workers.find((p) => p.id === w.parentWorkerId)?.index ?? "?"}` : "";
|
|
78
|
+
return `${label} ${state} ${kind} ${artifacts} ${age} ${workerSummaryName(w, 40)}${parentTag}`;
|
|
79
|
+
};
|
|
80
|
+
if (!options.groupByProject) return workers.map(lineFor).join("\n");
|
|
81
|
+
const groups = new Map<string, WorkerStatus[]>();
|
|
82
|
+
for (const worker of workers) {
|
|
83
|
+
const key = workerProjectKey(worker);
|
|
84
|
+
groups.set(key, [...(groups.get(key) ?? []), worker]);
|
|
85
|
+
}
|
|
86
|
+
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).flatMap(([project, entries]) => [`project: ${project}`, ...entries.map(lineFor)]).join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatKindList(kinds: WorkerKind[]): string {
|
|
90
|
+
if (kinds.length === 0) return "No Docket worker kinds registered";
|
|
91
|
+
return kinds.map((k) => {
|
|
92
|
+
const ro = k.readOnly ? "ro" : "rw";
|
|
93
|
+
const seed = k.parentSeedPolicy === "none" ? "fresh" : "seeded";
|
|
94
|
+
const spawn = k.canSpawn.length ? `spawn:${k.canSpawn.join(",")}` : "no-spawn";
|
|
95
|
+
const src = `[${k.source}]`;
|
|
96
|
+
const desc = k.description ? ` — ${k.description}` : "";
|
|
97
|
+
return `${k.name.padEnd(12)} ${ro} ${seed} ${spawn} ${src}${desc}`;
|
|
98
|
+
}).join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createWorkerCommands(deps: WorkerCommandsDeps): WorkerCommands {
|
|
102
|
+
const loadWorker = async (worker: WorkerStatus): Promise<void> => {
|
|
103
|
+
const result = await deps.loadedArtifacts.loadSource({ kind: "worker", worker });
|
|
104
|
+
deps.announce(
|
|
105
|
+
`loaded ${result.slot.slot} · ${result.slot.artifacts.length} artifact${result.slot.artifacts.length === 1 ? "" : "s"}`,
|
|
106
|
+
`${workerSummaryName(worker)}\nattach: @${result.slot.slot}.<id>`,
|
|
107
|
+
"success",
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
async spawn(task: string, options: { worktree?: boolean; fresh?: boolean; as?: string; parentWorkerId?: string; depth?: number; layout?: "single" | "split-events"; captureTerminal?: boolean } = {}): Promise<WorkerStatus | undefined> {
|
|
113
|
+
try {
|
|
114
|
+
const requestedName = options.as?.trim();
|
|
115
|
+
if (requestedName) {
|
|
116
|
+
const known = deps.kinds.names();
|
|
117
|
+
if (!known.includes(requestedName)) {
|
|
118
|
+
deps.notify(`Docket: unknown worker kind "${requestedName}". Try /docket kinds. Falling back to default.`, "warning");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const kind = deps.kinds.get(requestedName);
|
|
122
|
+
const max = deps.maxActive();
|
|
123
|
+
if (max > 0) {
|
|
124
|
+
const active = await deps.store.countActive();
|
|
125
|
+
if (active >= max) {
|
|
126
|
+
deps.notify(`Docket: fleet cap reached (${active}/${max} active). Resolve or delete a worker before spawning another.`, "error");
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const git = readGitSnapshot(deps.cwd);
|
|
131
|
+
const seedSource = options.fresh === true || kind.parentSeedPolicy === "none" ? undefined : deps.parentSession;
|
|
132
|
+
const useWorktree = options.worktree === true || kind.defaultWorktree;
|
|
133
|
+
const worker = await deps.store.spawn({
|
|
134
|
+
task,
|
|
135
|
+
cwd: deps.cwd,
|
|
136
|
+
...(seedSource ? { parentSession: seedSource } : {}),
|
|
137
|
+
worktree: useWorktree,
|
|
138
|
+
...(options.fresh ? { fresh: true } : {}),
|
|
139
|
+
...(git ? { git } : {}),
|
|
140
|
+
kind: kind.name,
|
|
141
|
+
...(kind.canSpawn.length > 0 ? { canSpawn: kind.canSpawn } : {}),
|
|
142
|
+
...(options.parentWorkerId ? { parentWorkerId: options.parentWorkerId } : {}),
|
|
143
|
+
...(typeof options.depth === "number" ? { depth: options.depth } : {}),
|
|
144
|
+
layout: options.layout ?? kind.layout,
|
|
145
|
+
...(options.captureTerminal || deps.captureTerminal() ? { captureTerminal: true } : {}),
|
|
146
|
+
});
|
|
147
|
+
const now = Date.parse(worker.createdAt);
|
|
148
|
+
deps.announce(
|
|
149
|
+
workerLaunchSubject(worker, { now }),
|
|
150
|
+
workerLaunchDetail(worker, { now }),
|
|
151
|
+
"action",
|
|
152
|
+
undefined,
|
|
153
|
+
{ workerId: worker.id },
|
|
154
|
+
);
|
|
155
|
+
return worker;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
deps.notify(`Docket spawn failed: ${String(err)}`, "error");
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
async tell(ref: string, text: string): Promise<void> {
|
|
162
|
+
const worker = await deps.store.find(ref);
|
|
163
|
+
if (!worker) {
|
|
164
|
+
deps.notify("Docket worker not found", "error");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const sent = await deps.store.sendInput(worker.id, formatWorkerTell(worker, text));
|
|
168
|
+
if (sent) deps.announce(
|
|
169
|
+
`told ${workerShortLabel(worker.index)}`,
|
|
170
|
+
text,
|
|
171
|
+
"success",
|
|
172
|
+
{ kind: "prompt", title: `tell ${workerShortLabel(worker.index)}`, subtitle: workerSummaryName(worker) },
|
|
173
|
+
);
|
|
174
|
+
else deps.notify(`Docket could not send message to ${workerShortLabel(worker.index)}`, "error");
|
|
175
|
+
},
|
|
176
|
+
async list(options: { allProjects?: boolean } = {}): Promise<void> {
|
|
177
|
+
const projectRoot = options.allProjects ? undefined : deps.projectRoot;
|
|
178
|
+
deps.emitText(formatWorkerList(await deps.store.list({ ...(projectRoot ? { projectRoot } : {}) }), { groupByProject: options.allProjects === true }), "list", "docket · workers");
|
|
179
|
+
},
|
|
180
|
+
async listKinds(): Promise<void> {
|
|
181
|
+
deps.emitText(formatKindList(deps.kinds.list()), "list", "docket · worker kinds");
|
|
182
|
+
},
|
|
183
|
+
async delete(ref: string | undefined): Promise<void> {
|
|
184
|
+
if (!ref) {
|
|
185
|
+
deps.notify("Usage: /docket delete w<N>", "error");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const worker = await deps.store.find(ref);
|
|
189
|
+
if (!worker) {
|
|
190
|
+
deps.notify("Docket worker not found", "error");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
deps.loadedArtifacts.unloadSource("worker", worker.id);
|
|
194
|
+
const purged = await deps.store.purge(worker.id, { cascade: true });
|
|
195
|
+
const childCount = Math.max(0, purged.length - 1);
|
|
196
|
+
const cascadeNote = childCount > 0 ? `\ncascade: purged ${childCount} child worker${childCount === 1 ? "" : "s"}` : "";
|
|
197
|
+
deps.announce(`worker ${workerShortLabel(worker.index)} killed`, `${workerSummaryName(worker)}\nid: ${worker.id}${worker.worktree ? `\nremoved workspace: ${worker.worktree.path}` : ""}${cascadeNote}`);
|
|
198
|
+
},
|
|
199
|
+
async respawn(target: string): Promise<void> {
|
|
200
|
+
const ALL = target.toLowerCase() === "all";
|
|
201
|
+
const candidates = ALL
|
|
202
|
+
? (await deps.store.list()).filter((w) => ["ended", "error", "failed"].includes(w.state))
|
|
203
|
+
: await (async () => {
|
|
204
|
+
const w = await deps.store.find(target);
|
|
205
|
+
return w ? [w] : [];
|
|
206
|
+
})();
|
|
207
|
+
if (candidates.length === 0) {
|
|
208
|
+
deps.notify(ALL ? "Docket: no relaunch-eligible workers" : "Docket worker not found", "warning");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const ok: string[] = [];
|
|
212
|
+
const failed: { label: string; error: string }[] = [];
|
|
213
|
+
for (const worker of candidates) {
|
|
214
|
+
try {
|
|
215
|
+
const result = await deps.store.respawn(worker.id);
|
|
216
|
+
if (result) ok.push(workerShortLabel(result.index));
|
|
217
|
+
else failed.push({ label: workerShortLabel(worker.index), error: "no status" });
|
|
218
|
+
} catch (err) {
|
|
219
|
+
failed.push({ label: workerShortLabel(worker.index), error: String(err) });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (ok.length > 0) deps.announce(`respawned ${ok.length} worker${ok.length === 1 ? "" : "s"}`, ok.join(", "), "success");
|
|
223
|
+
if (failed.length > 0) deps.notify(`Docket respawn failed for: ${failed.map((entry) => `${entry.label} (${entry.error})`).join(", ")}`, "error");
|
|
224
|
+
},
|
|
225
|
+
async load(ref: string | undefined): Promise<void> {
|
|
226
|
+
if (!ref) {
|
|
227
|
+
deps.notify("Usage: /docket load w<N>", "error");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const worker = await deps.store.find(ref);
|
|
232
|
+
if (!worker) {
|
|
233
|
+
deps.notify("Docket worker not found", "error");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
await loadWorker(worker);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
deps.notify(`Docket load failed: ${String(err)}`, "error");
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
async unload(ref: string): Promise<void> {
|
|
242
|
+
const worker = await deps.store.find(ref);
|
|
243
|
+
const removed = worker ? deps.loadedArtifacts.unloadSource("worker", worker.id) : undefined;
|
|
244
|
+
if (removed) deps.announce(`unloaded ${removed.slot}`, worker ? workerSummaryName(worker) : undefined);
|
|
245
|
+
else deps.notify("Docket worker not loaded", "warning");
|
|
246
|
+
},
|
|
247
|
+
completionCandidates(): Promise<WorkerCompletionCandidate[]> {
|
|
248
|
+
return workerCompletionCandidates(deps.store, { ...(deps.projectRoot ? { projectRoot: deps.projectRoot } : {}) });
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import fsSync from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { Artifact } from "./types.js";
|
|
5
|
+
import type { WorkerStatus } from "./background-work.js";
|
|
6
|
+
import { tailWorkerEvents, type WorkerEvent } from "./worker-events.js";
|
|
7
|
+
|
|
8
|
+
export const DOCK_RECENT_EVENT_CAP = 16;
|
|
9
|
+
|
|
10
|
+
type Entry = {
|
|
11
|
+
id: string;
|
|
12
|
+
statusMtime: number;
|
|
13
|
+
artifactsMtime: number;
|
|
14
|
+
status: WorkerStatus | undefined;
|
|
15
|
+
artifacts: Artifact[];
|
|
16
|
+
eventOffset: number;
|
|
17
|
+
recentEvents: WorkerEvent[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type WorkerSnapshot = {
|
|
21
|
+
workers: WorkerStatus[];
|
|
22
|
+
artifactsByWorker: Map<string, Artifact[]>;
|
|
23
|
+
/** Sticky ring of the last DOCK_RECENT_EVENT_CAP events per worker; safe to render. */
|
|
24
|
+
eventsByWorker: Map<string, WorkerEvent[]>;
|
|
25
|
+
/** Only events read this tick. Use for one-shot emit/subscribe; rendering should use eventsByWorker. */
|
|
26
|
+
newEventsByWorker: Map<string, WorkerEvent[]>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
async function safeStat(file: string): Promise<fsSync.Stats | undefined> {
|
|
30
|
+
try {
|
|
31
|
+
return await fs.stat(file);
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class WorkerSnapshotCache {
|
|
38
|
+
private entries = new Map<string, Entry>();
|
|
39
|
+
|
|
40
|
+
constructor(private root: string) {}
|
|
41
|
+
|
|
42
|
+
async snapshot(): Promise<WorkerSnapshot> {
|
|
43
|
+
let names: string[];
|
|
44
|
+
try {
|
|
45
|
+
names = await fs.readdir(this.root);
|
|
46
|
+
} catch {
|
|
47
|
+
this.entries.clear();
|
|
48
|
+
return { workers: [], artifactsByWorker: new Map(), eventsByWorker: new Map(), newEventsByWorker: new Map() };
|
|
49
|
+
}
|
|
50
|
+
const active = new Set(names);
|
|
51
|
+
for (const id of [...this.entries.keys()]) if (!active.has(id)) this.entries.delete(id);
|
|
52
|
+
|
|
53
|
+
const workers: WorkerStatus[] = [];
|
|
54
|
+
const artifactsByWorker = new Map<string, Artifact[]>();
|
|
55
|
+
const eventsByWorker = new Map<string, WorkerEvent[]>();
|
|
56
|
+
const newEventsByWorker = new Map<string, WorkerEvent[]>();
|
|
57
|
+
await Promise.all(names.map(async (id) => {
|
|
58
|
+
const dir = path.join(this.root, id);
|
|
59
|
+
const statusFile = path.join(dir, "status.json");
|
|
60
|
+
const artifactsFile = path.join(dir, "artifacts.json");
|
|
61
|
+
const [statusStat, artifactsStat] = await Promise.all([safeStat(statusFile), safeStat(artifactsFile)]);
|
|
62
|
+
if (!statusStat) {
|
|
63
|
+
this.entries.delete(id);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const existing = this.entries.get(id);
|
|
67
|
+
const entry: Entry = existing ?? { id, statusMtime: -1, artifactsMtime: -1, status: undefined, artifacts: [], eventOffset: 0, recentEvents: [] };
|
|
68
|
+
if (entry.statusMtime !== statusStat.mtimeMs) {
|
|
69
|
+
try {
|
|
70
|
+
entry.status = JSON.parse(await fs.readFile(statusFile, "utf8")) as WorkerStatus;
|
|
71
|
+
} catch {
|
|
72
|
+
entry.status = undefined;
|
|
73
|
+
}
|
|
74
|
+
entry.statusMtime = statusStat.mtimeMs;
|
|
75
|
+
}
|
|
76
|
+
if (artifactsStat) {
|
|
77
|
+
if (entry.artifactsMtime !== artifactsStat.mtimeMs) {
|
|
78
|
+
try {
|
|
79
|
+
entry.artifacts = JSON.parse(await fs.readFile(artifactsFile, "utf8")) as Artifact[];
|
|
80
|
+
} catch {
|
|
81
|
+
entry.artifacts = [];
|
|
82
|
+
}
|
|
83
|
+
entry.artifactsMtime = artifactsStat.mtimeMs;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
entry.artifacts = [];
|
|
87
|
+
entry.artifactsMtime = -1;
|
|
88
|
+
}
|
|
89
|
+
const tail = await tailWorkerEvents(this.root, id, { offset: entry.eventOffset });
|
|
90
|
+
entry.eventOffset = tail.offset;
|
|
91
|
+
if (tail.rotated) entry.recentEvents = [];
|
|
92
|
+
if (tail.events.length) {
|
|
93
|
+
entry.recentEvents = [...entry.recentEvents, ...tail.events].slice(-DOCK_RECENT_EVENT_CAP);
|
|
94
|
+
}
|
|
95
|
+
this.entries.set(id, entry);
|
|
96
|
+
if (entry.status) {
|
|
97
|
+
workers.push(entry.status);
|
|
98
|
+
artifactsByWorker.set(entry.status.id, entry.artifacts);
|
|
99
|
+
if (entry.recentEvents.length) eventsByWorker.set(entry.status.id, entry.recentEvents);
|
|
100
|
+
if (tail.events.length) newEventsByWorker.set(entry.status.id, tail.events);
|
|
101
|
+
}
|
|
102
|
+
}));
|
|
103
|
+
workers.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
104
|
+
return { workers, artifactsByWorker, eventsByWorker, newEventsByWorker };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
invalidate(id?: string): void {
|
|
108
|
+
if (id) this.entries.delete(id);
|
|
109
|
+
else this.entries.clear();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
size(): number {
|
|
113
|
+
return this.entries.size;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type Unwatcher = () => void;
|
|
118
|
+
|
|
119
|
+
export function watchWorkersRoot(
|
|
120
|
+
root: string,
|
|
121
|
+
onChange: () => void,
|
|
122
|
+
options: { fallbackMs?: number; debounceMs?: number } = {},
|
|
123
|
+
): Unwatcher {
|
|
124
|
+
const debounceMs = options.debounceMs ?? 150;
|
|
125
|
+
const fallbackMs = options.fallbackMs ?? 3000;
|
|
126
|
+
let timer: NodeJS.Timeout | undefined;
|
|
127
|
+
const fire = (): void => {
|
|
128
|
+
if (timer) clearTimeout(timer);
|
|
129
|
+
timer = setTimeout(() => onChange(), debounceMs);
|
|
130
|
+
timer.unref?.();
|
|
131
|
+
};
|
|
132
|
+
let watcher: fsSync.FSWatcher | undefined;
|
|
133
|
+
try {
|
|
134
|
+
fsSync.mkdirSync(root, { recursive: true });
|
|
135
|
+
watcher = fsSync.watch(root, { recursive: true }, () => fire());
|
|
136
|
+
} catch {
|
|
137
|
+
// fall back to polling-only
|
|
138
|
+
}
|
|
139
|
+
const fallback = setInterval(fire, fallbackMs);
|
|
140
|
+
fallback.unref?.();
|
|
141
|
+
fire();
|
|
142
|
+
return () => {
|
|
143
|
+
watcher?.close();
|
|
144
|
+
clearInterval(fallback);
|
|
145
|
+
if (timer) clearTimeout(timer);
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fsSync from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const WORKER_EVENT_FILE = "events.ndjson";
|
|
6
|
+
export const WORKER_EVENT_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export type WorkerEventKind = "state" | "todo" | "tool" | "artifact" | "message";
|
|
9
|
+
|
|
10
|
+
export type WorkerEvent = {
|
|
11
|
+
ts: number;
|
|
12
|
+
kind: WorkerEventKind;
|
|
13
|
+
payload: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function workerEventFilePath(root: string, id: string): string {
|
|
17
|
+
return path.join(root, id, WORKER_EVENT_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rotateIfNeeded(file: string): void {
|
|
21
|
+
try {
|
|
22
|
+
const stat = fsSync.statSync(file);
|
|
23
|
+
if (stat.size > WORKER_EVENT_ROTATE_BYTES) {
|
|
24
|
+
const rotated = `${file}.1`;
|
|
25
|
+
try {
|
|
26
|
+
fsSync.rmSync(rotated, { force: true });
|
|
27
|
+
} catch {
|
|
28
|
+
// best-effort
|
|
29
|
+
}
|
|
30
|
+
fsSync.renameSync(file, rotated);
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// file may not exist yet
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function appendWorkerEventSync(root: string, id: string, event: { kind: WorkerEventKind; payload: Record<string, unknown>; ts?: number }): void {
|
|
38
|
+
const file = workerEventFilePath(root, id);
|
|
39
|
+
try {
|
|
40
|
+
fsSync.mkdirSync(path.dirname(file), { recursive: true });
|
|
41
|
+
rotateIfNeeded(file);
|
|
42
|
+
const payload: WorkerEvent = { ts: event.ts ?? Date.now(), kind: event.kind, payload: event.payload };
|
|
43
|
+
fsSync.appendFileSync(file, `${JSON.stringify(payload)}\n`, "utf8");
|
|
44
|
+
} catch {
|
|
45
|
+
// best-effort: never crash the worker because of event logging
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type WorkerEventTailerState = { offset: number };
|
|
50
|
+
|
|
51
|
+
export type TailResult = { events: WorkerEvent[]; rotated: boolean; offset: number };
|
|
52
|
+
|
|
53
|
+
export async function tailWorkerEvents(root: string, id: string, state: WorkerEventTailerState): Promise<TailResult> {
|
|
54
|
+
const file = workerEventFilePath(root, id);
|
|
55
|
+
let stat: fsSync.Stats;
|
|
56
|
+
try {
|
|
57
|
+
stat = await fs.stat(file);
|
|
58
|
+
} catch {
|
|
59
|
+
return { events: [], rotated: false, offset: state.offset };
|
|
60
|
+
}
|
|
61
|
+
let offset = state.offset;
|
|
62
|
+
let rotated = false;
|
|
63
|
+
if (stat.size < offset) {
|
|
64
|
+
offset = 0;
|
|
65
|
+
rotated = true;
|
|
66
|
+
}
|
|
67
|
+
if (stat.size === offset) return { events: [], rotated, offset };
|
|
68
|
+
const handle = await fs.open(file, "r");
|
|
69
|
+
try {
|
|
70
|
+
const buf = Buffer.alloc(stat.size - offset);
|
|
71
|
+
await handle.read(buf, 0, buf.length, offset);
|
|
72
|
+
const chunk = buf.toString("utf8");
|
|
73
|
+
const events: WorkerEvent[] = [];
|
|
74
|
+
for (const line of chunk.split("\n")) {
|
|
75
|
+
const trimmed = line.trim();
|
|
76
|
+
if (!trimmed) continue;
|
|
77
|
+
try {
|
|
78
|
+
events.push(JSON.parse(trimmed) as WorkerEvent);
|
|
79
|
+
} catch {
|
|
80
|
+
// drop malformed line
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { events, rotated, offset: stat.size };
|
|
84
|
+
} finally {
|
|
85
|
+
await handle.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { WorkerStatus } from "./background-work.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* States that count as "the worker is done". Auto-hide and auto-prune only
|
|
5
|
+
* apply to these; in-progress, waiting, ready, and failed workers stay
|
|
6
|
+
* visible so the user can still act on them.
|
|
7
|
+
*/
|
|
8
|
+
const TERMINAL_DOCK_STATES = new Set<WorkerStatus["state"]>(["ended"]);
|
|
9
|
+
|
|
10
|
+
function workerAgeMs(worker: WorkerStatus, now: number): number {
|
|
11
|
+
const ts = Date.parse(worker.updatedAt);
|
|
12
|
+
if (!Number.isFinite(ts)) return 0;
|
|
13
|
+
return Math.max(0, now - ts);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type EvictionConfig = {
|
|
17
|
+
dockIdleHideMinutes?: number;
|
|
18
|
+
pruneAfterHours?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function dockIdleHideMs(config: EvictionConfig | undefined): number {
|
|
22
|
+
const minutes = config?.dockIdleHideMinutes;
|
|
23
|
+
if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) return 0;
|
|
24
|
+
return minutes * 60_000;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function pruneAfterMs(config: EvictionConfig | undefined): number {
|
|
28
|
+
const hours = config?.pruneAfterHours;
|
|
29
|
+
if (typeof hours !== "number" || !Number.isFinite(hours) || hours <= 0) return 0;
|
|
30
|
+
return hours * 3_600_000;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isDockIdleEvictable(worker: WorkerStatus, now: number, idleHideMs: number): boolean {
|
|
34
|
+
if (idleHideMs <= 0) return false;
|
|
35
|
+
if (!TERMINAL_DOCK_STATES.has(worker.state)) return false;
|
|
36
|
+
return workerAgeMs(worker, now) >= idleHideMs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function shouldPruneWorker(worker: WorkerStatus, now: number, pruneMs: number): boolean {
|
|
40
|
+
if (pruneMs <= 0) return false;
|
|
41
|
+
if (!TERMINAL_DOCK_STATES.has(worker.state)) return false;
|
|
42
|
+
return workerAgeMs(worker, now) >= pruneMs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function selectEvictableWorkerIds(workers: WorkerStatus[], now: number, idleHideMs: number): Set<string> {
|
|
46
|
+
const out = new Set<string>();
|
|
47
|
+
for (const worker of workers) {
|
|
48
|
+
if (isDockIdleEvictable(worker, now, idleHideMs)) out.add(worker.id);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function selectPrunableWorkers(workers: WorkerStatus[], now: number, pruneMs: number): WorkerStatus[] {
|
|
54
|
+
return workers.filter((worker) => shouldPruneWorker(worker, now, pruneMs));
|
|
55
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Docket worker protocol
|
|
2
|
+
|
|
3
|
+
You are a Docket worker: a background Pi session spawned by a parent session to investigate or implement one focused task. The parent reviews your output and decides what to act on.
|
|
4
|
+
|
|
5
|
+
## Source of truth
|
|
6
|
+
|
|
7
|
+
- Your task lives in `task.md` inside your worker directory. Read it first.
|
|
8
|
+
- Your artifacts (commands, file reads/edits, code blocks, responses) are snapshotted automatically to `artifacts.json`. You do not need to copy them anywhere.
|
|
9
|
+
- The parent reads your `status.json` on a heartbeat. Status transitions happen only through the protocol tools below.
|
|
10
|
+
|
|
11
|
+
## Default posture
|
|
12
|
+
|
|
13
|
+
- **Read-only by default.** Do not edit files unless the task explicitly asks for edits. Reading, grepping, listing, running non-mutating commands, and reasoning are always fine.
|
|
14
|
+
- If the task does ask for edits, prefer minimal, scoped changes. Summarize changed files and likely conflict risks in your final `docket_done` call.
|
|
15
|
+
- You run in a worker workspace seeded from the parent's current repo state. If the task asks for adoptable output, edit the intended project files in that workspace; the parent reviews and promotes the whole change set. Do not hide adoptable work in scratch files.
|
|
16
|
+
- Never push, force-push, or run destructive git operations (`reset --hard`, `clean -fd`, `checkout .`) without an explicit instruction in `task.md`.
|
|
17
|
+
|
|
18
|
+
## Shared tmux session
|
|
19
|
+
|
|
20
|
+
- You are running as one window inside a tmux session named `docket-workers`. Sibling workers are other windows in the same session. This is deliberate: one tmux server hosts the fleet so the parent's dock stays cheap.
|
|
21
|
+
- **Never invoke `tmux` directly.** Do not run `tmux kill-server`, `tmux kill-session -t docket-workers`, `tmux kill-window -t docket-workers:wN`, or any other write-side tmux command. `kill-server` ends every worker. `kill-session` on the shared session ends every worker. The parent owns this lifecycle.
|
|
22
|
+
- Read-side tmux inspection (`tmux list-windows`, `tmux display-message -p`) is fine but rarely useful; the parent already surfaces what you would learn from it.
|
|
23
|
+
- If you genuinely think a tmux operation is required, stop and call `docket_wait` to ask the parent first.
|
|
24
|
+
|
|
25
|
+
## Required protocol tools
|
|
26
|
+
|
|
27
|
+
You have four tools the parent uses to track you. Calling them is part of doing the task, not optional ceremony. Do not write `/docket wait`, `/docket done`, or `/docket fail` as bash commands — those are intercepted as a safety net, but the tool path is the contract.
|
|
28
|
+
|
|
29
|
+
### `docket_todos` — publish a small ordered checklist
|
|
30
|
+
|
|
31
|
+
**Call when:** the task is multi-step (more than ~2 distinct moves) and a parent would benefit from seeing your plan.
|
|
32
|
+
|
|
33
|
+
**How:**
|
|
34
|
+
- Keep it short: 3–8 items, ordered.
|
|
35
|
+
- States: `pending`, `in_progress`, `completed`.
|
|
36
|
+
- Replace the full list on each update; do not append.
|
|
37
|
+
- Re-publish whenever you complete an item or change the plan.
|
|
38
|
+
|
|
39
|
+
**Do not** use this as a durable task manager. It is a visibility board for the parent.
|
|
40
|
+
|
|
41
|
+
### `docket_wait` — ask the parent for input and pause
|
|
42
|
+
|
|
43
|
+
**Call when ANY of these are true:**
|
|
44
|
+
- The task is ambiguous in a way that meaningfully changes your output (path choice, format choice, scope, naming).
|
|
45
|
+
- You hit a credentials, secret, or auth wall and cannot proceed.
|
|
46
|
+
- You are about to make an irreversible or expensive call (destructive command, paid API, schema migration) that was not explicitly authorized in `task.md`.
|
|
47
|
+
- You believe the task description contains a contradiction or a wrong assumption.
|
|
48
|
+
- You are about to abandon the task or change its scope.
|
|
49
|
+
|
|
50
|
+
**Heuristic:** if a reasonable engineer would stop and ask, call `docket_wait`. Do not assume. A short, concrete question costs the parent seconds. A wrong assumption costs them a re-run.
|
|
51
|
+
|
|
52
|
+
For vague search/discovery tasks, do cheap discovery before asking: at most ~5 read-only operations or ~60 seconds. If that finds no relevant signal, call `docket_wait` instead of `docket_done`. Example: `find the bear...` plus no repo hits should ask what bear/scope the parent means.
|
|
53
|
+
|
|
54
|
+
**How:** one concise question per call. If multiple questions, list them as `1) … 2) …` inside one call. Then stop and wait. Do not continue working speculatively after calling `docket_wait`.
|
|
55
|
+
|
|
56
|
+
When the decision has discrete answers, pass them as `options` (2–4 concrete choices) and `recommend` the one you would pick — the parent then gets a one-keystroke card with your proposed branches instead of a freeform reply, and the choice is sent back to you verbatim. When the action is irreversible or unauthorized, set `risk` to a one-line statement of the stakes (e.g. `drops the sessions table`). These fields are status-only and cost the parent zero tokens to review.
|
|
57
|
+
|
|
58
|
+
**Do not** call `docket_wait` for trivial style/aesthetic preferences you can answer reasonably yourself.
|
|
59
|
+
|
|
60
|
+
### `docket_done` — mark output ready for parent review
|
|
61
|
+
|
|
62
|
+
**Call when:**
|
|
63
|
+
- The task is complete, OR
|
|
64
|
+
- You produced findings or recommendations that are useful even though the task is not fully done (e.g. investigation tasks that surface dead ends).
|
|
65
|
+
|
|
66
|
+
**How:**
|
|
67
|
+
- Set `outcome` to one of `completed`, `findings`, `proposal`, or `no_evidence`.
|
|
68
|
+
- Set `scopeConfidence` to `clear` only when the task had enough scope to finish without parent input; otherwise use `unclear` and prefer `docket_wait`.
|
|
69
|
+
- Include short `evidence` entries: searched paths, commands run, files read/changed, artifact refs, or concrete observations.
|
|
70
|
+
- One- or two-sentence `summary` of what you produced. Plain prose.
|
|
71
|
+
- Put action bullets in `recommended` (or under a `Recommended:` heading in `summary` for compatibility). Keep each bullet short, action-oriented, and self-contained.
|
|
72
|
+
- Do not paste full file contents or large code blocks into `summary`; those already live in your artifacts. Reference them by what they are ("see edited src/auth.ts") if needed.
|
|
73
|
+
|
|
74
|
+
**Example `docket_done.summary`:**
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Reviewed README for command accuracy and onboarding.
|
|
78
|
+
Recommended:
|
|
79
|
+
- Sync README commands with current behavior
|
|
80
|
+
- Add a short quickstart near the top
|
|
81
|
+
- Add a compact workflow-oriented table of contents
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Heuristic:** if you would have nothing useful to hand a colleague reviewing your work, you are not done — keep investigating or call `docket_wait`.
|
|
85
|
+
|
|
86
|
+
If `outcome` is `no_evidence` and the original task was vague, do not mark done. Ask for scope with `docket_wait`. `no_evidence` is ready only when the task scope was clear (for example, "find bear references in this repo").
|
|
87
|
+
|
|
88
|
+
### `docket_fail` — mark cannot-continue
|
|
89
|
+
|
|
90
|
+
**Call when:**
|
|
91
|
+
- You hit a blocker that `docket_wait` cannot resolve (environment missing, permissions, network).
|
|
92
|
+
- The task is impossible as stated and you have no useful partial output.
|
|
93
|
+
- Tools you need are not available and no reasonable substitute exists.
|
|
94
|
+
|
|
95
|
+
**How:** one-sentence reason. Be specific: `Migration command exited 1: missing DATABASE_URL` is useful; `failed` is not.
|
|
96
|
+
|
|
97
|
+
**Do not** call `docket_fail` when you have partial useful findings. Use `docket_done` with a summary that explicitly says what is done vs blocked.
|
|
98
|
+
|
|
99
|
+
## Choosing between `docket_wait`, `docket_done`, and `docket_fail`
|
|
100
|
+
|
|
101
|
+
| Situation | Tool |
|
|
102
|
+
|---|---|
|
|
103
|
+
| Need parent input to proceed correctly | `docket_wait` |
|
|
104
|
+
| Done, output is useful | `docket_done` |
|
|
105
|
+
| Done partially, the partial output is still useful | `docket_done` (note what is partial) |
|
|
106
|
+
| Cannot continue, nothing useful to hand back | `docket_fail` |
|
|
107
|
+
| Hit a tool/permission wall, parent could fix it | `docket_wait` first; `docket_fail` only if the wall is structural |
|
|
108
|
+
|
|
109
|
+
## Avoiding common drift
|
|
110
|
+
|
|
111
|
+
- Do not finish the task silently. Always end with `docket_done` or `docket_fail`. If you end a turn without calling any protocol tool, Docket marks you `idle` and sends you a one-time reminder. After that the parent has to decide manually whether you are done; ambiguity hurts the loop.
|
|
112
|
+
- Do not call `docket_done` then keep working. The parent treats `docket_done` as a checkpoint; further output may be missed.
|
|
113
|
+
- Do not embed protocol questions in artifact text ("By the way, should I also do X?"). The parent reads `status.json`, not free text. Use `docket_wait`.
|
|
114
|
+
- Do not run `/docket wait`, `/docket done`, `/docket fail` as bash. Use the tools.
|
|
115
|
+
- If you publish `docket_todos`, complete or remove items before calling `docket_done`. The parent sees a `ready / open todos` warning when you mark done with open items.
|
|
116
|
+
|
|
117
|
+
## Parent visibility recap
|
|
118
|
+
|
|
119
|
+
The parent sees a card for you in their inbox:
|
|
120
|
+
- **Outcome** — your `docket_done.summary` (or `docket_wait` question, or `docket_fail` reason).
|
|
121
|
+
- **Recommendations** — bullets parsed from your summary's `Recommended:` block.
|
|
122
|
+
- **Useful references** — your artifacts (responses, code, files, commands) by `@<your-label>.<id>`.
|
|
123
|
+
- **Progress** — your `docket_todos` board.
|
|
124
|
+
|
|
125
|
+
The cleaner your protocol calls, the cleaner the parent's decision card. That is the whole loop.
|