taskplane 0.26.1 โ 0.27.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 +4 -1
- package/bin/taskplane.mjs +12 -5
- package/extensions/taskplane/abort.ts +2 -1
- package/extensions/taskplane/agent-host.ts +100 -1
- package/extensions/taskplane/cleanup.ts +272 -10
- package/extensions/taskplane/engine.ts +182 -47
- package/extensions/taskplane/execution.ts +139 -49
- package/extensions/taskplane/extension.ts +5125 -5125
- package/extensions/taskplane/formatting.ts +70 -11
- package/extensions/taskplane/git.ts +34 -0
- package/extensions/taskplane/lane-runner.ts +219 -10
- package/extensions/taskplane/merge.ts +3128 -2917
- package/extensions/taskplane/persistence.ts +3 -0
- package/extensions/taskplane/resume.ts +86 -30
- package/extensions/taskplane/supervisor-primer.md +55 -0
- package/extensions/taskplane/types.ts +27 -2
- package/package.json +1 -1
- package/templates/agents/task-worker.md +51 -9
package/README.md
CHANGED
|
@@ -71,14 +71,17 @@ pi install -l npm:taskplane
|
|
|
71
71
|
## Quickstart
|
|
72
72
|
|
|
73
73
|
### 1. Initialize a project (to scaffold settings)
|
|
74
|
+
This step assumes you have installed Taskplane using one of the above options.
|
|
75
|
+
|
|
74
76
|
(NOTE: if 'my-project' is a monorepo, be sure to run git init first. Taskplane uses git worktrees to isolate agent coding until you're ready to merge back to your default branch.)
|
|
77
|
+
|
|
75
78
|
```bash
|
|
76
79
|
cd my-project
|
|
77
80
|
taskplane init
|
|
78
81
|
```
|
|
79
82
|
You'll answer a few questions. You can usually just accept the defaults.
|
|
80
83
|
|
|
81
|
-
This creates config files in `.pi/`, agent prompts, two example tasks, and adds `.gitignore` entries for runtime artifacts. On first install, init bootstraps global preferences at `~/.pi/agent/taskplane/preferences.json` with thinking defaults set to `high` for worker & reviewer, and off for merger. Init auto-detects whether you're in a
|
|
84
|
+
This creates: config files in `.pi/`, agent prompts, two example tasks, and adds `.gitignore` entries for runtime artifacts. On first install, init bootstraps global preferences at `~/.pi/agent/taskplane/preferences.json` with thinking defaults set to `high` for worker & reviewer, and off for merger. Init auto-detects whether you're in a monorepo or a polyrepo workspace. See the [install tutorial](docs/tutorials/install.md) for workspace mode and other scenarios.
|
|
82
85
|
|
|
83
86
|
Already have a task folder (for example `docs/task-management`)? Use:
|
|
84
87
|
|
package/bin/taskplane.mjs
CHANGED
|
@@ -787,18 +787,24 @@ export async function collectInitAgentConfig({
|
|
|
787
787
|
return initAgentConfig;
|
|
788
788
|
}
|
|
789
789
|
|
|
790
|
+
/** Normalize a path string to forward slashes (for config file output). */
|
|
791
|
+
function fwdSlash(p) {
|
|
792
|
+
return typeof p === "string" ? p.replace(/\\/g, "/") : p;
|
|
793
|
+
}
|
|
794
|
+
|
|
790
795
|
export function generateProjectConfig(vars, _initAgentConfig = null) {
|
|
796
|
+
const tasksRoot = fwdSlash(vars.tasks_root);
|
|
791
797
|
const projectConfig = {
|
|
792
798
|
configVersion: 1,
|
|
793
799
|
taskRunner: {
|
|
794
800
|
project: { name: vars.project_name, description: "" },
|
|
795
|
-
paths: { tasks:
|
|
801
|
+
paths: { tasks: tasksRoot },
|
|
796
802
|
testing: { commands: buildTestingCommands(vars) },
|
|
797
803
|
taskAreas: {
|
|
798
804
|
[vars.default_area]: {
|
|
799
|
-
path:
|
|
805
|
+
path: tasksRoot,
|
|
800
806
|
prefix: vars.default_prefix,
|
|
801
|
-
context: `${
|
|
807
|
+
context: `${tasksRoot}/CONTEXT.md`,
|
|
802
808
|
},
|
|
803
809
|
},
|
|
804
810
|
},
|
|
@@ -820,10 +826,11 @@ export function generateProjectConfig(vars, _initAgentConfig = null) {
|
|
|
820
826
|
}
|
|
821
827
|
|
|
822
828
|
function generateWorkspaceYaml(repoNames, defaultRepo, tasksRoot) {
|
|
829
|
+
const normalizedTasksRoot = fwdSlash(tasksRoot);
|
|
823
830
|
const reposBlock = repoNames
|
|
824
831
|
.map((name) => ` ${name}:\n path: "${name}"`)
|
|
825
832
|
.join("\n");
|
|
826
|
-
return `repos:\n${reposBlock}\nrouting:\n tasks_root: "${
|
|
833
|
+
return `repos:\n${reposBlock}\nrouting:\n tasks_root: "${normalizedTasksRoot}"\n default_repo: "${defaultRepo}"\n task_packet_repo: "${defaultRepo}"\n`;
|
|
827
834
|
}
|
|
828
835
|
|
|
829
836
|
function readWorkspaceJson(configRepoRoot) {
|
|
@@ -1923,7 +1930,7 @@ async function cmdInit(args) {
|
|
|
1923
1930
|
default_branch: "main",
|
|
1924
1931
|
})),
|
|
1925
1932
|
routing: {
|
|
1926
|
-
tasks_root: vars.tasks_root,
|
|
1933
|
+
tasks_root: fwdSlash(vars.tasks_root),
|
|
1927
1934
|
default_repo: configRepoName,
|
|
1928
1935
|
strict: false,
|
|
1929
1936
|
},
|
|
@@ -85,7 +85,8 @@ export function selectAbortTargetSessions(
|
|
|
85
85
|
laneId: lane.laneId,
|
|
86
86
|
taskId: currentTask?.taskId || null,
|
|
87
87
|
worktreePath: lane.worktreePath,
|
|
88
|
-
|
|
88
|
+
// TP-169: Guard against null task stubs from reconstructAllocatedLanes
|
|
89
|
+
taskFolder: currentTask?.task?.taskFolder || null,
|
|
89
90
|
});
|
|
90
91
|
}
|
|
91
92
|
|
|
@@ -136,6 +136,23 @@ export interface AgentHostOptions {
|
|
|
136
136
|
packet?: PacketPaths | null;
|
|
137
137
|
/** Extra environment variables for the child process */
|
|
138
138
|
env?: Record<string, string>;
|
|
139
|
+
/**
|
|
140
|
+
* Callback invoked when agent_end fires, before stdin is closed.
|
|
141
|
+
* Receives the last assistant message text.
|
|
142
|
+
* Return a string to send as a new prompt (re-prompt the agent),
|
|
143
|
+
* or null to close the session normally.
|
|
144
|
+
*
|
|
145
|
+
* @since TP-172
|
|
146
|
+
*/
|
|
147
|
+
onPrematureExit?: (assistantMessage: string) => Promise<string | null>;
|
|
148
|
+
/**
|
|
149
|
+
* Maximum number of exit interceptions before forcing session close.
|
|
150
|
+
* Prevents infinite loops where the callback always returns a new prompt.
|
|
151
|
+
* Default: 2
|
|
152
|
+
*
|
|
153
|
+
* @since TP-172
|
|
154
|
+
*/
|
|
155
|
+
maxExitInterceptions?: number;
|
|
139
156
|
}
|
|
140
157
|
|
|
141
158
|
/**
|
|
@@ -235,6 +252,7 @@ export function spawnAgent(
|
|
|
235
252
|
const cliPath = resolvePiCliPath();
|
|
236
253
|
const closeDelayMs = opts.closeDelayMs ?? 100;
|
|
237
254
|
const timeoutMs = opts.timeoutMs ?? 0;
|
|
255
|
+
const maxExitInterceptions = opts.maxExitInterceptions ?? 3;
|
|
238
256
|
|
|
239
257
|
// Build Pi CLI arguments
|
|
240
258
|
const piArgs: string[] = [cliPath, "--mode", "rpc", "--no-session"];
|
|
@@ -275,6 +293,12 @@ export function spawnAgent(
|
|
|
275
293
|
let contextUsage: AgentHostResult["contextUsage"] = null;
|
|
276
294
|
let stderrBuffer = "";
|
|
277
295
|
const STDERR_MAX = 2048;
|
|
296
|
+
/** Last assistant message text captured from message_end events (TP-172) */
|
|
297
|
+
let lastAssistantMessage = "";
|
|
298
|
+
/** Number of times exit interception has occurred (TP-172) */
|
|
299
|
+
let exitInterceptionCount = 0;
|
|
300
|
+
/** Whether the current turn had any tool calls (TP-172: text-only gate) */
|
|
301
|
+
let currentTurnHadToolCalls = false;
|
|
278
302
|
|
|
279
303
|
// Timeout
|
|
280
304
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -538,6 +562,8 @@ export function spawnAgent(
|
|
|
538
562
|
const content = extractAssistantText(event.message);
|
|
539
563
|
if (content) {
|
|
540
564
|
emitEvent("assistant_message", { text: truncatePayload(content, MAX_CONV_PAYLOAD_CHARS) });
|
|
565
|
+
// TP-172: Track last assistant message for exit interception
|
|
566
|
+
lastAssistantMessage = content;
|
|
541
567
|
}
|
|
542
568
|
}
|
|
543
569
|
// Request session stats immediately on first assistant message,
|
|
@@ -560,6 +586,7 @@ export function spawnAgent(
|
|
|
560
586
|
}
|
|
561
587
|
case "tool_execution_start": {
|
|
562
588
|
toolCalls++;
|
|
589
|
+
currentTurnHadToolCalls = true;
|
|
563
590
|
const toolName = event.toolName || "tool";
|
|
564
591
|
const argPreview = typeof event.args === "string" ? event.args.slice(0, 300) :
|
|
565
592
|
(event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0, 300) : "");
|
|
@@ -602,7 +629,79 @@ export function spawnAgent(
|
|
|
602
629
|
}
|
|
603
630
|
case "agent_end": {
|
|
604
631
|
agentEnded = true;
|
|
605
|
-
|
|
632
|
+
// TP-172: Exit interception โ intercept any exit when callback
|
|
633
|
+
// is provided and under limit. The callback (lane-runner) decides
|
|
634
|
+
// whether the worker made progress. We don't gate on tool calls
|
|
635
|
+
// because workers commonly use tools (reads/greps) then exit
|
|
636
|
+
// with a text declaration ("Now let me fix this:") without
|
|
637
|
+
// actually making the edit.
|
|
638
|
+
const shouldIntercept = opts.onPrematureExit
|
|
639
|
+
&& exitInterceptionCount < maxExitInterceptions;
|
|
640
|
+
if (shouldIntercept) {
|
|
641
|
+
exitInterceptionCount++;
|
|
642
|
+
const INTERCEPTION_TIMEOUT_MS = 120_000; // 2 minute safety timeout
|
|
643
|
+
// Wrap in Promise.resolve().then() to catch synchronous throws
|
|
644
|
+
const interceptPromise = Promise.resolve().then(() =>
|
|
645
|
+
opts.onPrematureExit!(lastAssistantMessage));
|
|
646
|
+
const timeoutPromise = new Promise<null>((res) =>
|
|
647
|
+
setTimeout(() => res(null), INTERCEPTION_TIMEOUT_MS));
|
|
648
|
+
Promise.race([interceptPromise, timeoutPromise])
|
|
649
|
+
.then(
|
|
650
|
+
(newPrompt: string | null) => {
|
|
651
|
+
if (newPrompt && !stdinClosed && proc.stdin && !proc.stdin.destroyed) {
|
|
652
|
+
// Re-prompt the agent with supervisor guidance
|
|
653
|
+
agentEnded = false; // Reset for the new turn
|
|
654
|
+
currentTurnHadToolCalls = false; // Reset for new turn
|
|
655
|
+
proc.stdin.write(JSON.stringify({ type: "prompt", message: newPrompt }) + "\n");
|
|
656
|
+
emitEvent("exit_intercepted", {
|
|
657
|
+
interceptionCount: exitInterceptionCount,
|
|
658
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
659
|
+
supervisorConsulted: true,
|
|
660
|
+
action: "reprompt",
|
|
661
|
+
newPromptPreview: truncatePayload(newPrompt, MAX_CONV_PAYLOAD_CHARS),
|
|
662
|
+
});
|
|
663
|
+
} else {
|
|
664
|
+
// Callback returned null or stdin already closed โ close session
|
|
665
|
+
const reason = stdinClosed ? "stdin_closed"
|
|
666
|
+
: newPrompt === null ? "callback_returned_null"
|
|
667
|
+
: "unknown";
|
|
668
|
+
emitEvent("exit_intercepted", {
|
|
669
|
+
interceptionCount: exitInterceptionCount,
|
|
670
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
671
|
+
supervisorConsulted: true,
|
|
672
|
+
action: "close",
|
|
673
|
+
reason,
|
|
674
|
+
});
|
|
675
|
+
closeStdin();
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
(err: unknown) => {
|
|
679
|
+
// Callback rejected โ emit single diagnostic event and close
|
|
680
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
681
|
+
emitEvent("exit_intercepted", {
|
|
682
|
+
interceptionCount: exitInterceptionCount,
|
|
683
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
684
|
+
supervisorConsulted: false,
|
|
685
|
+
action: "close",
|
|
686
|
+
reason: "callback_error",
|
|
687
|
+
error: msg,
|
|
688
|
+
});
|
|
689
|
+
closeStdin();
|
|
690
|
+
},
|
|
691
|
+
);
|
|
692
|
+
} else {
|
|
693
|
+
// No callback, had tool calls, or interception limit reached โ close normally
|
|
694
|
+
if (opts.onPrematureExit && exitInterceptionCount >= maxExitInterceptions) {
|
|
695
|
+
emitEvent("exit_intercepted", {
|
|
696
|
+
interceptionCount: exitInterceptionCount,
|
|
697
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
698
|
+
supervisorConsulted: false,
|
|
699
|
+
action: "close",
|
|
700
|
+
reason: "max_interceptions_reached",
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
closeStdin();
|
|
704
|
+
}
|
|
606
705
|
break;
|
|
607
706
|
}
|
|
608
707
|
}
|
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Artifact cleanup and log rotation for orchestrator runtime files.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Five cleanup layers prevent unbounded disk growth:
|
|
5
5
|
*
|
|
6
6
|
* 1. **Post-Integrate Cleanup** โ Deletes batch-specific telemetry and merge
|
|
7
7
|
* result files after successful /orch-integrate. Scoped by batchId.
|
|
8
8
|
*
|
|
9
|
-
* 2. **Age-Based Preflight Sweep** โ On /orch start, removes telemetry
|
|
10
|
-
* merge artifacts older than
|
|
11
|
-
* (e.g., aborted batches,
|
|
9
|
+
* 2. **Age-Based Preflight Sweep** โ On /orch start, removes telemetry,
|
|
10
|
+
* verification, conversation, lane-state, and merge artifacts older than
|
|
11
|
+
* 3 days. Catches files missed by Layer 1 (e.g., aborted batches,
|
|
12
|
+
* manual branch deletions).
|
|
12
13
|
*
|
|
13
14
|
* 3. **Size-Capped Log Rotation** โ Rotates append-only supervisor logs
|
|
14
15
|
* (events.jsonl, actions.jsonl) at a 5MB threshold during preflight.
|
|
15
16
|
* Keeps one .old generation.
|
|
16
17
|
*
|
|
18
|
+
* 4. **Telemetry Size Cap** โ Enforces a 500MB cap on `.pi/telemetry/`
|
|
19
|
+
* by evicting oldest files first when the directory exceeds the cap.
|
|
20
|
+
*
|
|
21
|
+
* 5. **Batch-Start Cleanup** โ Removes artifacts from prior completed
|
|
22
|
+
* batches when a new batch starts, protecting the current batch.
|
|
23
|
+
*
|
|
17
24
|
* All cleanup is **non-fatal** โ failures warn but never block execution.
|
|
18
25
|
*
|
|
19
26
|
* @module orch/cleanup
|
|
@@ -177,8 +184,8 @@ export function formatPostIntegrateCleanup(result: PostIntegrateCleanupResult):
|
|
|
177
184
|
|
|
178
185
|
// โโ Layer 2: Age-Based Preflight Sweep โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
179
186
|
|
|
180
|
-
/** Default max age for stale artifacts (
|
|
181
|
-
export const STALE_ARTIFACT_MAX_AGE_MS =
|
|
187
|
+
/** Default max age for stale artifacts (3 days in milliseconds). */
|
|
188
|
+
export const STALE_ARTIFACT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
|
|
182
189
|
|
|
183
190
|
/**
|
|
184
191
|
* Result of a preflight age-based sweep.
|
|
@@ -215,13 +222,16 @@ export interface SweepDeps {
|
|
|
215
222
|
* - `.pi/telemetry/lane-prompt-*.txt` โ temporary prompt files
|
|
216
223
|
* - `.pi/merge-result-*.json` โ merge result files
|
|
217
224
|
* - `.pi/merge-request-*.txt` โ merge request files
|
|
225
|
+
* - `.pi/verification/*` โ verification snapshots
|
|
226
|
+
* - `.pi/worker-conversation-*.jsonl` โ worker conversation logs
|
|
227
|
+
* - `.pi/lane-state-*.json` โ lane state files
|
|
218
228
|
*
|
|
219
229
|
* Uses file mtime for age detection. Skips files modified within maxAgeMs.
|
|
220
230
|
* If a batch is currently active (executing/merging), skips ALL cleanup.
|
|
221
231
|
*
|
|
222
232
|
* @param stateRoot - Root directory containing .pi/
|
|
223
233
|
* @param deps - Injectable dependencies for testability
|
|
224
|
-
* @param maxAgeMs - Maximum file age in milliseconds (default:
|
|
234
|
+
* @param maxAgeMs - Maximum file age in milliseconds (default: 3 days)
|
|
225
235
|
* @returns Sweep result with count and warnings
|
|
226
236
|
*/
|
|
227
237
|
export function sweepStaleArtifacts(
|
|
@@ -289,7 +299,17 @@ export function sweepStaleArtifacts(
|
|
|
289
299
|
(name.startsWith("merge-request-") && name.endsWith(".txt")),
|
|
290
300
|
);
|
|
291
301
|
|
|
292
|
-
// Sweep stale
|
|
302
|
+
// Sweep stale worker conversation logs (.pi/worker-conversation-*.jsonl)
|
|
303
|
+
sweepDir(join(stateRoot, ".pi"), (name) =>
|
|
304
|
+
name.startsWith("worker-conversation-") && name.endsWith(".jsonl"),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// Sweep stale lane state files (.pi/lane-state-*.json)
|
|
308
|
+
sweepDir(join(stateRoot, ".pi"), (name) =>
|
|
309
|
+
name.startsWith("lane-state-") && name.endsWith(".json"),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Sweep stale batch directories under a parent (mailbox, context-snapshots, verification)
|
|
293
313
|
const sweepBatchDirs = (parentDir: string, label: string): void => {
|
|
294
314
|
if (!existsSync(parentDir)) return;
|
|
295
315
|
try {
|
|
@@ -318,6 +338,9 @@ export function sweepStaleArtifacts(
|
|
|
318
338
|
// Sweep stale context-snapshot batch directories (.pi/context-snapshots/{batchId}/)
|
|
319
339
|
sweepBatchDirs(join(stateRoot, ".pi", "context-snapshots"), "context-snapshots");
|
|
320
340
|
|
|
341
|
+
// Sweep stale verification snapshot directories (.pi/verification/{opId}/)
|
|
342
|
+
sweepBatchDirs(join(stateRoot, ".pi", "verification"), "verification");
|
|
343
|
+
|
|
321
344
|
return result;
|
|
322
345
|
}
|
|
323
346
|
|
|
@@ -336,7 +359,7 @@ export function formatPreflightSweep(result: PreflightSweepResult): string {
|
|
|
336
359
|
const segments: string[] = [];
|
|
337
360
|
if (result.staleFilesDeleted > 0) segments.push(`${result.staleFilesDeleted} stale artifact(s)`);
|
|
338
361
|
if (result.staleDirsDeleted > 0) segments.push(`${result.staleDirsDeleted} stale mailbox dir(s)`);
|
|
339
|
-
parts.push(`๐งน Preflight cleanup: removed ${segments.join(" and ")} (>
|
|
362
|
+
parts.push(`๐งน Preflight cleanup: removed ${segments.join(" and ")} (>3 days old)`);
|
|
340
363
|
}
|
|
341
364
|
for (const warning of result.warnings) {
|
|
342
365
|
parts.push(` โ ๏ธ ${warning}`);
|
|
@@ -424,6 +447,245 @@ export function formatLogRotation(result: LogRotationResult): string {
|
|
|
424
447
|
return parts.join("\n");
|
|
425
448
|
}
|
|
426
449
|
|
|
450
|
+
// โโ Layer 4: Telemetry Directory Size Cap โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
451
|
+
|
|
452
|
+
/** Default telemetry directory size cap: 500 MB. */
|
|
453
|
+
export const TELEMETRY_SIZE_CAP_BYTES = 500 * 1024 * 1024;
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Result of telemetry size cap enforcement.
|
|
457
|
+
*/
|
|
458
|
+
export interface SizeCapResult {
|
|
459
|
+
/** Number of files deleted to bring directory under cap */
|
|
460
|
+
filesDeleted: number;
|
|
461
|
+
/** Total bytes freed */
|
|
462
|
+
bytesFreed: number;
|
|
463
|
+
/** Warnings from non-fatal failures */
|
|
464
|
+
warnings: string[];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Enforce a size cap on the telemetry directory by evicting oldest files first.
|
|
469
|
+
*
|
|
470
|
+
* Scans `.pi/telemetry/` and sums file sizes. If the total exceeds `capBytes`,
|
|
471
|
+
* deletes the oldest files (by mtime) until the total is under the cap.
|
|
472
|
+
*
|
|
473
|
+
* @param stateRoot - Root directory containing .pi/
|
|
474
|
+
* @param capBytes - Maximum allowed total size in bytes (default: 500MB)
|
|
475
|
+
* @returns Size cap enforcement result
|
|
476
|
+
*/
|
|
477
|
+
export function enforceTelemetrySizeCap(
|
|
478
|
+
stateRoot: string,
|
|
479
|
+
capBytes: number = TELEMETRY_SIZE_CAP_BYTES,
|
|
480
|
+
): SizeCapResult {
|
|
481
|
+
const result: SizeCapResult = {
|
|
482
|
+
filesDeleted: 0,
|
|
483
|
+
bytesFreed: 0,
|
|
484
|
+
warnings: [],
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const telemetryDir = join(stateRoot, ".pi", "telemetry");
|
|
488
|
+
if (!existsSync(telemetryDir)) return result;
|
|
489
|
+
|
|
490
|
+
// Collect all files with size and mtime
|
|
491
|
+
interface FileEntry {
|
|
492
|
+
name: string;
|
|
493
|
+
path: string;
|
|
494
|
+
size: number;
|
|
495
|
+
mtimeMs: number;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const files: FileEntry[] = [];
|
|
499
|
+
let totalSize = 0;
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
const entries = readdirSync(telemetryDir);
|
|
503
|
+
for (const entry of entries) {
|
|
504
|
+
const filePath = join(telemetryDir, entry);
|
|
505
|
+
try {
|
|
506
|
+
const stat = statSync(filePath);
|
|
507
|
+
if (!stat.isFile()) continue;
|
|
508
|
+
files.push({ name: entry, path: filePath, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
509
|
+
totalSize += stat.size;
|
|
510
|
+
} catch (err: unknown) {
|
|
511
|
+
result.warnings.push(`Failed to stat ${entry}: ${(err as Error).message}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch (err: unknown) {
|
|
515
|
+
result.warnings.push(`Failed to read telemetry directory: ${(err as Error).message}`);
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (totalSize <= capBytes) return result;
|
|
520
|
+
|
|
521
|
+
// Sort oldest first (lowest mtime first)
|
|
522
|
+
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
523
|
+
|
|
524
|
+
// Delete oldest files until under cap
|
|
525
|
+
for (const file of files) {
|
|
526
|
+
if (totalSize <= capBytes) break;
|
|
527
|
+
try {
|
|
528
|
+
unlinkSync(file.path);
|
|
529
|
+
totalSize -= file.size;
|
|
530
|
+
result.filesDeleted++;
|
|
531
|
+
result.bytesFreed += file.size;
|
|
532
|
+
} catch (err: unknown) {
|
|
533
|
+
result.warnings.push(`Failed to delete ${file.name}: ${(err as Error).message}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return result;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Format size cap result for logging.
|
|
542
|
+
*/
|
|
543
|
+
export function formatSizeCap(result: SizeCapResult): string {
|
|
544
|
+
if (result.filesDeleted === 0 && result.warnings.length === 0) return "";
|
|
545
|
+
const parts: string[] = [];
|
|
546
|
+
if (result.filesDeleted > 0) {
|
|
547
|
+
const mbFreed = (result.bytesFreed / (1024 * 1024)).toFixed(1);
|
|
548
|
+
parts.push(`๐งน Telemetry size cap: deleted ${result.filesDeleted} file(s), freed ${mbFreed} MB`);
|
|
549
|
+
}
|
|
550
|
+
for (const warning of result.warnings) {
|
|
551
|
+
parts.push(` โ ๏ธ ${warning}`);
|
|
552
|
+
}
|
|
553
|
+
return parts.join("\n");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// โโ Layer 5: Batch-Start Cleanup of Prior Batch Artifacts โโโโโโโโโโโโโ
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Result of prior-batch artifact cleanup.
|
|
560
|
+
*/
|
|
561
|
+
export interface PriorBatchCleanupResult {
|
|
562
|
+
/** Number of files/dirs deleted */
|
|
563
|
+
itemsDeleted: number;
|
|
564
|
+
/** Warnings from non-fatal failures */
|
|
565
|
+
warnings: string[];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Clean up artifacts from prior completed batches when a new batch starts.
|
|
570
|
+
*
|
|
571
|
+
* Removes batch-scoped files that may have been left behind by prior runs
|
|
572
|
+
* that were not integrated (e.g., aborted, crashed). Only cleans artifacts
|
|
573
|
+
* from batches that are NOT the currently active batch.
|
|
574
|
+
*
|
|
575
|
+
* Targets the same file patterns as `cleanupPostIntegrate` plus stale
|
|
576
|
+
* batch-state files.
|
|
577
|
+
*
|
|
578
|
+
* @param stateRoot - Root directory containing .pi/
|
|
579
|
+
* @param currentBatchId - The batch ID that is currently starting (will NOT be deleted)
|
|
580
|
+
* @returns Cleanup result
|
|
581
|
+
*/
|
|
582
|
+
export function cleanupPriorBatchArtifacts(
|
|
583
|
+
stateRoot: string,
|
|
584
|
+
currentBatchId: string,
|
|
585
|
+
): PriorBatchCleanupResult {
|
|
586
|
+
const result: PriorBatchCleanupResult = {
|
|
587
|
+
itemsDeleted: 0,
|
|
588
|
+
warnings: [],
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
if (!currentBatchId) {
|
|
592
|
+
result.warnings.push("No currentBatchId provided โ skipping prior batch cleanup");
|
|
593
|
+
return result;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const piDir = join(stateRoot, ".pi");
|
|
597
|
+
if (!existsSync(piDir)) return result;
|
|
598
|
+
|
|
599
|
+
// Helper: delete files in a directory matching a filter, skipping current batch
|
|
600
|
+
const cleanDir = (dir: string, filter: (name: string) => boolean): void => {
|
|
601
|
+
if (!existsSync(dir)) return;
|
|
602
|
+
try {
|
|
603
|
+
const entries = readdirSync(dir);
|
|
604
|
+
for (const entry of entries) {
|
|
605
|
+
if (!filter(entry)) continue;
|
|
606
|
+
if (entry.includes(currentBatchId)) continue; // Protect current batch
|
|
607
|
+
const filePath = join(dir, entry);
|
|
608
|
+
try {
|
|
609
|
+
const stat = statSync(filePath);
|
|
610
|
+
if (stat.isFile()) {
|
|
611
|
+
unlinkSync(filePath);
|
|
612
|
+
result.itemsDeleted++;
|
|
613
|
+
}
|
|
614
|
+
} catch (err: unknown) {
|
|
615
|
+
result.warnings.push(`Failed to delete ${entry}: ${(err as Error).message}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} catch (err: unknown) {
|
|
619
|
+
result.warnings.push(`Failed to read directory ${dir}: ${(err as Error).message}`);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// Clean telemetry files from prior batches
|
|
624
|
+
cleanDir(join(piDir, "telemetry"), (name) =>
|
|
625
|
+
name.endsWith(".jsonl") ||
|
|
626
|
+
name.endsWith("-exit.json") ||
|
|
627
|
+
(name.startsWith("lane-prompt-") && name.endsWith(".txt")),
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// Clean merge result/request files from prior batches
|
|
631
|
+
cleanDir(piDir, (name) =>
|
|
632
|
+
(name.startsWith("merge-result-") && name.endsWith(".json")) ||
|
|
633
|
+
(name.startsWith("merge-request-") && name.endsWith(".txt")),
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
// Clean worker conversation logs from prior batches
|
|
637
|
+
cleanDir(piDir, (name) =>
|
|
638
|
+
name.startsWith("worker-conversation-") && name.endsWith(".jsonl"),
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
// Clean lane state files from prior batches
|
|
642
|
+
cleanDir(piDir, (name) =>
|
|
643
|
+
name.startsWith("lane-state-") && name.endsWith(".json"),
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
// Clean batch-scoped directories (mailbox, context-snapshots)
|
|
647
|
+
const cleanBatchDirs = (parentDir: string): void => {
|
|
648
|
+
if (!existsSync(parentDir)) return;
|
|
649
|
+
try {
|
|
650
|
+
const entries = readdirSync(parentDir);
|
|
651
|
+
for (const entry of entries) {
|
|
652
|
+
if (entry === currentBatchId) continue; // Protect current batch
|
|
653
|
+
const entryPath = join(parentDir, entry);
|
|
654
|
+
try {
|
|
655
|
+
const stat = statSync(entryPath);
|
|
656
|
+
if (!stat.isDirectory()) continue;
|
|
657
|
+
rmSync(entryPath, { recursive: true, force: true });
|
|
658
|
+
result.itemsDeleted++;
|
|
659
|
+
} catch (err: unknown) {
|
|
660
|
+
result.warnings.push(`Failed to delete batch dir ${entry}: ${(err as Error).message}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
} catch (err: unknown) {
|
|
664
|
+
result.warnings.push(`Failed to read directory ${parentDir}: ${(err as Error).message}`);
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
cleanBatchDirs(join(piDir, MAILBOX_DIR_NAME));
|
|
669
|
+
cleanBatchDirs(join(piDir, "context-snapshots"));
|
|
670
|
+
|
|
671
|
+
return result;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Format prior batch cleanup result for logging.
|
|
676
|
+
*/
|
|
677
|
+
export function formatPriorBatchCleanup(result: PriorBatchCleanupResult): string {
|
|
678
|
+
if (result.itemsDeleted === 0 && result.warnings.length === 0) return "";
|
|
679
|
+
const parts: string[] = [];
|
|
680
|
+
if (result.itemsDeleted > 0) {
|
|
681
|
+
parts.push(`๐งน Prior batch cleanup: removed ${result.itemsDeleted} artifact(s) from previous batch(es)`);
|
|
682
|
+
}
|
|
683
|
+
for (const warning of result.warnings) {
|
|
684
|
+
parts.push(` โ ๏ธ ${warning}`);
|
|
685
|
+
}
|
|
686
|
+
return parts.join("\n");
|
|
687
|
+
}
|
|
688
|
+
|
|
427
689
|
// โโ Combined Preflight Cleanup โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
428
690
|
|
|
429
691
|
/**
|
|
@@ -466,7 +728,7 @@ export function formatPreflightCleanup(result: PreflightCleanupResult): string {
|
|
|
466
728
|
const segments: string[] = [];
|
|
467
729
|
if (result.sweep.staleFilesDeleted > 0) segments.push(`${result.sweep.staleFilesDeleted} stale artifact(s)`);
|
|
468
730
|
if (result.sweep.staleDirsDeleted > 0) segments.push(`${result.sweep.staleDirsDeleted} stale mailbox dir(s)`);
|
|
469
|
-
parts.push(`removed ${segments.join(" and ")} (>
|
|
731
|
+
parts.push(`removed ${segments.join(" and ")} (>3 days old)`);
|
|
470
732
|
}
|
|
471
733
|
|
|
472
734
|
// Layer 3: log rotation
|