@maxiaochao/pi-toolkit 0.10.1 → 0.10.3
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 +12 -0
- package/README.md +1 -1
- package/global/AGENTS.md +33 -50
- package/package.json +2 -2
- package/packages/herdr-subagents/CHANGELOG.md +14 -0
- package/packages/herdr-subagents/README.md +20 -18
- package/packages/herdr-subagents/TESTING.md +6 -4
- package/packages/herdr-subagents/docs/demo.webm +0 -0
- package/packages/herdr-subagents/docs/screenshot.png +0 -0
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +91 -51
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/batch-runner.ts +5 -5
- package/packages/herdr-subagents/src/blocking.ts +4 -4
- package/packages/herdr-subagents/src/fleet-controller.ts +164 -16
- package/packages/herdr-subagents/src/fleet.ts +119 -8
- package/packages/herdr-subagents/src/history.ts +11 -5
- package/packages/herdr-subagents/src/parameters.ts +16 -13
- package/packages/herdr-subagents/src/queued-runs.ts +3 -3
- package/packages/herdr-subagents/src/records.ts +53 -1
- package/packages/herdr-subagents/src/tool.ts +40 -6
- package/packages/herdr-subagents/src/validation.ts +16 -29
- package/packages/herdr-subagents/tests/extension-contract.ts +27 -9
- package/packages/herdr-subagents/tests/run-tests.mjs +153 -18
- package/packages/todo/CHANGELOG.md +4 -0
- package/packages/todo/README.md +19 -9
- package/packages/todo/TESTING.md +9 -7
- package/packages/todo/docs/demo.gif +0 -0
- package/packages/todo/docs/screenshot.png +0 -0
- package/packages/todo/extensions/todo.ts +120 -90
- package/packages/todo/package.json +2 -2
- package/packages/todo/src/todo-state.ts +48 -49
- package/packages/todo/tests/todo-extension.mjs +134 -0
- package/packages/todo/tests/todo-state.mjs +80 -70
|
@@ -9,7 +9,7 @@ import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
|
9
9
|
import type { OwnerIdentity } from "./ownership.ts";
|
|
10
10
|
import { ownerRecord } from "./ownership.ts";
|
|
11
11
|
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
12
|
-
import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, readReport, readRunRecord, readTaskRecord, readTurnRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type TaskRecord, type TaskStatus, type TurnRecord } from "./records.ts";
|
|
12
|
+
import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, projectTaskActivity, readReport, readRunRecord, readTaskRecord, readTurnRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type TaskActivity, type TaskRecord, type TaskStatus, type TurnRecord } from "./records.ts";
|
|
13
13
|
import { isAbortError } from "./errors.ts";
|
|
14
14
|
import { continuationSystemPrompt } from "./protocol.ts";
|
|
15
15
|
import { RunPaneAllocator } from "./run-pane-allocator.ts";
|
|
@@ -28,7 +28,7 @@ export interface ResumeBlockedResult {
|
|
|
28
28
|
requestedConcurrency: number;
|
|
29
29
|
effectiveConcurrency: number;
|
|
30
30
|
tasks?: Array<{ index: number; name: string; status: TaskStatus; summary: string; documents: PersistedDocument[]; error?: string; question?: string; paneId: string; recordDirectory: string; sessionFile: string }>;
|
|
31
|
-
activity?:
|
|
31
|
+
activity?: TaskActivity[];
|
|
32
32
|
tabId?: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -45,7 +45,7 @@ async function emitProgress(options: RespondToBlockedTaskOptions, runDirectory:
|
|
|
45
45
|
recordDirectory: runDirectory,
|
|
46
46
|
requestedConcurrency: Number(run.requestedConcurrency ?? 1),
|
|
47
47
|
effectiveConcurrency: Number(run.effectiveConcurrency ?? 1),
|
|
48
|
-
activity: tasks.map((entry, index) => (
|
|
48
|
+
activity: tasks.map((entry, index) => projectTaskActivity(entry, index)),
|
|
49
49
|
...(run.tabId ? { tabId: run.tabId } : {}),
|
|
50
50
|
});
|
|
51
51
|
}
|
|
@@ -73,7 +73,7 @@ async function projectSettledRun(runDirectory: string, runFile: string, run: Run
|
|
|
73
73
|
await writeJsonAtomic(runFile, run);
|
|
74
74
|
if (runStatus !== "blocked" && run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, onError: onCleanupError, failureStatus: "failed" });
|
|
75
75
|
const summary = runStatus === "completed" ? "All tasks completed after the answer." : runStatus === "blocked" ? `${finalTasks[blockedIndex]?.name ?? "Task"} needs input: ${blockedReport?.question ?? "Input required."}` : finalReports.map((entry, index) => `${finalTasks[index].name}: ${entry.summary}`).join("\n");
|
|
76
|
-
const activity = finalTasks.map((entry, index) => (
|
|
76
|
+
const activity = finalTasks.map((entry, index) => projectTaskActivity(entry, index));
|
|
77
77
|
const tasks = finalTasks.map((entry, index) => ({ index, name: activity[index].name, status: entry.status, summary: String(finalReports[index].summary ?? "Task has no report."), documents: finalReports[index].documents ?? [], ...(entry.error ? { error: String(entry.error) } : {}), ...(entry.question ? { question: String(entry.question) } : {}), paneId: String(entry.paneId ?? ""), recordDirectory: taskDirectories[index], sessionFile: String(entry.sessionFile ?? "") }));
|
|
78
78
|
return { ok: runStatus === "completed", runId: run.id, label: String(run.label ?? "batch"), status: runStatus, summary, ...(blockedReport?.question ? { question: blockedReport.question } : {}), documents: finalReports.flatMap((entry) => entry.documents ?? []), recordDirectory: runDirectory, requestedConcurrency: Number(run.requestedConcurrency ?? run.effectiveConcurrency ?? 1), effectiveConcurrency: Number(run.effectiveConcurrency ?? 1), tasks, activity, ...(run.tabId ? { tabId: String(run.tabId) } : {}) };
|
|
79
79
|
}
|
|
@@ -1,40 +1,175 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
1
3
|
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
4
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Container, isKeyRelease, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
6
|
import type { HerdrAutomation } from "./herdr.ts";
|
|
4
|
-
import { fleetEditorHasFocus, FleetSelection, fleetLines } from "./fleet.ts";
|
|
7
|
+
import { fleetEditorHasFocus, FleetSelection, fleetLines, readSessionTokens, type FleetTaskMetrics } from "./fleet.ts";
|
|
5
8
|
import { focusActiveTask } from "./monitor.ts";
|
|
6
9
|
import { isActiveStatus } from "./records.ts";
|
|
7
10
|
import type { HerdrSubagentsBatchDetails } from "./tool.ts";
|
|
8
11
|
|
|
12
|
+
interface Closable { close(): void; }
|
|
13
|
+
export interface FleetWidgetRuntime {
|
|
14
|
+
watchSession(path: string, onChange: () => void, onError: () => void): Closable;
|
|
15
|
+
setInterval(callback: () => void, ms: number): unknown;
|
|
16
|
+
clearInterval(handle: unknown): void;
|
|
17
|
+
setTimeout(callback: () => void, ms: number): unknown;
|
|
18
|
+
clearTimeout(handle: unknown): void;
|
|
19
|
+
readTokens(path: string): Promise<number | undefined>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const defaultFleetWidgetRuntime: FleetWidgetRuntime = {
|
|
23
|
+
watchSession(path, onChange, onError) {
|
|
24
|
+
const watcher = watch(dirname(path), (_event, filename) => {
|
|
25
|
+
if (!filename || filename.toString() === basename(path)) onChange();
|
|
26
|
+
});
|
|
27
|
+
watcher.on("error", () => { watcher.close(); onError(); });
|
|
28
|
+
return watcher;
|
|
29
|
+
},
|
|
30
|
+
setInterval: (callback, ms) => setInterval(callback, ms),
|
|
31
|
+
clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
|
|
32
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
33
|
+
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
34
|
+
readTokens: readSessionTokens,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export class FleetWidget implements Component {
|
|
38
|
+
private readonly tui: TUI;
|
|
39
|
+
private readonly theme: Theme;
|
|
40
|
+
private readonly selection: FleetSelection;
|
|
41
|
+
private readonly runtime: FleetWidgetRuntime;
|
|
42
|
+
private batch: HerdrSubagentsBatchDetails;
|
|
43
|
+
private readonly metrics = new Map<number, FleetTaskMetrics>();
|
|
44
|
+
private readonly watchers = new Map<string, Closable>();
|
|
45
|
+
private elapsedTimer?: unknown;
|
|
46
|
+
private refreshTimer?: unknown;
|
|
47
|
+
private generation = 0;
|
|
48
|
+
private disposed = false;
|
|
49
|
+
private sourceKey = "";
|
|
50
|
+
|
|
51
|
+
constructor(tui: TUI, theme: Theme, batch: HerdrSubagentsBatchDetails, selection: FleetSelection, runtime: FleetWidgetRuntime = defaultFleetWidgetRuntime) {
|
|
52
|
+
this.tui = tui;
|
|
53
|
+
this.theme = theme;
|
|
54
|
+
this.selection = selection;
|
|
55
|
+
this.runtime = runtime;
|
|
56
|
+
this.batch = batch;
|
|
57
|
+
this.configureLiveUpdates();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
update(batch: HerdrSubagentsBatchDetails): void {
|
|
61
|
+
if (this.disposed) return;
|
|
62
|
+
this.batch = batch;
|
|
63
|
+
this.configureLiveUpdates();
|
|
64
|
+
this.tui.requestRender();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
render(width: number): string[] {
|
|
68
|
+
return fleetLines(this.batch, this.selection, { width, metrics: this.metrics, theme: this.theme });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
invalidate(): void {}
|
|
72
|
+
|
|
73
|
+
dispose(): void {
|
|
74
|
+
if (this.disposed) return;
|
|
75
|
+
this.disposed = true;
|
|
76
|
+
this.generation += 1;
|
|
77
|
+
for (const watcher of this.watchers.values()) watcher.close();
|
|
78
|
+
this.watchers.clear();
|
|
79
|
+
if (this.elapsedTimer) this.runtime.clearInterval(this.elapsedTimer);
|
|
80
|
+
if (this.refreshTimer) this.runtime.clearTimeout(this.refreshTimer);
|
|
81
|
+
this.elapsedTimer = undefined;
|
|
82
|
+
this.refreshTimer = undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private configureLiveUpdates(): void {
|
|
86
|
+
const sessionFiles = new Set(this.batch.activity.map((task) => task.sessionFile).filter((path): path is string => Boolean(path)));
|
|
87
|
+
const nextSourceKey = `${this.batch.runId}\n${[...sessionFiles].sort().join("\n")}`;
|
|
88
|
+
if (nextSourceKey !== this.sourceKey) {
|
|
89
|
+
this.sourceKey = nextSourceKey;
|
|
90
|
+
this.generation += 1;
|
|
91
|
+
this.metrics.clear();
|
|
92
|
+
}
|
|
93
|
+
for (const [path, watcher] of this.watchers) {
|
|
94
|
+
if (sessionFiles.has(path)) continue;
|
|
95
|
+
watcher.close();
|
|
96
|
+
this.watchers.delete(path);
|
|
97
|
+
}
|
|
98
|
+
for (const path of sessionFiles) {
|
|
99
|
+
if (this.watchers.has(path)) continue;
|
|
100
|
+
try {
|
|
101
|
+
const watcher = this.runtime.watchSession(path, () => this.scheduleMetricsRefresh(), () => this.watchers.delete(path));
|
|
102
|
+
this.watchers.set(path, watcher);
|
|
103
|
+
} catch { /* The next durable activity update can retry the watch. */ }
|
|
104
|
+
}
|
|
105
|
+
const hasRunning = this.batch.activity.some((task) => task.status === "running");
|
|
106
|
+
if (hasRunning && !this.elapsedTimer) this.elapsedTimer = this.runtime.setInterval(() => this.tui.requestRender(), 1_000);
|
|
107
|
+
else if (!hasRunning && this.elapsedTimer) { this.runtime.clearInterval(this.elapsedTimer); this.elapsedTimer = undefined; }
|
|
108
|
+
this.scheduleMetricsRefresh(0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private scheduleMetricsRefresh(delay = 25): void {
|
|
112
|
+
if (this.disposed) return;
|
|
113
|
+
if (this.refreshTimer) this.runtime.clearTimeout(this.refreshTimer);
|
|
114
|
+
this.refreshTimer = this.runtime.setTimeout(() => { this.refreshTimer = undefined; void this.refreshMetrics(); }, delay);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async refreshMetrics(): Promise<void> {
|
|
118
|
+
const generation = ++this.generation;
|
|
119
|
+
const next = await Promise.all(this.batch.activity.map(async (task) => ({ index: task.index, tokens: task.sessionFile ? await this.runtime.readTokens(task.sessionFile) : undefined })));
|
|
120
|
+
if (this.disposed || generation !== this.generation) return;
|
|
121
|
+
this.metrics.clear();
|
|
122
|
+
for (const metric of next) if (metric.tokens !== undefined) this.metrics.set(metric.index, { tokens: metric.tokens });
|
|
123
|
+
this.tui.requestRender();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
9
127
|
export class FleetController {
|
|
128
|
+
private readonly herdr: HerdrAutomation;
|
|
129
|
+
private readonly isEditor: (value: unknown) => boolean;
|
|
130
|
+
private readonly createWidget?: (lines: string[]) => Container;
|
|
131
|
+
private readonly widgetRuntime: FleetWidgetRuntime;
|
|
10
132
|
private activeBatch?: HerdrSubagentsBatchDetails;
|
|
11
133
|
private readonly selection = new FleetSelection(matchesKey);
|
|
12
134
|
private unsubscribeInput?: () => void;
|
|
13
135
|
private ui?: ExtensionUIContext;
|
|
14
|
-
private tui?:
|
|
136
|
+
private tui?: TUI;
|
|
137
|
+
private widget?: FleetWidget;
|
|
15
138
|
|
|
16
|
-
constructor(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
139
|
+
constructor(herdr: HerdrAutomation, isEditor: (value: unknown) => boolean, createWidget?: (lines: string[]) => Container, widgetRuntime: FleetWidgetRuntime = defaultFleetWidgetRuntime) {
|
|
140
|
+
this.herdr = herdr;
|
|
141
|
+
this.isEditor = isEditor;
|
|
142
|
+
this.createWidget = createWidget;
|
|
143
|
+
this.widgetRuntime = widgetRuntime;
|
|
144
|
+
}
|
|
21
145
|
|
|
22
146
|
get active(): HerdrSubagentsBatchDetails | undefined { return this.activeBatch; }
|
|
23
147
|
|
|
24
148
|
bind(ui: ExtensionUIContext, tuiMode: boolean): void {
|
|
25
149
|
this.unsubscribeInput?.();
|
|
150
|
+
if (this.ui && this.ui !== ui) {
|
|
151
|
+
this.ui.setWidget("herdr-subagents", undefined, { placement: "belowEditor" });
|
|
152
|
+
this.widget = undefined;
|
|
153
|
+
this.tui = undefined;
|
|
154
|
+
}
|
|
26
155
|
this.ui = ui;
|
|
27
156
|
this.unsubscribeInput = tuiMode ? ui.onTerminalInput((data) => this.handleInput(data)) : undefined;
|
|
157
|
+
if (this.activeBatch) this.render();
|
|
28
158
|
}
|
|
29
159
|
|
|
30
160
|
sync(details: unknown): void {
|
|
161
|
+
if (!this.ui) return;
|
|
31
162
|
const batch = details as Partial<HerdrSubagentsBatchDetails>;
|
|
32
163
|
if (!batch.activity || !batch.label || !batch.status) return;
|
|
164
|
+
if (this.activeBatch && batch.runId !== this.activeBatch.runId) return;
|
|
165
|
+
if (this.activeBatch?.runId !== batch.runId) this.selection.reset();
|
|
33
166
|
this.activeBatch = isActiveStatus(batch.status) ? batch as HerdrSubagentsBatchDetails : undefined;
|
|
34
167
|
this.render();
|
|
35
168
|
}
|
|
36
169
|
|
|
37
170
|
setActive(batch: HerdrSubagentsBatchDetails | undefined): void {
|
|
171
|
+
if (!this.ui) return;
|
|
172
|
+
if (this.activeBatch?.runId !== batch?.runId) this.selection.reset();
|
|
38
173
|
this.activeBatch = batch;
|
|
39
174
|
this.render();
|
|
40
175
|
}
|
|
@@ -53,18 +188,31 @@ export class FleetController {
|
|
|
53
188
|
this.activeBatch = undefined;
|
|
54
189
|
this.selection.reset();
|
|
55
190
|
this.render();
|
|
191
|
+
this.ui = undefined;
|
|
192
|
+
this.widget = undefined;
|
|
193
|
+
this.tui = undefined;
|
|
56
194
|
}
|
|
57
195
|
|
|
58
196
|
render(): void {
|
|
59
197
|
if (!this.ui) return;
|
|
60
|
-
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
198
|
+
if (!this.activeBatch) {
|
|
199
|
+
this.widget = undefined;
|
|
200
|
+
this.ui.setWidget("herdr-subagents", undefined, { placement: "belowEditor" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (this.createWidget) {
|
|
204
|
+
this.ui.setWidget("herdr-subagents", (tui) => {
|
|
205
|
+
this.tui = tui;
|
|
206
|
+
return this.createWidget?.(fleetLines(this.activeBatch as HerdrSubagentsBatchDetails, this.selection)) ?? new Container();
|
|
207
|
+
}, { placement: "belowEditor" });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (this.widget) { this.widget.update(this.activeBatch); return; }
|
|
211
|
+
this.ui.setWidget("herdr-subagents", (tui, theme) => {
|
|
212
|
+
this.tui = tui;
|
|
213
|
+
this.widget = new FleetWidget(tui, theme, this.activeBatch as HerdrSubagentsBatchDetails, this.selection, this.widgetRuntime);
|
|
214
|
+
return this.widget;
|
|
215
|
+
}, { placement: "belowEditor" });
|
|
68
216
|
}
|
|
69
217
|
|
|
70
218
|
private handleInput(data: string): { consume: true } | undefined {
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
1
4
|
import type { BatchResult } from "./batch-runner.ts";
|
|
5
|
+
import type { TaskActivity } from "./records.ts";
|
|
2
6
|
|
|
3
7
|
type FleetKey = "up" | "down" | "enter" | "escape";
|
|
4
8
|
type KeyMatcher = (data: string, key: FleetKey) => boolean;
|
|
@@ -7,6 +11,8 @@ const legacyKeys: Record<FleetKey, string> = { up: "\u001b[A", down: "\u001b[B",
|
|
|
7
11
|
const legacyMatchesKey: KeyMatcher = (data, key) => data === legacyKeys[key];
|
|
8
12
|
|
|
9
13
|
export interface FleetInputResult { consume?: boolean; focusTask?: number; changed?: boolean; }
|
|
14
|
+
export interface FleetTaskMetrics { tokens?: number; }
|
|
15
|
+
export type FleetMetrics = ReadonlyMap<number, FleetTaskMetrics>;
|
|
10
16
|
|
|
11
17
|
export function fleetEditorHasFocus(focused: unknown, isEditor: (value: unknown) => boolean): boolean {
|
|
12
18
|
return isEditor(focused);
|
|
@@ -25,6 +31,7 @@ export class FleetSelection {
|
|
|
25
31
|
|
|
26
32
|
handle(data: string, editorText: string, taskCount: number): FleetInputResult {
|
|
27
33
|
if (taskCount === 0) { this.reset(); return {}; }
|
|
34
|
+
this.selected = this.selectedIndex(taskCount);
|
|
28
35
|
if (!this.selecting) {
|
|
29
36
|
if (!this.matchesKey(data, "down") || editorText.length !== 0) return {};
|
|
30
37
|
this.selecting = true;
|
|
@@ -36,21 +43,125 @@ export class FleetSelection {
|
|
|
36
43
|
this.selected = (this.selected + delta + taskCount) % taskCount;
|
|
37
44
|
return { consume: true, changed: true };
|
|
38
45
|
}
|
|
39
|
-
if (this.matchesKey(data, "enter")) return { consume: true, focusTask: this.
|
|
46
|
+
if (this.matchesKey(data, "enter")) return { consume: true, focusTask: this.selectedIndex(taskCount) + 1 };
|
|
40
47
|
if (this.matchesKey(data, "escape")) { this.selecting = false; return { consume: true, changed: true }; }
|
|
41
48
|
this.selecting = false;
|
|
42
49
|
return { changed: true };
|
|
43
50
|
}
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
|
|
53
|
+
function finiteTokenPart(value: unknown): number {
|
|
54
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function usageTokens(value: unknown): number {
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
|
|
59
|
+
const usage = value as Record<string, unknown>;
|
|
60
|
+
return finiteTokenPart(usage.input) + finiteTokenPart(usage.output) + finiteTokenPart(usage.cacheRead) + finiteTokenPart(usage.cacheWrite);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Match Pi's cumulative footer semantics without trusting an incomplete JSONL tail. */
|
|
64
|
+
export function aggregateSessionTokens(jsonl: string): number {
|
|
65
|
+
let total = 0;
|
|
66
|
+
for (const line of jsonl.split(/\r?\n/)) {
|
|
67
|
+
if (!line.trim()) continue;
|
|
68
|
+
let entry: Record<string, unknown>;
|
|
69
|
+
try {
|
|
70
|
+
const parsed: unknown = JSON.parse(line);
|
|
71
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
72
|
+
entry = parsed as Record<string, unknown>;
|
|
73
|
+
} catch { continue; }
|
|
74
|
+
if (entry.type === "usage") total += usageTokens(entry.usage);
|
|
75
|
+
else if (entry.type === "compaction" || entry.type === "branch_summary") total += usageTokens(entry.usage);
|
|
76
|
+
else if (entry.type === "message") {
|
|
77
|
+
const message = entry.message;
|
|
78
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
|
|
79
|
+
const record = message as Record<string, unknown>;
|
|
80
|
+
if (record.role === "assistant" || record.role === "toolResult") total += usageTokens(record.usage);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return total;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function readSessionTokens(path: string): Promise<number | undefined> {
|
|
87
|
+
try { return aggregateSessionTokens(await readFile(path, "utf8")); }
|
|
88
|
+
catch { return undefined; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function formatFleetDuration(task: TaskActivity, now = Date.now()): string | undefined {
|
|
92
|
+
if (!task.startedAt || task.status === "queued" || task.status === "starting") return undefined;
|
|
93
|
+
let elapsed = typeof task.elapsedMs === "number" && Number.isFinite(task.elapsedMs) ? Math.max(0, task.elapsedMs) : undefined;
|
|
94
|
+
if (task.status === "running" && task.activeStartedAt) {
|
|
95
|
+
const activeStarted = Date.parse(task.activeStartedAt);
|
|
96
|
+
if (Number.isFinite(activeStarted) && now >= activeStarted) elapsed = (elapsed ?? 0) + (now - activeStarted);
|
|
97
|
+
}
|
|
98
|
+
if (elapsed === undefined) {
|
|
99
|
+
const started = Date.parse(task.startedAt);
|
|
100
|
+
const terminal = task.completedAt ?? (task.status === "blocked" ? task.updatedAt : undefined);
|
|
101
|
+
const ended = terminal ? Date.parse(terminal) : now;
|
|
102
|
+
if (!Number.isFinite(started) || !Number.isFinite(ended) || ended < started) return undefined;
|
|
103
|
+
elapsed = ended - started;
|
|
104
|
+
}
|
|
105
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
106
|
+
if (seconds < 60) return `${seconds}s`;
|
|
107
|
+
const minutes = Math.floor(seconds / 60);
|
|
108
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
109
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatFleetTokens(tokens: number): string {
|
|
113
|
+
if (tokens < 1_000) return `${tokens} tok`;
|
|
114
|
+
if (tokens < 10_000) return `${(tokens / 1_000).toFixed(1)}k tok`;
|
|
115
|
+
if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k tok`;
|
|
116
|
+
return `${(tokens / 1_000_000).toFixed(1)}m tok`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function statusSummary(activity: TaskActivity[]): string {
|
|
47
120
|
const counts = new Map<string, number>();
|
|
48
|
-
for (const task of
|
|
49
|
-
|
|
121
|
+
for (const task of activity) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
122
|
+
return ["running", "queued", "blocked", "failed", "completed", "cancelled"]
|
|
50
123
|
.filter((status) => counts.has(status)).map((status) => `${status} ${counts.get(status)}`).join(" · ");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function fitColumns(left: string, right: string, width: number): string {
|
|
127
|
+
if (width <= 0) return "";
|
|
128
|
+
if (!right) return truncateToWidth(left, width);
|
|
129
|
+
const rightWidth = visibleWidth(right);
|
|
130
|
+
if (rightWidth + 2 >= width) return truncateToWidth(left, width);
|
|
131
|
+
const fittedLeft = truncateToWidth(left, width - rightWidth - 1);
|
|
132
|
+
const gap = " ".repeat(Math.max(1, width - visibleWidth(fittedLeft) - rightWidth));
|
|
133
|
+
return truncateToWidth(fittedLeft + gap + right, width);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type FleetTheme = Pick<Theme, "fg" | "bold">;
|
|
137
|
+
const plainTheme: FleetTheme = { fg: (_color, text) => text, bold: (text) => text } as FleetTheme;
|
|
138
|
+
|
|
139
|
+
export function fleetLines(
|
|
140
|
+
batch: Pick<BatchResult, "label" | "activity">,
|
|
141
|
+
selection: FleetSelection,
|
|
142
|
+
options: { width?: number; now?: number; metrics?: FleetMetrics; theme?: FleetTheme } = {},
|
|
143
|
+
): string[] {
|
|
144
|
+
const width = Math.max(1, options.width ?? 120);
|
|
145
|
+
const theme = options.theme ?? plainTheme;
|
|
51
146
|
const selected = selection.selectedIndex(batch.activity.length);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
147
|
+
const title = `─ ${theme.bold("Fleet")} · ${batch.label} · ${statusSummary(batch.activity)} `;
|
|
148
|
+
const titleWidth = visibleWidth(title);
|
|
149
|
+
const lines = [truncateToWidth(title + "─".repeat(Math.max(0, width - titleWidth)), width)];
|
|
150
|
+
const rows = batch.activity.map((task, index) => {
|
|
151
|
+
const chosen = selection.isSelecting() && index === selected;
|
|
152
|
+
const circle = task.status === "queued" || task.status === "starting" ? "○" : "●";
|
|
153
|
+
const color = task.status === "completed" ? "success" : task.status === "running" ? "warning" : task.status === "failed" ? "error" : task.status === "blocked" ? "warning" : task.status === "cancelled" ? "dim" : "accent";
|
|
154
|
+
const marker = chosen ? theme.fg("accent", "›") : " ";
|
|
155
|
+
const persona = theme.fg("muted", task.agent || "worker");
|
|
156
|
+
const left = `${marker} ${theme.fg(color, circle)} ${persona} ${task.name} ${theme.fg("dim", task.status)}`;
|
|
157
|
+
const duration = formatFleetDuration(task, options.now);
|
|
158
|
+
const tokens = task.status === "queued" || task.status === "starting" ? undefined : options.metrics?.get(task.index)?.tokens;
|
|
159
|
+
const right = [duration, tokens === undefined ? undefined : formatFleetTokens(tokens)].filter(Boolean).join(" ");
|
|
160
|
+
return { left, right: theme.fg("dim", right) };
|
|
161
|
+
});
|
|
162
|
+
const maxLeft = Math.max(0, ...rows.map(({ left }) => visibleWidth(left)));
|
|
163
|
+
const maxRight = Math.max(0, ...rows.map(({ right }) => visibleWidth(right)));
|
|
164
|
+
const contentWidth = Math.min(width, maxLeft + (maxRight ? 2 + maxRight : 0));
|
|
165
|
+
for (const row of rows) lines.push(fitColumns(row.left, row.right, contentWidth));
|
|
166
|
+
return lines;
|
|
56
167
|
}
|
|
@@ -8,7 +8,7 @@ import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
|
8
8
|
import type { OwnerIdentity } from "./ownership.ts";
|
|
9
9
|
import { ownerRecord } from "./ownership.ts";
|
|
10
10
|
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
11
|
-
import { applyReport, applyTaskStatus, isActiveStatus, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskRecord, type TaskStatus } from "./records.ts";
|
|
11
|
+
import { applyReport, applyTaskStatus, isActiveStatus, projectTaskActivity, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
|
|
12
12
|
import { continuationSystemPrompt } from "./protocol.ts";
|
|
13
13
|
import { isAbortError } from "./errors.ts";
|
|
14
14
|
import { findRunDirectory } from "./run-locator.ts";
|
|
@@ -96,9 +96,10 @@ export async function cleanSubagentRun(cwd: string, runId: string): Promise<bool
|
|
|
96
96
|
return true;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
export interface ResumeHistoricalTaskOptions { herdr?: HerdrAutomation; signal?: AbortSignal; owner?: OwnerIdentity; onCleanupError?: (message: string) => void; stalledWarningSeconds?: number; onStalled?: (message: string) => void; }
|
|
99
|
+
export interface ResumeHistoricalTaskOptions { herdr?: HerdrAutomation; signal?: AbortSignal; owner?: OwnerIdentity; onCleanupError?: (message: string) => void; stalledWarningSeconds?: number; onStalled?: (message: string) => void; onUpdate?: (result: ResumeHistoricalTaskResult) => void; }
|
|
100
|
+
export interface ResumeHistoricalTaskResult { runId: string; label: string; status: TaskStatus; summary: string; documents: PersistedDocument[]; tabId: string; paneId: string; sessionFile: string; activity: TaskActivity[]; }
|
|
100
101
|
|
|
101
|
-
export async function resumeHistoricalTask(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions = {}): Promise<
|
|
102
|
+
export async function resumeHistoricalTask(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions = {}): Promise<ResumeHistoricalTaskResult> {
|
|
102
103
|
const runDirectory = await findRunDirectory(join(cwd, ".pi", "herdr-subagents", "runs"), runId);
|
|
103
104
|
if (!runDirectory) throw new Error(`Unknown Subagent run '${runId}'.`);
|
|
104
105
|
const lockDirectory = join(runDirectory, ".resume.lock");
|
|
@@ -111,7 +112,7 @@ export async function resumeHistoricalTask(cwd: string, runId: string, taskNumbe
|
|
|
111
112
|
finally { await rmdir(lockDirectory); }
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions): Promise<
|
|
115
|
+
async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions): Promise<ResumeHistoricalTaskResult> {
|
|
115
116
|
const herdr = options.herdr ?? new CliHerdrAutomation();
|
|
116
117
|
const { signal, onCleanupError } = options;
|
|
117
118
|
const run = (await listSubagentHistory(cwd)).find((candidate) => candidate.id === runId);
|
|
@@ -158,6 +159,8 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
|
|
|
158
159
|
writeJsonAtomic(turnFile, { attempt, prompt, status: "running", sessionFile: historical.sessionFile, tabId: tab.tabId, paneId: tab.paneId, agentName, startedAt: new Date().toISOString() }),
|
|
159
160
|
writeJsonAtomic(activeRunFile, activeRunRecord),
|
|
160
161
|
]);
|
|
162
|
+
const runningActivity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
|
|
163
|
+
options.onUpdate?.({ runId, label: run.label, status: "running", summary: `${historical.name} is continuing.`, documents: [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile, activity: runningActivity });
|
|
161
164
|
report = (await promptLiveAgent({ herdr, task: { ...task, id: historical.id, sessionFile: historical.sessionFile }, paneId: tab.paneId, prompt, systemPromptFile: followupPrompt, agentName, signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => options.onStalled?.(`${historical.name} appears stalled; it is still running.`) })).report;
|
|
162
165
|
status = applyReport(task, report);
|
|
163
166
|
} catch (error) {
|
|
@@ -182,7 +185,8 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
|
|
|
182
185
|
if (status !== "blocked") {
|
|
183
186
|
await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError, failureStatus: "failed" });
|
|
184
187
|
}
|
|
185
|
-
|
|
188
|
+
const activity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
|
|
189
|
+
return { runId, label: run.label, status, summary: report.summary, documents: report.documents ?? [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile, activity };
|
|
186
190
|
} catch (error) {
|
|
187
191
|
const message = error instanceof Error ? error.message : String(error);
|
|
188
192
|
const completedAt = new Date().toISOString();
|
|
@@ -204,6 +208,8 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
|
|
|
204
208
|
writeJsonAtomic(turnFile, { attempt, prompt, status, summary: report.summary, documents: report.documents, error: message, sessionFile: historical.sessionFile, ...(tab ? { tabId: tab.tabId, paneId: tab.paneId } : {}), completedAt }),
|
|
205
209
|
writeJsonAtomic(join(run.recordDirectory, "run.json"), runRecord),
|
|
206
210
|
]);
|
|
211
|
+
const activity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
|
|
212
|
+
options.onUpdate?.({ runId, label: run.label, status, summary: report.summary, documents: report.documents, tabId: tab?.tabId ?? "", paneId: tab?.paneId ?? "", sessionFile: historical.sessionFile, activity });
|
|
207
213
|
if (tab) await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError, failureStatus: "failed" });
|
|
208
214
|
throw error;
|
|
209
215
|
}
|
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
+
const HerdrSubagentsTaskParams = Type.Object({
|
|
5
|
+
name: Type.String({ minLength: 1, maxLength: 48, description: "Short task name shown in the pane and task list." }),
|
|
6
|
+
prompt: Type.String({ minLength: 1, description: "Self-contained prompt for this task." }),
|
|
7
|
+
agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for this task. Defaults to worker." })),
|
|
8
|
+
model: Type.Optional(Type.String({ minLength: 1, description: "Optional model override for this task." })),
|
|
9
|
+
}, { additionalProperties: false });
|
|
10
|
+
|
|
4
11
|
export const HerdrSubagentsParams = Type.Object({
|
|
5
|
-
|
|
12
|
+
tasks: Type.Array(HerdrSubagentsTaskParams, { minItems: 1, description: "Ordered tasks to execute. Use one item for a single task; independent read-only tasks may run concurrently." }),
|
|
13
|
+
label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short Herdr run-tab label." })),
|
|
14
|
+
concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneously active Agents. Write-capable tasks force concurrency one." })),
|
|
15
|
+
background: Type.Optional(Type.Boolean({ description: "Return a run ID immediately while the run continues in this parent session." })),
|
|
16
|
+
}, { additionalProperties: false });
|
|
17
|
+
|
|
18
|
+
export const HerdrSubagentsControlParams = Type.Object({
|
|
19
|
+
action: StringEnum(["list", "respond", "history", "resume", "cleanup"] as const, { description: "Control operation to perform." }),
|
|
6
20
|
runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by respond, resume, or cleanup." })),
|
|
7
21
|
answer: Type.Optional(Type.String({ minLength: 1, description: "Answer sent to a blocked task when action is respond." })),
|
|
8
22
|
task: Type.Optional(Type.Integer({ minimum: 1, description: "One-based task number selected for respond or resume." })),
|
|
9
|
-
prompt: Type.Optional(Type.String({ minLength: 1, description: "
|
|
10
|
-
agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for single execution. Defaults to worker." })),
|
|
11
|
-
label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short Herdr run-tab label." })),
|
|
12
|
-
model: Type.Optional(Type.String({ minLength: 1, description: "Optional Pi model override, for example provider/model:high." })),
|
|
13
|
-
tasks: Type.Optional(Type.Array(Type.Object({
|
|
14
|
-
name: Type.String({ minLength: 1, maxLength: 48, description: "Short task name shown in the pane and task list." }),
|
|
15
|
-
prompt: Type.String({ minLength: 1, description: "Self-contained prompt for this task." }),
|
|
16
|
-
agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for this task. Defaults to worker." })),
|
|
17
|
-
model: Type.Optional(Type.String({ minLength: 1, description: "Optional model override for this task." })),
|
|
18
|
-
}), { minItems: 1, description: "Ordered tasks for bounded-concurrency batch execution." })),
|
|
19
|
-
concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneously active Agents in a batch." })),
|
|
20
|
-
background: Type.Optional(Type.Boolean({ description: "Return a run ID immediately while the batch continues in this parent session." })),
|
|
23
|
+
prompt: Type.Optional(Type.String({ minLength: 1, description: "Follow-up prompt used by resume." })),
|
|
21
24
|
}, { additionalProperties: false });
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
2
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import type {
|
|
4
|
+
import type { HerdrSubagentsRunParams } from "./tool.ts";
|
|
5
5
|
import { isProcessAlive, ownerRecord, type OwnerIdentity } from "./ownership.ts";
|
|
6
6
|
import { writeJsonAtomic } from "./state.ts";
|
|
7
7
|
import { findRunDirectory } from "./run-locator.ts";
|
|
8
8
|
|
|
9
|
-
export interface QueuedRunRecord { runId: string; cwd: string; ownerSessionId?: string; ownerProcessId?: number; params:
|
|
9
|
+
export interface QueuedRunRecord { runId: string; cwd: string; ownerSessionId?: string; ownerProcessId?: number; params: HerdrSubagentsRunParams; queuedAt: string; }
|
|
10
10
|
function queueDirectory(cwd: string): string { return join(cwd, ".pi", "herdr-subagents", "queue"); }
|
|
11
11
|
|
|
12
|
-
export async function persistQueuedRun(cwd: string, runId: string, params:
|
|
12
|
+
export async function persistQueuedRun(cwd: string, runId: string, params: HerdrSubagentsRunParams, owner?: OwnerIdentity): Promise<void> {
|
|
13
13
|
const directory = queueDirectory(cwd);
|
|
14
14
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
15
15
|
await writeJsonAtomic(join(directory, `${runId}.json`), { runId, cwd, ...ownerRecord(owner), params: { ...params, background: true }, queuedAt: new Date().toISOString() });
|
|
@@ -64,6 +64,8 @@ export interface TaskRecord {
|
|
|
64
64
|
systemPrompt?: string;
|
|
65
65
|
sessionFile?: string;
|
|
66
66
|
startedAt?: string;
|
|
67
|
+
activeStartedAt?: string;
|
|
68
|
+
elapsedMs?: number;
|
|
67
69
|
updatedAt?: string;
|
|
68
70
|
completedAt?: string;
|
|
69
71
|
tabId?: string;
|
|
@@ -76,6 +78,38 @@ export interface TaskRecord {
|
|
|
76
78
|
error?: string;
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
export interface TaskActivity {
|
|
82
|
+
index: number;
|
|
83
|
+
name: string;
|
|
84
|
+
status: TaskStatus;
|
|
85
|
+
agent: string;
|
|
86
|
+
prompt?: string;
|
|
87
|
+
paneId?: string;
|
|
88
|
+
sessionFile?: string;
|
|
89
|
+
startedAt?: string;
|
|
90
|
+
activeStartedAt?: string;
|
|
91
|
+
elapsedMs?: number;
|
|
92
|
+
completedAt?: string;
|
|
93
|
+
updatedAt?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function projectTaskActivity(task: TaskRecord, index = task.order - 1): TaskActivity {
|
|
97
|
+
return {
|
|
98
|
+
index,
|
|
99
|
+
name: String(task.name ?? task.agent ?? `Task ${index + 1}`),
|
|
100
|
+
status: task.status,
|
|
101
|
+
agent: String(task.agent ?? "worker"),
|
|
102
|
+
...(task.prompt ? { prompt: task.prompt } : {}),
|
|
103
|
+
...(task.paneId ? { paneId: task.paneId } : {}),
|
|
104
|
+
...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
|
|
105
|
+
...(task.startedAt ? { startedAt: task.startedAt } : {}),
|
|
106
|
+
...(task.activeStartedAt ? { activeStartedAt: task.activeStartedAt } : {}),
|
|
107
|
+
...(typeof task.elapsedMs === "number" ? { elapsedMs: task.elapsedMs } : {}),
|
|
108
|
+
...(task.completedAt ? { completedAt: task.completedAt } : {}),
|
|
109
|
+
...(task.updatedAt ? { updatedAt: task.updatedAt } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
79
113
|
export interface TurnRecord {
|
|
80
114
|
attempt: number;
|
|
81
115
|
prompt?: string;
|
|
@@ -184,7 +218,25 @@ export function applyLifecycleStatus<T extends { status: string; question?: stri
|
|
|
184
218
|
}
|
|
185
219
|
|
|
186
220
|
export function applyTaskStatus<T extends TaskRecord | TurnRecord>(record: T, status: TaskStatus, options: { question?: string; error?: string; at?: string } = {}): T {
|
|
187
|
-
|
|
221
|
+
const at = options.at ?? new Date().toISOString();
|
|
222
|
+
if ("order" in record) {
|
|
223
|
+
if (record.status === "running" && status !== "running") {
|
|
224
|
+
const activeStarted = Date.parse(record.activeStartedAt ?? record.startedAt ?? "");
|
|
225
|
+
const ended = Date.parse(at);
|
|
226
|
+
if (Number.isFinite(activeStarted) && Number.isFinite(ended) && ended >= activeStarted) record.elapsedMs = Math.max(0, Number(record.elapsedMs ?? 0)) + (ended - activeStarted);
|
|
227
|
+
delete record.activeStartedAt;
|
|
228
|
+
} else if (status === "running" && record.status !== "running") {
|
|
229
|
+
if (!(typeof record.elapsedMs === "number" && Number.isFinite(record.elapsedMs))) {
|
|
230
|
+
const frozenAt = record.status === "blocked" ? record.updatedAt : isSettledRunStatus(record.status) ? record.completedAt : undefined;
|
|
231
|
+
const started = Date.parse(record.startedAt ?? "");
|
|
232
|
+
const ended = Date.parse(frozenAt ?? "");
|
|
233
|
+
if (Number.isFinite(started) && Number.isFinite(ended) && ended >= started) record.elapsedMs = ended - started;
|
|
234
|
+
}
|
|
235
|
+
record.startedAt ??= at;
|
|
236
|
+
record.activeStartedAt = at;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return applyLifecycleStatus(record, status, { ...options, at });
|
|
188
240
|
}
|
|
189
241
|
|
|
190
242
|
export async function settleAttemptFailure<T extends TaskRecord | TurnRecord>(
|