kankaku-pi 1.0.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/LICENSE +21 -0
- package/README.md +1438 -0
- package/dist/adapters/cached-catalog.d.ts +42 -0
- package/dist/adapters/cached-catalog.js +121 -0
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/file-modes.d.ts +20 -0
- package/dist/adapters/file-modes.js +34 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/hub-credentials.d.ts +35 -0
- package/dist/adapters/hub-credentials.js +58 -0
- package/dist/adapters/jsonl-work-log.d.ts +20 -0
- package/dist/adapters/jsonl-work-log.js +62 -0
- package/dist/adapters/kankaku-dir.d.ts +38 -0
- package/dist/adapters/kankaku-dir.js +85 -0
- package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
- package/dist/adapters/lazy-jsonl-work-log.js +31 -0
- package/dist/adapters/pocketbase-catalog.d.ts +16 -0
- package/dist/adapters/pocketbase-catalog.js +56 -0
- package/dist/adapters/pocketbase-client.d.ts +81 -0
- package/dist/adapters/pocketbase-client.js +148 -0
- package/dist/adapters/pocketbase-sink.d.ts +53 -0
- package/dist/adapters/pocketbase-sink.js +181 -0
- package/dist/adapters/project-config.d.ts +42 -0
- package/dist/adapters/project-config.js +108 -0
- package/dist/adapters/report-data.d.ts +12 -0
- package/dist/adapters/report-data.js +8 -0
- package/dist/adapters/report-views.d.ts +45 -0
- package/dist/adapters/report-views.js +73 -0
- package/dist/adapters/report.d.ts +112 -0
- package/dist/adapters/report.js +236 -0
- package/dist/adapters/sync-runner.d.ts +114 -0
- package/dist/adapters/sync-runner.js +273 -0
- package/dist/adapters/sync-state-store.d.ts +62 -0
- package/dist/adapters/sync-state-store.js +188 -0
- package/dist/config.d.ts +168 -0
- package/dist/config.js +392 -0
- package/dist/domain/ancestry-match.d.ts +49 -0
- package/dist/domain/ancestry-match.js +82 -0
- package/dist/domain/client-label.d.ts +28 -0
- package/dist/domain/client-label.js +44 -0
- package/dist/domain/day.d.ts +2 -0
- package/dist/domain/day.js +8 -0
- package/dist/domain/export.d.ts +38 -0
- package/dist/domain/export.js +68 -0
- package/dist/domain/hub-entry.d.ts +234 -0
- package/dist/domain/hub-entry.js +265 -0
- package/dist/domain/index.d.ts +19 -0
- package/dist/domain/index.js +19 -0
- package/dist/domain/intervals.d.ts +17 -0
- package/dist/domain/intervals.js +43 -0
- package/dist/domain/registry-health.d.ts +49 -0
- package/dist/domain/registry-health.js +58 -0
- package/dist/domain/segment-rule.d.ts +10 -0
- package/dist/domain/segment-rule.js +1 -0
- package/dist/domain/subagent-profile.d.ts +278 -0
- package/dist/domain/subagent-profile.js +418 -0
- package/dist/domain/sync-plan.d.ts +151 -0
- package/dist/domain/sync-plan.js +196 -0
- package/dist/domain/task-view.d.ts +117 -0
- package/dist/domain/task-view.js +428 -0
- package/dist/domain/work-record.d.ts +236 -0
- package/dist/domain/work-record.js +91 -0
- package/dist/domain/work-target.d.ts +101 -0
- package/dist/domain/work-target.js +149 -0
- package/dist/domain/work-tracker.d.ts +90 -0
- package/dist/domain/work-tracker.js +405 -0
- package/dist/hub/index.d.ts +25 -0
- package/dist/hub/index.js +25 -0
- package/dist/ports/catalog.d.ts +31 -0
- package/dist/ports/catalog.js +1 -0
- package/dist/ports/clock.d.ts +3 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/index.d.ts +11 -0
- package/dist/ports/index.js +1 -0
- package/dist/ports/inflight-store.d.ts +15 -0
- package/dist/ports/inflight-store.js +1 -0
- package/dist/ports/process-registry.d.ts +72 -0
- package/dist/ports/process-registry.js +1 -0
- package/dist/ports/work-log.d.ts +14 -0
- package/dist/ports/work-log.js +1 -0
- package/dist/ports/work-sink.d.ts +39 -0
- package/dist/ports/work-sink.js +1 -0
- package/package.json +66 -0
- package/src/adapters/agent-info.ts +86 -0
- package/src/adapters/ancestry.ts +260 -0
- package/src/adapters/cached-catalog.ts +147 -0
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/file-modes.ts +35 -0
- package/src/adapters/hub-actions.ts +82 -0
- package/src/adapters/hub-credentials.ts +95 -0
- package/src/adapters/jsonl-work-log.ts +67 -0
- package/src/adapters/kankaku-command.ts +717 -0
- package/src/adapters/kankaku-dir.ts +102 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +39 -0
- package/src/adapters/machine-process-registry.ts +256 -0
- package/src/adapters/panel/kankaku-panel.ts +419 -0
- package/src/adapters/panel/panel-items.ts +87 -0
- package/src/adapters/panel/panel-lines.ts +13 -0
- package/src/adapters/panel/panel-theme.ts +32 -0
- package/src/adapters/panel/screens/about.ts +69 -0
- package/src/adapters/panel/screens/doctor.ts +89 -0
- package/src/adapters/panel/screens/export.ts +123 -0
- package/src/adapters/panel/screens/report.ts +143 -0
- package/src/adapters/panel/screens/sync.ts +136 -0
- package/src/adapters/panel/screens/target.ts +384 -0
- package/src/adapters/pi-tracker.ts +753 -0
- package/src/adapters/pocketbase-catalog.ts +89 -0
- package/src/adapters/pocketbase-client.ts +197 -0
- package/src/adapters/pocketbase-sink.ts +236 -0
- package/src/adapters/process-identity-memo.ts +102 -0
- package/src/adapters/process-identity.ts +162 -0
- package/src/adapters/project-config.ts +116 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +335 -0
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/session-dir.ts +28 -0
- package/src/adapters/session-target.ts +431 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/adapters/subagent-startup.ts +66 -0
- package/src/adapters/sync-runner.ts +340 -0
- package/src/adapters/sync-state-store.ts +227 -0
- package/src/adapters/target-picker.ts +127 -0
- package/src/config.ts +536 -0
- package/src/domain/ancestry-match.ts +84 -0
- package/src/domain/client-label.ts +56 -0
- package/src/domain/day.ts +8 -0
- package/src/domain/export.ts +107 -0
- package/src/domain/hub-entry.ts +433 -0
- package/src/domain/index.ts +19 -0
- package/src/domain/intervals.ts +53 -0
- package/src/domain/panel-model.ts +270 -0
- package/src/domain/registry-health.ts +87 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/subagent-profile.ts +495 -0
- package/src/domain/sync-plan.ts +266 -0
- package/src/domain/task-view.ts +526 -0
- package/src/domain/work-record.ts +320 -0
- package/src/domain/work-target.ts +234 -0
- package/src/domain/work-tracker.ts +485 -0
- package/src/extension.ts +346 -0
- package/src/hub/index.ts +25 -0
- package/src/ports/catalog.ts +33 -0
- package/src/ports/clock.ts +3 -0
- package/src/ports/index.ts +11 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/process-registry.ts +75 -0
- package/src/ports/work-log.ts +15 -0
- package/src/ports/work-sink.ts +35 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Clock } from "../ports/clock.ts";
|
|
3
|
+
import { clampIntervals, unionMs } from "./intervals.ts";
|
|
4
|
+
import { emptyUsage, EXTENSION_RUN_PROMPT, finiteOrZero, WORK_RECORD_SCHEMA } from "./work-record.ts";
|
|
5
|
+
import type { RunTrigger, SubagentSpan, UsageTotals, WorkRecordCore, WorkStatus } from "./work-record.ts";
|
|
6
|
+
import type { SegmentRule } from "./segment-rule.ts";
|
|
7
|
+
import { mergeAgreeingLaunchInfo, readLaunchInfo, readResultInfo, resolveToolProfile, safeAmbiguousResultInfo } from "./subagent-profile.ts";
|
|
8
|
+
import type { SubagentProfile } from "./subagent-profile.ts";
|
|
9
|
+
|
|
10
|
+
export interface WorkTrackerOptions {
|
|
11
|
+
clock: Clock;
|
|
12
|
+
interactiveTools: string[];
|
|
13
|
+
/** Every active {@link SubagentProfile} (ADR 0020); a tool call opens a subagent span when its name matches any profile's `toolNames`. See `config.ts#loadConfig`. */
|
|
14
|
+
subagentProfiles: SubagentProfile[];
|
|
15
|
+
/** Rules that tag a tool execution's span under a named segment. Defaults to none. */
|
|
16
|
+
segmentRules?: SegmentRule[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Interval {
|
|
20
|
+
start: number;
|
|
21
|
+
end: number | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface OpenSubagentSpan {
|
|
25
|
+
toolCallId: string;
|
|
26
|
+
toolName: string;
|
|
27
|
+
agent: string;
|
|
28
|
+
mode: string;
|
|
29
|
+
/** The unambiguously-matched profile's id, or `undefined` when 0 or 2+ profiles registered this tool name (SUBAGENT-REQ-005 — never guessed). */
|
|
30
|
+
profile: string | undefined;
|
|
31
|
+
start: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RunState {
|
|
35
|
+
/** Generated once when the run opens, so it stays stable across `peek` and the final `onSettled`/`onShutdown`. */
|
|
36
|
+
id: string;
|
|
37
|
+
startedAt: number;
|
|
38
|
+
prompt: string;
|
|
39
|
+
trigger?: RunTrigger;
|
|
40
|
+
runs: number;
|
|
41
|
+
turns: number;
|
|
42
|
+
tools: Record<string, number>;
|
|
43
|
+
usage: UsageTotals;
|
|
44
|
+
/** `true` once any turn has reported a real (finite) provider cost figure. See `WorkRecordCore.costObserved`. */
|
|
45
|
+
costObserved: boolean;
|
|
46
|
+
status: WorkStatus;
|
|
47
|
+
waitingSpans: Interval[];
|
|
48
|
+
/** Waiting spans opened by interactive tools, keyed by tool call id. */
|
|
49
|
+
openToolWaits: Map<string, Interval>;
|
|
50
|
+
subagents: SubagentSpan[];
|
|
51
|
+
openSubagents: Map<string, OpenSubagentSpan>;
|
|
52
|
+
/**
|
|
53
|
+
* Segment spans opened by a matching {@link SegmentRule}, keyed by tag.
|
|
54
|
+
* A `Map` rather than a plain object so a tag such as `__proto__` cannot
|
|
55
|
+
* pollute the object prototype while the run is in progress.
|
|
56
|
+
*/
|
|
57
|
+
segmentSpans: Map<string, Interval[]>;
|
|
58
|
+
/** The still-open segment span for a tool call id, if any. */
|
|
59
|
+
openSegments: Map<string, Interval>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RunEndMessage {
|
|
63
|
+
role: string;
|
|
64
|
+
stopReason?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pure domain state machine that turns pi lifecycle events into finished
|
|
69
|
+
* {@link WorkRecord} entries. Holds no I/O; timestamps come from the
|
|
70
|
+
* injected {@link Clock} so behaviour is deterministic under test.
|
|
71
|
+
*/
|
|
72
|
+
export class WorkTracker {
|
|
73
|
+
private readonly clock: Clock;
|
|
74
|
+
private readonly interactiveTools: Set<string>;
|
|
75
|
+
private readonly subagentProfiles: SubagentProfile[];
|
|
76
|
+
private readonly segmentRules: SegmentRule[];
|
|
77
|
+
private state: RunState | undefined;
|
|
78
|
+
/** Set by {@link onRunStart}, consumed by {@link onAgentStart}: tells a user-announced run from one an extension started. */
|
|
79
|
+
private runAnnounced = false;
|
|
80
|
+
/** `true` between an agent loop's start and its `agent_end`. */
|
|
81
|
+
private loopActive = false;
|
|
82
|
+
/**
|
|
83
|
+
* The previous record, set aside because a new run began after its last
|
|
84
|
+
* loop ended but BEFORE its `agent_settled` reached us. pi clears its
|
|
85
|
+
* "run active" flag and only then awaits the `agent_settled` handlers, in
|
|
86
|
+
* extension load order; an extension loaded earlier (gentle-pi) can start
|
|
87
|
+
* the next run from its own handler. Whether that `agent_start` was a new
|
|
88
|
+
* run or an `agent.continue()` of the same one is only knowable at settle
|
|
89
|
+
* time — see {@link settleAll}.
|
|
90
|
+
*/
|
|
91
|
+
private closing: { state: RunState; at: number } | undefined;
|
|
92
|
+
|
|
93
|
+
constructor(options: WorkTrackerOptions) {
|
|
94
|
+
this.clock = options.clock;
|
|
95
|
+
this.interactiveTools = new Set(options.interactiveTools);
|
|
96
|
+
this.subagentProfiles = options.subagentProfiles;
|
|
97
|
+
this.segmentRules = options.segmentRules ?? [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* An agent loop actually started. pi announces a USER prompt with
|
|
102
|
+
* `before_agent_start` ({@link onRunStart}) and then this; a run an
|
|
103
|
+
* extension starts itself (`sendCustomMessage(..., { triggerTurn: true })`,
|
|
104
|
+
* how gentle-pi wakes the orchestrator when a background subagent
|
|
105
|
+
* finishes) only ever produces this one. Without it that whole run — its
|
|
106
|
+
* time, its cost, and the subagent spans it opens, which its children need
|
|
107
|
+
* to join — was never recorded.
|
|
108
|
+
*/
|
|
109
|
+
onAgentStart(): void {
|
|
110
|
+
if (this.runAnnounced) {
|
|
111
|
+
// The loop of the user prompt onRunStart just opened.
|
|
112
|
+
this.runAnnounced = false;
|
|
113
|
+
this.loopActive = true;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (this.loopActive) return;
|
|
117
|
+
this.openRun(EXTENSION_RUN_PROMPT, "extension");
|
|
118
|
+
this.runAnnounced = false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
onRunStart(prompt: string, trigger?: RunTrigger): void {
|
|
122
|
+
this.openRun(prompt, trigger);
|
|
123
|
+
this.runAnnounced = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private openRun(prompt: string, trigger: RunTrigger | undefined): void {
|
|
127
|
+
if (this.state && this.loopActive) {
|
|
128
|
+
this.state.runs++;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (this.state && !this.closing) {
|
|
132
|
+
// A record is open but its last loop already ended: set it aside and
|
|
133
|
+
// start a fresh one now. settleAll() decides whether that was right.
|
|
134
|
+
this.closing = { state: this.state, at: this.clock.now() };
|
|
135
|
+
this.state = undefined;
|
|
136
|
+
}
|
|
137
|
+
this.loopActive = true;
|
|
138
|
+
if (this.state) {
|
|
139
|
+
this.state.runs++;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.state = {
|
|
143
|
+
id: randomUUID(),
|
|
144
|
+
startedAt: this.clock.now(),
|
|
145
|
+
prompt,
|
|
146
|
+
...(trigger !== undefined ? { trigger } : {}),
|
|
147
|
+
runs: 1,
|
|
148
|
+
turns: 0,
|
|
149
|
+
tools: {},
|
|
150
|
+
usage: emptyUsage(),
|
|
151
|
+
costObserved: false,
|
|
152
|
+
status: "completed",
|
|
153
|
+
waitingSpans: [],
|
|
154
|
+
openToolWaits: new Map(),
|
|
155
|
+
subagents: [],
|
|
156
|
+
openSubagents: new Map(),
|
|
157
|
+
segmentSpans: new Map(),
|
|
158
|
+
openSegments: new Map(),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
onTurnEnd(usage: Partial<UsageTotals> | undefined): void {
|
|
163
|
+
if (!this.state) return;
|
|
164
|
+
this.state.turns++;
|
|
165
|
+
if (!usage) return;
|
|
166
|
+
this.state.usage.input += finiteOrZero(usage.input);
|
|
167
|
+
this.state.usage.output += finiteOrZero(usage.output);
|
|
168
|
+
this.state.usage.cacheRead += finiteOrZero(usage.cacheRead);
|
|
169
|
+
this.state.usage.cacheWrite += finiteOrZero(usage.cacheWrite);
|
|
170
|
+
this.state.usage.cost += finiteOrZero(usage.cost);
|
|
171
|
+
// A real, finite cost figure (even an explicit 0) counts as "measured";
|
|
172
|
+
// an absent/non-finite one never un-sets a prior turn's observation.
|
|
173
|
+
if (typeof usage.cost === "number" && Number.isFinite(usage.cost)) {
|
|
174
|
+
this.state.costObserved = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
onToolStart(toolCallId: string, toolName: string, args: Record<string, unknown> | undefined): void {
|
|
179
|
+
if (!this.state) return;
|
|
180
|
+
this.state.tools[toolName] = (this.state.tools[toolName] ?? 0) + 1;
|
|
181
|
+
|
|
182
|
+
const rule = this.segmentRules.find((candidate) => candidate.tool === toolName && candidate.pattern.test(segmentText(args)));
|
|
183
|
+
if (rule) {
|
|
184
|
+
const span: Interval = { start: this.clock.now(), end: undefined };
|
|
185
|
+
const spans = this.state.segmentSpans.get(rule.tag) ?? [];
|
|
186
|
+
spans.push(span);
|
|
187
|
+
this.state.segmentSpans.set(rule.tag, spans);
|
|
188
|
+
this.state.openSegments.set(toolCallId, span);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (this.interactiveTools.has(toolName)) {
|
|
192
|
+
const span: Interval = { start: this.clock.now(), end: undefined };
|
|
193
|
+
this.state.waitingSpans.push(span);
|
|
194
|
+
this.state.openToolWaits.set(toolCallId, span);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// SUBAGENT-REQ-001/005: a tool call opens a subagent span when its name
|
|
199
|
+
// matches ANY active profile's toolNames — generalised from the single
|
|
200
|
+
// hardcoded `subagentTool` string. `resolveToolProfile` resolves which
|
|
201
|
+
// SPECIFIC profile matched (for `readLaunchInfo`/attribution) without
|
|
202
|
+
// ever guessing when 2+ profiles share the same tool name (e.g.
|
|
203
|
+
// "subagent", registered by both the pi reference example and
|
|
204
|
+
// pi-subagents): the span still opens either way (a subagent tool call
|
|
205
|
+
// genuinely happened), with `profile` left undefined. C1: when
|
|
206
|
+
// genuinely ambiguous, agent/mode are read with `mergeAgreeingLaunchInfo`
|
|
207
|
+
// (kept only when every candidate that reports a value agrees) instead
|
|
208
|
+
// of `readLaunchInfo`'s first-defined-wins merge — this is purely
|
|
209
|
+
// descriptive (never money/join-affecting), but "agreeing" is still the
|
|
210
|
+
// more honest answer than silently picking one candidate's guess.
|
|
211
|
+
const { profile, candidates, ambiguous } = resolveToolProfile(this.subagentProfiles, toolName);
|
|
212
|
+
if (candidates.length > 0) {
|
|
213
|
+
const launch = ambiguous ? mergeAgreeingLaunchInfo(candidates, args) : readLaunchInfo(candidates, args);
|
|
214
|
+
this.state.openSubagents.set(toolCallId, {
|
|
215
|
+
toolCallId,
|
|
216
|
+
toolName,
|
|
217
|
+
agent: launch.agent ?? "unknown",
|
|
218
|
+
mode: launch.mode ?? "task",
|
|
219
|
+
profile: profile?.id,
|
|
220
|
+
start: this.clock.now(),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
onToolEnd(toolCallId: string, result: unknown): void {
|
|
226
|
+
if (!this.state) return;
|
|
227
|
+
|
|
228
|
+
const openSegment = this.state.openSegments.get(toolCallId);
|
|
229
|
+
if (openSegment) {
|
|
230
|
+
this.state.openSegments.delete(toolCallId);
|
|
231
|
+
openSegment.end = this.clock.now();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const openSubagent = this.state.openSubagents.get(toolCallId);
|
|
235
|
+
if (openSubagent) {
|
|
236
|
+
// Consumed exactly once: a second onToolEnd for the same toolCallId
|
|
237
|
+
// (should never happen from a well-behaved pi runtime) finds nothing
|
|
238
|
+
// here and falls through — the guard that keeps SUBAGENT-REQ-006's
|
|
239
|
+
// usage forwarding below from ever double-attributing the same call.
|
|
240
|
+
this.state.openSubagents.delete(toolCallId);
|
|
241
|
+
|
|
242
|
+
const { ambiguous, candidates } = resolveToolProfile(this.subagentProfiles, openSubagent.toolName);
|
|
243
|
+
// C1 (CRITICAL fix, SUBAGENT-REQ-005/006): a genuinely ambiguous
|
|
244
|
+
// match (2+ profiles registered this tool name, never told apart)
|
|
245
|
+
// must never forward `usage` or `taskId` — see
|
|
246
|
+
// `subagent-profile.ts#safeAmbiguousResultInfo`'s doc comment for
|
|
247
|
+
// why. The unambiguous case keeps the existing best-effort merge
|
|
248
|
+
// (a no-op merge when there is exactly one candidate).
|
|
249
|
+
const resultInfo = ambiguous ? safeAmbiguousResultInfo(candidates, result) : readResultInfo(candidates, result);
|
|
250
|
+
|
|
251
|
+
// SUBAGENT-REQ-006 (revised by C1): forwarded usage is recorded on
|
|
252
|
+
// the SPAN itself as `forwardedUsage`, never folded into this
|
|
253
|
+
// record's own `usage` totals here — `domain/task-view.ts#buildTasks`
|
|
254
|
+
// (ADR 0006: aggregation across spans/children stays in exactly one
|
|
255
|
+
// place) is the only place that adds it to a task's total, and only
|
|
256
|
+
// for a span whose profile has no joined child record already
|
|
257
|
+
// carrying this same cost through its own confirmed-marker ancestry
|
|
258
|
+
// join (the writer-admitted "configured profile with both a marker
|
|
259
|
+
// AND usage forwarding" hole — see `buildConfiguredProfile`'s doc
|
|
260
|
+
// comment). `costObserved` is still set here, eagerly: a real cost
|
|
261
|
+
// figure genuinely WAS observed by this call, whether or not this
|
|
262
|
+
// specific number ends up in the final sum later.
|
|
263
|
+
const usage = resultInfo.usage;
|
|
264
|
+
if (usage && typeof usage.cost === "number" && Number.isFinite(usage.cost)) {
|
|
265
|
+
this.state.costObserved = true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.state.subagents.push({
|
|
269
|
+
toolCallId: openSubagent.toolCallId,
|
|
270
|
+
agent: openSubagent.agent,
|
|
271
|
+
mode: openSubagent.mode,
|
|
272
|
+
...(resultInfo.taskId !== undefined ? { taskId: resultInfo.taskId } : {}),
|
|
273
|
+
...(openSubagent.profile !== undefined ? { profile: openSubagent.profile } : {}),
|
|
274
|
+
// Clamped to >= 0: a backward clock jump while the subagent was
|
|
275
|
+
// running must never produce a negative duration.
|
|
276
|
+
ms: Math.max(0, this.clock.now() - openSubagent.start),
|
|
277
|
+
...(usage !== undefined ? { forwardedUsage: usage } : {}),
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const openWaitingSpan = this.state.openToolWaits.get(toolCallId);
|
|
283
|
+
if (openWaitingSpan) {
|
|
284
|
+
this.state.openToolWaits.delete(toolCallId);
|
|
285
|
+
openWaitingSpan.end = this.clock.now();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
onUiPromptStart(_kind: string): void {
|
|
290
|
+
if (!this.state) return;
|
|
291
|
+
this.state.waitingSpans.push({ start: this.clock.now(), end: undefined });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
onUiPromptEnd(_kind: string): void {
|
|
295
|
+
if (!this.state) return;
|
|
296
|
+
// Prompts are sequential; close the most recently opened span (LIFO).
|
|
297
|
+
for (let i = this.state.waitingSpans.length - 1; i >= 0; i--) {
|
|
298
|
+
const span = this.state.waitingSpans[i];
|
|
299
|
+
if (span && span.end === undefined) {
|
|
300
|
+
span.end = this.clock.now();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
onRunEnd(messages: RunEndMessage[]): void {
|
|
307
|
+
this.loopActive = false;
|
|
308
|
+
if (!this.state) return;
|
|
309
|
+
const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant");
|
|
310
|
+
if (lastAssistant?.stopReason === "aborted") {
|
|
311
|
+
this.state.status = "aborted";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Single-record convenience over {@link settleAll}; production code uses `settleAll`. */
|
|
316
|
+
onSettled(): WorkRecordCore | undefined {
|
|
317
|
+
return this.settleAll()[0];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* `agent_settled` arrived. Normally closes the one open record. When a
|
|
322
|
+
* record was set aside (see {@link closing}):
|
|
323
|
+
* - a loop is still running → the `agent_start` was a genuinely NEW run
|
|
324
|
+
* that overtook this settle: close only the old record, at the instant
|
|
325
|
+
* the new run began, and leave the new one open;
|
|
326
|
+
* - no loop is running → it was an `agent.continue()` of the same run
|
|
327
|
+
* (retry, overflow recovery, queued message): fold it back, one record.
|
|
328
|
+
* A user-announced prompt is never folded — it keeps its own record.
|
|
329
|
+
*/
|
|
330
|
+
settleAll(): WorkRecordCore[] {
|
|
331
|
+
const now = this.clock.now();
|
|
332
|
+
const closing = this.closing;
|
|
333
|
+
this.closing = undefined;
|
|
334
|
+
if (closing) {
|
|
335
|
+
if (this.loopActive && this.state) {
|
|
336
|
+
return [this.buildRecordFrom(closing.state, closing.state.status, closing.at)];
|
|
337
|
+
}
|
|
338
|
+
const fresh = this.state;
|
|
339
|
+
this.state = undefined;
|
|
340
|
+
this.runAnnounced = false;
|
|
341
|
+
this.loopActive = false;
|
|
342
|
+
if (!fresh) return [this.buildRecordFrom(closing.state, closing.state.status, now)];
|
|
343
|
+
if (fresh.trigger === "extension") {
|
|
344
|
+
return [this.buildRecordFrom(mergeRunStates(closing.state, fresh), fresh.status !== "completed" ? fresh.status : closing.state.status, now)];
|
|
345
|
+
}
|
|
346
|
+
return [this.buildRecordFrom(closing.state, closing.state.status, closing.at), this.buildRecordFrom(fresh, fresh.status, now)];
|
|
347
|
+
}
|
|
348
|
+
// Settled with nothing set aside: whatever loop we believed active is over.
|
|
349
|
+
this.loopActive = false;
|
|
350
|
+
if (!this.state) return [];
|
|
351
|
+
const record = this.buildRecordFrom(this.state, this.state.status, now);
|
|
352
|
+
this.state = undefined;
|
|
353
|
+
this.runAnnounced = false;
|
|
354
|
+
return [record];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Single-record convenience over {@link shutdownAll}. */
|
|
358
|
+
onShutdown(): WorkRecordCore | undefined {
|
|
359
|
+
const all = this.shutdownAll();
|
|
360
|
+
return all[all.length - 1];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The session is going away: every open record is written, the running one as `interrupted`. */
|
|
364
|
+
shutdownAll(): WorkRecordCore[] {
|
|
365
|
+
const now = this.clock.now();
|
|
366
|
+
const records: WorkRecordCore[] = [];
|
|
367
|
+
if (this.closing && this.state) {
|
|
368
|
+
// ONE record, the same shape and id as the crash checkpoint (peek). Two
|
|
369
|
+
// separate records here double-billed: if the process died before the
|
|
370
|
+
// checkpoint was cleared, recovery re-appended the merged snapshot next
|
|
371
|
+
// to the new run's own record — overlapping cost under two ids, which
|
|
372
|
+
// no dedupe by id can see.
|
|
373
|
+
records.push(this.buildRecordFrom(mergeRunStates(this.closing.state, this.state), "interrupted", now));
|
|
374
|
+
} else if (this.closing) {
|
|
375
|
+
records.push(this.buildRecordFrom(this.closing.state, this.closing.state.status, this.closing.at));
|
|
376
|
+
} else if (this.state) {
|
|
377
|
+
records.push(this.buildRecordFrom(this.state, "interrupted", now));
|
|
378
|
+
}
|
|
379
|
+
this.closing = undefined;
|
|
380
|
+
this.state = undefined;
|
|
381
|
+
this.runAnnounced = false;
|
|
382
|
+
this.loopActive = false;
|
|
383
|
+
return records;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Returns what {@link onSettled}/{@link onShutdown} would produce right
|
|
388
|
+
* now, without mutating any state: open spans are truncated only in the
|
|
389
|
+
* returned snapshot, so the tracker keeps running unaffected and a later
|
|
390
|
+
* `peek` or the eventual settle still sees the spans' true open-ended
|
|
391
|
+
* state. `undefined` when idle. The returned `id` matches the id the
|
|
392
|
+
* eventual settled record will carry, since both are generated once
|
|
393
|
+
* in {@link onRunStart}.
|
|
394
|
+
*/
|
|
395
|
+
peek(status: WorkStatus): WorkRecordCore | undefined {
|
|
396
|
+
if (!this.state) return undefined;
|
|
397
|
+
// A record set aside (see `closing`) is nowhere on disk yet: fold it into
|
|
398
|
+
// the snapshot so a crash before the settle cannot lose its time and
|
|
399
|
+
// cost. The snapshot keeps the set-aside record's id.
|
|
400
|
+
const state = this.closing ? mergeRunStates(this.closing.state, this.state) : this.state;
|
|
401
|
+
return this.buildRecordFrom(state, status, this.clock.now());
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private buildRecordFrom(state: RunState, status: WorkStatus, settledAt: number): WorkRecordCore {
|
|
405
|
+
// Clamped to >= 0: a backward clock jump (system clock adjustment, NTP
|
|
406
|
+
// correction) must never produce a negative duration.
|
|
407
|
+
const wallMs = Math.max(0, settledAt - state.startedAt);
|
|
408
|
+
|
|
409
|
+
const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
|
|
410
|
+
const waitingMs = Math.max(0, unionMs(clampIntervals(closedSpans, state.startedAt, settledAt)));
|
|
411
|
+
const workMs = Math.max(0, wallMs - waitingMs);
|
|
412
|
+
|
|
413
|
+
const segmentEntries: Array<[string, number]> = [];
|
|
414
|
+
for (const [tag, spans] of state.segmentSpans) {
|
|
415
|
+
const closedTagSpans = spans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
|
|
416
|
+
const tagMs = Math.max(0, unionMs(clampIntervals(closedTagSpans, state.startedAt, settledAt)));
|
|
417
|
+
if (tagMs > 0) {
|
|
418
|
+
segmentEntries.push([tag, tagMs]);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// Built via Object.fromEntries (never `segments[tag] = ...`) so a tag
|
|
422
|
+
// such as `__proto__` becomes an own data property instead of silently
|
|
423
|
+
// repointing the object's prototype.
|
|
424
|
+
const segments = Object.fromEntries(segmentEntries);
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
schema: WORK_RECORD_SCHEMA,
|
|
428
|
+
id: state.id,
|
|
429
|
+
prompt: state.prompt,
|
|
430
|
+
...(state.trigger !== undefined ? { trigger: state.trigger } : {}),
|
|
431
|
+
startedAt: new Date(state.startedAt).toISOString(),
|
|
432
|
+
settledAt: new Date(settledAt).toISOString(),
|
|
433
|
+
wallMs,
|
|
434
|
+
waitingMs,
|
|
435
|
+
workMs,
|
|
436
|
+
runs: state.runs,
|
|
437
|
+
turns: state.turns,
|
|
438
|
+
tools: state.tools,
|
|
439
|
+
subagents: state.subagents,
|
|
440
|
+
segments,
|
|
441
|
+
usage: state.usage,
|
|
442
|
+
...(state.costObserved ? { costObserved: true as const } : {}),
|
|
443
|
+
status,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Text to match a {@link SegmentRule} pattern against: the `command` string arg when present, else the whole args object as JSON. */
|
|
449
|
+
function segmentText(args: Record<string, unknown> | undefined): string {
|
|
450
|
+
const command = args?.["command"];
|
|
451
|
+
return typeof command === "string" ? command : JSON.stringify(args ?? {});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
/** Fold a continuation's counters back into the record it continued. Additive only: nothing is ever dropped. */
|
|
456
|
+
function mergeRunStates(base: RunState, continuation: RunState): RunState {
|
|
457
|
+
const tools: Record<string, number> = { ...base.tools };
|
|
458
|
+
for (const [name, count] of Object.entries(continuation.tools)) {
|
|
459
|
+
tools[name] = (tools[name] ?? 0) + count;
|
|
460
|
+
}
|
|
461
|
+
const segmentSpans = new Map(base.segmentSpans);
|
|
462
|
+
for (const [tag, spans] of continuation.segmentSpans) {
|
|
463
|
+
segmentSpans.set(tag, [...(segmentSpans.get(tag) ?? []), ...spans]);
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
...base,
|
|
467
|
+
runs: base.runs + continuation.runs,
|
|
468
|
+
turns: base.turns + continuation.turns,
|
|
469
|
+
tools,
|
|
470
|
+
usage: {
|
|
471
|
+
input: base.usage.input + continuation.usage.input,
|
|
472
|
+
output: base.usage.output + continuation.usage.output,
|
|
473
|
+
cacheRead: base.usage.cacheRead + continuation.usage.cacheRead,
|
|
474
|
+
cacheWrite: base.usage.cacheWrite + continuation.usage.cacheWrite,
|
|
475
|
+
cost: base.usage.cost + continuation.usage.cost,
|
|
476
|
+
},
|
|
477
|
+
costObserved: base.costObserved || continuation.costObserved,
|
|
478
|
+
waitingSpans: [...base.waitingSpans, ...continuation.waitingSpans],
|
|
479
|
+
openToolWaits: new Map([...base.openToolWaits, ...continuation.openToolWaits]),
|
|
480
|
+
subagents: [...base.subagents, ...continuation.subagents],
|
|
481
|
+
openSubagents: new Map([...base.openSubagents, ...continuation.openSubagents]),
|
|
482
|
+
segmentSpans,
|
|
483
|
+
openSegments: new Map([...base.openSegments, ...continuation.openSegments]),
|
|
484
|
+
};
|
|
485
|
+
}
|