@alecsibilia/luca 14.0.0-alpha.2 → 14.0.0-alpha.21
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 +76 -0
- package/README.md +7 -2
- package/board/client/board-look.ts +31 -1
- package/board/client/board-panel-parts.tsx +150 -0
- package/board/client/board-panel-stages.ts +99 -0
- package/board/client/board-panel-styles.ts +223 -0
- package/board/client/board-panel.tsx +77 -1275
- package/board/client/engine-life.ts +48 -0
- package/board/client/final-review-stack.tsx +144 -0
- package/board/client/heartbeat.tsx +61 -0
- package/board/client/helper-lines.tsx +69 -0
- package/board/client/live-feed.tsx +86 -0
- package/board/client/live-line.ts +73 -0
- package/board/client/model-line.tsx +178 -0
- package/board/client/needs-you-section.tsx +133 -0
- package/board/client/one-line.tsx +193 -0
- package/board/client/repo-wait-banner.tsx +69 -0
- package/board/client/run-footer.tsx +68 -0
- package/board/client/run-header.tsx +189 -0
- package/board/client/run-picker.tsx +98 -0
- package/board/client/status-bar.tsx +131 -0
- package/board/client/step-dots.tsx +127 -0
- package/board/client/stuck-actions.tsx +269 -0
- package/board/client/ticket-card-view.tsx +238 -0
- package/board/client/ticket-detail.tsx +87 -0
- package/board/client/ticket-stack.tsx +119 -0
- package/board/client/ticket-wait.ts +188 -0
- package/board/client/timeline-rows.tsx +41 -14
- package/board/client/usage-line.tsx +51 -0
- package/board/client/wait-line.tsx +39 -0
- package/board/index.client.tsx +1 -1
- package/board/index.server.ts +7 -1
- package/board/package.json +1 -1
- package/board/server/board-panel-state-helpers.ts +67 -0
- package/board/server/board-server-check.ts +294 -0
- package/board/server/board-server-engine.ts +129 -0
- package/board/server/board-server-events.ts +134 -0
- package/board/server/board-server-read.ts +190 -0
- package/board/server/board-server-resume.ts +164 -0
- package/board/server/board-server-start.ts +174 -0
- package/board/server/board-server-store.ts +281 -0
- package/board/server/board-server-types.ts +114 -0
- package/board/server/board-server-util.ts +4 -0
- package/board/server/board-server.ts +52 -943
- package/board/server/board-vocabulary-engine.ts +144 -0
- package/board/server/board-vocabulary-review.ts +115 -0
- package/board/server/board-vocabulary-run.ts +145 -0
- package/board/server/board-vocabulary-shapes.ts +153 -0
- package/board/server/board-vocabulary.ts +20 -375
- package/board/server/current-step-panel-helpers.ts +108 -0
- package/board/server/describe-record.ts +403 -0
- package/board/server/engine-launch.ts +20 -3
- package/board/server/engine-restart-helpers.ts +105 -0
- package/board/server/engine-watch.ts +45 -5
- package/board/server/journal-rebuild-helpers.ts +169 -0
- package/board/server/live-file.ts +130 -0
- package/board/server/luca-version.ts +1 -1
- package/board/server/make-rows.ts +14 -629
- package/board/server/record-texts.ts +350 -0
- package/board/server/reduce-board-agent-start.ts +224 -0
- package/board/server/reduce-board-agents.ts +160 -0
- package/board/server/reduce-board-counters.ts +138 -0
- package/board/server/reduce-board-create.ts +148 -0
- package/board/server/reduce-board-final-review.ts +174 -0
- package/board/server/reduce-board-needs-you.ts +283 -0
- package/board/server/reduce-board-reasons.ts +59 -0
- package/board/server/reduce-board-run.ts +255 -0
- package/board/server/reduce-board-settle.ts +88 -0
- package/board/server/reduce-board-steps.ts +226 -0
- package/board/server/reduce-board-text.ts +139 -0
- package/board/server/reduce-board-ticket-checks.ts +168 -0
- package/board/server/reduce-board-ticket-join.ts +144 -0
- package/board/server/reduce-board-ticket-start.ts +132 -0
- package/board/server/reduce-board-update.ts +117 -0
- package/board/server/reduce-board-usage.ts +247 -0
- package/board/server/reduce-board.ts +100 -1917
- package/board/server/reply-post.ts +407 -0
- package/board/server/run-registry.ts +8 -4
- package/board/server/ticket-stages-panel-helpers.ts +296 -0
- package/board/shared/board-rows.ts +9 -0
- package/board/shared/board-rpc.ts +42 -0
- package/board/shared/board-state-counts.ts +77 -0
- package/board/shared/board-state-live.ts +99 -0
- package/board/shared/board-state-review.ts +59 -0
- package/board/shared/board-state-run.ts +236 -0
- package/board/shared/board-state-ticket.ts +257 -0
- package/board/shared/board-state-usage.ts +107 -0
- package/board/shared/board-state.ts +97 -567
- package/board/shared/reply-actions.ts +207 -0
- package/engine/agents/agent-launcher.ts +27 -4
- package/engine/agents/claude-launcher-helpers.ts +233 -0
- package/engine/agents/claude-launcher.ts +28 -669
- package/engine/agents/claude-messages.ts +212 -0
- package/engine/agents/claude-options.ts +43 -8
- package/engine/agents/common-instructions.ts +138 -0
- package/engine/agents/input-channel.ts +36 -0
- package/engine/agents/launch-paths.ts +29 -0
- package/engine/agents/launch-session.ts +227 -0
- package/engine/agents/learner-instructions.ts +31 -0
- package/engine/agents/open-session.ts +210 -0
- package/engine/agents/prompt-sections.ts +95 -0
- package/engine/agents/rejoin-prompts.ts +135 -0
- package/engine/agents/reviewer-instructions.ts +99 -0
- package/engine/agents/role-instructions.ts +31 -244
- package/engine/agents/role-prompts.ts +39 -109
- package/engine/agents/role-results.ts +106 -7
- package/engine/agents/scripted-launcher.ts +21 -6
- package/engine/agents/session-pool.ts +124 -0
- package/engine/agents/turn-progress.ts +283 -0
- package/engine/agents/writer-instructions.ts +151 -0
- package/engine/cli/computer-adapters-real.ts +15 -3
- package/engine/cli/computer-adapters.ts +2 -0
- package/engine/cli/computer-checks.ts +87 -4
- package/engine/cli/config-reload-text.ts +39 -0
- package/engine/cli/decision-model-setup.ts +70 -0
- package/engine/cli/doctor-command.ts +1 -0
- package/engine/cli/doctor.ts +21 -3
- package/engine/cli/going-runs.ts +192 -36
- package/engine/cli/init-command.ts +1 -0
- package/engine/cli/init.ts +23 -1
- package/engine/cli/live-runs.ts +73 -0
- package/engine/cli/luca-run.ts +42 -7
- package/engine/cli/luca-skills.ts +183 -0
- package/engine/cli/luca.ts +8 -1
- package/engine/cli/models-command.ts +119 -0
- package/engine/cli/read-run.ts +81 -0
- package/engine/cli/run-args.ts +33 -6
- package/engine/cli/run-demo.ts +143 -0
- package/engine/cli/run-end.ts +184 -0
- package/engine/cli/run-modes-helpers.ts +81 -0
- package/engine/cli/run-modes.ts +62 -439
- package/engine/cli/setup.ts +5 -48
- package/engine/cli/unfinished-runs.ts +122 -0
- package/engine/cli/upgrade-command.ts +212 -30
- package/engine/cli/upgrade-finish.ts +149 -0
- package/engine/cli/upgrade.ts +112 -71
- package/engine/config/config-fields.ts +127 -0
- package/engine/config/config-reload.ts +250 -0
- package/engine/config/engine-config-schema.ts +235 -0
- package/engine/config/engine-config.ts +72 -142
- package/engine/config/test-commands.ts +102 -0
- package/engine/core/agent-guards-helpers.ts +84 -0
- package/engine/core/agent-turn.ts +395 -0
- package/engine/core/already-done.ts +44 -1
- package/engine/core/build-action.ts +214 -0
- package/engine/core/build-context.ts +135 -0
- package/engine/core/build-launch.ts +259 -0
- package/engine/core/commit-in.ts +282 -0
- package/engine/core/decide-build-early.ts +108 -0
- package/engine/core/decide-build-ending.ts +145 -0
- package/engine/core/decide-build-helpers.ts +87 -0
- package/engine/core/decide-build-tickets.ts +207 -0
- package/engine/core/decide-build-view.ts +103 -0
- package/engine/core/decide-build.ts +101 -1368
- package/engine/core/decide-final-fix.ts +203 -0
- package/engine/core/decide-final-review.ts +55 -310
- package/engine/core/decide-red-green.ts +398 -0
- package/engine/core/decide-rejoin.ts +259 -0
- package/engine/core/decide-repo-wait.ts +129 -0
- package/engine/core/decide-run-budget.ts +86 -12
- package/engine/core/decide-stuck-helpers.ts +127 -0
- package/engine/core/decide-stuck.ts +60 -8
- package/engine/core/decide-ticket-review.ts +95 -0
- package/engine/core/decide-ticket.ts +267 -0
- package/engine/core/decide-waits.ts +47 -0
- package/engine/core/decide.ts +74 -23
- package/engine/core/engine-chores.ts +163 -0
- package/engine/core/environment-rerun.ts +83 -0
- package/engine/core/evidence-text.ts +84 -0
- package/engine/core/execute-action.ts +303 -0
- package/engine/core/execute-already-done-check.ts +133 -0
- package/engine/core/execute-build.ts +68 -944
- package/engine/core/execute-changeset.ts +5 -7
- package/engine/core/execute-final-review.ts +125 -13
- package/engine/core/execute-gates.ts +182 -0
- package/engine/core/execute-intake.ts +130 -0
- package/engine/core/execute-join.ts +276 -0
- package/engine/core/execute-memory.ts +12 -0
- package/engine/core/execute-pull-request.ts +84 -0
- package/engine/core/execute-red-check.ts +219 -0
- package/engine/core/execute-repo-wait.ts +129 -0
- package/engine/core/execute-stuck.ts +25 -10
- package/engine/core/execute-waits.ts +158 -0
- package/engine/core/execute-with-jev.ts +118 -0
- package/engine/core/execute.ts +124 -831
- package/engine/core/final-failure-cap.ts +38 -0
- package/engine/core/final-review-action.ts +125 -0
- package/engine/core/final-review-text.ts +63 -17
- package/engine/core/final-stuck-text.ts +86 -0
- package/engine/core/fix-loop-text.ts +49 -2
- package/engine/core/left-out.ts +120 -0
- package/engine/core/live-file.ts +372 -0
- package/engine/core/loop-caps.ts +25 -2
- package/engine/core/not-in-run.ts +7 -3
- package/engine/core/pull-request-text.ts +255 -6
- package/engine/core/repo-wait-text.ts +81 -0
- package/engine/core/review-text.ts +55 -1
- package/engine/core/run-crash-recovery-helpers.ts +16 -0
- package/engine/core/run-session-close-helpers.ts +96 -0
- package/engine/core/run-start.ts +148 -0
- package/engine/core/run-stuck-text.ts +86 -0
- package/engine/core/schedule-rules.ts +193 -0
- package/engine/core/stuck-detail-text.ts +89 -0
- package/engine/core/stuck-format.ts +22 -0
- package/engine/core/stuck-text.ts +29 -332
- package/engine/core/ticket-stuck-text.ts +192 -0
- package/engine/core/worktree-changes.ts +106 -0
- package/engine/gates/already-done-check.ts +120 -0
- package/engine/gates/feature-map.ts +96 -0
- package/engine/gates/gate-runner.ts +108 -108
- package/engine/gates/gate-schemas.ts +61 -2
- package/engine/gates/leftover-scan.ts +154 -31
- package/engine/gates/new-tests.ts +45 -0
- package/engine/gates/own-files-check.ts +98 -0
- package/engine/gates/prepare-check.ts +60 -0
- package/engine/gates/prepare-slots.ts +99 -0
- package/engine/gates/red-check.ts +184 -16
- package/engine/gates/shell-check.ts +64 -0
- package/engine/gates/slot-waits.ts +55 -0
- package/engine/gates/slow-test-runner.ts +59 -0
- package/engine/gates/test-checks.ts +161 -0
- package/engine/gates/test-runner.ts +164 -5
- package/engine/gates/test-slots.ts +48 -0
- package/engine/git/git-adapter.ts +55 -396
- package/engine/git/git-commits.ts +132 -0
- package/engine/git/git-join.ts +139 -0
- package/engine/git/git-read.ts +123 -0
- package/engine/git/git-run.ts +32 -0
- package/engine/git/git-worktrees.ts +143 -0
- package/engine/git/outside-links.ts +55 -0
- package/engine/guards/after-turn-check.ts +31 -5
- package/engine/guards/guard-hook.ts +60 -12
- package/engine/guards/guard-roles.ts +68 -0
- package/engine/guards/helper-rules.ts +92 -0
- package/engine/guards/permission-rules.ts +161 -0
- package/engine/guards/role-rules-helpers.ts +48 -0
- package/engine/guards/role-rules.ts +34 -606
- package/engine/guards/shell-rules.ts +344 -0
- package/engine/guards/worktree-git.ts +208 -0
- package/engine/guards/worktree-snapshot.ts +412 -0
- package/engine/guards/worktree-state.ts +28 -825
- package/engine/guards/worktree-undo.ts +260 -0
- package/engine/guards/write-rules.ts +149 -0
- package/engine/index.ts +30 -9
- package/engine/intake/criteria-checks.ts +114 -0
- package/engine/intake/intake-checks.ts +164 -30
- package/engine/intake/intake-schemas.ts +37 -2
- package/engine/jev/clef-client.ts +259 -0
- package/engine/jev/decision-model-credentials.ts +212 -0
- package/engine/jev/jev-jobs.ts +144 -60
- package/engine/jev/jev-schemas.ts +86 -5
- package/engine/jev/jev-shadow-helpers.ts +218 -0
- package/engine/jev/jev-shadow.ts +109 -64
- package/engine/jev/model-pick.ts +275 -0
- package/engine/journal/agent-entries.ts +167 -0
- package/engine/journal/agent-message-entries.ts +72 -0
- package/engine/journal/agent-turn-replay.ts +203 -0
- package/engine/journal/already-done-entries.ts +55 -0
- package/engine/journal/commit-entries.ts +56 -0
- package/engine/journal/cross-ticket-replay.ts +222 -0
- package/engine/journal/decision-model-entries.ts +86 -0
- package/engine/journal/entry-fields.ts +10 -0
- package/engine/journal/final-review-entries.ts +128 -0
- package/engine/journal/final-review-replay.ts +324 -0
- package/engine/journal/final-review-round.ts +320 -0
- package/engine/journal/final-review-state.ts +180 -0
- package/engine/journal/gate-entries.ts +81 -0
- package/engine/journal/intake-entries.ts +87 -0
- package/engine/journal/join-entries.ts +126 -0
- package/engine/journal/journal-entry-schema.ts +218 -0
- package/engine/journal/journal-kinds.ts +97 -0
- package/engine/journal/journal-record-schema.ts +218 -0
- package/engine/journal/journal-record.ts +43 -1400
- package/engine/journal/journal.ts +6 -1
- package/engine/journal/limit-entries.ts +90 -0
- package/engine/journal/memory-entries.ts +124 -0
- package/engine/journal/memory-replay.ts +157 -0
- package/engine/journal/plan-replay.ts +139 -0
- package/engine/journal/pull-request-entries.ts +41 -0
- package/engine/journal/replay.ts +157 -2144
- package/engine/journal/reply-replay.ts +144 -0
- package/engine/journal/repo-wait-entries.ts +53 -0
- package/engine/journal/run-entries.ts +172 -0
- package/engine/journal/run-state.ts +264 -0
- package/engine/journal/shared-failure-replay.ts +133 -0
- package/engine/journal/stuck-entries.ts +193 -0
- package/engine/journal/ticket-progress.ts +392 -0
- package/engine/journal/ticket-replay.ts +411 -0
- package/engine/journal/ticket-result-replay.ts +364 -0
- package/engine/journal/ticket-retry-replay.ts +142 -0
- package/engine/journal/worktree-entries.ts +122 -0
- package/engine/models/model-score.ts +512 -0
- package/engine/shell/run-command.ts +187 -18
- package/engine/testing/agent-turn-fixtures.ts +287 -0
- package/engine/testing/already-done-fixtures.ts +97 -0
- package/engine/testing/build-fixtures.ts +68 -973
- package/engine/testing/checkouts-fixtures.ts +48 -0
- package/engine/testing/final-review-fixtures.ts +62 -1
- package/engine/testing/final-review-steps.ts +143 -0
- package/engine/testing/limit-fixtures.ts +85 -0
- package/engine/testing/live-file-fixtures.ts +57 -0
- package/engine/testing/practice-repo.ts +10 -4
- package/engine/testing/practice-run.ts +1 -1
- package/engine/testing/reply-fixtures.ts +88 -0
- package/engine/testing/run-budget-fixtures.ts +165 -0
- package/engine/testing/run-fixtures.ts +219 -0
- package/engine/testing/sdk-play.ts +177 -0
- package/engine/testing/setup-repo.ts +194 -0
- package/engine/testing/step-record-fixtures.ts +55 -0
- package/engine/testing/ticket-step-fixtures.ts +272 -0
- package/engine/testing/wait-scenarios.ts +186 -0
- package/engine/tracker/gh-retry.ts +217 -0
- package/engine/tracker/github-tracker.ts +310 -90
- package/engine/tracker/in-memory-tracker.ts +8 -4
- package/engine/tracker/tracker.ts +131 -6
- package/package.json +2 -1
- package/skills/luca-retro/SKILL.md +74 -0
- package/skills/luca-retro/scripts/retro-summary.ts +1356 -0
- package/skills/luca-unstick/SKILL.md +153 -0
- package/skills/luca-unstick/scripts/stuck-summary.ts +1063 -0
- package/engine/jev/jev-client.ts +0 -173
|
@@ -0,0 +1,1356 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Prints what went wrong in finished Luca runs, from their journals, for the
|
|
4
|
+
* `/luca-retro` skill:
|
|
5
|
+
*
|
|
6
|
+
* bun retro-summary.ts [run id ...] [--repo <path>] [--runs-dir <dir>]
|
|
7
|
+
*
|
|
8
|
+
* With no run id, it sums up the newest finished run of the repo (`--repo`,
|
|
9
|
+
* else the git repo of the current folder, or the main checkout of the
|
|
10
|
+
* worktree it is in). With several, it sums up each, then the patterns that
|
|
11
|
+
* repeat across them.
|
|
12
|
+
*
|
|
13
|
+
* Each summary names the journal's seq numbers (`seq 123`), so the skill can
|
|
14
|
+
* cite them: what got stuck and why, fix loops that hit their cap, leftover
|
|
15
|
+
* scan hits, agents out of turns, failed agent turns, Jev calls that failed,
|
|
16
|
+
* checks and tests that failed again and again, setup changes agents asked
|
|
17
|
+
* for, joins and rebases, the run budget, replies, the agents' assumptions,
|
|
18
|
+
* the findings fixers declined, slow steps, and tokens.
|
|
19
|
+
*
|
|
20
|
+
* It only reads the journal (`<runs folder>/<run id>/journal.jsonl`). It
|
|
21
|
+
* changes nothing.
|
|
22
|
+
*
|
|
23
|
+
* It imports nothing but Bun's and Node's own modules: it runs from
|
|
24
|
+
* `~/.claude/skills/luca-retro/`, where no packages are installed. So it
|
|
25
|
+
* reads records loosely, not with the engine's Zod schemas.
|
|
26
|
+
* `retro-summary.test.ts` checks it against journals built with the engine's
|
|
27
|
+
* own fixtures, and every kind in `KINDS_READ` against the journal's kinds,
|
|
28
|
+
* so the two can't drift apart unseen.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
31
|
+
import { homedir } from 'node:os'
|
|
32
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
33
|
+
|
|
34
|
+
/** Every journal record kind this script reads. */
|
|
35
|
+
export const KINDS_READ = [
|
|
36
|
+
'run_started',
|
|
37
|
+
'engine_resumed',
|
|
38
|
+
'config_reloaded',
|
|
39
|
+
'run_resumed',
|
|
40
|
+
'intake_read',
|
|
41
|
+
'spec_snapshot',
|
|
42
|
+
'ticket_snapshot',
|
|
43
|
+
'intake_refused',
|
|
44
|
+
'nothing_to_do',
|
|
45
|
+
'agent_finished',
|
|
46
|
+
'agent_failed',
|
|
47
|
+
'agent_session',
|
|
48
|
+
'red_check',
|
|
49
|
+
'leftover_scan',
|
|
50
|
+
'gates_run',
|
|
51
|
+
'ticket_joined',
|
|
52
|
+
'ticket_rebased',
|
|
53
|
+
'tests_sent_back',
|
|
54
|
+
'join_undone',
|
|
55
|
+
'ticket_stuck',
|
|
56
|
+
'run_stuck',
|
|
57
|
+
'final_review_stuck',
|
|
58
|
+
'reply_received',
|
|
59
|
+
'reply_ignored',
|
|
60
|
+
'run_stopped',
|
|
61
|
+
'pull_request_opened',
|
|
62
|
+
'jev_asked',
|
|
63
|
+
'jev_failed',
|
|
64
|
+
'usage_recorded',
|
|
65
|
+
'shared_git_changed',
|
|
66
|
+
'step_started',
|
|
67
|
+
'step_ended',
|
|
68
|
+
] as const
|
|
69
|
+
|
|
70
|
+
type KindRead = (typeof KINDS_READ)[number]
|
|
71
|
+
|
|
72
|
+
/** One journal line, read loosely. */
|
|
73
|
+
export type LooseRecord = {
|
|
74
|
+
seq: number
|
|
75
|
+
time: string
|
|
76
|
+
kind: string
|
|
77
|
+
ticket: number | null
|
|
78
|
+
content: Record<string, unknown>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The most entries a list prints before "... and N more". */
|
|
82
|
+
const LIST_MAX = 8
|
|
83
|
+
|
|
84
|
+
/** Longest text quoted from a record, such as a stuck detail. */
|
|
85
|
+
const DETAIL_MAX = 300
|
|
86
|
+
|
|
87
|
+
/** How many step types the slow steps list shows. */
|
|
88
|
+
const SLOW_STEPS_MAX = 8
|
|
89
|
+
|
|
90
|
+
// NOTE: these small readers are copies of the ones in
|
|
91
|
+
// `../../luca-unstick/scripts/stuck-summary.ts`: each skill's script must
|
|
92
|
+
// stand alone. Keep the two in step.
|
|
93
|
+
|
|
94
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
95
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
96
|
+
|
|
97
|
+
const text = (value: unknown): string =>
|
|
98
|
+
typeof value === 'string' ? value : ''
|
|
99
|
+
|
|
100
|
+
const numberOr = (value: unknown): number | null =>
|
|
101
|
+
typeof value === 'number' ? value : null
|
|
102
|
+
|
|
103
|
+
const listOf = (value: unknown): unknown[] =>
|
|
104
|
+
Array.isArray(value) ? value : []
|
|
105
|
+
|
|
106
|
+
const objectOf = (value: unknown): Record<string, unknown> =>
|
|
107
|
+
isObject(value) ? value : {}
|
|
108
|
+
|
|
109
|
+
/** One line, at most `max` characters, marked when cut. */
|
|
110
|
+
const clip = (value: string, max = DETAIL_MAX): string => {
|
|
111
|
+
const line = value.replace(/\s+/g, ' ').trim()
|
|
112
|
+
return line.length > max ? `${line.slice(0, max)} ...` : line
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One journal line as a loose record, or `null` when it isn't one. */
|
|
116
|
+
const toRecord = (line: string): LooseRecord | null => {
|
|
117
|
+
let json: unknown
|
|
118
|
+
try {
|
|
119
|
+
json = JSON.parse(line)
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
if (!isObject(json)) return null
|
|
124
|
+
const seq = numberOr(json.seq)
|
|
125
|
+
if (seq === null || typeof json.kind !== 'string') return null
|
|
126
|
+
return {
|
|
127
|
+
seq,
|
|
128
|
+
time: text(json.time),
|
|
129
|
+
kind: json.kind,
|
|
130
|
+
ticket: numberOr(json.ticket),
|
|
131
|
+
content: objectOf(json.content),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A run's records, oldest first. Lines that aren't records (such as a
|
|
137
|
+
* half-written last line) are skipped. No journal gives none.
|
|
138
|
+
*/
|
|
139
|
+
export const readRecords = ({ file }: { file: string }): LooseRecord[] =>
|
|
140
|
+
existsSync(file)
|
|
141
|
+
? readFileSync(file, 'utf8')
|
|
142
|
+
.split('\n')
|
|
143
|
+
.filter((line) => line.trim() !== '')
|
|
144
|
+
.map(toRecord)
|
|
145
|
+
.filter((record): record is LooseRecord => record !== null)
|
|
146
|
+
: []
|
|
147
|
+
|
|
148
|
+
const ofKind = (records: LooseRecord[], kind: KindRead): LooseRecord[] =>
|
|
149
|
+
records.filter((record) => record.kind === kind)
|
|
150
|
+
|
|
151
|
+
const lastOf = (
|
|
152
|
+
records: LooseRecord[],
|
|
153
|
+
kind: KindRead,
|
|
154
|
+
where: (record: LooseRecord) => boolean = () => true
|
|
155
|
+
): LooseRecord | undefined => ofKind(records, kind).filter(where).at(-1)
|
|
156
|
+
|
|
157
|
+
const msBetween = (from: string, to: string): number =>
|
|
158
|
+
Math.max(0, Date.parse(to) - Date.parse(from)) || 0
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A duration in words: `1h 02m`, `4m 05s`, `12s`.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* durationText(3_725_000) // '1h 02m'
|
|
165
|
+
*/
|
|
166
|
+
export const durationText = (ms: number): string => {
|
|
167
|
+
const s = Math.round(ms / 1000)
|
|
168
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
169
|
+
if (s >= 3600)
|
|
170
|
+
return `${Math.floor(s / 3600)}h ${pad(Math.floor((s % 3600) / 60))}m`
|
|
171
|
+
if (s >= 60) return `${Math.floor(s / 60)}m ${pad(s % 60)}s`
|
|
172
|
+
return `${s}s`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A token count in words: `9.48M`, `820k`, `512`.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* tokensText(9_476_629) // '9.48M'
|
|
180
|
+
*/
|
|
181
|
+
export const tokensText = (n: number): string =>
|
|
182
|
+
n >= 1_000_000
|
|
183
|
+
? `${(n / 1_000_000).toFixed(2)}M`
|
|
184
|
+
: n >= 1000
|
|
185
|
+
? `${Math.round(n / 1000)}k`
|
|
186
|
+
: String(n)
|
|
187
|
+
|
|
188
|
+
/** One group of like things: how many, and the seqs of the records. */
|
|
189
|
+
export type Tally = { key: string; count: number; seqs: number[] }
|
|
190
|
+
|
|
191
|
+
/** Groups `items` by key, biggest group first. Pure. */
|
|
192
|
+
const tally = (items: { key: string; seq: number }[]): Tally[] => {
|
|
193
|
+
const groups = new Map<string, Tally>()
|
|
194
|
+
for (const { key, seq } of items) {
|
|
195
|
+
const group = groups.get(key) ?? { key, count: 0, seqs: [] }
|
|
196
|
+
group.count += 1
|
|
197
|
+
group.seqs.push(seq)
|
|
198
|
+
groups.set(key, group)
|
|
199
|
+
}
|
|
200
|
+
return [...groups.values()].toSorted((a, b) => b.count - a.count)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A journal record's seq as `seq 123`, never `#123`, which GitHub links. */
|
|
204
|
+
const seqText = (seq: number): string => `seq ${seq}`
|
|
205
|
+
|
|
206
|
+
/** Seqs as `seq 1, seq 2, seq 3`, at most `max`. */
|
|
207
|
+
const seqsText = (seqs: number[], max = 6): string =>
|
|
208
|
+
seqs.length > max
|
|
209
|
+
? `${seqs.slice(0, max).map(seqText).join(', ')}, +${seqs.length - max}`
|
|
210
|
+
: seqs.map(seqText).join(', ')
|
|
211
|
+
|
|
212
|
+
/** The first `LIST_MAX` lines, then how many more there are. */
|
|
213
|
+
const capped = (lines: string[], max = LIST_MAX): string[] =>
|
|
214
|
+
lines.length > max
|
|
215
|
+
? [...lines.slice(0, max), `... and ${lines.length - max} more`]
|
|
216
|
+
: lines
|
|
217
|
+
|
|
218
|
+
/** Tokens as the run budget counts them: input, output, and cache writes. */
|
|
219
|
+
const countedTokens = (tokens: Record<string, unknown>): number =>
|
|
220
|
+
(numberOr(tokens.input_tokens) ?? 0) +
|
|
221
|
+
(numberOr(tokens.output_tokens) ?? 0) +
|
|
222
|
+
(numberOr(tokens.cache_creation_input_tokens) ?? 0)
|
|
223
|
+
|
|
224
|
+
/** Something that got stuck, and the owner's reply to it. */
|
|
225
|
+
export type StuckEvent = {
|
|
226
|
+
seq: number
|
|
227
|
+
kind: string
|
|
228
|
+
ticket: number | null
|
|
229
|
+
reason: string
|
|
230
|
+
detail: string
|
|
231
|
+
/** The cap a fix loop hit, from the detail: `3 fix rounds`, `3 rebases`. */
|
|
232
|
+
cap: string | null
|
|
233
|
+
reply: { seq: number; word: string; wait_ms: number } | null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A test or check that failed, from failing gates. */
|
|
237
|
+
export type GateFailures = {
|
|
238
|
+
runs: number
|
|
239
|
+
failed: number
|
|
240
|
+
/** By check name (`test`, `types`, `prepare (timed out)`, ...). */
|
|
241
|
+
by_check: Tally[]
|
|
242
|
+
/** By test name, from `(fail)` lines in a test check's output. */
|
|
243
|
+
by_test: Tally[]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** One step type's times, from `step_started` to `step_ended`. */
|
|
247
|
+
export type StepTimes = {
|
|
248
|
+
step: string
|
|
249
|
+
count: number
|
|
250
|
+
total_ms: number
|
|
251
|
+
max_ms: number
|
|
252
|
+
max_seq: number
|
|
253
|
+
max_ticket: number | null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** One agent role's sessions: turns, tokens, time. */
|
|
257
|
+
export type RoleUse = {
|
|
258
|
+
role: string
|
|
259
|
+
sessions: number
|
|
260
|
+
counted: number
|
|
261
|
+
turns: number
|
|
262
|
+
max_turns: number
|
|
263
|
+
duration_ms: number
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** An agent turn's text, such as an assumption or a declined finding. */
|
|
267
|
+
export type AgentNote = {
|
|
268
|
+
seq: number
|
|
269
|
+
ticket: number | null
|
|
270
|
+
role: string
|
|
271
|
+
text: string
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** One kind of pattern, for spotting repeats across runs. */
|
|
275
|
+
export type Signal = { key: string; count: number }
|
|
276
|
+
|
|
277
|
+
/** Everything `/luca-retro` reads from one run's journal. */
|
|
278
|
+
export type RunSummary = {
|
|
279
|
+
spec: { number: number | null; title: string; url: string }
|
|
280
|
+
repo: string
|
|
281
|
+
luca: string[]
|
|
282
|
+
started: string
|
|
283
|
+
ended: string
|
|
284
|
+
last_seq: number
|
|
285
|
+
over: boolean
|
|
286
|
+
ending: string
|
|
287
|
+
tickets: Map<number, string>
|
|
288
|
+
stuck: StuckEvent[]
|
|
289
|
+
leftovers: { scans: number; by_path: Tally[]; reasons: Tally[] }
|
|
290
|
+
out_of_turns: AgentNote[]
|
|
291
|
+
no_session: AgentNote[]
|
|
292
|
+
other_failures: AgentNote[]
|
|
293
|
+
jev: { asked: number; failed: Tally[]; jobs: Tally[] }
|
|
294
|
+
gates: GateFailures
|
|
295
|
+
red_checks: { runs: number; failed: number }
|
|
296
|
+
setup_changes: AgentNote[]
|
|
297
|
+
joins: {
|
|
298
|
+
ok: number
|
|
299
|
+
failed: { seq: number; ticket: number | null }[]
|
|
300
|
+
rebases: Tally[]
|
|
301
|
+
undone: { seq: number; ticket: number | null; shas: number }[]
|
|
302
|
+
tests_sent_back: AgentNote[]
|
|
303
|
+
}
|
|
304
|
+
replies: { words: Tally[]; ignored: Tally[] }
|
|
305
|
+
assumptions: {
|
|
306
|
+
total: number
|
|
307
|
+
by_role: Tally[]
|
|
308
|
+
by_ticket: Tally[]
|
|
309
|
+
notes: AgentNote[]
|
|
310
|
+
}
|
|
311
|
+
declined: { wont_fix: AgentNote[]; accepted: AgentNote[] }
|
|
312
|
+
steps: StepTimes[]
|
|
313
|
+
usage: {
|
|
314
|
+
run: Record<string, unknown> | null
|
|
315
|
+
agent_turns: number | null
|
|
316
|
+
by_ticket: { ticket: number; counted: number }[]
|
|
317
|
+
by_role: RoleUse[]
|
|
318
|
+
}
|
|
319
|
+
resumes: {
|
|
320
|
+
seq: number
|
|
321
|
+
version: string
|
|
322
|
+
down_ms: number
|
|
323
|
+
cut_off: number
|
|
324
|
+
}[]
|
|
325
|
+
config_reloaded: string[]
|
|
326
|
+
shared_git_changed: number
|
|
327
|
+
signals: Signal[]
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** How a run ended, from its last records; `over` when nothing more comes. */
|
|
331
|
+
const runEnding = ({
|
|
332
|
+
records,
|
|
333
|
+
}: {
|
|
334
|
+
records: LooseRecord[]
|
|
335
|
+
}): { over: boolean; line: string } => {
|
|
336
|
+
const pr = lastOf(records, 'pull_request_opened')
|
|
337
|
+
if (pr !== undefined) {
|
|
338
|
+
return {
|
|
339
|
+
over: true,
|
|
340
|
+
line: `opened PR #${String(pr.content.number)} ${text(pr.content.url)} (${seqText(pr.seq)})`,
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const refused = lastOf(records, 'intake_refused')
|
|
344
|
+
if (refused !== undefined) {
|
|
345
|
+
return {
|
|
346
|
+
over: true,
|
|
347
|
+
line: `intake refused it (${seqText(refused.seq)})`,
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const nothing = lastOf(records, 'nothing_to_do')
|
|
351
|
+
if (nothing !== undefined) {
|
|
352
|
+
return { over: true, line: `nothing to do (${seqText(nothing.seq)})` }
|
|
353
|
+
}
|
|
354
|
+
const stopped = lastOf(records, 'run_stopped')
|
|
355
|
+
if (
|
|
356
|
+
stopped !== undefined &&
|
|
357
|
+
(stopped.content.billing === true || stopped.content.crashed === true)
|
|
358
|
+
) {
|
|
359
|
+
return {
|
|
360
|
+
over: true,
|
|
361
|
+
line: `stopped for good (${seqText(stopped.seq)}): ${clip(text(stopped.content.reason))}`,
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const stop = lastOf(
|
|
365
|
+
records,
|
|
366
|
+
'reply_received',
|
|
367
|
+
({ content }) => content.word === 'stop'
|
|
368
|
+
)
|
|
369
|
+
if (stop !== undefined) {
|
|
370
|
+
return {
|
|
371
|
+
over: true,
|
|
372
|
+
line: `the owner replied stop (${seqText(stop.seq)}), no PR`,
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return { over: false, line: 'not over yet' }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Whether a run is over: a PR, a refusal, nothing to do, a stop. Pure. */
|
|
379
|
+
export const isFinished = ({ records }: { records: LooseRecord[] }): boolean =>
|
|
380
|
+
runEnding({ records }).over
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The stuck records, each with the owner's reply to it: the next
|
|
384
|
+
* `reply_received` for its ticket (or, for the final review or the run, the
|
|
385
|
+
* next one naming no ticket).
|
|
386
|
+
*/
|
|
387
|
+
const stuckEvents = ({ records }: { records: LooseRecord[] }): StuckEvent[] =>
|
|
388
|
+
records
|
|
389
|
+
.filter(
|
|
390
|
+
({ kind }) =>
|
|
391
|
+
kind === 'ticket_stuck' ||
|
|
392
|
+
kind === 'final_review_stuck' ||
|
|
393
|
+
kind === 'run_stuck'
|
|
394
|
+
)
|
|
395
|
+
.map((record) => {
|
|
396
|
+
const detail = text(record.content.detail)
|
|
397
|
+
const reply = records.find(
|
|
398
|
+
(later) =>
|
|
399
|
+
later.seq > record.seq &&
|
|
400
|
+
later.kind === 'reply_received' &&
|
|
401
|
+
numberOr(later.content.ticket) === record.ticket
|
|
402
|
+
)
|
|
403
|
+
return {
|
|
404
|
+
seq: record.seq,
|
|
405
|
+
kind: record.kind,
|
|
406
|
+
ticket: record.ticket,
|
|
407
|
+
reason: text(record.content.reason),
|
|
408
|
+
detail,
|
|
409
|
+
cap:
|
|
410
|
+
/after (\d+ (?:fix rounds|rebases))/.exec(detail)?.[1] ??
|
|
411
|
+
null,
|
|
412
|
+
reply:
|
|
413
|
+
reply === undefined
|
|
414
|
+
? null
|
|
415
|
+
: {
|
|
416
|
+
seq: reply.seq,
|
|
417
|
+
word: text(reply.content.word),
|
|
418
|
+
wait_ms: msBetween(record.time, reply.time),
|
|
419
|
+
},
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
/** The tests a bun test output says failed, once each. */
|
|
424
|
+
const failedTests = (output: string): string[] => [
|
|
425
|
+
...new Set(
|
|
426
|
+
[...output.matchAll(/^\(fail\) (.+?)(?: \[[\d.]+m?s\])?$/gm)].map(
|
|
427
|
+
(match) => match[1] ?? ''
|
|
428
|
+
)
|
|
429
|
+
),
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
/** Failing gates, by check and by test. Pure. */
|
|
433
|
+
const gateFailures = ({
|
|
434
|
+
records,
|
|
435
|
+
}: {
|
|
436
|
+
records: LooseRecord[]
|
|
437
|
+
}): GateFailures => {
|
|
438
|
+
const gates = ofKind(records, 'gates_run')
|
|
439
|
+
const checks: { key: string; seq: number }[] = []
|
|
440
|
+
const tests: { key: string; seq: number }[] = []
|
|
441
|
+
for (const gate of gates.filter(({ content }) => content.ok === false)) {
|
|
442
|
+
for (const check of listOf(gate.content.checks).map(objectOf)) {
|
|
443
|
+
if (check.ok !== false) continue
|
|
444
|
+
const name = text(check.name) || 'check'
|
|
445
|
+
checks.push({
|
|
446
|
+
key: check.exit_code === null ? `${name} (timed out)` : name,
|
|
447
|
+
seq: gate.seq,
|
|
448
|
+
})
|
|
449
|
+
for (const test of failedTests(text(check.output))) {
|
|
450
|
+
tests.push({ key: test, seq: gate.seq })
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
runs: gates.length,
|
|
456
|
+
failed: gates.filter(({ content }) => content.ok === false).length,
|
|
457
|
+
by_check: tally(checks),
|
|
458
|
+
by_test: tally(tests),
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Each step type's times: from a `step_started` to the `step_ended` with the
|
|
464
|
+
* same key. A step a crash cut off (no end) doesn't count. Slowest in total
|
|
465
|
+
* first. Pure.
|
|
466
|
+
*/
|
|
467
|
+
export const stepTimes = ({
|
|
468
|
+
records,
|
|
469
|
+
}: {
|
|
470
|
+
records: LooseRecord[]
|
|
471
|
+
}): StepTimes[] => {
|
|
472
|
+
const open = new Map<string, LooseRecord>()
|
|
473
|
+
const times = new Map<string, StepTimes>()
|
|
474
|
+
for (const record of records) {
|
|
475
|
+
const key = text(record.content.key)
|
|
476
|
+
if (record.kind === 'step_started') {
|
|
477
|
+
open.set(key, record)
|
|
478
|
+
} else if (record.kind === 'step_ended') {
|
|
479
|
+
const start = open.get(key)
|
|
480
|
+
if (start === undefined) continue
|
|
481
|
+
open.delete(key)
|
|
482
|
+
const step = text(start.content.step)
|
|
483
|
+
const ms = msBetween(start.time, record.time)
|
|
484
|
+
const entry = times.get(step) ?? {
|
|
485
|
+
step,
|
|
486
|
+
count: 0,
|
|
487
|
+
total_ms: 0,
|
|
488
|
+
max_ms: -1,
|
|
489
|
+
max_seq: start.seq,
|
|
490
|
+
max_ticket: start.ticket,
|
|
491
|
+
}
|
|
492
|
+
entry.count += 1
|
|
493
|
+
entry.total_ms += ms
|
|
494
|
+
if (ms > entry.max_ms) {
|
|
495
|
+
entry.max_ms = ms
|
|
496
|
+
entry.max_seq = start.seq
|
|
497
|
+
entry.max_ticket = start.ticket
|
|
498
|
+
}
|
|
499
|
+
times.set(step, entry)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return [...times.values()].toSorted((a, b) => b.total_ms - a.total_ms)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Each role's agent sessions: turns, counted tokens, and time. Pure. */
|
|
506
|
+
const roleUse = ({ records }: { records: LooseRecord[] }): RoleUse[] => {
|
|
507
|
+
const roles = new Map<string, RoleUse>()
|
|
508
|
+
for (const { content } of ofKind(records, 'agent_session')) {
|
|
509
|
+
const role = text(content.role)
|
|
510
|
+
const session = objectOf(content.session)
|
|
511
|
+
const models = Object.values(objectOf(session.model_usage)).map(
|
|
512
|
+
objectOf
|
|
513
|
+
)
|
|
514
|
+
const counted =
|
|
515
|
+
models.length === 0
|
|
516
|
+
? countedTokens(objectOf(session.usage))
|
|
517
|
+
: models.reduce((sum, tokens) => sum + countedTokens(tokens), 0)
|
|
518
|
+
const turns = numberOr(session.num_turns) ?? 0
|
|
519
|
+
const use = roles.get(role) ?? {
|
|
520
|
+
role,
|
|
521
|
+
sessions: 0,
|
|
522
|
+
counted: 0,
|
|
523
|
+
turns: 0,
|
|
524
|
+
max_turns: 0,
|
|
525
|
+
duration_ms: 0,
|
|
526
|
+
}
|
|
527
|
+
use.sessions += 1
|
|
528
|
+
use.counted += counted
|
|
529
|
+
use.turns += turns
|
|
530
|
+
use.max_turns = Math.max(use.max_turns, turns)
|
|
531
|
+
use.duration_ms += numberOr(session.duration_ms) ?? 0
|
|
532
|
+
roles.set(role, use)
|
|
533
|
+
}
|
|
534
|
+
return [...roles.values()].toSorted((a, b) => b.counted - a.counted)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** `#12` for a ticket, or `run` for none. */
|
|
538
|
+
const ticketText = (ticket: number | null): string =>
|
|
539
|
+
ticket === null ? 'run' : `#${ticket}`
|
|
540
|
+
|
|
541
|
+
/** The folder of a repo-relative path, or `.` for the root. */
|
|
542
|
+
const folderOf = (path: string): string =>
|
|
543
|
+
path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.'
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Everything `/luca-retro` reads from one run's records, oldest first.
|
|
547
|
+
* Pure.
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* const summary = summarizeJournal({ records: readRecords({ file }) })
|
|
551
|
+
* summary.stuck.map(({ reason }) => reason) // ['leftovers_found', 'run_budget']
|
|
552
|
+
*/
|
|
553
|
+
export const summarizeJournal = ({
|
|
554
|
+
records,
|
|
555
|
+
}: {
|
|
556
|
+
records: LooseRecord[]
|
|
557
|
+
}): RunSummary => {
|
|
558
|
+
const started = objectOf(lastOf(records, 'run_started')?.content)
|
|
559
|
+
const spec = objectOf(
|
|
560
|
+
objectOf(
|
|
561
|
+
(lastOf(records, 'spec_snapshot') ?? lastOf(records, 'intake_read'))
|
|
562
|
+
?.content
|
|
563
|
+
).spec
|
|
564
|
+
)
|
|
565
|
+
const ending = runEnding({ records })
|
|
566
|
+
const tickets = new Map<number, string>()
|
|
567
|
+
for (const { content } of ofKind(records, 'ticket_snapshot')) {
|
|
568
|
+
const number = numberOr(content.number)
|
|
569
|
+
if (number !== null) tickets.set(number, text(content.title))
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const note = (
|
|
573
|
+
record: LooseRecord,
|
|
574
|
+
body: string,
|
|
575
|
+
role = text(record.content.role)
|
|
576
|
+
): AgentNote => ({
|
|
577
|
+
seq: record.seq,
|
|
578
|
+
ticket: record.ticket,
|
|
579
|
+
role,
|
|
580
|
+
text: body,
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
// Leftover scans that blocked a commit.
|
|
584
|
+
const scans = ofKind(records, 'leftover_scan').filter(
|
|
585
|
+
({ content }) => listOf(content.hits).length > 0
|
|
586
|
+
)
|
|
587
|
+
const hits = scans.flatMap((scan) =>
|
|
588
|
+
listOf(scan.content.hits)
|
|
589
|
+
.map(objectOf)
|
|
590
|
+
.map((hit) => ({
|
|
591
|
+
seq: scan.seq,
|
|
592
|
+
path: text(hit.path),
|
|
593
|
+
reason: text(hit.reason),
|
|
594
|
+
}))
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
// Failed agent turns.
|
|
598
|
+
const failed = ofKind(records, 'agent_failed')
|
|
599
|
+
const error = (record: LooseRecord) => text(record.content.error)
|
|
600
|
+
const out_of_turns = failed
|
|
601
|
+
.filter((record) => error(record).includes('error_max_turns'))
|
|
602
|
+
.map((record) =>
|
|
603
|
+
note(
|
|
604
|
+
record,
|
|
605
|
+
/maximum number of turns \((\d+)\)/.exec(error(record))?.[1] ??
|
|
606
|
+
'?'
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
const no_session = failed
|
|
610
|
+
.filter((record) => error(record).startsWith('No open agent session'))
|
|
611
|
+
.map((record) => note(record, clip(error(record))))
|
|
612
|
+
const other_failures = failed
|
|
613
|
+
.filter(
|
|
614
|
+
(record) =>
|
|
615
|
+
!error(record).includes('error_max_turns') &&
|
|
616
|
+
!error(record).startsWith('No open agent session')
|
|
617
|
+
)
|
|
618
|
+
.map((record) =>
|
|
619
|
+
note(
|
|
620
|
+
record,
|
|
621
|
+
`${text(record.content.failure) || 'agent'}: ${clip(error(record))}`
|
|
622
|
+
)
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
// Jev calls that gave no answers.
|
|
626
|
+
const jev_failed = ofKind(records, 'jev_failed')
|
|
627
|
+
|
|
628
|
+
// What agents said in their results.
|
|
629
|
+
const finished = ofKind(records, 'agent_finished')
|
|
630
|
+
const results = finished.map((record) => ({
|
|
631
|
+
record,
|
|
632
|
+
result: objectOf(record.content.result),
|
|
633
|
+
}))
|
|
634
|
+
const setup_changes = results.flatMap(({ record, result }) => {
|
|
635
|
+
const setup = objectOf(result.setup_change)
|
|
636
|
+
return text(setup.file) === ''
|
|
637
|
+
? []
|
|
638
|
+
: [note(record, `${text(setup.file)}: ${clip(text(setup.reason))}`)]
|
|
639
|
+
})
|
|
640
|
+
const assumptions = results.flatMap(({ record, result }) =>
|
|
641
|
+
listOf(result.assumptions).map((line) =>
|
|
642
|
+
note(record, clip(text(line), 200))
|
|
643
|
+
)
|
|
644
|
+
)
|
|
645
|
+
const wont_fix = results.flatMap(({ record, result }) =>
|
|
646
|
+
listOf(result.finding_responses)
|
|
647
|
+
.map(objectOf)
|
|
648
|
+
.filter(({ response }) => response === 'wont_fix')
|
|
649
|
+
.map((answer) =>
|
|
650
|
+
note(
|
|
651
|
+
record,
|
|
652
|
+
`${text(answer.finding_id)}: ${clip(text(answer.reason), 200)}`
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
const accepted = results.flatMap(({ record, result }) =>
|
|
657
|
+
listOf(result.rulings)
|
|
658
|
+
.map(objectOf)
|
|
659
|
+
.filter(({ ruling }) => ruling === 'accepted')
|
|
660
|
+
.map((ruling) =>
|
|
661
|
+
note(
|
|
662
|
+
record,
|
|
663
|
+
`${text(ruling.finding_id)}: ${clip(text(ruling.reason), 200)}`
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
// Joins and rebases.
|
|
669
|
+
const joins = ofKind(records, 'ticket_joined')
|
|
670
|
+
const join_failed = joins
|
|
671
|
+
.filter(({ content }) => content.ok === false)
|
|
672
|
+
.map(({ seq, ticket }) => ({ seq, ticket }))
|
|
673
|
+
|
|
674
|
+
// Usage: the run's record, and each ticket's latest.
|
|
675
|
+
const usage = ofKind(records, 'usage_recorded')
|
|
676
|
+
const run_usage = usage
|
|
677
|
+
.filter(({ content }) => content.scope === 'run')
|
|
678
|
+
.at(-1)
|
|
679
|
+
const by_ticket = new Map<number, number>()
|
|
680
|
+
for (const { content } of usage) {
|
|
681
|
+
const ticket = numberOr(content.ticket)
|
|
682
|
+
if (content.scope === 'ticket' && ticket !== null) {
|
|
683
|
+
by_ticket.set(ticket, countedTokens(objectOf(content.tokens)))
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Each time the engine started again, and how long it was down.
|
|
688
|
+
const engine_resumed = ofKind(records, 'engine_resumed')
|
|
689
|
+
const resumes = engine_resumed.map((record, index) => {
|
|
690
|
+
const before = records.filter(({ seq }) => seq < record.seq).at(-1)
|
|
691
|
+
const next = engine_resumed[index + 1]?.seq ?? Infinity
|
|
692
|
+
// The steps a crash cut off, found before this resume's first step.
|
|
693
|
+
const resumed = records.find(
|
|
694
|
+
({ seq, kind }) =>
|
|
695
|
+
seq > record.seq && seq < next && kind === 'run_resumed'
|
|
696
|
+
)
|
|
697
|
+
return {
|
|
698
|
+
seq: record.seq,
|
|
699
|
+
version: text(record.content.luca_version),
|
|
700
|
+
down_ms:
|
|
701
|
+
before === undefined ? 0 : msBetween(before.time, record.time),
|
|
702
|
+
cut_off: listOf(resumed?.content.interrupted).length,
|
|
703
|
+
}
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
const stuck = stuckEvents({ records })
|
|
707
|
+
const gates = gateFailures({ records })
|
|
708
|
+
const red = ofKind(records, 'red_check')
|
|
709
|
+
const rebases = tally(
|
|
710
|
+
ofKind(records, 'ticket_rebased').map(({ seq, content }) => ({
|
|
711
|
+
key: text(content.cause),
|
|
712
|
+
seq,
|
|
713
|
+
}))
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
const summary: RunSummary = {
|
|
717
|
+
spec: {
|
|
718
|
+
number: numberOr(started.spec_number) ?? numberOr(spec.number),
|
|
719
|
+
title: text(spec.title),
|
|
720
|
+
url: text(spec.url),
|
|
721
|
+
},
|
|
722
|
+
repo: text(started.repo),
|
|
723
|
+
luca: [
|
|
724
|
+
text(started.luca_version) || '?',
|
|
725
|
+
...ofKind(records, 'engine_resumed').map(({ content }) =>
|
|
726
|
+
text(content.luca_version)
|
|
727
|
+
),
|
|
728
|
+
].filter((version, index, all) => all.indexOf(version) === index),
|
|
729
|
+
started: records[0]?.time ?? '',
|
|
730
|
+
ended: records.at(-1)?.time ?? '',
|
|
731
|
+
last_seq: records.at(-1)?.seq ?? 0,
|
|
732
|
+
over: ending.over,
|
|
733
|
+
ending: ending.line,
|
|
734
|
+
tickets,
|
|
735
|
+
stuck,
|
|
736
|
+
leftovers: {
|
|
737
|
+
scans: scans.length,
|
|
738
|
+
by_path: tally(hits.map(({ path, seq }) => ({ key: path, seq }))),
|
|
739
|
+
reasons: tally(
|
|
740
|
+
hits.map(({ reason, seq }) => ({ key: reason, seq }))
|
|
741
|
+
),
|
|
742
|
+
},
|
|
743
|
+
out_of_turns,
|
|
744
|
+
no_session,
|
|
745
|
+
other_failures,
|
|
746
|
+
jev: {
|
|
747
|
+
asked: ofKind(records, 'jev_asked').length,
|
|
748
|
+
failed: tally(
|
|
749
|
+
jev_failed.map(({ seq, content }) => ({
|
|
750
|
+
key: `${text(content.reason)}: ${clip(text(content.error), 120)}`,
|
|
751
|
+
seq,
|
|
752
|
+
}))
|
|
753
|
+
),
|
|
754
|
+
jobs: tally(
|
|
755
|
+
jev_failed.map(({ seq, content }) => ({
|
|
756
|
+
key: text(content.job),
|
|
757
|
+
seq,
|
|
758
|
+
}))
|
|
759
|
+
),
|
|
760
|
+
},
|
|
761
|
+
gates,
|
|
762
|
+
red_checks: {
|
|
763
|
+
runs: red.length,
|
|
764
|
+
failed: red.filter(({ content }) => content.ok === false).length,
|
|
765
|
+
},
|
|
766
|
+
setup_changes,
|
|
767
|
+
joins: {
|
|
768
|
+
ok: joins.length - join_failed.length,
|
|
769
|
+
failed: join_failed,
|
|
770
|
+
rebases,
|
|
771
|
+
undone: ofKind(records, 'join_undone').map(
|
|
772
|
+
({ seq, ticket, content }) => ({
|
|
773
|
+
seq,
|
|
774
|
+
ticket,
|
|
775
|
+
shas: listOf(content.shas).length,
|
|
776
|
+
})
|
|
777
|
+
),
|
|
778
|
+
tests_sent_back: ofKind(records, 'tests_sent_back').map(
|
|
779
|
+
(record) => {
|
|
780
|
+
const bad = objectOf(record.content.bad_test)
|
|
781
|
+
return note(
|
|
782
|
+
record,
|
|
783
|
+
`round ${String(record.content.round)}: ${text(bad.file)} > ${clip(text(bad.name), 160)}`,
|
|
784
|
+
'implementer'
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
),
|
|
788
|
+
},
|
|
789
|
+
replies: {
|
|
790
|
+
words: tally(
|
|
791
|
+
ofKind(records, 'reply_received').map(({ seq, content }) => ({
|
|
792
|
+
key: text(content.word),
|
|
793
|
+
seq,
|
|
794
|
+
}))
|
|
795
|
+
),
|
|
796
|
+
ignored: tally(
|
|
797
|
+
ofKind(records, 'reply_ignored').map(({ seq, content }) => ({
|
|
798
|
+
key: text(content.reason),
|
|
799
|
+
seq,
|
|
800
|
+
}))
|
|
801
|
+
),
|
|
802
|
+
},
|
|
803
|
+
assumptions: {
|
|
804
|
+
total: assumptions.length,
|
|
805
|
+
by_role: tally(
|
|
806
|
+
assumptions.map(({ role, seq }) => ({ key: role, seq }))
|
|
807
|
+
),
|
|
808
|
+
by_ticket: tally(
|
|
809
|
+
assumptions.map(({ ticket, seq }) => ({
|
|
810
|
+
key: ticketText(ticket),
|
|
811
|
+
seq,
|
|
812
|
+
}))
|
|
813
|
+
),
|
|
814
|
+
notes: assumptions,
|
|
815
|
+
},
|
|
816
|
+
declined: { wont_fix, accepted },
|
|
817
|
+
steps: stepTimes({ records }),
|
|
818
|
+
usage: {
|
|
819
|
+
run:
|
|
820
|
+
run_usage === undefined
|
|
821
|
+
? null
|
|
822
|
+
: objectOf(run_usage.content.tokens),
|
|
823
|
+
agent_turns:
|
|
824
|
+
run_usage === undefined
|
|
825
|
+
? null
|
|
826
|
+
: numberOr(run_usage.content.agent_turns),
|
|
827
|
+
by_ticket: [...by_ticket]
|
|
828
|
+
.map(([ticket, counted]) => ({ ticket, counted }))
|
|
829
|
+
.toSorted((a, b) => b.counted - a.counted),
|
|
830
|
+
by_role: roleUse({ records }),
|
|
831
|
+
},
|
|
832
|
+
resumes,
|
|
833
|
+
config_reloaded: ofKind(records, 'config_reloaded').flatMap(
|
|
834
|
+
({ content }) =>
|
|
835
|
+
listOf(content.changes).map((change) =>
|
|
836
|
+
text(objectOf(change).field)
|
|
837
|
+
)
|
|
838
|
+
),
|
|
839
|
+
shared_git_changed: ofKind(records, 'shared_git_changed').length,
|
|
840
|
+
signals: [],
|
|
841
|
+
}
|
|
842
|
+
summary.signals = signalsOf({ summary })
|
|
843
|
+
return summary
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* The patterns of one run, keyed so the same pattern in another run has
|
|
848
|
+
* the same key: stuck reasons, leftover reasons and folders, failure kinds,
|
|
849
|
+
* failing checks and tests, and the like. Pure.
|
|
850
|
+
*/
|
|
851
|
+
const signalsOf = ({ summary }: { summary: RunSummary }): Signal[] => {
|
|
852
|
+
const signals = new Map<string, number>()
|
|
853
|
+
const add = (key: string, count = 1) => {
|
|
854
|
+
if (count > 0) signals.set(key, (signals.get(key) ?? 0) + count)
|
|
855
|
+
}
|
|
856
|
+
for (const event of summary.stuck) {
|
|
857
|
+
add(`stuck: ${event.reason}`)
|
|
858
|
+
if (event.cap !== null) add(`fix loop at its cap (${event.cap})`)
|
|
859
|
+
}
|
|
860
|
+
for (const { key, count } of summary.leftovers.reasons) {
|
|
861
|
+
add(`leftover scan: ${key}`, count)
|
|
862
|
+
}
|
|
863
|
+
for (const { key, count } of summary.leftovers.by_path) {
|
|
864
|
+
add(`leftover in folder: ${folderOf(key)}/`, count)
|
|
865
|
+
}
|
|
866
|
+
for (const { role, text: max } of summary.out_of_turns) {
|
|
867
|
+
add(`out of turns: ${role} (max ${max})`)
|
|
868
|
+
}
|
|
869
|
+
for (const { role } of summary.no_session) {
|
|
870
|
+
add(`No open agent session: ${role}`)
|
|
871
|
+
}
|
|
872
|
+
for (const { key, count } of summary.jev.failed) {
|
|
873
|
+
add(`jev_failed ${key}`, count)
|
|
874
|
+
}
|
|
875
|
+
for (const { key, count } of summary.gates.by_check) {
|
|
876
|
+
add(`failed check: ${key}`, count)
|
|
877
|
+
}
|
|
878
|
+
for (const { key, count } of summary.gates.by_test) {
|
|
879
|
+
add(`failed test: ${key}`, count)
|
|
880
|
+
}
|
|
881
|
+
add('setup change asked for', summary.setup_changes.length)
|
|
882
|
+
add('join failed (clash)', summary.joins.failed.length)
|
|
883
|
+
add('join undone', summary.joins.undone.length)
|
|
884
|
+
add('tests sent back after a rebase', summary.joins.tests_sent_back.length)
|
|
885
|
+
for (const { key, count } of summary.replies.ignored) {
|
|
886
|
+
add(`reply ignored: ${key}`, count)
|
|
887
|
+
}
|
|
888
|
+
add('fixer declined a finding (wont_fix)', summary.declined.wont_fix.length)
|
|
889
|
+
add('shared .git changed during a turn', summary.shared_git_changed)
|
|
890
|
+
return [...signals].map(([key, count]) => ({ key, count }))
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** `#12 "Its title"` for a ticket, or `run` for none. */
|
|
894
|
+
const titledTicket = (summary: RunSummary, ticket: number | null): string => {
|
|
895
|
+
const title = ticket === null ? undefined : summary.tickets.get(ticket)
|
|
896
|
+
return title === undefined || title === ''
|
|
897
|
+
? ticketText(ticket)
|
|
898
|
+
: `${ticketText(ticket)} "${clip(title, 80)}"`
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** A list of agent notes, one per line, capped. */
|
|
902
|
+
const noteLines = (notes: AgentNote[]): string[] =>
|
|
903
|
+
capped(
|
|
904
|
+
notes.map(
|
|
905
|
+
({ seq, ticket, role, text: body }) =>
|
|
906
|
+
` ${seqText(seq)} ${ticketText(ticket)} ${role}: ${body}`
|
|
907
|
+
)
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
/** A tally as `key ×n (seq 1, seq 2)` lines, capped. */
|
|
911
|
+
const tallyLines = (groups: Tally[], max = LIST_MAX): string[] =>
|
|
912
|
+
capped(
|
|
913
|
+
groups.map(
|
|
914
|
+
({ key, count, seqs }) => ` ${key} ×${count} (${seqsText(seqs)})`
|
|
915
|
+
),
|
|
916
|
+
max
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* One run's summary as plain text, with seq numbers (`seq 123`) for evidence.
|
|
921
|
+
* Sections with nothing in them are left out. Pure.
|
|
922
|
+
*/
|
|
923
|
+
export const formatRunSummary = ({
|
|
924
|
+
run_id,
|
|
925
|
+
summary: s,
|
|
926
|
+
}: {
|
|
927
|
+
run_id: string
|
|
928
|
+
summary: RunSummary
|
|
929
|
+
}): string => {
|
|
930
|
+
const gh_repo = /^https:\/\/github\.com\/([^/]+\/[^/]+)\//.exec(
|
|
931
|
+
s.spec.url
|
|
932
|
+
)?.[1]
|
|
933
|
+
const lines: string[] = [
|
|
934
|
+
`== Run ${run_id}: spec #${s.spec.number ?? '?'}${s.spec.title === '' ? '' : ` "${s.spec.title}"`}`,
|
|
935
|
+
`Repo: ${s.repo || '?'}${gh_repo === undefined ? '' : ` (${gh_repo})`}`,
|
|
936
|
+
`Luca: ${s.luca.join(' -> ')}`,
|
|
937
|
+
`Time: ${s.started} to ${s.ended} (${durationText(msBetween(s.started, s.ended))}), last record ${seqText(s.last_seq)}`,
|
|
938
|
+
`Ended: ${s.ending}`,
|
|
939
|
+
`Tickets: ${s.tickets.size}`,
|
|
940
|
+
]
|
|
941
|
+
for (const resume of s.resumes) {
|
|
942
|
+
lines.push(
|
|
943
|
+
`Engine resumed (${seqText(resume.seq)}) on ${resume.version} after ${durationText(resume.down_ms)} down${resume.cut_off === 0 ? '' : `, ${resume.cut_off} steps cut off`}`
|
|
944
|
+
)
|
|
945
|
+
}
|
|
946
|
+
if (s.config_reloaded.length > 0) {
|
|
947
|
+
lines.push(`Config reloaded on resume: ${s.config_reloaded.join(', ')}`)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const section = (title: string, body: string[]) => {
|
|
951
|
+
if (body.length > 0) lines.push('', title, ...body)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const waited = s.stuck.reduce(
|
|
955
|
+
(sum, { reply }) => sum + (reply?.wait_ms ?? 0),
|
|
956
|
+
0
|
|
957
|
+
)
|
|
958
|
+
section(
|
|
959
|
+
`Stuck (${s.stuck.length}; the owner took ${durationText(waited)} in all to reply):`,
|
|
960
|
+
s.stuck.map(
|
|
961
|
+
(event) =>
|
|
962
|
+
` ${seqText(event.seq)} ${event.kind} ${titledTicket(s, event.ticket)}: ${event.reason}${event.reply === null ? ', no reply' : ` -> ${event.reply.word} (${seqText(event.reply.seq)}, after ${durationText(event.reply.wait_ms)})`}\n ${clip(event.detail)}`
|
|
963
|
+
)
|
|
964
|
+
)
|
|
965
|
+
section(
|
|
966
|
+
'Fix loops that hit their cap:',
|
|
967
|
+
s.stuck
|
|
968
|
+
.filter(({ cap }) => cap !== null)
|
|
969
|
+
.map(
|
|
970
|
+
({ seq, ticket, reason, cap }) =>
|
|
971
|
+
` ${seqText(seq)} ${ticketText(ticket)} ${reason} after ${cap}`
|
|
972
|
+
)
|
|
973
|
+
)
|
|
974
|
+
section(`Leftover scan blocked a commit (${s.leftovers.scans} scans):`, [
|
|
975
|
+
...s.leftovers.reasons.map(
|
|
976
|
+
({ key, count }) => ` reason: ${key} ×${count}`
|
|
977
|
+
),
|
|
978
|
+
...tallyLines(s.leftovers.by_path),
|
|
979
|
+
])
|
|
980
|
+
section(
|
|
981
|
+
`Agents out of turns (error_max_turns, ${s.out_of_turns.length}):`,
|
|
982
|
+
noteLines(
|
|
983
|
+
s.out_of_turns.map((note) => ({
|
|
984
|
+
...note,
|
|
985
|
+
text: `max ${note.text} turns`,
|
|
986
|
+
}))
|
|
987
|
+
)
|
|
988
|
+
)
|
|
989
|
+
section(
|
|
990
|
+
`Agent turns lost to "No open agent session" (${s.no_session.length}):`,
|
|
991
|
+
tallyLines(
|
|
992
|
+
tally(s.no_session.map(({ role, seq }) => ({ key: role, seq })))
|
|
993
|
+
)
|
|
994
|
+
)
|
|
995
|
+
section(
|
|
996
|
+
`Other failed agent turns (${s.other_failures.length}):`,
|
|
997
|
+
noteLines(s.other_failures)
|
|
998
|
+
)
|
|
999
|
+
if (s.jev.failed.length > 0) {
|
|
1000
|
+
section(
|
|
1001
|
+
`jev_failed: ${s.jev.failed.reduce((sum, { count }) => sum + count, 0)} of ${s.jev.asked} Jev calls gave no answer:`,
|
|
1002
|
+
[
|
|
1003
|
+
...s.jev.failed.map(({ key, count }) => ` ${key} ×${count}`),
|
|
1004
|
+
` by job: ${s.jev.jobs.map(({ key, count }) => `${key} ×${count}`).join(', ')}`,
|
|
1005
|
+
]
|
|
1006
|
+
)
|
|
1007
|
+
}
|
|
1008
|
+
if (s.gates.failed > 0) {
|
|
1009
|
+
section(
|
|
1010
|
+
`Failed gates: ${s.gates.failed} of ${s.gates.runs} gates_run (red checks failed: ${s.red_checks.failed} of ${s.red_checks.runs}):`,
|
|
1011
|
+
[
|
|
1012
|
+
...tallyLines(s.gates.by_check),
|
|
1013
|
+
...(s.gates.by_test.length === 0
|
|
1014
|
+
? []
|
|
1015
|
+
: [
|
|
1016
|
+
' tests that failed most:',
|
|
1017
|
+
...tallyLines(s.gates.by_test).map(
|
|
1018
|
+
(line) => ` ${line}`
|
|
1019
|
+
),
|
|
1020
|
+
]),
|
|
1021
|
+
]
|
|
1022
|
+
)
|
|
1023
|
+
}
|
|
1024
|
+
section(
|
|
1025
|
+
`Setup changes agents asked for (${s.setup_changes.length}):`,
|
|
1026
|
+
noteLines(s.setup_changes)
|
|
1027
|
+
)
|
|
1028
|
+
const j = s.joins
|
|
1029
|
+
if (
|
|
1030
|
+
j.failed.length +
|
|
1031
|
+
j.undone.length +
|
|
1032
|
+
j.rebases.length +
|
|
1033
|
+
j.tests_sent_back.length >
|
|
1034
|
+
0
|
|
1035
|
+
) {
|
|
1036
|
+
section('Joins:', [
|
|
1037
|
+
` ${j.ok} joined, ${j.failed.length} clashed (${seqsText(
|
|
1038
|
+
j.failed.map(({ seq }) => seq),
|
|
1039
|
+
10
|
|
1040
|
+
)})`,
|
|
1041
|
+
...j.rebases.map(
|
|
1042
|
+
({ key, count, seqs }) =>
|
|
1043
|
+
` rebased after ${key} ×${count} (${seqsText(seqs, 10)})`
|
|
1044
|
+
),
|
|
1045
|
+
...j.undone.map(
|
|
1046
|
+
({ seq, ticket, shas }) =>
|
|
1047
|
+
` ${seqText(seq)} join_undone ${ticketText(ticket)}: ${shas} commits taken off the run branch`
|
|
1048
|
+
),
|
|
1049
|
+
...noteLines(j.tests_sent_back).map((line) =>
|
|
1050
|
+
line.replace(/^ {2}/, ' tests_sent_back ')
|
|
1051
|
+
),
|
|
1052
|
+
])
|
|
1053
|
+
}
|
|
1054
|
+
section('Replies:', [
|
|
1055
|
+
...(s.replies.words.length === 0
|
|
1056
|
+
? []
|
|
1057
|
+
: [
|
|
1058
|
+
` ${s.replies.words.map(({ key, count }) => `${key} ×${count}`).join(', ')}`,
|
|
1059
|
+
]),
|
|
1060
|
+
...s.replies.ignored.map(
|
|
1061
|
+
({ key, count, seqs }) =>
|
|
1062
|
+
` reply_ignored ${key} ×${count} (${seqsText(seqs)})`
|
|
1063
|
+
),
|
|
1064
|
+
])
|
|
1065
|
+
section(
|
|
1066
|
+
`Assumptions agents made (${s.assumptions.total}; ${s.assumptions.by_role.map(({ key, count }) => `${key} ${count}`).join(', ')}):`,
|
|
1067
|
+
s.assumptions.total === 0
|
|
1068
|
+
? []
|
|
1069
|
+
: [
|
|
1070
|
+
` by ticket: ${s.assumptions.by_ticket.map(({ key, count }) => `${key} ${count}`).join(', ')}`,
|
|
1071
|
+
' the first of each ticket:',
|
|
1072
|
+
...noteLines(
|
|
1073
|
+
s.assumptions.notes.filter(
|
|
1074
|
+
(note, index, all) =>
|
|
1075
|
+
all.findIndex(
|
|
1076
|
+
({ ticket }) => ticket === note.ticket
|
|
1077
|
+
) === index
|
|
1078
|
+
)
|
|
1079
|
+
).map((line) => ` ${line}`),
|
|
1080
|
+
` all of them: jq -c 'select(.kind == "agent_finished") | {seq, ticket, role: .content.role, assumptions: .content.result.assumptions}' <journal>`,
|
|
1081
|
+
]
|
|
1082
|
+
)
|
|
1083
|
+
section(
|
|
1084
|
+
`Findings fixers declined (wont_fix, ${s.declined.wont_fix.length}) and rulings that accepted a decline (${s.declined.accepted.length}):`,
|
|
1085
|
+
[
|
|
1086
|
+
...noteLines(s.declined.wont_fix),
|
|
1087
|
+
...noteLines(s.declined.accepted).map((line) =>
|
|
1088
|
+
line.replace(/^ {2}/, ' accepted ')
|
|
1089
|
+
),
|
|
1090
|
+
]
|
|
1091
|
+
)
|
|
1092
|
+
section(
|
|
1093
|
+
`Slow steps (top ${SLOW_STEPS_MAX} by total time; count, total, mean, max):`,
|
|
1094
|
+
s.steps
|
|
1095
|
+
.slice(0, SLOW_STEPS_MAX)
|
|
1096
|
+
.map(
|
|
1097
|
+
(step) =>
|
|
1098
|
+
` ${step.step}: ${step.count}, ${durationText(step.total_ms)}, ${durationText(step.total_ms / step.count)}, ${durationText(step.max_ms)} (${seqText(step.max_seq)} ${ticketText(step.max_ticket)})`
|
|
1099
|
+
)
|
|
1100
|
+
)
|
|
1101
|
+
const run = s.usage.run
|
|
1102
|
+
section(
|
|
1103
|
+
'Tokens (counted as the run budget counts them: input, output, cache writes):',
|
|
1104
|
+
[
|
|
1105
|
+
...(run === null
|
|
1106
|
+
? []
|
|
1107
|
+
: [
|
|
1108
|
+
` run: ${tokensText(countedTokens(run))} counted, ${tokensText(numberOr(run.cache_read_input_tokens) ?? 0)} cache reads, ${s.usage.agent_turns ?? '?'} agent turns`,
|
|
1109
|
+
]),
|
|
1110
|
+
...s.usage.by_role.map(
|
|
1111
|
+
(use) =>
|
|
1112
|
+
` ${use.role}: ${use.sessions} sessions, ${tokensText(use.counted)}, ${use.turns} turns (max ${use.max_turns}), ${durationText(use.duration_ms)}`
|
|
1113
|
+
),
|
|
1114
|
+
...(s.usage.by_ticket.length === 0
|
|
1115
|
+
? []
|
|
1116
|
+
: [
|
|
1117
|
+
` costliest tickets: ${s.usage.by_ticket
|
|
1118
|
+
.slice(0, 5)
|
|
1119
|
+
.map(
|
|
1120
|
+
({ ticket, counted }) =>
|
|
1121
|
+
`#${ticket} ${tokensText(counted)}`
|
|
1122
|
+
)
|
|
1123
|
+
.join(', ')}`,
|
|
1124
|
+
]),
|
|
1125
|
+
]
|
|
1126
|
+
)
|
|
1127
|
+
if (s.shared_git_changed > 0) {
|
|
1128
|
+
section('Other:', [
|
|
1129
|
+
` shared_git_changed ×${s.shared_git_changed}: something changed the shared .git during an agent's turn`,
|
|
1130
|
+
])
|
|
1131
|
+
}
|
|
1132
|
+
return lines.join('\n')
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** A pattern seen in more than one run: its count in each. */
|
|
1136
|
+
export type Repeat = { key: string; runs: { run_id: string; count: number }[] }
|
|
1137
|
+
|
|
1138
|
+
/** The patterns that show up in two runs or more, most runs first. Pure. */
|
|
1139
|
+
export const repeatsAcross = ({
|
|
1140
|
+
runs,
|
|
1141
|
+
}: {
|
|
1142
|
+
runs: { run_id: string; summary: RunSummary }[]
|
|
1143
|
+
}): Repeat[] => {
|
|
1144
|
+
const seen = new Map<string, Repeat>()
|
|
1145
|
+
for (const { run_id, summary } of runs) {
|
|
1146
|
+
for (const { key, count } of summary.signals) {
|
|
1147
|
+
const repeat = seen.get(key) ?? { key, runs: [] }
|
|
1148
|
+
repeat.runs.push({ run_id, count })
|
|
1149
|
+
seen.set(key, repeat)
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
const total = (repeat: Repeat) =>
|
|
1153
|
+
repeat.runs.reduce((sum, { count }) => sum + count, 0)
|
|
1154
|
+
return [...seen.values()]
|
|
1155
|
+
.filter((repeat) => repeat.runs.length > 1)
|
|
1156
|
+
.toSorted(
|
|
1157
|
+
(a, b) => b.runs.length - a.runs.length || total(b) - total(a)
|
|
1158
|
+
)
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** The repeats as plain text. Pure. */
|
|
1162
|
+
export const formatRepeats = ({ repeats }: { repeats: Repeat[] }): string =>
|
|
1163
|
+
[
|
|
1164
|
+
'== Repeats across runs',
|
|
1165
|
+
...(repeats.length === 0
|
|
1166
|
+
? [' none: no pattern shows up in more than one run']
|
|
1167
|
+
: capped(
|
|
1168
|
+
repeats.map(
|
|
1169
|
+
({ key, runs }) =>
|
|
1170
|
+
` ${key}: ${runs.map(({ run_id, count }) => `${run_id} ×${count}`).join(', ')}`
|
|
1171
|
+
),
|
|
1172
|
+
20
|
|
1173
|
+
)),
|
|
1174
|
+
].join('\n')
|
|
1175
|
+
|
|
1176
|
+
/** One run of a repo, for picking the newest finished one. */
|
|
1177
|
+
export type RunChoice = { run_id: string; started: string; over: boolean }
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* The runs of any of `repos` in `runs_dir`, newest first. A run's repo is
|
|
1181
|
+
* its `run_started` repo.
|
|
1182
|
+
*/
|
|
1183
|
+
export const repoRuns = ({
|
|
1184
|
+
runs_dir,
|
|
1185
|
+
repos,
|
|
1186
|
+
}: {
|
|
1187
|
+
runs_dir: string
|
|
1188
|
+
repos: string[]
|
|
1189
|
+
}): RunChoice[] => {
|
|
1190
|
+
if (!existsSync(runs_dir)) return []
|
|
1191
|
+
const want = new Set(repos.map(realPath))
|
|
1192
|
+
return readdirSync(runs_dir)
|
|
1193
|
+
.map((run_id) => ({
|
|
1194
|
+
run_id,
|
|
1195
|
+
records: readRecords({
|
|
1196
|
+
file: join(runs_dir, run_id, 'journal.jsonl'),
|
|
1197
|
+
}),
|
|
1198
|
+
}))
|
|
1199
|
+
.filter(({ records }) => {
|
|
1200
|
+
const started = records.find((r) => r.kind === 'run_started')
|
|
1201
|
+
return (
|
|
1202
|
+
started !== undefined &&
|
|
1203
|
+
want.has(realPath(text(started.content.repo) || '/'))
|
|
1204
|
+
)
|
|
1205
|
+
})
|
|
1206
|
+
.map(({ run_id, records }) => ({
|
|
1207
|
+
run_id,
|
|
1208
|
+
started: records[0]?.time ?? '',
|
|
1209
|
+
over: isFinished({ records }),
|
|
1210
|
+
}))
|
|
1211
|
+
.toSorted((a, b) => b.started.localeCompare(a.started))
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* The newest finished run of `runs` (newest first, as `repoRuns` gives
|
|
1216
|
+
* them), or `null` when none is over. Pure.
|
|
1217
|
+
*/
|
|
1218
|
+
export const newestFinished = ({
|
|
1219
|
+
runs,
|
|
1220
|
+
}: {
|
|
1221
|
+
runs: RunChoice[]
|
|
1222
|
+
}): RunChoice | null => runs.find(({ over }) => over) ?? null
|
|
1223
|
+
|
|
1224
|
+
/** The flags, read. */
|
|
1225
|
+
export type RetroArgs = {
|
|
1226
|
+
run_ids: string[]
|
|
1227
|
+
repo: string | null
|
|
1228
|
+
runs_dir: string
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** Reads the flags; `env` gives the default runs folder. Pure. */
|
|
1232
|
+
export const parseArgs = ({
|
|
1233
|
+
argv,
|
|
1234
|
+
env,
|
|
1235
|
+
home,
|
|
1236
|
+
}: {
|
|
1237
|
+
argv: string[]
|
|
1238
|
+
env: Record<string, string | undefined>
|
|
1239
|
+
home: string
|
|
1240
|
+
}): RetroArgs => {
|
|
1241
|
+
const args: RetroArgs = {
|
|
1242
|
+
run_ids: [],
|
|
1243
|
+
repo: null,
|
|
1244
|
+
runs_dir:
|
|
1245
|
+
env.LUCA_RUNS_DIR ?? join(home, '.local', 'state', 'luca', 'runs'),
|
|
1246
|
+
}
|
|
1247
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1248
|
+
const arg = argv[i] ?? ''
|
|
1249
|
+
const value = argv[i + 1]
|
|
1250
|
+
if (arg === '--repo' && value !== undefined) {
|
|
1251
|
+
args.repo = value
|
|
1252
|
+
i += 1
|
|
1253
|
+
} else if (arg === '--runs-dir' && value !== undefined) {
|
|
1254
|
+
args.runs_dir = value
|
|
1255
|
+
i += 1
|
|
1256
|
+
} else if (arg !== '' && !arg.startsWith('-')) {
|
|
1257
|
+
args.run_ids.push(arg)
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return args
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* A path with its symlinks followed (`/tmp` is `/private/tmp` on macOS), or
|
|
1265
|
+
* just made absolute when it doesn't exist.
|
|
1266
|
+
*/
|
|
1267
|
+
const realPath = (path: string): string => {
|
|
1268
|
+
try {
|
|
1269
|
+
return realpathSync(path)
|
|
1270
|
+
} catch {
|
|
1271
|
+
return resolve(path)
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* The repos a run started from `cwd` may name, real paths, main first: the
|
|
1277
|
+
* main checkout (a run's repo is the main checkout), then the git top folder
|
|
1278
|
+
* (a worktree's own, or a subfolder's repo). Outside git, `cwd` itself. A
|
|
1279
|
+
* bare repo or a submodule has no `<checkout>/.git` common dir, so it adds
|
|
1280
|
+
* no main checkout, only its top folder.
|
|
1281
|
+
*
|
|
1282
|
+
* Its twin is `currentRepos` in `luca-unstick/scripts/stuck-summary.ts`: each skill folder is installed
|
|
1283
|
+
* to `~/.claude/skills/` on its own, so they can't share a file. Keep the two
|
|
1284
|
+
* the same; a test in each pins it.
|
|
1285
|
+
*
|
|
1286
|
+
* @example
|
|
1287
|
+
* currentRepos({ cwd: '/code/app-worktree' }) // ['/code/app', '/code/app-worktree']
|
|
1288
|
+
*/
|
|
1289
|
+
export const currentRepos = ({ cwd }: { cwd: string }): string[] => {
|
|
1290
|
+
const git = (...args: string[]) => {
|
|
1291
|
+
const out = Bun.spawnSync(['git', '-C', cwd, ...args])
|
|
1292
|
+
const said = out.stdout.toString().trim()
|
|
1293
|
+
return out.exitCode === 0 && said !== '' ? said : null
|
|
1294
|
+
}
|
|
1295
|
+
const top = git('rev-parse', '--show-toplevel')
|
|
1296
|
+
const common = git(
|
|
1297
|
+
'rev-parse',
|
|
1298
|
+
'--path-format=absolute',
|
|
1299
|
+
'--git-common-dir'
|
|
1300
|
+
)
|
|
1301
|
+
const main =
|
|
1302
|
+
common !== null && basename(common) === '.git' ? dirname(common) : null
|
|
1303
|
+
return [main, top ?? cwd]
|
|
1304
|
+
.filter((repo) => repo !== null)
|
|
1305
|
+
.map(realPath)
|
|
1306
|
+
.filter((repo, index, all) => all.indexOf(repo) === index)
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const main = () => {
|
|
1310
|
+
const args = parseArgs({
|
|
1311
|
+
argv: Bun.argv.slice(2),
|
|
1312
|
+
env: process.env,
|
|
1313
|
+
home: homedir(),
|
|
1314
|
+
})
|
|
1315
|
+
let run_ids = args.run_ids
|
|
1316
|
+
if (run_ids.length === 0) {
|
|
1317
|
+
const repos =
|
|
1318
|
+
args.repo === null
|
|
1319
|
+
? currentRepos({ cwd: process.cwd() })
|
|
1320
|
+
: [args.repo]
|
|
1321
|
+
const runs = repoRuns({ runs_dir: args.runs_dir, repos })
|
|
1322
|
+
const pick = newestFinished({ runs })
|
|
1323
|
+
if (pick === null) {
|
|
1324
|
+
console.log(
|
|
1325
|
+
runs.length === 0
|
|
1326
|
+
? `No Luca runs of ${repos.join(' or ')} in ${args.runs_dir}.`
|
|
1327
|
+
: `No finished Luca runs of ${repos.join(' or ')} in ${args.runs_dir} (${runs.length} not over yet: ${runs.map(({ run_id }) => run_id).join(', ')}).`
|
|
1328
|
+
)
|
|
1329
|
+
return
|
|
1330
|
+
}
|
|
1331
|
+
console.log(`The newest finished run of ${repos[0]}: ${pick.run_id}\n`)
|
|
1332
|
+
run_ids = [pick.run_id]
|
|
1333
|
+
}
|
|
1334
|
+
const runs = run_ids.map((run_id) => ({
|
|
1335
|
+
run_id,
|
|
1336
|
+
records: readRecords({
|
|
1337
|
+
file: join(args.runs_dir, run_id, 'journal.jsonl'),
|
|
1338
|
+
}),
|
|
1339
|
+
}))
|
|
1340
|
+
const summed = runs.flatMap(({ run_id, records }) => {
|
|
1341
|
+
if (records.length === 0) {
|
|
1342
|
+
console.log(
|
|
1343
|
+
`== Run ${run_id}: no journal at ${join(args.runs_dir, run_id, 'journal.jsonl')}\n`
|
|
1344
|
+
)
|
|
1345
|
+
return []
|
|
1346
|
+
}
|
|
1347
|
+
const summary = summarizeJournal({ records })
|
|
1348
|
+
console.log(`${formatRunSummary({ run_id, summary })}\n`)
|
|
1349
|
+
return [{ run_id, summary }]
|
|
1350
|
+
})
|
|
1351
|
+
if (summed.length > 1) {
|
|
1352
|
+
console.log(formatRepeats({ repeats: repeatsAcross({ runs: summed }) }))
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
if (import.meta.main) main()
|