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,526 @@
|
|
|
1
|
+
import { unionMs } from "./intervals.ts";
|
|
2
|
+
import { emptyUsage, finiteOrZero } from "./work-record.ts";
|
|
3
|
+
import type { UsageTotals, WorkRecord, WorkStatus } from "./work-record.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One orchestrator run plus every subagent it spawned, with `wallMs`
|
|
7
|
+
* recomputed as the union of the orchestrator's span and each child's span
|
|
8
|
+
* — never their sum — because background children keep running in parallel
|
|
9
|
+
* after the orchestrator settles.
|
|
10
|
+
*/
|
|
11
|
+
export interface TaskView {
|
|
12
|
+
id: string;
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
project: string;
|
|
15
|
+
prompt: string;
|
|
16
|
+
startedAt: string;
|
|
17
|
+
endedAt: string;
|
|
18
|
+
wallMs: number;
|
|
19
|
+
waitingMs: number;
|
|
20
|
+
workMs: number;
|
|
21
|
+
status: WorkStatus;
|
|
22
|
+
orchestrator: WorkRecord;
|
|
23
|
+
subagents: WorkRecord[];
|
|
24
|
+
usage: UsageTotals;
|
|
25
|
+
/** Who this task is billed to, from the orchestrator record only — subagent children do not carry their own. */
|
|
26
|
+
client?: string;
|
|
27
|
+
/** pi's session display name, from the orchestrator record. */
|
|
28
|
+
sessionName?: string;
|
|
29
|
+
/** Non-default session directory, from the orchestrator record. See `WorkRecordMetadata.sessionDir`. Local-only today — no hub field yet. */
|
|
30
|
+
sessionDir?: string;
|
|
31
|
+
/** Hub client record id, from the orchestrator record only. See `domain/work-target.ts`. */
|
|
32
|
+
clientId?: string;
|
|
33
|
+
/** Hub client display name, from the orchestrator record only. */
|
|
34
|
+
clientName?: string;
|
|
35
|
+
/** Hub project record id, from the orchestrator record only. */
|
|
36
|
+
projectId?: string;
|
|
37
|
+
/** Hub project display name, from the orchestrator record only. */
|
|
38
|
+
projectName?: string;
|
|
39
|
+
/** Hub task record id, from the orchestrator record only. See `domain/work-target.ts#HubTask`. */
|
|
40
|
+
hubTaskId?: string;
|
|
41
|
+
/** Hub task title, from the orchestrator record only. */
|
|
42
|
+
hubTaskTitle?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Per-tag total milliseconds across the orchestrator and every subagent,
|
|
45
|
+
* summed rather than unioned: unlike `wallMs`, segment intervals are not
|
|
46
|
+
* persisted on disk, so once a record settles its per-tag total is all
|
|
47
|
+
* that remains, and there is nothing left to union across records.
|
|
48
|
+
*/
|
|
49
|
+
segments: Record<string, number>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One or more tasks grouped by their pi session, with the same union rule. */
|
|
53
|
+
export interface SessionView {
|
|
54
|
+
sessionId: string;
|
|
55
|
+
project: string;
|
|
56
|
+
startedAt: string;
|
|
57
|
+
endedAt: string;
|
|
58
|
+
wallMs: number;
|
|
59
|
+
waitingMs: number;
|
|
60
|
+
workMs: number;
|
|
61
|
+
tasks: TaskView[];
|
|
62
|
+
usage: UsageTotals;
|
|
63
|
+
/** Per-tag total milliseconds summed across the session's tasks. See {@link TaskView.segments}. */
|
|
64
|
+
segments: Record<string, number>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toMs(iso: string): number {
|
|
68
|
+
return Date.parse(iso);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sum per-tag milliseconds across several segment maps (older records
|
|
73
|
+
* without one count as `{}`). Accumulated in a `Map`, then emitted via
|
|
74
|
+
* `Object.fromEntries` (never `result[tag] = ...` on a plain object) so a
|
|
75
|
+
* tag from a hand-edited worklog line named `__proto__` or `constructor`
|
|
76
|
+
* becomes an own data property with the right total instead of silently
|
|
77
|
+
* reading (and arithmetically corrupting) an inherited `Object.prototype`
|
|
78
|
+
* value. Mirrors `work-tracker.ts`'s own segment-building convention.
|
|
79
|
+
*/
|
|
80
|
+
function sumSegments(segmentMaps: Array<Record<string, number> | undefined>): Record<string, number> {
|
|
81
|
+
const totals = new Map<string, number>();
|
|
82
|
+
for (const segments of segmentMaps) {
|
|
83
|
+
for (const [tag, ms] of Object.entries(segments ?? {})) {
|
|
84
|
+
totals.set(tag, (totals.get(tag) ?? 0) + ms);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return Object.fromEntries(totals);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Sum several {@link UsageTotals}, tolerating a missing entry (a record
|
|
92
|
+
* without a `usage` field) and missing or non-finite numeric fields on an
|
|
93
|
+
* entry — both treated as zero rather than corrupting the sum with
|
|
94
|
+
* `undefined`/`NaN`.
|
|
95
|
+
*/
|
|
96
|
+
export function sumUsage(totals: Array<Partial<UsageTotals> | undefined>): UsageTotals {
|
|
97
|
+
const usage = emptyUsage();
|
|
98
|
+
for (const total of totals) {
|
|
99
|
+
usage.input += finiteOrZero(total?.input);
|
|
100
|
+
usage.output += finiteOrZero(total?.output);
|
|
101
|
+
usage.cacheRead += finiteOrZero(total?.cacheRead);
|
|
102
|
+
usage.cacheWrite += finiteOrZero(total?.cacheWrite);
|
|
103
|
+
usage.cost += finiteOrZero(total?.cost);
|
|
104
|
+
}
|
|
105
|
+
return usage;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A record is only eligible to anchor a new task when it is a *confirmed*
|
|
110
|
+
* orchestrator: `role === "orchestrator"` and not flagged `uncertain`
|
|
111
|
+
* (ADR 0022). An uncertain record is never dropped — see
|
|
112
|
+
* {@link uncertainRecords} — but it never anchors a task locally and is
|
|
113
|
+
* therefore never synced as one either (SUBAGENT-REQ-014), since sync
|
|
114
|
+
* (`adapters/sync-runner.ts`) uploads exactly what {@link buildTasks}
|
|
115
|
+
* produces.
|
|
116
|
+
*/
|
|
117
|
+
function isConfirmedOrchestrator(record: WorkRecord): boolean {
|
|
118
|
+
return record.role === "orchestrator" && record.roleConfidence !== "uncertain";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** How long after a record settled a child it launched may still start and be joined to it by span evidence (see {@link matchChildren}). */
|
|
122
|
+
const LATE_CHILD_GRACE_MS = 5000;
|
|
123
|
+
|
|
124
|
+
/** `true` when `orchestrator` opened more subagent spans of `child`'s profile than it has been given children for. A missing profile on either side matches any. */
|
|
125
|
+
function hasUnclaimedSpan(orchestrator: WorkRecord, child: WorkRecord, alreadyAssigned: WorkRecord[]): boolean {
|
|
126
|
+
const sameKind = (profile: string | undefined): boolean => child.profile === undefined || profile === undefined || profile === child.profile;
|
|
127
|
+
const spans = orchestrator.subagents.filter((span) => sameKind(span.profile)).length;
|
|
128
|
+
const claimed = alreadyAssigned.filter((other) => sameKind(other.profile)).length;
|
|
129
|
+
return spans > claimed;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A pid means nothing across machines. Only decidable when both records name theirs (`machine` is written when a hub is configured). */
|
|
133
|
+
function differentMachines(a: WorkRecord, b: WorkRecord): boolean {
|
|
134
|
+
return a.machine !== undefined && b.machine !== undefined && a.machine !== b.machine;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The same record can be in the log twice under one id: written at settle,
|
|
139
|
+
* then written again by crash recovery because the process died before its
|
|
140
|
+
* checkpoint was cleared. Count it once, keeping the most complete copy —
|
|
141
|
+
* the later `settledAt` (a checkpoint taken while a newer run was folded in
|
|
142
|
+
* is later AND larger than the settled copy; a stale one is earlier).
|
|
143
|
+
*/
|
|
144
|
+
/** Later `settledAt` wins; on a tie the copy carrying more cost, then more wall time — never insertion order. */
|
|
145
|
+
function moreComplete(candidate: WorkRecord, seen: WorkRecord): boolean {
|
|
146
|
+
const byEnd = toMs(candidate.settledAt) - toMs(seen.settledAt);
|
|
147
|
+
if (byEnd !== 0) return byEnd > 0;
|
|
148
|
+
const byCost = finiteOrZero(candidate.usage?.cost) - finiteOrZero(seen.usage?.cost);
|
|
149
|
+
if (byCost !== 0) return byCost > 0;
|
|
150
|
+
return candidate.wallMs > seen.wallMs;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function dedupeById(records: WorkRecord[]): WorkRecord[] {
|
|
154
|
+
const byId = new Map<string, WorkRecord>();
|
|
155
|
+
for (const record of records) {
|
|
156
|
+
const seen = byId.get(record.id);
|
|
157
|
+
if (!seen || moreComplete(record, seen)) byId.set(record.id, record);
|
|
158
|
+
}
|
|
159
|
+
if (byId.size === records.length) return records;
|
|
160
|
+
const kept = new Set(byId.values());
|
|
161
|
+
return records.filter((record) => kept.delete(record));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Second pass, only for a child no orchestrator window contains: join it to
|
|
166
|
+
* the most recent record of the very process that launched it, as PROVEN by
|
|
167
|
+
* its `orchestratorRef` (written at child start from a live registry entry
|
|
168
|
+
* whose OS start time was checked — never inferred here). `ref.startedAt`
|
|
169
|
+
* is when that process last REGISTERED, which `/new`, `/resume`, `/fork` and
|
|
170
|
+
* `/reload` re-stamp: a parent record older than the last re-registration is
|
|
171
|
+
* therefore not eligible. That only ever misses a rescue (the child stays an
|
|
172
|
+
* orphan, as before); it can never produce a wrong join.
|
|
173
|
+
*
|
|
174
|
+
* Why a child can start outside every window: its parent's run was never
|
|
175
|
+
* recorded (a run an extension started, before `WorkTracker.onAgentStart`
|
|
176
|
+
* existed), or the parent crashed before settling. Without this pass that
|
|
177
|
+
* child's time and cost stay in the log forever and never reach a task.
|
|
178
|
+
*
|
|
179
|
+
* Why this is an identity join and not the time-only join SUBAGENT-REQ-007
|
|
180
|
+
* forbids: a candidate must carry the referenced pid AND have started inside
|
|
181
|
+
* `[ref.startedAt, child.startedAt]`. The referenced process was alive at
|
|
182
|
+
* both ends of that interval, and the OS cannot hand a live process's pid to
|
|
183
|
+
* another one, so any record with that pid in that interval was written by
|
|
184
|
+
* that exact process. A record older than `ref.startedAt` (a previous owner
|
|
185
|
+
* of a reused pid) or newer than the child is never eligible; a child with
|
|
186
|
+
* no `orchestratorRef` is never rescued at all.
|
|
187
|
+
*/
|
|
188
|
+
function rescueByParentIdentity(child: WorkRecord, orchestrators: WorkRecord[], assigned: Map<string, WorkRecord[]>): WorkRecord | undefined {
|
|
189
|
+
const ref = child.orchestratorRef;
|
|
190
|
+
if (!ref) return undefined;
|
|
191
|
+
const processStart = toMs(ref.startedAt);
|
|
192
|
+
const childStart = toMs(child.startedAt);
|
|
193
|
+
if (!Number.isFinite(processStart) || !Number.isFinite(childStart)) return undefined;
|
|
194
|
+
|
|
195
|
+
let best: WorkRecord | undefined;
|
|
196
|
+
let bestStart = Number.NEGATIVE_INFINITY;
|
|
197
|
+
let bestLaunched = false;
|
|
198
|
+
for (const orchestrator of orchestrators) {
|
|
199
|
+
if (orchestrator.pid !== ref.pid) continue;
|
|
200
|
+
// A pid is only unique on ONE machine: a worklog shared between two
|
|
201
|
+
// (a synced KANKAKU_DIR) must never join across them.
|
|
202
|
+
if (differentMachines(orchestrator, child)) continue;
|
|
203
|
+
const start = toMs(orchestrator.startedAt);
|
|
204
|
+
if (!(start >= processStart && start <= childStart)) continue;
|
|
205
|
+
// Among the proven process's records, one that actually launched a
|
|
206
|
+
// subagent of this kind beats a newer one that launched nothing.
|
|
207
|
+
const launched = hasUnclaimedSpan(orchestrator, child, assigned.get(orchestrator.id) ?? []);
|
|
208
|
+
if (best === undefined || (launched && !bestLaunched) || (launched === bestLaunched && start > bestStart)) {
|
|
209
|
+
best = orchestrator;
|
|
210
|
+
bestStart = start;
|
|
211
|
+
bestLaunched = launched;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return best;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Match every subagent record to the orchestrator record it belongs to:
|
|
219
|
+
* `parentPid === orchestrator.pid` and the child's `startedAt` falls inside
|
|
220
|
+
* the orchestrator's `[startedAt, settledAt]` window. `project` is a
|
|
221
|
+
* *hint*, never a hard filter (ADR 0021, SUBAGENT-REQ-008): among several
|
|
222
|
+
* candidates matching on pid/time (a reused pid, or a genuine cross-project
|
|
223
|
+
* match), a same-project one is always preferred; a cross-project candidate
|
|
224
|
+
* is only ever eligible here because its record already lives in the same
|
|
225
|
+
* `worklog.jsonl` this array was read from — F1's write-side routing
|
|
226
|
+
* (`extension.ts`, `domain/ancestry-match.ts#resolveOrchestratorRef`)
|
|
227
|
+
* reunites a verified cross-worktree child with its orchestrator by writing
|
|
228
|
+
* straight into the orchestrator's own directory, so no later registry
|
|
229
|
+
* lookup is ever needed here — this function itself does no registry
|
|
230
|
+
* lookups and stays pure. Each child is assigned at most once; unmatched
|
|
231
|
+
* children are orphans.
|
|
232
|
+
*/
|
|
233
|
+
function matchChildren(allRecords: WorkRecord[]): {
|
|
234
|
+
childrenByOrchestratorId: Map<string, WorkRecord[]>;
|
|
235
|
+
orphans: WorkRecord[];
|
|
236
|
+
} {
|
|
237
|
+
const records = dedupeById(allRecords);
|
|
238
|
+
const orchestrators = records.filter(isConfirmedOrchestrator);
|
|
239
|
+
const subagents = records.filter((record) => record.role === "subagent").sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
|
|
240
|
+
|
|
241
|
+
const childrenByOrchestratorId = new Map<string, WorkRecord[]>();
|
|
242
|
+
for (const orchestrator of orchestrators) {
|
|
243
|
+
childrenByOrchestratorId.set(orchestrator.id, []);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const orphans: WorkRecord[] = [];
|
|
247
|
+
|
|
248
|
+
for (const child of subagents) {
|
|
249
|
+
const childStart = toMs(child.startedAt);
|
|
250
|
+
let best: WorkRecord | undefined;
|
|
251
|
+
let bestStart = Number.NEGATIVE_INFINITY;
|
|
252
|
+
let bestSameProject = false;
|
|
253
|
+
let bestLaunched = false;
|
|
254
|
+
|
|
255
|
+
for (const orchestrator of orchestrators) {
|
|
256
|
+
if (orchestrator.pid !== child.parentPid) continue;
|
|
257
|
+
if (differentMachines(orchestrator, child)) continue;
|
|
258
|
+
|
|
259
|
+
const parentStart = toMs(orchestrator.startedAt);
|
|
260
|
+
const parentEnd = toMs(orchestrator.settledAt);
|
|
261
|
+
if (childStart < parentStart) continue;
|
|
262
|
+
// Evidence that THIS record launched a subagent of the child's kind
|
|
263
|
+
// and has not been given a child for every such span yet.
|
|
264
|
+
const launched = hasUnclaimedSpan(orchestrator, child, childrenByOrchestratorId.get(orchestrator.id)!);
|
|
265
|
+
// A record can be closed the instant the next run begins (see
|
|
266
|
+
// WorkTracker.settleAll): its background child then appears a moment
|
|
267
|
+
// AFTER it settled, inside the next record's window. Only a record with
|
|
268
|
+
// span evidence gets this short grace — never time alone.
|
|
269
|
+
const inWindow = childStart <= parentEnd;
|
|
270
|
+
if (!inWindow && !(launched && childStart - parentEnd <= LATE_CHILD_GRACE_MS)) continue;
|
|
271
|
+
|
|
272
|
+
const sameProject = orchestrator.project === child.project;
|
|
273
|
+
const better =
|
|
274
|
+
best === undefined ||
|
|
275
|
+
(launched && !bestLaunched) ||
|
|
276
|
+
(launched === bestLaunched && sameProject && !bestSameProject) ||
|
|
277
|
+
(launched === bestLaunched && sameProject === bestSameProject && parentStart > bestStart);
|
|
278
|
+
if (better) {
|
|
279
|
+
best = orchestrator;
|
|
280
|
+
bestStart = parentStart;
|
|
281
|
+
bestSameProject = sameProject;
|
|
282
|
+
bestLaunched = launched;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
best ??= rescueByParentIdentity(child, orchestrators, childrenByOrchestratorId);
|
|
287
|
+
|
|
288
|
+
if (best) {
|
|
289
|
+
childrenByOrchestratorId.get(best.id)!.push(child);
|
|
290
|
+
} else {
|
|
291
|
+
orphans.push(child);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { childrenByOrchestratorId, orphans };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* C1 (CRITICAL fix): the reconciliation ADR 0006 keeps in exactly this one
|
|
300
|
+
* place — the only spot that decides whether a subagent span's
|
|
301
|
+
* `forwardedUsage` (`domain/work-tracker.ts#onToolEnd`, SUBAGENT-REQ-006
|
|
302
|
+
* revised) actually gets added to this task's total, or is assumed already
|
|
303
|
+
* covered by a joined child record's own `usage`.
|
|
304
|
+
*
|
|
305
|
+
* There is no explicit per-span correlation id today (same limitation
|
|
306
|
+
* `computeSubagentLinkage` in `domain/hub-entry.ts` already documents), so
|
|
307
|
+
* this reconciles at PROFILE granularity, not per span: a span's forwarded
|
|
308
|
+
* usage is excluded only when this task also has at least one joined child
|
|
309
|
+
* record confirmed by the SAME profile (`WorkRecord.profile`) — i.e. a
|
|
310
|
+
* profile that can BOTH forward usage AND be ancestry-joined for the same
|
|
311
|
+
* kind of call (today, only a user-configured profile that declares both a
|
|
312
|
+
* child-env marker and usage forwarding — see `buildConfiguredProfile`'s
|
|
313
|
+
* doc comment; none of the built-ins can be both at once, so their spans'
|
|
314
|
+
* forwarded usage is never excluded here). A span whose profile is
|
|
315
|
+
* `undefined` (an ambiguous match — see `safeAmbiguousResultInfo`) never
|
|
316
|
+
* carries `forwardedUsage` in the first place, so it never reaches this
|
|
317
|
+
* function at all.
|
|
318
|
+
*/
|
|
319
|
+
function unjoinedForwardedUsage(orchestrator: WorkRecord, subagents: WorkRecord[]): Array<Partial<UsageTotals>> {
|
|
320
|
+
// Only a DIRECT child can be the process behind one of this record's
|
|
321
|
+
// spans; a grandchild joined by {@link rescueByParentIdentity} never is.
|
|
322
|
+
// Deliberately NOT narrowed to children that started inside the window:
|
|
323
|
+
// gentle-pi's child process starts ~90 ms after `subagent_run` returns,
|
|
324
|
+
// often after this record settled, and it IS that span's child — narrowing
|
|
325
|
+
// billed it twice. The other error (a later, unrelated launch of the same
|
|
326
|
+
// profile cancelling this span's forwarded usage) under-bills instead,
|
|
327
|
+
// which is the safer side to be wrong on.
|
|
328
|
+
const launchedHere = subagents.filter((child) => child.parentPid === orchestrator.pid);
|
|
329
|
+
// One joined child accounts for ONE span of its profile, not for all of
|
|
330
|
+
// them: three spans and one child record means two children never wrote a
|
|
331
|
+
// record, and their forwarded usage is the only trace of their cost.
|
|
332
|
+
const remaining = new Map<string, number>();
|
|
333
|
+
for (const child of launchedHere) {
|
|
334
|
+
if (child.profile !== undefined) remaining.set(child.profile, (remaining.get(child.profile) ?? 0) + 1);
|
|
335
|
+
}
|
|
336
|
+
const kept: Array<Partial<UsageTotals>> = [];
|
|
337
|
+
for (const span of orchestrator.subagents) {
|
|
338
|
+
if (span.forwardedUsage === undefined) continue;
|
|
339
|
+
const left = span.profile !== undefined ? (remaining.get(span.profile) ?? 0) : 0;
|
|
340
|
+
if (left > 0) {
|
|
341
|
+
remaining.set(span.profile!, left - 1);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
kept.push(span.forwardedUsage);
|
|
345
|
+
}
|
|
346
|
+
return kept;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function buildTaskView(orchestrator: WorkRecord, subagents: WorkRecord[]): TaskView {
|
|
350
|
+
const parentStart = toMs(orchestrator.startedAt);
|
|
351
|
+
const parentEnd = toMs(orchestrator.settledAt);
|
|
352
|
+
|
|
353
|
+
const intervals = [
|
|
354
|
+
{ start: parentStart, end: parentEnd },
|
|
355
|
+
...subagents.map((child) => ({ start: toMs(child.startedAt), end: toMs(child.settledAt) })),
|
|
356
|
+
];
|
|
357
|
+
const wallMs = unionMs(intervals);
|
|
358
|
+
|
|
359
|
+
const endedAtMs = Math.max(parentEnd, ...subagents.map((child) => toMs(child.settledAt)));
|
|
360
|
+
const waitingMs = orchestrator.waitingMs;
|
|
361
|
+
const workMs = wallMs - waitingMs;
|
|
362
|
+
const usage = sumUsage([orchestrator.usage, ...subagents.map((child) => child.usage), ...unjoinedForwardedUsage(orchestrator, subagents)]);
|
|
363
|
+
const segments = sumSegments([orchestrator.segments, ...subagents.map((child) => child.segments)]);
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
id: orchestrator.id,
|
|
367
|
+
...(orchestrator.sessionId !== undefined ? { sessionId: orchestrator.sessionId } : {}),
|
|
368
|
+
...(orchestrator.client !== undefined ? { client: orchestrator.client } : {}),
|
|
369
|
+
...(orchestrator.sessionName !== undefined ? { sessionName: orchestrator.sessionName } : {}),
|
|
370
|
+
...(orchestrator.sessionDir !== undefined ? { sessionDir: orchestrator.sessionDir } : {}),
|
|
371
|
+
...(orchestrator.clientId !== undefined ? { clientId: orchestrator.clientId } : {}),
|
|
372
|
+
...(orchestrator.clientName !== undefined ? { clientName: orchestrator.clientName } : {}),
|
|
373
|
+
...(orchestrator.projectId !== undefined ? { projectId: orchestrator.projectId } : {}),
|
|
374
|
+
...(orchestrator.projectName !== undefined ? { projectName: orchestrator.projectName } : {}),
|
|
375
|
+
...(orchestrator.hubTaskId !== undefined ? { hubTaskId: orchestrator.hubTaskId } : {}),
|
|
376
|
+
...(orchestrator.hubTaskTitle !== undefined ? { hubTaskTitle: orchestrator.hubTaskTitle } : {}),
|
|
377
|
+
project: orchestrator.project,
|
|
378
|
+
prompt: orchestrator.prompt,
|
|
379
|
+
startedAt: orchestrator.startedAt,
|
|
380
|
+
endedAt: new Date(endedAtMs).toISOString(),
|
|
381
|
+
wallMs,
|
|
382
|
+
waitingMs,
|
|
383
|
+
workMs,
|
|
384
|
+
status: orchestrator.status,
|
|
385
|
+
orchestrator,
|
|
386
|
+
subagents,
|
|
387
|
+
usage,
|
|
388
|
+
segments,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Build one {@link TaskView} per *confirmed* orchestrator record, sorted by
|
|
394
|
+
* `startedAt`. An orchestrator-role record flagged `roleConfidence:
|
|
395
|
+
* "uncertain"` (ADR 0022) never anchors a task here — see
|
|
396
|
+
* {@link isConfirmedOrchestrator} and {@link uncertainRecords}.
|
|
397
|
+
*/
|
|
398
|
+
export function buildTasks(allRecords: WorkRecord[]): TaskView[] {
|
|
399
|
+
const records = dedupeById(allRecords);
|
|
400
|
+
const orchestrators = records.filter(isConfirmedOrchestrator).sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
|
|
401
|
+
|
|
402
|
+
const { childrenByOrchestratorId } = matchChildren(records);
|
|
403
|
+
|
|
404
|
+
return orchestrators.map((orchestrator) => buildTaskView(orchestrator, childrenByOrchestratorId.get(orchestrator.id) ?? []));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Subagent records that could not be matched to any orchestrator record. */
|
|
408
|
+
export function orphanSubagents(records: WorkRecord[]): WorkRecord[] {
|
|
409
|
+
return matchChildren(records).orphans;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Orchestrator-role records that could not be positively proven top-level
|
|
414
|
+
* (ADR 0022's "uncertain" state): no recognised child-env-marker matched,
|
|
415
|
+
* but a live tracked ancestor process was found. Never counted as a new
|
|
416
|
+
* task ({@link buildTasks} excludes them) and never synced as one, but
|
|
417
|
+
* never dropped either — surfaced here so `/kankaku doctor` and the report
|
|
418
|
+
* hint (SUBAGENT-REQ-017) can make the gap visible instead of silent.
|
|
419
|
+
*/
|
|
420
|
+
export function uncertainRecords(records: WorkRecord[]): WorkRecord[] {
|
|
421
|
+
return records.filter((record) => record.role === "orchestrator" && record.roleConfidence === "uncertain");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** One cluster of confirmed-orchestrator records sharing a pid with overlapping `[startedAt, settledAt]` windows — see {@link detectSameProcessOverlaps}. */
|
|
425
|
+
export interface SameProcessOverlap {
|
|
426
|
+
pid: number;
|
|
427
|
+
recordIds: string[];
|
|
428
|
+
/** The union (never the sum) of every clustered record's own wall-time window, via `unionMs`. */
|
|
429
|
+
unionedWallMs: number;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* SUBAGENT-REQ-015: flag confirmed-orchestrator records that share an OS
|
|
434
|
+
* pid AND overlap in time — a pattern that should never occur if pi only
|
|
435
|
+
* ever runs one session per process at a time, but is the observable
|
|
436
|
+
* signature an in-process nested session mechanism (if one existed) would
|
|
437
|
+
* leave behind: two independent `WorkTracker` records, same pid, running
|
|
438
|
+
* concurrently. Purely informational (`/kankaku doctor` reads this, see
|
|
439
|
+
* `adapters/kankaku-command.ts`) — it never changes {@link buildTasks}'
|
|
440
|
+
* own per-task `wallMs`, so a plain pi run or today's gentle-pi setup is
|
|
441
|
+
* completely unaffected; each flagged record still anchors its own
|
|
442
|
+
* `TaskView` exactly as before. `unionedWallMs` is provided (via the
|
|
443
|
+
* existing `unionMs` primitive, ADR 0006 — the interval-union rule stays
|
|
444
|
+
* in exactly this one place) so a human reading the doctor report can see
|
|
445
|
+
* what the corrected total would be, without kankaku silently changing any
|
|
446
|
+
* number on its own.
|
|
447
|
+
*/
|
|
448
|
+
export function detectSameProcessOverlaps(records: WorkRecord[]): SameProcessOverlap[] {
|
|
449
|
+
const orchestratorsByPid = new Map<number, WorkRecord[]>();
|
|
450
|
+
for (const record of records.filter(isConfirmedOrchestrator)) {
|
|
451
|
+
const group = orchestratorsByPid.get(record.pid);
|
|
452
|
+
if (group) {
|
|
453
|
+
group.push(record);
|
|
454
|
+
} else {
|
|
455
|
+
orchestratorsByPid.set(record.pid, [record]);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const overlaps: SameProcessOverlap[] = [];
|
|
460
|
+
|
|
461
|
+
for (const [pid, group] of orchestratorsByPid) {
|
|
462
|
+
if (group.length < 2) continue;
|
|
463
|
+
|
|
464
|
+
const intervals = group.map((record) => ({ start: toMs(record.startedAt), end: toMs(record.settledAt) }));
|
|
465
|
+
const anyOverlap = intervals.some((a, i) => intervals.some((b, j) => i !== j && a.start < b.end && b.start < a.end));
|
|
466
|
+
if (!anyOverlap) continue;
|
|
467
|
+
|
|
468
|
+
overlaps.push({
|
|
469
|
+
pid,
|
|
470
|
+
recordIds: group.map((record) => record.id),
|
|
471
|
+
unionedWallMs: unionMs(intervals),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return overlaps;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Group tasks by `sessionId` (tasks without one fall under `"unknown"`).
|
|
480
|
+
* `wallMs` is the union of every interval — orchestrator and subagent alike
|
|
481
|
+
* — across all of the session's tasks, not a sum of per-task `wallMs`.
|
|
482
|
+
*/
|
|
483
|
+
export function buildSessions(tasks: TaskView[]): SessionView[] {
|
|
484
|
+
const groups = new Map<string, TaskView[]>();
|
|
485
|
+
for (const task of tasks) {
|
|
486
|
+
const key = task.sessionId ?? "unknown";
|
|
487
|
+
const list = groups.get(key);
|
|
488
|
+
if (list) {
|
|
489
|
+
list.push(task);
|
|
490
|
+
} else {
|
|
491
|
+
groups.set(key, [task]);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const sessions: SessionView[] = [];
|
|
496
|
+
|
|
497
|
+
for (const [sessionId, sessionTasks] of groups) {
|
|
498
|
+
const intervals = sessionTasks.flatMap((task) => [
|
|
499
|
+
{ start: toMs(task.orchestrator.startedAt), end: toMs(task.orchestrator.settledAt) },
|
|
500
|
+
...task.subagents.map((child) => ({ start: toMs(child.startedAt), end: toMs(child.settledAt) })),
|
|
501
|
+
]);
|
|
502
|
+
|
|
503
|
+
const wallMs = unionMs(intervals);
|
|
504
|
+
const waitingMs = sessionTasks.reduce((sum, task) => sum + task.waitingMs, 0);
|
|
505
|
+
const workMs = wallMs - waitingMs;
|
|
506
|
+
const startedAtMs = Math.min(...sessionTasks.map((task) => toMs(task.startedAt)));
|
|
507
|
+
const endedAtMs = Math.max(...sessionTasks.map((task) => toMs(task.endedAt)));
|
|
508
|
+
const usage = sumUsage(sessionTasks.map((task) => task.usage));
|
|
509
|
+
const segments = sumSegments(sessionTasks.map((task) => task.segments));
|
|
510
|
+
|
|
511
|
+
sessions.push({
|
|
512
|
+
sessionId,
|
|
513
|
+
project: sessionTasks[0]!.project,
|
|
514
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
515
|
+
endedAt: new Date(endedAtMs).toISOString(),
|
|
516
|
+
wallMs,
|
|
517
|
+
waitingMs,
|
|
518
|
+
workMs,
|
|
519
|
+
tasks: sessionTasks,
|
|
520
|
+
usage,
|
|
521
|
+
segments,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return sessions.sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
|
|
526
|
+
}
|